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

662
admin/admin.css Normal file
View File

@ -0,0 +1,662 @@
:root {
--bg: #fafafa;
--panel: #ffffff;
--fg: #111111;
--muted: #6a6a6a;
--line: #e4e4e4;
--accent: #6b3d8f;
--accent-soft: #f3ecf7;
--accent-hover: #5a3079;
--danger: #b00020;
--sans: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--display: "Cinzel", "Cormorant Garamond", "Times New Roman", serif;
--serif: "Cormorant Garamond", "Times New Roman", serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
font-size: 14px;
line-height: 1.45;
}
a {
color: inherit;
}
img {
max-width: 100%;
display: block;
}
h1 {
font-size: 24px;
margin: 0 0 4px;
font-weight: 500;
}
h2 {
font-size: 18px;
margin: 0 0 16px;
font-weight: 500;
}
p {
margin: 0 0 12px;
}
/* ---------- login ---------- */
.login-lang-wrap {
position: absolute;
top: 18px;
right: 24px;
}
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--bg);
}
.login-card {
width: 360px;
background: var(--panel);
border: 1px solid var(--line);
padding: 40px 32px;
}
.login-card h1 {
text-align: center;
margin-bottom: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.login-card h1 .brand-mark {
width: 56px;
height: 56px;
}
.login-card h1 .brand-word {
font-family: var(--display);
font-size: 18px;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 500;
line-height: 1;
}
.login-card h1 .brand-sub {
font-size: 11px;
color: var(--muted);
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 400;
margin-top: 4px;
}
.login-card h1 .brand-word,
.login-card h1 .brand-sub {
display: block;
}
.login-card label {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.1em;
display: block;
margin-bottom: 6px;
}
.login-card input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
font: inherit;
background: #fff;
}
.login-card .hint {
font-size: 12px;
color: var(--muted);
text-align: center;
margin-top: 16px;
}
.error {
color: var(--danger);
font-size: 13px;
margin-top: 12px;
min-height: 16px;
}
/* ---------- layout ---------- */
.app {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
.sidebar {
background: #fff;
border-right: 1px solid var(--line);
padding: 24px 0;
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
}
.sidebar .brand-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 10px;
padding: 0 24px 20px;
border-bottom: 1px solid var(--line);
margin-bottom: 16px;
}
.sidebar .brand {
display: flex;
align-items: center;
gap: 10px;
color: var(--fg);
text-decoration: none;
flex: 1;
min-width: 0;
}
.sidebar .lang-controls {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 0 -2px;
flex-shrink: 0;
}
.sidebar .lang-select {
appearance: none;
-webkit-appearance: none;
background: #fff url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'><path fill='%239a9a9a' d='M0 0l5 6 5-6z'/></svg>") no-repeat right 7px center / 8px 5px;
border: 1px solid var(--line);
padding: 2px 20px 2px 8px;
font: inherit;
font-size: 11px;
color: var(--muted);
cursor: pointer;
border-radius: 0;
letter-spacing: 0.04em;
line-height: 1.4;
flex-shrink: 0;
}
.sidebar .lang-reset {
background: transparent;
border: 0;
color: var(--muted);
cursor: pointer;
font-size: 13px;
padding: 0 3px;
line-height: 1;
}
.sidebar .lang-reset:hover { color: var(--accent); }
.sidebar .lang-select:hover {
color: var(--fg);
border-color: #bdbdbd;
}
.sidebar .lang-select:focus {
outline: 1px solid var(--accent);
outline-offset: -1px;
color: var(--fg);
}
.sidebar .brand:hover {
opacity: 0.88;
}
.sidebar .brand-mark {
width: 30px;
height: 30px;
flex-shrink: 0;
}
.sidebar .brand-text {
line-height: 1.1;
min-width: 0;
}
.sidebar .brand-word {
font-family: var(--display);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg);
font-weight: 500;
white-space: nowrap;
}
.sidebar .brand-sub {
font-size: 10px;
color: var(--muted);
letter-spacing: 0.12em;
text-transform: uppercase;
margin-top: 3px;
white-space: nowrap;
}
.sidebar nav a {
display: block;
padding: 10px 24px;
color: var(--muted);
text-decoration: none;
font-size: 14px;
}
.sidebar nav a:hover {
color: var(--fg);
background: #f4f4f4;
}
.sidebar nav a.active {
color: var(--accent);
background: var(--accent-soft);
font-weight: 500;
border-left: 2px solid var(--accent);
padding-left: 22px;
}
.sidebar .sidebar-foot {
margin-top: auto;
padding: 16px 24px;
border-top: 1px solid var(--line);
font-size: 12px;
color: var(--muted);
}
.sidebar .sidebar-foot a {
display: block;
text-decoration: none;
margin-bottom: 6px;
}
.content {
padding: 32px 40px 80px;
max-width: 1280px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.topbar .subtitle {
color: var(--muted);
font-size: 13px;
}
/* ---------- buttons ---------- */
.btn {
background: var(--accent);
color: #fff;
border: 0;
padding: 9px 16px;
font: inherit;
cursor: pointer;
font-size: 13px;
}
.btn:hover {
background: var(--accent-hover);
}
.btn.ghost {
background: transparent;
color: var(--fg);
border: 1px solid var(--line);
}
.btn.ghost:hover {
background: #f4f4f4;
}
.btn.danger {
background: transparent;
color: var(--danger);
border: 1px solid var(--line);
}
.btn.danger:hover {
background: #fdecef;
}
.btn-small {
font-size: 12px;
padding: 6px 10px;
}
/* ---------- cards / stats ---------- */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.stat {
background: #fff;
border: 1px solid var(--line);
padding: 20px 20px 24px;
}
.stat .k {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 8px;
}
.stat .v {
font-size: 28px;
font-weight: 500;
}
.panel {
background: #fff;
border: 1px solid var(--line);
padding: 20px;
margin-bottom: 24px;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
/* ---------- table ---------- */
.table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid var(--line);
}
.table th,
.table td {
text-align: left;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
font-size: 13px;
vertical-align: middle;
}
.table th {
background: #f7f7f7;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 11px;
}
.table tr:last-child td {
border-bottom: 0;
}
.table td img {
width: 48px;
height: 48px;
object-fit: cover;
background: #eee;
}
.table .actions {
text-align: right;
white-space: nowrap;
}
.table .actions .btn {
margin-left: 6px;
}
.badge {
display: inline-block;
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.badge-current {
background: #e6f4ea;
color: #137333;
}
.badge-upcoming {
background: #fff4e0;
color: #8c5a00;
}
.badge-past {
background: #f0f0f0;
color: #555;
}
/* ---------- forms ---------- */
.form {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px 24px;
}
.form .full {
grid-column: 1 / -1;
}
.form label {
display: block;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
margin-bottom: 6px;
}
.form input,
.form textarea,
.form select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
font-size: 14px;
}
.form textarea {
min-height: 120px;
resize: vertical;
font-family: inherit;
}
.form .actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
/* ---------- modal ---------- */
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: none;
align-items: flex-start;
justify-content: center;
padding: 48px 24px;
overflow-y: auto;
z-index: 100;
}
.modal.open {
display: flex;
}
.modal .body {
background: #fff;
width: 720px;
max-width: 100%;
border: 1px solid var(--line);
padding: 28px 32px;
}
.modal .body h2 {
display: flex;
justify-content: space-between;
align-items: center;
}
.modal .close {
background: none;
border: 0;
font-size: 22px;
cursor: pointer;
color: var(--muted);
}
/* ---------- multilingual field ---------- */
.ml-field {
display: flex;
flex-direction: column;
gap: 0;
}
.ml-field .ml-tabs {
display: flex;
border: 1px solid var(--line);
border-bottom: 0;
align-self: flex-start;
}
.ml-field .ml-tab {
background: #f7f7f7;
border: 0;
border-right: 1px solid var(--line);
padding: 5px 14px;
font: inherit;
font-size: 11px;
letter-spacing: 0.08em;
color: var(--muted);
cursor: pointer;
}
.ml-field .ml-tab:last-child { border-right: 0; }
.ml-field .ml-tab.active {
background: #fff;
color: var(--accent);
font-weight: 500;
position: relative;
}
.ml-field .ml-tab.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 1px;
background: #fff;
}
.ml-field .ml-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
font-size: 14px;
resize: vertical;
}
.ml-field textarea.ml-input {
min-height: 120px;
font-family: inherit;
}
/* ---------- image field ---------- */
.image-field {
display: flex;
flex-direction: column;
gap: 10px;
}
.image-field-preview {
width: 100%;
height: 180px;
background: #f3f3f3;
border: 1px dashed #d4d4d4;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image-field.compact .image-field-preview {
height: 120px;
}
.image-field-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}
.image-field-empty {
font-size: 11px;
color: var(--muted);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.image-field-controls {
display: flex;
gap: 8px;
align-items: stretch;
}
.image-field-url {
flex: 1;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
font-size: 13px;
}
.image-field-upload {
display: inline-flex;
align-items: center;
cursor: pointer;
margin: 0;
white-space: nowrap;
}
.image-field-upload input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
/* ---------- works subform ---------- */
.works-list {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 14px;
}
.work-row {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1px solid var(--line);
background: #fafafa;
}
.work-row-meta {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr auto;
gap: 8px;
align-items: center;
}
.work-row-meta input {
padding: 8px 10px;
font-size: 13px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
}
/* ---------- toast ---------- */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: #111;
color: #fff;
padding: 12px 18px;
font-size: 13px;
z-index: 200;
opacity: 0;
transform: translateY(10px);
transition: all 0.2s;
pointer-events: none;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
.stats {
grid-template-columns: repeat(2, 1fr);
}
.form,
.work-row-meta {
grid-template-columns: 1fr;
}
}

