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:
@@ -123,6 +123,9 @@ const attiva = (v: { href: string; exact?: boolean }) =>
|
|||||||
}
|
}
|
||||||
.pill--draft { background: rgba(192,138,62,.18); color: #96682a; }
|
.pill--draft { background: rgba(192,138,62,.18); color: #96682a; }
|
||||||
|
|
||||||
|
.atoolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.amuted { color: var(--c-text-light); font-size: .9rem; }
|
||||||
|
|
||||||
.aform { max-width: 420px; background: #fff; border: 1px solid #e4dfd7; padding: 26px 24px; }
|
.aform { max-width: 420px; background: #fff; border: 1px solid #e4dfd7; padding: 26px 24px; }
|
||||||
.alabel {
|
.alabel {
|
||||||
display: block; margin-bottom: 6px; font-family: var(--font-heading); font-size: .68rem;
|
display: block; margin-bottom: 6px; font-family: var(--font-heading); font-size: .68rem;
|
||||||
@@ -161,6 +164,7 @@ const attiva = (v: { href: string; exact?: boolean }) =>
|
|||||||
.apw__input { width: auto; min-width: 260px; margin-bottom: 0; }
|
.apw__input { width: auto; min-width: 260px; margin-bottom: 0; }
|
||||||
|
|
||||||
/* Comandi di riga: icona sola, per non far pesare le azioni quanto il contenuto. */
|
/* Comandi di riga: icona sola, per non far pesare le azioni quanto il contenuto. */
|
||||||
|
a.aicon { text-decoration: none; }
|
||||||
.aicon {
|
.aicon {
|
||||||
display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px;
|
display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px;
|
||||||
padding: 0; background: none; border: 1px solid #ddd6cb; cursor: pointer; color: var(--c-text);
|
padding: 0; background: none; border: 1px solid #ddd6cb; cursor: pointer; color: var(--c-text);
|
||||||
|
|||||||
+9
-1
@@ -16,7 +16,9 @@ CREATE TABLE IF NOT EXISTS posts (
|
|||||||
draft INTEGER NOT NULL DEFAULT 1,
|
draft INTEGER NOT NULL DEFAULT 1,
|
||||||
published_at TEXT,
|
published_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
-- Cestino: valorizzata quando l'articolo viene rimosso dal sito ma resta recuperabile.
|
||||||
|
deleted_at TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -245,6 +247,12 @@ export function createDb(path?: string): Database.Database {
|
|||||||
// visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti.
|
// visibile ai soli admin): i tag che la servivano restano orfani nei DB esistenti.
|
||||||
db.prepare("DELETE FROM content_blocks WHERE tag IN ('global.nav.7.label', 'promo.pagehero.title')").run();
|
db.prepare("DELETE FROM content_blocks WHERE tag IN ('global.nav.7.label', 'promo.pagehero.title')").run();
|
||||||
|
|
||||||
|
// Cestino degli articoli: colonna aggiunta a posteriori sui database già esistenti.
|
||||||
|
const colonnePost = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
|
||||||
|
if (!colonnePost.some((c) => c.name === 'deleted_at')) {
|
||||||
|
db.exec('ALTER TABLE posts ADD COLUMN deleted_at TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
|
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
|
||||||
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
|
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
|
||||||
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
|
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
|
||||||
|
|||||||
+28
-6
@@ -4,6 +4,8 @@ export interface Post {
|
|||||||
id: number; title: string; slug: string; category: string; excerpt: string;
|
id: number; title: string; slug: string; category: string; excerpt: string;
|
||||||
body_html: string; cover: string | null; draft: number; author_id: number | null;
|
body_html: string; cover: string | null; draft: number; author_id: number | null;
|
||||||
published_at: string | null; created_at: string; updated_at: string;
|
published_at: string | null; created_at: string; updated_at: string;
|
||||||
|
/** Momento in cui è finito nel cestino; null se attivo. */
|
||||||
|
deleted_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostInput {
|
export interface PostInput {
|
||||||
@@ -30,6 +32,16 @@ export function updatePost(db: Database.Database, id: number, input: PostInput):
|
|||||||
).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
|
).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sposta nel cestino: l'articolo sparisce dal sito e dall'elenco, ma resta recuperabile. */
|
||||||
|
export function trashPost(db: Database.Database, id: number): void {
|
||||||
|
db.prepare("UPDATE posts SET deleted_at = datetime('now') WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restorePost(db: Database.Database, id: number): void {
|
||||||
|
db.prepare('UPDATE posts SET deleted_at = NULL WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancellazione definitiva: si usa solo dal cestino. */
|
||||||
export function deletePost(db: Database.Database, id: number): void {
|
export function deletePost(db: Database.Database, id: number): void {
|
||||||
db.prepare('DELETE FROM posts WHERE id = ?').run(id);
|
db.prepare('DELETE FROM posts WHERE id = ?').run(id);
|
||||||
}
|
}
|
||||||
@@ -39,7 +51,7 @@ export function getPostById(db: Database.Database, id: number): Post | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
|
export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined {
|
||||||
return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined;
|
return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0 AND deleted_at IS NULL').get(slug) as Post | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listPublished(
|
export function listPublished(
|
||||||
@@ -47,7 +59,8 @@ export function listPublished(
|
|||||||
opts: { category?: string; page?: number; perPage?: number } = {},
|
opts: { category?: string; page?: number; perPage?: number } = {},
|
||||||
): { items: Post[]; total: number } {
|
): { items: Post[]; total: number } {
|
||||||
const { category, page = 1, perPage = 9 } = opts;
|
const { category, page = 1, perPage = 9 } = opts;
|
||||||
const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0';
|
const base = 'WHERE draft = 0 AND deleted_at IS NULL';
|
||||||
|
const where = category ? `${base} AND category = @category` : base;
|
||||||
const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
|
const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`)
|
||||||
.get({ category }) as { n: number }).n;
|
.get({ category }) as { n: number }).n;
|
||||||
const items = db.prepare(
|
const items = db.prepare(
|
||||||
@@ -57,24 +70,33 @@ export function listPublished(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listAllPosts(db: Database.Database): Post[] {
|
export function listAllPosts(db: Database.Database): Post[] {
|
||||||
return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[];
|
return db.prepare('SELECT * FROM posts WHERE deleted_at IS NULL ORDER BY updated_at DESC').all() as Post[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Articoli nel cestino: tutti per chi gestisce, i propri per gli altri. */
|
||||||
|
export function listTrashed(db: Database.Database, authorId?: number): Post[] {
|
||||||
|
return authorId === undefined
|
||||||
|
? db.prepare('SELECT * FROM posts WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC').all() as Post[]
|
||||||
|
: db.prepare('SELECT * FROM posts WHERE deleted_at IS NOT NULL AND author_id = ? ORDER BY deleted_at DESC')
|
||||||
|
.all(authorId) as Post[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRecent(db: Database.Database, limit = 4): Post[] {
|
export function listRecent(db: Database.Database, limit = 4): Post[] {
|
||||||
return db.prepare(
|
return db.prepare(
|
||||||
'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?'
|
'SELECT * FROM posts WHERE draft = 0 AND deleted_at IS NULL ORDER BY published_at DESC LIMIT ?'
|
||||||
).all(limit) as Post[];
|
).all(limit) as Post[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
|
export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] {
|
||||||
return db.prepare(
|
return db.prepare(
|
||||||
`SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
|
`SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count
|
||||||
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
|
FROM posts WHERE draft = 0 AND deleted_at IS NULL GROUP BY month ORDER BY month DESC`
|
||||||
).all() as { month: string; count: number }[];
|
).all() as { month: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listByAuthor(db: Database.Database, authorId: number): Post[] {
|
export function listByAuthor(db: Database.Database, authorId: number): Post[] {
|
||||||
return db.prepare('SELECT * FROM posts WHERE author_id = ? ORDER BY updated_at DESC').all(authorId) as Post[];
|
return db.prepare('SELECT * FROM posts WHERE author_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC')
|
||||||
|
.all(authorId) as Post[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
|
export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
---
|
---
|
||||||
import Admin from '../../layouts/Admin.astro';
|
import Admin from '../../layouts/Admin.astro';
|
||||||
import { getDb } from '../../lib/db';
|
import { getDb } from '../../lib/db';
|
||||||
import { listAllPosts, listByAuthor } from '../../lib/posts';
|
import { listAllPosts, listByAuthor, listTrashed } from '../../lib/posts';
|
||||||
import { listUsers } from '../../lib/auth';
|
import { listUsers } from '../../lib/auth';
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
const user = Astro.locals.user!;
|
const user = Astro.locals.user!;
|
||||||
const isManager = user.role === 'superuser' || user.role === 'admin';
|
const isManager = user.role === 'superuser' || user.role === 'admin';
|
||||||
const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
|
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 nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
|
||||||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
---
|
---
|
||||||
<Admin title="Articoli">
|
<Admin title="Articoli">
|
||||||
<h1>Articoli</h1>
|
<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>
|
<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>
|
<tbody>
|
||||||
{posts.map((p) => (
|
{posts.map((p) => (
|
||||||
<tr>
|
<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>}
|
{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><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>{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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</Admin>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const rowMsg = document.getElementById('row-msg')!;
|
||||||
|
|
||||||
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||||||
b.addEventListener('click', async () => {
|
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' });
|
const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
|
||||||
if (res.ok) location.reload();
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { getDb } from '../../../../lib/db';
|
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';
|
import { parsePostBody } from '../../../../lib/post-body';
|
||||||
export const prerender = false;
|
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 id = Number(params.id);
|
||||||
const existing = getPostById(getDb(), id);
|
const existing = getPostById(getDb(), id);
|
||||||
if (!existing) return json(404, { error: 'Articolo non trovato.' });
|
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.' });
|
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 });
|
return json(200, { ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { createDb } from '../src/lib/db';
|
||||||
|
import {
|
||||||
|
createPost, trashPost, restorePost, deletePost, getPostById, listAllPosts, listByAuthor,
|
||||||
|
listTrashed, listPublished, listRecent, listArchiveMonths, getPublishedBySlug,
|
||||||
|
} from '../src/lib/posts';
|
||||||
|
|
||||||
|
let db: Database.Database;
|
||||||
|
let id: number;
|
||||||
|
|
||||||
|
const articolo = (slug: string, authorId: number | null = 1) => ({
|
||||||
|
title: `Titolo ${slug}`, slug, category: 'training', excerpt: 'x',
|
||||||
|
body_html: '<p>x</p>', draft: false, author_id: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createDb(':memory:');
|
||||||
|
id = createPost(db, articolo('uno'));
|
||||||
|
createPost(db, articolo('due', 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cestino', () => {
|
||||||
|
it('cestinare non cancella: l\'articolo resta leggibile per id', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
const p = getPostById(db, id);
|
||||||
|
expect(p).toBeDefined();
|
||||||
|
expect(p!.deleted_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lo toglie da tutti gli elenchi del pannello', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
expect(listAllPosts(db).map((p) => p.slug)).toEqual(['due']);
|
||||||
|
expect(listByAuthor(db, 1)).toEqual([]);
|
||||||
|
expect(listTrashed(db).map((p) => p.slug)).toEqual(['uno']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lo toglie dal sito pubblico: elenco, recenti, archivio e pagina singola', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
expect(listPublished(db).items.map((p) => p.slug)).toEqual(['due']);
|
||||||
|
expect(listPublished(db).total).toBe(1);
|
||||||
|
expect(listPublished(db, { category: 'training' }).total).toBe(1);
|
||||||
|
expect(listRecent(db).map((p) => p.slug)).toEqual(['due']);
|
||||||
|
expect(listArchiveMonths(db).reduce((n, m) => n + m.count, 0)).toBe(1);
|
||||||
|
expect(getPublishedBySlug(db, 'uno')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il cestino di un autore contiene solo i suoi articoli', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
expect(listTrashed(db, 1).map((p) => p.slug)).toEqual(['uno']);
|
||||||
|
expect(listTrashed(db, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('il ripristino lo rimette dov\'era', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
restorePost(db, id);
|
||||||
|
expect(getPostById(db, id)!.deleted_at).toBeNull();
|
||||||
|
expect(listTrashed(db)).toEqual([]);
|
||||||
|
expect(listAllPosts(db).map((p) => p.slug)).toContain('uno');
|
||||||
|
expect(getPublishedBySlug(db, 'uno')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la cancellazione definitiva rimuove davvero la riga', () => {
|
||||||
|
trashPost(db, id);
|
||||||
|
deletePost(db, id);
|
||||||
|
expect(getPostById(db, id)).toBeUndefined();
|
||||||
|
expect(listTrashed(db)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user