Sezione Campus: layout dedicato, route protette e documentazione

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 15:40:07 +02:00
parent 7baebebc09
commit c4107b84a5
15 changed files with 565 additions and 52 deletions
+1
View File
@@ -37,6 +37,7 @@ const user = Astro.locals.user;
<a href="/admin">Articoli</a>
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
{user.role === 'admin' && <a href="/campus">Campus</a>}
<a href="/admin/new">Nuovo</a>
<a href="/admin/logout">Esci</a>
<a href="/blog" target="_blank">Vedi sito ↗</a>
+137
View File
@@ -0,0 +1,137 @@
---
// Layout della sezione riservata Campus. Usa i token e i font del sito, ma
// non ne monta Header e Footer: la navigazione è tutta interna, con un solo
// richiamo alla home.
import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css';
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/600.css';
import '../styles/global.css';
import type { CampusNav } from '../lib/campus';
interface Props {
title: string;
nav: CampusNav;
currentArea?: string;
currentLesson?: string;
}
const { title, nav, currentArea, currentLesson } = Astro.props;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — Campus InsanityLab</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head>
<body class="cp">
<input type="checkbox" id="cp-menu" class="cp-menu-toggle" hidden />
<label for="cp-menu" class="cp-menu-btn" aria-label="Apri il menu">☰ Indice</label>
<aside class="cp-side">
<a class="cp-brand" href="/campus">
<span class="eyebrow">InsanityLab</span>
Campus
</a>
<a class="cp-home" href="/">← Torna alla home</a>
<nav class="cp-nav">
{nav.areas.map((area) => (
<div class="cp-area" data-on={area.slug === currentArea ? '' : null}>
<a class="cp-area__link" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>{area.title}</a>
{area.slug === currentArea && (
<ul class="cp-chapters">
{area.chapters.map((ch) => (
<li>
<span class="cp-ch" style={`--c:${ch.color}`}>{ch.num}. {ch.title}</span>
<ul>
{ch.lessons.map((l) => (
<li class={l.slug === currentLesson ? 'on' : undefined}>
<a href={`/campus/${area.slug}/${l.slug}`}>
<span class="cp-label">{l.label}</span> {l.titleShort}
</a>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
))}
</nav>
<a class="cp-logout" href="/admin/logout">Esci</a>
</aside>
<main class="cp-main"><slot /></main>
<style is:global>
body.cp { display: flex; align-items: flex-start; background: var(--c-bg); }
/* --- sidebar --- */
.cp-side {
width: 300px; flex: 0 0 300px; height: 100vh; position: sticky; top: 0; overflow-y: auto;
background: var(--c-dark); color: #cfc6bd; padding: 28px 22px 50px;
}
.cp-brand { display: block; font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.1; }
.cp-brand .eyebrow { display: block; color: var(--c-accent); margin-bottom: 4px; }
.cp-home { display: inline-block; margin: 18px 0 24px; font-family: var(--font-heading); font-size: .7rem;
font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--c-accent);
text-decoration: none; border-bottom: 1px solid transparent; }
.cp-home:hover { border-bottom-color: var(--c-accent); }
.cp-nav { border-top: 1px solid rgba(255,255,255,.12); padding-top: 18px; }
.cp-area + .cp-area { margin-top: 14px; }
.cp-area__link { display: block; font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none;
padding: 8px 10px; border-left: 3px solid var(--c); }
.cp-area__link:hover, .cp-area[data-on] .cp-area__link { background: rgba(255,255,255,.07); }
.cp-chapters { list-style: none; margin: 8px 0 0; padding: 0; }
.cp-chapters > li + li { margin-top: 12px; }
.cp-ch { display: block; font-family: var(--font-heading); font-size: .72rem; letter-spacing: .08em;
text-transform: uppercase; color: var(--c); padding-left: 12px; }
.cp-chapters ul { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; }
.cp-chapters ul a { display: block; text-decoration: none; color: #b6ada3; font-size: .85rem;
line-height: 1.35; padding: 4px 8px; }
.cp-chapters ul a:hover { color: #fff; background: rgba(255,255,255,.06); }
.cp-chapters ul li.on a { color: #fff; background: rgba(184,169,140,.22); }
.cp-label { font-family: var(--font-heading); font-size: .68rem; letter-spacing: .06em; color: var(--c-accent); }
.cp-logout { display: inline-block; margin-top: 30px; font-size: .78rem; letter-spacing: .1em;
text-transform: uppercase; color: #8d8379; text-decoration: none; }
.cp-logout:hover { color: #fff; }
/* --- contenuto --- */
.cp-main { flex: 1; min-width: 0; max-width: 920px; margin: 0 auto; padding: 56px 44px 100px; }
.cp-main h1 { font-size: clamp(1.9rem, 3.4vw, 2.8rem); text-transform: none; letter-spacing: .02em; }
.cp-main h2 { font-size: clamp(1.3rem, 2.2vw, 1.7rem); margin-top: 2em; padding-bottom: .25em;
border-bottom: 1px solid #e8e4de; text-transform: none; letter-spacing: .02em; }
.cp-main h3 { font-size: 1.12rem; margin-top: 1.7em; color: var(--c-accent-dark); }
.cp-main img { margin: 22px 0; border: 1px solid #e8e4de; }
.cp-main table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: .92rem; }
.cp-main th, .cp-main td { border: 1px solid #e8e4de; padding: 8px 12px; text-align: left; vertical-align: top; }
.cp-main th { background: var(--c-bg-alt); font-family: var(--font-heading); font-weight: 600; }
.cp-main blockquote { margin: 20px 0; padding: 4px 20px; border-left: 3px solid var(--c-accent); color: #6a6a6a; }
.cp-main code { background: var(--c-bg-alt); padding: 1px 5px; font-size: .9em; }
.cp-main li { margin-bottom: .35em; }
/* --- menu mobile (solo CSS) --- */
.cp-menu-btn { display: none; }
@media (max-width: 900px) {
body.cp { display: block; }
.cp-menu-btn { display: block; position: sticky; top: 0; z-index: 20; background: var(--c-dark); color: #fff;
font-family: var(--font-heading); font-size: .75rem; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; padding: 14px 20px; cursor: pointer; }
.cp-side { display: none; width: auto; flex: none; height: auto; position: static; }
.cp-menu-toggle:checked ~ .cp-side { display: block; }
.cp-main { padding: 32px 20px 70px; }
}
</style>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
// Accesso ai contenuti della sezione Campus, generati da `npm run sync-campus`
// in campus-content/. I file sono letti a runtime (non a build time) e tenuti
// in cache in memoria: cambiano solo con un nuovo deploy.
import { readFileSync, existsSync } from 'node:fs';
import { join, normalize, resolve } from 'node:path';
export interface CampusLink { slug: string; area: string; title: string }
export interface CampusLesson {
slug: string;
kind: string;
label: string;
titleFull: string;
titleShort: string;
relatore: string;
concetti: string[];
chapter: number;
area: string;
prev: CampusLink | null;
next: CampusLink | null;
}
export interface CampusChapter {
num: number;
title: string;
sub: string;
color: string;
lessons: CampusLesson[];
}
export interface CampusArea {
slug: string;
title: string;
sub: string;
color: string;
chapters: CampusChapter[];
}
export interface CampusNav { areas: CampusArea[]; count: number }
const CONTENT_DIR = resolve(process.env.CAMPUS_DIR ?? './campus-content');
let navCache: CampusNav | null = null;
/** Struttura della sezione, o null se i contenuti non sono stati generati. */
export function getNav(): CampusNav | null {
if (navCache) return navCache;
const file = join(CONTENT_DIR, 'nav.json');
if (!existsSync(file)) {
console.warn(`Campus: contenuti assenti in ${CONTENT_DIR}. Eseguire "npm run sync-campus".`);
return null;
}
navCache = JSON.parse(readFileSync(file, 'utf8')) as CampusNav;
return navCache;
}
export function findArea(nav: CampusNav, slug: string): CampusArea | null {
return nav.areas.find((a) => a.slug === slug) ?? null;
}
export function findLesson(nav: CampusNav, slug: string): CampusLesson | null {
for (const area of nav.areas)
for (const ch of area.chapters) {
const l = ch.lessons.find((x) => x.slug === slug);
if (l) return l;
}
return null;
}
export function chapterOf(nav: CampusNav, lesson: CampusLesson): CampusChapter | null {
for (const area of nav.areas) {
const ch = area.chapters.find((c) => c.num === lesson.chapter);
if (ch) return ch;
}
return null;
}
/** Corpo HTML di una guida; null se il frammento non esiste. */
export function readLessonHtml(slug: string): string | null {
const file = join(CONTENT_DIR, 'pages', `${slug}.html`);
if (!existsSync(file)) return null;
return readFileSync(file, 'utf8');
}
/**
* Percorso assoluto di un asset dentro campus-content/assets, oppure null se
* il path esce dalla cartella (traversal) o il file non esiste.
*/
export function resolveAsset(relPath: string): string | null {
const base = join(CONTENT_DIR, 'assets');
const full = resolve(base, normalize(relPath));
if (full !== base && !full.startsWith(base + '/')) return null;
return existsSync(full) ? full : null;
}
+4 -2
View File
@@ -1,7 +1,7 @@
---
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
import { login, SESSION_COOKIE } from '../../lib/auth';
import { login, getSessionUser, landingFor, SESSION_COOKIE } from '../../lib/auth';
import { rateLimit } from '../../lib/rate-limit';
export const prerender = false;
@@ -17,7 +17,9 @@ if (Astro.request.method === 'POST') {
httpOnly: true, sameSite: 'lax', path: '/',
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
});
return Astro.redirect('/admin');
// Ogni ruolo atterra sulla sua sezione: campus fuori dal pannello, gli
// altri sulla pagina admin che possono effettivamente aprire.
return Astro.redirect(landingFor(getSessionUser(getDb(), token)?.role ?? 'user'));
}
error = 'Credenziali non valide.';
}
+113
View File
@@ -0,0 +1,113 @@
---
// Due forme di URL:
// /campus/<area> → indice dei capitoli dell'area
// /campus/<area>/<guida> → corpo della guida
import Campus from '../../layouts/Campus.astro';
import { getNav, findArea, findLesson, chapterOf, readLessonHtml } from '../../lib/campus';
export const prerender = false;
const nav = getNav();
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
const parts = (Astro.params.slug ?? '').split('/').filter(Boolean);
if (parts.length === 0 || parts.length > 2) return new Response('Pagina non trovata', { status: 404 });
const [areaSlug, lessonSlug] = parts;
const area = findArea(nav, areaSlug);
if (!area) return new Response('Pagina non trovata', { status: 404 });
const lesson = lessonSlug ? findLesson(nav, lessonSlug) : null;
if (lessonSlug && (!lesson || lesson.area !== areaSlug)) return new Response('Pagina non trovata', { status: 404 });
const body = lesson ? readLessonHtml(lesson.slug) : null;
if (lesson && body === null) return new Response('Guida non disponibile', { status: 404 });
const chapter = lesson ? chapterOf(nav, lesson) : null;
---
<Campus title={lesson ? lesson.titleShort : area.title} nav={nav} currentArea={areaSlug} currentLesson={lesson?.slug}>
{lesson ? (
<article>
<nav class="cp-crumbs">
<a href="/campus">Campus</a> <span>/</span>
<a href={`/campus/${area.slug}`}>{area.title}</a>
{chapter && <><span>/</span> <span>{chapter.num}. {chapter.title}</span></>}
</nav>
<p class="eyebrow">{lesson.label} · {lesson.relatore}</p>
<h1>{lesson.titleFull}</h1>
{lesson.concetti.length > 0 && (
<ul class="cp-concetti">
{lesson.concetti.map((c) => <li>{c}</li>)}
</ul>
)}
<div class="cp-body" set:html={body} />
<nav class="cp-prevnext">
{lesson.prev
? <a href={`/campus/${lesson.prev.area}/${lesson.prev.slug}`}>← {lesson.prev.title}</a>
: <span />}
{lesson.next && <a class="cp-next" href={`/campus/${lesson.next.area}/${lesson.next.slug}`}>{lesson.next.title} →</a>}
</nav>
</article>
) : (
<>
<nav class="cp-crumbs"><a href="/campus">Campus</a> <span>/</span> <span>{area.title}</span></nav>
<p class="eyebrow">Area di studio</p>
<h1>{area.title}</h1>
<p class="cp-lead">{area.sub}</p>
{area.chapters.map((ch) => (
<section class="cp-chapter" style={`--c:${ch.color}`}>
<h2>{ch.num}. {ch.title}</h2>
<p class="cp-chapter__sub">{ch.sub}</p>
<ul class="cp-list">
{ch.lessons.map((l) => (
<li>
<a href={`/campus/${area.slug}/${l.slug}`}>
<span class="cp-list__label">{l.label}</span>
<span class="cp-list__title">{l.titleShort}</span>
<span class="cp-list__rel">{l.relatore}</span>
</a>
</li>
))}
</ul>
</section>
))}
</>
)}
</Campus>
<style is:global>
.cp-crumbs { font-size: .78rem; letter-spacing: .06em; color: var(--c-text-light); margin-bottom: 22px; }
.cp-crumbs a { color: var(--c-accent-dark); text-decoration: none; }
.cp-crumbs a:hover { text-decoration: underline; }
.cp-crumbs span { margin: 0 4px; }
.cp-lead { font-size: 1.08rem; color: var(--c-text-light); max-width: 62ch; margin-bottom: 40px; }
.cp-concetti { list-style: none; margin: 26px 0 34px; padding: 20px 24px; background: var(--c-bg-alt);
border-left: 3px solid var(--c-accent); }
.cp-concetti li { font-size: .93rem; margin-bottom: .3em; }
.cp-concetti li::before { content: '—'; color: var(--c-accent-dark); margin-right: 8px; }
.cp-chapter { margin-bottom: 42px; }
.cp-chapter h2 { font-size: 1.25rem; text-transform: uppercase; letter-spacing: .08em; border: 0;
padding: 0 0 0 12px; margin-bottom: .3em; border-left: 4px solid var(--c); }
.cp-chapter__sub { color: var(--c-text-light); font-size: .92rem; padding-left: 16px; }
.cp-list { list-style: none; margin: 0; padding: 0; }
.cp-list li { border-bottom: 1px solid #efece7; }
.cp-list a { display: grid; grid-template-columns: 70px 1fr auto; gap: 14px; align-items: baseline;
text-decoration: none; color: inherit; padding: 11px 16px; }
.cp-list a:hover { background: var(--c-bg-alt); }
.cp-list__label { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .06em; color: var(--c-accent-dark); }
.cp-list__rel { font-size: .78rem; color: var(--c-text-light); text-align: right; }
.cp-prevnext { display: flex; justify-content: space-between; gap: 20px; margin-top: 56px;
padding-top: 22px; border-top: 1px solid #e8e4de; font-size: .9rem; }
.cp-prevnext a { color: var(--c-accent-dark); text-decoration: none; }
.cp-prevnext a:hover { text-decoration: underline; }
.cp-prevnext .cp-next { margin-left: auto; text-align: right; }
@media (max-width: 640px) {
.cp-list a { grid-template-columns: 56px 1fr; }
.cp-list__rel { display: none; }
}
</style>
+35
View File
@@ -0,0 +1,35 @@
// Immagini delle slide del Campus. Passano dal middleware come il resto di
// /campus, quindi non sono raggiungibili senza login con ruolo abilitato.
import type { APIRoute } from 'astro';
import { createReadStream, statSync } from 'node:fs';
import { extname } from 'node:path';
import { Readable } from 'node:stream';
import { resolveAsset } from '../../../lib/campus';
export const prerender = false;
const TYPES: Record<string, string> = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
};
export const GET: APIRoute = ({ params }) => {
const rel = params.path;
if (!rel) return new Response('Not found', { status: 404 });
const type = TYPES[extname(rel).toLowerCase()];
if (!type) return new Response('Tipo non consentito', { status: 400 });
const file = resolveAsset(rel);
if (!file) return new Response('Not found', { status: 404 });
const { size } = statSync(file);
const stream = Readable.toWeb(createReadStream(file)) as ReadableStream;
return new Response(stream, {
headers: {
'Content-Type': type,
'Content-Length': String(size),
'Cache-Control': 'private, max-age=86400',
},
});
};
+43
View File
@@ -0,0 +1,43 @@
---
import Campus from '../../layouts/Campus.astro';
import { getNav } from '../../lib/campus';
export const prerender = false;
const nav = getNav();
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
---
<Campus title="Guida allo studio" nav={nav}>
<p class="eyebrow">Biohacking Campus 2025/2026</p>
<h1>Guida allo studio</h1>
<p class="cp-lead">
Le sintesi di tutte le lezioni del corso, organizzate in cinque aree:
{nav.count} guide con concetti chiave, sviluppo esteso e materiali originali.
</p>
<div class="cp-grid">
{nav.areas.map((area) => (
<a class="cp-card" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>
<h2>{area.title}</h2>
<p>{area.sub}</p>
<ul>
{area.chapters.map((ch) => <li>{ch.num}. {ch.title} <span>({ch.lessons.length})</span></li>)}
</ul>
</a>
))}
</div>
</Campus>
<style is:global>
.cp-lead { font-size: 1.1rem; color: var(--c-text-light); max-width: 60ch; margin-bottom: 40px; }
.cp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 20px; }
.cp-card { display: block; text-decoration: none; color: inherit; background: #fff;
border: 1px solid #e8e4de; border-top: 4px solid var(--c); padding: 22px 24px; transition: box-shadow .2s, transform .2s; }
.cp-card:hover { transform: translateY(-3px); box-shadow: 0 12px 28px rgba(58,41,41,.10); }
.cp-card h2 { font-size: 1.15rem; margin: 0 0 .4em; border: 0; padding: 0; letter-spacing: .06em;
text-transform: uppercase; }
.cp-card p { font-size: .92rem; color: var(--c-text-light); margin-bottom: .8em; }
.cp-card ul { list-style: none; margin: 0; padding: 12px 0 0; border-top: 1px solid #efece7;
font-size: .82rem; color: var(--c-text-light); }
.cp-card ul li { margin: 0; padding: 2px 0; }
.cp-card ul span { color: var(--c-accent-dark); }
</style>