// Shared helpers for the admin panel. function requireAuth() { if (!Auth.isAuthed()) { location.href = "index.html"; } } function slugify(s) { return (s || "") .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } function uid(prefix) { return prefix + "-" + Math.random().toString(36).slice(2, 8); } const ADMIN_LOGO_SVG = ` `; function sidebarHtml(active) { const items = [ ["dashboard.html", t("admin.sidebar.dashboard"), "dashboard"], ["artists.html", t("admin.sidebar.artists"), "artists"], ["exhibitions.html", t("admin.sidebar.exhibitions"), "exhibitions"], ["news.html", t("admin.sidebar.news"), "news"], ["settings.html", t("admin.sidebar.settings"), "settings"] ]; const curLang = getLang(); return ` `; } function mountSidebar(active) { const host = document.getElementById("sidebar-slot"); if (host) host.outerHTML = sidebarHtml(active); document.getElementById("sidebar-lang")?.addEventListener("change", (e) => { setLang(e.target.value); }); document.getElementById("lang-reset")?.addEventListener("click", (e) => { e.preventDefault(); clearLangOverride(); }); document.getElementById("logout")?.addEventListener("click", (e) => { e.preventDefault(); Auth.logout(); location.href = "index.html"; }); document.getElementById("reset-data")?.addEventListener("click", async (e) => { e.preventDefault(); if (!confirm(t("admin.confirm.reset"))) return; try { await Store.reset(); toast(t("admin.toast.data_reset")); setTimeout(() => location.reload(), 400); } catch (err) { alert(err.message || err); } }); document.getElementById("export-data")?.addEventListener("click", (e) => { e.preventDefault(); exportData(); }); document.getElementById("import-data")?.addEventListener("click", (e) => { e.preventDefault(); document.getElementById("import-file")?.click(); }); document.getElementById("import-file")?.addEventListener("change", (e) => { const f = e.target.files && e.target.files[0]; if (f) importDataFromFile(f); e.target.value = ""; }); } async function exportData() { let data; try { data = await Store.exportAll(); } catch (e) { alert(e.message || e); return; } const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; const ts = new Date().toISOString().slice(0, 10); a.download = `jimi-gallery-${ts}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast(t("admin.toast.exported")); } function importDataFromFile(file) { const reader = new FileReader(); reader.onerror = () => alert(t("admin.error.invalid_file")); reader.onload = async () => { let parsed; try { parsed = JSON.parse(reader.result); } catch (e) { alert(t("admin.error.invalid_file")); return; } if ( !parsed || typeof parsed !== "object" || !parsed.settings || !Array.isArray(parsed.artists) || !Array.isArray(parsed.exhibitions) || !Array.isArray(parsed.news) ) { alert(t("admin.error.invalid_file")); return; } if (!confirm(t("admin.confirm.import"))) return; try { await Store.importAll(parsed); toast(t("admin.toast.imported")); setTimeout(() => location.reload(), 400); } catch (err) { alert(err.message || err); } }; reader.readAsText(file); } let _toastTimer; function toast(msg) { let el = document.querySelector(".toast"); if (!el) { el = document.createElement("div"); el.className = "toast"; document.body.appendChild(el); } el.textContent = msg; requestAnimationFrame(() => el.classList.add("show")); clearTimeout(_toastTimer); _toastTimer = setTimeout(() => el.classList.remove("show"), 1800); } function openModal() { document.getElementById("modal")?.classList.add("open"); } function closeModal() { document.getElementById("modal")?.classList.remove("open"); } function fmtDateShort(iso) { if (!iso) return "—"; return new Date(iso + "T00:00:00").toLocaleDateString( typeof getLocale === "function" ? getLocale() : "en-US", { month: "short", day: "numeric", year: "numeric" } ); } // Mount a multilingual text field with EN/KO/JA tabs. Returns a handle with // .get() returning the { en, ko, ja } object. Pair this with form submit: // const bio = mountMultilingualField(el, { value: a.bio }); // // later: item.bio = bio.get(); function mountMultilingualField(container, opts = {}) { const { value = {}, type = "textarea", placeholder = "", minHeight = "" } = opts; const initial = typeof value === "string" ? { en: value, ko: "", ja: "" } : { en: (value && value.en) || "", ko: (value && value.ko) || "", ja: (value && value.ja) || "" }; const state = { ...initial }; const langs = SUPPORTED_LANGS; let current = getLang(); if (!langs.includes(current)) current = "en"; container.classList.add("ml-field"); const inputEl = type === "input" ? `` : ``; container.innerHTML = `
${langs .map( (l) => `` ) .join("")}
${inputEl} `; const input = container.querySelector(".ml-input"); const tabs = container.querySelectorAll(".ml-tab"); function switchTo(lang) { state[current] = input.value; current = lang; input.value = state[lang] || ""; tabs.forEach((tb) => tb.classList.toggle("active", tb.dataset.lang === lang)); } tabs.forEach((tb) => tb.addEventListener("click", () => switchTo(tb.dataset.lang))); // initial active tab tabs.forEach((tb) => tb.classList.toggle("active", tb.dataset.lang === current)); input.value = state[current] || ""; return { get() { state[current] = input.value; return { en: state.en || "", ko: state.ko || "", ja: state.ja || "" }; } }; } function escapeAttr(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(/"/g, """); } // Read a File, downscale if large, return a data URL string. async function fileToDataURL(file, maxDim = 1400, quality = 0.82) { const originalDataUrl = await new Promise((resolve, reject) => { const r = new FileReader(); r.onerror = () => reject(r.error || new Error("read failed")); r.onload = () => resolve(r.result); r.readAsDataURL(file); }); if (file.type === "image/gif" || file.size < 300 * 1024) return originalDataUrl; return new Promise((resolve) => { const img = new Image(); img.onerror = () => resolve(originalDataUrl); img.onload = () => { const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); if (scale >= 1) return resolve(originalDataUrl); const w = Math.round(img.width * scale); const h = Math.round(img.height * scale); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; canvas.getContext("2d").drawImage(img, 0, 0, w, h); try { resolve(canvas.toDataURL("image/jpeg", quality)); } catch (e) { resolve(originalDataUrl); } }; img.src = originalDataUrl; }); } // Mount an image field (preview + URL input + upload + clear) into `container`. // Returns the URL input element. function mountImageField(container, opts = {}) { const { value = "", id = "", dataK = "", placeholder = "Paste image URL", compact = false } = opts; container.className = "image-field" + (compact ? " compact" : ""); container.innerHTML = `
No image selected
`; const urlInput = container.querySelector(".image-field-url"); const fileInput = container.querySelector('input[type="file"]'); const previewImg = container.querySelector(".image-field-preview img"); const emptyEl = container.querySelector(".image-field-empty"); const clearBtn = container.querySelector(".image-field-clear"); function updatePreview() { const v = urlInput.value.trim(); if (v) { previewImg.src = v; previewImg.style.display = ""; emptyEl.style.display = "none"; } else { previewImg.removeAttribute("src"); previewImg.style.display = "none"; emptyEl.style.display = ""; } } urlInput.addEventListener("input", updatePreview); previewImg.addEventListener("error", () => { previewImg.style.display = "none"; emptyEl.textContent = "Preview unavailable"; emptyEl.style.display = ""; }); fileInput.addEventListener("change", async () => { const file = fileInput.files && fileInput.files[0]; if (!file) return; if (!file.type.startsWith("image/")) { alert("Please select an image file."); fileInput.value = ""; return; } if (file.size > 20 * 1024 * 1024) { alert("Image too large — please choose a file under 20MB."); fileInput.value = ""; return; } try { const dataUrl = await fileToDataURL(file); urlInput.value = dataUrl; emptyEl.textContent = "No image selected"; updatePreview(); } catch (err) { alert("Could not read image: " + (err.message || err)); } fileInput.value = ""; }); clearBtn.addEventListener("click", () => { urlInput.value = ""; emptyEl.textContent = "No image selected"; updatePreview(); }); updatePreview(); return urlInput; }