Files
cowneko-CWK_Checkpoints_Pre…/web/cwk_context_menu.js
T

474 lines
19 KiB
JavaScript

/**
* CWK Preset Manager — Context menu, image picker, version checker.
*
* Exports
* ───────
* isVideoUrl(url)
* fmtSize(kb)
* closeContextMenu()
* showContextMenu(x, y, items)
* showImagePicker(modelName, images, onPick)
* showVersionChecker(modelName, apiKey, onDownloadDone)
*/
// ─── Utilities ────────────────────────────────────────────────────────────────
export function isVideoUrl(url) {
if (!url) return false;
try {
const p = new URL(url).pathname.toLowerCase();
return p.endsWith(".mp4") || p.endsWith(".webm");
} catch {
const l = url.toLowerCase();
return l.includes(".mp4") || l.includes(".webm");
}
}
export function fmtSize(kb) {
if (!kb) return "";
if (kb > 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`;
if (kb > 1024) return `${(kb / 1024).toFixed(1)} MB`;
return `${Math.round(kb)} KB`;
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
function _esc(str) {
return String(str ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function _overlay() {
const el = document.createElement("div");
el.style.cssText = `
position:fixed; inset:0; background:rgba(0,0,0,.75);
z-index:99998; display:flex; align-items:center; justify-content:center;
`;
return el;
}
// ─── Context menu ─────────────────────────────────────────────────────────────
let _activeMenu = null;
export function closeContextMenu() {
if (_activeMenu) { _activeMenu.remove(); _activeMenu = null; }
}
export function showContextMenu(x, y, items) {
closeContextMenu();
const menu = document.createElement("div");
menu.style.cssText = `
position:fixed; left:${x}px; top:${y}px; z-index:99999;
background:#1a2035; border:1px solid #313552; border-radius:8px;
padding:4px 0; min-width:224px;
box-shadow:0 8px 32px rgba(0,0,0,.65);
font-family:Inter,system-ui,sans-serif; font-size:13px; color:#cdd6f4;
`;
for (const item of items) {
if (item === "---") {
const sep = document.createElement("div");
sep.style.cssText = "height:1px;background:#2a2f45;margin:4px 0;";
menu.appendChild(sep);
continue;
}
const el = document.createElement("div");
el.style.cssText = `
padding:8px 14px; cursor:pointer;
display:flex; align-items:center; gap:8px;
transition:background .1s;
${item.danger ? "color:#f38ba8;" : ""}
${item.disabled ? "opacity:.4;pointer-events:none;" : ""}
`;
el.innerHTML =
`<span style="font-size:15px;width:18px;text-align:center">${item.icon ?? ""}</span>` +
_esc(item.label);
el.addEventListener("mouseenter", () => { el.style.background = "#2a2f45"; });
el.addEventListener("mouseleave", () => { el.style.background = ""; });
el.addEventListener("click", () => { closeContextMenu(); item.action?.(); });
menu.appendChild(el);
}
document.body.appendChild(menu);
_activeMenu = menu;
requestAnimationFrame(() => {
const r = menu.getBoundingClientRect();
if (r.right > window.innerWidth) menu.style.left = (x - r.width) + "px";
if (r.bottom > window.innerHeight) menu.style.top = (y - r.height) + "px";
});
setTimeout(() =>
document.addEventListener("click", closeContextMenu, { once: true }), 0);
}
// ─── Image picker modal ───────────────────────────────────────────────────────
export function showImagePicker(modelName, images, onPick) {
const overlay = _overlay();
const box = document.createElement("div");
box.style.cssText = `
background:#141824; border:1px solid #2a2f45; border-radius:10px;
padding:18px; max-width:820px; width:90vw; max-height:72vh;
display:flex; flex-direction:column; gap:12px;
font-family:Inter,sans-serif; color:#cdd6f4;
`;
box.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between">
<span style="font-weight:700;color:#89b4fa;font-size:14px">
Pick Thumbnail — ${_esc(modelName)}
(${images.length} image${images.length !== 1 ? "s" : ""})
</span>
<button id="cwk-pk-close"
style="background:none;border:none;color:#6c7086;font-size:20px;cursor:pointer">✕</button>
</div>
<div id="cwk-pk-grid" style="
display:grid;
grid-template-columns:repeat(auto-fill,minmax(130px,1fr));
gap:8px; overflow-y:auto; flex:1; padding:4px;
"></div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
box.querySelector("#cwk-pk-close").onclick = () => overlay.remove();
overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); });
const grid = box.querySelector("#cwk-pk-grid");
for (const img of images) {
const url = typeof img === "string" ? img : img.url;
if (!url) continue;
const nsfw = (img?.nsfwLevel ?? 0) > 1;
const blur = nsfw ? "filter:blur(12px);" : "";
const card = document.createElement("div");
card.style.cssText = `
position:relative; border-radius:6px; overflow:hidden; cursor:pointer;
border:2px solid transparent; aspect-ratio:2/3; background:#1e2335;
transition:border-color .15s;
`;
card.innerHTML = isVideoUrl(url)
? `<video src="${_esc(url)}" muted autoplay loop playsinline
style="width:100%;height:100%;object-fit:cover;${blur}"></video>`
: `<img src="${_esc(url)}" loading="lazy"
style="width:100%;height:100%;object-fit:cover;${blur}"/>`;
card.addEventListener("mouseenter", () => { card.style.borderColor = "#89b4fa"; });
card.addEventListener("mouseleave", () => { card.style.borderColor = "transparent"; });
card.addEventListener("click", () => { onPick(url); overlay.remove(); });
grid.appendChild(card);
}
}
// ─── Version checker modal ────────────────────────────────────────────────────
export function showVersionChecker(modelName, apiKey, onDownloadDone) {
const overlay = _overlay();
const box = document.createElement("div");
box.style.cssText = `
background:#141824; border:1px solid #2a2f45; border-radius:10px;
padding:18px; max-width:700px; width:90vw; max-height:75vh;
display:flex; flex-direction:column; gap:12px;
font-family:Inter,sans-serif; color:#cdd6f4;
`;
box.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between">
<span style="font-weight:700;color:#89b4fa;font-size:14px">
Model Versions — ${_esc(modelName)}
</span>
<button id="cwk-ver-close"
style="background:none;border:none;color:#6c7086;font-size:20px;cursor:pointer">✕</button>
</div>
<div id="cwk-ver-list"
style="overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:8px;">
<div style="color:#6c7086;font-size:13px;padding:8px 0">Loading versions…</div>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
box.querySelector("#cwk-ver-close").onclick = () => overlay.remove();
overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); });
const list = box.querySelector("#cwk-ver-list");
// ── Fetch version list ─────────────────────────────────────────────────────
fetch(
`/cwk/civitai/versions` +
`?model=${encodeURIComponent(modelName)}` +
`&api_key=${encodeURIComponent(apiKey)}`
)
.then(r => r.json())
.then(({ versions, error }) => {
if (error) {
list.innerHTML =
`<div style="color:#f38ba8;font-size:13px;padding:8px 0">${_esc(error)}</div>`;
return;
}
if (!versions?.length) {
list.innerHTML =
`<div style="color:#6c7086;font-size:13px;padding:8px 0">No versions found.</div>`;
return;
}
list.innerHTML = "";
for (const v of versions) {
const row = document.createElement("div");
const thumb = v.images?.[0]?.url ?? "";
const size = fmtSize(v.size_kb);
const date = v.created_at?.slice(0, 10) ?? "";
row.style.cssText = `
background:#1e2335; border:1px solid #313552; border-radius:8px;
padding:12px; display:flex; align-items:center; gap:12px;
`;
// ── Thumbnail ─────────────────────────────────────────���────────────
const thumbEl = document.createElement("div");
thumbEl.style.cssText = `width:48px;height:64px;flex-shrink:0;border-radius:4px;overflow:hidden;background:#181d2e;display:flex;align-items:center;justify-content:center;font-size:20px;`;
if (thumb) {
const img = document.createElement("img");
img.src = thumb;
img.loading = "lazy";
img.style.cssText = "width:100%;height:100%;object-fit:cover;";
thumbEl.appendChild(img);
} else {
thumbEl.textContent = "🖼";
}
// ── Info column ────────────────────────────────────────────────────
const infoCol = document.createElement("div");
infoCol.style.cssText = "flex:1;min-width:0;";
const nameEl = document.createElement("div");
nameEl.style.cssText = `font-weight:600;font-size:13px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;${v.is_installed ? "color:#a6e3a1;" : ""}`;
nameEl.textContent = v.name;
if (v.is_installed) {
const installedBadge = document.createElement("span");
installedBadge.style.cssText = "font-size:11px;color:#a6e3a1;";
installedBadge.textContent = v.is_current ? "✓ installed (current)" : "✓ installed";
nameEl.appendChild(installedBadge);
}
if (v.early_access_ends) {
const deadline = new Date(v.early_access_ends);
const isEarlyAccess = deadline > new Date();
if (isEarlyAccess) {
const eaBadge = document.createElement("span");
eaBadge.textContent = `⏳ Early Access · until ${deadline.toLocaleDateString()}`;
eaBadge.style.cssText = `
font-size:10px; font-weight:700;
background:#f9e2af; color:#1e1e2e;
border-radius:4px; padding:2px 6px;
white-space:nowrap;
`;
nameEl.appendChild(eaBadge);
}
}
const metaEl = document.createElement("div");
metaEl.style.cssText = "font-size:11px;color:#6c7086;margin-top:3px;";
metaEl.textContent = [v.base_model, size, date].filter(Boolean).join(" · ");
const fileEl = document.createElement("div");
fileEl.style.cssText = "font-size:10px;color:#45475a;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
fileEl.textContent = v.filename ?? "";
// ── Per-row progress bar ───────────────────────────────────────────
const progressWrap = document.createElement("div");
progressWrap.style.cssText = `
display:none; margin-top:8px; height:4px; border-radius:2px;
background:#2a2f45; overflow:hidden;
`;
const progressBar = document.createElement("div");
progressBar.style.cssText = `
height:100%; width:0%; background:#89b4fa;
transition:width .15s ease; border-radius:2px;
`;
progressWrap.appendChild(progressBar);
infoCol.appendChild(nameEl);
infoCol.appendChild(metaEl);
if (v.filename) infoCol.appendChild(fileEl);
infoCol.appendChild(progressWrap);
// ── Action buttons column ──────────────────────────────────────────
const actionsCol = document.createElement("div");
actionsCol.style.cssText = "flex-shrink:0;display:flex;flex-direction:column;gap:6px;align-items:stretch;";
if (!v.is_installed && v.download_url) {
const dlBtn = document.createElement("button");
dlBtn.className = "cwk-ver-dl";
dlBtn.dataset.url = v.download_url;
dlBtn.dataset.fn = v.filename ?? "";
dlBtn.textContent = "⬇ Download";
dlBtn.style.cssText = `
padding:6px 12px; border-radius:6px; border:none;
background:#89b4fa; color:#1e1e2e;
font-size:12px; font-weight:700; cursor:pointer; min-width:90px; text-align:center;
`;
actionsCol.appendChild(dlBtn);
}
if (v.is_installed) {
const delBtn = document.createElement("button");
delBtn.className = "cwk-ver-del";
delBtn.dataset.model = modelName;
delBtn.textContent = "🗑 Delete";
delBtn.style.cssText = `
padding:6px 12px; border-radius:6px; border:1px solid #f38ba8;
background:transparent; color:#f38ba8;
font-size:12px; font-weight:700; cursor:pointer; min-width:90px; text-align:center;
`;
actionsCol.appendChild(delBtn);
}
row.appendChild(thumbEl);
row.appendChild(infoCol);
row.appendChild(actionsCol);
list.appendChild(row);
}
// ── Wire up download buttons ───────────────────────────────────────────
list.querySelectorAll(".cwk-ver-dl").forEach(btn => {
btn.addEventListener("click", async () => {
const dlUrl = btn.dataset.url;
const fn = btn.dataset.fn;
const row = btn.closest("div[style*='background:#1e2335']");
const infoCol = row?.querySelector("div[style*='flex:1']");
const pWrap = infoCol?.querySelector("div[style*='height:4px']");
const pBar = pWrap?.firstElementChild;
btn.disabled = true;
btn.textContent = "0%";
if (pWrap) pWrap.style.display = "block";
try {
const response = await fetch("/cwk/civitai/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: modelName,
download_url: dlUrl,
filename: fn,
api_key: apiKey,
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop();
for (const part of parts) {
const line = part.trim();
if (!line.startsWith("data:")) continue;
let p;
try { p = JSON.parse(line.slice(5)); } catch { continue; }
if (p.progress !== undefined) {
btn.textContent = `${p.progress}%`;
if (pBar) pBar.style.width = `${p.progress}%`;
}
if (p.done && !p.error) {
btn.textContent = "✓ Done";
btn.style.background = "#a6e3a1";
btn.style.color = "#1e1e2e";
if (pBar) {
pBar.style.width = "100%";
pBar.style.background = "#a6e3a1";
}
// Pass civitai metadata and registered name if the server
// fetched it inline — panel can update the card immediately
onDownloadDone?.("downloaded", p.civitai ?? null, p.registered_name ?? null);
}
if (p.error) {
btn.textContent = "✗ Error";
btn.style.background = "#f38ba8";
btn.style.color = "#1e1e2e";
btn.title = p.error;
btn.disabled = false;
if (pWrap) pWrap.style.display = "none";
}
}
}
} catch (e) {
btn.textContent = "✗ Failed";
btn.style.background = "#f38ba8";
btn.style.color = "#1e1e2e";
btn.title = e.message;
btn.disabled = false;
if (pWrap) pWrap.style.display = "none";
}
});
});
// ── Wire up delete buttons ─────────────────────────────────────────────
list.querySelectorAll(".cwk-ver-del").forEach(btn => {
btn.addEventListener("click", async () => {
const modelToDelete = btn.dataset.model;
if (!confirm(`Permanently delete "${modelToDelete}"?\n\nThis cannot be undone.`)) return;
btn.disabled = true;
btn.textContent = "Deleting…";
try {
const res = await fetch("/cwk/model", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelToDelete }),
});
const data = await res.json();
if (data.ok) {
const row = btn.closest("div[style*='background:#1e2335']");
if (row) {
row.style.opacity = "0.4";
row.style.pointerEvents = "none";
}
btn.textContent = "✓ Deleted";
btn.style.color = "#6c7086";
btn.style.border = "1px solid #45475a";
onDownloadDone?.("deleted");
} else {
btn.textContent = "✗ Failed";
btn.style.color = "#f38ba8";
btn.title = data.errors?.join(", ") ?? "Delete failed";
btn.disabled = false;
}
} catch (e) {
btn.textContent = "✗ Failed";
btn.style.color = "#f38ba8";
btn.title = e.message;
btn.disabled = false;
}
});
});
})
.catch(e => {
list.innerHTML =
`<div style="color:#f38ba8;font-size:13px;padding:8px 0">
Failed to load versions: ${_esc(e.message)}
</div>`;
});
}