- 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
233 lines
9.4 KiB
HTML
233 lines
9.4 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Exhibitions — Jimi Gallery Admin</title>
|
||
<link
|
||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@300;400;500&display=swap"
|
||
rel="stylesheet"
|
||
/>
|
||
<link rel="stylesheet" href="admin.css" />
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<div id="sidebar-slot"></div>
|
||
<div class="content">
|
||
<div class="topbar">
|
||
<div>
|
||
<h1 id="pg-title"></h1>
|
||
<div class="subtitle" id="pg-sub"></div>
|
||
</div>
|
||
<button class="btn" id="new-btn"></button>
|
||
</div>
|
||
|
||
<div class="panel" style="padding:0">
|
||
<table class="table" id="tbl"></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal" id="modal">
|
||
<div class="body">
|
||
<h2>
|
||
<span id="modal-title"></span>
|
||
<button class="close" onclick="closeModal()">×</button>
|
||
</h2>
|
||
<form id="form" class="form">
|
||
<input type="hidden" id="f-id" />
|
||
<div class="full">
|
||
<label id="lbl-title"></label>
|
||
<div id="f-title-wrap"></div>
|
||
</div>
|
||
<div>
|
||
<label id="lbl-status"></label>
|
||
<select id="f-status">
|
||
<option value="current"></option>
|
||
<option value="upcoming"></option>
|
||
<option value="past"></option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label id="lbl-venue"></label>
|
||
<input id="f-venue" />
|
||
</div>
|
||
<div>
|
||
<label id="lbl-start"></label>
|
||
<input id="f-start" type="date" />
|
||
</div>
|
||
<div>
|
||
<label id="lbl-end"></label>
|
||
<input id="f-end" type="date" />
|
||
</div>
|
||
<div class="full">
|
||
<label id="lbl-hero"></label>
|
||
<div id="f-hero-wrap"></div>
|
||
</div>
|
||
<div class="full">
|
||
<label id="lbl-artists"></label>
|
||
<select id="f-artists" multiple size="6"></select>
|
||
</div>
|
||
<div class="full">
|
||
<label id="lbl-desc"></label>
|
||
<div id="f-desc-wrap"></div>
|
||
</div>
|
||
<div class="full">
|
||
<label id="lbl-press"></label>
|
||
<div id="f-press-wrap"></div>
|
||
</div>
|
||
<div class="actions">
|
||
<button type="button" class="btn ghost" id="btn-cancel" onclick="closeModal()"></button>
|
||
<button type="submit" class="btn" id="btn-save"></button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="../assets/i18n.js"></script>
|
||
<script src="../assets/data.js"></script>
|
||
<script src="admin.js"></script>
|
||
<script>
|
||
(async () => {
|
||
requireAuth();
|
||
try { await Store.load(); } catch (e) { return showBootError(e); }
|
||
mountSidebar("exhibitions");
|
||
|
||
document.getElementById("pg-title").textContent = t("admin.page.exhibitions");
|
||
document.getElementById("pg-sub").textContent = t("admin.page.exhibitions_sub");
|
||
document.getElementById("new-btn").textContent = t("admin.btn.new_exhibition");
|
||
document.getElementById("lbl-title").textContent = t("admin.form.title");
|
||
document.getElementById("lbl-status").textContent = t("admin.form.status");
|
||
document.getElementById("lbl-venue").textContent = t("admin.form.venue");
|
||
document.getElementById("lbl-start").textContent = t("admin.form.start_date");
|
||
document.getElementById("lbl-end").textContent = t("admin.form.end_date");
|
||
document.getElementById("lbl-hero").textContent = t("admin.form.hero_image");
|
||
document.getElementById("lbl-artists").innerHTML = `${t("admin.form.artists")} <span style="color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">${t("admin.form.artists_hint")}</span>`;
|
||
document.getElementById("lbl-desc").textContent = t("admin.form.description");
|
||
document.getElementById("lbl-press").textContent = t("admin.form.press_note");
|
||
document.getElementById("btn-cancel").textContent = t("admin.btn.cancel");
|
||
document.getElementById("btn-save").textContent = t("admin.btn.save");
|
||
|
||
// status select options
|
||
const statusSel = document.getElementById("f-status");
|
||
statusSel.options[0].textContent = t("status.current");
|
||
statusSel.options[1].textContent = t("status.upcoming");
|
||
statusSel.options[2].textContent = t("status.past");
|
||
|
||
let fields = {};
|
||
|
||
const order = { current: 0, upcoming: 1, past: 2 };
|
||
function render() {
|
||
const rows = [...Store.exhibitions()]
|
||
.sort((a, b) =>
|
||
order[a.status] !== order[b.status]
|
||
? order[a.status] - order[b.status]
|
||
: b.startDate.localeCompare(a.startDate)
|
||
)
|
||
.map((e) => {
|
||
const artists = e.artistIds.map((id) => Store.artist(id)?.name).filter(Boolean).join(", ");
|
||
return `
|
||
<tr>
|
||
<td><img src="${e.hero}" alt=""></td>
|
||
<td><em>${L(e.title)}</em></td>
|
||
<td>${artists}</td>
|
||
<td>${fmtDateShort(e.startDate)} – ${fmtDateShort(e.endDate)}</td>
|
||
<td><span class="badge badge-${e.status}">${t("status." + e.status)}</span></td>
|
||
<td class="actions">
|
||
<a class="btn ghost btn-small" href="../exhibition.html?id=${e.id}" target="_blank">${t("admin.btn.view")}</a>
|
||
<button class="btn ghost btn-small" onclick="edit('${e.id}')">${t("admin.btn.edit")}</button>
|
||
<button class="btn danger btn-small" onclick="del('${e.id}')">${t("admin.btn.delete")}</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.join("");
|
||
document.getElementById("tbl").innerHTML = `
|
||
<thead><tr><th></th><th>${t("admin.table.title")}</th><th>${t("admin.table.artists")}</th><th>${t("admin.table.dates")}</th><th>${t("admin.table.status")}</th><th></th></tr></thead>
|
||
<tbody>${rows || `<tr><td colspan="6" style="text-align:center;padding:24px;color:var(--muted)">${t("admin.empty.exhibitions")}</td></tr>`}</tbody>
|
||
`;
|
||
}
|
||
render();
|
||
|
||
function fillArtistOptions(selectedIds = []) {
|
||
const sel = document.getElementById("f-artists");
|
||
sel.innerHTML = Store.artists()
|
||
.map(
|
||
(a) =>
|
||
`<option value="${a.id}" ${selectedIds.includes(a.id) ? "selected" : ""}>${a.name}</option>`
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
window.edit = function (id) {
|
||
const e = id ? Store.exhibition(id) : null;
|
||
document.getElementById("modal-title").textContent = e
|
||
? t("admin.modal.edit_exhibition")
|
||
: t("admin.modal.new_exhibition");
|
||
document.getElementById("f-id").value = e?.id || "";
|
||
document.getElementById("f-status").value = e?.status || "upcoming";
|
||
document.getElementById("f-venue").value = e?.venue || "145 Grand Street";
|
||
document.getElementById("f-start").value = e?.startDate || "";
|
||
document.getElementById("f-end").value = e?.endDate || "";
|
||
fields.title = mountMultilingualField(document.getElementById("f-title-wrap"), {
|
||
type: "input",
|
||
value: e?.title || ""
|
||
});
|
||
fields.description = mountMultilingualField(document.getElementById("f-desc-wrap"), {
|
||
type: "textarea",
|
||
value: e?.description || "",
|
||
minHeight: "140px"
|
||
});
|
||
fields.press = mountMultilingualField(document.getElementById("f-press-wrap"), {
|
||
type: "textarea",
|
||
value: e?.press || "",
|
||
minHeight: "70px"
|
||
});
|
||
mountImageField(document.getElementById("f-hero-wrap"), {
|
||
id: "f-hero",
|
||
value: e?.hero || "",
|
||
placeholder: t("admin.paste_or_upload")
|
||
});
|
||
fillArtistOptions(e?.artistIds || []);
|
||
openModal();
|
||
};
|
||
|
||
window.del = async function (id) {
|
||
if (!confirm(t("admin.confirm.delete_exhibition"))) return;
|
||
try {
|
||
await Store.remove("exhibitions", id);
|
||
toast(t("admin.toast.exhibition_removed"));
|
||
render();
|
||
} catch (err) { alert(err.message || err); }
|
||
};
|
||
|
||
document.getElementById("new-btn").addEventListener("click", () => window.edit(null));
|
||
|
||
document.getElementById("form").addEventListener("submit", async (ev) => {
|
||
ev.preventDefault();
|
||
const id = document.getElementById("f-id").value;
|
||
const titleVal = fields.title.get();
|
||
const item = {
|
||
id: id || slugify(titleVal.en || titleVal.ko || titleVal.ja) || uid("exh"),
|
||
title: titleVal,
|
||
status: document.getElementById("f-status").value,
|
||
venue: document.getElementById("f-venue").value.trim(),
|
||
startDate: document.getElementById("f-start").value,
|
||
endDate: document.getElementById("f-end").value,
|
||
hero: document.getElementById("f-hero").value.trim(),
|
||
description: fields.description.get(),
|
||
press: fields.press.get(),
|
||
artistIds: [...document.getElementById("f-artists").selectedOptions].map((o) => o.value)
|
||
};
|
||
try {
|
||
await Store.upsert("exhibitions", item);
|
||
closeModal();
|
||
toast(t("admin.toast.saved"));
|
||
render();
|
||
} catch (err) { alert(err.message || err); }
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|