Compare commits

..

21 Commits

Author SHA1 Message Date
jamesp 09323fbd91 Merge pull request 'Cafe opening hours' (#11) from cafe-update into main
Reviewed-on: #11
2026-06-29 16:19:37 -04:00
jamesp 6f59e7fdbc Cafe opening hours 2026-06-29 16:19:12 -04:00
jamesp 7e7fcbc458 Merge pull request 'Chocks Away page' (#10) from chocks-away into main
Reviewed-on: #10
2026-06-29 16:15:02 -04:00
jamesp a3aa683190 Chocks Away page 2026-06-29 16:14:34 -04:00
jamesp 9d7d7a8b6d Merge pull request 'Drones to AGL' (#9) from drones-agl into main
Reviewed-on: #9
2026-06-29 15:50:38 -04:00
jamesp 8fc5c1fa29 Drones to AGL 2026-06-29 06:27:22 -04:00
jamesp 4f79b4dd3c Merge pull request 'Add webcam' (#8) from webcam-apron into main
Reviewed-on: #8
2026-06-28 14:25:30 -04:00
jamesp 352fa24e6d Add webcam 2026-06-28 14:24:28 -04:00
jamesp fabd8becc5 Merge pull request 'Jun 27 last chance' (#7) from jun-27-last-chance into main
Reviewed-on: #7
2026-06-27 14:10:07 -04:00
jamesp cfd0e54f07 Doh 2026-06-27 09:45:57 -04:00
jamesp c9410cb114 Update GFC text 2026-06-27 09:45:34 -04:00
jamesp befe3e6ba3 Merge pull request 'Drones to main menu' (#6) from move-drones into main
Reviewed-on: #6
2026-06-26 08:41:25 -04:00
jamesp 63522f545a Drones to main menu 2026-06-26 08:41:04 -04:00
jamesp 85020d2dae Merge pull request 'jun-26-revisions' (#5) from jun-26-revisions into main
Reviewed-on: #5
2026-06-26 08:21:49 -04:00
jamesp 6a9daeab0d More mobile tidy 2026-06-26 08:21:24 -04:00
jamesp 730eb7758a Mobile tidy-ups 2026-06-26 08:21:09 -04:00
jamesp c43e4acc32 Mobile price list 2026-06-26 08:13:52 -04:00
jamesp 4a4279e91f Remove directus placeholder from News 2026-06-26 07:50:53 -04:00
jamesp d5d643fbcb Merge pull request 'jun-26-updates' (#4) from jun-26-updates into main
Reviewed-on: #4
2026-06-26 07:43:37 -04:00
jamesp 002ba4047d Menu improvement on mobile 2026-06-26 07:42:51 -04:00
jamesp 18a9b247c4 banner and fuel updates 2026-06-26 07:38:46 -04:00
19 changed files with 813 additions and 79 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

+24 -19
View File
@@ -26,30 +26,35 @@ const slides = images.length > 0 ? images : [{ src: '/images/banner.png', alt: '
{slides.length > 1 && (
<script>
(() => {
const root = document.querySelector('[data-banner-rotator]');
if (!root || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const slides = Array.from(root.querySelectorAll('[data-banner-slide]'));
if (slides.length < 2) return;
const roots = Array.from(document.querySelectorAll('[data-banner-rotator]:not([data-banner-rotator-ready])'));
if (root.getAttribute('data-randomize-after-first') === 'true') {
const firstSlide = slides[0];
const restSlides = slides.slice(1);
roots.forEach((root) => {
root.setAttribute('data-banner-rotator-ready', 'true');
for (let index = restSlides.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
[restSlides[index], restSlides[swapIndex]] = [restSlides[swapIndex], restSlides[index]];
const slides = Array.from(root.querySelectorAll('[data-banner-slide]'));
if (slides.length < 2) return;
if (root.getAttribute('data-randomize-after-first') === 'true') {
const firstSlide = slides[0];
const restSlides = slides.slice(1);
for (let index = restSlides.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
[restSlides[index], restSlides[swapIndex]] = [restSlides[swapIndex], restSlides[index]];
}
slides.splice(0, slides.length, firstSlide, ...restSlides);
}
slides.splice(0, slides.length, firstSlide, ...restSlides);
}
let currentIndex = 0;
window.setInterval(() => {
slides[currentIndex].classList.remove('active');
currentIndex = (currentIndex + 1) % slides.length;
slides[currentIndex].classList.add('active');
}, 3500);
let currentIndex = 0;
window.setInterval(() => {
slides[currentIndex].classList.remove('active');
currentIndex = (currentIndex + 1) % slides.length;
slides[currentIndex].classList.add('active');
}, 3500);
});
})();
</script>
)}
+1 -1
View File
@@ -41,8 +41,8 @@ const contactRequestEndpoint = `${pprApiBase}/contact-requests/public`;
<select id="contact-enquiry-type" name="enquiry_type" required>
<option value="">Select enquiry type</option>
<option value="general">General enquiry</option>
<option value="pilot">Pilot or visiting aircraft / Fuel</option>
<option value="aviation_business">Aviation business / basing</option>
<option value="pilot">Pilot or visiting aircraft</option>
<option value="events">Events and visits</option>
<option value="community">Community or local resident</option>
</select>
+3 -3
View File
@@ -62,8 +62,8 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
</div>
<div class="drone-field">
<label for="max-elevation">Maximum Elevation, feet AMSL <span aria-hidden="true">*</span></label>
<input type="number" id="max-elevation" name="maximum_elevation_ft_amsl" min="0" step="1" inputmode="numeric" required />
<label for="max-elevation">Max height above ground in feet <span aria-hidden="true">*</span></label>
<input type="number" id="max-elevation" name="maximum_elevation_ft_agl" min="0" step="1" inputmode="numeric" required />
</div>
<div class="drone-field drone-full">
@@ -407,7 +407,7 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
if (!fieldValue) return;
const normalizedValue = key === 'operator_id' || key === 'flyer_id' ? fieldValue.toUpperCase() : fieldValue;
if (key === 'maximum_elevation_ft_amsl') {
if (key === 'maximum_elevation_ft_agl') {
data[key] = Number.parseInt(normalizedValue, 10);
} else if (key === 'location_latitude' || key === 'location_longitude') {
data[key] = Number.parseFloat(normalizedValue);
+83 -2
View File
@@ -3,10 +3,14 @@ import SectionHeading from './SectionHeading.astro';
import type { FuelPrice } from '../lib/fallback-data';
type Props = {
contactHref?: string;
fuelPrices: FuelPrice[];
moreInfoHref?: string;
sectionId?: string;
serviceNotes?: string[];
};
const { fuelPrices } = Astro.props as Props;
const { contactHref, fuelPrices, moreInfoHref, sectionId, serviceNotes = [] } = Astro.props as Props;
function formatFuelPrice(value: unknown): string {
const numeric = Number(value);
@@ -18,8 +22,16 @@ function formatVatLabel(value: unknown): string {
}
---
<section>
<section id={sectionId}>
<SectionHeading title="Fuel at Swansea" />
{serviceNotes.length > 0 && (
<div class="fuel-service-notes">
{serviceNotes.map((note) => <p>{note}</p>)}
{contactHref && (
<a class="button primary fuel-contact-link" href={contactHref}>Contact Us</a>
)}
</div>
)}
<div class="cards-grid fuel-cards-grid">
{fuelPrices.map((fuel) => (
<article class="card fuel-card">
@@ -37,6 +49,14 @@ function formatVatLabel(value: unknown): string {
</article>
))}
</div>
{moreInfoHref && (
<div class="fuel-more-info">
<a class="button primary fuel-info-link" href={moreInfoHref}>
More fuel information
<span aria-hidden="true">-&gt;</span>
</a>
</div>
)}
</section>
<style>
@@ -44,6 +64,59 @@ function formatVatLabel(value: unknown): string {
gap: 1.1rem;
}
.fuel-service-notes {
margin: 0 0 1.9rem;
}
.fuel-service-notes p {
margin: 0 0 0.65rem;
}
.fuel-service-notes p:last-child {
margin-bottom: 0;
}
.fuel-contact-link {
display: flex;
width: fit-content;
margin-top: 0.55rem;
margin-inline: auto;
}
.fuel-more-info {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
}
.fuel-info-link {
gap: 0.5rem;
padding-inline: 1.15rem;
box-shadow: 0 12px 24px rgba(11, 79, 122, 0.18);
transition:
background 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.fuel-info-link span {
font-weight: 800;
transition: transform 0.18s ease;
}
.fuel-info-link:hover,
.fuel-info-link:focus-visible {
color: white;
background: var(--brand-2);
box-shadow: 0 14px 28px rgba(11, 79, 122, 0.24);
transform: translateY(-1px);
}
.fuel-info-link:hover span,
.fuel-info-link:focus-visible span {
transform: translateX(2px);
}
.fuel-card {
border: 1px solid rgba(11, 79, 122, 0.18);
background:
@@ -104,6 +177,14 @@ function formatVatLabel(value: unknown): string {
}
@media (max-width: 560px) {
.fuel-more-info {
justify-content: stretch;
}
.fuel-info-link {
width: 100%;
}
.fuel-row {
grid-template-columns: 1fr;
gap: 0.75rem;
+1 -1
View File
@@ -26,7 +26,7 @@ const { news, title = 'Latest news', description = 'Fresh updates, operational c
) : (
<article class="card">
<h3>No news items</h3>
<p>News articles will be generated from Directus at build time.</p>
<p>Stay tuned for updates as they are published!</p>
</article>
)}
</div>
+160
View File
@@ -0,0 +1,160 @@
---
const webcamBase = (import.meta.env.PUBLIC_WEATHER_BASE ?? 'https://wx.swansea-airport.wales').replace(/\/$/, '');
const webcamImage = `${webcamBase}/webcam/apron.jpg`;
---
<section class="webcam-shell" aria-labelledby="webcam-heading">
<div class="webcam-head">
<div>
<p class="eyebrow">Live apron webcam</p>
<h1 id="webcam-heading" class="section-title">Swansea Airport apron</h1>
<p class="section-copy">
View from the tower facing south west.<br>The peak in the middle of the view is Cefn Bryn, 5500 metres distant.</p>
</div>
<div class="webcam-status" aria-live="polite">
<span class="status-dot waiting" id="webcam-dot"></span>
<span id="webcam-status">Loading</span>
</div>
</div>
<figure class="webcam-frame">
<img
id="webcam-image"
src={webcamImage}
alt="Live webcam view of Swansea Airport apron"
decoding="async"
fetchpriority="high"
/>
<figcaption>
<span id="webcam-updated">Waiting for latest image</span>
</figcaption>
</figure>
</section>
<script define:vars={{ webcamImage }}>
(() => {
const REFRESH_MS = 30000;
const image = document.getElementById('webcam-image');
const status = document.getElementById('webcam-status');
const dot = document.getElementById('webcam-dot');
const updated = document.getElementById('webcam-updated');
function setStatus(text, className) {
status.textContent = text;
dot.className = `status-dot ${className}`;
}
function refreshImage() {
const next = new URL(webcamImage);
next.searchParams.set('t', Date.now().toString());
image.src = next.toString();
}
image.addEventListener('load', () => {
const time = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date());
setStatus('Live', 'ok');
updated.textContent = `Last refreshed ${time}`;
});
image.addEventListener('error', () => {
setStatus('Image unavailable', 'error');
updated.textContent = 'The webcam image could not be loaded.';
});
refreshImage();
window.setInterval(refreshImage, REFRESH_MS);
})();
</script>
<style>
.webcam-shell {
display: grid;
gap: 1rem;
padding: 1.5rem 0 2.5rem;
}
.webcam-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
.webcam-status {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex: 0 0 auto;
padding: 0.45rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
color: var(--muted);
font-size: 0.84rem;
font-weight: 800;
box-shadow: 0 10px 22px rgba(16, 34, 51, 0.06);
}
.status-dot {
width: 0.62rem;
height: 0.62rem;
border-radius: 999px;
background: var(--muted);
box-shadow: 0 0 0 0.25rem rgba(81, 100, 117, 0.14);
}
.status-dot.ok {
background: #13834f;
box-shadow: 0 0 0 0.25rem rgba(19, 131, 79, 0.15);
}
.status-dot.waiting {
background: var(--warning);
box-shadow: 0 0 0 0.25rem rgba(187, 104, 0, 0.15);
}
.status-dot.error {
background: var(--critical);
box-shadow: 0 0 0 0.25rem rgba(161, 31, 58, 0.14);
}
.webcam-frame {
margin: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.84);
box-shadow: var(--shadow);
}
.webcam-frame img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: rgba(16, 34, 51, 0.08);
}
.webcam-frame figcaption {
display: block;
padding: 0.8rem 1rem;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.9rem;
font-weight: 700;
}
@media (max-width: 720px) {
.webcam-head {
display: grid;
}
.webcam-status {
justify-self: start;
}
}
</style>
+4 -1
View File
@@ -24,7 +24,10 @@ const pageTitle = title ? `${title} · ${site.name}` : site.name;
<div class="container topbar-inner">
<a class="brand brand-mobile" href="/"><img src="/images/logo-text.webp" alt="Swansea Airport" /></a>
<details class="mobile-nav">
<summary></summary>
<summary aria-label="Main menu">
<span class="mobile-nav-icon" aria-hidden="true"></span>
<span>Menu</span>
</summary>
<div class="mobile-nav-panel">
{site.navigation.map((item) => (
item.children ? (
+9
View File
@@ -4,7 +4,9 @@ import {
fallbackDocuments,
fallbackEvents,
fallbackFuelPrices,
fallbackGiftShopImages,
fallbackHomepageBannerImages,
fallbackHomepageVolunteerImages,
fallbackNews,
fallbackNotices,
type ContactItem,
@@ -45,7 +47,9 @@ const directusAssetUrlTemplate =
const directusToken = process.env.DIRECTUS_TOKEN ?? process.env.DIRECTUS_ADMIN_TOKEN;
const directusDebug = ['1', 'true', 'yes', 'on'].includes((process.env.DIRECTUS_DEBUG ?? '').toLowerCase());
const homepageBannerFolder = process.env.DIRECTUS_HOMEPAGE_BANNER_FOLDER ?? 'homepage-banners';
const homepageVolunteersFolder = process.env.DIRECTUS_HOMEPAGE_VOLUNTEERS_FOLDER ?? 'homepage-volunteers';
const cafePageFolder = process.env.DIRECTUS_CAFE_PAGE_FOLDER ?? 'cafe-page';
const giftShopFolder = process.env.DIRECTUS_GIFT_SHOP_FOLDER ?? 'gift-shop';
type DirectusFolder = {
id: string;
@@ -293,7 +297,12 @@ export const getHomepageBannerImages = () =>
firstTag: 'first',
shuffleRest: true,
});
export const getHomepageVolunteerImages = () =>
getImagesFromFolder(homepageVolunteersFolder, fallbackHomepageVolunteerImages, {
shuffleRest: true,
});
export const getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages);
export const getGiftShopImages = () => getImagesFromFolder(giftShopFolder, fallbackGiftShopImages);
function stripHtml(value = ''): string {
return value
+14
View File
@@ -100,6 +100,20 @@ export const fallbackCafePageImages: HomepageBannerImage[] = [
},
];
export const fallbackGiftShopImages: HomepageBannerImage[] = [
{
src: '/images/camain.jpg',
alt: 'Chocks Away Gift Shop at Swansea Airport',
},
];
export const fallbackHomepageVolunteerImages: HomepageBannerImage[] = [
{
src: '/images/cessna.jpg',
alt: 'A Cessna aircraft on the apron at Swansea Airport',
},
];
export const fallbackEvents: EventItem[] = [
{
title: 'Airfield open day',
+5 -2
View File
@@ -17,21 +17,24 @@ export const site = {
{ label: 'Home', href: '/' },
{ label: 'Pilot Info', href: '/pilot-info/' },
{ label: 'Weather', href: '/weather/' },
{ label: 'Webcam', href: '/webcam/' },
{
label: 'About',
href: '/about/',
children: [
{ label: 'Cafe', href: '/about/cafe/' },
{ label: 'Gift Shop', href: '/about/gift-shop/' },
{ label: 'History', href: '/about/history/' },
{ label: 'Drones', href: '/about/drones/' },
{ label: 'Fees and Charges', href: '/about/fees-and-charges/' },
{ label: 'Noise', href: '/about/noise/' },
{ label: 'Volunteering', href: '/about/volunteering/' },
],
},
{ label: 'Drones', href: '/about/drones/' },
{ label: 'Events', href: '/events/' },
{ label: 'News', href: '/news/' },
{ label: 'Documents', href: '/documents/' },
// Keep the documents page available, but hidden from the menu until needed.
// { label: 'Documents', href: '/documents/' },
{ label: 'Contact', href: '/contact/' },
],
};
+45
View File
@@ -21,11 +21,21 @@ const cafeImages = await getCafePageImages();
Fairwood Common and the Gower, the cafe offers a comfortable place to pause and enjoy the
airport atmosphere.
</p>
<div class="opening-times">
<p>Open 7 days a week.</p>
<div class="opening-list">
<p><strong>Monday &amp; Tuesday</strong><span>Snacks and drinks served 10am - 2pm</span></p>
<p><strong>Wednesday - Sunday</strong><span>Open 9am - 3pm, full menu served 9.30am - 2.30pm</span></p>
</div>
</div>
<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">
@@ -62,6 +72,40 @@ const cafeImages = await getCafePageImages();
min-height: clamp(16rem, 42vw, 30rem);
}
.opening-times {
display: grid;
gap: 0.65rem;
margin: 0.2rem 0;
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.86);
box-shadow: var(--shadow);
}
.opening-times h2 {
margin: 0;
font-size: 1.25rem;
}
.opening-times p {
margin: 0;
}
.opening-list {
display: grid;
gap: 0.5rem;
}
.opening-list p {
display: grid;
gap: 0.15rem;
}
.opening-list span {
color: var(--muted);
}
.social-follow {
display: grid;
gap: 0.7rem;
@@ -93,4 +137,5 @@ const cafeImages = await getCafePageImages();
line-height: 1;
transform: translateY(-0.02em);
}
</style>
+110 -19
View File
@@ -46,6 +46,11 @@ const additionalCharges = [
['Runway closure', '£50', 'At management discretion following incident or accident.'],
['Drones', '£25', 'Commercial drones need 2 days notice before flight and a permit.'],
];
const gaHeaders = ['Type', 'Landing fee', 'Daytime parking', 'Overnight parking outside', 'Overnight parking hangar'];
const touchAndGoHeaders = ['Type', 'Single', 'Unlimited'];
const businessHeaders = ['MTOW', 'Landing fee', 'Daytime parking', 'Overnight parking'];
const additionalHeaders = ['Charge', 'Price', 'Notes'];
---
<BaseLayout title="Fees and Charges" description="Swansea Airport landing, parking, handling, and related charges.">
@@ -60,17 +65,13 @@ const additionalCharges = [
<table class="fee-table">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Landing fee</th>
<th scope="col">Daytime parking</th>
<th scope="col">Overnight parking outside</th>
<th scope="col">Overnight parking hangar</th>
{gaHeaders.map((header) => <th scope="col">{header}</th>)}
</tr>
</thead>
<tbody>
{gaCharges.map((row) => (
<tr>
{row.map((cell) => <td>{cell}</td>)}
{row.map((cell, index) => <td data-label={gaHeaders[index]}>{cell}</td>)}
</tr>
))}
</tbody>
@@ -84,15 +85,13 @@ const additionalCharges = [
<table class="fee-table">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Single</th>
<th scope="col">Unlimited</th>
{touchAndGoHeaders.map((header) => <th scope="col">{header}</th>)}
</tr>
</thead>
<tbody>
{touchAndGoCharges.map((row) => (
<tr>
{row.map((cell) => <td>{cell}</td>)}
{row.map((cell, index) => <td data-label={touchAndGoHeaders[index]}>{cell}</td>)}
</tr>
))}
</tbody>
@@ -119,16 +118,13 @@ const additionalCharges = [
<table class="fee-table">
<thead>
<tr>
<th scope="col">MTOW</th>
<th scope="col">Landing fee</th>
<th scope="col">Daytime parking</th>
<th scope="col">Overnight parking</th>
{businessHeaders.map((header) => <th scope="col">{header}</th>)}
</tr>
</thead>
<tbody>
{businessCharges.map((row) => (
<tr>
{row.map((cell) => <td>{cell}</td>)}
{row.map((cell, index) => <td data-label={businessHeaders[index]}>{cell}</td>)}
</tr>
))}
</tbody>
@@ -149,15 +145,13 @@ const additionalCharges = [
<table class="fee-table">
<thead>
<tr>
<th scope="col">Charge</th>
<th scope="col">Price</th>
<th scope="col">Notes</th>
{additionalHeaders.map((header) => <th scope="col">{header}</th>)}
</tr>
</thead>
<tbody>
{additionalCharges.map((row) => (
<tr>
{row.map((cell) => <td>{cell}</td>)}
{row.map((cell, index) => <td data-label={additionalHeaders[index]}>{cell}</td>)}
</tr>
))}
</tbody>
@@ -236,4 +230,101 @@ const additionalCharges = [
.fee-section ul {
margin-top: 0.6rem;
}
@media (max-width: 700px) {
.fees-page {
gap: 1rem;
}
.fee-section {
padding-block: 0.35rem;
}
.fee-table-wrap {
overflow: visible;
}
.fee-table,
.fee-table tbody,
.fee-table tr,
.fee-table td {
display: block;
width: 100%;
}
.fee-table {
min-width: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.fee-table-wrap.compact .fee-table {
min-width: 0;
}
.fee-table thead {
display: none;
}
.fee-table tbody {
display: grid;
gap: 0.75rem;
}
.fee-table tr {
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 10px 22px rgba(16, 34, 51, 0.07);
}
.fee-table tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.88);
}
.fee-table td {
display: grid;
grid-template-columns: minmax(8rem, 0.9fr) minmax(0, 1.1fr);
gap: 0.75rem;
padding: 0.68rem 0.85rem;
border-bottom: 1px solid var(--line);
overflow-wrap: anywhere;
}
.fee-table td:first-child {
display: block;
padding: 0.85rem;
background: linear-gradient(180deg, rgba(11, 79, 122, 0.12), rgba(29, 118, 184, 0.07));
color: var(--text);
font-weight: 800;
}
.fee-table td:first-child::before {
content: none;
}
.fee-table td:last-child {
border-bottom: 0;
}
.fee-table td::before {
content: attr(data-label);
color: var(--brand);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1.25;
text-transform: uppercase;
}
}
@media (max-width: 420px) {
.fee-table td {
grid-template-columns: 1fr;
gap: 0.2rem;
}
}
</style>
+139
View File
@@ -0,0 +1,139 @@
---
import BannerRotator from '../../components/BannerRotator.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getGiftShopImages } from '../../lib/directus';
const giftShopImages = await getGiftShopImages();
---
<BaseLayout
title="Gift Shop"
description="Discover Chocks Away Gift Shop at Swansea Airport, with aviation memorabilia, RAF-themed gifts, Welsh souvenirs, toys, clothing, and accessories."
>
<section class="container prose gift-shop-page">
<figure class="gift-shop-hero">
<img src="/images/camain.jpg" alt="Chocks Away Gift Shop at Swansea Airport" />
</figure>
<div class="gift-shop-copy">
<h1 class="section-title">Gift Shop</h1>
<p>
Discover Chocks Away Gift Shop at Swansea Airport, a small shop packed with unique gifts
and aviation charm. Inspired by the airport's rich WWII heritage, you will find aviation
memorabilia, RAF-themed gifts, Welsh souvenirs, children's toys, clothing, accessories,
and much more.
</p>
<p>
From Spitfires to dragons, cufflinks to caps, whether you are flying in, visiting the
airport, or simply looking for the best gift or souvenir, there is something for every
aviation lover.
</p>
<div class="social-follow">
<div class="cta-row">
<a class="button primary social-button" href="https://www.facebook.com/profile.php?id=61590636748267" target="_blank" rel="noopener noreferrer">
<span class="facebook-mark" aria-hidden="true">f</span>
Chocks Away Gift Shop on Facebook
</a>
</div>
</div>
</div>
<div class="gift-shop-gallery" aria-label="Gift shop photo gallery">
<BannerRotator images={giftShopImages} />
</div>
<p class="gift-shop-disclaimer">
Chocks Away Gift Shop is an independent business based at Swansea Airport and is not
affiliated with airport management.
</p>
</section>
</BaseLayout>
<style>
.gift-shop-page {
display: grid;
gap: 1.5rem;
}
.gift-shop-hero {
margin: 0;
overflow: hidden;
border-radius: var(--radius);
box-shadow: var(--shadow);
aspect-ratio: 21 / 9;
background: var(--line);
}
.gift-shop-hero img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
}
.gift-shop-copy {
display: grid;
gap: 0.85rem;
}
.gift-shop-copy p {
margin: 0;
}
.gift-shop-gallery {
overflow: hidden;
border-radius: var(--radius);
box-shadow: var(--shadow);
background: var(--line);
}
.gift-shop-gallery :global(.banner-rotator) {
min-height: clamp(16rem, 42vw, 30rem);
}
.gift-shop-disclaimer {
margin-top: 0.35rem !important;
padding-top: 0.85rem;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.95rem;
font-weight: 700;
}
.social-follow {
display: grid;
gap: 0.7rem;
}
.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);
}
@media (max-width: 760px) {
.gift-shop-hero {
aspect-ratio: 16 / 9;
}
}
</style>
+4
View File
@@ -10,6 +10,10 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
<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/gift-shop/">Gift Shop</a></h3>
<p>Aviation gifts, RAF-themed keepsakes, Welsh souvenirs, toys, clothing, and accessories.</p>
</article>
<article class="card">
<h3><a href="/about/history/">History</a></h3>
<p>The story of Swansea Airport, formerly RAF Fairwood Common.</p>
+9 -8
View File
@@ -6,13 +6,14 @@ import EventsList from '../components/EventsList.astro';
import FacebookWidget from '../components/FacebookWidget.astro';
import NewsFeed from '../components/NewsFeed.astro';
import { getUpcomingEvents } from '../lib/events';
import { getEvents, getFuelPrices, getHomepageBannerImages, getNews } from '../lib/directus';
import { getEvents, getFuelPrices, getHomepageBannerImages, getHomepageVolunteerImages, getNews } from '../lib/directus';
const [fuelPrices, events, news, bannerImages] = await Promise.all([
const [fuelPrices, events, news, bannerImages, volunteerImages] = await Promise.all([
getFuelPrices(),
getEvents(),
getNews(),
getHomepageBannerImages(),
getHomepageVolunteerImages(),
]);
const upcomingEvents = getUpcomingEvents(events);
@@ -26,28 +27,28 @@ const businessPromos = [
href: 'https://www.goskydive.com/dropzone/skydive-centre-swansea/',
logo: '/images/goskydive.png',
alt: 'Go Skydive logo',
description: 'Tandem skydiving and experiences from Swansea.',
description: 'Tandem skydiving from Swansea',
},
{
name: 'Gower Flight Centre',
href: 'https://www.gowerflightcentre.co.uk/',
logo: '/images/gowerflightcentre.jpg',
alt: 'Gower Flight Centre logo',
description: 'Flying lessons, aircraft hire, and pilot training.',
description: 'Air experiences, and pilot training',
},
{
name: 'Fly A Spitfire',
href: 'https://flyaspitfire.com/',
logo: '/images/flyaspitfire.png',
alt: 'Fly A Spitfire logo',
description: 'Spitfire flight experiences and aviation events.',
description: 'Spitfire flights',
},
{
name: 'AeroSuperBatics',
href: 'https://www.aerosuperbatics.com/',
logo: '/images/aerosuperbatics.jpg',
alt: 'AeroSuperBatics logo',
description: 'Wingwalking and aerobatic entertainment flights.',
description: 'Wingwalking and aerobatic entertainment flights',
},
];
---
@@ -74,7 +75,7 @@ const businessPromos = [
</section>
<div class="container stack">
<FuelPricesWidget fuelPrices={fuelPrices} />
<FuelPricesWidget fuelPrices={fuelPrices} moreInfoHref="/pilot-info/#fuel" />
</div>
<section class="container business-promo">
@@ -140,7 +141,7 @@ const businessPromos = [
</article>
<figure class="story-image">
<img src="/images/cessna.jpg" alt="A Cessna aircraft on the apron at Swansea Airport" loading="lazy" />
<BannerRotator images={volunteerImages} />
</figure>
</section>
+11 -6
View File
@@ -5,6 +5,7 @@ import { getFuelPrices } from '../lib/directus';
import { site } from '../lib/site';
const runwayPattern = /^Runway\s+(\d+)\s+-\s+([^\s]+)\s+LDA\s+\(([^)]+)\)\s+Code\s+(\d+)\s+\(([^)]+)\)$/;
const runwayHeaders = ['Runway', 'LDA', 'Surface', 'Code', 'Circuits'];
const runwayRows = site.runwayFacts
.map((fact) => {
@@ -19,6 +20,10 @@ const runwayRows = site.runwayFacts
const otherFacts = site.runwayFacts.filter((fact) => !fact.match(runwayPattern));
const fuelPrices = await getFuelPrices();
const fuelServiceNotes = [
'Attended refuelling is available. Please advise on PPR, or after landing, if fuel is required.',
'JET A1 is available gravity fed or pressurised. Rotors-running and out-of-hours fuel may be available by prior arrangement.',
];
---
<BaseLayout title="Pilot Info" description="Essential information for pilots planning a visit to Swansea Airport.">
@@ -110,11 +115,11 @@ When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea T
<tbody>
{runwayRows.map((runway) => (
<tr>
<td>{runway.runway}</td>
<td>{runway.lda}</td>
<td>{runway.surface}</td>
<td>{runway.code}</td>
<td>{runway.circuits}</td>
<td data-label={runwayHeaders[0]}>{runway.runway}</td>
<td data-label={runwayHeaders[1]}>{runway.lda}</td>
<td data-label={runwayHeaders[2]}>{runway.surface}</td>
<td data-label={runwayHeaders[3]}>{runway.code}</td>
<td data-label={runwayHeaders[4]}>{runway.circuits}</td>
</tr>
))}
</tbody>
@@ -128,7 +133,7 @@ When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea T
)}
<FuelPricesWidget fuelPrices={fuelPrices} />
<FuelPricesWidget contactHref="/contact/" fuelPrices={fuelPrices} sectionId="fuel" serviceNotes={fuelServiceNotes} />
</div>
+10
View File
@@ -0,0 +1,10 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import WebcamPanel from '../components/WebcamPanel.astro';
---
<BaseLayout title="Webcam" description="View from the tower facing south west. The peak in the middle of the view is Cefn Bryn, 5500 metres distant.">
<section class="container">
<WebcamPanel />
</section>
</BaseLayout>
+181 -17
View File
@@ -190,34 +190,91 @@ img {
.mobile-nav > summary {
list-style: none;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
min-width: 2.75rem;
padding: 0;
gap: 0.45rem;
min-height: 3rem;
min-width: 5.5rem;
padding: 0 0.85rem;
border: 1px solid var(--line);
border-radius: 0.7rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(228, 240, 252, 0.95));
color: var(--brand);
border-radius: 0.85rem;
background: var(--brand);
color: #ffffff;
font-weight: 800;
font-size: 1.4rem;
font-size: 0.96rem;
line-height: 1;
cursor: pointer;
box-shadow: var(--shadow);
box-shadow: 0 12px 28px rgba(11, 79, 122, 0.22);
text-decoration: none;
}
.mobile-nav > summary span:not(.mobile-nav-icon) {
display: block;
line-height: 1;
}
.mobile-nav > summary:hover,
.mobile-nav > summary:focus-visible {
background: #083c5f;
}
.mobile-nav > summary:focus-visible {
outline: 3px solid rgba(246, 181, 56, 0.72);
outline-offset: 3px;
}
.mobile-nav > summary::-webkit-details-marker {
display: none;
}
.mobile-nav > summary::before {
content: '☰';
line-height: 1;
.mobile-nav-icon {
position: relative;
display: block;
width: 1.2rem;
height: 0.9rem;
flex: 0 0 auto;
border-block: 0.18rem solid currentColor;
}
.mobile-nav[open] > summary::before {
content: '✕';
font-size: 1.2rem;
.mobile-nav-icon::before,
.mobile-nav-icon::after {
content: '';
position: absolute;
left: 0;
width: 100%;
height: 0.18rem;
border-radius: 999px;
background: currentColor;
}
.mobile-nav-icon::before {
top: 50%;
transform: translateY(-50%);
}
.mobile-nav-icon::after {
display: none;
}
.mobile-nav[open] .mobile-nav-icon {
width: 1rem;
height: 1rem;
border-block: 0;
}
.mobile-nav[open] .mobile-nav-icon::before,
.mobile-nav[open] .mobile-nav-icon::after {
display: block;
top: 50%;
}
.mobile-nav[open] .mobile-nav-icon::before {
transform: translateY(-50%) rotate(45deg);
}
.mobile-nav[open] .mobile-nav-icon::after {
transform: translateY(-50%) rotate(-45deg);
}
.mobile-nav-panel {
@@ -406,12 +463,18 @@ section {
.story-image img {
width: 100%;
display: block;
aspect-ratio: 16 / 9;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 0;
box-shadow: none;
}
.story-image .banner-rotator {
min-height: 0;
aspect-ratio: 4 / 3;
background: transparent;
}
.business-promo {
display: grid;
gap: 1rem;
@@ -897,7 +960,108 @@ section {
max-height: 2rem;
}
@media (max-width: 390px) {
.topbar-inner {
gap: 0.65rem;
}
.brand-mobile img {
max-width: min(12rem, 58vw);
}
}
.runway-facts-table {
min-width: 38rem;
min-width: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.runway-table-wrap {
overflow: visible;
}
.runway-facts-table,
.runway-facts-table tbody,
.runway-facts-table tr,
.runway-facts-table td {
display: block;
width: 100%;
}
.runway-facts-table thead {
display: none;
}
.runway-facts-table tbody {
display: grid;
gap: 0.75rem;
}
.runway-facts-table tr {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.75rem;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 10px 22px rgba(16, 34, 51, 0.07);
}
.runway-facts-table tbody tr:nth-child(even),
.runway-facts-table tbody tr:hover {
background: rgba(255, 255, 255, 0.88);
}
.runway-facts-table td {
display: grid;
gap: 0.18rem;
min-height: 3.4rem;
padding: 0.62rem 0.7rem;
border: 1px solid rgba(16, 34, 51, 0.08);
border-radius: 0.7rem;
background: rgba(255, 255, 255, 0.72);
font-weight: 800;
line-height: 1.2;
white-space: normal;
}
.runway-facts-table td:first-child {
display: flex;
align-items: center;
gap: 0.45rem;
grid-column: 1 / -1;
min-height: 0;
padding: 0.85rem;
border: 0;
background: linear-gradient(180deg, rgba(11, 79, 122, 0.12), rgba(29, 118, 184, 0.07));
color: var(--text);
font-size: 1.08rem;
font-weight: 800;
}
.runway-facts-table td:first-child::before {
content: 'Runway';
color: var(--brand);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.runway-facts-table td:last-child {
border-bottom: 1px solid rgba(16, 34, 51, 0.08);
}
.runway-facts-table td::before {
content: attr(data-label);
color: var(--brand);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1;
text-transform: uppercase;
}
}