feat: build headless theatre laboratory website

This commit is contained in:
bisco
2026-06-22 19:42:43 +02:00
parent cae9180bc6
commit cff9a17c3c
65 changed files with 8994 additions and 128 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.astro
npm-debug.log
+15
View File
@@ -0,0 +1,15 @@
FROM node:22.20-alpine
WORKDIR /app
RUN chown node:node /app
USER node
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY --chown=node:node . .
EXPOSE 4321
CMD ["npm", "run", "dev"]
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "astro/config";
export default defineConfig({
output: "static",
server: {
host: true,
port: 4321,
},
});
+5793
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "laboratorio-teatrale-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"preview": "astro preview --host 0.0.0.0",
"check": "astro check"
},
"dependencies": {
"astro": "6.4.6"
},
"devDependencies": {
"@astrojs/check": "0.9.4",
"typescript": "5.7.3"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#A4513B"/>
<path d="M18 16h28v7H36v25h-8V23H18z" fill="#FFF8EF"/>
</svg>

After

Width:  |  Height:  |  Size: 181 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Il laboratorio insieme</title><rect width="860" height="700" fill="#FFF8EF"/><circle cx="430" cy="350" r="292" fill="#D8C7AE"/><circle cx="229" cy="250" r="58" fill="#A4513B"/><circle cx="430" cy="188" r="58" fill="#515D53"/><circle cx="631" cy="250" r="58" fill="#4A433A"/><path d="M111 615c15-171 54-259 117-265 73 4 116 93 130 265z" fill="#4A433A"/><path d="M313 615c13-203 52-304 117-305 75 2 115 104 120 305z" fill="#A4513B"/><path d="M514 615c17-171 56-259 117-265 73 4 116 93 130 265z" fill="#515D53"/></svg>

After

Width:  |  Height:  |  Size: 631 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Ascolto e movimento</title><rect width="860" height="700" fill="#D8C7AE"/><circle cx="220" cy="232" r="90" fill="#FFF8EF"/><path d="m72 700 92-366c82-28 148 13 198 122l-41 244z" fill="#515D53"/><circle cx="628" cy="175" r="78" fill="#A4513B"/><path d="m500 700 69-411c77-24 143 35 199 176l-2 235z" fill="#4A433A"/><path d="M305 418c116-87 221-122 316-104" fill="none" stroke="#B8674C" stroke-width="18" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 549 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Le cose che restano in scena</title><rect width="860" height="700" fill="#515D53"/><circle cx="430" cy="360" r="251" fill="#B8674C"/><path d="M0 560c234-153 497-163 860-41v181H0z" fill="#D8C7AE"/><path d="M157 560c33-136 90-212 171-228 92 26 151 104 177 236zM496 550c11-121 58-202 142-244 93 43 140 128 141 254z" fill="#4A433A"/><circle cx="326" cy="270" r="52" fill="#FFF8EF"/><circle cx="639" cy="251" r="52" fill="#A4513B"/></svg>

After

Width:  |  Height:  |  Size: 549 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Prima di entrare in scena</title><rect width="860" height="700" fill="#4A433A"/><path d="M0 0h237v700H0zM623 0h237v700H623z" fill="#A4513B"/><ellipse cx="430" cy="720" rx="275" ry="346" fill="#D8C7AE"/><circle cx="430" cy="286" r="88" fill="#FFF8EF"/><path d="M290 700c24-192 71-298 140-318 82 25 129 131 140 318z" fill="#515D53"/></svg>

After

Width:  |  Height:  |  Size: 453 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 1120" role="img" aria-labelledby="title desc">
<title id="title">Il gruppo durante un esercizio teatrale</title><desc id="desc">Composizione astratta calda con figure in movimento nello spazio.</desc>
<rect width="960" height="1120" fill="#D8C7AE"/><path d="M0 0h420v1120H0z" fill="#B8674C" opacity=".55"/><circle cx="746" cy="222" r="178" fill="#FFF8EF" opacity=".5"/>
<path d="M192 926c42-210 43-404 5-580 87-83 174-75 261 23-54 180-52 368 8 564z" fill="#4A433A"/><circle cx="326" cy="254" r="102" fill="#F3EBDD"/>
<path d="M494 979c50-169 70-325 60-470 72-79 154-70 245 29-34 120-26 273 24 458z" fill="#515D53"/><circle cx="651" cy="427" r="86" fill="#A4513B"/>
<path d="M405 427c114 25 209 7 283-55" fill="none" stroke="#FFF8EF" stroke-width="22" stroke-linecap="round" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 863 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 760" role="img" aria-labelledby="title desc">
<title id="title">Esercizio di movimento nello spazio</title><desc id="desc">Sagome astratte dialogano in uno spazio color sabbia.</desc>
<rect width="960" height="760" fill="#FFF8EF"/><path d="M0 538 960 208v552H0z" fill="#D8C7AE"/>
<circle cx="248" cy="195" r="73" fill="#A4513B"/><path d="M158 630c13-139 35-251 68-335 74 1 123 40 147 117l-57 218z" fill="#515D53"/>
<circle cx="671" cy="245" r="65" fill="#4A433A"/><path d="m572 635 78-310c73 11 119 59 138 145l-19 165z" fill="#B8674C"/>
<path d="M329 391c115 62 214 43 297-58" fill="none" stroke="#4A433A" stroke-width="16" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 723 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="title desc">
<title id="title">Le cose che restano</title><desc id="desc">Locandina astratta dello spettacolo.</desc>
<rect width="700" height="880" fill="#515D53"/><circle cx="350" cy="427" r="227" fill="#D8C7AE"/><path d="M0 672c163-175 366-220 700-138v346H0z" fill="#A4513B"/>
<path d="M239 595c19-127 52-235 99-324 80 36 127 139 140 309z" fill="#4A433A"/><circle cx="362" cy="218" r="62" fill="#FFF8EF"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="title desc">
<title id="title">Fuori campo</title><desc id="desc">Locandina geometrica dello spettacolo.</desc>
<rect width="700" height="880" fill="#FFF8EF"/><path d="M95 0h510v880H95z" fill="#D8C7AE"/><circle cx="350" cy="390" r="203" fill="#A4513B"/>
<path d="M0 605 700 319v561H0z" fill="#4A433A" opacity=".92"/><path d="M264 680c11-153 36-263 77-330 76 57 117 154 123 289z" fill="#F3EBDD"/>
</svg>

After

Width:  |  Height:  |  Size: 499 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 900" role="img" aria-labelledby="title desc">
<title id="title">Ritratto del maestro</title><desc id="desc">Ritratto editoriale astratto nei toni terracotta e salvia.</desc>
<rect width="760" height="900" fill="#A4513B"/><circle cx="375" cy="289" r="166" fill="#D8C7AE"/><path d="M127 900c15-268 95-405 240-411 159 9 249 146 268 411z" fill="#515D53"/>
<path d="M310 279c40-34 84-34 131 0M335 354c29 16 57 16 85-1" fill="none" stroke="#4A433A" stroke-width="14" stroke-linecap="round"/>
<circle cx="313" cy="268" r="9" fill="#4A433A"/><circle cx="448" cy="268" r="9" fill="#4A433A"/>
<path d="M208 146c61-94 197-127 292-41 26 24 45 58 56 102-89-68-197-87-348-61z" fill="#4A433A"/>
</svg>

After

Width:  |  Height:  |  Size: 754 B

@@ -0,0 +1,26 @@
---
import type { SiteSettings } from "../lib/api";
interface Props { settings: SiteSettings }
const { settings } = Astro.props;
const tel = settings.phone.replace(/[^+\d]/g, "");
const whatsapp = settings.whatsapp.replace(/\D/g, "");
const hasContacts = settings.email || settings.phone || settings.whatsapp;
---
<section class="contact section" id="contatti" aria-labelledby="contact-title">
<div class="container contact-grid">
<div class="contact-intro">
<p class="eyebrow">Facciamo conoscenza</p>
<h2 id="contact-title">Vieni a trovarci</h2>
<p class="lead">Se vuoi capire se questo percorso fa per te, scrivici o chiamaci. Ti risponderemo con calma, senza formule automatiche.</p>
{settings.address && <address>{settings.address}</address>}
</div>
{hasContacts ? (
<div class="contact-actions" aria-label="Canali di contatto">
{settings.email && <a href={`mailto:${settings.email}`}><span>Scrivi una mail</span><strong>{settings.email}</strong><span aria-hidden="true">↗</span></a>}
{settings.phone && <a href={`tel:${tel}`}><span>Chiama</span><strong>{settings.phone}</strong><span aria-hidden="true">↗</span></a>}
{settings.whatsapp && <a href={`https://wa.me/${whatsapp}`} target="_blank" rel="noreferrer"><span>WhatsApp</span><strong>{settings.whatsapp}</strong><span aria-hidden="true">↗</span></a>}
</div>
) : <p class="empty-state contact-empty">I contatti saranno disponibili presto.</p>}
</div>
</section>
@@ -0,0 +1,25 @@
---
import type { FeatureCardContent } from "../lib/api";
interface Props { cards: FeatureCardContent[] }
const { cards } = Astro.props;
---
<section class="features section section-light" aria-labelledby="features-title">
<div class="container">
<div class="section-heading">
<p class="eyebrow">Tre modi per cominciare</p>
<h2 id="features-title">Perché partecipare</h2>
</div>
{cards.length ? (
<div class="feature-grid">
{cards.map((card, index) => (
<article class="feature-card">
<span class="card-number" aria-hidden="true">0{index + 1}</span>
<h3>{card.title}</h3>
<p>{card.text}</p>
</article>
))}
</div>
) : <p class="empty-state">I percorsi del laboratorio saranno pubblicati presto.</p>}
</div>
</section>
+20
View File
@@ -0,0 +1,20 @@
---
import type { SiteSettings } from "../lib/api";
interface Props { settings: SiteSettings }
const { settings } = Astro.props;
const year = new Date().getFullYear();
---
<footer class="site-footer">
<div class="container footer-main">
<div><a class="wordmark" href="#inizio">{settings.site_name}</a><p>{settings.footer_text}</p></div>
<nav aria-label="Social media">
{settings.instagram && <a href={settings.instagram} target="_blank" rel="noreferrer">Instagram</a>}
{settings.facebook && <a href={settings.facebook} target="_blank" rel="noreferrer">Facebook</a>}
</nav>
</div>
<div class="container footer-bottom">
<p>© {year} {settings.site_name}</p>
<p>Progetto realizzato con cura · <span>Privacy (TODO)</span></p>
</div>
</footer>
@@ -0,0 +1,21 @@
---
import type { GalleryItemContent } from "../lib/api";
interface Props { items: GalleryItemContent[] }
const { items } = Astro.props;
---
<section class="gallery section" id="galleria" aria-labelledby="gallery-title">
<div class="container">
<div class="section-heading"><p class="eyebrow">Dentro il laboratorio</p><h2 id="gallery-title">Galleria</h2></div>
{items.length ? (
<div class="gallery-grid">
{items.map((item) => (
<figure class="gallery-item">
<img src={item.image.url} alt={item.image.alt} width="860" height="700" loading="lazy" />
<figcaption><span>{item.category}</span>{item.caption}</figcaption>
</figure>
))}
</div>
) : <p class="empty-state">Le fotografie del laboratorio arriveranno presto.</p>}
</div>
</section>
+33
View File
@@ -0,0 +1,33 @@
---
interface Props {
siteName: string;
ctaLabel: string;
ctaUrl: string;
}
const { siteName, ctaLabel, ctaUrl } = Astro.props;
const links = [
["Il laboratorio", "#laboratorio"],
["Il maestro", "#maestro"],
["Le lezioni", "#lezioni"],
["Progetti", "#spettacoli"],
["Galleria", "#galleria"],
];
---
<header class="site-header">
<div class="container header-inner">
<a class="wordmark" href="#inizio" aria-label={`${siteName}, torna allinizio`}>{siteName}</a>
<nav class="desktop-nav" aria-label="Navigazione principale">
{links.map(([label, href]) => <a href={href}>{label}</a>)}
</nav>
<a class="button button-small desktop-cta" href={ctaUrl}>{ctaLabel}</a>
<details class="mobile-menu">
<summary aria-label="Apri il menu"><span aria-hidden="true"></span><span class="menu-label">Menu</span></summary>
<nav aria-label="Navigazione mobile">
{links.map(([label, href]) => <a href={href}>{label}</a>)}
<a class="button button-small" href={ctaUrl}>{ctaLabel}</a>
</nav>
</details>
</div>
</header>
+23
View File
@@ -0,0 +1,23 @@
---
import type { HomePageContent } from "../lib/api";
interface Props { content: HomePageContent }
const { content } = Astro.props;
---
<section class="hero section" id="inizio" aria-labelledby="hero-title">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">Laboratorio teatrale · corpo, voce, relazione</p>
<h1 id="hero-title">{content.hero_title}</h1>
<p class="hero-subtitle">{content.hero_subtitle}</p>
<div class="button-row">
<a class="button" href={content.cta_primary_url}>{content.cta_primary_label}</a>
<a class="text-link" href={content.cta_secondary_url}>{content.cta_secondary_label}<span aria-hidden="true"> ↓</span></a>
</div>
</div>
<figure class="hero-visual">
<img src={content.hero_image.url} alt={content.hero_image.alt} width="960" height="1120" fetchpriority="high" />
<figcaption>Il gruppo è materia viva.</figcaption>
</figure>
</div>
</section>
@@ -0,0 +1,21 @@
---
import type { HomePageContent } from "../lib/api";
interface Props { content: HomePageContent }
const { content } = Astro.props;
---
<section class="split-section section" id="laboratorio" aria-labelledby="laboratory-title">
<div class="container split-grid">
<div class="editorial-image">
<img src={content.laboratory_image.url} alt={content.laboratory_image.alt} width="960" height="760" loading="lazy" />
</div>
<div class="split-copy">
<p class="eyebrow">Pratica e ricerca</p>
<h2 id="laboratory-title">{content.laboratory_title}</h2>
<div class="lead rich-text" set:html={content.laboratory_body} />
<ul class="practice-list" aria-label="Aree di lavoro">
<li>Corpo</li><li>Voce</li><li>Improvvisazione</li><li>Ascolto</li><li>Presenza scenica</li>
</ul>
</div>
</div>
</section>
@@ -0,0 +1,30 @@
---
import type { LessonContent } from "../lib/api";
interface Props { lesson: LessonContent | null; contactUrl: string }
const { lesson, contactUrl } = Astro.props;
---
<section class="lessons section" id="lezioni" aria-labelledby="lessons-title">
<div class="container">
<div class="section-heading horizontal-heading">
<div><p class="eyebrow">Informazioni pratiche</p><h2 id="lessons-title">Le lezioni</h2></div>
<p>Un appuntamento settimanale per allenarsi con continuità, senza fretta.</p>
</div>
{lesson ? (
<div class="lesson-panel">
<dl class="lesson-details">
<div><dt>Quando</dt><dd>{lesson.schedule}</dd></div>
<div><dt>Dove</dt><dd>{lesson.location}</dd></div>
<div><dt>Per chi</dt><dd>{lesson.audience}</dd></div>
<div><dt>Cosa facciamo</dt><dd><div class="rich-text" set:html={lesson.description} /></dd></div>
</dl>
<aside class="trial-card" aria-label="Lezione di prova">
<p class="eyebrow">Puoi provare</p>
<h3>{lesson.trial_lesson}</h3>
{lesson.notes && <p>{lesson.notes}</p>}
<a class="button" href={contactUrl}>Prenota un incontro</a>
</aside>
</div>
) : <p class="empty-state">Il calendario delle lezioni sarà pubblicato presto.</p>}
</div>
</section>
+12
View File
@@ -0,0 +1,12 @@
---
interface Props { title: string; body: string }
const { title, body } = Astro.props;
---
<section class="manifesto section" aria-labelledby="manifesto-title">
<div class="container narrow">
<p class="eyebrow">Il nostro modo di lavorare</p>
<h2 id="manifesto-title">{title}</h2>
<div class="manifesto-text rich-text" set:html={body} />
</div>
</section>
@@ -0,0 +1,29 @@
---
import type { ShowContent } from "../lib/api";
interface Props { shows: ShowContent[] }
const { shows } = Astro.props;
---
<section class="shows section section-light" id="spettacoli" aria-labelledby="shows-title">
<div class="container">
<div class="section-heading horizontal-heading">
<div><p class="eyebrow">La ricerca incontra il pubblico</p><h2 id="shows-title">Spettacoli e progetti</h2></div>
<p>Esiti, attraversamenti e storie nate durante il lavoro in sala.</p>
</div>
{shows.length ? (
<div class="show-grid">
{shows.map((show) => (
<article class="show-card">
<div class="show-image"><img src={show.image.url} alt={show.image.alt} width="700" height="880" loading="lazy" /></div>
<div class="show-copy">
<p class="show-meta"><span>{show.year}</span>{show.location && <span>{show.location}</span>}</p>
<h3>{show.title}</h3>
<p>{show.short_description}</p>
{show.cast && <p class="muted">{show.cast}</p>}
</div>
</article>
))}
</div>
) : <p class="empty-state">Nuovi spettacoli e progetti sono in preparazione.</p>}
</div>
</section>
@@ -0,0 +1,25 @@
---
import type { TeacherContent } from "../lib/api";
interface Props { teacher: TeacherContent | null }
const { teacher } = Astro.props;
---
<section class="teacher section section-sand" id="maestro" aria-labelledby="teacher-title">
<div class="container">
{teacher ? (
<div class="teacher-grid">
<div class="teacher-photo"><img src={teacher.photo.url} alt={teacher.photo.alt} width="760" height="900" loading="lazy" /></div>
<div class="teacher-copy">
<p class="eyebrow">La guida del percorso</p>
<h2 id="teacher-title">Il maestro</h2>
<h3>{teacher.name}</h3>
<p class="lead">{teacher.short_bio}</p>
{teacher.full_bio && <div class="rich-text" set:html={teacher.full_bio} />}
{teacher.quote && <blockquote>“{teacher.quote}”</blockquote>}
</div>
</div>
) : (
<div class="empty-state"><h2 id="teacher-title">Il maestro</h2><p>Il profilo dellinsegnante sarà disponibile presto.</p></div>
)}
</div>
</section>
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_CMS_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+26
View File
@@ -0,0 +1,26 @@
---
import "../styles/global.css";
interface Props {
title: string;
description: string;
}
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<meta name="theme-color" content="#F3EBDD" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>{title}</title>
</head>
<body>
<a class="skip-link" href="#contenuto">Vai al contenuto</a>
<slot />
</body>
</html>
+321
View File
@@ -0,0 +1,321 @@
export interface CmsImage {
url: string;
alt: string;
}
export interface SiteSettings {
site_name: string;
short_claim: string;
email: string;
phone: string;
whatsapp: string;
instagram: string;
facebook: string;
address: string;
footer_text: string;
}
export interface HomePageContent {
hero_title: string;
hero_subtitle: string;
hero_image: CmsImage;
intro_title: string;
intro_body: string;
laboratory_title: string;
laboratory_body: string;
laboratory_image: CmsImage;
cta_primary_label: string;
cta_primary_url: string;
cta_secondary_label: string;
cta_secondary_url: string;
}
export interface FeatureCardContent {
title: string;
text: string;
order: number;
}
export interface TeacherContent {
name: string;
photo: CmsImage;
short_bio: string;
full_bio: string;
quote: string;
}
export interface LessonContent {
schedule: string;
location: string;
audience: string;
description: string;
trial_lesson: string;
notes: string;
}
export interface ShowContent {
title: string;
year: string;
short_description: string;
description: string;
image: CmsImage;
location: string;
cast: string;
order: number;
}
export interface GalleryItemContent {
image: CmsImage;
category: string;
caption: string;
order: number;
}
export interface HomeData {
settings: SiteSettings;
homepage: HomePageContent;
feature_cards: FeatureCardContent[];
teacher: TeacherContent | null;
lesson_info: LessonContent | null;
shows: ShowContent[];
gallery_items: GalleryItemContent[];
}
const placeholder = (name: string, alt: string): CmsImage => ({
url: `/images/${name}.svg`,
alt,
});
export const fallbackHomeData: HomeData = {
settings: {
site_name: "Laboratorio Teatrale",
short_claim: "Il teatro diventa presenza.",
email: "ciao@laboratorioteatrale.it",
phone: "+39 333 123 4567",
whatsapp: "+39 333 123 4567",
instagram: "https://instagram.com/",
facebook: "https://facebook.com/",
address: "Via della Scena 12, Roma",
footer_text: "Uno spazio aperto a chi desidera incontrare il teatro, insieme.",
},
homepage: {
hero_title: "Il teatro diventa presenza.",
hero_subtitle: "Un laboratorio dove corpo, voce, ascolto e relazione diventano scena.",
hero_image: placeholder("hero", "Il gruppo durante un esercizio teatrale"),
intro_title: "Uno spazio per esserci davvero",
intro_body:
"Ogni incontro è uno spazio di pratica: si prova, si sbaglia, si ascolta, si ricomincia. Il gruppo diventa materia viva, la scena diventa occasione di scoperta.",
laboratory_title: "Il laboratorio",
laboratory_body:
"Lavoriamo con il corpo, la voce e limprovvisazione per allenare ascolto e presenza scenica. Attraverso esercizi individuali e di gruppo, ogni persona trova un modo autentico di stare nello spazio e nella relazione.",
laboratory_image: placeholder("laboratory", "Esercizio di movimento nello spazio"),
cta_primary_label: "Vieni a conoscerci",
cta_primary_url: "#contatti",
cta_secondary_label: "Guarda la galleria",
cta_secondary_url: "#galleria",
},
feature_cards: [
{
title: "Per chi inizia",
text: "Non serve esperienza: serve curiosità, ascolto e voglia di mettersi in gioco.",
order: 1,
},
{
title: "Per chi vuole crescere",
text: "Un tempo regolare per approfondire strumenti, consapevolezza e libertà espressiva.",
order: 2,
},
{
title: "Per chi ama il gruppo",
text: "La scena nasce dalla fiducia: si crea insieme, rispettando tempi e sensibilità diverse.",
order: 3,
},
],
teacher: {
name: "Andrea Morelli",
photo: placeholder("teacher", "Ritratto del maestro Andrea Morelli"),
short_bio:
"Attore, regista e formatore, accompagna gruppi di ogni esperienza con cura e concretezza.",
full_bio:
"Da oltre quindici anni conduce percorsi dedicati alla ricerca dellautenticità scenica, intrecciando pedagogia teatrale, movimento e lavoro sulla voce.",
quote: "Il teatro non è fingere: è imparare a essere presenti.",
},
lesson_info: {
schedule: "Ogni martedì, dalle 20:00 alle 22:30",
location: "Spazio Scena, Via della Scena 12, Roma",
audience: "Adulti, con o senza esperienza",
description: "Training fisico e vocale, improvvisazione, ascolto, costruzione del personaggio e lavoro di scena.",
trial_lesson: "La prima lezione di prova è gratuita, su prenotazione.",
notes: "Abiti comodi e calze antiscivolo consigliati.",
},
shows: [
{
title: "Le cose che restano",
year: "2025",
short_description: "Un lavoro corale su memoria, gesti quotidiani e piccoli cambiamenti.",
description: "",
image: placeholder("show-one", "Locandina dello spettacolo Le cose che restano"),
location: "Teatro di Quartiere",
cast: "Gruppo avanzato",
order: 1,
},
{
title: "Fuori campo",
year: "2024",
short_description: "Storie ai margini della scena che chiedono, finalmente, di essere ascoltate.",
description: "",
image: placeholder("show-two", "Locandina dello spettacolo Fuori campo"),
location: "Spazio Scena",
cast: "Laboratorio annuale",
order: 2,
},
],
gallery_items: [
{ image: placeholder("gallery-one", "Esercizio di gruppo durante una lezione"), category: "Lezioni", caption: "Ascolto e movimento", order: 1 },
{ image: placeholder("gallery-two", "Il gruppo dietro le quinte"), category: "Backstage", caption: "Prima di entrare in scena", order: 2 },
{ image: placeholder("gallery-three", "Una scena dello spettacolo finale"), category: "Spettacoli", caption: "Le cose che restano", order: 3 },
{ image: placeholder("gallery-four", "Ritratto del gruppo teatrale"), category: "Gruppo", caption: "Il laboratorio, insieme", order: 4 },
],
};
type UnknownRecord = Record<string, unknown>;
const record = (value: unknown): UnknownRecord =>
value !== null && typeof value === "object" ? (value as UnknownRecord) : {};
const text = (value: unknown, fallback = ""): string =>
typeof value === "string" && value.trim() ? value : fallback;
const optionalText = (value: unknown, fallback = ""): string =>
typeof value === "string" ? value.trim() : fallback;
const number = (value: unknown, fallback = 0): number =>
typeof value === "number" ? value : Number(value) || fallback;
const image = (value: unknown, fallback: CmsImage): CmsImage => {
if (typeof value === "string" && value) return { url: value, alt: fallback.alt };
const item = record(value);
return {
url: text(item.url, text(item.src, fallback.url)),
alt: text(item.alt, text(item.title, fallback.alt)),
};
};
const absoluteImage = (value: CmsImage, baseUrl: string): CmsImage => {
if (!value.url.startsWith("/media/")) return value;
try {
return { ...value, url: new URL(value.url, baseUrl).toString() };
} catch {
return value;
}
};
function normalizeHomeData(payload: unknown, baseUrl: string): HomeData {
const source = record(payload);
const settings = record(source.settings);
const homepage = record(source.homepage);
const teacher = source.teacher === null ? null : record(source.teacher);
const lesson = source.lesson_info === null ? null : record(source.lesson_info);
const fallback = fallbackHomeData;
const resolveImage = (value: unknown, defaultImage: CmsImage) =>
absoluteImage(image(value, defaultImage), baseUrl);
return {
settings: {
site_name: text(settings.site_name, text(settings.laboratory_name, text(settings.name, fallback.settings.site_name))),
short_claim: optionalText(settings.short_claim, optionalText(settings.claim, fallback.settings.short_claim)),
email: optionalText(settings.email, fallback.settings.email),
phone: optionalText(settings.phone, fallback.settings.phone),
whatsapp: optionalText(settings.whatsapp, fallback.settings.whatsapp),
instagram: optionalText(settings.instagram, fallback.settings.instagram),
facebook: optionalText(settings.facebook, fallback.settings.facebook),
address: optionalText(settings.address, fallback.settings.address),
footer_text: optionalText(settings.footer_text, fallback.settings.footer_text),
},
homepage: {
hero_title: text(homepage.hero_title, fallback.homepage.hero_title),
hero_subtitle: text(homepage.hero_subtitle, fallback.homepage.hero_subtitle),
hero_image: resolveImage(homepage.hero_image, fallback.homepage.hero_image),
intro_title: text(homepage.intro_title, fallback.homepage.intro_title),
intro_body: text(homepage.intro_body, fallback.homepage.intro_body),
laboratory_title: text(homepage.laboratory_title, fallback.homepage.laboratory_title),
laboratory_body: text(homepage.laboratory_body, fallback.homepage.laboratory_body),
laboratory_image: resolveImage(homepage.laboratory_image, fallback.homepage.laboratory_image),
cta_primary_label: text(homepage.cta_primary_label, fallback.homepage.cta_primary_label),
cta_primary_url: text(homepage.cta_primary_url, fallback.homepage.cta_primary_url),
cta_secondary_label: text(homepage.cta_secondary_label, fallback.homepage.cta_secondary_label),
cta_secondary_url: text(homepage.cta_secondary_url, fallback.homepage.cta_secondary_url),
},
feature_cards: Array.isArray(source.feature_cards)
? source.feature_cards.map((item, index) => {
const card = record(item);
return { title: text(card.title), text: text(card.text, text(card.body)), order: number(card.order, index + 1) };
}).filter((item) => item.title).sort((a, b) => a.order - b.order)
: fallback.feature_cards,
teacher: teacher
? {
name: text(teacher.name, fallback.teacher?.name),
photo: resolveImage(teacher.photo, fallback.teacher?.photo ?? placeholder("teacher", "Ritratto del maestro")),
short_bio: text(teacher.short_bio, text(teacher.bio_breve, fallback.teacher?.short_bio)),
full_bio: text(teacher.full_bio, text(teacher.bio_completa, fallback.teacher?.full_bio)),
quote: text(teacher.quote, text(teacher.citazione, fallback.teacher?.quote)),
}
: null,
lesson_info: lesson
? {
schedule: text(lesson.schedule, text(lesson.day_time, text(lesson.giorno_orario, fallback.lesson_info?.schedule))),
location: text(lesson.location, text(lesson.luogo, fallback.lesson_info?.location)),
audience: text(lesson.audience, text(lesson.destinatari, fallback.lesson_info?.audience)),
description: text(lesson.description, text(lesson.descrizione, fallback.lesson_info?.description)),
trial_lesson: text(lesson.trial_lesson, text(lesson.lezione_di_prova, fallback.lesson_info?.trial_lesson)),
notes: text(lesson.notes, text(lesson.note, fallback.lesson_info?.notes)),
}
: null,
shows: Array.isArray(source.shows)
? source.shows.map((item, index) => {
const show = record(item);
const fallbackImage = placeholder(index % 2 ? "show-two" : "show-one", "Locandina dello spettacolo");
return {
title: text(show.title, text(show.titolo)),
year: text(show.year, text(show.anno)),
short_description: text(show.short_description, text(show.descrizione_breve)),
description: text(show.description, text(show.descrizione)),
image: resolveImage(show.image ?? show.poster ?? show.immagine, fallbackImage),
location: text(show.location, text(show.luogo)),
cast: text(show.cast),
order: number(show.order, text(show.ordine) ? number(show.ordine) : index + 1),
};
}).filter((item) => item.title).sort((a, b) => a.order - b.order)
: fallback.shows,
gallery_items: Array.isArray(source.gallery_items)
? source.gallery_items.map((item, index) => {
const galleryItem = record(item);
const fallbackImage = placeholder(`gallery-${["one", "two", "three", "four"][index % 4]}`, "Immagine dal laboratorio");
return {
image: resolveImage(galleryItem.image ?? galleryItem.immagine, fallbackImage),
category: text(galleryItem.category, text(galleryItem.categoria, "Laboratorio")),
caption: text(galleryItem.caption, text(galleryItem.didascalia)),
order: number(galleryItem.order, text(galleryItem.ordine) ? number(galleryItem.ordine) : index + 1),
};
}).sort((a, b) => a.order - b.order)
: fallback.gallery_items,
};
}
export async function getHomeData(): Promise<HomeData> {
const baseUrl = import.meta.env.PUBLIC_CMS_API_URL?.replace(/\/$/, "");
if (!baseUrl) return fallbackHomeData;
try {
const response = await fetch(`${baseUrl}/api/site/home/`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(3500),
});
if (!response.ok) throw new Error(`CMS returned ${response.status}`);
return normalizeHomeData(await response.json(), baseUrl);
} catch (error) {
console.warn("CMS unavailable; using local demo content.", error);
return fallbackHomeData;
}
}
+33
View File
@@ -0,0 +1,33 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import Header from "../components/Header.astro";
import Hero from "../components/Hero.astro";
import Manifesto from "../components/Manifesto.astro";
import FeatureCards from "../components/FeatureCards.astro";
import LaboratorySection from "../components/LaboratorySection.astro";
import TeacherSection from "../components/TeacherSection.astro";
import LessonsSection from "../components/LessonsSection.astro";
import ShowsSection from "../components/ShowsSection.astro";
import GallerySection from "../components/GallerySection.astro";
import ContactSection from "../components/ContactSection.astro";
import Footer from "../components/Footer.astro";
import { getHomeData } from "../lib/api";
const data = await getHomeData();
---
<BaseLayout title={`${data.settings.site_name} · ${data.settings.short_claim}`} description={data.homepage.hero_subtitle}>
<Header siteName={data.settings.site_name} ctaLabel={data.homepage.cta_primary_label} ctaUrl={data.homepage.cta_primary_url} />
<main id="contenuto">
<Hero content={data.homepage} />
<Manifesto title={data.homepage.intro_title} body={data.homepage.intro_body} />
<FeatureCards cards={data.feature_cards} />
<LaboratorySection content={data.homepage} />
<TeacherSection teacher={data.teacher} />
<LessonsSection lesson={data.lesson_info} contactUrl="#contatti" />
<ShowsSection shows={data.shows} />
<GallerySection items={data.gallery_items} />
<ContactSection settings={data.settings} />
</main>
<Footer settings={data.settings} />
</BaseLayout>
+972
View File
@@ -0,0 +1,972 @@
:root {
--color-bg: #f3ebdd;
--color-bg-light: #fff8ef;
--color-dark: #4a433a;
--color-dark-soft: #5a5046;
--color-dark-text: #1d1a17;
--color-primary: #a4513b;
--color-primary-soft: #b8674c;
--color-sage: #515d53;
--color-sand: #d8c7ae;
--color-muted: #7a7067;
--color-border: #e6d8c8;
--font-display: "Cormorant Garamond", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
--font-body: "Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--shadow-soft: 0 24px 60px rgb(74 67 58 / 10%);
--radius: 0.35rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 5rem;
}
body {
margin: 0;
min-width: 320px;
overflow-x: hidden;
background: var(--color-bg);
color: var(--color-dark-text);
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.65;
text-rendering: optimizeLegibility;
}
body::before {
position: fixed;
z-index: -1;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.035'/%3E%3C/svg%3E");
content: "";
pointer-events: none;
}
img {
display: block;
width: 100%;
max-width: 100%;
height: auto;
}
a {
color: inherit;
text-underline-offset: 0.2em;
}
a:hover {
color: var(--color-primary);
}
:focus-visible {
border-radius: 0.15rem;
outline: 3px solid var(--color-primary);
outline-offset: 4px;
}
h1,
h2,
h3,
p,
figure,
blockquote,
dl,
dd {
margin-top: 0;
}
h1,
h2,
h3 {
font-family: var(--font-display);
font-weight: 500;
line-height: 1.02;
letter-spacing: -0.025em;
overflow-wrap: anywhere;
}
h1 {
max-width: 11ch;
margin-bottom: 1.5rem;
font-size: clamp(3.5rem, 15vw, 7.5rem);
}
h2 {
margin-bottom: 1.5rem;
font-size: clamp(2.7rem, 10vw, 5.3rem);
}
h3 {
margin-bottom: 0.75rem;
font-size: clamp(1.7rem, 5vw, 2.35rem);
}
.container {
width: min(100% - 2.5rem, 76rem);
margin-inline: auto;
}
.narrow {
max-width: 58rem;
}
.section {
padding-block: clamp(5rem, 12vw, 9.5rem);
}
.section-light {
background: var(--color-bg-light);
}
.section-sand {
background: color-mix(in srgb, var(--color-sand) 68%, var(--color-bg));
}
.eyebrow {
margin-bottom: 1rem;
color: var(--color-primary);
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.16em;
line-height: 1.4;
text-transform: uppercase;
}
.lead {
color: var(--color-dark-soft);
font-size: clamp(1.15rem, 2vw, 1.38rem);
line-height: 1.65;
}
.rich-text > :last-child {
margin-bottom: 0;
}
.rich-text a {
color: var(--color-primary);
}
.muted {
color: var(--color-dark-soft);
}
.skip-link {
position: fixed;
z-index: 100;
top: 1rem;
left: 1rem;
padding: 0.65rem 1rem;
transform: translateY(-180%);
background: var(--color-dark-text);
color: var(--color-bg-light);
}
.skip-link:focus {
transform: translateY(0);
}
.site-header {
position: absolute;
z-index: 20;
top: 0;
right: 0;
left: 0;
padding-block: 1.25rem;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.wordmark {
max-width: 10rem;
color: var(--color-dark-text);
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1;
text-decoration: none;
}
.desktop-nav,
.desktop-cta {
display: none;
}
.mobile-menu {
position: relative;
}
.mobile-menu summary {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 2.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-dark-text);
cursor: pointer;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
list-style: none;
text-transform: uppercase;
}
.mobile-menu summary::-webkit-details-marker {
display: none;
}
.mobile-menu summary > span:first-child,
.mobile-menu summary > span:first-child::before,
.mobile-menu summary > span:first-child::after {
display: block;
width: 1rem;
height: 1px;
background: currentcolor;
content: "";
}
.mobile-menu summary > span:first-child::before {
transform: translateY(-0.3rem);
}
.mobile-menu summary > span:first-child::after {
transform: translateY(0.25rem);
}
.mobile-menu[open] summary > span:first-child {
background: transparent;
}
.mobile-menu[open] summary > span:first-child::before {
transform: translateY(0.05rem) rotate(45deg);
}
.mobile-menu[open] summary > span:first-child::after {
transform: translateY(-0.02rem) rotate(-45deg);
}
.mobile-menu nav {
position: absolute;
top: calc(100% + 0.6rem);
right: 0;
display: grid;
width: min(18rem, calc(100vw - 2.5rem));
padding: 1rem;
border: 1px solid var(--color-border);
background: var(--color-bg-light);
box-shadow: var(--shadow-soft);
}
.mobile-menu nav > a:not(.button) {
padding: 0.7rem 0.25rem;
border-bottom: 1px solid var(--color-border);
text-decoration: none;
}
.mobile-menu .button {
margin-top: 1rem;
}
.button {
display: inline-flex;
min-height: 3.15rem;
align-items: center;
justify-content: center;
padding: 0.75rem 1.35rem;
border: 1px solid var(--color-primary);
border-radius: var(--radius);
background: var(--color-primary);
color: #fff8ef;
font-size: 0.83rem;
font-weight: 700;
letter-spacing: 0.05em;
line-height: 1.2;
text-align: center;
text-decoration: none;
text-transform: uppercase;
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
}
.button:hover {
border-color: var(--color-dark-text);
background: var(--color-dark-text);
color: var(--color-bg-light);
transform: translateY(-2px);
}
.button-small {
min-height: 2.65rem;
padding: 0.65rem 1rem;
font-size: 0.73rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
}
.text-link {
padding-block: 0.65rem;
font-size: 0.86rem;
font-weight: 700;
letter-spacing: 0.04em;
text-decoration-thickness: 1px;
text-transform: uppercase;
}
.hero {
position: relative;
min-height: min(48rem, 100svh);
padding-bottom: 4.5rem;
padding-top: 7.5rem;
overflow: hidden;
}
.hero::after {
position: absolute;
z-index: -1;
top: 7%;
right: -12rem;
width: 23rem;
height: 23rem;
border: 1px solid rgb(164 81 59 / 20%);
border-radius: 50%;
content: "";
}
.hero-grid {
display: grid;
gap: 3rem;
}
.hero-copy {
align-self: center;
}
.hero-subtitle {
max-width: 34rem;
margin-bottom: 2rem;
color: var(--color-dark-soft);
font-size: clamp(1.18rem, 2vw, 1.45rem);
}
.hero-visual {
position: relative;
margin-bottom: 0;
}
.hero-visual::before {
position: absolute;
z-index: -1;
inset: -1rem 1rem 1rem -1rem;
border: 1px solid var(--color-sand);
content: "";
}
.hero-visual img {
max-height: 28rem;
aspect-ratio: 4 / 3;
object-fit: cover;
object-position: center 42%;
}
.hero-visual figcaption {
position: absolute;
right: -0.2rem;
bottom: 0;
padding: 0.65rem 0 0.15rem 1rem;
background: var(--color-bg);
color: var(--color-dark-soft);
font-family: var(--font-display);
font-size: 1.05rem;
font-style: italic;
}
.manifesto {
text-align: center;
}
.manifesto h2 {
margin-inline: auto;
}
.manifesto-text {
max-width: 37ch;
margin-right: auto;
margin-bottom: 0;
margin-left: auto;
color: var(--color-dark-soft);
font-family: var(--font-display);
font-size: clamp(1.55rem, 4vw, 2.45rem);
line-height: 1.42;
}
.manifesto-text > :last-child {
margin-bottom: 0;
}
.section-heading {
margin-bottom: clamp(2.5rem, 7vw, 4.5rem);
}
.section-heading h2 {
margin-bottom: 0;
}
.feature-grid {
display: grid;
gap: 1rem;
}
.feature-card {
min-height: 17rem;
padding: 1.75rem;
border: 1px solid var(--color-border);
background: rgb(255 255 255 / 24%);
}
.feature-card:nth-child(2) {
background: color-mix(in srgb, var(--color-sage) 8%, var(--color-bg-light));
}
.card-number {
display: block;
margin-bottom: 3.75rem;
color: var(--color-primary);
font-family: var(--font-display);
font-size: 1.2rem;
}
.feature-card p {
max-width: 31ch;
margin-bottom: 0;
color: var(--color-dark-soft);
}
.split-grid,
.teacher-grid {
display: grid;
gap: 3rem;
}
.editorial-image,
.teacher-photo,
.show-image {
overflow: hidden;
background: var(--color-sand);
}
.editorial-image img {
aspect-ratio: 5 / 4;
object-fit: cover;
}
.split-copy {
align-self: center;
}
.practice-list {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin: 2.25rem 0 0;
padding: 0;
list-style: none;
}
.practice-list li {
padding: 0.5rem 0.85rem;
border: 1px solid var(--color-sand);
border-radius: 99rem;
color: var(--color-sage);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.teacher-photo img {
aspect-ratio: 4 / 5;
object-fit: cover;
}
.teacher-copy {
align-self: center;
}
.teacher-copy h2 {
margin-bottom: 0.75rem;
}
.teacher-copy h3 {
margin-bottom: 1.5rem;
color: var(--color-primary);
}
blockquote {
position: relative;
margin: 2.5rem 0 0;
padding: 1.5rem 0 0 1.5rem;
border-top: 1px solid rgb(74 67 58 / 20%);
color: var(--color-dark);
font-family: var(--font-display);
font-size: clamp(1.65rem, 3vw, 2.35rem);
font-style: italic;
line-height: 1.25;
}
blockquote::before {
position: absolute;
top: 1.5rem;
left: 0;
color: var(--color-primary);
content: "—";
}
.horizontal-heading > p {
max-width: 33rem;
margin-bottom: 0;
color: var(--color-dark-soft);
}
.lesson-panel {
border-top: 1px solid var(--color-sand);
}
.lesson-details {
margin-bottom: 2rem;
}
.lesson-details > div {
display: grid;
gap: 0.35rem;
padding-block: 1.4rem;
border-bottom: 1px solid var(--color-border);
}
.lesson-details dt {
color: var(--color-primary);
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.lesson-details dd {
margin-bottom: 0;
font-size: 1.05rem;
}
.trial-card {
padding: 2rem;
background: var(--color-sage);
color: var(--color-bg-light);
}
.trial-card .eyebrow {
color: var(--color-sand);
}
.trial-card h3 {
font-size: clamp(1.9rem, 4vw, 2.7rem);
line-height: 1.15;
}
.trial-card p:not(.eyebrow) {
color: rgb(255 248 239 / 78%);
}
.trial-card .button {
margin-top: 1rem;
border-color: var(--color-bg-light);
background: var(--color-bg-light);
color: var(--color-sage);
}
.show-grid {
display: grid;
gap: 2.5rem;
}
.show-card {
display: grid;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-light);
}
.show-image img {
aspect-ratio: 4 / 5;
object-fit: cover;
transition: transform 500ms ease;
}
.show-card:hover .show-image img {
transform: scale(1.02);
}
.show-copy {
padding: 1.5rem 0 2rem;
}
.show-meta {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1.25rem;
color: var(--color-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.show-copy p:last-child {
margin-bottom: 0;
}
.gallery-grid {
display: grid;
gap: 1.5rem;
}
.gallery-item {
margin-bottom: 0;
}
.gallery-item img {
aspect-ratio: 5 / 4;
object-fit: cover;
}
.gallery-item figcaption {
padding-top: 0.75rem;
color: var(--color-dark-soft);
font-size: 0.88rem;
}
.gallery-item figcaption span {
margin-right: 0.75rem;
color: var(--color-primary);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.contact {
background: var(--color-dark);
color: var(--color-bg-light);
}
.contact-grid {
display: grid;
gap: 3rem;
}
.contact .eyebrow {
color: var(--color-sand);
}
.contact .lead {
color: rgb(255 248 239 / 78%);
}
.contact address {
color: var(--color-sand);
font-style: normal;
}
.contact-actions {
border-top: 1px solid rgb(255 248 239 / 20%);
}
.contact-actions a {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
align-items: center;
padding-block: 1.35rem;
border-bottom: 1px solid rgb(255 248 239 / 20%);
color: var(--color-bg-light);
text-decoration: none;
}
.contact-actions a > span:first-child {
color: var(--color-sand);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.contact-actions strong {
grid-row: 2;
min-width: 0;
font-family: var(--font-display);
font-size: clamp(1.15rem, 4vw, 1.65rem);
font-weight: 500;
overflow-wrap: anywhere;
}
.contact-actions a > span:last-child {
grid-row: 1 / 3;
grid-column: 2;
font-size: 1.4rem;
transition: transform 160ms ease;
}
.contact-actions a:hover {
color: var(--color-bg-light);
}
.contact-actions a:hover > span:last-child {
transform: translate(0.2rem, -0.2rem);
}
.contact a:focus-visible {
outline-color: var(--color-bg-light);
}
.empty-state {
padding: 2rem;
border: 1px solid var(--color-border);
color: var(--color-dark-soft);
}
.contact-empty {
border-color: rgb(255 248 239 / 20%);
color: var(--color-sand);
}
.site-footer {
padding-block: 3rem 1.5rem;
background: var(--color-bg-light);
}
.footer-main,
.footer-bottom {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.footer-main > div p {
max-width: 35rem;
margin: 1rem 0 0;
color: var(--color-dark-soft);
}
.footer-main nav {
display: flex;
gap: 1.25rem;
}
.footer-bottom {
margin-top: 2.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--color-border);
color: var(--color-dark-soft);
font-size: 0.8rem;
}
.footer-bottom p {
margin: 0;
}
@media (min-width: 36rem) {
.container {
width: min(100% - 4rem, 76rem);
}
.feature-grid,
.gallery-grid,
.show-grid {
grid-template-columns: repeat(2, 1fr);
}
.gallery-item:nth-child(3n + 1) {
grid-column: span 2;
}
.gallery-item:nth-child(3n + 1) img {
aspect-ratio: 2 / 1;
}
.lesson-details > div {
grid-template-columns: 9rem 1fr;
gap: 1rem;
}
.lesson-details dd {
font-size: 1.1rem;
}
}
@media (min-width: 54rem) {
.site-header {
padding-block: 1.75rem;
}
.desktop-nav,
.desktop-cta {
display: flex;
}
.desktop-nav {
align-items: center;
gap: clamp(1rem, 2.5vw, 2rem);
}
.desktop-nav a {
font-size: 0.79rem;
font-weight: 600;
text-decoration: none;
}
.mobile-menu {
display: none;
}
.hero {
min-height: min(54rem, 100svh);
padding-top: 9.5rem;
}
.hero-grid {
grid-template-columns: minmax(0, 1.05fr) minmax(20rem, 0.7fr);
align-items: center;
gap: clamp(3rem, 7vw, 7rem);
}
.hero-visual {
margin: 0;
}
.hero-visual img {
max-height: none;
aspect-ratio: 6 / 7;
}
.horizontal-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 4rem;
}
.feature-grid {
grid-template-columns: repeat(3, 1fr);
}
.feature-card {
min-height: 20rem;
padding: 2.25rem;
}
.feature-card:nth-child(2) {
transform: translateY(1.75rem);
}
.split-grid {
grid-template-columns: minmax(0, 1.1fr) minmax(20rem, 0.9fr);
gap: clamp(4rem, 8vw, 8rem);
}
.teacher-grid {
grid-template-columns: minmax(18rem, 0.65fr) minmax(25rem, 1fr);
gap: clamp(4rem, 8vw, 8rem);
}
.teacher-photo {
transform: rotate(-1.25deg);
}
.lesson-panel {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(18rem, 0.65fr);
gap: clamp(3rem, 8vw, 7rem);
align-items: start;
padding-top: 2rem;
}
.trial-card {
margin-top: 1rem;
padding: 2.5rem;
}
.show-grid {
gap: 2.5rem;
}
.show-copy {
padding: 1.75rem 0 2.25rem;
}
.gallery-grid {
grid-template-columns: repeat(12, 1fr);
align-items: start;
}
.gallery-item,
.gallery-item:nth-child(3n + 1) {
grid-column: span 6;
}
.gallery-item:nth-child(4n + 2),
.gallery-item:nth-child(4n + 3) {
grid-column: span 5;
}
.gallery-item:nth-child(4n + 3) {
grid-column-start: 8;
margin-top: 4rem;
}
.gallery-item:nth-child(4n + 4) {
grid-column: 3 / span 8;
margin-top: 2rem;
}
.gallery-item img,
.gallery-item:nth-child(3n + 1) img {
aspect-ratio: 5 / 4;
}
.contact-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(26rem, 1fr);
gap: clamp(5rem, 10vw, 10rem);
}
.footer-main,
.footer-bottom {
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}