promptforge / frontend /client.js
Really-amin's picture
Upload PromptForge v1.0 β€” Structured prompt generator for Google AI Studio
7732582 verified
/**
* 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
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);
})();