@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user