feat: initial Jimi Gallery prototype
- 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
This commit is contained in:
392
admin/admin.js
Normal file
392
admin/admin.js
Normal file
@ -0,0 +1,392 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user