"use strict"; const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); let MODEL_TYPES = []; let downloadsTimer = null; // ---- helpers -------------------------------------------------------------- async function api(path, opts) { const resp = await fetch(path, opts); const ct = resp.headers.get("content-type") || ""; const data = ct.includes("application/json") ? await resp.json() : null; if (!resp.ok) { const msg = (data && data.detail) ? data.detail : `HTTP ${resp.status}`; throw new Error(msg); } return data; } function fmtBytes(n) { if (!n) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let i = 0; while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`; } function fmtDate(epoch) { if (!epoch) return ""; return new Date(epoch * 1000).toLocaleString(); } // ---- navigation ----------------------------------------------------------- function showView(name) { $$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); $$(".view").forEach((v) => v.classList.toggle("active", v.id === `view-${name}`)); if (name === "installed") loadModels(); if (name === "gallery") loadGallery(true); if (name === "browse" && !browseState.loaded) runSearch(true); if (name === "download") { loadDownloads(); startDownloadsPolling(); } else stopDownloadsPolling(); if (name === "settings") loadSettings(); } $$(".nav-item").forEach((btn) => btn.addEventListener("click", () => showView(btn.dataset.view))); // ---- model types ---------------------------------------------------------- async function loadModelTypes() { MODEL_TYPES = await api("/api/model-types"); const sel = $("#dlType"); sel.innerHTML = ""; for (const t of MODEL_TYPES) { const opt = document.createElement("option"); opt.value = t.key; opt.textContent = t.label; sel.appendChild(opt); } } // ---- installed models ----------------------------------------------------- async function loadModels() { const container = $("#modelsContainer"); try { const models = await api("/api/models"); renderModels(models); } catch (err) { container.innerHTML = `

Error: ${err.message}

`; } } function renderModels(models) { const container = $("#modelsContainer"); const filter = $("#modelSearch").value.trim().toLowerCase(); const shown = filter ? models.filter((m) => m.name.toLowerCase().includes(filter)) : models; if (!shown.length) { container.innerHTML = `

${ models.length ? "No models match your filter." : "No models installed yet. Use Add / Download." }

`; return; } const groups = {}; for (const m of shown) (groups[m.type_label] ||= []).push(m); container.innerHTML = ""; for (const label of Object.keys(groups).sort()) { const group = document.createElement("div"); group.className = "type-group"; group.innerHTML = `

${label} · ${groups[label].length}

`; const grid = document.createElement("div"); grid.className = "model-grid"; for (const m of groups[label]) { const card = document.createElement("div"); card.className = "model-card"; card.innerHTML = `
${fmtBytes(m.size)} · ${fmtDate(m.mtime)}
${m.folder}/
`; card.querySelector(".name").textContent = m.name; card.querySelector(".danger").addEventListener("click", () => deleteModel(m)); grid.appendChild(card); } group.appendChild(grid); container.appendChild(group); } } async function deleteModel(m) { if (!confirm(`Delete ${m.name}?`)) return; try { await api("/api/models", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ folder: m.folder, name: m.name }), }); loadModels(); } catch (err) { alert(`Delete failed: ${err.message}`); } } $("#refreshModels").addEventListener("click", loadModels); $("#modelSearch").addEventListener("input", () => loadModels()); // ---- downloads ------------------------------------------------------------ function detectSource(url) { url = url.toLowerCase(); if (url.includes("civitai.com")) return "CivitAI (type auto-detected, API key used)"; if (url.includes("huggingface.co") || url.includes("hf.co")) return "HuggingFace (token used)"; if (url) return "Direct download"; return ""; } $("#dlUrl").addEventListener("input", (e) => { $("#sourceHint").textContent = detectSource(e.target.value.trim()); }); $("#startDownload").addEventListener("click", async () => { const url = $("#dlUrl").value.trim(); const msg = $("#dlMsg"); if (!url) { msg.className = "form-msg err"; msg.textContent = "Enter a URL."; return; } const btn = $("#startDownload"); btn.disabled = true; msg.className = "form-msg"; msg.textContent = "Starting…"; try { await api("/api/downloads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, model_type: $("#dlType").value || null, filename: $("#dlFilename").value.trim() || null, }), }); msg.className = "form-msg ok"; msg.textContent = "Download started."; $("#dlUrl").value = ""; $("#dlFilename").value = ""; $("#sourceHint").textContent = ""; loadDownloads(); startDownloadsPolling(); } catch (err) { msg.className = "form-msg err"; msg.textContent = err.message; } finally { btn.disabled = false; } }); async function loadDownloads() { let rows; try { rows = await api("/api/downloads"); } catch { return; } renderDownloads(rows); const active = rows.some((r) => r.status === "downloading" || r.status === "queued"); if (!active) stopDownloadsPolling(); } function renderDownloads(rows) { const container = $("#downloadsContainer"); if (!rows.length) { container.innerHTML = `

No downloads yet.