392
admin/admin.js Normal file
View File

@ -0,0 +1,392 @@
// Shared helpers for the admin panel.
function requireAuth() {
if (!Auth.isAuthed()) {
location.href = "index.html";
}
}
function slugify(s) {
return (s || "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function uid(prefix) {
return prefix + "-" + Math.random().toString(36).slice(2, 8);
}
const ADMIN_LOGO_SVG = `
<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 sidebarHtml(active) {
const items = [
["dashboard.html", t("admin.sidebar.dashboard"), "dashboard"],
["artists.html", t("admin.sidebar.artists"), "artists"],
["exhibitions.html", t("admin.sidebar.exhibitions"), "exhibitions"],
["news.html", t("admin.sidebar.news"), "news"],
["settings.html", t("admin.sidebar.settings"), "settings"]
];
const curLang = getLang();
return `
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="dashboard.html">
${ADMIN_LOGO_SVG}
<div class="brand-text">
<div class="brand-word">Jimi Gallery</div>
<div class="brand-sub">${t("admin.login.title")}</div>
</div>
</a>
<div class="lang-controls">
<select class="lang-select" id="sidebar-lang" aria-label="${t("admin.sidebar.language")}">
${SUPPORTED_LANGS.map(
(l) =>
`<option value="${l}"${l === curLang ? " selected" : ""}>${LANG_LABELS[l]}</option>`
).join("")}
</select>
${
hasLangOverride()
? `<button type="button" class="lang-reset" id="lang-reset" title="${t("lang.auto_hint")}" aria-label="${t("lang.auto_hint")}">↺</button>`
: ""
}
</div>
</div>
<nav>
${items
.map(
([h, l, k]) =>
`<a href="${h}" class="${active === k ? "active" : ""}">${l}</a>`
)
.join("")}
</nav>
<div class="sidebar-foot">
<a href="../index.html" target="_blank">${t("admin.sidebar.view_site")}</a>
<a href="#" id="export-data">${t("admin.sidebar.export")}</a>
<a href="#" id="import-data">${t("admin.sidebar.import")}</a>
<input type="file" id="import-file" accept="application/json,.json" style="display:none" />
<a href="#" id="logout">${t("admin.sidebar.logout")}</a>
<a href="#" id="reset-data" style="color:var(--danger)">${t("admin.sidebar.reset")}</a>
</div>
</aside>
`;
}
function mountSidebar(active) {
const host = document.getElementById("sidebar-slot");
if (host) host.outerHTML = sidebarHtml(active);
document.getElementById("sidebar-lang")?.addEventListener("change", (e) => {
setLang(e.target.value);
});
document.getElementById("lang-reset")?.addEventListener("click", (e) => {
e.preventDefault();
clearLangOverride();
});
document.getElementById("logout")?.addEventListener("click", (e) => {
e.preventDefault();
Auth.logout();
location.href = "index.html";
});
document.getElementById("reset-data")?.addEventListener("click", async (e) => {
e.preventDefault();
if (!confirm(t("admin.confirm.reset"))) return;
try {
await Store.reset();
toast(t("admin.toast.data_reset"));
setTimeout(() => location.reload(), 400);
} catch (err) {
alert(err.message || err);
}
});
document.getElementById("export-data")?.addEventListener("click", (e) => {
e.preventDefault();
exportData();
});
document.getElementById("import-data")?.addEventListener("click", (e) => {
e.preventDefault();
document.getElementById("import-file")?.click();
});
document.getElementById("import-file")?.addEventListener("change", (e) => {
const f = e.target.files && e.target.files[0];
if (f) importDataFromFile(f);
e.target.value = "";
});
}
async function exportData() {
let data;
try {
data = await Store.exportAll();
} catch (e) {
alert(e.message || e);
return;
}
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const ts = new Date().toISOString().slice(0, 10);
a.download = `jimi-gallery-${ts}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast(t("admin.toast.exported"));
}
function importDataFromFile(file) {
const reader = new FileReader();
reader.onerror = () => alert(t("admin.error.invalid_file"));
reader.onload = async () => {
let parsed;
try {
parsed = JSON.parse(reader.result);
} catch (e) {
alert(t("admin.error.invalid_file"));
return;
}
if (
!parsed ||
typeof parsed !== "object" ||
!parsed.settings ||
!Array.isArray(parsed.artists) ||
!Array.isArray(parsed.exhibitions) ||
!Array.isArray(parsed.news)
) {
alert(t("admin.error.invalid_file"));
return;
}
if (!confirm(t("admin.confirm.import"))) return;
try {
await Store.importAll(parsed);
toast(t("admin.toast.imported"));
setTimeout(() => location.reload(), 400);
} catch (err) {
alert(err.message || err);
}
};
reader.readAsText(file);
}
let _toastTimer;
function toast(msg) {
let el = document.querySelector(".toast");
if (!el) {
el = document.createElement("div");
el.className = "toast";
document.body.appendChild(el);
}
el.textContent = msg;
requestAnimationFrame(() => el.classList.add("show"));
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove("show"), 1800);
}
function openModal() {
document.getElementById("modal")?.classList.add("open");
}
function closeModal() {
document.getElementById("modal")?.classList.remove("open");
}
function fmtDateShort(iso) {
if (!iso) return "—";
return new Date(iso + "T00:00:00").toLocaleDateString(
typeof getLocale === "function" ? getLocale() : "en-US",
{ month: "short", day: "numeric", year: "numeric" }
);
}
// Mount a multilingual text field with EN/KO/JA tabs. Returns a handle with
// .get() returning the { en, ko, ja } object. Pair this with form submit:
// const bio = mountMultilingualField(el, { value: a.bio });
// // later: item.bio = bio.get();
function mountMultilingualField(container, opts = {}) {
const { value = {}, type = "textarea", placeholder = "", minHeight = "" } = opts;
const initial =
typeof value === "string"
? { en: value, ko: "", ja: "" }
: { en: (value && value.en) || "", ko: (value && value.ko) || "", ja: (value && value.ja) || "" };
const state = { ...initial };
const langs = SUPPORTED_LANGS;
let current = getLang();
if (!langs.includes(current)) current = "en";
container.classList.add("ml-field");
const inputEl =
type === "input"
? `<input type="text" class="ml-input" placeholder="${escapeAttr(placeholder)}" />`
: `<textarea class="ml-input" placeholder="${escapeAttr(placeholder)}"${
minHeight ? ` style="min-height:${minHeight}"` : ""
}></textarea>`;
container.innerHTML = `
<div class="ml-tabs">
${langs
.map(
(l) =>
`<button type="button" class="ml-tab" data-lang="${l}">${LANG_LABELS[l]}</button>`
)
.join("")}
</div>
${inputEl}
`;
const input = container.querySelector(".ml-input");
const tabs = container.querySelectorAll(".ml-tab");
function switchTo(lang) {
state[current] = input.value;
current = lang;
input.value = state[lang] || "";
tabs.forEach((tb) => tb.classList.toggle("active", tb.dataset.lang === lang));
}
tabs.forEach((tb) => tb.addEventListener("click", () => switchTo(tb.dataset.lang)));
// initial active tab
tabs.forEach((tb) => tb.classList.toggle("active", tb.dataset.lang === current));
input.value = state[current] || "";
return {
get() {
state[current] = input.value;
return { en: state.en || "", ko: state.ko || "", ja: state.ja || "" };
}
};
}
function escapeAttr(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;");
}
// Read a File, downscale if large, return a data URL string.
async function fileToDataURL(file, maxDim = 1400, quality = 0.82) {
const originalDataUrl = await new Promise((resolve, reject) => {
const r = new FileReader();
r.onerror = () => reject(r.error || new Error("read failed"));
r.onload = () => resolve(r.result);
r.readAsDataURL(file);
});
if (file.type === "image/gif" || file.size < 300 * 1024) return originalDataUrl;
return new Promise((resolve) => {
const img = new Image();
img.onerror = () => resolve(originalDataUrl);
img.onload = () => {
const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
if (scale >= 1) return resolve(originalDataUrl);
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
try {
resolve(canvas.toDataURL("image/jpeg", quality));
} catch (e) {
resolve(originalDataUrl);
}
};
img.src = originalDataUrl;
});
}
// Mount an image field (preview + URL input + upload + clear) into `container`.
// Returns the URL input element.
function mountImageField(container, opts = {}) {
const {
value = "",
id = "",
dataK = "",
placeholder = "Paste image URL",
compact = false
} = opts;
container.className = "image-field" + (compact ? " compact" : "");
container.innerHTML = `
<div class="image-field-preview">
<img alt="" />
<div class="image-field-empty">No image selected</div>
</div>
<div class="image-field-controls">
<input type="url" class="image-field-url"${id ? ` id="${id}"` : ""}${
dataK ? ` data-k="${dataK}"` : ""
} placeholder="${placeholder}" value="${escapeAttr(value)}" />
<label class="btn ghost btn-small image-field-upload">
Upload
<input type="file" accept="image/*" />
</label>
<button type="button" class="btn ghost btn-small image-field-clear">Clear</button>
</div>
`;
const urlInput = container.querySelector(".image-field-url");
const fileInput = container.querySelector('input[type="file"]');
const previewImg = container.querySelector(".image-field-preview img");
const emptyEl = container.querySelector(".image-field-empty");
const clearBtn = container.querySelector(".image-field-clear");
function updatePreview() {
const v = urlInput.value.trim();
if (v) {
previewImg.src = v;
previewImg.style.display = "";
emptyEl.style.display = "none";
} else {
previewImg.removeAttribute("src");
previewImg.style.display = "none";
emptyEl.style.display = "";
}
}
urlInput.addEventListener("input", updatePreview);
previewImg.addEventListener("error", () => {
previewImg.style.display = "none";
emptyEl.textContent = "Preview unavailable";
emptyEl.style.display = "";
});
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
alert("Please select an image file.");
fileInput.value = "";
return;
}
if (file.size > 20 * 1024 * 1024) {
alert("Image too large — please choose a file under 20MB.");
fileInput.value = "";
return;
}
try {
const dataUrl = await fileToDataURL(file);
urlInput.value = dataUrl;
emptyEl.textContent = "No image selected";
updatePreview();
} catch (err) {
alert("Could not read image: " + (err.message || err));
}
fileInput.value = "";
});
clearBtn.addEventListener("click", () => {
urlInput.value = "";
emptyEl.textContent = "No image selected";
updatePreview();
});
updatePreview();
return urlInput;
}

