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

212
exhibition.html Normal file
View File

@ -0,0 +1,212 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Exhibition — Jimi Gallery</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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="assets/styles.css" />
</head>
<body>
<div id="nav-slot"></div>
<main id="content"></main>
<div id="footer-slot"></div>
<div id="slider" class="slider" role="dialog" aria-modal="true" aria-hidden="true"></div>
<script src="assets/i18n.js"></script>
<script src="assets/data.js"></script>
<script src="assets/app.js"></script>
<script>
(async () => {
try { await Store.load(); } catch (e) { return showBootError(e); }
renderChrome("exhibition");
const id = qs("id");
const e = Store.exhibition(id);
if (!e) {
mount(
"content",
`<section class="section"><h1>${t("misc.not_found")}</h1><p><a href="exhibitions.html">${t("cta.all_exhibitions")}</a></p></section>`
);
} else {
const artists = e.artistIds.map((id) => Store.artist(id)).filter(Boolean);
const artistNames = artists.map((a) => a.name).join(", ");
const works = artists.flatMap((a) =>
(a.works || []).map((w) => ({ ...w, artist: a.name }))
);
mount(
"content",
`
<section class="hero">
<img class="hero-image slider-trigger" data-slider-index="hero" src="${e.hero}" alt="${L(e.title)}" />
<div class="hero-meta">
<div>
<div class="eyebrow">${t("status." + e.status)}</div>
<h1>${artistNames}<br><em style="font-style:italic">${L(e.title)}</em></h1>
</div>
<div class="hero-sub">
<strong>${fmtRange(e.startDate, e.endDate)}</strong>
${e.venue}
</div>
</div>
</section>
<hr class="divider" />
<section class="section" style="display:grid;grid-template-columns:1fr 1fr;gap:80px">
<div>
<div class="eyebrow">${t("eyebrow.about_exhibition")}</div>
</div>
<div>
<p style="font-size:18px;line-height:1.6">${L(e.description)}</p>
${e.press ? `<p class="muted">${L(e.press)}</p>` : ""}
<p style="margin-top:32px">
${artists
.map(
(a) => `<a href="artist.html?id=${a.id}" style="text-decoration:underline">${t("cta.about_prefix")} ${a.name}</a>`
)
.join("<br>")}
</p>
</div>
</section>
${
works.length
? `<hr class="divider"/>
<section class="section">
<div class="eyebrow">${t("eyebrow.works_in_exhibition")}</div>
<div class="works-grid" style="margin-top:32px">
${works
.map(
(w, i) => `
<figure class="work">
<img class="slider-trigger" data-slider-index="${i}" src="${w.image}" alt="${L(w.title)}"/>
<figcaption>
<strong>${w.artist}</strong>
<em style="font-style:italic">${L(w.title)}</em>, ${w.year}<br>
${L(w.medium)}<br>${w.dimensions}
</figcaption>
</figure>
`
)
.join("")}
</div>
</section>`
: ""
}
`
);
// --- Slider / lightbox ---
const slides = works.map((w) => ({
image: w.image,
title: L(w.title),
artist: w.artist,
year: w.year,
medium: L(w.medium),
dimensions: w.dimensions
}));
const sliderEl = document.getElementById("slider");
let sliderIdx = 0;
function renderSlider() {
if (!slides.length) return;
const s = slides[sliderIdx];
sliderEl.innerHTML = `
<div class="slider-header">
<span class="slider-counter">${sliderIdx + 1} / ${slides.length}</span>
<button class="slider-close" aria-label="Close">×</button>
</div>
<div class="slider-stage">
<button class="slider-nav prev" aria-label="Previous" ${sliderIdx === 0 ? "disabled" : ""}></button>
<img src="${s.image}" alt="${s.title}" />
<button class="slider-nav next" aria-label="Next" ${sliderIdx === slides.length - 1 ? "disabled" : ""}></button>
</div>
<div class="slider-caption">
<strong>${s.title}</strong>
${s.artist} · ${s.year} · ${s.medium} · ${s.dimensions}
</div>
<div class="slider-strip">
${slides
.map(
(x, i) =>
`<button data-idx="${i}" class="${i === sliderIdx ? "active" : ""}"><img src="${x.image}" alt=""></button>`
)
.join("")}
</div>
`;
sliderEl.querySelector(".slider-close").addEventListener("click", closeSlider);
sliderEl.querySelector(".slider-nav.prev").addEventListener("click", () => goSlider(-1));
sliderEl.querySelector(".slider-nav.next").addEventListener("click", () => goSlider(1));
sliderEl.querySelectorAll(".slider-strip button").forEach((b) => {
b.addEventListener("click", () => jumpSlider(parseInt(b.dataset.idx, 10)));
});
// scroll active thumb into view
const activeThumb = sliderEl.querySelector(".slider-strip button.active");
if (activeThumb) activeThumb.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
}
function openSlider(idx) {
if (!slides.length) return;
sliderIdx = Math.max(0, Math.min(slides.length - 1, idx || 0));
sliderEl.classList.add("open");
sliderEl.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
const p = new URLSearchParams(location.search);
p.set("view", "slider");
history.replaceState(null, "", location.pathname + "?" + p.toString() + "#" + (sliderIdx + 1));
renderSlider();
}
function closeSlider() {
sliderEl.classList.remove("open");
sliderEl.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
const p = new URLSearchParams(location.search);
p.delete("view");
const qStr = p.toString();
history.replaceState(null, "", location.pathname + (qStr ? "?" + qStr : ""));
}
function goSlider(delta) {
const next = sliderIdx + delta;
if (next < 0 || next >= slides.length) return;
sliderIdx = next;
history.replaceState(null, "", location.pathname + location.search + "#" + (sliderIdx + 1));
renderSlider();
}
function jumpSlider(idx) {
if (idx < 0 || idx >= slides.length) return;
sliderIdx = idx;
history.replaceState(null, "", location.pathname + location.search + "#" + (sliderIdx + 1));
renderSlider();
}
document.querySelectorAll(".slider-trigger").forEach((el) => {
el.addEventListener("click", () => {
const raw = el.dataset.sliderIndex;
if (raw === "hero") openSlider(0);
else openSlider(parseInt(raw, 10) || 0);
});
});
document.addEventListener("keydown", (ev) => {
if (!sliderEl.classList.contains("open")) return;
if (ev.key === "Escape") closeSlider();
else if (ev.key === "ArrowLeft") goSlider(-1);
else if (ev.key === "ArrowRight") goSlider(1);
});
// Deep-link: ?view=slider#N
if (qs("view") === "slider" && slides.length) {
const hashIdx = parseInt(location.hash.replace("#", ""), 10);
openSlider(isFinite(hashIdx) && hashIdx > 0 ? hashIdx - 1 : 0);
}
}
})();
</script>
</body>
</html>