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

129
assets/app.js Normal file
View File

@ -0,0 +1,129 @@
// Shared helpers for public pages.
function fmtDate(iso) {
if (!iso) return "";
const d = new Date(iso + "T00:00:00");
return d.toLocaleDateString(getLocale(), {
month: "long",
day: "numeric",
year: "numeric"
});
}
function fmtRange(start, end) {
if (!start || !end) return "";
const locale = getLocale();
const s = new Date(start + "T00:00:00");
const e = new Date(end + "T00:00:00");
const sMonth = s.toLocaleDateString(locale, { month: "long" });
const eMonth = e.toLocaleDateString(locale, { month: "long" });
const sameYear = s.getFullYear() === e.getFullYear();
if (sMonth === eMonth && sameYear) {
return `${sMonth} ${s.getDate()} ${e.getDate()}, ${e.getFullYear()}`;
}
if (sameYear) {
return `${sMonth} ${s.getDate()} ${eMonth} ${e.getDate()}, ${e.getFullYear()}`;
}
return `${sMonth} ${s.getDate()}, ${s.getFullYear()} ${eMonth} ${e.getDate()}, ${e.getFullYear()}`;
}
function qs(key) {
return new URLSearchParams(location.search).get(key);
}
function mount(id, html) {
const el = document.getElementById(id);
if (el) el.innerHTML = html;
}
const 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 navHtml(active) {
const items = [
["index.html", t("nav.home"), "home"],
["artists.html", t("nav.artists"), "artists"],
["exhibitions.html", t("nav.exhibitions"), "exhibitions"],
["news.html", t("nav.news"), "news"],
["about.html", t("nav.about"), "about"],
["contact.html", t("nav.contact"), "contact"]
];
const s = Store.settings();
return `
<nav class="nav">
<div class="nav-inner">
<a href="index.html" class="brand">
${LOGO_SVG}
<span class="brand-word">${s.galleryName}</span>
</a>
<div class="nav-right">
<div class="nav-links">
${items
.map(
([href, label, key]) =>
`<a href="${href}" class="${
active === key ? "active" : ""
}">${label}</a>`
)
.join("")}
</div>
${langSwitcherHtml()}
</div>
</div>
</nav>
`;
}
function footerHtml() {
const s = Store.settings();
return `
<footer>
<div class="footer-inner">
<div>
<strong>${s.galleryName}</strong>
<div>${L(s.tagline)}</div>
<div style="margin-top:12px">${s.address}</div>
</div>
<div>
<strong>${t("footer.hours")}</strong>
<div>${L(s.hours)}</div>
</div>
<div>
<strong>${t("footer.contact")}</strong>
<a href="tel:${s.phone}">${s.phone}</a>
<a href="mailto:${s.email}">${s.email}</a>
</div>
<div>
<strong>${t("footer.follow")}</strong>
<a href="#">${t("footer.instagram")} ${s.instagram}</a>
<a href="news.html">${t("misc.newsletter")}</a>
</div>
</div>
<div class="footer-inner footer-admin">
<div>© ${new Date().getFullYear()} ${s.galleryName}. ${t("misc.rights_suffix")}</div>
<div></div>
<div></div>
<div style="text-align:right"><a href="admin/index.html">${t("misc.staff_login")}</a></div>
</div>
</footer>
`;
}
function renderChrome(active) {
mount("nav-slot", navHtml(active));
mount("footer-slot", footerHtml());
wireLangSwitcher();
// Update document title if page key maps to a known title
if (active) {
const titleKey = "title." + active;
const s = Store.settings();
document.title = `${t(titleKey)}${s.galleryName}`;
}
}