feat: pannello admin contenuti

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 17:30:39 +02:00
parent bf19cba4d3
commit be52781499
2 changed files with 247 additions and 1 deletions
+5 -1
View File
@@ -22,13 +22,17 @@ const user = Astro.locals.user;
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; } th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
.pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; } .pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
.pill--draft { background: #f3d9a4; } .pill--draft { background: #f3d9a4; }
.form-msg { font-size: .9rem; margin-top: 8px; }
.form-msg--ok { color: #3c7a3c; }
.form-msg--err { color: #b03030; }
</style> </style>
</head> </head>
<body> <body>
<div class="abar"> <div class="abar">
<strong>InsanityLab · Admin</strong> <strong>InsanityLab · Admin</strong>
<span> <span>
{user && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>} {user && user.role === 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin/content">Contenuti</a> <a href="/admin/logout">Esci</a></>}
{user && user.role !== 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/content">Contenuti</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
</span> </span>
</div> </div>
<div class="awrap"><slot /></div> <div class="awrap"><slot /></div>
+242
View File
@@ -0,0 +1,242 @@
---
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
import { seedByTag } from '../../data/content-seed';
import { STYLE_GROUPS } from '../../lib/style-presets';
export const prerender = false;
// Mappa esplicita prefisso tag → gruppo del pannello (spec §7).
const GROUP_BY_PREFIX: Record<string, string> = {
home: 'Home',
about: 'About', team: 'About', method: 'About',
program: 'Programmi', programs: 'Programmi', pricing: 'Programmi',
training: 'Training',
contact: 'Contatti', form: 'Contatti',
global: 'Globali', header: 'Globali', footer: 'Globali',
agenda: 'Globali', partner: 'Globali', notfound: 'Globali',
};
const GROUP_ORDER = ['Home', 'About', 'Programmi', 'Training', 'Contatti', 'Globali', 'Altro'];
interface Row { tag: string; type: string; value: string; styles: string; updated_at: string; updated_by: string | null }
const rows = getDb().prepare(
'SELECT tag, type, value, styles, updated_at, updated_by FROM content_blocks ORDER BY tag'
).all() as Row[];
interface Item extends Row { parsedStyles: Record<string, string>; styleOptions: readonly string[] }
const groups = new Map<string, Item[]>(GROUP_ORDER.map((g) => [g, []]));
for (const r of rows) {
let parsedStyles: Record<string, string> = {};
try { parsedStyles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
if (typeof parsedStyles !== 'object' || parsedStyles === null) parsedStyles = {};
const group = GROUP_BY_PREFIX[r.tag.split('.')[0]] ?? 'Altro';
groups.get(group)!.push({ ...r, parsedStyles, styleOptions: seedByTag.get(r.tag)?.styleOptions ?? [] });
}
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
const snippet = (v: string) => (v.length > 60 ? `${v.slice(0, 60)}…` : v);
const isLong = (v: string) => v.length > 80 || v.includes('\n');
const imgLabel = (v: string) => (v ? '(immagine personalizzata)' : '(immagine originale)');
const allStyles = STYLE_GROUPS as Record<string, readonly string[]>;
---
<Admin title="Contenuti">
<div class="chead">
<h1>Contenuti</h1>
<a href="/?annotate=1" target="_blank">Vedi sito con tag ↗</a>
</div>
<input class="afield" id="csearch" type="search" placeholder="Cerca per tag o contenuto…" autocomplete="off" />
<div id="cgroups">
{GROUP_ORDER.map((g) => {
const items = groups.get(g)!;
return items.length > 0 && (
<details class="cgroup">
<summary><strong>{g}</strong> <span class="pill">{items.length}</span></summary>
{items.map((it) => (
<details class="crow" id={it.tag} data-tag={it.tag} data-q={`${it.tag} ${it.value}`.toLowerCase()}>
<summary>
<code>{it.tag}</code>
<span class="crow-snippet">{it.type === 'image' ? imgLabel(it.value) : snippet(it.value)}</span>
</summary>
<div class="crow-body">
{it.type === 'image' ? (
<>
<p class="cimg-preview">
{it.value
? <img src={`/uploads/${it.value}`} alt={it.tag} />
: <em>Nessun override: il sito usa l'immagine originale.</em>}
</p>
<p class="cactions">
<input type="file" accept="image/*" data-file />
<button class="abtn" type="button" data-upload>Carica</button>
<button class="abtn abtn--danger" type="button" data-restore>Ripristina originale</button>
</p>
</>
) : (
<>
{isLong(it.value)
? <textarea class="afield" rows="4" data-value>{it.value}</textarea>
: <input class="afield" value={it.value} data-value />}
{it.styleOptions.length > 0 && (
<p class="cstyles">
{it.styleOptions.map((sg) => (
<label>{sg}
<select data-style={sg}>
<option value="">(default)</option>
{allStyles[sg].map((v) => (
<option value={v} selected={it.parsedStyles[sg] === v}>{v}</option>
))}
</select>
</label>
))}
</p>
)}
<p class="cactions"><button class="abtn" type="button" data-save>Salva</button></p>
</>
)}
<p class="form-msg" data-msg hidden></p>
{it.updated_by && (
<p class="cmeta">Aggiornato {fmt.format(new Date(it.updated_at.replace(' ', 'T') + 'Z'))} da {it.updated_by}</p>
)}
</div>
</details>
))}
</details>
);
})}
</div>
<p id="cempty" hidden>Nessun contenuto corrisponde alla ricerca.</p>
</Admin>
<style is:global>
.chead { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
.chead a { color: #7a6a55; }
.cgroup { background: #fff; border: 1px solid #ddd; margin-bottom: 14px; }
.cgroup > summary { padding: 12px 16px; cursor: pointer; background: #eee9e1; font-size: 1.05rem; }
.crow { border-top: 1px solid #eee; }
.crow > summary { padding: 8px 16px; cursor: pointer; display: flex; gap: 12px; align-items: baseline; }
.crow > summary code { background: #f0ece5; padding: 1px 6px; font-size: .82rem; white-space: nowrap; }
.crow-snippet { color: #888; font-size: .85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.crow-body { padding: 6px 16px 12px; }
.crow-body .afield { margin-bottom: 8px; }
.cstyles { display: flex; gap: 14px; flex-wrap: wrap; margin: 0 0 8px; }
.cstyles label { font-size: .85rem; color: #555; }
.cstyles select { margin-left: 4px; padding: 4px; font: inherit; }
.cactions { margin: 0 0 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.cimg-preview img { max-width: 240px; max-height: 140px; display: block; border: 1px solid #ddd; }
.cmeta { color: #999; font-size: .78rem; margin: 4px 0 0; }
.crow--hl { outline: 2px solid #b5a48b; }
</style>
<script>
const wrap = document.getElementById('cgroups')!;
function showMsg(row: HTMLElement, ok: boolean, text: string): void {
const m = row.querySelector<HTMLElement>('[data-msg]')!;
m.textContent = text;
m.hidden = false;
m.className = `form-msg ${ok ? 'form-msg--ok' : 'form-msg--err'}`;
}
function setPreview(row: HTMLElement, url: string | null): void {
const p = row.querySelector<HTMLElement>('.cimg-preview')!;
if (url) {
p.textContent = '';
const img = document.createElement('img');
img.src = url;
img.alt = row.dataset.tag ?? '';
p.append(img);
} else {
p.innerHTML = "<em>Nessun override: il sito usa l'immagine originale.</em>";
}
row.querySelector<HTMLElement>('.crow-snippet')!.textContent =
url ? '(immagine personalizzata)' : '(immagine originale)';
}
async function call(row: HTMLElement, btn: HTMLButtonElement, req: () => Promise<Response>,
onOk: (data: Record<string, string>) => void, okMsg: string, errMsg: string): Promise<void> {
btn.disabled = true;
try {
const res = await req();
const data = await res.json().catch(() => ({}));
if (res.ok) { onOk(data); showMsg(row, true, okMsg); }
else showMsg(row, false, data.error ?? errMsg);
} catch {
showMsg(row, false, 'Errore di rete.');
} finally {
btn.disabled = false;
}
}
wrap.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('button');
if (!btn) return;
const row = btn.closest<HTMLElement>('details.crow')!;
const api = `/api/admin/content/${encodeURIComponent(row.dataset.tag!)}`;
if (btn.hasAttribute('data-save')) {
const value = row.querySelector<HTMLInputElement | HTMLTextAreaElement>('[data-value]')!.value;
const styles: Record<string, string> = {};
row.querySelectorAll<HTMLSelectElement>('[data-style]').forEach((s) => {
if (s.value) styles[s.dataset.style!] = s.value;
});
call(row, btn,
() => fetch(api, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value, styles }),
}),
() => {
row.querySelector<HTMLElement>('.crow-snippet')!.textContent =
value.length > 60 ? `${value.slice(0, 60)}…` : value;
},
'Salvato.', 'Salvataggio non riuscito.');
} else if (btn.hasAttribute('data-upload')) {
const file = row.querySelector<HTMLInputElement>('[data-file]')!.files?.[0];
if (!file) { showMsg(row, false, 'Seleziona prima un file.'); return; }
const fd = new FormData();
fd.append('file', file);
call(row, btn,
() => fetch(`${api}/image`, { method: 'POST', body: fd }),
(data) => setPreview(row, data.url),
'Immagine caricata.', 'Upload non riuscito.');
} else if (btn.hasAttribute('data-restore')) {
call(row, btn,
() => fetch(`${api}/image`, { method: 'DELETE' }),
() => setPreview(row, null),
'Ripristinata immagine originale.', 'Ripristino non riuscito.');
}
});
// Ricerca client-side: nasconde le righe non corrispondenti (tag o contenuto).
const search = document.getElementById('csearch') as HTMLInputElement;
const rowEls = Array.from(document.querySelectorAll<HTMLDetailsElement>('details.crow'));
const groupEls = Array.from(document.querySelectorAll<HTMLDetailsElement>('details.cgroup'));
search.addEventListener('input', () => {
const q = search.value.trim().toLowerCase();
for (const r of rowEls) r.hidden = q !== '' && !(r.dataset.q ?? '').includes(q);
let any = false;
for (const g of groupEls) {
const visible = g.querySelector('details.crow:not([hidden])') !== null;
g.hidden = !visible;
if (q && visible) g.open = true;
any = any || visible;
}
document.getElementById('cempty')!.hidden = any;
});
// Ancore: #<tag> apre gruppo + riga, scrolla ed evidenzia.
function openHash(): void {
const tag = decodeURIComponent(location.hash.slice(1));
if (!tag) return;
const row = document.getElementById(tag);
if (!row || !row.classList.contains('crow')) return;
const group = row.closest<HTMLDetailsElement>('details.cgroup');
if (group) group.open = true;
(row as HTMLDetailsElement).open = true;
row.scrollIntoView({ block: 'center' });
row.classList.add('crow--hl');
setTimeout(() => row.classList.remove('crow--hl'), 2500);
}
window.addEventListener('hashchange', openHash);
openHash();
</script>