239
admin/artists.html Normal file
View File

@ -0,0 +1,239 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Artists — Jimi Gallery Admin</title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="admin.css" />
</head>
<body>
<div class="app">
<div id="sidebar-slot"></div>
<div class="content">
<div class="topbar">
<div>
<h1 id="pg-title"></h1>
<div class="subtitle" id="pg-sub"></div>
</div>
<button class="btn" id="new-btn"></button>
</div>
<div class="panel" style="padding:0">
<table class="table" id="tbl"></table>
</div>
</div>
</div>
<div class="modal" id="modal">
<div class="body">
<h2>
<span id="modal-title"></span>
<button class="close" onclick="closeModal()">×</button>
</h2>
<form id="form" class="form">
<input type="hidden" id="f-id" />
<div>
<label id="lbl-name"></label>
<input id="f-name" required />
</div>
<div>
<label id="lbl-born"></label>
<div id="f-born-wrap"></div>
</div>
<div class="full">
<label id="lbl-lives"></label>
<div id="f-lives-wrap"></div>
</div>
<div class="full">
<label id="lbl-portrait"></label>
<div id="f-portrait-wrap"></div>
</div>
<div class="full">
<label id="lbl-bio"></label>
<div id="f-bio-wrap"></div>
</div>
<div class="full">
<label style="display:flex;justify-content:space-between;align-items:center">
<span id="lbl-works"></span>
<button type="button" class="btn ghost btn-small" id="add-work"></button>
</label>
<div class="works-list" id="works"></div>
</div>
<div class="actions">
<button type="button" class="btn ghost" id="btn-cancel" onclick="closeModal()"></button>
<button type="submit" class="btn" id="btn-save"></button>
</div>
</form>
</div>
</div>
<script src="../assets/i18n.js"></script>
<script src="../assets/data.js"></script>
<script src="admin.js"></script>
<script>
(async () => {
requireAuth();
try { await Store.load(); } catch (e) { return showBootError(e); }
mountSidebar("artists");
document.getElementById("pg-title").textContent = t("admin.page.artists");
document.getElementById("pg-sub").textContent = t("admin.page.artists_sub");
document.getElementById("new-btn").textContent = t("admin.btn.new_artist");
document.getElementById("lbl-name").textContent = t("admin.form.name");
document.getElementById("lbl-born").textContent = t("admin.form.born");
document.getElementById("lbl-lives").textContent = t("admin.form.lives");
document.getElementById("lbl-portrait").textContent = t("admin.form.portrait");
document.getElementById("lbl-bio").textContent = t("admin.form.biography");
document.getElementById("lbl-works").textContent = t("admin.form.selected_works");
document.getElementById("add-work").textContent = t("admin.btn.add_work");
document.getElementById("btn-cancel").textContent = t("admin.btn.cancel");
document.getElementById("btn-save").textContent = t("admin.btn.save");
// per-form field handles (for multilingual fields)
let fields = {};
function render() {
const rows = Store.artists()
.map(
(a) => `
<tr>
<td><img src="${a.portrait}" alt=""></td>
<td><strong>${a.name}</strong></td>
<td>${L(a.born) || ""}</td>
<td>${t("admin.works_count", { n: (a.works || []).length })}</td>
<td class="actions">
<a class="btn ghost btn-small" href="../artist.html?id=${a.id}" target="_blank">${t("admin.btn.view")}</a>
<button class="btn ghost btn-small" onclick="edit('${a.id}')">${t("admin.btn.edit")}</button>
<button class="btn danger btn-small" onclick="del('${a.id}')">${t("admin.btn.delete")}</button>
</td>
</tr>
`
)
.join("");
document.getElementById("tbl").innerHTML = `
<thead><tr><th></th><th>${t("admin.table.name")}</th><th>${t("admin.table.born")}</th><th>${t("admin.table.works")}</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="5" style="text-align:center;padding:24px;color:var(--muted)">${t("admin.empty.artists")}</td></tr>`}</tbody>
`;
}
render();
function workRow(w = {}) {
const div = document.createElement("div");
div.className = "work-row";
div.innerHTML = `
<div class="work-row-image"></div>
<div class="work-row-meta">
<div class="ml-field-slot title-slot"></div>
<input placeholder="${t("admin.form.year")}" value="${escapeAttr(w.year || "")}" data-k="year" />
<div class="ml-field-slot medium-slot"></div>
<input placeholder="${t("admin.form.dimensions")}" value="${escapeAttr(w.dimensions || "")}" data-k="dimensions" />
<button type="button" class="btn danger btn-small work-remove" title="Remove">×</button>
</div>
`;
mountImageField(div.querySelector(".work-row-image"), {
value: w.image || "",
dataK: "image",
compact: true,
placeholder: t("admin.paste_or_upload")
});
const titleField = mountMultilingualField(div.querySelector(".title-slot"), {
type: "input",
value: w.title || "",
placeholder: t("admin.form.title")
});
const mediumField = mountMultilingualField(div.querySelector(".medium-slot"), {
type: "input",
value: w.medium || "",
placeholder: t("admin.form.medium")
});
div._titleField = titleField;
div._mediumField = mediumField;
div.querySelector(".work-remove").addEventListener("click", () => div.remove());
return div;
}
window.edit = function (id) {
const a = id ? Store.artist(id) : null;
document.getElementById("modal-title").textContent = a
? t("admin.modal.edit_artist")
: t("admin.modal.new_artist");
document.getElementById("f-id").value = a?.id || "";
document.getElementById("f-name").value = a?.name || "";
fields.born = mountMultilingualField(document.getElementById("f-born-wrap"), {
type: "input",
value: a?.born || "",
placeholder: "b. 1978, Beirut"
});
fields.lives = mountMultilingualField(document.getElementById("f-lives-wrap"), {
type: "input",
value: a?.lives || ""
});
fields.bio = mountMultilingualField(document.getElementById("f-bio-wrap"), {
type: "textarea",
value: a?.bio || ""
});
mountImageField(document.getElementById("f-portrait-wrap"), {
id: "f-portrait",
value: a?.portrait || "",
placeholder: t("admin.paste_or_upload")
});
const wrap = document.getElementById("works");
wrap.innerHTML = "";
(a?.works || []).forEach((w) => wrap.appendChild(workRow(w)));
openModal();
};
window.del = async function (id) {
if (!confirm(t("admin.confirm.delete_artist"))) return;
try {
await Store.remove("artists", id);
toast(t("admin.toast.artist_removed"));
render();
} catch (err) { alert(err.message || err); }
};
document.getElementById("new-btn").addEventListener("click", () => window.edit(null));
document.getElementById("add-work").addEventListener("click", () => {
document.getElementById("works").appendChild(workRow());
});
document.getElementById("form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const id = document.getElementById("f-id").value;
const name = document.getElementById("f-name").value.trim();
const works = [...document.querySelectorAll(".work-row")]
.map((row) => {
const o = {};
row
.querySelectorAll("input[data-k]")
.forEach((i) => (o[i.dataset.k] = i.value.trim()));
if (row._titleField) o.title = row._titleField.get();
if (row._mediumField) o.medium = row._mediumField.get();
return o;
})
.filter((w) => (typeof w.title === "object" ? L(w.title) : w.title) || w.image);
const artist = {
id: id || slugify(name) || uid("artist"),
name,
born: fields.born.get(),
lives: fields.lives.get(),
portrait: document.getElementById("f-portrait").value.trim(),
bio: fields.bio.get(),
works
};
try {
await Store.upsert("artists", artist);
closeModal();
toast(t("admin.toast.saved"));
render();
} catch (err) { alert(err.message || err); }
});
})();
</script>
</body>
</html>

112
admin/dashboard.html Normal file
View File

@ -0,0 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dashboard — Jimi Gallery Admin</title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="admin.css" />
</head>
<body>
<div class="app">
<div id="sidebar-slot"></div>
<div class="content">
<div class="topbar">
<div>
<h1 id="pg-title"></h1>
<div class="subtitle" id="pg-sub"></div>
</div>
<a class="btn" href="exhibitions.html" id="new-exh"></a>
</div>
<div class="stats" id="stats"></div>
<div class="panel">
<div class="panel-head">
<h2 id="panel-upc"></h2>
<a class="btn ghost btn-small" href="exhibitions.html" id="manage-exh"></a>
</div>
<table class="table" id="current-tbl"></table>
</div>
<div class="panel">
<div class="panel-head">
<h2 id="panel-news"></h2>
<a class="btn ghost btn-small" href="news.html" id="manage-news"></a>
</div>
<table class="table" id="news-tbl"></table>
</div>
</div>
</div>
<script src="../assets/i18n.js"></script>
<script src="../assets/data.js"></script>
<script src="admin.js"></script>
<script>
(async () => {
requireAuth();
try { await Store.load(); } catch (e) { return showBootError(e); }
mountSidebar("dashboard");
document.getElementById("pg-title").textContent = t("admin.page.dashboard");
document.getElementById("pg-sub").textContent = t("admin.page.dashboard_sub");
document.getElementById("new-exh").textContent = t("admin.btn.new_exhibition");
document.getElementById("panel-upc").textContent = t("admin.dash.current_upcoming");
document.getElementById("manage-exh").textContent = t("admin.btn.manage");
document.getElementById("panel-news").textContent = t("admin.dash.recent_news");
document.getElementById("manage-news").textContent = t("admin.btn.manage");
const a = Store.artists();
const e = Store.exhibitions();
const n = Store.news();
document.getElementById("stats").innerHTML = `
<div class="stat"><div class="k">${t("admin.stat.artists")}</div><div class="v">${a.length}</div></div>
<div class="stat"><div class="k">${t("admin.stat.exhibitions")}</div><div class="v">${e.length}</div></div>
<div class="stat"><div class="k">${t("admin.stat.current_upcoming")}</div><div class="v">${e.filter((x) => x.status !== "past").length}</div></div>
<div class="stat"><div class="k">${t("admin.stat.news")}</div><div class="v">${n.length}</div></div>
`;
const upc = e.filter((x) => x.status !== "past").sort((x, y) => x.startDate.localeCompare(y.startDate));
document.getElementById("current-tbl").innerHTML = `
<thead><tr><th></th><th>${t("admin.table.title")}</th><th>${t("admin.table.artists")}</th><th>${t("admin.table.dates")}</th><th>${t("admin.table.status")}</th></tr></thead>
<tbody>
${
upc.length
? upc
.map((x) => {
const artists = x.artistIds.map((id) => Store.artist(id)?.name).filter(Boolean).join(", ");
return `<tr>
<td><img src="${x.hero}" alt=""></td>
<td><em>${L(x.title)}</em></td>
<td>${artists}</td>
<td>${fmtDateShort(x.startDate)} ${fmtDateShort(x.endDate)}</td>
<td><span class="badge badge-${x.status}">${t("status." + x.status)}</span></td>
</tr>`;
})
.join("")
: `<tr><td colspan="5" style="text-align:center;color:var(--muted);padding:24px">${t("admin.dash.nothing_scheduled")}</td></tr>`
}
</tbody>
`;
const recent = [...n].sort((x, y) => y.date.localeCompare(x.date)).slice(0, 5);
document.getElementById("news-tbl").innerHTML = `
<thead><tr><th></th><th>${t("admin.table.title")}</th><th>${t("admin.table.date")}</th></tr></thead>
<tbody>
${recent
.map(
(x) => `<tr>
<td><img src="${x.image}" alt=""></td>
<td>${L(x.title)}</td>
<td>${fmtDateShort(x.date)}</td>
</tr>`
)
.join("")}
</tbody>
`;
})();
</script>
</body>
</html>

232
admin/exhibitions.html Normal file
View File

@ -0,0 +1,232 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Exhibitions — Jimi Gallery Admin</title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="admin.css" />
</head>
<body>
<div class="app">
<div id="sidebar-slot"></div>
<div class="content">
<div class="topbar">
<div>
<h1 id="pg-title"></h1>
<div class="subtitle" id="pg-sub"></div>
</div>
<button class="btn" id="new-btn"></button>
</div>
<div class="panel" style="padding:0">
<table class="table" id="tbl"></table>
</div>
</div>
</div>
<div class="modal" id="modal">
<div class="body">
<h2>
<span id="modal-title"></span>
<button class="close" onclick="closeModal()">×</button>
</h2>
<form id="form" class="form">
<input type="hidden" id="f-id" />
<div class="full">
<label id="lbl-title"></label>
<div id="f-title-wrap"></div>
</div>
<div>
<label id="lbl-status"></label>
<select id="f-status">
<option value="current"></option>
<option value="upcoming"></option>
<option value="past"></option>
</select>
</div>
<div>
<label id="lbl-venue"></label>
<input id="f-venue" />
</div>
<div>
<label id="lbl-start"></label>
<input id="f-start" type="date" />
</div>
<div>
<label id="lbl-end"></label>
<input id="f-end" type="date" />
</div>
<div class="full">
<label id="lbl-hero"></label>
<div id="f-hero-wrap"></div>
</div>
<div class="full">
<label id="lbl-artists"></label>
<select id="f-artists" multiple size="6"></select>
</div>
<div class="full">
<label id="lbl-desc"></label>
<div id="f-desc-wrap"></div>
</div>
<div class="full">
<label id="lbl-press"></label>
<div id="f-press-wrap"></div>
</div>
<div class="actions">
<button type="button" class="btn ghost" id="btn-cancel" onclick="closeModal()"></button>
<button type="submit" class="btn" id="btn-save"></button>
</div>
</form>
</div>
</div>
<script src="../assets/i18n.js"></script>
<script src="../assets/data.js"></script>
<script src="admin.js"></script>
<script>
(async () => {
requireAuth();
try { await Store.load(); } catch (e) { return showBootError(e); }
mountSidebar("exhibitions");
document.getElementById("pg-title").textContent = t("admin.page.exhibitions");
document.getElementById("pg-sub").textContent = t("admin.page.exhibitions_sub");
document.getElementById("new-btn").textContent = t("admin.btn.new_exhibition");
document.getElementById("lbl-title").textContent = t("admin.form.title");
document.getElementById("lbl-status").textContent = t("admin.form.status");
document.getElementById("lbl-venue").textContent = t("admin.form.venue");
document.getElementById("lbl-start").textContent = t("admin.form.start_date");
document.getElementById("lbl-end").textContent = t("admin.form.end_date");
document.getElementById("lbl-hero").textContent = t("admin.form.hero_image");
document.getElementById("lbl-artists").innerHTML = `${t("admin.form.artists")} <span style="color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">${t("admin.form.artists_hint")}</span>`;
document.getElementById("lbl-desc").textContent = t("admin.form.description");
document.getElementById("lbl-press").textContent = t("admin.form.press_note");
document.getElementById("btn-cancel").textContent = t("admin.btn.cancel");
document.getElementById("btn-save").textContent = t("admin.btn.save");
// status select options
const statusSel = document.getElementById("f-status");
statusSel.options[0].textContent = t("status.current");
statusSel.options[1].textContent = t("status.upcoming");
statusSel.options[2].textContent = t("status.past");
let fields = {};
const order = { current: 0, upcoming: 1, past: 2 };
function render() {
const rows = [...Store.exhibitions()]
.sort((a, b) =>
order[a.status] !== order[b.status]
? order[a.status] - order[b.status]
: b.startDate.localeCompare(a.startDate)
)
.map((e) => {
const artists = e.artistIds.map((id) => Store.artist(id)?.name).filter(Boolean).join(", ");
return `
<tr>
<td><img src="${e.hero}" alt=""></td>
<td><em>${L(e.title)}</em></td>
<td>${artists}</td>
<td>${fmtDateShort(e.startDate)} ${fmtDateShort(e.endDate)}</td>
<td><span class="badge badge-${e.status}">${t("status." + e.status)}</span></td>
<td class="actions">
<a class="btn ghost btn-small" href="../exhibition.html?id=${e.id}" target="_blank">${t("admin.btn.view")}</a>
<button class="btn ghost btn-small" onclick="edit('${e.id}')">${t("admin.btn.edit")}</button>
<button class="btn danger btn-small" onclick="del('${e.id}')">${t("admin.btn.delete")}</button>
</td>
</tr>
`;
})
.join("");
document.getElementById("tbl").innerHTML = `
<thead><tr><th></th><th>${t("admin.table.title")}</th><th>${t("admin.table.artists")}</th><th>${t("admin.table.dates")}</th><th>${t("admin.table.status")}</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="6" style="text-align:center;padding:24px;color:var(--muted)">${t("admin.empty.exhibitions")}</td></tr>`}</tbody>
`;
}
render();
function fillArtistOptions(selectedIds = []) {
const sel = document.getElementById("f-artists");
sel.innerHTML = Store.artists()
.map(
(a) =>
`<option value="${a.id}" ${selectedIds.includes(a.id) ? "selected" : ""}>${a.name}</option>`
)
.join("");
}
window.edit = function (id) {
const e = id ? Store.exhibition(id) : null;
document.getElementById("modal-title").textContent = e
? t("admin.modal.edit_exhibition")
: t("admin.modal.new_exhibition");
document.getElementById("f-id").value = e?.id || "";
document.getElementById("f-status").value = e?.status || "upcoming";
document.getElementById("f-venue").value = e?.venue || "145 Grand Street";
document.getElementById("f-start").value = e?.startDate || "";
document.getElementById("f-end").value = e?.endDate || "";
fields.title = mountMultilingualField(document.getElementById("f-title-wrap"), {
type: "input",
value: e?.title || ""
});
fields.description = mountMultilingualField(document.getElementById("f-desc-wrap"), {
type: "textarea",
value: e?.description || "",
minHeight: "140px"
});
fields.press = mountMultilingualField(document.getElementById("f-press-wrap"), {
type: "textarea",
value: e?.press || "",
minHeight: "70px"
});
mountImageField(document.getElementById("f-hero-wrap"), {
id: "f-hero",
value: e?.hero || "",
placeholder: t("admin.paste_or_upload")
});
fillArtistOptions(e?.artistIds || []);
openModal();
};
window.del = async function (id) {
if (!confirm(t("admin.confirm.delete_exhibition"))) return;
try {
await Store.remove("exhibitions", id);
toast(t("admin.toast.exhibition_removed"));
render();
} catch (err) { alert(err.message || err); }
};
document.getElementById("new-btn").addEventListener("click", () => window.edit(null));
document.getElementById("form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const id = document.getElementById("f-id").value;
const titleVal = fields.title.get();
const item = {
id: id || slugify(titleVal.en || titleVal.ko || titleVal.ja) || uid("exh"),
title: titleVal,
status: document.getElementById("f-status").value,
venue: document.getElementById("f-venue").value.trim(),
startDate: document.getElementById("f-start").value,
endDate: document.getElementById("f-end").value,
hero: document.getElementById("f-hero").value.trim(),
description: fields.description.get(),
press: fields.press.get(),
artistIds: [...document.getElementById("f-artists").selectedOptions].map((o) => o.value)
};
try {
await Store.upsert("exhibitions", item);
closeModal();
toast(t("admin.toast.saved"));
render();
} catch (err) { alert(err.message || err); }
});
})();
</script>
</body>
</html>

