feat: build headless theatre laboratory website
@@ -4,9 +4,9 @@ Edit this file for each repository.
|
|||||||
|
|
||||||
## Project identity
|
## Project identity
|
||||||
|
|
||||||
Project name: `CHANGE_ME`
|
Project name: `Laboratorio Teatrale`
|
||||||
Project description: `CHANGE_ME`
|
Project description: `Headless Wagtail CMS and Astro single-page website for a theatre workshop.`
|
||||||
Primary language/runtime: `CHANGE_ME`
|
Primary language/runtime: `Python 3.12 and Node.js 22`
|
||||||
|
|
||||||
## Project mode
|
## Project mode
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ Choose one:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
project_mode: personal
|
project_mode: personal
|
||||||
project_mode: work
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
@@ -29,7 +28,6 @@ Enable only the profiles that apply to this repository:
|
|||||||
```text
|
```text
|
||||||
enabled_profiles:
|
enabled_profiles:
|
||||||
- docker
|
- docker
|
||||||
- ansible
|
|
||||||
- python
|
- python
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,7 +75,10 @@ All tests MUST be executed inside Docker containers.
|
|||||||
Configure the canonical test command for this repository:
|
Configure the canonical test command for this repository:
|
||||||
|
|
||||||
```bash
|
```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:
|
Examples:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
```text
|
||||||
.
|
.
|
||||||
├── AGENTS.md
|
├── backend/ # Wagtail/Django CMS and API
|
||||||
├── README.md
|
├── frontend/ # Astro public website
|
||||||
├── .codex/
|
├── docs/ # Architecture, operations, security, and ADRs
|
||||||
│ ├── project.md
|
├── docker-compose.yml
|
||||||
│ ├── workflow.md
|
├── .env.example
|
||||||
│ ├── security.md
|
└── README.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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## How to use
|
## Database and media backups
|
||||||
|
|
||||||
1. Copy this template into a new or existing repository.
|
Database rows and uploaded files must be backed up together:
|
||||||
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/`.
|
|
||||||
|
|
||||||
## 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`;
|
## Production TODOs
|
||||||
- create a dedicated `feature/`, `fix/`, or `hotfix/` branch;
|
|
||||||
- use pragmatic TDD;
|
This repository deliberately targets a simple, working local deployment. Before a
|
||||||
- keep changes minimal and focused;
|
public production launch, add a TLS reverse proxy, production-grade static/media
|
||||||
- run the configured Docker-based test command before completion;
|
serving, off-host backups, monitoring, and a deployment-specific secret manager.
|
||||||
- update documentation and ADRs when needed;
|
|
||||||
- produce a final report with summary, tests, risks, and rollback notes;
|
|
||||||
- commit using Conventional Commits.
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
db.sqlite3
|
||||||
|
media/
|
||||||
|
static/
|
||||||
@@ -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", "-"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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 "$@"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class HomeConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "home"
|
||||||
@@ -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:30–22: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."))
|
||||||
@@ -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},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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"})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
# Architecture
|
# 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;
|
The aggregate response contains `settings`, `homepage`, `feature_cards`, `teacher`,
|
||||||
- runtime dependencies;
|
`lesson_info`, `shows`, and `gallery_items`. Image values contain browser-facing URLs
|
||||||
- data flow;
|
and alternative text. Only published home content and records marked for home display
|
||||||
- persistence;
|
are exposed. Astro validates/normalizes the response and falls back to checked-in
|
||||||
- external integrations;
|
Italian demo content if fetching fails.
|
||||||
- deployment topology;
|
|
||||||
- relevant ADRs.
|
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).
|
||||||
|
|||||||
@@ -1,15 +1,43 @@
|
|||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
Describe how this project is deployed.
|
## Local environment
|
||||||
|
|
||||||
Include:
|
Copy `.env.example` to `.env`, replace the development placeholders, then run:
|
||||||
|
|
||||||
- environments;
|
```bash
|
||||||
- Docker/Compose usage;
|
cp .env.example .env
|
||||||
- required configuration;
|
docker compose up --build -d
|
||||||
- secrets handling;
|
docker compose ps
|
||||||
- exposed ports;
|
docker compose exec backend python manage.py seed_demo
|
||||||
- volumes;
|
```
|
||||||
- networks;
|
|
||||||
- deployment commands;
|
The backend applies migrations and collects static files before starting Gunicorn.
|
||||||
- rollback procedure.
|
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.
|
||||||
|
|||||||
@@ -1,13 +1,44 @@
|
|||||||
# Operations
|
# 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;
|
Compose health checks PostgreSQL and Django. The public health endpoint is
|
||||||
- health checks;
|
`http://localhost:8000/health/`; the aggregate content endpoint is
|
||||||
- logs;
|
`http://localhost:8000/api/site/home/`.
|
||||||
- monitoring;
|
|
||||||
- backup and restore;
|
Apply schema changes with `docker compose exec backend python manage.py migrate`.
|
||||||
- routine maintenance;
|
Create editors with `createsuperuser`; use Wagtail permissions for later non-superuser
|
||||||
- known operational risks.
|
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.
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
# Runbook
|
# 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
|
## Rollback
|
||||||
|
|
||||||
Document rollback procedures here.
|
Revert the application commit and rebuild containers. Preserve database/media volumes.
|
||||||
|
Before reversing migrations or deleting volumes, make and validate coordinated backups.
|
||||||
## Emergency contacts
|
|
||||||
|
|
||||||
Document project-specific escalation paths if appropriate.
|
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
# Security
|
# 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:
|
Manual production hardening remains required for reverse proxy headers, TLS, media
|
||||||
|
storage, backup retention, monitoring, and admin network policy.
|
||||||
- authentication;
|
|
||||||
- authorization;
|
|
||||||
- network exposure;
|
|
||||||
- TLS/certificates;
|
|
||||||
- secrets management;
|
|
||||||
- logging of sensitive data;
|
|
||||||
- container privileges;
|
|
||||||
- filesystem permissions;
|
|
||||||
- dependency management;
|
|
||||||
- relevant ADRs.
|
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
Describe how tests are executed.
|
|
||||||
|
|
||||||
All tests should run inside Docker containers.
|
All tests should run inside Docker containers.
|
||||||
|
|
||||||
## Canonical test command
|
## Canonical test command
|
||||||
|
|
||||||
```bash
|
```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
|
## Test categories
|
||||||
|
|
||||||
Describe applicable categories:
|
The test suite covers:
|
||||||
|
|
||||||
- unit tests;
|
- Django model and API integration tests;
|
||||||
- integration tests;
|
- Astro static type and template validation;
|
||||||
- linting;
|
- production frontend build;
|
||||||
- formatting checks;
|
- Docker Compose configuration validation.
|
||||||
- Ansible syntax checks;
|
|
||||||
- Docker/Compose validation;
|
All commands run in containers. The backend test container starts PostgreSQL through
|
||||||
- smoke tests.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
npm-debug.log
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
output: "static",
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 4321,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 all’inizio`}>{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>
|
||||||
@@ -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>
|
||||||
@@ -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 dell’insegnante sarà disponibile presto.</p></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly PUBLIC_CMS_API_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 l’improvvisazione 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 dell’autenticità 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noUncheckedIndexedAccess": true
|
||||||
|
}
|
||||||
|
}
|
||||||