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
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 = `<li class="err">${r.error}</li>`; 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 = '<div>Search results:</div>';
|
||||
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 = ''; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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('</body>', '<div class="v-badge">Rendered by: chat</div></body>')
|
||||
return result
|
||||
@@ -1,4 +1,3 @@
|
||||
module chat
|
||||
module ui
|
||||
|
||||
// Placeholder for chat-specific utilities
|
||||
|
||||
@@ -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: "📁";
|
||||
}
|
||||
@@ -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('<user_instructions>');
|
||||
output.push(userInstructions);
|
||||
output.push('</user_instructions>');
|
||||
output.push('');
|
||||
|
||||
// Generate file map
|
||||
output.push('<file_map>');
|
||||
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('</file_map>');
|
||||
output.push('');
|
||||
|
||||
if (this.selectedFiles.size === 0) {
|
||||
this.showToast('No files selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate file contents
|
||||
output.push('<file_contents>');
|
||||
|
||||
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('</file_contents>');
|
||||
|
||||
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 => `<option value="${n}" ${n === this.currentWorkspace ? 'selected' : ''}>${n}</option>`).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 = '<p class="text-muted">Select a workspace to view its directories and files.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspace.dirs.length === 0) {
|
||||
this.elements.workspaceContent.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📁</div>
|
||||
<p>No directories added to this workspace.</p>
|
||||
<p class="text-muted">Click "Add Directory" to get started.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Load current workspace details from backend
|
||||
try {
|
||||
await this.loadCurrentWorkspaceDetails();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.elements.workspaceContent.innerHTML = '<p class="text-muted">Failed to load workspace details.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const dirs = Array.from(this.selectedDirs);
|
||||
if (dirs.length === 0) {
|
||||
this.elements.workspaceContent.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📁</div>
|
||||
<p>No directories added to this workspace.</p>
|
||||
<p class="text-muted">Click "Add Directory" to get started.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render directories as expandable tree explorers
|
||||
this.elements.workspaceContent.innerHTML = dirs.map((dirPath, dirIndex) => `
|
||||
<div class="directory-item">
|
||||
<div class="directory-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${this.getDirectoryName(dirPath)}</strong>
|
||||
<div class="directory-path">${dirPath}</div>
|
||||
</div>
|
||||
<div class="btn-group-actions">
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="heroprompt.removeDirectory('${dirPath}')">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-tree" id="file-tree-${dirIndex}"></div>
|
||||
</div>
|
||||
`).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(`
|
||||
<div class="file-item">
|
||||
<span class="file-icon">📁</span>
|
||||
<a href="#" class="toggle" data-target="${nodeId}" data-path="${subPath}">${indent}${sub}</a>
|
||||
<div id="${nodeId}" class="children" style="display:none; margin-left:12px;"></div>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
files.forEach(file => {
|
||||
const absPath = `${dirPath}/${file}`;
|
||||
const isSel = this.selectedFiles.has(absPath);
|
||||
list.push(`
|
||||
<div class="file-item ${isSel ? 'selected' : ''}" onclick="heroprompt.toggleFileSelection('${absPath}')">
|
||||
<input type="checkbox" ${isSel ? 'checked' : ''} onclick="event.stopPropagation()">
|
||||
<span class="file-icon ${this.getFileIconClass(file)}"></span>
|
||||
<span class="file-name">${indent}${file}</span>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
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 = `<div class="text-muted small">Failed to load ${dirPath}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.title}} - Heroprompt</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{.css_colors_url}}">
|
||||
<link rel="stylesheet" href="{{.css_main_url}}">
|
||||
<link rel="stylesheet" href="{{.css_heroprompt_url}}">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-dark bg-dark fixed-top header px-2">
|
||||
<div class="d-flex w-100 align-items-center justify-content-between">
|
||||
<div class="text-white fw-bold">{{.title}}</div>
|
||||
<div class="text-white-50">Heroprompt</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="p-2">
|
||||
<div class="menu-section">Navigation</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{{.menu_html}}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h5 class="mb-0">Heroprompt</h5>
|
||||
<span class="ms-2 text-muted small">/admin/heroprompt</span>
|
||||
</div>
|
||||
|
||||
<div class="heroprompt-container">
|
||||
<!-- Left Column: Workspaces -->
|
||||
<div class="workspaces-panel">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Workspaces</h6>
|
||||
<button id="create-workspace" class="btn btn-sm btn-primary">New</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<label for="workspace-select" class="form-label small text-muted">Select workspace</label>
|
||||
<select id="workspace-select" class="form-select form-select-sm mb-2"></select>
|
||||
<button id="delete-workspace" class="btn btn-sm btn-outline-danger"
|
||||
style="display:none;">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle Column: Workspace Details -->
|
||||
<div class="workspace-details">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<span id="current-workspace-name">Select a workspace</span>
|
||||
<button id="delete-workspace" class="btn btn-sm btn-outline-danger ms-2"
|
||||
style="display: none;">Delete</button>
|
||||
</h6>
|
||||
<div>
|
||||
<button id="add-directory" class="btn btn-sm btn-secondary me-2"
|
||||
style="display: none;">Add Directory</button>
|
||||
<button id="copy-selection" class="btn btn-sm btn-success" style="display: none;">Copy
|
||||
Selection</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="workspace-content">
|
||||
<p class="text-muted">Select a workspace to view its directories and files.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: User Instructions -->
|
||||
<div class="instructions-panel">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">User Instructions</h6>
|
||||
<button id="clear-instructions" class="btn btn-sm btn-outline-secondary">Clear</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<textarea id="user-instructions" class="form-control" rows="10" placeholder="Enter your instructions for what needs to be done with the selected code...
|
||||
|
||||
Example:
|
||||
- Analyze the code structure
|
||||
- Identify potential improvements
|
||||
- Add error handling
|
||||
- Optimize performance
|
||||
- Add documentation"></textarea>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">
|
||||
These instructions will be included in the generated prompt along with the selected
|
||||
files.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Toast for notifications -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="notification-toast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">Heroprompt</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<!-- Toast message will be set by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="{{.js_theme_url}}"></script>
|
||||
<script src="{{.js_heroprompt_url}}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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"}')
|
||||
@@ -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('</body>', '<div class="v-badge">Rendered by: heroprompt</div></body>')
|
||||
return result
|
||||
@@ -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('</body>', '<div class="v-badge">Rendered by: heroscript</div></body>')
|
||||
return result
|
||||
@@ -1,4 +1,3 @@
|
||||
module heroscript
|
||||
module ui
|
||||
|
||||
// Placeholder for heroscript-specific utilities
|
||||
|
||||
@@ -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('</body>', '<div class="v-badge">Rendered by: heroprompt</div></body>')
|
||||
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<!doctype html>\n<html lang="en">\n<head>\n\t<meta charset="utf-8">\n\t<meta name="viewport" content="width=device-width, initial-scale=1">\n\t<title>${app.title}</title>\n\t<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">\n\t<style>body { padding-top: 44px; } .header { height: 44px; line-height: 44px; font-size: 14px; } .sidebar { position: fixed; top: 44px; bottom: 0; left: 0; width: 260px; overflow-y: auto; background: #f8f9fa; border-right: 1px solid #e0e0e0; } .main { margin-left: 260px; padding: 16px; } .list-group-item { border: 0; padding: .35rem .75rem; background: transparent; } .menu-leaf a { color: #212529; text-decoration: none; } .menu-toggle { text-decoration: none; color: #212529; } .menu-toggle .chev { font-size: 10px; opacity: .6; } .menu-section { font-weight: 600; color: #6c757d; padding: .5rem .75rem; }</style>\n</head>\n<body>\n\t<nav class="navbar navbar-dark bg-dark fixed-top header px-2">\n\t\t<div class="d-flex w-100 align-items-center justify-content-between">\n\t\t\t<div class="text-white fw-bold">${app.title}</div>\n\t\t\t<div class="text-white-50">Admin</div>\n\t\t</div>\n\t</nav>\n\n\t<aside class="sidebar">\n\t\t<div class="p-2">\n\t\t\t<div class="menu-section">Navigation</div>\n\t\t\t<div class="list-group list-group-flush">\n\t\t\t\t${menu_html(app.menu,
|
||||
0, 'm')}\n\t\t\t</div>\n\t\t</div>\n\t</aside>\n\n\t<main class="main">\n\t\t<div class="container-fluid">\n\t\t\t<div class="d-flex align-items-center mb-3">\n\t\t\t\t<h5 class="mb-0">${heading}</h5>\n\t\t\t\t<span class="ms-2 text-muted small">/admin/${path}</span>\n\t\t\t</div>\n\t\t\t<div class="card">\n\t\t\t\t<div class="card-body">\n\t\t\t\t\t<p class="text-muted">This is a placeholder admin content area for: <code>/admin/${path}</code>.</p>\n\t\t\t\t\t<p class="mb-0">Use the treeview on the left to navigate.</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</main>\n\n\t<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>\n</body>\n</html>\n'
|
||||
menu_content := menu_html(app.menu, 0, 'm')
|
||||
return '\n<!doctype html>\n<html lang="en">\n<head>\n\t<meta charset="utf-8">\n\t<meta name="viewport" content="width=device-width, initial-scale=1">\n\t<title>${app.title}</title>\n\t<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">\n\t<style>body { padding-top: 44px; } .header { height: 44px; line-height: 44px; font-size: 14px; } .sidebar { position: fixed; top: 44px; bottom: 0; left: 0; width: 260px; overflow-y: auto; background: #f8f9fa; border-right: 1px solid #e0e0e0; } .main { margin-left: 260px; padding: 16px; } .list-group-item { border: 0; padding: .35rem .75rem; background: transparent; } .menu-leaf a { color: #212529; text-decoration: none; } .menu-toggle { text-decoration: none; color: #212529; } .menu-toggle .chev { font-size: 10px; opacity: .6; } .menu-section { font-weight: 600; color: #6c757d; padding: .5rem .75rem; }</style>\n</head>\n<body>\n\t<nav class="navbar navbar-dark bg-dark fixed-top header px-2">\n\t\t<div class="d-flex w-100 align-items-center justify-content-between">\n\t\t\t<div class="text-white fw-bold">${app.title}</div>\n\t\t\t<div class="text-white-50">Admin</div>\n\t\t</div>\n\t</nav>\n\n\t<aside class="sidebar">\n\t\t<div class="p-2">\n\t\t\t<div class="menu-section">Navigation</div>\n\t\t\t<div class="list-group list-group-flush">\n\t\t\t\t${menu_content}\n\t\t\t</div>\n\t\t</div>\n\t</aside>\n\n\t<main class="main">\n\t\t<div class="container-fluid">\n\t\t\t<div class="d-flex align-items-center mb-3">\n\t\t\t\t<h5 class="mb-0">${heading}</h5>\n\t\t\t\t<span class="ms-2 text-muted small">/admin/${path}</span>\n\t\t\t</div>\n\t\t\t<div class="card">\n\t\t\t\t<div class="card-body">\n\t\t\t\t\t<p class="text-muted">This is a placeholder admin content area for: <code>/admin/${path}</code>.</p>\n\t\t\t\t\t<p class="mb-0">Use the treeview on the left to navigate.</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</main>\n\n\t<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>\n</body>\n</html>\n'
|
||||
}
|
||||
|
||||
329
lib/web/ui/static/css/heroprompt.css
Normal file
329
lib/web/ui/static/css/heroprompt.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
576
lib/web/ui/static/js/heroprompt.js
Normal file
576
lib/web/ui/static/js/heroprompt.js
Normal file
@@ -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 = '<div class="loading">Loading...</div>';
|
||||
|
||||
const r = await api(`/api/heroprompt/file?name=${currentWs}&path=${encodeURIComponent(filePath)}`);
|
||||
if (r.error) {
|
||||
previewEl.innerHTML = `<div class="error-message">Error: ${r.error}</div>`;
|
||||
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 = `<li class="text-danger small">${r.error}</li>`;
|
||||
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 = '<div class="loading">Loading workspace...</div>';
|
||||
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 = '<li class="text-muted small">No files selected</li>';
|
||||
} 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 = '<option>Loading...</option>';
|
||||
const names = await api('/api/heroprompt/workspaces');
|
||||
|
||||
sel.innerHTML = '';
|
||||
if (names.error || !Array.isArray(names)) {
|
||||
sel.innerHTML = '<option>Error loading workspaces</option>';
|
||||
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 = '<div class="text-muted small">No workspaces available. Create one to get started.</div>';
|
||||
}
|
||||
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 = '<div class="text-muted small">Search functionality coming soon...</div>';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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 = '<div class="success-message">Prompt copied to clipboard!</div>';
|
||||
setTimeout(() => {
|
||||
outputEl.innerHTML = '<div class="text-muted small">Generated prompt will appear here</div>';
|
||||
}, 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('copy prompt failed', e);
|
||||
const outputEl = el('promptOutput');
|
||||
if (outputEl) {
|
||||
outputEl.innerHTML = '<div class="error-message">Failed to copy prompt</div>';
|
||||
setTimeout(() => {
|
||||
outputEl.innerHTML = '<div class="text-muted small">Generated prompt will appear here</div>';
|
||||
}, 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 = '<div class="text-muted">Loading workspaces...</div>';
|
||||
|
||||
const names = await api('/api/heroprompt/workspaces');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (names.error || !Array.isArray(names)) {
|
||||
list.innerHTML = '<div class="error-message">Failed to load workspaces</div>';
|
||||
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');
|
||||
});
|
||||
}
|
||||
});
|
||||
254
lib/web/ui/templates/heroprompt.html
Normal file
254
lib/web/ui/templates/heroprompt.html
Normal file
@@ -0,0 +1,254 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.title}} - Heroprompt</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{.css_colors_url}}">
|
||||
<link rel="stylesheet" href="{{.css_main_url}}">
|
||||
<link rel="stylesheet" href="{{.css_heroprompt_url}}">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-dark bg-dark fixed-top header px-2">
|
||||
<div class="d-flex w-100 align-items-center justify-content-between">
|
||||
<div class="text-white fw-bold">{{.title}}</div>
|
||||
<div class="text-white-50">Heroprompt</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="p-2">
|
||||
<div class="menu-section">Navigation</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{{.menu_html}}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="container-fluid h-100">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h5 class="mb-0">Heroprompt</h5>
|
||||
<div class="ms-auto">
|
||||
<button id="wsCreateBtn" class="btn btn-primary btn-sm me-2">
|
||||
+ New Workspace
|
||||
</button>
|
||||
<button id="refreshWs" class="btn btn-outline-secondary btn-sm">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row h-100">
|
||||
<!-- Left Panel: Workspace & File Tree -->
|
||||
<div class="col-md-4 h-100">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Workspace Explorer</h6>
|
||||
<div>
|
||||
<button id="wsDetailsBtn" class="btn btn-sm btn-outline-secondary me-1"
|
||||
title="Workspace Settings">⚙</button>
|
||||
<button id="openWsManage" class="btn btn-sm btn-outline-secondary"
|
||||
title="Manage Workspaces">📋</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="mb-3">
|
||||
<label for="workspaceSelect" class="form-label small">Current Workspace:</label>
|
||||
<select id="workspaceSelect" class="form-select form-select-sm"></select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<input id="search" class="form-control" placeholder="Search files...">
|
||||
<button id="doSearch" class="btn btn-outline-secondary">🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tree" class="border rounded p-2"
|
||||
style="min-height: 300px; max-height: 400px; overflow-y: auto;">
|
||||
<div class="text-muted small">Select a workspace to browse files</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Click + buttons to add files/directories to selection</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Tabs for Selection, Prompt, Chat -->
|
||||
<div class="col-md-8 h-100">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="mainTabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active tab" data-tab="selection" href="#tab-selection">
|
||||
Selection (<span id="selCount">0</span>)
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link tab" data-tab="prompt" href="#tab-prompt">Prompt</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link tab" data-tab="chat" href="#tab-chat">Chat</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body p-0 h-100">
|
||||
<!-- Selection Tab -->
|
||||
<div id="tab-selection" class="tab-pane active h-100 p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Selected Files & Directories</h6>
|
||||
<span class="badge bg-secondary">~<span id="tokenCount">0</span> tokens</span>
|
||||
</div>
|
||||
<div class="row h-100">
|
||||
<div class="col-md-6">
|
||||
<div class="border rounded p-2" style="height: 300px; overflow-y: auto;">
|
||||
<ul id="selected" class="list-unstyled mb-0">
|
||||
<li class="text-muted small">No files selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="border rounded p-2" style="height: 300px; overflow-y: auto;">
|
||||
<div id="preview" class="text-muted small">Select a file to preview</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Tab -->
|
||||
<div id="tab-prompt" class="tab-pane h-100 p-3" style="display: none;">
|
||||
<div class="h-100 d-flex flex-column">
|
||||
<div class="mb-3">
|
||||
<label for="promptText" class="form-label">Instructions:</label>
|
||||
<textarea id="promptText" class="form-control" rows="8" placeholder="Enter your instructions for what needs to be done with the selected code...
|
||||
|
||||
Example:
|
||||
- Analyze the code structure
|
||||
- Identify potential improvements
|
||||
- Add error handling
|
||||
- Optimize performance
|
||||
- Add documentation"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button id="copyPrompt" class="btn btn-primary">Copy Generated Prompt</button>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div id="promptOutput" class="border rounded p-2 h-100 overflow-auto">
|
||||
<div class="text-muted small">Generated prompt will appear here</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Tab -->
|
||||
<div id="tab-chat" class="tab-pane h-100 p-3" style="display: none;">
|
||||
<div class="h-100 d-flex flex-column">
|
||||
<div class="flex-grow-1 border rounded p-2 mb-3 overflow-auto"
|
||||
style="height: 300px;">
|
||||
<div id="chatMessages">
|
||||
<div class="text-muted small">Chat functionality coming soon...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<textarea id="chatInput" class="form-control" rows="2"
|
||||
placeholder="Type your message..."></textarea>
|
||||
<button id="sendChat" class="btn btn-primary">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="wsCreate" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create Workspace</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" id="wcClose"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="wcName" class="form-label">Workspace Name (optional)</label>
|
||||
<input type="text" class="form-control" id="wcName" placeholder="Enter workspace name">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="wcPath" class="form-label">Base Path (required)</label>
|
||||
<input type="text" class="form-control" id="wcPath" placeholder="Enter base directory path">
|
||||
</div>
|
||||
<div id="wcError" class="text-danger small"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"
|
||||
id="wcCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="wcCreate">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="wsDetails" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Workspace Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" id="wdClose"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="wdName" class="form-label">Workspace Name</label>
|
||||
<input type="text" class="form-control" id="wdName">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="wdPath" class="form-label">Base Path</label>
|
||||
<input type="text" class="form-control" id="wdPath">
|
||||
</div>
|
||||
<div id="wdError" class="text-danger small"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" id="wdDelete">Delete</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"
|
||||
id="wdCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="wdSave">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="wsManage" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Manage Workspaces</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="wmError" class="text-danger small mb-3"></div>
|
||||
<div class="list-group" id="wmList">
|
||||
<div class="text-muted">Loading workspaces...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="{{.js_theme_url}}"></script>
|
||||
<script src="{{.js_heroprompt_url}}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user