Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b34ee1697 | |||
| 2468ce95f7 | |||
| 9966ec3616 | |||
| 140146fce1 | |||
| 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 | |||
| c4cb05e142 | |||
| ba89568a38 | |||
| 45e5214417 | |||
| 2fdd1b9a7f | |||
| ac75720bd9 | |||
| 50ce224d11 | |||
| 6caba6fee1 | |||
| d3b5e67132 | |||
| 42d603b55b | |||
| c9a9ff91fd | |||
| 4445c422f8 | |||
| 810e5d8dcd | |||
| 551957e2e1 | |||
| d8ef59e370 |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
uploads
|
||||
.env
|
||||
.git
|
||||
docs
|
||||
assets-src
|
||||
tests
|
||||
*.md
|
||||
@@ -0,0 +1,247 @@
|
||||
# Task 2 Report — Estrazione e mappatura asset immagine da Figma
|
||||
|
||||
**Data:** 2026-07-01
|
||||
**Fonte:** `../FigmaDoc/Web Insanity.fig` (60 immagini uniche + ~66 duplicati = 126 estratte)
|
||||
**Destinazione:** `src/assets/img/` (31 file) + `public/img/` (2 loghi)
|
||||
|
||||
---
|
||||
|
||||
## Mappature completate
|
||||
|
||||
### Hero
|
||||
| File destinazione | Hash sorgente | Descrizione |
|
||||
|---|---|---|
|
||||
| `hero-1.jpg` | `8b71f158...` | Uomo con palla medica (medicine ball) che la tiene verso donna in posizione crunch, palestra scura — "uomo con palla medica + donna" ✓ |
|
||||
|
||||
### Info sezioni (PERFORMANCE / BALANCE / LONGEVITY)
|
||||
| File destinazione | Hash sorgente | Descrizione |
|
||||
|---|---|---|
|
||||
| `info-performance.jpg` | `0f803439...` | Uomo muscoloso in planche su sfondo grigio neutro |
|
||||
| `info-balance.jpg` | `432d397a...` | Due donne sedute schiena a schiena in abiti sportivi beige/neutri |
|
||||
| `info-longevity.jpg` | `d7fcf0aa...` | Donna anziana in posizione push-up — longevità attiva |
|
||||
|
||||
### Feature sezioni (immagini più grandi per ciascun pilastro)
|
||||
| File destinazione | Hash sorgente | Descrizione |
|
||||
|---|---|---|
|
||||
| `feature-performance.jpg` | `6d602573...` | Uomo con chalk sulle mani — preparazione sollevamento pesi, performance atletica |
|
||||
| `feature-balance.jpg` | `16e611de...` | Gruppo yoga all'aperto al tramonto — equilibrio e benessere |
|
||||
| `feature-longevity.jpg` | `98f1f8d2...` | Donna anziana in piedi con bottiglia d'acqua, outfit sportivo beige — longevità sana |
|
||||
|
||||
### Quote background
|
||||
| File destinazione | Hash sorgente | Descrizione |
|
||||
|---|---|---|
|
||||
| `quote-bg.jpg` | `28e8157d...` | Scritta "PERFORMANCE. BALANCE. LONGEVITY." su muro di intonaco — elemento visivo identitario |
|
||||
|
||||
### Training programs
|
||||
| File destinazione | Hash sorgente | Descrizione |
|
||||
|---|---|---|
|
||||
| `training-one-to-one.jpg` | `85808a0c...` | Trainer e cliente a colloquio in palestra, tablet in mano — consulenza personalizzata |
|
||||
| `training-small-groups.jpg` | `bb632b8d...` | Due donne con manubri leggeri in posizione atletica — small group training |
|
||||
| `training-performance-class.jpg` | `91aa82b5...` | Grande gruppo in sala fitness/danza con palle fitball — performance class |
|
||||
| `training-fit-remote.jpg` | `86c1363b...` | Donna a casa sul tappetino con laptop — allenamento remoto online |
|
||||
|
||||
### Team Trainer (homepage — 4 ritratti luce dorata)
|
||||
| File destinazione | Hash sorgente | Persona identificata |
|
||||
|---|---|---|
|
||||
| `team-donata.jpg` | `0b6cf694...` | Donna capelli lunghi scuri dritti, luce dorada laterale — Donata ✓ |
|
||||
| `team-nicola.jpg` | `1cc33c41...` | Uomo barba folta, turtleneck scuro, luce dorata — Nicola ✓ |
|
||||
| `team-eleonora.jpg` | `a75ca105...` | Donna capelli biondi/chiari, stessa luce dorata — Eleonora ✓ |
|
||||
| `team-antonello.jpg` | `e3c84f99...` | Uomo occhiali tondi, capelli rasatissimi, luce dorata — Antonello ✓ |
|
||||
|
||||
### Il Metodo (4 passi circolari)
|
||||
| File destinazione | Hash sorgente | Step identificato |
|
||||
|---|---|---|
|
||||
| `method-step-1.jpg` | `88cfcd5f...` | Calibro plicometro su addome — valutazione composizione corporea |
|
||||
| `method-step-2.jpg` | `d21671bb...` | Donna in cucina con verdure fresche e metro — pianificazione nutrizionale |
|
||||
| `method-step-3.jpg` | `a3e84d7d...` | Silhouette uomo in palestra in controluce — sessione allenamento |
|
||||
| `method-step-4.jpg` | `e50b6f78...` | Scarpe su tapis roulant — monitoraggio continuo e progressione |
|
||||
|
||||
### Partner loghi
|
||||
| File destinazione | Hash sorgente | Logo identificato |
|
||||
|---|---|---|
|
||||
| `partner-edenred.png` | `bee22f37...` | "Ticket Welfare Edenred" — logo rosso/blu su sfondo trasparente ✓ |
|
||||
| `partner-welbee.png` | `cf079250...` | "welbee" — logo verde su sfondo bianco ✓ |
|
||||
| `partner-aon.png` | `014a7708...` | "Aon" — logo rosso su sfondo bianco ✓ |
|
||||
| `partner-howden.png` | `872c3679...` | "HOWDEN" — logo blu su sfondo bianco ✓ |
|
||||
|
||||
### Loghi InsanityLab (public/img/)
|
||||
| File destinazione | Sorgente | Note |
|
||||
|---|---|---|
|
||||
| `logo-dark.png` | `../Dati/logo-dark.png` | **Fallback** — logo non presente nel file Figma |
|
||||
| `logo-light.png` | `../Dati/logo-light.png` | **Fallback** — logo non presente nel file Figma |
|
||||
|
||||
### About page (bonus — non richiesti dal brief)
|
||||
| File destinazione | Hash sorgente | Descrizione |
|
||||
|---|---|---|
|
||||
| `about-exterior.jpg` | `8ffc5f7a...` | Facciata esterna palestra/studio InsanityLab, sera |
|
||||
| `about-team-group.jpg` | `32ecd00a...` | Foto di gruppo team InsanityLab in bianco e nero |
|
||||
| `about-training-group.jpg` | `d4608b10...` | Gruppo che salta con kettlebell in sala beige |
|
||||
| `about-trainer-1.jpg` | `dafcc193...` | Trainer donna capelli scuri ondulati, luce dorata |
|
||||
| `about-trainer-2.jpg` | `9c50f1d3...` | Trainer donna capelli scuri più corti, matura, luce dorata |
|
||||
| `about-trainer-3.jpg` | `c51a49b9...` | Trainer donna capelli neri lisci, sorriso, sfondo chiaro |
|
||||
| `about-trainer-4.jpg` | `3341a11b...` | Trainer uomo giovane senza barba, turtleneck scuro |
|
||||
|
||||
---
|
||||
|
||||
## Immagini attese ma non trovate nel Figma
|
||||
|
||||
| Nome atteso | Motivo mancanza | Azione intrapresa |
|
||||
|---|---|---|
|
||||
| `logo-dark.png` | Assente in `Web Insanity.fig` | Fallback da `../Dati/logo-dark.png` ✓ |
|
||||
| `logo-light.png` | Assente in `Web Insanity.fig` | Fallback da `../Dati/logo-light.png` ✓ |
|
||||
|
||||
Tutti e 4 i loghi partner sono stati trovati nel Figma. Nessuna altra immagine richiesta dal brief risulta mancante.
|
||||
|
||||
---
|
||||
|
||||
## Dimensioni finali
|
||||
|
||||
```
|
||||
src/assets/img/ 3.7 MB (31 file JPG + 4 PNG partner loghi)
|
||||
public/img/ ~3.4 KB (2 PNG logo: logo-dark.png 1.7 KB, logo-light.png 1.7 KB)
|
||||
```
|
||||
|
||||
Tutte le immagini sono ≤ 1400 px larghezza e ≤ 1600 px altezza. Il file più pesante è quote-bg.jpg a 460 KB. Nessun file supera 500 KB.
|
||||
|
||||
*Aggiornato dopo fix round 2 — i numeri precedenti (3.4 MB / nessun file >400 KB) erano errati: la pipeline originale non vincolava l'altezza, lasciando immagini portrait a 2-3 MB ciascuna.*
|
||||
|
||||
---
|
||||
|
||||
## Note tecnica — PNG → JPG
|
||||
|
||||
Le foto trainer e training erano estratte dal Figma come PNG lossless (6–8 MB ciascuna). Convertite a JPG qualità 85 con resize a 1200 px max → risparmio ~98% per file. I loghi partner restano PNG per preservare sfondo trasparente.
|
||||
|
||||
Il file `asset-map.json` referenzia gli hash originali (estensione .png/.jpg come estratti). I file di destinazione finali hanno tutti estensione .jpg (eccetto loghi partner .png), il che diverge dai nomi originali nel map ma i file in `src/assets/img/` riflettono il formato convertito.
|
||||
|
||||
---
|
||||
|
||||
## Conteggio
|
||||
|
||||
- **Mappate e presenti:** 33 (31 in src/assets/img + 2 in public/img)
|
||||
- **Mancanti nel Figma:** 2 (logo-dark, logo-light) → risolti con fallback
|
||||
- **Immagini estratte totali:** 126 (inclusi ~66 duplicati)
|
||||
- **Lasciate in assets-src/ (gitignored):** 126 file sorgente
|
||||
|
||||
---
|
||||
|
||||
## Fix round 1
|
||||
|
||||
**Data:** 2026-07-02
|
||||
|
||||
### Cosa cambiato
|
||||
|
||||
1. **`scripts/asset-map.json`** — rimossa sezione `"public/img"` (chiavi non-hash, rompeva pipeline dopo `extract-fig-images.sh`). Aggiunta sezione top-level `"fallbacks"` con struttura `{ "public/img": { "../Dati/logo-dark.png": "logo-dark.png", "../Dati/logo-light.png": "logo-light.png" } }`. Le destinazioni in `src/assets/img` mantengono l'estensione REALE del sorgente estratto da Figma (`.png` per PNG Figma, `.jpg` per JPEG già presenti in assets-src).
|
||||
|
||||
2. **`scripts/apply-asset-map.mjs`** — aggiunto commento d'intestazione sull'ordine pipeline (extract → apply → optimize). Aggiunto loop `fallbacks`: copia da path relativo alla root del repo invece che da `assets-src/`, senza exit 1 per file assenti nel .fig.
|
||||
|
||||
3. **`scripts/optimize-assets.sh`** (nuovo) — bash, `set -euo pipefail`. Converte tutti i PNG non-partner in `src/assets/img/` → JPG qualità 85 con resize `1400x>`, poi ridimensiona JPG già esistenti se larghezza > 1400px. Idempotente (nullglob su `.png`).
|
||||
|
||||
### Output verifica riproducibilità
|
||||
|
||||
```
|
||||
node scripts/apply-asset-map.mjs → "Copiate 33 immagini"
|
||||
./scripts/optimize-assets.sh → 16 PNG→JPG, 5 JPG ridimensionati (hero-1, info-balance, info-longevity, method-step-1, method-step-2)
|
||||
git status --short → elenco file identico al pre-fix (31 img + 4 PNG partner)
|
||||
18 file binari rigenerati (ricompressione diversa da conversione manuale originale)
|
||||
→ ricommittati: repo = output pipeline ✓
|
||||
```
|
||||
|
||||
### Esito punto 4 — bassa risoluzione
|
||||
|
||||
- **`method-step-3.jpg`** (184×276): sorgente in `assets-src/a3e84d7daf0b25c750ef694f4b9fc10bf0f2f1a8.jpg` è già 184×276 — stessa risoluzione del file committato. Nessuna versione ad alta risoluzione disponibile in `assets-src/`. **Richiede sorgente migliore dal cliente.**
|
||||
- **`about-team-group.jpg`** (270×480): sorgente in `assets-src/32ecd00a20321e58c700fbaf739e53ac3bf03876.png` è già 270×480. Nessuna versione ad alta risoluzione disponibile. **Richiede sorgente migliore dal cliente.**
|
||||
|
||||
---
|
||||
|
||||
## Fix round 2
|
||||
|
||||
**Data:** 2026-07-02
|
||||
|
||||
### Numeri finali
|
||||
|
||||
```
|
||||
src/assets/img/ 3.7 MB totale (31 JPG + 4 PNG partner)
|
||||
```
|
||||
|
||||
Top-5 per peso:
|
||||
| File | Dimensioni | Peso |
|
||||
|---|---|---|
|
||||
| quote-bg.jpg | 1087×1600 | 460 KB |
|
||||
| info-performance.jpg | 1200×1500 | 256 KB |
|
||||
| info-longevity.jpg | 1095×1600 | 248 KB |
|
||||
| training-performance-class.jpg | 1400×1400 | 240 KB |
|
||||
| about-exterior.jpg | 1400×781 | 188 KB |
|
||||
|
||||
Nessun file supera 500 KB. Precedente info-longevity.jpg era 2.7 MB, info-balance.jpg 2.0 MB.
|
||||
|
||||
### Cosa cambiato
|
||||
|
||||
1. **`scripts/optimize-assets.sh`** — vincolo resize cambiato da `'1400x>'` (solo larghezza) a `'1400x1600>'` (larghezza E altezza). Qualità JPG da 85 a 83. Tutti i JPG già esistenti vengono ri-codificati a q83 con nuovo vincolo (loop separato, skip file appena convertiti per evitare doppia codifica). Risultato: portrait da 2-3 MB → 248-460 KB.
|
||||
|
||||
2. **`scripts/asset-map.json`** — aggiunta chiave `"_nota"` in testa per documentare la conversione PNG→JPG operata da optimize-assets.sh (JSON non supporta commenti, chiave dedicata con prefisso `_`).
|
||||
|
||||
3. **`scripts/apply-asset-map.mjs`** — aggiunto `destDir.startsWith('_')` nella condizione di skip, così le chiavi meta `_*` del JSON vengono ignorate nella pipeline.
|
||||
|
||||
### Esito idempotenza
|
||||
|
||||
Terza esecuzione `apply-asset-map.mjs + optimize-assets.sh`: md5sum su tutti i .jpg identico prima e dopo → **IDEMPOTENTE** ✓
|
||||
|
||||
### Esito verifica loghi
|
||||
|
||||
`public/img/logo-dark.png` (130×19, 1725 B) e `logo-light.png` (135×19, 1701 B) sono **identici** ai file sorgente in `../Dati/logo-dark.png` e `../Dati/logo-light.png` (stessa dimensione byte-level). Non sono placeholder: sono i loghi reali InsanityLab copiati dalla sezione `fallbacks` di asset-map.json tramite apply-asset-map.mjs. Le dimensioni in pixel ridotte (130×19 px) sono caratteristiche del PNG sorgente in Dati/, non di un errore di pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Fix round 3
|
||||
|
||||
**Data:** 2026-07-02
|
||||
|
||||
### Problema chiuso
|
||||
|
||||
`public/img/logo-dark.png` e `logo-light.png` contenevano i loghi del template "PROWESS++" copiati da `../Dati/` (sorgente errato). Sostituiti con i loghi reali "insanitylab" estratti vettorialmente dal PDF Figma.
|
||||
|
||||
### Metodo
|
||||
|
||||
Sorgente: `../FigmaDoc/Homepage.pdf` (1334×10460 pt, 1 pagina).
|
||||
|
||||
Render esplorativi a 72–150 dpi per individuare le zone logo:
|
||||
- **Logo dark** (header, fondo bianco): riga nav in alto, x≈25pt, y≈10pt
|
||||
- **Logo light** (footer, fondo scuro `srgb(58,41,41)`): sezione footer, y≈8490pt
|
||||
|
||||
Estrazione finale con `pdftoppm -r 300`, poi ImageMagick:
|
||||
- Dark: `-fuzz 8% -transparent white -trim -resize 600x -strip`
|
||||
- Light: `-crop 850x660+130+60` (esclude tagline e colonne destra), `-fuzz 12% -transparent 'srgb(58,41,41)' -trim -resize 600x -strip`
|
||||
|
||||
`-strip` su entrambi garantisce idempotenza md5sum tra esecuzioni successive.
|
||||
|
||||
### Coordinate pdftoppm a 300 dpi (pixel)
|
||||
|
||||
| Logo | -x | -y | -W | -H | crop interno |
|
||||
|---|---|---|---|---|---|
|
||||
| dark | 104 | 41 | 895 | 250 | — |
|
||||
| light | 0 | 35375 | 1800 | 833 | `850x660+130+60` |
|
||||
|
||||
### Dimensioni finali
|
||||
|
||||
| File | Dimensioni | Tipo |
|
||||
|---|---|---|
|
||||
| `public/img/logo-dark.png` | **600×108 px** | TrueColorAlpha (PNG trasparente) |
|
||||
| `public/img/logo-light.png` | **600×342 px** | TrueColorAlpha (PNG trasparente) |
|
||||
|
||||
### Aggiornamenti pipeline
|
||||
|
||||
- Creato `scripts/extract-logos.sh` (bash, `set -euo pipefail`, coordinate fisse, idempotente)
|
||||
- `scripts/asset-map.json`: rimossa sezione `fallbacks` (loghi ora da extract-logos.sh, non da `../Dati`)
|
||||
- `scripts/apply-asset-map.mjs`: commento pipeline aggiornato a step 4
|
||||
- Ordine pipeline: `extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh`
|
||||
|
||||
### Verifica visiva
|
||||
|
||||
- `logo-dark.png`: icona onda/battito cardiaco beige + scritta "insanitylab" scura, layout orizzontale, sfondo trasparente. **Nessun residuo PROWESS++.**
|
||||
- `logo-light.png`: icona onda/battito cardiaco beige sopra, scritta "insanitylab" beige sotto, layout stacked, sfondo trasparente. **Nessun residuo PROWESS++.**
|
||||
|
||||
### Idempotenza
|
||||
|
||||
```
|
||||
md5sum run1 = md5sum run2 → IDEMPOTENTE ✓
|
||||
```
|
||||
@@ -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,21 @@
|
||||
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]' },
|
||||
// Modifiche 02: "Pilates Flow" → "Profilazione". "Rehab" esce dalla griglia Servizi e
|
||||
// diventa un riquadro Training, ma la pagina dettaglio resta su /services/rehab.
|
||||
'/services/pilates-flow': { status: 301, destination: '/services/profilazione' },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
Disallow: /api
|
||||
Sitemap: https://insanitylab.tielogic.xyz/sitemap.xml
|
||||
@@ -0,0 +1,42 @@
|
||||
// Pipeline asset: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
|
||||
// 1. extract-fig-images.sh: estrae hash-named images da .fig → assets-src/
|
||||
// 2. apply-asset-map.mjs: copia da assets-src/ → destinazioni (src/assets/img, public/img)
|
||||
// 3. optimize-assets.sh: converte PNG fotografici → JPG q85, ridimensiona ≤1400px
|
||||
// 4. extract-logos.sh: estrae loghi insanitylab da FigmaDoc/Homepage.pdf → public/img/logo-{dark,light}.png
|
||||
|
||||
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const map = JSON.parse(readFileSync(join(root, 'scripts/asset-map.json'), 'utf8'));
|
||||
let n = 0, missing = 0;
|
||||
|
||||
// Sezione principale: copia da assets-src/
|
||||
for (const [destDir, entries] of Object.entries(map)) {
|
||||
if (destDir.startsWith('_') || destDir === 'fallbacks') continue;
|
||||
mkdirSync(join(root, destDir), { recursive: true });
|
||||
for (const [src, dest] of Object.entries(entries)) {
|
||||
const from = join(root, 'assets-src', src);
|
||||
if (!existsSync(from)) { console.error(`MANCANTE: ${src}`); missing++; continue; }
|
||||
copyFileSync(from, join(root, destDir, dest));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sezione fallbacks: copia da path relativo alla root del repo (es. ../Dati/)
|
||||
if (map.fallbacks) {
|
||||
for (const [destDir, entries] of Object.entries(map.fallbacks)) {
|
||||
mkdirSync(join(root, destDir), { recursive: true });
|
||||
for (const [srcRelPath, dest] of Object.entries(entries)) {
|
||||
const from = join(root, srcRelPath);
|
||||
if (!existsSync(from)) { console.error(`MANCANTE (fallback): ${srcRelPath}`); missing++; continue; }
|
||||
copyFileSync(from, join(root, destDir, dest));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Copiate ${n} immagini${missing ? `, ${missing} mancanti` : ''}`);
|
||||
process.exit(missing ? 1 : 0);
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"_nota": "Le destinazioni .png fotografiche vengono convertite in .jpg da optimize-assets.sh; il nome finale su disco ha estensione .jpg",
|
||||
"src/assets/img": {
|
||||
"8b71f158c30a0a23cc5113bfe1cd9a6c4f19e4c4.jpg": "hero-1.jpg",
|
||||
"0f8034394657985cc4cf35eb8441ae93a7ce5302.jpg": "info-performance.jpg",
|
||||
"432d397a342b4e8312a3a13a4fea836b3e867a64.jpg": "info-balance.jpg",
|
||||
"d7fcf0aa827ec50f660145c0d319049b9153fefb.jpg": "info-longevity.jpg",
|
||||
"6d6025730c3d0b73c0052fa67b1bb111c8462a08.jpg": "feature-performance.jpg",
|
||||
"16e611de84f51bf5d01486a5e74ba0f8249850be.jpg": "feature-balance.jpg",
|
||||
"98f1f8d257dfeffbaf35507d033e5bb51e08895e.png": "feature-longevity.png",
|
||||
"28e8157dc88a42cffdbcd028e4cb02a6eb3de337.png": "quote-bg.png",
|
||||
"85808a0c9aeb461c70c059b013eab47faa4fc4f1.png": "training-one-to-one.png",
|
||||
"bb632b8da5e9e4d4b05207f04a8883163caac53a.png": "training-small-groups.png",
|
||||
"91aa82b5dedee8ad56c6db249c9947c7d6d9c89a.png": "training-performance-class.png",
|
||||
"86c1363b22a8fb3bb7b2bf044e52856d474d927e.jpg": "training-fit-remote.jpg",
|
||||
"0b6cf69438af21bbca934b0b27995ac4fa967451.png": "team-donata.png",
|
||||
"1cc33c41368d47c30d949e991c5a442ff51d1a91.png": "team-nicola.png",
|
||||
"a75ca105e362e3c3bc5cfe0bfc7c39e69645660d.png": "team-eleonora.png",
|
||||
"e3c84f9989df8b160d185bd40b774e460a4f6896.png": "team-antonello.png",
|
||||
"88cfcd5fd321b5ca6e4833e3980b792a95090545.jpg": "method-step-1.jpg",
|
||||
"d21671bb08f4f2eadde09f91a46b568788a209d7.jpg": "method-step-2.jpg",
|
||||
"a3e84d7daf0b25c750ef694f4b9fc10bf0f2f1a8.jpg": "method-step-3.jpg",
|
||||
"e50b6f7818b3742ddba0f4ad4a162a8aafc505e0.jpg": "method-step-4.jpg",
|
||||
"bee22f37bba6f18d3defcd47762375e1c8ddd0f5.png": "partner-edenred.png",
|
||||
"cf079250ea1db7f0055b76af21e04b1ed1870770.png": "partner-welbee.png",
|
||||
"872c3679df24bd0d3fdca7c4cb9a24d1836309a0.png": "partner-howden.png",
|
||||
"014a7708ad61b12c5765ad4a60539d2fa325f9aa.png": "partner-aon.png",
|
||||
"8ffc5f7a2428b0d0eabd09f676ab98d3e5644f0d.png": "about-exterior.png",
|
||||
"32ecd00a20321e58c700fbaf739e53ac3bf03876.png": "about-team-group.png",
|
||||
"d4608b102eb67ac067e6585250b3c37963db1d34.png": "about-training-group.png",
|
||||
"dafcc19362e3a76dd20e9a656499a8068a3a9fca.png": "about-trainer-1.png",
|
||||
"9c50f1d3b3a0477e80e03c0849d3d4687610b461.png": "about-trainer-2.png",
|
||||
"c51a49b9be7f1b1a31de6b2f5261c8c5b5fdab52.png": "about-trainer-3.png",
|
||||
"3341a11b7153349a23c75b1fa2a9a62d48953330.png": "about-trainer-4.png"
|
||||
},
|
||||
"_loghi": "logo-dark.png e logo-light.png estratti da extract-logos.sh (FigmaDoc/Homepage.pdf), non da ../Dati"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const [, , username, password, role = 'admin'] = process.argv;
|
||||
if (!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';
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
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,
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
);`);
|
||||
db.prepare(
|
||||
`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,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
FIG="../FigmaDoc/Web Insanity.fig"
|
||||
OUT="assets-src"
|
||||
rm -rf "$OUT" && mkdir -p "$OUT"
|
||||
unzip -o -j -q "$FIG" 'images/*' -d "$OUT"
|
||||
cd "$OUT"
|
||||
for f in *; do
|
||||
case "$(file --brief --mime-type "$f")" in
|
||||
image/png) mv "$f" "$f.png" ;;
|
||||
image/jpeg) mv "$f" "$f.jpg" ;;
|
||||
image/webp) mv "$f" "$f.webp" ;;
|
||||
*) rm -f "$f" ;;
|
||||
esac
|
||||
done
|
||||
echo "Estratte: $(ls | wc -l) immagini in $OUT/"
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# extract-logos.sh — estrae loghi insanitylab dal PDF Figma Homepage
|
||||
# Pipeline: extract-fig-images.sh → apply-asset-map.mjs → optimize-assets.sh → extract-logos.sh
|
||||
# Idempotente: stessi file ad ogni esecuzione, coordinate fisse.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PDF="$ROOT/../FigmaDoc/Homepage.pdf"
|
||||
OUT="$ROOT/public/img"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
if [[ ! -f "$PDF" ]]; then
|
||||
echo "ERRORE: PDF non trovato: $PDF" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
echo "Estrazione loghi da $PDF..."
|
||||
|
||||
# ── Logo DARK (header, su fondo bianco) ─────────────────────────────────────
|
||||
# Zona: x=104, y=41, W=895, H=250 px a 300dpi (pt: x≈25, y≈10, W≈215, H≈60)
|
||||
pdftoppm -png -r 300 -f 1 -l 1 \
|
||||
-x 104 -y 41 -W 895 -H 250 \
|
||||
"$PDF" "$WORK/hdr"
|
||||
|
||||
convert "$WORK/hdr-1.png" \
|
||||
-fuzz 8% -transparent white \
|
||||
-trim +repage \
|
||||
-resize 600x \
|
||||
-strip \
|
||||
"$OUT/logo-dark.png"
|
||||
|
||||
# ── Logo LIGHT (footer, su fondo scuro #3A2929) ─────────────────────────────
|
||||
# Zona: x=0, y=35375, W=1800, H=833 px a 300dpi (pt: x≈0, y≈8490, W≈432, H≈200)
|
||||
# poi crop interno: x=130, y=60, W=850, H=660 per includere solo icona+testo
|
||||
pdftoppm -png -r 300 -f 1 -l 1 \
|
||||
-x 0 -y 35375 -W 1800 -H 833 \
|
||||
"$PDF" "$WORK/ftr"
|
||||
|
||||
convert "$WORK/ftr-1.png" \
|
||||
-crop 850x660+130+60 +repage \
|
||||
-fuzz 12% -transparent 'srgb(58,41,41)' \
|
||||
-trim +repage \
|
||||
-resize 600x \
|
||||
-strip \
|
||||
"$OUT/logo-light.png"
|
||||
|
||||
echo "Logo dark: $OUT/logo-dark.png $(identify -format '%wx%h' "$OUT/logo-dark.png")"
|
||||
echo "Logo light: $OUT/logo-light.png $(identify -format '%wx%h' "$OUT/logo-light.png")"
|
||||
echo "Done."
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Passo 3 della pipeline asset (dopo apply-asset-map.mjs).
|
||||
# Converte PNG fotografici in src/assets/img/ → JPG q83 e ridimensiona a ≤1400x1600px.
|
||||
# I PNG partner-* (loghi con trasparenza) restano PNG.
|
||||
# Tutti i JPG esistenti vengono ri-codificati a q83 con vincolo 1400x1600>
|
||||
# (larghezza ≤1400 px E altezza ≤1600 px, aspect ratio preservato).
|
||||
# Idempotente se abbinato a apply-asset-map.mjs (che ripristina i sorgenti originali).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
IMG_DIR="$ROOT/src/assets/img"
|
||||
|
||||
cd "$IMG_DIR"
|
||||
|
||||
# --- PNG fotografici → JPG q83 + resize ≤1400x1600 ---
|
||||
shopt -s nullglob
|
||||
declare -A just_converted
|
||||
converted=0
|
||||
for png in *.png; do
|
||||
[[ "$png" == partner-* ]] && continue
|
||||
jpg="${png%.png}.jpg"
|
||||
convert "$png" -quality 83 -resize '1400x1600>' "$jpg"
|
||||
rm "$png"
|
||||
echo " PNG→JPG: $png → $jpg"
|
||||
just_converted["$jpg"]=1
|
||||
(( converted++ )) || true
|
||||
done
|
||||
|
||||
# --- Ri-codifica JPG esistenti a q83 + vincolo 1400x1600 ---
|
||||
# Skip file appena convertiti (già a q83), elabora tutti gli altri.
|
||||
reencoded=0
|
||||
for jpg in *.jpg; do
|
||||
[[ -n "${just_converted[$jpg]+x}" ]] && continue
|
||||
mogrify -quality 83 -resize '1400x1600>' "$jpg"
|
||||
echo " Ricodificato: $jpg"
|
||||
(( reencoded++ )) || true
|
||||
done
|
||||
|
||||
echo "Ottimizzazione completata: ${converted} PNG→JPG, ${reencoded} JPG ricodificati (q83, ≤1400x1600)."
|
||||
@@ -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 |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 80 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 |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 236 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 |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,55 @@
|
||||
---
|
||||
import { agenda, disciplines, days, slots } from '../data/agenda';
|
||||
import T from './content/T.astro';
|
||||
---
|
||||
<section class="section section--dark agenda">
|
||||
<div class="container">
|
||||
<div class="section-heading" data-reveal><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'}><T tag={`agenda.discipline.${i + 1}`} as="span" /></button>
|
||||
))}
|
||||
</div>
|
||||
<div class="agenda__grid" data-reveal style="--reveal-delay:.2s">
|
||||
<div class="agenda__corner"></div>
|
||||
{days.map((_, i) => <T tag={`agenda.day.${i + 1}`} as="div" class="agenda__day" />)}
|
||||
{slots.map((slot, si) => (
|
||||
<>
|
||||
<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
|
||||
? <div class="agenda__cell" data-discipline={entry.discipline}><strong>{entry.discipline}</strong><span>{entry.time}</span><em>{entry.trainer}</em></div>
|
||||
: <div class="agenda__cell agenda__cell--empty"></div>;
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.agenda__tabs { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; margin-bottom: 40px; }
|
||||
.agenda__tab { background: transparent; color: #b9aea4; border: 0; cursor: pointer; font-family: var(--font-heading); font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; padding: 10px 18px; }
|
||||
.agenda__tab.is-active { background: var(--c-accent); color: #fff; }
|
||||
.agenda__grid { display: grid; grid-template-columns: 70px repeat(7, 1fr); gap: 2px; }
|
||||
.agenda__day, .agenda__slot { font-family: var(--font-heading); font-size: .66rem; letter-spacing: .14em; text-transform: uppercase; color: #b9aea4; display: flex; align-items: center; justify-content: center; padding: 10px 4px; }
|
||||
.agenda__cell { background: var(--c-accent); color: #fff; padding: 14px 8px; text-align: center; font-size: .72rem; display: flex; flex-direction: column; gap: 2px; }
|
||||
.agenda__cell strong { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.agenda__cell em { font-style: normal; opacity: .85; }
|
||||
.agenda__cell--empty { background: transparent; }
|
||||
.agenda__cell.is-dim { opacity: .15; }
|
||||
@media (max-width: 991px) { .agenda__grid { grid-template-columns: 60px repeat(7, minmax(90px, 1fr)); overflow-x: auto; } }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll<HTMLElement>('.agenda').forEach((root) => {
|
||||
const tabs = root.querySelectorAll<HTMLButtonElement>('.agenda__tab');
|
||||
const cells = root.querySelectorAll<HTMLElement>('.agenda__cell[data-discipline]');
|
||||
tabs.forEach((tab) => tab.addEventListener('click', () => {
|
||||
tabs.forEach((t) => { t.classList.toggle('is-active', t === tab); t.setAttribute('aria-selected', String(t === tab)); });
|
||||
const f = tab.dataset.filter!;
|
||||
cells.forEach((c) => c.classList.toggle('is-dim', f !== 'Events' && c.dataset.discipline !== f));
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
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
|
||||
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"><T tag="form.submit" as="span" /></button>
|
||||
<p class="form-msg" hidden></p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = form.querySelector<HTMLParagraphElement>('.form-msg')!;
|
||||
const btn = form.querySelector<HTMLButtonElement>('button')!;
|
||||
btn.disabled = true;
|
||||
msg.hidden = true;
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
const data = await res.json();
|
||||
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 = form.dataset.msgErrorNetwork!;
|
||||
msg.className = 'form-msg form-msg--err';
|
||||
}
|
||||
msg.hidden = false;
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
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={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">
|
||||
<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="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>
|
||||
<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} <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>
|
||||
|
||||
<style>
|
||||
.ftr { background: var(--c-dark); color: #b9aea4; font-size: .88rem; }
|
||||
.ftr a { color: #b9aea4; text-decoration: none; display: block; padding: 4px 0; }
|
||||
.ftr a:hover { color: #fff; }
|
||||
.ftr h4 { color: #fff; text-transform: uppercase; letter-spacing: .18em; font-size: .78rem; margin-bottom: 18px; }
|
||||
.ftr__grid { display: grid; grid-template-columns: 1.3fr 1fr 1fr 1.3fr; gap: 40px; padding-block: 70px; }
|
||||
.ftr__tag { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .2em; color: #fff; }
|
||||
.ftr__hours { margin: 0; }
|
||||
.ftr__bar { border-top: 1px solid rgba(255,255,255,.08); padding-block: 18px; }
|
||||
.ftr__bar-in { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
|
||||
.ftr__bar-in a { display: inline; margin-left: 12px; }
|
||||
@media (max-width: 991px) { .ftr__grid { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 600px) { .ftr__grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
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={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, 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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.hdr { position: sticky; top: 0; z-index: 100; background: #fff; transition: box-shadow .2s; }
|
||||
.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); 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; }
|
||||
@media (max-width: 991px) {
|
||||
.hdr__nav { display: none; position: absolute; top: var(--header-h); left: 0; right: 0; background: #fff; flex-direction: column; gap: 0; padding: 10px 20px 20px; box-shadow: 0 10px 20px rgba(0,0,0,.08); }
|
||||
.hdr__nav.is-open { display: flex; }
|
||||
.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>
|
||||
const header = document.getElementById('site-header')!;
|
||||
const toggle = document.getElementById('nav-toggle')!;
|
||||
const nav = document.getElementById('site-nav')!;
|
||||
addEventListener('scroll', () => header.classList.toggle('is-scrolled', scrollY > 10), { passive: true });
|
||||
toggle.addEventListener('click', () => {
|
||||
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>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
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: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>
|
||||
/* 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>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
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">
|
||||
<T tag="about.storylab.title" as="h2" />
|
||||
<T tag="about.storylab.body" as="p" />
|
||||
</div>
|
||||
<div class="tl">
|
||||
<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>+ {t(`about.timeline.stage${n}.label`)}</summary>
|
||||
<T tag={`about.timeline.stage${n}.text`} as="p" />
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tl { max-width: 720px; margin-inline: auto; }
|
||||
.tl__label { text-transform: uppercase; letter-spacing: .16em; font-size: .9rem; }
|
||||
.tl__stage { border-top: 1px solid #e4ded6; padding-block: 14px; }
|
||||
.tl__stage summary { cursor: pointer; font-family: var(--font-heading); font-size: .82rem; letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading); list-style: none; }
|
||||
.tl__stage summary::-webkit-details-marker { display: none; }
|
||||
</style>
|
||||
@@ -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} />}
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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 = [
|
||||
{ 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 }]}>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.feat { display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; padding-block: 40px; }
|
||||
.feat--reverse .feat__img { order: 2; }
|
||||
.feat__text h2 { text-transform: uppercase; letter-spacing: .1em; }
|
||||
@media (max-width: 767px) { .feat { grid-template-columns: 1fr; gap: 24px; } .feat--reverse .feat__img { order: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
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 = [
|
||||
{ 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">
|
||||
{slides.map((s, i) => (
|
||||
<div class:list={['hero__slide', { 'is-active': i === 0 }]}>
|
||||
<div class="container hero__in">
|
||||
<div class="hero__text">
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
{slides.length > 1 && (
|
||||
<>
|
||||
<button class="hero__arrow hero__arrow--prev" aria-label="precedente">‹</button>
|
||||
<button class="hero__arrow hero__arrow--next" aria-label="successiva">›</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero { position: relative; background:
|
||||
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; }
|
||||
/* 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 { 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 { 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>
|
||||
const slides = document.querySelectorAll('.hero__slide');
|
||||
if (slides.length > 1) {
|
||||
let cur = 0;
|
||||
const show = (i: number) => {
|
||||
slides[cur].classList.remove('is-active');
|
||||
cur = (i + slides.length) % slides.length;
|
||||
slides[cur].classList.add('is-active');
|
||||
};
|
||||
document.querySelector('.hero__arrow--prev')?.addEventListener('click', () => show(cur - 1));
|
||||
document.querySelector('.hero__arrow--next')?.addEventListener('click', () => show(cur + 1));
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +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';
|
||||
import T from '../content/T.astro';
|
||||
import { contentImageUrl } from '../../lib/content';
|
||||
// getImage garantisce l'emissione dell'asset anche quando il file è byte-identico
|
||||
// ad altri import (Astro dedupa e non emette l'originale usato solo come background).
|
||||
const cols = await Promise.all([
|
||||
{ titleTag: 'home.info.col1.title', imageTag: 'home.info.col1.image', image: infoPerformance },
|
||||
{ titleTag: 'home.info.col2.title', imageTag: 'home.info.col2.image', image: infoBalance },
|
||||
{ titleTag: 'home.info.col3.title', imageTag: 'home.info.col3.image', image: infoLongevity },
|
||||
].map(async (c) => ({ ...c, bg: contentImageUrl(c.imageTag, (await getImage({ src: c.image, width: 1200 })).src) })));
|
||||
---
|
||||
<section class="info" aria-label="pilastri">
|
||||
{cols.map((c, i) => (
|
||||
<a class="info__col" href="/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); }
|
||||
/* 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; } .info__col { aspect-ratio: auto; } }
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { methodSteps } from '../../data/method';
|
||||
import T from '../content/T.astro';
|
||||
import TImg from '../content/TImg.astro';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/method-*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
---
|
||||
<section class="section section--alt">
|
||||
<div class="container">
|
||||
<div class="section-heading" data-reveal>
|
||||
<T tag="home.method.title" as="h2" />
|
||||
<T tag="method.intro" as="p" />
|
||||
</div>
|
||||
<T tag="method.extra-note" as="p" class="method__note" />
|
||||
<div class="method__row">
|
||||
{methodSteps.map((s, i) => (
|
||||
<div class:list={['method__step', { 'method__step--extra': s.extra }]} data-reveal style={`--reveal-delay:${(i * 0.15).toFixed(2)}s`}>
|
||||
<div class="method__circle">
|
||||
{imgOf(s.image) && <TImg tag={`method.step.${s.n}.image`} src={imgOf(s.image)!} alt="" widths={[220]} sizes="220px" />}
|
||||
<span>{s.n}</span>
|
||||
</div>
|
||||
<T tag={`method.step.${s.n}.title`} as="h3" />
|
||||
<T tag={`method.step.${s.n}.text`} as="p" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="method__cta" data-reveal>
|
||||
<a class="btn btn--dark" href="/contact"><T tag="home.method.cta" as="span" /></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.method__note { text-align: right; font-size: .8rem; color: var(--c-text-light); max-width: 260px; margin-left: auto; }
|
||||
.method__cta { text-align: center; margin-top: 48px; }
|
||||
.method__row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; text-align: center; position: relative; }
|
||||
.method__circle { position: relative; width: 150px; height: 150px; margin: 0 auto 20px; border: 1px dashed var(--c-accent-dark); border-radius: 50%; padding: 8px; }
|
||||
.method__circle img { border-radius: 50%; width: 100%; height: 100%; object-fit: cover; }
|
||||
.method__circle span { position: absolute; top: -6px; left: -6px; font-family: var(--font-heading); font-size: 2.4rem; color: var(--c-heading); background: var(--c-bg-alt); padding: 0 8px; }
|
||||
.method__step h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .14em; }
|
||||
.method__step p { font-size: .85rem; color: var(--c-text-light); }
|
||||
@media (max-width: 767px) { .method__row { grid-template-columns: 1fr 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { partners } from '../../data/partners';
|
||||
import TImg from '../content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/partner-*.{jpg,png}', { eager: true });
|
||||
const imgOf = (name: string) => Object.entries(images).find(([p]) => p.includes(name))?.[1].default;
|
||||
---
|
||||
<section class="pstrip" aria-label="partner">
|
||||
<div class="container pstrip__in" data-reveal="fade">
|
||||
{partners.map((p, i) => imgOf(p.image) && <TImg tag={`partner.${i + 1}.image`} src={imgOf(p.image)!} alt={t(`partner.${i + 1}.name`)} height={44} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.pstrip { background: var(--c-accent); padding-block: 40px; }
|
||||
.pstrip__in { display: flex; align-items: center; justify-content: space-around; gap: 30px; flex-wrap: wrap; }
|
||||
.pstrip img { height: 44px; width: auto; opacity: .9; }
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import { getImage } from 'astro:assets';
|
||||
import quoteBgSrc from '../../assets/img/quote-bg.jpg';
|
||||
import { t, contentImageUrl } from '../../lib/content';
|
||||
const quoteBg = contentImageUrl('home.quote.image', (await getImage({ src: quoteBgSrc, width: 1920 })).src);
|
||||
---
|
||||
<section class="quote" data-parallax>
|
||||
<div class="quote__bg" style={`background-image:url(${quoteBg})`} data-tag="home.quote.image"></div>
|
||||
<div class="quote__in">
|
||||
<p class="quote__mark">“</p>
|
||||
<blockquote>
|
||||
<div class="quote__marquee">
|
||||
<p class="quote__track">
|
||||
<span class="quote__seg" 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; 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; }
|
||||
/* 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>
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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;
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<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, 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>
|
||||
|
||||
<style>
|
||||
.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; 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>
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
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'];
|
||||
// riga 1 (training 0-1): card, foto | riga 2 (training 2-3): foto, card
|
||||
const isSwapRow = (i: number) => Math.floor(i / 2) % 2 === 1;
|
||||
---
|
||||
<section class="tcards">
|
||||
{trainings.map((tr, i) => {
|
||||
const img = imgOf(tr.image);
|
||||
const card = (
|
||||
<div class:list={['tcards__cell', `tcards__cell--${tones[i % tones.length]}`]} 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 ? (
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.tcards { display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||
.tcards__cell { padding: 48px 36px; display: flex; flex-direction: column; justify-content: center; aspect-ratio: 1; }
|
||||
.tcards__cell--taupe { background: #6b5d5b; }
|
||||
.tcards__cell--dark { background: var(--c-dark); }
|
||||
.tcards__cell--charcoal { background: #27252a; }
|
||||
.tcards__cell--taupe h3, .tcards__cell--taupe p,
|
||||
.tcards__cell--dark h3, .tcards__cell--dark p,
|
||||
.tcards__cell--charcoal h3, .tcards__cell--charcoal p { color: #efe9e3; }
|
||||
.tcards__cell--tan { background: var(--c-accent); }
|
||||
.tcards__cell--tan h3, .tcards__cell--tan p { color: #fff; }
|
||||
.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__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,36 @@
|
||||
export const disciplines = ["Events", "Crossfit", "Cardio", "Box", "Meditation", "Yoga Classes", "Body Balance"] as const;
|
||||
export const days = ["Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato", "Domenica"] as const;
|
||||
export const slots = ["10.00", "12.00", "16.00", "18.00", "20.00"] as const;
|
||||
|
||||
export interface AgendaEntry { day: number; slot: string; discipline: string; time: string; trainer: string }
|
||||
|
||||
// Placeholder dal template Prowess (come nel Figma); il cliente fornirà l’orario reale.
|
||||
export const agenda: AgendaEntry[] = [
|
||||
{ day: 0, slot: "10.00", discipline: "Crossfit", time: "10.00 - 11.00", trainer: "Amanda Snow" },
|
||||
{ day: 0, slot: "12.00", discipline: "Box", time: "12.00 - 13.00", trainer: "Matthew Dunn" },
|
||||
{ day: 0, slot: "18.00", discipline: "Cardio", time: "18.00 - 19.00", trainer: "Randy Nelson" },
|
||||
{ day: 0, slot: "20.00", discipline: "Crossfit", time: "20.00 - 21.00", trainer: "Amanda Snow" },
|
||||
{ day: 1, slot: "10.00", discipline: "Cardio", time: "10.00 - 11.00", trainer: "Randy Nelson" },
|
||||
{ day: 1, slot: "12.00", discipline: "Crossfit", time: "12.00 - 13.00", trainer: "Amanda Snow" },
|
||||
{ day: 1, slot: "16.00", discipline: "Body Balance", time: "16.00 - 17.00", trainer: "Barbara Holland" },
|
||||
{ day: 1, slot: "20.00", discipline: "Box", time: "20.00 - 21.00", trainer: "Matthew Dunn" },
|
||||
{ day: 2, slot: "10.00", discipline: "Crossfit", time: "10.00 - 11.00", trainer: "Amanda Snow" },
|
||||
{ day: 2, slot: "12.00", discipline: "Meditation", time: "12.00 - 13.00", trainer: "Nicole Fuller" },
|
||||
{ day: 2, slot: "16.00", discipline: "Box", time: "16.00 - 17.00", trainer: "Matthew Dunn" },
|
||||
{ day: 2, slot: "20.00", discipline: "Body Balance", time: "20.00 - 21.00", trainer: "Barbara Holland" },
|
||||
{ day: 3, slot: "10.00", discipline: "Body Balance", time: "10.00 - 11.00", trainer: "Barbara Holland" },
|
||||
{ day: 3, slot: "12.00", discipline: "Box", time: "12.00 - 13.00", trainer: "Matthew Dunn" },
|
||||
{ day: 3, slot: "16.00", discipline: "Crossfit", time: "16.00 - 17.00", trainer: "Amanda Snow" },
|
||||
{ day: 3, slot: "20.00", discipline: "Body Balance", time: "20.00 - 21.00", trainer: "Randy Nelson" },
|
||||
{ day: 4, slot: "12.00", discipline: "Crossfit", time: "12.00 - 13.00", trainer: "Amanda Snow" },
|
||||
{ day: 4, slot: "16.00", discipline: "Body Balance", time: "16.00 - 17.00", trainer: "Barbara Holland" },
|
||||
{ day: 4, slot: "18.00", discipline: "Cardio", time: "18.00 - 19.00", trainer: "Randy Nelson" },
|
||||
{ day: 4, slot: "20.00", discipline: "Yoga Classes", time: "20.00 - 21.00", trainer: "Amanda Snow" },
|
||||
{ day: 5, slot: "10.00", discipline: "Cardio", time: "10.00 - 11.00", trainer: "Randy Nelson" },
|
||||
{ day: 5, slot: "12.00", discipline: "Body Balance", time: "12.00 - 13.00", trainer: "Barbara Holland" },
|
||||
{ day: 5, slot: "18.00", discipline: "Box", time: "18.00 - 19.00", trainer: "Matthew Dunn" },
|
||||
{ day: 6, slot: "12.00", discipline: "Crossfit", time: "12.00 - 13.00", trainer: "Amanda Snow" },
|
||||
{ day: 6, slot: "16.00", discipline: "Cardio", time: "16.00 - 17.00", trainer: "Randy Nelson" },
|
||||
{ day: 6, slot: "18.00", discipline: "Box", time: "18.00 - 19.00", trainer: "Matthew Dunn" },
|
||||
{ day: 6, slot: "20.00", discipline: "Body Balance", time: "20.00 - 21.00", trainer: "Barbara Holland" },
|
||||
];
|
||||