`; return; } container.innerHTML = ""; for (const r of rows) { const pct = r.bytes_total ? Math.floor((r.bytes_done / r.bytes_total) * 100) : 0; const row = document.createElement("div"); row.className = "dl-row"; const sizeText = r.bytes_total ? `${fmtBytes(r.bytes_done)} / ${fmtBytes(r.bytes_total)} (${pct}%)` : fmtBytes(r.bytes_done); const sub = r.status === "failed" && r.error ? r.error : `${r.source} · ${r.model_type} · ${sizeText}`; row.innerHTML = `
${r.status}
${r.status === "downloading" ? `
` : ""}`; row.querySelector(".dl-name").textContent = r.filename || r.url; row.querySelector(".dl-sub").textContent = sub; const actionBtn = row.querySelector(".danger"); const active = r.status === "downloading" || r.status === "queued"; actionBtn.textContent = active ? "Cancel" : "Remove"; actionBtn.addEventListener("click", () => removeDownload(r.id)); container.appendChild(row); } } async function removeDownload(id) { try { await api(`/api/downloads/${id}`, { method: "DELETE" }); loadDownloads(); } catch (err) { alert(`Failed: ${err.message}`); } } function startDownloadsPolling() { if (downloadsTimer) return; downloadsTimer = setInterval(loadDownloads, 1000); } function stopDownloadsPolling() { if (downloadsTimer) { clearInterval(downloadsTimer); downloadsTimer = null; } } // ---- gallery (generated photos) ------------------------------------------- const galleryState = { offset: 0, limit: 60, total: 0 }; async function loadGallery(reset) { const grid = $("#galleryGrid"); const moreBtn = $("#galleryMore"); if (reset) { galleryState.offset = 0; grid.innerHTML = `

Loading…

`; } else { moreBtn.textContent = "Loading…"; } try { const data = await api( `/api/gallery?limit=${galleryState.limit}&offset=${galleryState.offset}`); if (reset) grid.innerHTML = ""; if (!data.items.length && reset) { grid.innerHTML = `

No generated photos yet.

`; } else { for (const p of data.items) grid.appendChild(renderPhotoCard(p)); } galleryState.total = data.total; galleryState.offset += data.returned; const hasMore = galleryState.offset < galleryState.total; moreBtn.style.display = hasMore ? "" : "none"; moreBtn.textContent = "Load more"; } catch (err) { if (reset) grid.innerHTML = `

Error: ${err.message}

`; moreBtn.textContent = "Load more"; } } function photoUrl(p) { return `/gallery/file?path=${encodeURIComponent(p.path)}`; } function renderPhotoCard(p) { const card = document.createElement("div"); card.className = "photo-card"; const img = document.createElement("img"); img.loading = "lazy"; img.src = photoUrl(p); img.alt = p.name; const del = document.createElement("button"); del.className = "p-del"; del.title = "Delete"; del.textContent = "🗑"; del.addEventListener("click", (e) => { e.stopPropagation(); deletePhoto(p, card); }); const meta = document.createElement("div"); meta.className = "p-meta"; meta.textContent = `${p.name} · ${fmtBytes(p.size)}`; card.append(img, del, meta); card.addEventListener("click", () => openLightbox(p, card)); return card; } async function deletePhoto(p, card) { if (!confirm(`Permanently delete ${p.name}?`)) return; try { await api("/api/gallery", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: p.path }), }); if (card) card.remove(); galleryState.total = Math.max(0, galleryState.total - 1); galleryState.offset = Math.max(0, galleryState.offset - 1); closeLightbox(); toast($("#galleryToast"), "Deleted", false); if (!$("#galleryGrid").children.length) loadGallery(true); } catch (err) { toast($("#galleryToast"), err.message, true); } } // lightbox let lbCurrent = null; function openLightbox(p, card) { lbCurrent = { p, card }; $("#lbImage").src = photoUrl(p); $("#lbName").textContent = p.path; $("#lbOpen").href = photoUrl(p); $("#lightbox").hidden = false; } function closeLightbox() { $("#lightbox").hidden = true; $("#lbImage").src = ""; lbCurrent = null; } $("#lbClose").addEventListener("click", closeLightbox); $("#lightbox").addEventListener("click", (e) => { if (e.target.id === "lightbox") closeLightbox(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#lightbox").hidden) closeLightbox(); }); $("#lbDelete").addEventListener("click", () => { if (lbCurrent) deletePhoto(lbCurrent.p, lbCurrent.card); }); $("#refreshGallery").addEventListener("click", () => loadGallery(true)); $("#galleryMore").addEventListener("click", () => loadGallery(false)); // ---- browse civitai ------------------------------------------------------- const browseState = { loaded: false, page: null, cursor: null, busy: false }; function browseParams() { const p = new URLSearchParams(); const q = $("#bSearch").value.trim(); if (q) p.set("query", q); if ($("#bType").value) p.set("types", $("#bType").value); p.set("sort", $("#bSort").value); p.set("period", $("#bPeriod").value); p.set("nsfw", $("#bNsfw").checked ? "true" : "false"); return p; } async function runSearch(reset) { if (browseState.busy) return; browseState.busy = true; browseState.loaded = true; const grid = $("#browseGrid"); const moreBtn = $("#bMore"); const p = browseParams(); if (reset) { browseState.page = null; browseState.cursor = null; grid.innerHTML = `

