And now with files
This commit is contained in:
@@ -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,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 />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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':
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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/' },
|
||||
|
||||
@@ -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,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,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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user