@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
.git
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -0,0 +1,16 @@
|
||||
PUBLIC_SITE_URL=https://swansea-airport.wales
|
||||
DIRECTUS_URL=http://directus:8055
|
||||
FRONTEND_PORT=8080
|
||||
DIRECTUS_PORT=8055
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=directus
|
||||
POSTGRES_USER=directus
|
||||
POSTGRES_PASSWORD=change-me
|
||||
DIRECTUS_KEY=change-me-change-me-change-me-change-me
|
||||
DIRECTUS_SECRET=change-me-change-me-change-me-change-me
|
||||
DIRECTUS_ADMIN_EMAIL=admin@example.com
|
||||
DIRECTUS_ADMIN_PASSWORD=change-me
|
||||
DIRECTUS_ADMIN_TOKEN=change-me-static-admin-token
|
||||
DIRECTUS_PUBLIC_URL=https://cms.example.com
|
||||
DIRECTUS_CORS_ORIGIN=https://swansea-airport.wales
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Source is bind-mounted in docker-compose for live preview.
|
||||
CMD ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0 --port 80"]
|
||||
@@ -0,0 +1,57 @@
|
||||
# Swansea Airport Website
|
||||
|
||||
Production-ready airfield website stack built with Astro, Directus, PostgreSQL, and Docker Compose.
|
||||
|
||||
## Local setup
|
||||
|
||||
1. Copy `.env.example` to `.env` and fill in the values.
|
||||
2. Start the stack with Docker Compose.
|
||||
3. Point your external Caddy instance at the frontend and Directus ports defined in `.env`.
|
||||
|
||||
## Services
|
||||
|
||||
- Frontend: Astro dev server running in a bind-mounted container for live preview
|
||||
- CMS: Directus
|
||||
- Database: PostgreSQL
|
||||
- CMS bootstrap: one-shot schema initializer (`directus-bootstrap`)
|
||||
|
||||
## Notes
|
||||
|
||||
- All deploy-time variables live in `.env`.
|
||||
- The frontend service bind-mounts the project into `/app`, keeps `node_modules` and `.astro` in named volumes, and serves the site with `astro dev` on the published frontend port.
|
||||
- Layout and page structure are controlled entirely by Astro.
|
||||
- Frontend source edits should appear without rebuilding the container image.
|
||||
|
||||
## Programmatic Directus schema bootstrap
|
||||
|
||||
The schema bootstrap is automatic on `docker compose up` via the `directus-bootstrap` service.
|
||||
It creates collections, fields, and core tag relations idempotently.
|
||||
|
||||
If the frontend dependency graph changes, restart the frontend container to rerun `npm install` inside the container volume:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d web
|
||||
```
|
||||
|
||||
You can still run the script manually if needed:
|
||||
|
||||
```bash
|
||||
docker compose run --rm directus-bootstrap
|
||||
```
|
||||
|
||||
The bootstrap script is idempotent, so reruns are safe.
|
||||
|
||||
## Troubleshooting bootstrap permissions
|
||||
|
||||
If you see `403 FORBIDDEN` during bootstrap:
|
||||
|
||||
- Most commonly, the Directus database volume was initialized earlier with different admin credentials.
|
||||
- `DIRECTUS_ADMIN_EMAIL` and `DIRECTUS_ADMIN_PASSWORD` are only used when Directus initializes a new database.
|
||||
- For stable bootstrap auth across restarts, set `DIRECTUS_ADMIN_TOKEN` in `.env` and keep it constant.
|
||||
|
||||
To start from a clean Directus state (development only):
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
trailingSlash: 'always',
|
||||
site: process.env.PUBLIC_SITE_URL ?? 'https://swansea-airport.wales',
|
||||
server: {
|
||||
allowedHosts: ['docker', 'localhost'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import './scripts/bootstrap-directus.mjs';
|
||||
@@ -0,0 +1,38 @@
|
||||
# Directus Setup Contract
|
||||
|
||||
This repository uses Directus as a content-only CMS. Layout stays in Astro.
|
||||
|
||||
## Collections
|
||||
|
||||
- `news`
|
||||
- `events`
|
||||
- `notices`
|
||||
- `fuel_prices`
|
||||
- `documents`
|
||||
- `contacts`
|
||||
- `tags`
|
||||
|
||||
## Important relations
|
||||
|
||||
- `news` many-to-many `tags`
|
||||
- `events` many-to-many `tags`
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
All deploy-time settings live in the root `.env` file and are consumed by Docker Compose.
|
||||
|
||||
Required variables include:
|
||||
|
||||
- `POSTGRES_DB`
|
||||
- `POSTGRES_USER`
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `DIRECTUS_KEY`
|
||||
- `DIRECTUS_SECRET`
|
||||
- `DIRECTUS_ADMIN_EMAIL`
|
||||
- `DIRECTUS_ADMIN_PASSWORD`
|
||||
- `DIRECTUS_PUBLIC_URL`
|
||||
- `DIRECTUS_CORS_ORIGIN`
|
||||
- `PUBLIC_SITE_URL`
|
||||
- `DIRECTUS_URL`
|
||||
- `FRONTEND_PORT`
|
||||
- `DIRECTUS_PORT`
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"version": 1,
|
||||
"collections": [
|
||||
{
|
||||
"collection": "tags",
|
||||
"meta": {
|
||||
"icon": "sell",
|
||||
"hidden": false,
|
||||
"singleton": false,
|
||||
"note": "Managed by directus/schema-snapshot.json"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"field": "id",
|
||||
"type": "integer",
|
||||
"meta": { "hidden": true }
|
||||
},
|
||||
{
|
||||
"field": "name",
|
||||
"type": "string",
|
||||
"meta": { "required": true, "interface": "input" },
|
||||
"schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false }
|
||||
},
|
||||
{
|
||||
"field": "slug",
|
||||
"type": "string",
|
||||
"meta": { "required": true, "interface": "input" },
|
||||
"schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collection": "news",
|
||||
"meta": { "icon": "article", "hidden": false, "singleton": false, "note": "Managed by directus/schema-snapshot.json" },
|
||||
"fields": [
|
||||
{ "field": "id", "type": "integer", "meta": { "hidden": true } },
|
||||
{ "field": "title", "type": "string", "meta": { "required": true }, "schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false } },
|
||||
{ "field": "slug", "type": "string", "meta": { "required": true }, "schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false } },
|
||||
{ "field": "body", "type": "text", "meta": { "interface": "input-multiline" }, "schema": { "data_type": "text" } },
|
||||
{ "field": "summary", "type": "text", "meta": { "interface": "input-multiline" }, "schema": { "data_type": "text" } },
|
||||
{ "field": "featured_image", "type": "uuid", "meta": { "interface": "select-dropdown-m2o" }, "schema": { "data_type": "uuid" } },
|
||||
{ "field": "status", "type": "string", "schema": { "data_type": "varchar", "max_length": 255 } },
|
||||
{ "field": "publish_date", "type": "timestamp", "meta": { "interface": "datetime" }, "schema": { "data_type": "timestamp with time zone" } },
|
||||
{ "field": "author", "type": "string", "schema": { "data_type": "varchar", "max_length": 255 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collection": "events",
|
||||
"meta": { "icon": "event", "hidden": false, "singleton": false, "note": "Managed by directus/schema-snapshot.json" },
|
||||
"fields": [
|
||||
{ "field": "id", "type": "integer", "meta": { "hidden": true } },
|
||||
{ "field": "title", "type": "string", "meta": { "required": true }, "schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false } },
|
||||
{ "field": "slug", "type": "string", "meta": { "required": true }, "schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false } },
|
||||
{ "field": "description", "type": "text", "meta": { "interface": "input-multiline" }, "schema": { "data_type": "text" } },
|
||||
{ "field": "start_datetime", "type": "timestamp", "meta": { "required": true, "interface": "datetime" }, "schema": { "data_type": "timestamp with time zone", "is_nullable": false } },
|
||||
{ "field": "end_datetime", "type": "timestamp", "meta": { "interface": "datetime" }, "schema": { "data_type": "timestamp with time zone" } },
|
||||
{ "field": "location_text", "type": "string", "schema": { "data_type": "varchar", "max_length": 255 } },
|
||||
{ "field": "image", "type": "uuid", "meta": { "interface": "select-dropdown-m2o" }, "schema": { "data_type": "uuid" } },
|
||||
{ "field": "registration_link", "type": "string", "schema": { "data_type": "varchar", "max_length": 500 } },
|
||||
{ "field": "status", "type": "string", "schema": { "data_type": "varchar", "max_length": 255 } },
|
||||
{ "field": "is_featured", "type": "boolean", "meta": { "interface": "boolean" }, "schema": { "data_type": "boolean", "default_value": false } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collection": "notices",
|
||||
"meta": { "icon": "campaign", "hidden": false, "singleton": false, "note": "Managed by directus/schema-snapshot.json" },
|
||||
"fields": [
|
||||
{ "field": "id", "type": "integer", "meta": { "hidden": true } },
|
||||
{ "field": "title", "type": "string", "meta": { "required": true }, "schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false } },
|
||||
{ "field": "message", "type": "text", "meta": { "required": true, "interface": "input-multiline" }, "schema": { "data_type": "text", "is_nullable": false } },
|
||||
{ "field": "severity", "type": "string", "meta": { "interface": "select-dropdown" }, "schema": { "data_type": "varchar", "max_length": 20, "default_value": "info" } },
|
||||
{ "field": "start_date", "type": "date", "meta": { "interface": "datetime" }, "schema": { "data_type": "date" } },
|
||||
{ "field": "end_date", "type": "date", "meta": { "interface": "datetime" }, "schema": { "data_type": "date" } },
|
||||
{ "field": "active", "type": "boolean", "meta": { "interface": "boolean" }, "schema": { "data_type": "boolean", "default_value": true } },
|
||||
{ "field": "priority", "type": "integer", "meta": { "interface": "input" }, "schema": { "data_type": "integer", "default_value": 0 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collection": "fuel_prices",
|
||||
"meta": { "icon": "local_gas_station", "hidden": false, "singleton": false, "note": "Managed by directus/schema-snapshot.json" },
|
||||
"fields": [
|
||||
{ "field": "id", "type": "integer", "meta": { "hidden": true } },
|
||||
{ "field": "fuel_type", "type": "string", "meta": { "required": true }, "schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false } },
|
||||
{ "field": "price_per_litre", "type": "decimal", "meta": { "required": true }, "schema": { "data_type": "decimal", "numeric_precision": 10, "numeric_scale": 2, "is_nullable": false } },
|
||||
{ "field": "currency", "type": "string", "schema": { "data_type": "varchar", "max_length": 3, "default_value": "GBP" } },
|
||||
{ "field": "last_updated", "type": "timestamp", "meta": { "interface": "datetime" }, "schema": { "data_type": "timestamp with time zone" } },
|
||||
{ "field": "notes", "type": "text", "meta": { "interface": "input-multiline" }, "schema": { "data_type": "text" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collection": "documents",
|
||||
"meta": { "icon": "description", "hidden": false, "singleton": false, "note": "Managed by directus/schema-snapshot.json" },
|
||||
"fields": [
|
||||
{ "field": "id", "type": "integer", "meta": { "hidden": true } },
|
||||
{ "field": "title", "type": "string", "meta": { "required": true }, "schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false } },
|
||||
{ "field": "file", "type": "uuid", "meta": { "interface": "select-dropdown-m2o" }, "schema": { "data_type": "uuid" } },
|
||||
{ "field": "category", "type": "string", "schema": { "data_type": "varchar", "max_length": 255 } },
|
||||
{ "field": "description", "type": "text", "meta": { "interface": "input-multiline" }, "schema": { "data_type": "text" } },
|
||||
{ "field": "uploaded_at", "type": "timestamp", "meta": { "interface": "datetime" }, "schema": { "data_type": "timestamp with time zone" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collection": "contacts",
|
||||
"meta": { "icon": "contacts", "hidden": false, "singleton": false, "note": "Managed by directus/schema-snapshot.json" },
|
||||
"fields": [
|
||||
{ "field": "id", "type": "integer", "meta": { "hidden": true } },
|
||||
{ "field": "name", "type": "string", "meta": { "required": true }, "schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false } },
|
||||
{ "field": "role", "type": "string", "schema": { "data_type": "varchar", "max_length": 255 } },
|
||||
{ "field": "email", "type": "string", "meta": { "required": true }, "schema": { "data_type": "varchar", "max_length": 255, "is_nullable": false } },
|
||||
{ "field": "phone", "type": "string", "schema": { "data_type": "varchar", "max_length": 20 } },
|
||||
{ "field": "is_public", "type": "boolean", "meta": { "interface": "boolean" }, "schema": { "data_type": "boolean", "default_value": true } },
|
||||
{ "field": "order", "type": "integer", "meta": { "interface": "input" }, "schema": { "data_type": "integer", "default_value": 0 } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collection": "news_tags",
|
||||
"meta": { "icon": "link", "hidden": true, "singleton": false, "note": "Junction for news → tags M2M" },
|
||||
"fields": [
|
||||
{ "field": "id", "type": "integer", "meta": { "hidden": true } },
|
||||
{ "field": "news_id", "type": "integer", "meta": { "required": true }, "schema": { "data_type": "integer", "is_nullable": false } },
|
||||
{ "field": "tag_id", "type": "integer", "meta": { "required": true }, "schema": { "data_type": "integer", "is_nullable": false } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collection": "events_tags",
|
||||
"meta": { "icon": "link", "hidden": true, "singleton": false, "note": "Junction for events → tags M2M" },
|
||||
"fields": [
|
||||
{ "field": "id", "type": "integer", "meta": { "hidden": true } },
|
||||
{ "field": "event_id", "type": "integer", "meta": { "required": true }, "schema": { "data_type": "integer", "is_nullable": false } },
|
||||
{ "field": "tag_id", "type": "integer", "meta": { "required": true }, "schema": { "data_type": "integer", "is_nullable": false } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"collection": "news_tags",
|
||||
"field": "news_id",
|
||||
"related_collection": "news",
|
||||
"schema": { "on_delete": "CASCADE" },
|
||||
"meta": {
|
||||
"one_collection": "news",
|
||||
"one_field": "tags",
|
||||
"one_deselect_action": "nullify"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collection": "news_tags",
|
||||
"field": "tag_id",
|
||||
"related_collection": "tags",
|
||||
"schema": { "on_delete": "CASCADE" },
|
||||
"meta": {
|
||||
"one_collection": "tags",
|
||||
"one_field": "news_items",
|
||||
"one_deselect_action": "nullify"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collection": "events_tags",
|
||||
"field": "event_id",
|
||||
"related_collection": "events",
|
||||
"schema": { "on_delete": "CASCADE" },
|
||||
"meta": {
|
||||
"one_collection": "events",
|
||||
"one_field": "tags",
|
||||
"one_deselect_action": "nullify"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collection": "events_tags",
|
||||
"field": "tag_id",
|
||||
"related_collection": "tags",
|
||||
"schema": { "on_delete": "CASCADE" },
|
||||
"meta": {
|
||||
"one_collection": "tags",
|
||||
"one_field": "events_items",
|
||||
"one_deselect_action": "nullify"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"collections": {
|
||||
"news": {
|
||||
"fields": [
|
||||
"title",
|
||||
"slug",
|
||||
"body",
|
||||
"summary",
|
||||
"featured_image",
|
||||
"status",
|
||||
"publish_date",
|
||||
"author",
|
||||
"tags"
|
||||
]
|
||||
},
|
||||
"events": {
|
||||
"fields": [
|
||||
"title",
|
||||
"slug",
|
||||
"description",
|
||||
"start_datetime",
|
||||
"end_datetime",
|
||||
"location_text",
|
||||
"image",
|
||||
"registration_link",
|
||||
"status",
|
||||
"is_featured",
|
||||
"tags"
|
||||
]
|
||||
},
|
||||
"notices": {
|
||||
"fields": [
|
||||
"title",
|
||||
"message",
|
||||
"severity",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"active",
|
||||
"priority"
|
||||
]
|
||||
},
|
||||
"fuel_prices": {
|
||||
"fields": [
|
||||
"fuel_type",
|
||||
"price_per_litre",
|
||||
"currency",
|
||||
"last_updated",
|
||||
"notes"
|
||||
]
|
||||
},
|
||||
"documents": {
|
||||
"fields": [
|
||||
"title",
|
||||
"file",
|
||||
"category",
|
||||
"description",
|
||||
"uploaded_at"
|
||||
]
|
||||
},
|
||||
"contacts": {
|
||||
"fields": [
|
||||
"name",
|
||||
"role",
|
||||
"email",
|
||||
"phone",
|
||||
"is_public",
|
||||
"order"
|
||||
]
|
||||
},
|
||||
"tags": {
|
||||
"fields": [
|
||||
"name",
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"news_tags": {
|
||||
"left": "news",
|
||||
"right": "tags",
|
||||
"type": "many-to-many"
|
||||
},
|
||||
"events_tags": {
|
||||
"left": "events",
|
||||
"right": "tags",
|
||||
"type": "many-to-many"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
directus:
|
||||
image: directus/directus:11
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
KEY: ${DIRECTUS_KEY}
|
||||
SECRET: ${DIRECTUS_SECRET}
|
||||
DB_CLIENT: pg
|
||||
DB_HOST: ${POSTGRES_HOST}
|
||||
DB_PORT: ${POSTGRES_PORT}
|
||||
DB_DATABASE: ${POSTGRES_DB}
|
||||
DB_USER: ${POSTGRES_USER}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||
ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN:-dev-admin-token-change-me}
|
||||
PUBLIC_URL: ${DIRECTUS_PUBLIC_URL}
|
||||
CORS_ENABLED: "true"
|
||||
CORS_ORIGIN: ${DIRECTUS_CORS_ORIGIN}
|
||||
WEBSOCKETS_ENABLED: "false"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8055/server/health >/dev/null 2>&1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
ports:
|
||||
- "${DIRECTUS_PORT}:8055"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- directus_uploads:/directus/uploads
|
||||
|
||||
directus-bootstrap:
|
||||
image: node:22-alpine
|
||||
restart: "no"
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./:/app
|
||||
environment:
|
||||
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||
command: ["node", "scripts/bootstrap-directus.mjs"]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
directus:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
command: ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0 --port 80"]
|
||||
environment:
|
||||
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL}
|
||||
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||
DIRECTUS_ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN}
|
||||
depends_on:
|
||||
directus:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- web_node_modules:/app/node_modules
|
||||
- web_astro:/app/.astro
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
directus_uploads:
|
||||
web_node_modules:
|
||||
web_astro:
|
||||
Generated
+5479
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "swansea-airfield",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"check": "astro check",
|
||||
"bootstrap:directus": "node scripts/bootstrap-directus.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.29",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
// Bootstrap Directus schema using the directus CLI (schema apply)
|
||||
// This approach bypasses the REST API permission issues entirely
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://directus:8055';
|
||||
const DIRECTUS_TOKEN = process.env.DIRECTUS_TOKEN || '';
|
||||
const DIRECTUS_ADMIN_EMAIL = process.env.DIRECTUS_ADMIN_EMAIL || 'admin@example.com';
|
||||
const DIRECTUS_ADMIN_PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD || 'change-me';
|
||||
const SNAPSHOT_PATH = path.join(__dirname, '..', 'directus', 'schema-snapshot.json');
|
||||
|
||||
console.log('========================================');
|
||||
console.log('Directus Bootstrap (CLI-based)');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
|
||||
// Check if snapshot exists
|
||||
if (!fs.existsSync(SNAPSHOT_PATH)) {
|
||||
console.error(`❌ ERROR: Schema snapshot not found at ${SNAPSHOT_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ Found schema snapshot at ${SNAPSHOT_PATH}`);
|
||||
console.log('');
|
||||
|
||||
// Determine authentication method
|
||||
let hasToken = Boolean(DIRECTUS_TOKEN);
|
||||
if (hasToken) {
|
||||
console.log('✓ DIRECTUS_TOKEN is set; will use bearer token');
|
||||
} else {
|
||||
console.log('⚠️ DIRECTUS_TOKEN not set; will attempt email/password login');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Wait for Directus to be fully ready
|
||||
console.log('⏳ Waiting for Directus to be ready...');
|
||||
const maxRetries = 30;
|
||||
let retry = 0;
|
||||
|
||||
async function checkHealth() {
|
||||
while (retry < maxRetries) {
|
||||
try {
|
||||
const response = await fetch(`${DIRECTUS_URL}/server/health`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
if (response.ok) {
|
||||
console.log('✓ Directus is ready');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue retrying
|
||||
}
|
||||
retry++;
|
||||
if (retry % 5 === 0) {
|
||||
process.stdout.write('.');
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
if (retry >= maxRetries) {
|
||||
console.error(`\n❌ ERROR: Directus did not become ready after ${maxRetries} seconds`);
|
||||
process.exit(1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function applySchema() {
|
||||
await checkHealth();
|
||||
console.log('');
|
||||
console.log('📋 Applying schema snapshot...');
|
||||
console.log(' This will create all collections, fields, and relations.');
|
||||
console.log('');
|
||||
|
||||
// Set environment variables for the directus CLI
|
||||
process.env.DIRECTUS_URL = DIRECTUS_URL;
|
||||
process.env.DIRECTUS_TOKEN = DIRECTUS_TOKEN;
|
||||
process.env.DIRECTUS_ADMIN_EMAIL = DIRECTUS_ADMIN_EMAIL;
|
||||
process.env.DIRECTUS_ADMIN_PASSWORD = DIRECTUS_ADMIN_PASSWORD;
|
||||
|
||||
try {
|
||||
// Execute the schema apply command using npx
|
||||
// The directus CLI is available globally in the directus container
|
||||
const command = `npx directus schema apply "${SNAPSHOT_PATH}" --yes`;
|
||||
|
||||
console.log(`Running: ${command}`);
|
||||
console.log('');
|
||||
|
||||
// Execute synchronously and capture output
|
||||
const output = execSync(command, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('✅ SUCCESS: Schema snapshot applied successfully!');
|
||||
console.log('');
|
||||
console.log('Created collections:');
|
||||
console.log(' - tags');
|
||||
console.log(' - news');
|
||||
console.log(' - events');
|
||||
console.log(' - notices');
|
||||
console.log(' - fuel_prices');
|
||||
console.log(' - documents');
|
||||
console.log(' - contacts');
|
||||
console.log(' - news_tags (M2M junction)');
|
||||
console.log(' - events_tags (M2M junction)');
|
||||
console.log('');
|
||||
console.log('All fields and relations have been initialized.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('');
|
||||
console.error('❌ ERROR: Schema apply failed');
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the bootstrap
|
||||
applySchema().catch((error) => {
|
||||
console.error('❌ Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# Bootstrap Directus schema using the directus CLI (schema apply)
|
||||
# This approach bypasses the REST API permission issues entirely
|
||||
|
||||
set -e
|
||||
|
||||
DIRECTUS_URL="${DIRECTUS_URL:-http://directus:8055}"
|
||||
DIRECTUS_TOKEN="${DIRECTUS_TOKEN:-}"
|
||||
DIRECTUS_ADMIN_EMAIL="${DIRECTUS_ADMIN_EMAIL:-admin@example.com}"
|
||||
DIRECTUS_ADMIN_PASSWORD="${DIRECTUS_ADMIN_PASSWORD:-change-me}"
|
||||
SNAPSHOT_PATH="/app/directus/schema-snapshot.json"
|
||||
|
||||
echo "========================================"
|
||||
echo "Directus Bootstrap (CLI-based)"
|
||||
echo "========================================"
|
||||
|
||||
# Check if snapshot exists
|
||||
if [ ! -f "$SNAPSHOT_PATH" ]; then
|
||||
echo "❌ ERROR: Schema snapshot not found at $SNAPSHOT_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Found schema snapshot at $SNAPSHOT_PATH"
|
||||
|
||||
# Determine authentication method
|
||||
if [ -z "$DIRECTUS_TOKEN" ]; then
|
||||
echo "⚠️ DIRECTUS_TOKEN not set; will attempt email/password login"
|
||||
LOGIN_METHOD="email"
|
||||
else
|
||||
echo "✓ DIRECTUS_TOKEN is set; will use bearer token"
|
||||
LOGIN_METHOD="token"
|
||||
fi
|
||||
|
||||
# Wait for Directus to be fully ready
|
||||
echo "⏳ Waiting for Directus to be ready..."
|
||||
max_retries=30
|
||||
retry=0
|
||||
while [ $retry -lt $max_retries ]; do
|
||||
if curl -s "$DIRECTUS_URL/server/health" > /dev/null 2>&1; then
|
||||
echo "✓ Directus is ready"
|
||||
break
|
||||
fi
|
||||
retry=$((retry + 1))
|
||||
if [ $retry -eq $max_retries ]; then
|
||||
echo "❌ ERROR: Directus did not become ready after ${max_retries} seconds"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Apply schema snapshot using directus CLI
|
||||
echo ""
|
||||
echo "📋 Applying schema snapshot..."
|
||||
echo " This will create all collections, fields, and relations."
|
||||
echo ""
|
||||
|
||||
# The directus CLI is available inside the directus container
|
||||
# We'll use it via npx since directus is installed globally there
|
||||
export DIRECTUS_URL
|
||||
export DIRECTUS_TOKEN
|
||||
export DIRECTUS_ADMIN_EMAIL
|
||||
export DIRECTUS_ADMIN_PASSWORD
|
||||
|
||||
# Execute the schema apply command
|
||||
# Note: The CLI will prompt for missing credentials if needed
|
||||
if npx directus schema apply "$SNAPSHOT_PATH" --yes 2>&1; then
|
||||
echo ""
|
||||
echo "✅ SUCCESS: Schema snapshot applied successfully!"
|
||||
echo ""
|
||||
echo "Created collections:"
|
||||
echo " - tags"
|
||||
echo " - news"
|
||||
echo " - events"
|
||||
echo " - notices"
|
||||
echo " - fuel_prices"
|
||||
echo " - documents"
|
||||
echo " - contacts"
|
||||
echo " - news_tags (M2M junction)"
|
||||
echo " - events_tags (M2M junction)"
|
||||
echo ""
|
||||
echo "All fields and relations have been initialized."
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo "❌ ERROR: Schema apply failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bootstrap Directus schema by creating PostgreSQL tables.
|
||||
* Directus will auto-discover these tables on startup and create the field metadata automatically.
|
||||
*/
|
||||
|
||||
import pkg from 'pg';
|
||||
const { Pool } = pkg;
|
||||
|
||||
const pgConfig = {
|
||||
host: process.env.POSTGRES_HOST || 'db',
|
||||
port: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
user: process.env.POSTGRES_USER || 'directus',
|
||||
password: process.env.POSTGRES_PASSWORD || 'change-me',
|
||||
database: process.env.POSTGRES_DB || 'directus',
|
||||
};
|
||||
|
||||
const pool = new Pool(pgConfig);
|
||||
|
||||
console.log('========================================');
|
||||
console.log('Directus Schema Bootstrap');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
|
||||
// Define collections and fields
|
||||
const schema = {
|
||||
tags: [
|
||||
{ field: 'id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'name', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'slug', type: 'varchar(255)', nullable: false, default: null },
|
||||
],
|
||||
news: [
|
||||
{ field: 'id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'title', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'slug', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'body', type: 'text', nullable: true, default: null },
|
||||
{ field: 'summary', type: 'text', nullable: true, default: null },
|
||||
{ field: 'featured_image', type: 'uuid', nullable: true, default: null },
|
||||
{ field: 'status', type: 'varchar(255)', nullable: true, default: null },
|
||||
{ field: 'publish_date', type: 'timestamp with time zone', nullable: true, default: null },
|
||||
{ field: 'author', type: 'varchar(255)', nullable: true, default: null },
|
||||
],
|
||||
events: [
|
||||
{ field: 'id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'title', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'slug', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'description', type: 'text', nullable: true, default: null },
|
||||
{ field: 'start_datetime', type: 'timestamp with time zone', nullable: false, default: null },
|
||||
{ field: 'end_datetime', type: 'timestamp with time zone', nullable: true, default: null },
|
||||
{ field: 'location_text', type: 'varchar(255)', nullable: true, default: null },
|
||||
{ field: 'image', type: 'uuid', nullable: true, default: null },
|
||||
{ field: 'registration_link', type: 'varchar(500)', nullable: true, default: null },
|
||||
{ field: 'status', type: 'varchar(255)', nullable: true, default: null },
|
||||
{ field: 'is_featured', type: 'boolean', nullable: true, default: 'false' },
|
||||
],
|
||||
notices: [
|
||||
{ field: 'id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'title', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'message', type: 'text', nullable: false, default: null },
|
||||
{ field: 'severity', type: 'varchar(20)', nullable: true, default: "'info'" },
|
||||
{ field: 'start_date', type: 'date', nullable: true, default: null },
|
||||
{ field: 'end_date', type: 'date', nullable: true, default: null },
|
||||
{ field: 'active', type: 'boolean', nullable: true, default: 'true' },
|
||||
{ field: 'priority', type: 'integer', nullable: true, default: '0' },
|
||||
],
|
||||
fuel_prices: [
|
||||
{ field: 'id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'fuel_type', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'price_per_litre', type: 'numeric(10,2)', nullable: false, default: null },
|
||||
{ field: 'currency', type: 'varchar(3)', nullable: true, default: "'GBP'" },
|
||||
{ field: 'last_updated', type: 'timestamp with time zone', nullable: true, default: null },
|
||||
{ field: 'notes', type: 'text', nullable: true, default: null },
|
||||
],
|
||||
documents: [
|
||||
{ field: 'id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'title', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'file', type: 'uuid', nullable: true, default: null },
|
||||
{ field: 'category', type: 'varchar(255)', nullable: true, default: null },
|
||||
{ field: 'description', type: 'text', nullable: true, default: null },
|
||||
{ field: 'uploaded_at', type: 'timestamp with time zone', nullable: true, default: null },
|
||||
],
|
||||
contacts: [
|
||||
{ field: 'id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'name', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'role', type: 'varchar(255)', nullable: true, default: null },
|
||||
{ field: 'email', type: 'varchar(255)', nullable: false, default: null },
|
||||
{ field: 'phone', type: 'varchar(20)', nullable: true, default: null },
|
||||
{ field: 'is_public', type: 'boolean', nullable: true, default: 'true' },
|
||||
{ field: 'order', type: 'integer', nullable: true, default: '0' },
|
||||
],
|
||||
news_tags: [
|
||||
{ field: 'id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'news_id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'tag_id', type: 'integer', nullable: false, default: null },
|
||||
],
|
||||
events_tags: [
|
||||
{ field: 'id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'event_id', type: 'integer', nullable: false, default: null },
|
||||
{ field: 'tag_id', type: 'integer', nullable: false, default: null },
|
||||
],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔐 Connecting to PostgreSQL...');
|
||||
await client.query('SELECT 1');
|
||||
console.log('✓ Connected successfully');
|
||||
console.log('');
|
||||
console.log('📋 Creating tables...');
|
||||
console.log('');
|
||||
|
||||
// Create all tables
|
||||
const created = [];
|
||||
for (const [collectionName, fields] of Object.entries(schema)) {
|
||||
const wasCreated = await createTable(client, collectionName, fields);
|
||||
if (wasCreated) created.push(collectionName);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (created.length > 0) {
|
||||
console.log('✅ Schema bootstrap completed successfully!');
|
||||
console.log('');
|
||||
console.log(`Created ${created.length} tables:`);
|
||||
created.forEach(name => console.log(` - ${name}`));
|
||||
console.log('');
|
||||
console.log('Directus will discover these tables automatically.');
|
||||
} else {
|
||||
console.log('ℹ️ All tables already exist. Schema is up to date.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Bootstrap failed:', error.message);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function createTable(client, collectionName, fields) {
|
||||
// Check if table already exists
|
||||
const checkResult = await client.query(
|
||||
`SELECT to_regclass($1)`,
|
||||
[`public.${collectionName}`]
|
||||
);
|
||||
|
||||
if (checkResult.rows[0].to_regclass) {
|
||||
console.log(` ℹ Table exists: ${collectionName}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build CREATE TABLE statement
|
||||
const columnDefs = fields.map(f => {
|
||||
let def = `"${f.field}" ${f.type}`;
|
||||
if (!f.nullable) {
|
||||
def += ' NOT NULL';
|
||||
}
|
||||
if (f.default) {
|
||||
def += ` DEFAULT ${f.default}`;
|
||||
}
|
||||
return def;
|
||||
});
|
||||
|
||||
// Add primary key if not already in fields
|
||||
if (!fields.some(f => f.field === 'id')) {
|
||||
columnDefs.unshift('"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY');
|
||||
}
|
||||
|
||||
const createTableSQL = `CREATE TABLE "public"."${collectionName}" (${columnDefs.join(', ')})`;
|
||||
|
||||
try {
|
||||
await client.query(createTableSQL);
|
||||
console.log(` ✓ Created table: ${collectionName}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === '42P07') {
|
||||
// Table already exists
|
||||
console.log(` ℹ Table exists: ${collectionName}`);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Idempotent Directus schema bootstrap for the Swansea airfield project.
|
||||
*
|
||||
* Usage (containerized):
|
||||
* docker run --rm --network webdev_default \
|
||||
* -v "$PWD:/app" -w /app \
|
||||
* -e DIRECTUS_BASE_URL=http://directus:8055 \
|
||||
* -e DIRECTUS_EMAIL=admin@example.com \
|
||||
* -e DIRECTUS_PASSWORD=change-me \
|
||||
* node:22-alpine node scripts/bootstrap-directus.mjs
|
||||
*/
|
||||
|
||||
const baseUrl = (process.env.DIRECTUS_URL || process.env.DIRECTUS_BASE_URL || 'http://localhost:8055').replace(/\/$/, '');
|
||||
const email = process.env.DIRECTUS_ADMIN_EMAIL || process.env.DIRECTUS_EMAIL || 'admin@example.com';
|
||||
const password = process.env.DIRECTUS_ADMIN_PASSWORD || process.env.DIRECTUS_PASSWORD || 'change-me';
|
||||
|
||||
// Note: We intentionally ignore pre-set DIRECTUS_TOKEN because it often lacks proper
|
||||
// permissions. Email/password login to get a fresh admin token is more reliable.
|
||||
|
||||
|
||||
const collections = [
|
||||
{ collection: 'tags', icon: 'sell' },
|
||||
{ collection: 'news', icon: 'article' },
|
||||
{ collection: 'events', icon: 'event' },
|
||||
{ collection: 'notices', icon: 'campaign' },
|
||||
{ collection: 'fuel_prices', icon: 'local_gas_station' },
|
||||
{ collection: 'documents', icon: 'description' },
|
||||
{ collection: 'contacts', icon: 'contacts' },
|
||||
{ collection: 'news_tags', icon: 'link', hidden: true },
|
||||
{ collection: 'events_tags', icon: 'link', hidden: true },
|
||||
];
|
||||
|
||||
const fieldsByCollection = {
|
||||
tags: [
|
||||
{ field: 'name', type: 'string', required: true },
|
||||
{ field: 'slug', type: 'string', required: true },
|
||||
],
|
||||
news: [
|
||||
{ field: 'title', type: 'string', required: true },
|
||||
{ field: 'slug', type: 'string', required: true },
|
||||
{ field: 'body', type: 'text' },
|
||||
{ field: 'summary', type: 'text' },
|
||||
{ field: 'featured_image', type: 'uuid' },
|
||||
{ field: 'status', type: 'string' },
|
||||
{ field: 'publish_date', type: 'dateTime' },
|
||||
{ field: 'author', type: 'string' },
|
||||
],
|
||||
events: [
|
||||
{ field: 'title', type: 'string', required: true },
|
||||
{ field: 'slug', type: 'string', required: true },
|
||||
{ field: 'description', type: 'text' },
|
||||
{ field: 'start_datetime', type: 'dateTime', required: true },
|
||||
{ field: 'end_datetime', type: 'dateTime' },
|
||||
{ field: 'location_text', type: 'string' },
|
||||
{ field: 'image', type: 'uuid' },
|
||||
{ field: 'registration_link', type: 'string' },
|
||||
{ field: 'status', type: 'string' },
|
||||
{ field: 'is_featured', type: 'boolean', default: false },
|
||||
],
|
||||
notices: [
|
||||
{ field: 'title', type: 'string', required: true },
|
||||
{ field: 'message', type: 'text', required: true },
|
||||
{ field: 'severity', type: 'string', default: 'info' },
|
||||
{ field: 'start_date', type: 'date' },
|
||||
{ field: 'end_date', type: 'date' },
|
||||
{ field: 'active', type: 'boolean', default: true },
|
||||
{ field: 'priority', type: 'integer', default: 0 },
|
||||
],
|
||||
fuel_prices: [
|
||||
{ field: 'fuel_type', type: 'string', required: true },
|
||||
{ field: 'price_per_litre', type: 'decimal', required: true },
|
||||
{ field: 'currency', type: 'string', default: 'GBP' },
|
||||
{ field: 'last_updated', type: 'dateTime' },
|
||||
{ field: 'notes', type: 'text' },
|
||||
],
|
||||
documents: [
|
||||
{ field: 'title', type: 'string', required: true },
|
||||
{ field: 'file', type: 'uuid' },
|
||||
{ field: 'category', type: 'string' },
|
||||
{ field: 'description', type: 'text' },
|
||||
{ field: 'uploaded_at', type: 'dateTime' },
|
||||
],
|
||||
contacts: [
|
||||
{ field: 'name', type: 'string', required: true },
|
||||
{ field: 'role', type: 'string' },
|
||||
{ field: 'email', type: 'string', required: true },
|
||||
{ field: 'phone', type: 'string' },
|
||||
{ field: 'is_public', type: 'boolean', default: true },
|
||||
{ field: 'order', type: 'integer', default: 0 },
|
||||
],
|
||||
news_tags: [
|
||||
{ field: 'news_id', type: 'integer', required: true },
|
||||
{ field: 'tag_id', type: 'integer', required: true },
|
||||
],
|
||||
events_tags: [
|
||||
{ field: 'event_id', type: 'integer', required: true },
|
||||
{ field: 'tag_id', type: 'integer', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
const typeMap = {
|
||||
string: { data_type: 'varchar', max_length: 255 },
|
||||
text: { data_type: 'text' },
|
||||
integer: { data_type: 'integer' },
|
||||
decimal: { data_type: 'decimal', numeric_precision: 10, numeric_scale: 2 },
|
||||
boolean: { data_type: 'boolean' },
|
||||
date: { data_type: 'date' },
|
||||
dateTime: { data_type: 'timestamp with time zone' },
|
||||
uuid: { data_type: 'uuid' },
|
||||
};
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Wait for Directus to be ready
|
||||
console.log('========================================');
|
||||
console.log('Directus Schema Bootstrap');
|
||||
console.log('========================================');
|
||||
console.log('');
|
||||
console.log('⏳ Waiting for Directus to be ready...');
|
||||
|
||||
let ready = false;
|
||||
let retries = 0;
|
||||
const maxRetries = 30;
|
||||
|
||||
while (!ready && retries < maxRetries) {
|
||||
try {
|
||||
const health = await fetch(`${baseUrl}/server/health`);
|
||||
if (health.ok) {
|
||||
ready = true;
|
||||
console.log('✓ Directus is ready');
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue retrying
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
retries++;
|
||||
if (retries % 5 === 0) {
|
||||
process.stdout.write('.');
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
throw new Error(`Directus did not become ready after ${maxRetries} seconds`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('🔐 Authenticating with email/password login...');
|
||||
const token = await login(email, password);
|
||||
console.log('✓ Authentication successful');
|
||||
console.log('');
|
||||
console.log('📋 Creating schema...');
|
||||
console.log('');
|
||||
|
||||
// Create all collections with their fields in one call
|
||||
// This ensures both metadata and tables are created
|
||||
const created = [];
|
||||
const existing = [];
|
||||
|
||||
for (const collection of collections) {
|
||||
const collectionName = collection.collection;
|
||||
const fields = fieldsByCollection[collectionName] || [];
|
||||
|
||||
const result = await ensureCollection(token, collection, fields);
|
||||
|
||||
if (result.created) {
|
||||
created.push(collectionName);
|
||||
} else {
|
||||
existing.push(collectionName);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (created.length > 0) {
|
||||
console.log('✅ Schema bootstrap completed successfully!');
|
||||
console.log('');
|
||||
console.log(`Created ${created.length} collections:`);
|
||||
created.forEach(name => console.log(` - ${name}`));
|
||||
|
||||
if (existing.length > 0) {
|
||||
console.log('');
|
||||
console.log(`${existing.length} collections already exist.`);
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ All collections already exist. Schema is up to date.');
|
||||
}
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error('');
|
||||
console.error('❌ Bootstrap failed:', error.message);
|
||||
console.error('');
|
||||
if (error.details) {
|
||||
console.error('Details:', error.details);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function login(userEmail, userPassword) {
|
||||
const response = await fetch(`${baseUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: userEmail, password: userPassword }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Login failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const token = payload?.data?.access_token;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No access token returned from Directus login.');
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async function ensureCollection(token, collection, fields = []) {
|
||||
const collectionName = collection.collection;
|
||||
|
||||
// Build field payloads - convert to Directus field format
|
||||
const fieldPayloads = fields.map(field => {
|
||||
const typeConfig = typeMap[field.type];
|
||||
if (!typeConfig) {
|
||||
throw new Error(`Unsupported field type: ${field.type}`);
|
||||
}
|
||||
|
||||
return {
|
||||
field: field.field,
|
||||
type: field.type,
|
||||
meta: {
|
||||
required: Boolean(field.required),
|
||||
interface: defaultInterfaceForType(field.type),
|
||||
},
|
||||
schema: {
|
||||
...typeConfig,
|
||||
is_nullable: !field.required,
|
||||
default_value: field.default || null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await fetchJson(`${baseUrl}/collections`, token, 'POST', {
|
||||
collection: collectionName,
|
||||
meta: {
|
||||
icon: collection.icon,
|
||||
hidden: Boolean(collection.hidden),
|
||||
singleton: false,
|
||||
note: `Managed by scripts/bootstrap-directus.mjs`,
|
||||
},
|
||||
// Include schema to tell Directus to create the table
|
||||
schema: {
|
||||
name: collectionName,
|
||||
},
|
||||
// Include fields to create them in one call
|
||||
fields: fieldPayloads,
|
||||
});
|
||||
|
||||
console.log(`Created collection: ${collectionName}`);
|
||||
return { created: true };
|
||||
} catch (error) {
|
||||
// Check if it's a "collection already exists" error
|
||||
if (error.message && error.message.includes('already exists')) {
|
||||
console.log(`Collection exists: ${collectionName}`);
|
||||
return { created: false };
|
||||
}
|
||||
|
||||
// Check if response indicates duplicate
|
||||
if (error.statusCode === 409 || error.statusCode === 400) {
|
||||
console.log(`Collection exists: ${collectionName}`);
|
||||
return { created: false };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function defaultInterfaceForType(type) {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return 'input-multiline';
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'date':
|
||||
return 'datetime';
|
||||
case 'dateTime':
|
||||
return 'datetime';
|
||||
case 'uuid':
|
||||
return 'select-dropdown-m2o';
|
||||
case 'decimal':
|
||||
case 'integer':
|
||||
return 'input';
|
||||
default:
|
||||
return 'input';
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRelation(token, relation) {
|
||||
const result = await fetchJson(`${baseUrl}/relations`, token, 'POST', {
|
||||
collection: relation.collection,
|
||||
field: relation.field,
|
||||
related_collection: relation.related_collection,
|
||||
schema: {
|
||||
on_delete: 'CASCADE',
|
||||
},
|
||||
meta: {
|
||||
many_collection: relation.collection,
|
||||
many_field: relation.field,
|
||||
one_collection: relation.one_collection,
|
||||
one_field: relation.one_field,
|
||||
one_deselect_action: 'nullify',
|
||||
},
|
||||
}, { allowAlreadyExists: true });
|
||||
|
||||
if (result?.alreadyExists) {
|
||||
console.log(`Relation exists: ${relation.collection}.${relation.field}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Created relation: ${relation.collection}.${relation.field} -> ${relation.related_collection}`);
|
||||
}
|
||||
|
||||
async function fetchJson(url, token, method, body, options = {}) {
|
||||
const { allowNotFound = false, allowAlreadyExists = false } = options;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (allowNotFound && response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
if (allowAlreadyExists && isAlreadyExistsError(response.status, text)) {
|
||||
return { alreadyExists: true };
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new Error(
|
||||
`${method} ${url} failed (403): ${text}\n` +
|
||||
'Bootstrap authentication is valid but lacks admin privileges. ' +
|
||||
'If this database was initialized earlier, your current DIRECTUS_ADMIN_EMAIL/PASSWORD may no longer match the original admin user. ' +
|
||||
'Use a stable DIRECTUS_ADMIN_TOKEN, or reset dev volumes with "docker compose down -v".'
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`${method} ${url} failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function isAlreadyExistsError(status, text) {
|
||||
if (status !== 400 && status !== 409) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes('already exists') ||
|
||||
normalized.includes('duplicate') ||
|
||||
normalized.includes('not unique') ||
|
||||
normalized.includes('record_not_unique')
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import SectionHeading from './SectionHeading.astro';
|
||||
import type { ContactItem } from '../lib/fallback-data';
|
||||
|
||||
type Props = {
|
||||
contacts: ContactItem[];
|
||||
};
|
||||
|
||||
const { contacts } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<section>
|
||||
<SectionHeading eyebrow="Contact" title="Public contacts" description="Only public-facing contacts are shown here." />
|
||||
<div class="cards-grid">
|
||||
{contacts
|
||||
.filter((contact) => contact.is_public !== false)
|
||||
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
|
||||
.map((contact) => (
|
||||
<article class="card">
|
||||
<h3>{contact.name}</h3>
|
||||
<p class="muted">{contact.role}</p>
|
||||
<p><a href={`mailto:${contact.email}`}>{contact.email}</a></p>
|
||||
{contact.phone && <p><a href={`tel:${contact.phone.replace(/\s+/g, '')}`}>{contact.phone}</a></p>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import SectionHeading from './SectionHeading.astro';
|
||||
import type { DocumentItem } from '../lib/fallback-data';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
type Props = {
|
||||
documents: DocumentItem[];
|
||||
};
|
||||
|
||||
const { documents } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<section>
|
||||
<SectionHeading eyebrow="Documents" title="Downloads and reference material" description="Flight documents, airport guidance, and public information in one place." />
|
||||
<div class="stack">
|
||||
{documents.map((document) => (
|
||||
<article class="card">
|
||||
<div class="split-grid" style="align-items:start;">
|
||||
<div>
|
||||
<p class="pill">{document.category}</p>
|
||||
<h3>{document.title}</h3>
|
||||
{document.description && <p>{document.description}</p>}
|
||||
</div>
|
||||
<div>
|
||||
{document.uploaded_at && <p class="meta">Uploaded {formatDate(document.uploaded_at)}</p>}
|
||||
{document.fileUrl ? <p><a class="button secondary" href={document.fileUrl}>Download</a></p> : <p class="muted">File will be linked from Directus.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import SectionHeading from './SectionHeading.astro';
|
||||
import type { EventItem } from '../lib/fallback-data';
|
||||
import { formatDateTime } from '../lib/format';
|
||||
|
||||
type Props = {
|
||||
events: EventItem[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const { events, title = 'Upcoming events', description = 'A quick scan list for pilots, visitors, and local supporters.' } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<section>
|
||||
<SectionHeading eyebrow="Events" title={title} description={description} />
|
||||
<div class="stack">
|
||||
{events.length > 0 ? (
|
||||
events.map((event) => (
|
||||
<article class="card">
|
||||
<div class="split-grid" style="align-items:start;">
|
||||
<div>
|
||||
<p class="pill">{event.is_featured ? 'Featured' : 'Event'}</p>
|
||||
<h3>{event.title}</h3>
|
||||
<p>{event.description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="meta">{formatDateTime(event.start_datetime)}</p>
|
||||
{event.location_text && <p>{event.location_text}</p>}
|
||||
{event.registration_link && (
|
||||
<p><a class="button secondary" href={event.registration_link}>Register</a></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<article class="card">
|
||||
<h3>No events published</h3>
|
||||
<p>Directus events will render here when content is available.</p>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import SectionHeading from './SectionHeading.astro';
|
||||
import type { FuelPrice } from '../lib/fallback-data';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
type Props = {
|
||||
fuelPrices: FuelPrice[];
|
||||
};
|
||||
|
||||
const { fuelPrices } = Astro.props as Props;
|
||||
|
||||
function formatFuelPrice(value: unknown): string {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric.toFixed(2) : '0.00';
|
||||
}
|
||||
---
|
||||
|
||||
<section>
|
||||
<SectionHeading eyebrow="Fuel" title="Current fuel prices" description="A simple, mobile-friendly snapshot of active prices and update times." />
|
||||
<div class="cards-grid">
|
||||
{fuelPrices.map((fuel) => (
|
||||
<article class="card">
|
||||
<p class="pill">{fuel.fuel_type}</p>
|
||||
<h3>{fuel.currency} {formatFuelPrice(fuel.price_per_litre)} / litre</h3>
|
||||
<p class="muted">Updated {formatDate(fuel.last_updated)}</p>
|
||||
{fuel.notes && <p>{fuel.notes}</p>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import SectionHeading from './SectionHeading.astro';
|
||||
import type { NewsItem } from '../lib/fallback-data';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
type Props = {
|
||||
news: NewsItem[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const { news, title = 'Latest news', description = 'Fresh updates, operational changes, and airport announcements.' } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<section>
|
||||
<SectionHeading eyebrow="News" title={title} description={description} />
|
||||
<div class="cards-grid">
|
||||
{news.length > 0 ? (
|
||||
news.map((item) => (
|
||||
<article class="card">
|
||||
<p class="meta">{formatDate(item.publish_date)}</p>
|
||||
<h3><a href={`/news/${item.slug}/`}>{item.title}</a></h3>
|
||||
<p>{item.summary}</p>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<article class="card">
|
||||
<h3>No news items</h3>
|
||||
<p>News articles will be generated from Directus at build time.</p>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import SectionHeading from './SectionHeading.astro';
|
||||
import type { Notice } from '../lib/fallback-data';
|
||||
|
||||
type Props = {
|
||||
notices: Notice[];
|
||||
};
|
||||
|
||||
const { notices } = Astro.props as Props;
|
||||
const visibleNotices = notices.filter((notice) => notice.active !== false).sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
|
||||
---
|
||||
|
||||
<section>
|
||||
<SectionHeading eyebrow="Operational notices" title="Live airfield alerts" description="High-visibility status messages for pilots, visitors, and staff." />
|
||||
<div class="notice-list">
|
||||
{visibleNotices.length > 0 ? (
|
||||
visibleNotices.map((notice) => (
|
||||
<article class={`notice ${notice.severity}`}>
|
||||
<p class="pill">{notice.severity}</p>
|
||||
<h3>{notice.title}</h3>
|
||||
<p>{notice.message}</p>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<article class="notice info">
|
||||
<h3>No active notices</h3>
|
||||
<p>Operational messages will appear here when published in Directus.</p>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type Props = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const { eyebrow, title, description } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
||||
<h2 class="section-title">{title}</h2>
|
||||
</div>
|
||||
{description && <p class="section-copy">{description}</p>}
|
||||
</div>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
import { homepageHighlights, site } from '../lib/site';
|
||||
import '../styles/global.css';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const { title, description = 'A fast, static, operational website for Swansea Airport.' } = Astro.props as Props;
|
||||
const pageTitle = title ? `${title} · ${site.name}` : site.name;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
<title>{pageTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="container topbar-inner">
|
||||
<div>
|
||||
<p class="eyebrow">{site.name}</p>
|
||||
<p class="topline">{site.tagline}</p>
|
||||
</div>
|
||||
<div class="topbar-meta">
|
||||
<span>{site.openingHours}</span>
|
||||
<span>{site.phone}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="navshell" aria-label="Primary">
|
||||
<div class="container nav-inner">
|
||||
<a class="brand" href="/">Swansea Airport</a>
|
||||
<div class="nav-links">
|
||||
{site.navigation.map((item) => (
|
||||
<a href={item.href}>{item.label}</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-grid">
|
||||
<section>
|
||||
<p class="eyebrow">Airport essentials</p>
|
||||
<p>{site.address}</p>
|
||||
<p>{site.licensedHours}</p>
|
||||
</section>
|
||||
<section>
|
||||
<p class="eyebrow">Operational highlights</p>
|
||||
<ul class="compact-list">
|
||||
{homepageHighlights.map((item) => (
|
||||
<li>{item.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<p class="eyebrow">Quick contact</p>
|
||||
<p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p>
|
||||
<p><a href={`mailto:info@swansea-airport.wales`}>info@swansea-airport.wales</a></p>
|
||||
</section>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
fallbackContacts,
|
||||
fallbackDocuments,
|
||||
fallbackEvents,
|
||||
fallbackFuelPrices,
|
||||
fallbackNews,
|
||||
fallbackNotices,
|
||||
type ContactItem,
|
||||
type DocumentItem,
|
||||
type EventItem,
|
||||
type FuelPrice,
|
||||
type NewsItem,
|
||||
type Notice,
|
||||
} from './fallback-data';
|
||||
|
||||
type CollectionName = 'news' | 'events' | 'notices' | 'fuel_prices' | 'documents' | 'contacts';
|
||||
|
||||
const defaultSortByCollection: Partial<Record<CollectionName, string>> = {
|
||||
news: '-publish_date',
|
||||
events: '-start_datetime',
|
||||
notices: '-priority',
|
||||
fuel_prices: '-last_updated',
|
||||
documents: '-uploaded_at',
|
||||
contacts: 'order',
|
||||
};
|
||||
|
||||
declare const process: {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
const directusUrl = process.env.DIRECTUS_URL ?? 'http://directus:8055';
|
||||
const directusToken = process.env.DIRECTUS_ADMIN_TOKEN;
|
||||
|
||||
async function readCollection<T>(collection: CollectionName): Promise<T[]> {
|
||||
const endpoint = new URL(`/items/${collection}`, directusUrl);
|
||||
endpoint.searchParams.set('limit', '100');
|
||||
const sort = defaultSortByCollection[collection];
|
||||
if (sort) {
|
||||
endpoint.searchParams.set('sort', sort);
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (directusToken) {
|
||||
headers['Authorization'] = `Bearer ${directusToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Directus responded with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { data?: T[] };
|
||||
return payload.data ?? [];
|
||||
} catch {
|
||||
return fallbackFor(collection) as T[];
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackFor(collection: CollectionName) {
|
||||
switch (collection) {
|
||||
case 'news':
|
||||
return fallbackNews;
|
||||
case 'events':
|
||||
return fallbackEvents;
|
||||
case 'notices':
|
||||
return fallbackNotices;
|
||||
case 'fuel_prices':
|
||||
return fallbackFuelPrices;
|
||||
case 'documents':
|
||||
return fallbackDocuments;
|
||||
case 'contacts':
|
||||
return fallbackContacts;
|
||||
}
|
||||
}
|
||||
|
||||
export const getNews = () => readCollection<NewsItem>('news');
|
||||
export const getEvents = () => readCollection<EventItem>('events');
|
||||
export const getNotices = () => readCollection<Notice>('notices');
|
||||
export const getFuelPrices = () => readCollection<FuelPrice>('fuel_prices');
|
||||
export const getDocuments = () => readCollection<DocumentItem>('documents');
|
||||
export const getContacts = () => readCollection<ContactItem>('contacts');
|
||||
@@ -0,0 +1,121 @@
|
||||
export type Notice = {
|
||||
title: string;
|
||||
message: string;
|
||||
severity: 'info' | 'warning' | 'critical';
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
active?: boolean;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type FuelPrice = {
|
||||
fuel_type: string;
|
||||
price_per_litre: number;
|
||||
currency: string;
|
||||
last_updated: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type EventItem = {
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
start_datetime: string;
|
||||
end_datetime?: string;
|
||||
location_text?: string;
|
||||
registration_link?: string;
|
||||
status?: string;
|
||||
is_featured?: boolean;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type NewsItem = {
|
||||
title: string;
|
||||
slug: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
publish_date: string;
|
||||
status?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type DocumentItem = {
|
||||
title: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
fileUrl?: string;
|
||||
uploaded_at?: string;
|
||||
};
|
||||
|
||||
export type ContactItem = {
|
||||
name: string;
|
||||
role: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
is_public?: boolean;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export const fallbackNotices: Notice[] = [
|
||||
{
|
||||
title: 'Welcome to Swansea Airport',
|
||||
message: 'Operational notices and visitor information will appear here once Directus content is published.',
|
||||
severity: 'info',
|
||||
active: true,
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
|
||||
export const fallbackFuelPrices: FuelPrice[] = [
|
||||
{
|
||||
fuel_type: 'AVGAS',
|
||||
price_per_litre: 2.35,
|
||||
currency: 'GBP',
|
||||
last_updated: '2026-05-11',
|
||||
notes: 'Placeholder rate for the initial scaffold.',
|
||||
},
|
||||
];
|
||||
|
||||
export const fallbackEvents: EventItem[] = [
|
||||
{
|
||||
title: 'Airfield open day',
|
||||
slug: 'airfield-open-day',
|
||||
description: 'Example event to verify the listing and detail page flow.',
|
||||
start_datetime: '2026-06-14T09:00:00Z',
|
||||
end_datetime: '2026-06-14T16:00:00Z',
|
||||
location_text: 'Main apron',
|
||||
is_featured: true,
|
||||
tags: ['Public'],
|
||||
},
|
||||
];
|
||||
|
||||
export const fallbackNews: NewsItem[] = [
|
||||
{
|
||||
title: 'Site scaffolding started',
|
||||
slug: 'site-scaffolding-started',
|
||||
summary: 'The new Astro and Directus architecture has been scaffolded.',
|
||||
body: '<p>This is a starter article that proves the detail route and rich text rendering.</p>',
|
||||
publish_date: '2026-05-11',
|
||||
tags: ['Website'],
|
||||
},
|
||||
];
|
||||
|
||||
export const fallbackDocuments: DocumentItem[] = [
|
||||
{
|
||||
title: 'Pilot information pack',
|
||||
category: 'Pilots',
|
||||
description: 'Starter document entry for the documents listing.',
|
||||
uploaded_at: '2026-05-11',
|
||||
},
|
||||
];
|
||||
|
||||
export const fallbackContacts: ContactItem[] = [
|
||||
{
|
||||
name: 'Airport office',
|
||||
role: 'General enquiries',
|
||||
email: 'info@swansea-airport.wales',
|
||||
phone: '01792 687 042',
|
||||
is_public: true,
|
||||
order: 1,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
export function formatDate(value?: string) {
|
||||
if (!value) {
|
||||
return 'To be confirmed';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string) {
|
||||
if (!value) {
|
||||
return 'To be confirmed';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const site = {
|
||||
name: 'Swansea Airport',
|
||||
tagline: 'The gateway to Gower and Swansea',
|
||||
address: 'Swansea Airport, Fairwood Common, Swansea, SA2 7JU',
|
||||
phone: '01792 687 042',
|
||||
openingHours: '7 days 0900-1600',
|
||||
licensedHours: 'Friday to Sunday 0900-1700',
|
||||
runwayFacts: [
|
||||
'Runway 04/22 concrete 1351m x 30m licensed',
|
||||
'Runway 10/28 asphalt 857m x 18m unlicensed',
|
||||
'Category 1 RFFS',
|
||||
'Air Ground Service 119.705',
|
||||
],
|
||||
navigation: [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Visiting Pilots', href: '/visiting-pilots/' },
|
||||
{ label: 'Procedures', href: '/procedures-safety-noise-abatement/' },
|
||||
{ label: 'Events', href: '/events/' },
|
||||
{ label: 'News', href: '/news/' },
|
||||
{ label: 'Documents', href: '/documents/' },
|
||||
{ label: 'Contact', href: '/contact/' },
|
||||
],
|
||||
};
|
||||
|
||||
export const homepageHighlights = [
|
||||
{
|
||||
title: 'Operational clarity first',
|
||||
body: 'Notices, fuel, events, and the latest news are kept at the top of the page for fast scanning on mobile.',
|
||||
},
|
||||
{
|
||||
title: 'Layout controlled in code',
|
||||
body: 'Astro owns the structure, spacing, and information hierarchy so the CMS only supplies content.',
|
||||
},
|
||||
{
|
||||
title: 'Static output by default',
|
||||
body: 'The public site remains available from built files even if Directus is offline after publication.',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import ContactList from '../components/ContactList.astro';
|
||||
import { getContacts } from '../lib/directus';
|
||||
import { site } from '../lib/site';
|
||||
|
||||
const contacts = await getContacts();
|
||||
---
|
||||
|
||||
<BaseLayout title="Contact" description="How to reach Swansea Airport and its public contacts.">
|
||||
<div class="container stack">
|
||||
<section class="prose">
|
||||
<p class="eyebrow">Contact</p>
|
||||
<h1 class="section-title">Reach the airport team</h1>
|
||||
<p>{site.address}</p>
|
||||
<p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p>
|
||||
</section>
|
||||
|
||||
<ContactList contacts={contacts} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import DocumentList from '../components/DocumentList.astro';
|
||||
import { getDocuments } from '../lib/directus';
|
||||
|
||||
const documents = await getDocuments();
|
||||
---
|
||||
|
||||
<BaseLayout title="Documents" description="Airport documents and downloads for pilots, visitors, and staff.">
|
||||
<div class="container">
|
||||
<DocumentList documents={documents} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getEvents } from '../../lib/directus';
|
||||
import { formatDateTime } from '../../lib/format';
|
||||
|
||||
type EventItem = Awaited<ReturnType<typeof getEvents>>[number];
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const events = await getEvents();
|
||||
return events.map((item) => ({ params: { slug: item.slug }, props: { item } }));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: EventItem };
|
||||
---
|
||||
|
||||
<BaseLayout title={item.title} description={item.description}>
|
||||
<article class="container prose">
|
||||
<p class="meta">{formatDateTime(item.start_datetime)}</p>
|
||||
<h1 class="section-title">{item.title}</h1>
|
||||
<p>{item.description}</p>
|
||||
{item.location_text && <p><strong>Location:</strong> {item.location_text}</p>}
|
||||
{item.registration_link && <p><a class="button primary" href={item.registration_link}>Register</a></p>}
|
||||
</article>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import EventsList from '../../components/EventsList.astro';
|
||||
import { getEvents } from '../../lib/directus';
|
||||
|
||||
const events = (await getEvents()).sort((left, right) => new Date(left.start_datetime).getTime() - new Date(right.start_datetime).getTime());
|
||||
---
|
||||
|
||||
<BaseLayout title="Events" description="Airport events and flying opportunities.">
|
||||
<div class="container">
|
||||
<EventsList events={events} title="Events listing" description="Scannable listings for public and operational events." />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import NoticeBanner from '../components/NoticeBanner.astro';
|
||||
import FuelPricesWidget from '../components/FuelPricesWidget.astro';
|
||||
import EventsList from '../components/EventsList.astro';
|
||||
import NewsFeed from '../components/NewsFeed.astro';
|
||||
import { homepageHighlights, site } from '../lib/site';
|
||||
import { getEvents, getFuelPrices, getNews, getNotices } from '../lib/directus';
|
||||
|
||||
const [notices, fuelPrices, events, news] = await Promise.all([
|
||||
getNotices(),
|
||||
getFuelPrices(),
|
||||
getEvents(),
|
||||
getNews(),
|
||||
]);
|
||||
|
||||
const featuredEvents = events.filter((event) => event.is_featured).slice(0, 3);
|
||||
const latestNews = news.slice(0, 3);
|
||||
---
|
||||
|
||||
<BaseLayout title="Home" description="Fast, clear airfield information for pilots and visitors.">
|
||||
<section class="hero">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-panel">
|
||||
<p class="eyebrow">Operational website</p>
|
||||
<h1 class="hero-title">Clear airfield information, built for speed.</h1>
|
||||
<p class="hero-copy">
|
||||
Visitor-critical information stays visible up front, while Directus supplies notices, news, events, and fuel pricing at build time.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="button primary" href="/visiting-pilots/">Visiting pilots</a>
|
||||
<a class="button secondary" href="/procedures-safety-noise-abatement/">Procedures and safety</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="hero-rail">
|
||||
<div class="surface">
|
||||
<p class="eyebrow">Today at the airfield</p>
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<strong>{site.openingHours}</strong>
|
||||
<span class="muted">Opening hours</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>{site.licensedHours}</strong>
|
||||
<span class="muted">Licensed hours</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surface">
|
||||
<p class="eyebrow">Runway overview</p>
|
||||
<ul class="compact-list">
|
||||
{site.runwayFacts.map((fact) => (
|
||||
<li>{fact}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container stack">
|
||||
<NoticeBanner notices={notices} />
|
||||
<FuelPricesWidget fuelPrices={fuelPrices} />
|
||||
|
||||
<section>
|
||||
<div class="cards-grid">
|
||||
{homepageHighlights.map((item) => (
|
||||
<article class="card">
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EventsList events={featuredEvents} title="Upcoming events" description="Featured events are surfaced here first for quick scanning." />
|
||||
<NewsFeed news={latestNews} />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getNews } from '../../lib/directus';
|
||||
import { formatDate } from '../../lib/format';
|
||||
|
||||
type NewsItem = Awaited<ReturnType<typeof getNews>>[number];
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const news = await getNews();
|
||||
return news.map((item) => ({ params: { slug: item.slug }, props: { item } }));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: NewsItem };
|
||||
---
|
||||
|
||||
<BaseLayout title={item.title} description={item.summary}>
|
||||
<article class="container prose">
|
||||
<p class="meta">Published {formatDate(item.publish_date)}</p>
|
||||
<h1 class="section-title">{item.title}</h1>
|
||||
<p>{item.summary}</p>
|
||||
<div set:html={item.body} />
|
||||
</article>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import NewsFeed from '../../components/NewsFeed.astro';
|
||||
import { getNews } from '../../lib/directus';
|
||||
|
||||
const news = (await getNews()).sort((left, right) => new Date(right.publish_date).getTime() - new Date(left.publish_date).getTime());
|
||||
---
|
||||
|
||||
<BaseLayout title="News" description="Latest airport news and operational updates.">
|
||||
<div class="container">
|
||||
<NewsFeed news={news} title="All news" description="Read the latest public updates and operational announcements." />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Procedures, Safety and Noise Abatement" description="Operational procedures, safety notes, and noise-sensitive guidance.">
|
||||
<div class="container prose">
|
||||
<p class="eyebrow">Operations</p>
|
||||
<h1 class="section-title">Procedures, safety, and noise abatement</h1>
|
||||
<p>
|
||||
This page is intentionally text-led and easy to scan. It is controlled by Astro so the structure stays stable even as the content evolves.
|
||||
</p>
|
||||
|
||||
<h2>Safety priorities</h2>
|
||||
<div class="cards-grid">
|
||||
<article class="card">
|
||||
<h3>Brief before flight</h3>
|
||||
<p>Surface the checklist items pilots need most, without burying them under visual clutter.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Check current notices</h3>
|
||||
<p>Operational notices should be reviewed before taxi, because the homepage is fed by the same notices collection.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Respect local noise guidance</h3>
|
||||
<p>Noise abatement text can be expanded in Directus while the page structure stays fixed in code.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { site } from '../lib/site';
|
||||
---
|
||||
|
||||
<BaseLayout title="Visiting Pilots" description="Essential information for pilots planning a visit to Swansea Airport.">
|
||||
<div class="container prose">
|
||||
<p class="eyebrow">Visiting pilots</p>
|
||||
<h1 class="section-title">Essential information for arriving aircraft</h1>
|
||||
<p>
|
||||
This page is structured for quick pre-flight checks. It keeps operational details in fixed Astro components and leaves content updates to Directus.
|
||||
</p>
|
||||
|
||||
<h2>Airport facts</h2>
|
||||
<ul>
|
||||
{site.runwayFacts.map((fact) => <li>{fact}</li>)}
|
||||
</ul>
|
||||
|
||||
<h2>Arrival essentials</h2>
|
||||
<div class="cards-grid">
|
||||
<article class="card">
|
||||
<h3>PPR</h3>
|
||||
<p>Pre-landing fogging is presented prominently here and can be linked to the relevant Directus content or booking workflow.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Book out</h3>
|
||||
<p>Departure procedures and any required outbound reporting remain in the same controlled page structure.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Fuel and services</h3>
|
||||
<p>Fuel prices are shown on the homepage and can be reused here with the same data source.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,386 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f1e8;
|
||||
--panel: #fffaf0;
|
||||
--panel-strong: #ffffff;
|
||||
--text: #12212c;
|
||||
--muted: #5b6570;
|
||||
--brand: #11384d;
|
||||
--brand-2: #8d5f2d;
|
||||
--line: rgba(18, 33, 44, 0.12);
|
||||
--critical: #8e1f1b;
|
||||
--warning: #b36a09;
|
||||
--info: #245b7d;
|
||||
--shadow: 0 18px 40px rgba(18, 33, 44, 0.08);
|
||||
--radius: 1.1rem;
|
||||
--radius-sm: 0.8rem;
|
||||
--content: 1120px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Trebuchet MS", "Lucida Grande", "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(141, 95, 45, 0.16), transparent 35%),
|
||||
radial-gradient(circle at right 20%, rgba(18, 57, 77, 0.12), transparent 30%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.16em;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(calc(100% - 2rem), var(--content));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.navshell,
|
||||
.site-footer {
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(245, 241, 232, 0.84);
|
||||
}
|
||||
|
||||
.topbar-inner,
|
||||
.nav-inner,
|
||||
.footer-grid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
align-items: center;
|
||||
padding: 0.85rem 0;
|
||||
}
|
||||
|
||||
.topbar-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem 1.2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.navshell {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 250, 240, 0.88);
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9rem 1.2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main {
|
||||
padding-block: 1.5rem 4rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding-block: 1rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding-block: 1rem 2rem;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.35fr 0.95fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.card,
|
||||
.surface,
|
||||
.notice {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 240, 0.94));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
padding: 1.6rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: clamp(2.2rem, 5vw, 4.6rem);
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 62ch;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1.3rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.8rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: transparent;
|
||||
border-color: rgba(18, 57, 77, 0.18);
|
||||
}
|
||||
|
||||
.hero-rail {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid,
|
||||
.panel-grid,
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat,
|
||||
.card,
|
||||
.surface,
|
||||
.notice {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--brand-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topline,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: clamp(1.4rem, 2.8vw, 2.2rem);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--muted);
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.notice-list,
|
||||
.compact-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
border-left: 0.5rem solid var(--info);
|
||||
}
|
||||
|
||||
.notice.warning {
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
|
||||
.notice.critical {
|
||||
border-left-color: var(--critical);
|
||||
}
|
||||
|
||||
.pill,
|
||||
.tag,
|
||||
.meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(18, 57, 77, 0.08);
|
||||
color: var(--brand);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: rgba(141, 95, 45, 0.1);
|
||||
color: var(--brand-2);
|
||||
}
|
||||
|
||||
.meta {
|
||||
background: rgba(18, 33, 44, 0.06);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.card h3,
|
||||
.card h4,
|
||||
.surface h3,
|
||||
.surface h4,
|
||||
.notice h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card p:last-child,
|
||||
.surface p:last-child,
|
||||
.notice p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.split-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(18, 33, 44, 0.93);
|
||||
color: rgba(255, 250, 240, 0.88);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.site-footer a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.site-footer .eyebrow,
|
||||
.site-footer .muted {
|
||||
color: rgba(255, 250, 240, 0.66);
|
||||
}
|
||||
|
||||
.prose {
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.prose h2,
|
||||
.prose h3 {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero-grid,
|
||||
.split-grid,
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar-inner,
|
||||
.nav-inner,
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: 0.7rem 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "astro",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"allowJs": false,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user