Sezione Campus: layout dedicato, route protette e documentazione
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ COPY package.json package-lock.json ./
|
|||||||
RUN npm ci --omit=dev && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
|
# Contenuti della sezione Campus (generati con `npm run sync-campus` e committati)
|
||||||
|
COPY campus-content ./campus-content
|
||||||
|
|
||||||
ENV HOST=0.0.0.0 \
|
ENV HOST=0.0.0.0 \
|
||||||
PORT=4321 \
|
PORT=4321 \
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node
|
|||||||
- **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina.
|
- **Contenuti taggati**: ogni testo/immagine ha un tag (es. `home.hero.title-line-1`) modificabile dal pannello o in pagina.
|
||||||
- **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito.
|
- **Modifica inline**: chi ha i permessi può attivare l'overlay dei tag e modificare i contenuti direttamente mentre naviga il sito.
|
||||||
- **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo.
|
- **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo.
|
||||||
- **Gestione utenti** con tre ruoli (`admin`, `superuser`, `user`).
|
- **Gestione utenti** con quattro ruoli (`admin`, `superuser`, `user`, `campus`).
|
||||||
|
- **Campus**: sezione riservata con le guide di studio del corso Biohacking Campus, accessibile solo dopo il login e con navigazione autonoma.
|
||||||
- **Form contatti** con validazione, honeypot anti‑spam, rate‑limit e invio email via SMTP.
|
- **Form contatti** con validazione, honeypot anti‑spam, rate‑limit e invio email via SMTP.
|
||||||
|
|
||||||
## Installazione (sviluppo)
|
## Installazione (sviluppo)
|
||||||
@@ -53,7 +54,8 @@ Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato aut
|
|||||||
| `npm run preview` | Anteprima locale della build |
|
| `npm run preview` | Anteprima locale della build |
|
||||||
| `npm run start` | Server di produzione (`node --env-file=.env`) |
|
| `npm run start` | Server di produzione (`node --env-file=.env`) |
|
||||||
| `npm run test` | Test con Vitest |
|
| `npm run test` | Test con Vitest |
|
||||||
| `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user]` |
|
| `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user\|campus]` |
|
||||||
|
| `npm run sync-campus` | Rigenera i contenuti del Campus: `-- <path-progetto-biohacking-campus>` |
|
||||||
|
|
||||||
## Gestione contenuti
|
## Gestione contenuti
|
||||||
|
|
||||||
@@ -69,31 +71,41 @@ Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui conten
|
|||||||
|
|
||||||
## Ruoli e accessi
|
## Ruoli e accessi
|
||||||
|
|
||||||
Tre ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
|
Quattro ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
|
||||||
|
|
||||||
| Ruolo | Può fare |
|
| Ruolo | Può fare |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`) |
|
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`), Campus |
|
||||||
| `superuser` | Pannello contenuti + blog |
|
| `superuser` | Pannello contenuti + blog |
|
||||||
| `user` | Solo i **propri** articoli del blog |
|
| `user` | Solo i **propri** articoli del blog |
|
||||||
|
| `campus` | Solo la sezione riservata `/campus`; nessun accesso al pannello |
|
||||||
|
|
||||||
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin.
|
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). Dopo l'autenticazione ogni ruolo viene indirizzato alla propria pagina iniziale: il pannello per admin e user, i contenuti per superuser, il Campus per il ruolo omonimo. La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password una‑tantum, cambio ruolo ed eliminazione, con guardie contro l'auto‑eliminazione e la rimozione dell'ultimo admin.
|
||||||
|
|
||||||
|
## Campus (sezione riservata)
|
||||||
|
|
||||||
|
La sezione `/campus` raccoglie le guide di studio del corso Biohacking Campus. È protetta dal middleware come il pannello: un visitatore anonimo viene mandato al login, un utente con ruolo non abilitato torna alla propria pagina iniziale. Le pagine non montano l'header e il footer del sito — la navigazione avviene attraverso la barra laterale delle aree, con un unico richiamo per tornare alla home — ma usano i font, i colori e il CSS globale di InsanityLab.
|
||||||
|
|
||||||
|
I contenuti non vengono scritti a mano: lo script `npm run sync-campus` legge le guide sorgente (`*.studio.md`) dal progetto Biohacking Campus, le converte in frammenti HTML e produce la cartella `campus-content/` (indice `nav.json`, pagine e immagini delle slide), che va committata perché il deploy la copia nell'immagine Docker. Lo script è idempotente: rigenerarlo dopo l'aggiunta di nuove lezioni aggiorna indice e pagine e rimuove quelle non più presenti. Le nuove guide vanno prima dichiarate nella struttura dei capitoli in `scripts/campus/structure.mjs`.
|
||||||
|
|
||||||
|
Le immagini delle slide sono servite dalla route `/campus/assets/...`, anch'essa protetta: non sono raggiungibili senza una sessione valida.
|
||||||
|
|
||||||
## Struttura del progetto
|
## Struttura del progetto
|
||||||
|
|
||||||
```
|
```
|
||||||
insanitylab-website/
|
insanitylab-website/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── pages/ # Route Astro (vetrina, /admin, /api)
|
│ ├── pages/ # Route Astro (vetrina, /admin, /api, /campus)
|
||||||
│ ├── components/ # Componenti (home/, content/, Header, Footer, …)
|
│ ├── components/ # Componenti (home/, content/, Header, Footer, …)
|
||||||
│ ├── layouts/ # Layout Base
|
│ ├── layouts/ # Layout Base, Admin, Campus
|
||||||
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed)
|
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed)
|
||||||
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env
|
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env
|
||||||
│ ├── assets/img/ # Immagini ottimizzate da Astro
|
│ ├── assets/img/ # Immagini ottimizzate da Astro
|
||||||
│ └── styles/ # global.css
|
│ └── styles/ # global.css
|
||||||
├── data/ # SQLite (volume in produzione)
|
├── data/ # SQLite (volume in produzione)
|
||||||
├── uploads/ # Upload immagini (volume in produzione)
|
├── uploads/ # Upload immagini (volume in produzione)
|
||||||
├── scripts/ # create-user, smoke test
|
├── campus-content/ # Contenuti del Campus generati da sync-campus
|
||||||
|
├── scripts/ # create-user, sync-campus, smoke test
|
||||||
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
|
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
|
||||||
├── compose.yaml # Stack Docker + label Traefik
|
├── compose.yaml # Stack Docker + label Traefik
|
||||||
├── Dockerfile # Build multi-stage
|
├── Dockerfile # Build multi-stage
|
||||||
@@ -115,6 +127,7 @@ Il file `.env` (non versionato) richiede:
|
|||||||
| `CONTACT_TO` | Destinatario dei messaggi del form |
|
| `CONTACT_TO` | Destinatario dei messaggi del form |
|
||||||
| `DB_PATH` | Percorso del database SQLite |
|
| `DB_PATH` | Percorso del database SQLite |
|
||||||
| `UPLOADS_DIR` | Cartella degli upload |
|
| `UPLOADS_DIR` | Cartella degli upload |
|
||||||
|
| `CAMPUS_DIR` | Cartella dei contenuti del Campus (default `./campus-content`) |
|
||||||
|
|
||||||
La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`.
|
La posta del dominio `@insanitylab.it` è ospitata su **Aruba**; l'invio del form usa l'SMTP autenticato Aruba (`smtps.aruba.it:465`). Aruba impone che il mittente sia la casella autenticata, quindi `CONTACT_FROM` deve essere uguale a `SMTP_USER`.
|
||||||
|
|
||||||
|
|||||||
+17
-17
@@ -915,9 +915,9 @@
|
|||||||
"title": "Longevità e Mantenere un Cervello Sano"
|
"title": "Longevità e Mantenere un Cervello Sano"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"slug": "accelerator-1-2025-11-11",
|
"slug": "slide-hrv",
|
||||||
"area": "live-e-ospiti",
|
"area": "slide",
|
||||||
"title": "Accelerator 1"
|
"title": "HRV: Variabilità della frequenza cardiaca"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -954,9 +954,9 @@
|
|||||||
"chapter": 13,
|
"chapter": 13,
|
||||||
"area": "slide",
|
"area": "slide",
|
||||||
"prev": {
|
"prev": {
|
||||||
"slug": "lezione-live-4-2026-06-30",
|
"slug": "lezione-25-focus-microbiota-plus",
|
||||||
"area": "live-e-ospiti",
|
"area": "corso-base",
|
||||||
"title": "Lezione live 4"
|
"title": "FOCUS Microbiota Plus"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"slug": "slide-dna",
|
"slug": "slide-dna",
|
||||||
@@ -1127,9 +1127,9 @@
|
|||||||
"title": "Focus Ormoni (Dott.ssa Smeazzetto)"
|
"title": "Focus Ormoni (Dott.ssa Smeazzetto)"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"slug": "speaker-dna-focus-moduli",
|
"slug": "accelerator-1-2025-11-11",
|
||||||
"area": "live-e-ospiti",
|
"area": "live-e-ospiti",
|
||||||
"title": "DNA: Focus modulo per modulo"
|
"title": "Accelerator 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1166,9 +1166,9 @@
|
|||||||
"chapter": 11,
|
"chapter": 11,
|
||||||
"area": "live-e-ospiti",
|
"area": "live-e-ospiti",
|
||||||
"prev": {
|
"prev": {
|
||||||
"slug": "lezione-25-focus-microbiota-plus",
|
"slug": "slide-focus-microbiota-plus",
|
||||||
"area": "corso-base",
|
"area": "slide",
|
||||||
"title": "FOCUS Microbiota Plus"
|
"title": "Focus Microbiota PLUS"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"slug": "accelerator-2-2025-12-20",
|
"slug": "accelerator-2-2025-12-20",
|
||||||
@@ -1459,9 +1459,9 @@
|
|||||||
"title": "Lezione live 3"
|
"title": "Lezione live 3"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"slug": "slide-hrv",
|
"slug": "speaker-dna-focus-moduli",
|
||||||
"area": "slide",
|
"area": "live-e-ospiti",
|
||||||
"title": "HRV: Variabilità della frequenza cardiaca"
|
"title": "DNA: Focus modulo per modulo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1490,9 +1490,9 @@
|
|||||||
"chapter": 14,
|
"chapter": 14,
|
||||||
"area": "live-e-ospiti",
|
"area": "live-e-ospiti",
|
||||||
"prev": {
|
"prev": {
|
||||||
"slug": "slide-focus-microbiota-plus",
|
"slug": "lezione-live-4-2026-06-30",
|
||||||
"area": "slide",
|
"area": "live-e-ospiti",
|
||||||
"title": "Focus Microbiota PLUS"
|
"title": "Lezione live 4"
|
||||||
},
|
},
|
||||||
"next": {
|
"next": {
|
||||||
"slug": "speaker-seipel-campi-elettromagnetici",
|
"slug": "speaker-seipel-campi-elettromagnetici",
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ un ruolo dedicato. Le pagine devono adottare lo stile grafico del sito
|
|||||||
oggi per short-circuit).
|
oggi per short-circuit).
|
||||||
- Middleware: `/campus/*` diventa percorso protetto (oltre a `/admin`,
|
- Middleware: `/campus/*` diventa percorso protetto (oltre a `/admin`,
|
||||||
`/api/admin`). Non autenticato → redirect a `/admin/login`.
|
`/api/admin`). Non autenticato → redirect a `/admin/login`.
|
||||||
- `landingFor('campus')` → `/campus` (dopo il login l'utente campus atterra
|
- `landingFor('campus')` → `/campus`. La pagina di login rimandava sempre a
|
||||||
direttamente sulla sezione).
|
`/admin`: ora usa `landingFor` sul ruolo dell'utente appena autenticato,
|
||||||
|
così ogni ruolo atterra su una pagina che può davvero aprire.
|
||||||
- Il ruolo `campus` NON deve accedere ai percorsi comuni dei loggati del
|
- Il ruolo `campus` NON deve accedere ai percorsi comuni dei loggati del
|
||||||
pannello (blog admin, upload): `canAccessAdminPath` ritorna `false` per
|
pannello (blog admin, upload): `canAccessAdminPath` ritorna `false` per
|
||||||
`campus` su `/admin/*` e `/api/admin/*` non esplicitamente consentiti.
|
`campus` su `/admin/*` e `/api/admin/*` non esplicitamente consentiti.
|
||||||
@@ -54,7 +55,9 @@ un ruolo dedicato. Le pagine devono adottare lo stile grafico del sito
|
|||||||
riferimenti alle immagini slide verso `/campus/assets/...`.
|
riferimenti alle immagini slide verso `/campus/assets/...`.
|
||||||
- Output in `campus-content/` (root repo, committato):
|
- Output in `campus-content/` (root repo, committato):
|
||||||
- `nav.json` — aree, capitoli, lezioni ordinate (slug, titolo, sottotitolo,
|
- `nav.json` — aree, capitoli, lezioni ordinate (slug, titolo, sottotitolo,
|
||||||
docente, prev/next);
|
docente, prev/next). La catena prev/next segue l'ordine di navigazione
|
||||||
|
aree → capitoli → guide, non la numerazione dei capitoli: le aree li
|
||||||
|
raggruppano in un ordine diverso;
|
||||||
- `pages/<slug>.html` — frammento HTML del corpo di ogni guida;
|
- `pages/<slug>.html` — frammento HTML del corpo di ogni guida;
|
||||||
- `assets/` — immagini slide copiate (~37 MB, commit una tantum).
|
- `assets/` — immagini slide copiate (~37 MB, commit una tantum).
|
||||||
- Idempotente: rigenera tutto e rimuove i file orfani.
|
- Idempotente: rigenera tutto e rimuove i file orfani.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ if (!username || !password) {
|
|||||||
console.error('Uso: npm run create-user -- <username> <password> [role]');
|
console.error('Uso: npm run create-user -- <username> <password> [role]');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!['admin', 'superuser', 'user'].includes(role)) {
|
if (!['admin', 'superuser', 'user', 'campus'].includes(role)) {
|
||||||
console.error('Ruolo non valido. Ammessi: admin, superuser, user');
|
console.error('Ruolo non valido. Ammessi: admin, superuser, user, campus');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
|
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
|
||||||
|
|||||||
+12
-12
@@ -63,7 +63,6 @@ async function main() {
|
|||||||
// 1. parsing di tutte le guide, capitolo per capitolo
|
// 1. parsing di tutte le guide, capitolo per capitolo
|
||||||
const chapters = [];
|
const chapters = [];
|
||||||
const written = new Set();
|
const written = new Set();
|
||||||
const flat = []; // ordine globale di lettura, per prev/next
|
|
||||||
for (const [num, title, sub, slugs] of CHAPTERS) {
|
for (const [num, title, sub, slugs] of CHAPTERS) {
|
||||||
const lessons = [];
|
const lessons = [];
|
||||||
for (const [i, slug] of slugs.entries()) {
|
for (const [i, slug] of slugs.entries()) {
|
||||||
@@ -81,27 +80,28 @@ async function main() {
|
|||||||
chapter: num,
|
chapter: num,
|
||||||
};
|
};
|
||||||
lessons.push(entry);
|
lessons.push(entry);
|
||||||
flat.push(entry);
|
|
||||||
}
|
}
|
||||||
chapters.push({ num, title, sub, color: CH_COLORS[num - 1], lessons });
|
chapters.push({ num, title, sub, color: CH_COLORS[num - 1], lessons });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. prev/next lungo l'ordine globale, e area di appartenenza
|
|
||||||
const areaOf = new Map();
|
|
||||||
for (const [slug, , , , chapterNums] of AREAS)
|
|
||||||
for (const n of chapterNums) areaOf.set(n, slug);
|
|
||||||
flat.forEach((l, i) => {
|
|
||||||
l.area = areaOf.get(l.chapter);
|
|
||||||
l.prev = flat[i - 1] ? { slug: flat[i - 1].slug, area: areaOf.get(flat[i - 1].chapter), title: flat[i - 1].titleShort } : null;
|
|
||||||
l.next = flat[i + 1] ? { slug: flat[i + 1].slug, area: areaOf.get(flat[i + 1].chapter), title: flat[i + 1].titleShort } : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const byNum = new Map(chapters.map((c) => [c.num, c]));
|
const byNum = new Map(chapters.map((c) => [c.num, c]));
|
||||||
const areas = AREAS.map(([slug, title, sub, color, chapterNums]) => ({
|
const areas = AREAS.map(([slug, title, sub, color, chapterNums]) => ({
|
||||||
slug, title, sub, color,
|
slug, title, sub, color,
|
||||||
chapters: chapterNums.map((n) => byNum.get(n)).filter(Boolean),
|
chapters: chapterNums.map((n) => byNum.get(n)).filter(Boolean),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 2. prev/next nell'ordine in cui l'utente naviga: aree → capitoli → guide,
|
||||||
|
// che non coincide con l'ordine dei capitoli (le aree li raggruppano).
|
||||||
|
const flat = [];
|
||||||
|
for (const area of areas)
|
||||||
|
for (const ch of area.chapters)
|
||||||
|
for (const l of ch.lessons) { l.area = area.slug; flat.push(l); }
|
||||||
|
const linkTo = (l) => (l ? { slug: l.slug, area: l.area, title: l.titleShort } : null);
|
||||||
|
flat.forEach((l, i) => {
|
||||||
|
l.prev = linkTo(flat[i - 1]);
|
||||||
|
l.next = linkTo(flat[i + 1]);
|
||||||
|
});
|
||||||
|
|
||||||
await writeFile(join(OUT_DIR, 'nav.json'), JSON.stringify({ areas, count: flat.length }, null, 2));
|
await writeFile(join(OUT_DIR, 'nav.json'), JSON.stringify({ areas, count: flat.length }, null, 2));
|
||||||
|
|
||||||
// 3. rimozione dei frammenti orfani
|
// 3. rimozione dei frammenti orfani
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const user = Astro.locals.user;
|
|||||||
<a href="/admin">Articoli</a>
|
<a href="/admin">Articoli</a>
|
||||||
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</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="/admin/users">Utenti</a>}
|
||||||
|
{user.role === 'admin' && <a href="/campus">Campus</a>}
|
||||||
<a href="/admin/new">Nuovo</a>
|
<a href="/admin/new">Nuovo</a>
|
||||||
<a href="/admin/logout">Esci</a>
|
<a href="/admin/logout">Esci</a>
|
||||||
<a href="/blog" target="_blank">Vedi sito ↗</a>
|
<a href="/blog" target="_blank">Vedi sito ↗</a>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Admin from '../../layouts/Admin.astro';
|
import Admin from '../../layouts/Admin.astro';
|
||||||
import { getDb } from '../../lib/db';
|
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';
|
import { rateLimit } from '../../lib/rate-limit';
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@@ -17,7 +17,9 @@ if (Astro.request.method === 'POST') {
|
|||||||
httpOnly: true, sameSite: 'lax', path: '/',
|
httpOnly: true, sameSite: 'lax', path: '/',
|
||||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
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.';
|
error = 'Credenziali non valide.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getNav, findArea, findLesson, chapterOf, readLessonHtml, resolveAsset } from '../src/lib/campus';
|
||||||
|
|
||||||
|
// I test girano sui contenuti reali generati da `npm run sync-campus`.
|
||||||
|
const nav = getNav();
|
||||||
|
|
||||||
|
describe('getNav', () => {
|
||||||
|
it('carica aree e capitoli dai contenuti generati', () => {
|
||||||
|
expect(nav).not.toBeNull();
|
||||||
|
expect(nav!.count).toBeGreaterThan(100);
|
||||||
|
expect(nav!.areas.map((a) => a.slug)).toEqual(
|
||||||
|
['corso-base', 'slide', 'live-e-ospiti', 'consulenze-masterclass', 'qa']);
|
||||||
|
for (const a of nav!.areas) expect(a.chapters.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lookup', () => {
|
||||||
|
it('trova area e guida, e il capitolo della guida', () => {
|
||||||
|
const area = findArea(nav!, 'corso-base');
|
||||||
|
expect(area?.title).toBe('Corso base');
|
||||||
|
const l = findLesson(nav!, 'lezione-01-ottimizzazione-del-sonno');
|
||||||
|
expect(l).toMatchObject({ area: 'corso-base', chapter: 1, label: 'L1' });
|
||||||
|
expect(chapterOf(nav!, l!)?.title).toBe('Sonno e ritmi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ritorna null per slug inesistenti', () => {
|
||||||
|
expect(findArea(nav!, 'inventata')).toBeNull();
|
||||||
|
expect(findLesson(nav!, 'lezione-99-inventata')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la catena prev/next collega tutte le guide in ordine', () => {
|
||||||
|
const all = nav!.areas.flatMap((a) => a.chapters.flatMap((c) => c.lessons));
|
||||||
|
expect(all[0].prev).toBeNull();
|
||||||
|
expect(all.at(-1)!.next).toBeNull();
|
||||||
|
for (let i = 0; i < all.length - 1; i++) expect(all[i].next?.slug).toBe(all[i + 1].slug);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readLessonHtml', () => {
|
||||||
|
it('legge il frammento di una guida esistente', () => {
|
||||||
|
const html = readLessonHtml('lezione-01-ottimizzazione-del-sonno');
|
||||||
|
expect(html).toContain('<h2');
|
||||||
|
expect(html).not.toContain('Guida allo studio</h1>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ritorna null per una guida inesistente', () => {
|
||||||
|
expect(readLessonHtml('non-esiste')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le immagini puntano alla route protetta', () => {
|
||||||
|
const html = readLessonHtml('slide-hrv')!;
|
||||||
|
expect(html).toContain('src="/campus/assets/');
|
||||||
|
expect(html).not.toContain('src="assets/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveAsset', () => {
|
||||||
|
it('risolve un asset esistente', () => {
|
||||||
|
expect(resolveAsset('slide/slide-hrv')).toMatch(/campus-content\/assets\/slide\/slide-hrv$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rifiuta i path traversal e i path assoluti', () => {
|
||||||
|
expect(resolveAsset('../pages/lezione-01-ottimizzazione-del-sonno.html')).toBeNull();
|
||||||
|
expect(resolveAsset('../../package.json')).toBeNull();
|
||||||
|
expect(resolveAsset('/etc/passwd')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ritorna null per un asset inesistente', () => {
|
||||||
|
expect(resolveAsset('slide/non-esiste.png')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
// @ts-expect-error — modulo JS senza tipi, usato solo dallo script di sync
|
|
||||||
import { parseGuide, kindOf, rewriteAssets } from '../scripts/campus/parse.mjs';
|
import { parseGuide, kindOf, rewriteAssets } from '../scripts/campus/parse.mjs';
|
||||||
// @ts-expect-error — idem
|
|
||||||
import { sourceDirFor, CHAPTERS, AREAS, CH_COLORS } from '../scripts/campus/structure.mjs';
|
import { sourceDirFor, CHAPTERS, AREAS, CH_COLORS } from '../scripts/campus/structure.mjs';
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
@@ -104,18 +102,19 @@ describe('rewriteAssets', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('struttura del corso', () => {
|
describe('struttura del corso', () => {
|
||||||
|
const chapterNums = CHAPTERS.map((c) => c[0] as number);
|
||||||
|
|
||||||
it('ogni capitolo ha un colore e ogni area capitoli esistenti', () => {
|
it('ogni capitolo ha un colore e ogni area capitoli esistenti', () => {
|
||||||
const nums = CHAPTERS.map((c: [number, string, string, string[]]) => c[0]);
|
|
||||||
expect(CH_COLORS.length).toBe(CHAPTERS.length);
|
expect(CH_COLORS.length).toBe(CHAPTERS.length);
|
||||||
for (const [, , , , chapterNums] of AREAS as [string, string, string, string, number[]][])
|
for (const area of AREAS)
|
||||||
for (const n of chapterNums) expect(nums).toContain(n);
|
for (const n of area[4] as number[]) expect(chapterNums).toContain(n);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nessuno slug duplicato e ogni capitolo è in una sola area', () => {
|
it('nessuno slug duplicato e ogni capitolo è in una sola area', () => {
|
||||||
const slugs = CHAPTERS.flatMap((c: [number, string, string, string[]]) => c[3]);
|
const slugs = CHAPTERS.flatMap((c) => c[3] as string[]);
|
||||||
expect(new Set(slugs).size).toBe(slugs.length);
|
expect(new Set(slugs).size).toBe(slugs.length);
|
||||||
const assigned = (AREAS as [string, string, string, string, number[]][]).flatMap((a) => a[4]);
|
const assigned = AREAS.flatMap((a) => a[4] as number[]);
|
||||||
expect(new Set(assigned).size).toBe(assigned.length);
|
expect(new Set(assigned).size).toBe(assigned.length);
|
||||||
expect(assigned.sort((a, b) => a - b)).toEqual(CHAPTERS.map((c: [number, string, string, string[]]) => c[0]));
|
expect(assigned.sort((a, b) => a - b)).toEqual(chapterNums);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user