- Public site (Home/Artists/Exhibitions/News/About/Contact) with EN/KO/JA i18n - Admin panel with login, CRUD, image upload, multilingual editing - Exhibition slider/lightbox view - FastAPI + MongoDB backend, JWT auth - Docker Compose deployment, behind nginx at jimi.yakenator.io
393 lines
12 KiB
JavaScript
393 lines
12 KiB
JavaScript
// 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 = `
|
|
<svg class="brand-mark" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
<circle cx="50" cy="50" r="50" fill="#6b3d8f"/>
|
|
<g fill="#ffffff" font-family="'Cormorant Garamond','Playfair Display','Times New Roman',serif" font-weight="500" font-style="italic">
|
|
<text x="37" y="70" text-anchor="middle" font-size="60">J</text>
|
|
<text x="63" y="70" text-anchor="middle" font-size="60">M</text>
|
|
</g>
|
|
</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 `
|
|
<aside class="sidebar">
|
|
<div class="brand-row">
|
|
<a class="brand" href="dashboard.html">
|
|
${ADMIN_LOGO_SVG}
|
|
<div class="brand-text">
|
|
<div class="brand-word">Jimi Gallery</div>
|
|
<div class="brand-sub">${t("admin.login.title")}</div>
|
|
</div>
|
|
</a>
|
|
<div class="lang-controls">
|
|
<select class="lang-select" id="sidebar-lang" aria-label="${t("admin.sidebar.language")}">
|
|
${SUPPORTED_LANGS.map(
|
|
(l) =>
|
|
`<option value="${l}"${l === curLang ? " selected" : ""}>${LANG_LABELS[l]}</option>`
|
|
).join("")}
|
|
</select>
|
|
${
|
|
hasLangOverride()
|
|
? `<button type="button" class="lang-reset" id="lang-reset" title="${t("lang.auto_hint")}" aria-label="${t("lang.auto_hint")}">↺</button>`
|
|
: ""
|
|
}
|
|
</div>
|
|
</div>
|
|
<nav>
|
|
${items
|
|
.map(
|
|
([h, l, k]) =>
|
|
`<a href="${h}" class="${active === k ? "active" : ""}">${l}</a>`
|
|
)
|
|
.join("")}
|
|
</nav>
|
|
<div class="sidebar-foot">
|
|
<a href="../index.html" target="_blank">${t("admin.sidebar.view_site")}</a>
|
|
<a href="#" id="export-data">${t("admin.sidebar.export")}</a>
|
|
<a href="#" id="import-data">${t("admin.sidebar.import")}</a>
|
|
<input type="file" id="import-file" accept="application/json,.json" style="display:none" />
|
|
<a href="#" id="logout">${t("admin.sidebar.logout")}</a>
|
|
<a href="#" id="reset-data" style="color:var(--danger)">${t("admin.sidebar.reset")}</a>
|
|
</div>
|
|
</aside>
|
|
`;
|
|
}
|
|
|
|
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"
|
|
? `<input type="text" class="ml-input" placeholder="${escapeAttr(placeholder)}" />`
|
|
: `<textarea class="ml-input" placeholder="${escapeAttr(placeholder)}"${
|
|
minHeight ? ` style="min-height:${minHeight}"` : ""
|
|
}></textarea>`;
|
|
container.innerHTML = `
|
|
<div class="ml-tabs">
|
|
${langs
|
|
.map(
|
|
(l) =>
|
|
`<button type="button" class="ml-tab" data-lang="${l}">${LANG_LABELS[l]}</button>`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
${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 = `
|
|
<div class="image-field-preview">
|
|
<img alt="" />
|
|
<div class="image-field-empty">No image selected</div>
|
|
</div>
|
|
<div class="image-field-controls">
|
|
<input type="url" class="image-field-url"${id ? ` id="${id}"` : ""}${
|
|
dataK ? ` data-k="${dataK}"` : ""
|
|
} placeholder="${placeholder}" value="${escapeAttr(value)}" />
|
|
<label class="btn ghost btn-small image-field-upload">
|
|
Upload
|
|
<input type="file" accept="image/*" />
|
|
</label>
|
|
<button type="button" class="btn ghost btn-small image-field-clear">Clear</button>
|
|
</div>
|
|
`;
|
|
|
|
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;
|
|
}
|