65
admin/index.html Normal file
View File

@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Jimi Gallery Admin — Sign in</title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="admin.css" />
</head>
<body>
<div class="login-lang-wrap"></div>
<div class="login-wrap">
<form class="login-card" id="f">
<h1>
<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>
<span>
<span class="brand-word">Jimi Gallery</span>
<span class="brand-sub" id="login-role"></span>
</span>
</h1>
<label for="pw" id="pw-label"></label>
<input type="password" id="pw" autofocus autocomplete="current-password" />
<div class="error" id="err"></div>
<button class="btn" style="width:100%;margin-top:12px" type="submit" id="submit-btn"></button>
<div class="hint"><span id="hint-text"></span> <code>admin</code></div>
</form>
</div>
<script src="../assets/i18n.js"></script>
<script src="../assets/data.js"></script>
<script src="admin.js"></script>
<script>
if (Auth.isAuthed()) location.href = "dashboard.html";
document.getElementById("login-role").textContent = t("admin.login.title");
document.getElementById("pw-label").textContent = t("admin.login.password");
document.getElementById("submit-btn").textContent = t("admin.login.submit");
document.getElementById("hint-text").textContent = t("admin.login.hint");
document.title = "Jimi Gallery · " + t("admin.login.title") + " — " + t("admin.login.submit");
// language switcher in corner
document.querySelector(".login-lang-wrap").innerHTML = langSwitcherHtml();
wireLangSwitcher();
document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault();
const pw = document.getElementById("pw").value;
const ok = await Auth.login(pw);
if (ok) {
location.href = "dashboard.html";
} else {
document.getElementById("err").textContent = t("admin.login.incorrect");
}
});
</script>
</body>
</html>

