56 Commits

Author SHA1 Message Date
Adriano 2ed7169b88 Aggiunge pagina /pacchetti (landing abbonamenti e ingressi)
Landing standalone auto-contenuta con voce di menu, sitemap e bottoni
acquisto verso il gestionale bsport (link centralizzato).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rUFjLYZ1TZ7DgxwTR5q9K
2026-07-11 12:21:05 +02:00
Adriano 7211d74d3a fix(sicurezza): safeNext blocca backslash (open redirect) + guardia ownership su /admin/edit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:34:16 +02:00
Adriano e2bbc9074f fix(create-user): ruoli aggiornati a superuser/user (gap dal piano) 2026-07-05 22:23:32 +02:00
Adriano be77285613 test(smoke): login pubblico, badge, protezione /admin/users 2026-07-05 22:21:30 +02:00
Adriano c36ad4cb2d feat(users): gestione utenti /admin/users con guardie self/ultimo-admin 2026-07-05 22:18:20 +02:00
Adriano 0f2b2ba014 feat(blog): firma autore sotto il titolo, fallback Staff InsanityLab 2026-07-05 22:15:30 +02:00
Adriano 2bf6b3d4de feat(admin): nav per 3 ruoli, blog filtrato per autore + colonna autore 2026-07-05 22:13:15 +02:00
Adriano 22d72be74d feat(inline): badge con guid + modal di modifica contenuti in pagina 2026-07-05 22:10:50 +02:00
Adriano 842371604b feat(showtags): endpoint cookie + toggle nel menu utente 2026-07-05 22:05:46 +02:00
Adriano 71cd1626e0 feat(header): identità utente SSR, link Accedi / menu utente 2026-07-05 22:03:07 +02:00
Adriano 6b85872515 feat(login): pagina pubblica /login con next sicuro 2026-07-05 21:59:50 +02:00
Adriano 5ce10da9bd feat(content): guid in resolve/componenti + GET /api/admin/content/[tag] 2026-07-05 21:56:53 +02:00
Adriano c3267c9be2 feat(posts-api): assegna autore in POST, ownership su PUT/DELETE 2026-07-05 21:51:34 +02:00
Adriano 6d1e2607ee feat(posts): author_id nel repository + listByAuthor + helper utenti 2026-07-05 21:49:16 +02:00
Adriano db92fc9c02 feat(middleware): autorizzazione a matrice + redirect per ruolo 2026-07-05 21:47:24 +02:00
Adriano e87a464090 feat(auth): matrice permessi 3 ruoli + landingFor 2026-07-05 21:44:30 +02:00
Adriano 5cea895d51 feat(db): migrazioni ruoli editor->superuser, guid contenuti, posts.author_id 2026-07-05 21:40:28 +02:00
Adriano d4cf359805 docs: rifinisci test migrazione nel piano 2026-07-05 21:38:47 +02:00
Adriano d0fa75a3d3 docs: piano implementazione ruoli/login/GUID/modifica inline 2026-07-05 21:37:53 +02:00
Adriano 6c225281e0 docs: spec ruoli/login/GUID/modifica inline 2026-07-05 21:14:30 +02:00
Adriano 8ceea9b16f feat: rinomina sezione Programmi in Servizi (route, tag, migrazione DB)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:23:48 +02:00
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
Adriano 98c42aae54 Allinea PageHero, lista programmi e dettaglio programma al design Figma
- PageHero: banner scuro min-height 380px, titolo bianco uppercase ~44px,
  variante con foto di sfondo e overlay scuro
- /programs: card con foto (aspect 4/3, hover zoom), ombra, reveal stagger
- Dettaglio programma: hero con foto, immagine grande nel corpo, categorie
  corrette da Figma, sidebar con icone programmi, pricing con card colorate
  tan/taupe/dark e prezzo grande
