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' });
+---
+ Il cestino è vuoto. Nessun articolo: creane uno nuovo. Nessun articolo: creane uno nuovo.Cestino
+
+
+ {posts.length === 0 ? (
+
+
+
+ )}
+
+
+
+
+ {posts.map((p) => (
+ Titolo Categoria {isManager && Autore }Nel cestino dal Azioni
+
+ ))}
+
+ {p.title}
+ {p.category}
+ {isManager && {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')) : '—'}
+
+
+
+
+ Articoli
-
+
+
+
-
- {posts.length === 0 &&
+
+ Titolo Categoria {isManager && Autore }Stato Aggiornato
+
{posts.map((p) => (
Titolo Categoria {isManager && Autore }Stato Aggiornato Azioni
@@ -23,20 +31,33 @@ const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 's
{isManager &&
))}
{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'))}
-
+
+
+
+
+
+
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([]); + }); +});