- 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
200 lines
5.2 KiB
JavaScript
200 lines
5.2 KiB
JavaScript
// 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;
|