From cf187d46b30c9cea55500f1da79aa8ae595d83af Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Thu, 21 Aug 2025 20:01:43 +0300 Subject: [PATCH] feat: improve Heroprompt UI and refactor modules - Refactor all UI rendering logic into a single `ui` module - Centralize static assets serving to `/static` directory - Redesign Heroprompt page with Bootstrap 5 components - Enhance workspace management and file tree interactions - Add Bootstrap modal support for UI dialogs --- lib/web/heroprompt/static/css/main.css | 624 -------------- lib/web/heroprompt/static/js/main.js | 391 --------- .../ui/{chat/endpoints.v => chat_endpoints.v} | 11 +- lib/web/ui/{chat/utils.v => chat_utils.v} | 3 +- .../ui/heroprompt/static/css/heroprompt.css | 267 ------ lib/web/ui/heroprompt/static/js/heroprompt.js | 760 ------------------ .../ui/heroprompt/templates/heroprompt.html | 130 --- .../endpoints.v => heroprompt_api.v} | 31 +- .../{heroprompt/page.v => heroprompt_page.v} | 4 +- .../utils.v => heroprompt_utils.v} | 0 .../endpoints.v => heroscript_endpoints.v} | 11 +- .../utils.v => heroscript_utils.v} | 3 +- lib/web/ui/server.v | 36 +- lib/web/ui/{chat => }/static/css/chat.css | 0 lib/web/ui/static/css/heroprompt.css | 329 ++++++++ .../static/css/heroscript.css | 0 lib/web/ui/{chat => }/static/js/chat.js | 0 lib/web/ui/static/js/heroprompt.js | 576 +++++++++++++ .../{heroscript => }/static/js/heroscript.js | 0 lib/web/ui/{chat => }/templates/chat.html | 0 lib/web/ui/templates/heroprompt.html | 254 ++++++ .../templates/heroscript_editor.html | 0 22 files changed, 1214 insertions(+), 2216 deletions(-) delete mode 100644 lib/web/heroprompt/static/css/main.css delete mode 100644 lib/web/heroprompt/static/js/main.js rename lib/web/ui/{chat/endpoints.v => chat_endpoints.v} (67%) rename lib/web/ui/{chat/utils.v => chat_utils.v} (77%) delete mode 100644 lib/web/ui/heroprompt/static/css/heroprompt.css delete mode 100644 lib/web/ui/heroprompt/static/js/heroprompt.js delete mode 100644 lib/web/ui/heroprompt/templates/heroprompt.html rename lib/web/ui/{heroprompt/endpoints.v => heroprompt_api.v} (76%) rename lib/web/ui/{heroprompt/page.v => heroprompt_page.v} (80%) rename lib/web/ui/{heroprompt/utils.v => heroprompt_utils.v} (100%) rename lib/web/ui/{heroscript/endpoints.v => heroscript_endpoints.v} (66%) rename lib/web/ui/{heroscript/utils.v => heroscript_utils.v} (72%) rename lib/web/ui/{chat => }/static/css/chat.css (100%) create mode 100644 lib/web/ui/static/css/heroprompt.css rename lib/web/ui/{heroscript => }/static/css/heroscript.css (100%) rename lib/web/ui/{chat => }/static/js/chat.js (100%) create mode 100644 lib/web/ui/static/js/heroprompt.js rename lib/web/ui/{heroscript => }/static/js/heroscript.js (100%) rename lib/web/ui/{chat => }/templates/chat.html (100%) create mode 100644 lib/web/ui/templates/heroprompt.html rename lib/web/ui/{heroscript => }/templates/heroscript_editor.html (100%) diff --git a/lib/web/heroprompt/static/css/main.css b/lib/web/heroprompt/static/css/main.css deleted file mode 100644 index 309e68c9..00000000 --- a/lib/web/heroprompt/static/css/main.css +++ /dev/null @@ -1,624 +0,0 @@ -:root { - --bg: #111827; - --text: #e5e7eb; - --panel: #0b1220; - --border: #1f2937; - --muted: #94a3b8; - --accent: #93c5fd; - --input: #0f172a; - --input-border: #334155; - --btn: #334155; - --btn-border: #475569; -} - -:root.light { - --bg: #f9fafb; - --text: #0f172a; - --panel: #ffffff; - --border: #e5e7eb; - --muted: #475569; - --accent: #2563eb; - --input: #ffffff; - --input-border: #cbd5e1; - --btn: #e5e7eb; - --btn-border: #cbd5e1; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; - margin: 0; - padding: 24px; - background: var(--bg); - color: var(--text); -} - -h1 { - font-size: 20px; - margin: 0 0 12px -} - -.container { - max-width: 1200px; - margin: 0 auto -} - -a { - color: var(--accent) -} - -.toolbar { - display: flex; - gap: 8px; - align-items: center; - margin-bottom: 12px -} - -.toolbar .spacer { - flex: 1 -} - -.toolbar input { - background: var(--input); - border: 1px solid var(--input-border); - color: var(--text); - padding: 6px 8px; - border-radius: 6px -} - -.toolbar button { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - padding: 6px 10px; - border-radius: 6px; - cursor: pointer -} - -.layout { - display: grid; - grid-template-columns: minmax(300px, 380px) 1fr; - gap: 16px; - min-height: 70vh -} - -.sidebar { - background: var(--panel); - border: 1px solid var(--border); - padding: 8px; - border-radius: 8px -} - -.sidebar-header { - display: flex; - gap: 8px; - row-gap: 6px; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - flex-wrap: wrap -} - -.sidebar-header .spacer { - display: none -} - -.sidebar-header select { - min-width: 140px; - max-width: 100% -} - -@media (min-width: 900px) { - .sidebar-header { - flex-wrap: nowrap - } - - .sidebar-header .spacer { - display: block; - flex: 1 - } -} - -.ws-info { - font-size: 12px; - color: var(--muted); - margin-left: auto -} - -.sidebar-header label { - font-size: 12px; - color: var(--muted) -} - -.sidebar-header select { - background: var(--input); - border: 1px solid var(--input-border); - color: var(--text); - padding: 6px 8px; - border-radius: 6px -} - -.sidebar-header button { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - padding: 6px 10px; - border-radius: 6px; - cursor: pointer; - white-space: nowrap; - flex: 0 0 auto -} - -.sidebar-body { - .sidebar-header #wsCreateBtn { - width: 32px; - height: 32px; - padding: 0; - line-height: 30px; - text-align: center; - font-weight: bold - } - - .hint { - color: var(--muted); - font-size: 12px - } - - .prompt .actions { - margin-top: 8px - } - - .prompt .actions button { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - padding: 6px 10px; - border-radius: 6px; - cursor: pointer - } - - display: flex; - flex-direction: column; - gap: 8px -} - -.searchbar { - display: flex; - gap: 8px -} - -.searchbar input { - background: var(--input); - border: 1px solid var(--input-border); - color: var(--text); - padding: 6px 8px; - border-radius: 6px -} - -.searchbar button { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - padding: 6px 10px; - border-radius: 6px; - cursor: pointer -} - -.sidebar-header button { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - padding: 6px 10px; - border-radius: 6px; - cursor: pointer -} - -.main { - display: flex; - flex-direction: column; - - .prompt { - background: var(--panel); - border: 1px solid var(--border); - padding: 12px; - border-radius: 8px - } - - .prompt textarea { - width: 100%; - min-height: 120px; - background: var(--input); - color: var(--text); - border: 1px solid var(--input-border); - border-radius: 6px; - padding: 8px; - resize: vertical - } - - gap: 16px -} - -/* Tabs */ -.tabs { - display: flex; - justify-content: center; - gap: 8px; - margin-bottom: 8px -} - -.tab { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - padding: 6px 10px; - border-radius: 6px; - cursor: pointer -} - -.tab.active { - background: transparent; - border-color: var(--accent); - color: var(--accent) -} - -.tab-pane { - display: none; -} - -.tab-pane.active { - display: block; -} - -.subbar { - display: flex; - gap: 8px; - align-items: center; - margin: 8px 0 -} - -.subbar input { - background: var(--input); - border: 1px solid var(--input-border); - color: var(--text); - padding: 6px 8px; - border-radius: 6px -} - -.subbar button { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - padding: 6px 10px; - border-radius: 6px; - cursor: pointer -} - -.subbar input { - background: var(--input); - border: 1px solid var(--input-border); - color: var(--text); - padding: 6px 8px; - border-radius: 6px -} - -.subbar button { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - padding: 6px 10px; - border-radius: 6px; - cursor: pointer -} - - -/* Chat */ -.chat { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - display: flex; - flex-direction: column; - height: 60vh -} - -.messages { - flex: 1; - padding: 12px; - overflow: auto; - display: flex; - flex-direction: column; - gap: 10px -} - -.message { - display: flex; - gap: 8px; -} - -.message .bubble { - max-width: 70%; - padding: 10px 12px; - border-radius: 12px; - border: 1px solid var(--border) -} - -.message.user .bubble { - background: rgba(99, 102, 241, 0.15); -} - -.message.ai .bubble { - background: rgba(16, 185, 129, 0.15); -} - -.chat-input { - border-top: 1px solid var(--border); - padding: 10px; - display: flex; - gap: 8px -} - -.chat-input textarea { - flex: 1; - background: var(--input); - border: 1px solid var(--input-border); - color: var(--text); - border-radius: 8px; - padding: 8px; - min-height: 60px -} - -.chat-input button { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - border-radius: 8px; - padding: 8px 12px; - cursor: pointer -} - -.preview { - background: var(--panel); - border: 1px solid var(--border); - padding: 8px; - border-radius: 6px; - min-height: 300px -} - -.prompt, -.selection { - background: var(--panel); - border: 1px solid var(--border); - padding: 12px; - border-radius: 6px -} - -.prompt textarea { - width: 100%; - min-height: 120px; - background: var(--input); - color: var(--text); - border: 1px solid var(--input-border); - border-radius: 6px; - padding: 8px; - resize: vertical; -} - -.prompt .actions { - margin-top: 8px; -} - -.prompt-output { - margin-top: 10px; -} - -.prompt-output pre { - white-space: pre-wrap; - background: var(--panel); - border: 1px solid var(--border); - padding: 8px; - border-radius: 6px; -} - -/* Collapsible tree styles */ -#tree ul { - list-style: none; - margin: 0; - padding-left: 12px; -} - -#tree li { - margin: 2px 0; -} - -#tree .dir { - color: var(--text); -} - -#tree .dir .dir-label .name { - color: var(--text); -} - -#tree .file a { - color: var(--text); - text-decoration: none; -} - -#tree .file a:hover { - text-decoration: underline; -} - -/* Modal */ -.modal[aria-hidden="true"] { - display: none -} - -.modal { - position: fixed; - inset: 0; - z-index: 1000; -} - -.modal-backdrop { - position: absolute; - inset: 0; - - background: rgba(0, 0, 0, 0.5) -} - -.modal .row { - display: flex; - gap: 12px -} - -.modal .col { - flex: 1; - display: flex; - flex-direction: column; - gap: 8px -} - -.modal .list { - list-style: none; - margin: 0; - padding: 0; - max-height: 40vh; - overflow: auto -} - -.modal .list li { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - padding: 6px 0; - border-bottom: 1px dashed var(--border) -} - -.modal .list .use { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - border-radius: 6px; - padding: 4px 8px; - cursor: pointer -} - -.modal-dialog { - position: relative; - margin: 8vh auto; - max-width: 520px; - background: var(--panel); - color: var(--text); - border: 1px solid var(--border); - border-radius: 10px; - padding: 12px; - z-index: 1 -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px -} - -.modal-header .icon { - background: transparent; - border: none; - color: var(--text); - cursor: pointer; - font-size: 18px -} - -.modal-body { - display: flex; - flex-direction: column; - gap: 8px -} - -.modal-body input { - background: var(--input); - color: var(--text); - border: 1px solid var(--input-border); - border-radius: 6px; - padding: 8px -} - -.modal-footer { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 12px -} - -.modal-footer button { - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - border-radius: 6px; - padding: 6px 10px; - cursor: pointer -} - -.error { - color: #ef4444; - font-size: 12px; - min-height: 16px -} - - -#tree .toggle { - appearance: none; - -webkit-appearance: none; - width: 0; - height: 0; - position: absolute; -} - -#tree .dir-label { - display: inline-flex; - align-items: center; - gap: 6px; - cursor: pointer; - user-select: none; -} - -#tree .dir .chev { - display: inline-block; - transform: rotate(-90deg); - transition: transform 0.15s ease; - width: 10px; - height: 10px; - border-right: 2px solid #93c5fd; - border-bottom: 2px solid #93c5fd; - margin-left: 2px; -} - -#tree .dir .toggle:checked+.dir-label .chev { - transform: rotate(0deg); -} - -#tree .children { - display: none; - margin-left: 16px; -} - -#tree .dir .toggle:checked~.children { - display: block; -} - -#tree button { - margin-left: 6px; - background: var(--btn); - border: 1px solid var(--btn-border); - color: var(--text); - padding: 2px 6px; - border-radius: 4px; - cursor: pointer; - font-size: 12px; -} - -#selected li { - display: flex; - justify-content: space-between; - gap: 8px -} \ No newline at end of file diff --git a/lib/web/heroprompt/static/js/main.js b/lib/web/heroprompt/static/js/main.js deleted file mode 100644 index 40669cd0..00000000 --- a/lib/web/heroprompt/static/js/main.js +++ /dev/null @@ -1,391 +0,0 @@ -console.log('Heroprompt UI loaded'); - -let currentWs = localStorage.getItem('heroprompt-current-ws') || 'default'; -let selected = []; - -const el = (id) => document.getElementById(id); - -async function api(url) { - try { const r = await fetch(url); return await r.json(); } - catch { return { error: 'request failed' }; } -} -async function post(url, data) { - const form = new FormData(); - Object.entries(data).forEach(([k, v]) => form.append(k, v)); - try { const r = await fetch(url, { method: 'POST', body: form }); return await r.json(); } - catch { return { error: 'request failed' }; } -} - -// Checkbox-based collapsible tree -let nodeId = 0; - -function renderTree(displayName, fullPath) { - const c = document.createElement('div'); - c.className = 'tree'; - const ul = document.createElement('ul'); - ul.className = 'tree-root'; - const root = buildDirNode(displayName, fullPath, true); - ul.appendChild(root); - c.appendChild(ul); - return c; -} - -function buildDirNode(name, fullPath, expanded = false) { - const li = document.createElement('li'); - li.className = 'dir'; - const id = `tn_${nodeId++}`; - - const toggle = document.createElement('input'); - toggle.type = 'checkbox'; - toggle.className = 'toggle'; - toggle.id = id; - if (expanded) toggle.checked = true; - - const label = document.createElement('label'); - label.htmlFor = id; - label.className = 'dir-label'; - const icon = document.createElement('span'); - icon.className = 'chev'; - const text = document.createElement('span'); - text.className = 'name'; - text.textContent = name; - label.appendChild(icon); - label.appendChild(text); - - const add = document.createElement('button'); - add.textContent = '+'; - add.title = 'Add directory to selection'; - add.onclick = () => addDirToSelection(fullPath); - - const children = document.createElement('ul'); - children.className = 'children'; - - toggle.addEventListener('change', async () => { - if (toggle.checked) { - if (!li.dataset.loaded) { - await loadChildren(fullPath, children); - li.dataset.loaded = '1'; - } - } - }); - - // Load immediately if expanded by default - if (expanded) { - setTimeout(async () => { - await loadChildren(fullPath, children); - li.dataset.loaded = '1'; - }, 0); - } - - li.appendChild(toggle); - li.appendChild(label); - li.appendChild(add); - li.appendChild(children); - return li; -} - -function createFileNode(name, fullPath) { - const li = document.createElement('li'); - li.className = 'file'; - const a = document.createElement('a'); - a.href = '#'; - a.textContent = name; - a.onclick = (e) => { e.preventDefault(); }; - const add = document.createElement('button'); - add.textContent = '+'; - add.title = 'Add file to selection'; - add.onclick = () => addFileToSelection(fullPath); - li.appendChild(a); - li.appendChild(add); - return li; -} - -async function loadChildren(parentPath, ul) { - const r = await api(`/api/heroprompt/directory?name=${currentWs}&path=${encodeURIComponent(parentPath)}`); - if (r.error) { ul.innerHTML = `
  • ${r.error}
  • `; return; } - ul.innerHTML = ''; - for (const it of r.items) { - const full = parentPath.endsWith('/') ? parentPath + it.name : parentPath + '/' + it.name; - if (it.type === 'directory') { - ul.appendChild(buildDirNode(it.name, full, false)); - } else { - ul.appendChild(createFileNode(it.name, full)); - } - } -} - -async function loadDir(p) { - el('tree').innerHTML = ''; - const display = p.split('/').filter(Boolean).slice(-1)[0] || p; - el('tree').appendChild(renderTree(display, p)); - updateSelectionList(); -} - -function updateSelectionList() { - el('selCount').textContent = String(selected.length); - const ul = el('selected'); - ul.innerHTML = ''; - for (const p of selected) { - const li = document.createElement('li'); - li.textContent = p; - const btn = document.createElement('button'); - btn.textContent = 'remove'; - btn.onclick = () => { selected = selected.filter(x => x !== p); updateSelectionList(); }; - li.appendChild(btn); - ul.appendChild(li); - } - // naive token estimator ~ 4 chars/token - const tokens = Math.ceil(selected.join('\n').length / 4); - el('tokenCount').textContent = String(Math.ceil(tokens)); -} - -function addToSelection(p) { - if (!selected.includes(p)) { selected.push(p); updateSelectionList(); } -} - - -async function addDirToSelection(p) { - const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/dirs`, { method: 'POST', body: new URLSearchParams({ path: p }) }); - const j = await r.json().catch(() => ({ error: 'request failed' })); - if (j && j.ok !== false && !j.error) { if (!selected.includes(p)) selected.push(p); updateSelectionList(); } -} - -async function addFileToSelection(p) { - if (selected.includes(p)) return; - const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/files`, { method: 'POST', body: new URLSearchParams({ path: p }) }); - const j = await r.json().catch(() => ({ error: 'request failed' })); - if (j && j.ok !== false && !j.error) { selected.push(p); updateSelectionList(); } -} - - -// Theme persistence and toggle -(function initTheme() { - const saved = localStorage.getItem('hero-theme'); - const root = document.documentElement; - if (saved === 'light') root.classList.add('light'); -})(); - -el('toggleTheme').onclick = () => { - const root = document.documentElement; - const isLight = root.classList.toggle('light'); - localStorage.setItem('hero-theme', isLight ? 'light' : 'dark'); -}; - -// Workspaces list + selector -async function reloadWorkspaces() { - const sel = document.getElementById('workspaceSelect'); - if (!sel) return; - sel.innerHTML = ''; - const names = await api('/api/heroprompt/workspaces').catch(() => []); - for (const n of names || []) { - const opt = document.createElement('option'); - opt.value = n; opt.textContent = n; - sel.appendChild(opt); - } - // ensure current ws name exists or select first - function updateWsInfo(info) { const box = document.getElementById('wsInfo'); if (!box) return; if (!info || info.error) { box.textContent = ''; return; } box.textContent = `${info.name} — ${info.base_path}`; } - - if ([...sel.options].some(o => o.value === currentWs)) sel.value = currentWs; - else if (sel.options.length > 0) sel.value = sel.options[0].value; -} -// On initial load: pick current or first workspace and load its base -(async function initWorkspace() { - const sel = document.getElementById('workspaceSelect'); - const names = await api('/api/heroprompt/workspaces').catch(() => []); - if (!names || names.length === 0) return; - if (!currentWs || !names.includes(currentWs)) { currentWs = names[0]; localStorage.setItem('heroprompt-current-ws', currentWs); } - if (sel) sel.value = currentWs; - const info = await api(`/api/heroprompt/workspaces/${currentWs}`); - const base = info?.base_path || ''; - if (base) await loadDir(base); -})(); -// Create workspace modal wiring -const wcShow = () => { el('wcName').value = ''; el('wcPath').value = ''; el('wcError').textContent = ''; showModal('wsCreate'); }; -el('wsCreateBtn')?.addEventListener('click', wcShow); -el('wcClose')?.addEventListener('click', () => hideModal('wsCreate')); -el('wcCancel')?.addEventListener('click', () => hideModal('wsCreate')); - -el('wcCreate')?.addEventListener('click', async () => { - const name = el('wcName').value.trim(); - const path = el('wcPath').value.trim(); - if (!path) { el('wcError').textContent = 'Path is required.'; return; } - const formData = { base_path: path }; - if (name) formData.name = name; - const resp = await post('/api/heroprompt/workspaces', formData); - if (resp.error) { el('wcError').textContent = resp.error; return; } - currentWs = resp.name || currentWs; - localStorage.setItem('heroprompt-current-ws', currentWs); - await reloadWorkspaces(); - const info = await api(`/api/heroprompt/workspaces/${currentWs}`); - const base = info?.base_path || ''; - if (base) await loadDir(base); - hideModal('wsCreate'); -}); -// Workspace details modal -el('wsDetailsBtn')?.addEventListener('click', async () => { - const info = await api(`/api/heroprompt/workspaces/${currentWs}`); - if (info && !info.error) { el('wdName').value = info.name || currentWs; el('wdPath').value = info.base_path || ''; el('wdError').textContent = ''; showModal('wsDetails'); } -}); - -el('wdClose')?.addEventListener('click', () => hideModal('wsDetails')); -el('wdCancel')?.addEventListener('click', () => hideModal('wsDetails')); - -el('wdSave')?.addEventListener('click', async () => { - const newName = el('wdName').value.trim(); - const newPath = el('wdPath').value.trim(); - // update via create semantics if name changed, or add an update endpoint later - const form = new FormData(); if (newName) form.append('name', newName); if (newPath) form.append('base_path', newPath); - const resp = await fetch('/api/heroprompt/workspaces', { method: 'POST', body: form }); - const j = await resp.json().catch(() => ({ error: 'request failed' })); - if (j.error) { el('wdError').textContent = j.error; return; } - currentWs = j.name || newName || currentWs; localStorage.setItem('heroprompt-current-ws', currentWs); - await reloadWorkspaces(); - const info = await api(`/api/heroprompt/workspaces/${currentWs}`); - const base = info?.base_path || ''; - if (base) await loadDir(base); - hideModal('wsDetails'); -}); - -el('wdDelete')?.addEventListener('click', async () => { - // simple delete through factory delete via dedicated endpoint would be ideal; for now we can implement a delete endpoint later - const ok = confirm('Delete this workspace?'); if (!ok) return; - const r = await fetch(`/api/heroprompt/workspaces/${currentWs}`, { method: 'DELETE' }); - const j = await r.json().catch(() => ({})); - // ignore errors for now - await reloadWorkspaces(); - const sel = document.getElementById('workspaceSelect'); - currentWs = sel?.value || ''; - localStorage.setItem('heroprompt-current-ws', currentWs); - if (currentWs) { const info = await api(`/api/heroprompt/workspaces/${currentWs}`); const base = info?.base_path || ''; if (base) await loadDir(base); } - hideModal('wsDetails'); -}); - - - - -if (document.getElementById('workspaceSelect')) { - // Copy Prompt: generate on server using workspace.prompt and copy to clipboard - el('copyPrompt')?.addEventListener('click', async () => { - const text = el('promptText')?.value || ''; - try { - const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/prompt`, { method: 'POST', body: new URLSearchParams({ text }) }); - const out = await r.text(); - await navigator.clipboard.writeText(out); - } catch (e) { - console.warn('copy prompt failed', e); - } - }); - - reloadWorkspaces(); - document.getElementById('workspaceSelect').addEventListener('change', async (e) => { - currentWs = e.target.value; - localStorage.setItem('heroprompt-current-ws', currentWs); - const info = await api(`/api/heroprompt/workspaces/${currentWs}`); - const base = info?.base_path || ''; - if (base) await loadDir(base); - }); -} - -document.getElementById('refreshWs')?.addEventListener('click', async () => { - const info = await api(`/api/heroprompt/workspaces/${currentWs}`); - const base = info?.base_path || ''; - if (base) await loadDir(base); -}); -document.getElementById('openWsManage')?.addEventListener('click', async () => { - // populate manage list and open - const list = el('wmList'); const err = el('wmError'); if (!list) return; - err.textContent = ''; list.innerHTML = ''; - const names = await api('/api/heroprompt/workspaces').catch(() => []); - for (const n of names || []) { const li = document.createElement('li'); const s = document.createElement('span'); s.textContent = n; const b = document.createElement('button'); b.className = 'use'; b.textContent = 'Use'; b.onclick = async () => { currentWs = n; await reloadWorkspaces(); const info = await api(`/api/heroprompt/workspaces/${currentWs}`); const base = info?.base_path || ''; if (base) await loadDir(base); hideModal('wsManage'); }; li.appendChild(s); li.appendChild(b); list.appendChild(li); } - showModal('wsManage'); -}); - -// legacy setWs kept for backward compat - binds currentWs -el('setWs')?.addEventListener('click', async () => { - const base = el('basePath')?.value?.trim(); - if (!base) { alert('Enter base path'); return; } - const r = await post('/api/heroprompt/workspaces', { name: currentWs, base_path: base }); - if (r.error) { alert(r.error); return; } - await loadDir(base); -}); - -el('doSearch').onclick = async () => { - const q = el('search').value.trim(); - if (!q) return; - const r = await api(`/api/heroprompt/search?name=${currentWs}&q=${encodeURIComponent(q)}`); - if (r.error) { alert(r.error); return; } - const tree = el('tree'); - tree.innerHTML = '
    Search results:
    '; - const ul = document.createElement('ul'); - for (const it of r) { - const li = document.createElement('li'); - li.className = it.type; - const a = document.createElement('a'); - a.href = '#'; a.textContent = it.path; - a.onclick = async (e) => { - e.preventDefault(); - if (it.type === 'file') { - const rf = await api(`/api/heroprompt/file?name=${currentWs}&path=${encodeURIComponent(it.path)}`); - if (!rf.error) el('preview').textContent = rf.content; - } else { - await loadDir(it.path); - } - }; - const add = document.createElement('button'); - add.textContent = '+'; - add.title = 'Add to selection'; - add.onclick = () => addToSelection(it.path); - li.appendChild(a); - li.appendChild(add); - ul.appendChild(li); - } - tree.appendChild(ul); -}; - -// Tabs -function switchTab(id) { - for (const t of document.querySelectorAll('.tab')) t.classList.remove('active'); - for (const p of document.querySelectorAll('.tab-pane')) p.classList.remove('active'); - const btn = document.querySelector(`.tab[data-tab="${id}"]`); - const pane = document.getElementById(`tab-${id}`); - if (btn && pane) { - btn.classList.add('active'); - pane.classList.add('active'); - } -} - -for (const btn of document.querySelectorAll('.tab')) { - btn.addEventListener('click', () => switchTab(btn.dataset.tab)); -} - -// Chat (client-side mock for now) -el('sendChat').onclick = () => { - const input = el('chatInput'); - const text = input.value.trim(); - if (!text) return; - addChatMessage('user', text); - input.value = ''; - // Mock AI response - setTimeout(() => addChatMessage('ai', 'This is a placeholder AI response.'), 500); -}; - -function addChatMessage(role, text) { - const msg = document.createElement('div'); - msg.className = `message ${role}`; - const bubble = document.createElement('div'); - bubble.className = 'bubble'; - bubble.textContent = text; - msg.appendChild(bubble); - el('chatMessages').appendChild(msg); - el('chatMessages').scrollTop = el('chatMessages').scrollHeight; -} - -// Modal helpers -function showModal(id) { const m = el(id); if (!m) return; m.setAttribute('aria-hidden', 'false'); } -function hideModal(id) { const m = el(id); if (!m) return; m.setAttribute('aria-hidden', 'true'); el('wsError').textContent = ''; } - - - - - - diff --git a/lib/web/ui/chat/endpoints.v b/lib/web/ui/chat_endpoints.v similarity index 67% rename from lib/web/ui/chat/endpoints.v rename to lib/web/ui/chat_endpoints.v index 952ca743..41670468 100644 --- a/lib/web/ui/chat/endpoints.v +++ b/lib/web/ui/chat_endpoints.v @@ -1,20 +1,19 @@ -module chat +module ui import os -import freeflowuniverse.herolib.web.ui -pub fn render(app &ui.App) !string { +pub fn render_chat_alt(app &App) !string { tpl := os.join_path(os.dir(@FILE), 'templates', 'chat.html') content := os.read_file(tpl)! - menu_content := ui.menu_html(app.menu, 0, 'm') + menu_content := menu_html(app.menu, 0, 'm') mut result := content result = result.replace('{{.title}}', app.title) result = result.replace('{{.menu_html}}', menu_content) result = result.replace('{{.css_colors_url}}', '/static/css/colors.css') result = result.replace('{{.css_main_url}}', '/static/css/main.css') - result = result.replace('{{.css_chat_url}}', '/static/chat/css/chat.css') + result = result.replace('{{.css_chat_url}}', '/static/css/chat.css') result = result.replace('{{.js_theme_url}}', '/static/js/theme.js') - result = result.replace('{{.js_chat_url}}', '/static/chat/js/chat.js') + result = result.replace('{{.js_chat_url}}', '/static/js/chat.js') // version banner result = result.replace('', '
    Rendered by: chat
    ') return result diff --git a/lib/web/ui/chat/utils.v b/lib/web/ui/chat_utils.v similarity index 77% rename from lib/web/ui/chat/utils.v rename to lib/web/ui/chat_utils.v index 127b3671..79ca78a8 100644 --- a/lib/web/ui/chat/utils.v +++ b/lib/web/ui/chat_utils.v @@ -1,4 +1,3 @@ -module chat +module ui // Placeholder for chat-specific utilities - diff --git a/lib/web/ui/heroprompt/static/css/heroprompt.css b/lib/web/ui/heroprompt/static/css/heroprompt.css deleted file mode 100644 index 4694b6b7..00000000 --- a/lib/web/ui/heroprompt/static/css/heroprompt.css +++ /dev/null @@ -1,267 +0,0 @@ -/* Heroprompt-specific styles */ - -.heroprompt-container { - display: flex; - gap: 1rem; - height: calc(100vh - 200px); -} - -.workspaces-panel { - flex: 0 0 280px; - min-width: 280px; -} - -.workspace-details { - flex: 1; - min-width: 0; -} - -.instructions-panel { - flex: 0 0 350px; - min-width: 350px; -} - -/* Workspace list styling */ -.workspace-item { - cursor: pointer; - transition: background-color 0.2s ease; - border: none !important; - padding: 0.75rem 1rem; -} - -.workspace-item:hover { - background-color: var(--bs-gray-100); -} - -[data-bs-theme="dark"] .workspace-item:hover { - background-color: var(--bs-gray-800); -} - -.workspace-item.active { - background-color: var(--bs-primary); - color: white; -} - -.workspace-item.active:hover { - background-color: var(--bs-primary); -} - -/* Directory and file styling */ -.directory-item { - border: 1px solid var(--bs-border-color); - border-radius: 0.375rem; - margin-bottom: 1rem; - background-color: var(--bs-body-bg); -} - -.directory-header { - background-color: var(--bs-gray-50); - border-bottom: 1px solid var(--bs-border-color); - padding: 0.75rem 1rem; - font-weight: 600; - border-radius: 0.375rem 0.375rem 0 0; -} - -[data-bs-theme="dark"] .directory-header { - background-color: var(--bs-gray-800); -} - -.file-list { - padding: 0.5rem; - max-height: 300px; - overflow-y: auto; -} - -.file-item { - display: flex; - align-items: center; - padding: 0.5rem; - border-radius: 0.25rem; - cursor: pointer; - transition: background-color 0.2s ease; - margin-bottom: 0.25rem; -} - -.file-item:hover { - background-color: var(--bs-gray-100); -} - -[data-bs-theme="dark"] .file-item:hover { - background-color: var(--bs-gray-700); -} - -.file-item.selected { - background-color: var(--bs-success-bg-subtle); - border: 1px solid var(--bs-success-border-subtle); -} - -.file-item input[type="checkbox"] { - margin-right: 0.5rem; -} - -.file-name { - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 0.875rem; -} - -/* Empty state styling */ -.empty-state { - text-align: center; - padding: 2rem; - color: var(--bs-text-muted); -} - -.empty-state-icon { - font-size: 3rem; - margin-bottom: 1rem; - opacity: 0.5; -} - -/* Button styling */ -.btn-group-actions { - display: flex; - gap: 0.5rem; - align-items: center; -} - -/* Toast styling */ -.toast { - min-width: 300px; -} - -/* Instructions panel styling */ -.instructions-panel textarea { - resize: vertical; - min-height: 200px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 0.875rem; - line-height: 1.5; -} - -.instructions-panel .card-body { - padding: 1rem; -} - -/* Responsive design */ -@media (max-width: 1200px) { - .heroprompt-container { - flex-direction: column; - height: auto; - } - - .workspaces-panel, - .workspace-details, - .instructions-panel { - flex: none; - min-width: auto; - margin-bottom: 1rem; - } - - .instructions-panel { - order: 3; - } -} - -@media (max-width: 768px) { - .heroprompt-container { - flex-direction: column; - height: auto; - } - - .workspaces-panel, - .workspace-details, - .instructions-panel { - flex: none; - min-width: auto; - margin-bottom: 1rem; - } -} - -/* Selection counter */ -.selection-counter { - font-size: 0.875rem; - color: var(--bs-text-muted); - margin-left: 0.5rem; -} - -.selection-counter.has-selection { - color: var(--bs-success); - font-weight: 600; -} - -/* Directory path styling */ -.directory-path { - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 0.75rem; - color: var(--bs-text-muted); - margin-top: 0.25rem; -} - -/* Workspace name input */ -.workspace-name-input { - border: 1px solid var(--bs-border-color); - border-radius: 0.25rem; - padding: 0.5rem; - width: 100%; - margin-bottom: 0.5rem; -} - -/* Loading state */ -.loading-spinner { - display: inline-block; - width: 1rem; - height: 1rem; - border: 2px solid var(--bs-border-color); - border-radius: 50%; - border-top-color: var(--bs-primary); - animation: spin 1s ease-in-out infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* File type icons */ -.file-icon { - margin-right: 0.5rem; - width: 16px; - text-align: center; -} - -.file-icon.file-md::before { - content: "📝"; -} - -.file-icon.file-v::before { - content: "⚡"; -} - -.file-icon.file-js::before { - content: "📜"; -} - -.file-icon.file-css::before { - content: "🎨"; -} - -.file-icon.file-html::before { - content: "🌐"; -} - -.file-icon.file-json::before { - content: "📋"; -} - -.file-icon.file-yaml::before { - content: "⚙️"; -} - -.file-icon.file-txt::before { - content: "📄"; -} - -.file-icon.file-default::before { - content: "📁"; -} \ No newline at end of file diff --git a/lib/web/ui/heroprompt/static/js/heroprompt.js b/lib/web/ui/heroprompt/static/js/heroprompt.js deleted file mode 100644 index 1353ae66..00000000 --- a/lib/web/ui/heroprompt/static/js/heroprompt.js +++ /dev/null @@ -1,760 +0,0 @@ -/** - * Heroprompt - Client-side workspace and file selection management - * Updated to work with V backend API and support subdirectories - */ - -class Heroprompt { - constructor() { - // Backend-integrated state (no localStorage) - this.currentWorkspace = ''; - this.workspaces = []; - this.selectedFiles = new Set(); - this.selectedDirs = new Set(); - - this.initializeUI(); - this.bindEvents(); - // Load workspaces from backend and render - this.refreshWorkspaces(); - } - - // Data management - loadData() { - try { - const stored = localStorage.getItem(this.storageKey); - if (stored) { - return JSON.parse(stored); - } - } catch (e) { - console.warn('Failed to load heroprompt data:', e); - } - - return { - workspaces: { - default: { dirs: [] } - }, - current: 'default' - }; - } - - saveData() { - try { - localStorage.setItem(this.storageKey, JSON.stringify(this.data)); - } catch (e) { - console.error('Failed to save heroprompt data:', e); - this.showToast('Failed to save data', 'error'); - } - } - - // API calls to V backend - async fetchDirectory(path) { - try { - const qs = new URLSearchParams({ name: this.currentWorkspace || 'default', path }).toString(); - const response = await fetch(`/api/heroprompt/directory?${qs}`); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - return await response.json(); - } catch (e) { - console.error('Failed to fetch directory:', e); - throw e; - } - } - - async fetchFileContent(path) { - try { - const qs = new URLSearchParams({ name: this.currentWorkspace || 'default', path }).toString(); - const response = await fetch(`/api/heroprompt/file?${qs}`); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - return await response.json(); - } catch (e) { - console.error('Failed to fetch file:', e); - throw e; - } - } - - // Workspace management - createWorkspace(name) { - if (!name || name.trim() === '') { - this.showToast('Workspace name cannot be empty', 'error'); - return; - } - - name = name.trim(); - if (this.data.workspaces[name]) { - this.showToast('Workspace already exists', 'error'); - return; - } - - this.data.workspaces[name] = { dirs: [] }; - this.data.current = name; - this.currentWorkspace = name; - this.saveData(); - this.render(); - this.showToast(`Workspace "${name}" created`, 'success'); - } - - deleteWorkspace(name) { - if (name === 'default') { - this.showToast('Cannot delete default workspace', 'error'); - return; - } - - if (!confirm(`Are you sure you want to delete workspace "${name}"?`)) { - return; - } - - delete this.data.workspaces[name]; - - if (this.currentWorkspace === name) { - this.currentWorkspace = 'default'; - this.data.current = 'default'; - } - - this.saveData(); - this.render(); - this.showToast(`Workspace "${name}" deleted`, 'success'); - } - - selectWorkspace(name) { - if (!this.data.workspaces[name]) { - this.showToast('Workspace not found', 'error'); - return; - } - - this.currentWorkspace = name; - this.data.current = name; - this.saveData(); - this.render(); - } - - // Directory management - async addDirectory() { - // Try to use the File System Access API if available - if ('showDirectoryPicker' in window) { - await this.addDirectoryWithPicker(); - } else { - this.addDirectoryWithPrompt(); - } - } - // Backend API helpers - async apiListWorkspaces() { - const res = await fetch('/api/heroprompt/workspaces'); - if (!res.ok) throw new Error('Failed to list workspaces'); - return await res.json(); // array of names - } - - async apiCreateWorkspace(name, base_path = '') { - const form = new FormData(); - form.append('name', name); - if (base_path) form.append('base_path', base_path); - const res = await fetch('/api/heroprompt/workspaces', { method: 'POST', body: form }); - if (!res.ok) throw new Error('Failed to create workspace'); - return await res.json(); - } - - async apiDeleteWorkspace(name) { - const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(name)}`, { method: 'DELETE' }); - if (!res.ok) throw new Error('Failed to delete workspace'); - return await res.json(); - } - - async refreshWorkspaces(selectName = '') { - try { - this.workspaces = await this.apiListWorkspaces(); - if (this.workspaces.length > 0) { - this.currentWorkspace = selectName || this.currentWorkspace || this.workspaces[0]; - } else { - this.currentWorkspace = ''; - } - await this.render(); - } catch (e) { - console.error(e); - this.showToast('Failed to load workspaces', 'error'); - } - } - async apiGetWorkspace(name) { - const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(name)}`); - if (!res.ok) throw new Error('Failed to get workspace'); - return await res.json(); - } - - async apiAddDir(path) { - const form = new FormData(); - form.append('path', path); - const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(this.currentWorkspace)}/dirs`, { - method: 'POST', body: form - }); - if (!res.ok) throw new Error('Failed to add directory'); - return await res.json(); - } - - async apiRemoveDir(path) { - const form = new FormData(); - form.append('path', path); - const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(this.currentWorkspace)}/dirs/remove`, { - method: 'POST', body: form - }); - if (!res.ok) throw new Error('Failed to remove directory'); - return await res.json(); - } - - async apiAddFile(path) { - const form = new FormData(); - form.append('path', path); - const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(this.currentWorkspace)}/files`, { - method: 'POST', body: form - }); - if (!res.ok) throw new Error('Failed to add file'); - return await res.json(); - } - - async apiRemoveFile(path) { - const form = new FormData(); - form.append('path', path); - const res = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(this.currentWorkspace)}/files/remove`, { - method: 'POST', body: form - }); - if (!res.ok) throw new Error('Failed to remove file'); - return await res.json(); - } - - async loadCurrentWorkspaceDetails() { - if (!this.currentWorkspace) { - this.currentDetails = null; - this.selectedFiles = new Set(); - this.selectedDirs = new Set(); - return; - } - const data = await this.apiGetWorkspace(this.currentWorkspace); - this.currentDetails = data; - const files = new Set(); - const dirs = new Set(); - for (const ch of (data.children || [])) { - if (ch.type === 'file') files.add(ch.path); - if (ch.type === 'directory') dirs.add(ch.path); - } - this.selectedFiles = files; - this.selectedDirs = dirs; - } - - - - async addDirectoryWithPicker() { - try { - const dirHandle = await window.showDirectoryPicker(); - - // For File System Access API, we need to read the directory contents directly - // since we can't pass the handle to the backend - const files = []; - const subdirs = []; - - for await (const [name, handle] of dirHandle.entries()) { - if (handle.kind === 'file') { - files.push(name); - } else if (handle.kind === 'directory') { - subdirs.push(name); - } - } - - const processedDir = { - path: dirHandle.name, // Use the directory name as path for now - files: files.sort(), - subdirs: subdirs.sort(), - selected: [] - }; - - this.data.workspaces[this.currentWorkspace].dirs.push(processedDir); - this.saveData(); - this.render(); - this.showToast(`Directory "${dirHandle.name}" added`, 'success'); - - } catch (e) { - if (e.name !== 'AbortError') { - console.error('Directory picker error:', e); - this.showToast('Failed to access directory', 'error'); - } - } - } - - async processDirectoryHandle(dirHandle, basePath = '') { - // This method is no longer used with the File System Access API approach - // but kept for compatibility - try { - const fullPath = basePath ? `${basePath}/${dirHandle.name}` : dirHandle.name; - const dirData = await this.fetchDirectory(fullPath); - - // Check if we got an error response - if (dirData.error) { - throw new Error(dirData.error); - } - - // Process the directory structure from the backend - const processedDir = { - path: dirData.path, - files: [], - subdirs: [], - selected: [] - }; - - // Separate files and directories - if (dirData.items && Array.isArray(dirData.items)) { - for (const item of dirData.items) { - if (item.type === 'file') { - processedDir.files.push(item.name); - } else if (item.type === 'directory') { - processedDir.subdirs.push(item.name); - } - } - } - - this.data.workspaces[this.currentWorkspace].dirs.push(processedDir); - this.saveData(); - this.render(); - this.showToast(`Directory "${dirHandle.name}" added`, 'success'); - - } catch (e) { - console.error('Failed to process directory:', e); - this.showToast('Failed to process directory', 'error'); - } - } - - async addDirectoryWithPrompt() { - const path = prompt('Enter directory path:'); - if (!path || path.trim() === '') return; - try { - await this.apiAddDir(path.trim()); - await this.render(); - this.showToast(`Directory "${path}" added`, 'success'); - } catch (e) { - console.error('Failed to add directory:', e); - this.showToast(`Failed to add directory: ${e.message}`, 'error'); - } - } - - async removeDirectory(path) { - if (!confirm('Are you sure you want to remove this directory?')) return; - try { - await this.apiRemoveDir(path); - await this.render(); - this.showToast('Directory removed', 'success'); - } catch (e) { - console.error('Failed to remove directory:', e); - this.showToast('Failed to remove directory', 'error'); - } - } - - // File selection management (backend-synced) - async toggleFileSelection(filePath) { - try { - if (this.selectedFiles.has(filePath)) { - await this.apiRemoveFile(filePath); - this.selectedFiles.delete(filePath); - } else { - await this.apiAddFile(filePath); - this.selectedFiles.add(filePath); - } - await this.renderWorkspaceDetails(); - } catch (e) { - console.error('Failed to toggle file selection:', e); - this.showToast('Failed to toggle file selection', 'error'); - } - } - - selectAllFiles(workspaceName, dirIndex) { - const dir = this.data.workspaces[workspaceName].dirs[dirIndex]; - dir.selected = [...dir.files]; - this.saveData(); - this.renderWorkspaceDetails(); - } - - deselectAllFiles(workspaceName, dirIndex) { - const dir = this.data.workspaces[workspaceName].dirs[dirIndex]; - dir.selected = []; - this.saveData(); - this.renderWorkspaceDetails(); - } - - // Generate file tree structure - generateFileTree(dirPath, files, subdirs) { - const lines = []; - const dirName = dirPath.split('/').pop() || dirPath; - - lines.push(`${dirPath}`); - - // Add files - files.forEach((file, index) => { - const isLast = index === files.length - 1 && subdirs.length === 0; - lines.push(`${isLast ? '└──' : '├──'} ${file}`); - }); - - // Add subdirectories (placeholder for now) - subdirs.forEach((subdir, index) => { - const isLast = index === subdirs.length - 1; - lines.push(`${isLast ? '└──' : '├──'} ${subdir}/`); - }); - - return lines.join('\n'); - } - - // Clipboard functionality with new format - async copySelection() { - if (!this.currentWorkspace) { - this.showToast('Select a workspace first', 'error'); - return; - } - - const userInstructions = document.getElementById('user-instructions').value.trim(); - if (!userInstructions) { - this.showToast('Please enter user instructions', 'error'); - return; - } - - let hasSelection = false; - const output = []; - - // Add user instructions - output.push(''); - output.push(userInstructions); - output.push(''); - output.push(''); - - // Generate file map - output.push(''); - const dirSet = new Set(Array.from(this.selectedFiles).map(p => p.split('/').slice(0, -1).join('/'))); - for (const dirPath of dirSet) { - const data = await this.fetchDirectory(dirPath); - const files = (data.items || []).filter(it => it.type === 'file').map(it => it.name); - const subdirs = (data.items || []).filter(it => it.type === 'directory').map(it => it.name); - const fileTree = this.generateFileTree(dirPath, files, subdirs); - output.push(fileTree); - } - output.push(''); - output.push(''); - - if (this.selectedFiles.size === 0) { - this.showToast('No files selected', 'error'); - return; - } - - // Generate file contents - output.push(''); - - try { - for (const filePath of Array.from(this.selectedFiles)) { - try { - const fileData = await this.fetchFileContent(filePath); - output.push(`File: ${filePath}`); - output.push(''); - output.push(`\`\`\`${fileData.language}`); - output.push(fileData.content); - output.push('```'); - output.push(''); - } catch (e) { - console.error(`Failed to fetch file ${filePath}:`, e); - output.push(`File: ${filePath}`); - output.push(''); - output.push('```text'); - output.push(`Error: Failed to read file - ${e.message}`); - output.push('```'); - output.push(''); - } - } - } catch (e) { - console.error('Error fetching file contents:', e); - this.showToast('Error fetching file contents', 'error'); - return; - } - - output.push(''); - - const text = output.join('\n'); - - if (navigator.clipboard && navigator.clipboard.writeText) { - try { - await navigator.clipboard.writeText(text); - this.showToast('Selection copied to clipboard', 'success'); - } catch (e) { - console.error('Clipboard error:', e); - this.fallbackCopyToClipboard(text); - } - } else { - this.fallbackCopyToClipboard(text); - } - } - - fallbackCopyToClipboard(text) { - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.style.position = 'fixed'; - textArea.style.left = '-999999px'; - textArea.style.top = '-999999px'; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - try { - document.execCommand('copy'); - this.showToast('Selection copied to clipboard', 'success'); - } catch (e) { - console.error('Fallback copy failed:', e); - this.showToast('Failed to copy to clipboard', 'error'); - } - - document.body.removeChild(textArea); - } - - // UI Management - initializeUI() { - // Cache DOM elements - this.elements = { - workspaceSelect: document.getElementById('workspace-select'), - workspaceContent: document.getElementById('workspace-content'), - currentWorkspaceName: document.getElementById('current-workspace-name'), - createWorkspaceBtn: document.getElementById('create-workspace'), - deleteWorkspaceBtn: document.getElementById('delete-workspace'), - addDirectoryBtn: document.getElementById('add-directory'), - copySelectionBtn: document.getElementById('copy-selection'), - clearInstructionsBtn: document.getElementById('clear-instructions'), - userInstructions: document.getElementById('user-instructions'), - toast: document.getElementById('notification-toast') - }; - } - - bindEvents() { - // Workspace management - this.elements.createWorkspaceBtn.addEventListener('click', async () => { - const name = prompt('Enter workspace name:'); - if (!name) return; - const base_path = prompt('Enter base path for this workspace (optional):') || ''; - try { - await this.apiCreateWorkspace(name.trim(), base_path.trim()); - await this.refreshWorkspaces(name.trim()); - this.showToast(`Workspace "${name}" created`, 'success'); - } catch (e) { - console.error(e); - this.showToast('Failed to create workspace', 'error'); - } - }); - - this.elements.deleteWorkspaceBtn.addEventListener('click', async () => { - if (!this.currentWorkspace) return; - if (!confirm(`Are you sure you want to delete workspace "${this.currentWorkspace}"?`)) return; - try { - await this.apiDeleteWorkspace(this.currentWorkspace); - await this.refreshWorkspaces(); - this.showToast('Workspace deleted', 'success'); - } catch (e) { - console.error(e); - this.showToast('Failed to delete workspace', 'error'); - } - }); - - this.elements.workspaceSelect.addEventListener('change', async () => { - this.currentWorkspace = this.elements.workspaceSelect.value; - await this.render(); - }); - - // Directory management - this.elements.addDirectoryBtn.addEventListener('click', () => { - this.addDirectory(); - }); - - // Copy selection - this.elements.copySelectionBtn.addEventListener('click', () => { - this.copySelection(); - }); - - // Clear instructions - this.elements.clearInstructionsBtn.addEventListener('click', () => { - this.elements.userInstructions.value = ''; - }); - } - - render() { - this.renderWorkspaceSelect(); - this.renderWorkspaceDetails(); - } - - renderWorkspaceSelect() { - const names = this.workspaces; - const options = names.map(n => ``).join(''); - this.elements.workspaceSelect.innerHTML = options; - this.elements.deleteWorkspaceBtn.style.display = this.currentWorkspace && this.currentWorkspace !== 'default' ? 'inline-block' : 'none'; - this.elements.currentWorkspaceName.textContent = this.currentWorkspace || 'Select a workspace'; - } - - renderWorkspaceDetails() { - const workspace = this.data.workspaces[this.currentWorkspace]; - - // Update header - this.elements.currentWorkspaceName.textContent = this.currentWorkspace; - - // Show/hide buttons based on selection - const hasWorkspace = !!this.currentWorkspace; - this.elements.deleteWorkspaceBtn.style.display = hasWorkspace && this.currentWorkspace !== 'default' ? 'inline-block' : 'none'; - this.elements.addDirectoryBtn.style.display = hasWorkspace ? 'inline-block' : 'none'; - this.elements.copySelectionBtn.style.display = hasWorkspace ? 'inline-block' : 'none'; - - if (!workspace) { - this.elements.workspaceContent.innerHTML = '

    Select a workspace to view its directories and files.

    '; - return; - } - - if (workspace.dirs.length === 0) { - this.elements.workspaceContent.innerHTML = ` -
    -
    📁
    -

    No directories added to this workspace.

    -

    Click "Add Directory" to get started.

    -
    - `; - return; - } - - // Load current workspace details from backend - try { - await this.loadCurrentWorkspaceDetails(); - } catch (e) { - console.error(e); - this.elements.workspaceContent.innerHTML = '

    Failed to load workspace details.

    '; - return; - } - - const dirs = Array.from(this.selectedDirs); - if (dirs.length === 0) { - this.elements.workspaceContent.innerHTML = ` -
    -
    📁
    -

    No directories added to this workspace.

    -

    Click "Add Directory" to get started.

    -
    - `; - return; - } - - // Render directories as expandable tree explorers - this.elements.workspaceContent.innerHTML = dirs.map((dirPath, dirIndex) => ` -
    -
    -
    -
    - ${this.getDirectoryName(dirPath)} -
    ${dirPath}
    -
    -
    - -
    -
    -
    -
    -
    - `).join(''); - - // Populate tree nodes asynchronously - dirs.forEach((dirPath, dirIndex) => { - const container = document.getElementById(`file-tree-${dirIndex}`); - this.renderDirNode(dirPath, dirIndex, container, 0); - }); - } - // Render a directory node with lazy subdir loading - async renderDirNode(dirPath, dirIndex, container, level) { - try { - // Fetch directory listing from backend - const data = await this.fetchDirectory(dirPath); - const items = data.items || []; - - // Separate directories and files - const dirs = items.filter(it => it.type === 'directory').map(it => it.name); - const files = items.filter(it => it.type === 'file').map(it => it.name); - - // Build HTML - const indent = ' '.repeat(level * 2); - const list = []; - - dirs.forEach(sub => { - const subPath = `${dirPath}/${sub}`; - const nodeId = `dir-node-${dirIndex}-${level}-${sub.replace(/[^a-zA-Z0-9_-]/g, '_')}`; - list.push(` -
    - 📁 - ${indent}${sub} - -
    - `); - }); - - files.forEach(file => { - const absPath = `${dirPath}/${file}`; - const isSel = this.selectedFiles.has(absPath); - list.push(` -
    - - - ${indent}${file} -
    - `); - }); - - container.innerHTML = list.join(''); - - // Bind toggles for lazy load - container.querySelectorAll('a.toggle').forEach(a => { - a.addEventListener('click', async (e) => { - e.preventDefault(); - const targetId = a.getAttribute('data-target'); - const path = a.getAttribute('data-path'); - const target = document.getElementById(targetId); - if (target.getAttribute('data-loaded') !== '1') { - await this.renderDirNode(path, dirIndex, target, level + 1); - target.setAttribute('data-loaded', '1'); - } - target.style.display = (target.style.display === 'none') ? 'block' : 'none'; - }); - }); - } catch (e) { - console.error('Failed to render directory node:', e); - container.innerHTML = `
    Failed to load ${dirPath}
    `; - } - } - - getDirectoryName(path) { - return path.split('/').pop() || path.split('\\').pop() || path; - } - - getFileIconClass(fileName) { - const ext = fileName.split('.').pop().toLowerCase(); - return `file-${ext}` || 'file-default'; - } - - showToast(message, type = 'info') { - const toast = this.elements.toast; - const toastBody = toast.querySelector('.toast-body'); - - toastBody.textContent = message; - - // Set toast color based on type - toast.className = 'toast'; - if (type === 'success') { - toast.classList.add('text-bg-success'); - } else if (type === 'error') { - toast.classList.add('text-bg-danger'); - } else { - toast.classList.add('text-bg-info'); - } - - const bsToast = new bootstrap.Toast(toast); - bsToast.show(); - } -} - -// Initialize when DOM is loaded -document.addEventListener('DOMContentLoaded', () => { - window.heroprompt = new Heroprompt(); -}); - -// Export for potential external use -if (typeof module !== 'undefined' && module.exports) { - module.exports = Heroprompt; -} \ No newline at end of file diff --git a/lib/web/ui/heroprompt/templates/heroprompt.html b/lib/web/ui/heroprompt/templates/heroprompt.html deleted file mode 100644 index 8345b825..00000000 --- a/lib/web/ui/heroprompt/templates/heroprompt.html +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - {{.title}} - Heroprompt - - - - - - - - - - - - -
    -
    -
    -
    Heroprompt
    - /admin/heroprompt -
    - -
    - -
    -
    -
    -
    Workspaces
    - -
    -
    - - - -
    -
    -
    - - -
    -
    -
    -
    - Select a workspace - -
    -
    - - -
    -
    -
    -
    -

    Select a workspace to view its directories and files.

    -
    -
    -
    -
    - - -
    -
    -
    -
    User Instructions
    - -
    -
    - -
    - - These instructions will be included in the generated prompt along with the selected - files. - -
    -
    -
    -
    -
    -
    -
    - - -
    - -
    - - - - - - - \ No newline at end of file diff --git a/lib/web/ui/heroprompt/endpoints.v b/lib/web/ui/heroprompt_api.v similarity index 76% rename from lib/web/ui/heroprompt/endpoints.v rename to lib/web/ui/heroprompt_api.v index 2ceaa5b4..03bbcbb6 100644 --- a/lib/web/ui/heroprompt/endpoints.v +++ b/lib/web/ui/heroprompt_api.v @@ -1,4 +1,4 @@ -module heroprompt +module ui import veb import os @@ -18,9 +18,9 @@ struct DirResp { // APIs @['/api/heroprompt/workspaces'; get] -pub fn api_heroprompt_list(mut ctx ui.Context) veb.Result { +pub fn (app &App) api_heroprompt_list(mut ctx Context) veb.Result { mut names := []string{} - ws := hp.list(fromdb: true) or { []&hp.Workspace{} } + ws := hp.list_workspaces_fromdb() or { []&hp.Workspace{} } for w in ws { names << w.name } @@ -29,7 +29,7 @@ pub fn api_heroprompt_list(mut ctx ui.Context) veb.Result { } @['/api/heroprompt/workspaces'; post] -pub fn (app &App) api_create(mut ctx Context) veb.Result { +pub fn (app &App) api_heroprompt_create(mut ctx Context) veb.Result { name := ctx.form['name'] or { 'default' } base_path_in := ctx.form['base_path'] or { '' } if base_path_in.len == 0 { @@ -50,8 +50,21 @@ pub fn (app &App) api_create(mut ctx Context) veb.Result { })) } +@['/api/heroprompt/workspaces/:name'; get] +pub fn (app &App) api_heroprompt_get(mut ctx Context, name string) veb.Result { + wsp := hp.get(name: name, create: false) or { + return ctx.text('{"error":"workspace not found"}') + } + ctx.set_content_type('application/json') + return ctx.text(json.encode({ + 'name': wsp.name + 'base_path': wsp.base_path + 'selected_files': wsp.selected_children().len.str() + })) +} + @['/api/heroprompt/directory'; get] -pub fn (app &App) api_directory(mut ctx Context) veb.Result { +pub fn (app &App) api_heroprompt_directory(mut ctx Context) veb.Result { wsname := ctx.query['name'] or { 'default' } path_q := ctx.query['path'] or { '' } mut wsp := hp.get(name: wsname, create: false) or { @@ -73,7 +86,7 @@ pub fn (app &App) api_directory(mut ctx Context) veb.Result { } @['/api/heroprompt/file'; get] -pub fn (app &App) api_file(mut ctx Context) veb.Result { +pub fn (app &App) api_heroprompt_file(mut ctx Context) veb.Result { wsname := ctx.query['name'] or { 'default' } path_q := ctx.query['path'] or { '' } if path_q.len == 0 { @@ -100,7 +113,7 @@ pub fn (app &App) api_file(mut ctx Context) veb.Result { } @['/api/heroprompt/workspaces/:name/files'; post] -pub fn (app &App) api_add_file(mut ctx Context, name string) veb.Result { +pub fn (app &App) api_heroprompt_add_file(mut ctx Context, name string) veb.Result { path := ctx.form['path'] or { '' } if path.len == 0 { return ctx.text('{"error":"path required"}') @@ -113,7 +126,7 @@ pub fn (app &App) api_add_file(mut ctx Context, name string) veb.Result { } @['/api/heroprompt/workspaces/:name/dirs'; post] -pub fn (app &App) api_add_dir(mut ctx Context, name string) veb.Result { +pub fn (app &App) api_heroprompt_add_dir(mut ctx Context, name string) veb.Result { path := ctx.form['path'] or { '' } if path.len == 0 { return ctx.text('{"error":"path required"}') @@ -126,7 +139,7 @@ pub fn (app &App) api_add_dir(mut ctx Context, name string) veb.Result { } @['/api/heroprompt/workspaces/:name/prompt'; post] -pub fn (app &App) api_generate_prompt(mut ctx Context, name string) veb.Result { +pub fn (app &App) api_heroprompt_generate_prompt(mut ctx Context, name string) veb.Result { text := ctx.form['text'] or { '' } mut wsp := hp.get(name: name, create: false) or { return ctx.text('{"error":"workspace not found"}') diff --git a/lib/web/ui/heroprompt/page.v b/lib/web/ui/heroprompt_page.v similarity index 80% rename from lib/web/ui/heroprompt/page.v rename to lib/web/ui/heroprompt_page.v index 2a70a2b5..30b7435f 100644 --- a/lib/web/ui/heroprompt/page.v +++ b/lib/web/ui/heroprompt_page.v @@ -12,9 +12,9 @@ pub fn render_heroprompt_page(app &App) !string { result = result.replace('{{.menu_html}}', menu_content) result = result.replace('{{.css_colors_url}}', '/static/css/colors.css') result = result.replace('{{.css_main_url}}', '/static/css/main.css') - result = result.replace('{{.css_heroprompt_url}}', '/static/heroprompt/css/heroprompt.css') + result = result.replace('{{.css_heroprompt_url}}', '/static/css/heroprompt.css') result = result.replace('{{.js_theme_url}}', '/static/js/theme.js') - result = result.replace('{{.js_heroprompt_url}}', '/static/heroprompt/js/heroprompt.js') + result = result.replace('{{.js_heroprompt_url}}', '/static/js/heroprompt.js') // version banner result = result.replace('', '
    Rendered by: heroprompt
    ') return result diff --git a/lib/web/ui/heroprompt/utils.v b/lib/web/ui/heroprompt_utils.v similarity index 100% rename from lib/web/ui/heroprompt/utils.v rename to lib/web/ui/heroprompt_utils.v diff --git a/lib/web/ui/heroscript/endpoints.v b/lib/web/ui/heroscript_endpoints.v similarity index 66% rename from lib/web/ui/heroscript/endpoints.v rename to lib/web/ui/heroscript_endpoints.v index 36713e0e..307aaa09 100644 --- a/lib/web/ui/heroscript/endpoints.v +++ b/lib/web/ui/heroscript_endpoints.v @@ -1,13 +1,12 @@ -module heroscript +module ui import os -import freeflowuniverse.herolib.web.ui // Render HeroScript page -pub fn render(app &ui.App) !string { +pub fn render_heroscript_alt(app &App) !string { tpl := os.join_path(os.dir(@FILE), 'templates', 'heroscript_editor.html') content := os.read_file(tpl)! - menu_content := ui.menu_html(app.menu, 0, 'm') + menu_content := menu_html(app.menu, 0, 'm') mut result := content result = result.replace('{{.title}}', app.title) result = result.replace('{{.menu_html}}', menu_content) @@ -16,8 +15,8 @@ pub fn render(app &ui.App) !string { result = result.replace('{{.css_main_url}}', '/static/css/main.css') result = result.replace('{{.js_theme_url}}', '/static/js/theme.js') // feature CSS/JS - result = result.replace('{{.css_heroscript_url}}', '/static/heroscript/css/heroscript.css') - result = result.replace('{{.js_heroscript_url}}', '/static/heroscript/js/heroscript.js') + result = result.replace('{{.css_heroscript_url}}', '/static/css/heroscript.css') + result = result.replace('{{.js_heroscript_url}}', '/static/js/heroscript.js') // version banner result = result.replace('', '
    Rendered by: heroscript
    ') return result diff --git a/lib/web/ui/heroscript/utils.v b/lib/web/ui/heroscript_utils.v similarity index 72% rename from lib/web/ui/heroscript/utils.v rename to lib/web/ui/heroscript_utils.v index e99cf686..d8f35dc1 100644 --- a/lib/web/ui/heroscript/utils.v +++ b/lib/web/ui/heroscript_utils.v @@ -1,4 +1,3 @@ -module heroscript +module ui // Placeholder for heroscript-specific utilities - diff --git a/lib/web/ui/server.v b/lib/web/ui/server.v index 4948ee34..7d59788b 100644 --- a/lib/web/ui/server.v +++ b/lib/web/ui/server.v @@ -2,8 +2,6 @@ module ui import veb import os -import net.http -import json // Feature endpoint files live in subdirectories but share the `ui` module, // so no explicit imports are needed here. @@ -47,12 +45,9 @@ pub fn new(args FactoryArgs) !&App { menu: if args.menu.len > 0 { args.menu } else { get_default_menu() } port: args.port } - // Mount shared and per-feature static folders + // Mount shared static folder base := os.dir(@FILE) app.mount_static_folder_at(os.join_path(base, 'static'), '/static')! - app.mount_static_folder_at(os.join_path(base, 'heroprompt', 'static'), '/static/heroprompt')! - app.mount_static_folder_at(os.join_path(base, 'heroscript', 'static'), '/static/heroscript')! - app.mount_static_folder_at(os.join_path(base, 'chat', 'static'), '/static/chat')! return &app } @@ -123,6 +118,13 @@ pub fn (app &App) admin_section(mut ctx Context, path string) veb.Result { } } +// Test API endpoint to verify routing works +@['/api/test'; get] +pub fn (app &App) api_test(mut ctx Context) veb.Result { + ctx.set_content_type('application/json') + return ctx.text('{"status":"ok","message":"API is working"}') +} + // Pure functions for rendering templates fn render_admin(app &App, path string, heading string) string { template_path := os.join_path(os.dir(@FILE), 'templates', 'admin', 'layout.html') @@ -142,7 +144,7 @@ fn render_admin(app &App, path string, heading string) string { } fn render_heroscript(app &App) string { - template_path := os.join_path(os.dir(@FILE), 'heroscript', 'templates', 'heroscript_editor.html') + template_path := os.join_path(os.dir(@FILE), 'templates', 'heroscript_editor.html') template_content := os.read_file(template_path) or { return render_heroscript_fallback(app) } menu_content := menu_html(app.menu, 0, 'm') mut result := template_content @@ -150,14 +152,14 @@ fn render_heroscript(app &App) string { result = result.replace('{{.menu_html}}', menu_content) result = result.replace('{{.css_colors_url}}', '/static/css/colors.css') result = result.replace('{{.css_main_url}}', '/static/css/main.css') - result = result.replace('{{.css_heroscript_url}}', '/static/heroscript/css/heroscript.css') + result = result.replace('{{.css_heroscript_url}}', '/static/css/heroscript.css') result = result.replace('{{.js_theme_url}}', '/static/js/theme.js') - result = result.replace('{{.js_heroscript_url}}', '/static/heroscript/js/heroscript.js') + result = result.replace('{{.js_heroscript_url}}', '/static/js/heroscript.js') return result } fn render_heroprompt(app &App) string { - template_path := os.join_path(os.dir(@FILE), 'heroprompt', 'templates', 'heroprompt.html') + template_path := os.join_path(os.dir(@FILE), 'templates', 'heroprompt.html') template_content := os.read_file(template_path) or { return render_heroprompt_fallback(app) } menu_content := menu_html(app.menu, 0, 'm') mut result := template_content @@ -165,16 +167,16 @@ fn render_heroprompt(app &App) string { result = result.replace('{{.menu_html}}', menu_content) result = result.replace('{{.css_colors_url}}', '/static/css/colors.css') result = result.replace('{{.css_main_url}}', '/static/css/main.css') - result = result.replace('{{.css_heroprompt_url}}', '/static/heroprompt/css/heroprompt.css') + result = result.replace('{{.css_heroprompt_url}}', '/static/css/heroprompt.css') result = result.replace('{{.js_theme_url}}', '/static/js/theme.js') - result = result.replace('{{.js_heroprompt_url}}', '/static/heroprompt/js/heroprompt.js') + result = result.replace('{{.js_heroprompt_url}}', '/static/js/heroprompt.js') // version banner result = result.replace('', '
    Rendered by: heroprompt
    ') return result } fn render_chat(app &App) string { - template_path := os.join_path(os.dir(@FILE), 'chat', 'templates', 'chat.html') + template_path := os.join_path(os.dir(@FILE), 'templates', 'chat.html') template_content := os.read_file(template_path) or { return render_chat_fallback(app) } menu_content := menu_html(app.menu, 0, 'm') mut result := template_content @@ -182,9 +184,9 @@ fn render_chat(app &App) string { result = result.replace('{{.menu_html}}', menu_content) result = result.replace('{{.css_colors_url}}', '/static/css/colors.css') result = result.replace('{{.css_main_url}}', '/static/css/main.css') - result = result.replace('{{.css_chat_url}}', '/static/chat/css/chat.css') + result = result.replace('{{.css_chat_url}}', '/static/css/chat.css') result = result.replace('{{.js_theme_url}}', '/static/js/theme.js') - result = result.replace('{{.js_chat_url}}', '/static/chat/js/chat.js') + result = result.replace('{{.js_chat_url}}', '/static/js/chat.js') return result } @@ -202,6 +204,6 @@ fn render_chat_fallback(app &App) string { } fn render_admin_fallback(app &App, path string, heading string) string { - return '\n\n\n\n\t\n\t\n\t${app.title}\n\t\n\t\n\n\n\t\n\n\t\n\n\t
    \n\t\t
    \n\t\t\t
    \n\t\t\t\t
    ${heading}
    \n\t\t\t\t/admin/${path}\n\t\t\t
    \n\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t

    This is a placeholder admin content area for: /admin/${path}.

    \n\t\t\t\t\t

    Use the treeview on the left to navigate.

    \n\t\t\t\t
    \n\t\t\t
    \n\t\t
    \n\t
    \n\n\t\n\n\n' + menu_content := menu_html(app.menu, 0, 'm') + return '\n\n\n\n\t\n\t\n\t${app.title}\n\t\n\t\n\n\n\t\n\n\t\n\n\t
    \n\t\t
    \n\t\t\t
    \n\t\t\t\t
    ${heading}
    \n\t\t\t\t/admin/${path}\n\t\t\t
    \n\t\t\t
    \n\t\t\t\t
    \n\t\t\t\t\t

    This is a placeholder admin content area for: /admin/${path}.

    \n\t\t\t\t\t

    Use the treeview on the left to navigate.

    \n\t\t\t\t
    \n\t\t\t
    \n\t\t
    \n\t
    \n\n\t\n\n\n' } diff --git a/lib/web/ui/chat/static/css/chat.css b/lib/web/ui/static/css/chat.css similarity index 100% rename from lib/web/ui/chat/static/css/chat.css rename to lib/web/ui/static/css/chat.css diff --git a/lib/web/ui/static/css/heroprompt.css b/lib/web/ui/static/css/heroprompt.css new file mode 100644 index 00000000..c18af77f --- /dev/null +++ b/lib/web/ui/static/css/heroprompt.css @@ -0,0 +1,329 @@ +/* Heroprompt specific styles using UI project theme system */ + +/* Tree view specific styles */ +.tree { + font-size: 0.875rem; +} + +.tree .chev { + transition: transform 0.15s ease; + display: inline-block; + font-size: 0.75rem; + opacity: var(--menu-chevron-opacity); +} + +.tree .toggle:checked+.dir-label .chev { + transform: rotate(90deg); +} + +.tree .children { + margin-left: 16px; + border-left: 1px solid var(--border-primary); + padding-left: 8px; +} + +.tree .dir-label { + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + transition: all 0.2s ease; + color: var(--text-primary); +} + +.tree .dir-label:hover { + background-color: var(--menu-item-hover-bg); + color: var(--menu-item-hover-text); +} + +.tree .file { + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + transition: all 0.2s ease; +} + +.tree .file:hover { + background-color: var(--menu-item-hover-bg); +} + +.tree .file a { + color: var(--text-primary); + text-decoration: none; + transition: color 0.2s ease; +} + +.tree .file a:hover { + color: var(--link-hover-color); +} + +/* Tab content styling */ +.tab-pane { + height: calc(100vh - 200px); + overflow-y: auto; + background-color: var(--bg-primary); + color: var(--text-primary); +} + +/* Selection list styling */ +#selected { + max-height: 300px; + overflow-y: auto; + background-color: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 0.375rem; + padding: 0.5rem; +} + +#selected li { + background-color: var(--card-bg); + border: 1px solid var(--card-border); + color: var(--text-primary); +} + +#selected li:hover { + background-color: var(--bg-tertiary); +} + +/* Preview area styling */ +#preview { + background-color: var(--bg-secondary); + border: 1px solid var(--border-primary); + color: var(--text-primary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 0.75rem; + white-space: pre-wrap; + line-height: 1.4; + border-radius: 0.375rem; + padding: 0.75rem; +} + +/* Prompt output styling */ +#promptOutput { + background-color: var(--bg-secondary); + border: 1px solid var(--border-primary); + color: var(--text-primary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 0.75rem; + white-space: pre-wrap; + line-height: 1.4; + border-radius: 0.375rem; + padding: 0.75rem; +} + +/* Chat messages styling */ +#chatMessages { + background-color: var(--bg-secondary); + border: 1px solid var(--border-primary); + color: var(--text-primary); + border-radius: 0.375rem; + padding: 0.75rem; +} + +/* Form controls theme integration */ +.form-control { + background-color: var(--bg-primary); + border-color: var(--border-primary); + color: var(--text-primary); +} + +.form-control:focus { + background-color: var(--bg-primary); + border-color: var(--link-color); + color: var(--text-primary); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +.form-control::placeholder { + color: var(--text-muted); +} + +/* Select elements */ +.form-select { + background-color: var(--bg-primary); + border-color: var(--border-primary); + color: var(--text-primary); +} + +.form-select:focus { + background-color: var(--bg-primary); + border-color: var(--link-color); + color: var(--text-primary); +} + +/* Button theme integration */ +.btn-outline-secondary { + color: var(--text-secondary); + border-color: var(--border-primary); +} + +.btn-outline-secondary:hover { + background-color: var(--bg-tertiary); + border-color: var(--border-secondary); + color: var(--text-primary); +} + +.btn-outline-primary { + color: var(--link-color); + border-color: var(--link-color); +} + +.btn-outline-primary:hover { + background-color: var(--link-color); + border-color: var(--link-color); + color: var(--text-light); +} + +.btn-outline-danger { + color: var(--danger-color); + border-color: var(--danger-color); +} + +.btn-outline-danger:hover { + background-color: var(--danger-color); + border-color: var(--danger-color); + color: var(--text-light); +} + +/* Badge styling */ +.badge { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +/* Modal theme integration */ +.modal-content { + background-color: var(--card-bg); + border: 1px solid var(--card-border); + color: var(--text-primary); +} + +.modal-header { + border-bottom-color: var(--border-primary); +} + +.modal-footer { + border-top-color: var(--border-primary); +} + +/* Alert styling */ +.alert-success { + background-color: rgba(25, 135, 84, 0.1); + border-color: var(--success-color); + color: var(--success-color); +} + +.alert-danger { + background-color: rgba(220, 53, 69, 0.1); + border-color: var(--danger-color); + color: var(--danger-color); +} + +/* Tree node buttons */ +.tree .btn { + font-size: 0.75rem; + padding: 0.125rem 0.375rem; + line-height: 1.2; +} + +/* Workspace info styling */ +.workspace-info { + background-color: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 0.375rem; + padding: 0.5rem; + font-size: 0.75rem; + color: var(--text-muted); +} + +/* Loading states */ +.loading { + opacity: 0.6; + pointer-events: none; +} + +.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid var(--border-primary); + border-top-color: var(--link-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Error states */ +.error-message { + color: var(--danger-color); + background-color: rgba(220, 53, 69, 0.1); + border: 1px solid var(--danger-color); + border-radius: 0.375rem; + padding: 0.5rem; + font-size: 0.875rem; +} + +/* Success states */ +.success-message { + color: var(--success-color); + background-color: rgba(25, 135, 84, 0.1); + border: 1px solid var(--success-color); + border-radius: 0.375rem; + padding: 0.5rem; + font-size: 0.875rem; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .tree { + font-size: 0.8rem; + } + + .tab-pane { + height: calc(100vh - 150px); + } + + #preview, + #promptOutput { + font-size: 0.7rem; + } +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + + .tree .dir-label:hover, + .tree .file:hover { + background-color: var(--text-primary); + color: var(--bg-primary); + } + + .btn-outline-primary, + .btn-outline-secondary, + .btn-outline-danger { + border-width: 2px; + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + + .tree .chev, + .tree .dir-label, + .tree .file, + .form-control, + .btn { + transition: none; + } + + .loading::after { + animation: none; + } +} \ No newline at end of file diff --git a/lib/web/ui/heroscript/static/css/heroscript.css b/lib/web/ui/static/css/heroscript.css similarity index 100% rename from lib/web/ui/heroscript/static/css/heroscript.css rename to lib/web/ui/static/css/heroscript.css diff --git a/lib/web/ui/chat/static/js/chat.js b/lib/web/ui/static/js/chat.js similarity index 100% rename from lib/web/ui/chat/static/js/chat.js rename to lib/web/ui/static/js/chat.js diff --git a/lib/web/ui/static/js/heroprompt.js b/lib/web/ui/static/js/heroprompt.js new file mode 100644 index 00000000..3a9255c0 --- /dev/null +++ b/lib/web/ui/static/js/heroprompt.js @@ -0,0 +1,576 @@ +console.log('Heroprompt UI loaded'); + +let currentWs = localStorage.getItem('heroprompt-current-ws') || 'default'; +let selected = []; + +const el = (id) => document.getElementById(id); + +async function api(url) { + try { + const r = await fetch(url); + if (!r.ok) { + console.warn(`API call failed: ${url} - ${r.status}`); + return { error: `HTTP ${r.status}` }; + } + return await r.json(); + } + catch (e) { + console.warn(`API call error: ${url}`, e); + return { error: 'request failed' }; + } +} + +async function post(url, data) { + const form = new FormData(); + Object.entries(data).forEach(([k, v]) => form.append(k, v)); + try { + const r = await fetch(url, { method: 'POST', body: form }); + if (!r.ok) { + console.warn(`POST failed: ${url} - ${r.status}`); + return { error: `HTTP ${r.status}` }; + } + return await r.json(); + } + catch (e) { + console.warn(`POST error: ${url}`, e); + return { error: 'request failed' }; + } +} + +// Bootstrap modal helpers +function showModal(id) { + const modalEl = el(id); + if (modalEl) { + const modal = new bootstrap.Modal(modalEl); + modal.show(); + } +} + +function hideModal(id) { + const modalEl = el(id); + if (modalEl) { + const modal = bootstrap.Modal.getInstance(modalEl); + if (modal) modal.hide(); + } +} + +// Tab switching with Bootstrap +function switchTab(tabName) { + // Hide all tab panes + document.querySelectorAll('.tab-pane').forEach(pane => { + pane.style.display = 'none'; + pane.classList.remove('active'); + }); + + // Remove active class from all tabs + document.querySelectorAll('.tab').forEach(tab => { + tab.classList.remove('active'); + }); + + // Show selected tab pane + const targetPane = el(`tab-${tabName}`); + if (targetPane) { + targetPane.style.display = 'block'; + targetPane.classList.add('active'); + } + + // Add active class to clicked tab + const targetTab = document.querySelector(`.tab[data-tab="${tabName}"]`); + if (targetTab) { + targetTab.classList.add('active'); + } +} + +// Initialize tab switching +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', function (e) { + e.preventDefault(); + const tabName = this.getAttribute('data-tab'); + switchTab(tabName); + }); + }); +}); + +// Checkbox-based collapsible tree +let nodeId = 0; + +function renderTree(displayName, fullPath) { + const c = document.createElement('div'); + c.className = 'tree'; + const ul = document.createElement('ul'); + ul.className = 'tree-root list-unstyled'; + const root = buildDirNode(displayName, fullPath, true); + ul.appendChild(root); + c.appendChild(ul); + return c; +} + +function buildDirNode(name, fullPath, expanded = false) { + const li = document.createElement('li'); + li.className = 'dir mb-1'; + const id = `tn_${nodeId++}`; + + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.className = 'toggle d-none'; + toggle.id = id; + if (expanded) toggle.checked = true; + + const label = document.createElement('label'); + label.htmlFor = id; + label.className = 'dir-label d-flex align-items-center text-decoration-none'; + label.style.cursor = 'pointer'; + + const icon = document.createElement('span'); + icon.className = 'chev me-1'; + icon.innerHTML = expanded ? '📂' : '📁'; + + const text = document.createElement('span'); + text.className = 'name flex-grow-1'; + text.textContent = name; + + label.appendChild(icon); + label.appendChild(text); + + const add = document.createElement('button'); + add.className = 'btn btn-sm btn-outline-primary ms-1'; + add.textContent = '+'; + add.title = 'Add directory to selection'; + add.onclick = (e) => { + e.stopPropagation(); + addDirToSelection(fullPath); + }; + + const children = document.createElement('ul'); + children.className = 'children list-unstyled ms-3'; + children.style.display = expanded ? 'block' : 'none'; + + toggle.addEventListener('change', async () => { + if (toggle.checked) { + children.style.display = 'block'; + icon.innerHTML = '📂'; + if (!li.dataset.loaded) { + await loadChildren(fullPath, children); + li.dataset.loaded = '1'; + } + } else { + children.style.display = 'none'; + icon.innerHTML = '📁'; + } + }); + + // Load immediately if expanded by default + if (expanded) { + setTimeout(async () => { + await loadChildren(fullPath, children); + li.dataset.loaded = '1'; + }, 0); + } + + li.appendChild(toggle); + li.appendChild(label); + li.appendChild(add); + li.appendChild(children); + return li; +} + +function createFileNode(name, fullPath) { + const li = document.createElement('li'); + li.className = 'file d-flex align-items-center mb-1'; + + const icon = document.createElement('span'); + icon.className = 'me-2'; + icon.innerHTML = '📄'; + + const a = document.createElement('a'); + a.href = '#'; + a.className = 'text-decoration-none flex-grow-1'; + a.textContent = name; + a.onclick = (e) => { + e.preventDefault(); + previewFile(fullPath); + }; + + const add = document.createElement('button'); + add.className = 'btn btn-sm btn-outline-primary ms-1'; + add.textContent = '+'; + add.title = 'Add file to selection'; + add.onclick = (e) => { + e.stopPropagation(); + addFileToSelection(fullPath); + }; + + li.appendChild(icon); + li.appendChild(a); + li.appendChild(add); + return li; +} + +async function previewFile(filePath) { + const previewEl = el('preview'); + if (!previewEl) return; + + previewEl.innerHTML = '
    Loading...
    '; + + const r = await api(`/api/heroprompt/file?name=${currentWs}&path=${encodeURIComponent(filePath)}`); + if (r.error) { + previewEl.innerHTML = `
    Error: ${r.error}
    `; + return; + } + + previewEl.textContent = r.content || 'No content'; +} + +async function loadChildren(parentPath, ul) { + const r = await api(`/api/heroprompt/directory?name=${currentWs}&path=${encodeURIComponent(parentPath)}`); + if (r.error) { + ul.innerHTML = `
  • ${r.error}
  • `; + return; + } + ul.innerHTML = ''; + for (const it of r.items || []) { + const full = parentPath.endsWith('/') ? parentPath + it.name : parentPath + '/' + it.name; + if (it.type === 'directory') { + ul.appendChild(buildDirNode(it.name, full, false)); + } else { + ul.appendChild(createFileNode(it.name, full)); + } + } +} + +async function loadDir(p) { + const treeEl = el('tree'); + if (!treeEl) return; + + treeEl.innerHTML = '
    Loading workspace...
    '; + const display = p.split('/').filter(Boolean).slice(-1)[0] || p; + treeEl.appendChild(renderTree(display, p)); + updateSelectionList(); +} + +function updateSelectionList() { + const selCountEl = el('selCount'); + const tokenCountEl = el('tokenCount'); + const selectedEl = el('selected'); + + if (selCountEl) selCountEl.textContent = String(selected.length); + if (selectedEl) { + selectedEl.innerHTML = ''; + if (selected.length === 0) { + selectedEl.innerHTML = '
  • No files selected
  • '; + } else { + for (const p of selected) { + const li = document.createElement('li'); + li.className = 'd-flex justify-content-between align-items-center mb-1 p-2 border rounded'; + + const span = document.createElement('span'); + span.className = 'small'; + span.textContent = p; + + const btn = document.createElement('button'); + btn.className = 'btn btn-sm btn-outline-danger'; + btn.textContent = '×'; + btn.onclick = () => { + selected = selected.filter(x => x !== p); + updateSelectionList(); + }; + + li.appendChild(span); + li.appendChild(btn); + selectedEl.appendChild(li); + } + } + } + + // naive token estimator ~ 4 chars/token + const tokens = Math.ceil(selected.join('\n').length / 4); + if (tokenCountEl) tokenCountEl.textContent = String(Math.ceil(tokens)); +} + +function addToSelection(p) { + if (!selected.includes(p)) { + selected.push(p); + updateSelectionList(); + } +} + +async function addDirToSelection(p) { + const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/dirs`, { + method: 'POST', + body: new URLSearchParams({ path: p }) + }); + const j = await r.json().catch(() => ({ error: 'request failed' })); + if (j && j.ok !== false && !j.error) { + if (!selected.includes(p)) selected.push(p); + updateSelectionList(); + } else { + console.warn('Failed to add directory:', j.error || 'Unknown error'); + } +} + +async function addFileToSelection(p) { + if (selected.includes(p)) return; + const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/files`, { + method: 'POST', + body: new URLSearchParams({ path: p }) + }); + const j = await r.json().catch(() => ({ error: 'request failed' })); + if (j && j.ok !== false && !j.error) { + selected.push(p); + updateSelectionList(); + } else { + console.warn('Failed to add file:', j.error || 'Unknown error'); + } +} + +// Workspaces list + selector +async function reloadWorkspaces() { + const sel = el('workspaceSelect'); + if (!sel) return; + + sel.innerHTML = ''; + const names = await api('/api/heroprompt/workspaces'); + + sel.innerHTML = ''; + if (names.error || !Array.isArray(names)) { + sel.innerHTML = ''; + console.warn('Failed to load workspaces:', names); + return; + } + + for (const n of names) { + const opt = document.createElement('option'); + opt.value = n; + opt.textContent = n; + sel.appendChild(opt); + } + + // ensure current ws name exists or select first + if (names.includes(currentWs)) { + sel.value = currentWs; + } else if (names.length > 0) { + currentWs = names[0]; + sel.value = currentWs; + localStorage.setItem('heroprompt-current-ws', currentWs); + } +} + +// On initial load: pick current or first workspace and load its base +async function initWorkspace() { + const names = await api('/api/heroprompt/workspaces'); + if (names.error || !Array.isArray(names) || names.length === 0) { + console.warn('No workspaces available'); + const treeEl = el('tree'); + if (treeEl) { + treeEl.innerHTML = '
    No workspaces available. Create one to get started.
    '; + } + return; + } + + if (!currentWs || !names.includes(currentWs)) { + currentWs = names[0]; + localStorage.setItem('heroprompt-current-ws', currentWs); + } + + const sel = el('workspaceSelect'); + if (sel) sel.value = currentWs; + + const info = await api(`/api/heroprompt/workspaces/${currentWs}`); + const base = info?.base_path || ''; + if (base) await loadDir(base); +} + +// Initialize everything when DOM is ready +document.addEventListener('DOMContentLoaded', function () { + // Initialize workspaces + initWorkspace(); + reloadWorkspaces(); + + // Workspace selector change handler + const workspaceSelect = el('workspaceSelect'); + if (workspaceSelect) { + workspaceSelect.addEventListener('change', async (e) => { + currentWs = e.target.value; + localStorage.setItem('heroprompt-current-ws', currentWs); + const info = await api(`/api/heroprompt/workspaces/${currentWs}`); + const base = info?.base_path || ''; + if (base) await loadDir(base); + }); + } + + // Create workspace modal handlers + const wsCreateBtn = el('wsCreateBtn'); + if (wsCreateBtn) { + wsCreateBtn.addEventListener('click', () => { + const nameEl = el('wcName'); + const pathEl = el('wcPath'); + const errorEl = el('wcError'); + + if (nameEl) nameEl.value = ''; + if (pathEl) pathEl.value = ''; + if (errorEl) errorEl.textContent = ''; + + showModal('wsCreate'); + }); + } + + const wcCreateBtn = el('wcCreate'); + if (wcCreateBtn) { + wcCreateBtn.addEventListener('click', async () => { + const name = el('wcName')?.value?.trim() || ''; + const path = el('wcPath')?.value?.trim() || ''; + const errorEl = el('wcError'); + + if (!path) { + if (errorEl) errorEl.textContent = 'Path is required.'; + return; + } + + const formData = { base_path: path }; + if (name) formData.name = name; + + const resp = await post('/api/heroprompt/workspaces', formData); + if (resp.error) { + if (errorEl) errorEl.textContent = resp.error; + return; + } + + currentWs = resp.name || currentWs; + localStorage.setItem('heroprompt-current-ws', currentWs); + await reloadWorkspaces(); + + const info = await api(`/api/heroprompt/workspaces/${currentWs}`); + const base = info?.base_path || ''; + if (base) await loadDir(base); + + hideModal('wsCreate'); + }); + } + + // Refresh workspace handler + const refreshBtn = el('refreshWs'); + if (refreshBtn) { + refreshBtn.addEventListener('click', async () => { + const info = await api(`/api/heroprompt/workspaces/${currentWs}`); + const base = info?.base_path || ''; + if (base) await loadDir(base); + }); + } + + // Search handler + const searchBtn = el('doSearch'); + if (searchBtn) { + searchBtn.onclick = async () => { + const q = el('search')?.value?.trim(); + if (!q) return; + + // For now, just show a message since search endpoint might not exist + const tree = el('tree'); + if (tree) { + tree.innerHTML = '
    Search functionality coming soon...
    '; + } + }; + } + + // Copy prompt handler + const copyPromptBtn = el('copyPrompt'); + if (copyPromptBtn) { + copyPromptBtn.addEventListener('click', async () => { + const text = el('promptText')?.value || ''; + try { + const r = await fetch(`/api/heroprompt/workspaces/${currentWs}/prompt`, { + method: 'POST', + body: new URLSearchParams({ text }) + }); + const out = await r.text(); + await navigator.clipboard.writeText(out); + + // Show success feedback + const outputEl = el('promptOutput'); + if (outputEl) { + outputEl.innerHTML = '
    Prompt copied to clipboard!
    '; + setTimeout(() => { + outputEl.innerHTML = '
    Generated prompt will appear here
    '; + }, 3000); + } + } catch (e) { + console.warn('copy prompt failed', e); + const outputEl = el('promptOutput'); + if (outputEl) { + outputEl.innerHTML = '
    Failed to copy prompt
    '; + setTimeout(() => { + outputEl.innerHTML = '
    Generated prompt will appear here
    '; + }, 3000); + } + } + }); + } + + // Workspace details modal handler + const wsDetailsBtn = el('wsDetailsBtn'); + if (wsDetailsBtn) { + wsDetailsBtn.addEventListener('click', async () => { + const info = await api(`/api/heroprompt/workspaces/${currentWs}`); + if (info && !info.error) { + const nameEl = el('wdName'); + const pathEl = el('wdPath'); + const errorEl = el('wdError'); + + if (nameEl) nameEl.value = info.name || currentWs; + if (pathEl) pathEl.value = info.base_path || ''; + if (errorEl) errorEl.textContent = ''; + + showModal('wsDetails'); + } + }); + } + + // Workspace manage modal handler + const openWsManageBtn = el('openWsManage'); + if (openWsManageBtn) { + openWsManageBtn.addEventListener('click', async () => { + const list = el('wmList'); + const err = el('wmError'); + if (!list) return; + + if (err) err.textContent = ''; + list.innerHTML = '
    Loading workspaces...
    '; + + const names = await api('/api/heroprompt/workspaces'); + list.innerHTML = ''; + + if (names.error || !Array.isArray(names)) { + list.innerHTML = '
    Failed to load workspaces
    '; + return; + } + + for (const n of names) { + const item = document.createElement('div'); + item.className = 'list-group-item d-flex justify-content-between align-items-center'; + + const span = document.createElement('span'); + span.textContent = n; + + const btn = document.createElement('button'); + btn.className = 'btn btn-sm btn-primary'; + btn.textContent = 'Use'; + btn.onclick = async () => { + currentWs = n; + localStorage.setItem('heroprompt-current-ws', currentWs); + await reloadWorkspaces(); + const info = await api(`/api/heroprompt/workspaces/${currentWs}`); + const base = info?.base_path || ''; + if (base) await loadDir(base); + hideModal('wsManage'); + }; + + item.appendChild(span); + item.appendChild(btn); + list.appendChild(item); + } + + showModal('wsManage'); + }); + } +}); diff --git a/lib/web/ui/heroscript/static/js/heroscript.js b/lib/web/ui/static/js/heroscript.js similarity index 100% rename from lib/web/ui/heroscript/static/js/heroscript.js rename to lib/web/ui/static/js/heroscript.js diff --git a/lib/web/ui/chat/templates/chat.html b/lib/web/ui/templates/chat.html similarity index 100% rename from lib/web/ui/chat/templates/chat.html rename to lib/web/ui/templates/chat.html diff --git a/lib/web/ui/templates/heroprompt.html b/lib/web/ui/templates/heroprompt.html new file mode 100644 index 00000000..db46623b --- /dev/null +++ b/lib/web/ui/templates/heroprompt.html @@ -0,0 +1,254 @@ + + + + + + + {{.title}} - Heroprompt + + + + + + + + + + + + +
    +
    +
    +
    Heroprompt
    +
    + + +
    +
    + +
    + +
    +
    +
    +
    Workspace Explorer
    +
    + + +
    +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + +
    +
    Select a workspace to browse files
    +
    + +
    + Click + buttons to add files/directories to selection +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    + +
    +
    +
    Selected Files & Directories
    + ~0 tokens +
    +
    +
    +
    +
      +
    • No files selected
    • +
    +
    +
    +
    +
    +
    Select a file to preview
    +
    +
    +
    +
    + + + + + + +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/web/ui/heroscript/templates/heroscript_editor.html b/lib/web/ui/templates/heroscript_editor.html similarity index 100% rename from lib/web/ui/heroscript/templates/heroscript_editor.html rename to lib/web/ui/templates/heroscript_editor.html