- Nuove foto card program-*.jpg e modulo condiviso program-icons.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:10:36 +02:00
Adriano 7d2f0b7048 fix: colonne info con aspect ratio figma 413/308 2026-07-02 15:52:10 +02:00
Adriano ecdf12ee0e fix: hero a piena altezza con testo sovrapposto come da figma 2026-07-02 14:40:15 +02:00
Adriano 2d5ff1816d feat: hero completo uomo+donna, donna estratta dal fig via mcp figma 2026-07-02 14:16:31 +02:00
Adriano 6cc1cf5f44 fix: rimappa tutte le foto sugli slot corretti del design figma
Le immagini erano asset reali del .fig ma piazzate in slot sbagliati.
Rimappate home/training/about dal confronto con i PDF Figma; aggiunti
i 9 ritratti team about (chiari in home, scuri in about via imageAbout),
immagini mancanti per metodo/vision/convenzioni/studios/trial e hero
contatti. Fix imgOf (match esatto) e getImage per i background dedupati.
2026-07-02 13:56:55 +02:00
Adriano 0d9b645636 feat: animazioni stile template prowess (reveal, parallax, marquee, hero, hover) 2026-07-02 13:08:09 +02:00
Adriano e54daa105f docs: spec animazioni stile template prowess 2026-07-02 12:58:34 +02:00
133 changed files with 7294 additions and 531 deletions
+10 -2
View File
@@ -15,7 +15,7 @@ Il progetto realizza un sito web per InsanityLab, combinando una vetrina azienda
- **Email**: Nodemailer
- **Markup Editor**: TipTap (rich text)
- **Font System**: @fontsource (Montserrat, Open Sans)
- **Sitemap**: @astrojs/sitemap
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina, programmi e articoli pubblicati)
- **Testing**: Vitest
- **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 start` | Avvia il server production (richiede `npm run build` preliminare) |
| `npm run test` | Esegui i test con Vitest |
| `npm run create-user` | Crea un nuovo utente (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
+5 -2
View File
@@ -1,6 +1,5 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://insanitylab.tielogic.xyz',
@@ -11,5 +10,9 @@ export default defineConfig({
allowedDomains: [{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' }],
},
adapter: node({ mode: 'standalone' }),
integrations: [sitemap({ filter: (page) => !page.includes('/admin') })],
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
redirects: {
'/programs': { status: 301, destination: '/services' },
'/programs/[slug]': { status: 301, destination: '/services/[slug]' },
},
});
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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
# Animazioni stile template Prowess — Design
Data: 2026-07-02 · Stato: approvato dall'operatore
## Obiettivo
Il sito InsanityLab, oggi quasi del tutto statico, deve riprendere il linguaggio di animazione del template WordPress "Prowess" (Qode Interactive) da cui deriva il design Figma. L'operatore ha scelto il pacchetto completo: reveal allo scroll, parallax degli sfondi, animazione d'ingresso della hero, effetti hover su card e team, e citazione con testo a scorrimento continuo.
## Evidenze raccolte dal template
Dalla demo ufficiale `prowess.qodeinteractive.com` (cassetta.me è in manutenzione):
- gli elementi appaiono allo scroll con un offset di attivazione di 100 px dal viewport (`qodefElementAppearAmount: -100`);
- due sezioni usano parallax di sfondo (`data-parallax-bg-speed="1"`);
- la hero è uno Slider Revolution con testi che entrano in sequenza;
- card e foto hanno hover con zoom immagine e transizioni morbide.
## Decisioni
1. **Implementazione vanilla**: CSS + IntersectionObserver, nessuna libreria. Il sito non ha dipendenze frontend e resta così; costo totale ~2 KB.
2. **Citazione**: si mantiene il banner su foto scura del Figma, ma il testo della citazione scorre in orizzontale a loop infinito come il marquee del template (scelta esplicita dell'operatore). La firma "Insanitylab" resta statica.
3. **Accessibilità**: con `prefers-reduced-motion: reduce` ogni animazione è disattivata (reveal, marquee, parallax, hero). Senza JavaScript il sito resta interamente visibile: lo stato nascosto si applica solo sotto `html.js`.
## Architettura
- **`src/styles/global.css`** — utilities: `[data-reveal]` (stato iniziale `opacity: 0` + `translateY(40px)`, transizione `.9s ease`; varianti `left`, `right`, `fade`; ritardo per stagger via variabile `--reveal-delay` impostata inline), keyframes marquee, blocco `prefers-reduced-motion`.
- **`src/layouts/Base.astro`** — uno script inline in `<head>` aggiunge la classe `js` a `<html>` prima del paint (evita flash); uno script bundled attiva l'IntersectionObserver (`rootMargin: 0 0 -100px`, `unobserve` dopo il primo ingresso) e il parallax (`transform: translateY` calcolato su scroll con `requestAnimationFrame`, velocità ~0.3, attivo solo da 992 px in su e senza reduced-motion).
- **Componenti** — ricevono attributi `data-reveal` e stagger inline; nessuna modifica strutturale se non il wrapper overflow per gli zoom hover e la ristrutturazione del banner citazione (sfondo su div dedicato trasformabile, testo in marquee con copia `aria-hidden`).
## Dettaglio per componente
| Componente | Effetto |
|---|---|
| HeroSlider | Ingresso al load: righe del titolo slide-up in sequenza (stagger 150 ms), bottone dopo, immagine da destra con fade |
| InfoColumns | Reveal fade con stagger 0/150/300 ms |
| FeatureBlocks | Immagine entra dal proprio lato, testo dal lato opposto |
| QuoteBanner | Sfondo parallax + testo citazione in marquee loop (pausa su hover), firma statica |
| TrainingCards | Reveal con stagger; hover zoom foto (wrapper overflow hidden) |
| ProgramsGrid | Heading reveal; item con stagger; hover: traslazione -4 px e colore |
| Agenda | Reveal heading e tabella |
| TeamSection | Head reveal; card con stagger; hover zoom foto |
| MethodSteps | Heading reveal; step con stagger |
| PartnersStrip | Reveal fade dell'intera striscia |
| PageHero (pagine interne) | Reveal del titolo all'ingresso pagina |
| Header | Transizione morbida dell'ombra già presente |
| Bottoni `.btn` | Freccia trasla a destra su hover |
## Errori e casi limite
- JS assente: nessun elemento nascosto (gate su `html.js`).
- Reduced motion: contenuto immediatamente visibile, marquee fermo su testo statico multilinea.
- Il marquee usa due copie identiche del segmento (`translateX(-50%)`) per loop senza scatti; la seconda copia è `aria-hidden`.
- Solo `opacity` e `transform`: nessun layout shift, nessun impatto CLS.
## Verifica
Build senza errori, vitest esistenti verdi, controllo visivo locale su homepage e pagine interne, poi deploy su VPS e smoke test produzione.
## Fuori scope
Slider hero multi-slide automatico (oggi esiste una sola slide), animazioni admin, contatori animati e BMI calculator (assenti dal design Figma).
@@ -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.
@@ -0,0 +1,210 @@
# Utenti, ruoli, login pubblico, GUID contenuti e modifica inline — Design
Data: 2026-07-05
Progetto: sito InsanityLab (Astro 7 SSR, SQLite/better-sqlite3, live su https://insanitylab.tielogic.xyz)
## Obiettivo
Portare il sito da due ruoli (`admin` | `editor`) a un sistema a tre ruoli con login pubblico,
identità utente visibile nell'header, blog con ownership per autore, un identificatore GUID stabile
per ogni contenuto taggato, e la modifica dei contenuti direttamente in pagina tramite un overlay
attivabile con toggle. Nessuna dipendenza frontend nuova: tutto vanilla, in continuità con l'esistente.
## Ruoli e matrice permessi
Tre ruoli su `users.role`: `admin`, `superuser`, `user`. Il vecchio ruolo `editor` viene migrato a
`superuser`.
| Capacità | user | superuser | admin |
|---|:---:|:---:|:---:|
| Login + nome nell'header + logout | ✓ | ✓ | ✓ |
| Blog: CRUD **solo dei propri** articoli | ✓ | ✓ | ✓ |
| Blog: gestione articoli **di tutti gli autori** | | ✓ | ✓ |
| Pannello `/admin/content` | | ✓ | ✓ |
| Toggle "Mostra tag" + modifica inline in pagina | | ✓ | ✓ |
| Gestione utenti `/admin/users` (crea, reset psw, ruolo, elimina) | | | ✓ |
La regola: ogni ruolo può fare tutto ciò che può il ruolo inferiore, più le proprie capacità.
## Modello dati (migrazioni idempotenti in `src/lib/db.ts`, prima di `syncSeed`)
Tutte additive, nello stile delle migrazioni esistenti (PRAGMA table_info + ALTER, transazione).
1. **Ruolo `editor` → `superuser`**
`UPDATE users SET role = 'superuser' WHERE role = 'editor'`.
Idempotente: al secondo giro nessuna riga `editor` resta.
2. **GUID contenuti** — colonna `content_blocks.guid TEXT`.
- `ALTER TABLE content_blocks ADD COLUMN guid TEXT` se assente (PRAGMA table_info).
- Backfill: per ogni riga con `guid IS NULL OR guid = ''`, assegna `crypto.randomUUID()`.
- Indice univoco: `CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`
(creato dopo il backfill, così non fallisce su righe pre-esistenti a guid nullo).
- `syncSeed` (in `db.ts` e in `content.ts`): l'INSERT delle righe seed nuove valorizza il guid con
`crypto.randomUUID()` alla creazione. Le righe già presenti non vengono toccate (INSERT OR IGNORE),
quindi il loro guid è quello del backfill.
- Il **tag semantico resta la chiave primaria** e resta l'identificatore usato nel codice (`t('...')`,
`<T tag="...">`) e nel pannello. Il GUID si affianca: è l'ID stabile esposto verso l'esterno
(attributo `data-guid` nel DOM, badge, eventuale API/link). Nessun tag cambia nome.
3. **Autore articoli** — colonna `posts.author_id INTEGER` (nullable).
- `ALTER TABLE posts ADD COLUMN author_id INTEGER` se assente.
- Nessun vincolo FK stretto a livello schema (coerente con lo stile del repo, dove le FK esplicite
sono solo su `sessions`); l'integrità è gestita nel codice.
- Post storici: `author_id` resta nullo → firma di fallback "Staff InsanityLab" nel blog pubblico.
## Auth e sessioni (`src/lib/auth.ts`)
- `Role` diventa `'admin' | 'superuser' | 'user'`. `createUser` accetta i tre ruoli.
- `SESSION_COOKIE`, `login`, `getSessionUser`, `logout` restano invariati.
- Nuova funzione di autorizzazione che sostituisce `EDITOR_ALLOWED`/`canAccessAdminPath` con una
**matrice esplicita per prefisso di rotta**:
```
/admin/users, /api/admin/users* → admin
/admin/content, /api/admin/content* → superuser, admin
/admin (blog: index/new/edit/posts),
/api/admin/posts*, /api/admin/upload → user, superuser, admin
/admin/logout, /admin/login → chiunque (login pubblico)
```
Firma proposta: `canAccessAdminPath(role, pathname): boolean`. La landing per redirect dipende dal
ruolo (funzione `landingFor(role)`): `user → /admin` (lista blog), `superuser → /admin/content`,
`admin → /admin`.
- L'hashing password è già in `hashPassword`/`verifyPassword`; `scripts/create-user.mjs` continua a
funzionare. Nessuna duplicazione: le API utenti richiamano le funzioni di `auth.ts`.
## Middleware (`src/middleware.ts`)
Struttura invariata (protegge `/admin` e `/api/admin`, esclude `/admin/login`). Cambia solo il ramo
autorizzazione: usa la nuova matrice. Utente loggato ma senza permesso su una rotta pagina →
`redirect(landingFor(role))`; su una rotta API → `403` JSON. `context.locals.user` continua a esporre
`{ id, username, role }`.
## Login pubblico e header
### Pagina `/login` (`src/pages/login.astro`)
- Layout `Base` (stile sito pubblico), **non** `Admin`.
- Form username+password. Riusa la logica di `/admin/login`: stesso `rateLimit`, stesso `login()`,
stesso set del cookie `session` (httpOnly, sameSite lax, secure in prod, maxAge 7 giorni).
- Query `?next=<path>`: dopo login valido redirige a `next` se è un path interno sicuro (inizia con `/`,
non `//`, non `/api/`); altrimenti alla home `/`.
- Se già loggato: redirige a `next` sicuro o a `/`.
- `/admin/login` resta come alias funzionante (nessuna rimozione).
### Header (`src/components/Header.astro`, già incluso da `Base.astro`)
Reso SSR con lettura sessione dal cookie:
- **Anonimo**: link "Accedi" → `/login?next=<pagina-corrente>`.
- **Loggato**: nome utente + menu a tendina:
- "Area riservata" → `landingFor(role)`.
- Toggle "Mostra tag" (solo superuser/admin) — vedi sezione successiva.
- "Esci" → `/admin/logout`.
- Il menu è vanilla (dettaglio/summary o piccolo script già in stile repo), senza librerie.
## Toggle "Mostra tag" e modifica inline
### Attivazione
- Cookie `showtags=1` (non httpOnly, così lo script client può leggerlo; path `/`), impostato/rimosso
dal toggle nel menu header. Solo superuser/admin possono attivarlo (l'endpoint che lo setta verifica
il ruolo).
- `Base.astro` calcola `showTags = (cookie showtags === '1') && sessione con ruolo superuser|admin` e
passa il flag al layout. La persistenza tra pagine è data dal cookie (niente più `?annotate=1` da
trascinare nell'URL).
- Compat: `?annotate=1` continua a funzionare attivando il cookie/flag per la richiesta corrente.
### Badge
- Quando `showTags` è attivo, lo script badge esistente in `Base.astro` (overlay assoluto sugli
elementi con `data-tag`) viene esteso per: mostrare il **nome tag**, esporre il **`data-guid`**, e
aggiungere un pulsante matita ✎ cliccabile.
- Nessun badge per i visitatori anonimi o per il ruolo `user` (lo script e il markup non vengono
nemmeno emessi).
### Modal di modifica inline
Click sulla matita → apre un modal vanilla sovrapposto alla pagina corrente (nessun redirect al
pannello):
- **Nuovo endpoint** `GET /api/admin/content/[tag]` → `{ tag, guid, type, value, styleOptions }`
(`styleOptions` da `seedByTag`/style-presets, come già fa `GET /api/admin/content`).
- Il form si costruisce in base a `type`:
- `text` / `html` → textarea + eventuali select di stile dagli `styleOptions`; salva con
**`PUT /api/admin/content/[tag]`** (esistente, ritorna `{ ok, value }` post-sanitizzazione).
- `image` → input file → **`POST /api/admin/content/[tag]/image`** (esistente) e rimozione via
**`DELETE`** (esistente).
- Al salvataggio riuscito il modal aggiorna il contenuto in-place nel DOM (dal `value` ritornato) e si
chiude, senza reload. Errori mostrati dentro il modal.
- Il codice del modal (markup + script) è caricato **solo** quando `showTags` è attivo: zero peso per
chi non è superuser/admin con toggle attivo.
### Riuso vs nuovo
Le API di scrittura contenuti (PUT, POST/DELETE image) restano **invariate** — sono le stesse usate dal
pannello `/admin/content`. L'unica aggiunta backend è il `GET /api/admin/content/[tag]` singolo. Il
modal è l'unico componente frontend nuovo.
## Gestione utenti (`/admin/users`, solo admin)
### Pagina `src/pages/admin/users.astro` (layout `Admin`)
Lista utenti: username, ruolo, ultimo accesso (se ricavabile dalle sessioni). Form/azioni per:
crea, reset password, cambia ruolo, elimina.
### API `src/pages/api/admin/users/*` (admin-only via middleware)
- **`POST /api/admin/users`** → crea utente: `username` + `password` + `role`
(`user`|`superuser`|`admin`). Rifiuta username duplicato (409/400 con messaggio). Usa `createUser`.
- **`POST /api/admin/users/[id]/reset-password`** → genera password casuale, la ritorna **una sola
volta** in chiaro nella risposta JSON (l'admin la copia e la consegna). Aggiorna `password_hash`.
Non viene mai ri-mostrata né loggata.
- **`PUT /api/admin/users/[id]/role`** → cambia ruolo.
- **`DELETE /api/admin/users/[id]`** → elimina l'utente.
### Guardie (invarianti di sicurezza)
- Non puoi **eliminare te stesso** (confronto con `locals.user.id`).
- Non puoi eliminare **l'ultimo admin** rimasto, né declassarne il ruolo se è l'ultimo admin
(query di conteggio `role='admin'` nella stessa transazione).
- Eliminazione utente → cascade sulle sue sessioni (già `ON DELETE CASCADE` su `sessions.user_id`).
Gli articoli con `author_id` di un utente eliminato: `author_id` resta valorizzato ma "orfano" →
firma di fallback "Staff InsanityLab" nel pubblico (nessuna cancellazione a cascata dei post).
## Blog ownership
### API `/api/admin/posts*` e `/api/admin/posts/[id]`
- **`user`**: la lista in `/admin` è filtrata sui propri `author_id`; `POST` forza
`author_id = locals.user.id`; `PUT`/`DELETE` su un post con `author_id` diverso → **403**.
- **`superuser` / `admin`**: vedono e gestiscono tutti i post; il `POST` valorizza comunque
`author_id = locals.user.id` (l'autore è chi crea).
- Implementazione: `createPost` accetta `author_id`; `updatePost`/`deletePost` restano invariati ma le
route caricano il post e verificano la ownership prima di procedere (già caricano via `getPostById`
su PUT; aggiungere il caricamento su DELETE che oggi non lo fa).
### Pannello blog (`/admin`, `/admin/new`, `/admin/edit/[id]`)
- `user` vede solo i propri articoli; superuser/admin vedono tutti con una colonna "Autore".
- Editing di un post altrui bloccato lato UI per `user` (e comunque 403 lato API).
### Blog pubblico
- Firma autore sotto il titolo dell'articolo: nome dell'utente `author_id`; fallback
"Staff InsanityLab" quando `author_id` è nullo o orfano.
## Gestione errori
- API: JSON `{ error }` con status coerente (400 input non valido, 401 non autenticato — dal
middleware, 403 permesso/ownership, 404 risorsa inesistente, 409/400 duplicati).
- `next` non sicuro nel login → ignorato, redirect a `/` (niente open redirect).
- Cookie `showtags` presente ma ruolo insufficiente → nessun badge (il flag è ricalcolato server-side
con il ruolo reale, il cookie da solo non basta).
- Reset password / creazione: password mostrata una sola volta, mai persistita in chiaro.
## Testing
- **Unit** (`tests/roles.test.ts` esteso): matrice `canAccessAdminPath` per i tre ruoli su tutti i
prefissi; `landingFor`.
- **Unit ownership**: `user` non può PUT/DELETE post altrui; `POST` imposta `author_id`; guardie
gestione utenti (no self-delete, no last-admin delete/demote).
- **Unit migrazioni**: su DB in-memory con righe `editor` e `content_blocks` senza guid → dopo
`createDb`: ruoli migrati, guid non nulli e univoci, colonna `posts.author_id` presente.
- **Smoke** (`scripts/smoke.sh` esteso): `/login` 200; header mostra "Accedi" da anonimo; rotte admin →
redirect/403 da `user`; badge con `data-guid` presenti da superuser+ con toggle; modal/badge assenti
da anonimo.
## Fuori scope (YAGNI)
- Nessun framework/reattività frontend (no Alpine/petite-vue).
- Nessuna tabella permessi configurabile: i tre ruoli sono fissi in codice.
- Nessuna registrazione self-service: gli utenti li crea l'admin.
- Nessun cambio del nome tag semantico: il GUID si affianca, non sostituisce.
- Nessuna gestione avatar/profilo utente oltre username + ruolo.
+2 -52
View File
@@ -9,7 +9,6 @@
"version": "0.1.0",
"dependencies": {
"@astrojs/node": "^11.0.1",
"@astrojs/sitemap": "^3.7.3",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@tiptap/core": "^3.27.1",
@@ -360,17 +359,6 @@
"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": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz",
@@ -2512,6 +2500,7 @@
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -2537,15 +2526,6 @@
"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": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -2881,12 +2861,6 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -5622,25 +5596,6 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"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": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
@@ -5695,12 +5650,6 @@
"dev": true,
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -5972,6 +5921,7 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unified": {
-1
View File
@@ -13,7 +13,6 @@
},
"dependencies": {
"@astrojs/node": "^11.0.1",
"@astrojs/sitemap": "^3.7.3",
"@fontsource/montserrat": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@tiptap/core": "^3.27.1",
+1 -1
View File
@@ -1,4 +1,4 @@
User-agent: *
Disallow: /admin
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 { dirname } from 'node:path';
const [, , username, password] = process.argv;
const [, , username, password, role = 'admin'] = process.argv;
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', 'superuser', 'user'].includes(role)) {
console.error('Ruolo non valido. Ammessi: admin, superuser, user');
process.exit(1);
}
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
);`);
db.prepare(
`INSERT INTO users (username, password_hash) VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash`
).run(username, bcrypt.hashSync(password, 12));
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash, role = excluded.role`
).run(username, bcrypt.hashSync(password, 12), role);
console.log(`Utente "${username}" creato/aggiornato in ${path}`);
+35 -1
View File
@@ -6,7 +6,7 @@ PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$!
trap 'kill $SRV' EXIT
sleep 2
fail=0
for path in / /about /training /programs /programs/performance-class /programs/rehab /blog /contact /admin/login /robots.txt; do
for path in / /about /training /services /services/performance-class /services/rehab /blog /contact /admin/login /robots.txt; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path")
echo "$code $path"
[ "$code" = "200" ] || fail=1
@@ -14,4 +14,38 @@ done
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin)
echo "$code /admin (atteso 302)"
[ "$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
# /login pubblico raggiungibile
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/login)
echo "$code /login"
[ "$code" = "200" ] || fail=1
# Header mostra "Accedi" da anonimo
curl -s http://localhost:4399/ | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; fail=1; }
# Nessun badge tag-badge da anonimo sulla home semplice
badges2=$(curl -s http://localhost:4399/ | grep -c 'tag-badge' || true)
echo "$badges2 tag-badge da anonimo in home semplice (atteso 0)"
[ "$badges2" = "0" ] || fail=1
# Rotta gestione utenti protetta (redirect al login da anonimo)
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin/users)
echo "$code /admin/users (atteso 302)"
[ "$code" = "302" ] || fail=1
echo "smoke ruoli/login/inline OK"
exit $fail
Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+8 -7
View File
@@ -1,20 +1,21 @@
---
import { agenda, disciplines, days, slots } from '../data/agenda';
import T from './content/T.astro';
---
<section class="section section--dark agenda">
<div class="container">
<div class="section-heading"><h2>Agenda</h2></div>
<div class="agenda__tabs" role="tablist">
<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">
{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 class="agenda__grid">
<div class="agenda__grid" data-reveal style="--reveal-delay:.2s">
<div class="agenda__corner"></div>
{days.map((d) => <div class="agenda__day">{d}</div>)}
{slots.map((slot) => (
{days.map((_, i) => <T tag={`agenda.day.${i + 1}`} as="div" class="agenda__day" />)}
{slots.map((slot, si) => (
<>
<div class="agenda__slot">{slot}</div>
<T tag={`agenda.slot.${si + 1}`} as="div" class="agenda__slot" />
{days.map((_, dayIdx) => {
const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
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' }
const { variant = 'full' } = Astro.props;
---
<form class:list={['cform', `cform--${variant}`]} method="post" action="/api/contact" novalidate>
<input class="field" name="firstName" placeholder="Nome" required maxlength="100" />
{variant === 'full' && <input class="field" name="lastName" placeholder="Cognome" maxlength="100" />}
{variant === 'full' && <input class="field" name="phone" placeholder="Telefono" maxlength="40" />}
<input class="field" type="email" name="email" placeholder="E-mail" required maxlength="200" />
<textarea class="field" name="message" placeholder="Messaggio" required maxlength="5000"></textarea>
<form
class:list={['cform', `cform--${variant}`]}
method="post"
action="/api/contact"
novalidate
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" />
<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>
</form>
@@ -28,11 +38,11 @@ const { variant = 'full' } = Astro.props;
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
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'}`;
if (res.ok) form.reset();
} catch {
msg.textContent = 'Errore di rete, riprova più tardi.';
msg.textContent = form.dataset.msgErrorNetwork!;
msg.className = 'form-msg form-msg--err';
}
msg.hidden = false;
+16 -13
View File
@@ -1,34 +1,37 @@
---
import { site } from '../data/site';
import ContactForm from './ContactForm.astro';
import { t, contentImageUrl } from '../lib/content';
import T from './content/T.astro';
const year = new Date().getFullYear();
---
<footer class="ftr">
<div class="container ftr__grid">
<div>
<img src="/img/logo-light.png" alt="InsanityLab" width="140" loading="lazy" />
<p class="ftr__tag">PERFORMANCE.&nbsp; BALANCE.&nbsp; LONGEVITY.</p>
<p>Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.</p>
<p>{site.phone}<br />{site.address}, {site.cityLine}</p>
{site.hours.map((h) => <p class="ftr__hours">{h}</p>)}
<img src={contentImageUrl('footer.logo.image', '/img/logo-light.png')} alt="InsanityLab" width="140" loading="lazy" />
<T tag="footer.tagline" as="p" class="ftr__tag" />
<T tag="footer.about" as="p" />
<p>{t('global.contact.phone')}<br />{t('global.contact.address')}, {t('global.contact.city-line')}</p>
<p class="ftr__hours">{t('global.hours.1')}</p>
<p class="ftr__hours">{t('global.hours.2')}</p>
</div>
<nav aria-label="training">
<h4>Training</h4>
{site.footerTraining.map((l) => <a href={l.href}>{l.label}</a>)}
<T tag="footer.col.training-title" as="h4" />
{site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)}
</nav>
<nav aria-label="programmi">
<h4>Programmi</h4>
{site.footerPrograms.map((l) => <a href={l.href}>{l.label}</a>)}
<nav aria-label="servizi">
<T tag="footer.col.services-title" as="h4" />
{site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
</nav>
<div>
<h4>Contact</h4>
<T tag="footer.col.contact-title" as="h4" />
<ContactForm variant="footer" />
</div>
</div>
<div class="ftr__bar">
<div class="container ftr__bar-in">
<p>© {year} <strong>Insanitylab</strong>, All Rights Reserved</p>
<p>Seguici {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
<p>© {year} <T tag="footer.copyright" as="span" /></p>
<p>{t('footer.social-label')} {site.socials.map((s) => <a href={s.url} rel="noopener" target="_blank">{s.label}</a>)}</p>
</div>
</div>
</footer>
+52 -4
View File
@@ -1,15 +1,40 @@
---
import { site } from '../data/site';
import { t, contentImageUrl } from '../lib/content';
interface Props {
user: { username: string; role: string } | null;
showTags: boolean;
canTags: boolean;
}
const { user, canTags, showTags } = Astro.props;
const path = Astro.url.pathname;
const loginHref = `/login?next=${encodeURIComponent(path)}`;
const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
---
<header class="hdr" id="site-header">
<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">
{site.navigation.map((item) => (
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{item.label}</a>
{site.navigation.map((item, i) => (
<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>
<div class="hdr__user">
{user ? (
<details class="umenu">
<summary class="umenu__btn">{user.username} ▾</summary>
<div class="umenu__panel">
<a href={areaHref}>Area riservata</a>
{canTags && (
<button type="button" class="umenu__tags" id="toggle-tags" data-on={showTags ? '1' : '0'}>{showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
)}
<a href="/admin/logout">Esci</a>
</div>
</details>
) : (
<a class="hdr__login" href={loginHref}>Accedi</a>
)}
</div>
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
@@ -21,7 +46,7 @@ const path = Astro.url.pathname;
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
.hdr__nav { display: flex; gap: 34px; }
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
@@ -31,6 +56,15 @@ const path = Astro.url.pathname;
.hdr__link { padding: 12px 0; }
.hdr__burger { display: block; }
}
.hdr__user { display: flex; align-items: center; }
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
.hdr__login:hover { color: var(--c-accent-dark); }
.umenu { position: relative; }
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
.umenu__btn::-webkit-details-marker { display: none; }
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
</style>
<script>
@@ -42,4 +76,18 @@ const path = Astro.url.pathname;
const open = nav.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', String(open));
});
const tagsBtn = document.getElementById('toggle-tags');
if (tagsBtn) {
const on = tagsBtn.dataset.on === '1';
tagsBtn.addEventListener('click', async () => {
const res = await fetch('/api/admin/showtags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: !on }),
});
if (res.ok) location.reload();
else alert('Operazione non riuscita.');
});
}
</script>
+16 -6
View File
@@ -1,15 +1,25 @@
---
interface Props { title: string; eyebrow?: string }
const { title, eyebrow } = Astro.props;
import type { ImageMetadata } from 'astro';
import { getImage } from 'astro:assets';
interface Props { title: string; eyebrow?: string; bgImage?: ImageMetadata | string }
const { title, eyebrow, bgImage } = Astro.props;
const bgUrl = typeof bgImage === 'string' ? bgImage : bgImage ? (await getImage({ src: bgImage, width: 1920 })).src : null;
---
<section class="phero">
<div class="container">
<section class:list={['phero', { 'phero--photo': bgUrl }]} style={bgUrl ? `background-image:url(${bgUrl})` : undefined}>
<div class="container" data-reveal>
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
<h1>{title}</h1>
</div>
</section>
<style>
.phero { background: var(--c-bg-alt); padding-block: 70px; }
.phero h1 { text-transform: lowercase; font-weight: 500; margin: 0; }
/* Banner scuro come nel Figma: ~460px, titolo bianco maiuscolo ~44px */
.phero { background: var(--c-dark); min-height: 380px; display: flex; align-items: center; }
.phero .container { width: 100%; }
.phero h1 { text-transform: uppercase; font-weight: 500; color: #fff; font-size: clamp(2.2rem, 4vw, 2.75rem); letter-spacing: .04em; margin: 0; }
.phero--photo { position: relative; background-size: cover; background-position: center; }
.phero--photo::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.6); }
.phero--photo .container { position: relative; }
@media (max-width: 767px) { .phero { min-height: 240px; } }
</style>
+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à.`;
const stages = [
{ label: 'INSANITYLAB 2025', text: 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.' },
{ 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.' },
];
import T from '../content/T.astro';
import { t } from '../../lib/content';
const stages = [1, 2, 3];
---
<section class="section">
<div class="container">
<div class="section-heading">
<h2>Storylab</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.title" as="h2" />
<T tag="about.storylab.body" as="p" />
</div>
<div class="tl">
<h3 class="tl__label">INSANITYLAB 2021-2024</h3>
<p>{openText}</p>
{stages.map((s) => (
<T tag="about.timeline.label" as="h3" class="tl__label" />
<T tag="about.timeline.open-text" as="p" />
{stages.map((n) => (
<details class="tl__stage">
<summary>+ {s.label}</summary>
<p>{s.text}</p>
<summary>+ {t(`about.timeline.stage${n}.label`)}</summary>
<T tag={`about.timeline.stage${n}.text`} as="p" />
</details>
))}
</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} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
: <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
+16
View File
@@ -0,0 +1,16 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import { resolveContent } 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 c = resolveContent(tag);
const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null;
const guid = c.guid || undefined;
---
{override
? <img src={override} alt={alt} class={cls} data-tag={tag} data-guid={guid} loading={rest.loading} />
: <Image src={src} alt={alt} class={cls} data-tag={tag} data-guid={guid} {...rest} />}
+11 -12
View File
@@ -1,26 +1,25 @@
---
import { Image } from 'astro:assets';
import featPerformance from '../../assets/img/feature-performance.jpg';
import featBalance from '../../assets/img/feature-balance.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 = [
{ title: '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.' },
{ title: 'BALANCE', image: featBalance, reverse: true,
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.' },
{ key: 'performance', image: featPerformance, reverse: false },
{ key: 'balance', image: featBalance, reverse: true },
{ key: 'longevity', image: featLongevity, reverse: false },
];
---
<section class="section">
<div class="container">
{blocks.map((b) => (
<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" />
<div class="feat__text">
<h2>{b.title}</h2>
<p>{b.text}</p>
<a class="btn" href="/programs">Scopri</a>
<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'}>
<T tag={`home.feature.${b.key}.title`} as="h2" />
<T tag={`home.feature.${b.key}.body`} as="p" />
<a class="btn" href="/services">{t(`home.feature.${b.key}.cta`)}</a>
</div>
</div>
))}
+31 -9
View File
@@ -1,8 +1,10 @@
---
import { Image } from 'astro:assets';
import hero1 from '../../assets/img/hero-1.jpg';
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 = [
{ 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">
@@ -10,10 +12,10 @@ const slides = [
<div class:list={['hero__slide', { 'is-active': i === 0 }]}>
<div class="container hero__in">
<div class="hero__text">
<h1>{s.title.map((line) => <span class="hero__line">{line}</span>)}</h1>
<a class="btn" href="/programs">Scopri</a>
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
<a class="btn" href="/services">{t('home.hero.cta')}</a>
</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>
))}
@@ -30,13 +32,33 @@ const slides = [
repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
.hero__slide { display: none; }
.hero__slide.is-active { display: block; }
.hero__in { display: grid; grid-template-columns: 1fr 1.2fr; align-items: center; min-height: calc(100vh - var(--header-h)); gap: 30px; }
/* Come nel Figma: figure a piena altezza ancorate in basso a destra,
testo sovrapposto sulla sinistra. */
.hero__in { position: relative; display: flex; align-items: center; min-height: calc(100vh - var(--header-h)); }
.hero__line { display: block; }
.hero__text { position: relative; z-index: 1; max-width: 46%; }
.hero__text h1 { font-weight: 500; }
.hero__img { object-fit: cover; height: 100%; max-height: calc(100vh - var(--header-h)); width: 100%; }
.hero__img { position: absolute; right: 0; bottom: 0; height: 100%; width: auto; max-width: 80%; object-fit: contain; object-position: right bottom; }
.hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
.hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
@media (max-width: 991px) { .hero__in { grid-template-columns: 1fr; padding-block: 40px; min-height: 0; } }
@media (max-width: 991px) {
.hero__in { flex-direction: column; align-items: flex-start; padding-block: 40px; min-height: 0; gap: 24px; }
.hero__text { max-width: none; }
.hero__img { position: static; max-width: 100%; height: auto; }
}
/* Ingresso al load stile Slider Revolution del template */
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } }
@keyframes hero-side { from { opacity: 0; transform: translateX(60px); } to { opacity: 1; transform: none; } }
:global(html.js) .hero__line { opacity: 0; animation: hero-up .8s ease forwards; }
:global(html.js) .hero__line:nth-child(1) { animation-delay: .15s; }
:global(html.js) .hero__line:nth-child(2) { animation-delay: .3s; }
:global(html.js) .hero__line:nth-child(3) { animation-delay: .45s; }
:global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; }
:global(html.js) .hero__img { opacity: 0; animation: hero-side 1s ease .2s forwards; }
@media (prefers-reduced-motion: reduce) {
:global(html.js) .hero__line, :global(html.js) .hero__text .btn, :global(html.js) .hero__img { opacity: 1; animation: none; }
}
</style>
<script>
+16 -10
View File
@@ -1,26 +1,32 @@
---
import { getImage } from 'astro:assets';
import infoPerformance from '../../assets/img/info-performance.jpg';
import infoBalance from '../../assets/img/info-balance.jpg';
import infoLongevity from '../../assets/img/info-longevity.jpg';
const cols = [
{ title: 'PERFORMANCE.', image: infoPerformance },
{ title: 'BALANCE.', image: infoBalance },
{ title: 'LONGEVITY.', image: infoLongevity },
];
import T from '../content/T.astro';
import { contentImageUrl } from '../../lib/content';
// 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).
const cols = await Promise.all([
{ titleTag: 'home.info.col1.title', imageTag: 'home.info.col1.image', image: infoPerformance },
{ titleTag: 'home.info.col2.title', imageTag: 'home.info.col2.image', image: infoBalance },
{ titleTag: 'home.info.col3.title', imageTag: 'home.info.col3.image', image: infoLongevity },
].map(async (c) => ({ ...c, bg: contentImageUrl(c.imageTag, (await getImage({ src: c.image, width: 1200 })).src) })));
---
<section class="info" aria-label="pilastri">
{cols.map((c) => (
<a class="info__col" href="/programs" style={`background-image:url(${c.image.src})`}>
<span class="info__title">{c.title}</span>
{cols.map((c, i) => (
<a class="info__col" href="/services" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}>
<T tag={c.titleTag} as="span" class="info__title" />
</a>
))}
</section>
<style>
.info { display: grid; grid-template-columns: repeat(3, 1fr); }
.info__col { position: relative; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
/* Proporzioni Figma: colonna 413x308 su frame 1209 — l'altezza scala con la larghezza */
.info__col { position: relative; aspect-ratio: 413 / 308; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
.info__col::before { content: ''; position: absolute; inset: 0; background: rgba(30,24,20,.35); transition: background .2s; }
.info__col:hover::before { background: rgba(30,24,20,.15); }
.info__title { position: relative; color: #fff; font-family: var(--font-heading); font-weight: 600; letter-spacing: .1em; font-size: 1.1rem; }
@media (max-width: 767px) { .info { grid-template-columns: 1fr; } }
@media (max-width: 767px) { .info { grid-template-columns: 1fr; } .info__col { aspect-ratio: auto; } }
</style>
+12 -11
View File
@@ -1,26 +1,27 @@
---
import { Image } from 'astro:assets';
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 imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
---
<section class="section section--alt">
<div class="container">
<div class="section-heading">
<h2>Il metodo<br />InsanityLab</h2>
<p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} />
<div class="section-heading" data-reveal>
<T tag="home.method.title" as="h2" />
<T tag="method.intro" as="p" />
</div>
<p class="method__note">{methodExtraNote}</p>
<T tag="method.extra-note" as="p" class="method__note" />
<div class="method__row">
{methodSteps.map((s) => (
<div class:list={['method__step', { 'method__step--extra': s.extra }]}>
{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="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>
</div>
<h3>{s.title}</h3>
<p>{s.text}</p>
<T tag={`method.step.${s.n}.title`} as="h3" />
<T tag={`method.step.${s.n}.text`} as="p" />
</div>
))}
</div>
+4 -3
View File
@@ -1,13 +1,14 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
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 imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
---
<section class="pstrip" aria-label="partner">
<div class="container pstrip__in">
{partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)}
<div class="container pstrip__in" data-reveal="fade">
{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>
</section>
-38
View File
@@ -1,38 +0,0 @@
---
import { programs } from '../../data/programs';
const icons: Record<string, string> = {
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
};
---
<section class="section">
<div class="container">
<div class="section-heading">
<h2>Programmi</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>
</div>
<div class="pgrid">
{programs.map((p) => (
<a class="pgrid__item" href={`/programs/${p.slug}`}>
<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>
<p>{p.subtitle}</p>
</a>
))}
</div>
</div>
</section>
<style>
.pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
.pgrid__item { text-decoration: none; }
.pgrid__item svg { margin-inline: auto; }
.pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
.pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
@media (max-width: 767px) { .pgrid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 480px) { .pgrid { grid-template-columns: 1fr; } }
</style>
+31 -9
View File
@@ -1,22 +1,44 @@
---
import quoteBg from '../../assets/img/quote-bg.jpg';
import { getImage } from 'astro:assets';
import quoteBgSrc from '../../assets/img/quote-bg.jpg';
import { t, contentImageUrl } from '../../lib/content';
const quoteBg = contentImageUrl('home.quote.image', (await getImage({ src: quoteBgSrc, width: 1920 })).src);
---
<section class="quote" style={`background-image:url(${quoteBg.src})`}>
<div class="container quote__in">
<section class="quote" data-parallax>
<div class="quote__bg" style={`background-image:url(${quoteBg})`} data-tag="home.quote.image"></div>
<div class="quote__in">
<p class="quote__mark">“</p>
<blockquote>
<p><strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.</p>
<cite>Insanitylab</cite>
<div class="quote__marquee">
<p class="quote__track">
<span class="quote__seg" data-tag="home.quote.text" set:html={t('home.quote.text')} />
<span class="quote__seg" aria-hidden="true" data-tag="home.quote.text" set:html={t('home.quote.text')} />
</p>
</div>
<cite data-tag="home.quote.cite">{t('home.quote.cite')}</cite>
</blockquote>
</div>
</section>
<style>
.quote { position: relative; background-size: cover; background-position: center; text-align: center; }
.quote::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
.quote__in { position: relative; padding-block: 110px; max-width: 820px; }
.quote { position: relative; overflow: hidden; text-align: center; }
/* Sfondo su div dedicato, esteso oltre i bordi per il margine di corsa del parallax */
.quote__bg { position: absolute; inset: -120px 0; background-size: cover; background-position: center; transform: translateY(var(--parallax-y, 0)); will-change: transform; }
.quote__bg::after { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
.quote__in { position: relative; padding-block: 110px; }
.quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; }
.quote blockquote { margin: 0; }
.quote blockquote p { color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
/* Marquee: testo a scorrimento infinito come il ticker del template */
.quote__marquee { overflow: hidden; white-space: nowrap; }
.quote__track { display: inline-flex; margin: 0; animation: quote-scroll 30s linear infinite; will-change: transform; }
.quote__marquee:hover .quote__track { animation-play-state: paused; }
.quote__seg { padding-right: 4em; color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
@keyframes quote-scroll { to { transform: translateX(-50%); } }
.quote cite { display: block; margin-top: 24px; color: var(--c-accent); font-style: normal; font-family: var(--font-heading); font-size: .75rem; letter-spacing: .3em; text-transform: uppercase; }
@media (prefers-reduced-motion: reduce) {
.quote__track { animation: none; white-space: normal; display: block; max-width: 820px; margin-inline: auto; padding-inline: 20px; }
.quote__seg { padding-right: 0; }
.quote__seg[aria-hidden] { display: none; }
.quote__bg { transform: none; }
}
</style>
+35
View File
@@ -0,0 +1,35 @@
---
import { services } from '../../data/services';
import { serviceIcons as icons } from '../../data/service-icons';
import T from '../content/T.astro';
---
<section class="section">
<div class="container">
<div class="section-heading" data-reveal>
<T tag="home.services.title" as="h2" />
<T tag="home.services.body" as="p" />
</div>
<div class="pgrid">
{services.map((p, i) => (
<a class="pgrid__item" href={`/services/${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>
<T tag={`service.${p.slug}.title`} as="h3" />
<T tag={`service.${p.slug}.subtitle`} as="p" />
</a>
))}
</div>
</div>
</section>
<style>
.pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
.pgrid__item { text-decoration: none; transition: transform .3s ease; }
.pgrid__item:hover { transform: translateY(-4px); }
.pgrid__item:hover h3 { color: var(--c-accent-dark); }
.pgrid__item h3 { transition: color .3s ease; }
.pgrid__item svg { margin-inline: auto; }
.pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
.pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
@media (max-width: 767px) { .pgrid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 480px) { .pgrid { grid-template-columns: 1fr; } }
</style>
+19 -13
View File
@@ -1,24 +1,29 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
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 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;
---
<section class="section">
<div class="container">
<div class="tteam__head">
<h2>Team<br />Trainer</h2>
<a class="btn" href="/about">Scopri team</a>
<div class="tteam__head" data-reveal>
<T tag="home.team.title" as="h2" />
<a class="btn" href="/about"><T tag="home.team.cta" as="span" /></a>
</div>
<div class="tteam__grid">
{members.map((m) => (
<figure class="tteam__card">
<Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
<figcaption>{m.shortName}</figcaption>
</figure>
))}
{members.map((m, i) => {
const slug = m.shortName.toLowerCase();
return (
<figure class="tteam__card" data-reveal style={`--reveal-delay:${i * 0.12}s`}>
<TImg tag={`team.${slug}.image`} src={imgOf(m.image!)!} alt={t(`team.${slug}.name`)} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
<T tag={`team.${slug}.short-name`} as="figcaption" />
</figure>
);
})}
</div>
</div>
</section>
@@ -27,8 +32,9 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
.tteam__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px; }
.tteam__head h2 { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
.tteam__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
.tteam__card { position: relative; margin: 0; }
.tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; }
.tteam__card { position: relative; margin: 0; overflow: hidden; }
.tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; transition: transform .5s ease; }
.tteam__card:hover img { transform: scale(1.06); }
.tteam__card figcaption { position: absolute; right: 10px; bottom: 14px; background: #fff; padding: 10px 6px; writing-mode: vertical-rl; font-family: var(--font-heading); font-size: .62rem; letter-spacing: .3em; text-transform: uppercase; color: var(--c-accent-dark); }
@media (max-width: 767px) { .tteam__grid { grid-template-columns: 1fr 1fr; } }
</style>
+17 -9
View File
@@ -1,7 +1,9 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
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 imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
const tones = ['taupe', 'tan', 'dark', 'charcoal'];
@@ -9,16 +11,20 @@ const tones = ['taupe', 'tan', 'dark', 'charcoal'];
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
---
<section class="tcards">
{trainings.map((t, i) => {
const img = imgOf(t.image);
{trainings.map((tr, i) => {
const img = imgOf(tr.image);
const card = (
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]}>
<h3>{t.title}</h3>
<p>{t.caption}</p>
<a class="tcards__link" href={t.cta.href}>Scopri →</a>
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
<T tag={`training.${tr.id}.title`} as="h3" />
<T tag={`training.${tr.id}.caption`} as="p" />
<a class="tcards__link" href={tr.cta.href}><T tag="home.training-cards.link-label" as="span" /> →</a>
</div>
);
const photo = img ? <Image src={img} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" /> : null;
const photo = img ? (
<div class="tcards__photo" data-reveal="fade" style={`--reveal-delay:${((i % 2) * 0.15 + 0.1).toFixed(2)}s`}>
<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>
) : null;
return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>;
})}
</section>
@@ -37,6 +43,8 @@ const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
.tcards__cell h3 { text-transform: uppercase; letter-spacing: .12em; }
.tcards__cell p { font-size: .88rem; }
.tcards__link { color: #fff; font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; margin-top: 18px; }
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; }
.tcards__photo { overflow: hidden; }
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; }
.tcards__photo:hover .tcards__img { transform: scale(1.06); }
@media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } }
</style>
+306
View File
@@ -0,0 +1,306 @@
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 { services } from './services';
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, []),
T('global.nav.6.label', site.navigation[5].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.services-title', 'Servizi'),
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.services.1.label', site.footerServices[0].label, []),
T('footer.services.2.label', site.footerServices[1].label, []),
T('footer.services.3.label', site.footerServices[2].label, []),
T('footer.services.4.label', site.footerServices[3].label, []),
T('footer.services.5.label', site.footerServices[4].label, []),
T('footer.services.6.label', site.footerServices[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: servizi ---
T('home.services.title', 'Servizi'),
T('home.services.body', 'Questi sono i servizi 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.'),
// --- Services: lista (/services) ---
T('services.meta.title', 'Servizi', []),
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.', []),
T('services.hero.title', 'Servizi', []),
T('services.intro.title', 'Servizi'),
T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
T('services.card.more-label', 'Per saperne di più →', []),
// --- Services: dettaglio (data-driven da services.ts) ---
...services.flatMap((p) => [
T(`service.${p.slug}.title`, p.title),
T(`service.${p.slug}.subtitle`, p.subtitle),
T(`service.${p.slug}.excerpt`, p.excerpt),
T(`service.${p.slug}.long-description`, p.longDescription),
...p.features.map((f, i) => T(`service.${p.slug}.feature.${i + 1}`, f)),
...p.faq.flatMap((item, i) => [
T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
]),
I(`service.${p.slug}.image`),
]),
// --- Services: dettaglio, template ---
T('service.side.categories-label', 'Categorie'),
T('service.side.category.1', 'Fitness', []),
T('service.side.category.2', 'Salute', []),
T('service.side.category.3', 'Stile di vita', []),
T('service.side.category.4', 'Nutrizione', []),
T('service.side.others-label', 'Altri servizi'),
H('service.pricing.empty-note', 'Contattaci per i dettagli di questo servizio. <a href="/contact">Scrivici →</a>'),
T('service.pricing.per', 'al mese, semestrale', []),
T('service.pricing.entries-singular', 'ingresso settimanale', []),
T('service.pricing.entries-plural', 'ingressi settimanali', []),
T('service.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]));
+9
View File
@@ -0,0 +1,9 @@
// Path SVG (stroke, viewBox 0 0 24 24) delle icone servizi, condivisi tra griglia home e sidebar
export const serviceIcons: Record<string, string> = {
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
};
@@ -1,4 +1,4 @@
export interface Program {
export interface Service {
slug: string;
title: string;
subtitle: string;
@@ -9,7 +9,7 @@ export interface Program {
hasPricing: boolean;
}
export const programs: Program[] = [
export const services: Service[] = [
{
slug: "personal-training",
title: "Personal Training",
@@ -99,6 +99,6 @@ export const programs: Program[] = [
},
];
export function getProgram(slug: string): Program | undefined {
return programs.find((p) => p.slug === slug);
export function getService(slug: string): Service | undefined {
return services.find((p) => p.slug === slug);
}
+9 -8
View File
@@ -13,9 +13,10 @@ export const site = {
navigation: [
{ label: "Insanitylab", href: "/about" },
{ label: "Training", href: "/training" },
{ label: "Programmi", href: "/programs" },
{ label: "Servizi", href: "/services" },
{ label: "Blog & Edugo", href: "/blog" },
{ label: "Contatti", href: "/contact" },
{ label: "Pacchetti", href: "/pacchetti" },
],
footerTraining: [
{ label: "One to one", href: "/training#one-to-one" },
@@ -23,12 +24,12 @@ export const site = {
{ label: "Performance class", href: "/training#performance-class" },
{ label: "Fit remote", href: "/training#fit-remote" },
],
footerPrograms: [
{ label: "Personal Training", href: "/programs/personal-training" },
{ label: "Performance Class", href: "/programs/performance-class" },
{ label: "Coaching", href: "/programs/coaching" },
{ label: "Rehab", href: "/programs/rehab" },
{ label: "Pilates Flow", href: "/programs/pilates-flow" },
{ label: "Nutrizione", href: "/programs/nutrition" },
footerServices: [
{ label: "Personal Training", href: "/services/personal-training" },
{ label: "Performance Class", href: "/services/performance-class" },
{ label: "Coaching", href: "/services/coaching" },
{ label: "Rehab", href: "/services/rehab" },
{ label: "Pilates Flow", href: "/services/pilates-flow" },
{ label: "Nutrizione", href: "/services/nutrition" },
],
};
+10 -10
View File
@@ -1,23 +1,23 @@
export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; founder: boolean }
export interface Member { name: string; shortName: string; role: string; bio: string; image?: string; imageAbout?: string; founder: boolean }
export const team: Member[] = [
{ name: "Donata Dal Pastro", shortName: "Donata", role: "Chinesiologa e Osteopata", founder: true, image: "team-donata",
{ name: "Donata Dal Pastro", shortName: "Donata", role: "Chinesiologa e Osteopata", founder: true, imageAbout: "team-donata-dark", image: "team-donata",
bio: "Donata Dal Pastro è chinesiologa e osteopata specializzata in rieducazione funzionale, riatletizzazione sportiva ed esercizio adattato. Supervisiona larea dedicata a prevenzione, recupero e benessere, con competenze specifiche anche in pediatria e gravidanza." },
{ name: "Nicola Antonelli", shortName: "Nicola", role: "Chinesiologo e Osteopata", founder: true, image: "team-nicola",
{ name: "Nicola Antonelli", shortName: "Nicola", role: "Chinesiologo e Osteopata", founder: true, imageAbout: "team-nicola-dark", image: "team-nicola",
bio: "Nicola Antonelli è chinesiologo e osteopata specializzato in performance sportiva, functional training e bodybuilding. Coordina lo sviluppo tecnico dei percorsi dedicati alla performance e allallenamento avanzato." },
{ name: "Eva Cassetta", shortName: "Eva", role: "Business Manager Balance & Strategy", founder: true,
{ name: "Eva Cassetta", shortName: "Eva", role: "Business Manager Balance & Strategy", founder: true, imageAbout: "team-eva-dark",
bio: "Eva Cassetta ricopre il ruolo di Business Manager Balance & Strategy e si occupa della gestione amministrativa, economica e finanziaria del progetto, garantendone solidità, organizzazione e sostenibilità nel tempo." },
{ name: "Alessandro Lazzari", shortName: "Alessandro", role: "Trainer Functional & Movement", founder: false,
{ name: "Alessandro Lazzari", shortName: "Alessandro", role: "Trainer Functional & Movement", founder: false, imageAbout: "team-alessandro-dark",
bio: "Trainer Functional & Movement. Laureando in Scienze Motorie, specializzato in running e atletica leggera." },
{ name: "Eleonora Cassani", shortName: "Eleonora", role: "Trainer Planning Manager", founder: false, image: "team-eleonora",
{ name: "Eleonora Cassani", shortName: "Eleonora", role: "Trainer Planning Manager", founder: false, imageAbout: "team-eleonora-dark", image: "team-eleonora",
bio: "Trainer Planning Manager. Chinesiologa specializzata in attività preventiva e adattata, con focus su allenamento in gravidanza e post-gravidanza." },
{ name: "Antonello DAlessandro", shortName: "Antonello", role: "Trainer Exercise & Health", founder: false, image: "team-antonello",
{ name: "Antonello DAlessandro", shortName: "Antonello", role: "Trainer Exercise & Health", founder: false, imageAbout: "team-antonello-dark", image: "team-antonello",
bio: "Trainer Exercise & Health. Chinesiologo specializzato in attività preventiva e adattata, Dottore in Osteopatia." },
{ name: "Alice Roda", shortName: "Alice", role: "Trainer Functional & Movement", founder: false,
{ name: "Alice Roda", shortName: "Alice", role: "Trainer Functional & Movement", founder: false, imageAbout: "team-alice-dark",
bio: "Trainer Functional & Movement. Laureanda in Scienze Motorie e Preparatore Atletico. Scienza del movimento e massima cura della persona: un approccio al functional training empatico e accogliente, dove lascolto umano guida e supporta ogni tuo progresso." },
{ name: "Davide Bisagni", shortName: "Davide", role: "Trainer Functional & Movement", founder: false,
{ name: "Davide Bisagni", shortName: "Davide", role: "Trainer Functional & Movement", founder: false, imageAbout: "team-davide-dark",
bio: "Trainer Functional & Movement. Chinesiologo e allenamento funzionale al servizio del corpo: massima cura del gesto atletico e attenzione mirata al recupero per prestazioni sostenibili e durature." },
{ name: "Giulia Barbàra", shortName: "Giulia", role: "Trainer Pilates, Yoga & Mindfulness", founder: false,
{ name: "Giulia Barbàra", shortName: "Giulia", role: "Trainer Pilates, Yoga & Mindfulness", founder: false, imageAbout: "team-giulia-dark",
bio: "Trainer Pilates, Yoga & Mindfulness. Laureata in Psicologia Clinica e Riabilitazione, insegnante di Pilates, Yoga e Mindfulness, con approccio orientato al benessere e allequilibrio mente-corpo." },
];
+1 -1
View File
@@ -1,6 +1,6 @@
export const trainings = [
{ id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Contattaci", href: "/contact" } },
{ id: "small-groups", title: "Small Groups", caption: "Mini gruppi (max 3 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 3 persone con un trainer dedicato. Unesperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Contattaci", href: "/contact" } },
{ id: "performance-class", title: "Performance Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-performance-class", description: "Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.", cta: { label: "Scopri", href: "/programs/performance-class" } },
{ id: "performance-class", title: "Performance Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-performance-class", description: "Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.", cta: { label: "Scopri", href: "/services/performance-class" } },
{ id: "fit-remote", title: "Fit Remote", caption: "Piano di allenamento su misura, ovunque ti alleni.", image: "training-fit-remote", description: "Con Fit Remote hai un piano di allenamento su misura, ovunque ti alleni. Programmi personalizzati creati sui tuoi obiettivi, da seguire in palestra, a casa o in viaggio. Supporto costante e allenamenti pensati per adattarsi al tuo stile di vita.", cta: { label: "Contattaci", href: "/contact" } },
] as const;
+1 -1
View File
@@ -2,6 +2,6 @@
declare namespace App {
interface Locals {
user?: { id: number; username: string };
user?: { id: number; username: string; role: string };
}
}
+14 -1
View File
@@ -22,13 +22,26 @@ const user = Astro.locals.user;
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--draft { background: #f3d9a4; }
.form-msg { font-size: .9rem; margin-top: 8px; }
.form-msg--ok { color: #3c7a3c; }
.form-msg--err { color: #b03030; }
</style>
</head>
<body>
<div class="abar">
<strong>InsanityLab · Admin</strong>
<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 && (
<>
{`Ciao, ${user.username}`}
<a href="/admin">Articoli</a>
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
<a href="/admin/new">Nuovo</a>
<a href="/admin/logout">Esci</a>
<a href="/blog" target="_blank">Vedi sito ↗</a>
</>
)}
</span>
</div>
<div class="awrap"><slot /></div>
+187 -2
View File
@@ -8,9 +8,17 @@ import '@fontsource/open-sans/600.css';
import '../styles/global.css';
import Header from '../components/Header.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 }
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
const user = tok ? getSessionUser(getDb(), tok) : null;
const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
const showTags = canTags && wantTags;
---
<!doctype html>
<html lang="it">
@@ -24,11 +32,188 @@ const { title, description = 'InsanityLab — Performance. Balance. Longevity. A
<meta property="og:type" content="website" />
<meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} />
<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>
</head>
<body>
<Header />
<Header user={user} showTags={showTags} canTags={canTags} />
<main><slot /></main>
<Footer />
<script>
// Reveal allo scroll: trigger a -100px dal viewport come il template Prowess.
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('is-inview');
io.unobserve(e.target);
// A reveal concluso l'attributo va tolto, così la transition del reveal
// non rallenta gli hover su transform del componente.
e.target.addEventListener('transitionend', () => {
e.target.removeAttribute('data-reveal');
}, { once: true });
}
}
}, { rootMargin: '0px 0px -100px 0px' });
document.querySelectorAll('[data-reveal], .section-heading').forEach((el) => io.observe(el));
const parallaxEls = document.querySelectorAll<HTMLElement>('[data-parallax]');
if (parallaxEls.length && matchMedia('(min-width: 992px)').matches
&& !matchMedia('(prefers-reduced-motion: reduce)').matches) {
let raf = 0;
const update = () => {
raf = 0;
for (const el of parallaxEls) {
const r = el.getBoundingClientRect();
// Clamp entro il margine di sfondo (inset -120px) per non scoprire i bordi
const y = Math.max(-110, Math.min(110, (r.top + r.height / 2 - innerHeight / 2) * -0.3));
el.style.setProperty('--parallax-y', `${y.toFixed(1)}px`);
}
};
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
update();
}
</script>
{showTags && (
<style is:global>
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
.tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; }
.tag-badge:hover { opacity: 1; }
.tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; }
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; }
.tag-modal[hidden] { display: none; }
.tag-modal__box { background: #fff; color: #222; width: min(560px, 92vw); max-height: 88vh; overflow: auto; padding: 20px; border-radius: 4px; }
.tag-modal__box h3 { margin: 0 0 4px; font: 600 14px monospace; }
.tag-modal__box .guid { font: 11px monospace; color: #888; margin: 0 0 14px; }
.tag-modal__box textarea { width: 100%; min-height: 140px; font: inherit; padding: 8px; box-sizing: border-box; }
.tag-modal__box select { margin: 6px 8px 6px 0; }
.tag-modal__row { display: flex; gap: 10px; margin-top: 14px; }
.tag-modal__row button { padding: 9px 16px; border: 0; cursor: pointer; }
.tag-modal__save { background: #3a2d26; color: #fff; }
.tag-modal__cancel { background: #ddd; }
.tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; }
</style>
<div class="tag-modal" id="tag-modal" hidden>
<div class="tag-modal__box">
<h3 id="tm-tag"></h3>
<p class="guid" id="tm-guid"></p>
<div id="tm-body"></div>
<div class="tag-modal__msg" id="tm-msg"></div>
<div class="tag-modal__row">
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
<button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
</div>
</div>
</div>
<script is:inline>
(() => {
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'] };
const modal = document.getElementById('tag-modal');
const tmTag = document.getElementById('tm-tag');
const tmGuid = document.getElementById('tm-guid');
const tmBody = document.getElementById('tm-body');
const tmMsg = document.getElementById('tm-msg');
const tmSave = document.getElementById('tm-save');
let current = null; // { tag, type, el }
function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
document.getElementById('tm-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
async function openModal(tag, el) {
tmMsg.textContent = '';
let data;
try {
const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
data = await res.json();
} catch { alert('Errore di rete.'); return; }
current = { tag: data.tag, type: data.type, el };
tmTag.textContent = data.tag;
tmGuid.textContent = data.guid || '';
tmBody.innerHTML = '';
if (data.type === 'image') {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
tmBody.appendChild(inp);
const del = document.createElement('button');
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del';
del.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
tmBody.appendChild(del);
del.addEventListener('click', async () => {
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
});
} else {
const ta = document.createElement('textarea');
ta.id = 'tm-value'; ta.value = data.value;
tmBody.appendChild(ta);
(data.styleOptions || []).forEach((group) => {
const opts = STYLE_GROUPS[group];
if (!opts) return;
const sel = document.createElement('select');
sel.dataset.group = group;
sel.className = 'tm-style';
const none = document.createElement('option');
none.value = ''; none.textContent = group + ': —';
sel.appendChild(none);
opts.forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = group + ': ' + v; sel.appendChild(o); });
tmBody.appendChild(sel);
});
}
modal.hidden = false;
}
tmSave.addEventListener('click', async () => {
if (!current) return;
tmMsg.textContent = '';
if (current.type === 'image') {
const file = document.getElementById('tm-file').files[0];
if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
const fd = new FormData(); fd.append('file', file);
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
return;
}
const value = document.getElementById('tm-value').value;
const styles = {};
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
const body = { value };
if (Object.keys(styles).length) body.styles = styles;
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
const saved = await r.json();
// Aggiorna in-place il contenuto nel DOM.
if (current.type === 'html') current.el.innerHTML = saved.value;
else current.el.textContent = saved.value;
closeModal();
});
document.querySelectorAll('[data-tag]').forEach((el) => {
const tag = el.dataset.tag;
const guid = el.dataset.guid || '';
const badge = document.createElement('span');
badge.className = 'tag-badge';
const label = document.createElement('span');
label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
const edit = document.createElement('button');
edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
edit.addEventListener('click', () => openModal(tag, el));
badge.appendChild(label); badge.appendChild(edit);
document.body.appendChild(badge);
const place = () => {
const r = el.getBoundingClientRect();
badge.style.top = window.scrollY + r.top - 16 + 'px';
badge.style.left = window.scrollX + r.left + 'px';
};
place();
addEventListener('scroll', place, { passive: true });
addEventListener('resize', place);
});
})();
</script>
)}
</body>
</html>
+73 -7
View File
@@ -5,6 +5,8 @@ import { randomBytes } from 'node:crypto';
export const SESSION_COOKIE = 'session';
const SESSION_DAYS = 7;
export type Role = 'admin' | 'superuser' | 'user';
export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, 12);
}
@@ -13,9 +15,9 @@ export function verifyPassword(plain: string, hash: string): boolean {
return bcrypt.compareSync(plain, hash);
}
export function createUser(db: Database.Database, username: string, password: string): number {
const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
.run(username, hashPassword(password));
export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number {
const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
.run(username, hashPassword(password), role);
return Number(res.lastInsertRowid);
}
@@ -30,19 +32,83 @@ export function login(db: Database.Database, username: string, password: string)
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(
`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 = ?`
).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.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
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 {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
}
// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo
// utenti già autenticati).
const RULES: [RegExp, Role[]][] = [
[/^\/admin\/users(\/|$)/, ['admin']],
[/^\/api\/admin\/users(\/|$)/, ['admin']],
[/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
[/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
];
export function canAccessAdminPath(role: string, pathname: string): boolean {
if (role === 'admin') return true;
for (const [re, roles] of RULES) {
if (re.test(pathname)) return roles.includes(role as Role);
}
return true; // blog, upload, logout, showtags: tutti i loggati
}
export function getUsernameById(db: Database.Database, id: number): string | null {
const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined;
return row?.username ?? null;
}
export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] {
return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as
{ id: number; username: string; role: string }[];
}
export function countAdmins(db: Database.Database): number {
return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n;
}
export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null {
const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as
{ id: number; username: string; role: string } | undefined;
return row ?? null;
}
export function updateUserRole(db: Database.Database, id: number, role: Role): void {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
}
export function updateUserPassword(db: Database.Database, id: number, hash: string): void {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
}
export function deleteUser(db: Database.Database, id: number): void {
db.prepare('DELETE FROM users WHERE id = ?').run(id);
}
export function randomPassword(): string {
return randomBytes(9).toString('base64url'); // ~12 caratteri
}
export function isRole(v: unknown): v is Role {
return v === 'admin' || v === 'superuser' || v === 'user';
}
export function landingFor(role: string): string {
if (role === 'superuser') return '/admin/content';
return '/admin';
}
+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 };
}
+56
View File
@@ -0,0 +1,56 @@
import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
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>; guid: string }
export function syncSeed(db: Database.Database): void {
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
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, guid FROM content_blocks').all() as
{ tag: string; type: ContentType; value: string; styles: string; guid: string | null }[];
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, guid: r.guid ?? '' }];
}));
return cache;
};
return {
resolve(tag: string): { value: string; type: ContentType; classes: string; guid: string } {
const hit = load().get(tag);
if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles), guid: hit.guid };
const seed = seedByTag.get(tag);
if (seed) return { value: seed.value, type: seed.type, classes: '', guid: '' };
console.warn(`[content] tag sconosciuto: ${tag}`);
return { value: '', type: 'text', classes: '', guid: '' };
},
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;
+86
View File
@@ -1,6 +1,8 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { randomUUID } from 'node:crypto';
import { contentSeed } from '../data/content-seed';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS posts (
@@ -27,6 +29,14 @@ CREATE TABLE IF NOT EXISTS sessions (
expires_at TEXT NOT NULL
);
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 {
@@ -36,6 +46,82 @@ export function createDb(path?: string): Database.Database {
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
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'`);
}
// Ruolo editor unificato in superuser (3 ruoli: admin, superuser, user). Idempotente.
db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
// GUID stabile per ogni contenuto taggato: colonna additiva + backfill + indice univoco.
const contentCols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
if (!contentCols.some((c) => c.name === 'guid')) {
db.exec(`ALTER TABLE content_blocks ADD COLUMN guid TEXT`);
}
// Autore articoli: colonna nullable (post storici restano senza autore).
const postCols = db.prepare(`PRAGMA table_info(posts)`).all() as { name: string }[];
if (!postCols.some((c) => c.name === 'author_id')) {
db.exec(`ALTER TABLE posts ADD COLUMN author_id INTEGER`);
}
// Migrazione: la sezione "Programmi" è diventata "Servizi" → rinomina dei tag esistenti
// preservando le modifiche fatte dal pannello. Ordine: prima i prefissi più specifici.
// UPDATE OR IGNORE salta le righe in conflitto (tag nuovo già presente); la DELETE
// successiva ripulisce le righe vecchie rimaste. Idempotente: al secondo giro nessun match.
const TAG_RENAMES: [string, string][] = [
['home.programs.', 'home.services.'],
['footer.programs.', 'footer.services.'],
['programs.', 'services.'],
['program.', 'service.'],
];
const renameUpd = db.prepare('UPDATE OR IGNORE content_blocks SET tag = ? || substr(tag, ?) WHERE tag LIKE ?');
const renameDel = db.prepare('DELETE FROM content_blocks WHERE tag LIKE ?');
const renameTx = db.transaction(() => {
for (const [oldP, newP] of TAG_RENAMES) {
renameUpd.run(newP, oldP.length + 1, `${oldP}%`);
renameDel.run(`${oldP}%`);
}
// Tag singolo, non coperto dai prefissi: uguaglianza esatta.
db.prepare("UPDATE OR IGNORE content_blocks SET tag = 'footer.col.services-title' WHERE tag = 'footer.col.programs-title'").run();
db.prepare("DELETE FROM content_blocks WHERE tag = 'footer.col.programs-title'").run();
// I valori di default che nominavano la sezione cambiano con la rinomina. Il seed è
// INSERT OR IGNORE e non tocca le righe esistenti, quindi si aggiornano qui — ma solo
// le righe rimaste identiche al valore originale del seed (mai modificate dal pannello):
// il nuovo valore è quello del seed corrente per lo stesso tag.
const OLD_SECTION_DEFAULTS: [string, string][] = [
['global.nav.3.label', 'Programmi'],
['footer.col.services-title', 'Programmi'],
['home.services.title', 'Programmi'],
['home.services.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.'],
['services.meta.title', 'Programmi'],
['services.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.'],
['services.hero.title', 'Programmi'],
['services.intro.title', 'Programmi'],
["services.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."],
['service.side.others-label', 'Altri programmi'],
['service.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'],
];
const seedValue = new Map(contentSeed.map((e) => [e.tag, e.value]));
const updValue = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
for (const [tag, oldValue] of OLD_SECTION_DEFAULTS) {
const next = seedValue.get(tag);
if (next !== undefined) updValue.run(next, tag, oldValue);
}
});
renameTx();
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
syncTx();
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
const backfill = db.prepare('UPDATE content_blocks SET guid = ? WHERE tag = ?');
const bfTx = db.transaction(() => { for (const m of missing) backfill.run(randomUUID(), m.tag); });
bfTx();
// Indice univoco creato dopo il backfill, così non fallisce su righe a guid nullo.
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`);
return db;
}
+14 -5
View File
@@ -2,21 +2,21 @@ import type Database from 'better-sqlite3';
export interface Post {
id: number; title: string; slug: string; category: string; excerpt: string;
body_html: string; cover: string | null; draft: number;
body_html: string; cover: string | null; draft: number; author_id: number | null;
published_at: string | null; created_at: string; updated_at: string;
}
export interface PostInput {
title: string; slug: string; category: string; excerpt: string;
body_html: string; cover?: string | null; draft: boolean;
body_html: string; cover?: string | null; draft: boolean; author_id?: number | null;
}
export function createPost(db: Database.Database, input: PostInput): number {
const res = db.prepare(
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at)
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft,
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at)
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, @author_id,
CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 });
).run({ ...input, cover: input.cover ?? null, author_id: input.author_id ?? null, draft: input.draft ? 1 : 0 });
return Number(res.lastInsertRowid);
}
@@ -72,3 +72,12 @@ export function listArchiveMonths(db: Database.Database): { month: string; count
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
).all() as { month: string; count: number }[];
}
export function listByAuthor(db: Database.Database, authorId: number): Post[] {
return db.prepare('SELECT * FROM posts WHERE author_id = ? ORDER BY updated_at DESC').all(authorId) as Post[];
}
export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
if (role === 'admin' || role === 'superuser') return true;
return post.author_id === userId;
}
+8
View File
@@ -0,0 +1,8 @@
// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere
// protocol-relative ("//"), non puntare alle API. Tutto il resto → home.
export function safeNext(next: string | null): string {
if (next && next.includes('\\')) return '/';
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
if (next.startsWith('/api/')) return '/';
return next;
}
+9
View File
@@ -9,3 +9,12 @@ export function sanitizeHtml(html: string): string {
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 { getDb } from './lib/db';
import { getSessionUser, SESSION_COOKIE } from './lib/auth';
import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor } from './lib/auth';
export const onRequest = defineMiddleware((context, next) => {
const { pathname } = context.url;
@@ -19,6 +19,16 @@ export const onRequest = defineMiddleware((context, next) => {
}
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(landingFor(user.role));
}
context.locals.user = user;
return next();
});
+8 -6
View File
@@ -1,14 +1,16 @@
---
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;">
<div class="container">
<p class="eyebrow">Errore 404</p>
<h1>Pagina non trovata</h1>
<p>La pagina che cerchi non esiste o è stata spostata.</p>
<a class="btn" href="/">Torna alla home</a>
<T tag="notfound.eyebrow" as="p" class="eyebrow" />
<T tag="notfound.title" as="h1" />
<T tag="notfound.body" as="p" />
<a class="btn" href="/">{t('notfound.cta')}</a>
</div>
</section>
</Base>
+92 -57
View File
@@ -1,29 +1,31 @@
---
import type { ImageMetadata } from 'astro';
import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro';
import Timeline from '../components/about/Timeline.astro';
import { Image } from 'astro:assets';
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 imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1]?.default;
const founders = 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.">
<PageHero title="Insanitylab" />
<Base title={t('about.meta.title')} description={t('about.meta.description')}>
<PageHero title={t('about.hero.title')} />
<section class="section">
<div class="container">
<div class="about-img-row">
<div class="about-img-row__text section-heading">
<h2>Mission</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.title" as="h2" />
<T tag="about.mission.body" as="p" />
</div>
{imgOf('about-training-group') && (
<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>
@@ -33,14 +35,14 @@ export const prerender = true;
<section class="section section--alt">
<div class="container about-2col">
<div>
<h2>Direzione</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>
<a class="btn" href="/programs">Scopri</a>
<T tag="about.direzione.title" as="h2" />
<T tag="about.direzione.body" as="p" />
<a class="btn" href="/services">{t('about.direzione.cta')}</a>
</div>
<div>
<h2>Approccio</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>
<a class="btn btn--dark" href="/contact">Contattaci</a>
<T tag="about.approccio.title" as="h2" />
<T tag="about.approccio.body" as="p" />
<a class="btn btn--dark" href="/contact">{t('about.approccio.cta')}</a>
</div>
</div>
</section>
@@ -49,12 +51,12 @@ export const prerender = true;
<div class="container">
<div class="about-img-row about-img-row--reverse">
<div class="about-img-row__text section-heading">
<h2>About</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.title" as="h2" />
<T tag="about.about.body" as="p" />
</div>
{imgOf('about-team-group') && (
{imgOf('about-pushup') && (
<div class="about-img-row__img about-img-row__img--native">
<Image src={imgOf('about-team-group')!} alt="Team InsanityLab" widths={[270]} sizes="270px" />
<TImg tag="about.about.image" src={imgOf('about-pushup')!} alt="Allenamento InsanityLab" widths={[320, 640]} sizes="320px" />
</div>
)}
</div>
@@ -66,53 +68,75 @@ export const prerender = true;
<section class="section section--dark">
<div class="container">
<div class="section-heading">
<h2>Convenzioni</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.title" as="h2" />
<T tag="about.convenzioni.body" as="p" />
</div>
<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>
<li>Ogni collaborazione parte da una consulenza gratuita preliminare per analizzare esigenze e obiettivi, così da sviluppare soluzioni efficaci e sostenibili.</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.item1" as="li" />
<T tag="about.convenzioni.item2" as="li" />
<T tag="about.convenzioni.item3" as="li" />
</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') && (
<div class="round-img">
<TImg tag="about.convenzioni.image" src={imgOf('about-conventions')!} alt="Convenzioni aziendali" widths={[360, 720]} sizes="360px" />
</div>
)}
</div>
</section>
<section class="section">
<div class="container">
<div class="section-heading">
<h2>Team</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.title" as="h2" />
<T tag="about.team.body" as="p" />
</div>
<div class="tgrid tgrid--founders">
{founders.map((m) => (
<article class="tcard">
{m.image && imgOf(m.image) && <Image src={imgOf(m.image)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
<h3>{m.shortName}</h3>
<p class="tcard__role">{m.role}</p>
<p>{m.bio}</p>
</article>
))}
{founders.map((m) => {
const slug = m.shortName.toLowerCase();
return (
<article class="tcard">
{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" />}
<T tag={`team.${slug}.short-name`} as="h3" />
<T tag={`team.${slug}.role`} as="p" class="tcard__role" />
<T tag={`team.${slug}.bio`} as="p" />
</article>
);
})}
</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">
{staff.map((m) => (
<article class="tcard">
{m.image && imgOf(m.image) && <Image src={imgOf(m.image)!} alt={m.name} widths={[300, 500]} sizes="(max-width:767px) 100vw, 33vw" />}
<h3>{m.shortName}</h3>
<p class="tcard__role">{m.role}</p>
<p>{m.bio}</p>
</article>
))}
{staff.map((m) => {
const slug = m.shortName.toLowerCase();
return (
<article class="tcard">
{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" />}
<T tag={`team.${slug}.short-name`} as="h3" />
<T tag={`team.${slug}.role`} as="p" class="tcard__role" />
<T tag={`team.${slug}.bio`} as="p" />
</article>
);
})}
</div>
</div>
</section>
<section class="section section--alt">
<div class="container section-heading">
<h2>Metodo</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>
<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>
<div class="container">
<div class="section-heading">
<T tag="about.metodo.title" as="h2" />
<T tag="about.metodo.body" as="p" />
</div>
<div class="about-img-row">
<div class="about-img-row__text section-heading">
<T tag="about.metodo.closing" as="p" />
</div>
{imgOf('about-team-group') && (
<div class="round-img">
<TImg tag="about.metodo.image" src={imgOf('about-team-group')!} alt="Il team InsanityLab" widths={[360, 720]} sizes="360px" />
</div>
)}
</div>
</div>
</section>
@@ -120,14 +144,14 @@ export const prerender = true;
<div class="container">
<div class="about-img-row">
<div class="about-img-row__text section-heading">
<h2>In-Sanity Studios</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>
<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>
<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.title" as="h2" />
<T tag="about.studios.body1" as="p" />
<T tag="about.studios.body2" as="p" />
<T tag="about.studios.body3" as="p" />
</div>
{imgOf('about-exterior') && (
{imgOf('about-studios') && (
<div class="about-img-row__img">
<Image src={imgOf('about-exterior')!} 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>
@@ -135,10 +159,19 @@ export const prerender = true;
</section>
<section class="section section--alt">
<div class="container section-heading">
<h2>Vision</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>
<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>
<div class="container">
<div class="about-img-row">
{imgOf('about-vision') && (
<div class="round-img">
<TImg tag="about.vision.image" src={imgOf('about-vision')!} alt="La comunità InsanityLab" widths={[360, 720]} sizes="360px" />
</div>
)}
<div class="about-img-row__text section-heading">
<T tag="about.vision.title" as="h2" />
<T tag="about.vision.body1" as="p" />
<T tag="about.vision.body2" as="p" />
</div>
</div>
</div>
</section>
</Base>
@@ -152,6 +185,8 @@ export const prerender = true;
.about-img-row__img--native { max-width: 320px; margin: 0 auto; }
.conv { max-width: 760px; margin: 0 auto 30px; display: grid; gap: 18px; padding-left: 20px; }
.conv__cta { text-align: center; font-family: var(--font-heading); letter-spacing: .06em; }
.round-img { max-width: 380px; margin: 30px auto 0; }
.round-img img { border-radius: 50%; aspect-ratio: 1; object-fit: cover; width: 100%; }
.tgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px; }
.tcard img { aspect-ratio: 3/4; object-fit: cover; margin-bottom: 16px; }
.tcard h3 { text-transform: lowercase; letter-spacing: .2em; }
+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',
service: 'Servizi', services: 'Servizi', pricing: 'Servizi',
training: 'Training',
contact: 'Contatti', form: 'Contatti',
global: 'Globali', header: 'Globali', footer: 'Globali',
agenda: 'Globali', partner: 'Globali', notfound: 'Globali',
};
const GROUP_ORDER = ['Home', 'About', 'Servizi', '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>
+3 -1
View File
@@ -2,10 +2,12 @@
import Admin from '../../../layouts/Admin.astro';
import PostForm from '../../../components/admin/PostForm.astro';
import { getDb } from '../../../lib/db';
import { getPostById } from '../../../lib/posts';
import { getPostById, canModifyPost } from '../../../lib/posts';
export const prerender = false;
const post = getPostById(getDb(), Number(Astro.params.id));
if (!post) return Astro.redirect('/admin');
const u = Astro.locals.user!;
if (!canModifyPost(u.role, u.id, post)) return Astro.redirect('/admin');
---
<Admin title={`Modifica: ${post.title}`}>
<h1>Modifica articolo</h1>
+8 -3
View File
@@ -1,21 +1,26 @@
---
import Admin from '../../layouts/Admin.astro';
import { getDb } from '../../lib/db';
import { listAllPosts } from '../../lib/posts';
import { listAllPosts, listByAuthor } from '../../lib/posts';
import { listUsers } from '../../lib/auth';
export const prerender = false;
const posts = listAllPosts(getDb());
const user = Astro.locals.user!;
const isManager = user.role === 'superuser' || user.role === 'admin';
const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
---
<Admin title="Articoli">
<h1>Articoli</h1>
<p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
<table>
<thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
<thead><tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
<tbody>
{posts.map((p) => (
<tr>
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
<td>{p.category}</td>
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>

Some files were not shown because too many files have changed in this diff Show More