Spaces:
Runtime error
Runtime error
| /** | |
| * PromptForge v4.0 β client.js | |
| * Upgrades: sidebar navigation, stats dashboard, full-text search, | |
| * favorites/archive, setting duplicate, keyboard shortcuts, theme toggle, | |
| * API key validation, char counters, tag suggestions, smooth transitions. | |
| */ | |
| const API = ""; | |
| let currentPromptId = null; | |
| let allSettings = []; | |
| let favoritesFilter = false; | |
| const $ = id => document.getElementById(id); | |
| const esc = s => String(s ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); | |
| const show = el => el?.classList.remove("hidden"); | |
| const hide = el => el?.classList.add("hidden"); | |
| /* ββ API fetch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function apiFetch(path, method = "GET", body = null) { | |
| const opts = { method, headers: { "Content-Type": "application/json" } }; | |
| if (body) opts.body = JSON.stringify(body); | |
| const r = await fetch(API + path, opts); | |
| if (!r.ok) { | |
| const e = await r.json().catch(() => ({ detail: r.statusText })); | |
| throw new Error(e.detail || "Request failed"); | |
| } | |
| return r.json(); | |
| } | |
| /* ββ Toast ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function toast(msg, type = "info", duration = 4200) { | |
| const icons = { success:"β ", error:"β", info:"π‘", warn:"β οΈ" }; | |
| const t = document.createElement("div"); | |
| t.className = `toast ${type}`; | |
| t.innerHTML = `<span class="toast-icon">${icons[type]||"π‘"}</span><span>${msg}</span>`; | |
| $("toast-container").appendChild(t); | |
| const remove = () => { t.classList.add("leaving"); t.addEventListener("animationend", () => t.remove(), {once:true}); }; | |
| const timer = setTimeout(remove, duration); | |
| t.addEventListener("click", () => { clearTimeout(timer); remove(); }); | |
| } | |
| /* ββ Loading state ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function setLoading(btn, on) { | |
| if (!btn) return; | |
| btn.disabled = on; | |
| if (!btn._orig) btn._orig = btn.innerHTML; | |
| btn.innerHTML = on ? `<span class="spinner"></span> Workingβ¦` : btn._orig; | |
| } | |
| /* ββ Sidebar navigation βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| document.querySelectorAll(".nav-item").forEach(item => { | |
| item.addEventListener("click", () => { | |
| document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active")); | |
| document.querySelectorAll(".page").forEach(p => { p.classList.remove("active"); hide(p); }); | |
| item.classList.add("active"); | |
| const page = $(`page-${item.dataset.page}`); | |
| if (page) { show(page); page.classList.add("active"); } | |
| // lazy-load data | |
| if (item.dataset.page === "settings") loadSettingsList(); | |
| if (item.dataset.page === "history") loadHistory(); | |
| if (item.dataset.page === "stats") loadStats(); | |
| }); | |
| }); | |
| /* ββ Sidebar collapse βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| $("btn-sidebar-toggle")?.addEventListener("click", () => { | |
| const sb = $("sidebar"); | |
| sb.classList.toggle("collapsed"); | |
| localStorage.setItem("pf_sidebar", sb.classList.contains("collapsed") ? "1" : "0"); | |
| }); | |
| if (localStorage.getItem("pf_sidebar") === "1") $("sidebar")?.classList.add("collapsed"); | |
| /* ββ Theme toggle βββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function applyTheme(theme) { | |
| document.documentElement.dataset.theme = theme; | |
| $("btn-theme").textContent = theme === "dark" ? "π" : "βοΈ"; | |
| localStorage.setItem("pf_theme", theme); | |
| } | |
| $("btn-theme")?.addEventListener("click", () => { | |
| const cur = document.documentElement.dataset.theme || "dark"; | |
| applyTheme(cur === "dark" ? "light" : "dark"); | |
| }); | |
| applyTheme(localStorage.getItem("pf_theme") || "dark"); | |
| /* ββ Keyboard shortcuts βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| $("btn-shortcuts")?.addEventListener("click", () => show($("shortcuts-modal"))); | |
| $("btn-shortcuts-close")?.addEventListener("click", () => hide($("shortcuts-modal"))); | |
| $("shortcuts-modal")?.addEventListener("click", e => { if (e.target === $("shortcuts-modal")) hide($("shortcuts-modal")); }); | |
| document.addEventListener("keydown", e => { | |
| if (e.altKey) { | |
| if (e.key === "b" || e.key === "B") { $("sidebar")?.classList.toggle("collapsed"); return; } | |
| if (e.key === "t" || e.key === "T") { applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"); return; } | |
| if (e.key === "1") { document.querySelector('[data-page="generate"]')?.click(); return; } | |
| if (e.key === "2") { document.querySelector('[data-page="settings"]')?.click(); return; } | |
| if (e.key === "3") { document.querySelector('[data-page="history"]')?.click(); return; } | |
| if (e.key === "4") { document.querySelector('[data-page="stats"]')?.click(); return; } | |
| } | |
| if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { | |
| if ($("page-generate")?.classList.contains("active")) $("btn-generate")?.click(); | |
| return; | |
| } | |
| if (e.key === "?" && !["INPUT","TEXTAREA","SELECT"].includes(e.target.tagName)) { | |
| show($("shortcuts-modal")); return; | |
| } | |
| if (e.key === "Escape") { | |
| hide($("modal-overlay")); hide($("shortcuts-modal")); | |
| } | |
| }); | |
| /* ββ Config / env βββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function loadConfig() { | |
| try { | |
| const cfg = await apiFetch("/api/config"); | |
| const dotHF = $("dot-hf"); | |
| const dotGG = $("dot-google"); | |
| if (cfg.hf_key_set) dotHF?.classList.add("active"); | |
| if (cfg.google_key_set) dotGG?.classList.add("active"); | |
| } catch {} | |
| } | |
| /* ββ API key panel ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| const PROVIDER_INFO = { | |
| none: { hint:"No key needed β local engine only", placeholder:"Not required" }, | |
| google: { hint:"google.com/aistudio β Get API key", placeholder:"AIzaSyβ¦" }, | |
| huggingface: { hint:"huggingface.co/settings/tokens", placeholder:"hf_β¦" }, | |
| }; | |
| $("btn-api-panel-toggle")?.addEventListener("click", () => { | |
| const body = $("api-panel-body"); | |
| const chevron = $("api-chevron"); | |
| body.classList.toggle("open"); | |
| chevron.classList.toggle("open"); | |
| }); | |
| $("provider")?.addEventListener("change", () => { | |
| const p = $("provider").value; | |
| const info = PROVIDER_INFO[p] || PROVIDER_INFO.none; | |
| $("key-hint").textContent = info.hint; | |
| const needsKey = p !== "none"; | |
| $("api-key").disabled = !needsKey; | |
| $("api-key").placeholder = info.placeholder; | |
| $("btn-check-key").disabled = !needsKey; | |
| $("model-field").style.display = needsKey ? "block" : "none"; | |
| $("api-panel-status").textContent = needsKey ? "AI Enhancement Available" : "Local Engine Active"; | |
| if (!needsKey) { | |
| $("api-key").value = ""; | |
| $("key-dot").className = "status-dot"; | |
| $("key-status-text").textContent = "No key needed"; | |
| } | |
| }); | |
| $("api-key")?.addEventListener("input", () => { | |
| const has = $("api-key").value.trim().length >= 10; | |
| $("key-dot").className = "status-dot" + (has ? " ok" : ""); | |
| $("key-status-text").textContent = has ? "Key entered β click β to validate" : "Enter your API key"; | |
| }); | |
| $("btn-toggle-key")?.addEventListener("click", () => { | |
| const k = $("api-key"); | |
| k.type = k.type === "password" ? "text" : "password"; | |
| $("btn-toggle-key").textContent = k.type === "password" ? "π" : "π"; | |
| }); | |
| $("btn-check-key")?.addEventListener("click", async () => { | |
| const provider = $("provider").value; | |
| const key = $("api-key").value.trim(); | |
| if (!key) { toast("Enter a key first.", "warn"); return; } | |
| const btn = $("btn-check-key"); | |
| setLoading(btn, true); | |
| try { | |
| const res = await apiFetch("/api/check-key", "POST", { provider, api_key: key }); | |
| $("key-dot").className = "status-dot " + (res.valid ? "ok" : "err"); | |
| $("key-status-text").textContent = res.message; | |
| toast(res.message, res.valid ? "success" : "error"); | |
| if (res.valid) { $("dot-" + (provider === "google" ? "google" : "hf"))?.classList.add("active"); } | |
| } catch (e) { toast(`Key check failed: ${e.message}`, "error"); } | |
| finally { setLoading(btn, false); } | |
| }); | |
| /* ββ Character counters βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function bindCharCounter(textareaId, counterId, max) { | |
| const ta = $(textareaId), ct = $(counterId); | |
| if (!ta || !ct) return; | |
| ta.addEventListener("input", () => { | |
| const len = ta.value.length; | |
| ct.textContent = len; | |
| ct.parentElement.className = "char-counter" + (len > max * .95 ? " over" : len > max * .85 ? " warn" : ""); | |
| }); | |
| } | |
| bindCharCounter("instruction", "instr-count", 8000); | |
| bindCharCounter("s-instruction", "s-instr-count", 8000); | |
| /* ββ Persona / custom persona βββββββββββββββββββββββββββββββββββββββββ */ | |
| $("gen-persona")?.addEventListener("change", () => { | |
| $("custom-persona-field").style.display = $("gen-persona").value === "custom" ? "block" : "none"; | |
| }); | |
| $("s-persona")?.addEventListener("change", () => { | |
| $("s-custom-persona-field").style.display = $("s-persona").value === "custom" ? "block" : "none"; | |
| }); | |
| /* ββ Tag autocomplete for settings form βββββββββββββββββββββββββββββββ */ | |
| const COMMON_TAGS = ["react","typescript","python","frontend","backend","devops","ml","security","testing","database","mobile","writing","docker","api","fastapi","tailwind"]; | |
| function renderTagSugs(inputEl, containerEl) { | |
| const cur = inputEl.value.split(",").map(t=>t.trim()).filter(Boolean); | |
| const avail = COMMON_TAGS.filter(t => !cur.includes(t)); | |
| containerEl.innerHTML = avail.slice(0,8).map(t => | |
| `<span class="tag-sug" data-tag="${esc(t)}">${esc(t)}</span>` | |
| ).join(""); | |
| containerEl.querySelectorAll(".tag-sug").forEach(el => { | |
| el.addEventListener("click", () => { | |
| const existing = inputEl.value.trim(); | |
| inputEl.value = existing ? `${existing}, ${el.dataset.tag}` : el.dataset.tag; | |
| renderTagSugs(inputEl, containerEl); | |
| }); | |
| }); | |
| } | |
| const tagsInput = $("s-tags"), tagSugs = $("tag-suggestions"); | |
| if (tagsInput && tagSugs) { | |
| tagsInput.addEventListener("input", () => renderTagSugs(tagsInput, tagSugs)); | |
| renderTagSugs(tagsInput, tagSugs); | |
| } | |
| /* ββ Step progress ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function setStep(n) { | |
| document.querySelectorAll(".step").forEach((s, i) => { | |
| const idx = parseInt(s.dataset.step); | |
| s.classList.remove("active","done"); | |
| if (idx < n) s.classList.add("done"); | |
| if (idx === n) s.classList.add("active"); | |
| }); | |
| document.querySelectorAll(".step-line").forEach((l, i) => { | |
| l.classList.toggle("filled", i + 1 < n); | |
| }); | |
| } | |
| /* ββ Copy buttons βββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| document.addEventListener("click", e => { | |
| const btn = e.target.closest(".copy-btn"); | |
| if (!btn) return; | |
| const el = $(btn.dataset.target); | |
| if (!el) return; | |
| navigator.clipboard.writeText(el.textContent).then(() => { | |
| const orig = btn.textContent; | |
| btn.textContent = "β Copied!"; | |
| btn.classList.add("copied"); | |
| setTimeout(() => { btn.classList.remove("copied"); btn.textContent = orig; }, 2000); | |
| }); | |
| }); | |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| GENERATE PAGE | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* ββ STEP 1: Generate βββ */ | |
| $("btn-generate")?.addEventListener("click", doGenerate); | |
| async function doGenerate() { | |
| const instruction = $("instruction").value.trim(); | |
| if (instruction.length < 5) { toast("Enter a meaningful instruction (min 5 chars).", "error"); return; } | |
| const btn = $("btn-generate"); | |
| setLoading(btn, true); | |
| try { | |
| const provider = $("provider").value; | |
| const apiKey = $("api-key")?.value.trim() || null; | |
| const persona = $("gen-persona")?.value || "default"; | |
| const style = $("gen-style")?.value || "professional"; | |
| const customPerso = persona === "custom" ? ($("gen-custom-persona")?.value.trim() || null) : null; | |
| const constrRaw = $("gen-constraints")?.value.trim() || ""; | |
| const constraints = constrRaw ? constrRaw.split("\n").map(s=>s.trim()).filter(Boolean) : []; | |
| const modelOvr = $("provider-model")?.value.trim() || null; | |
| const data = await apiFetch("/api/generate", "POST", { | |
| instruction, | |
| output_format: "both", | |
| provider, api_key: apiKey, | |
| enhance: provider !== "none" && !!apiKey, | |
| extra_context: $("extra-context").value.trim() || null, | |
| persona, custom_persona: customPerso, style, | |
| user_constraints: constraints, | |
| provider_model: modelOvr || undefined, | |
| }); | |
| currentPromptId = data.prompt_id; | |
| renderManifest(data.manifest); | |
| hide($("step-input")); show($("step-manifest")); | |
| $("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" }); | |
| setStep(2); | |
| updateBadge("history-badge", null, +1); | |
| toast("Manifest generated β review and approve.", "success"); | |
| } catch (e) { toast(`Error: ${e.message}`, "error"); } | |
| finally { setLoading(btn, false); } | |
| } | |
| /* ββ Render manifest fields βββ */ | |
| function renderManifest(manifest) { | |
| const sp = manifest.structured_prompt; | |
| const grid = $("manifest-grid"); | |
| grid.innerHTML = ""; | |
| const fields = [ | |
| { key:"role", label:"Role", value:sp.role, full:false }, | |
| { key:"style", label:"Style & Tone", value:sp.style, full:false }, | |
| { key:"task", label:"Task", value:sp.task, full:true }, | |
| { key:"input_format", label:"Input Format", value:sp.input_format, full:false }, | |
| { key:"output_format", label:"Output Format", value:sp.output_format, full:false }, | |
| { key:"constraints", label:"Constraints", value:sp.constraints.join("\n"), full:true }, | |
| { key:"safety", label:"Safety", value:sp.safety.join("\n"), full:true }, | |
| ]; | |
| fields.forEach(f => { | |
| const d = document.createElement("div"); | |
| d.className = `manifest-field${f.full?" full":""}`; | |
| d.innerHTML = `<label>${esc(f.label)}</label> | |
| <textarea id="field-${f.key}" rows="${f.full?3:2}">${esc(f.value)}</textarea>`; | |
| grid.appendChild(d); | |
| }); | |
| $("manifest-json").textContent = JSON.stringify(manifest, null, 2); | |
| hide($("explanation-panel")); | |
| // Word count badge | |
| const wc = sp.word_count || sp.raw_prompt_text?.split(/\s+/).length || 0; | |
| if (!$("wc-badge")) { | |
| const b = document.createElement("span"); | |
| b.id = "wc-badge"; b.className = "step-tag"; | |
| b.style.marginLeft = "auto"; | |
| $("step-manifest")?.querySelector(".panel-header")?.appendChild(b); | |
| } | |
| $("wc-badge").textContent = `~${wc} words`; | |
| } | |
| /* ββ Explain βββ */ | |
| $("btn-explain")?.addEventListener("click", async () => { | |
| if (!currentPromptId) return; | |
| const btn = $("btn-explain"); | |
| setLoading(btn, true); | |
| try { | |
| const data = await apiFetch(`/api/explain/${currentPromptId}`); | |
| $("explanation-text").textContent = data.explanation; | |
| $("key-decisions").innerHTML = data.key_decisions | |
| .map(d => `<div class="decision-chip">${esc(d)}</div>`).join(""); | |
| const panel = $("explanation-panel"); | |
| show(panel); | |
| panel.scrollIntoView({ behavior:"smooth", block:"nearest" }); | |
| } catch (e) { toast(`Explanation error: ${e.message}`, "warn"); } | |
| finally { setLoading(btn, false); } | |
| }); | |
| /* ββ Approve βββ */ | |
| $("btn-approve")?.addEventListener("click", async () => { | |
| if (!currentPromptId) return; | |
| const edits = {}; | |
| ["role","style","task","input_format","output_format"].forEach(k => { | |
| const el = $(`field-${k}`); if (el) edits[k] = el.value.trim(); | |
| }); | |
| const cEl = $("field-constraints"); if (cEl) edits.constraints = cEl.value.trim().split("\n").filter(Boolean); | |
| const sEl = $("field-safety"); if (sEl) edits.safety = sEl.value.trim().split("\n").filter(Boolean); | |
| const btn = $("btn-approve"); | |
| setLoading(btn, true); | |
| try { | |
| const data = await apiFetch("/api/approve", "POST", { prompt_id: currentPromptId, edits }); | |
| renderFinalized(data.finalized_prompt); | |
| hide($("step-manifest")); show($("step-finalized")); | |
| $("step-finalized").scrollIntoView({ behavior:"smooth", block:"start" }); | |
| setStep(3); | |
| toast("Prompt approved! π", "success"); | |
| } catch (e) { toast(`Approval failed: ${e.message}`, "error"); } | |
| finally { setLoading(btn, false); } | |
| }); | |
| function renderFinalized(sp) { | |
| $("finalized-text").textContent = sp.raw_prompt_text; | |
| $("finalized-json").textContent = JSON.stringify(sp, null, 2); | |
| } | |
| /* ββ Inner tabs (text/json) βββ */ | |
| document.querySelectorAll(".inner-tab").forEach(tab => { | |
| tab.addEventListener("click", () => { | |
| const group = tab.closest(".panel"); | |
| group.querySelectorAll(".inner-tab").forEach(t => t.classList.remove("active")); | |
| group.querySelectorAll(".inner-panel").forEach(p => hide(p)); | |
| tab.classList.add("active"); | |
| const panel = group.querySelector(`#itab-${tab.dataset.tab}`); | |
| if (panel) show(panel); | |
| }); | |
| }); | |
| /* ββ Export βββ */ | |
| async function doExport(format) { | |
| if (!currentPromptId) return; | |
| try { | |
| const data = await apiFetch("/api/export", "POST", { prompt_id: currentPromptId, export_format: format }); | |
| const content = format === "json" ? JSON.stringify(data.data, null, 2) : String(data.data); | |
| const blob = new Blob([content], { type: format === "json" ? "application/json" : "text/plain" }); | |
| const a = Object.assign(document.createElement("a"), { | |
| href: URL.createObjectURL(blob), | |
| download: `prompt-${currentPromptId.slice(0,8)}.${format === "json" ? "json" : "txt"}`, | |
| }); | |
| a.click(); URL.revokeObjectURL(a.href); | |
| setStep(4); | |
| toast(`Exported as ${format.toUpperCase()}!`, "success"); | |
| } catch (e) { toast(`Export failed: ${e.message}`, "error"); } | |
| } | |
| $("btn-export-json")?.addEventListener("click", () => doExport("json")); | |
| $("btn-export-txt")?.addEventListener("click", () => doExport("text")); | |
| /* ββ Favourite prompt βββ */ | |
| $("btn-favorite-prompt")?.addEventListener("click", async () => { | |
| if (!currentPromptId) return; | |
| try { | |
| const r = await apiFetch(`/api/prompts/${currentPromptId}/favorite`, "POST"); | |
| $("btn-favorite-prompt").textContent = r.is_favorite ? "β Favourited" : "β Favourite"; | |
| toast(r.is_favorite ? "Added to favourites β " : "Removed from favourites", "info"); | |
| } catch (e) { toast("Failed: " + e.message, "error"); } | |
| }); | |
| /* ββ Save as Setting βββ */ | |
| $("btn-save-as-setting")?.addEventListener("click", () => { | |
| const instruction = $("instruction")?.value.trim() || ""; | |
| const context = $("extra-context")?.value.trim() || ""; | |
| document.querySelector('[data-page="settings"]')?.click(); | |
| setTimeout(() => { | |
| clearSettingsForm(); | |
| if ($("s-title")) $("s-title").value = instruction.slice(0,60) + (instruction.length > 60 ? "β¦" : ""); | |
| if ($("s-instruction")) $("s-instruction").value = instruction; | |
| if ($("s-extra-context")) $("s-extra-context").value = context; | |
| $("s-title")?.focus(); | |
| toast("Pre-filled from prompt β adjust title and save!", "info"); | |
| }, 150); | |
| }); | |
| /* ββ Refine βββ */ | |
| $("btn-refine")?.addEventListener("click", () => { | |
| hide($("step-finalized")); show($("step-refine")); | |
| $("step-refine").scrollIntoView({ behavior:"smooth", block:"start" }); | |
| setStep(5); | |
| }); | |
| $("btn-cancel-refine")?.addEventListener("click", () => { | |
| hide($("step-refine")); show($("step-finalized")); setStep(3); | |
| }); | |
| $("btn-submit-refine")?.addEventListener("click", async () => { | |
| const feedback = $("feedback").value.trim(); | |
| if (!feedback) { toast("Describe what to change.", "error"); return; } | |
| const btn = $("btn-submit-refine"); | |
| setLoading(btn, true); | |
| try { | |
| const data = await apiFetch("/api/refine", "POST", { | |
| prompt_id: currentPromptId, feedback, | |
| provider: $("provider").value, | |
| api_key: $("api-key")?.value.trim() || null, | |
| }); | |
| currentPromptId = data.prompt_id; | |
| renderManifest(data.manifest); | |
| hide($("step-refine")); show($("step-manifest")); | |
| $("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" }); | |
| setStep(2); | |
| toast(`Refined to v${data.manifest.version} β review and approve!`, "success"); | |
| } catch (e) { toast(`Refinement failed: ${e.message}`, "error"); } | |
| finally { setLoading(btn, false); } | |
| }); | |
| /* ββ Reset / New βββ */ | |
| $("btn-reset")?.addEventListener("click", () => { | |
| hide($("step-manifest")); show($("step-input")); setStep(1); | |
| $("instruction").value = ""; $("extra-context").value = ""; | |
| currentPromptId = null; toast("Reset.", "info"); | |
| }); | |
| $("btn-new")?.addEventListener("click", () => { | |
| hide($("step-finalized")); show($("step-input")); setStep(1); | |
| $("instruction").value = ""; $("extra-context").value = ""; | |
| $("instr-count").textContent = "0"; | |
| currentPromptId = null; | |
| $("step-input").scrollIntoView({ behavior:"smooth", block:"start" }); | |
| }); | |
| /* ββ Load-from-settings modal βββ */ | |
| $("btn-load-from-settings")?.addEventListener("click", async () => { | |
| await loadSettingsForModal(); | |
| show($("modal-overlay")); | |
| }); | |
| $("btn-modal-close")?.addEventListener("click", () => hide($("modal-overlay"))); | |
| $("modal-overlay")?.addEventListener("click", e => { if (e.target === $("modal-overlay")) hide($("modal-overlay")); }); | |
| $("modal-search")?.addEventListener("input", () => { | |
| const q = $("modal-search").value.toLowerCase(); | |
| document.querySelectorAll(".modal-item").forEach(item => { | |
| item.style.display = item.dataset.search?.includes(q) ? "" : "none"; | |
| }); | |
| }); | |
| async function loadSettingsForModal() { | |
| const list = $("modal-list"); | |
| try { | |
| const data = await apiFetch("/api/instructions"); | |
| if (!data.items?.length) { | |
| list.innerHTML = `<div class="modal-empty">No saved settings. Create some in the Settings page.</div>`; | |
| return; | |
| } | |
| list.innerHTML = data.items.map(s => ` | |
| <div class="modal-item" data-id="${esc(s.settings_id)}" data-search="${esc((s.title+s.instruction).toLowerCase())}"> | |
| <div class="modal-item-title">${personaEmoji(s.persona)} ${esc(s.title)}</div> | |
| <div class="modal-item-desc">${esc(s.instruction.slice(0,110))}${s.instruction.length > 110 ? "β¦" : ""}</div> | |
| </div>`).join(""); | |
| document.querySelectorAll(".modal-item").forEach(item => { | |
| item.addEventListener("click", async () => { | |
| hide($("modal-overlay")); | |
| await generateFromSetting(item.dataset.id); | |
| }); | |
| }); | |
| } catch (e) { | |
| list.innerHTML = `<div class="modal-empty">Failed: ${esc(e.message)}</div>`; | |
| } | |
| } | |
| async function generateFromSetting(sid) { | |
| const btn = $("btn-generate"); | |
| setLoading(btn, true); | |
| document.querySelector('[data-page="generate"]')?.click(); | |
| try { | |
| const apiKey = $("api-key")?.value.trim() || null; | |
| const data = await apiFetch("/api/generate/from-settings", "POST", { settings_id: sid, api_key: apiKey }); | |
| currentPromptId = data.prompt_id; | |
| renderManifest(data.manifest); | |
| hide($("step-input")); show($("step-manifest")); | |
| $("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" }); | |
| setStep(2); | |
| updateBadge("history-badge", null, +1); | |
| toast(`Generated from saved setting! β¨`, "success"); | |
| } catch (e) { toast(`Error: ${e.message}`, "error"); } | |
| finally { setLoading(btn, false); } | |
| } | |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SETTINGS PAGE | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| $("btn-settings-save")?.addEventListener("click", async () => { | |
| const title = $("s-title").value.trim(); | |
| const instruction = $("s-instruction").value.trim(); | |
| if (!title) { toast("Title is required.", "error"); $("s-title").focus(); return; } | |
| if (instruction.length < 5) { toast("Instruction too short.", "error"); $("s-instruction").focus(); return; } | |
| const editId = $("edit-settings-id").value; | |
| const persona = $("s-persona").value; | |
| const constraints = ($("s-constraints").value.trim() || "").split("\n").map(s=>s.trim()).filter(Boolean); | |
| const tags = ($("s-tags").value.trim() || "").split(",").map(s=>s.trim().toLowerCase()).filter(Boolean); | |
| const payload = { | |
| title, | |
| description: $("s-description").value.trim() || null, | |
| instruction, | |
| extra_context: $("s-extra-context")?.value.trim() || null, | |
| output_format: $("s-output-format").value, | |
| persona, | |
| custom_persona: persona === "custom" ? ($("s-custom-persona")?.value.trim() || null) : null, | |
| style: $("s-style").value, | |
| constraints, tags, | |
| provider: $("s-provider").value, | |
| enhance: $("s-enhance")?.checked || false, | |
| is_favorite: $("s-favorite")?.checked || false, | |
| }; | |
| const btn = $("btn-settings-save"); | |
| setLoading(btn, true); | |
| try { | |
| if (editId) { | |
| await apiFetch(`/api/instructions/${editId}`, "PATCH", payload); | |
| toast("Setting updated! β ", "success"); | |
| } else { | |
| await apiFetch("/api/instructions", "POST", payload); | |
| toast("Setting saved! πΎ", "success"); | |
| } | |
| clearSettingsForm(); | |
| await loadSettingsList(); | |
| } catch (e) { toast(`Save failed: ${e.message}`, "error"); } | |
| finally { setLoading(btn, false); } | |
| }); | |
| $("btn-settings-clear")?.addEventListener("click", clearSettingsForm); | |
| $("btn-export-all-settings")?.addEventListener("click", async () => { | |
| try { | |
| const data = await apiFetch("/api/instructions/export"); | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type:"application/json" }); | |
| const a = Object.assign(document.createElement("a"), { | |
| href: URL.createObjectURL(blob), download: "promptforge-settings.json" | |
| }); | |
| a.click(); URL.revokeObjectURL(a.href); | |
| toast(`Exported ${data.total} settings.`, "success"); | |
| } catch (e) { toast("Export failed: " + e.message, "error"); } | |
| }); | |
| function clearSettingsForm() { | |
| $("edit-settings-id").value = ""; | |
| ["s-title","s-description","s-instruction","s-extra-context","s-constraints","s-tags","s-custom-persona"].forEach(id => { | |
| const el = $(id); if (el) el.value = ""; | |
| }); | |
| $("s-persona").value = "default"; | |
| $("s-style").value = "professional"; | |
| $("s-output-format").value = "both"; | |
| $("s-provider").value = "none"; | |
| if ($("s-enhance")) $("s-enhance").checked = false; | |
| if ($("s-favorite")) $("s-favorite").checked = false; | |
| if ($("s-custom-persona-field")) $("s-custom-persona-field").style.display = "none"; | |
| if ($("s-instr-count")) $("s-instr-count").textContent = "0"; | |
| if ($("settings-form-title")) $("settings-form-title").textContent = "β New Setting"; | |
| const genBtn = $("btn-settings-generate"); | |
| if (genBtn) { genBtn.classList.add("hidden"); genBtn._orig = null; } | |
| document.querySelectorAll(".setting-card").forEach(c => c.classList.remove("editing")); | |
| // reset tag suggestions | |
| if (tagsInput && tagSugs) renderTagSugs(tagsInput, tagSugs); | |
| } | |
| /* ββ Load settings list βββ */ | |
| async function loadSettingsList() { | |
| try { | |
| const q = $("settings-search")?.value.trim() || ""; | |
| const tag = $("settings-filter-tag")?.value || ""; | |
| let url = "/api/instructions?"; | |
| if (q) url += `q=${encodeURIComponent(q)}&`; | |
| if (tag) url += `tag=${encodeURIComponent(tag)}&`; | |
| if (favoritesFilter) url += "favorites_only=true&"; | |
| const data = await apiFetch(url); | |
| allSettings = data.items || []; | |
| renderSettingsList(allSettings); | |
| const n = data.total || 0; | |
| if ($("settings-total-count")) $("settings-total-count").textContent = n; | |
| if ($("settings-badge")) $("settings-badge").textContent = n; | |
| // refresh tag filter | |
| const tagData = await apiFetch("/api/instructions/tags"); | |
| const filterEl = $("settings-filter-tag"); | |
| if (filterEl) { | |
| const cur = filterEl.value; | |
| filterEl.innerHTML = `<option value="">All tags</option>` + | |
| tagData.tags.map(t => `<option value="${esc(t)}" ${t===cur?"selected":""}>${esc(t)}</option>`).join(""); | |
| } | |
| } catch (e) { toast(`Failed to load settings: ${e.message}`, "error"); } | |
| } | |
| function renderSettingsList(items) { | |
| const container = $("settings-list"); | |
| if (!items.length) { | |
| container.innerHTML = `<div class="empty-state"><div class="empty-icon">π</div><p>No settings yet.</p></div>`; | |
| return; | |
| } | |
| container.innerHTML = items.map(s => ` | |
| <div class="setting-card${s.is_favorite?" favorite":""}${$("edit-settings-id").value===s.settings_id?" editing":""}" | |
| data-id="${esc(s.settings_id)}"> | |
| <div class="setting-card-top"> | |
| <div class="setting-card-title">${personaEmoji(s.persona)} ${esc(s.title)}</div> | |
| <span class="s-star">${s.is_favorite?"β ":"β"}</span> | |
| </div> | |
| ${s.description ? `<div class="setting-card-desc">${esc(s.description)}</div>` : ""} | |
| <div class="setting-card-meta"> | |
| ${(s.tags||[]).slice(0,4).map(t=>`<span class="tag-chip">${esc(t)}</span>`).join("")} | |
| <span class="tag-chip style-tag">${esc(s.style)}</span> | |
| <span class="use-count">Γ ${s.use_count||0}</span> | |
| </div> | |
| <div class="setting-card-actions"> | |
| <button class="icon-btn" title="Edit" onclick="editSetting('${esc(s.settings_id)}')">βοΈ</button> | |
| <button class="icon-btn" title="Duplicate" onclick="duplicateSetting('${esc(s.settings_id)}')">β§</button> | |
| <button class="icon-btn" title="Favourite" onclick="toggleSettingFav('${esc(s.settings_id)}')">β</button> | |
| <button class="icon-btn btn-danger" title="Delete" onclick="deleteSetting('${esc(s.settings_id)}')">π</button> | |
| <button class="btn-primary btn-sm" onclick="generateFromSetting('${esc(s.settings_id)}')">β‘</button> | |
| </div> | |
| </div>`).join(""); | |
| } | |
| /* ββ Search / filter βββ */ | |
| let settingsSearchTimer; | |
| $("settings-search")?.addEventListener("input", () => { | |
| clearTimeout(settingsSearchTimer); | |
| settingsSearchTimer = setTimeout(loadSettingsList, 250); | |
| }); | |
| $("settings-filter-tag")?.addEventListener("change", loadSettingsList); | |
| $("btn-filter-favorites")?.addEventListener("click", () => { | |
| favoritesFilter = !favoritesFilter; | |
| $("btn-filter-favorites").textContent = favoritesFilter ? "β " : "β"; | |
| $("btn-filter-favorites").style.color = favoritesFilter ? "var(--amber)" : ""; | |
| loadSettingsList(); | |
| }); | |
| /* ββ Edit setting βββ */ | |
| async function editSetting(sid) { | |
| try { | |
| const s = await apiFetch(`/api/instructions/${sid}`); | |
| $("edit-settings-id").value = s.settings_id; | |
| $("s-title").value = s.title; | |
| $("s-description").value = s.description || ""; | |
| $("s-instruction").value = s.instruction; | |
| $("s-instruction").dispatchEvent(new Event("input")); | |
| $("s-extra-context").value = s.extra_context || ""; | |
| $("s-output-format").value = s.output_format; | |
| $("s-persona").value = s.persona; | |
| $("s-persona").dispatchEvent(new Event("change")); | |
| $("s-custom-persona").value = s.custom_persona || ""; | |
| $("s-style").value = s.style; | |
| $("s-constraints").value = (s.constraints || []).join("\n"); | |
| $("s-tags").value = (s.tags || []).join(", "); | |
| $("s-provider").value = s.provider; | |
| if ($("s-enhance")) $("s-enhance").checked = s.enhance; | |
| if ($("s-favorite")) $("s-favorite").checked = s.is_favorite; | |
| if ($("settings-form-title")) $("settings-form-title").textContent = `βοΈ Edit: ${s.title}`; | |
| const genBtn = $("btn-settings-generate"); | |
| if (genBtn) { genBtn.classList.remove("hidden"); genBtn._orig = null; } | |
| document.querySelectorAll(".setting-card").forEach(c => c.classList.toggle("editing", c.dataset.id === sid)); | |
| // scroll form into view | |
| document.querySelector(".settings-form-col")?.scrollIntoView({ behavior:"smooth", block:"start" }); | |
| renderTagSugs($("s-tags"), $("tag-suggestions")); | |
| } catch (e) { toast(`Failed to load setting: ${e.message}`, "error"); } | |
| } | |
| window.editSetting = editSetting; | |
| /* ββ Duplicate setting βββ */ | |
| async function duplicateSetting(sid) { | |
| try { | |
| const s = await apiFetch(`/api/instructions/${sid}/duplicate`, "POST"); | |
| toast(`Duplicated β "${s.title}"`, "success"); | |
| await loadSettingsList(); | |
| } catch (e) { toast("Duplicate failed: " + e.message, "error"); } | |
| } | |
| window.duplicateSetting = duplicateSetting; | |
| /* ββ Toggle favourite βββ */ | |
| async function toggleSettingFav(sid) { | |
| try { | |
| const r = await apiFetch(`/api/instructions/${sid}/favorite`, "POST"); | |
| toast(r.is_favorite ? "Added to favourites β " : "Removed from favourites", "info"); | |
| await loadSettingsList(); | |
| } catch (e) { toast("Failed: " + e.message, "error"); } | |
| } | |
| window.toggleSettingFav = toggleSettingFav; | |
| /* ββ Delete setting βββ */ | |
| async function deleteSetting(sid) { | |
| if (!confirm("Delete this instruction setting? This cannot be undone.")) return; | |
| try { | |
| await apiFetch(`/api/instructions/${sid}`, "DELETE"); | |
| toast("Setting deleted.", "success"); | |
| if ($("edit-settings-id").value === sid) clearSettingsForm(); | |
| await loadSettingsList(); | |
| } catch (e) { toast(`Delete failed: ${e.message}`, "error"); } | |
| } | |
| window.deleteSetting = deleteSetting; | |
| /* ββ Generate now (from edit form) βββ */ | |
| $("btn-settings-generate")?.addEventListener("click", async () => { | |
| const sid = $("edit-settings-id").value; | |
| if (!sid) return; | |
| document.querySelector('[data-page="generate"]')?.click(); | |
| await generateFromSetting(sid); | |
| }); | |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| HISTORY PAGE | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| $("btn-history-refresh")?.addEventListener("click", loadHistory); | |
| let histSearchTimer; | |
| $("history-search")?.addEventListener("input", () => { | |
| clearTimeout(histSearchTimer); | |
| histSearchTimer = setTimeout(loadHistory, 300); | |
| }); | |
| $("history-status-filter")?.addEventListener("change", loadHistory); | |
| async function loadHistory() { | |
| const status = $("history-status-filter")?.value || ""; | |
| const q = $("history-search")?.value.trim() || ""; | |
| let url = "/api/history?"; | |
| if (status) url += `status_filter=${status}&`; | |
| try { | |
| const data = await apiFetch(url); | |
| let entries = data.entries || []; | |
| // client-side search if q provided | |
| if (q) { | |
| const ql = q.toLowerCase(); | |
| entries = entries.filter(e => e.instruction?.toLowerCase().includes(ql) || | |
| e.prompt_id?.toLowerCase().includes(ql) || | |
| (e.tags||[]).join(",").includes(ql)); | |
| } | |
| const tbody = $("history-body"); | |
| const total = entries.length; | |
| if ($("history-badge")) $("history-badge").textContent = data.total || 0; | |
| if (!total) { | |
| tbody.innerHTML = `<tr><td class="empty-msg" colspan="8">No prompts found.</td></tr>`; | |
| return; | |
| } | |
| tbody.innerHTML = entries.map(e => ` | |
| <tr> | |
| <td><span class="fav-star${e.is_favorite?' active':''}" | |
| onclick="toggleHistFav('${esc(e.prompt_id)}')">${e.is_favorite?"β ":"β"}</span></td> | |
| <td><code style="font-size:.7rem;color:var(--text-muted)">${esc(e.prompt_id?.slice(0,8))}β¦</code></td> | |
| <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" | |
| title="${esc(e.instruction)}">${esc(e.instruction?.slice(0,55)||"β")}</td> | |
| <td style="font-family:var(--font-mono);font-size:.75rem">v${e.version||1}</td> | |
| <td><span class="badge badge-${e.status||'pending'}">${esc(e.status||"pending")}</span></td> | |
| <td>${(e.tags||[]).slice(0,3).map(t=>`<span class="tag-chip">${esc(t)}</span>`).join(" ")}</td> | |
| <td style="white-space:nowrap;font-size:.74rem;color:var(--text-muted)">${e.created_at?new Date(e.created_at).toLocaleDateString():"β"}</td> | |
| <td style="white-space:nowrap;display:flex;gap:4px"> | |
| <button class="icon-btn btn-sm" title="Archive" onclick="archivePrompt('${esc(e.prompt_id)}')">π¦</button> | |
| <button class="icon-btn btn-danger btn-sm" title="Delete" onclick="deleteHistory('${esc(e.prompt_id)}')">π</button> | |
| </td> | |
| </tr>`).join(""); | |
| toast(`${total} prompt(s) loaded.`, "info", 2000); | |
| } catch (e) { toast(`History error: ${e.message}`, "error"); } | |
| } | |
| async function toggleHistFav(pid) { | |
| try { | |
| const r = await apiFetch(`/api/prompts/${pid}/favorite`, "POST"); | |
| document.querySelectorAll(".fav-star").forEach(el => { | |
| if (el.getAttribute("onclick")?.includes(pid)) { | |
| el.textContent = r.is_favorite ? "β " : "β"; | |
| el.classList.toggle("active", r.is_favorite); | |
| } | |
| }); | |
| } catch (e) { toast("Failed: " + e.message, "error"); } | |
| } | |
| window.toggleHistFav = toggleHistFav; | |
| async function archivePrompt(pid) { | |
| try { | |
| await apiFetch(`/api/prompts/${pid}/archive`, "POST"); | |
| toast("Prompt archived.", "success"); | |
| loadHistory(); | |
| } catch (e) { toast("Archive failed: " + e.message, "error"); } | |
| } | |
| window.archivePrompt = archivePrompt; | |
| async function deleteHistory(id) { | |
| if (!confirm("Delete this prompt permanently?")) return; | |
| try { | |
| await apiFetch(`/api/history/${id}`, "DELETE"); | |
| toast("Deleted.", "success"); | |
| loadHistory(); | |
| } catch (e) { toast(`Delete failed: ${e.message}`, "error"); } | |
| } | |
| window.deleteHistory = deleteHistory; | |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| STATS PAGE | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| $("btn-stats-refresh")?.addEventListener("click", loadStats); | |
| async function loadStats() { | |
| const grid = $("stats-grid"); | |
| try { | |
| const s = await apiFetch("/api/stats"); | |
| grid.innerHTML = [ | |
| { val:s.total_prompts, lbl:"Total Prompts", sub:"all time", hi:false }, | |
| { val:s.total_settings, lbl:"Saved Settings", sub:`${s.favorite_settings} favourited`, hi:false }, | |
| { val:s.total_refinements,lbl:"Refinements", sub:"iterative improvements", hi:true }, | |
| { val:s.avg_constraints, lbl:"Avg Constraints", sub:"per prompt", hi:false }, | |
| ].map(c => ` | |
| <div class="stat-card${c.hi?" highlight":""}"> | |
| <div class="stat-value">${c.val}</div> | |
| <div class="stat-label">${c.lbl}</div> | |
| <div class="stat-sub">${c.sub}</div> | |
| </div>`).join(""); | |
| // Persona bars | |
| const maxP = Math.max(1, ...Object.values(s.top_personas)); | |
| $("stats-personas").innerHTML = Object.entries(s.top_personas).length | |
| ? Object.entries(s.top_personas).sort((a,b)=>b[1]-a[1]).map(([k,v]) => ` | |
| <div class="bar-row"> | |
| <div class="bar-row-label">${esc(k)}</div> | |
| <div class="bar-track"><div class="bar-fill" style="width:${Math.round(v/maxP*100)}%"></div></div> | |
| <div class="bar-count">${v}</div> | |
| </div>`).join("") | |
| : `<p class="muted" style="padding:4px 0">No data yet.</p>`; | |
| // Style bars | |
| const maxS = Math.max(1, ...Object.values(s.top_styles)); | |
| $("stats-styles").innerHTML = Object.entries(s.top_styles).length | |
| ? Object.entries(s.top_styles).sort((a,b)=>b[1]-a[1]).map(([k,v]) => ` | |
| <div class="bar-row"> | |
| <div class="bar-row-label">${esc(k)}</div> | |
| <div class="bar-track"><div class="bar-fill" style="width:${Math.round(v/maxS*100)}%"></div></div> | |
| <div class="bar-count">${v}</div> | |
| </div>`).join("") | |
| : `<p class="muted" style="padding:4px 0">No data yet.</p>`; | |
| // Status breakdown | |
| $("stats-statuses").innerHTML = [ | |
| { status:"pending", count:s.pending_count, color:"var(--amber)" }, | |
| { status:"approved", count:s.approved_count, color:"var(--green)" }, | |
| { status:"exported", count:s.exported_count, color:"var(--accent)" }, | |
| { status:"archived", count:s.archived_count, color:"var(--text-faint)" }, | |
| ].map(x => ` | |
| <div class="status-item"> | |
| <div class="status-item-count" style="color:${x.color}">${x.count}</div> | |
| <div class="status-item-label">${x.status}</div> | |
| </div>`).join(""); | |
| toast("Stats refreshed.", "info", 2000); | |
| } catch (e) { | |
| grid.innerHTML = `<div class="stat-card" style="grid-column:1/-1;text-align:center;color:var(--text-muted)">Could not load stats β is the server running?</div>`; | |
| toast("Stats error: " + e.message, "error"); | |
| } | |
| } | |
| /* ββ Utilities βββ */ | |
| function personaEmoji(persona) { | |
| const m = { senior_dev:"π¨βπ»", data_scientist:"π", tech_writer:"βοΈ", product_mgr:"π", | |
| security_eng:"π", devops_eng:"π", ml_engineer:"π§ ", custom:"βοΈ" }; | |
| return m[persona] || "π€"; | |
| } | |
| function updateBadge(id, total, delta) { | |
| const el = $(id); | |
| if (!el) return; | |
| if (total !== null) { el.textContent = total; } | |
| else { el.textContent = parseInt(el.textContent || "0") + delta; } | |
| } | |
| /* ββ Init βββ */ | |
| (async () => { | |
| await loadConfig(); | |
| await loadSettingsList(); | |
| setStep(1); | |
| })(); | |