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

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;