175
admin/news.html Normal file
View File

@ -0,0 +1,175 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>News — Jimi Gallery Admin</title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="admin.css" />
</head>
<body>
<div class="app">
<div id="sidebar-slot"></div>
<div class="content">
<div class="topbar">
<div>
<h1 id="pg-title"></h1>
<div class="subtitle" id="pg-sub"></div>
</div>
<button class="btn" id="new-btn"></button>
</div>
<div class="panel" style="padding:0">
<table class="table" id="tbl"></table>
</div>
</div>
</div>
<div class="modal" id="modal">
<div class="body">
<h2>
<span id="modal-title"></span>
<button class="close" onclick="closeModal()">×</button>
</h2>
<form id="form" class="form">
<input type="hidden" id="f-id" />
<div class="full">
<label id="lbl-title"></label>
<div id="f-title-wrap"></div>
</div>
<div>
<label id="lbl-date"></label>
<input id="f-date" type="date" required />
</div>
<div class="full">
<label id="lbl-image"></label>
<div id="f-image-wrap"></div>
</div>
<div class="full">
<label id="lbl-excerpt"></label>
<div id="f-excerpt-wrap"></div>
</div>
<div class="full">
<label id="lbl-body"></label>
<div id="f-body-wrap"></div>
</div>
<div class="actions">
<button type="button" class="btn ghost" id="btn-cancel" onclick="closeModal()"></button>
<button type="submit" class="btn" id="btn-save"></button>
</div>
</form>
</div>
</div>
<script src="../assets/i18n.js"></script>
<script src="../assets/data.js"></script>
<script src="admin.js"></script>
<script>
(async () => {
requireAuth();
try { await Store.load(); } catch (e) { return showBootError(e); }
mountSidebar("news");
document.getElementById("pg-title").textContent = t("admin.page.news");
document.getElementById("pg-sub").textContent = t("admin.page.news_sub");
document.getElementById("new-btn").textContent = t("admin.btn.new_post");
document.getElementById("lbl-title").textContent = t("admin.form.title");
document.getElementById("lbl-date").textContent = t("admin.form.date");
document.getElementById("lbl-image").textContent = t("admin.form.image");
document.getElementById("lbl-excerpt").textContent = t("admin.form.excerpt");
document.getElementById("lbl-body").textContent = t("admin.form.body");
document.getElementById("btn-cancel").textContent = t("admin.btn.cancel");
document.getElementById("btn-save").textContent = t("admin.btn.save");
let fields = {};
function render() {
const rows = [...Store.news()]
.sort((a, b) => b.date.localeCompare(a.date))
.map(
(n) => `
<tr>
<td><img src="${n.image}" alt=""></td>
<td><strong>${L(n.title)}</strong><br><span style="color:var(--muted);font-size:12px">${L(n.excerpt) || ""}</span></td>
<td>${fmtDateShort(n.date)}</td>
<td class="actions">
<button class="btn ghost btn-small" onclick="edit('${n.id}')">${t("admin.btn.edit")}</button>
<button class="btn danger btn-small" onclick="del('${n.id}')">${t("admin.btn.delete")}</button>
</td>
</tr>
`
)
.join("");
document.getElementById("tbl").innerHTML = `
<thead><tr><th></th><th>${t("admin.table.title")}</th><th>${t("admin.table.date")}</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="4" style="text-align:center;padding:24px;color:var(--muted)">${t("admin.empty.news")}</td></tr>`}</tbody>
`;
}
render();
window.edit = function (id) {
const n = id ? Store.newsItem(id) : null;
document.getElementById("modal-title").textContent = n
? t("admin.modal.edit_post")
: t("admin.modal.new_post");
document.getElementById("f-id").value = n?.id || "";
document.getElementById("f-date").value = n?.date || new Date().toISOString().slice(0, 10);
fields.title = mountMultilingualField(document.getElementById("f-title-wrap"), {
type: "input",
value: n?.title || ""
});
fields.excerpt = mountMultilingualField(document.getElementById("f-excerpt-wrap"), {
type: "textarea",
value: n?.excerpt || "",
minHeight: "70px"
});
fields.body = mountMultilingualField(document.getElementById("f-body-wrap"), {
type: "textarea",
value: n?.body || "",
minHeight: "140px"
});
mountImageField(document.getElementById("f-image-wrap"), {
id: "f-image",
value: n?.image || "",
placeholder: t("admin.paste_or_upload")
});
openModal();
};
window.del = async function (id) {
if (!confirm(t("admin.confirm.delete_post"))) return;
try {
await Store.remove("news", id);
toast(t("admin.toast.post_removed"));
render();
} catch (err) { alert(err.message || err); }
};
document.getElementById("new-btn").addEventListener("click", () => window.edit(null));
document.getElementById("form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const id = document.getElementById("f-id").value;
const titleVal = fields.title.get();
const item = {
id: id || slugify(titleVal.en || titleVal.ko || titleVal.ja) || uid("news"),
title: titleVal,
date: document.getElementById("f-date").value,
image: document.getElementById("f-image").value.trim(),
excerpt: fields.excerpt.get(),
body: fields.body.get()
};
try {
await Store.upsert("news", item);
closeModal();
toast(t("admin.toast.saved"));
render();
} catch (err) { alert(err.message || err); }
});
})();
</script>
</body>
</html>

127
admin/settings.html Normal file
View File

@ -0,0 +1,127 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Settings — Jimi Gallery Admin</title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="admin.css" />
</head>
<body>
<div class="app">
<div id="sidebar-slot"></div>
<div class="content">
<div class="topbar">
<div>
<h1 id="pg-title"></h1>
<div class="subtitle" id="pg-sub"></div>
</div>
</div>
<div class="panel">
<form id="form" class="form">
<div>
<label id="lbl-gn"></label>
<input id="f-galleryName" />
</div>
<div>
<label id="lbl-tagline"></label>
<div id="f-tagline-wrap"></div>
</div>
<div class="full">
<label id="lbl-address"></label>
<input id="f-address" />
</div>
<div>
<label id="lbl-phone"></label>
<input id="f-phone" />
</div>
<div>
<label id="lbl-email"></label>
<input id="f-email" />
</div>
<div class="full">
<label id="lbl-hours"></label>
<div id="f-hours-wrap"></div>
</div>
<div>
<label id="lbl-ig"></label>
<input id="f-instagram" />
</div>
<div class="full">
<label id="lbl-about"></label>
<div id="f-about-wrap"></div>
</div>
<div class="actions">
<button type="submit" class="btn" id="btn-save"></button>
</div>
</form>
</div>
</div>
</div>
<script src="../assets/i18n.js"></script>
<script src="../assets/data.js"></script>
<script src="admin.js"></script>
<script>
(async () => {
requireAuth();
try { await Store.load(); } catch (e) { return showBootError(e); }
mountSidebar("settings");
document.getElementById("pg-title").textContent = t("admin.page.settings");
document.getElementById("pg-sub").textContent = t("admin.page.settings_sub");
document.getElementById("lbl-gn").textContent = t("admin.form.gallery_name");
document.getElementById("lbl-tagline").textContent = t("admin.form.tagline");
document.getElementById("lbl-address").textContent = t("admin.form.address");
document.getElementById("lbl-phone").textContent = t("admin.form.phone");
document.getElementById("lbl-email").textContent = t("admin.form.email");
document.getElementById("lbl-hours").textContent = t("admin.form.hours");
document.getElementById("lbl-ig").textContent = t("admin.form.instagram");
document.getElementById("lbl-about").textContent = t("admin.form.about");
document.getElementById("btn-save").textContent = t("admin.btn.save_changes");
const s = Store.settings();
document.getElementById("f-galleryName").value = s.galleryName || "";
document.getElementById("f-address").value = s.address || "";
document.getElementById("f-phone").value = s.phone || "";
document.getElementById("f-email").value = s.email || "";
document.getElementById("f-instagram").value = s.instagram || "";
const fTagline = mountMultilingualField(document.getElementById("f-tagline-wrap"), {
type: "input",
value: s.tagline || ""
});
const fHours = mountMultilingualField(document.getElementById("f-hours-wrap"), {
type: "input",
value: s.hours || ""
});
const fAbout = mountMultilingualField(document.getElementById("f-about-wrap"), {
type: "textarea",
value: s.about || "",
minHeight: "180px"
});
document.getElementById("form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
await Store.updateSettings({
galleryName: document.getElementById("f-galleryName").value.trim(),
tagline: fTagline.get(),
address: document.getElementById("f-address").value.trim(),
phone: document.getElementById("f-phone").value.trim(),
email: document.getElementById("f-email").value.trim(),
hours: fHours.get(),
instagram: document.getElementById("f-instagram").value.trim(),
about: fAbout.get()
});
toast(t("admin.toast.settings_saved"));
} catch (err) { alert(err.message || err); }
});
})();
</script>
</body>
</html>