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

175
admin/news.html Normal file
View File

@ -0,0 +1,175 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>News — 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-date"></label>
<input id="f-date" type="date" required />
</div>
<div class="full">
<label id="lbl-image"></label>
<div id="f-image-wrap"></div>
</div>
<div class="full">
<label id="lbl-excerpt"></label>
<div id="f-excerpt-wrap"></div>
</div>
<div class="full">
<label id="lbl-body"></label>
<div id="f-body-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("news");
document.getElementById("pg-title").textContent = t("admin.page.news");
document.getElementById("pg-sub").textContent = t("admin.page.news_sub");
document.getElementById("new-btn").textContent = t("admin.btn.new_post");
document.getElementById("lbl-title").textContent = t("admin.form.title");
document.getElementById("lbl-date").textContent = t("admin.form.date");
document.getElementById("lbl-image").textContent = t("admin.form.image");
document.getElementById("lbl-excerpt").textContent = t("admin.form.excerpt");
document.getElementById("lbl-body").textContent = t("admin.form.body");
document.getElementById("btn-cancel").textContent = t("admin.btn.cancel");
document.getElementById("btn-save").textContent = t("admin.btn.save");
let fields = {};
function render() {
const rows = [...Store.news()]
.sort((a, b) => b.date.localeCompare(a.date))
.map(
(n) => `
<tr>
<td><img src="${n.image}" alt=""></td>
<td><strong>${L(n.title)}</strong><br><span style="color:var(--muted);font-size:12px">${L(n.excerpt) || ""}</span></td>
<td>${fmtDateShort(n.date)}</td>
<td class="actions">
<button class="btn ghost btn-small" onclick="edit('${n.id}')">${t("admin.btn.edit")}</button>
<button class="btn danger btn-small" onclick="del('${n.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.date")}</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="4" style="text-align:center;padding:24px;color:var(--muted)">${t("admin.empty.news")}</td></tr>`}</tbody>
`;
}
render();
window.edit = function (id) {
const n = id ? Store.newsItem(id) : null;
document.getElementById("modal-title").textContent = n
? t("admin.modal.edit_post")
: t("admin.modal.new_post");
document.getElementById("f-id").value = n?.id || "";
document.getElementById("f-date").value = n?.date || new Date().toISOString().slice(0, 10);
fields.title = mountMultilingualField(document.getElementById("f-title-wrap"), {
type: "input",
value: n?.title || ""
});
fields.excerpt = mountMultilingualField(document.getElementById("f-excerpt-wrap"), {
type: "textarea",
value: n?.excerpt || "",
minHeight: "70px"
});
fields.body = mountMultilingualField(document.getElementById("f-body-wrap"), {
type: "textarea",
value: n?.body || "",
minHeight: "140px"
});
mountImageField(document.getElementById("f-image-wrap"), {
id: "f-image",
value: n?.image || "",
placeholder: t("admin.paste_or_upload")
});
openModal();
};
window.del = async function (id) {
if (!confirm(t("admin.confirm.delete_post"))) return;
try {
await Store.remove("news", id);
toast(t("admin.toast.post_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("news"),
title: titleVal,
date: document.getElementById("f-date").value,
image: document.getElementById("f-image").value.trim(),
excerpt: fields.excerpt.get(),
body: fields.body.get()
};
try {
await Store.upsert("news", item);
closeModal();
toast(t("admin.toast.saved"));
render();
} catch (err) { alert(err.message || err); }
});
})();
</script>
</body>
</html>