diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro index 5f6e49d..4eb56b5 100644 --- a/src/layouts/Admin.astro +++ b/src/layouts/Admin.astro @@ -123,6 +123,9 @@ const attiva = (v: { href: string; exact?: boolean }) => } .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; } .alabel { 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; } /* Comandi di riga: icona sola, per non far pesare le azioni quanto il contenuto. */ + a.aicon { text-decoration: none; } .aicon { 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); diff --git a/src/lib/db.ts b/src/lib/db.ts index 23fe5af..dbfb80e 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -16,7 +16,9 @@ CREATE TABLE IF NOT EXISTS posts ( draft INTEGER NOT NULL DEFAULT 1, published_at TEXT, 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 ( 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. 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 // 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 }[]; diff --git a/src/lib/posts.ts b/src/lib/posts.ts index 2b346dd..199df79 100644 --- a/src/lib/posts.ts +++ b/src/lib/posts.ts @@ -4,6 +4,8 @@ export interface Post { id: number; title: string; slug: string; category: string; excerpt: string; body_html: string; cover: string | null; draft: number; author_id: number | null; 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 { @@ -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 }); } +/** 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 { 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 { - 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( @@ -47,7 +59,8 @@ export function listPublished( opts: { category?: string; page?: number; perPage?: number } = {}, ): { items: Post[]; total: number } { 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}`) .get({ category }) as { n: number }).n; const items = db.prepare( @@ -57,24 +70,33 @@ export function listPublished( } 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[] { 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[]; } export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] { return db.prepare( `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 }[]; } 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 { diff --git a/src/pages/admin/cestino.astro b/src/pages/admin/cestino.astro new file mode 100644 index 0000000..99b3c15 --- /dev/null +++ b/src/pages/admin/cestino.astro @@ -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' }); +--- + +

Cestino

+

← Torna agli articoli

+ + {posts.length === 0 ? ( +

Il cestino è vuoto.

+ ) : ( + + + {isManager && } + + + {posts.map((p) => ( + + + + {isManager && } + + + + ))} + +
TitoloCategoriaAutoreNel cestino dalAzioni
{p.title}{p.category}{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}{p.deleted_at ? fmt.format(new Date(p.deleted_at.replace(' ', 'T') + 'Z')) : '—'} + + +
+ )} +

+
+ + diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index 24cb0ca..e5708fa 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -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' }); ---

Articoli

-

+ Nuovo articolo

+ +

+ + Nuovo articolo + {cestinati > 0 && Cestino ({cestinati})} +

+ - {isManager && } + + {isManager && } + {posts.map((p) => ( @@ -23,20 +31,33 @@ const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 's {isManager && } - + ))}
TitoloCategoriaAutoreStatoAggiornato
TitoloCategoriaAutoreStatoAggiornatoAzioni
{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}{p.draft ? 'Bozza' : 'Pubblicato'} {fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))} + + + + +
- {posts.length === 0 &&

Nessun articolo: creane uno nuovo.

} + {posts.length === 0 &&

Nessun articolo: creane uno nuovo.

} +

diff --git a/src/pages/api/admin/posts/[id].ts b/src/pages/api/admin/posts/[id].ts index 8c7ef21..7b50e77 100644 --- a/src/pages/api/admin/posts/[id].ts +++ b/src/pages/api/admin/posts/[id].ts @@ -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 }); }; diff --git a/tests/posts-cestino.test.ts b/tests/posts-cestino.test.ts new file mode 100644 index 0000000..ae5b91b --- /dev/null +++ b/tests/posts-cestino.test.ts @@ -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: '

x

', 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([]); + }); +});