feat: pannello admin contenuti
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
.pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
|
||||
.pill--draft { background: #f3d9a4; }
|
||||
.form-msg { font-size: .9rem; margin-top: 8px; }
|
||||
.form-msg--ok { color: #3c7a3c; }
|
||||
.form-msg--err { color: #b03030; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="abar">
|
||||
<strong>InsanityLab · Admin</strong>
|
||||
<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>
|
||||
</div>
|
||||
<div class="awrap"><slot /></div>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user