feat: initial Jimi Gallery prototype

- Public site (Home/Artists/Exhibitions/News/About/Contact) with EN/KO/JA i18n
- Admin panel with login, CRUD, image upload, multilingual editing
- Exhibition slider/lightbox view
- FastAPI + MongoDB backend, JWT auth
- Docker Compose deployment, behind nginx at jimi.yakenator.io
This commit is contained in:
2026-04-25 12:47:36 +09:00
commit 098b55e3b0
32 changed files with 5334 additions and 0 deletions

129
assets/app.js Normal file
View 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
View 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
View 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
View 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
View 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; }
}