Articoli: cestino con ripristino, e azioni a icona come in Utenti

Eliminare un articolo non lasciava nulla di recuperabile: ora la riga
finisce nel cestino (colonna deleted_at, aggiunta anche ai database già
esistenti) e sparisce da sito e pannello, ma resta ripristinabile dalla
nuova pagina /admin/cestino, dove sta anche la cancellazione definitiva.
L'API la accetta solo su articoli già cestinati, così il contenuto non si
perde per un clic distratto.

Ogni lettura pubblica — elenco, recenti, archivio per mese, pagina del
singolo articolo — filtra il cestino, e chi non gestisce vede nel cestino
soltanto i propri articoli.

Le azioni di riga diventano icone (matita e cestino) come nell'elenco
utenti, con etichetta accessibile che nomina l'articolo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:47:27 +02:00
parent 59d6246c77
commit 6a97a98325
7 changed files with 232 additions and 17 deletions
+70
View File
@@ -0,0 +1,70 @@
---
// Cestino degli articoli: qui si recupera ciò che è stato tolto dal sito, o lo si elimina
// per sempre. Chi gestisce vede tutto, gli altri solo i propri articoli.
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
import { listTrashed } from '../../lib/posts';
import { listUsers } from '../../lib/auth';
export const prerender = false;
const user = Astro.locals.user!;
const isManager = user.role === 'superuser' || user.role === 'admin';
const posts = listTrashed(getDb(), isManager ? undefined : user.id);
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
---
<Admin title="Cestino">
<h1>Cestino</h1>
<p class="atoolbar"><a class="abtn abtn--ghost" href="/admin">← Torna agli articoli</a></p>
{posts.length === 0 ? (
<p class="amuted">Il cestino è vuoto.</p>
) : (
<table>
<thead>
<tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Nel cestino dal</th><th class="uactions">Azioni</th></tr>
</thead>
<tbody>
{posts.map((p) => (
<tr>
<td>{p.title}</td>
<td>{p.category}</td>
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
<td>{p.deleted_at ? fmt.format(new Date(p.deleted_at.replace(' ', 'T') + 'Z')) : '—'}</td>
<td class="uactions">
<button class="aicon" data-restore={p.id} title="Ripristina" aria-label={`Ripristina ${p.title}`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5V2L7 6l5 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7Z"/></svg>
</button>
<button class="aicon aicon--danger" data-purge={p.id} title="Elimina definitivamente" aria-label={`Elimina definitivamente ${p.title}`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<p class="form-msg" id="row-msg"></p>
</Admin>
<script>
const rowMsg = document.getElementById('row-msg')!;
const errore = async (res: Response) => {
rowMsg.className = 'form-msg form-msg--err';
rowMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
};
document.querySelectorAll<HTMLButtonElement>('[data-restore]').forEach((b) =>
b.addEventListener('click', async () => {
const res = await fetch(`/api/admin/posts/${b.dataset.restore}`, { method: 'POST' });
if (res.ok) location.reload();
else errore(res);
}));
document.querySelectorAll<HTMLButtonElement>('[data-purge]').forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Eliminare definitivamente questo articolo? Il contenuto sarà perso.')) return;
const res = await fetch(`/api/admin/posts/${b.dataset.purge}?definitivo=1`, { method: 'DELETE' });
if (res.ok) location.reload();
else errore(res);
}));
</script>
+28 -7
View File
@@ -1,20 +1,28 @@
---
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
import { listAllPosts, listByAuthor } from '../../lib/posts';
import { listAllPosts, listByAuthor, listTrashed } from '../../lib/posts';
import { listUsers } from '../../lib/auth';
export const prerender = false;
const user = Astro.locals.user!;
const isManager = user.role === 'superuser' || user.role === 'admin';
const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
const cestinati = listTrashed(getDb(), isManager ? undefined : user.id).length;
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
---
<Admin title="Articoli">
<h1>Articoli</h1>
<p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
<p class="atoolbar">
<a class="abtn" href="/admin/new">+ Nuovo articolo</a>
{cestinati > 0 && <a class="abtn abtn--ghost" href="/admin/cestino">Cestino ({cestinati})</a>}
</p>
<table>
<thead><tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
<thead>
<tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th class="uactions">Azioni</th></tr>
</thead>
<tbody>
{posts.map((p) => (
<tr>
@@ -23,20 +31,33 @@ const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 's
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
<td class="uactions">
<a class="aicon" href={`/admin/edit/${p.id}`} title="Modifica" aria-label={`Modifica ${p.title}`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 17 9.5-9.5 3 3L7 20H4v-3Zm11.7-11.7 1.6-1.6a1.4 1.4 0 0 1 2 0l1 1a1.4 1.4 0 0 1 0 2l-1.6 1.6-3-3Z"/></svg>
</a>
<button class="aicon aicon--danger" data-del={p.id} title="Sposta nel cestino" aria-label={`Sposta ${p.title} nel cestino`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
{posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>}
{posts.length === 0 && <p class="amuted">Nessun articolo: creane uno nuovo.</p>}
<p class="form-msg" id="row-msg"></p>
</Admin>
<script>
const rowMsg = document.getElementById('row-msg')!;
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Eliminare definitivamente questo articolo?')) return;
if (!confirm('Spostare questo articolo nel cestino? Sparirà dal sito, ma potrai recuperarlo.')) return;
const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
if (res.ok) location.reload();
else alert('Eliminazione non riuscita.');
else {
rowMsg.className = 'form-msg form-msg--err';
rowMsg.textContent = (await res.json()).error ?? 'Operazione non riuscita.';
}
}));
</script>
+24 -3
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db';
import { getPostById, updatePost, deletePost, canModifyPost } from '../../../../lib/posts';
import { getPostById, updatePost, deletePost, trashPost, restorePost, canModifyPost } from '../../../../lib/posts';
import { parsePostBody } from '../../../../lib/post-body';
export const prerender = false;
@@ -25,11 +25,32 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
}
};
export const DELETE: APIRoute = ({ params, locals }) => {
/**
* Sposta l'articolo nel cestino. Con `?definitivo=1` lo cancella davvero: è la sola via per
* perdere il contenuto, e vale solo per ciò che è già nel cestino.
*/
export const DELETE: APIRoute = ({ params, locals, url }) => {
const id = Number(params.id);
const existing = getPostById(getDb(), id);
if (!existing) return json(404, { error: 'Articolo non trovato.' });
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' });
deletePost(getDb(), id);
if (url.searchParams.get('definitivo') === '1') {
if (!existing.deleted_at) return json(400, { error: 'Sposta prima l\'articolo nel cestino.' });
deletePost(getDb(), id);
return json(200, { ok: true, definitivo: true });
}
trashPost(getDb(), id);
return json(200, { ok: true });
};
/** Riporta l'articolo fuori dal cestino, nello stato in cui era (bozza o pubblicato). */
export const POST: APIRoute = ({ params, locals }) => {
const id = Number(params.id);
const existing = getPostById(getDb(), id);
if (!existing) return json(404, { error: 'Articolo non trovato.' });
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi ripristinare questo articolo.' });
restorePost(getDb(), id);
return json(200, { ok: true });
};