Compare commits
64 Commits
c4cb05e142
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 0d9b645636 | |||
| e54daa105f | |||
| eccc7ee237 | |||
| 3c949da75a | |||
| 6d1963737e | |||
| 29d633c795 | |||
| f0ad162f74 | |||
| f3fb2fe005 | |||
| e9840105a3 | |||
| be4d933ca3 | |||
| ed35491d4c |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
uploads
|
||||
.env
|
||||
.git
|
||||
docs
|
||||
assets-src
|
||||
tests
|
||||
*.md
|
||||
@@ -0,0 +1,23 @@
|
||||
# Stage 1: build
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: runtime
|
||||
FROM node:22-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY scripts ./scripts
|
||||
|
||||
ENV HOST=0.0.0.0 \
|
||||
PORT=4321 \
|
||||
DB_PATH=/app/data/insanitylab.db \
|
||||
UPLOADS_DIR=/app/uploads
|
||||
EXPOSE 4321
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
@@ -4,18 +4,18 @@ Sito vetrina per InsanityLab con blog integrato e form di contatti.
|
||||
|
||||
## Descrizione
|
||||
|
||||
Il progetto realizza un sito web per InsanityLab, combinando una vetrina aziendale con un sistema di blog gestito tramite SQLite e un form di contatti funzionante. L'architettura è costruita su **Astro 5** con adapter **Node.js** per deployment su server, garantendo performance ottimali e SEO-friendly pages.
|
||||
Il progetto realizza un sito web per InsanityLab, combinando una vetrina aziendale con un sistema di blog gestito tramite SQLite e un form di contatti funzionante. L'architettura è costruita su **Astro 7** con adapter **Node.js** per deployment su server, garantendo performance ottimali e SEO-friendly pages.
|
||||
|
||||
## Stack Tecnologico
|
||||
|
||||
- **Framework**: Astro 5
|
||||
- **Framework**: Astro 7
|
||||
- **Runtime**: Node.js (adapter standalone)
|
||||
- **Database**: SQLite (better-sqlite3)
|
||||
- **Autenticazione**: bcryptjs
|
||||
- **Email**: Nodemailer
|
||||
- **Markup Editor**: TipTap (rich text)
|
||||
- **Font System**: @fontsource (Montserrat, Open Sans)
|
||||
- **Sitemap**: @astrojs/sitemap
|
||||
- **Sitemap**: endpoint runtime `/sitemap.xml` (route vetrina, programmi e articoli pubblicati)
|
||||
- **Testing**: Vitest
|
||||
- **Linguaggio**: TypeScript
|
||||
|
||||
@@ -44,12 +44,20 @@ Modifica `.env` con le credenziali SMTP, percorsi database e upload directory se
|
||||
|
||||
| Comando | Descrizione |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Avvia il server di sviluppo (localhost:3000) |
|
||||
| `npm run dev` | Avvia il server di sviluppo (localhost:4321) |
|
||||
| `npm run build` | Compila il progetto per production |
|
||||
| `npm run preview` | Visualizza l'anteprima della build (locale) |
|
||||
| `npm run start` | Avvia il server production (richiede `npm run build` preliminare) |
|
||||
| `npm run test` | Esegui i test con Vitest |
|
||||
| `npm run create-user` | Crea un nuovo utente (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,10 +1,18 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://insanitylab.tielogic.xyz',
|
||||
output: 'server',
|
||||
// Dietro Traefik il TLS termina al proxy: senza allowedDomains Astro ignora
|
||||
// X-Forwarded-Proto/Host e il checkOrigin CSRF respinge i POST (403).
|
||||
security: {
|
||||
allowedDomains: [{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' }],
|
||||
},
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
integrations: [sitemap()],
|
||||
// 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,29 @@
|
||||
services:
|
||||
insanitylab:
|
||||
build: .
|
||||
image: insanitylab-website:latest
|
||||
container_name: insanitylab
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- DB_PATH=/app/data/insanitylab.db
|
||||
- UPLOADS_DIR=/app/uploads
|
||||
volumes:
|
||||
- /opt/docker/insanitylab/data:/app/data
|
||||
- /opt/docker/insanitylab/uploads:/app/uploads
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.insanitylab.rule=Host(`insanitylab.tielogic.xyz`)
|
||||
- traefik.http.routers.insanitylab.tls=true
|
||||
- traefik.http.routers.insanitylab.entrypoints=websecure
|
||||
- traefik.http.routers.insanitylab.tls.certresolver=mytlschallenge
|
||||
- traefik.http.services.insanitylab.loadbalancer.server.port=4321
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
@@ -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,61 @@
|
||||
# Animazioni stile template Prowess — Design
|
||||
|
||||
Data: 2026-07-02 · Stato: approvato dall'operatore
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Il sito InsanityLab, oggi quasi del tutto statico, deve riprendere il linguaggio di animazione del template WordPress "Prowess" (Qode Interactive) da cui deriva il design Figma. L'operatore ha scelto il pacchetto completo: reveal allo scroll, parallax degli sfondi, animazione d'ingresso della hero, effetti hover su card e team, e citazione con testo a scorrimento continuo.
|
||||
|
||||
## Evidenze raccolte dal template
|
||||
|
||||
Dalla demo ufficiale `prowess.qodeinteractive.com` (cassetta.me è in manutenzione):
|
||||
|
||||
- gli elementi appaiono allo scroll con un offset di attivazione di −100 px dal viewport (`qodefElementAppearAmount: -100`);
|
||||
- due sezioni usano parallax di sfondo (`data-parallax-bg-speed="1"`);
|
||||
- la hero è uno Slider Revolution con testi che entrano in sequenza;
|
||||
- card e foto hanno hover con zoom immagine e transizioni morbide.
|
||||
|
||||
## Decisioni
|
||||
|
||||
1. **Implementazione vanilla**: CSS + IntersectionObserver, nessuna libreria. Il sito non ha dipendenze frontend e resta così; costo totale ~2 KB.
|
||||
2. **Citazione**: si mantiene il banner su foto scura del Figma, ma il testo della citazione scorre in orizzontale a loop infinito come il marquee del template (scelta esplicita dell'operatore). La firma "Insanitylab" resta statica.
|
||||
3. **Accessibilità**: con `prefers-reduced-motion: reduce` ogni animazione è disattivata (reveal, marquee, parallax, hero). Senza JavaScript il sito resta interamente visibile: lo stato nascosto si applica solo sotto `html.js`.
|
||||
|
||||
## Architettura
|
||||
|
||||
- **`src/styles/global.css`** — utilities: `[data-reveal]` (stato iniziale `opacity: 0` + `translateY(40px)`, transizione `.9s ease`; varianti `left`, `right`, `fade`; ritardo per stagger via variabile `--reveal-delay` impostata inline), keyframes marquee, blocco `prefers-reduced-motion`.
|
||||
- **`src/layouts/Base.astro`** — uno script inline in `<head>` aggiunge la classe `js` a `<html>` prima del paint (evita flash); uno script bundled attiva l'IntersectionObserver (`rootMargin: 0 0 -100px`, `unobserve` dopo il primo ingresso) e il parallax (`transform: translateY` calcolato su scroll con `requestAnimationFrame`, velocità ~0.3, attivo solo da 992 px in su e senza reduced-motion).
|
||||
- **Componenti** — ricevono attributi `data-reveal` e stagger inline; nessuna modifica strutturale se non il wrapper overflow per gli zoom hover e la ristrutturazione del banner citazione (sfondo su div dedicato trasformabile, testo in marquee con copia `aria-hidden`).
|
||||
|
||||
## Dettaglio per componente
|
||||
|
||||
| Componente | Effetto |
|
||||
|---|---|
|
||||
| HeroSlider | Ingresso al load: righe del titolo slide-up in sequenza (stagger 150 ms), bottone dopo, immagine da destra con fade |
|
||||
| InfoColumns | Reveal fade con stagger 0/150/300 ms |
|
||||
| FeatureBlocks | Immagine entra dal proprio lato, testo dal lato opposto |
|
||||
| QuoteBanner | Sfondo parallax + testo citazione in marquee loop (pausa su hover), firma statica |
|
||||
| TrainingCards | Reveal con stagger; hover zoom foto (wrapper overflow hidden) |
|
||||
| ProgramsGrid | Heading reveal; item con stagger; hover: traslazione -4 px e colore |
|
||||
| Agenda | Reveal heading e tabella |
|
||||
| TeamSection | Head reveal; card con stagger; hover zoom foto |
|
||||
| MethodSteps | Heading reveal; step con stagger |
|
||||
| PartnersStrip | Reveal fade dell'intera striscia |
|
||||
| PageHero (pagine interne) | Reveal del titolo all'ingresso pagina |
|
||||
| Header | Transizione morbida dell'ombra già presente |
|
||||
| Bottoni `.btn` | Freccia trasla a destra su hover |
|
||||
|
||||
## Errori e casi limite
|
||||
|
||||
- JS assente: nessun elemento nascosto (gate su `html.js`).
|
||||
- Reduced motion: contenuto immediatamente visibile, marquee fermo su testo statico multilinea.
|
||||
- Il marquee usa due copie identiche del segmento (`translateX(-50%)`) per loop senza scatti; la seconda copia è `aria-hidden`.
|
||||
- Solo `opacity` e `transform`: nessun layout shift, nessun impatto CLS.
|
||||
|
||||
## Verifica
|
||||
|
||||
Build senza errori, vitest esistenti verdi, controllo visivo locale su homepage e pagine interne, poi deploy su VPS e smoke test produzione.
|
||||
|
||||
## Fuori scope
|
||||
|
||||
Slider hero multi-slide automatico (oggi esiste una sola slide), animazioni admin, contatori animati e BMI calculator (assenti dal design Figma).
|
||||
@@ -0,0 +1,146 @@
|
||||
# Sistema di contenuti taggati e pannello editor
|
||||
|
||||
Data: 2026-07-05
|
||||
Stato: approvato dall'operatore (design), spec in revisione
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Ogni testo e ogni immagine delle pagine vetrina del sito riceve un tag univoco e stabile. I contenuti vengono estratti dal codice e salvati in SQLite, così da poter essere modificati senza intervenire sui sorgenti. Un pannello di amministrazione (`/admin/content`) permette a un utente con ruolo dedicato di modificare testi, stili (tramite preset controllati) e immagini; una modalità "annotate" sovrappone al sito i badge con i nomi dei tag, rendendo immediato individuare l'elemento da modificare.
|
||||
|
||||
Il sistema serve due scopi complementari: nel breve periodo dà all'operatore un vocabolario preciso per dettare modifiche ("cambia `home.hero.title` in…"); nel medio periodo consente al cliente di aggiornare i contenuti in autonomia, senza rischio di rompere il layout.
|
||||
|
||||
## Decisioni prese in fase di design
|
||||
|
||||
| Decisione | Scelta |
|
||||
| --- | --- |
|
||||
| Scopo | Tagging + pannello editor nello stesso progetto |
|
||||
| Ambito modifiche | Testi, immagini, stili tramite preset chiusi (no CSS libero) |
|
||||
| Utenti | Ruolo `admin` (tutto) e ruolo `editor` (solo contenuti) |
|
||||
| Pubblicazione | Pagine vetrina passano a SSR con cache in memoria invalidata al salvataggio |
|
||||
| UX | Ibrido: pannello `/admin/content` + overlay annotate sul sito con link ai form |
|
||||
|
||||
## Architettura
|
||||
|
||||
### 1. Schema dei tag
|
||||
|
||||
Formato gerarchico `pagina.sezione.elemento`, tutto minuscolo, separatore punto, senza spazi:
|
||||
|
||||
- `home.hero.title`, `home.hero.subtitle`, `home.hero.cta`
|
||||
- `about.team.donata.bio`, `about.intro.body`
|
||||
- `programs.pilates-flow.price`, `training.fit-remote.description`
|
||||
- `global.footer.tagline`, `global.header.cta` (elementi condivisi tra pagine)
|
||||
|
||||
I tag sono stabili: una volta assegnati non cambiano nome, perché sono chiave primaria nel DB e riferimento nelle conversazioni. I dati oggi strutturati in `src/data/*.ts` (team, pricing, agenda, method, programs, trainings, partners, site) vengono scomposti in tag granulari con lo stesso schema.
|
||||
|
||||
### 2. Storage (SQLite, DB esistente)
|
||||
|
||||
Nuova tabella nella `SCHEMA` di `src/lib/db.ts`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS content_blocks (
|
||||
tag TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK (type IN ('text','html','image')),
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
styles TEXT NOT NULL DEFAULT '{}', -- JSON, solo chiavi preset
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_by TEXT
|
||||
);
|
||||
```
|
||||
|
||||
- `type = 'text'`: testo semplice, escapato in render.
|
||||
- `type = 'html'`: frammenti con markup minimo (grassetti, a-capo) sanificati con la pipeline già usata dal blog (`src/lib/sanitize.ts`). Usato solo dove il testo attuale contiene markup.
|
||||
- `type = 'image'`: `value` contiene il percorso dell'override caricato (es. `content/home-hero-1.jpg`, relativo a `uploads/`) oppure stringa vuota se vale l'asset originale.
|
||||
|
||||
### 3. Seed e fonte di verità
|
||||
|
||||
`src/data/content-seed.ts` contiene l'inventario completo dei tag con i valori estratti dai testi attuali: `{ tag, type, value, styleOptions }`. Il campo `styleOptions` dichiara quali preset ha senso esporre per quel tag (vedi §6); non finisce nel DB, guida solo il pannello.
|
||||
|
||||
All'avvio (in `createDb`) un passo di sync inserisce nel DB i tag presenti nel seed ma assenti nella tabella (`INSERT OR IGNORE`). Regole:
|
||||
|
||||
- In produzione il DB è la fonte di verità: le modifiche del cliente non vengono mai sovrascritte dal seed.
|
||||
- I tag nuovi arrivano con il codice (aggiunta al seed) e compaiono nel DB al deploy successivo.
|
||||
- I tag rimossi dal seed restano orfani nel DB e vengono ignorati; nessuna cancellazione automatica.
|
||||
- Il seed nel repo funge anche da backup versionato dei testi originali.
|
||||
|
||||
### 4. Rendering
|
||||
|
||||
- Le pagine vetrina (`index`, `about`, `contact`, `training`, `programs/index`, `programs/[slug]`, `404`) passano da `prerender = true` a `prerender = false` (SSR, già attivo per blog e admin con adapter node standalone).
|
||||
- Nuovo modulo `src/lib/content.ts`:
|
||||
- `getContent(tag)` / `getAllContent()`: lettura con cache in memoria (`Map` popolata al primo accesso con una sola query).
|
||||
- `invalidateContentCache()`: svuota la cache; chiamata dall'API di salvataggio. Modifica visibile alla richiesta successiva.
|
||||
- Fallback a cascata: valore nel DB → valore nel seed → stringa vuota con log di warning. Il sito non si rompe mai per un tag mancante.
|
||||
- Nuovi componenti Astro:
|
||||
- `<T tag="home.hero.title" as="h1" class="…" />`: renderizza il contenuto nel tag HTML indicato (`as`, default `span`), applica le classi preset derivate da `styles`, emette `data-tag` per l'overlay annotate. Per `type='text'` il valore è escapato; per `type='html'` è già sanificato al salvataggio.
|
||||
- `<TImg tag="home.hero.image" … />`: se esiste override in DB serve il file da `/uploads/content/…` (route `src/pages/uploads/[...path].ts` già esistente), altrimenti l'asset originale ottimizzato dalla pipeline Astro. Le prop di layout (dimensioni, alt di default, loading) restano nel codice.
|
||||
- La sitemap non cambia (le route restano le stesse); verificare che l'integrazione sitemap continui a emetterle con `output: 'server'` e prerender disattivato, altrimenti passare a una sitemap statica generata a mano.
|
||||
|
||||
### 5. Ruoli utente
|
||||
|
||||
- Migrazione additiva: `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'` (eseguita in `createDb` se la colonna manca; gli utenti esistenti restano admin).
|
||||
- `getSessionUser` ritorna anche `role`; `context.locals.user` lo espone.
|
||||
- Middleware: le route `/admin/content` e `/api/admin/content*` sono accessibili ad `admin` ed `editor`; tutte le altre route `/admin*` e `/api/admin*` restano riservate ad `admin` (l'editor che le richiede riceve redirect a `/admin/content` o 403 sulle API).
|
||||
- Creazione utente editor: script CLI in `scripts/` (pattern già usato per l'admin), credenziali consegnate al cliente fuori banda.
|
||||
|
||||
### 6. Stili preset
|
||||
|
||||
Il DB memorizza solo chiavi simboliche; la mappa chiave → classe CSS vive nel codice (`src/lib/style-presets.ts` + classi in `global.css`). Nessun CSS arbitrario può entrare dal pannello.
|
||||
|
||||
| Gruppo | Chiavi | Note |
|
||||
| --- | --- | --- |
|
||||
| `size` | `s` / `m` / `l` | scala relativa alla dimensione base dell'elemento |
|
||||
| `color` | palette del sito (brand-dark, tan, taupe, bianco, …) | lista chiusa derivata dalle variabili CSS esistenti |
|
||||
| `weight` | `normal` / `bold` | |
|
||||
| `style` | `normal` / `italic` | |
|
||||
| `align` | `left` / `center` / `right` | solo su elementi a blocco |
|
||||
|
||||
Ogni tag dichiara nel seed quali gruppi espone (`styleOptions`): un titolo hero espone size e color, una voce di prezzo magari solo color. Valori non in whitelist vengono rifiutati dall'API al salvataggio.
|
||||
|
||||
### 7. Pannello `/admin/content`
|
||||
|
||||
Pagina SSR protetta, stessa shell di `src/layouts/Admin.astro`:
|
||||
|
||||
- Tag raggruppati per pagina (Home, About, Programs, Training, Contact, Globali), gruppi collassabili, campo di ricerca per nome tag o contenuto.
|
||||
- Per ogni tag: form con campo testo (input o textarea in base alla lunghezza), controlli preset solo per i gruppi dichiarati, upload immagine per i tag `image` (validazione mime/dimensione riusando la logica dell'upload blog) con anteprima e pulsante "ripristina originale".
|
||||
- Salvataggio per singolo tag (pulsante per riga) via `fetch` alle API; feedback inline di esito.
|
||||
- Ancore `id` per tag: `/admin/content#home.hero.title` scrolla ed evidenzia il form corrispondente.
|
||||
- Pulsante "Vedi sul sito con tag" che apre la pagina pubblica in modalità annotate.
|
||||
|
||||
API:
|
||||
|
||||
- `GET /api/admin/content` — elenco completo (per il pannello).
|
||||
- `PUT /api/admin/content/[tag]` — aggiorna `value` e/o `styles`; valida tipo, preset in whitelist, sanifica se `type='html'`; aggiorna `updated_at`/`updated_by`; invalida la cache.
|
||||
- `POST /api/admin/content/[tag]/image` — upload override immagine in `uploads/content/`; `DELETE` sullo stesso path rimuove l'override e ripristina l'asset originale.
|
||||
|
||||
### 8. Overlay annotate
|
||||
|
||||
- Attivazione: query string `?annotate=1` su qualunque pagina vetrina, solo con sessione valida (admin o editor). Senza sessione il parametro è ignorato: nessuna traccia dei tag per i visitatori.
|
||||
- `Base.astro` in modalità annotate inietta un piccolo CSS+JS vanilla (coerente con lo stile del progetto: niente librerie): ogni elemento con `data-tag` riceve un bordo tratteggiato e un badge con il nome del tag; il click sul badge apre `/admin/content#<tag>` in nuova scheda.
|
||||
- Le animazioni reveal restano attive; il badge è posizionato in modo da non alterare il layout (position absolute su wrapper relativo o outline, da verificare in implementazione).
|
||||
|
||||
### 9. Gestione errori
|
||||
|
||||
- Tag mancante nel DB e nel seed: stringa vuota + `console.warn` lato server. La pagina renderizza comunque.
|
||||
- `styles` JSON corrotto nel DB: si ignora e si usa `{}`.
|
||||
- Upload immagine: stessi limiti e messaggi del blog; file orfani in `uploads/content/` tollerati (nessuna garbage collection automatica).
|
||||
- API: 401 senza sessione, 403 per ruolo insufficiente, 400 con messaggio esplicito per tag inesistente, tipo errato o preset fuori whitelist.
|
||||
|
||||
### 10. Test
|
||||
|
||||
- Unit (`vitest`, pattern esistente in `tests/`): content lib (fallback a cascata, invalidazione cache, sync del seed), validazione preset, migrazione colonna `role`.
|
||||
- API: autorizzazione per ruolo (editor può contenuti, non può post; anonimo 401), salvataggio e rilettura, rifiuto preset non in whitelist.
|
||||
- Smoke: le pagine vetrina renderizzano in SSR con contenuti dal DB; overlay annotate presente con sessione e assente senza.
|
||||
|
||||
## Fasi di lavoro previste (indicative per il piano)
|
||||
|
||||
1. Infrastruttura: tabella, migrazione ruoli, content lib con cache, componenti `<T>`/`<TImg>`, preset.
|
||||
2. Estrazione e tagging: inventario completo testi/immagini pagina per pagina, creazione seed, sostituzione nei template e nei componenti (lavoro meccanico ma esteso; è la fase più lunga).
|
||||
3. Pannello + API + overlay annotate + ruolo editor.
|
||||
4. Test, verifica end-to-end locale, deploy e verifica in produzione.
|
||||
|
||||
## Fuori ambito
|
||||
|
||||
- CSS libero per elemento (escluso per scelta).
|
||||
- Versionamento/storico delle modifiche ai contenuti (eventuale evoluzione futura).
|
||||
- Modifica della struttura delle pagine (aggiunta/rimozione sezioni) dal pannello.
|
||||
- Gestione multilingua.
|
||||
- Blog: resta gestito dal suo admin TipTap esistente, fuori dal sistema tag.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Utenti, ruoli, login pubblico, GUID contenuti e modifica inline — Design
|
||||
|
||||
Data: 2026-07-05
|
||||
Progetto: sito InsanityLab (Astro 7 SSR, SQLite/better-sqlite3, live su https://insanitylab.tielogic.xyz)
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Portare il sito da due ruoli (`admin` | `editor`) a un sistema a tre ruoli con login pubblico,
|
||||
identità utente visibile nell'header, blog con ownership per autore, un identificatore GUID stabile
|
||||
per ogni contenuto taggato, e la modifica dei contenuti direttamente in pagina tramite un overlay
|
||||
attivabile con toggle. Nessuna dipendenza frontend nuova: tutto vanilla, in continuità con l'esistente.
|
||||
|
||||
## Ruoli e matrice permessi
|
||||
|
||||
Tre ruoli su `users.role`: `admin`, `superuser`, `user`. Il vecchio ruolo `editor` viene migrato a
|
||||
`superuser`.
|
||||
|
||||
| Capacità | user | superuser | admin |
|
||||
|---|:---:|:---:|:---:|
|
||||
| Login + nome nell'header + logout | ✓ | ✓ | ✓ |
|
||||
| Blog: CRUD **solo dei propri** articoli | ✓ | ✓ | ✓ |
|
||||
| Blog: gestione articoli **di tutti gli autori** | | ✓ | ✓ |
|
||||
| Pannello `/admin/content` | | ✓ | ✓ |
|
||||
| Toggle "Mostra tag" + modifica inline in pagina | | ✓ | ✓ |
|
||||
| Gestione utenti `/admin/users` (crea, reset psw, ruolo, elimina) | | | ✓ |
|
||||
|
||||
La regola: ogni ruolo può fare tutto ciò che può il ruolo inferiore, più le proprie capacità.
|
||||
|
||||
## Modello dati (migrazioni idempotenti in `src/lib/db.ts`, prima di `syncSeed`)
|
||||
|
||||
Tutte additive, nello stile delle migrazioni esistenti (PRAGMA table_info + ALTER, transazione).
|
||||
|
||||
1. **Ruolo `editor` → `superuser`**
|
||||
`UPDATE users SET role = 'superuser' WHERE role = 'editor'`.
|
||||
Idempotente: al secondo giro nessuna riga `editor` resta.
|
||||
|
||||
2. **GUID contenuti** — colonna `content_blocks.guid TEXT`.
|
||||
- `ALTER TABLE content_blocks ADD COLUMN guid TEXT` se assente (PRAGMA table_info).
|
||||
- Backfill: per ogni riga con `guid IS NULL OR guid = ''`, assegna `crypto.randomUUID()`.
|
||||
- Indice univoco: `CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`
|
||||
(creato dopo il backfill, così non fallisce su righe pre-esistenti a guid nullo).
|
||||
- `syncSeed` (in `db.ts` e in `content.ts`): l'INSERT delle righe seed nuove valorizza il guid con
|
||||
`crypto.randomUUID()` alla creazione. Le righe già presenti non vengono toccate (INSERT OR IGNORE),
|
||||
quindi il loro guid è quello del backfill.
|
||||
- Il **tag semantico resta la chiave primaria** e resta l'identificatore usato nel codice (`t('...')`,
|
||||
`<T tag="...">`) e nel pannello. Il GUID si affianca: è l'ID stabile esposto verso l'esterno
|
||||
(attributo `data-guid` nel DOM, badge, eventuale API/link). Nessun tag cambia nome.
|
||||
|
||||
3. **Autore articoli** — colonna `posts.author_id INTEGER` (nullable).
|
||||
- `ALTER TABLE posts ADD COLUMN author_id INTEGER` se assente.
|
||||
- Nessun vincolo FK stretto a livello schema (coerente con lo stile del repo, dove le FK esplicite
|
||||
sono solo su `sessions`); l'integrità è gestita nel codice.
|
||||
- Post storici: `author_id` resta nullo → firma di fallback "Staff InsanityLab" nel blog pubblico.
|
||||
|
||||
## Auth e sessioni (`src/lib/auth.ts`)
|
||||
|
||||
- `Role` diventa `'admin' | 'superuser' | 'user'`. `createUser` accetta i tre ruoli.
|
||||
- `SESSION_COOKIE`, `login`, `getSessionUser`, `logout` restano invariati.
|
||||
- Nuova funzione di autorizzazione che sostituisce `EDITOR_ALLOWED`/`canAccessAdminPath` con una
|
||||
**matrice esplicita per prefisso di rotta**:
|
||||
|
||||
```
|
||||
/admin/users, /api/admin/users* → admin
|
||||
/admin/content, /api/admin/content* → superuser, admin
|
||||
/admin (blog: index/new/edit/posts),
|
||||
/api/admin/posts*, /api/admin/upload → user, superuser, admin
|
||||
/admin/logout, /admin/login → chiunque (login pubblico)
|
||||
```
|
||||
|
||||
Firma proposta: `canAccessAdminPath(role, pathname): boolean`. La landing per redirect dipende dal
|
||||
ruolo (funzione `landingFor(role)`): `user → /admin` (lista blog), `superuser → /admin/content`,
|
||||
`admin → /admin`.
|
||||
- L'hashing password è già in `hashPassword`/`verifyPassword`; `scripts/create-user.mjs` continua a
|
||||
funzionare. Nessuna duplicazione: le API utenti richiamano le funzioni di `auth.ts`.
|
||||
|
||||
## Middleware (`src/middleware.ts`)
|
||||
|
||||
Struttura invariata (protegge `/admin` e `/api/admin`, esclude `/admin/login`). Cambia solo il ramo
|
||||
autorizzazione: usa la nuova matrice. Utente loggato ma senza permesso su una rotta pagina →
|
||||
`redirect(landingFor(role))`; su una rotta API → `403` JSON. `context.locals.user` continua a esporre
|
||||
`{ id, username, role }`.
|
||||
|
||||
## Login pubblico e header
|
||||
|
||||
### Pagina `/login` (`src/pages/login.astro`)
|
||||
- Layout `Base` (stile sito pubblico), **non** `Admin`.
|
||||
- Form username+password. Riusa la logica di `/admin/login`: stesso `rateLimit`, stesso `login()`,
|
||||
stesso set del cookie `session` (httpOnly, sameSite lax, secure in prod, maxAge 7 giorni).
|
||||
- Query `?next=<path>`: dopo login valido redirige a `next` se è un path interno sicuro (inizia con `/`,
|
||||
non `//`, non `/api/`); altrimenti alla home `/`.
|
||||
- Se già loggato: redirige a `next` sicuro o a `/`.
|
||||
- `/admin/login` resta come alias funzionante (nessuna rimozione).
|
||||
|
||||
### Header (`src/components/Header.astro`, già incluso da `Base.astro`)
|
||||
Reso SSR con lettura sessione dal cookie:
|
||||
- **Anonimo**: link "Accedi" → `/login?next=<pagina-corrente>`.
|
||||
- **Loggato**: nome utente + menu a tendina:
|
||||
- "Area riservata" → `landingFor(role)`.
|
||||
- Toggle "Mostra tag" (solo superuser/admin) — vedi sezione successiva.
|
||||
- "Esci" → `/admin/logout`.
|
||||
- Il menu è vanilla (dettaglio/summary o piccolo script già in stile repo), senza librerie.
|
||||
|
||||
## Toggle "Mostra tag" e modifica inline
|
||||
|
||||
### Attivazione
|
||||
- Cookie `showtags=1` (non httpOnly, così lo script client può leggerlo; path `/`), impostato/rimosso
|
||||
dal toggle nel menu header. Solo superuser/admin possono attivarlo (l'endpoint che lo setta verifica
|
||||
il ruolo).
|
||||
- `Base.astro` calcola `showTags = (cookie showtags === '1') && sessione con ruolo superuser|admin` e
|
||||
passa il flag al layout. La persistenza tra pagine è data dal cookie (niente più `?annotate=1` da
|
||||
trascinare nell'URL).
|
||||
- Compat: `?annotate=1` continua a funzionare attivando il cookie/flag per la richiesta corrente.
|
||||
|
||||
### Badge
|
||||
- Quando `showTags` è attivo, lo script badge esistente in `Base.astro` (overlay assoluto sugli
|
||||
elementi con `data-tag`) viene esteso per: mostrare il **nome tag**, esporre il **`data-guid`**, e
|
||||
aggiungere un pulsante matita ✎ cliccabile.
|
||||
- Nessun badge per i visitatori anonimi o per il ruolo `user` (lo script e il markup non vengono
|
||||
nemmeno emessi).
|
||||
|
||||
### Modal di modifica inline
|
||||
Click sulla matita → apre un modal vanilla sovrapposto alla pagina corrente (nessun redirect al
|
||||
pannello):
|
||||
- **Nuovo endpoint** `GET /api/admin/content/[tag]` → `{ tag, guid, type, value, styleOptions }`
|
||||
(`styleOptions` da `seedByTag`/style-presets, come già fa `GET /api/admin/content`).
|
||||
- Il form si costruisce in base a `type`:
|
||||
- `text` / `html` → textarea + eventuali select di stile dagli `styleOptions`; salva con
|
||||
**`PUT /api/admin/content/[tag]`** (esistente, ritorna `{ ok, value }` post-sanitizzazione).
|
||||
- `image` → input file → **`POST /api/admin/content/[tag]/image`** (esistente) e rimozione via
|
||||
**`DELETE`** (esistente).
|
||||
- Al salvataggio riuscito il modal aggiorna il contenuto in-place nel DOM (dal `value` ritornato) e si
|
||||
chiude, senza reload. Errori mostrati dentro il modal.
|
||||
- Il codice del modal (markup + script) è caricato **solo** quando `showTags` è attivo: zero peso per
|
||||
chi non è superuser/admin con toggle attivo.
|
||||
|
||||
### Riuso vs nuovo
|
||||
Le API di scrittura contenuti (PUT, POST/DELETE image) restano **invariate** — sono le stesse usate dal
|
||||
pannello `/admin/content`. L'unica aggiunta backend è il `GET /api/admin/content/[tag]` singolo. Il
|
||||
modal è l'unico componente frontend nuovo.
|
||||
|
||||
## Gestione utenti (`/admin/users`, solo admin)
|
||||
|
||||
### Pagina `src/pages/admin/users.astro` (layout `Admin`)
|
||||
Lista utenti: username, ruolo, ultimo accesso (se ricavabile dalle sessioni). Form/azioni per:
|
||||
crea, reset password, cambia ruolo, elimina.
|
||||
|
||||
### API `src/pages/api/admin/users/*` (admin-only via middleware)
|
||||
- **`POST /api/admin/users`** → crea utente: `username` + `password` + `role`
|
||||
(`user`|`superuser`|`admin`). Rifiuta username duplicato (409/400 con messaggio). Usa `createUser`.
|
||||
- **`POST /api/admin/users/[id]/reset-password`** → genera password casuale, la ritorna **una sola
|
||||
volta** in chiaro nella risposta JSON (l'admin la copia e la consegna). Aggiorna `password_hash`.
|
||||
Non viene mai ri-mostrata né loggata.
|
||||
- **`PUT /api/admin/users/[id]/role`** → cambia ruolo.
|
||||
- **`DELETE /api/admin/users/[id]`** → elimina l'utente.
|
||||
|
||||
### Guardie (invarianti di sicurezza)
|
||||
- Non puoi **eliminare te stesso** (confronto con `locals.user.id`).
|
||||
- Non puoi eliminare **l'ultimo admin** rimasto, né declassarne il ruolo se è l'ultimo admin
|
||||
(query di conteggio `role='admin'` nella stessa transazione).
|
||||
- Eliminazione utente → cascade sulle sue sessioni (già `ON DELETE CASCADE` su `sessions.user_id`).
|
||||
Gli articoli con `author_id` di un utente eliminato: `author_id` resta valorizzato ma "orfano" →
|
||||
firma di fallback "Staff InsanityLab" nel pubblico (nessuna cancellazione a cascata dei post).
|
||||
|
||||
## Blog ownership
|
||||
|
||||
### API `/api/admin/posts*` e `/api/admin/posts/[id]`
|
||||
- **`user`**: la lista in `/admin` è filtrata sui propri `author_id`; `POST` forza
|
||||
`author_id = locals.user.id`; `PUT`/`DELETE` su un post con `author_id` diverso → **403**.
|
||||
- **`superuser` / `admin`**: vedono e gestiscono tutti i post; il `POST` valorizza comunque
|
||||
`author_id = locals.user.id` (l'autore è chi crea).
|
||||
- Implementazione: `createPost` accetta `author_id`; `updatePost`/`deletePost` restano invariati ma le
|
||||
route caricano il post e verificano la ownership prima di procedere (già caricano via `getPostById`
|
||||
su PUT; aggiungere il caricamento su DELETE che oggi non lo fa).
|
||||
|
||||
### Pannello blog (`/admin`, `/admin/new`, `/admin/edit/[id]`)
|
||||
- `user` vede solo i propri articoli; superuser/admin vedono tutti con una colonna "Autore".
|
||||
- Editing di un post altrui bloccato lato UI per `user` (e comunque 403 lato API).
|
||||
|
||||
### Blog pubblico
|
||||
- Firma autore sotto il titolo dell'articolo: nome dell'utente `author_id`; fallback
|
||||
"Staff InsanityLab" quando `author_id` è nullo o orfano.
|
||||
|
||||
## Gestione errori
|
||||
|
||||
- API: JSON `{ error }` con status coerente (400 input non valido, 401 non autenticato — dal
|
||||
middleware, 403 permesso/ownership, 404 risorsa inesistente, 409/400 duplicati).
|
||||
- `next` non sicuro nel login → ignorato, redirect a `/` (niente open redirect).
|
||||
- Cookie `showtags` presente ma ruolo insufficiente → nessun badge (il flag è ricalcolato server-side
|
||||
con il ruolo reale, il cookie da solo non basta).
|
||||
- Reset password / creazione: password mostrata una sola volta, mai persistita in chiaro.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** (`tests/roles.test.ts` esteso): matrice `canAccessAdminPath` per i tre ruoli su tutti i
|
||||
prefissi; `landingFor`.
|
||||
- **Unit ownership**: `user` non può PUT/DELETE post altrui; `POST` imposta `author_id`; guardie
|
||||
gestione utenti (no self-delete, no last-admin delete/demote).
|
||||
- **Unit migrazioni**: su DB in-memory con righe `editor` e `content_blocks` senza guid → dopo
|
||||
`createDb`: ruoli migrati, guid non nulli e univoci, colonna `posts.author_id` presente.
|
||||
- **Smoke** (`scripts/smoke.sh` esteso): `/login` 200; header mostra "Accedi" da anonimo; rotte admin →
|
||||
redirect/403 da `user`; badge con `data-guid` presenti da superuser+ con toggle; modal/badge assenti
|
||||
da anonimo.
|
||||
|
||||
## Fuori scope (YAGNI)
|
||||
|
||||
- Nessun framework/reattività frontend (no Alpine/petite-vue).
|
||||
- Nessuna tabella permessi configurabile: i tre ruoli sono fissi in codice.
|
||||
- Nessuna registrazione self-service: gli utenti li crea l'admin.
|
||||
- Nessun cambio del nome tag semantico: il GUID si affianca, non sostituisce.
|
||||
- Nessuna gestione avatar/profilo utente oltre username + ruolo.
|
||||
@@ -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",
|
||||
@@ -27,6 +26,7 @@
|
||||
"sanitize-html": "^2.17.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#3a2929"/>
|
||||
<path d="M4 18h5l3-8 4 12 3-6h9" fill="none" stroke="#b8a98c" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 255 B |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
Disallow: /api
|
||||
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}`);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
npm run build
|
||||
PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$!
|
||||
trap 'kill $SRV' EXIT
|
||||
sleep 2
|
||||
fail=0
|
||||
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
|
||||
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 |
|
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"><h2>Agenda</h2></div>
|
||||
<div class="agenda__tabs" role="tablist">
|
||||
<div class="section-heading" data-reveal><T tag="agenda.title" as="h2" /></div>
|
||||
<div class="agenda__tabs" role="tablist" data-reveal style="--reveal-delay:.1s">
|
||||
{disciplines.map((d, i) => (
|
||||
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}>{d}</button>
|
||||
<button class:list={['agenda__tab', { 'is-active': i === 0 }]} data-filter={d} role="tab" aria-selected={i === 0 ? 'true' : 'false'}><T tag={`agenda.discipline.${i + 1}`} as="span" /></button>
|
||||
))}
|
||||
</div>
|
||||
<div class="agenda__grid">
|
||||
<div class="agenda__grid" data-reveal style="--reveal-delay:.2s">
|
||||
<div class="agenda__corner"></div>
|
||||
{days.map((d) => <div class="agenda__day">{d}</div>)}
|
||||
{slots.map((slot) => (
|
||||
{days.map((_, i) => <T tag={`agenda.day.${i + 1}`} as="div" class="agenda__day" />)}
|
||||
{slots.map((slot, si) => (
|
||||
<>
|
||||
<div class="agenda__slot">{slot}</div>
|
||||
<T tag={`agenda.slot.${si + 1}`} as="div" class="agenda__slot" />
|
||||
{days.map((_, dayIdx) => {
|
||||
const entry = agenda.find((e) => e.day === dayIdx && e.slot === slot);
|
||||
return entry
|
||||
|
||||
@@ -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>
|
||||
@@ -21,7 +46,7 @@ const path = Astro.url.pathname;
|
||||
.hdr.is-scrolled { box-shadow: 0 2px 14px rgba(0,0,0,.08); }
|
||||
.hdr__in { display: flex; align-items: center; justify-content: space-between; height: var(--header-h); }
|
||||
.hdr__nav { display: flex; gap: 34px; }
|
||||
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||||
.hdr__link { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); transition: color .3s ease; }
|
||||
.hdr__link:hover, .hdr__link.is-active { color: var(--c-accent-dark); }
|
||||
.hdr__burger { display: none; background: none; border: 0; cursor: pointer; padding: 8px; }
|
||||
.hdr__burger span { display: block; width: 22px; height: 2px; background: var(--c-heading); margin: 5px 0; }
|
||||
@@ -31,6 +56,15 @@ const path = Astro.url.pathname;
|
||||
.hdr__link { padding: 12px 0; }
|
||||
.hdr__burger { display: block; }
|
||||
}
|
||||
.hdr__user { display: flex; align-items: center; }
|
||||
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||||
.hdr__login:hover { color: var(--c-accent-dark); }
|
||||
.umenu { position: relative; }
|
||||
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
|
||||
.umenu__btn::-webkit-details-marker { display: none; }
|
||||
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
|
||||
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
|
||||
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -42,4 +76,18 @@ const path = Astro.url.pathname;
|
||||
const open = nav.classList.toggle('is-open');
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
|
||||
const tagsBtn = document.getElementById('toggle-tags');
|
||||
if (tagsBtn) {
|
||||
const on = tagsBtn.dataset.on === '1';
|
||||
tagsBtn.addEventListener('click', async () => {
|
||||
const res = await fetch('/api/admin/showtags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ on: !on }),
|
||||
});
|
||||
if (res.ok) location.reload();
|
||||
else alert('Operazione non riuscita.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
---
|
||||
interface Props { title: string; eyebrow?: string }
|
||||
const { title, eyebrow } = Astro.props;
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
|
||||
interface Props { title: string; eyebrow?: string; bgImage?: ImageMetadata | string }
|
||||
const { title, eyebrow, bgImage } = Astro.props;
|
||||
const bgUrl = typeof bgImage === 'string' ? bgImage : bgImage ? (await getImage({ src: bgImage, width: 1920 })).src : null;
|
||||
---
|
||||
<section class="phero">
|
||||
<div class="container">
|
||||
<section class:list={['phero', { 'phero--photo': bgUrl }]} style={bgUrl ? `background-image:url(${bgUrl})` : undefined}>
|
||||
<div class="container" data-reveal>
|
||||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.phero { background: var(--c-bg-alt); padding-block: 70px; }
|
||||
.phero h1 { text-transform: lowercase; font-weight: 500; margin: 0; }
|
||||
/* Banner scuro come nel Figma: ~460px, titolo bianco maiuscolo ~44px */
|
||||
.phero { background: var(--c-dark); min-height: 380px; display: flex; align-items: center; }
|
||||
.phero .container { width: 100%; }
|
||||
.phero h1 { text-transform: uppercase; font-weight: 500; color: #fff; font-size: clamp(2.2rem, 4vw, 2.75rem); letter-spacing: .04em; margin: 0; }
|
||||
.phero--photo { position: relative; background-size: cover; background-position: center; }
|
||||
.phero--photo::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.6); }
|
||||
.phero--photo .container { position: relative; }
|
||||
@media (max-width: 767px) { .phero { min-height: 240px; } }
|
||||
</style>
|
||||
|
||||
@@ -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,136 @@
|
||||
---
|
||||
import { CATEGORIES } from '../../lib/blog-const';
|
||||
import type { Post } from '../../lib/posts';
|
||||
interface Props { post?: Post }
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
<form id="post-form" data-id={post?.id ?? ''}>
|
||||
<input class="afield" name="title" placeholder="Titolo" required value={post?.title ?? ''} />
|
||||
<input class="afield" name="slug" placeholder="Slug (vuoto = generato dal titolo)" value={post?.slug ?? ''} />
|
||||
<select class="afield" name="category">
|
||||
{CATEGORIES.map((c) => <option value={c} selected={post?.category === c}>{c}</option>)}
|
||||
</select>
|
||||
<textarea class="afield" name="excerpt" placeholder="Estratto (anteprima in lista blog)" maxlength="300">{post?.excerpt ?? ''}</textarea>
|
||||
|
||||
<label>Copertina: <input type="file" id="cover-file" accept="image/*" /></label>
|
||||
<input type="hidden" name="cover" value={post?.cover ?? ''} />
|
||||
<p id="cover-preview">{post?.cover && <img src={post.cover} style="max-width:200px" />}</p>
|
||||
|
||||
<div class="ed-toolbar" id="ed-toolbar">
|
||||
<button type="button" data-cmd="h2">H2</button>
|
||||
<button type="button" data-cmd="h3">H3</button>
|
||||
<button type="button" data-cmd="bold"><b>B</b></button>
|
||||
<button type="button" data-cmd="italic"><i>I</i></button>
|
||||
<button type="button" data-cmd="bullet">• Elenco</button>
|
||||
<button type="button" data-cmd="ordered">1. Elenco</button>
|
||||
<button type="button" data-cmd="quote">" Citazione</button>
|
||||
<button type="button" data-cmd="link">Link</button>
|
||||
<button type="button" data-cmd="image">Immagine</button>
|
||||
</div>
|
||||
<div id="editor" class="ed-body"></div>
|
||||
|
||||
<label><input type="checkbox" name="draft" checked={post ? post.draft === 1 : true} /> Bozza (non visibile sul sito)</label>
|
||||
<p><button class="abtn" type="submit">Salva</button> <span id="save-msg"></span></p>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.ed-toolbar { display: flex; gap: 6px; flex-wrap: wrap; background: #fff; border: 1px solid #ccc; border-bottom: 0; padding: 8px; }
|
||||
.ed-toolbar button { background: #eee; border: 1px solid #ccc; padding: 4px 10px; cursor: pointer; }
|
||||
.ed-toolbar button.is-active { background: #b5a48b; color: #fff; }
|
||||
.ed-body { background: #fff; border: 1px solid #ccc; min-height: 340px; padding: 12px 16px; margin-bottom: 14px; }
|
||||
.ed-body :global(.ProseMirror) { outline: none; min-height: 320px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Link from '@tiptap/extension-link';
|
||||
|
||||
const form = document.getElementById('post-form') as HTMLFormElement;
|
||||
const initial = (document.getElementById('initial-body') as HTMLScriptElement | null)?.textContent ?? '';
|
||||
|
||||
const editor = new Editor({
|
||||
element: document.getElementById('editor')!,
|
||||
extensions: [
|
||||
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
||||
Image,
|
||||
Link.configure({ openOnClick: false }),
|
||||
],
|
||||
content: initial,
|
||||
});
|
||||
|
||||
async function uploadFile(file: File): Promise<string | null> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('/api/admin/upload', { method: 'POST', body: fd });
|
||||
if (!res.ok) { alert('Upload non riuscito.'); return null; }
|
||||
return (await res.json()).url;
|
||||
}
|
||||
|
||||
document.getElementById('ed-toolbar')!.addEventListener('click', async (e) => {
|
||||
const btn = (e.target as HTMLElement).closest('button');
|
||||
if (!btn) return;
|
||||
const c = editor.chain().focus();
|
||||
switch (btn.dataset.cmd) {
|
||||
case 'h2': c.toggleHeading({ level: 2 }).run(); break;
|
||||
case 'h3': c.toggleHeading({ level: 3 }).run(); break;
|
||||
case 'bold': c.toggleBold().run(); break;
|
||||
case 'italic': c.toggleItalic().run(); break;
|
||||
case 'bullet': c.toggleBulletList().run(); break;
|
||||
case 'ordered': c.toggleOrderedList().run(); break;
|
||||
case 'quote': c.toggleBlockquote().run(); break;
|
||||
case 'link': {
|
||||
const url = prompt('URL del link:');
|
||||
if (url) c.setLink({ href: url }).run();
|
||||
break;
|
||||
}
|
||||
case 'image': {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file'; input.accept = 'image/*';
|
||||
input.onchange = async () => {
|
||||
const url = input.files?.[0] && await uploadFile(input.files[0]);
|
||||
if (url) editor.chain().focus().setImage({ src: url }).run();
|
||||
};
|
||||
input.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('cover-file')!.addEventListener('change', async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const url = await uploadFile(file);
|
||||
if (url) {
|
||||
(form.elements.namedItem('cover') as HTMLInputElement).value = url;
|
||||
document.getElementById('cover-preview')!.innerHTML = `<img src="${url}" style="max-width:200px">`;
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const id = form.dataset.id;
|
||||
const body = {
|
||||
title: (form.elements.namedItem('title') as HTMLInputElement).value,
|
||||
slug: (form.elements.namedItem('slug') as HTMLInputElement).value,
|
||||
category: (form.elements.namedItem('category') as HTMLSelectElement).value,
|
||||
excerpt: (form.elements.namedItem('excerpt') as HTMLTextAreaElement).value,
|
||||
cover: (form.elements.namedItem('cover') as HTMLInputElement).value || null,
|
||||
draft: (form.elements.namedItem('draft') as HTMLInputElement).checked,
|
||||
body_html: editor.getHTML(),
|
||||
};
|
||||
const res = await fetch(id ? `/api/admin/posts/${id}` : '/api/admin/posts', {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const msg = document.getElementById('save-msg')!;
|
||||
if (res.ok) {
|
||||
msg.textContent = 'Salvato ✓';
|
||||
if (!id) location.href = '/admin';
|
||||
} else {
|
||||
msg.textContent = (await res.json()).error ?? 'Errore di salvataggio';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import { getDb } from '../../lib/db';
|
||||
import { listRecent, listArchiveMonths } from '../../lib/posts';
|
||||
import { CATEGORIES } from '../../lib/blog-const';
|
||||
const db = getDb();
|
||||
const recent = listRecent(db, 4);
|
||||
const months = listArchiveMonths(db);
|
||||
const monthLabel = (m: string) => new Intl.DateTimeFormat('it-IT', { month: 'long', year: 'numeric' }).format(new Date(`${m}-01`));
|
||||
---
|
||||
<aside class="bside">
|
||||
<h3>Categorie</h3>
|
||||
{CATEGORIES.map((c) => <p><a href={`/blog?categoria=${encodeURIComponent(c)}`}>{c}</a></p>)}
|
||||
<h3>Ultimi articoli</h3>
|
||||
{recent.map((p) => (
|
||||
<p><a href={`/blog/${p.slug}`}>{p.title}</a></p>
|
||||
))}
|
||||
<h3>Archivio</h3>
|
||||
{months.map((m) => <p class="bside__month">{monthLabel(m.month)} ({m.count})</p>)}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.bside h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; margin-top: 30px; }
|
||||
.bside a { text-decoration: none; color: var(--c-text); }
|
||||
.bside a:hover { color: var(--c-accent-dark); }
|
||||
.bside__month { text-transform: capitalize; }
|
||||
</style>
|
||||
@@ -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" />
|
||||
<div class="feat__text">
|
||||
<h2>{b.title}</h2>
|
||||
<p>{b.text}</p>
|
||||
<a class="btn" href="/programs">Scopri</a>
|
||||
<TImg tag={`home.feature.${b.key}.image`} src={b.image} alt={t(`home.feature.${b.key}.title`)} class="feat__img" widths={[500, 900]} sizes="(max-width: 767px) 100vw, 45vw" data-reveal={b.reverse ? 'right' : 'left'} />
|
||||
<div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}>
|
||||
<T tag={`home.feature.${b.key}.title`} as="h2" />
|
||||
<T tag={`home.feature.${b.key}.body`} as="p" />
|
||||
<a class="btn" href="/services">{t(`home.feature.${b.key}.cta`)}</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import hero1 from '../../assets/img/hero-1.jpg';
|
||||
import hero1 from '../../assets/img/hero-1.png';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const slides = [
|
||||
{ title: ['PERFORMANCE.', 'BALANCE.', 'LONGEVITY.'], image: hero1, alt: 'Atleti InsanityLab' },
|
||||
{ titleTags: ['home.hero.title-line-1', 'home.hero.title-line-2', 'home.hero.title-line-3'], image: hero1, alt: 'Atleti InsanityLab' },
|
||||
];
|
||||
---
|
||||
<section class="hero" aria-label="presentazione">
|
||||
@@ -10,10 +12,10 @@ const slides = [
|
||||
<div class:list={['hero__slide', { 'is-active': i === 0 }]}>
|
||||
<div class="container hero__in">
|
||||
<div class="hero__text">
|
||||
<h1>{s.title.map((line) => <span class="hero__line">{line}</span>)}</h1>
|
||||
<a class="btn" href="/programs">Scopri</a>
|
||||
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
|
||||
<a class="btn" href="/services">{t('home.hero.cta')}</a>
|
||||
</div>
|
||||
<Image src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
|
||||
<TImg tag="home.hero.image" src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -30,13 +32,33 @@ const slides = [
|
||||
repeating-linear-gradient(to bottom, transparent 0 10px, rgba(0,0,0,.05) 10px 11px) left top / 40% 60% no-repeat, #fff; }
|
||||
.hero__slide { display: none; }
|
||||
.hero__slide.is-active { display: block; }
|
||||
.hero__in { display: grid; grid-template-columns: 1fr 1.2fr; align-items: center; min-height: calc(100vh - var(--header-h)); gap: 30px; }
|
||||
/* Come nel Figma: figure a piena altezza ancorate in basso a destra,
|
||||
testo sovrapposto sulla sinistra. */
|
||||
.hero__in { position: relative; display: flex; align-items: center; min-height: calc(100vh - var(--header-h)); }
|
||||
.hero__line { display: block; }
|
||||
.hero__text { position: relative; z-index: 1; max-width: 46%; }
|
||||
.hero__text h1 { font-weight: 500; }
|
||||
.hero__img { object-fit: cover; height: 100%; max-height: calc(100vh - var(--header-h)); width: 100%; }
|
||||
.hero__img { position: absolute; right: 0; bottom: 0; height: 100%; width: auto; max-width: 80%; object-fit: contain; object-position: right bottom; }
|
||||
.hero__arrow { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: 0; font-size: 3rem; color: var(--c-heading); cursor: pointer; }
|
||||
.hero__arrow--prev { left: 12px; } .hero__arrow--next { right: 12px; }
|
||||
@media (max-width: 991px) { .hero__in { grid-template-columns: 1fr; padding-block: 40px; min-height: 0; } }
|
||||
@media (max-width: 991px) {
|
||||
.hero__in { flex-direction: column; align-items: flex-start; padding-block: 40px; min-height: 0; gap: 24px; }
|
||||
.hero__text { max-width: none; }
|
||||
.hero__img { position: static; max-width: 100%; height: auto; }
|
||||
}
|
||||
|
||||
/* Ingresso al load stile Slider Revolution del template */
|
||||
@keyframes hero-up { from { opacity: 0; transform: translateY(60px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes hero-side { from { opacity: 0; transform: translateX(60px); } to { opacity: 1; transform: none; } }
|
||||
:global(html.js) .hero__line { opacity: 0; animation: hero-up .8s ease forwards; }
|
||||
:global(html.js) .hero__line:nth-child(1) { animation-delay: .15s; }
|
||||
:global(html.js) .hero__line:nth-child(2) { animation-delay: .3s; }
|
||||
:global(html.js) .hero__line:nth-child(3) { animation-delay: .45s; }
|
||||
:global(html.js) .hero__text .btn { opacity: 0; animation: hero-up .8s ease .7s forwards; }
|
||||
:global(html.js) .hero__img { opacity: 0; animation: hero-side 1s ease .2s forwards; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(html.js) .hero__line, :global(html.js) .hero__text .btn, :global(html.js) .hero__img { opacity: 1; animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
---
|
||||
import { getImage } from 'astro:assets';
|
||||
import infoPerformance from '../../assets/img/info-performance.jpg';
|
||||
import infoBalance from '../../assets/img/info-balance.jpg';
|
||||
import infoLongevity from '../../assets/img/info-longevity.jpg';
|
||||
const cols = [
|
||||
{ title: 'PERFORMANCE.', image: infoPerformance },
|
||||
{ title: 'BALANCE.', image: infoBalance },
|
||||
{ title: 'LONGEVITY.', image: infoLongevity },
|
||||
];
|
||||
import T from '../content/T.astro';
|
||||
import { contentImageUrl } from '../../lib/content';
|
||||
// getImage garantisce l'emissione dell'asset anche quando il file è byte-identico
|
||||
// ad altri import (Astro dedupa e non emette l'originale usato solo come background).
|
||||
const cols = await Promise.all([
|
||||
{ titleTag: 'home.info.col1.title', imageTag: 'home.info.col1.image', image: infoPerformance },
|
||||
{ titleTag: 'home.info.col2.title', imageTag: 'home.info.col2.image', image: infoBalance },
|
||||
{ titleTag: 'home.info.col3.title', imageTag: 'home.info.col3.image', image: infoLongevity },
|
||||
].map(async (c) => ({ ...c, bg: contentImageUrl(c.imageTag, (await getImage({ src: c.image, width: 1200 })).src) })));
|
||||
---
|
||||
<section class="info" aria-label="pilastri">
|
||||
{cols.map((c) => (
|
||||
<a class="info__col" href="/programs" style={`background-image:url(${c.image.src})`}>
|
||||
<span class="info__title">{c.title}</span>
|
||||
{cols.map((c, i) => (
|
||||
<a class="info__col" href="/services" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}>
|
||||
<T tag={c.titleTag} as="span" class="info__title" />
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.info { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.info__col { position: relative; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
|
||||
/* Proporzioni Figma: colonna 413x308 su frame 1209 — l'altezza scala con la larghezza */
|
||||
.info__col { position: relative; aspect-ratio: 413 / 308; min-height: 320px; background-size: cover; background-position: center; display: flex; align-items: center; justify-content: center; text-decoration: none; }
|
||||
.info__col::before { content: ''; position: absolute; inset: 0; background: rgba(30,24,20,.35); transition: background .2s; }
|
||||
.info__col:hover::before { background: rgba(30,24,20,.15); }
|
||||
.info__title { position: relative; color: #fff; font-family: var(--font-heading); font-weight: 600; letter-spacing: .1em; font-size: 1.1rem; }
|
||||
@media (max-width: 767px) { .info { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 767px) { .info { grid-template-columns: 1fr; } .info__col { aspect-ratio: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { methodIntro, methodExtraNote, methodSteps } from '../../data/method';
|
||||
import { methodSteps } from '../../data/method';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
---
|
||||
<section class="section section--alt">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<h2>Il metodo<br />InsanityLab</h2>
|
||||
<p set:html={methodIntro.replace('Metodo Insanitylab', '<strong>Metodo Insanitylab</strong>')} />
|
||||
<div class="section-heading" data-reveal>
|
||||
<T tag="home.method.title" as="h2" />
|
||||
<T tag="method.intro" as="p" />
|
||||
</div>
|
||||
<p class="method__note">{methodExtraNote}</p>
|
||||
<T tag="method.extra-note" as="p" class="method__note" />
|
||||
<div class="method__row">
|
||||
{methodSteps.map((s) => (
|
||||
<div class:list={['method__step', { 'method__step--extra': s.extra }]}>
|
||||
{methodSteps.map((s, i) => (
|
||||
<div class:list={['method__step', { 'method__step--extra': s.extra }]} data-reveal style={`--reveal-delay:${(i * 0.15).toFixed(2)}s`}>
|
||||
<div class="method__circle">
|
||||
{imgOf(s.image) && <Image src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
|
||||
{imgOf(s.image) && <TImg tag={`method.step.${s.n}.image`} src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
|
||||
<span>{s.n}</span>
|
||||
</div>
|
||||
<h3>{s.title}</h3>
|
||||
<p>{s.text}</p>
|
||||
<T tag={`method.step.${s.n}.title`} as="h3" />
|
||||
<T tag={`method.step.${s.n}.text`} as="p" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { partners } from '../../data/partners';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
---
|
||||
<section class="pstrip" aria-label="partner">
|
||||
<div class="container pstrip__in">
|
||||
{partners.map((p) => imgOf(p.image) && <Image src={imgOf(p.image)!} alt={p.name} height={44} />)}
|
||||
<div class="container pstrip__in" data-reveal="fade">
|
||||
{partners.map((p, i) => imgOf(p.image) && <TImg tag={`partner.${i + 1}.image`} src={imgOf(p.image)!} alt={t(`partner.${i + 1}.name`)} height={44} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
import { programs } from '../../data/programs';
|
||||
const icons: Record<string, string> = {
|
||||
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
|
||||
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
|
||||
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
|
||||
'pilates-flow': 'M12 4a2 2 0 110 4 2 2 0 010-4zM6 20c2-6 10-6 12 0M12 8v6',
|
||||
'rehab': 'M12 21C7 17 3 13 3 9a5 5 0 019-3 5 5 0 019 3c0 4-4 8-9 12z',
|
||||
'nutrition': 'M12 3c4 0 7 3 7 8s-3 10-7 10-7-5-7-10 3-8 7-8zm0-1v4',
|
||||
};
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<h2>Programmi</h2>
|
||||
<p>Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.</p>
|
||||
</div>
|
||||
<div class="pgrid">
|
||||
{programs.map((p) => (
|
||||
<a class="pgrid__item" href={`/programs/${p.slug}`}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
|
||||
<h3>{p.title}</h3>
|
||||
<p>{p.subtitle}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
|
||||
.pgrid__item { text-decoration: none; }
|
||||
.pgrid__item svg { margin-inline: auto; }
|
||||
.pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
|
||||
.pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
|
||||
@media (max-width: 767px) { .pgrid { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 480px) { .pgrid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -1,22 +1,44 @@
|
||||
---
|
||||
import quoteBg from '../../assets/img/quote-bg.jpg';
|
||||
import { getImage } from 'astro:assets';
|
||||
import quoteBgSrc from '../../assets/img/quote-bg.jpg';
|
||||
import { t, contentImageUrl } from '../../lib/content';
|
||||
const quoteBg = contentImageUrl('home.quote.image', (await getImage({ src: quoteBgSrc, width: 1920 })).src);
|
||||
---
|
||||
<section class="quote" style={`background-image:url(${quoteBg.src})`}>
|
||||
<div class="container quote__in">
|
||||
<section class="quote" data-parallax>
|
||||
<div class="quote__bg" style={`background-image:url(${quoteBg})`} data-tag="home.quote.image"></div>
|
||||
<div class="quote__in">
|
||||
<p class="quote__mark">“</p>
|
||||
<blockquote>
|
||||
<p><strong>PERFORMANCE</strong> È SPINGERE OLTRE I PROPRI LIMITI, <strong>BALANCE</strong> È SAPERLI ASCOLTARE, <strong>LONGEVITY</strong> È COSTRUIRE NEL TEMPO CIÒ CHE DAVVERO CONTA.</p>
|
||||
<cite>Insanitylab</cite>
|
||||
<div class="quote__marquee">
|
||||
<p class="quote__track">
|
||||
<span class="quote__seg" data-tag="home.quote.text" set:html={t('home.quote.text')} />
|
||||
<span class="quote__seg" aria-hidden="true" data-tag="home.quote.text" set:html={t('home.quote.text')} />
|
||||
</p>
|
||||
</div>
|
||||
<cite data-tag="home.quote.cite">{t('home.quote.cite')}</cite>
|
||||
</blockquote>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.quote { position: relative; background-size: cover; background-position: center; text-align: center; }
|
||||
.quote::before { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
|
||||
.quote__in { position: relative; padding-block: 110px; max-width: 820px; }
|
||||
.quote { position: relative; overflow: hidden; text-align: center; }
|
||||
/* Sfondo su div dedicato, esteso oltre i bordi per il margine di corsa del parallax */
|
||||
.quote__bg { position: absolute; inset: -120px 0; background-size: cover; background-position: center; transform: translateY(var(--parallax-y, 0)); will-change: transform; }
|
||||
.quote__bg::after { content: ''; position: absolute; inset: 0; background: rgba(46,32,24,.82); }
|
||||
.quote__in { position: relative; padding-block: 110px; }
|
||||
.quote__mark { color: #fff; font-size: 4rem; font-family: var(--font-heading); margin: 0; line-height: 1; }
|
||||
.quote blockquote { margin: 0; }
|
||||
.quote blockquote p { color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
|
||||
/* Marquee: testo a scorrimento infinito come il ticker del template */
|
||||
.quote__marquee { overflow: hidden; white-space: nowrap; }
|
||||
.quote__track { display: inline-flex; margin: 0; animation: quote-scroll 30s linear infinite; will-change: transform; }
|
||||
.quote__marquee:hover .quote__track { animation-play-state: paused; }
|
||||
.quote__seg { padding-right: 4em; color: #fff; font-family: var(--font-heading); font-size: clamp(1.1rem, 2.4vw, 1.6rem); letter-spacing: .06em; line-height: 1.6; }
|
||||
@keyframes quote-scroll { to { transform: translateX(-50%); } }
|
||||
.quote cite { display: block; margin-top: 24px; color: var(--c-accent); font-style: normal; font-family: var(--font-heading); font-size: .75rem; letter-spacing: .3em; text-transform: uppercase; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.quote__track { animation: none; white-space: normal; display: block; max-width: 820px; margin-inline: auto; padding-inline: 20px; }
|
||||
.quote__seg { padding-right: 0; }
|
||||
.quote__seg[aria-hidden] { display: none; }
|
||||
.quote__bg { transform: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import { services } from '../../data/services';
|
||||
import { serviceIcons as icons } from '../../data/service-icons';
|
||||
import T from '../content/T.astro';
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading" data-reveal>
|
||||
<T tag="home.services.title" as="h2" />
|
||||
<T tag="home.services.body" as="p" />
|
||||
</div>
|
||||
<div class="pgrid">
|
||||
{services.map((p, i) => (
|
||||
<a class="pgrid__item" href={`/services/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
|
||||
<T tag={`service.${p.slug}.title`} as="h3" />
|
||||
<T tag={`service.${p.slug}.subtitle`} as="p" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.pgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 50px 40px; text-align: center; }
|
||||
.pgrid__item { text-decoration: none; transition: transform .3s ease; }
|
||||
.pgrid__item:hover { transform: translateY(-4px); }
|
||||
.pgrid__item:hover h3 { color: var(--c-accent-dark); }
|
||||
.pgrid__item h3 { transition: color .3s ease; }
|
||||
.pgrid__item svg { margin-inline: auto; }
|
||||
.pgrid__item h3 { text-transform: uppercase; letter-spacing: .12em; font-size: .95rem; margin: 18px 0 6px; }
|
||||
.pgrid__item p { font-size: .85rem; color: var(--c-text-light); margin: 0; }
|
||||
@media (max-width: 767px) { .pgrid { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 480px) { .pgrid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -1,24 +1,29 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { team, homeTeam } from '../../data/team';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/team-*.{jpg,png}', { eager: true });
|
||||
const members = homeTeam.map((short) => team.find((m) => m.shortName === short)!).filter((m) => m.image);
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(`${name}.`))?.[1].default;
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="tteam__head">
|
||||
<h2>Team<br />Trainer</h2>
|
||||
<a class="btn" href="/about">Scopri team</a>
|
||||
<div class="tteam__head" data-reveal>
|
||||
<T tag="home.team.title" as="h2" />
|
||||
<a class="btn" href="/about"><T tag="home.team.cta" as="span" /></a>
|
||||
</div>
|
||||
<div class="tteam__grid">
|
||||
{members.map((m) => (
|
||||
<figure class="tteam__card">
|
||||
<Image src={imgOf(m.image!)!} alt={m.name} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
<figcaption>{m.shortName}</figcaption>
|
||||
{members.map((m, i) => {
|
||||
const slug = m.shortName.toLowerCase();
|
||||
return (
|
||||
<figure class="tteam__card" data-reveal style={`--reveal-delay:${i * 0.12}s`}>
|
||||
<TImg tag={`team.${slug}.image`} src={imgOf(m.image!)!} alt={t(`team.${slug}.name`)} widths={[300, 500]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
<T tag={`team.${slug}.short-name`} as="figcaption" />
|
||||
</figure>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -27,8 +32,9 @@ const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(
|
||||
.tteam__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px; }
|
||||
.tteam__head h2 { text-transform: uppercase; letter-spacing: .1em; margin: 0; }
|
||||
.tteam__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
||||
.tteam__card { position: relative; margin: 0; }
|
||||
.tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; }
|
||||
.tteam__card { position: relative; margin: 0; overflow: hidden; }
|
||||
.tteam__card img { aspect-ratio: 3/4; object-fit: cover; width: 100%; transition: transform .5s ease; }
|
||||
.tteam__card:hover img { transform: scale(1.06); }
|
||||
.tteam__card figcaption { position: absolute; right: 10px; bottom: 14px; background: #fff; padding: 10px 6px; writing-mode: vertical-rl; font-family: var(--font-heading); font-size: .62rem; letter-spacing: .3em; text-transform: uppercase; color: var(--c-accent-dark); }
|
||||
@media (max-width: 767px) { .tteam__grid { grid-template-columns: 1fr 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { trainings } from '../../data/trainings';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/training-*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
const tones = ['taupe', 'tan', 'dark', 'charcoal'];
|
||||
@@ -9,16 +11,20 @@ const tones = ['taupe', 'tan', 'dark', 'charcoal'];
|
||||
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
|
||||
---
|
||||
<section class="tcards">
|
||||
{trainings.map((t, i) => {
|
||||
const img = imgOf(t.image);
|
||||
{trainings.map((tr, i) => {
|
||||
const img = imgOf(tr.image);
|
||||
const card = (
|
||||
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]}>
|
||||
<h3>{t.title}</h3>
|
||||
<p>{t.caption}</p>
|
||||
<a class="tcards__link" href={t.cta.href}>Scopri →</a>
|
||||
<div class:list={['tcards__cell', `tcards__cell--${tones[i]}`]} data-reveal="fade" style={`--reveal-delay:${(i % 2) * 0.15}s`}>
|
||||
<T tag={`training.${tr.id}.title`} as="h3" />
|
||||
<T tag={`training.${tr.id}.caption`} as="p" />
|
||||
<a class="tcards__link" href={tr.cta.href}><T tag="home.training-cards.link-label" as="span" /> →</a>
|
||||
</div>
|
||||
);
|
||||
const photo = img ? <Image src={img} alt={t.title} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" /> : null;
|
||||
const photo = img ? (
|
||||
<div class="tcards__photo" data-reveal="fade" style={`--reveal-delay:${((i % 2) * 0.15 + 0.1).toFixed(2)}s`}>
|
||||
<TImg tag={`training.${tr.id}.image`} src={img} alt={t(`training.${tr.id}.title`)} class="tcards__img" widths={[400, 700]} sizes="(max-width: 767px) 50vw, 25vw" />
|
||||
</div>
|
||||
) : null;
|
||||
return isSwapRow(i) ? <>{photo}{card}</> : <>{card}{photo}</>;
|
||||
})}
|
||||
</section>
|
||||
@@ -37,6 +43,8 @@ const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
|
||||
.tcards__cell h3 { text-transform: uppercase; letter-spacing: .12em; }
|
||||
.tcards__cell p { font-size: .88rem; }
|
||||
.tcards__link { color: #fff; font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; margin-top: 18px; }
|
||||
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; }
|
||||
.tcards__photo { overflow: hidden; }
|
||||
.tcards__img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 1; transition: transform .5s ease; }
|
||||
.tcards__photo:hover .tcards__img { transform: scale(1.06); }
|
||||
@media (max-width: 767px) { .tcards { grid-template-columns: repeat(2, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { StyleGroup } from '../lib/style-presets';
|
||||
import { site } from './site';
|
||||
import { trainings } from './trainings';
|
||||
import { team } from './team';
|
||||
import { methodIntro, methodExtraNote, methodSteps } from './method';
|
||||
import { partners } from './partners';
|
||||
import { disciplines, days, slots } from './agenda';
|
||||
import { 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, []),
|
||||
|
||||
// --- 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,7 +13,7 @@ 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" },
|
||||
],
|
||||
@@ -23,12 +23,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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
interface Props { title: string }
|
||||
const { title } = Astro.props;
|
||||
const user = Astro.locals.user;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — Admin InsanityLab</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<style is:global>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #f4f2ef; color: #333; }
|
||||
.abar { background: #3a2d26; color: #fff; padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.abar a { color: #d9cfc4; text-decoration: none; margin-left: 16px; }
|
||||
.awrap { max-width: 960px; margin: 30px auto; padding: 0 20px; }
|
||||
.abtn { background: #b5a48b; color: #fff; border: 0; padding: 10px 18px; cursor: pointer; font-size: .9rem; text-decoration: none; display: inline-block; }
|
||||
.abtn--danger { background: #a33; }
|
||||
.afield { width: 100%; padding: 10px; margin-bottom: 14px; border: 1px solid #ccc; box-sizing: border-box; font: inherit; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; }
|
||||
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>
|
||||
{(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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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">
|
||||
@@ -22,12 +30,190 @@ const { title, description = 'InsanityLab — Performance. Balance. Longevity. A
|
||||
<meta property="og:title" content={`${title} — InsanityLab`} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link rel="sitemap" href="/sitemap.xml" />
|
||||
<script is:inline>document.documentElement.classList.add('js');</script>
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
<Header user={user} showTags={showTags} canTags={canTags} />
|
||||
<main><slot /></main>
|
||||
<Footer />
|
||||
<script>
|
||||
// Reveal allo scroll: trigger a -100px dal viewport come il template Prowess.
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add('is-inview');
|
||||
io.unobserve(e.target);
|
||||
// A reveal concluso l'attributo va tolto, così la transition del reveal
|
||||
// non rallenta gli hover su transform del componente.
|
||||
e.target.addEventListener('transitionend', () => {
|
||||
e.target.removeAttribute('data-reveal');
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}, { rootMargin: '0px 0px -100px 0px' });
|
||||
document.querySelectorAll('[data-reveal], .section-heading').forEach((el) => io.observe(el));
|
||||
|
||||
const parallaxEls = document.querySelectorAll<HTMLElement>('[data-parallax]');
|
||||
if (parallaxEls.length && matchMedia('(min-width: 992px)').matches
|
||||
&& !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
let raf = 0;
|
||||
const update = () => {
|
||||
raf = 0;
|
||||
for (const el of parallaxEls) {
|
||||
const r = el.getBoundingClientRect();
|
||||
// Clamp entro il margine di sfondo (inset -120px) per non scoprire i bordi
|
||||
const y = Math.max(-110, Math.min(110, (r.top + r.height / 2 - innerHeight / 2) * -0.3));
|
||||
el.style.setProperty('--parallax-y', `${y.toFixed(1)}px`);
|
||||
}
|
||||
};
|
||||
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
|
||||
update();
|
||||
}
|
||||
</script>
|
||||
{showTags && (
|
||||
<style is:global>
|
||||
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
|
||||
.tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
|
||||
background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; }
|
||||
.tag-badge:hover { opacity: 1; }
|
||||
.tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; }
|
||||
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; }
|
||||
.tag-modal[hidden] { display: none; }
|
||||
.tag-modal__box { background: #fff; color: #222; width: min(560px, 92vw); max-height: 88vh; overflow: auto; padding: 20px; border-radius: 4px; }
|
||||
.tag-modal__box h3 { margin: 0 0 4px; font: 600 14px monospace; }
|
||||
.tag-modal__box .guid { font: 11px monospace; color: #888; margin: 0 0 14px; }
|
||||
.tag-modal__box textarea { width: 100%; min-height: 140px; font: inherit; padding: 8px; box-sizing: border-box; }
|
||||
.tag-modal__box select { margin: 6px 8px 6px 0; }
|
||||
.tag-modal__row { display: flex; gap: 10px; margin-top: 14px; }
|
||||
.tag-modal__row button { padding: 9px 16px; border: 0; cursor: pointer; }
|
||||
.tag-modal__save { background: #3a2d26; color: #fff; }
|
||||
.tag-modal__cancel { background: #ddd; }
|
||||
.tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; }
|
||||
</style>
|
||||
<div class="tag-modal" id="tag-modal" hidden>
|
||||
<div class="tag-modal__box">
|
||||
<h3 id="tm-tag"></h3>
|
||||
<p class="guid" id="tm-guid"></p>
|
||||
<div id="tm-body"></div>
|
||||
<div class="tag-modal__msg" id="tm-msg"></div>
|
||||
<div class="tag-modal__row">
|
||||
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
|
||||
<button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const STYLE_GROUPS = { size: ['s','m','l'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right'] };
|
||||
const modal = document.getElementById('tag-modal');
|
||||
const tmTag = document.getElementById('tm-tag');
|
||||
const tmGuid = document.getElementById('tm-guid');
|
||||
const tmBody = document.getElementById('tm-body');
|
||||
const tmMsg = document.getElementById('tm-msg');
|
||||
const tmSave = document.getElementById('tm-save');
|
||||
let current = null; // { tag, type, el }
|
||||
|
||||
function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
|
||||
document.getElementById('tm-cancel').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
||||
|
||||
async function openModal(tag, el) {
|
||||
tmMsg.textContent = '';
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
|
||||
if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
|
||||
data = await res.json();
|
||||
} catch { alert('Errore di rete.'); return; }
|
||||
current = { tag: data.tag, type: data.type, el };
|
||||
tmTag.textContent = data.tag;
|
||||
tmGuid.textContent = data.guid || '';
|
||||
tmBody.innerHTML = '';
|
||||
if (data.type === 'image') {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
|
||||
tmBody.appendChild(inp);
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del';
|
||||
del.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
|
||||
tmBody.appendChild(del);
|
||||
del.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
|
||||
if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.id = 'tm-value'; ta.value = data.value;
|
||||
tmBody.appendChild(ta);
|
||||
(data.styleOptions || []).forEach((group) => {
|
||||
const opts = STYLE_GROUPS[group];
|
||||
if (!opts) return;
|
||||
const sel = document.createElement('select');
|
||||
sel.dataset.group = group;
|
||||
sel.className = 'tm-style';
|
||||
const none = document.createElement('option');
|
||||
none.value = ''; none.textContent = group + ': —';
|
||||
sel.appendChild(none);
|
||||
opts.forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = group + ': ' + v; sel.appendChild(o); });
|
||||
tmBody.appendChild(sel);
|
||||
});
|
||||
}
|
||||
modal.hidden = false;
|
||||
}
|
||||
|
||||
tmSave.addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
tmMsg.textContent = '';
|
||||
if (current.type === 'image') {
|
||||
const file = document.getElementById('tm-file').files[0];
|
||||
if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
|
||||
if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
|
||||
return;
|
||||
}
|
||||
const value = document.getElementById('tm-value').value;
|
||||
const styles = {};
|
||||
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
|
||||
const body = { value };
|
||||
if (Object.keys(styles).length) body.styles = styles;
|
||||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
|
||||
const saved = await r.json();
|
||||
// Aggiorna in-place il contenuto nel DOM.
|
||||
if (current.type === 'html') current.el.innerHTML = saved.value;
|
||||
else current.el.textContent = saved.value;
|
||||
closeModal();
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-tag]').forEach((el) => {
|
||||
const tag = el.dataset.tag;
|
||||
const guid = el.dataset.guid || '';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'tag-badge';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
|
||||
const edit = document.createElement('button');
|
||||
edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
|
||||
edit.addEventListener('click', () => openModal(tag, el));
|
||||
badge.appendChild(label); badge.appendChild(edit);
|
||||
document.body.appendChild(badge);
|
||||
const place = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
badge.style.top = window.scrollY + r.top - 16 + 'px';
|
||||
badge.style.left = window.scrollX + r.left + 'px';
|
||||
};
|
||||
place();
|
||||
addEventListener('scroll', place, { passive: true });
|
||||
addEventListener('resize', place);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,6 @@
|
||||
export const CATEGORIES = [
|
||||
'Articoli tecnici & scientifici',
|
||||
'Contenuti educativi',
|
||||
'LinkedIn / Insight',
|
||||
'Eventi & Presidi',
|
||||
] as const;
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface ContactData {
|
||||
firstName: string; lastName: string; phone: string;
|
||||
email: string; message: string; website: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function str(v: unknown, max: number): string {
|
||||
return typeof v === 'string' ? v.trim().slice(0, max + 1) : '';
|
||||
}
|
||||
|
||||
export function validateContact(data: unknown):
|
||||
| { ok: true; value: ContactData }
|
||||
| { ok: false; error: string } {
|
||||
if (typeof data !== 'object' || data === null) return { ok: false, error: 'Dati non validi.' };
|
||||
const d = data as Record<string, unknown>;
|
||||
const value: ContactData = {
|
||||
firstName: str(d.firstName, 100),
|
||||
lastName: str(d.lastName, 100),
|
||||
phone: str(d.phone, 40),
|
||||
email: str(d.email, 200),
|
||||
message: str(d.message, 5000),
|
||||
website: str(d.website, 200),
|
||||
};
|
||||
if (value.website) return { ok: false, error: 'Richiesta non valida.' };
|
||||
if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
|
||||
if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' };
|
||||
if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' };
|
||||
if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' };
|
||||
return { ok: true, value };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getEnv } from './env';
|
||||
import type { ContactData } from './contact';
|
||||
|
||||
export async function sendContactEmail(data: ContactData): Promise<void> {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: getEnv('SMTP_HOST'),
|
||||
port: Number(getEnv('SMTP_PORT', '587')),
|
||||
secure: Number(getEnv('SMTP_PORT', '587')) === 465,
|
||||
auth: { user: getEnv('SMTP_USER'), pass: getEnv('SMTP_PASS') },
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: getEnv('CONTACT_FROM'),
|
||||
to: getEnv('CONTACT_TO', 'info@insanitylab.it'),
|
||||
replyTo: data.email,
|
||||
subject: `Nuovo contatto dal sito: ${data.firstName} ${data.lastName}`.trim(),
|
||||
text: [
|
||||
`Nome: ${data.firstName} ${data.lastName}`.trim(),
|
||||
data.phone && `Telefono: ${data.phone}`,
|
||||
`E-mail: ${data.email}`,
|
||||
'',
|
||||
data.message,
|
||||
].filter(Boolean).join('\n'),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { slugify } from './slug';
|
||||
import { sanitizeHtml } from './sanitize';
|
||||
import { CATEGORIES } from './blog-const';
|
||||
|
||||
export function parsePostBody(data: Record<string, unknown>) {
|
||||
const title = String(data.title ?? '').trim();
|
||||
if (!title) return { error: 'Il titolo è obbligatorio.' } as const;
|
||||
const category = String(data.category ?? '');
|
||||
if (!(CATEGORIES as readonly string[]).includes(category)) return { error: 'Categoria non valida.' } as const;
|
||||
const slug = slugify(String(data.slug ?? '') || title);
|
||||
if (!slug) return { error: 'Slug non valido.' } as const;
|
||||
return {
|
||||
value: {
|
||||
title, slug, category,
|
||||
excerpt: String(data.excerpt ?? '').trim().slice(0, 300),
|
||||
body_html: sanitizeHtml(String(data.body_html ?? '')),
|
||||
cover: data.cover ? String(data.cover) : null,
|
||||
draft: Boolean(data.draft),
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||