- 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
213 lines
8.5 KiB
HTML
213 lines
8.5 KiB
HTML
<!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>
|