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:
2026-04-25 12:47:36 +09:00
commit 098b55e3b0
32 changed files with 5334 additions and 0 deletions

392
admin/admin.js Normal file
View 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, "&amp;")
.replace(/"/g, "&quot;");
}
// 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;
}