docs: README unico e aggiornato; rimozione piani/spec superati

- README riscritto come documentazione viva e completa: stack, funzionalita'
  (contenuti taggati + GUID + modifica inline, 3 ruoli admin/superuser/user,
  blog ownership, gestione utenti), env SMTP Aruba, deploy Docker/Traefik,
  dominio insanitylab.it, migrazioni DB e trappole tecniche note
- rimossi docs/superpowers/plans e docs/superpowers/specs: piani gia' eseguiti
  e spec di design ora accorpati nel README (restano nella storia git)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:50:21 +02:00
parent d79aa912b1
commit 1aa82f5332
9 changed files with 103 additions and 7159 deletions
+103 -94
View File
@@ -1,145 +1,154 @@
# InsanityLab Website
Sito vetrina per InsanityLab con blog integrato e form di contatti.
Sito ufficiale di InsanityLab: vetrina aziendale, blog integrato, form di contatti e sistema di contenuti modificabili senza toccare il codice. In produzione su **https://insanitylab.it**.
## Descrizione
Il progetto realizza un sito web per InsanityLab, combinando una vetrina aziendale con un sistema di blog gestito tramite SQLite e un form di contatti funzionante. L'architettura è costruita su **Astro 7** con adapter **Node.js** per deployment su server, garantendo performance ottimali e SEO-friendly pages.
Il sito è costruito su **Astro 7** in modalità server (SSR) con adapter **Node.js standalone**, servito in un container Docker dietro **Traefik** (TLS Let's Encrypt automatico). I contenuti testuali e le immagini della parte vetrina sono "taggati" e salvati su **SQLite**: si modificano da un pannello di amministrazione o direttamente in pagina, senza deploy. Il blog è gestito internamente con editor richtext, e il form di contatti invia email tramite SMTP.
## Stack Tecnologico
## Stack tecnologico
- **Framework**: Astro 7
- **Runtime**: Node.js (adapter standalone)
- **Database**: SQLite (better-sqlite3)
- **Autenticazione**: bcryptjs
- **Email**: Nodemailer
- **Markup Editor**: TipTap (rich text)
- **Font System**: @fontsource (Montserrat, Open Sans)
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina, programmi e articoli pubblicati)
- **Testing**: Vitest
- **Framework**: Astro 7 (output `server`, `prerender = false` sulle pagine dinamiche)
- **Runtime**: Node.js 22 (adapter `@astrojs/node` standalone)
- **Database**: SQLite via `better-sqlite3`
- **Autenticazione**: sessioni con password hashate (`bcryptjs`)
- **Email**: Nodemailer (SMTP)
- **Editor articoli**: TipTap (rich text)
- **Font**: `@fontsource` (Montserrat, Open Sans) — nessun CDN esterno
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina + articoli pubblicati)
- **Test**: Vitest
- **Linguaggio**: TypeScript
- **Deploy**: Docker (multistage, `node:22-slim`) + Traefik su VPS
## Installazione
## Funzionalità principali
- **Vetrina**: home, chi siamo, training, servizi (con schede di dettaglio), blog, contatti, landing "Promo ed eventi".
- **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.
- **Blog** con editor TipTap, immagini caricate, firma autore e proprietà degli articoli per ruolo.
- **Gestione utenti** con tre ruoli (`admin`, `superuser`, `user`).
- **Form contatti** con validazione, honeypot antispam, ratelimit e invio email via SMTP.
## Installazione (sviluppo)
### Prerequisiti
- Node.js 18+ e npm
### Setup
```bash
# Installa dipendenze
npm install
# Copia il template di configurazione env
cp .env.example .env
# Crea il primo utente amministratore (dopo aver configurato il DB)
npm run create-user -- admin tuapassword
cp .env.example .env # poi compila i valori reali
npm run create-user -- admin <password> admin
npm run dev # http://localhost:4321
```
Modifica `.env` con le credenziali SMTP, percorsi database e upload directory secondo il tuo ambiente.
Il database `data/insanitylab.db` viene creato al primo avvio ed è popolato automaticamente con i contenuti di default (seed) e le migrazioni idempotenti.
## Comandi Disponibili
## Comandi
| Comando | Descrizione |
|---------|-------------|
| `npm run dev` | Avvia il server di sviluppo (localhost:4321) |
| `npm run build` | Compila il progetto per production |
| `npm run preview` | Visualizza l'anteprima della build (locale) |
| `npm run start` | Avvia il server production (richiede `npm run build` preliminare) |
| `npm run test` | Esegui i test con Vitest |
| `npm run create-user` | Crea un nuovo utente: `-- <nome> <password> [admin\|editor]` |
| `npm run dev` | Server di sviluppo (localhost:4321) |
| `npm run build` | Build di produzione |
| `npm run preview` | Anteprima locale della build |
| `npm run start` | Server di produzione (`node --env-file=.env`) |
| `npm run test` | Test con Vitest |
| `npm run create-user` | Crea/aggiorna utente: `-- <nome> <password> [admin\|superuser\|user]` |
## Gestione Contenuti
## Gestione contenuti
I testi e le immagini del sito vetrina sono contenuti taggati, modificabili senza toccare il codice tramite il pannello `/admin/content`. Ogni blocco di contenuto è identificato da un tag (ad esempio `home.hero.title`) e può essere aggiornato nel valore, negli stili predefiniti (dimensione, peso, colore) e — per le immagini — sostituito con un upload o riportato all'originale.
I testi e le immagini della vetrina sono **blocchi taggati** salvati nella tabella `content_blocks`. Ogni blocco ha un tag stabile (es. `home.hero.title-line-1`), un valore, eventuali stili predefiniti, un tipo (`text` / `html` / `image`) e un **GUID** univoco.
Il pannello raggruppa i tag per pagina, offre una ricerca per tag o contenuto e mostra per ogni blocco l'ultima modifica effettuata. Visitando il sito da utente autenticato con il parametro `?annotate=1` (link "Vedi sito con tag" nel pannello), ogni contenuto taggato viene evidenziato con un badge che rimanda direttamente al form di modifica corrispondente.
- **Pannello** `/admin/content`: i blocchi sono raggruppati per pagina, con ricerca per tag o contenuto, upload delle immagini e ripristino all'originale.
- **Modifica inline**: attivando "Mostra tag" dal menu utente, ogni blocco mostra un badge (nome tag + matita) che apre una finestra di modifica direttamente in pagina.
- **Componenti** `<T>` e `<TImg>`: rendono i blocchi con fallback automatico al seed quando un tag non è ancora nel database.
Sono previsti due ruoli utente: `admin` ha accesso completo (articoli del blog, contenuti, upload), mentre `editor` può operare esclusivamente sul pannello contenuti. Gli utenti si creano con `npm run create-user -- <nome> <password> editor`.
Il seed dei contenuti è in `src/data/content-seed.ts`; le variazioni sui contenuti già esistenti (rinomine, testi aggiornati) si applicano tramite **migrazioni idempotenti** in `src/lib/db.ts`, così da non sovrascrivere le modifiche fatte dal pannello.
## Struttura Progetto
> Nota tecnica: gli elementi resi dai componenti `<T>`/`<TImg>` **non** ereditano il `data-astro-cid` del componente che li usa. Uno stile scoped basato solo sulla classe (es. `.hero__line`) non li raggiunge: va ancorato a un contenitore che ha il cid usando `:global()` (es. `.hero__text :global(.hero__line)`).
## Ruoli e accessi
Tre ruoli, con autorizzazione a matrice di prefissi in `src/lib/auth.ts`:
| Ruolo | Può fare |
|-------|----------|
| `admin` | Tutto: contenuti, blog, upload, **gestione utenti** (`/admin/users`) |
| `superuser` | Pannello contenuti + blog |
| `user` | Solo i **propri** articoli del blog |
Login pubblico su `/login` (`?next` sanitizzato contro open redirect). La gestione utenti (`/admin/users`, solo admin) consente creazione, reset password unatantum, cambio ruolo ed eliminazione, con guardie contro l'autoeliminazione e la rimozione dell'ultimo admin.
## Struttura del progetto
```
insanitylab-website/
├── src/
│ ├── pages/ # Pagine Astro (routing automatico)
│ ├── components/ # Componenti Astro riutilizzabili
│ ├── layouts/ # Layout master
│ ├── utils/ # Funzioni di utilità
│ ├── env.d.ts # Type definitions globali
── ...
├── data/ # Database SQLite e dati persistenti
│ └── insanitylab.db # (generato al primo avvio)
├── uploads/ # Directory per upload immagini e file
├── dist/ # Output build (server)
├── tests/ # Test suite
├── astro.config.mjs # Configurazione Astro
├── tsconfig.json # Configurazione TypeScript
├── vitest.config.ts # Configurazione test framework
── .env.example # Template variabili di ambiente
├── .env # Variabili di ambiente (non versionato)
├── package.json # Dipendenze e script npm
└── README.md # Questo file
│ ├── pages/ # Route Astro (vetrina, /admin, /api)
│ ├── components/ # Componenti (home/, content/, Header, Footer, …)
│ ├── layouts/ # Layout Base
│ ├── data/ # Dati statici + seed contenuti (services, trainings, site, content-seed)
│ ├── lib/ # db, auth, content, mailer, rate-limit, safe-next, env
── assets/img/ # Immagini ottimizzate da Astro
│ └── styles/ # global.css
├── data/ # SQLite (volume in produzione)
├── uploads/ # Upload immagini (volume in produzione)
├── scripts/ # create-user, smoke test
├── astro.config.mjs # Config Astro (site, allowedDomains, redirects)
├── compose.yaml # Stack Docker + label Traefik
├── Dockerfile # Build multi-stage
├── .env.example # Template variabili d'ambiente
── README.md
```
## Configurazione Ambiente
## Variabili d'ambiente
Il file `.env` richiede le seguenti variabili:
Il file `.env` (non versionato) richiede:
- `SMTP_HOST`: Host SMTP per invio email
- `SMTP_PORT`: Porta SMTP (solitamente 587)
- `SMTP_USER`: Utente SMTP
- `SMTP_PASS`: Password SMTP
- `CONTACT_TO`: Email di destinazione contatti
- `CONTACT_FROM`: Email mittente
- `DB_PATH`: Percorso database SQLite
- `UPLOADS_DIR`: Cartella upload file
| Variabile | Descrizione |
|-----------|-------------|
| `SMTP_HOST` | Host SMTP (produzione: `smtps.aruba.it`) |
| `SMTP_PORT` | Porta SMTP (`465` con SSL) |
| `SMTP_USER` | Casella di invio (deve coincidere con `CONTACT_FROM`) |
| `SMTP_PASS` | Password della casella |
| `CONTACT_FROM` | Mittente delle email (= `SMTP_USER`) |
| `CONTACT_TO` | Destinatario dei messaggi del form |
| `DB_PATH` | Percorso del database SQLite |
| `UPLOADS_DIR` | Cartella degli upload |
## Development Workflow
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`.
1. **Avvia il server locale**: `npm run dev`
2. **Modifica file** in `src/` (hot reload automatico)
3. **Test**: `npm run test`
4. **Build**: `npm run build` (verifica errori di build)
5. **Commit**: Con prefisso `feat:` (feature) o `chore:` (manutenzione)
## Deploy in produzione
## Deployment
Il sito gira in Docker su un VPS, dietro Traefik. Lo stack è in `/opt/docker/insanitylab/` (repo in `src/`, volumi `data/` e `uploads/`). Aggiornamento:
```bash
# Build per production
npm run build
# Opzionalmente, preview locale
npm run preview
# In production, il server parte con:
npm run start
# Variabili ambiente in production vanno impostate sull'hosting
# (es. Hostinger VPS, Vercel, etc.)
cd /opt/docker/insanitylab/src
git pull
docker compose up -d --build
```
## Riferimenti Tecnici
Le migrazioni del database (in `src/lib/db.ts`) sono idempotenti e girano all'avvio del container: aggiornano lo schema e i contenuti senza perdere le modifiche fatte dal pannello.
Per dettagli su architettura, schema database, componenti e specifiche vedi:
- `/docs/superpowers/specs/2026-07-01-insanitylab-website-design.md`
- `/docs/superpowers/plans/2026-07-01-insanitylab-website.md`
### Dominio e DNS
## Problemi Comuni
- Dominio ufficiale: **insanitylab.it** (canonico l'apex; `www` fa redirect 301 all'apex tramite Traefik).
- I record **A** di `@` e `www` puntano al VPS; la **posta resta su Aruba** (MX, SPF, webmail non vengono toccati).
- TLS Let's Encrypt automatico via Traefik (certresolver `mytlschallenge`).
### `better-sqlite3` fallisce su npm install
> Attenzione dietro Traefik: `allowedDomains` in `astro.config.mjs` deve elencare i domini serviti, altrimenti Astro ignora `X-Forwarded-Proto/Host` e il controllo CSRF respinge i POST con 403.
Se `better-sqlite3` non compila, assicurati di avere build tools installati:
- **Linux**: `sudo apt-get install build-essential python3`
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: Visual Studio Build Tools o MinGW
## Problemi comuni
### Database locked
**`better-sqlite3` non compila su `npm install`** — servono i build tools:
- Linux: `sudo apt-get install build-essential python3`
- macOS: `xcode-select --install`
- Windows: Visual Studio Build Tools
Se il database è locato durante i test, verifica che non ci siano processi in background che accedono a `data/insanitylab.db`.
**Modifiche allo `<style>` scoped di `[slug].astro` non compaiono in dev** — l'HMR di Astro 7 non sempre ricompila lo stile scoped delle route dinamiche: riavviare il dev server (`astro dev stop` + `npm run dev`).
**Database "locked" durante i test** — assicurarsi che nessun processo stia accedendo a `data/insanitylab.db`.
## Licenza
Proprietario InsanityLab.
Proprietà di InsanityLab.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,101 +0,0 @@
# Inventario contenuti — pagine vetrina (2026-07-05)
Documento di riferimento per il piano `2026-07-05-contenuti-taggati-pannello-editor.md`. Elenca, file per file, i testi visibili e le immagini da estrarre nel sistema di tag. I valori esatti si copiano SEMPRE dal file sorgente al momento dell'estrazione; qui compaiono abbreviati per identificazione.
**Nota nomenclatura**: i nomi tag canonici sono quelli nella sezione "Interfaces → Produces" dei task del piano (segmenti kebab-case, es. `home.hero.title-line-1`); dove questo inventario usa camelCase va convertito.
## Pagine
### `src/pages/index.astro` (home)
Solo orchestrazione componenti. Hardcoded: `home.meta.title` ("Performance. Balance. Longevity.", prop title di Base), `home.training.title` ("Training"), `home.training.subtitle` ("Porta i tuoi allenamenti al livello successivo: trova il percorso…"). Nessuna immagine diretta.
### `src/pages/about.astro`
Meta (2): `about.meta.title` ("Insanitylab"), `about.meta.description` ("Mission, storia, team e metodo…"). Hero: `about.hero.title` ("Insanitylab", via PageHero).
Sezioni testo: mission (title + body), direzione (title + body + cta "Scopri"), approccio (title + body + cta "Contattaci"), about (title + body), convenzioni (title + body + item1-3 in `<ul class="conv">` + riga finale con `{site.email}` · `{site.phone}` interpolati → spezzare in `about.convenzioni.cta-text` + tag global), team (title + body + staff-intro), metodo (title + body + closing), studios (title + body1-3), vision (title + body1-2).
Immagini (glob, alt hardcoded): `about.mission.image` (about-training-group.jpg), `about.about.image` (about-pushup.jpg), `about.convenzioni.image` (about-conventions.jpg), `about.metodo.image` (about-team-group.jpg), `about.studios.image` (about-studios.jpg), `about.vision.image` (about-vision.jpg). Griglia team: 9 foto da `imageAbout` (tag `team.<slug>.image-about`).
### `src/pages/contact.astro`
`contact.meta.title` ("Contatti"), `contact.meta.description`, `contact.hero.title` ("Contattaci"), `contact.hero.image` (contact-hero.jpg, background PageHero), `contact.map.button` ("Carica la mappa (Google Maps)"), `contact.map.note` ("La mappa viene caricata da Google solo dopo il tuo consenso."), label info (address-label "Indirizzo", phone-label "Telefono", email-label "E-mail" — i valori sono i tag `global.contact.*`), `contact.invite` ("Per informazioni o per richiedere una consulenza iniziale…"). Iframe Maps generato via JS (title tecnico, escluso).
### `src/pages/training.astro`
`training.meta.title`, `training.meta.description`, `training.hero.title` ("training"), `training.intro.title` ("Training"), `training.intro.subtitle` ("Porta i tuoi allenamenti al livello successivo: programmi completi…" — variante diversa dalla home), `training.trial.title` ("Prenota la lezione di prova"), `training.trial.body`, `training.trial.cta` ("Prenota"), `training.trial.image` (trial-class.jpg). Blocchi per-training: testi dai tag `training.<id>.*` (vedi trainings.ts); nota `<h2>{t.title.toLowerCase()}</h2>` (lowercase a runtime, conservare).
### `src/pages/404.astro`
`notfound.meta.title`, `notfound.eyebrow` ("Errore 404"), `notfound.title` ("Pagina non trovata"), `notfound.body`, `notfound.cta` ("Torna alla home").
### `src/pages/programs/index.astro`
`programs.meta.title`, `programs.meta.description`, `programs.hero.title` ("Programmi"), `programs.intro.title`, `programs.intro.body` ("Questi sono i programmi delle classi di Insanity Lab…"), `programs.card.more-label` ("Per saperne di più →", ripetuto). Card e immagini (`program-<slug>.jpg`, alt = title) da programs.ts.
### `src/pages/programs/[slug].astro`
Hardcoded: `program.side.categories-label` ("Categorie"), `program.side.category.1-4` ("Fitness", "Salute", "Stile di vita", "Nutrizione"), `program.side.others-label` ("Altri programmi"), `program.pricing.empty-note` (html con link "Contattaci per i dettagli… Scrivici →"), `program.pricing.per` ("al mese, semestrale"), `program.pricing.entries-singular/-plural` ("ingresso settimanale"/"ingressi settimanali"), `program.pricing.buy-cta` ("Acquista").
Dinamici: meta = title/excerpt del programma; hero = `title.toUpperCase()` (conservare la trasformazione); corpo/features/faq/pricing da data. Immagine `program-<slug>.jpg` come hero bg e inline.
## Componenti condivisi
### `Header.astro`
Logo `/img/logo-dark.png` (public) con alt "InsanityLab" (`header.logo.alt`). Nav: label da `site.navigation` → tag `global.nav.<i>.label` (5 voci: "Insanitylab", "Training", "Programmi", "Blog & Edugo", "Contatti"). Aria-label esclusi.
### `Footer.astro`
Logo light + alt; `footer.tagline` (html, "PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY."); `footer.about` ("Promuoviamo il movimento come cura di sé…"); riga contatti con `<br>` (comporre dai tag `global.contact.*`); orari (`global.hours.1/2`); titoli colonne training/programs/contact; `footer.copyright` ("Insanitylab, All Rights Reserved" — `<strong>` e anno dinamico restano fuori dal tag); `footer.social-label` ("Seguici"); label link da `site.footerTraining` (4) e `site.footerPrograms` (6) → `footer.training.<i>.label`, `footer.programs.<i>.label`.
### `ContactForm.astro`
Placeholder: first-name "Nome", last-name "Cognome" (solo full), phone "Telefono" (solo full), email "E-mail", message "Messaggio". Submit "Invia messaggio". Messaggi nello `<script>` client (passare via data-*): success "Messaggio inviato, ti ricontatteremo al più presto.", error-generic "Errore di invio, riprova più tardi.", error-network "Errore di rete, riprova più tardi.".
### `Agenda.astro` + `agenda.ts`
`agenda.title` ("Agenda"); da agenda.ts: `agenda.discipline.<i>` (7: Events, Crossfit, Cardio, Box, Meditation, Yoga Classes, Body Balance), `agenda.day.<i>` (7: Lunedì…Domenica), `agenda.slot.<i>` (5: 10.00…20.00). **Celle (28 voci discipline/time/trainer) ESCLUSE**: dati placeholder Prowess, in attesa orario reale dal cliente.
### `PageHero.astro`
Nessun testo proprio (props title/eyebrow/bgImage passate dalle pagine con i rispettivi tag). Per bgImage da override serve accettare anche URL stringa.
## Componenti home
### `HeroSlider.astro`
`home.hero.title-line-1/-2/-3` ("PERFORMANCE." / "BALANCE." / "LONGEVITY."), `home.hero.cta` ("Scopri"), `home.hero.image` (hero-1.png, alt "Atleti InsanityLab"). Frecce slider: aria, escluse.
### `InfoColumns.astro`
3 colonne: `home.info.col<n>.title` ("PERFORMANCE." / "BALANCE." / "LONGEVITY.") + `home.info.col<n>.image` (info-performance/balance/longevity.jpg, background via getImage → usare `contentImageUrl`).
### `FeatureBlocks.astro`
3 blocchi × (title, body, cta "Scopri") → `home.feature.<performance|balance|longevity>.title/.body/.cta` + `.image` (feature-*.jpg, alt = title).
### `QuoteBanner.astro`
`home.quote.text` (html — congelare nel seed l'output della regex che avvolge le parole chiave in `<strong>`, poi rimuovere la regex), `home.quote.cite` ("Insanitylab"), `home.quote.image` (quote-bg.jpg, background). Testo renderizzato 2× per il marquee (seconda copia aria-hidden).
### `TrainingCards.astro`
`home.training-cards.link-label` ("Scopri →"). Card: testi/immagini dai tag `training.<id>.*`. Usato in home e in training.astro.
### `ProgramsGrid.astro`
`home.programs.title` ("Programmi"), `home.programs.body` (uguale a programs.intro.body ma tag separato). Griglia da programs.ts + SVG da program-icons.ts (icone escluse dal tagging).
### `TeamSection.astro`
`home.team.title` (html, "Team<br>Trainer"), `home.team.cta` ("Scopri team"). Membri: filtro `homeTeam` su team.ts, foto `team.<slug>.image`, figcaption = shortName.
### `MethodSteps.astro`
`home.method.title` (html, "Il metodo<br>InsanityLab"). Intro/note/steps dai tag `method.*` (intro html con `<strong>` congelato, rimuovere il set:html con wrapping runtime). Immagini step alt="" (decorative).
### `PartnersStrip.astro`
Solo loghi: `partner.<i>.name` (alt) + `partner.<i>.image` da partners.ts.
## Componente about
### `about/Timeline.astro`
`about.storylab.title` ("Storylab"), `about.storylab.body`, `about.timeline.label` ("INSANITYLAB 2021-2024"), `about.timeline.open-text`, `about.timeline.stage<1-3>.label` ("INSANITYLAB 2025"/"2026"/"OGGI") + `.text` (placeholder "Contenuto in arrivo…" — testi Storylab attesi dal cliente). Struttura `<details>/<summary>` da conservare.
## Data file
- **site.ts**: phone "351 7535977", email "info@insanitylab.it", address "Via Leandro Alberti, 76", cityLine "40139 Bologna", hours (2), socials (3, label+url — url restano nel data file), navigation (5), footerTraining (4), footerPrograms (6).
- **team.ts**: 9 membri (slug da shortName lowercase): donata, nicola, eva (founder); alessandro, eleonora, antonello, alice, davide, giulia. Campi: name, role, bio → `team.<slug>.name/.role/.bio`; immagini: `image` (4: donata, nicola, eleonora, antonello) e `imageAbout` (tutti e 9, file `team-*-dark`).
- **programs.ts**: 6 slug: personal-training, performance-class, coaching, pilates-flow, rehab, nutrition. Campi: title, subtitle, excerpt, longDescription, features[] (4-5), faq[] (3-6 coppie q/a). Solo performance-class ha hasPricing. → `program.<slug>.title/.subtitle/.excerpt/.long-description/.feature.<i>/.faq.<i>.q/.a`.
- **pricing.ts**: heading "Acquista la tua performance class", intro, 3 gruppi (label; solo il gruppo 1 "semestrale" ha 3 plans). Per plan: monthly/entries (numerici, restano nel data file), note, installments, best → `pricing.group.1.plan.<i>.note/.installments/.best`.
- **method.ts**: intro (html), extraNote, 4 step (title, text, image method-step-1..4) → `method.intro`, `method.extra-note`, `method.step.<n>.title/.text/.image`.
- **trainings.ts**: 4 id: one-to-one, small-groups, performance-class, fit-remote. Campi title, caption, description, cta.label (href resta) + image → `training.<id>.title/.caption/.description/.cta-label/.image`.
- **partners.ts**: 4: "Ticket Welfare Edenred", "Welbee", "AON", "Howden" → `partner.<i>.name/.image`.
- **program-icons.ts**: path SVG, esclusi dal tagging.
## Note trasversali
1. Tag `type: 'html'` necessari per: footer.tagline (`&nbsp;`), home.team.title e home.method.title (`<br>`), home.quote.text e method.intro (`<strong>` congelato), program.pricing.empty-note (link `<a>`).
2. Trasformazioni runtime da conservare nel template (non nel valore): `.toLowerCase()` (training h2), `.toUpperCase()` (hero dettaglio programma), anno copyright.
3. Duplicazioni intenzionali (tag distinti, valori oggi identici): programs.intro.body / home.programs.body / pricing.intro; home.training.subtitle / training.intro.subtitle (varianti).
4. Loghi header/footer stanno in `public/img/` (non passano dalla pipeline asset): TImg non applicabile — per l'override basta `contentImageUrl(tag, '/img/logo-dark.png')` su `<img>`.
5. Conteggi indicativi: ~150 tag testo da template, ~200 da data file, ~60 slot immagine.
File diff suppressed because it is too large Load Diff
@@ -1,130 +0,0 @@
# Sito web InsanityLab — Design
**Data:** 2026-07-01
**Dominio di destinazione:** insanitylab.tielogic.xyz (VPS Hostinger, porting in fase successiva)
**Stato:** approvato dall'operatore durante la sessione di brainstorming
## Obiettivo
Realizzare il sito vetrina di InsanityLab (centro di allenamento e benessere, Bologna) a partire dal design Figma fornito dal designer (`FigmaDoc/*.pdf`, `Web Insanity.fig`), che ristilizza il template WordPress "Prowess" (analizzato in `Dati/cassetta-me-analisi.md`) con una palette beige/marrone al posto del rosso originale. Il sito include un blog gestibile dal cliente tramite pannello protetto da login e un form contatti che invia email. La gestione degli acquisti online (pagina prezzi Performance Class) è rimandata a una fase successiva.
## Stack
| Livello | Scelta |
|---|---|
| Framework | Astro 5, output `server` con adapter Node |
| Pagine vetrina | Prerenderizzate (`prerender = true`) |
| Blog e admin | Server-rendered |
| Stile | CSS custom (palette estratta dai PDF Figma), nessun framework CSS |
| Font | Montserrat (titoli) + Open Sans (corpo), self-hosted |
| JS client | Vanilla, minimo indispensabile (slider, tab, accordion, header sticky) |
| Database | SQLite via `better-sqlite3`, file `data/insanitylab.db` |
| Editor admin | WYSIWYG TipTap, con upload immagini nel corpo articolo |
| Email | nodemailer → SMTP (credenziali via `.env`) |
| Test | vitest (logica) + smoke test su build |
| Versionamento | git dal primo commit, remote su GitLab |
| Deploy (fase 2) | Dockerfile multi-stage + compose, gestito poi via Komodo sul VPS |
Motivazione: sito prevalentemente statico con una sola area dinamica (blog). Un'unica applicazione Node leggera evita la manutenzione di un CMS completo, mantiene ottime prestazioni e SEO, e resta estendibile (pagamenti in fase futura).
## Route
### Pubbliche
| Route | Contenuto | Fonte design |
|---|---|---|
| `/` | Homepage: hero slider, 3 colonne Performance/Balance/Longevity, feature alternata, banner citazione, Training (4 card), Programmi (6 icone), Agenda con tab, Team trainer, Metodo (4 step), loghi partner (Ticket Welfare Edenred, Welbee, AON, Howden), footer con mini-form | Homepage.pdf |
| `/about` | Mission, direzione, approccio, about, timeline "Storylab" (2021-2024 aperta; +2025, +2026, +oggi ad accordion), convenzioni aziendali, team completo (9 persone), metodo, IN-SANITY STUDIOS, vision | Insanitylab.pdf |
| `/training` | 4 servizi (One to One, Small Groups, Performance Class, Fit Remote) con blocchi alternati, agenda, CTA "prenota la lezione di prova" | Training.pdf |
| `/programs` | Griglia 6 programmi con testi reali | Programmi.pdf |
| `/programs/[slug]` | Pagina dettaglio per ogni programma: `personal-training`, `performance-class`, `coaching`, `pilates-flow`, `rehab`, `nutrition` | Performance class.pdf |
| `/blog` | Blog & Edugo: intro, filtri per categoria, griglia articoli, paginazione | Blog & Edugo.pdf |
| `/blog/[slug]` | Articolo singolo con sidebar (autore, categorie, ultimi articoli, archivio) | Articolo.pdf |
| `/contact` | Form contatti (Nome, Cognome, Telefono, E-mail, Messaggio), mappa Google, recapiti | Contatti.pdf |
Dettaglio programmi: layout unico modellato su Performance class.pdf (FAQ ad accordion, descrizione, sidebar categorie + altri programmi). Il blocco pricing compare solo dove esistono prezzi — al lancio solo `performance-class` (3 piani semestrali: 130/234/300 € al mese, con varianti trimestrale e mensile ad accordion). I bottoni "acquista" puntano per ora a `/contact`. I testi degli altri cinque programmi partono dalle descrizioni di Programmi.pdf; il cliente li integrerà.
### Admin (richiedono sessione)
| Route | Funzione |
|---|---|
| `/admin/login` | Login user/password |
| `/admin` | Elenco articoli (pubblicati e bozze) |
| `/admin/new` | Nuovo articolo |
| `/admin/edit/[id]` | Modifica articolo |
### API
| Endpoint | Funzione |
|---|---|
| `POST /api/contact` | Invio email form contatti (honeypot antispam + rate-limit) |
| `POST/PUT/DELETE /api/admin/posts*` | CRUD articoli (sessione richiesta) |
| `POST /api/admin/upload` | Upload immagini (copertina e corpo articolo, salvate in `public/uploads/`) |
## Design system
- **Palette** (valori esatti da campionare dai PDF in fase di implementazione): accento beige/tan ~`#b8a98a` (bottoni, etichette), marrone scuro ~`#3a2e28` (sezioni dark: agenda, banner citazione, footer, card scure), fondi chiari `#f5f3f0`/bianco.
- **Tipografia**: Montserrat uppercase con letterspacing largo per titoli e bottoni; Open Sans per il corpo. File woff2 self-hosted (niente Google Fonts CDN, conformità GDPR).
- **Componenti chiave**: header sticky (trasparente sopra l'hero, bianco con ombra allo scroll), hero slider (scroll-snap CSS + JS vanilla), card programma testo/foto alternate, agenda settimanale con tab per disciplina, accordion FAQ e pricing, form con stati di validazione.
- **Responsive**: mobile-first; il Figma è solo desktop (1366 px), il comportamento mobile si deriva dalle convenzioni del template (menu hamburger sotto i 992 px, griglie che collassano a colonna).
- **Immagini**: estratte dall'archivio `Web Insanity.fig` (è uno ZIP con cartella `images/`; i file hanno nomi hash e vanno identificati visivamente e rinominati per sezione). Fallback: asset in `Dati/` e estrazione da PDF con `pdfimages`. Ottimizzazione con `astro:assets` (webp, lazy loading, srcset).
- **SEO**: title/description per pagina, Open Graph, sitemap, robots.txt, favicon dal logo.
- **Mappa contatti**: iframe Google Maps con click-to-load (consenso prima del caricamento, GDPR).
## Backend
### Database
SQLite in `data/insanitylab.db` (fuori da `src/`, montabile come volume Docker in fase 2). Schema creato da script SQL idempotente all'avvio.
```sql
posts (id, title, slug UNIQUE, category, excerpt, body_html,
cover, published_at, draft, created_at, updated_at)
users (id, username UNIQUE, password_hash)
sessions (token PK, user_id, expires_at)
```
Categorie blog fisse (da Figma): Articoli tecnici & scientifici, Contenuti educativi, LinkedIn / Insight, Eventi & Presidi.
Il corpo articolo è HTML prodotto da TipTap, sanitizzato lato server prima del salvataggio e del rendering.
### Autenticazione
- Password con hash bcrypt; utenti creati via script CLI (`npm run create-user`).
- Sessione: token random in cookie httpOnly, SameSite=Lax, scadenza 7 giorni, verificata da middleware su tutte le route `/admin` e `/api/admin`.
- Rate-limit sul login (protezione brute force).
### Email
`POST /api/contact` valida i campi, applica honeypot e rate-limit, invia via nodemailer con SMTP configurato in `.env` (host, porta, credenziali, destinatario — default `info@insanitylab.it`). Risposta JSON per feedback inline nel form.
### Dati statici
Agenda settimanale, prezzi, team, programmi, loghi partner: file TypeScript/JSON in `src/data/`, modificati dallo sviluppatore e versionati in git. Nessuna gestione da admin per questi contenuti al lancio.
## Deploy
- **Fase attuale (locale)**: `npm run dev`; build verificata con `npm run build && npm run preview`.
- **Versionamento**: repository git con remote su GitLab (namespace da confermare in fase di implementazione).
- **Fase 2 (VPS)**: Dockerfile multi-stage (build → `node:22-slim`), `compose.yaml` con volumi per `data/` e `public/uploads/`, porta interna 4321; aggancio a Komodo e reverse proxy per `insanitylab.tielogic.xyz` quando si porterà in produzione. Fuori scope di questa spec.
## Gestione errori
- Form contatti: validazione client + server, messaggi di errore inline, fallback se SMTP non risponde (errore esplicito all'utente, log lato server).
- Admin: redirect a `/admin/login` se sessione assente/scaduta; conferma prima dell'eliminazione articoli.
- 404 personalizzata coerente col design.
- Upload: limite dimensione (5 MB) e whitelist tipi immagine.
## Test e verifica
- vitest su: helper slug, validazione form, auth (hash/sessioni), query posts.
- Smoke test: build completa senza errori; fetch delle route principali con risposta 200 e contenuti attesi.
- Verifica visiva locale contro i PDF Figma pagina per pagina.
## Fuori scope (fasi future)
- Pagamenti online piani Performance Class (Stripe o simile).
- Gestione da admin di agenda, prezzi, team.
- Porting su VPS, DNS, TLS (fase 2 dedicata).
- Ricerca nel blog e ricerca globale (icona lente del template: omessa al lancio).
- E-commerce/carrello (icona carrello nell'header Figma: omessa al lancio, era ereditata dal template).
@@ -1,61 +0,0 @@
# Animazioni stile template Prowess — Design
Data: 2026-07-02 · Stato: approvato dall'operatore
## Obiettivo
Il sito InsanityLab, oggi quasi del tutto statico, deve riprendere il linguaggio di animazione del template WordPress "Prowess" (Qode Interactive) da cui deriva il design Figma. L'operatore ha scelto il pacchetto completo: reveal allo scroll, parallax degli sfondi, animazione d'ingresso della hero, effetti hover su card e team, e citazione con testo a scorrimento continuo.
## Evidenze raccolte dal template
Dalla demo ufficiale `prowess.qodeinteractive.com` (cassetta.me è in manutenzione):
- gli elementi appaiono allo scroll con un offset di attivazione di 100 px dal viewport (`qodefElementAppearAmount: -100`);
- due sezioni usano parallax di sfondo (`data-parallax-bg-speed="1"`);
- la hero è uno Slider Revolution con testi che entrano in sequenza;
- card e foto hanno hover con zoom immagine e transizioni morbide.
## Decisioni
1. **Implementazione vanilla**: CSS + IntersectionObserver, nessuna libreria. Il sito non ha dipendenze frontend e resta così; costo totale ~2 KB.
2. **Citazione**: si mantiene il banner su foto scura del Figma, ma il testo della citazione scorre in orizzontale a loop infinito come il marquee del template (scelta esplicita dell'operatore). La firma "Insanitylab" resta statica.
3. **Accessibilità**: con `prefers-reduced-motion: reduce` ogni animazione è disattivata (reveal, marquee, parallax, hero). Senza JavaScript il sito resta interamente visibile: lo stato nascosto si applica solo sotto `html.js`.
## Architettura
- **`src/styles/global.css`** — utilities: `[data-reveal]` (stato iniziale `opacity: 0` + `translateY(40px)`, transizione `.9s ease`; varianti `left`, `right`, `fade`; ritardo per stagger via variabile `--reveal-delay` impostata inline), keyframes marquee, blocco `prefers-reduced-motion`.
- **`src/layouts/Base.astro`** — uno script inline in `<head>` aggiunge la classe `js` a `<html>` prima del paint (evita flash); uno script bundled attiva l'IntersectionObserver (`rootMargin: 0 0 -100px`, `unobserve` dopo il primo ingresso) e il parallax (`transform: translateY` calcolato su scroll con `requestAnimationFrame`, velocità ~0.3, attivo solo da 992 px in su e senza reduced-motion).
- **Componenti** — ricevono attributi `data-reveal` e stagger inline; nessuna modifica strutturale se non il wrapper overflow per gli zoom hover e la ristrutturazione del banner citazione (sfondo su div dedicato trasformabile, testo in marquee con copia `aria-hidden`).
## Dettaglio per componente
| Componente | Effetto |
|---|---|
| HeroSlider | Ingresso al load: righe del titolo slide-up in sequenza (stagger 150 ms), bottone dopo, immagine da destra con fade |
| InfoColumns | Reveal fade con stagger 0/150/300 ms |
| FeatureBlocks | Immagine entra dal proprio lato, testo dal lato opposto |
| QuoteBanner | Sfondo parallax + testo citazione in marquee loop (pausa su hover), firma statica |
| TrainingCards | Reveal con stagger; hover zoom foto (wrapper overflow hidden) |
| ProgramsGrid | Heading reveal; item con stagger; hover: traslazione -4 px e colore |
| Agenda | Reveal heading e tabella |
| TeamSection | Head reveal; card con stagger; hover zoom foto |
| MethodSteps | Heading reveal; step con stagger |
| PartnersStrip | Reveal fade dell'intera striscia |
| PageHero (pagine interne) | Reveal del titolo all'ingresso pagina |
| Header | Transizione morbida dell'ombra già presente |
| Bottoni `.btn` | Freccia trasla a destra su hover |
## Errori e casi limite
- JS assente: nessun elemento nascosto (gate su `html.js`).
- Reduced motion: contenuto immediatamente visibile, marquee fermo su testo statico multilinea.
- Il marquee usa due copie identiche del segmento (`translateX(-50%)`) per loop senza scatti; la seconda copia è `aria-hidden`.
- Solo `opacity` e `transform`: nessun layout shift, nessun impatto CLS.
## Verifica
Build senza errori, vitest esistenti verdi, controllo visivo locale su homepage e pagine interne, poi deploy su VPS e smoke test produzione.
## Fuori scope
Slider hero multi-slide automatico (oggi esiste una sola slide), animazioni admin, contatori animati e BMI calculator (assenti dal design Figma).
@@ -1,146 +0,0 @@
# Sistema di contenuti taggati e pannello editor
Data: 2026-07-05
Stato: approvato dall'operatore (design), spec in revisione
## Obiettivo
Ogni testo e ogni immagine delle pagine vetrina del sito riceve un tag univoco e stabile. I contenuti vengono estratti dal codice e salvati in SQLite, così da poter essere modificati senza intervenire sui sorgenti. Un pannello di amministrazione (`/admin/content`) permette a un utente con ruolo dedicato di modificare testi, stili (tramite preset controllati) e immagini; una modalità "annotate" sovrappone al sito i badge con i nomi dei tag, rendendo immediato individuare l'elemento da modificare.
Il sistema serve due scopi complementari: nel breve periodo dà all'operatore un vocabolario preciso per dettare modifiche ("cambia `home.hero.title` in…"); nel medio periodo consente al cliente di aggiornare i contenuti in autonomia, senza rischio di rompere il layout.
## Decisioni prese in fase di design
| Decisione | Scelta |
| --- | --- |
| Scopo | Tagging + pannello editor nello stesso progetto |
| Ambito modifiche | Testi, immagini, stili tramite preset chiusi (no CSS libero) |
| Utenti | Ruolo `admin` (tutto) e ruolo `editor` (solo contenuti) |
| Pubblicazione | Pagine vetrina passano a SSR con cache in memoria invalidata al salvataggio |
| UX | Ibrido: pannello `/admin/content` + overlay annotate sul sito con link ai form |
## Architettura
### 1. Schema dei tag
Formato gerarchico `pagina.sezione.elemento`, tutto minuscolo, separatore punto, senza spazi:
- `home.hero.title`, `home.hero.subtitle`, `home.hero.cta`
- `about.team.donata.bio`, `about.intro.body`
- `programs.pilates-flow.price`, `training.fit-remote.description`
- `global.footer.tagline`, `global.header.cta` (elementi condivisi tra pagine)
I tag sono stabili: una volta assegnati non cambiano nome, perché sono chiave primaria nel DB e riferimento nelle conversazioni. I dati oggi strutturati in `src/data/*.ts` (team, pricing, agenda, method, programs, trainings, partners, site) vengono scomposti in tag granulari con lo stesso schema.
### 2. Storage (SQLite, DB esistente)
Nuova tabella nella `SCHEMA` di `src/lib/db.ts`:
```sql
CREATE TABLE IF NOT EXISTS content_blocks (
tag TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('text','html','image')),
value TEXT NOT NULL DEFAULT '',
styles TEXT NOT NULL DEFAULT '{}', -- JSON, solo chiavi preset
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_by TEXT
);
```
- `type = 'text'`: testo semplice, escapato in render.
- `type = 'html'`: frammenti con markup minimo (grassetti, a-capo) sanificati con la pipeline già usata dal blog (`src/lib/sanitize.ts`). Usato solo dove il testo attuale contiene markup.
- `type = 'image'`: `value` contiene il percorso dell'override caricato (es. `content/home-hero-1.jpg`, relativo a `uploads/`) oppure stringa vuota se vale l'asset originale.
### 3. Seed e fonte di verità
`src/data/content-seed.ts` contiene l'inventario completo dei tag con i valori estratti dai testi attuali: `{ tag, type, value, styleOptions }`. Il campo `styleOptions` dichiara quali preset ha senso esporre per quel tag (vedi §6); non finisce nel DB, guida solo il pannello.
All'avvio (in `createDb`) un passo di sync inserisce nel DB i tag presenti nel seed ma assenti nella tabella (`INSERT OR IGNORE`). Regole:
- In produzione il DB è la fonte di verità: le modifiche del cliente non vengono mai sovrascritte dal seed.
- I tag nuovi arrivano con il codice (aggiunta al seed) e compaiono nel DB al deploy successivo.
- I tag rimossi dal seed restano orfani nel DB e vengono ignorati; nessuna cancellazione automatica.
- Il seed nel repo funge anche da backup versionato dei testi originali.
### 4. Rendering
- Le pagine vetrina (`index`, `about`, `contact`, `training`, `programs/index`, `programs/[slug]`, `404`) passano da `prerender = true` a `prerender = false` (SSR, già attivo per blog e admin con adapter node standalone).
- Nuovo modulo `src/lib/content.ts`:
- `getContent(tag)` / `getAllContent()`: lettura con cache in memoria (`Map` popolata al primo accesso con una sola query).
- `invalidateContentCache()`: svuota la cache; chiamata dall'API di salvataggio. Modifica visibile alla richiesta successiva.
- Fallback a cascata: valore nel DB → valore nel seed → stringa vuota con log di warning. Il sito non si rompe mai per un tag mancante.
- Nuovi componenti Astro:
- `<T tag="home.hero.title" as="h1" class="…" />`: renderizza il contenuto nel tag HTML indicato (`as`, default `span`), applica le classi preset derivate da `styles`, emette `data-tag` per l'overlay annotate. Per `type='text'` il valore è escapato; per `type='html'` è già sanificato al salvataggio.
- `<TImg tag="home.hero.image" … />`: se esiste override in DB serve il file da `/uploads/content/…` (route `src/pages/uploads/[...path].ts` già esistente), altrimenti l'asset originale ottimizzato dalla pipeline Astro. Le prop di layout (dimensioni, alt di default, loading) restano nel codice.
- La sitemap non cambia (le route restano le stesse); verificare che l'integrazione sitemap continui a emetterle con `output: 'server'` e prerender disattivato, altrimenti passare a una sitemap statica generata a mano.
### 5. Ruoli utente
- Migrazione additiva: `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'` (eseguita in `createDb` se la colonna manca; gli utenti esistenti restano admin).
- `getSessionUser` ritorna anche `role`; `context.locals.user` lo espone.
- Middleware: le route `/admin/content` e `/api/admin/content*` sono accessibili ad `admin` ed `editor`; tutte le altre route `/admin*` e `/api/admin*` restano riservate ad `admin` (l'editor che le richiede riceve redirect a `/admin/content` o 403 sulle API).
- Creazione utente editor: script CLI in `scripts/` (pattern già usato per l'admin), credenziali consegnate al cliente fuori banda.
### 6. Stili preset
Il DB memorizza solo chiavi simboliche; la mappa chiave → classe CSS vive nel codice (`src/lib/style-presets.ts` + classi in `global.css`). Nessun CSS arbitrario può entrare dal pannello.
| Gruppo | Chiavi | Note |
| --- | --- | --- |
| `size` | `s` / `m` / `l` | scala relativa alla dimensione base dell'elemento |
| `color` | palette del sito (brand-dark, tan, taupe, bianco, …) | lista chiusa derivata dalle variabili CSS esistenti |
| `weight` | `normal` / `bold` | |
| `style` | `normal` / `italic` | |
| `align` | `left` / `center` / `right` | solo su elementi a blocco |
Ogni tag dichiara nel seed quali gruppi espone (`styleOptions`): un titolo hero espone size e color, una voce di prezzo magari solo color. Valori non in whitelist vengono rifiutati dall'API al salvataggio.
### 7. Pannello `/admin/content`
Pagina SSR protetta, stessa shell di `src/layouts/Admin.astro`:
- Tag raggruppati per pagina (Home, About, Programs, Training, Contact, Globali), gruppi collassabili, campo di ricerca per nome tag o contenuto.
- Per ogni tag: form con campo testo (input o textarea in base alla lunghezza), controlli preset solo per i gruppi dichiarati, upload immagine per i tag `image` (validazione mime/dimensione riusando la logica dell'upload blog) con anteprima e pulsante "ripristina originale".
- Salvataggio per singolo tag (pulsante per riga) via `fetch` alle API; feedback inline di esito.
- Ancore `id` per tag: `/admin/content#home.hero.title` scrolla ed evidenzia il form corrispondente.
- Pulsante "Vedi sul sito con tag" che apre la pagina pubblica in modalità annotate.
API:
- `GET /api/admin/content` — elenco completo (per il pannello).
- `PUT /api/admin/content/[tag]` — aggiorna `value` e/o `styles`; valida tipo, preset in whitelist, sanifica se `type='html'`; aggiorna `updated_at`/`updated_by`; invalida la cache.
- `POST /api/admin/content/[tag]/image` — upload override immagine in `uploads/content/`; `DELETE` sullo stesso path rimuove l'override e ripristina l'asset originale.
### 8. Overlay annotate
- Attivazione: query string `?annotate=1` su qualunque pagina vetrina, solo con sessione valida (admin o editor). Senza sessione il parametro è ignorato: nessuna traccia dei tag per i visitatori.
- `Base.astro` in modalità annotate inietta un piccolo CSS+JS vanilla (coerente con lo stile del progetto: niente librerie): ogni elemento con `data-tag` riceve un bordo tratteggiato e un badge con il nome del tag; il click sul badge apre `/admin/content#<tag>` in nuova scheda.
- Le animazioni reveal restano attive; il badge è posizionato in modo da non alterare il layout (position absolute su wrapper relativo o outline, da verificare in implementazione).
### 9. Gestione errori
- Tag mancante nel DB e nel seed: stringa vuota + `console.warn` lato server. La pagina renderizza comunque.
- `styles` JSON corrotto nel DB: si ignora e si usa `{}`.
- Upload immagine: stessi limiti e messaggi del blog; file orfani in `uploads/content/` tollerati (nessuna garbage collection automatica).
- API: 401 senza sessione, 403 per ruolo insufficiente, 400 con messaggio esplicito per tag inesistente, tipo errato o preset fuori whitelist.
### 10. Test
- Unit (`vitest`, pattern esistente in `tests/`): content lib (fallback a cascata, invalidazione cache, sync del seed), validazione preset, migrazione colonna `role`.
- API: autorizzazione per ruolo (editor può contenuti, non può post; anonimo 401), salvataggio e rilettura, rifiuto preset non in whitelist.
- Smoke: le pagine vetrina renderizzano in SSR con contenuti dal DB; overlay annotate presente con sessione e assente senza.
## Fasi di lavoro previste (indicative per il piano)
1. Infrastruttura: tabella, migrazione ruoli, content lib con cache, componenti `<T>`/`<TImg>`, preset.
2. Estrazione e tagging: inventario completo testi/immagini pagina per pagina, creazione seed, sostituzione nei template e nei componenti (lavoro meccanico ma esteso; è la fase più lunga).
3. Pannello + API + overlay annotate + ruolo editor.
4. Test, verifica end-to-end locale, deploy e verifica in produzione.
## Fuori ambito
- CSS libero per elemento (escluso per scelta).
- Versionamento/storico delle modifiche ai contenuti (eventuale evoluzione futura).
- Modifica della struttura delle pagine (aggiunta/rimozione sezioni) dal pannello.
- Gestione multilingua.
- Blog: resta gestito dal suo admin TipTap esistente, fuori dal sistema tag.
@@ -1,210 +0,0 @@
# Utenti, ruoli, login pubblico, GUID contenuti e modifica inline — Design
Data: 2026-07-05
Progetto: sito InsanityLab (Astro 7 SSR, SQLite/better-sqlite3, live su https://insanitylab.tielogic.xyz)
## Obiettivo
Portare il sito da due ruoli (`admin` | `editor`) a un sistema a tre ruoli con login pubblico,
identità utente visibile nell'header, blog con ownership per autore, un identificatore GUID stabile
per ogni contenuto taggato, e la modifica dei contenuti direttamente in pagina tramite un overlay
attivabile con toggle. Nessuna dipendenza frontend nuova: tutto vanilla, in continuità con l'esistente.
## Ruoli e matrice permessi
Tre ruoli su `users.role`: `admin`, `superuser`, `user`. Il vecchio ruolo `editor` viene migrato a
`superuser`.
| Capacità | user | superuser | admin |
|---|:---:|:---:|:---:|
| Login + nome nell'header + logout | ✓ | ✓ | ✓ |
| Blog: CRUD **solo dei propri** articoli | ✓ | ✓ | ✓ |
| Blog: gestione articoli **di tutti gli autori** | | ✓ | ✓ |
| Pannello `/admin/content` | | ✓ | ✓ |
| Toggle "Mostra tag" + modifica inline in pagina | | ✓ | ✓ |
| Gestione utenti `/admin/users` (crea, reset psw, ruolo, elimina) | | | ✓ |
La regola: ogni ruolo può fare tutto ciò che può il ruolo inferiore, più le proprie capacità.
## Modello dati (migrazioni idempotenti in `src/lib/db.ts`, prima di `syncSeed`)
Tutte additive, nello stile delle migrazioni esistenti (PRAGMA table_info + ALTER, transazione).
1. **Ruolo `editor` → `superuser`**
`UPDATE users SET role = 'superuser' WHERE role = 'editor'`.
Idempotente: al secondo giro nessuna riga `editor` resta.
2. **GUID contenuti** — colonna `content_blocks.guid TEXT`.
- `ALTER TABLE content_blocks ADD COLUMN guid TEXT` se assente (PRAGMA table_info).
- Backfill: per ogni riga con `guid IS NULL OR guid = ''`, assegna `crypto.randomUUID()`.
- Indice univoco: `CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`
(creato dopo il backfill, così non fallisce su righe pre-esistenti a guid nullo).
- `syncSeed` (in `db.ts` e in `content.ts`): l'INSERT delle righe seed nuove valorizza il guid con
`crypto.randomUUID()` alla creazione. Le righe già presenti non vengono toccate (INSERT OR IGNORE),
quindi il loro guid è quello del backfill.
- Il **tag semantico resta la chiave primaria** e resta l'identificatore usato nel codice (`t('...')`,
`<T tag="...">`) e nel pannello. Il GUID si affianca: è l'ID stabile esposto verso l'esterno
(attributo `data-guid` nel DOM, badge, eventuale API/link). Nessun tag cambia nome.
3. **Autore articoli** — colonna `posts.author_id INTEGER` (nullable).
- `ALTER TABLE posts ADD COLUMN author_id INTEGER` se assente.
- Nessun vincolo FK stretto a livello schema (coerente con lo stile del repo, dove le FK esplicite
sono solo su `sessions`); l'integrità è gestita nel codice.
- Post storici: `author_id` resta nullo → firma di fallback "Staff InsanityLab" nel blog pubblico.
## Auth e sessioni (`src/lib/auth.ts`)
- `Role` diventa `'admin' | 'superuser' | 'user'`. `createUser` accetta i tre ruoli.
- `SESSION_COOKIE`, `login`, `getSessionUser`, `logout` restano invariati.
- Nuova funzione di autorizzazione che sostituisce `EDITOR_ALLOWED`/`canAccessAdminPath` con una
**matrice esplicita per prefisso di rotta**:
```
/admin/users, /api/admin/users* → admin
/admin/content, /api/admin/content* → superuser, admin
/admin (blog: index/new/edit/posts),
/api/admin/posts*, /api/admin/upload → user, superuser, admin
/admin/logout, /admin/login → chiunque (login pubblico)
```
Firma proposta: `canAccessAdminPath(role, pathname): boolean`. La landing per redirect dipende dal
ruolo (funzione `landingFor(role)`): `user → /admin` (lista blog), `superuser → /admin/content`,
`admin → /admin`.
- L'hashing password è già in `hashPassword`/`verifyPassword`; `scripts/create-user.mjs` continua a
funzionare. Nessuna duplicazione: le API utenti richiamano le funzioni di `auth.ts`.
## Middleware (`src/middleware.ts`)
Struttura invariata (protegge `/admin` e `/api/admin`, esclude `/admin/login`). Cambia solo il ramo
autorizzazione: usa la nuova matrice. Utente loggato ma senza permesso su una rotta pagina →
`redirect(landingFor(role))`; su una rotta API → `403` JSON. `context.locals.user` continua a esporre
`{ id, username, role }`.
## Login pubblico e header
### Pagina `/login` (`src/pages/login.astro`)
- Layout `Base` (stile sito pubblico), **non** `Admin`.
- Form username+password. Riusa la logica di `/admin/login`: stesso `rateLimit`, stesso `login()`,
stesso set del cookie `session` (httpOnly, sameSite lax, secure in prod, maxAge 7 giorni).
- Query `?next=<path>`: dopo login valido redirige a `next` se è un path interno sicuro (inizia con `/`,
non `//`, non `/api/`); altrimenti alla home `/`.
- Se già loggato: redirige a `next` sicuro o a `/`.
- `/admin/login` resta come alias funzionante (nessuna rimozione).
### Header (`src/components/Header.astro`, già incluso da `Base.astro`)
Reso SSR con lettura sessione dal cookie:
- **Anonimo**: link "Accedi" → `/login?next=<pagina-corrente>`.
- **Loggato**: nome utente + menu a tendina:
- "Area riservata" → `landingFor(role)`.
- Toggle "Mostra tag" (solo superuser/admin) — vedi sezione successiva.
- "Esci" → `/admin/logout`.
- Il menu è vanilla (dettaglio/summary o piccolo script già in stile repo), senza librerie.
## Toggle "Mostra tag" e modifica inline
### Attivazione
- Cookie `showtags=1` (non httpOnly, così lo script client può leggerlo; path `/`), impostato/rimosso
dal toggle nel menu header. Solo superuser/admin possono attivarlo (l'endpoint che lo setta verifica
il ruolo).
- `Base.astro` calcola `showTags = (cookie showtags === '1') && sessione con ruolo superuser|admin` e
passa il flag al layout. La persistenza tra pagine è data dal cookie (niente più `?annotate=1` da
trascinare nell'URL).
- Compat: `?annotate=1` continua a funzionare attivando il cookie/flag per la richiesta corrente.
### Badge
- Quando `showTags` è attivo, lo script badge esistente in `Base.astro` (overlay assoluto sugli
elementi con `data-tag`) viene esteso per: mostrare il **nome tag**, esporre il **`data-guid`**, e
aggiungere un pulsante matita ✎ cliccabile.
- Nessun badge per i visitatori anonimi o per il ruolo `user` (lo script e il markup non vengono
nemmeno emessi).
### Modal di modifica inline
Click sulla matita → apre un modal vanilla sovrapposto alla pagina corrente (nessun redirect al
pannello):
- **Nuovo endpoint** `GET /api/admin/content/[tag]` → `{ tag, guid, type, value, styleOptions }`
(`styleOptions` da `seedByTag`/style-presets, come già fa `GET /api/admin/content`).
- Il form si costruisce in base a `type`:
- `text` / `html` → textarea + eventuali select di stile dagli `styleOptions`; salva con
**`PUT /api/admin/content/[tag]`** (esistente, ritorna `{ ok, value }` post-sanitizzazione).
- `image` → input file → **`POST /api/admin/content/[tag]/image`** (esistente) e rimozione via
**`DELETE`** (esistente).
- Al salvataggio riuscito il modal aggiorna il contenuto in-place nel DOM (dal `value` ritornato) e si
chiude, senza reload. Errori mostrati dentro il modal.
- Il codice del modal (markup + script) è caricato **solo** quando `showTags` è attivo: zero peso per
chi non è superuser/admin con toggle attivo.
### Riuso vs nuovo
Le API di scrittura contenuti (PUT, POST/DELETE image) restano **invariate** — sono le stesse usate dal
pannello `/admin/content`. L'unica aggiunta backend è il `GET /api/admin/content/[tag]` singolo. Il
modal è l'unico componente frontend nuovo.
## Gestione utenti (`/admin/users`, solo admin)
### Pagina `src/pages/admin/users.astro` (layout `Admin`)
Lista utenti: username, ruolo, ultimo accesso (se ricavabile dalle sessioni). Form/azioni per:
crea, reset password, cambia ruolo, elimina.
### API `src/pages/api/admin/users/*` (admin-only via middleware)
- **`POST /api/admin/users`** → crea utente: `username` + `password` + `role`
(`user`|`superuser`|`admin`). Rifiuta username duplicato (409/400 con messaggio). Usa `createUser`.
- **`POST /api/admin/users/[id]/reset-password`** → genera password casuale, la ritorna **una sola
volta** in chiaro nella risposta JSON (l'admin la copia e la consegna). Aggiorna `password_hash`.
Non viene mai ri-mostrata né loggata.
- **`PUT /api/admin/users/[id]/role`** → cambia ruolo.
- **`DELETE /api/admin/users/[id]`** → elimina l'utente.
### Guardie (invarianti di sicurezza)
- Non puoi **eliminare te stesso** (confronto con `locals.user.id`).
- Non puoi eliminare **l'ultimo admin** rimasto, né declassarne il ruolo se è l'ultimo admin
(query di conteggio `role='admin'` nella stessa transazione).
- Eliminazione utente → cascade sulle sue sessioni (già `ON DELETE CASCADE` su `sessions.user_id`).
Gli articoli con `author_id` di un utente eliminato: `author_id` resta valorizzato ma "orfano" →
firma di fallback "Staff InsanityLab" nel pubblico (nessuna cancellazione a cascata dei post).
## Blog ownership
### API `/api/admin/posts*` e `/api/admin/posts/[id]`
- **`user`**: la lista in `/admin` è filtrata sui propri `author_id`; `POST` forza
`author_id = locals.user.id`; `PUT`/`DELETE` su un post con `author_id` diverso → **403**.
- **`superuser` / `admin`**: vedono e gestiscono tutti i post; il `POST` valorizza comunque
`author_id = locals.user.id` (l'autore è chi crea).
- Implementazione: `createPost` accetta `author_id`; `updatePost`/`deletePost` restano invariati ma le
route caricano il post e verificano la ownership prima di procedere (già caricano via `getPostById`
su PUT; aggiungere il caricamento su DELETE che oggi non lo fa).
### Pannello blog (`/admin`, `/admin/new`, `/admin/edit/[id]`)
- `user` vede solo i propri articoli; superuser/admin vedono tutti con una colonna "Autore".
- Editing di un post altrui bloccato lato UI per `user` (e comunque 403 lato API).
### Blog pubblico
- Firma autore sotto il titolo dell'articolo: nome dell'utente `author_id`; fallback
"Staff InsanityLab" quando `author_id` è nullo o orfano.
## Gestione errori
- API: JSON `{ error }` con status coerente (400 input non valido, 401 non autenticato — dal
middleware, 403 permesso/ownership, 404 risorsa inesistente, 409/400 duplicati).
- `next` non sicuro nel login → ignorato, redirect a `/` (niente open redirect).
- Cookie `showtags` presente ma ruolo insufficiente → nessun badge (il flag è ricalcolato server-side
con il ruolo reale, il cookie da solo non basta).
- Reset password / creazione: password mostrata una sola volta, mai persistita in chiaro.
## Testing
- **Unit** (`tests/roles.test.ts` esteso): matrice `canAccessAdminPath` per i tre ruoli su tutti i
prefissi; `landingFor`.
- **Unit ownership**: `user` non può PUT/DELETE post altrui; `POST` imposta `author_id`; guardie
gestione utenti (no self-delete, no last-admin delete/demote).
- **Unit migrazioni**: su DB in-memory con righe `editor` e `content_blocks` senza guid → dopo
`createDb`: ruoli migrati, guid non nulli e univoci, colonna `posts.author_id` presente.
- **Smoke** (`scripts/smoke.sh` esteso): `/login` 200; header mostra "Accedi" da anonimo; rotte admin →
redirect/403 da `user`; badge con `data-guid` presenti da superuser+ con toggle; modal/badge assenti
da anonimo.
## Fuori scope (YAGNI)
- Nessun framework/reattività frontend (no Alpine/petite-vue).
- Nessuna tabella permessi configurabile: i tre ruoli sono fissi in codice.
- Nessuna registrazione self-service: gli utenti li crea l'admin.
- Nessun cambio del nome tag semantico: il GUID si affianca, non sostituisce.
- Nessuna gestione avatar/profilo utente oltre username + ruolo.