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:
239
admin/artists.html
Normal file
239
admin/artists.html
Normal 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>
|
||||
Reference in New Issue
Block a user