Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ed7169b88 | |||
| 7211d74d3a | |||
| e2bbc9074f | |||
| be77285613 | |||
| c36ad4cb2d | |||
| 0f2b2ba014 | |||
| 2bf6b3d4de | |||
| 22d72be74d | |||
| 842371604b | |||
| 71cd1626e0 | |||
| 6b85872515 | |||
| 5ce10da9bd | |||
| c3267c9be2 | |||
| 6d1e2607ee | |||
| db92fc9c02 | |||
| e87a464090 | |||
| 5cea895d51 | |||
| d4cf359805 | |||
| d0fa75a3d3 | |||
| 6c225281e0 | |||
| 8ceea9b16f | |||
| c975dcac12 | |||
| 8495d9edd4 | |||
| 56ca6eca14 | |||
| 608b1b68d2 | |||
| be52781499 | |||
| bf19cba4d3 | |||
| eda4d73aac | |||
| b5c0c107bc | |||
| 685ef4a36e | |||
| 3551fae132 | |||
| bf38fc865c | |||
| 31354c4457 | |||
| b84463c9df | |||
| d5b19f2c39 | |||
| 34fd3d2e18 | |||
| 3f01721a99 | |||
| f4c350ad64 | |||
| b079ded266 | |||
| b22cc40b03 | |||
| 6f51aec4c6 | |||
| 5f1d9f3740 | |||
| 0f94b5017c | |||
| 274a6c7600 | |||
| 77041f9d5a | |||
| c7d9d4ba5d | |||
| 83237feb65 | |||
| b83d62c10c | |||
| 875b369939 | |||
| 98c42aae54 | |||
| 7d2f0b7048 | |||
| ecdf12ee0e | |||
| 2d5ff1816d | |||
| 6cc1cf5f44 |
@@ -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
|
||||
|
||||
|
||||
@@ -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]' },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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. BALANCE. 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 (` `), home.team.title e home.method.title (`<br>`), home.quote.text e method.intro (`<strong>` congelato), program.pricing.empty-note (link `<a>`).
|
||||
2. Trasformazioni runtime da conservare nel template (non nel valore): `.toLowerCase()` (training h2), `.toUpperCase()` (hero dettaglio programma), anno copyright.
|
||||
3. Duplicazioni intenzionali (tag distinti, valori oggi identici): programs.intro.body / home.programs.body / pricing.intro; home.training.subtitle / training.intro.subtitle (varianti).
|
||||
4. Loghi header/footer stanno in `public/img/` (non passano dalla pipeline asset): TImg non applicabile — per l'override basta `contentImageUrl(tag, '/img/logo-dark.png')` su `<img>`.
|
||||
5. Conteggi indicativi: ~150 tag testo da template, ~200 da data file, ~60 slot immagine.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Sistema di contenuti taggati e pannello editor
|
||||
|
||||
Data: 2026-07-05
|
||||
Stato: approvato dall'operatore (design), spec in revisione
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Ogni testo e ogni immagine delle pagine vetrina del sito riceve un tag univoco e stabile. I contenuti vengono estratti dal codice e salvati in SQLite, così da poter essere modificati senza intervenire sui sorgenti. Un pannello di amministrazione (`/admin/content`) permette a un utente con ruolo dedicato di modificare testi, stili (tramite preset controllati) e immagini; una modalità "annotate" sovrappone al sito i badge con i nomi dei tag, rendendo immediato individuare l'elemento da modificare.
|
||||
|
||||
Il sistema serve due scopi complementari: nel breve periodo dà all'operatore un vocabolario preciso per dettare modifiche ("cambia `home.hero.title` in…"); nel medio periodo consente al cliente di aggiornare i contenuti in autonomia, senza rischio di rompere il layout.
|
||||
|
||||
## Decisioni prese in fase di design
|
||||
|
||||
| Decisione | Scelta |
|
||||
| --- | --- |
|
||||
| Scopo | Tagging + pannello editor nello stesso progetto |
|
||||
| Ambito modifiche | Testi, immagini, stili tramite preset chiusi (no CSS libero) |
|
||||
| Utenti | Ruolo `admin` (tutto) e ruolo `editor` (solo contenuti) |
|
||||
| Pubblicazione | Pagine vetrina passano a SSR con cache in memoria invalidata al salvataggio |
|
||||
| UX | Ibrido: pannello `/admin/content` + overlay annotate sul sito con link ai form |
|
||||
|
||||
## Architettura
|
||||
|
||||
### 1. Schema dei tag
|
||||
|
||||
Formato gerarchico `pagina.sezione.elemento`, tutto minuscolo, separatore punto, senza spazi:
|
||||
|
||||
- `home.hero.title`, `home.hero.subtitle`, `home.hero.cta`
|
||||
- `about.team.donata.bio`, `about.intro.body`
|
||||
- `programs.pilates-flow.price`, `training.fit-remote.description`
|
||||
- `global.footer.tagline`, `global.header.cta` (elementi condivisi tra pagine)
|
||||
|
||||
I tag sono stabili: una volta assegnati non cambiano nome, perché sono chiave primaria nel DB e riferimento nelle conversazioni. I dati oggi strutturati in `src/data/*.ts` (team, pricing, agenda, method, programs, trainings, partners, site) vengono scomposti in tag granulari con lo stesso schema.
|
||||
|
||||
### 2. Storage (SQLite, DB esistente)
|
||||
|
||||
Nuova tabella nella `SCHEMA` di `src/lib/db.ts`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS content_blocks (
|
||||
tag TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK (type IN ('text','html','image')),
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
styles TEXT NOT NULL DEFAULT '{}', -- JSON, solo chiavi preset
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_by TEXT
|
||||
);
|
||||
```
|
||||
|
||||
- `type = 'text'`: testo semplice, escapato in render.
|
||||
- `type = 'html'`: frammenti con markup minimo (grassetti, a-capo) sanificati con la pipeline già usata dal blog (`src/lib/sanitize.ts`). Usato solo dove il testo attuale contiene markup.
|
||||
- `type = 'image'`: `value` contiene il percorso dell'override caricato (es. `content/home-hero-1.jpg`, relativo a `uploads/`) oppure stringa vuota se vale l'asset originale.
|
||||
|
||||
### 3. Seed e fonte di verità
|
||||
|
||||
`src/data/content-seed.ts` contiene l'inventario completo dei tag con i valori estratti dai testi attuali: `{ tag, type, value, styleOptions }`. Il campo `styleOptions` dichiara quali preset ha senso esporre per quel tag (vedi §6); non finisce nel DB, guida solo il pannello.
|
||||
|
||||
All'avvio (in `createDb`) un passo di sync inserisce nel DB i tag presenti nel seed ma assenti nella tabella (`INSERT OR IGNORE`). Regole:
|
||||
|
||||
- In produzione il DB è la fonte di verità: le modifiche del cliente non vengono mai sovrascritte dal seed.
|
||||
- I tag nuovi arrivano con il codice (aggiunta al seed) e compaiono nel DB al deploy successivo.
|
||||
- I tag rimossi dal seed restano orfani nel DB e vengono ignorati; nessuna cancellazione automatica.
|
||||
- Il seed nel repo funge anche da backup versionato dei testi originali.
|
||||
|
||||
### 4. Rendering
|
||||
|
||||
- Le pagine vetrina (`index`, `about`, `contact`, `training`, `programs/index`, `programs/[slug]`, `404`) passano da `prerender = true` a `prerender = false` (SSR, già attivo per blog e admin con adapter node standalone).
|
||||
- Nuovo modulo `src/lib/content.ts`:
|
||||
- `getContent(tag)` / `getAllContent()`: lettura con cache in memoria (`Map` popolata al primo accesso con una sola query).
|
||||
- `invalidateContentCache()`: svuota la cache; chiamata dall'API di salvataggio. Modifica visibile alla richiesta successiva.
|
||||
- Fallback a cascata: valore nel DB → valore nel seed → stringa vuota con log di warning. Il sito non si rompe mai per un tag mancante.
|
||||
- Nuovi componenti Astro:
|
||||
- `<T tag="home.hero.title" as="h1" class="…" />`: renderizza il contenuto nel tag HTML indicato (`as`, default `span`), applica le classi preset derivate da `styles`, emette `data-tag` per l'overlay annotate. Per `type='text'` il valore è escapato; per `type='html'` è già sanificato al salvataggio.
|
||||
- `<TImg tag="home.hero.image" … />`: se esiste override in DB serve il file da `/uploads/content/…` (route `src/pages/uploads/[...path].ts` già esistente), altrimenti l'asset originale ottimizzato dalla pipeline Astro. Le prop di layout (dimensioni, alt di default, loading) restano nel codice.
|
||||
- La sitemap non cambia (le route restano le stesse); verificare che l'integrazione sitemap continui a emetterle con `output: 'server'` e prerender disattivato, altrimenti passare a una sitemap statica generata a mano.
|
||||
|
||||
### 5. Ruoli utente
|
||||
|
||||
- Migrazione additiva: `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'` (eseguita in `createDb` se la colonna manca; gli utenti esistenti restano admin).
|
||||
- `getSessionUser` ritorna anche `role`; `context.locals.user` lo espone.
|
||||
- Middleware: le route `/admin/content` e `/api/admin/content*` sono accessibili ad `admin` ed `editor`; tutte le altre route `/admin*` e `/api/admin*` restano riservate ad `admin` (l'editor che le richiede riceve redirect a `/admin/content` o 403 sulle API).
|
||||
- Creazione utente editor: script CLI in `scripts/` (pattern già usato per l'admin), credenziali consegnate al cliente fuori banda.
|
||||
|
||||
### 6. Stili preset
|
||||
|
||||
Il DB memorizza solo chiavi simboliche; la mappa chiave → classe CSS vive nel codice (`src/lib/style-presets.ts` + classi in `global.css`). Nessun CSS arbitrario può entrare dal pannello.
|
||||
|
||||
| Gruppo | Chiavi | Note |
|
||||
| --- | --- | --- |
|
||||
| `size` | `s` / `m` / `l` | scala relativa alla dimensione base dell'elemento |
|
||||
| `color` | palette del sito (brand-dark, tan, taupe, bianco, …) | lista chiusa derivata dalle variabili CSS esistenti |
|
||||
| `weight` | `normal` / `bold` | |
|
||||
| `style` | `normal` / `italic` | |
|
||||
| `align` | `left` / `center` / `right` | solo su elementi a blocco |
|
||||
|
||||
Ogni tag dichiara nel seed quali gruppi espone (`styleOptions`): un titolo hero espone size e color, una voce di prezzo magari solo color. Valori non in whitelist vengono rifiutati dall'API al salvataggio.
|
||||
|
||||
### 7. Pannello `/admin/content`
|
||||
|
||||
Pagina SSR protetta, stessa shell di `src/layouts/Admin.astro`:
|
||||
|
||||
- Tag raggruppati per pagina (Home, About, Programs, Training, Contact, Globali), gruppi collassabili, campo di ricerca per nome tag o contenuto.
|
||||
- Per ogni tag: form con campo testo (input o textarea in base alla lunghezza), controlli preset solo per i gruppi dichiarati, upload immagine per i tag `image` (validazione mime/dimensione riusando la logica dell'upload blog) con anteprima e pulsante "ripristina originale".
|
||||
- Salvataggio per singolo tag (pulsante per riga) via `fetch` alle API; feedback inline di esito.
|
||||
- Ancore `id` per tag: `/admin/content#home.hero.title` scrolla ed evidenzia il form corrispondente.
|
||||
- Pulsante "Vedi sul sito con tag" che apre la pagina pubblica in modalità annotate.
|
||||
|
||||
API:
|
||||
|
||||
- `GET /api/admin/content` — elenco completo (per il pannello).
|
||||
- `PUT /api/admin/content/[tag]` — aggiorna `value` e/o `styles`; valida tipo, preset in whitelist, sanifica se `type='html'`; aggiorna `updated_at`/`updated_by`; invalida la cache.
|
||||
- `POST /api/admin/content/[tag]/image` — upload override immagine in `uploads/content/`; `DELETE` sullo stesso path rimuove l'override e ripristina l'asset originale.
|
||||
|
||||
### 8. Overlay annotate
|
||||
|
||||
- Attivazione: query string `?annotate=1` su qualunque pagina vetrina, solo con sessione valida (admin o editor). Senza sessione il parametro è ignorato: nessuna traccia dei tag per i visitatori.
|
||||
- `Base.astro` in modalità annotate inietta un piccolo CSS+JS vanilla (coerente con lo stile del progetto: niente librerie): ogni elemento con `data-tag` riceve un bordo tratteggiato e un badge con il nome del tag; il click sul badge apre `/admin/content#<tag>` in nuova scheda.
|
||||
- Le animazioni reveal restano attive; il badge è posizionato in modo da non alterare il layout (position absolute su wrapper relativo o outline, da verificare in implementazione).
|
||||
|
||||
### 9. Gestione errori
|
||||
|
||||
- Tag mancante nel DB e nel seed: stringa vuota + `console.warn` lato server. La pagina renderizza comunque.
|
||||
- `styles` JSON corrotto nel DB: si ignora e si usa `{}`.
|
||||
- Upload immagine: stessi limiti e messaggi del blog; file orfani in `uploads/content/` tollerati (nessuna garbage collection automatica).
|
||||
- API: 401 senza sessione, 403 per ruolo insufficiente, 400 con messaggio esplicito per tag inesistente, tipo errato o preset fuori whitelist.
|
||||
|
||||
### 10. Test
|
||||
|
||||
- Unit (`vitest`, pattern esistente in `tests/`): content lib (fallback a cascata, invalidazione cache, sync del seed), validazione preset, migrazione colonna `role`.
|
||||
- API: autorizzazione per ruolo (editor può contenuti, non può post; anonimo 401), salvataggio e rilettura, rifiuto preset non in whitelist.
|
||||
- Smoke: le pagine vetrina renderizzano in SSR con contenuti dal DB; overlay annotate presente con sessione e assente senza.
|
||||
|
||||
## Fasi di lavoro previste (indicative per il piano)
|
||||
|
||||
1. Infrastruttura: tabella, migrazione ruoli, content lib con cache, componenti `<T>`/`<TImg>`, preset.
|
||||
2. Estrazione e tagging: inventario completo testi/immagini pagina per pagina, creazione seed, sostituzione nei template e nei componenti (lavoro meccanico ma esteso; è la fase più lunga).
|
||||
3. Pannello + API + overlay annotate + ruolo editor.
|
||||
4. Test, verifica end-to-end locale, deploy e verifica in produzione.
|
||||
|
||||
## Fuori ambito
|
||||
|
||||
- CSS libero per elemento (escluso per scelta).
|
||||
- Versionamento/storico delle modifiche ai contenuti (eventuale evoluzione futura).
|
||||
- Modifica della struttura delle pagine (aggiunta/rimozione sezioni) dal pannello.
|
||||
- Gestione multilingua.
|
||||
- Blog: resta gestito dal suo admin TipTap esistente, fuori dal sistema tag.
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
Disallow: /api
|
||||
Sitemap: https://insanitylab.tielogic.xyz/sitemap-index.xml
|
||||
Sitemap: https://insanitylab.tielogic.xyz/sitemap.xml
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -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" data-reveal><h2>Agenda</h2></div>
|
||||
<div class="section-heading" data-reveal><T tag="agenda.title" as="h2" /></div>
|
||||
<div class="agenda__tabs" role="tablist" data-reveal style="--reveal-delay:.1s">
|
||||
{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" 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. BALANCE. 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
---
|
||||
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">
|
||||
<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>
|
||||
@@ -10,6 +14,12 @@ const { title, eyebrow } = Astro.props;
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
@@ -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} />}
|
||||
@@ -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: 'L’equilibrio 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" data-reveal={b.reverse ? 'right' : 'left'} />
|
||||
<TImg tag={`home.feature.${b.key}.image`} src={b.image} alt={t(`home.feature.${b.key}.title`)} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" data-reveal={b.reverse ? 'right' : 'left'} />
|
||||
<div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}>
|
||||
<h2>{b.title}</h2>
|
||||
<p>{b.text}</p>
|
||||
<a class="btn" href="/programs">Scopri</a>
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -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,20 @@ 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; } }
|
||||
|
||||
@@ -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, i) => (
|
||||
<a class="info__col" href="/programs" style={`background-image:url(${c.image.src});--reveal-delay:${i * 0.15}s`} data-reveal="fade">
|
||||
<span class="info__title">{c.title}</span>
|
||||
<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>
|
||||
|
||||
@@ -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" data-reveal>
|
||||
<h2>Il metodo<br />InsanityLab</h2>
|
||||
<p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} />
|
||||
<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, 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>
|
||||
|
||||
@@ -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" data-reveal="fade">
|
||||
{partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)}
|
||||
{partners.map((p, i) => imgOf(p.image) && <TImg tag={`partner.${i + 1}.image`} src={imgOf(p.image)!} alt={t(`partner.${i + 1}.name`)} height={44} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
---
|
||||
import quoteBg from '../../assets/img/quote-bg.jpg';
|
||||
const quote = 'PERFORMANCE È SPINGERE OLTRE I PROPRI LIMITI, BALANCE È SAPERLI ASCOLTARE, LONGEVITY È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.';
|
||||
const quoteHtml = quote.replace(/(PERFORMANCE|BALANCE|LONGEVITY)/g, '<strong>$1</strong>');
|
||||
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" data-parallax>
|
||||
<div class="quote__bg" style={`background-image:url(${quoteBg.src})`}></div>
|
||||
<div class="quote__bg" style={`background-image:url(${quoteBg})`} data-tag="home.quote.image"></div>
|
||||
<div class="quote__in">
|
||||
<p class="quote__mark">“</p>
|
||||
<blockquote>
|
||||
<div class="quote__marquee">
|
||||
<p class="quote__track">
|
||||
<span class="quote__seg" set:html={quoteHtml} />
|
||||
<span class="quote__seg" aria-hidden="true" set:html={quoteHtml} />
|
||||
<span class="quote__seg" data-tag="home.quote.text" set:html={t('home.quote.text')} />
|
||||
<span class="quote__seg" aria-hidden="true" data-tag="home.quote.text" set:html={t('home.quote.text')} />
|
||||
</p>
|
||||
</div>
|
||||
<cite>Insanitylab</cite>
|
||||
<cite data-tag="home.quote.cite">{t('home.quote.cite')}</cite>
|
||||
</blockquote>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
---
|
||||
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',
|
||||
};
|
||||
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>
|
||||
<h2>Programmi</h2>
|
||||
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.</p>
|
||||
<T tag="home.services.title" as="h2" />
|
||||
<T tag="home.services.body" as="p" />
|
||||
</div>
|
||||
<div class="pgrid">
|
||||
{programs.map((p, i) => (
|
||||
<a class="pgrid__item" href={`/programs/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
|
||||
{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>
|
||||
<h3>{p.title}</h3>
|
||||
<p>{p.subtitle}</p>
|
||||
<T tag={`service.${p.slug}.title`} as="h3" />
|
||||
<T tag={`service.${p.slug}.subtitle`} as="p" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -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" data-reveal>
|
||||
<h2>Team<br />Trainer</h2>
|
||||
<a class="btn" href="/about">Scopri team</a>
|
||||
<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, i) => (
|
||||
{members.map((m, i) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return (
|
||||
<figure class="tteam__card" data-reveal style={`--reveal-delay:${i * 0.12}s`}>
|
||||
<Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
<figcaption>{m.shortName}</figcaption>
|
||||
<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>
|
||||
|
||||
@@ -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,18 +11,18 @@ 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]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
|
||||
<h3>{t.title}</h3>
|
||||
<p>{t.caption}</p>
|
||||
<a class="tcards__link" href={t.cta.href}>Scopri →</a>
|
||||
<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 ? (
|
||||
<div class="tcards__photo" data-reveal="fade" style={`--reveal-delay:${((i % 2) * 0.15 + 0.1).toFixed(2)}s`}>
|
||||
<Image src={img} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
<TImg tag={`training.${tr.id}.image`} src={img} alt={t(`training.${tr.id}.title`)} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
</div>
|
||||
) : null;
|
||||
return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>;
|
||||
|
||||
@@ -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. BALANCE. 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', 'L’equilibrio 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 un’esperienza 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]));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 l’area 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 all’allenamento 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 D’Alessandro", shortName: "Antonello", role: "Trainer – Exercise & Health", founder: false, image: "team-antonello",
|
||||
{ name: "Antonello D’Alessandro", 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 l’ascolto 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 all’equilibrio mente-corpo." },
|
||||
];
|
||||
|
||||
|
||||
@@ -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. Un’esperienza 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;
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
user?: { id: number; username: string };
|
||||
user?: { id: number; username: string; role: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,11 @@ 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>
|
||||
@@ -65,5 +73,147 @@ const { title, description = 'InsanityLab — Performance. Balance. Longevity. A
|
||||
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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 un’esperienza 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
{founders.map((m) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return (
|
||||
<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>
|
||||
{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) => (
|
||||
{staff.map((m) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return (
|
||||
<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>
|
||||
{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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { listUsers } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
const users = listUsers(getDb());
|
||||
const me = Astro.locals.user!;
|
||||
---
|
||||
<Admin title="Utenti">
|
||||
<h1>Utenti</h1>
|
||||
|
||||
<h2>Nuovo utente</h2>
|
||||
<form id="new-user" style="max-width:420px">
|
||||
<input class="afield" name="username" placeholder="Nome utente" required />
|
||||
<input class="afield" name="password" type="text" placeholder="Password (min 8)" required />
|
||||
<select class="afield" name="role">
|
||||
<option value="user">user</option>
|
||||
<option value="superuser">superuser</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button class="abtn" type="submit">Crea</button>
|
||||
<span class="form-msg" id="new-msg"></span>
|
||||
</form>
|
||||
|
||||
<h2 style="margin-top:30px">Elenco</h2>
|
||||
<table>
|
||||
<thead><tr><th>Utente</th><th>Ruolo</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr data-id={u.id}>
|
||||
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
|
||||
<td>
|
||||
<select class="role-sel" data-id={u.id}>
|
||||
{['user', 'superuser', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button class="abtn" data-reset={u.id}>Reset password</button>
|
||||
{u.id !== me.id && <button class="abtn abtn--danger" data-del={u.id}>Elimina</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="form-msg" id="row-msg"></p>
|
||||
</Admin>
|
||||
|
||||
<script>
|
||||
const rowMsg = document.getElementById('row-msg')!;
|
||||
const show = (el: HTMLElement, text: string, ok = false) => {
|
||||
el.textContent = text;
|
||||
el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
|
||||
};
|
||||
|
||||
document.getElementById('new-user')!.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const f = e.target as HTMLFormElement;
|
||||
const body = {
|
||||
username: (f.elements.namedItem('username') as HTMLInputElement).value,
|
||||
password: (f.elements.namedItem('password') as HTMLInputElement).value,
|
||||
role: (f.elements.namedItem('role') as HTMLSelectElement).value,
|
||||
};
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
const msg = document.getElementById('new-msg') as HTMLElement;
|
||||
if (res.ok) { show(msg, 'Creato ✓', true); setTimeout(() => location.reload(), 600); }
|
||||
else show(msg, (await res.json()).error ?? 'Errore');
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
|
||||
sel.addEventListener('change', async () => {
|
||||
const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
|
||||
});
|
||||
if (res.ok) show(rowMsg, 'Ruolo aggiornato ✓', true);
|
||||
else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
|
||||
}));
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
|
||||
b.addEventListener('click', async () => {
|
||||
if (!confirm('Generare una nuova password per questo utente?')) return;
|
||||
const res = await fetch(`/api/admin/users/${b.dataset.reset}/reset-password`, { method: 'POST' });
|
||||
if (res.ok) { const { password } = await res.json(); prompt('Nuova password (copiala e consegnala ora):', password); }
|
||||
else show(rowMsg, (await res.json()).error ?? 'Errore');
|
||||
}));
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||||
b.addEventListener('click', async () => {
|
||||
if (!confirm('Eliminare definitivamente questo utente?')) return;
|
||||
const res = await fetch(`/api/admin/users/${b.dataset.del}`, { method: 'DELETE' });
|
||||
if (res.ok) location.reload();
|
||||
else show(rowMsg, (await res.json()).error ?? 'Errore');
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { applyContentUpdate } from '../../../../lib/content-update';
|
||||
import { invalidateContentCache } from '../../../../lib/content';
|
||||
import { seedByTag } from '../../../../data/content-seed';
|
||||
export const prerender = false;
|
||||
|
||||
const json = (status: number, body: object) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = ({ params }) => {
|
||||
const tag = params.tag ?? '';
|
||||
const row = getDb().prepare('SELECT tag, type, value, guid FROM content_blocks WHERE tag = ?').get(tag) as
|
||||
{ tag: string; type: string; value: string; guid: string | null } | undefined;
|
||||
if (!row) return json(404, { error: 'Tag inesistente.' });
|
||||
return json(200, {
|
||||
tag: row.tag,
|
||||
guid: row.guid ?? '',
|
||||
type: row.type,
|
||||
value: row.value,
|
||||
styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
let data: Record<string, unknown>;
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
if (typeof data !== 'object' || data === null) return json(400, { error: 'Dati non validi.' });
|
||||
const r = applyContentUpdate(getDb(), params.tag ?? '', data, locals.user!.username);
|
||||
if (!r.ok) return json(r.status, { error: r.error });
|
||||
invalidateContentCache();
|
||||
// Il value salvato può differire da quello inviato (sanitizeInline sui tag html):
|
||||
// lo restituiamo così il pannello mostra il dato reale.
|
||||
const row = getDb().prepare('SELECT value FROM content_blocks WHERE tag = ?').get(params.tag) as { value: string };
|
||||
return json(200, { ok: true, value: row.value });
|
||||
};
|
||||