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; }
|
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>
|
||||||
|
|||||||
@@ -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