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:
212
exhibition.html
Normal file
212
exhibition.html
Normal 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>
|
||||
Reference in New Issue
Block a user