feat: area admin con editor tiptap, api crud e upload immagini
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import PostForm from '../../../components/admin/PostForm.astro';
|
||||
import { getDb } from '../../../lib/db';
|
||||
import { getPostById } from '../../../lib/posts';
|
||||
export const prerender = false;
|
||||
const post = getPostById(getDb(), Number(Astro.params.id));
|
||||
if (!post) return Astro.redirect('/admin');
|
||||
---
|
||||
<Admin title={`Modifica: ${post.title}`}>
|
||||
<h1>Modifica articolo</h1>
|
||||
<script id="initial-body" type="text/plain" set:html={post.body_html}></script>
|
||||
<PostForm post={post} />
|
||||
</Admin>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { listAllPosts } from '../../lib/posts';
|
||||
export const prerender = false;
|
||||
const posts = listAllPosts(getDb());
|
||||
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>
|
||||
<table>
|
||||
<thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{posts.map((p) => (
|
||||
<tr>
|
||||
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
|
||||
<td>{p.category}</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{posts.length === 0 && <p>Nessun articolo: creane uno nuovo.</p>}
|
||||
</Admin>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||||
b.addEventListener('click', async () => {
|
||||
if (!confirm('Eliminare definitivamente questo articolo?')) return;
|
||||
const res = await fetch(`/api/admin/posts/${b.dataset.del}`, { method: 'DELETE' });
|
||||
if (res.ok) location.reload();
|
||||
else alert('Eliminazione non riuscita.');
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { login, SESSION_COOKIE } from '../../lib/auth';
|
||||
import { rateLimit } from '../../lib/rate-limit';
|
||||
export const prerender = false;
|
||||
|
||||
let error = '';
|
||||
if (Astro.request.method === 'POST') {
|
||||
if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
|
||||
error = 'Troppi tentativi: riprova tra 15 minuti.';
|
||||
} else {
|
||||
const form = await Astro.request.formData();
|
||||
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
||||
if (token) {
|
||||
Astro.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||||
});
|
||||
return Astro.redirect('/admin');
|
||||
}
|
||||
error = 'Credenziali non valide.';
|
||||
}
|
||||
}
|
||||
---
|
||||
<Admin title="Login">
|
||||
<h1>Accesso</h1>
|
||||
{error && <p style="color:#a33">{error}</p>}
|
||||
<form method="post" style="max-width:360px">
|
||||
<input class="afield" name="username" placeholder="Utente" required autocomplete="username" />
|
||||
<input class="afield" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
||||
<button class="abtn" type="submit">Entra</button>
|
||||
</form>
|
||||
</Admin>
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { logout, SESSION_COOKIE } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = ({ cookies, redirect }) => {
|
||||
const token = cookies.get(SESSION_COOKIE)?.value;
|
||||
if (token) logout(getDb(), token);
|
||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
return redirect('/admin/login');
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import PostForm from '../../components/admin/PostForm.astro';
|
||||
export const prerender = false;
|
||||
---
|
||||
<Admin title="Nuovo articolo">
|
||||
<h1>Nuovo articolo</h1>
|
||||
<script id="initial-body" type="text/plain"></script>
|
||||
<PostForm />
|
||||
</Admin>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { getPostById, updatePost, deletePost } from '../../../../lib/posts';
|
||||
import { parsePostBody } from '../../../../lib/post-body';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request }) => {
|
||||
const id = Number(params.id);
|
||||
if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' });
|
||||
let data: Record<string, unknown>;
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
const parsed = parsePostBody(data);
|
||||
if ('error' in parsed) return json(400, { error: parsed.error });
|
||||
try {
|
||||
updatePost(getDb(), id, parsed.value);
|
||||
return json(200, { ok: true });
|
||||
} catch (err: any) {
|
||||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = ({ params }) => {
|
||||
deletePost(getDb(), Number(params.id));
|
||||
return json(200, { ok: true });
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { createPost } from '../../../../lib/posts';
|
||||
import { parsePostBody } from '../../../../lib/post-body';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
let data: Record<string, unknown>;
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
const parsed = parsePostBody(data);
|
||||
if ('error' in parsed) return json(400, { error: parsed.error });
|
||||
try {
|
||||
const id = createPost(getDb(), parsed.value);
|
||||
return json(201, { id, slug: parsed.value.slug });
|
||||
} catch (err: any) {
|
||||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { join, extname } from 'node:path';
|
||||
import { getEnv } from '../../../lib/env';
|
||||
export const prerender = false;
|
||||
|
||||
const ALLOWED: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const form = await request.formData().catch(() => null);
|
||||
const file = form?.get('file');
|
||||
if (!(file instanceof File)) return json(400, { error: 'Nessun file.' });
|
||||
const ext = extname(file.name).toLowerCase();
|
||||
if (!ALLOWED[ext]) return json(400, { error: 'Formato non consentito (jpg, png, webp, gif).' });
|
||||
if (file.size > MAX_BYTES) return json(400, { error: 'File troppo grande (max 5 MB).' });
|
||||
const dir = getEnv('UPLOADS_DIR', 'uploads');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const name = `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
||||
await writeFile(join(dir, name), Buffer.from(await file.arrayBuffer()));
|
||||
return json(200, { url: `/uploads/${name}` });
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, normalize, extname } from 'node:path';
|
||||
import { getEnv } from '../../lib/env';
|
||||
export const prerender = false;
|
||||
|
||||
const MIME: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const rel = normalize(params.path ?? '').replace(/^(\.\.[/\\])+/, '');
|
||||
if (!rel || rel.includes('..')) return new Response('Not found', { status: 404 });
|
||||
const mime = MIME[extname(rel).toLowerCase()];
|
||||
if (!mime) return new Response('Not found', { status: 404 });
|
||||
try {
|
||||
const buf = await readFile(join(getEnv('UPLOADS_DIR', 'uploads'), rel));
|
||||
return new Response(buf, { headers: { 'Content-Type': mime, 'Cache-Control': 'public, max-age=31536000, immutable' } });
|
||||
} catch {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user