Initial commit

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-11 15:55:14 -04:00
commit 290ff0bc1e
41 changed files with 7998 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.astro
.git
.env
.DS_Store
+16
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.astro
.env
.DS_Store
+7
View File
@@ -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"]
+57
View File
@@ -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
```
+10
View File
@@ -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'],
},
});
+1
View File
@@ -0,0 +1 @@
import './scripts/bootstrap-directus.mjs';
+38
View File
@@ -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`
+180
View File
@@ -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"
}
}
]
}
+89
View File
@@ -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"
}
}
}
+89
View File
@@ -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:
+5479
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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"
}
}
+129
View File
@@ -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);
});
+87
View File
@@ -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
+187
View File
@@ -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();
+389
View File
@@ -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);
});
+27
View File
@@ -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>
+32
View File
@@ -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>
+44
View File
@@ -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>
+30
View File
@@ -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>
+33
View File
@@ -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>
+31
View File
@@ -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>
+17
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
/// <reference types="astro/client" />
+74
View File
@@ -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>
+84
View File
@@ -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');
+121
View File
@@ -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,
},
];
+25
View File
@@ -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));
}
+38
View File
@@ -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.',
},
];
+21
View File
@@ -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>
+13
View File
@@ -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>
+24
View File
@@ -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>
+13
View File
@@ -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>
+80
View File
@@ -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>
+23
View File
@@ -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>
+13
View File
@@ -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>
+35
View File
@@ -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>
+386
View File
@@ -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;
}
}
+16
View File
@@ -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"]
}
}