commit 098b55e3b0c89b2294d4537dcb6930526fd859d8 Author: yakenator Date: Sat Apr 25 12:47:36 2026 +0900 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..96d3d43 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/__pycache__ +**/.pytest_cache +**/*.pyc +.git +.gitignore +node_modules +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bc37d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.venv/ +venv/ +env/ + +# OS +.DS_Store +Thumbs.db + +# Editors +.vscode/ +.idea/ +*.swp +*.swo + +# Env / secrets +.env +.env.local +*.pem + +# Build artifacts +*.log +dist/ +build/ + +# Local data +mongo_data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cadae53 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# Jimi Gallery + +Contemporary art gallery website prototype — public site + admin panel. +Design language inspired by Jack Shainman, Crossing Art, and Derek Eller. + +## Stack + +- **Frontend**: Vanilla HTML/CSS/JS (no build step). i18n in EN/KO/JA. Exhibition slider. Per-field image upload with client-side downscale. +- **Backend**: FastAPI + Motor (async MongoDB driver) + PyJWT. +- **Database**: MongoDB 7. +- **Deploy**: Docker Compose — single-command bring-up. + +Brand mark: purple circle with serif `JM` monogram (inline SVG in `assets/app.js` +and `admin/admin.js`), paired with a `JIMI GALLERY` wordmark in Cinzel. + +## Run it + +```bash +cd /Users/jungwoochoi/Desktop/prototype/gallery +docker compose up --build +``` + +Then visit: + +- **Public site** → http://localhost:5891 +- **Admin** → http://localhost:5891/admin/ (password: `admin`) + +Ports (deliberately uncommon to avoid local collisions): + +| Service | Host port | Container port | +|---------|-----------|----------------| +| API + static | **5891** | 8000 | +| MongoDB | **47017** | 27017 | + +First boot auto-seeds the demo data. Subsequent boots persist to the `mongo_data` volume. + +### Environment + +Override via shell env or a `.env` at the repo root: + +``` +ADMIN_PASSWORD=your-password +JWT_SECRET=change-me-to-a-long-random-string +``` + +`backend/.env.example` lists all knobs. + +## Structure + +``` +gallery/ +├── index.html, artists.html, ... public site pages +├── assets/ +│ ├── data.js Store (API-backed, cached) + Auth (JWT) +│ ├── i18n.js EN/KO/JA dictionary, detection, switcher helpers +│ ├── app.js nav + footer + slider helpers +│ └── styles.css +├── admin/ +│ ├── index.html login +│ ├── dashboard.html, artists.html, exhibitions.html, news.html, settings.html +│ ├── admin.js sidebar, image field, multilingual field, export/import +│ └── admin.css +├── backend/ +│ ├── main.py FastAPI routes + static mount +│ ├── db.py Motor client +│ ├── auth.py JWT issue/verify +│ ├── seed.py demo seed +│ ├── requirements.txt +│ ├── Dockerfile +│ └── .env.example +└── docker-compose.yml +``` + +## API surface + +All writes require `Authorization: Bearer `; reads are public. + +``` +POST /api/auth/login → {token} +GET /api/auth/me → {ok: true} + +GET /api/settings +PUT /api/settings + +GET /api/artists +GET /api/artists/{id} +PUT /api/artists/{id} +DELETE /api/artists/{id} + +GET /api/exhibitions +GET /api/exhibitions/{id} +PUT /api/exhibitions/{id} +DELETE /api/exhibitions/{id} + +GET /api/news +GET /api/news/{id} +PUT /api/news/{id} +DELETE /api/news/{id} + +POST /api/admin/seed reset all collections to demo seed +GET /api/admin/export full DB snapshot +POST /api/admin/import replace full DB with uploaded snapshot +``` + +Interactive docs (Swagger UI) at http://localhost:5891/docs. + +## Frontend data access pattern + +```js +// On every page — inline script pattern: +(async () => { + await Store.load(); // fetches /api/settings + /api/artists + /api/exhibitions + /api/news, caches + renderChrome("home"); + // ... accessors below are sync reads from cache ... + Store.artists(); + Store.exhibition(id); +})(); + +// Admin mutations are async: +await Store.upsert("artists", artist); +await Store.remove("news", id); +await Store.updateSettings({...}); +await Store.reset(); +await Store.importAll(json); +await Store.exportAll(); +``` + +Auth: + +```js +await Auth.login(password); // posts to /api/auth/login, stores JWT in localStorage +Auth.isAuthed(); // sync — presence check; API returns 401 if expired +Auth.logout(); // clears token +``` + +## Dev (no Docker) + +If you want to iterate on the backend without rebuilding the image: + +```bash +# Mongo (container only — still uncommon port) +docker run -d -p 47017:27017 --name jimi-mongo mongo:7 + +# Backend +cd backend +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +MONGO_URL=mongodb://localhost:47017 STATIC_DIR=.. \ + uvicorn main:app --reload --port 5891 +``` + +Hot-reload on Python changes, frontend edits land on browser refresh. diff --git a/about.html b/about.html new file mode 100644 index 0000000..a12f9fe --- /dev/null +++ b/about.html @@ -0,0 +1,44 @@ + + + + + + About — Jimi Gallery + + + + + + + +
+
+
+

+
+
+
+
+
+ + + + + + + diff --git a/admin/admin.css b/admin/admin.css new file mode 100644 index 0000000..a943b23 --- /dev/null +++ b/admin/admin.css @@ -0,0 +1,662 @@ +:root { + --bg: #fafafa; + --panel: #ffffff; + --fg: #111111; + --muted: #6a6a6a; + --line: #e4e4e4; + --accent: #6b3d8f; + --accent-soft: #f3ecf7; + --accent-hover: #5a3079; + --danger: #b00020; + --sans: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif; + --display: "Cinzel", "Cormorant Garamond", "Times New Roman", serif; + --serif: "Cormorant Garamond", "Times New Roman", serif; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 14px; + line-height: 1.45; +} +a { + color: inherit; +} +img { + max-width: 100%; + display: block; +} +h1 { + font-size: 24px; + margin: 0 0 4px; + font-weight: 500; +} +h2 { + font-size: 18px; + margin: 0 0 16px; + font-weight: 500; +} +p { + margin: 0 0 12px; +} + +/* ---------- login ---------- */ +.login-lang-wrap { + position: absolute; + top: 18px; + right: 24px; +} +.login-wrap { + min-height: 100vh; + display: grid; + place-items: center; + background: var(--bg); +} +.login-card { + width: 360px; + background: var(--panel); + border: 1px solid var(--line); + padding: 40px 32px; +} +.login-card h1 { + text-align: center; + margin-bottom: 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; +} +.login-card h1 .brand-mark { + width: 56px; + height: 56px; +} +.login-card h1 .brand-word { + font-family: var(--display); + font-size: 18px; + letter-spacing: 0.14em; + text-transform: uppercase; + font-weight: 500; + line-height: 1; +} +.login-card h1 .brand-sub { + font-size: 11px; + color: var(--muted); + letter-spacing: 0.18em; + text-transform: uppercase; + font-weight: 400; + margin-top: 4px; +} +.login-card h1 .brand-word, +.login-card h1 .brand-sub { + display: block; +} +.login-card label { + font-size: 12px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.1em; + display: block; + margin-bottom: 6px; +} +.login-card input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--line); + font: inherit; + background: #fff; +} +.login-card .hint { + font-size: 12px; + color: var(--muted); + text-align: center; + margin-top: 16px; +} +.error { + color: var(--danger); + font-size: 13px; + margin-top: 12px; + min-height: 16px; +} + +/* ---------- layout ---------- */ +.app { + display: grid; + grid-template-columns: 240px 1fr; + min-height: 100vh; +} +.sidebar { + background: #fff; + border-right: 1px solid var(--line); + padding: 24px 0; + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; +} +.sidebar .brand-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px; + padding: 0 24px 20px; + border-bottom: 1px solid var(--line); + margin-bottom: 16px; +} +.sidebar .brand { + display: flex; + align-items: center; + gap: 10px; + color: var(--fg); + text-decoration: none; + flex: 1; + min-width: 0; +} +.sidebar .lang-controls { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 0 0 -2px; + flex-shrink: 0; +} +.sidebar .lang-select { + appearance: none; + -webkit-appearance: none; + background: #fff url("data:image/svg+xml;utf8,") no-repeat right 7px center / 8px 5px; + border: 1px solid var(--line); + padding: 2px 20px 2px 8px; + font: inherit; + font-size: 11px; + color: var(--muted); + cursor: pointer; + border-radius: 0; + letter-spacing: 0.04em; + line-height: 1.4; + flex-shrink: 0; +} +.sidebar .lang-reset { + background: transparent; + border: 0; + color: var(--muted); + cursor: pointer; + font-size: 13px; + padding: 0 3px; + line-height: 1; +} +.sidebar .lang-reset:hover { color: var(--accent); } +.sidebar .lang-select:hover { + color: var(--fg); + border-color: #bdbdbd; +} +.sidebar .lang-select:focus { + outline: 1px solid var(--accent); + outline-offset: -1px; + color: var(--fg); +} +.sidebar .brand:hover { + opacity: 0.88; +} +.sidebar .brand-mark { + width: 30px; + height: 30px; + flex-shrink: 0; +} +.sidebar .brand-text { + line-height: 1.1; + min-width: 0; +} +.sidebar .brand-word { + font-family: var(--display); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg); + font-weight: 500; + white-space: nowrap; +} +.sidebar .brand-sub { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.12em; + text-transform: uppercase; + margin-top: 3px; + white-space: nowrap; +} +.sidebar nav a { + display: block; + padding: 10px 24px; + color: var(--muted); + text-decoration: none; + font-size: 14px; +} +.sidebar nav a:hover { + color: var(--fg); + background: #f4f4f4; +} +.sidebar nav a.active { + color: var(--accent); + background: var(--accent-soft); + font-weight: 500; + border-left: 2px solid var(--accent); + padding-left: 22px; +} +.sidebar .sidebar-foot { + margin-top: auto; + padding: 16px 24px; + border-top: 1px solid var(--line); + font-size: 12px; + color: var(--muted); +} +.sidebar .sidebar-foot a { + display: block; + text-decoration: none; + margin-bottom: 6px; +} + +.content { + padding: 32px 40px 80px; + max-width: 1280px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} +.topbar .subtitle { + color: var(--muted); + font-size: 13px; +} + +/* ---------- buttons ---------- */ +.btn { + background: var(--accent); + color: #fff; + border: 0; + padding: 9px 16px; + font: inherit; + cursor: pointer; + font-size: 13px; +} +.btn:hover { + background: var(--accent-hover); +} +.btn.ghost { + background: transparent; + color: var(--fg); + border: 1px solid var(--line); +} +.btn.ghost:hover { + background: #f4f4f4; +} +.btn.danger { + background: transparent; + color: var(--danger); + border: 1px solid var(--line); +} +.btn.danger:hover { + background: #fdecef; +} +.btn-small { + font-size: 12px; + padding: 6px 10px; +} + +/* ---------- cards / stats ---------- */ +.stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 32px; +} +.stat { + background: #fff; + border: 1px solid var(--line); + padding: 20px 20px 24px; +} +.stat .k { + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 8px; +} +.stat .v { + font-size: 28px; + font-weight: 500; +} + +.panel { + background: #fff; + border: 1px solid var(--line); + padding: 20px; + margin-bottom: 24px; +} +.panel-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +/* ---------- table ---------- */ +.table { + width: 100%; + border-collapse: collapse; + background: #fff; + border: 1px solid var(--line); +} +.table th, +.table td { + text-align: left; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + font-size: 13px; + vertical-align: middle; +} +.table th { + background: #f7f7f7; + font-weight: 500; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 11px; +} +.table tr:last-child td { + border-bottom: 0; +} +.table td img { + width: 48px; + height: 48px; + object-fit: cover; + background: #eee; +} +.table .actions { + text-align: right; + white-space: nowrap; +} +.table .actions .btn { + margin-left: 6px; +} +.badge { + display: inline-block; + font-size: 11px; + padding: 3px 8px; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.badge-current { + background: #e6f4ea; + color: #137333; +} +.badge-upcoming { + background: #fff4e0; + color: #8c5a00; +} +.badge-past { + background: #f0f0f0; + color: #555; +} + +/* ---------- forms ---------- */ +.form { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px 24px; +} +.form .full { + grid-column: 1 / -1; +} +.form label { + display: block; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + margin-bottom: 6px; +} +.form input, +.form textarea, +.form select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--line); + background: #fff; + font: inherit; + font-size: 14px; +} +.form textarea { + min-height: 120px; + resize: vertical; + font-family: inherit; +} +.form .actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +/* ---------- modal ---------- */ +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: none; + align-items: flex-start; + justify-content: center; + padding: 48px 24px; + overflow-y: auto; + z-index: 100; +} +.modal.open { + display: flex; +} +.modal .body { + background: #fff; + width: 720px; + max-width: 100%; + border: 1px solid var(--line); + padding: 28px 32px; +} +.modal .body h2 { + display: flex; + justify-content: space-between; + align-items: center; +} +.modal .close { + background: none; + border: 0; + font-size: 22px; + cursor: pointer; + color: var(--muted); +} + +/* ---------- multilingual field ---------- */ +.ml-field { + display: flex; + flex-direction: column; + gap: 0; +} +.ml-field .ml-tabs { + display: flex; + border: 1px solid var(--line); + border-bottom: 0; + align-self: flex-start; +} +.ml-field .ml-tab { + background: #f7f7f7; + border: 0; + border-right: 1px solid var(--line); + padding: 5px 14px; + font: inherit; + font-size: 11px; + letter-spacing: 0.08em; + color: var(--muted); + cursor: pointer; +} +.ml-field .ml-tab:last-child { border-right: 0; } +.ml-field .ml-tab.active { + background: #fff; + color: var(--accent); + font-weight: 500; + position: relative; +} +.ml-field .ml-tab.active::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: #fff; +} +.ml-field .ml-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--line); + background: #fff; + font: inherit; + font-size: 14px; + resize: vertical; +} +.ml-field textarea.ml-input { + min-height: 120px; + font-family: inherit; +} + +/* ---------- image field ---------- */ +.image-field { + display: flex; + flex-direction: column; + gap: 10px; +} +.image-field-preview { + width: 100%; + height: 180px; + background: #f3f3f3; + border: 1px dashed #d4d4d4; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.image-field.compact .image-field-preview { + height: 120px; +} +.image-field-preview img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + display: block; +} +.image-field-empty { + font-size: 11px; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; +} +.image-field-controls { + display: flex; + gap: 8px; + align-items: stretch; +} +.image-field-url { + flex: 1; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--line); + background: #fff; + font: inherit; + font-size: 13px; +} +.image-field-upload { + display: inline-flex; + align-items: center; + cursor: pointer; + margin: 0; + white-space: nowrap; +} +.image-field-upload input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} + +/* ---------- works subform ---------- */ +.works-list { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 14px; +} +.work-row { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + background: #fafafa; +} +.work-row-meta { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr auto; + gap: 8px; + align-items: center; +} +.work-row-meta input { + padding: 8px 10px; + font-size: 13px; + border: 1px solid var(--line); + background: #fff; + font: inherit; +} + +/* ---------- toast ---------- */ +.toast { + position: fixed; + bottom: 24px; + right: 24px; + background: #111; + color: #fff; + padding: 12px 18px; + font-size: 13px; + z-index: 200; + opacity: 0; + transform: translateY(10px); + transition: all 0.2s; + pointer-events: none; +} +.toast.show { + opacity: 1; + transform: translateY(0); +} + +@media (max-width: 900px) { + .app { + grid-template-columns: 1fr; + } + .sidebar { + position: static; + height: auto; + } + .stats { + grid-template-columns: repeat(2, 1fr); + } + .form, + .work-row-meta { + grid-template-columns: 1fr; + } +} diff --git a/admin/admin.js b/admin/admin.js new file mode 100644 index 0000000..18a13f3 --- /dev/null +++ b/admin/admin.js @@ -0,0 +1,392 @@ +// Shared helpers for the admin panel. + +function requireAuth() { + if (!Auth.isAuthed()) { + location.href = "index.html"; + } +} + +function slugify(s) { + return (s || "") + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function uid(prefix) { + return prefix + "-" + Math.random().toString(36).slice(2, 8); +} + +const ADMIN_LOGO_SVG = ` + +`; + +function sidebarHtml(active) { + const items = [ + ["dashboard.html", t("admin.sidebar.dashboard"), "dashboard"], + ["artists.html", t("admin.sidebar.artists"), "artists"], + ["exhibitions.html", t("admin.sidebar.exhibitions"), "exhibitions"], + ["news.html", t("admin.sidebar.news"), "news"], + ["settings.html", t("admin.sidebar.settings"), "settings"] + ]; + const curLang = getLang(); + return ` + + `; +} + +function mountSidebar(active) { + const host = document.getElementById("sidebar-slot"); + if (host) host.outerHTML = sidebarHtml(active); + document.getElementById("sidebar-lang")?.addEventListener("change", (e) => { + setLang(e.target.value); + }); + document.getElementById("lang-reset")?.addEventListener("click", (e) => { + e.preventDefault(); + clearLangOverride(); + }); + document.getElementById("logout")?.addEventListener("click", (e) => { + e.preventDefault(); + Auth.logout(); + location.href = "index.html"; + }); + document.getElementById("reset-data")?.addEventListener("click", async (e) => { + e.preventDefault(); + if (!confirm(t("admin.confirm.reset"))) return; + try { + await Store.reset(); + toast(t("admin.toast.data_reset")); + setTimeout(() => location.reload(), 400); + } catch (err) { + alert(err.message || err); + } + }); + document.getElementById("export-data")?.addEventListener("click", (e) => { + e.preventDefault(); + exportData(); + }); + document.getElementById("import-data")?.addEventListener("click", (e) => { + e.preventDefault(); + document.getElementById("import-file")?.click(); + }); + document.getElementById("import-file")?.addEventListener("change", (e) => { + const f = e.target.files && e.target.files[0]; + if (f) importDataFromFile(f); + e.target.value = ""; + }); +} + +async function exportData() { + let data; + try { + data = await Store.exportAll(); + } catch (e) { + alert(e.message || e); + return; + } + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const ts = new Date().toISOString().slice(0, 10); + a.download = `jimi-gallery-${ts}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast(t("admin.toast.exported")); +} + +function importDataFromFile(file) { + const reader = new FileReader(); + reader.onerror = () => alert(t("admin.error.invalid_file")); + reader.onload = async () => { + let parsed; + try { + parsed = JSON.parse(reader.result); + } catch (e) { + alert(t("admin.error.invalid_file")); + return; + } + if ( + !parsed || + typeof parsed !== "object" || + !parsed.settings || + !Array.isArray(parsed.artists) || + !Array.isArray(parsed.exhibitions) || + !Array.isArray(parsed.news) + ) { + alert(t("admin.error.invalid_file")); + return; + } + if (!confirm(t("admin.confirm.import"))) return; + try { + await Store.importAll(parsed); + toast(t("admin.toast.imported")); + setTimeout(() => location.reload(), 400); + } catch (err) { + alert(err.message || err); + } + }; + reader.readAsText(file); +} + +let _toastTimer; +function toast(msg) { + let el = document.querySelector(".toast"); + if (!el) { + el = document.createElement("div"); + el.className = "toast"; + document.body.appendChild(el); + } + el.textContent = msg; + requestAnimationFrame(() => el.classList.add("show")); + clearTimeout(_toastTimer); + _toastTimer = setTimeout(() => el.classList.remove("show"), 1800); +} + +function openModal() { + document.getElementById("modal")?.classList.add("open"); +} +function closeModal() { + document.getElementById("modal")?.classList.remove("open"); +} + +function fmtDateShort(iso) { + if (!iso) return "—"; + return new Date(iso + "T00:00:00").toLocaleDateString( + typeof getLocale === "function" ? getLocale() : "en-US", + { month: "short", day: "numeric", year: "numeric" } + ); +} + +// Mount a multilingual text field with EN/KO/JA tabs. Returns a handle with +// .get() returning the { en, ko, ja } object. Pair this with form submit: +// const bio = mountMultilingualField(el, { value: a.bio }); +// // later: item.bio = bio.get(); +function mountMultilingualField(container, opts = {}) { + const { value = {}, type = "textarea", placeholder = "", minHeight = "" } = opts; + const initial = + typeof value === "string" + ? { en: value, ko: "", ja: "" } + : { en: (value && value.en) || "", ko: (value && value.ko) || "", ja: (value && value.ja) || "" }; + const state = { ...initial }; + const langs = SUPPORTED_LANGS; + let current = getLang(); + if (!langs.includes(current)) current = "en"; + + container.classList.add("ml-field"); + const inputEl = + type === "input" + ? `` + : ``; + container.innerHTML = ` +
+ ${langs + .map( + (l) => + `` + ) + .join("")} +
+ ${inputEl} + `; + + const input = container.querySelector(".ml-input"); + const tabs = container.querySelectorAll(".ml-tab"); + + function switchTo(lang) { + state[current] = input.value; + current = lang; + input.value = state[lang] || ""; + tabs.forEach((tb) => tb.classList.toggle("active", tb.dataset.lang === lang)); + } + + tabs.forEach((tb) => tb.addEventListener("click", () => switchTo(tb.dataset.lang))); + + // initial active tab + tabs.forEach((tb) => tb.classList.toggle("active", tb.dataset.lang === current)); + input.value = state[current] || ""; + + return { + get() { + state[current] = input.value; + return { en: state.en || "", ko: state.ko || "", ja: state.ja || "" }; + } + }; +} + +function escapeAttr(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(/"/g, """); +} + +// Read a File, downscale if large, return a data URL string. +async function fileToDataURL(file, maxDim = 1400, quality = 0.82) { + const originalDataUrl = await new Promise((resolve, reject) => { + const r = new FileReader(); + r.onerror = () => reject(r.error || new Error("read failed")); + r.onload = () => resolve(r.result); + r.readAsDataURL(file); + }); + if (file.type === "image/gif" || file.size < 300 * 1024) return originalDataUrl; + return new Promise((resolve) => { + const img = new Image(); + img.onerror = () => resolve(originalDataUrl); + img.onload = () => { + const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); + if (scale >= 1) return resolve(originalDataUrl); + const w = Math.round(img.width * scale); + const h = Math.round(img.height * scale); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + canvas.getContext("2d").drawImage(img, 0, 0, w, h); + try { + resolve(canvas.toDataURL("image/jpeg", quality)); + } catch (e) { + resolve(originalDataUrl); + } + }; + img.src = originalDataUrl; + }); +} + +// Mount an image field (preview + URL input + upload + clear) into `container`. +// Returns the URL input element. +function mountImageField(container, opts = {}) { + const { + value = "", + id = "", + dataK = "", + placeholder = "Paste image URL", + compact = false + } = opts; + + container.className = "image-field" + (compact ? " compact" : ""); + container.innerHTML = ` +
+ +
No image selected
+
+
+ + + +
+ `; + + const urlInput = container.querySelector(".image-field-url"); + const fileInput = container.querySelector('input[type="file"]'); + const previewImg = container.querySelector(".image-field-preview img"); + const emptyEl = container.querySelector(".image-field-empty"); + const clearBtn = container.querySelector(".image-field-clear"); + + function updatePreview() { + const v = urlInput.value.trim(); + if (v) { + previewImg.src = v; + previewImg.style.display = ""; + emptyEl.style.display = "none"; + } else { + previewImg.removeAttribute("src"); + previewImg.style.display = "none"; + emptyEl.style.display = ""; + } + } + + urlInput.addEventListener("input", updatePreview); + previewImg.addEventListener("error", () => { + previewImg.style.display = "none"; + emptyEl.textContent = "Preview unavailable"; + emptyEl.style.display = ""; + }); + + fileInput.addEventListener("change", async () => { + const file = fileInput.files && fileInput.files[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + alert("Please select an image file."); + fileInput.value = ""; + return; + } + if (file.size > 20 * 1024 * 1024) { + alert("Image too large — please choose a file under 20MB."); + fileInput.value = ""; + return; + } + try { + const dataUrl = await fileToDataURL(file); + urlInput.value = dataUrl; + emptyEl.textContent = "No image selected"; + updatePreview(); + } catch (err) { + alert("Could not read image: " + (err.message || err)); + } + fileInput.value = ""; + }); + + clearBtn.addEventListener("click", () => { + urlInput.value = ""; + emptyEl.textContent = "No image selected"; + updatePreview(); + }); + + updatePreview(); + return urlInput; +} diff --git a/admin/artists.html b/admin/artists.html new file mode 100644 index 0000000..dfef5c7 --- /dev/null +++ b/admin/artists.html @@ -0,0 +1,239 @@ + + + + + + Artists — Jimi Gallery Admin + + + + +
+ +
+
+
+

+
+
+ +
+ +
+
+
+
+
+ + + + + + + + + diff --git a/admin/dashboard.html b/admin/dashboard.html new file mode 100644 index 0000000..21d9ffa --- /dev/null +++ b/admin/dashboard.html @@ -0,0 +1,112 @@ + + + + + + Dashboard — Jimi Gallery Admin + + + + +
+ +
+
+
+

+
+
+ +
+ +
+ +
+
+

+ +
+
+
+ +
+
+

+ +
+
+
+
+
+ + + + + + diff --git a/admin/exhibitions.html b/admin/exhibitions.html new file mode 100644 index 0000000..f22644d --- /dev/null +++ b/admin/exhibitions.html @@ -0,0 +1,232 @@ + + + + + + Exhibitions — Jimi Gallery Admin + + + + +
+ +
+
+
+

+
+
+ +
+ +
+
+
+
+
+ + + + + + + + + diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..ad9e659 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,65 @@ + + + + + + Jimi Gallery Admin — Sign in + + + + + + + + + + + + diff --git a/admin/news.html b/admin/news.html new file mode 100644 index 0000000..b955e6d --- /dev/null +++ b/admin/news.html @@ -0,0 +1,175 @@ + + + + + + News — Jimi Gallery Admin + + + + +
+ +
+
+
+

+
+
+ +
+ +
+
+
+
+
+ + + + + + + + + diff --git a/admin/settings.html b/admin/settings.html new file mode 100644 index 0000000..83ee37f --- /dev/null +++ b/admin/settings.html @@ -0,0 +1,127 @@ + + + + + + Settings — Jimi Gallery Admin + + + + +
+ +
+
+
+

+
+
+
+ +
+
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ +
+
+
+ +
+
+
+
+
+ + + + + + + diff --git a/artist.html b/artist.html new file mode 100644 index 0000000..13f2017 --- /dev/null +++ b/artist.html @@ -0,0 +1,90 @@ + + + + + + Artist — Jimi Gallery + + + + + + + +
+ + + + + + + diff --git a/artists.html b/artists.html new file mode 100644 index 0000000..417e520 --- /dev/null +++ b/artists.html @@ -0,0 +1,53 @@ + + + + + + Artists — Jimi Gallery + + + + + + + +
+
+
+

+
+
+
+
+
+ + + + + + + diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..3477e45 --- /dev/null +++ b/assets/app.js @@ -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 = ` + +`; + +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 ` + + `; +} + +function footerHtml() { + const s = Store.settings(); + return ` + + `; +} + +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}`; + } +} diff --git a/assets/data.js b/assets/data.js new file mode 100644 index 0000000..128988d --- /dev/null +++ b/assets/data.js @@ -0,0 +1,199 @@ +// Gallery data store — backed by the FastAPI + MongoDB backend. +// +// Pattern: preload once (await Store.load()), then all accessors are sync +// reads from the in-memory cache. Mutators (upsert/remove/updateSettings/ +// reset/importAll) hit the API and invalidate the cache. +// +// Pages should wrap their inline script in: +// (async () => { +// await Store.load(); +// renderChrome(...); +// // ... rest of rendering +// })(); + +const API_BASE = ""; +const AUTH_KEY = "jimi_gallery_admin_token_v1"; + +// Legacy localStorage key used by the prototype — unused now but kept here +// as a reference in case a migration path is ever needed. +const LEGACY_STORE_KEY = "jimi_gallery_store_v2"; + +async function apiGet(path) { + const r = await fetch(API_BASE + path, { method: "GET" }); + if (!r.ok) throw new Error(`GET ${path} → ${r.status}`); + return r.json(); +} + +async function apiJson(path, method, body, authed) { + const headers = { "Content-Type": "application/json" }; + if (authed) { + const tok = localStorage.getItem(AUTH_KEY); + if (tok) headers["Authorization"] = "Bearer " + tok; + } + const r = await fetch(API_BASE + path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body) + }); + if (r.status === 401 && authed) { + localStorage.removeItem(AUTH_KEY); + // Bounce to login if we're in the admin area. + if (location.pathname.includes("/admin/")) { + location.href = "index.html"; + } + throw new Error("unauthorized"); + } + if (!r.ok) { + let detail = `${method} ${path} → ${r.status}`; + try { + const j = await r.json(); + if (j && j.detail) detail = String(j.detail); + } catch (e) {} + throw new Error(detail); + } + if (r.status === 204) return null; + return r.json(); +} + +const Store = { + _cache: null, + _loadPromise: null, + + async load() { + if (this._cache) return this._cache; + if (this._loadPromise) return this._loadPromise; + this._loadPromise = Promise.all([ + apiGet("/api/settings"), + apiGet("/api/artists"), + apiGet("/api/exhibitions"), + apiGet("/api/news") + ]) + .then(([settings, artists, exhibitions, news]) => { + this._cache = { settings, artists, exhibitions, news }; + return this._cache; + }) + .finally(() => { + this._loadPromise = null; + }); + return this._loadPromise; + }, + + async reload() { + this._cache = null; + return this.load(); + }, + + // --- sync accessors (assume cache is warm) --- + + settings() { + return (this._cache && this._cache.settings) || {}; + }, + artists() { + return (this._cache && this._cache.artists) || []; + }, + artist(id) { + return this.artists().find((a) => a.id === id); + }, + exhibitions() { + return (this._cache && this._cache.exhibitions) || []; + }, + exhibition(id) { + return this.exhibitions().find((e) => e.id === id); + }, + news() { + return (this._cache && this._cache.news) || []; + }, + newsItem(id) { + return this.news().find((n) => n.id === id); + }, + + // --- async mutators --- + + async upsert(collection, item) { + if (!item || !item.id) throw new Error("id required"); + await apiJson( + `/api/${collection}/${encodeURIComponent(item.id)}`, + "PUT", + item, + true + ); + await this.reload(); + }, + + async remove(collection, id) { + await apiJson( + `/api/${collection}/${encodeURIComponent(id)}`, + "DELETE", + undefined, + true + ); + await this.reload(); + }, + + async updateSettings(patch) { + await apiJson("/api/settings", "PUT", patch, true); + await this.reload(); + }, + + async reset() { + await apiJson("/api/admin/seed", "POST", {}, true); + await this.reload(); + }, + + async exportAll() { + return apiGet("/api/admin/export"); + }, + + async importAll(data) { + await apiJson("/api/admin/import", "POST", data, true); + await this.reload(); + } +}; + +const Auth = { + async login(password) { + try { + const data = await apiJson( + "/api/auth/login", + "POST", + { password }, + false + ); + if (data && data.token) { + localStorage.setItem(AUTH_KEY, data.token); + return true; + } + } catch (e) { + // fall through + } + return false; + }, + logout() { + localStorage.removeItem(AUTH_KEY); + }, + // Sync check — we trust the token's presence; the API will 401 and apiJson + // redirects to login when it's actually expired. + isAuthed() { + return !!localStorage.getItem(AUTH_KEY); + }, + token() { + return localStorage.getItem(AUTH_KEY); + } +}; + +function showBootError(e) { + const msg = (e && e.message) || String(e); + document.body.innerHTML = ` +
+

