feat: initial Jimi Gallery prototype
- Public site (Home/Artists/Exhibitions/News/About/Contact) with EN/KO/JA i18n - Admin panel with login, CRUD, image upload, multilingual editing - Exhibition slider/lightbox view - FastAPI + MongoDB backend, JWT auth - Docker Compose deployment, behind nginx at jimi.yakenator.io
This commit is contained in:
129
assets/app.js
Normal file
129
assets/app.js
Normal file
@ -0,0 +1,129 @@
|
||||
// Shared helpers for public pages.
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString(getLocale(), {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric"
|
||||
});
|
||||
}
|
||||
|
||||
function fmtRange(start, end) {
|
||||
if (!start || !end) return "";
|
||||
const locale = getLocale();
|
||||
const s = new Date(start + "T00:00:00");
|
||||
const e = new Date(end + "T00:00:00");
|
||||
const sMonth = s.toLocaleDateString(locale, { month: "long" });
|
||||
const eMonth = e.toLocaleDateString(locale, { month: "long" });
|
||||
const sameYear = s.getFullYear() === e.getFullYear();
|
||||
if (sMonth === eMonth && sameYear) {
|
||||
return `${sMonth} ${s.getDate()} – ${e.getDate()}, ${e.getFullYear()}`;
|
||||
}
|
||||
if (sameYear) {
|
||||
return `${sMonth} ${s.getDate()} – ${eMonth} ${e.getDate()}, ${e.getFullYear()}`;
|
||||
}
|
||||
return `${sMonth} ${s.getDate()}, ${s.getFullYear()} – ${eMonth} ${e.getDate()}, ${e.getFullYear()}`;
|
||||
}
|
||||
|
||||
function qs(key) {
|
||||
return new URLSearchParams(location.search).get(key);
|
||||
}
|
||||
|
||||
function mount(id, html) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.innerHTML = html;
|
||||
}
|
||||
|
||||
const LOGO_SVG = `
|
||||
<svg class="brand-mark" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="50" cy="50" r="50" fill="#6b3d8f"/>
|
||||
<g fill="#ffffff" font-family="'Cormorant Garamond','Playfair Display','Times New Roman',serif" font-weight="500" font-style="italic">
|
||||
<text x="37" y="70" text-anchor="middle" font-size="60">J</text>
|
||||
<text x="63" y="70" text-anchor="middle" font-size="60">M</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
function navHtml(active) {
|
||||
const items = [
|
||||
["index.html", t("nav.home"), "home"],
|
||||
["artists.html", t("nav.artists"), "artists"],
|
||||
["exhibitions.html", t("nav.exhibitions"), "exhibitions"],
|
||||
["news.html", t("nav.news"), "news"],
|
||||
["about.html", t("nav.about"), "about"],
|
||||
["contact.html", t("nav.contact"), "contact"]
|
||||
];
|
||||
const s = Store.settings();
|
||||
return `
|
||||
<nav class="nav">
|
||||
<div class="nav-inner">
|
||||
<a href="index.html" class="brand">
|
||||
${LOGO_SVG}
|
||||
<span class="brand-word">${s.galleryName}</span>
|
||||
</a>
|
||||
<div class="nav-right">
|
||||
<div class="nav-links">
|
||||
${items
|
||||
.map(
|
||||
([href, label, key]) =>
|
||||
`<a href="${href}" class="${
|
||||
active === key ? "active" : ""
|
||||
}">${label}</a>`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
${langSwitcherHtml()}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
|
||||
function footerHtml() {
|
||||
const s = Store.settings();
|
||||
return `
|
||||
<footer>
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<strong>${s.galleryName}</strong>
|
||||
<div>${L(s.tagline)}</div>
|
||||
<div style="margin-top:12px">${s.address}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>${t("footer.hours")}</strong>
|
||||
<div>${L(s.hours)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>${t("footer.contact")}</strong>
|
||||
<a href="tel:${s.phone}">${s.phone}</a>
|
||||
<a href="mailto:${s.email}">${s.email}</a>
|
||||
</div>
|
||||
<div>
|
||||
<strong>${t("footer.follow")}</strong>
|
||||
<a href="#">${t("footer.instagram")} ${s.instagram}</a>
|
||||
<a href="news.html">${t("misc.newsletter")}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-inner footer-admin">
|
||||
<div>© ${new Date().getFullYear()} ${s.galleryName}. ${t("misc.rights_suffix")}</div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div style="text-align:right"><a href="admin/index.html">${t("misc.staff_login")}</a></div>
|
||||
</div>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderChrome(active) {
|
||||
mount("nav-slot", navHtml(active));
|
||||
mount("footer-slot", footerHtml());
|
||||
wireLangSwitcher();
|
||||
// Update document title if page key maps to a known title
|
||||
if (active) {
|
||||
const titleKey = "title." + active;
|
||||
const s = Store.settings();
|
||||
document.title = `${t(titleKey)} — ${s.galleryName}`;
|
||||
}
|
||||
}
|
||||
199
assets/data.js
Normal file
199
assets/data.js
Normal file
@ -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 = `
|
||||
<main style="padding:60px;max-width:640px;margin:48px auto;font-family:'Inter',system-ui,sans-serif;line-height:1.55;color:#111">
|
||||
<h1 style="color:#b00020;font-size:22px;margin:0 0 12px">Backend unreachable</h1>
|
||||
<p style="margin:0 0 14px">Make sure the API + Mongo stack is running:</p>
|
||||
<pre style="background:#f4f4f4;padding:12px;font-size:12px;overflow:auto;margin:0 0 14px">docker compose up</pre>
|
||||
<p style="margin:0 0 14px">Then reload this page.</p>
|
||||
<p style="color:#999;font-size:12px;margin:0">${msg}</p>
|
||||
</main>
|
||||
`;
|
||||
}
|
||||
|
||||
window.Store = Store;
|
||||
window.Auth = Auth;
|
||||
window.showBootError = showBootError;
|
||||
684
assets/i18n.js
Normal file
684
assets/i18n.js
Normal file
@ -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 `
|
||||
<div class="lang-controls">
|
||||
<div class="${cls}" role="group" aria-label="${t("admin.sidebar.language")}">
|
||||
${SUPPORTED_LANGS.map(
|
||||
(l) =>
|
||||
`<button type="button" class="lang-btn ${
|
||||
l === CURRENT_LANG ? "active" : ""
|
||||
}" data-lang="${l}" title="${LANG_NAMES[l]}">${LANG_LABELS[l]}</button>`
|
||||
).join("")}
|
||||
</div>
|
||||
${
|
||||
override
|
||||
? `<button type="button" class="lang-reset" title="${t("lang.auto_hint")}" aria-label="${t("lang.auto_hint")}">↺</button>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
9
assets/logo.svg
Normal file
9
assets/logo.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100" role="img" aria-label="Jimi Gallery">
|
||||
<title>Jimi Gallery</title>
|
||||
<circle cx="50" cy="50" r="50" fill="#6b3d8f"/>
|
||||
<g fill="#ffffff" font-family="'Cormorant Garamond','Playfair Display','Times New Roman',serif" font-weight="500" font-style="italic">
|
||||
<text x="37" y="70" text-anchor="middle" font-size="60">J</text>
|
||||
<text x="63" y="70" text-anchor="middle" font-size="60">M</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
672
assets/styles.css
Normal file
672
assets/styles.css
Normal file
@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user