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:
662
admin/admin.css
Normal file
662
admin/admin.css
Normal 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
392
admin/admin.js
Normal 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, "&")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// 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
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>
|
||||
112
admin/dashboard.html
Normal file
112
admin/dashboard.html
Normal 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
232
admin/exhibitions.html
Normal 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
65
admin/index.html
Normal 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
175
admin/news.html
Normal 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
127
admin/settings.html
Normal 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>
|
||||
Reference in New Issue
Block a user