Compare commits

...

28 Commits

Author SHA1 Message Date
Adriano c975dcac12 fix review finale: card programmi home taggate, README gestione contenuti, import type e commento seed 2026-07-05 17:45:31 +02:00
Adriano 8495d9edd4 test: smoke contenuti taggati e utente editor 2026-07-05 17:38:33 +02:00
Adriano 56ca6eca14 feat: overlay annotate per utenti loggati 2026-07-05 17:37:02 +02:00
Adriano 608b1b68d2 fix: pannello contenuti — PUT ritorna value salvato, data-q aggiornato, guard hash, aria-label 2026-07-05 17:35:41 +02:00
Adriano be52781499 feat: pannello admin contenuti
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:30:39 +02:00
Adriano bf19cba4d3 fix: guard body null su PUT contenuti, formato tag e suffisso random su upload immagine 2026-07-05 17:22:23 +02:00
Adriano eda4d73aac feat: API contenuti taggati
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:18:30 +02:00
Adriano b5c0c107bc chore: rimossa dep @astrojs/sitemap, README aggiornato 2026-07-05 17:14:10 +02:00
Adriano 685ef4a36e feat: pagine vetrina in SSR e sitemap runtime 2026-07-05 17:11:20 +02:00
Adriano 3551fae132 fix: hero contact ottimizzata senza override, mapsQuery da tag 2026-07-05 17:08:41 +02:00
Adriano bf38fc865c feat: tag contenuti training, contact e 404 2026-07-05 17:04:17 +02:00
Adriano 31354c4457 feat: tag contenuti programmi e pricing 2026-07-05 16:48:03 +02:00
Adriano b84463c9df feat: tag short-name membri team 2026-07-05 16:33:39 +02:00
Adriano d5b19f2c39 feat: tag contenuti pagina about e timeline 2026-07-05 16:29:55 +02:00
Adriano 34fd3d2e18 feat: tag contenuti home cards, team, metodo, partner, agenda 2026-07-05 16:17:31 +02:00
Adriano 3f01721a99 feat: tag contenuti home hero, info, feature, quote 2026-07-05 16:04:27 +02:00
Adriano f4c350ad64 feat: tag contenuti header, footer e form contatti 2026-07-05 15:54:29 +02:00
Adriano b079ded266 feat: componenti T e TImg per contenuti taggati 2026-07-05 15:46:11 +02:00
Adriano b22cc40b03 feat: content store con seed, cache e fallback 2026-07-05 15:43:29 +02:00
Adriano 6f51aec4c6 feat: preset stili contenuto con whitelist 2026-07-05 15:40:09 +02:00
Adriano 5f1d9f3740 feat: middleware limita editor ai percorsi contenuti 2026-07-05 15:38:21 +02:00
Adriano 0f94b5017c docs: fix regex EDITOR_ALLOWED nel piano (bypass prefisso) 2026-07-05 15:37:24 +02:00
Adriano 274a6c7600 fix: regex percorsi editor senza bypass prefisso 2026-07-05 15:36:38 +02:00
Adriano 77041f9d5a feat: ruoli admin/editor in auth e create-user 2026-07-05 15:31:53 +02:00
Adriano c7d9d4ba5d feat: tabella content_blocks e colonna role utenti 2026-07-05 15:28:17 +02:00
Adriano 83237feb65 docs: piano implementazione contenuti taggati + inventario
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:26:26 +02:00
Adriano b83d62c10c docs: fix stile tabelle markdownlint in spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:14:32 +02:00
Adriano 875b369939 docs: spec sistema contenuti taggati e pannello editor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:10:17 +02:00
58 changed files with 2797 additions and 329 deletions
+10 -2
View File
@@ -15,7 +15,7 @@ Il progetto realizza un sito web per InsanityLab, combinando una vetrina azienda
- **Email**: Nodemailer - **Email**: Nodemailer
- **Markup Editor**: TipTap (rich text) - **Markup Editor**: TipTap (rich text)
- **Font System**: @fontsource (Montserrat, Open Sans) - **Font System**: @fontsource (Montserrat, Open Sans)
- **Sitemap**: @astrojs/sitemap - **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina, programmi e articoli pubblicati)
- **Testing**: Vitest - **Testing**: Vitest
- **Linguaggio**: TypeScript - **Linguaggio**: TypeScript
@@ -49,7 +49,15 @@ Modifica `.env` con le credenziali SMTP, percorsi database e upload directory se
| `npm run preview` | Visualizza l'anteprima della build (locale) | | `npm run preview` | Visualizza l'anteprima della build (locale) |
| `npm run start` | Avvia il server production (richiede `npm run build` preliminare) | | `npm run start` | Avvia il server production (richiede `npm run build` preliminare) |
| `npm run test` | Esegui i test con Vitest | | `npm run test` | Esegui i test con Vitest |
| `npm run create-user` | Crea un nuovo utente (admin o redattore) | | `npm run create-user` | Crea un nuovo utente: `-- <nome> <password> [admin\|editor]` |
## 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.
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.
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`.
## Struttura Progetto ## Struttura Progetto
-2
View File
@@ -1,6 +1,5 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import node from '@astrojs/node'; import node from '@astrojs/node';
import sitemap from '@astrojs/sitemap';
export default defineConfig({ export default defineConfig({
site: 'https://insanitylab.tielogic.xyz', site: 'https://insanitylab.tielogic.xyz',
@@ -11,5 +10,4 @@ export default defineConfig({
allowedDomains: [{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' }], allowedDomains: [{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' }],
}, },
adapter: node({ mode: 'standalone' }), adapter: node({ mode: 'standalone' }),
integrations: [sitemap({ filter: (page) => !page.includes('/admin') })],
}); });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
# 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.
@@ -0,0 +1,146 @@
# 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.
+2 -52
View File
@@ -9,7 +9,6 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@astrojs/node": "^11.0.1", "@astrojs/node": "^11.0.1",
"@astrojs/sitemap": "^3.7.3",
"@fontsource/montserrat": "^5.2.8", "@fontsource/montserrat": "^5.2.8",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@tiptap/core": "^3.27.1", "@tiptap/core": "^3.27.1",
@@ -360,17 +359,6 @@
"node": ">=22.12.0" "node": ">=22.12.0"
} }
}, },
"node_modules/@astrojs/sitemap": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz",
"integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==",
"license": "MIT",
"dependencies": {
"sitemap": "^9.0.0",
"stream-replace-string": "^2.0.0",
"zod": "^4.3.6"
}
},
"node_modules/@astrojs/telemetry": { "node_modules/@astrojs/telemetry": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz", "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz",
@@ -2512,6 +2500,7 @@
"version": "24.13.2", "version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
@@ -2537,15 +2526,6 @@
"htmlparser2": "^10.1" "htmlparser2": "^10.1"
} }
}, },
"node_modules/@types/sax": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -2881,12 +2861,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT"
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -5622,25 +5596,6 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sitemap": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
"integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.9.2",
"@types/sax": "^1.2.1",
"arg": "^5.0.0",
"sax": "^1.4.1"
},
"bin": {
"sitemap": "dist/esm/cli.js"
},
"engines": {
"node": ">=20.19.5",
"npm": ">=10.8.2"
}
},
"node_modules/smol-toml": { "node_modules/smol-toml": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
@@ -5695,12 +5650,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/stream-replace-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
"integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
"license": "MIT"
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -5972,6 +5921,7 @@
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unified": { "node_modules/unified": {
-1
View File
@@ -13,7 +13,6 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^11.0.1", "@astrojs/node": "^11.0.1",
"@astrojs/sitemap": "^3.7.3",
"@fontsource/montserrat": "^5.2.8", "@fontsource/montserrat": "^5.2.8",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@tiptap/core": "^3.27.1", "@tiptap/core": "^3.27.1",
+1 -1
View File
@@ -1,4 +1,4 @@
User-agent: * User-agent: *
Disallow: /admin Disallow: /admin
Disallow: /api Disallow: /api
Sitemap: https://insanitylab.tielogic.xyz/sitemap-index.xml Sitemap: https://insanitylab.tielogic.xyz/sitemap.xml
+11 -6
View File
@@ -3,9 +3,13 @@ import bcrypt from 'bcryptjs';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
const [, , username, password] = process.argv; const [, , username, password, role = 'admin'] = process.argv;
if (!username || !password) { if (!username || !password) {
console.error('Uso: npm run create-user -- <username> <password>'); console.error('Uso: npm run create-user -- <username> <password> [role]');
process.exit(1);
}
if (!['admin', 'editor'].includes(role)) {
console.error('Ruolo non valido. Ammessi: admin, editor');
process.exit(1); process.exit(1);
} }
const path = process.env.DB_PATH ?? 'data/insanitylab.db'; const path = process.env.DB_PATH ?? 'data/insanitylab.db';
@@ -14,10 +18,11 @@ const db = new Database(path);
db.exec(`CREATE TABLE IF NOT EXISTS users ( db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
);`); );`);
db.prepare( db.prepare(
`INSERT INTO users (username, password_hash) VALUES (?, ?) `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash` ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash, role = excluded.role`
).run(username, bcrypt.hashSync(password, 12)); ).run(username, bcrypt.hashSync(password, 12), role);
console.log(`Utente "${username}" creato/aggiornato in ${path}`); console.log(`Utente "${username}" creato/aggiornato in ${path}`);
+14
View File
@@ -14,4 +14,18 @@ done
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin) code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin)
echo "$code /admin (atteso 302)" echo "$code /admin (atteso 302)"
[ "$code" = "302" ] || fail=1 [ "$code" = "302" ] || fail=1
# Contenuti taggati
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/sitemap.xml)
echo "$code /sitemap.xml"
[ "$code" = "200" ] || fail=1
locs=$(curl -s http://localhost:4399/sitemap.xml | grep -c '<loc>')
echo "$locs <loc> in sitemap (atteso >= 12)"
[ "$locs" -ge 12 ] || fail=1
tags=$(curl -s http://localhost:4399/ | grep -c 'data-tag=')
echo "$tags righe con data-tag= in home (atteso >= 1)"
[ "$tags" -ge 1 ] || fail=1
badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true)
echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)"
[ "$badges" = "0" ] || fail=1
exit $fail exit $fail
+6 -5
View File
@@ -1,20 +1,21 @@
--- ---
import { agenda, disciplines, days, slots } from '../data/agenda'; import { agenda, disciplines, days, slots } from '../data/agenda';
import T from './content/T.astro';
--- ---
<section class="section section--dark agenda"> <section class="section section--dark agenda">
<div class="container"> <div class="container">
<div class="section-heading" data-reveal><h2>Agenda</h2></div> <div class="section-heading" data-reveal><T tag="agenda.title" as="h2" /></div>
<div class="agenda__tabs" role="tablist" data-reveal style="--reveal-delay:.1s"> <div class="agenda__tabs" role="tablist" data-reveal style="--reveal-delay:.1s">
{disciplines.map((d, i) => ( {disciplines.map((d, i) => (
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}>{d}</button> <button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}><T tag={`agenda.discipline.${i + 1}`} as="span" /></button>
))} ))}
</div> </div>
<div class="agenda__grid" data-reveal style="--reveal-delay:.2s"> <div class="agenda__grid" data-reveal style="--reveal-delay:.2s">
<div class="agenda__corner"></div> <div class="agenda__corner"></div>
{days.map((d) => <div class="agenda__day">{d}</div>)} {days.map((_, i) => <T tag={`agenda.day.${i + 1}`} as="div" class="agenda__day" />)}
{slots.map((slot) => ( {slots.map((slot, si) => (
<> <>
<div class="agenda__slot">{slot}</div> <T tag={`agenda.slot.${si + 1}`} as="div" class="agenda__slot" />
{days.map((_, dayIdx) => { {days.map((_, dayIdx) => {
const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot); const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
return entry return entry
+19 -9
View File
@@ -1,15 +1,25 @@
--- ---
import { t } from '../lib/content';
import T from './content/T.astro';
interface Props { variant?: 'full' | 'footer' } interface Props { variant?: 'full' | 'footer' }
const { variant = 'full' } = Astro.props; const { variant = 'full' } = Astro.props;
--- ---
<form class:list={['cform', `cform--${variant}`]} method="post" action="/api/contact" novalidate> <form
<input class="field" name="firstName" placeholder="Nome" required maxlength="100" /> class:list={['cform', `cform--${variant}`]}
{variant === 'full' && <input class="field" name="lastName" placeholder="Cognome" maxlength="100" />} method="post"
{variant === 'full' && <input class="field" name="phone" placeholder="Telefono" maxlength="40" />} action="/api/contact"
<input class="field" type="email" name="email" placeholder="E-mail" required maxlength="200" /> novalidate
<textarea class="field" name="message" placeholder="Messaggio" required maxlength="5000"></textarea> data-msg-success={t('form.msg.success')}
data-msg-error-generic={t('form.msg.error-generic')}
data-msg-error-network={t('form.msg.error-network')}
>
<input class="field" name="firstName" placeholder={t('form.field.first-name')} required maxlength="100" />
{variant === 'full' && <input class="field" name="lastName" placeholder={t('form.field.last-name')} maxlength="100" />}
{variant === 'full' && <input class="field" name="phone" placeholder={t('form.field.phone')} maxlength="40" />}
<input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" />
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
<input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" /> <input class="hp" type="text" name="website" tabindex="-1" autocomplete="off" />
<button class="btn" type="submit">Invia messaggio</button> <button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
<p class="form-msg" hidden></p> <p class="form-msg" hidden></p>
</form> </form>
@@ -28,11 +38,11 @@ const { variant = 'full' } = Astro.props;
body: JSON.stringify(Object.fromEntries(new FormData(form))), body: JSON.stringify(Object.fromEntries(new FormData(form))),
}); });
const data = await res.json(); const data = await res.json();
msg.textContent = res.ok ? 'Messaggio inviato, ti ricontatteremo al più presto.' : (data.error ?? 'Errore di invio, riprova più tardi.'); msg.textContent = res.ok ? form.dataset.msgSuccess! : (data.error ?? form.dataset.msgErrorGeneric);
msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`; msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
if (res.ok) form.reset(); if (res.ok) form.reset();
} catch { } catch {
msg.textContent = 'Errore di rete, riprova più tardi.'; msg.textContent = form.dataset.msgErrorNetwork!;
msg.className = 'form-msg form-msg--err'; msg.className = 'form-msg form-msg--err';
} }
msg.hidden = false; msg.hidden = false;
+15 -12
View File
@@ -1,34 +1,37 @@
--- ---
import { site } from '../data/site'; import { site } from '../data/site';
import ContactForm from './ContactForm.astro'; import ContactForm from './ContactForm.astro';
import { t, contentImageUrl } from '../lib/content';
import T from './content/T.astro';
const year = new Date().getFullYear(); const year = new Date().getFullYear();
--- ---
<footer class="ftr"> <footer class="ftr">
<div class="container ftr__grid"> <div class="container ftr__grid">
<div> <div>
<img src="/img/logo-light.png" alt="InsanityLab" width="140" loading="lazy" /> <img src={contentImageUrl('footer.logo.image', '/img/logo-light.png')} alt="InsanityLab" width="140" loading="lazy" />
<p class="ftr__tag">PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.</p> <T tag="footer.tagline" as="p" class="ftr__tag" />
<p>Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.</p> <T tag="footer.about" as="p" />
<p>{site.phone}<br />{site.address}, {site.cityLine}</p> <p>{t('global.contact.phone')}<br />{t('global.contact.address')}, {t('global.contact.city-line')}</p>
{site.hours.map((h) => <p class="ftr__hours">{h}</p>)} <p class="ftr__hours">{t('global.hours.1')}</p>
<p class="ftr__hours">{t('global.hours.2')}</p>
</div> </div>
<nav aria-label="training"> <nav aria-label="training">
<h4>Training</h4> <T tag="footer.col.training-title" as="h4" />
{site.footerTraining.map((l) => <a href={l.href}>{l.label}</a>)} {site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)}
</nav> </nav>
<nav aria-label="programmi"> <nav aria-label="programmi">
<h4>Programmi</h4> <T tag="footer.col.programs-title" as="h4" />
{site.footerPrograms.map((l) => <a href={l.href}>{l.label}</a>)} {site.footerPrograms.map((l, i) => <a href={l.href}>{t(`footer.programs.${i + 1}.label`)}</a>)}
</nav> </nav>
<div> <div>
<h4>Contact</h4> <T tag="footer.col.contact-title" as="h4" />
<ContactForm variant="footer" /> <ContactForm variant="footer" />
</div> </div>
</div> </div>
<div class="ftr__bar"> <div class="ftr__bar">
<div class="container ftr__bar-in"> <div class="container ftr__bar-in">
<p>© {year} <strong>Insanitylab</strong>, All Rights Reserved</p> <p>© {year} <T tag="footer.copyright" as="span" /></p>
<p>Seguici {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p> <p>{t('footer.social-label')} {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
</div> </div>
</div> </div>
</footer> </footer>
+4 -3
View File
@@ -1,13 +1,14 @@
--- ---
import { site } from '../data/site'; import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content';
const path = Astro.url.pathname; const path = Astro.url.pathname;
--- ---
<header class="hdr" id="site-header"> <header class="hdr" id="site-header">
<div class="container hdr__in"> <div class="container hdr__in">
<a href="/" class="hdr__logo"><img src="/img/logo-dark.png" alt="InsanityLab" width="150" /></a> <a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
<nav class="hdr__nav" id="site-nav" aria-label="principale"> <nav class="hdr__nav" id="site-nav" aria-label="principale">
{site.navigation.map((item) => ( {site.navigation.map((item, i) => (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{item.label}</a> <a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
))} ))}
</nav> </nav>
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false"> <button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
+3 -3
View File
@@ -2,11 +2,11 @@
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { getImage } from 'astro:assets'; import { getImage } from 'astro:assets';
interface Props { title: string; eyebrow?: string; bgImage?: ImageMetadata } interface Props { title: string; eyebrow?: string; bgImage?: ImageMetadata | string }
const { title, eyebrow, bgImage } = Astro.props; const { title, eyebrow, bgImage } = Astro.props;
const bg = bgImage ? await getImage({ src: bgImage, width: 1920 }) : null; const bgUrl = typeof bgImage === 'string' ? bgImage : bgImage ? (await getImage({ src: bgImage, width: 1920 })).src : null;
--- ---
<section class:list={['phero', { 'phero--photo': bg }]} style={bg ? `background-image:url(${bg.src})` : undefined}> <section class:list={['phero', { 'phero--photo': bgUrl }]} style={bgUrl ? `background-image:url(${bgUrl})` : undefined}>
<div class="container" data-reveal> <div class="container" data-reveal>
{eyebrow && <p class="eyebrow">{eyebrow}</p>} {eyebrow && <p class="eyebrow">{eyebrow}</p>}
<h1>{title}</h1> <h1>{title}</h1>
+10 -13
View File
@@ -1,24 +1,21 @@
--- ---
const openText = `Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità.`; import T from '../content/T.astro';
const stages = [ import { t } from '../../lib/content';
{ label: 'INSANITYLAB 2025', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' }, const stages = [1, 2, 3];
{ label: 'INSANITYLAB 2026', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
{ label: 'INSANITYLAB OGGI', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
];
--- ---
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
<h2>Storylab</h2> <T tag="about.storylab.title" as="h2" />
<p>Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.</p> <T tag="about.storylab.body" as="p" />
</div> </div>
<div class="tl"> <div class="tl">
<h3 class="tl__label">INSANITYLAB 2021-2024</h3> <T tag="about.timeline.label" as="h3" class="tl__label" />
<p>{openText}</p> <T tag="about.timeline.open-text" as="p" />
{stages.map((s) => ( {stages.map((n) => (
<details class="tl__stage"> <details class="tl__stage">
<summary>+ {s.label}</summary> <summary>+ {t(`about.timeline.stage${n}.label`)}</summary>
<p>{s.text}</p> <T tag={`about.timeline.stage${n}.text`} as="p" />
</details> </details>
))} ))}
</div> </div>
+11
View File
@@ -0,0 +1,11 @@
---
import { resolveContent } from '../../lib/content';
interface Props { tag: string; as?: string; class?: string }
const { tag, as = 'span', class: cls } = Astro.props;
const Tag = as;
const c = resolveContent(tag);
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
---
{c.type === 'html'
? <Tag data-tag={tag} class={classes} set:html={c.value} />
: <Tag data-tag={tag} class={classes}>{c.value}</Tag>}
+14
View File
@@ -0,0 +1,14 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import { getImageOverride } from '../../lib/content';
interface Props {
tag: string; src: ImageMetadata; alt: string;
class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number;
}
const { tag, src, alt, class: cls, ...rest } = Astro.props;
const override = getImageOverride(tag);
---
{override
? <img src={override} alt={alt} class={cls} data-tag={tag} loading={rest.loading} />
: <Image src={src} alt={alt} class={cls} data-tag={tag} {...rest} />}
+10 -11
View File
@@ -1,26 +1,25 @@
--- ---
import { Image } from 'astro:assets';
import featPerformance from '../../assets/img/feature-performance.jpg'; import featPerformance from '../../assets/img/feature-performance.jpg';
import featBalance from '../../assets/img/feature-balance.jpg'; import featBalance from '../../assets/img/feature-balance.jpg';
import featLongevity from '../../assets/img/feature-longevity.jpg'; import featLongevity from '../../assets/img/feature-longevity.jpg';
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content';
const blocks = [ const blocks = [
{ title: 'PERFORMANCE', image: featPerformance, reverse: false, { key: 'performance', image: featPerformance, reverse: false },
text: 'Allenamenti progettati su misura per adattarsi ai tuoi obiettivi e al tuo livello. Programmi mirati che sviluppano forza, resistenza e funzionalità in modo equilibrato. Un metodo efficace per migliorare le performance e ottenere risultati concreti e duraturi.' }, { key: 'balance', image: featBalance, reverse: true },
{ title: 'BALANCE', image: featBalance, reverse: true, { key: 'longevity', image: featLongevity, reverse: false },
text: 'Lequilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.' },
{ title: 'LONGEVITY', image: featLongevity, reverse: false,
text: 'Benessere completo tra movimento, nutrizione e stile di vita. Un percorso personalizzato che unisce prevenzione, recupero ed equilibrio. Investi nella tua salute e qualità della vita nel tempo.' },
]; ];
--- ---
<section class="section"> <section class="section">
<div class="container"> <div class="container">
{blocks.map((b) => ( {blocks.map((b) => (
<div class:list={['feat', { 'feat--reverse': b.reverse }]}> <div class:list={['feat', { 'feat--reverse': b.reverse }]}>
<Image src={b.image} alt={b.title} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" data-reveal={b.reverse ? 'right' : 'left'} /> <TImg tag={`home.feature.${b.key}.image`} src={b.image} alt={t(`home.feature.${b.key}.title`)} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" data-reveal={b.reverse ? 'right' : 'left'} />
<div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}> <div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}>
<h2>{b.title}</h2> <T tag={`home.feature.${b.key}.title`} as="h2" />
<p>{b.text}</p> <T tag={`home.feature.${b.key}.body`} as="p" />
<a class="btn" href="/programs">Scopri</a> <a class="btn" href="/programs">{t(`home.feature.${b.key}.cta`)}</a>
</div> </div>
</div> </div>
))} ))}
+7 -5
View File
@@ -1,8 +1,10 @@
--- ---
import { Image } from 'astro:assets';
import hero1 from '../../assets/img/hero-1.png'; import hero1 from '../../assets/img/hero-1.png';
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content';
const slides = [ const slides = [
{ title: ['PERFORMANCE.', 'BALANCE.', 'LONGEVITY.'], image: hero1, alt: 'Atleti InsanityLab' }, { titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'], image: hero1, alt: 'Atleti InsanityLab' },
]; ];
--- ---
<section class="hero" aria-label="presentazione"> <section class="hero" aria-label="presentazione">
@@ -10,10 +12,10 @@ const slides = [
<div class:list={['hero__slide', { 'is-active': i === 0 }]}> <div class:list={['hero__slide', { 'is-active': i === 0 }]}>
<div class="container hero__in"> <div class="container hero__in">
<div class="hero__text"> <div class="hero__text">
<h1>{s.title.map((line) => <span class="hero__line">{line}</span>)}</h1> <h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
<a class="btn" href="/programs">Scopri</a> <a class="btn" href="/programs">{t('home.hero.cta')}</a>
</div> </div>
<Image src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" /> <TImg tag="home.hero.image" src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
</div> </div>
</div> </div>
))} ))}
+8 -6
View File
@@ -3,18 +3,20 @@ import { getImage } from 'astro:assets';
import infoPerformance from '../../assets/img/info-performance.jpg'; import infoPerformance from '../../assets/img/info-performance.jpg';
import infoBalance from '../../assets/img/info-balance.jpg'; import infoBalance from '../../assets/img/info-balance.jpg';
import infoLongevity from '../../assets/img/info-longevity.jpg'; import infoLongevity from '../../assets/img/info-longevity.jpg';
import T from '../content/T.astro';
import { contentImageUrl } from '../../lib/content';
// getImage garantisce l'emissione dell'asset anche quando il file è byte-identico // getImage garantisce l'emissione dell'asset anche quando il file è byte-identico
// ad altri import (Astro dedupa e non emette l'originale usato solo come background). // ad altri import (Astro dedupa e non emette l'originale usato solo come background).
const cols = await Promise.all([ const cols = await Promise.all([
{ title: 'PERFORMANCE.', image: infoPerformance }, { titleTag: 'home.info.col1.title', imageTag: 'home.info.col1.image', image: infoPerformance },
{ title: 'BALANCE.', image: infoBalance }, { titleTag: 'home.info.col2.title', imageTag: 'home.info.col2.image', image: infoBalance },
{ title: 'LONGEVITY.', image: infoLongevity }, { titleTag: 'home.info.col3.title', imageTag: 'home.info.col3.image', image: infoLongevity },
].map(async (c) => ({ ...c, bg: (await getImage({ src: c.image, width: 1200 })).src }))); ].map(async (c) => ({ ...c, bg: contentImageUrl(c.imageTag, (await getImage({ src: c.image, width: 1200 })).src) })));
--- ---
<section class="info" aria-label="pilastri"> <section class="info" aria-label="pilastri">
{cols.map((c, i) => ( {cols.map((c, i) => (
<a class="info__col" href="/programs" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade"> <a class="info__col" href="/programs" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}>
<span class="info__title">{c.title}</span> <T tag={c.titleTag} as="span" class="info__title" />
</a> </a>
))} ))}
</section> </section>
+9 -8
View File
@@ -1,26 +1,27 @@
--- ---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { methodIntro, methodExtraNote, methodSteps } from '../../data/method'; import { methodSteps } from '../../data/method';
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
--- ---
<section class="section section--alt"> <section class="section section--alt">
<div class="container"> <div class="container">
<div class="section-heading" data-reveal> <div class="section-heading" data-reveal>
<h2>Il metodo<br />InsanityLab</h2> <T tag="home.method.title" as="h2" />
<p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} /> <T tag="method.intro" as="p" />
</div> </div>
<p class="method__note">{methodExtraNote}</p> <T tag="method.extra-note" as="p" class="method__note" />
<div class="method__row"> <div class="method__row">
{methodSteps.map((s, i) => ( {methodSteps.map((s, i) => (
<div class:list={['method__step', { 'method__step--extra': s.extra }]} data-reveal style={`--reveal-delay:${(i * 0.15).toFixed(2)}s`}> <div class:list={['method__step', { 'method__step--extra': s.extra }]} data-reveal style={`--reveal-delay:${(i * 0.15).toFixed(2)}s`}>
<div class="method__circle"> <div class="method__circle">
{imgOf(s.image) && <Image src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />} {imgOf(s.image) && <TImg tag={`method.step.${s.n}.image`} src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
<span>{s.n}</span> <span>{s.n}</span>
</div> </div>
<h3>{s.title}</h3> <T tag={`method.step.${s.n}.title`} as="h3" />
<p>{s.text}</p> <T tag={`method.step.${s.n}.text`} as="p" />
</div> </div>
))} ))}
</div> </div>
+3 -2
View File
@@ -1,13 +1,14 @@
--- ---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { partners } from '../../data/partners'; import { partners } from '../../data/partners';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
--- ---
<section class="pstrip" aria-label="partner"> <section class="pstrip" aria-label="partner">
<div class="container pstrip__in" data-reveal="fade"> <div class="container pstrip__in" data-reveal="fade">
{partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)} {partners.map((p, i) => imgOf(p.image) && <TImg tag={`partner.${i + 1}.image`} src={imgOf(p.image)!} alt={t(`partner.${i + 1}.name`)} height={44} />)}
</div> </div>
</section> </section>
+5 -4
View File
@@ -1,19 +1,20 @@
--- ---
import { programs } from '../../data/programs'; import { programs } from '../../data/programs';
import { programIcons as icons } from '../../data/program-icons'; import { programIcons as icons } from '../../data/program-icons';
import T from '../content/T.astro';
--- ---
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="section-heading" data-reveal> <div class="section-heading" data-reveal>
<h2>Programmi</h2> <T tag="home.programs.title" as="h2" />
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.</p> <T tag="home.programs.body" as="p" />
</div> </div>
<div class="pgrid"> <div class="pgrid">
{programs.map((p, i) => ( {programs.map((p, i) => (
<a class="pgrid__item" href={`/programs/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}> <a class="pgrid__item" href={`/programs/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
<h3>{p.title}</h3> <T tag={`program.${p.slug}.title`} as="h3" />
<p>{p.subtitle}</p> <T tag={`program.${p.slug}.subtitle`} as="p" />
</a> </a>
))} ))}
</div> </div>
+6 -7
View File
@@ -1,22 +1,21 @@
--- ---
import { getImage } from 'astro:assets'; import { getImage } from 'astro:assets';
import quoteBgSrc from '../../assets/img/quote-bg.jpg'; import quoteBgSrc from '../../assets/img/quote-bg.jpg';
const quoteBg = await getImage({ src: quoteBgSrc, width: 1920 }); import { t, contentImageUrl } from '../../lib/content';
const quote = 'PERFORMANCE È SPINGERE OLTRE I PROPRI LIMITI, BALANCE È SAPERLI ASCOLTARE, LONGEVITY È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.'; const quoteBg = contentImageUrl('home.quote.image', (await getImage({ src: quoteBgSrc, width: 1920 })).src);
const quoteHtml = quote.replace(/(PERFORMANCE|BALANCE|LONGEVITY)/g, '<strong>$1</strong>');
--- ---
<section class="quote" data-parallax> <section class="quote" data-parallax>
<div class="quote__bg" style={`background-image:url(${quoteBg.src})`}></div> <div class="quote__bg" style={`background-image:url(${quoteBg})`} data-tag="home.quote.image"></div>
<div class="quote__in"> <div class="quote__in">
<p class="quote__mark">“</p> <p class="quote__mark">“</p>
<blockquote> <blockquote>
<div class="quote__marquee"> <div class="quote__marquee">
<p class="quote__track"> <p class="quote__track">
<span class="quote__seg" set:html={quoteHtml} /> <span class="quote__seg" data-tag="home.quote.text" set:html={t('home.quote.text')} />
<span class="quote__seg" aria-hidden="true" set:html={quoteHtml} /> <span class="quote__seg" aria-hidden="true" data-tag="home.quote.text" set:html={t('home.quote.text')} />
</p> </p>
</div> </div>
<cite>Insanitylab</cite> <cite data-tag="home.quote.cite">{t('home.quote.cite')}</cite>
</blockquote> </blockquote>
</div> </div>
</section> </section>
+12 -7
View File
@@ -1,7 +1,9 @@
--- ---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { team, homeTeam } from '../../data/team'; import { team, homeTeam } from '../../data/team';
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/team-*.{jpg,png}', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/team-*.{jpg,png}', { eager: true });
const members = homeTeam.map((short) => team.find((m) => m.shortName === short)!).filter((m) => m.image); const members = homeTeam.map((short) => team.find((m) => m.shortName === short)!).filter((m) => m.image);
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(`${name}.`))?.[1].default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(`${name}.`))?.[1].default;
@@ -9,16 +11,19 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="tteam__head" data-reveal> <div class="tteam__head" data-reveal>
<h2>Team<br />Trainer</h2> <T tag="home.team.title" as="h2" />
<a class="btn" href="/about">Scopri team</a> <a class="btn" href="/about"><T tag="home.team.cta" as="span" /></a>
</div> </div>
<div class="tteam__grid"> <div class="tteam__grid">
{members.map((m, i) => ( {members.map((m, i) => {
const slug = m.shortName.toLowerCase();
return (
<figure class="tteam__card" data-reveal style={`--reveal-delay:${i * 0.12}s`}> <figure class="tteam__card" data-reveal style={`--reveal-delay:${i * 0.12}s`}>
<Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" /> <TImg tag={`team.${slug}.image`} src={imgOf(m.image!)!} alt={t(`team.${slug}.name`)} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
<figcaption>{m.shortName}</figcaption> <T tag={`team.${slug}.short-name`} as="figcaption" />
</figure> </figure>
))} );
})}
</div> </div>
</div> </div>
</section> </section>
+9 -7
View File
@@ -1,7 +1,9 @@
--- ---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { trainings } from '../../data/trainings'; import { trainings } from '../../data/trainings';
import T from '../content/T.astro';
import TImg from '../content/TImg.astro';
import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
const tones = ['taupe', 'tan', 'dark', 'charcoal']; const tones = ['taupe', 'tan', 'dark', 'charcoal'];
@@ -9,18 +11,18 @@ const tones = ['taupe', 'tan', 'dark', 'charcoal'];
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1; const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
--- ---
<section class="tcards"> <section class="tcards">
{trainings.map((t, i) => { {trainings.map((tr, i) => {
const img = imgOf(t.image); const img = imgOf(tr.image);
const card = ( const card = (
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}> <div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
<h3>{t.title}</h3> <T tag={`training.${tr.id}.title`} as="h3" />
<p>{t.caption}</p> <T tag={`training.${tr.id}.caption`} as="p" />
<a class="tcards__link" href={t.cta.href}>Scopri →</a> <a class="tcards__link" href={tr.cta.href}><T tag="home.training-cards.link-label" as="span" /> →</a>
</div> </div>
); );
const photo = img ? ( const photo = img ? (
<div class="tcards__photo" data-reveal="fade" style={`--reveal-delay:${((i % 2) * 0.15 + 0.1).toFixed(2)}s`}> <div class="tcards__photo" data-reveal="fade" style={`--reveal-delay:${((i % 2) * 0.15 + 0.1).toFixed(2)}s`}>
<Image src={img} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" /> <TImg tag={`training.${tr.id}.image`} src={img} alt={t(`training.${tr.id}.title`)} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />
</div> </div>
) : null; ) : null;
return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>; return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>;
+305
View File
@@ -0,0 +1,305 @@
import type { StyleGroup } from '../lib/style-presets';
import { site } from './site';
import { trainings } from './trainings';
import { team } from './team';
import { methodIntro, methodExtraNote, methodSteps } from './method';
import { partners } from './partners';
import { disciplines, days, slots } from './agenda';
import { programs } from './programs';
import { performanceClassPricing } from './pricing';
export type ContentType = 'text' | 'html' | 'image';
export interface SeedEntry { tag: string; type: ContentType; value: string; styleOptions?: StyleGroup[] }
const T = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color', 'weight', 'style']): SeedEntry =>
({ tag, type: 'text', value, styleOptions });
const H = (tag: string, value: string, styleOptions: StyleGroup[] = ['size', 'color']): SeedEntry =>
({ tag, type: 'html', value, styleOptions });
const I = (tag: string): SeedEntry => ({ tag, type: 'image', value: '' });
export const contentSeed: SeedEntry[] = [
// --- Globali (site.ts) ---
T('global.contact.phone', site.phone, []),
T('global.contact.email', site.email, []),
T('global.contact.address', site.address, []),
T('global.contact.city-line', site.cityLine, []),
T('global.hours.1', site.hours[0], []),
T('global.hours.2', site.hours[1], []),
T('global.nav.1.label', site.navigation[0].label, []),
T('global.nav.2.label', site.navigation[1].label, []),
T('global.nav.3.label', site.navigation[2].label, []),
T('global.nav.4.label', site.navigation[3].label, []),
T('global.nav.5.label', site.navigation[4].label, []),
// --- Header ---
T('header.logo.alt', 'InsanityLab', []),
I('header.logo.image'),
// --- Footer ---
I('footer.logo.image'),
H('footer.tagline', 'PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.'),
T('footer.about', 'Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.'),
T('footer.col.training-title', 'Training'),
T('footer.col.programs-title', 'Programmi'),
T('footer.col.contact-title', 'Contact'),
H('footer.copyright', '<strong>Insanitylab</strong>, All Rights Reserved'),
T('footer.social-label', 'Seguici', []),
T('footer.training.1.label', site.footerTraining[0].label, []),
T('footer.training.2.label', site.footerTraining[1].label, []),
T('footer.training.3.label', site.footerTraining[2].label, []),
T('footer.training.4.label', site.footerTraining[3].label, []),
T('footer.programs.1.label', site.footerPrograms[0].label, []),
T('footer.programs.2.label', site.footerPrograms[1].label, []),
T('footer.programs.3.label', site.footerPrograms[2].label, []),
T('footer.programs.4.label', site.footerPrograms[3].label, []),
T('footer.programs.5.label', site.footerPrograms[4].label, []),
T('footer.programs.6.label', site.footerPrograms[5].label, []),
// --- Contact form ---
T('form.field.first-name', 'Nome', []),
T('form.field.last-name', 'Cognome', []),
T('form.field.phone', 'Telefono', []),
T('form.field.email', 'E-mail', []),
T('form.field.message', 'Messaggio', []),
T('form.submit', 'Invia messaggio'),
T('form.msg.success', 'Messaggio inviato, ti ricontatteremo al più presto.', []),
T('form.msg.error-generic', 'Errore di invio, riprova più tardi.', []),
T('form.msg.error-network', 'Errore di rete, riprova più tardi.', []),
// --- Home: meta, training heading ---
T('home.meta.title', 'Performance. Balance. Longevity.', []),
T('home.training.title', 'Training'),
T('home.training.subtitle', 'Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.'),
// --- Home: hero ---
T('home.hero.title-line-1', 'PERFORMANCE.'),
T('home.hero.title-line-2', 'BALANCE.'),
T('home.hero.title-line-3', 'LONGEVITY.'),
T('home.hero.cta', 'Scopri'),
I('home.hero.image'),
// --- Home: info columns ---
T('home.info.col1.title', 'PERFORMANCE.'),
T('home.info.col2.title', 'BALANCE.'),
T('home.info.col3.title', 'LONGEVITY.'),
I('home.info.col1.image'),
I('home.info.col2.image'),
I('home.info.col3.image'),
// --- Home: feature blocks ---
T('home.feature.performance.title', 'PERFORMANCE'),
T('home.feature.performance.body', 'Allenamenti progettati su misura per adattarsi ai tuoi obiettivi e al tuo livello. Programmi mirati che sviluppano forza, resistenza e funzionalità in modo equilibrato. Un metodo efficace per migliorare le performance e ottenere risultati concreti e duraturi.'),
T('home.feature.performance.cta', 'Scopri'),
T('home.feature.balance.title', 'BALANCE'),
T('home.feature.balance.body', 'Lequilibrio tra corpo e mente è la base di ogni performance duratura. Attraverso movimento, recupero e gestione dello stress, costruiamo stabilità e controllo. Un approccio integrato che sostiene benessere, energia e continuità nel tempo.'),
T('home.feature.balance.cta', 'Scopri'),
T('home.feature.longevity.title', 'LONGEVITY'),
T('home.feature.longevity.body', 'Benessere completo tra movimento, nutrizione e stile di vita. Un percorso personalizzato che unisce prevenzione, recupero ed equilibrio. Investi nella tua salute e qualità della vita nel tempo.'),
T('home.feature.longevity.cta', 'Scopri'),
I('home.feature.performance.image'),
I('home.feature.balance.image'),
I('home.feature.longevity.image'),
// --- Home: quote banner ---
H('home.quote.text', '<strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.'),
T('home.quote.cite', 'Insanitylab', []),
I('home.quote.image'),
// --- Home: training cards (data-driven da trainings.ts) ---
T('home.training-cards.link-label', 'Scopri', []),
...trainings.flatMap((tr) => [
T(`training.${tr.id}.title`, tr.title),
T(`training.${tr.id}.caption`, tr.caption),
T(`training.${tr.id}.description`, tr.description),
T(`training.${tr.id}.cta-label`, tr.cta.label, []),
I(`training.${tr.id}.image`),
]),
// --- Home: programmi ---
T('home.programs.title', 'Programmi'),
T('home.programs.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi unesperienza di allenamento completa e mirata.'),
// --- Home: team (data-driven da team.ts) ---
H('home.team.title', 'Team<br />Trainer'),
T('home.team.cta', 'Scopri team', []),
...team.flatMap((m) => {
const slug = m.shortName.toLowerCase();
return [
T(`team.${slug}.name`, m.name, []),
T(`team.${slug}.short-name`, m.shortName),
T(`team.${slug}.role`, m.role, []),
T(`team.${slug}.bio`, m.bio),
I(`team.${slug}.image`),
I(`team.${slug}.image-about`),
];
}),
// --- Home: metodo (data-driven da method.ts) ---
H('home.method.title', 'Il metodo<br />InsanityLab'),
H('method.intro', methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')),
T('method.extra-note', methodExtraNote, []),
...methodSteps.flatMap((s) => [
T(`method.step.${s.n}.title`, s.title),
T(`method.step.${s.n}.text`, s.text),
I(`method.step.${s.n}.image`),
]),
// --- Home: partner (data-driven da partners.ts) ---
...partners.flatMap((p, i) => [
T(`partner.${i + 1}.name`, p.name, []),
I(`partner.${i + 1}.image`),
]),
// --- Agenda (scope limitato: titolo, discipline, giorni, slot — celle escluse) ---
T('agenda.title', 'Agenda'),
...disciplines.map((d, i) => T(`agenda.discipline.${i + 1}`, d, [])),
...days.map((d, i) => T(`agenda.day.${i + 1}`, d, [])),
...slots.map((s, i) => T(`agenda.slot.${i + 1}`, s, [])),
// --- About: meta, hero ---
T('about.meta.title', 'Insanitylab', []),
T('about.meta.description', 'Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato.', []),
T('about.hero.title', 'Insanitylab', []),
// --- About: mission ---
T('about.mission.title', 'Mission'),
T('about.mission.body', 'In InsanityLab crediamo che il movimento non debba essere vissuto come un obbligo o un sacrificio, ma come una forma autentica di cura di sé. La nostra missione è accompagnare ogni persona in un percorso di consapevolezza, aiutandola a riscoprire il benessere fisico e mentale attraverso un approccio sostenibile, concreto e duraturo.'),
I('about.mission.image'),
// --- About: direzione + approccio ---
T('about.direzione.title', 'Direzione'),
T('about.direzione.body', 'Non ci limitiamo ad allenare il corpo: creiamo percorsi personalizzati che uniscono movimento, equilibrio e benessere, attraverso il supporto di professionisti qualificati, programmi su misura e un approccio centrato sulla persona e su risultati duraturi.'),
T('about.direzione.cta', 'Scopri'),
T('about.approccio.title', 'Approccio'),
T('about.approccio.body', 'InsanityLab si distingue per un approccio serio e personalizzato al benessere, basato su competenze specializzate, costanza e cura quotidiana, rivolto a chiunque voglia migliorare il proprio stile di vita in modo consapevole e duraturo.'),
T('about.approccio.cta', 'Contattaci'),
// --- About: about ---
T('about.about.title', 'About'),
T('about.about.body', "InsanityLab nasce con l'obiettivo di unire fitness e salute in un unico percorso dedicato al benessere globale, superando il concetto tradizionale di palestra. Un laboratorio esclusivo, guidato da osteopati e chinesiologi e professionisti specializzati, dove movimento, prevenzione e performance convivono all'interno di un approccio personalizzato e consapevole."),
I('about.about.image'),
// --- About: convenzioni ---
T('about.convenzioni.title', 'Convenzioni'),
T('about.convenzioni.body', "InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l'obiettivo di portare il valore del benessere anche all'interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana."),
T('about.convenzioni.item1', 'Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati, con percorsi individuali, small group training e classi di gruppo seguiti da professionisti qualificati.'),
T('about.convenzioni.item2', 'Ogni collaborazione parte da una consulenza gratuita preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.'),
T('about.convenzioni.item3', "Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner."),
T('about.convenzioni.cta-text', 'Per ricevere maggiori informazioni o attivare una convenzione:', []),
I('about.convenzioni.image'),
// --- About: team ---
T('about.team.title', 'Team'),
T('about.team.body', 'Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.'),
T('about.team.staff-intro', 'Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:'),
// --- About: metodo ---
T('about.metodo.title', 'Metodo'),
T('about.metodo.body', 'Il team InsanityLab lavora con un approccio empatico e personalizzato, seguendo ogni persona in un percorso continuo e su misura. Unisce competenze multidisciplinari in allenamento, prevenzione, riabilitazione e performance, supportate da formazione costante. Il metodo si basa su sinergia, condivisione e qualità, per generare risultati concreti e duraturi.'),
T('about.metodo.closing', 'In poche parole, il team di InsanityLab è composto da professionisti che credono profondamente in ciò che fanno: un gruppo unito da valori condivisi, formato per eccellere e guidato dalla convinzione che il movimento sia uno strumento di benessere, equilibrio e qualità della vita.'),
I('about.metodo.image'),
// --- About: studios ---
T('about.studios.title', 'In-Sanity Studios'),
T('about.studios.body1', "IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute, del benessere e della performance. Più che un semplice insieme di studi professionali, rappresenta un ecosistema multidisciplinare pensato per favorire collaborazione, qualità del servizio e integrazione tra competenze diverse."),
T('about.studios.body2', "Il progetto nasce con l'obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all'interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente."),
T('about.studios.body3', 'IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all\'interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.'),
I('about.studios.image'),
// --- About: vision ---
T('about.vision.title', 'Vision'),
T('about.vision.body1', "InsanityLab nasce con l'obiettivo di aiutare le persone a ritrovare energia ed equilibrio attraverso il movimento, promuovendo un approccio autentico, scientifico e umano al benessere. La sua visione va oltre l'estetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita."),
T('about.vision.body2', "L'obiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità."),
I('about.vision.image'),
// --- About: storylab + timeline (Timeline.astro) ---
T('about.storylab.title', 'Storylab'),
T('about.storylab.body', 'Scopri la nostra storia: come nasce InsanityLab, perché abbiamo deciso di creare qualcosa di diverso e chi sono i visionari che hanno trasformato questa idea in un nuovo modo di vivere benessere, movimento e salute.'),
T('about.timeline.label', 'INSANITYLAB 2021-2024', []),
T('about.timeline.open-text', "Il percorso di crescita di InsanityLab si è sviluppato attraverso tappe precise e coerenti con la visione iniziale. Nato nel 2021 con un piccolo team e grandi ambizioni, il progetto ha conosciuto una forte evoluzione nel 2024, anno in cui il servizio si è ampliato attraverso nuovi format come gli small group e una sempre maggiore specializzazione nella profilazione fisica del cliente, grazie a sistemi avanzati di valutazione e monitoraggio. Parallelamente, anche l'area dedicata alla riabilitazione e all'esercizio preventivo si è consolidata, ponendo le basi per un importante salto di qualità."),
T('about.timeline.stage1.label', 'INSANITYLAB 2025', []),
T('about.timeline.stage1.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
T('about.timeline.stage2.label', 'INSANITYLAB 2026', []),
T('about.timeline.stage2.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
T('about.timeline.stage3.label', 'INSANITYLAB OGGI', []),
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
// --- Programs: lista (/programs) ---
T('programs.meta.title', 'Programmi', []),
T('programs.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.', []),
T('programs.hero.title', 'Programmi', []),
T('programs.intro.title', 'Programmi'),
T('programs.intro.body', "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
T('programs.card.more-label', 'Per saperne di più →', []),
// --- Programs: dettaglio (data-driven da programs.ts) ---
...programs.flatMap((p) => [
T(`program.${p.slug}.title`, p.title),
T(`program.${p.slug}.subtitle`, p.subtitle),
T(`program.${p.slug}.excerpt`, p.excerpt),
T(`program.${p.slug}.long-description`, p.longDescription),
...p.features.map((f, i) => T(`program.${p.slug}.feature.${i + 1}`, f)),
...p.faq.flatMap((item, i) => [
T(`program.${p.slug}.faq.${i + 1}.q`, item.q),
T(`program.${p.slug}.faq.${i + 1}.a`, item.a),
]),
I(`program.${p.slug}.image`),
]),
// --- Programs: dettaglio, template ---
T('program.side.categories-label', 'Categorie'),
T('program.side.category.1', 'Fitness', []),
T('program.side.category.2', 'Salute', []),
T('program.side.category.3', 'Stile di vita', []),
T('program.side.category.4', 'Nutrizione', []),
T('program.side.others-label', 'Altri programmi'),
H('program.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'),
T('program.pricing.per', 'al mese, semestrale', []),
T('program.pricing.entries-singular', 'ingresso settimanale', []),
T('program.pricing.entries-plural', 'ingressi settimanali', []),
T('program.pricing.buy-cta', 'Acquista', []),
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
T('pricing.heading', performanceClassPricing.heading),
T('pricing.intro', performanceClassPricing.intro),
...performanceClassPricing.groups.flatMap((g, gi) => [
T(`pricing.group.${gi + 1}.label`, g.label),
...g.plans.flatMap((plan, pi) => [
T(`pricing.group.${gi + 1}.plan.${pi + 1}.note`, plan.note, []),
T(`pricing.group.${gi + 1}.plan.${pi + 1}.installments`, plan.installments, []),
T(`pricing.group.${gi + 1}.plan.${pi + 1}.best`, plan.best, []),
]),
]),
// --- Training: meta, hero, intro, trial ---
T('training.meta.title', 'Training', []),
T('training.meta.description', 'One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te.', []),
T('training.hero.title', 'training', []),
T('training.intro.title', 'Training'),
T('training.intro.subtitle', 'Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.'),
T('training.trial.title', 'Prenota la lezione di prova'),
T('training.trial.body', "La lezione di prova è l'occasione perfetta per conoscere il metodo, vivere l'esperienza in prima persona e capire il percorso più adatto a te. Inizia oggi il tuo percorso: prenota la tua lezione di prova."),
T('training.trial.cta', 'Prenota', []),
I('training.trial.image'),
// --- Contact ---
T('contact.meta.title', 'Contatti', []),
T('contact.meta.description', 'Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.', []),
T('contact.hero.title', 'Contattaci', []),
I('contact.hero.image'),
T('contact.map.button', 'Carica la mappa (Google Maps)', []),
T('contact.map.note', 'La mappa viene caricata da Google solo dopo il tuo consenso.', []),
T('contact.info.address-label', 'Indirizzo'),
T('contact.info.phone-label', 'Telefono'),
T('contact.info.email-label', 'E-mail'),
T('contact.invite', 'Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.'),
// --- 404 ---
T('notfound.meta.title', 'Pagina non trovata', []),
T('notfound.eyebrow', 'Errore 404', []),
T('notfound.title', 'Pagina non trovata'),
T('notfound.body', 'La pagina che cerchi non esiste o è stata spostata.'),
T('notfound.cta', 'Torna alla home', []),
];
export const seedByTag = new Map(contentSeed.map((e) => [e.tag, e]));
+1 -1
View File
@@ -2,6 +2,6 @@
declare namespace App { declare namespace App {
interface Locals { interface Locals {
user?: { id: number; username: string }; user?: { id: number; username: string; role: string };
} }
} }
+5 -1
View File
@@ -22,13 +22,17 @@ const user = Astro.locals.user;
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; } th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; font-size: .92rem; }
.pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; } .pill { font-size: .75rem; padding: 2px 10px; border-radius: 10px; background: #e5decf; }
.pill--draft { background: #f3d9a4; } .pill--draft { background: #f3d9a4; }
.form-msg { font-size: .9rem; margin-top: 8px; }
.form-msg--ok { color: #3c7a3c; }
.form-msg--err { color: #b03030; }
</style> </style>
</head> </head>
<body> <body>
<div class="abar"> <div class="abar">
<strong>InsanityLab · Admin</strong> <strong>InsanityLab · Admin</strong>
<span> <span>
{user && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>} {user && user.role === 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin/content">Contenuti</a> <a href="/admin/logout">Esci</a></>}
{user && user.role !== 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/content">Contenuti</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
</span> </span>
</div> </div>
<div class="awrap"><slot /></div> <div class="awrap"><slot /></div>
+36 -1
View File
@@ -8,9 +8,18 @@ import '@fontsource/open-sans/600.css';
import '../styles/global.css'; import '../styles/global.css';
import Header from '../components/Header.astro'; import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
import { getDb } from '../lib/db';
import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
interface Props { title: string; description?: string } interface Props { title: string; description?: string }
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props; const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
// Overlay annotate: badge coi tag contenuto, solo per utenti loggati con ?annotate=1.
let annotate = false;
if (Astro.url.searchParams.get('annotate') === '1') {
const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
annotate = Boolean(tok && getSessionUser(getDb(), tok));
}
--- ---
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
@@ -24,7 +33,7 @@ const { title, description = 'InsanityLab — Performance. Balance. Longevity. A
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} /> <meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap.xml" />
<script is:inline>document.documentElement.classList.add('js');</script> <script is:inline>document.documentElement.classList.add('js');</script>
</head> </head>
<body> <body>
@@ -65,5 +74,31 @@ const { title, description = 'InsanityLab — Performance. Balance. Longevity. A
update(); update();
} }
</script> </script>
{annotate && (
<style is:global>
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
.tag-badge { position: absolute; z-index: 9999; background: #3a2929; color: #fff;
font: 600 10px/1.6 monospace; padding: 0 6px; text-decoration: none; opacity: .85; }
.tag-badge:hover { opacity: 1; background: #9c8b70; }
</style>
<script is:inline>
document.querySelectorAll('[data-tag]').forEach((el) => {
const a = document.createElement('a');
a.className = 'tag-badge';
a.textContent = el.dataset.tag;
a.href = '/admin/content#' + encodeURIComponent(el.dataset.tag);
a.target = '_blank';
document.body.appendChild(a);
const place = () => {
const r = el.getBoundingClientRect();
a.style.top = window.scrollY + r.top - 14 + 'px';
a.style.left = window.scrollX + r.left + 'px';
};
place();
addEventListener('scroll', place, { passive: true });
addEventListener('resize', place);
});
</script>
)}
</body> </body>
</html> </html>
+17 -7
View File
@@ -5,6 +5,8 @@ import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session'; export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7; const SESSION_DAYS = 7;
export type Role = 'admin' | 'editor';
export function hashPassword(plain: string): string { export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12); return bcrypt.hashSync(plain, 12);
} }
@@ -13,9 +15,9 @@ export function verifyPassword(plain: string, hash: string): boolean {
return bcrypt.compareSync(plain, hash); return bcrypt.compareSync(plain, hash);
} }
export function createUser(db: Database.Database, username: string, password: string): number { export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number {
const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)') const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(username, hashPassword(password)); .run(username, hashPassword(password), role);
return Number(res.lastInsertRowid); return Number(res.lastInsertRowid);
} }
@@ -30,19 +32,27 @@ export function login(db: Database.Database, username: string, password: string)
return token; return token;
} }
export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null { export function getSessionUser(db: Database.Database, token: string): { id: number; username: string; role: string } | null {
const row = db.prepare( const row = db.prepare(
`SELECT s.token, s.expires_at, u.id, u.username `SELECT s.token, s.expires_at, u.id, u.username, u.role
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?` FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?`
).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined; ).get(token) as { token: string; expires_at: string; id: number; username: string; role: string } | undefined;
if (!row) return null; if (!row) return null;
if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) { if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token); db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
return null; return null;
} }
return { id: row.id, username: row.username }; return { id: row.id, username: row.username, role: row.role };
} }
export function logout(db: Database.Database, token: string): void { export function logout(db: Database.Database, token: string): void {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token); db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
} }
// Percorsi admin consentiti anche al ruolo editor.
const EDITOR_ALLOWED = [/^\/admin\/content(\/|$)/, /^\/api\/admin\/content(\/|$)/, /^\/admin\/logout$/];
export function canAccessAdminPath(role: string, pathname: string): boolean {
if (role === 'admin') return true;
return EDITOR_ALLOWED.some((re) => re.test(pathname));
}
+31
View File
@@ -0,0 +1,31 @@
import type Database from 'better-sqlite3';
import { validateStyles } from './style-presets';
import { sanitizeInline } from './sanitize';
interface Body { value?: unknown; styles?: unknown }
export function applyContentUpdate(db: Database.Database, tag: string, body: Body, username: string):
{ ok: true } | { ok: false; status: number; error: string } {
const row = db.prepare('SELECT type FROM content_blocks WHERE tag = ?').get(tag) as { type: string } | undefined;
if (!row) return { ok: false, status: 404, error: 'Tag inesistente.' };
const sets: string[] = [];
const args: unknown[] = [];
if (body.value !== undefined) {
if (typeof body.value !== 'string') return { ok: false, status: 400, error: 'Valore non valido.' };
if (row.type === 'image') return { ok: false, status: 400, error: 'Le immagini si aggiornano via upload.' };
sets.push('value = ?');
args.push(row.type === 'html' ? sanitizeInline(body.value) : body.value);
}
if (body.styles !== undefined) {
const v = validateStyles(body.styles);
if (!v.ok) return { ok: false, status: 400, error: v.error };
sets.push('styles = ?');
args.push(JSON.stringify(v.styles));
}
if (!sets.length) return { ok: false, status: 400, error: 'Nessuna modifica.' };
sets.push(`updated_at = datetime('now')`, 'updated_by = ?');
args.push(username, tag);
db.prepare(`UPDATE content_blocks SET ${sets.join(', ')} WHERE tag = ?`).run(...args);
return { ok: true };
}
+55
View File
@@ -0,0 +1,55 @@
import type Database from 'better-sqlite3';
import { getDb } from './db';
import { contentSeed, seedByTag, type ContentType } from '../data/content-seed';
import { stylesToClasses } from './style-presets';
interface Entry { value: string; type: ContentType; styles: Record<string, string> }
export function syncSeed(db: Database.Database): void {
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); });
tx();
}
export function makeContentStore(dbGetter: () => Database.Database) {
let cache: Map<string, Entry> | null = null;
const load = (): Map<string, Entry> => {
if (cache) return cache;
const rows = dbGetter().prepare('SELECT tag, type, value, styles FROM content_blocks').all() as
{ tag: string; type: ContentType; value: string; styles: string }[];
cache = new Map(rows.map((r) => {
let styles: Record<string, string> = {};
try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
if (typeof styles !== 'object' || styles === null) styles = {};
return [r.tag, { value: r.value, type: r.type, styles }];
}));
return cache;
};
return {
resolve(tag: string): { value: string; type: ContentType; classes: string } {
const hit = load().get(tag);
if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles) };
const seed = seedByTag.get(tag);
if (seed) return { value: seed.value, type: seed.type, classes: '' };
console.warn(`[content] tag sconosciuto: ${tag}`);
return { value: '', type: 'text', classes: '' };
},
invalidate(): void { cache = null; },
};
}
const store = makeContentStore(getDb);
export const resolveContent = (tag: string) => store.resolve(tag);
export const t = (tag: string): string => store.resolve(tag).value;
export const invalidateContentCache = (): void => store.invalidate();
export function getImageOverride(tag: string): string | null {
const r = store.resolve(tag);
return r.type === 'image' && r.value ? `/uploads/${r.value}` : null;
}
export const contentImageUrl = (tag: string, fallback: string): string =>
getImageOverride(tag) ?? fallback;
+17
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { contentSeed } from '../data/content-seed';
const SCHEMA = ` const SCHEMA = `
CREATE TABLE IF NOT EXISTS posts ( CREATE TABLE IF NOT EXISTS posts (
@@ -27,6 +28,14 @@ CREATE TABLE IF NOT EXISTS sessions (
expires_at TEXT NOT NULL expires_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC); CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC);
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 '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_by TEXT
);
`; `;
export function createDb(path?: string): Database.Database { export function createDb(path?: string): Database.Database {
@@ -36,6 +45,14 @@ export function createDb(path?: string): Database.Database {
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
db.exec(SCHEMA); db.exec(SCHEMA);
// Migrazione additiva: DB creati prima dell'introduzione dei ruoli non hanno users.role.
const userCols = db.prepare(`PRAGMA table_info(users)`).all() as { name: string }[];
if (!userCols.some((c) => c.name === 'role')) {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
}
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); });
syncTx();
return db; return db;
} }
+9
View File
@@ -9,3 +9,12 @@ export function sanitizeHtml(html: string): string {
allowProtocolRelative: false, allowProtocolRelative: false,
}); });
} }
export function sanitizeInline(html: string): string {
return sanitize(html, {
allowedTags: ['strong', 'em', 'u', 's', 'br', 'a'],
allowedAttributes: { a: ['href', 'rel', 'target'] },
allowedSchemes: ['https', 'http', 'mailto'],
allowProtocolRelative: false,
});
}
+31
View File
@@ -0,0 +1,31 @@
export const STYLE_GROUPS = {
size: ['s', 'm', 'l'],
color: ['accent', 'accent-dark', 'dark', 'heading', 'text', 'text-light', 'white'],
weight: ['normal', 'bold'],
style: ['normal', 'italic'],
align: ['left', 'center', 'right'],
} as const;
export type StyleGroup = keyof typeof STYLE_GROUPS;
export function validateStyles(input: unknown):
| { ok: true; styles: Record<string, string> }
| { ok: false; error: string } {
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
return { ok: false, error: 'Stili non validi.' };
}
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(input)) {
const allowed = (STYLE_GROUPS as Record<string, readonly string[]>)[k];
if (!allowed) return { ok: false, error: `Gruppo stile sconosciuto: ${k}` };
if (typeof v !== 'string' || !allowed.includes(v)) {
return { ok: false, error: `Valore non ammesso per ${k}: ${String(v)}` };
}
out[k] = v;
}
return { ok: true, styles: out };
}
export function stylesToClasses(styles: Record<string, string>): string {
return Object.entries(styles).map(([k, v]) => `cs-${k}-${v}`).join(' ');
}
+16
View File
@@ -0,0 +1,16 @@
import { extname } from 'node:path';
export const ALLOWED: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
};
export const MAX_BYTES = 5 * 1024 * 1024;
export function validateUploadFile(file: unknown):
| { ok: true; file: File; ext: string }
| { ok: false; error: string } {
if (!(file instanceof File)) return { ok: false, error: 'Nessun file.' };
const ext = extname(file.name).toLowerCase();
if (!ALLOWED[ext]) return { ok: false, error: 'Formato non consentito (jpg, png, webp, gif).' };
if (file.size > MAX_BYTES) return { ok: false, error: 'File troppo grande (max 5 MB).' };
return { ok: true, file, ext };
}
+11 -1
View File
@@ -1,6 +1,6 @@
import { defineMiddleware } from 'astro:middleware'; import { defineMiddleware } from 'astro:middleware';
import { getDb } from './lib/db'; import { getDb } from './lib/db';
import { getSessionUser, SESSION_COOKIE } from './lib/auth'; import { getSessionUser, SESSION_COOKIE, canAccessAdminPath } from './lib/auth';
export const onRequest = defineMiddleware((context, next) => { export const onRequest = defineMiddleware((context, next) => {
const { pathname } = context.url; const { pathname } = context.url;
@@ -19,6 +19,16 @@ export const onRequest = defineMiddleware((context, next) => {
} }
return context.redirect('/admin/login'); return context.redirect('/admin/login');
} }
if (!canAccessAdminPath(user.role, pathname)) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Permessi insufficienti' }), {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
return context.redirect('/admin/content');
}
context.locals.user = user; context.locals.user = user;
return next(); return next();
}); });
+8 -6
View File
@@ -1,14 +1,16 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
export const prerender = true; import T from '../components/content/T.astro';
import { t } from '../lib/content';
export const prerender = false;
--- ---
<Base title="Pagina non trovata"> <Base title={t('notfound.meta.title')}>
<section class="section" style="text-align:center; padding-block:140px;"> <section class="section" style="text-align:center; padding-block:140px;">
<div class="container"> <div class="container">
<p class="eyebrow">Errore 404</p> <T tag="notfound.eyebrow" as="p" class="eyebrow" />
<h1>Pagina non trovata</h1> <T tag="notfound.title" as="h1" />
<p>La pagina che cerchi non esiste o è stata spostata.</p> <T tag="notfound.body" as="p" />
<a class="btn" href="/">Torna alla home</a> <a class="btn" href="/">{t('notfound.cta')}</a>
</div> </div>
</section> </section>
</Base> </Base>
+60 -52
View File
@@ -1,29 +1,31 @@
--- ---
import type { ImageMetadata } from 'astro';
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro'; import PageHero from '../components/PageHero.astro';
import Timeline from '../components/about/Timeline.astro'; import Timeline from '../components/about/Timeline.astro';
import { Image } from 'astro:assets';
import { team } from '../data/team'; import { team } from '../data/team';
import { site } from '../data/site'; import T from '../components/content/T.astro';
import TImg from '../components/content/TImg.astro';
import { t } from '../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/*.{jpg,png}', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
const founders = team.filter((m) => m.founder); const founders = team.filter((m) => m.founder);
const staff = team.filter((m) => !m.founder); const staff = team.filter((m) => !m.founder);
export const prerender = true; export const prerender = false;
--- ---
<Base title="Insanitylab" description="Mission, storia, team e metodo di InsanityLab: un laboratorio esclusivo dove movimento, prevenzione e performance convivono in un approccio personalizzato."> <Base title={t('about.meta.title')} description={t('about.meta.description')}>
<PageHero title="Insanitylab" /> <PageHero title={t('about.hero.title')} />
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="about-img-row"> <div class="about-img-row">
<div class="about-img-row__text section-heading"> <div class="about-img-row__text section-heading">
<h2>Mission</h2> <T tag="about.mission.title" as="h2" />
<p>In InsanityLab crediamo che il movimento non debba essere vissuto come un obbligo o un sacrificio, ma come una forma autentica di cura di sé. La nostra missione è accompagnare ogni persona in un percorso di consapevolezza, aiutandola a riscoprire il benessere fisico e mentale attraverso un approccio sostenibile, concreto e duraturo.</p> <T tag="about.mission.body" as="p" />
</div> </div>
{imgOf('about-training-group') && ( {imgOf('about-training-group') && (
<div class="about-img-row__img"> <div class="about-img-row__img">
<Image src={imgOf('about-training-group')!} alt="Training group InsanityLab" widths={[600, 900]} sizes="(max-width:767px) 100vw, 50vw" /> <TImg tag="about.mission.image" src={imgOf('about-training-group')!} alt="Training group InsanityLab" widths={[600, 900]} sizes="(max-width:767px) 100vw, 50vw" />
</div> </div>
)} )}
</div> </div>
@@ -33,14 +35,14 @@ export const prerender = true;
<section class="section section--alt"> <section class="section section--alt">
<div class="container about-2col"> <div class="container about-2col">
<div> <div>
<h2>Direzione</h2> <T tag="about.direzione.title" as="h2" />
<p>Non ci limitiamo ad allenare il corpo: creiamo percorsi personalizzati che uniscono movimento, equilibrio e benessere, attraverso il supporto di professionisti qualificati, programmi su misura e un approccio centrato sulla persona e su risultati duraturi.</p> <T tag="about.direzione.body" as="p" />
<a class="btn" href="/programs">Scopri</a> <a class="btn" href="/programs">{t('about.direzione.cta')}</a>
</div> </div>
<div> <div>
<h2>Approccio</h2> <T tag="about.approccio.title" as="h2" />
<p>InsanityLab si distingue per un approccio serio e personalizzato al benessere, basato su competenze specializzate, costanza e cura quotidiana, rivolto a chiunque voglia migliorare il proprio stile di vita in modo consapevole e duraturo.</p> <T tag="about.approccio.body" as="p" />
<a class="btn btn--dark" href="/contact">Contattaci</a> <a class="btn btn--dark" href="/contact">{t('about.approccio.cta')}</a>
</div> </div>
</div> </div>
</section> </section>
@@ -49,12 +51,12 @@ export const prerender = true;
<div class="container"> <div class="container">
<div class="about-img-row about-img-row--reverse"> <div class="about-img-row about-img-row--reverse">
<div class="about-img-row__text section-heading"> <div class="about-img-row__text section-heading">
<h2>About</h2> <T tag="about.about.title" as="h2" />
<p>InsanityLab nasce con l'obiettivo di unire fitness e salute in un unico percorso dedicato al benessere globale, superando il concetto tradizionale di palestra. Un laboratorio esclusivo, guidato da osteopati e chinesiologi e professionisti specializzati, dove movimento, prevenzione e performance convivono all'interno di un approccio personalizzato e consapevole.</p> <T tag="about.about.body" as="p" />
</div> </div>
{imgOf('about-pushup') && ( {imgOf('about-pushup') && (
<div class="about-img-row__img about-img-row__img--native"> <div class="about-img-row__img about-img-row__img--native">
<Image src={imgOf('about-pushup')!} alt="Allenamento InsanityLab" widths={[320, 640]} sizes="320px" /> <TImg tag="about.about.image" src={imgOf('about-pushup')!} alt="Allenamento InsanityLab" widths={[320, 640]} sizes="320px" />
</div> </div>
)} )}
</div> </div>
@@ -66,18 +68,18 @@ export const prerender = true;
<section class="section section--dark"> <section class="section section--dark">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
<h2>Convenzioni</h2> <T tag="about.convenzioni.title" as="h2" />
<p>InsanityLab collabora con aziende, enti e organizzazioni che desiderano investire concretamente nel benessere delle proprie persone, attraverso programmi dedicati alla salute, al movimento e alla qualità della vita. Le convenzioni nascono con l'obiettivo di portare il valore del benessere anche all'interno del contesto professionale, creando percorsi che favoriscano equilibrio fisico, prevenzione e performance quotidiana.</p> <T tag="about.convenzioni.body" as="p" />
</div> </div>
<ul class="conv"> <ul class="conv">
<li>Le realtà convenzionate possono accedere a programmi di welfare aziendale personalizzati, con percorsi individuali, small group training e classi di gruppo seguiti da professionisti qualificati.</li> <T tag="about.convenzioni.item1" as="li" />
<li>Ogni collaborazione parte da una consulenza gratuita preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.</li> <T tag="about.convenzioni.item2" as="li" />
<li>Le convenzioni vengono definite su misura insieme a InsanityLab, in base alle caratteristiche e alle necessità dell'azienda partner.</li> <T tag="about.convenzioni.item3" as="li" />
</ul> </ul>
<p class="conv__cta">Per ricevere maggiori informazioni o attivare una convenzione: {site.email} · {site.phone}</p> <p class="conv__cta">{t('about.convenzioni.cta-text')} {t('global.contact.email')} · {t('global.contact.phone')}</p>
{imgOf('about-conventions') && ( {imgOf('about-conventions') && (
<div class="round-img"> <div class="round-img">
<Image src={imgOf('about-conventions')!} alt="Convenzioni aziendali" widths={[360, 720]} sizes="360px" /> <TImg tag="about.convenzioni.image" src={imgOf('about-conventions')!} alt="Convenzioni aziendali" widths={[360, 720]} sizes="360px" />
</div> </div>
)} )}
</div> </div>
@@ -86,29 +88,35 @@ export const prerender = true;
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
<h2>Team</h2> <T tag="about.team.title" as="h2" />
<p>Il team di InsanityLab riunisce professionisti specializzati in allenamento, salute e benessere, con un approccio multidisciplinare basato su competenza, personalizzazione e attenzione alla persona.</p> <T tag="about.team.body" as="p" />
</div> </div>
<div class="tgrid tgrid--founders"> <div class="tgrid tgrid--founders">
{founders.map((m) => ( {founders.map((m) => {
const slug = m.shortName.toLowerCase();
return (
<article class="tcard"> <article class="tcard">
{m.imageAbout && imgOf(m.imageAbout) && <Image src={imgOf(m.imageAbout)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />} {m.imageAbout && imgOf(m.imageAbout) && <TImg tag={`team.${slug}.image-about`} src={imgOf(m.imageAbout)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
<h3>{m.shortName}</h3> <T tag={`team.${slug}.short-name`} as="h3" />
<p class="tcard__role">{m.role}</p> <T tag={`team.${slug}.role`} as="p" class="tcard__role" />
<p>{m.bio}</p> <T tag={`team.${slug}.bio`} as="p" />
</article> </article>
))} );
})}
</div> </div>
<p class="section-heading" style="margin-top:60px">Accanto ai fondatori opera un team in continua crescita composto da trainer e professionisti con specializzazioni complementari:</p> <p class="section-heading" style="margin-top:60px">{t('about.team.staff-intro')}</p>
<div class="tgrid"> <div class="tgrid">
{staff.map((m) => ( {staff.map((m) => {
const slug = m.shortName.toLowerCase();
return (
<article class="tcard"> <article class="tcard">
{m.imageAbout && imgOf(m.imageAbout) && <Image src={imgOf(m.imageAbout)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />} {m.imageAbout && imgOf(m.imageAbout) && <TImg tag={`team.${slug}.image-about`} src={imgOf(m.imageAbout)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
<h3>{m.shortName}</h3> <T tag={`team.${slug}.short-name`} as="h3" />
<p class="tcard__role">{m.role}</p> <T tag={`team.${slug}.role`} as="p" class="tcard__role" />
<p>{m.bio}</p> <T tag={`team.${slug}.bio`} as="p" />
</article> </article>
))} );
})}
</div> </div>
</div> </div>
</section> </section>
@@ -116,16 +124,16 @@ export const prerender = true;
<section class="section section--alt"> <section class="section section--alt">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
<h2>Metodo</h2> <T tag="about.metodo.title" as="h2" />
<p>Il team InsanityLab lavora con un approccio empatico e personalizzato, seguendo ogni persona in un percorso continuo e su misura. Unisce competenze multidisciplinari in allenamento, prevenzione, riabilitazione e performance, supportate da formazione costante. Il metodo si basa su sinergia, condivisione e qualità, per generare risultati concreti e duraturi.</p> <T tag="about.metodo.body" as="p" />
</div> </div>
<div class="about-img-row"> <div class="about-img-row">
<div class="about-img-row__text section-heading"> <div class="about-img-row__text section-heading">
<p>In poche parole, il team di InsanityLab è composto da professionisti che credono profondamente in ciò che fanno: un gruppo unito da valori condivisi, formato per eccellere e guidato dalla convinzione che il movimento sia uno strumento di benessere, equilibrio e qualità della vita.</p> <T tag="about.metodo.closing" as="p" />
</div> </div>
{imgOf('about-team-group') && ( {imgOf('about-team-group') && (
<div class="round-img"> <div class="round-img">
<Image src={imgOf('about-team-group')!} alt="Il team InsanityLab" widths={[360, 720]} sizes="360px" /> <TImg tag="about.metodo.image" src={imgOf('about-team-group')!} alt="Il team InsanityLab" widths={[360, 720]} sizes="360px" />
</div> </div>
)} )}
</div> </div>
@@ -136,14 +144,14 @@ export const prerender = true;
<div class="container"> <div class="container">
<div class="about-img-row"> <div class="about-img-row">
<div class="about-img-row__text section-heading"> <div class="about-img-row__text section-heading">
<h2>In-Sanity Studios</h2> <T tag="about.studios.title" as="h2" />
<p>IN-SANITY STUDIOS è il nuovo spazio professionale nato dall'evoluzione di InsanityLab, dedicato ai professionisti della salute, del benessere e della performance. Più che un semplice insieme di studi professionali, rappresenta un ecosistema multidisciplinare pensato per favorire collaborazione, qualità del servizio e integrazione tra competenze diverse.</p> <T tag="about.studios.body1" as="p" />
<p>Il progetto nasce con l'obiettivo di offrire a liberi professionisti uno spazio elegante, riservato e già attrezzato, in cui poter svolgere la propria attività all'interno di un contesto consolidato e altamente qualificato, senza dover affrontare i costi e le complessità di una struttura indipendente.</p> <T tag="about.studios.body2" as="p" />
<p>IN-SANITY STUDIOS si rivolge a figure specializzate nel settore salute e wellness, come fisioterapisti, osteopati, psicologi, nutrizionisti, massaggiatori e altri professionisti dedicati alla cura della persona. Ogni studio è progettato per garantire comfort, professionalità e continuità operativa, all'interno di un ambiente coerente con la filosofia di InsanityLab: mettere il benessere globale della persona al centro del percorso.</p> <T tag="about.studios.body3" as="p" />
</div> </div>
{imgOf('about-studios') && ( {imgOf('about-studios') && (
<div class="about-img-row__img"> <div class="about-img-row__img">
<Image src={imgOf('about-studios')!} alt="In-Sanity Studios" widths={[600, 900]} sizes="(max-width:767px) 100vw, 50vw" /> <TImg tag="about.studios.image" src={imgOf('about-studios')!} alt="In-Sanity Studios" widths={[600, 900]} sizes="(max-width:767px) 100vw, 50vw" />
</div> </div>
)} )}
</div> </div>
@@ -155,13 +163,13 @@ export const prerender = true;
<div class="about-img-row"> <div class="about-img-row">
{imgOf('about-vision') && ( {imgOf('about-vision') && (
<div class="round-img"> <div class="round-img">
<Image src={imgOf('about-vision')!} alt="La comunità InsanityLab" widths={[360, 720]} sizes="360px" /> <TImg tag="about.vision.image" src={imgOf('about-vision')!} alt="La comunità InsanityLab" widths={[360, 720]} sizes="360px" />
</div> </div>
)} )}
<div class="about-img-row__text section-heading"> <div class="about-img-row__text section-heading">
<h2>Vision</h2> <T tag="about.vision.title" as="h2" />
<p>InsanityLab nasce con l'obiettivo di aiutare le persone a ritrovare energia ed equilibrio attraverso il movimento, promuovendo un approccio autentico, scientifico e umano al benessere. La sua visione va oltre l'estetica e la performance: allenarsi significa prendersi cura di sé, costruendo abitudini sane, sostenibili e orientate alla longevità, alla prevenzione e alla qualità della vita.</p> <T tag="about.vision.body1" as="p" />
<p>L'obiettivo è diventare un punto di riferimento capace di lasciare un impatto reale nella comunità, trasformando il movimento in uno strumento di salute, consapevolezza e identità.</p> <T tag="about.vision.body2" as="p" />
</div> </div>
</div> </div>
</div> </div>
+247
View File
@@ -0,0 +1,247 @@
---
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
import { seedByTag } from '../../data/content-seed';
import { STYLE_GROUPS } from '../../lib/style-presets';
export const prerender = false;
// Mappa esplicita prefisso tag → gruppo del pannello (spec §7).
const GROUP_BY_PREFIX: Record<string, string> = {
home: 'Home',
about: 'About', team: 'About', method: 'About',
program: 'Programmi', programs: 'Programmi', pricing: 'Programmi',
training: 'Training',
contact: 'Contatti', form: 'Contatti',
global: 'Globali', header: 'Globali', footer: 'Globali',
agenda: 'Globali', partner: 'Globali', notfound: 'Globali',
};
const GROUP_ORDER = ['Home', 'About', 'Programmi', 'Training', 'Contatti', 'Globali', 'Altro'];
interface Row { tag: string; type: string; value: string; styles: string; updated_at: string; updated_by: string | null }
const rows = getDb().prepare(
'SELECT tag, type, value, styles, updated_at, updated_by FROM content_blocks ORDER BY tag'
).all() as Row[];
interface Item extends Row { parsedStyles: Record<string, string>; styleOptions: readonly string[] }
const groups = new Map<string, Item[]>(GROUP_ORDER.map((g) => [g, []]));
for (const r of rows) {
let parsedStyles: Record<string, string> = {};
try { parsedStyles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
if (typeof parsedStyles !== 'object' || parsedStyles === null) parsedStyles = {};
const group = GROUP_BY_PREFIX[r.tag.split('.')[0]] ?? 'Altro';
groups.get(group)!.push({ ...r, parsedStyles, styleOptions: seedByTag.get(r.tag)?.styleOptions ?? [] });
}
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
const snippet = (v: string) => (v.length > 60 ? `${v.slice(0, 60)}…` : v);
const isLong = (v: string) => v.length > 80 || v.includes('\n');
const imgLabel = (v: string) => (v ? '(immagine personalizzata)' : '(immagine originale)');
const allStyles = STYLE_GROUPS as Record<string, readonly string[]>;
---
<Admin title="Contenuti">
<div class="chead">
<h1>Contenuti</h1>
<a href="/?annotate=1" target="_blank">Vedi sito con tag ↗</a>
</div>
<input class="afield" id="csearch" type="search" placeholder="Cerca per tag o contenuto…" aria-label="Cerca per tag o contenuto" autocomplete="off" />
<div id="cgroups">
{GROUP_ORDER.map((g) => {
const items = groups.get(g)!;
return items.length > 0 && (
<details class="cgroup">
<summary><strong>{g}</strong> <span class="pill">{items.length}</span></summary>
{items.map((it) => (
<details class="crow" id={it.tag} data-tag={it.tag} data-q={`${it.tag} ${it.value}`.toLowerCase()}>
<summary>
<code>{it.tag}</code>
<span class="crow-snippet">{it.type === 'image' ? imgLabel(it.value) : snippet(it.value)}</span>
</summary>
<div class="crow-body">
{it.type === 'image' ? (
<>
<p class="cimg-preview">
{it.value
? <img src={`/uploads/${it.value}`} alt={it.tag} />
: <em>Nessun override: il sito usa l'immagine originale.</em>}
</p>
<p class="cactions">
<input type="file" accept="image/*" aria-label={`Nuova immagine per ${it.tag}`} data-file />
<button class="abtn" type="button" data-upload>Carica</button>
<button class="abtn abtn--danger" type="button" data-restore>Ripristina originale</button>
</p>
</>
) : (
<>
{isLong(it.value)
? <textarea class="afield" rows="4" aria-label={`Contenuto ${it.tag}`} data-value>{it.value}</textarea>
: <input class="afield" value={it.value} aria-label={`Contenuto ${it.tag}`} data-value />}
{it.styleOptions.length > 0 && (
<p class="cstyles">
{it.styleOptions.map((sg) => (
<label>{sg}
<select data-style={sg}>
<option value="">(default)</option>
{allStyles[sg].map((v) => (
<option value={v} selected={it.parsedStyles[sg] === v}>{v}</option>
))}
</select>
</label>
))}
</p>
)}
<p class="cactions"><button class="abtn" type="button" data-save>Salva</button></p>
</>
)}
<p class="form-msg" data-msg hidden></p>
{it.updated_by && (
<p class="cmeta">Aggiornato {fmt.format(new Date(it.updated_at.replace(' ', 'T') + 'Z'))} da {it.updated_by}</p>
)}
</div>
</details>
))}
</details>
);
})}
</div>
<p id="cempty" hidden>Nessun contenuto corrisponde alla ricerca.</p>
</Admin>
<style is:global>
.chead { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
.chead a { color: #7a6a55; }
.cgroup { background: #fff; border: 1px solid #ddd; margin-bottom: 14px; }
.cgroup > summary { padding: 12px 16px; cursor: pointer; background: #eee9e1; font-size: 1.05rem; }
.crow { border-top: 1px solid #eee; }
.crow > summary { padding: 8px 16px; cursor: pointer; display: flex; gap: 12px; align-items: baseline; }
.crow > summary code { background: #f0ece5; padding: 1px 6px; font-size: .82rem; white-space: nowrap; }
.crow-snippet { color: #888; font-size: .85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.crow-body { padding: 6px 16px 12px; }
.crow-body .afield { margin-bottom: 8px; }
.cstyles { display: flex; gap: 14px; flex-wrap: wrap; margin: 0 0 8px; }
.cstyles label { font-size: .85rem; color: #555; }
.cstyles select { margin-left: 4px; padding: 4px; font: inherit; }
.cactions { margin: 0 0 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.cimg-preview img { max-width: 240px; max-height: 140px; display: block; border: 1px solid #ddd; }
.cmeta { color: #999; font-size: .78rem; margin: 4px 0 0; }
.crow--hl { outline: 2px solid #b5a48b; }
</style>
<script>
const wrap = document.getElementById('cgroups')!;
function showMsg(row: HTMLElement, ok: boolean, text: string): void {
const m = row.querySelector<HTMLElement>('[data-msg]')!;
m.textContent = text;
m.hidden = false;
m.className = `form-msg ${ok ? 'form-msg--ok' : 'form-msg--err'}`;
}
function setPreview(row: HTMLElement, url: string | null): void {
const p = row.querySelector<HTMLElement>('.cimg-preview')!;
if (url) {
p.textContent = '';
const img = document.createElement('img');
img.src = url;
img.alt = row.dataset.tag ?? '';
p.append(img);
} else {
p.innerHTML = "<em>Nessun override: il sito usa l'immagine originale.</em>";
}
row.querySelector<HTMLElement>('.crow-snippet')!.textContent =
url ? '(immagine personalizzata)' : '(immagine originale)';
}
async function call(row: HTMLElement, btn: HTMLButtonElement, req: () => Promise<Response>,
onOk: (data: Record<string, string>) => void, okMsg: string, errMsg: string): Promise<void> {
btn.disabled = true;
try {
const res = await req();
const data = await res.json().catch(() => ({}));
if (res.ok) { onOk(data); showMsg(row, true, okMsg); }
else showMsg(row, false, data.error ?? errMsg);
} catch {
showMsg(row, false, 'Errore di rete.');
} finally {
btn.disabled = false;
}
}
wrap.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('button');
if (!btn) return;
const row = btn.closest<HTMLElement>('details.crow')!;
const api = `/api/admin/content/${encodeURIComponent(row.dataset.tag!)}`;
if (btn.hasAttribute('data-save')) {
const value = row.querySelector<HTMLInputElement | HTMLTextAreaElement>('[data-value]')!.value;
const styles: Record<string, string> = {};
row.querySelectorAll<HTMLSelectElement>('[data-style]').forEach((s) => {
if (s.value) styles[s.dataset.style!] = s.value;
});
call(row, btn,
() => fetch(api, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value, styles }),
}),
(data) => {
const saved = typeof data.value === 'string' ? data.value : value;
const field = row.querySelector<HTMLInputElement | HTMLTextAreaElement>('[data-value]')!;
field.value = saved;
row.dataset.q = `${row.dataset.tag} ${saved}`.toLowerCase();
row.querySelector<HTMLElement>('.crow-snippet')!.textContent =
saved.length > 60 ? `${saved.slice(0, 60)}…` : saved;
},
'Salvato.', 'Salvataggio non riuscito.');
} else if (btn.hasAttribute('data-upload')) {
const file = row.querySelector<HTMLInputElement>('[data-file]')!.files?.[0];
if (!file) { showMsg(row, false, 'Seleziona prima un file.'); return; }
const fd = new FormData();
fd.append('file', file);
call(row, btn,
() => fetch(`${api}/image`, { method: 'POST', body: fd }),
(data) => setPreview(row, data.url),
'Immagine caricata.', 'Upload non riuscito.');
} else if (btn.hasAttribute('data-restore')) {
call(row, btn,
() => fetch(`${api}/image`, { method: 'DELETE' }),
() => setPreview(row, null),
'Ripristinata immagine originale.', 'Ripristino non riuscito.');
}
});
// Ricerca client-side: nasconde le righe non corrispondenti (tag o contenuto).
const search = document.getElementById('csearch') as HTMLInputElement;
const rowEls = Array.from(document.querySelectorAll<HTMLDetailsElement>('details.crow'));
const groupEls = Array.from(document.querySelectorAll<HTMLDetailsElement>('details.cgroup'));
search.addEventListener('input', () => {
const q = search.value.trim().toLowerCase();
for (const r of rowEls) r.hidden = q !== '' && !(r.dataset.q ?? '').includes(q);
let any = false;
for (const g of groupEls) {
const visible = g.querySelector('details.crow:not([hidden])') !== null;
g.hidden = !visible;
if (q && visible) g.open = true;
any = any || visible;
}
document.getElementById('cempty')!.hidden = any;
});
// Ancore: #<tag> apre gruppo + riga, scrolla ed evidenzia.
function openHash(): void {
let tag: string;
try { tag = decodeURIComponent(location.hash.slice(1)); } catch { return; }
if (!tag) return;
const row = document.getElementById(tag);
if (!row || !row.classList.contains('crow')) return;
const group = row.closest<HTMLDetailsElement>('details.cgroup');
if (group) group.open = true;
(row as HTMLDetailsElement).open = true;
row.scrollIntoView({ block: 'center' });
row.classList.add('crow--hl');
setTimeout(() => row.classList.remove('crow--hl'), 2500);
}
window.addEventListener('hashchange', openHash);
openHash();
</script>
+21
View File
@@ -0,0 +1,21 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db';
import { applyContentUpdate } from '../../../../lib/content-update';
import { invalidateContentCache } from '../../../../lib/content';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const PUT: APIRoute = async ({ params, request, locals }) => {
let data: Record<string, unknown>;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
if (typeof data !== 'object' || data === null) return json(400, { error: 'Dati non validi.' });
const r = applyContentUpdate(getDb(), params.tag ?? '', data, locals.user!.username);
if (!r.ok) return json(r.status, { error: r.error });
invalidateContentCache();
// Il value salvato può differire da quello inviato (sanitizeInline sui tag html):
// lo restituiamo così il pannello mostra il dato reale.
const row = getDb().prepare('SELECT value FROM content_blocks WHERE tag = ?').get(params.tag) as { value: string };
return json(200, { ok: true, value: row.value });
};
@@ -0,0 +1,53 @@
import type { APIRoute } from 'astro';
import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { getDb } from '../../../../../lib/db';
import { getEnv } from '../../../../../lib/env';
import { invalidateContentCache } from '../../../../../lib/content';
import { validateUploadFile } from '../../../../../lib/upload-rules';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
// Difesa in profondità: oggi i tag arrivano solo dal seed, ma il nome file deriva
// dal tag — vincolarne il formato evita sorprese se in futuro i tag diventano dinamici.
const TAG_RE = /^[a-z0-9][a-z0-9.-]*$/;
function findImageTag(tag: string): { ok: true } | { ok: false; status: number; error: string } {
if (!TAG_RE.test(tag)) return { ok: false, status: 400, error: 'Tag non valido.' };
const row = getDb().prepare('SELECT type FROM content_blocks WHERE tag = ?').get(tag) as { type: string } | undefined;
if (!row) return { ok: false, status: 404, error: 'Tag inesistente.' };
if (row.type !== 'image') return { ok: false, status: 400, error: 'Il tag non è di tipo immagine.' };
return { ok: true };
}
export const POST: APIRoute = async ({ params, request, locals }) => {
const tag = params.tag ?? '';
const found = findImageTag(tag);
if (!found.ok) return json(found.status, { error: found.error });
const form = await request.formData().catch(() => null);
const checked = validateUploadFile(form?.get('file'));
if (!checked.ok) return json(400, { error: checked.error });
const dir = join(getEnv('UPLOADS_DIR', 'uploads'), 'content');
await mkdir(dir, { recursive: true });
const name = `${tag.replaceAll('.', '-')}-${Date.now()}-${randomBytes(4).toString('hex')}${checked.ext}`;
await writeFile(join(dir, name), Buffer.from(await checked.file.arrayBuffer()));
getDb().prepare(
`UPDATE content_blocks SET value = ?, updated_at = datetime('now'), updated_by = ? WHERE tag = ?`
).run(`content/${name}`, locals.user!.username, tag);
invalidateContentCache();
return json(200, { url: `/uploads/content/${name}` });
};
export const DELETE: APIRoute = ({ params, locals }) => {
const tag = params.tag ?? '';
const found = findImageTag(tag);
if (!found.ok) return json(found.status, { error: found.error });
getDb().prepare(
`UPDATE content_blocks SET value = '', updated_at = datetime('now'), updated_by = ? WHERE tag = ?`
).run(locals.user!.username, tag);
invalidateContentCache();
return json(200, { ok: true });
};
+28
View File
@@ -0,0 +1,28 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../../lib/db';
import { seedByTag } from '../../../../data/content-seed';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = () => {
const rows = getDb().prepare(
'SELECT tag, type, value, styles, updated_at, updated_by FROM content_blocks ORDER BY tag'
).all() as { tag: string; type: string; value: string; styles: string; updated_at: string; updated_by: string | null }[];
const items = rows.map((r) => {
let styles: Record<string, string> = {};
try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
if (typeof styles !== 'object' || styles === null) styles = {};
return {
tag: r.tag,
type: r.type,
value: r.value,
styles,
styleOptions: seedByTag.get(r.tag)?.styleOptions ?? [],
updatedAt: r.updated_at,
updatedBy: r.updated_by,
};
});
return json(200, { items });
};
+6 -11
View File
@@ -1,26 +1,21 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { writeFile, mkdir } from 'node:fs/promises'; import { writeFile, mkdir } from 'node:fs/promises';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { join, extname } from 'node:path'; import { join } from 'node:path';
import { getEnv } from '../../../lib/env'; import { getEnv } from '../../../lib/env';
import { validateUploadFile } from '../../../lib/upload-rules';
export const prerender = false; export const prerender = false;
const ALLOWED: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif' };
const MAX_BYTES = 5 * 1024 * 1024;
const json = (status: number, body: object) => const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
const form = await request.formData().catch(() => null); const form = await request.formData().catch(() => null);
const file = form?.get('file'); const checked = validateUploadFile(form?.get('file'));
if (!(file instanceof File)) return json(400, { error: 'Nessun file.' }); if (!checked.ok) return json(400, { error: checked.error });
const ext = extname(file.name).toLowerCase();
if (!ALLOWED[ext]) return json(400, { error: 'Formato non consentito (jpg, png, webp, gif).' });
if (file.size > MAX_BYTES) return json(400, { error: 'File troppo grande (max 5 MB).' });
const dir = getEnv('UPLOADS_DIR', 'uploads'); const dir = getEnv('UPLOADS_DIR', 'uploads');
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
const name = `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`; const name = `${Date.now()}-${randomBytes(4).toString('hex')}${checked.ext}`;
await writeFile(join(dir, name), Buffer.from(await file.arrayBuffer())); await writeFile(join(dir, name), Buffer.from(await checked.file.arrayBuffer()));
return json(200, { url: `/uploads/${name}` }); return json(200, { url: `/uploads/${name}` });
}; };
+15 -14
View File
@@ -2,37 +2,38 @@
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro'; import PageHero from '../components/PageHero.astro';
import ContactForm from '../components/ContactForm.astro'; import ContactForm from '../components/ContactForm.astro';
import { site } from '../data/site';
import contactHero from '../assets/img/contact-hero.jpg'; import contactHero from '../assets/img/contact-hero.jpg';
export const prerender = true; import T from '../components/content/T.astro';
const mapsQuery = encodeURIComponent(`${site.address}, ${site.cityLine}`); import { t, getImageOverride } from '../lib/content';
export const prerender = false;
const mapsQuery = encodeURIComponent(`${t('global.contact.address')}, ${t('global.contact.city-line')}`);
--- ---
<Base title="Contatti" description="Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale."> <Base title={t('contact.meta.title')} description={t('contact.meta.description')}>
<PageHero title="Contattaci" bgImage={contactHero} /> <PageHero title={t('contact.hero.title')} bgImage={getImageOverride('contact.hero.image') ?? contactHero} />
<section class="section"> <section class="section">
<div class="container cgrid"> <div class="container cgrid">
<div class="cmap" id="map-box"> <div class="cmap" id="map-box">
<button class="btn btn--dark" id="map-load">Carica la mappa (Google Maps)</button> <button class="btn btn--dark" id="map-load">{t('contact.map.button')}</button>
<p class="cmap__note">La mappa viene caricata da Google solo dopo il tuo consenso.</p> <p class="cmap__note">{t('contact.map.note')}</p>
</div> </div>
<ContactForm variant="full" /> <ContactForm variant="full" />
</div> </div>
<div class="container cinfo"> <div class="container cinfo">
<div> <div>
<h3>Indirizzo</h3> <T tag="contact.info.address-label" as="h3" />
<p>{site.address} {site.cityLine}</p> <p>{t('global.contact.address')} {t('global.contact.city-line')}</p>
</div> </div>
<div> <div>
<h3>Telefono</h3> <T tag="contact.info.phone-label" as="h3" />
<p>{site.phone}</p> <p>{t('global.contact.phone')}</p>
</div> </div>
<div> <div>
<h3>E-mail</h3> <T tag="contact.info.email-label" as="h3" />
<p><a href={`mailto:${site.email}`}>{site.email}</a></p> <p><a href={`mailto:${t('global.contact.email')}`}>{t('global.contact.email')}</a></p>
</div> </div>
</div> </div>
<div class="container"> <div class="container">
<p class="cinvite">Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.</p> <T tag="contact.invite" as="p" class="cinvite" />
</div> </div>
</section> </section>
</Base> </Base>
+6 -4
View File
@@ -10,17 +10,19 @@ import Agenda from '../components/Agenda.astro';
import TeamSection from '../components/home/TeamSection.astro'; import TeamSection from '../components/home/TeamSection.astro';
import MethodSteps from '../components/home/MethodSteps.astro'; import MethodSteps from '../components/home/MethodSteps.astro';
import PartnersStrip from '../components/home/PartnersStrip.astro'; import PartnersStrip from '../components/home/PartnersStrip.astro';
export const prerender = true; import T from '../components/content/T.astro';
import { t } from '../lib/content';
export const prerender = false;
--- ---
<Base title="Performance. Balance. Longevity."> <Base title={t('home.meta.title')}>
<HeroSlider /> <HeroSlider />
<InfoColumns /> <InfoColumns />
<FeatureBlocks /> <FeatureBlocks />
<QuoteBanner /> <QuoteBanner />
<section class="section" style="padding-bottom:0"> <section class="section" style="padding-bottom:0">
<div class="section-heading"> <div class="section-heading">
<h2>Training</h2> <T tag="home.training.title" as="h2" />
<p>Porta i tuoi allenamenti al livello successivo: trova il percorso più adatto alle tue esigenze.</p> <T tag="home.training.subtitle" as="p" />
</div> </div>
</section> </section>
<TrainingCards /> <TrainingCards />
+34 -33
View File
@@ -1,59 +1,60 @@
--- ---
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro'; import PageHero from '../../components/PageHero.astro';
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { programs } from '../../data/programs'; import { programs, getProgram } from '../../data/programs';
import { performanceClassPricing } from '../../data/pricing'; import { performanceClassPricing } from '../../data/pricing';
import { programIcons } from '../../data/program-icons'; import { programIcons } from '../../data/program-icons';
export const prerender = true; import T from '../../components/content/T.astro';
import TImg from '../../components/content/TImg.astro';
import { t } from '../../lib/content';
export const prerender = false;
export function getStaticPaths() { const program = getProgram(Astro.params.slug!);
return programs.map((p) => ({ params: { slug: p.slug }, props: { program: p } })); if (!program) return new Response(null, { status: 404 });
} const slug = program.slug;
const { program } = Astro.props;
const others = programs.filter((p) => p.slug !== program.slug); const others = programs.filter((p) => p.slug !== program.slug);
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
const heroImg = imgOf(program.slug); const heroImg = imgOf(program.slug);
// Categorie come da Figma (Performance class.pdf), non quelle del blog // Categorie come da Figma (Performance class.pdf), non quelle del blog → tag program.side.category.1-4
const categories = ['Fitness', 'Salute', 'Stile di vita', 'Nutrizione']; const categoryIndexes = [1, 2, 3, 4];
// Ordine card pricing come da Figma: tan, taupe, dark // Ordine card pricing come da Figma: tan, taupe, dark
const tones = ['tan', 'taupe', 'dark']; const tones = ['tan', 'taupe', 'dark'];
--- ---
<Base title={program.title} description={program.excerpt}> <Base title={t(`program.${slug}.title`)} description={t(`program.${slug}.excerpt`)}>
<PageHero title={program.title.toUpperCase()} bgImage={heroImg} /> <PageHero title={t(`program.${slug}.title`).toUpperCase()} bgImage={heroImg} />
<section class="section"> <section class="section">
<div class="container prog"> <div class="container prog">
<div> <div>
<h2>{program.title.toUpperCase()}</h2> <h2>{t(`program.${slug}.title`).toUpperCase()}</h2>
<p>{program.longDescription}</p> <T tag={`program.${slug}.long-description`} as="p" />
<ul class="prog__features"> <ul class="prog__features">
{program.features.map((f) => <li>{f}</li>)} {program.features.map((_, i) => <T tag={`program.${slug}.feature.${i + 1}`} as="li" />)}
</ul> </ul>
{heroImg && ( {heroImg && (
<Image src={heroImg} alt={program.title} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" /> <TImg tag={`program.${slug}.image`} src={heroImg} alt={t(`program.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" />
)} )}
<div class="prog__faq"> <div class="prog__faq">
{program.faq.map((item, i) => ( {program.faq.map((_, i) => (
<details open={i === 0}> <details open={i === 0}>
<summary>{item.q}</summary> <summary><T tag={`program.${slug}.faq.${i + 1}.q`} /></summary>
<p>{item.a}</p> <T tag={`program.${slug}.faq.${i + 1}.a`} as="p" />
</details> </details>
))} ))}
</div> </div>
</div> </div>
<aside class="prog__side"> <aside class="prog__side">
<h3>Categorie</h3> <T tag="program.side.categories-label" as="h3" />
{categories.map((c) => <p class="prog__cat">{c}</p>)} {categoryIndexes.map((n) => <T tag={`program.side.category.${n}`} as="p" class="prog__cat" />)}
<h3>Altri programmi</h3> <T tag="program.side.others-label" as="h3" />
{others.map((o) => ( {others.map((o) => (
<a class="prog__other" href={`/programs/${o.slug}`}> <a class="prog__other" href={`/programs/${o.slug}`}>
<span class="prog__icon"> <span class="prog__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true"><path d={programIcons[o.slug]} /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true"><path d={programIcons[o.slug]} /></svg>
</span> </span>
<span>{o.title}</span> <T tag={`program.${o.slug}.title`} />
</a> </a>
))} ))}
</aside> </aside>
@@ -64,25 +65,25 @@ const tones = ['tan', 'taupe', 'dark'];
<section class="section section--alt"> <section class="section section--alt">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
<h2>{performanceClassPricing.heading}</h2> <T tag="pricing.heading" as="h2" />
<p>{performanceClassPricing.intro}</p> <T tag="pricing.intro" as="p" />
</div> </div>
{performanceClassPricing.groups.map((g) => ( {performanceClassPricing.groups.map((g, gi) => (
<details class="price__group" open={g.open}> <details class="price__group" open={g.open}>
<summary>{g.open ? '+' : ''} {g.label}</summary> <summary>{g.open ? '+' : ''} <T tag={`pricing.group.${gi + 1}.label`} /></summary>
{g.plans.length === 0 ? ( {g.plans.length === 0 ? (
<p class="price__empty">Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a></p> <T tag="program.pricing.empty-note" as="p" class="price__empty" />
) : ( ) : (
<div class="price__row"> <div class="price__row">
{g.plans.map((p, i) => ( {g.plans.map((p, i) => (
<div class:list={['price__card', `price__card--${tones[i % 3]}`]}> <div class:list={['price__card', `price__card--${tones[i % 3]}`]}>
<p class="price__amount">{p.monthly}€</p> <p class="price__amount">{p.monthly}€</p>
<p class="price__per">al mese, semestrale</p> <T tag="program.pricing.per" as="p" class="price__per" />
<p class="price__entries">{p.entries} {p.entries === 1 ? 'ingresso settimanale' : 'ingressi settimanali'}</p> <p class="price__entries">{p.entries} {p.entries === 1 ? t('program.pricing.entries-singular') : t('program.pricing.entries-plural')}</p>
<p>{p.note}</p> <T tag={`pricing.group.${gi + 1}.plan.${i + 1}.note`} as="p" />
<p>{p.installments}</p> <T tag={`pricing.group.${gi + 1}.plan.${i + 1}.installments`} as="p" />
<p class="price__best">{p.best}</p> <T tag={`pricing.group.${gi + 1}.plan.${i + 1}.best`} as="p" class="price__best" />
<a class="btn btn--light" href="/contact">Acquista</a> <a class="btn btn--light" href="/contact">{t('program.pricing.buy-cta')}</a>
</div> </div>
))} ))}
</div> </div>
+13 -11
View File
@@ -1,34 +1,36 @@
--- ---
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro'; import PageHero from '../../components/PageHero.astro';
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { programs } from '../../data/programs'; import { programs } from '../../data/programs';
import T from '../../components/content/T.astro';
import TImg from '../../components/content/TImg.astro';
import { t } from '../../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default; const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
export const prerender = true; export const prerender = false;
--- ---
<Base title="Programmi" description="I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione."> <Base title={t('programs.meta.title')} description={t('programs.meta.description')}>
<PageHero title="Programmi" /> <PageHero title={t('programs.hero.title')} />
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
<h2>Programmi</h2> <T tag="programs.intro.title" as="h2" />
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata.</p> <T tag="programs.intro.body" as="p" />
</div> </div>
<div class="plist"> <div class="plist">
{programs.map((p, i) => ( {programs.map((p, i) => (
<article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}> <article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}>
{imgOf(p.slug) && ( {imgOf(p.slug) && (
<a class="plist__photo" href={`/programs/${p.slug}`}> <a class="plist__photo" href={`/programs/${p.slug}`}>
<Image src={imgOf(p.slug)!} alt={p.title} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" /> <TImg tag={`program.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`program.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" />
</a> </a>
)} )}
<div class="plist__body"> <div class="plist__body">
<h3>{p.title}</h3> <T tag={`program.${p.slug}.title`} as="h3" />
<p class="plist__sub">{p.subtitle}</p> <T tag={`program.${p.slug}.subtitle`} as="p" class="plist__sub" />
<p>{p.excerpt}</p> <T tag={`program.${p.slug}.excerpt`} as="p" />
<a class="plist__more" href={`/programs/${p.slug}`}>Per saperne di più →</a> <a class="plist__more" href={`/programs/${p.slug}`}>{t('programs.card.more-label')}</a>
</div> </div>
</article> </article>
))} ))}
+18
View File
@@ -0,0 +1,18 @@
import type { APIRoute } from 'astro';
import { getDb } from '../lib/db';
import { programs } from '../data/programs';
export const prerender = false;
const STATIC = ['/', '/about', '/contact', '/training', '/programs', '/blog'];
export const GET: APIRoute = ({ site }) => {
const base = (site ?? new URL('https://insanitylab.tielogic.xyz')).toString().replace(/\/$/, '');
const posts = getDb().prepare('SELECT slug FROM posts WHERE draft = 0').all() as { slug: string }[];
const urls = [
...STATIC,
...programs.map((p) => `/programs/${p.slug}`),
...posts.map((p) => `/blog/${p.slug}`),
];
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => ` <url><loc>${base}${u}</loc></url>`).join('\n')}\n</urlset>`;
return new Response(xml, { headers: { 'Content-Type': 'application/xml' } });
};
+18 -15
View File
@@ -6,30 +6,33 @@ import Agenda from '../components/Agenda.astro';
import { Image } from 'astro:assets'; import { Image } from 'astro:assets';
import { trainings } from '../data/trainings'; import { trainings } from '../data/trainings';
import trialImg from '../assets/img/trial-class.jpg'; import trialImg from '../assets/img/trial-class.jpg';
import T from '../components/content/T.astro';
import TImg from '../components/content/TImg.astro';
import { t } from '../lib/content';
const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true }); const images = import.meta.glob<{ default: ImageMetadata }>('../assets/img/training-*.{jpg,png}', { eager: true });
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default; const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
export const prerender = true; export const prerender = false;
--- ---
<Base title="Training" description="One to One, Small Groups, Performance Class e Fit Remote: trova il training InsanityLab più adatto a te."> <Base title={t('training.meta.title')} description={t('training.meta.description')}>
<PageHero title="training" /> <PageHero title={t('training.hero.title')} />
<section class="section" style="padding-bottom:0"> <section class="section" style="padding-bottom:0">
<div class="section-heading"> <div class="section-heading">
<h2>Training</h2> <T tag="training.intro.title" as="h2" />
<p>Porta i tuoi allenamenti al livello successivo: programmi completi e percorsi su misura. Trova il training più adatto alle tue esigenze.</p> <T tag="training.intro.subtitle" as="p" />
</div> </div>
</section> </section>
<TrainingCards /> <TrainingCards />
<section class="section"> <section class="section">
<div class="container"> <div class="container">
{trainings.map((t, i) => ( {trainings.map((tr, i) => (
<div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={t.id}> <div class:list={['tblock', { 'tblock--reverse': i % 2 === 1 }]} id={tr.id}>
{imgOf(t.image) && <Image src={imgOf(t.image)!} alt={t.title} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />} {imgOf(tr.image) && <Image src={imgOf(tr.image)!} alt={t(`training.${tr.id}.title`)} widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal={i % 2 === 1 ? 'right' : 'left'} />}
<div data-reveal={i % 2 === 1 ? 'left' : 'right'}> <div data-reveal={i % 2 === 1 ? 'left' : 'right'}>
<h2>{t.title.toLowerCase()}</h2> <h2 data-tag={`training.${tr.id}.title`}>{t(`training.${tr.id}.title`).toLowerCase()}</h2>
<p>{t.description}</p> <T tag={`training.${tr.id}.description`} as="p" />
<a class="btn" href={t.cta.href}>{t.cta.label}</a> <a class="btn" href={tr.cta.href}><T tag={`training.${tr.id}.cta-label`} as="span" /></a>
</div> </div>
</div> </div>
))} ))}
@@ -40,11 +43,11 @@ export const prerender = true;
<section class="section section--alt"> <section class="section section--alt">
<div class="container trial"> <div class="container trial">
<Image src={trialImg} alt="Lezione di prova InsanityLab" widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal="left" /> <TImg tag="training.trial.image" src={trialImg} alt="Lezione di prova InsanityLab" widths={[500, 900]} sizes="(max-width:767px) 100vw, 45vw" data-reveal="left" />
<div data-reveal="right"> <div data-reveal="right">
<h2>Prenota la lezione di prova</h2> <T tag="training.trial.title" as="h2" />
<p>La lezione di prova è l'occasione perfetta per conoscere il metodo, vivere l'esperienza in prima persona e capire il percorso più adatto a te. Inizia oggi il tuo percorso: prenota la tua lezione di prova.</p> <T tag="training.trial.body" as="p" />
<a class="btn btn--dark" href="/contact">Prenota</a> <a class="btn btn--dark" href="/contact">{t('training.trial.cta')}</a>
</div> </div>
</div> </div>
</section> </section>
+19
View File
@@ -68,3 +68,22 @@ html.js [data-reveal].is-inview, html.js .section-heading.is-inview { opacity: 1
html.js [data-reveal], html.js .section-heading { opacity: 1; transform: none; transition: none; } html.js [data-reveal], html.js .section-heading { opacity: 1; transform: none; transition: none; }
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
} }
/* === Stili contenuto (preset pannello /admin/content) === */
.cs-size-s { font-size: .85em; }
.cs-size-m { font-size: 1em; }
.cs-size-l { font-size: 1.25em; }
.cs-color-accent { color: var(--c-accent); }
.cs-color-accent-dark { color: var(--c-accent-dark); }
.cs-color-dark { color: var(--c-dark); }
.cs-color-heading { color: var(--c-heading); }
.cs-color-text { color: var(--c-text); }
.cs-color-text-light { color: var(--c-text-light); }
.cs-color-white { color: #fff; }
.cs-weight-normal { font-weight: 400; }
.cs-weight-bold { font-weight: 700; }
.cs-style-normal { font-style: normal; }
.cs-style-italic { font-style: italic; }
.cs-align-left { text-align: left; }
.cs-align-center { text-align: center; }
.cs-align-right { text-align: right; }
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { contentSeed } from '../src/data/content-seed';
import { applyContentUpdate } from '../src/lib/content-update';
let db: Database.Database;
const textTag = contentSeed.find((e) => e.type === 'text')!.tag;
const htmlTag = contentSeed.find((e) => e.type === 'html')?.tag;
beforeEach(() => { db = createDb(':memory:'); });
describe('applyContentUpdate', () => {
it('aggiorna value e styles validi', () => {
const r = applyContentUpdate(db, textTag, { value: 'Nuovo testo', styles: { size: 'l' } }, 'admin');
expect(r).toEqual({ ok: true });
const row = db.prepare('SELECT value, styles, updated_by FROM content_blocks WHERE tag = ?').get(textTag) as any;
expect(row.value).toBe('Nuovo testo');
expect(JSON.parse(row.styles)).toEqual({ size: 'l' });
expect(row.updated_by).toBe('admin');
});
it('404 su tag inesistente, 400 su styles fuori whitelist', () => {
expect(applyContentUpdate(db, 'no.tag', { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 404 });
expect(applyContentUpdate(db, textTag, { styles: { size: 'xxl' } }, 'a')).toMatchObject({ ok: false, status: 400 });
});
it('sanifica i tag html e rifiuta value su tag image', () => {
if (htmlTag) {
applyContentUpdate(db, htmlTag, { value: 'ciao <script>alert(1)</script><strong>ok</strong>' }, 'a');
const row = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(htmlTag) as any;
expect(row.value).not.toContain('<script>');
expect(row.value).toContain('<strong>ok</strong>');
}
const imgTag = contentSeed.find((e) => e.type === 'image')!.tag;
expect(applyContentUpdate(db, imgTag, { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 400 });
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { createDb } from '../src/lib/db';
describe('schema content_blocks e ruoli', () => {
it('crea la tabella content_blocks', () => {
const db = createDb(':memory:');
const cols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
expect(cols.map((c) => c.name)).toEqual(
expect.arrayContaining(['tag', 'type', 'value', 'styles', 'updated_at', 'updated_by'])
);
});
it('rifiuta type non valido', () => {
const db = createDb(':memory:');
expect(() =>
db.prepare(`INSERT INTO content_blocks (tag, type, value) VALUES ('x.y', 'video', '')`).run()
).toThrow();
});
it('users ha colonna role con default admin', () => {
const db = createDb(':memory:');
db.prepare(`INSERT INTO users (username, password_hash) VALUES ('a', 'h')`).run();
const row = db.prepare(`SELECT role FROM users WHERE username = 'a'`).get() as { role: string };
expect(row.role).toBe('admin');
});
it('migra un DB esistente senza colonna role', () => {
// simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile,
// quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia)
const db = createDb(':memory:');
expect(() => createDb(':memory:')).not.toThrow();
expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { contentSeed } from '../src/data/content-seed';
import { makeContentStore, syncSeed } from '../src/lib/content';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('seed', () => {
it('tag univoci, validi e valori non vuoti', () => {
const tags = contentSeed.map((e) => e.tag);
expect(new Set(tags).size).toBe(tags.length);
for (const e of contentSeed) {
expect(e.tag).toMatch(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/);
if (e.type !== 'image') expect(e.value.length).toBeGreaterThan(0);
}
});
it('createDb sincronizza il seed senza sovrascrivere modifiche', () => {
const n = (db.prepare('SELECT COUNT(*) AS n FROM content_blocks').get() as { n: number }).n;
expect(n).toBe(contentSeed.length);
db.prepare(`UPDATE content_blocks SET value = 'MODIFICATO' WHERE tag = ?`).run(contentSeed[0].tag);
// ri-sync (come a un nuovo avvio) non deve toccare la modifica
syncSeed(db);
const v = db.prepare('SELECT value FROM content_blocks WHERE tag = ?').get(contentSeed[0].tag) as { value: string };
expect(v.value).toBe('MODIFICATO');
});
});
describe('content store', () => {
it('legge dal DB con classi preset e cache invalidabile', () => {
const store = makeContentStore(() => db);
const tag = contentSeed[0].tag;
db.prepare(`UPDATE content_blocks SET value = 'Nuovo', styles = '{"size":"l"}' WHERE tag = ?`).run(tag);
store.invalidate();
const r = store.resolve(tag);
expect(r.value).toBe('Nuovo');
expect(r.classes).toBe('cs-size-l');
// senza invalidate la cache resta
db.prepare(`UPDATE content_blocks SET value = 'Altro' WHERE tag = ?`).run(tag);
expect(store.resolve(tag).value).toBe('Nuovo');
store.invalidate();
expect(store.resolve(tag).value).toBe('Altro');
});
it('fallback: tag assente nel DB usa il seed, sconosciuto stringa vuota', () => {
const store = makeContentStore(() => db);
db.prepare('DELETE FROM content_blocks WHERE tag = ?').run(contentSeed[0].tag);
store.invalidate();
expect(store.resolve(contentSeed[0].tag).value).toBe(contentSeed[0].value);
expect(store.resolve('tag.inesistente').value).toBe('');
});
it('styles corrotto nel DB viene ignorato', () => {
const store = makeContentStore(() => db);
db.prepare(`UPDATE content_blocks SET styles = 'non-json' WHERE tag = ?`).run(contentSeed[0].tag);
store.invalidate();
expect(store.resolve(contentSeed[0].tag).classes).toBe('');
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createDb } from '../src/lib/db';
import { createUser, login, getSessionUser, canAccessAdminPath } from '../src/lib/auth';
let db: Database.Database;
beforeEach(() => { db = createDb(':memory:'); });
describe('ruoli', () => {
it('createUser accetta ruolo editor e getSessionUser lo ritorna', () => {
createUser(db, 'cliente', 'segreta123', 'editor');
const token = login(db, 'cliente', 'segreta123')!;
expect(getSessionUser(db, token)).toMatchObject({ username: 'cliente', role: 'editor' });
});
it('createUser senza ruolo crea admin', () => {
createUser(db, 'adriano', 'segreta123');
const token = login(db, 'adriano', 'segreta123')!;
expect(getSessionUser(db, token)).toMatchObject({ role: 'admin' });
});
it('admin accede a tutto, editor solo a contenuti e logout', () => {
expect(canAccessAdminPath('admin', '/admin')).toBe(true);
expect(canAccessAdminPath('admin', '/api/admin/posts')).toBe(true);
expect(canAccessAdminPath('editor', '/admin/content')).toBe(true);
expect(canAccessAdminPath('editor', '/api/admin/content/home.hero.title')).toBe(true);
expect(canAccessAdminPath('editor', '/admin/logout')).toBe(true);
expect(canAccessAdminPath('editor', '/admin')).toBe(false);
expect(canAccessAdminPath('editor', '/admin/new')).toBe(false);
expect(canAccessAdminPath('editor', '/api/admin/posts')).toBe(false);
expect(canAccessAdminPath('editor', '/api/admin/upload')).toBe(false);
expect(canAccessAdminPath('editor', '/admin/contentious')).toBe(false);
expect(canAccessAdminPath('editor', '/admin/content-x')).toBe(false);
expect(canAccessAdminPath('editor', '/admin/content')).toBe(true);
expect(canAccessAdminPath('editor', '/admin/content/')).toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { validateStyles, stylesToClasses, STYLE_GROUPS } from '../src/lib/style-presets';
describe('style presets', () => {
it('accetta chiavi e valori in whitelist', () => {
const r = validateStyles({ size: 'l', color: 'accent', align: 'center' });
expect(r).toEqual({ ok: true, styles: { size: 'l', color: 'accent', align: 'center' } });
});
it('rifiuta gruppo sconosciuto e valore fuori lista', () => {
expect(validateStyles({ font: 'comic' }).ok).toBe(false);
expect(validateStyles({ size: 'xxl' }).ok).toBe(false);
expect(validateStyles('non-oggetto').ok).toBe(false);
});
it('mappa stili in classi cs-*', () => {
expect(stylesToClasses({ size: 's', weight: 'bold' })).toBe('cs-size-s cs-weight-bold');
expect(stylesToClasses({})).toBe('');
});
it('espone i gruppi attesi', () => {
expect(Object.keys(STYLE_GROUPS)).toEqual(['size', 'color', 'weight', 'style', 'align']);
});
});