Searching…

`; } else { if (browseState.cursor) p.set("cursor", browseState.cursor); else if (browseState.page) p.set("page", browseState.page); moreBtn.textContent = "Loading…"; } try { const data = await api(`/api/civitai/search?${p.toString()}`); if (reset) grid.innerHTML = ""; if (!data.items.length && reset) { grid.innerHTML = `

No models found.

`; } else { for (const m of data.items) grid.appendChild(renderBrowseCard(m)); } // Set up "load more": prefer cursor, fall back to next page. browseState.cursor = data.nextCursor || null; browseState.page = data.nextPage || null; const hasMore = Boolean(browseState.cursor || browseState.page); moreBtn.style.display = hasMore ? "" : "none"; moreBtn.textContent = "Load more"; } catch (err) { if (reset) grid.innerHTML = `

Error: ${err.message}

`; else toast($("#browseToast"), err.message, true); moreBtn.textContent = "Load more"; } finally { browseState.busy = false; } } function renderBrowseCard(m) { const card = document.createElement("div"); card.className = "browse-card"; const thumb = document.createElement("div"); thumb.className = "thumb"; if (m.thumbnail && m.thumbnail_type === "image") { thumb.style.backgroundImage = `url("${m.thumbnail}")`; } if (m.type) { const tag = document.createElement("span"); tag.className = "type-tag"; tag.textContent = m.type; thumb.appendChild(tag); } if (m.nsfw) { const n = document.createElement("span"); n.className = "nsfw-tag"; n.textContent = "NSFW"; thumb.appendChild(n); } const body = document.createElement("div"); body.className = "body"; const name = document.createElement("div"); name.className = "b-name"; name.textContent = m.name || "(untitled)"; const meta = document.createElement("div"); meta.className = "b-meta"; meta.textContent = `${m.creator || "?"} · ${fmtNum(m.downloads)} downloads`; const foot = document.createElement("div"); foot.className = "b-foot"; // Version selector when there's more than one. let versionSel = null; if (m.versions && m.versions.length > 1) { versionSel = document.createElement("select"); for (const v of m.versions) { const o = document.createElement("option"); o.value = v.id; o.textContent = v.name || `v${v.id}`; versionSel.appendChild(o); } foot.appendChild(versionSel); } const btn = document.createElement("button"); btn.className = "btn primary"; btn.textContent = "Download"; btn.addEventListener("click", () => { const vid = versionSel ? Number(versionSel.value) : m.primary_version_id; downloadVersion(vid, btn); }); foot.appendChild(btn); body.append(name, meta, foot); card.append(thumb, body); return card; } async function downloadVersion(versionId, btn) { if (!versionId) { toast($("#browseToast"), "No version available", true); return; } btn.disabled = true; const original = btn.textContent; btn.textContent = "Queued ✓"; try { await api("/api/civitai/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ version_id: versionId }), }); toast($("#browseToast"), "Download started — see Add / Download", false); setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 2500); } catch (err) { toast($("#browseToast"), err.message, true); btn.disabled = false; btn.textContent = original; } } function fmtNum(n) { if (n == null) return "?"; if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; if (n >= 1e3) return (n / 1e3).toFixed(1) + "k"; return String(n); } let toastTimer = null; function toast(el, msg, isErr) { el.textContent = msg; el.className = `form-msg ${isErr ? "err" : "ok"}`; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.textContent = ""; }, 4000); } $("#bGo").addEventListener("click", () => runSearch(true)); $("#bSearch").addEventListener("keydown", (e) => { if (e.key === "Enter") runSearch(true); }); $("#bMore").addEventListener("click", () => runSearch(false)); // ---- settings ------------------------------------------------------------- async function loadSettings() { try { const s = await api("/api/settings"); setBadge($("#civitaiBadge"), s.civitai_api_key_set); setBadge($("#hfBadge"), s.huggingface_token_set); } catch { /* ignore */ } } function setBadge(el, isSet) { el.textContent = isSet ? "configured" : "not set"; el.className = `badge ${isSet ? "set" : "unset"}`; } $("#saveSettings").addEventListener("click", async () => { const msg = $("#settingsMsg"); const body = {}; const civ = $("#civitaiKey").value; const hf = $("#hfToken").value; // Only send fields the user actually typed into (blank = leave unchanged). if (civ !== "") body.civitai_api_key = civ; if (hf !== "") body.huggingface_token = hf; if (!Object.keys(body).length) { msg.className = "form-msg"; msg.textContent = "Nothing to save."; return; } try { await api("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); msg.className = "form-msg ok"; msg.textContent = "Saved."; $("#civitaiKey").value = ""; $("#hfToken").value = ""; loadSettings(); } catch (err) { msg.className = "form-msg err"; msg.textContent = err.message; } }); // ---- boot ----------------------------------------------------------------- (async function init() { await loadModelTypes(); const wanted = new URLSearchParams(location.search).get("view"); const valid = ["installed", "gallery", "browse", "download", "settings"]; showView(valid.includes(wanted) ? wanted : "installed"); })();