And now with files

This commit is contained in:
2026-06-20 13:36:19 -04:00
parent 6bc7f132e9
commit 17b2a5d835
18 changed files with 631 additions and 161 deletions
+1
View File
@@ -19,6 +19,7 @@ Production-ready airfield website stack built with Astro, Directus, PostgreSQL,
- All deploy-time variables live in `.env`.
- `PUBLIC_PPR_API_BASE` controls the browser-side PPR API base URL; PPR and drone request endpoints are derived from it at build/dev-server startup.
- `DIRECTUS_HOMEPAGE_BANNER_FOLDER` names the Directus file folder used for rotating homepage banner images. If the folder is missing or empty, the site falls back to `/images/banner.png`.
- 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.
+1
View File
@@ -74,6 +74,7 @@ services:
PUBLIC_PPR_API_BASE: ${PUBLIC_PPR_API_BASE}
DIRECTUS_URL: ${DIRECTUS_URL}
DIRECTUS_PUBLIC_URL: ${DIRECTUS_PUBLIC_URL}
DIRECTUS_HOMEPAGE_BANNER_FOLDER: ${DIRECTUS_HOMEPAGE_BANNER_FOLDER}
DIRECTUS_PORT: ${DIRECTUS_PORT}
DIRECTUS_ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN}
depends_on:
+78
View File
@@ -0,0 +1,78 @@
---
import type { HomepageBannerImage } from '../lib/fallback-data';
type Props = {
images: HomepageBannerImage[];
};
const { images } = Astro.props;
const slides = images.length > 0 ? images : [{ src: '/images/banner.png', alt: 'Swansea Airport banner' }];
---
<div class="banner-rotator" data-banner-rotator>
{slides.map((image, index) => (
<img
class:list={['banner-slide', { active: index === 0 }]}
src={image.src}
alt={image.alt}
loading={index === 0 ? 'eager' : 'lazy'}
decoding="async"
data-banner-slide
/>
))}
</div>
{slides.length > 1 && (
<script>
(() => {
const root = document.querySelector('[data-banner-rotator]');
if (!root || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const slides = Array.from(root.querySelectorAll('[data-banner-slide]'));
if (slides.length < 2) return;
let currentIndex = 0;
window.setInterval(() => {
slides[currentIndex].classList.remove('active');
currentIndex = (currentIndex + 1) % slides.length;
slides[currentIndex].classList.add('active');
}, 3500);
})();
</script>
)}
<style>
.banner-rotator {
position: relative;
width: 100%;
min-height: clamp(9rem, 21vw, 16rem);
overflow: hidden;
isolation: isolate;
background: #dcecff;
}
.banner-slide {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
opacity: 0;
transform: scale(1.035);
transition:
opacity 1.1s ease,
transform 7s ease;
}
.banner-slide.active {
opacity: 1;
transform: scale(1);
}
@media (prefers-reduced-motion: reduce) {
.banner-slide {
transition: none;
}
}
</style>
+13 -12
View File
@@ -13,7 +13,9 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
Tell us when and where you plan to fly. Pick the operating location on the map or enter it
manually, then click Submit. We will review your request and contact you if we need any further information.
</p>
<p>
If your flight is outside the FRZ, you do not need to submit a request, but it would still be appreciated if it is in the vicinity. Please follow the <a href="/about/drones/">local drone guidance</a> and fly safely.
</p>
</div>
@@ -30,6 +32,16 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
<input type="text" id="operator-id" name="operator_id" autocomplete="off" required />
</div>
<div class="drone-field">
<label for="flyer-name">Flyer Name <span aria-hidden="true">*</span></label>
<input type="text" id="flyer-name" name="flyer_name" autocomplete="name" required />
</div>
<div class="drone-field">
<label for="flyer-id">Flyer ID <span aria-hidden="true">*</span></label>
<input type="text" id="flyer-id" name="flyer_id" autocomplete="off" required />
</div>
<div class="drone-field">
<label for="flight-date">Date of Flight <span aria-hidden="true">*</span></label>
<input type="date" id="flight-date" name="flight_date" required />
@@ -93,17 +105,6 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
<input type="hidden" id="location-inside-frz" name="location_inside_frz" />
</div>
<div class="drone-field">
<label for="flyer-name">Flyer Name <span aria-hidden="true">*</span></label>
<input type="text" id="flyer-name" name="flyer_name" autocomplete="name" required />
</div>
<div class="drone-field">
<label for="flyer-id">Flyer ID <span aria-hidden="true">*</span></label>
<input type="text" id="flyer-id" name="flyer_id" autocomplete="off" required />
</div>
<div class="drone-field">
<label for="email">Email Address <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" autocomplete="email" required />
+103
View File
@@ -0,0 +1,103 @@
---
import SectionHeading from './SectionHeading.astro';
const facebookUrl = 'https://www.facebook.com/swanseaairportofficial';
---
<section class="facebook-section">
<SectionHeading eyebrow="Social" title="Follow Swansea Airport" />
<a class="facebook-card" href={facebookUrl} target="_blank" rel="noopener noreferrer">
<span class="facebook-mark" aria-hidden="true">f</span>
<span class="facebook-copy">
<strong>Facebook updates</strong>
<span>Follow the official page for day-to-day airport news, photos, events, and community updates.</span>
</span>
<span class="facebook-action">Open Facebook</span>
</a>
</section>
<style>
.facebook-section {
display: grid;
gap: 1rem;
}
.facebook-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 1rem;
align-items: center;
padding: 1.1rem;
border: 1px solid rgba(11, 79, 122, 0.16);
border-radius: var(--radius);
background:
linear-gradient(90deg, rgba(24, 119, 242, 0.12), transparent 42%),
rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow);
color: inherit;
text-decoration: none;
transition:
border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
.facebook-card:hover {
border-color: rgba(24, 119, 242, 0.32);
color: inherit;
box-shadow: 0 22px 48px rgba(16, 34, 51, 0.14);
transform: translateY(-2px);
}
.facebook-mark {
display: grid;
place-items: center;
width: 3.25rem;
aspect-ratio: 1;
border-radius: 50%;
background: #1877f2;
color: white;
font-family: Arial, sans-serif;
font-size: 2rem;
font-weight: 800;
line-height: 1;
}
.facebook-copy {
display: grid;
gap: 0.25rem;
}
.facebook-copy strong {
font-size: 1.1rem;
color: var(--text);
}
.facebook-copy span {
color: var(--muted);
}
.facebook-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.6rem;
padding: 0.65rem 0.9rem;
border-radius: 999px;
background: var(--brand);
color: white;
font-weight: 800;
white-space: nowrap;
}
@media (max-width: 640px) {
.facebook-card {
grid-template-columns: auto minmax(0, 1fr);
}
.facebook-action {
grid-column: 1 / -1;
width: fit-content;
}
}
</style>
+68 -11
View File
@@ -12,15 +12,27 @@ function formatFuelPrice(value: unknown): string {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : '0.00';
}
function formatVatLabel(value: unknown): string {
return value === true || value === 1 || value === '1' || value === 'true' ? 'Inc VAT' : 'Ex VAT';
}
---
<section>
<SectionHeading eyebrow="Fuel" title="Current prices" description="A simple, mobile-friendly snapshot of active prices." />
<SectionHeading title="Fuel at Swansea" />
<div class="cards-grid fuel-cards-grid">
{fuelPrices.map((fuel) => (
<article class="card fuel-card">
<p class="pill fuel-type-pill">{fuel.fuel_type}</p>
<h3 class="fuel-price">{fuel.currency} {formatFuelPrice(fuel.price_per_litre)} <span>/ litre</span></h3>
<div class="fuel-row">
<p class="fuel-type">{fuel.fuel_type}</p>
<div class="fuel-price-group">
<h3 class="fuel-price">
{fuel.currency} {formatFuelPrice(fuel.price_per_litre)}
<span>/ litre</span>
<span class="vat-label">{formatVatLabel(fuel.inc_vat)}</span>
</h3>
</div>
</div>
{fuel.notes && <p>{fuel.notes}</p>}
</article>
))}
@@ -34,22 +46,40 @@ function formatFuelPrice(value: unknown): string {
.fuel-card {
border: 1px solid rgba(11, 79, 122, 0.18);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(232, 243, 253, 0.94));
background:
linear-gradient(90deg, rgba(11, 79, 122, 0.11), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(232, 243, 253, 0.94));
}
.fuel-type-pill {
width: fit-content;
margin: 0 0 0.7rem;
padding: 0.35rem 0.85rem;
font-size: 0.83rem;
letter-spacing: 0.08em;
.fuel-row {
display: grid;
grid-template-columns: minmax(7rem, 0.75fr) minmax(0, 1.25fr);
gap: 1rem;
align-items: center;
}
.fuel-type {
margin: 0;
font-size: clamp(1.45rem, 2.5vw, 2rem);
line-height: 1;
text-transform: uppercase;
font-weight: 800;
background: linear-gradient(180deg, rgba(11, 79, 122, 0.17), rgba(29, 118, 184, 0.15));
color: var(--brand);
overflow-wrap: anywhere;
}
.fuel-price-group {
display: grid;
justify-items: end;
text-align: right;
}
.fuel-price {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: baseline;
gap: 0.42rem;
margin: 0;
font-size: clamp(1.7rem, 3vw, 2.25rem);
line-height: 1.1;
@@ -61,4 +91,31 @@ function formatFuelPrice(value: unknown): string {
font-weight: 600;
color: var(--muted);
}
.vat-label {
display: inline-flex;
align-items: center;
padding: 0.28rem 0.7rem;
border-radius: 999px;
background: rgba(11, 79, 122, 0.1);
color: var(--brand);
font-size: 0.82rem;
font-weight: 800;
}
@media (max-width: 560px) {
.fuel-row {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.fuel-price-group {
justify-items: start;
text-align: left;
}
.fuel-price {
justify-content: flex-start;
}
}
</style>
+2 -2
View File
@@ -12,8 +12,8 @@ const pprApiBase = configuredApiBase.replace(/\/$/, '');
the airport will contact you if additional information is required.
</p>
<p class="ppr-note">
This form is under test. If you have any issues, email
<a href="mailto:james.pattinson@sasalliance.org">james.pattinson@sasalliance.org</a>.
If you have any issues, email
<a href="mailto:james.pattinson@sasalliance.org">james.pattinson@sasalliance.org</a> - our resident IT nerd!
</p>
</div>
+10 -13
View File
@@ -1,5 +1,5 @@
---
import { homepageHighlights, site } from '../lib/site';
import { site } from '../lib/site';
import '../styles/global.css';
type Props = {
@@ -77,22 +77,19 @@ const pageTitle = title ? `${title} · ${site.name}` : site.name;
<div class="container footer-grid">
<section>
<img class="footer-logo" src="/images/swansea.webp" alt="Swansea Airport logo" loading="lazy" />
<p class="eyebrow">Airport essentials</p>
<p class="eyebrow">Address</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 class="eyebrow">Phone</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>
<p class="eyebrow">Email</p>
<p><a href="mailto:info@swansea-airport.wales">info@swansea-airport.wales</a></p>
</section>
<section>
<p class="eyebrow">Contact</p>
<p>For public contacts and general enquiries, use the contact page.</p>
<p><a class="footer-link" href="/contact/">Contact Swansea Airport</a></p>
</section>
</div>
</footer>
+86 -7
View File
@@ -1,14 +1,17 @@
import {
fallbackCafePageImages,
fallbackContacts,
fallbackDocuments,
fallbackEvents,
fallbackFuelPrices,
fallbackHomepageBannerImages,
fallbackNews,
fallbackNotices,
type ContactItem,
type DocumentItem,
type EventItem,
type FuelPrice,
type HomepageBannerImage,
type NewsItem,
type Notice,
} from './fallback-data';
@@ -19,7 +22,7 @@ const defaultSortByCollection: Partial<Record<CollectionName, string>> = {
news: '-publish_date',
events: '-start_datetime',
notices: '-priority',
fuel_prices: '-last_updated',
fuel_prices: 'fuel_type',
documents: '-uploaded_at',
contacts: 'order',
};
@@ -29,7 +32,30 @@ declare const process: {
};
const directusUrl = process.env.DIRECTUS_URL ?? 'http://directus:8055';
const directusPublicUrl = process.env.DIRECTUS_PUBLIC_URL && !process.env.DIRECTUS_PUBLIC_URL.includes('example.com')
? process.env.DIRECTUS_PUBLIC_URL
: directusUrl;
const directusToken = process.env.DIRECTUS_ADMIN_TOKEN;
const homepageBannerFolder = process.env.DIRECTUS_HOMEPAGE_BANNER_FOLDER ?? 'homepage-banners';
const cafePageFolder = process.env.DIRECTUS_CAFE_PAGE_FOLDER ?? 'cafe-page';
type DirectusFolder = {
id: string;
name: string;
};
type DirectusFile = {
id: string;
title?: string;
description?: string;
filename_download?: string;
type?: string;
};
function directusHeaders(): Record<string, string> | undefined {
if (!directusToken) return undefined;
return { Authorization: `Bearer ${directusToken}` };
}
async function readCollection<T>(collection: CollectionName): Promise<T[]> {
const endpoint = new URL(`/items/${collection}`, directusUrl);
@@ -40,13 +66,8 @@ async function readCollection<T>(collection: CollectionName): Promise<T[]> {
}
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,
headers: directusHeaders(),
});
if (!response.ok) {
throw new Error(`Directus responded with ${response.status}`);
@@ -59,6 +80,64 @@ async function readCollection<T>(collection: CollectionName): Promise<T[]> {
}
}
async function readDirectusEndpoint<T>(endpoint: URL): Promise<T[]> {
let response = await fetch(endpoint, {
headers: directusHeaders(),
});
if (response.status === 403 && directusToken) {
response = await fetch(endpoint);
}
if (!response.ok) {
throw new Error(`Directus responded with ${response.status}`);
}
const payload = (await response.json()) as { data?: T[] };
return payload.data ?? [];
}
function resolveDirectusAssetUrl(fileId: string): string {
return new URL(`/assets/${fileId}`, directusPublicUrl).toString();
}
async function findFolderByName(name: string): Promise<DirectusFolder | null> {
const endpoint = new URL('/folders', directusUrl);
endpoint.searchParams.set('limit', '1');
endpoint.searchParams.set('filter[name][_eq]', name);
endpoint.searchParams.set('fields', 'id,name');
const folders = await readDirectusEndpoint<DirectusFolder>(endpoint);
return folders[0] ?? null;
}
async function getImagesFromFolder(folderName: string, fallbackImages: HomepageBannerImage[]): Promise<HomepageBannerImage[]> {
try {
const folder = await findFolderByName(folderName);
if (!folder) return fallbackImages;
const endpoint = new URL('/files', directusUrl);
endpoint.searchParams.set('limit', '20');
endpoint.searchParams.set('sort', '-uploaded_on');
endpoint.searchParams.set('filter[folder][_eq]', folder.id);
endpoint.searchParams.set('filter[type][_starts_with]', 'image/');
endpoint.searchParams.set('fields', 'id,title,description,filename_download,type');
const files = await readDirectusEndpoint<DirectusFile>(endpoint);
const images = files.map((file) => ({
src: resolveDirectusAssetUrl(file.id),
alt: file.description || file.title || file.filename_download || 'Swansea Airport',
}));
return images.length > 0 ? images : fallbackImages;
} catch {
return fallbackImages;
}
}
export const getHomepageBannerImages = () => getImagesFromFolder(homepageBannerFolder, fallbackHomepageBannerImages);
export const getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages);
function fallbackFor(collection: CollectionName) {
switch (collection) {
case 'news':
+24
View File
@@ -0,0 +1,24 @@
import type { EventItem } from './fallback-data';
function getEventEndTime(event: EventItem): number {
const value = event.end_datetime || event.start_datetime;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
function getEventStartTime(event: EventItem): number {
const timestamp = new Date(event.start_datetime).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function isUpcomingEvent(event: EventItem, now = new Date()): boolean {
return getEventEndTime(event) >= now.getTime();
}
export function sortEventsByStartDate(events: EventItem[]): EventItem[] {
return [...events].sort((left, right) => getEventStartTime(left) - getEventStartTime(right));
}
export function getUpcomingEvents(events: EventItem[], now = new Date()): EventItem[] {
return sortEventsByStartDate(events.filter((event) => isUpcomingEvent(event, now)));
}
+25
View File
@@ -12,6 +12,7 @@ export type FuelPrice = {
fuel_type: string;
price_per_litre: number;
currency: string;
inc_vat?: boolean;
last_updated: string;
notes?: string;
};
@@ -58,6 +59,11 @@ export type ContactItem = {
order?: number;
};
export type HomepageBannerImage = {
src: string;
alt: string;
};
export const fallbackNotices: Notice[] = [
{
title: 'Welcome to Swansea Airport',
@@ -73,11 +79,23 @@ export const fallbackFuelPrices: FuelPrice[] = [
fuel_type: 'AVGAS',
price_per_litre: 2.35,
currency: 'GBP',
inc_vat: true,
last_updated: '2026-05-11',
notes: 'Placeholder rate for the initial scaffold.',
},
];
export const fallbackCafePageImages: HomepageBannerImage[] = [
{
src: '/images/cessna.jpg',
alt: 'Aircraft on the apron at Swansea Airport',
},
{
src: '/images/banner.png',
alt: 'Swansea Airport',
},
];
export const fallbackEvents: EventItem[] = [
{
title: 'Airfield open day',
@@ -122,3 +140,10 @@ export const fallbackContacts: ContactItem[] = [
order: 1,
},
];
export const fallbackHomepageBannerImages: HomepageBannerImage[] = [
{
src: '/images/banner.png',
alt: 'Swansea Airport banner',
},
];
+1
View File
@@ -20,6 +20,7 @@ export const site = {
label: 'About',
href: '/about/',
children: [
{ label: 'Cafe', href: '/about/cafe/' },
{ label: 'History', href: '/about/history/' },
{ label: 'Drones', href: '/about/drones/' },
{ label: 'Fees and Charges', href: '/about/fees-and-charges/' },
+100
View File
@@ -0,0 +1,100 @@
---
import BannerRotator from '../../components/BannerRotator.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCafePageImages } from '../../lib/directus';
const cafeImages = await getCafePageImages();
---
<BaseLayout title="Cafe" description="A friendly, dog-friendly cafe at Swansea Airport.">
<section class="container cafe-page">
<div class="cafe-copy">
<h1 class="section-title">The Whirlybird Cafe</h1>
<p>
The cafe at Swansea Airport is a relaxed, welcoming spot for pilots, visitors, walkers,
families, and anyone passing through the airfield. It is friendly, informal, and dog
friendly, making it an easy place to stop for a drink, a bite to eat, and a view of the
airfield.
</p>
<p>
Whether you are meeting friends, watching the aircraft, or taking a break while exploring
Fairwood Common and the Gower, the cafe offers a comfortable place to pause and enjoy the
airport atmosphere.
</p>
<p>
On Thursday to Sunday evenings, the cafe transforms into Thai Bach Express, serving Thai
food for delivery and dining in.
<a href="https://thaibach.co.uk/" target="_blank" rel="noopener noreferrer">Visit Thai Bach</a>.
</p>
<div class="social-follow">
<div class="cta-row">
<a class="button primary social-button" href="https://www.facebook.com/SwanseaAirportCafe" target="_blank" rel="noopener noreferrer">
<span class="facebook-mark" aria-hidden="true">f</span>
Whirlybird Cafe on Facebook
</a>
<a class="button primary social-button" href="https://www.facebook.com/p/Thai-Bach-Express-61560037651153/" target="_blank" rel="noopener noreferrer">
<span class="facebook-mark" aria-hidden="true">f</span>
Thai Bach Express on Facebook
</a>
</div>
</div>
</div>
<div class="cafe-gallery" aria-label="Cafe photo gallery">
<BannerRotator images={cafeImages} />
</div>
</section>
</BaseLayout>
<style>
.cafe-page {
display: grid;
gap: 1.25rem;
}
.cafe-copy {
max-width: 72ch;
}
.cafe-gallery {
overflow: hidden;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.cafe-gallery :global(.banner-rotator) {
min-height: clamp(16rem, 42vw, 30rem);
}
.social-follow {
display: grid;
gap: 0.7rem;
}
.social-follow p {
margin: 0;
color: var(--muted);
font-weight: 700;
}
.social-button {
gap: 0.55rem;
}
.facebook-mark {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.45rem;
height: 1.45rem;
border-radius: 50%;
background: #1877f2;
color: white;
font-family: Arial, sans-serif;
font-size: 1rem;
font-weight: 800;
line-height: 1;
transform: translateY(-0.02em);
}
</style>
+4 -6
View File
@@ -4,14 +4,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
<BaseLayout title="About" description="Background and community information for Swansea Airport.">
<section class="container prose">
<p class="eyebrow">About</p>
<h1 class="section-title">About</h1>
<p>
This section provides information for local communities and visitors. Choose a topic
below to view placeholder content that will be replaced with final approved copy.
</p>
<div class="cards-grid">
<article class="card">
<h3><a href="/about/cafe/">Cafe</a></h3>
<p>A friendly, dog-friendly cafe for visitors, walkers, pilots, and local families.</p>
</article>
<article class="card">
<h3><a href="/about/history/">History</a></h3>
<p>The story of Swansea Airport, formerly RAF Fairwood Common.</p>
+2 -1
View File
@@ -2,8 +2,9 @@
import BaseLayout from '../../layouts/BaseLayout.astro';
import EventsList from '../../components/EventsList.astro';
import { getEvents } from '../../lib/directus';
import { getUpcomingEvents } from '../../lib/events';
const events = (await getEvents()).sort((left, right) => new Date(left.start_datetime).getTime() - new Date(right.start_datetime).getTime());
const events = getUpcomingEvents(await getEvents());
---
<BaseLayout title="Events" description="Airport events and flying opportunities.">
+38 -71
View File
@@ -1,20 +1,24 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import BannerRotator from '../components/BannerRotator.astro';
import NoticeBanner from '../components/NoticeBanner.astro';
import FuelPricesWidget from '../components/FuelPricesWidget.astro';
import EventsList from '../components/EventsList.astro';
import FacebookWidget from '../components/FacebookWidget.astro';
import NewsFeed from '../components/NewsFeed.astro';
import { homepageHighlights, site } from '../lib/site';
import { getEvents, getFuelPrices, getNews, getNotices } from '../lib/directus';
import { getUpcomingEvents } from '../lib/events';
import { homepageHighlights } from '../lib/site';
import { getEvents, getFuelPrices, getHomepageBannerImages, getNews, getNotices } from '../lib/directus';
const [notices, fuelPrices, events, news] = await Promise.all([
const [notices, fuelPrices, events, news, bannerImages] = await Promise.all([
getNotices(),
getFuelPrices(),
getEvents(),
getNews(),
getHomepageBannerImages(),
]);
const featuredEvents = events.filter((event) => event.is_featured).slice(0, 3);
const featuredEvents = getUpcomingEvents(events).filter((event) => event.is_featured).slice(0, 3);
const latestNews = news.slice(0, 3);
const businessPromos = [
@@ -50,9 +54,7 @@ const businessPromos = [
---
<BaseLayout title="Home" description="Fast, clear airfield information for pilots and visitors.">
<div class="banner-image">
<img src="/images/banner.png" alt="Swansea Airport banner" loading="eager" />
</div>
<BannerRotator images={bannerImages} />
<section class="hero">
<div class="container hero-stack">
@@ -71,35 +73,22 @@ const businessPromos = [
</div>
</div>
</section>
<div class="container stack">
<section class="container story-flow">
<article class="story-copy">
<p class="eyebrow">Flying from Swansea</p>
<h2 class="section-title">A practical base for training, touring, and quick access</h2>
<p>
Swansea Airport is set up for straightforward arrivals and departures, with a compact layout, a clear operating rhythm, and enough room to keep the focus on flying rather than logistics.
</p>
<p>
Operational notices, fuel pricing, events, and visitor guidance are presented together so people can scan what they need without digging through the page.
</p>
</article>
<figure class="story-image">
<img src="/images/cessna.jpg" alt="A Cessna aircraft on the apron at Swansea Airport" loading="lazy" />
</figure>
</section>
<FuelPricesWidget fuelPrices={fuelPrices} />
</div>
<section class="container business-promo">
<div class="section-head">
<div>
<h2 class="section-title">Experience, training, and adventure on your doorstep</h2>
</div>
</div>
<div class="business-grid">
{businessPromos.map((business) => (
<a class="business-card" href={business.href} target="_blank" rel="noopener noreferrer">
<img class="business-logo" src={business.logo} alt={business.alt} loading="lazy" />
<span class="business-logo-frame">
<img class="business-logo" src={business.logo} alt={business.alt} loading="lazy" />
</span>
<div class="business-copy">
<h3>{business.name}</h3>
<p>{business.description}</p>
@@ -110,56 +99,34 @@ const businessPromos = [
</div>
</section>
<section class="container story-flow">
<article class="story-copy">
<p class="eyebrow">Flying from Swansea</p>
<h2 class="section-title">Run by passionate volunteers</h2>
<p>
Swansea Airport is mainly run by volunteers who are passionate about aviation and the local area. The team is dedicated to providing a safe, welcoming, and enjoyable experience for pilots, visitors, and aviation enthusiasts alike.
</p>
<p>
We are always looking for new volunteers to join our team. Whether you have experience in aviation or simply a love for flying, there are many ways to get involved and help support the airport.
</p>
<div class="cta-row">
<a class="button primary" href="/about/volunteering/">Volunteer with us</a>
</div>
</article>
<figure class="story-image">
<img src="/images/cessna.jpg" alt="A Cessna aircraft on the apron at Swansea Airport" loading="lazy" />
</figure>
</section>
<div class="container stack">
<section class="operational-info">
<div class="section-head">
<div>
<p class="eyebrow">Operational information</p>
<h2 class="section-title">Essential details for today</h2>
</div>
</div>
<div class="operational-grid">
<article 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>
</article>
<article class="surface">
<p class="eyebrow">Runway overview</p>
<ul class="compact-list">
{site.runwayFacts.map((fact) => (
<li>{fact}</li>
))}
</ul>
</article>
</div>
</section>
<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} />
<FacebookWidget />
</div>
</BaseLayout>
+5 -15
View File
@@ -56,6 +56,10 @@ When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea T
and there is to be <strong>no dead-side flight within the ATZ</strong>.
</p>
<p>All joins are to be downwind or base leg joins only.</p>
<p>
<strong>Caution:</strong> Pilots should be aware of possible windshear on short final for
Runway 10, especially when surface winds are above 10 kt.
</p>
<p>
When already on frequency, parachuting will be notified by the Air/Ground service using the
following message:
@@ -126,20 +130,6 @@ When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea T
<FuelPricesWidget fuelPrices={fuelPrices} />
<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>
+70 -23
View File
@@ -89,6 +89,8 @@ img {
}
.navshell {
position: relative;
z-index: 30;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.84);
}
@@ -290,22 +292,6 @@ main {
padding-block: 1.5rem 4rem;
}
.banner-image {
width: 100%;
display: block;
overflow: hidden;
margin: 0;
background: rgba(255, 255, 255, 0.4);
}
.banner-image img {
width: 100%;
height: auto;
display: block;
max-height: 12rem;
object-fit: cover;
}
section {
padding-block: 1rem;
}
@@ -439,30 +425,76 @@ section {
}
.business-card {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: 6.5rem minmax(0, 1fr);
gap: 1rem;
grid-template-columns: minmax(8rem, 10rem) minmax(0, 1fr);
min-height: 9.25rem;
gap: 1.2rem;
align-items: center;
padding: 1rem;
padding: 1.1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.9);
text-decoration: none;
box-shadow: var(--shadow);
overflow: hidden;
transition:
border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
.business-card::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background:
linear-gradient(135deg, rgba(29, 118, 184, 0.15), transparent 38%),
linear-gradient(315deg, rgba(246, 181, 56, 0.2), transparent 34%);
opacity: 0;
transition: opacity 180ms ease;
}
.business-card:hover {
color: inherit;
transform: translateY(-1px);
border-color: rgba(29, 118, 184, 0.28);
box-shadow: 0 22px 48px rgba(16, 34, 51, 0.16);
transform: translateY(-3px);
}
.business-card:hover::before {
opacity: 1;
}
.business-logo-frame {
display: grid;
place-items: center;
min-height: 7rem;
padding: 0.85rem;
border: 1px solid rgba(29, 118, 184, 0.14);
border-radius: 0.9rem;
background:
radial-gradient(circle at 30% 18%, rgba(255, 255, 255, 0.92), transparent 38%),
linear-gradient(145deg, #ffffff 0%, #f4f9fd 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 12px 28px rgba(16, 34, 51, 0.1);
}
.business-logo {
width: 100%;
height: 100%;
max-height: 5rem;
height: 6.4rem;
object-fit: contain;
border-radius: 0;
box-shadow: none;
filter: drop-shadow(0 8px 12px rgba(16, 34, 51, 0.12));
transition: transform 180ms ease;
}
.business-card:hover .business-logo {
transform: scale(1.08);
}
.business-copy {
@@ -701,6 +733,11 @@ section {
color: inherit;
}
.footer-link {
font-weight: 800;
text-decoration-color: rgba(245, 250, 255, 0.42);
}
.footer-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -799,7 +836,17 @@ section {
}
.business-card {
grid-template-columns: 5.5rem minmax(0, 1fr);
grid-template-columns: minmax(6.8rem, 8rem) minmax(0, 1fr);
min-height: 8.5rem;
}
.business-logo-frame {
min-height: 6rem;
padding: 0.7rem;
}
.business-logo {
height: 5.4rem;
}
.topbar-inner,