Backend unreachable

+

Make sure the API + Mongo stack is running:

+
docker compose up
+

Then reload this page.

+

${msg}

+
+ `; +} + +window.Store = Store; +window.Auth = Auth; +window.showBootError = showBootError; diff --git a/assets/i18n.js b/assets/i18n.js new file mode 100644 index 0000000..48df517 --- /dev/null +++ b/assets/i18n.js @@ -0,0 +1,684 @@ +// i18n — UI string dictionary + helpers for EN / KO / JA. +// Content fields (bio, description, etc.) are stored per-language in data.js +// as { en, ko, ja } objects; use L(v) to read them. + +const LANG_KEY = "jimi_lang_v1"; +const SUPPORTED_LANGS = ["en", "ko", "ja"]; +const LANG_LABELS = { en: "EN", ko: "한", ja: "日" }; +const LANG_NAMES = { en: "English", ko: "한국어", ja: "日本語" }; +const LANG_LOCALES = { en: "en-US", ko: "ko-KR", ja: "ja-JP" }; + +// Language detection, split in two so we can tell "auto" apart from "manual". +// detectFromBrowser() — walks navigator.languages, picks ko/ja/en, else en. +// detectLang() — explicit saved choice wins, otherwise browser. +function detectFromBrowser() { + const prefs = + navigator.languages && navigator.languages.length + ? navigator.languages + : [navigator.language || "en"]; + for (const pref of prefs) { + const lc = String(pref).toLowerCase(); + if (lc.startsWith("ko")) return "ko"; + if (lc.startsWith("ja")) return "ja"; + if (lc.startsWith("en")) return "en"; + } + return "en"; +} + +function detectLang() { + try { + const saved = localStorage.getItem(LANG_KEY); + if (saved && SUPPORTED_LANGS.includes(saved)) return saved; + } catch (e) {} + return detectFromBrowser(); +} + +function hasLangOverride() { + try { + const saved = localStorage.getItem(LANG_KEY); + return !!(saved && SUPPORTED_LANGS.includes(saved)); + } catch (e) { + return false; + } +} + +function clearLangOverride() { + try { + localStorage.removeItem(LANG_KEY); + } catch (e) {} + location.reload(); +} + +let CURRENT_LANG = detectLang(); + +try { + document.documentElement.lang = CURRENT_LANG; +} catch (e) {} + +const I18N = { + en: { + "nav.home": "Home", + "nav.artists": "Artists", + "nav.exhibitions": "Exhibitions", + "nav.news": "News", + "nav.about": "About", + "nav.contact": "Contact", + + "filter.all": "All", + "status.current": "Current", + "status.upcoming": "Upcoming", + "status.past": "Past", + + "title.home": "Contemporary Art, New York", + "title.artists": "Artists", + "title.exhibitions": "Exhibitions", + "title.news": "News", + "title.about": "About", + "title.contact": "Contact", + "title.artist": "Artist", + "title.exhibition": "Exhibition", + + "eyebrow.now_on_view": "Now on view", + "eyebrow.upcoming_recent": "Upcoming & recent", + "eyebrow.artists": "Artists", + "eyebrow.roster": "Gallery roster", + "eyebrow.program": "Program", + "eyebrow.announcements": "Announcements & press", + "eyebrow.visit": "Visit & get in touch", + "eyebrow.about_gallery": "About the gallery", + "eyebrow.about_exhibition": "About the exhibition", + "eyebrow.works_in_exhibition": "Works in the exhibition", + "eyebrow.selected_works": "Selected works", + "eyebrow.exhibitions": "Exhibitions", + + "cta.view_exhibition": "View exhibition →", + "cta.about_prefix": "About", + "cta.all_artists": "← All artists", + "cta.all_exhibitions": "← All exhibitions", + + "misc.not_found": "Not found", + "misc.newsletter": "Newsletter", + "misc.staff_login": "Staff login", + "misc.rights_suffix": "All rights reserved.", + + "footer.hours": "Hours", + "footer.contact": "Contact", + "footer.follow": "Follow", + "footer.instagram": "Instagram", + + "contact.gallery": "Gallery", + "contact.press_sales": "Press & sales", + + "admin.sidebar.dashboard": "Dashboard", + "admin.sidebar.artists": "Artists", + "admin.sidebar.exhibitions": "Exhibitions", + "admin.sidebar.news": "News", + "admin.sidebar.settings": "Settings", + "admin.sidebar.view_site": "↗ View public site", + "admin.sidebar.export": "⬇ Export data", + "admin.sidebar.import": "⬆ Import data", + "admin.sidebar.logout": "Log out", + "admin.sidebar.reset": "Load demo data", + "admin.sidebar.language": "Language", + "lang.auto_hint": "Back to auto-detect", + "admin.confirm.import": "This will replace all current data. Continue?", + "admin.toast.exported": "Data exported", + "admin.toast.imported": "Data imported", + "admin.error.invalid_file": "Invalid file — expected a Jimi Gallery JSON export.", + + "admin.login.title": "Admin", + "admin.login.password": "Password", + "admin.login.submit": "Sign in", + "admin.login.incorrect": "Incorrect password.", + "admin.login.hint": "Demo password:", + + "admin.page.dashboard": "Dashboard", + "admin.page.dashboard_sub": "Overview of gallery content", + "admin.page.artists": "Artists", + "admin.page.artists_sub": "Manage the gallery roster", + "admin.page.exhibitions": "Exhibitions", + "admin.page.exhibitions_sub": "Current, upcoming, and past", + "admin.page.news": "News", + "admin.page.news_sub": "Announcements and press", + "admin.page.settings": "Settings", + "admin.page.settings_sub": "Gallery information shown across the site", + + "admin.btn.new_artist": "+ New artist", + "admin.btn.new_exhibition": "+ New exhibition", + "admin.btn.new_post": "+ New post", + "admin.btn.edit": "Edit", + "admin.btn.delete": "Delete", + "admin.btn.view": "View", + "admin.btn.save": "Save", + "admin.btn.save_changes": "Save changes", + "admin.btn.cancel": "Cancel", + "admin.btn.upload": "Upload", + "admin.btn.clear": "Clear", + "admin.btn.add_work": "+ Add work", + "admin.btn.manage": "Manage", + + "admin.dash.current_upcoming": "Current & upcoming", + "admin.dash.recent_news": "Recent news", + "admin.dash.nothing_scheduled": "Nothing scheduled.", + + "admin.stat.artists": "Artists", + "admin.stat.exhibitions": "Exhibitions", + "admin.stat.current_upcoming": "Current / Upcoming", + "admin.stat.news": "News items", + + "admin.table.name": "Name", + "admin.table.born": "Born", + "admin.table.works": "Works", + "admin.table.title": "Title", + "admin.table.artists": "Artists", + "admin.table.dates": "Dates", + "admin.table.status": "Status", + "admin.table.date": "Date", + + "admin.empty.artists": "No artists yet.", + "admin.empty.exhibitions": "No exhibitions yet.", + "admin.empty.news": "No news yet.", + + "admin.modal.edit_artist": "Edit artist", + "admin.modal.new_artist": "New artist", + "admin.modal.edit_exhibition": "Edit exhibition", + "admin.modal.new_exhibition": "New exhibition", + "admin.modal.edit_post": "Edit post", + "admin.modal.new_post": "New post", + + "admin.confirm.delete_artist": "Delete this artist? Works will be removed too.", + "admin.confirm.delete_exhibition": "Delete this exhibition?", + "admin.confirm.delete_post": "Delete this post?", + "admin.confirm.reset": "Replace current data with the demo set? This cannot be undone.", + + "admin.toast.saved": "Saved", + "admin.toast.artist_removed": "Artist removed", + "admin.toast.exhibition_removed": "Exhibition removed", + "admin.toast.post_removed": "Post removed", + "admin.toast.settings_saved": "Settings saved", + "admin.toast.data_reset": "Demo data loaded", + + "admin.form.name": "Name", + "admin.form.born": "Born", + "admin.form.lives": "Lives & works", + "admin.form.portrait": "Portrait image", + "admin.form.biography": "Biography", + "admin.form.selected_works": "Selected works", + "admin.form.title": "Title", + "admin.form.status": "Status", + "admin.form.venue": "Venue", + "admin.form.start_date": "Start date", + "admin.form.end_date": "End date", + "admin.form.hero_image": "Hero image", + "admin.form.artists": "Artists", + "admin.form.artists_hint": "(hold ⌘/Ctrl to select multiple)", + "admin.form.description": "Description", + "admin.form.press_note": "Press note (optional)", + "admin.form.image": "Image", + "admin.form.date": "Date", + "admin.form.excerpt": "Excerpt", + "admin.form.body": "Body", + "admin.form.gallery_name": "Gallery name", + "admin.form.tagline": "Tagline", + "admin.form.address": "Address", + "admin.form.phone": "Phone", + "admin.form.email": "Email", + "admin.form.hours": "Hours", + "admin.form.instagram": "Instagram handle", + "admin.form.about": "About copy", + "admin.form.year": "Year", + "admin.form.medium": "Medium", + "admin.form.dimensions": "Dimensions", + + "admin.works_count": "{n} works", + "admin.paste_or_upload": "Paste image URL or upload from your computer", + "admin.no_image": "No image selected", + "admin.preview_unavailable": "Preview unavailable", + "admin.ml_hint": "Each language tab is saved independently." + }, + + ko: { + "nav.home": "홈", + "nav.artists": "작가", + "nav.exhibitions": "전시", + "nav.news": "소식", + "nav.about": "소개", + "nav.contact": "연락", + + "filter.all": "전체", + "status.current": "진행 중", + "status.upcoming": "예정", + "status.past": "지난", + + "title.home": "현대미술, 뉴욕", + "title.artists": "작가", + "title.exhibitions": "전시", + "title.news": "소식", + "title.about": "소개", + "title.contact": "연락", + "title.artist": "작가", + "title.exhibition": "전시", + + "eyebrow.now_on_view": "현재 전시", + "eyebrow.upcoming_recent": "예정 · 최근 전시", + "eyebrow.artists": "작가", + "eyebrow.roster": "갤러리 로스터", + "eyebrow.program": "프로그램", + "eyebrow.announcements": "공지 · 보도", + "eyebrow.visit": "방문 · 문의", + "eyebrow.about_gallery": "갤러리 소개", + "eyebrow.about_exhibition": "전시 소개", + "eyebrow.works_in_exhibition": "출품작", + "eyebrow.selected_works": "주요 작품", + "eyebrow.exhibitions": "전시", + + "cta.view_exhibition": "전시 보기 →", + "cta.about_prefix": "작가 소개 —", + "cta.all_artists": "← 작가 목록", + "cta.all_exhibitions": "← 전시 목록", + + "misc.not_found": "찾을 수 없음", + "misc.newsletter": "뉴스레터", + "misc.staff_login": "직원 로그인", + "misc.rights_suffix": "모든 권리 보유.", + + "footer.hours": "운영 시간", + "footer.contact": "연락처", + "footer.follow": "소셜", + "footer.instagram": "인스타그램", + + "contact.gallery": "갤러리", + "contact.press_sales": "보도 · 판매", + + "admin.sidebar.dashboard": "대시보드", + "admin.sidebar.artists": "작가", + "admin.sidebar.exhibitions": "전시", + "admin.sidebar.news": "소식", + "admin.sidebar.settings": "설정", + "admin.sidebar.view_site": "↗ 공개 사이트 보기", + "admin.sidebar.export": "⬇ 데이터 내보내기", + "admin.sidebar.import": "⬆ 데이터 가져오기", + "admin.sidebar.logout": "로그아웃", + "admin.sidebar.reset": "데모 데이터 설정하기", + "admin.sidebar.language": "언어", + "lang.auto_hint": "자동 감지로 돌아가기", + "admin.confirm.import": "현재 데이터 전체가 가져온 파일로 교체됩니다. 계속하시겠습니까?", + "admin.toast.exported": "데이터를 내보냈습니다", + "admin.toast.imported": "데이터를 가져왔습니다", + "admin.error.invalid_file": "파일 형식이 올바르지 않습니다 — 지미 갤러리 JSON 내보내기 파일이어야 합니다.", + + "admin.login.title": "관리자", + "admin.login.password": "비밀번호", + "admin.login.submit": "로그인", + "admin.login.incorrect": "비밀번호가 올바르지 않습니다.", + "admin.login.hint": "데모 비밀번호:", + + "admin.page.dashboard": "대시보드", + "admin.page.dashboard_sub": "갤러리 콘텐츠 개요", + "admin.page.artists": "작가", + "admin.page.artists_sub": "갤러리 로스터 관리", + "admin.page.exhibitions": "전시", + "admin.page.exhibitions_sub": "진행 중 · 예정 · 지난 전시", + "admin.page.news": "소식", + "admin.page.news_sub": "공지와 보도", + "admin.page.settings": "설정", + "admin.page.settings_sub": "사이트 전반에 표시되는 갤러리 정보", + + "admin.btn.new_artist": "+ 새 작가", + "admin.btn.new_exhibition": "+ 새 전시", + "admin.btn.new_post": "+ 새 글", + "admin.btn.edit": "편집", + "admin.btn.delete": "삭제", + "admin.btn.view": "보기", + "admin.btn.save": "저장", + "admin.btn.save_changes": "변경 저장", + "admin.btn.cancel": "취소", + "admin.btn.upload": "업로드", + "admin.btn.clear": "지우기", + "admin.btn.add_work": "+ 작품 추가", + "admin.btn.manage": "관리", + + "admin.dash.current_upcoming": "진행 중 · 예정", + "admin.dash.recent_news": "최근 소식", + "admin.dash.nothing_scheduled": "예정된 항목이 없습니다.", + + "admin.stat.artists": "작가", + "admin.stat.exhibitions": "전시", + "admin.stat.current_upcoming": "진행 중 / 예정", + "admin.stat.news": "소식", + + "admin.table.name": "이름", + "admin.table.born": "출생", + "admin.table.works": "작품", + "admin.table.title": "제목", + "admin.table.artists": "작가", + "admin.table.dates": "기간", + "admin.table.status": "상태", + "admin.table.date": "일자", + + "admin.empty.artists": "등록된 작가가 없습니다.", + "admin.empty.exhibitions": "등록된 전시가 없습니다.", + "admin.empty.news": "등록된 소식이 없습니다.", + + "admin.modal.edit_artist": "작가 편집", + "admin.modal.new_artist": "새 작가", + "admin.modal.edit_exhibition": "전시 편집", + "admin.modal.new_exhibition": "새 전시", + "admin.modal.edit_post": "글 편집", + "admin.modal.new_post": "새 글", + + "admin.confirm.delete_artist": "이 작가를 삭제하시겠습니까? 작품도 함께 제거됩니다.", + "admin.confirm.delete_exhibition": "이 전시를 삭제하시겠습니까?", + "admin.confirm.delete_post": "이 글을 삭제하시겠습니까?", + "admin.confirm.reset": "현재 데이터를 데모 데이터로 설정하시겠습니까? 되돌릴 수 없습니다.", + + "admin.toast.saved": "저장됨", + "admin.toast.artist_removed": "작가가 삭제되었습니다", + "admin.toast.exhibition_removed": "전시가 삭제되었습니다", + "admin.toast.post_removed": "글이 삭제되었습니다", + "admin.toast.settings_saved": "설정이 저장되었습니다", + "admin.toast.data_reset": "데모 데이터가 설정되었습니다", + + "admin.form.name": "이름", + "admin.form.born": "출생", + "admin.form.lives": "거주 · 작업", + "admin.form.portrait": "초상 이미지", + "admin.form.biography": "작가 소개", + "admin.form.selected_works": "주요 작품", + "admin.form.title": "제목", + "admin.form.status": "상태", + "admin.form.venue": "장소", + "admin.form.start_date": "시작일", + "admin.form.end_date": "종료일", + "admin.form.hero_image": "대표 이미지", + "admin.form.artists": "작가", + "admin.form.artists_hint": "(⌘/Ctrl 키로 여러 명 선택)", + "admin.form.description": "소개", + "admin.form.press_note": "보도 문구 (선택)", + "admin.form.image": "이미지", + "admin.form.date": "일자", + "admin.form.excerpt": "요약", + "admin.form.body": "본문", + "admin.form.gallery_name": "갤러리명", + "admin.form.tagline": "태그라인", + "admin.form.address": "주소", + "admin.form.phone": "전화", + "admin.form.email": "이메일", + "admin.form.hours": "운영 시간", + "admin.form.instagram": "인스타그램 핸들", + "admin.form.about": "소개 본문", + "admin.form.year": "연도", + "admin.form.medium": "매체", + "admin.form.dimensions": "크기", + + "admin.works_count": "작품 {n}점", + "admin.paste_or_upload": "이미지 URL을 붙여넣거나 파일을 업로드하세요", + "admin.no_image": "이미지 없음", + "admin.preview_unavailable": "미리보기 불가", + "admin.ml_hint": "언어 탭별로 독립 저장됩니다." + }, + + ja: { + "nav.home": "ホーム", + "nav.artists": "アーティスト", + "nav.exhibitions": "展覧会", + "nav.news": "ニュース", + "nav.about": "ギャラリー", + "nav.contact": "お問い合わせ", + + "filter.all": "すべて", + "status.current": "開催中", + "status.upcoming": "予定", + "status.past": "過去", + + "title.home": "コンテンポラリーアート、ニューヨーク", + "title.artists": "アーティスト", + "title.exhibitions": "展覧会", + "title.news": "ニュース", + "title.about": "ギャラリー", + "title.contact": "お問い合わせ", + "title.artist": "アーティスト", + "title.exhibition": "展覧会", + + "eyebrow.now_on_view": "開催中", + "eyebrow.upcoming_recent": "予定・最近の展覧会", + "eyebrow.artists": "アーティスト", + "eyebrow.roster": "ギャラリー所属作家", + "eyebrow.program": "プログラム", + "eyebrow.announcements": "お知らせ・プレス", + "eyebrow.visit": "ご来廊・お問い合わせ", + "eyebrow.about_gallery": "ギャラリー概要", + "eyebrow.about_exhibition": "展覧会について", + "eyebrow.works_in_exhibition": "出品作品", + "eyebrow.selected_works": "主要作品", + "eyebrow.exhibitions": "展覧会", + + "cta.view_exhibition": "展覧会を見る →", + "cta.about_prefix": "作家について —", + "cta.all_artists": "← アーティスト一覧", + "cta.all_exhibitions": "← 展覧会一覧", + + "misc.not_found": "見つかりません", + "misc.newsletter": "ニュースレター", + "misc.staff_login": "スタッフログイン", + "misc.rights_suffix": "All rights reserved.", + + "footer.hours": "営業時間", + "footer.contact": "お問い合わせ", + "footer.follow": "フォロー", + "footer.instagram": "Instagram", + + "contact.gallery": "ギャラリー", + "contact.press_sales": "プレス・販売", + + "admin.sidebar.dashboard": "ダッシュボード", + "admin.sidebar.artists": "アーティスト", + "admin.sidebar.exhibitions": "展覧会", + "admin.sidebar.news": "ニュース", + "admin.sidebar.settings": "設定", + "admin.sidebar.view_site": "↗ 公開サイトを見る", + "admin.sidebar.export": "⬇ データをエクスポート", + "admin.sidebar.import": "⬆ データをインポート", + "admin.sidebar.logout": "ログアウト", + "admin.sidebar.reset": "デモデータを読み込む", + "admin.sidebar.language": "言語", + "lang.auto_hint": "自動検出に戻す", + "admin.confirm.import": "現在のデータがすべて置き換えられます。続行しますか?", + "admin.toast.exported": "データをエクスポートしました", + "admin.toast.imported": "データをインポートしました", + "admin.error.invalid_file": "ファイル形式が正しくありません — ジミ・ギャラリーのJSONエクスポートが必要です。", + + "admin.login.title": "管理画面", + "admin.login.password": "パスワード", + "admin.login.submit": "サインイン", + "admin.login.incorrect": "パスワードが正しくありません。", + "admin.login.hint": "デモ用パスワード:", + + "admin.page.dashboard": "ダッシュボード", + "admin.page.dashboard_sub": "ギャラリーコンテンツの概要", + "admin.page.artists": "アーティスト", + "admin.page.artists_sub": "所属作家の管理", + "admin.page.exhibitions": "展覧会", + "admin.page.exhibitions_sub": "開催中・予定・過去の展覧会", + "admin.page.news": "ニュース", + "admin.page.news_sub": "お知らせとプレス", + "admin.page.settings": "設定", + "admin.page.settings_sub": "サイト全体に表示されるギャラリー情報", + + "admin.btn.new_artist": "+ 新規アーティスト", + "admin.btn.new_exhibition": "+ 新規展覧会", + "admin.btn.new_post": "+ 新規投稿", + "admin.btn.edit": "編集", + "admin.btn.delete": "削除", + "admin.btn.view": "表示", + "admin.btn.save": "保存", + "admin.btn.save_changes": "変更を保存", + "admin.btn.cancel": "キャンセル", + "admin.btn.upload": "アップロード", + "admin.btn.clear": "クリア", + "admin.btn.add_work": "+ 作品を追加", + "admin.btn.manage": "管理", + + "admin.dash.current_upcoming": "開催中・予定", + "admin.dash.recent_news": "最新ニュース", + "admin.dash.nothing_scheduled": "予定された項目はありません。", + + "admin.stat.artists": "アーティスト", + "admin.stat.exhibitions": "展覧会", + "admin.stat.current_upcoming": "開催中 / 予定", + "admin.stat.news": "ニュース", + + "admin.table.name": "名前", + "admin.table.born": "生年", + "admin.table.works": "作品", + "admin.table.title": "タイトル", + "admin.table.artists": "アーティスト", + "admin.table.dates": "会期", + "admin.table.status": "状態", + "admin.table.date": "日付", + + "admin.empty.artists": "登録済みのアーティストはありません。", + "admin.empty.exhibitions": "登録済みの展覧会はありません。", + "admin.empty.news": "登録済みのニュースはありません。", + + "admin.modal.edit_artist": "アーティストを編集", + "admin.modal.new_artist": "新規アーティスト", + "admin.modal.edit_exhibition": "展覧会を編集", + "admin.modal.new_exhibition": "新規展覧会", + "admin.modal.edit_post": "投稿を編集", + "admin.modal.new_post": "新規投稿", + + "admin.confirm.delete_artist": "このアーティストを削除しますか?作品も併せて削除されます。", + "admin.confirm.delete_exhibition": "この展覧会を削除しますか?", + "admin.confirm.delete_post": "この投稿を削除しますか?", + "admin.confirm.reset": "現在のデータをデモデータに置き換えますか?元に戻せません。", + + "admin.toast.saved": "保存しました", + "admin.toast.artist_removed": "アーティストを削除しました", + "admin.toast.exhibition_removed": "展覧会を削除しました", + "admin.toast.post_removed": "投稿を削除しました", + "admin.toast.settings_saved": "設定を保存しました", + "admin.toast.data_reset": "デモデータを読み込みました", + + "admin.form.name": "名前", + "admin.form.born": "生年", + "admin.form.lives": "拠点・制作地", + "admin.form.portrait": "ポートレイト画像", + "admin.form.biography": "作家紹介", + "admin.form.selected_works": "主要作品", + "admin.form.title": "タイトル", + "admin.form.status": "状態", + "admin.form.venue": "会場", + "admin.form.start_date": "開始日", + "admin.form.end_date": "終了日", + "admin.form.hero_image": "メインビジュアル", + "admin.form.artists": "アーティスト", + "admin.form.artists_hint": "(⌘/Ctrlで複数選択)", + "admin.form.description": "解説", + "admin.form.press_note": "プレスノート (任意)", + "admin.form.image": "画像", + "admin.form.date": "日付", + "admin.form.excerpt": "要約", + "admin.form.body": "本文", + "admin.form.gallery_name": "ギャラリー名", + "admin.form.tagline": "タグライン", + "admin.form.address": "住所", + "admin.form.phone": "電話", + "admin.form.email": "メール", + "admin.form.hours": "営業時間", + "admin.form.instagram": "Instagramハンドル", + "admin.form.about": "ギャラリー紹介", + "admin.form.year": "制作年", + "admin.form.medium": "技法", + "admin.form.dimensions": "サイズ", + + "admin.works_count": "作品 {n}点", + "admin.paste_or_upload": "画像URLを貼り付けるか、ファイルをアップロード", + "admin.no_image": "画像が選択されていません", + "admin.preview_unavailable": "プレビュー表示不可", + "admin.ml_hint": "言語タブごとに個別に保存されます。" + } +}; + +function t(key, vars) { + const dict = I18N[CURRENT_LANG] || I18N.en; + let v = dict[key]; + if (v == null) v = I18N.en[key]; + if (v == null) return key; + if (vars) { + for (const k in vars) v = v.replace("{" + k + "}", vars[k]); + } + return v; +} + +// Localized content getter. Accepts either: +// - a plain string (returned as-is, for legacy/single-lang data) +// - an object { en, ko, ja } (returns CURRENT_LANG value, falling back) +function L(v) { + if (v == null) return ""; + if (typeof v === "string") return v; + if (typeof v !== "object") return String(v); + return v[CURRENT_LANG] || v.en || v.ko || v.ja || Object.values(v)[0] || ""; +} + +function setLang(lang) { + if (!SUPPORTED_LANGS.includes(lang) || lang === CURRENT_LANG) return; + try { + localStorage.setItem(LANG_KEY, lang); + } catch (e) {} + location.reload(); +} + +function getLang() { + return CURRENT_LANG; +} + +function getLocale() { + return LANG_LOCALES[CURRENT_LANG] || "en-US"; +} + +function langSwitcherHtml(variant) { + const cls = variant === "sidebar" ? "lang-switcher sidebar" : "lang-switcher"; + const override = hasLangOverride(); + return ` +
+
+ ${SUPPORTED_LANGS.map( + (l) => + `` + ).join("")} +
+ ${ + override + ? `` + : "" + } +
+ `; +} + +function wireLangSwitcher(root) { + const r = root || document; + r.querySelectorAll(".lang-btn[data-lang]").forEach((b) => { + b.addEventListener("click", () => setLang(b.dataset.lang)); + }); + r.querySelectorAll(".lang-reset").forEach((b) => { + b.addEventListener("click", () => clearLangOverride()); + }); +} + +window.t = t; +window.L = L; +window.setLang = setLang; +window.getLang = getLang; +window.getLocale = getLocale; +window.detectFromBrowser = detectFromBrowser; +window.hasLangOverride = hasLangOverride; +window.clearLangOverride = clearLangOverride; +window.SUPPORTED_LANGS = SUPPORTED_LANGS; +window.LANG_LABELS = LANG_LABELS; +window.LANG_NAMES = LANG_NAMES; +window.langSwitcherHtml = langSwitcherHtml; +window.wireLangSwitcher = wireLangSwitcher; diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..6475c19 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,9 @@ + + + Jimi Gallery + + + J + M + + diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..f520dcc --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,672 @@ +:root { + --bg: #ffffff; + --fg: #111111; + --muted: #6a6a6a; + --line: #e6e6e6; + --hover: #000000; + --accent: #6b3d8f; + --accent-soft: #f3ecf7; + --max: 1440px; + --pad: 40px; + --sans: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif; + --serif: "Cormorant Garamond", "Times New Roman", Times, serif; + --display: "Cinzel", "Cormorant Garamond", "Times New Roman", serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + color: var(--muted); +} + +img { + display: block; + max-width: 100%; + height: auto; +} + +h1, +h2, +h3, +h4 { + font-weight: 400; + margin: 0; + letter-spacing: -0.01em; +} + +h1 { + font-family: var(--serif); + font-size: 64px; + line-height: 1.05; + letter-spacing: -0.02em; +} + +h2 { + font-family: var(--serif); + font-size: 40px; + line-height: 1.1; +} + +h3 { + font-size: 18px; + font-weight: 500; +} + +p { + margin: 0 0 1em; +} + +hr { + border: 0; + border-top: 1px solid var(--line); + margin: 0; +} + +/* ---------- nav ---------- */ + +.nav { + position: sticky; + top: 0; + z-index: 50; + background: var(--bg); + border-bottom: 1px solid var(--line); +} + +.nav-inner { + max-width: var(--max); + margin: 0 auto; + padding: 18px var(--pad); + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + color: var(--fg); +} + +.brand:hover { + color: var(--fg); + opacity: 0.85; +} + +.brand-mark { + width: 34px; + height: 34px; + flex-shrink: 0; +} + +.brand-word { + font-family: var(--display); + font-size: 17px; + letter-spacing: 0.12em; + font-weight: 500; + text-transform: uppercase; + line-height: 1; + color: var(--fg); +} + +.nav-right { + display: flex; + align-items: center; + gap: 28px; +} + +.nav-links { + display: flex; + gap: 28px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.lang-controls { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.lang-reset { + background: transparent; + border: 0; + color: var(--muted); + cursor: pointer; + font-size: 14px; + padding: 2px 4px; + line-height: 1; +} +.lang-reset:hover { color: var(--fg); } + +.lang-switcher { + display: inline-flex; + gap: 0; + border: 1px solid var(--line); + font-size: 11px; + letter-spacing: 0.05em; +} +.lang-switcher .lang-btn { + background: transparent; + border: 0; + padding: 4px 8px; + font: inherit; + font-size: 11px; + color: var(--muted); + cursor: pointer; + border-right: 1px solid var(--line); + text-transform: none; +} +.lang-switcher .lang-btn:last-child { border-right: 0; } +.lang-switcher .lang-btn:hover { color: var(--fg); } +.lang-switcher .lang-btn.active { + background: var(--fg); + color: #fff; +} + +.nav-links a.active { + color: var(--accent); + border-bottom: 1px solid var(--accent); + padding-bottom: 2px; +} + +/* ---------- layout ---------- */ + +main { + max-width: var(--max); + margin: 0 auto; + padding: 0 var(--pad); +} + +.section { + padding: 80px 0; +} + +.section-tight { + padding: 40px 0; +} + +.eyebrow { + font-size: 12px; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 16px; +} + +.muted { + color: var(--muted); +} + +.divider { + border-top: 1px solid var(--line); + margin: 0; +} + +/* ---------- hero ---------- */ + +.hero { + padding: 48px 0 96px; +} + +.hero-image { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + margin-bottom: 32px; + background: #f2f2f2; +} + +.hero-meta { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 40px; + align-items: end; +} + +.hero-meta h1 { + max-width: 900px; +} + +.hero-sub { + font-size: 14px; + color: var(--muted); + line-height: 1.6; +} + +.hero-sub strong { + color: var(--fg); + font-weight: 500; + display: block; +} + +/* ---------- grids ---------- */ + +.grid { + display: grid; + gap: 48px 32px; +} + +.grid-3 { + grid-template-columns: repeat(3, 1fr); +} + +.grid-2 { + grid-template-columns: repeat(2, 1fr); +} + +.grid-4 { + grid-template-columns: repeat(4, 1fr); +} + +.card-image { + width: 100%; + aspect-ratio: 4 / 5; + object-fit: cover; + background: #f2f2f2; + margin-bottom: 14px; +} + +.card-image.wide { + aspect-ratio: 3 / 2; +} + +.card-title { + font-size: 16px; + font-weight: 500; + margin-bottom: 4px; +} + +.card-sub { + font-size: 13px; + color: var(--muted); +} + +/* ---------- artist / exhibition detail ---------- */ + +.detail { + padding: 64px 0; +} + +.detail-header { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 60px; + margin-bottom: 80px; + align-items: start; +} + +.detail-header img { + aspect-ratio: 4 / 5; + object-fit: cover; + width: 100%; +} + +.detail-text .meta { + margin-bottom: 32px; + font-size: 13px; + color: var(--muted); +} + +.detail-text .meta span + span::before { + content: "·"; + margin: 0 10px; +} + +.detail-text p { + font-size: 16px; + max-width: 60ch; +} + +.works-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 80px 40px; +} + +.work figcaption { + margin-top: 14px; + font-size: 13px; + color: var(--muted); +} + +.work figcaption strong { + display: block; + color: var(--fg); + font-weight: 500; + font-size: 14px; + margin-bottom: 2px; +} + +.work img { + width: 100%; + background: #f2f2f2; +} + +/* ---------- filters ---------- */ + +.filters { + display: flex; + gap: 24px; + margin-bottom: 40px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.filters button { + background: none; + border: 0; + padding: 0; + font: inherit; + color: var(--muted); + cursor: pointer; + letter-spacing: inherit; + text-transform: inherit; +} + +.filters button.active { + color: var(--accent); + border-bottom: 1px solid var(--accent); + padding-bottom: 2px; +} + +.filters button:hover { + color: var(--fg); +} + +/* ---------- footer ---------- */ + +footer { + border-top: 1px solid var(--line); + margin-top: 120px; + padding: 48px var(--pad); + font-size: 13px; + color: var(--muted); +} + +.footer-inner { + max-width: var(--max); + margin: 0 auto; + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 40px; +} + +.footer-inner strong { + display: block; + color: var(--fg); + font-weight: 500; + margin-bottom: 12px; + letter-spacing: 0.05em; + text-transform: uppercase; + font-size: 12px; +} + +.footer-inner a { + display: block; + margin-bottom: 6px; +} + +.footer-admin { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid var(--line); + font-size: 12px; +} + +/* ---------- misc ---------- */ + +.page-title { + padding: 60px 0 40px; +} + +.page-title h1 { + font-size: 56px; +} + +.about-body { + max-width: 62ch; + font-size: 18px; + line-height: 1.65; +} + +.contact-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 80px; + font-size: 16px; +} + +.contact-grid strong { + display: block; + font-weight: 500; + margin-bottom: 6px; +} + +.news-item { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 32px; + padding: 32px 0; + border-top: 1px solid var(--line); +} + +.news-item:last-child { + border-bottom: 1px solid var(--line); +} + +.news-item img { + aspect-ratio: 4 / 3; + object-fit: cover; + background: #f2f2f2; +} + +.news-item time { + font-size: 12px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.1em; + display: block; + margin-bottom: 12px; +} + +.news-item h3 { + font-family: var(--serif); + font-size: 28px; + font-weight: 400; + margin-bottom: 12px; +} + +/* ---------- responsive ---------- */ + +@media (max-width: 960px) { + :root { + --pad: 24px; + } + h1 { + font-size: 40px; + } + .grid-3, + .grid-4 { + grid-template-columns: repeat(2, 1fr); + } + .hero-meta, + .detail-header, + .works-grid, + .news-item, + .contact-grid { + grid-template-columns: 1fr; + gap: 32px; + } + .footer-inner { + grid-template-columns: 1fr 1fr; + } + .nav-right { + gap: 16px; + } + .nav-links { + gap: 16px; + font-size: 11px; + } +} + +@media (max-width: 560px) { + .grid-3, + .grid-4, + .grid-2 { + grid-template-columns: 1fr; + } + .footer-inner { + grid-template-columns: 1fr; + } + .nav-links a:not(.brand) { + display: none; + } +} + +/* ---------- exhibition slider (lightbox) ---------- */ +.slider-trigger { + cursor: zoom-in; +} + +.slider { + position: fixed; + inset: 0; + background: #000; + display: none; + z-index: 200; + color: #fff; +} +.slider.open { display: flex; flex-direction: column; } +.slider-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 28px; + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255,255,255,0.75); +} +.slider-header .slider-counter { font-variant-numeric: tabular-nums; } +.slider-close { + background: none; + border: 0; + color: #fff; + font-size: 22px; + cursor: pointer; + padding: 4px 8px; + line-height: 1; +} +.slider-stage { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + padding: 0 40px; + min-height: 0; +} +.slider-stage img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} +.slider-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 52px; + height: 52px; + border-radius: 50%; + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.18); + color: #fff; + font-size: 22px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.slider-nav:hover { background: rgba(255,255,255,0.16); } +.slider-nav.prev { left: 20px; } +.slider-nav.next { right: 20px; } +.slider-nav:disabled { opacity: 0.25; cursor: default; } + +.slider-caption { + padding: 20px 40px 14px; + text-align: center; + font-size: 14px; + line-height: 1.5; + color: rgba(255,255,255,0.85); +} +.slider-caption strong { + display: block; + font-family: var(--serif); + font-style: italic; + font-weight: 400; + font-size: 20px; + color: #fff; + margin-bottom: 4px; +} + +.slider-strip { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 14px 28px 22px; + scrollbar-width: thin; +} +.slider-strip button { + flex-shrink: 0; + width: 64px; + height: 64px; + border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.04); + padding: 0; + cursor: pointer; + opacity: 0.5; + transition: opacity 0.15s; +} +.slider-strip button.active { + opacity: 1; + border-color: rgba(255,255,255,0.8); +} +.slider-strip img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +@media (max-width: 640px) { + .slider-stage { padding: 0 16px; } + .slider-nav { width: 42px; height: 42px; font-size: 18px; } + .slider-nav.prev { left: 8px; } + .slider-nav.next { right: 8px; } + .slider-strip { padding: 10px 14px 18px; } + .slider-strip button { width: 48px; height: 48px; } +} + diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5e3906d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +ADMIN_PASSWORD=admin +JWT_SECRET=change-me-to-a-long-random-string +MONGO_URL=mongodb://mongo:27017 +DB_NAME=jimi_gallery diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..2509505 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY backend/requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +# Backend code +COPY backend/ /app/backend/ + +# Static frontend baked into the image so the image is self-contained in prod. +# Local dev can still override via a volume mount if desired. +COPY index.html artist.html artists.html exhibition.html exhibitions.html news.html about.html contact.html /static/ +COPY assets /static/assets +COPY admin /static/admin + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + STATIC_DIR=/static + +EXPOSE 8000 + +WORKDIR /app/backend +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..785c066 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,37 @@ +"""Admin auth: bcrypt-less password check + JWT issuance and verification.""" +import os +from datetime import datetime, timedelta, timezone + +import jwt +from fastapi import Header, HTTPException + +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin") +JWT_SECRET = os.environ.get("JWT_SECRET", "dev-secret-change-me") +JWT_ALGO = "HS256" +TOKEN_TTL_HOURS = int(os.environ.get("JWT_TTL_HOURS", "24")) + + +def issue_token() -> str: + payload = { + "sub": "admin", + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO) + + +def verify_token(token: str) -> bool: + try: + jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO]) + return True + except Exception: + return False + + +async def require_auth(authorization: str = Header(default=None)): + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="Missing bearer token") + token = authorization.split(" ", 1)[1].strip() + if not verify_token(token): + raise HTTPException(status_code=401, detail="Invalid or expired token") + return True diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..ba24df8 --- /dev/null +++ b/backend/db.py @@ -0,0 +1,24 @@ +"""Mongo connection and collection accessors.""" +import os + +from motor.motor_asyncio import AsyncIOMotorClient + +MONGO_URL = os.environ.get("MONGO_URL", "mongodb://localhost:27017") +DB_NAME = os.environ.get("DB_NAME", "jimi_gallery") + +client = AsyncIOMotorClient(MONGO_URL) +db = client[DB_NAME] + +artists = db.artists +exhibitions = db.exhibitions +news = db.news +settings_col = db.settings # single-document collection keyed by _id="singleton" + + +def strip_id(doc): + """Drop Mongo's _id before returning to the client.""" + if doc is None: + return None + doc = dict(doc) + doc.pop("_id", None) + return doc diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..c686604 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,228 @@ +"""FastAPI app — Jimi Gallery API + static file serving.""" +import os +from pathlib import Path + +from fastapi import Body, Depends, FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from auth import ADMIN_PASSWORD, issue_token, require_auth +from db import artists, exhibitions, news, settings_col, strip_id +from seed import SEED + +app = FastAPI(title="Jimi Gallery API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + + +# -------------------- bootstrap -------------------- + +@app.on_event("startup") +async def bootstrap(): + """Seed an empty DB on first run so the app has something to show.""" + if await settings_col.find_one({"_id": "singleton"}) is None: + await settings_col.insert_one({"_id": "singleton", **SEED["settings"]}) + if await artists.estimated_document_count() == 0 and SEED["artists"]: + await artists.insert_many([dict(a) for a in SEED["artists"]]) + if await exhibitions.estimated_document_count() == 0 and SEED["exhibitions"]: + await exhibitions.insert_many([dict(e) for e in SEED["exhibitions"]]) + if await news.estimated_document_count() == 0 and SEED["news"]: + await news.insert_many([dict(n) for n in SEED["news"]]) + + +# -------------------- auth -------------------- + +@app.post("/api/auth/login") +async def login(body: dict = Body(...)): + if body.get("password") != ADMIN_PASSWORD: + raise HTTPException(status_code=401, detail="Incorrect password") + return {"token": issue_token()} + + +@app.get("/api/auth/me") +async def me(_: bool = Depends(require_auth)): + return {"ok": True} + + +# -------------------- settings -------------------- + +@app.get("/api/settings") +async def get_settings(): + doc = await settings_col.find_one({"_id": "singleton"}) + if doc is None: + doc = {"_id": "singleton", **SEED["settings"]} + await settings_col.insert_one(dict(doc)) + return strip_id(doc) + + +@app.put("/api/settings") +async def put_settings(body: dict = Body(...), _: bool = Depends(require_auth)): + body.pop("_id", None) + await settings_col.update_one( + {"_id": "singleton"}, {"$set": body}, upsert=True + ) + return strip_id(await settings_col.find_one({"_id": "singleton"})) + + +# -------------------- helpers for collections -------------------- + +def _require_id(body): + if not body.get("id"): + raise HTTPException(status_code=400, detail="id is required") + + +async def _list(coll): + return [strip_id(d) async for d in coll.find({})] + + +async def _get(coll, id): + d = await coll.find_one({"id": id}) + if d is None: + raise HTTPException(status_code=404, detail="not found") + return strip_id(d) + + +async def _upsert(coll, id, body): + body.pop("_id", None) + body["id"] = id + await coll.update_one({"id": id}, {"$set": body}, upsert=True) + return strip_id(await coll.find_one({"id": id})) + + +async def _delete(coll, id): + await coll.delete_one({"id": id}) + return {"ok": True} + + +# -------------------- artists -------------------- + +@app.get("/api/artists") +async def list_artists(): + return await _list(artists) + + +@app.get("/api/artists/{id}") +async def get_artist(id: str): + return await _get(artists, id) + + +@app.put("/api/artists/{id}") +async def put_artist(id: str, body: dict = Body(...), _: bool = Depends(require_auth)): + return await _upsert(artists, id, body) + + +@app.delete("/api/artists/{id}") +async def delete_artist(id: str, _: bool = Depends(require_auth)): + return await _delete(artists, id) + + +# -------------------- exhibitions -------------------- + +@app.get("/api/exhibitions") +async def list_exhibitions(): + return await _list(exhibitions) + + +@app.get("/api/exhibitions/{id}") +async def get_exhibition(id: str): + return await _get(exhibitions, id) + + +@app.put("/api/exhibitions/{id}") +async def put_exhibition(id: str, body: dict = Body(...), _: bool = Depends(require_auth)): + return await _upsert(exhibitions, id, body) + + +@app.delete("/api/exhibitions/{id}") +async def delete_exhibition(id: str, _: bool = Depends(require_auth)): + return await _delete(exhibitions, id) + + +# -------------------- news -------------------- + +@app.get("/api/news") +async def list_news(): + return await _list(news) + + +@app.get("/api/news/{id}") +async def get_news_item(id: str): + return await _get(news, id) + + +@app.put("/api/news/{id}") +async def put_news_item(id: str, body: dict = Body(...), _: bool = Depends(require_auth)): + return await _upsert(news, id, body) + + +@app.delete("/api/news/{id}") +async def delete_news_item(id: str, _: bool = Depends(require_auth)): + return await _delete(news, id) + + +# -------------------- admin: seed / export / import -------------------- + +async def _apply_full_set(settings_doc, artists_list, exhibitions_list, news_list): + await settings_col.delete_many({}) + await artists.delete_many({}) + await exhibitions.delete_many({}) + await news.delete_many({}) + + s = dict(settings_doc) + s.pop("_id", None) + await settings_col.insert_one({"_id": "singleton", **s}) + + if artists_list: + await artists.insert_many([dict(a) for a in artists_list]) + if exhibitions_list: + await exhibitions.insert_many([dict(e) for e in exhibitions_list]) + if news_list: + await news.insert_many([dict(n) for n in news_list]) + + +@app.post("/api/admin/seed") +async def apply_seed(_: bool = Depends(require_auth)): + await _apply_full_set( + SEED["settings"], SEED["artists"], SEED["exhibitions"], SEED["news"] + ) + return {"ok": True} + + +@app.get("/api/admin/export") +async def export_all(): + settings_doc = await settings_col.find_one({"_id": "singleton"}) or {} + return { + "settings": strip_id(settings_doc) or dict(SEED["settings"]), + "artists": [strip_id(d) async for d in artists.find({})], + "exhibitions": [strip_id(d) async for d in exhibitions.find({})], + "news": [strip_id(d) async for d in news.find({})], + } + + +@app.post("/api/admin/import") +async def import_all(body: dict = Body(...), _: bool = Depends(require_auth)): + missing = [ + k for k in ("settings", "artists", "exhibitions", "news") if k not in body + ] + if missing: + raise HTTPException(status_code=400, detail=f"missing keys: {missing}") + await _apply_full_set( + body["settings"], body["artists"], body["exhibitions"], body["news"] + ) + return {"ok": True} + + +# -------------------- static file serving -------------------- + +STATIC_DIR = Path( + os.environ.get("STATIC_DIR", Path(__file__).resolve().parent.parent) +) + +# Mounted last so /api/* routes defined above take precedence. +app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..eb20467 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +motor==3.6.0 +pyjwt==2.9.0 +python-dotenv==1.0.1 diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..45144aa --- /dev/null +++ b/backend/seed.py @@ -0,0 +1,376 @@ +"""Demo seed data — Python port of the multilingual seed from assets/data.js.""" + +SEED = { + "settings": { + "galleryName": "Jimi Gallery", + "tagline": { + "en": "Contemporary art. New York.", + "ko": "현대미술. 뉴욕.", + "ja": "コンテンポラリー・アート。ニューヨーク。", + }, + "address": "145 Grand Street, New York, NY 10013", + "phone": "+1 212 555 0180", + "email": "info@jimigallery.com", + "hours": { + "en": "Tuesday – Saturday, 10 – 6", + "ko": "화 – 토, 오전 10시 – 오후 6시", + "ja": "火 – 土、10時 – 18時", + }, + "instagram": "@jimigallery", + "about": { + "en": "Jimi Gallery is a contemporary art gallery established in 2014 in downtown New York. The gallery represents an international roster of artists working across painting, sculpture, photography, and installation. Through a program of solo exhibitions, curated group shows, and participation in major art fairs, Jimi Gallery is committed to supporting rigorous, idiosyncratic voices at every stage of their careers.", + "ko": "지미 갤러리는 2014년 뉴욕 다운타운에 설립된 현대미술 갤러리입니다. 회화·조각·사진·설치를 아우르는 국제적 작가들을 대표하며, 개인전과 기획 그룹전, 주요 아트페어 참가로 이루어진 프로그램을 통해 경력 단계를 막론하고 엄정하고 독자적인 목소리를 지원하는 데 전념합니다.", + "ja": "ジミ・ギャラリーは2014年にニューヨーク・ダウンタウンに設立されたコンテンポラリーアートギャラリーです。絵画、彫刻、写真、インスタレーションを横断する国際的な作家陣を擁し、個展、キュレーションによるグループ展、主要アートフェアへの参加を通じて、キャリアのあらゆる段階で厳格かつ独自な声を支えることに取り組んでいます。", + }, + }, + "artists": [ + { + "id": "lena-rashid", + "name": "Lena Rashid", + "born": {"en": "b. 1978, Beirut", "ko": "1978년 베이루트 출생", "ja": "1978年ベイルート生まれ"}, + "lives": { + "en": "Lives and works in Brooklyn, NY", + "ko": "뉴욕 브루클린에서 거주 · 작업", + "ja": "ニューヨーク・ブルックリンを拠点に活動", + }, + "bio": { + "en": "Lena Rashid's paintings move between abstraction and figuration, drawing on the visual logic of textiles, architecture, and 1970s graphic design. Her surfaces, built up over months in thin layers of oil and wax, hold colour the way a wall holds light at dusk.", + "ko": "레나 라시드의 회화는 추상과 구상 사이를 오가며, 직물과 건축, 1970년대 그래픽 디자인의 시각 논리에서 자양분을 얻는다. 수개월에 걸쳐 얇은 유채와 왁스 층으로 쌓아 올린 그녀의 화면은, 황혼 녘의 벽이 빛을 품는 방식으로 색을 품는다.", + "ja": "レナ・ラシッドの絵画は抽象と具象の間を行き来し、テキスタイル、建築、1970年代のグラフィックデザインの視覚的論理に着想を得ている。数ヶ月にわたり油彩とワックスの薄い層を重ねて築かれる画面は、夕暮れの壁が光を抱くように色を宿している。", + }, + "portrait": "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=900&auto=format&fit=crop&q=70", + "works": [ + { + "title": {"en": "Interior (Coral)", "ko": "인테리어 (코랄)", "ja": "インテリア(コーラル)"}, + "year": "2025", + "medium": {"en": "Oil and wax on linen", "ko": "리넨에 유채와 왁스", "ja": "リネンに油彩とワックス"}, + "dimensions": "180 × 140 cm", + "image": "https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=1200&auto=format&fit=crop&q=70", + }, + { + "title": {"en": "Lantern Study II", "ko": "랜턴 습작 II", "ja": "ランタン習作 II"}, + "year": "2024", + "medium": {"en": "Oil on linen", "ko": "리넨에 유채", "ja": "リネンに油彩"}, + "dimensions": "120 × 90 cm", + "image": "https://images.unsplash.com/photo-1541961017774-22349e4a1262?w=1200&auto=format&fit=crop&q=70", + }, + { + "title": {"en": "Dust Room", "ko": "먼지의 방", "ja": "塵の部屋"}, + "year": "2024", + "medium": {"en": "Oil and wax on linen", "ko": "리넨에 유채와 왁스", "ja": "リネンに油彩とワックス"}, + "dimensions": "200 × 160 cm", + "image": "https://images.unsplash.com/photo-1547891654-e66ed7ebb968?w=1200&auto=format&fit=crop&q=70", + }, + ], + }, + { + "id": "hiroshi-okada", + "name": "Hiroshi Okada", + "born": {"en": "b. 1965, Osaka", "ko": "1965년 오사카 출생", "ja": "1965年大阪生まれ"}, + "lives": { + "en": "Lives and works in Kyoto and Los Angeles", + "ko": "교토와 로스앤젤레스에서 거주 · 작업", + "ja": "京都とロサンゼルスを拠点に活動", + }, + "bio": { + "en": "Working primarily in bronze and cast concrete, Hiroshi Okada builds quiet geometric objects that register the passage of hand and time. His practice draws equally on Shinto shrine architecture and mid-century minimalism.", + "ko": "히로시 오카다는 주로 청동과 주조 콘크리트로 작업하며, 손과 시간의 흔적을 품은 고요한 기하학적 오브제를 구축한다. 그의 작업은 신토 신사 건축과 미드센추리 미니멀리즘 양쪽으로부터 고르게 자양분을 얻는다.", + "ja": "岡田ひろしは主に青銅とキャストコンクリートを用い、手と時間の経過を記録する静謐な幾何学的オブジェを制作する。その実践は神道の社殿建築とミッドセンチュリー・ミニマリズムの双方から等しく影響を受けている。", + }, + "portrait": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=900&auto=format&fit=crop&q=70", + "works": [ + { + "title": {"en": "Stele No. 14", "ko": "스텔레 No. 14", "ja": "ステラ No. 14"}, + "year": "2025", + "medium": {"en": "Patinated bronze", "ko": "파티네이션된 청동", "ja": "パティナ加工の青銅"}, + "dimensions": "210 × 40 × 40 cm", + "image": "https://images.unsplash.com/photo-1578321272176-b7bbc0679853?w=1200&auto=format&fit=crop&q=70", + }, + { + "title": {"en": "Threshold", "ko": "스레숄드", "ja": "スレッショルド"}, + "year": "2024", + "medium": {"en": "Cast concrete, steel", "ko": "주조 콘크리트, 강철", "ja": "キャストコンクリート、スチール"}, + "dimensions": "90 × 120 × 40 cm", + "image": "https://images.unsplash.com/photo-1536924430914-91f9e2041b83?w=1200&auto=format&fit=crop&q=70", + }, + ], + }, + { + "id": "mara-chen", + "name": "Mara Chen", + "born": {"en": "b. 1990, Taipei", "ko": "1990년 타이베이 출생", "ja": "1990年台北生まれ"}, + "lives": { + "en": "Lives and works in New York", + "ko": "뉴욕에서 거주 · 작업", + "ja": "ニューヨークを拠点に活動", + }, + "bio": { + "en": "Mara Chen's photographs and video installations treat the city as a layered archive. Her long-term projects document construction sites, shipping ports, and migrant neighbourhoods on the eastern seaboard.", + "ko": "마라 첸의 사진과 영상 설치는 도시를 겹겹이 쌓인 아카이브로 다룬다. 그녀의 장기 프로젝트는 미국 동해안의 공사 현장과 항만, 이주민 거주 지역을 기록한다.", + "ja": "マラ・チェンの写真と映像インスタレーションは、都市を幾重にも重なったアーカイヴとして扱う。長期プロジェクトでは、アメリカ東海岸の建設現場、港湾、移民コミュニティを記録している。", + }, + "portrait": "https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=900&auto=format&fit=crop&q=70", + "works": [ + { + "title": {"en": "Red Hook, 4:42am", "ko": "레드 훅, 새벽 4:42", "ja": "レッドフック、午前4:42"}, + "year": "2025", + "medium": {"en": "Archival pigment print", "ko": "아카이벌 피그먼트 프린트", "ja": "アーカイバル顔料プリント"}, + "dimensions": "110 × 140 cm, ed. of 5", + "image": "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&auto=format&fit=crop&q=70", + }, + { + "title": {"en": "Sunset Park (Series)", "ko": "선셋 파크 (연작)", "ja": "サンセット・パーク(シリーズ)"}, + "year": "2024", + "medium": {"en": "Archival pigment print", "ko": "아카이벌 피그먼트 프린트", "ja": "アーカイバル顔料プリント"}, + "dimensions": "80 × 100 cm, ed. of 7", + "image": "https://images.unsplash.com/photo-1493514789931-586cb221d7a7?w=1200&auto=format&fit=crop&q=70", + }, + { + "title": {"en": "Container VII", "ko": "컨테이너 VII", "ja": "コンテナ VII"}, + "year": "2024", + "medium": {"en": "Archival pigment print", "ko": "아카이벌 피그먼트 프린트", "ja": "アーカイバル顔料プリント"}, + "dimensions": "100 × 125 cm, ed. of 5", + "image": "https://images.unsplash.com/photo-1504276048855-f3d60e69632f?w=1200&auto=format&fit=crop&q=70", + }, + ], + }, + { + "id": "david-okafor", + "name": "David Okafor", + "born": {"en": "b. 1983, Lagos", "ko": "1983년 라고스 출생", "ja": "1983年ラゴス生まれ"}, + "lives": { + "en": "Lives and works in London", + "ko": "런던에서 거주 · 작업", + "ja": "ロンドンを拠点に活動", + }, + "bio": { + "en": "David Okafor's large-scale paintings combine portraiture with densely patterned backgrounds, drawing from Yoruba textiles, 17th-century Dutch interiors, and contemporary street photography.", + "ko": "데이비드 오카포르의 대형 회화는 초상과 촘촘히 짜인 패턴 배경을 결합하며, 요루바 직물과 17세기 네덜란드 실내화, 동시대 거리 사진에서 요소를 길어 올린다.", + "ja": "デイヴィッド・オカフォーの大型絵画は、肖像と緻密に敷き詰められた模様の背景を組み合わせ、ヨルバのテキスタイル、17世紀オランダの室内画、現代のストリートフォトグラフィーから引用している。", + }, + "portrait": "https://images.unsplash.com/photo-1504593811423-6dd665756598?w=900&auto=format&fit=crop&q=70", + "works": [ + { + "title": {"en": "Sitter in Green", "ko": "녹색의 시터", "ja": "グリーンのシッター"}, + "year": "2025", + "medium": {"en": "Oil on canvas", "ko": "캔버스에 유채", "ja": "キャンバスに油彩"}, + "dimensions": "220 × 170 cm", + "image": "https://images.unsplash.com/photo-1579762593175-20226054cad0?w=1200&auto=format&fit=crop&q=70", + }, + { + "title": {"en": "Brother, Afternoon", "ko": "형제, 오후", "ja": "兄弟、午後"}, + "year": "2024", + "medium": {"en": "Oil on canvas", "ko": "캔버스에 유채", "ja": "キャンバスに油彩"}, + "dimensions": "190 × 150 cm", + "image": "https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200&auto=format&fit=crop&q=70", + }, + ], + }, + { + "id": "ines-valverde", + "name": "Inés Valverde", + "born": {"en": "b. 1975, Madrid", "ko": "1975년 마드리드 출생", "ja": "1975年マドリード生まれ"}, + "lives": { + "en": "Lives and works in Madrid", + "ko": "마드리드에서 거주 · 작업", + "ja": "マドリードを拠点に活動", + }, + "bio": { + "en": "Inés Valverde's installations pair found objects with hand-blown glass to meditate on domestic labour, inheritance, and the architecture of Iberian interiors.", + "ko": "이네스 발베르데의 설치 작업은 발견된 오브제와 수공 블로운 글라스를 짝 지어, 가사 노동과 상속, 그리고 이베리아 반도 주거 공간의 건축을 사유한다.", + "ja": "イネス・バルベルデのインスタレーションは、発見されたオブジェと手吹きガラスを組み合わせ、家事労働、継承、そしてイベリアの室内建築をめぐって思索する。", + }, + "portrait": "https://images.unsplash.com/photo-1544723795-3fb6469f5b39?w=900&auto=format&fit=crop&q=70", + "works": [ + { + "title": {"en": "Pantry (Herrería)", "ko": "팬트리 (에레리아)", "ja": "パントリー(エレリア)"}, + "year": "2025", + "medium": {"en": "Blown glass, iron, oak", "ko": "블로운 글라스, 철, 오크", "ja": "吹きガラス、鉄、オーク"}, + "dimensions": "variable", + "image": "https://images.unsplash.com/photo-1578926288207-a90a5366759d?w=1200&auto=format&fit=crop&q=70", + } + ], + }, + { + "id": "teo-bellini", + "name": "Teo Bellini", + "born": {"en": "b. 1988, Milan", "ko": "1988년 밀라노 출생", "ja": "1988年ミラノ生まれ"}, + "lives": { + "en": "Lives and works in Berlin", + "ko": "베를린에서 거주 · 작업", + "ja": "ベルリンを拠点に活동", + }, + "bio": { + "en": "Teo Bellini works with discarded electronics, constructing wall-mounted reliefs that hum, glow, and occasionally broadcast fragments of short-wave radio.", + "ko": "테오 벨리니는 버려진 전자 부품으로 작업하며, 웅웅거리고 빛나며 때때로 단파 라디오의 파편을 송출하는 벽 부착형 릴리프를 구축한다.", + "ja": "テオ・ベッリーニは廃棄された電子機器を素材に、低くうなり、発光し、時折短波ラジオの断片を発信する壁掛けのレリーフを構築する。", + }, + "portrait": "https://images.unsplash.com/photo-1506277886164-e25aa3f4ef7f?w=900&auto=format&fit=crop&q=70", + "works": [ + { + "title": {"en": "Channel 88", "ko": "채널 88", "ja": "チャンネル 88"}, + "year": "2025", + "medium": { + "en": "Mixed media, LEDs, radio components", + "ko": "혼합 매체, LED, 라디오 부품", + "ja": "ミクストメディア、LED、ラジオ部品", + }, + "dimensions": "140 × 140 × 12 cm", + "image": "https://images.unsplash.com/photo-1517423440428-a5a00ad493e8?w=1200&auto=format&fit=crop&q=70", + } + ], + }, + ], + "exhibitions": [ + { + "id": "lena-rashid-interiors", + "title": {"en": "Interiors", "ko": "인테리어스", "ja": "インテリアーズ"}, + "artistIds": ["lena-rashid"], + "status": "current", + "startDate": "2026-04-03", + "endDate": "2026-05-16", + "venue": "145 Grand Street", + "hero": "https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=1800&auto=format&fit=crop&q=80", + "description": { + "en": "Jimi Gallery is pleased to present Interiors, Lena Rashid's third solo exhibition with Jimi Gallery. The show gathers twelve new paintings made over the last eighteen months, alongside a group of smaller works on paper. Rashid's recent canvases turn inward: rooms remembered, surfaces half-seen, the low hum of late afternoon light.", + "ko": "지미 갤러리는 레나 라시드의 세 번째 지미 갤러리 개인전 «인테리어스»를 선보이게 되어 기쁩니다. 이번 전시는 지난 18개월 동안 제작한 열두 점의 신작 회화와 소규모 종이 작업 일군을 함께 소개합니다. 라시드의 최근 화면은 안쪽을 향합니다 — 기억 속의 방, 절반쯤 보이는 표면, 늦은 오후 빛의 낮은 울림.", + "ja": "ジミ・ギャラリーは、レナ・ラシッドの当ギャラリーにおける3度目の個展「インテリアーズ」を開催いたします。本展では、過去18ヶ月の間に制作された新作絵画12点と、より小さな紙作品群を併せて紹介します。ラシッドの近作キャンバスは内面へと向かいます——記憶のなかの部屋、半ば見える表面、午後遅くの光の低い響き。", + }, + "press": { + "en": "Accompanied by a new monograph published by Jimi Gallery Editions, with an essay by the poet Kei Miller.", + "ko": "지미 갤러리 에디션즈에서 발간한 신간 모노그래프 동반 출간. 시인 케이 밀러의 에세이 수록.", + "ja": "ジミ・ギャラリー・エディションズより新モノグラフを刊行、詩人ケイ・ミラーによるエッセイを収録。", + }, + }, + { + "id": "okafor-sitter", + "title": {"en": "The Sitter", "ko": "시터", "ja": "ザ・シッター"}, + "artistIds": ["david-okafor"], + "status": "upcoming", + "startDate": "2026-05-28", + "endDate": "2026-07-11", + "venue": "145 Grand Street", + "hero": "https://images.unsplash.com/photo-1579762593175-20226054cad0?w=1800&auto=format&fit=crop&q=80", + "description": { + "en": "David Okafor's first exhibition with Jimi Gallery brings together six monumental portraits painted between 2024 and 2026. The works extend Okafor's ongoing inquiry into the history of the posed figure, routing European portrait traditions through the specific rooms, textiles, and light of the Lagos-London corridor.", + "ko": "데이비드 오카포르의 지미 갤러리 첫 개인전은 2024년부터 2026년 사이에 그려진 여섯 점의 기념비적 초상화를 한자리에 모읍니다. 이 작품들은 포즈를 취한 인물의 역사에 대한 오카포르의 지속적 탐구를 확장하며, 유럽 초상화 전통을 라고스–런던 축의 구체적인 방, 직물, 빛을 통해 다시 짜냅니다.", + "ja": "デイヴィッド・オカフォーのジミ・ギャラリー初個展では、2024年から2026年にかけて描かれた6点の大型ポートレイトを一堂に展示します。本作群はポーズをとる人物像の歴史への持続的探究をさらに押し広げ、ヨーロッパのポートレイト伝統を、ラゴス—ロンドン回廊の具体的な部屋、テキスタイル、光を通じて再編しています。", + }, + "press": "", + }, + { + "id": "threshold-group", + "title": {"en": "Threshold", "ko": "스레숄드", "ja": "スレッショルド"}, + "artistIds": ["hiroshi-okada", "ines-valverde", "teo-bellini"], + "status": "upcoming", + "startDate": "2026-07-23", + "endDate": "2026-08-30", + "venue": "145 Grand Street", + "hero": "https://images.unsplash.com/photo-1557672172-298e090bd0f1?w=1800&auto=format&fit=crop&q=80", + "description": { + "en": "A three-person summer exhibition bringing together new sculpture and installation by Hiroshi Okada, Inés Valverde, and Teo Bellini. Across bronze, glass, concrete and electronics, the works gathered here share an interest in passages: between rooms, between signals, between one material state and the next.", + "ko": "히로시 오카다, 이네스 발베르데, 테오 벨리니의 신작 조각과 설치를 한자리에 모은 3인 여름 전시. 청동, 유리, 콘크리트, 전자 부품을 가로지르며, 이곳에 모인 작품들은 통로에 대한 관심을 공유합니다 — 방과 방 사이, 신호와 신호 사이, 한 물질적 상태에서 다음 상태로의 이행.", + "ja": "岡田ひろし、イネス・バルベルデ、テオ・ベッリーニの新作彫刻・インスタレーションを集めた夏の3人展。青銅、ガラス、コンクリート、電子機器を横断しながら、本展の作品群は「通路」への関心を共有しています——部屋と部屋の間、信号と信号の間、ある物質的状態から次の状態への移行。", + }, + "press": "", + }, + { + "id": "mara-chen-eastern-seaboard", + "title": {"en": "Eastern Seaboard", "ko": "이스턴 시보드", "ja": "イースタン・シーボード"}, + "artistIds": ["mara-chen"], + "status": "past", + "startDate": "2026-02-07", + "endDate": "2026-03-22", + "venue": "145 Grand Street", + "hero": "https://images.unsplash.com/photo-1504276048855-f3d60e69632f?w=1800&auto=format&fit=crop&q=80", + "description": { + "en": "A solo exhibition of new photographs and a single-channel video by Mara Chen, drawn from four years of fieldwork along the ports, rail yards, and migrant neighbourhoods of the eastern United States.", + "ko": "마라 첸의 신작 사진과 싱글 채널 영상으로 구성된 개인전. 미국 동부의 항구, 철도 조차장, 이주민 거주 지역을 4년간 현장 조사한 결과에서 길어 올린 작업들을 소개합니다.", + "ja": "マラ・チェンによる新作写真群とシングルチャンネル映像からなる個展。アメリカ東部の港湾、操車場、移民コミュニティを4年にわたりフィールドワークした成果から構成されます。", + }, + "press": "", + }, + { + "id": "okada-survey", + "title": { + "en": "Hiroshi Okada: Fifteen Years", + "ko": "히로시 오카다: 15년", + "ja": "岡田ひろし:15年", + }, + "artistIds": ["hiroshi-okada"], + "status": "past", + "startDate": "2025-10-04", + "endDate": "2025-12-20", + "venue": "145 Grand Street", + "hero": "https://images.unsplash.com/photo-1578321272176-b7bbc0679853?w=1800&auto=format&fit=crop&q=80", + "description": { + "en": "A fifteen-year survey of Hiroshi Okada's sculpture, organised in collaboration with the artist's studio in Kyoto.", + "ko": "히로시 오카다의 조각 15년을 조망하는 서베이 전시. 교토의 작가 스튜디오와 공동 기획.", + "ja": "岡田ひろしの彫刻の15年を概観するサーベイ展。京都の作家スタジオと共同企画。", + }, + "press": "", + }, + ], + "news": [ + { + "id": "rashid-kei-miller", + "title": { + "en": "Kei Miller writes on Lena Rashid's Interiors", + "ko": "케이 밀러, 레나 라시드 «인테리어스»에 부쳐", + "ja": "ケイ・ミラー、レナ・ラシッド「インテリアーズ」によせて", + }, + "date": "2026-04-15", + "excerpt": { + "en": "A new essay by the poet Kei Miller accompanies Rashid's current exhibition, published in the Jimi Gallery Editions monograph.", + "ko": "시인 케이 밀러의 신작 에세이가 라시드의 현재 전시와 함께 지미 갤러리 에디션즈 모노그래프에 수록됩니다.", + "ja": "詩人ケイ・ミラーによる新しいエッセイが、ラシッドの現在の展覧会に寄せて、ジミ・ギャラリー・エディションズのモノグラフに掲載されます。", + }, + "body": { + "en": "Kei Miller's essay, 'A room is a kind of sentence,' appears in the monograph published on the occasion of Lena Rashid's exhibition Interiors. Copies are available at the gallery and through Jimi Gallery Editions.", + "ko": "케이 밀러의 에세이 «방은 일종의 문장이다»가 레나 라시드의 전시 «인테리어스» 개최에 맞춰 발간된 모노그래프에 실립니다. 갤러리 및 지미 갤러리 에디션즈를 통해 구입하실 수 있습니다.", + "ja": "ケイ・ミラーのエッセイ「部屋とはひとつの文のようなもの」が、レナ・ラシッドの展覧会「インテリアーズ」開催に合わせて刊行されるモノグラフに掲載されます。ギャラリーおよびジミ・ギャラリー・エディションズにてお求めいただけます。", + }, + "image": "https://images.unsplash.com/photo-1549490349-8643362247b5?w=1200&auto=format&fit=crop&q=70", + }, + { + "id": "basel-2026", + "title": { + "en": "Jimi Gallery at Art Basel 2026", + "ko": "지미 갤러리, 아트 바젤 2026 참가", + "ja": "ジミ・ギャラリー、アート・バーゼル2026に参加", + }, + "date": "2026-06-10", + "excerpt": { + "en": "The gallery will present a two-artist booth of works by David Okafor and Inés Valverde at Art Basel, Hall 2.1, booth C14.", + "ko": "데이비드 오카포르와 이네스 발베르데의 작품을 선보이는 2인 부스로 아트 바젤에 참가합니다. 홀 2.1, 부스 C14.", + "ja": "デイヴィッド・オカフォーとイネス・バルベルデの作品によるデュオブースでアート・バーゼルに参加します。ホール2.1、ブースC14。", + }, + "body": { + "en": "Jimi Gallery is pleased to announce its participation in Art Basel 2026 with a two-artist booth of new works by David Okafor and Inés Valverde. The booth will be located in Hall 2.1, C14. Preview days are 16–17 June, with public days running 18–21 June.", + "ko": "지미 갤러리는 아트 바젤 2026에 데이비드 오카포르와 이네스 발베르데의 신작으로 구성된 2인 부스로 참가함을 알려드립니다. 부스는 홀 2.1, C14에 위치합니다. 프리뷰 데이는 6월 16–17일, 일반 공개는 6월 18–21일입니다.", + "ja": "ジミ・ギャラリーはデイヴィッド・オカフォーとイネス・バルベルデの新作によるデュオブースで、アート・バーゼル2026に参加いたします。ブースはホール2.1、C14に設置されます。プレビュー日は6月16–17日、一般公開日は6月18–21日です。", + }, + "image": "https://images.unsplash.com/photo-1547891654-e66ed7ebb968?w=1200&auto=format&fit=crop&q=70", + }, + { + "id": "chen-moma", + "title": { + "en": "Mara Chen acquired by MoMA", + "ko": "마라 첸, 뉴욕 현대미술관 소장", + "ja": "マラ・チェン、ニューヨーク近代美術館に収蔵", + }, + "date": "2026-03-02", + "excerpt": { + "en": "Three works from the series 'Eastern Seaboard' have entered the permanent collection of the Museum of Modern Art.", + "ko": "«이스턴 시보드» 연작 중 세 점이 뉴욕 현대미술관 영구 소장품으로 편입되었습니다.", + "ja": "シリーズ「イースタン・シーボード」より3点が、ニューヨーク近代美術館の永久コレクションに加わりました。", + }, + "body": { + "en": "The gallery is delighted to announce that three photographs from Mara Chen's 'Eastern Seaboard' series have been acquired by the Museum of Modern Art for its permanent collection. The works were on view at Jimi Gallery earlier this spring.", + "ko": "지미 갤러리는 마라 첸의 «이스턴 시보드» 연작 중 세 점의 사진이 뉴욕 현대미술관 영구 소장품으로 수집되었음을 알리게 되어 기쁩니다. 해당 작품들은 올해 초봄 지미 갤러리에서 선보였습니다.", + "ja": "マラ・チェン「イースタン・シーボード」シリーズより写真3点が、ニューヨーク近代美術館の永久コレクションに収蔵されましたことをお知らせいたします。本作品は今春、ジミ・ギャラリーにて展示されていました。", + }, + "image": "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&auto=format&fit=crop&q=70", + }, + ], +} diff --git a/contact.html b/contact.html new file mode 100644 index 0000000..3c29d9e --- /dev/null +++ b/contact.html @@ -0,0 +1,59 @@ + + + + + + Contact — Jimi Gallery + + + + + + + +
+
+
+

+
+
+
+
+
+ + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..22b7b1e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + mongo: + image: mongo:7 + restart: unless-stopped + volumes: + - mongo_data:/data/db + # No external port — reachable only by the api container via the default network. + + api: + # In prod: pull the pre-built image from the gitea-hosted registry. + # In dev: `docker compose up --build` uses the local Dockerfile below. + image: gitea.yakenator.io/yakenator/jimi-gallery:latest + build: + context: . + dockerfile: backend/Dockerfile + restart: unless-stopped + ports: + # Bound to loopback so nginx on the host is the only gateway. + # Host port 5891 is uncommon to avoid collisions with other dev servers. + - "127.0.0.1:5891:8000" + environment: + - MONGO_URL=mongodb://mongo:27017 + - DB_NAME=jimi_gallery + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin} + - JWT_SECRET=${JWT_SECRET:-dev-secret-change-me} + - STATIC_DIR=/static + depends_on: + - mongo + +volumes: + mongo_data: diff --git a/exhibition.html b/exhibition.html new file mode 100644 index 0000000..7866edc --- /dev/null +++ b/exhibition.html @@ -0,0 +1,212 @@ + + + + + + Exhibition — Jimi Gallery + + + + + + + +
+ + + + + + + + diff --git a/exhibitions.html b/exhibitions.html new file mode 100644 index 0000000..93feb9f --- /dev/null +++ b/exhibitions.html @@ -0,0 +1,95 @@ + + + + + + Exhibitions — Jimi Gallery + + + + + + + +
+
+
+

+
+
+
+
+
+
+ + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..3f2dd1d --- /dev/null +++ b/index.html @@ -0,0 +1,106 @@ + + + + + + Jimi Gallery — Contemporary Art, New York + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + diff --git a/news.html b/news.html new file mode 100644 index 0000000..d95dcfb --- /dev/null +++ b/news.html @@ -0,0 +1,58 @@ + + + + + + News — Jimi Gallery + + + + + + + +
+
+
+

+
+
+
+
+
+ + + + + + +