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
+7 -6
View File
@@ -4,9 +4,9 @@ Edit this file for each repository.
## Project identity
Project name: `CHANGE_ME`
Project description: `CHANGE_ME`
Primary language/runtime: `CHANGE_ME`
Project name: `Laboratorio Teatrale`
Project description: `Headless Wagtail CMS and Astro single-page website for a theatre workshop.`
Primary language/runtime: `Python 3.12 and Node.js 22`
## Project mode
@@ -14,7 +14,6 @@ Choose one:
```text
project_mode: personal
project_mode: work
```
Rules:
@@ -29,7 +28,6 @@ Enable only the profiles that apply to this repository:
```text
enabled_profiles:
- docker
- ansible
- python
```
@@ -77,7 +75,10 @@ All tests MUST be executed inside Docker containers.
Configure the canonical test command for this repository:
```bash
CHANGE_ME
docker compose run --rm backend python manage.py test
docker compose run --rm frontend npm run check
docker compose run --rm frontend npm run build
docker compose config --quiet
```
Examples:
+10
View File
@@ -0,0 +1,10 @@
# Development-only values. Replace every secret outside local development.
POSTGRES_DB=theatre_lab
POSTGRES_USER=theatre_lab
POSTGRES_PASSWORD=replace-with-a-local-password
DATABASE_URL=postgresql://theatre_lab:replace-with-a-local-password@postgres:5432/theatre_lab
DJANGO_SECRET_KEY=replace-with-a-long-random-development-key
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend
WAGTAILADMIN_BASE_URL=http://localhost:8000
PUBLIC_CMS_API_URL=http://backend:8000
+21
View File
@@ -0,0 +1,21 @@
.env
.env.*
!.env.example
.DS_Store
__pycache__/
*.py[cod]
.coverage
.coverage.*
htmlcov/
.pytest_cache/
.ruff_cache/
backend/media/
backend/static/
backend/staticfiles/
frontend/.astro/
frontend/dist/
frontend/node_modules/
backups/
media-backup/
*.sql
*.dump
+124 -59
View File
@@ -1,73 +1,138 @@
# codex-bootstrap
# Laboratorio Teatrale
A repository template for AI-assisted development with Codex.
A warm, editorial single-page website for a contemporary theatre workshop. Wagtail
provides an editor-friendly headless CMS, Astro renders the public site, PostgreSQL
stores content, and Docker Compose supplies a reproducible local environment.
This template defines a repeatable workflow for using Codex as an autonomous coding agent that can create branches, modify code, run Docker-based tests, update documentation, write ADRs, and commit changes using Conventional Commits.
## Architecture
## Purpose
- `backend/`: Django and Wagtail admin, content models, media uploads, and the
aggregate read-only API at `GET /api/site/home/`.
- `frontend/`: Astro components, responsive design system, typed API adapter, and
Italian fallback content for development when the CMS is unavailable.
- `postgres`: persistent CMS database.
- Docker volumes: `postgres_data` for the database and `media_data` for uploads.
Use this template to bootstrap repositories where Codex must operate with clear rules, minimal changes, pragmatic TDD, security guardrails, and explicit documentation requirements.
See [the architecture document](docs/architecture.md) and
[ADR-0001](docs/adr/0001-headless-wagtail-astro.md) for the rationale.
## Repository structure
## Prerequisites
- Docker Engine with Docker Compose v2.
- Ports `4321` and `8000` available on localhost.
No host Python or Node.js installation is required.
## Start locally
```bash
cp .env.example .env
docker compose up --build -d
docker compose ps
docker compose exec backend python manage.py seed_demo
```
Open:
- public site: <http://localhost:4321>
- Wagtail admin: <http://localhost:8000/admin/>
- aggregate API: <http://localhost:8000/api/site/home/>
The frontend remains usable with curated fallback content if the API is unavailable.
On first startup the backend applies migrations and collects static files before
starting Gunicorn. Wait until `postgres`, `backend`, and `frontend` report healthy.
## Create an editor account
```bash
docker compose exec backend python manage.py createsuperuser
```
Sign in to Wagtail at <http://localhost:8000/admin/>. The public site has no user
accounts or authentication.
## Demo content
The seed command is idempotent and creates or updates the initial home page, site
settings, participation cards, teacher, lessons, shows, gallery entries, and local
placeholder images:
```bash
docker compose exec backend python manage.py seed_demo
```
Re-run it only when you intentionally want to restore the demo records. Content edited
after seeding may be overwritten for records managed by the command.
## Editing content
1. Open Wagtail admin and select **Pages** to edit the home hero, introduction,
laboratory section, and the three participation cards.
2. Select **Settings > Site settings** for the workshop name and contact details.
3. Use **Snippets** for the teacher, lessons, shows, and gallery.
4. Publish page changes. Snippet and settings changes are returned immediately by the
aggregate endpoint.
The frontend reads `PUBLIC_CMS_API_URL`; inside Compose it defaults to
`http://backend:8000`. Media URLs use `WAGTAILADMIN_BASE_URL` so browsers receive the
public `http://localhost:8000` address. Change both values for another environment.
The standard published-page endpoint is also available at `/api/v2/pages/`.
## Useful commands
```bash
# Follow application logs
docker compose logs -f postgres backend frontend
# Run database migrations
docker compose exec backend python manage.py migrate
# Run all required checks in containers
docker compose run --rm backend python manage.py test
docker compose run --rm frontend npm run check
docker compose run --rm frontend npm run build
docker compose config --quiet
# Stop the stack without deleting content
docker compose down
# Stop and permanently delete local database and uploaded media
docker compose down --volumes
```
The final command is destructive. It is useful only when intentionally resetting the
local environment; run the seed command again after the next startup.
## Directory structure
```text
.
├── AGENTS.md
├── README.md
├── .codex/
│ ├── project.md
│ ├── workflow.md
│ ├── security.md
│ ├── quality.md
│ ├── orchestration.md
│ ├── prompts/
│ │ ├── task.md
│ │ ├── bugfix.md
│ │ ├── refactor.md
│ │ ├── security-review.md
│ │ └── documentation.md
│ ├── agents/
│ │ ├── architect.md
│ │ ├── developer.md
│ │ ├── reviewer.md
│ │ ├── security-reviewer.md
│ │ ├── test-engineer.md
│ │ └── documentation-writer.md
│ └── profiles/
│ ├── docker.md
│ ├── ansible.md
│ └── python.md
└── docs/
├── adr/
│ └── 0000-template.md
├── architecture.md
├── deployment.md
├── operations.md
├── security.md
├── testing.md
└── runbook.md
├── backend/ # Wagtail/Django CMS and API
├── frontend/ # Astro public website
├── docs/ # Architecture, operations, security, and ADRs
├── docker-compose.yml
├── .env.example
└── README.md
```
## How to use
## Database and media backups
1. Copy this template into a new or existing repository.
2. Edit `.codex/project.md` and configure:
- project mode;
- enabled profiles;
- Docker-based test command;
- branch naming rules if needed.
3. Add project-specific details to the documentation under `docs/`.
4. When asking Codex to work on a task, use one of the prompt templates under `.codex/prompts/`.
Database rows and uploaded files must be backed up together:
## Core rules
```bash
mkdir -p backups/media
docker compose stop frontend backend
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backups/database.sql
docker compose cp backend:/app/media/. backups/media/
docker compose start backend frontend
```
Codex must:
This brief maintenance window keeps database rows and uploaded files in sync. See
[operations](docs/operations.md) for the destructive restore procedure and its
cautions. Do not commit database dumps, `.env`, or uploaded media.
- start work from `develop`;
- create a dedicated `feature/`, `fix/`, or `hotfix/` branch;
- use pragmatic TDD;
- keep changes minimal and focused;
- run the configured Docker-based test command before completion;
- update documentation and ADRs when needed;
- produce a final report with summary, tests, risks, and rollback notes;
- commit using Conventional Commits.
## Production TODOs
This repository deliberately targets a simple, working local deployment. Before a
public production launch, add a TLS reverse proxy, production-grade static/media
serving, off-host backups, monitoring, and a deployment-specific secret manager.
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.venv/
db.sqlite3
media/
static/
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.12.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN addgroup --system django \
&& adduser --system --ingroup django --home /home/django django
COPY requirements.txt ./
RUN pip install --no-cache-dir --requirement requirements.txt
COPY --chown=django:django . .
RUN chmod 755 entrypoint.sh \
&& mkdir -p media static \
&& chown -R django:django media static
USER django
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-"]
+1
View File
@@ -0,0 +1 @@
+121
View File
@@ -0,0 +1,121 @@
import os
from pathlib import Path
import dj_database_url
BASE_DIR = Path(__file__).resolve().parent.parent
def env_bool(name: str, default: bool = False) -> bool:
return os.getenv(name, str(default)).lower() in {"1", "true", "yes", "on"}
DEBUG = env_bool("DJANGO_DEBUG", False)
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = "development-only-secret-key"
else:
raise RuntimeError("DJANGO_SECRET_KEY must be set when DJANGO_DEBUG is false")
ALLOWED_HOSTS = [
host.strip()
for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
if host.strip()
]
INSTALLED_APPS = [
"home",
"wagtail.api.v2",
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.contrib.settings",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
"wagtail.snippets",
"wagtail.documents",
"wagtail.images",
"wagtail.search",
"wagtail.admin",
"wagtail",
"modelcluster",
"taggit",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
}
]
WSGI_APPLICATION = "config.wsgi.application"
DATABASES = {
"default": dj_database_url.parse(
os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'db.sqlite3'}"),
conn_max_age=60,
conn_health_checks=True,
)
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "it-it"
TIME_ZONE = "Europe/Rome"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "static"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"
},
}
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WAGTAIL_SITE_NAME = "Laboratorio Teatrale"
WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
WAGTAIL_I18N_ENABLED = False
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
+26
View File
@@ -0,0 +1,26 @@
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.documents import urls as wagtaildocs_urls
from home.views import health, home_api
api_router = WagtailAPIRouter("wagtailapi")
api_router.register_endpoint("pages", PagesAPIViewSet)
urlpatterns = [
path("health/", health, name="health"),
path("api/site/home/", home_api, name="home-api"),
path("api/v2/", api_router.urls),
path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)),
path("", include(wagtail_urls)),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
attempt=1
until python manage.py migrate --noinput; do
if [ "$attempt" -ge 10 ]; then
echo "Database migrations failed after $attempt attempts." >&2
exit 1
fi
attempt=$((attempt + 1))
sleep 2
done
python manage.py collectstatic --noinput
exec "$@"
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class HomeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "home"
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,212 @@
from io import BytesIO
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from PIL import Image as PillowImage
from wagtail.images import get_image_model
from wagtail.models import Page, Site
from home.models import GalleryItem, HomePage, LessonInfo, Show, SiteSettings, TeacherProfile
class Command(BaseCommand):
help = "Create or refresh the Italian demo content without creating duplicates."
def placeholder(self, title: str, filename: str, color: str):
image_model = get_image_model()
existing = image_model.objects.filter(title=title).first()
if existing:
return existing
output = BytesIO()
PillowImage.new("RGB", (1200, 800), color=color).save(
output,
format="JPEG",
quality=85,
)
return image_model.objects.create(
title=title,
file=ContentFile(output.getvalue(), name=filename),
)
def handle(self, *args, **options):
hero_image = self.placeholder("Prova in sala", "demo-hero.jpg", "#D8C7AE")
laboratory_image = self.placeholder(
"Esercizio di gruppo", "demo-laboratory.jpg", "#A9A497"
)
teacher_image = self.placeholder("Il maestro", "demo-teacher.jpg", "#B8674C")
show_image = self.placeholder("Locandina Tracce", "demo-show.jpg", "#515D53")
second_show_image = self.placeholder(
"Locandina Sottovoce", "demo-show-sottovoce.jpg", "#B8674C"
)
gallery_images = [
self.placeholder("Lezione", "demo-gallery-lesson.jpg", "#D8C7AE"),
self.placeholder("Backstage", "demo-gallery-backstage.jpg", "#A4513B"),
self.placeholder("In scena", "demo-gallery-show.jpg", "#515D53"),
self.placeholder("Il gruppo", "demo-gallery-group.jpg", "#4A433A"),
]
homepage = HomePage.objects.first()
if homepage is None:
homepage = HomePage(
title="Home",
slug="laboratorio",
hero_title="Il teatro diventa presenza.",
hero_subtitle=(
"Un laboratorio dove corpo, voce, ascolto e relazione "
"diventano scena."
),
)
Page.get_first_root_node().add_child(instance=homepage)
homepage.hero_title = "Il teatro diventa presenza."
homepage.hero_subtitle = (
"Un laboratorio dove corpo, voce, ascolto e relazione diventano scena."
)
homepage.hero_image = hero_image
homepage.intro_title = "Uno spazio per esserci davvero"
homepage.intro_body = (
"<p>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.</p>"
)
homepage.cta_primary_label = "Vieni a conoscerci"
homepage.cta_primary_url = "#contatti"
homepage.cta_secondary_label = "Guarda la galleria"
homepage.cta_secondary_url = "#galleria"
homepage.laboratory_title = "Il laboratorio"
homepage.laboratory_body = (
"<p>Corpo, voce e improvvisazione sono strumenti per allenare "
"l'ascolto, la relazione e una presenza scenica autentica.</p>"
)
homepage.laboratory_image = laboratory_image
homepage.save_revision().publish()
homepage.feature_cards.all().delete()
cards = [
(
"Per chi inizia",
"Non serve esperienza: servono curiosità e voglia di mettersi in gioco.",
),
(
"Per chi vuole crescere",
"Una pratica costante per conoscere meglio corpo, voce e presenza.",
),
(
"Per chi ama il gruppo",
"La scena nasce dall'ascolto e dalla fiducia costruita insieme.",
),
]
for order, (title, text) in enumerate(cards):
homepage.feature_cards.create(title=title, text=text, sort_order=order)
homepage.save_revision().publish()
site = Site.objects.filter(is_default_site=True).first()
if site is None:
site = Site.objects.create(
hostname="localhost",
port=8000,
site_name="Laboratorio Teatrale",
root_page=homepage,
is_default_site=True,
)
elif site.root_page_id != homepage.id:
site.root_page = homepage
site.site_name = "Laboratorio Teatrale"
site.save(update_fields=["root_page", "site_name"])
settings, _ = SiteSettings.objects.get_or_create(site=site)
settings.laboratory_name = "Laboratorio Teatrale"
settings.short_claim = "Il teatro diventa presenza."
settings.email = "ciao@laboratorioteatrale.it"
settings.phone = "+39 333 123 4567"
settings.whatsapp = "+39 333 123 4567"
settings.instagram = "https://www.instagram.com/"
settings.facebook = "https://www.facebook.com/"
settings.address = "Via del Teatro 12, Roma"
settings.footer_text = "Uno spazio di ricerca, ascolto e presenza."
settings.save()
TeacherProfile.objects.update_or_create(
name="Andrea Rossi",
defaults={
"photo": teacher_image,
"short_bio": (
"Attore, regista e formatore. Accompagna gruppi e persone "
"nella ricerca di una presenza scenica sincera."
),
"full_bio": (
"<p>Da oltre quindici anni lavora tra formazione, creazione "
"collettiva e spettacolo dal vivo.</p>"
),
"quote": "Il teatro non è fingere: è imparare a essere presenti.",
},
)
LessonInfo.objects.update_or_create(
day_time="Ogni mercoledì, 19:3022:00",
defaults={
"location": "Spazio Teatro, Via del Teatro 12, Roma",
"audience": "Adulti, con o senza esperienza precedente.",
"description": (
"<p>Training fisico e vocale, improvvisazione, ascolto, "
"composizione e lavoro sulla scena.</p>"
),
"trial_lesson": "La prima lezione di prova è gratuita su prenotazione.",
"notes": "Abbigliamento comodo consigliato.",
},
)
Show.objects.update_or_create(
title="Tracce",
year=2025,
defaults={
"short_description": (
"Un lavoro corale sulle storie che lasciamo e che ci attraversano."
),
"description": (
"<p>Esito scenico nato dalle improvvisazioni e dalle scritture "
"del gruppo.</p>"
),
"poster": show_image,
"location": "Teatro di Quartiere",
"cast": "Allieve e allievi del laboratorio",
"order": 0,
"visible_on_home": True,
},
)
Show.objects.update_or_create(
title="Sottovoce",
year=2024,
defaults={
"short_description": (
"Un attraversamento delicato delle parole che restano inascoltate."
),
"description": (
"<p>Un progetto costruito attraverso voce, silenzio e movimento.</p>"
),
"poster": second_show_image,
"location": "Spazio Teatro",
"cast": "Laboratorio annuale",
"order": 1,
"visible_on_home": True,
},
)
gallery_entries = [
(GalleryItem.Category.LESSONS, "Ascolto e movimento"),
(GalleryItem.Category.BACKSTAGE, "Prima dello spettacolo"),
(GalleryItem.Category.SHOWS, "Una scena di Tracce"),
(GalleryItem.Category.GROUP, "Il laboratorio, insieme"),
]
for order, (image, (category, caption)) in enumerate(
zip(gallery_images, gallery_entries, strict=True)
):
GalleryItem.objects.update_or_create(
image=image,
defaults={
"category": category,
"caption": caption,
"order": order,
"visible_on_home": True,
},
)
self.stdout.write(self.style.SUCCESS("Demo content is ready."))
+116
View File
@@ -0,0 +1,116 @@
import django.db.models.deletion
import modelcluster.fields
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("wagtailcore", "0001_squashed_0016_change_page_url_path_to_text_field"),
("wagtailimages", "0001_squashed_0021"),
]
operations = [
migrations.CreateModel(
name="GalleryItem",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("category", models.CharField(choices=[("lessons", "Lessons"), ("backstage", "Backstage"), ("shows", "Shows"), ("group", "Group")], max_length=20)),
("caption", models.CharField(blank=True, max_length=255)),
("order", models.PositiveIntegerField(default=0)),
("visible_on_home", models.BooleanField(default=True)),
("image", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="+", to="wagtailimages.image")),
],
options={"ordering": ["order", "id"]},
),
migrations.CreateModel(
name="HomePage",
fields=[
("page_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="wagtailcore.page")),
("hero_title", models.CharField(max_length=180)),
("hero_subtitle", models.TextField()),
("intro_title", models.CharField(blank=True, max_length=180)),
("intro_body", wagtail.fields.RichTextField(blank=True)),
("cta_primary_label", models.CharField(blank=True, max_length=80)),
("cta_primary_url", models.CharField(blank=True, max_length=255)),
("cta_secondary_label", models.CharField(blank=True, max_length=80)),
("cta_secondary_url", models.CharField(blank=True, max_length=255)),
("laboratory_title", models.CharField(blank=True, max_length=180)),
("laboratory_body", wagtail.fields.RichTextField(blank=True)),
("hero_image", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
("laboratory_image", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
],
bases=("wagtailcore.page",),
),
migrations.CreateModel(
name="LessonInfo",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("day_time", models.CharField(max_length=180)),
("location", models.CharField(max_length=255)),
("audience", models.TextField()),
("description", wagtail.fields.RichTextField()),
("trial_lesson", models.CharField(blank=True, max_length=255)),
("notes", models.TextField(blank=True)),
],
options={"verbose_name": "lesson information", "verbose_name_plural": "lesson information"},
),
migrations.CreateModel(
name="Show",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("title", models.CharField(max_length=180)),
("year", models.PositiveSmallIntegerField()),
("short_description", models.TextField()),
("description", wagtail.fields.RichTextField(blank=True)),
("location", models.CharField(blank=True, max_length=255)),
("cast", models.TextField(blank=True)),
("order", models.PositiveIntegerField(default=0)),
("visible_on_home", models.BooleanField(default=True)),
("poster", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
],
options={"ordering": ["order", "-year", "title"]},
),
migrations.CreateModel(
name="SiteSettings",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("laboratory_name", models.CharField(default="Laboratorio Teatrale", max_length=120)),
("short_claim", models.CharField(blank=True, max_length=180)),
("email", models.EmailField(blank=True, max_length=254)),
("phone", models.CharField(blank=True, max_length=40)),
("whatsapp", models.CharField(blank=True, max_length=40)),
("instagram", models.URLField(blank=True)),
("facebook", models.URLField(blank=True)),
("address", models.CharField(blank=True, max_length=255)),
("footer_text", models.CharField(blank=True, max_length=255)),
("site", models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to="wagtailcore.site")),
],
options={"verbose_name": "site settings", "abstract": False},
),
migrations.CreateModel(
name="TeacherProfile",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=120)),
("short_bio", models.TextField()),
("full_bio", wagtail.fields.RichTextField(blank=True)),
("quote", models.TextField(blank=True)),
("photo", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
],
options={"verbose_name": "teacher profile"},
),
migrations.CreateModel(
name="FeatureCard",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("sort_order", models.IntegerField(blank=True, editable=False, null=True)),
("title", models.CharField(max_length=120)),
("text", models.TextField()),
("page", modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name="feature_cards", to="home.homepage")),
],
options={"ordering": ["sort_order"], "abstract": False},
),
]
+1
View File
@@ -0,0 +1 @@
+245
View File
@@ -0,0 +1,245 @@
from django.db import models
from modelcluster.fields import ParentalKey
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
from wagtail.fields import RichTextField
from wagtail.models import Orderable, Page
from wagtail.snippets.models import register_snippet
@register_setting(icon="cog")
class SiteSettings(BaseSiteSetting):
laboratory_name = models.CharField(max_length=120, default="Laboratorio Teatrale")
short_claim = models.CharField(max_length=180, blank=True)
email = models.EmailField(blank=True)
phone = models.CharField(max_length=40, blank=True)
whatsapp = models.CharField(max_length=40, blank=True)
instagram = models.URLField(blank=True)
facebook = models.URLField(blank=True)
address = models.CharField(max_length=255, blank=True)
footer_text = models.CharField(max_length=255, blank=True)
panels = [
MultiFieldPanel(
[FieldPanel("laboratory_name"), FieldPanel("short_claim")],
heading="Identity",
),
MultiFieldPanel(
[
FieldPanel("email"),
FieldPanel("phone"),
FieldPanel("whatsapp"),
FieldPanel("instagram"),
FieldPanel("facebook"),
FieldPanel("address"),
],
heading="Contacts",
),
FieldPanel("footer_text"),
]
class Meta:
verbose_name = "site settings"
class HomePage(Page):
hero_title = models.CharField(max_length=180)
hero_subtitle = models.TextField()
hero_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
intro_title = models.CharField(max_length=180, blank=True)
intro_body = RichTextField(blank=True)
cta_primary_label = models.CharField(max_length=80, blank=True)
cta_primary_url = models.CharField(max_length=255, blank=True)
cta_secondary_label = models.CharField(max_length=80, blank=True)
cta_secondary_url = models.CharField(max_length=255, blank=True)
laboratory_title = models.CharField(max_length=180, blank=True)
laboratory_body = RichTextField(blank=True)
laboratory_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
max_count = 1
subpage_types: list[str] = []
content_panels = Page.content_panels + [
MultiFieldPanel(
[
FieldPanel("hero_title"),
FieldPanel("hero_subtitle"),
FieldPanel("hero_image"),
],
heading="Hero",
),
MultiFieldPanel(
[FieldPanel("intro_title"), FieldPanel("intro_body")],
heading="Manifesto",
),
MultiFieldPanel(
[
FieldPanel("cta_primary_label"),
FieldPanel("cta_primary_url"),
FieldPanel("cta_secondary_label"),
FieldPanel("cta_secondary_url"),
],
heading="Calls to action",
),
InlinePanel("feature_cards", label="Feature card", max_num=3),
MultiFieldPanel(
[
FieldPanel("laboratory_title"),
FieldPanel("laboratory_body"),
FieldPanel("laboratory_image"),
],
heading="Laboratory",
),
]
class FeatureCard(Orderable):
page = ParentalKey(
HomePage,
on_delete=models.CASCADE,
related_name="feature_cards",
)
title = models.CharField(max_length=120)
text = models.TextField()
panels = [FieldPanel("title"), FieldPanel("text")]
def __str__(self) -> str:
return self.title
@register_snippet
class TeacherProfile(models.Model):
name = models.CharField(max_length=120)
photo = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
short_bio = models.TextField()
full_bio = RichTextField(blank=True)
quote = models.TextField(blank=True)
panels = [
FieldPanel("name"),
FieldPanel("photo"),
FieldPanel("short_bio"),
FieldPanel("full_bio"),
FieldPanel("quote"),
]
class Meta:
verbose_name = "teacher profile"
def __str__(self) -> str:
return self.name
@register_snippet
class LessonInfo(models.Model):
day_time = models.CharField(max_length=180)
location = models.CharField(max_length=255)
audience = models.TextField()
description = RichTextField()
trial_lesson = models.CharField(max_length=255, blank=True)
notes = models.TextField(blank=True)
panels = [
FieldPanel("day_time"),
FieldPanel("location"),
FieldPanel("audience"),
FieldPanel("description"),
FieldPanel("trial_lesson"),
FieldPanel("notes"),
]
class Meta:
verbose_name = "lesson information"
verbose_name_plural = "lesson information"
def __str__(self) -> str:
return self.day_time
@register_snippet
class Show(models.Model):
title = models.CharField(max_length=180)
year = models.PositiveSmallIntegerField()
short_description = models.TextField()
description = RichTextField(blank=True)
poster = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
location = models.CharField(max_length=255, blank=True)
cast = models.TextField(blank=True)
order = models.PositiveIntegerField(default=0)
visible_on_home = models.BooleanField(default=True)
panels = [
FieldPanel("title"),
FieldPanel("year"),
FieldPanel("short_description"),
FieldPanel("description"),
FieldPanel("poster"),
FieldPanel("location"),
FieldPanel("cast"),
FieldPanel("order"),
FieldPanel("visible_on_home"),
]
class Meta:
ordering = ["order", "-year", "title"]
def __str__(self) -> str:
return f"{self.title} ({self.year})"
@register_snippet
class GalleryItem(models.Model):
class Category(models.TextChoices):
LESSONS = "lessons", "Lessons"
BACKSTAGE = "backstage", "Backstage"
SHOWS = "shows", "Shows"
GROUP = "group", "Group"
image = models.ForeignKey(
"wagtailimages.Image",
on_delete=models.CASCADE,
related_name="+",
)
category = models.CharField(max_length=20, choices=Category.choices)
caption = models.CharField(max_length=255, blank=True)
order = models.PositiveIntegerField(default=0)
visible_on_home = models.BooleanField(default=True)
panels = [
FieldPanel("image"),
FieldPanel("category"),
FieldPanel("caption"),
FieldPanel("order"),
FieldPanel("visible_on_home"),
]
class Meta:
ordering = ["order", "id"]
def __str__(self) -> str:
return self.caption or self.image.title
+1
View File
@@ -0,0 +1 @@
+70
View File
@@ -0,0 +1,70 @@
import tempfile
from django.core.management import call_command
from django.test import TestCase, override_settings
from home.models import GalleryItem, HomePage, Show
@override_settings(WAGTAILADMIN_BASE_URL="https://cms.example.test")
class HomeApiTests(TestCase):
def setUp(self):
self.media_directory = tempfile.TemporaryDirectory()
self.settings_override = override_settings(MEDIA_ROOT=self.media_directory.name)
self.settings_override.enable()
call_command("seed_demo", verbosity=0)
def tearDown(self):
self.settings_override.disable()
self.media_directory.cleanup()
def test_home_endpoint_returns_aggregated_content(self):
response = self.client.get("/api/site/home/", HTTP_HOST="localhost")
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(
set(payload),
{
"settings",
"homepage",
"feature_cards",
"teacher",
"lesson_info",
"shows",
"gallery_items",
},
)
self.assertEqual(payload["homepage"]["hero_title"], "Il teatro diventa presenza.")
self.assertEqual(len(payload["feature_cards"]), 3)
self.assertTrue(
payload["homepage"]["hero_image"]["url"].startswith(
"https://cms.example.test/media/"
)
)
self.assertEqual(payload["homepage"]["hero_image"]["alt"], "Prova in sala")
def test_home_endpoint_only_includes_visible_items(self):
Show.objects.update(visible_on_home=False)
GalleryItem.objects.update(visible_on_home=False)
payload = self.client.get("/api/site/home/", HTTP_HOST="localhost").json()
self.assertEqual(payload["shows"], [])
self.assertEqual(payload["gallery_items"], [])
def test_seed_command_is_idempotent(self):
call_command("seed_demo", verbosity=0)
self.assertEqual(HomePage.objects.count(), 1)
self.assertEqual(HomePage.objects.first().feature_cards.count(), 3)
self.assertEqual(Show.objects.count(), 2)
self.assertEqual(GalleryItem.objects.count(), 4)
class HealthTests(TestCase):
def test_health_endpoint_checks_database(self):
response = self.client.get("/health/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
+139
View File
@@ -0,0 +1,139 @@
from urllib.parse import urljoin
from django.conf import settings as django_settings
from django.db import DatabaseError, connection
from django.http import JsonResponse
from django.views.decorators.http import require_GET
from wagtail.models import Site
from .models import GalleryItem, HomePage, LessonInfo, Show, SiteSettings, TeacherProfile
def serialize_image(request, image):
if image is None:
return None
public_base_url = django_settings.WAGTAILADMIN_BASE_URL or request.build_absolute_uri("/")
return {
"url": urljoin(f"{public_base_url.rstrip('/')}/", image.file.url),
"alt": image.title,
}
def find_site(request):
return Site.find_for_request(request) or Site.objects.filter(is_default_site=True).first()
@require_GET
def health(request):
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
except DatabaseError:
return JsonResponse({"status": "unavailable"}, status=503)
return JsonResponse({"status": "ok"})
@require_GET
def home_api(request):
site = find_site(request)
settings = SiteSettings.for_site(site) if site else None
homepage = (
HomePage.objects.live().filter(id=site.root_page_id).first()
if site
else HomePage.objects.live().first()
)
teacher = TeacherProfile.objects.order_by("id").first()
lesson = LessonInfo.objects.order_by("id").first()
settings_data = None
if settings:
settings_data = {
"laboratory_name": settings.laboratory_name,
"short_claim": settings.short_claim,
"email": settings.email,
"phone": settings.phone,
"whatsapp": settings.whatsapp,
"instagram": settings.instagram,
"facebook": settings.facebook,
"address": settings.address,
"footer_text": settings.footer_text,
}
homepage_data = None
feature_cards = []
if homepage:
homepage_data = {
"hero_title": homepage.hero_title,
"hero_subtitle": homepage.hero_subtitle,
"hero_image": serialize_image(request, homepage.hero_image),
"intro_title": homepage.intro_title,
"intro_body": str(homepage.intro_body),
"cta_primary_label": homepage.cta_primary_label,
"cta_primary_url": homepage.cta_primary_url,
"cta_secondary_label": homepage.cta_secondary_label,
"cta_secondary_url": homepage.cta_secondary_url,
"laboratory_title": homepage.laboratory_title,
"laboratory_body": str(homepage.laboratory_body),
"laboratory_image": serialize_image(request, homepage.laboratory_image),
}
feature_cards = [
{"title": card.title, "text": card.text, "order": card.sort_order}
for card in homepage.feature_cards.all()
]
teacher_data = None
if teacher:
teacher_data = {
"name": teacher.name,
"photo": serialize_image(request, teacher.photo),
"short_bio": teacher.short_bio,
"full_bio": str(teacher.full_bio),
"quote": teacher.quote,
}
lesson_data = None
if lesson:
lesson_data = {
"day_time": lesson.day_time,
"location": lesson.location,
"audience": lesson.audience,
"description": str(lesson.description),
"trial_lesson": lesson.trial_lesson,
"notes": lesson.notes,
}
shows = [
{
"title": show.title,
"year": show.year,
"short_description": show.short_description,
"description": str(show.description),
"poster": serialize_image(request, show.poster),
"location": show.location,
"cast": show.cast,
"order": show.order,
}
for show in Show.objects.filter(visible_on_home=True).select_related("poster")
]
gallery_items = [
{
"image": serialize_image(request, item.image),
"category": item.category,
"caption": item.caption,
"order": item.order,
}
for item in GalleryItem.objects.filter(visible_on_home=True).select_related("image")
]
return JsonResponse(
{
"settings": settings_data,
"homepage": homepage_data,
"feature_cards": feature_cards,
"teacher": teacher_data,
"lesson_info": lesson_data,
"shows": shows,
"gallery_items": gallery_items,
}
)
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
Django==5.2.13
Wagtail==7.4.2
dj-database-url==2.3.0
gunicorn==23.0.0
psycopg[binary]==3.2.4
whitenoise==6.9.0
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page.title }}</title>
</head>
<body>
<main>
<h1>{{ page.hero_title }}</h1>
<p>This content is published through the headless CMS API.</p>
<p><a href="/api/site/home/">Open the home API</a></p>
</main>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
services:
postgres:
image: postgres:16.9-alpine
restart: unless-stopped
init: true
environment:
POSTGRES_DB: ${POSTGRES_DB:-theatre_lab}
POSTGRES_USER: ${POSTGRES_USER:-theatre_lab}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-replace-with-a-local-password}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 5s
security_opt:
- no-new-privileges:true
backend:
build:
context: ./backend
restart: unless-stopped
init: true
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://theatre_lab:replace-with-a-local-password@postgres:5432/theatre_lab}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-replace-with-a-long-random-development-key}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend}
WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://localhost:8000}
volumes:
- media_data:/app/media
ports:
- "127.0.0.1:8000:8000"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/', timeout=2)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
security_opt:
- no-new-privileges:true
frontend:
build:
context: ./frontend
restart: unless-stopped
init: true
environment:
PUBLIC_CMS_API_URL: ${PUBLIC_CMS_API_URL:-http://backend:8000}
ports:
- "127.0.0.1:4321:4321"
depends_on:
backend:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:4321/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
security_opt:
- no-new-privileges:true
volumes:
postgres_data:
media_data:
+56
View File
@@ -0,0 +1,56 @@
# ADR-0001: Headless Wagtail with an Astro frontend
Date: 2026-06-22
Status: Accepted
## Context
The theatre workshop needs an editorially polished public single page whose core
content can be updated without code changes. The required stack is Wagtail/Django,
Astro, PostgreSQL, and Docker Compose. A small team must be able to run and maintain
the system locally without unnecessary services.
## Decision
Use Wagtail as a headless CMS backed by PostgreSQL and Astro as the public frontend.
Wagtail owns uploaded media and exposes a read-only aggregate endpoint at
`/api/site/home/`. Astro renders the page server-side in development/build time and
uses curated Italian fallback content when the CMS is temporarily unavailable.
Docker Compose runs PostgreSQL, Django, and Astro on one private default network.
The content model uses one Wagtail `HomePage` with three ordered feature cards, site
settings, and reusable snippets for teacher details, lesson details, shows, and gallery
items. This keeps editing simple and avoids a custom frontend state layer.
## Consequences
- Editors use the authenticated Wagtail admin while the public API remains read-only.
- The public page needs only one API request and degrades gracefully during CMS outages.
- Uploaded media and PostgreSQL data require separate persistent volumes and backups.
- Local frontend rendering depends on container networking; browser-facing media URLs
are generated from the configured public Wagtail base URL.
## Alternatives considered
- Calling the standard Wagtail endpoints separately was rejected because it adds
frontend orchestration for a single page.
- Embedding the frontend in Django templates was rejected because Astro is required.
- A JavaScript SPA and client-side state library were rejected as unnecessary.
## Security impact
The aggregate endpoint exposes published editorial content only. Wagtail admin keeps
its normal authentication and CSRF protections. Services bind to loopback for local
development, containers are not privileged, and secrets are supplied through the
environment.
## Operational impact
Operators must back up the PostgreSQL and media volumes. CMS schema changes require
normal Django migrations. Health checks order local startup.
## Rollback
Revert the feature commit and remove the Compose volumes only if their stored content
is no longer needed. Keep database and media backups when rolling back code alone.
+17 -9
View File
@@ -1,13 +1,21 @@
# Architecture
Describe the project architecture here.
The system has three runtime components on the Docker Compose default network:
Include:
1. Astro renders the public single page and requests one aggregate JSON document.
2. Wagtail/Django manages editorial content, serves media in development, and exposes
the read-only `/api/site/home/` endpoint.
3. PostgreSQL persists Wagtail content and metadata.
- main components;
- runtime dependencies;
- data flow;
- persistence;
- external integrations;
- deployment topology;
- relevant ADRs.
The aggregate response contains `settings`, `homepage`, `feature_cards`, `teacher`,
`lesson_info`, `shows`, and `gallery_items`. Image values contain browser-facing URLs
and alternative text. Only published home content and records marked for home display
are exposed. Astro validates/normalizes the response and falls back to checked-in
Italian demo content if fetching fails.
Wagtail's authenticated admin remains the only editing interface. The public frontend
has no database access and no public write API. Uploaded media is stored in a dedicated
Compose volume.
The deployment topology and content model are recorded in
[ADR-0001](adr/0001-headless-wagtail-astro.md).
+39 -11
View File
@@ -1,15 +1,43 @@
# Deployment
Describe how this project is deployed.
## Local environment
Include:
Copy `.env.example` to `.env`, replace the development placeholders, then run:
- environments;
- Docker/Compose usage;
- required configuration;
- secrets handling;
- exposed ports;
- volumes;
- networks;
- deployment commands;
- rollback procedure.
```bash
cp .env.example .env
docker compose up --build -d
docker compose ps
docker compose exec backend python manage.py seed_demo
```
The backend applies migrations and collects static files before starting Gunicorn.
All services have health checks; wait for healthy status before opening the site.
Compose exposes the Astro site on loopback port `4321` and Django/Wagtail on loopback
port `8000`. PostgreSQL is available only on the Compose network. `postgres_data` and
`media_data` are persistent named volumes.
The stack uses explicit PostgreSQL 16.9, Python 3.12.12, and Node.js 22.20 image
versions. Containers are not privileged and use `no-new-privileges`.
Required runtime variables are `DATABASE_URL`, `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`,
`DJANGO_ALLOWED_HOSTS`, `WAGTAILADMIN_BASE_URL`, and `PUBLIC_CMS_API_URL`. PostgreSQL
bootstrap variables are also documented in `.env.example`. Do not use the example
credentials outside local development.
`WAGTAILADMIN_BASE_URL` must be browser-reachable because it is used for media URLs.
`PUBLIC_CMS_API_URL` must be reachable by Astro; within Compose it is
`http://backend:8000`.
## Production boundary
The Compose stack is a local development deployment. A public environment still needs
TLS termination, production static/media serving, restricted admin access, managed
secrets, off-host backups, monitoring, and an explicit domain/allowed-host policy.
## Rollback
Revert the application commit and rebuild images. Keep the database and media volumes
unless content deletion is intentional. Schema rollback must be evaluated per Django
migration; take coordinated database and media backups first.
+40 -9
View File
@@ -1,13 +1,44 @@
# Operations
Describe operational procedures.
## Routine commands
Include:
```bash
docker compose up --build -d
docker compose ps
docker compose logs -f backend frontend postgres
docker compose down
```
- startup and shutdown;
- health checks;
- logs;
- monitoring;
- backup and restore;
- routine maintenance;
- known operational risks.
Compose health checks PostgreSQL and Django. The public health endpoint is
`http://localhost:8000/health/`; the aggregate content endpoint is
`http://localhost:8000/api/site/home/`.
Apply schema changes with `docker compose exec backend python manage.py migrate`.
Create editors with `createsuperuser`; use Wagtail permissions for later non-superuser
accounts.
The backend applies migrations at container startup. To permanently reset local
state, use `docker compose down --volumes`; this deletes both database and media.
## Backup and restore
Back up the database and media at the same logical point in time:
```bash
mkdir -p backups/media
docker compose stop frontend backend
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backups/database.sql
docker compose cp backend:/app/media/. backups/media/
docker compose start backend frontend
```
Restore is destructive: verify the backup in a disposable environment and take a fresh
backup first. Then stop both applications, recreate and restore the database, replace
the media content, restart, and verify health, API, admin login, and several images.
Keep real backups encrypted and off-host with a defined retention policy.
## Known risks
- Frontend fallback content can mask a CMS outage, so monitor backend health directly.
- Local named volumes are not off-host backups.
- Development media serving is unsuitable for production traffic.
+26 -10
View File
@@ -1,19 +1,35 @@
# Runbook
Operational runbook for this project.
## Site shows fallback content
## Common tasks
1. Check `docker compose ps` and the backend health status.
2. Request `http://localhost:8000/api/site/home/` directly.
3. Inspect `docker compose logs backend postgres`.
4. Verify `PUBLIC_CMS_API_URL` resolves from the frontend container.
5. Restart the frontend after recovery so its static page is rebuilt from CMS data.
Document routine operational tasks here.
## Wagtail does not start
## Troubleshooting
1. Confirm PostgreSQL is healthy.
2. Check `DATABASE_URL` and allowed hosts without printing secret values in shared logs.
3. Run `docker compose exec backend python manage.py migrate`.
4. Review backend logs for a specific migration or configuration error.
Document known issues, symptoms, checks, and remediation steps.
## Images are missing
1. Verify the `media_data` volume is mounted at `/app/media`.
2. Check `WAGTAILADMIN_BASE_URL` uses a URL reachable by the browser.
3. Confirm the image still exists in Wagtail and has not been removed from the volume.
## A service is unhealthy
1. Run `docker compose ps` and inspect `docker compose logs --tail=200 SERVICE`.
2. For PostgreSQL, check volume capacity and database/user configuration.
3. For the backend, check `/health/`, migrations, and database connectivity.
4. For the frontend, request port `4321`, then check the API separately because
fallback content can mask a CMS outage.
## Rollback
Document rollback procedures here.
## Emergency contacts
Document project-specific escalation paths if appropriate.
Revert the application commit and rebuild containers. Preserve database/media volumes.
Before reversing migrations or deleting volumes, make and validate coordinated backups.
+20 -13
View File
@@ -1,16 +1,23 @@
# Security
Describe security assumptions and controls.
- Wagtail admin uses Django authentication, authorization, sessions, CSRF protection,
and password hashing. The public site does not add accounts or write endpoints.
- The aggregate API is intentionally unauthenticated and returns published editorial
content only; secrets and unpublished drafts must never be serialized.
- PostgreSQL has no host-published port. Web ports bind to loopback for local use.
- Containers are unprivileged at the Compose level: no privileged mode, host network,
Docker socket, or added capabilities are used. `no-new-privileges` is enabled and
the application images run as non-root users.
- `.env` is ignored. `.env.example` contains replaceable development placeholders,
never production credentials. Use a deployment secret manager outside local use.
- `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Add TLS
at the edge before public exposure.
- Database and uploaded media backups may contain personal data. Restrict, encrypt,
retain, and delete them according to the operator's privacy policy.
- Avoid placing personal phone numbers or private contact details in logs. The API
legitimately exposes only contact details approved for publication.
- Dependency and image versions are explicit. Operators remain responsible for patch
upgrades, vulnerability scans, and production digest pinning.
Include:
- authentication;
- authorization;
- network exposure;
- TLS/certificates;
- secrets management;
- logging of sensitive data;
- container privileges;
- filesystem permissions;
- dependency management;
- relevant ADRs.
Manual production hardening remains required for reverse proxy headers, TLS, media
storage, backup retention, monitoring, and admin network policy.
+16 -11
View File
@@ -1,23 +1,28 @@
# Testing
Describe how tests are executed.
All tests should run inside Docker containers.
## Canonical test command
```bash
CHANGE_ME
docker compose run --rm backend python manage.py test
docker compose run --rm frontend npm run check
docker compose run --rm frontend npm run build
docker compose config --quiet
```
## Test categories
Describe applicable categories:
The test suite covers:
- unit tests;
- integration tests;
- linting;
- formatting checks;
- Ansible syntax checks;
- Docker/Compose validation;
- smoke tests.
- Django model and API integration tests;
- Astro static type and template validation;
- production frontend build;
- Docker Compose configuration validation.
All commands run in containers. The backend test container starts PostgreSQL through
Compose; the frontend checks use the checked-in lockfile for reproducible installs.
After seeding, smoke-test `/health/`, `/api/site/home/`, and the public page. Basic
keyboard navigation, responsive layout, and accessible names require a manual browser
pass until browser automation is added.
+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
}
}