Compare commits
25 Commits
dbd177871e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09323fbd91 | |||
| 6f59e7fdbc | |||
| 7e7fcbc458 | |||
| a3aa683190 | |||
| 9d7d7a8b6d | |||
| 8fc5c1fa29 | |||
| 4f79b4dd3c | |||
| 352fa24e6d | |||
| fabd8becc5 | |||
| cfd0e54f07 | |||
| c9410cb114 | |||
| befe3e6ba3 | |||
| 63522f545a | |||
| 85020d2dae | |||
| 6a9daeab0d | |||
| 730eb7758a | |||
| c43e4acc32 | |||
| 4a4279e91f | |||
| d5d643fbcb | |||
| 002ba4047d | |||
| 18a9b247c4 | |||
| 5e77741907 | |||
| 29092b467f | |||
| d18f75b144 | |||
| d1f41d91bb |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
@@ -3,13 +3,14 @@ import type { HomepageBannerImage } from '../lib/fallback-data';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
images: HomepageBannerImage[];
|
images: HomepageBannerImage[];
|
||||||
|
randomizeAfterFirst?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { images } = Astro.props;
|
const { images, randomizeAfterFirst = false } = Astro.props;
|
||||||
const slides = images.length > 0 ? images : [{ src: '/images/banner.png', alt: 'Swansea Airport banner' }];
|
const slides = images.length > 0 ? images : [{ src: '/images/banner.png', alt: 'Swansea Airport banner' }];
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="banner-rotator" data-banner-rotator>
|
<div class="banner-rotator" data-banner-rotator data-randomize-after-first={randomizeAfterFirst ? 'true' : undefined}>
|
||||||
{slides.map((image, index) => (
|
{slides.map((image, index) => (
|
||||||
<img
|
<img
|
||||||
class:list={['banner-slide', { active: index === 0 }]}
|
class:list={['banner-slide', { active: index === 0 }]}
|
||||||
@@ -25,18 +26,35 @@ const slides = images.length > 0 ? images : [{ src: '/images/banner.png', alt: '
|
|||||||
{slides.length > 1 && (
|
{slides.length > 1 && (
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const root = document.querySelector('[data-banner-rotator]');
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
if (!root || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
||||||
|
const roots = Array.from(document.querySelectorAll('[data-banner-rotator]:not([data-banner-rotator-ready])'));
|
||||||
|
|
||||||
|
roots.forEach((root) => {
|
||||||
|
root.setAttribute('data-banner-rotator-ready', 'true');
|
||||||
|
|
||||||
const slides = Array.from(root.querySelectorAll('[data-banner-slide]'));
|
const slides = Array.from(root.querySelectorAll('[data-banner-slide]'));
|
||||||
if (slides.length < 2) return;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
window.setInterval(() => {
|
window.setInterval(() => {
|
||||||
slides[currentIndex].classList.remove('active');
|
slides[currentIndex].classList.remove('active');
|
||||||
currentIndex = (currentIndex + 1) % slides.length;
|
currentIndex = (currentIndex + 1) % slides.length;
|
||||||
slides[currentIndex].classList.add('active');
|
slides[currentIndex].classList.add('active');
|
||||||
}, 3500);
|
}, 3500);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ const contactRequestEndpoint = `${pprApiBase}/contact-requests/public`;
|
|||||||
<select id="contact-enquiry-type" name="enquiry_type" required>
|
<select id="contact-enquiry-type" name="enquiry_type" required>
|
||||||
<option value="">Select enquiry type</option>
|
<option value="">Select enquiry type</option>
|
||||||
<option value="general">General enquiry</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="aviation_business">Aviation business / basing</option>
|
||||||
<option value="pilot">Pilot or visiting aircraft</option>
|
|
||||||
<option value="events">Events and visits</option>
|
<option value="events">Events and visits</option>
|
||||||
<option value="community">Community or local resident</option>
|
<option value="community">Community or local resident</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drone-field">
|
<div class="drone-field">
|
||||||
<label for="max-elevation">Maximum Elevation, feet AMSL <span aria-hidden="true">*</span></label>
|
<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_amsl" min="0" step="1" inputmode="numeric" required />
|
<input type="number" id="max-elevation" name="maximum_elevation_ft_agl" min="0" step="1" inputmode="numeric" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drone-field drone-full">
|
<div class="drone-field drone-full">
|
||||||
@@ -131,9 +131,12 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
|
|||||||
Submitting your drone request...
|
Submitting your drone request...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drone-success notice" id="success-message" role="status" aria-live="polite">
|
<div class="drone-success notice" id="success-message" role="status" aria-live="polite" tabindex="-1">
|
||||||
<h3>Drone Request Submitted.</h3>
|
<h3>Drone Request Submitted.</h3>
|
||||||
<p>Your drone request has been submitted. We will review it and contact you if we need any further information.</p>
|
<p>Your drone request has been submitted. We will review it and contact you if we need any further information.</p>
|
||||||
|
<p class="drone-reference" id="reference-number-message" hidden>
|
||||||
|
Reference number: <strong id="reference-number"></strong> - please make a note of this in case you don't get the email.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -404,7 +407,7 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
|
|||||||
if (!fieldValue) return;
|
if (!fieldValue) return;
|
||||||
const normalizedValue = key === 'operator_id' || key === 'flyer_id' ? fieldValue.toUpperCase() : fieldValue;
|
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);
|
data[key] = Number.parseInt(normalizedValue, 10);
|
||||||
} else if (key === 'location_latitude' || key === 'location_longitude') {
|
} else if (key === 'location_latitude' || key === 'location_longitude') {
|
||||||
data[key] = Number.parseFloat(normalizedValue);
|
data[key] = Number.parseFloat(normalizedValue);
|
||||||
@@ -447,8 +450,24 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
|
|||||||
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
|
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json().catch(() => ({}));
|
||||||
|
const referenceNumber = responseData?.reference_number;
|
||||||
|
const referenceMessage = get('reference-number-message');
|
||||||
|
const referenceNumberElement = get('reference-number');
|
||||||
|
|
||||||
|
if (referenceNumber) {
|
||||||
|
referenceNumberElement.textContent = referenceNumber;
|
||||||
|
referenceMessage.hidden = false;
|
||||||
|
} else {
|
||||||
|
referenceNumberElement.textContent = '';
|
||||||
|
referenceMessage.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
form.style.display = 'none';
|
form.style.display = 'none';
|
||||||
get('success-message').style.display = 'block';
|
const successMessage = get('success-message');
|
||||||
|
successMessage.style.display = 'block';
|
||||||
|
successMessage.focus({ preventScroll: true });
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
showNotification('Drone request submitted successfully!');
|
showNotification('Drone request submitted successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting drone request:', error);
|
console.error('Error submitting drone request:', error);
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import SectionHeading from './SectionHeading.astro';
|
|||||||
import type { FuelPrice } from '../lib/fallback-data';
|
import type { FuelPrice } from '../lib/fallback-data';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
contactHref?: string;
|
||||||
fuelPrices: FuelPrice[];
|
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 {
|
function formatFuelPrice(value: unknown): string {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -18,8 +22,16 @@ function formatVatLabel(value: unknown): string {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<section>
|
<section id={sectionId}>
|
||||||
<SectionHeading title="Fuel at Swansea" />
|
<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">
|
<div class="cards-grid fuel-cards-grid">
|
||||||
{fuelPrices.map((fuel) => (
|
{fuelPrices.map((fuel) => (
|
||||||
<article class="card fuel-card">
|
<article class="card fuel-card">
|
||||||
@@ -37,6 +49,14 @@ function formatVatLabel(value: unknown): string {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{moreInfoHref && (
|
||||||
|
<div class="fuel-more-info">
|
||||||
|
<a class="button primary fuel-info-link" href={moreInfoHref}>
|
||||||
|
More fuel information
|
||||||
|
<span aria-hidden="true">-></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -44,6 +64,59 @@ function formatVatLabel(value: unknown): string {
|
|||||||
gap: 1.1rem;
|
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 {
|
.fuel-card {
|
||||||
border: 1px solid rgba(11, 79, 122, 0.18);
|
border: 1px solid rgba(11, 79, 122, 0.18);
|
||||||
background:
|
background:
|
||||||
@@ -104,6 +177,14 @@ function formatVatLabel(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.fuel-more-info {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuel-info-link {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.fuel-row {
|
.fuel-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const { news, title = 'Latest news', description = 'Fresh updates, operational c
|
|||||||
) : (
|
) : (
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<h3>No news items</h3>
|
<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>
|
</article>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -24,7 +24,10 @@ const pageTitle = title ? `${title} · ${site.name}` : site.name;
|
|||||||
<div class="container topbar-inner">
|
<div class="container topbar-inner">
|
||||||
<a class="brand brand-mobile" href="/"><img src="/images/logo-text.webp" alt="Swansea Airport" /></a>
|
<a class="brand brand-mobile" href="/"><img src="/images/logo-text.webp" alt="Swansea Airport" /></a>
|
||||||
<details class="mobile-nav">
|
<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">
|
<div class="mobile-nav-panel">
|
||||||
{site.navigation.map((item) => (
|
{site.navigation.map((item) => (
|
||||||
item.children ? (
|
item.children ? (
|
||||||
|
|||||||
+71
-4
@@ -4,7 +4,9 @@ import {
|
|||||||
fallbackDocuments,
|
fallbackDocuments,
|
||||||
fallbackEvents,
|
fallbackEvents,
|
||||||
fallbackFuelPrices,
|
fallbackFuelPrices,
|
||||||
|
fallbackGiftShopImages,
|
||||||
fallbackHomepageBannerImages,
|
fallbackHomepageBannerImages,
|
||||||
|
fallbackHomepageVolunteerImages,
|
||||||
fallbackNews,
|
fallbackNews,
|
||||||
fallbackNotices,
|
fallbackNotices,
|
||||||
type ContactItem,
|
type ContactItem,
|
||||||
@@ -45,7 +47,9 @@ const directusAssetUrlTemplate =
|
|||||||
const directusToken = process.env.DIRECTUS_TOKEN ?? process.env.DIRECTUS_ADMIN_TOKEN;
|
const directusToken = process.env.DIRECTUS_TOKEN ?? process.env.DIRECTUS_ADMIN_TOKEN;
|
||||||
const directusDebug = ['1', 'true', 'yes', 'on'].includes((process.env.DIRECTUS_DEBUG ?? '').toLowerCase());
|
const directusDebug = ['1', 'true', 'yes', 'on'].includes((process.env.DIRECTUS_DEBUG ?? '').toLowerCase());
|
||||||
const homepageBannerFolder = process.env.DIRECTUS_HOMEPAGE_BANNER_FOLDER ?? 'homepage-banners';
|
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 cafePageFolder = process.env.DIRECTUS_CAFE_PAGE_FOLDER ?? 'cafe-page';
|
||||||
|
const giftShopFolder = process.env.DIRECTUS_GIFT_SHOP_FOLDER ?? 'gift-shop';
|
||||||
|
|
||||||
type DirectusFolder = {
|
type DirectusFolder = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,6 +63,12 @@ type DirectusFile = {
|
|||||||
filename_download?: string;
|
filename_download?: string;
|
||||||
filename_disk?: string;
|
filename_disk?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
tags?: string[] | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImageFolderOptions = {
|
||||||
|
firstTag?: string;
|
||||||
|
shuffleRest?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EventTemplateRecord = {
|
type EventTemplateRecord = {
|
||||||
@@ -170,6 +180,49 @@ function directusObjectKey(file: string | DirectusFile): string {
|
|||||||
return `${fileId}${extensionFromFilename(typeof file === 'string' ? undefined : file.filename_download)}`;
|
return `${fileId}${extensionFromFilename(typeof file === 'string' ? undefined : file.filename_download)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileTags(file: DirectusFile): string[] {
|
||||||
|
if (Array.isArray(file.tags)) {
|
||||||
|
return file.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof file.tags === 'string') {
|
||||||
|
return file.tags
|
||||||
|
.split(',')
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTag(file: DirectusFile, tag: string): boolean {
|
||||||
|
const targetTag = tag.toLowerCase();
|
||||||
|
return fileTags(file).some((fileTag) => fileTag.toLowerCase() === targetTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleFiles(files: DirectusFile[]): DirectusFile[] {
|
||||||
|
const shuffled = [...files];
|
||||||
|
|
||||||
|
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||||
|
const swapIndex = Math.floor(Math.random() * (index + 1));
|
||||||
|
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderImageFiles(files: DirectusFile[], options: ImageFolderOptions): DirectusFile[] {
|
||||||
|
const { firstTag, shuffleRest } = options;
|
||||||
|
|
||||||
|
if (!firstTag) {
|
||||||
|
return shuffleRest ? shuffleFiles(files) : files;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstFiles = files.filter((file) => hasTag(file, firstTag));
|
||||||
|
const restFiles = files.filter((file) => !hasTag(file, firstTag));
|
||||||
|
return [...firstFiles, ...(shuffleRest ? shuffleFiles(restFiles) : restFiles)];
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveDirectusAssetUrl(file: string | DirectusFile): string {
|
export function resolveDirectusAssetUrl(file: string | DirectusFile): string {
|
||||||
const fileId = directusFileId(file);
|
const fileId = directusFileId(file);
|
||||||
const r2ObjectKey = directusObjectKey(file);
|
const r2ObjectKey = directusObjectKey(file);
|
||||||
@@ -200,7 +253,11 @@ async function findFolderByName(name: string): Promise<DirectusFolder | null> {
|
|||||||
return folders[0] ?? null;
|
return folders[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getImagesFromFolder(folderName: string, fallbackImages: HomepageBannerImage[]): Promise<HomepageBannerImage[]> {
|
async function getImagesFromFolder(
|
||||||
|
folderName: string,
|
||||||
|
fallbackImages: HomepageBannerImage[],
|
||||||
|
options: ImageFolderOptions = {},
|
||||||
|
): Promise<HomepageBannerImage[]> {
|
||||||
try {
|
try {
|
||||||
const folder = await findFolderByName(folderName);
|
const folder = await findFolderByName(folderName);
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
@@ -213,10 +270,11 @@ async function getImagesFromFolder(folderName: string, fallbackImages: HomepageB
|
|||||||
endpoint.searchParams.set('sort', '-uploaded_on');
|
endpoint.searchParams.set('sort', '-uploaded_on');
|
||||||
endpoint.searchParams.set('filter[folder][_eq]', folder.id);
|
endpoint.searchParams.set('filter[folder][_eq]', folder.id);
|
||||||
endpoint.searchParams.set('filter[type][_starts_with]', 'image/');
|
endpoint.searchParams.set('filter[type][_starts_with]', 'image/');
|
||||||
endpoint.searchParams.set('fields', 'id,title,description,filename_download,filename_disk,type');
|
endpoint.searchParams.set('fields', 'id,title,description,filename_download,filename_disk,type,tags');
|
||||||
|
|
||||||
const files = await readDirectusEndpoint<DirectusFile>(endpoint);
|
const files = await readDirectusEndpoint<DirectusFile>(endpoint);
|
||||||
const images = files.map((file) => ({
|
const orderedFiles = orderImageFiles(files, options);
|
||||||
|
const images = orderedFiles.map((file) => ({
|
||||||
src: resolveDirectusAssetUrl(file),
|
src: resolveDirectusAssetUrl(file),
|
||||||
alt: file.description || file.title || file.filename_download || 'Swansea Airport',
|
alt: file.description || file.title || file.filename_download || 'Swansea Airport',
|
||||||
}));
|
}));
|
||||||
@@ -234,8 +292,17 @@ async function getImagesFromFolder(folderName: string, fallbackImages: HomepageB
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getHomepageBannerImages = () => getImagesFromFolder(homepageBannerFolder, fallbackHomepageBannerImages);
|
export const getHomepageBannerImages = () =>
|
||||||
|
getImagesFromFolder(homepageBannerFolder, fallbackHomepageBannerImages, {
|
||||||
|
firstTag: 'first',
|
||||||
|
shuffleRest: true,
|
||||||
|
});
|
||||||
|
export const getHomepageVolunteerImages = () =>
|
||||||
|
getImagesFromFolder(homepageVolunteersFolder, fallbackHomepageVolunteerImages, {
|
||||||
|
shuffleRest: true,
|
||||||
|
});
|
||||||
export const getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages);
|
export const getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages);
|
||||||
|
export const getGiftShopImages = () => getImagesFromFolder(giftShopFolder, fallbackGiftShopImages);
|
||||||
|
|
||||||
function stripHtml(value = ''): string {
|
function stripHtml(value = ''): string {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -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[] = [
|
export const fallbackEvents: EventItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Airfield open day',
|
title: 'Airfield open day',
|
||||||
|
|||||||
+5
-2
@@ -17,21 +17,24 @@ export const site = {
|
|||||||
{ label: 'Home', href: '/' },
|
{ label: 'Home', href: '/' },
|
||||||
{ label: 'Pilot Info', href: '/pilot-info/' },
|
{ label: 'Pilot Info', href: '/pilot-info/' },
|
||||||
{ label: 'Weather', href: '/weather/' },
|
{ label: 'Weather', href: '/weather/' },
|
||||||
|
{ label: 'Webcam', href: '/webcam/' },
|
||||||
{
|
{
|
||||||
label: 'About',
|
label: 'About',
|
||||||
href: '/about/',
|
href: '/about/',
|
||||||
children: [
|
children: [
|
||||||
{ label: 'Cafe', href: '/about/cafe/' },
|
{ label: 'Cafe', href: '/about/cafe/' },
|
||||||
|
{ label: 'Gift Shop', href: '/about/gift-shop/' },
|
||||||
{ label: 'History', href: '/about/history/' },
|
{ label: 'History', href: '/about/history/' },
|
||||||
{ label: 'Drones', href: '/about/drones/' },
|
|
||||||
{ label: 'Fees and Charges', href: '/about/fees-and-charges/' },
|
{ label: 'Fees and Charges', href: '/about/fees-and-charges/' },
|
||||||
{ label: 'Noise', href: '/about/noise/' },
|
{ label: 'Noise', href: '/about/noise/' },
|
||||||
{ label: 'Volunteering', href: '/about/volunteering/' },
|
{ label: 'Volunteering', href: '/about/volunteering/' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ label: 'Drones', href: '/about/drones/' },
|
||||||
{ label: 'Events', href: '/events/' },
|
{ label: 'Events', href: '/events/' },
|
||||||
{ label: 'News', href: '/news/' },
|
{ 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/' },
|
{ label: 'Contact', href: '/contact/' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const cafeImages = await getCafePageImages();
|
|||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Cafe" description="A friendly, dog-friendly cafe at Swansea Airport.">
|
<BaseLayout title="Cafe" description="A friendly, dog-friendly cafe at Swansea Airport.">
|
||||||
<section class="container cafe-page">
|
<section class="container prose cafe-page">
|
||||||
<div class="cafe-copy">
|
<div class="cafe-copy">
|
||||||
<h1 class="section-title">The Whirlybird Cafe</h1>
|
<h1 class="section-title">The Whirlybird Cafe</h1>
|
||||||
<p>
|
<p>
|
||||||
@@ -21,11 +21,21 @@ const cafeImages = await getCafePageImages();
|
|||||||
Fairwood Common and the Gower, the cafe offers a comfortable place to pause and enjoy the
|
Fairwood Common and the Gower, the cafe offers a comfortable place to pause and enjoy the
|
||||||
airport atmosphere.
|
airport atmosphere.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="opening-times">
|
||||||
|
<p>Open 7 days a week.</p>
|
||||||
|
<div class="opening-list">
|
||||||
|
<p><strong>Monday & 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>
|
<p>
|
||||||
On Thursday to Sunday evenings, the cafe transforms into Thai Bach Express, serving Thai
|
On Thursday to Sunday evenings, the cafe transforms into Thai Bach Express, serving Thai
|
||||||
food for delivery and dining in.
|
food for delivery and dining in.
|
||||||
<a href="https://thaibach.co.uk/" target="_blank" rel="noopener noreferrer">Visit Thai Bach</a>.
|
<a href="https://thaibach.co.uk/" target="_blank" rel="noopener noreferrer">Visit Thai Bach</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="social-follow">
|
<div class="social-follow">
|
||||||
<div class="cta-row">
|
<div class="cta-row">
|
||||||
<a class="button primary social-button" href="https://www.facebook.com/SwanseaAirportCafe" target="_blank" rel="noopener noreferrer">
|
<a class="button primary social-button" href="https://www.facebook.com/SwanseaAirportCafe" target="_blank" rel="noopener noreferrer">
|
||||||
@@ -52,10 +62,6 @@ const cafeImages = await getCafePageImages();
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cafe-copy {
|
|
||||||
max-width: 72ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cafe-gallery {
|
.cafe-gallery {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -66,6 +72,40 @@ const cafeImages = await getCafePageImages();
|
|||||||
min-height: clamp(16rem, 42vw, 30rem);
|
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 {
|
.social-follow {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
@@ -97,4 +137,5 @@ const cafeImages = await getCafePageImages();
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
transform: translateY(-0.02em);
|
transform: translateY(-0.02em);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ const additionalCharges = [
|
|||||||
['Runway closure', '£50', 'At management discretion following incident or accident.'],
|
['Runway closure', '£50', 'At management discretion following incident or accident.'],
|
||||||
['Drones', '£25', 'Commercial drones need 2 days notice before flight and a permit.'],
|
['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.">
|
<BaseLayout title="Fees and Charges" description="Swansea Airport landing, parking, handling, and related charges.">
|
||||||
@@ -60,17 +65,13 @@ const additionalCharges = [
|
|||||||
<table class="fee-table">
|
<table class="fee-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Type</th>
|
{gaHeaders.map((header) => <th scope="col">{header}</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{gaCharges.map((row) => (
|
{gaCharges.map((row) => (
|
||||||
<tr>
|
<tr>
|
||||||
{row.map((cell) => <td>{cell}</td>)}
|
{row.map((cell, index) => <td data-label={gaHeaders[index]}>{cell}</td>)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -84,15 +85,13 @@ const additionalCharges = [
|
|||||||
<table class="fee-table">
|
<table class="fee-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Type</th>
|
{touchAndGoHeaders.map((header) => <th scope="col">{header}</th>)}
|
||||||
<th scope="col">Single</th>
|
|
||||||
<th scope="col">Unlimited</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{touchAndGoCharges.map((row) => (
|
{touchAndGoCharges.map((row) => (
|
||||||
<tr>
|
<tr>
|
||||||
{row.map((cell) => <td>{cell}</td>)}
|
{row.map((cell, index) => <td data-label={touchAndGoHeaders[index]}>{cell}</td>)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -119,16 +118,13 @@ const additionalCharges = [
|
|||||||
<table class="fee-table">
|
<table class="fee-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">MTOW</th>
|
{businessHeaders.map((header) => <th scope="col">{header}</th>)}
|
||||||
<th scope="col">Landing fee</th>
|
|
||||||
<th scope="col">Daytime parking</th>
|
|
||||||
<th scope="col">Overnight parking</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{businessCharges.map((row) => (
|
{businessCharges.map((row) => (
|
||||||
<tr>
|
<tr>
|
||||||
{row.map((cell) => <td>{cell}</td>)}
|
{row.map((cell, index) => <td data-label={businessHeaders[index]}>{cell}</td>)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -149,15 +145,13 @@ const additionalCharges = [
|
|||||||
<table class="fee-table">
|
<table class="fee-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Charge</th>
|
{additionalHeaders.map((header) => <th scope="col">{header}</th>)}
|
||||||
<th scope="col">Price</th>
|
|
||||||
<th scope="col">Notes</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{additionalCharges.map((row) => (
|
{additionalCharges.map((row) => (
|
||||||
<tr>
|
<tr>
|
||||||
{row.map((cell) => <td>{cell}</td>)}
|
{row.map((cell, index) => <td data-label={additionalHeaders[index]}>{cell}</td>)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -236,4 +230,101 @@ const additionalCharges = [
|
|||||||
.fee-section ul {
|
.fee-section ul {
|
||||||
margin-top: 0.6rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -3,31 +3,141 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="History" description="A brief history of Swansea Airport, formerly RAF Fairwood Common.">
|
<BaseLayout title="History" description="A brief history of Swansea Airport, formerly RAF Fairwood Common.">
|
||||||
<section class="container prose">
|
<script is:inline>
|
||||||
<p class="eyebrow">About</p>
|
document.documentElement.classList.add('js');
|
||||||
<h1 class="section-title">History</h1>
|
</script>
|
||||||
|
|
||||||
<p>
|
<section class="container prose history-page">
|
||||||
|
<figure class="history-banner" data-history-banner>
|
||||||
|
<img src="/images/chipmunk.jpeg" alt="Historic aircraft at Swansea Airport" />
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<p class="eyebrow reveal-item" data-reveal>About</p>
|
||||||
|
<h1 class="section-title reveal-item" data-reveal>History</h1>
|
||||||
|
|
||||||
|
<p class="reveal-item" data-reveal>
|
||||||
Swansea Airport stands on a site with deep aviation roots. Before operating as Swansea
|
Swansea Airport stands on a site with deep aviation roots. Before operating as Swansea
|
||||||
Airport, the airfield was known as RAF Fairwood Common.
|
Airport, the airfield was known as RAF Fairwood Common.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
During the wartime period, Fairwood Common played an operational role as part of the wider
|
During the wartime period, Fairwood Common played an operational role as part of the wider
|
||||||
air defence and training network. Its location on the Gower Peninsula gave it a strategic
|
air defence and training network. Its location on the Gower Peninsula gave it a strategic
|
||||||
position, and over time the airfield developed infrastructure that shaped its later civil use.
|
position, and over time the airfield developed infrastructure that shaped its later civil use.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
In the post-war years, the site evolved from military use into a civilian aerodrome. That
|
In the post-war years, the site evolved from military use into a civilian aerodrome. That
|
||||||
transition reflected the broader story of many UK airfields, where former RAF stations became
|
transition reflected the broader story of many UK airfields, where former RAF stations became
|
||||||
local centres for flight training, private aviation, and community flying activity.
|
local centres for flight training, private aviation, and community flying activity.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
Today, Swansea Airport continues that legacy. While the role of the airfield has changed, its
|
Today, Swansea Airport continues that legacy. While the role of the airfield has changed, its
|
||||||
connection to aviation history remains central to its identity, linking RAF Fairwood Common's
|
connection to aviation history remains central to its identity, linking RAF Fairwood Common's
|
||||||
past with present-day operations serving Swansea and the surrounding region.
|
past with present-day operations serving Swansea and the surrounding region.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.history-banner {
|
||||||
|
--history-parallax: 0px;
|
||||||
|
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
aspect-ratio: 21 / 9;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-banner img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% + 6rem);
|
||||||
|
margin-top: -3rem;
|
||||||
|
object-fit: cover;
|
||||||
|
transform: translate3d(0, var(--history-parallax), 0) scale(1.03);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.js) .reveal-item {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(1.4rem);
|
||||||
|
transition:
|
||||||
|
opacity 680ms ease,
|
||||||
|
transform 680ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.js) .reveal-item.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.history-banner {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.history-banner img,
|
||||||
|
:global(.js) .reveal-item {
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.js) .reveal-item {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const banner = document.querySelector('[data-history-banner]');
|
||||||
|
const revealItems = Array.from(document.querySelectorAll('[data-reveal]'));
|
||||||
|
|
||||||
|
if (!motionQuery.matches) {
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
const updateBanner = () => {
|
||||||
|
if (!(banner instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const rect = banner.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height);
|
||||||
|
const clampedProgress = Math.min(Math.max(progress, 0), 1);
|
||||||
|
const offset = (clampedProgress - 0.5) * 64;
|
||||||
|
|
||||||
|
banner.style.setProperty('--history-parallax', `${offset}px`);
|
||||||
|
ticking = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestBannerUpdate = () => {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
window.requestAnimationFrame(updateBanner);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateBanner();
|
||||||
|
window.addEventListener('scroll', requestBannerUpdate, { passive: true });
|
||||||
|
window.addEventListener('resize', requestBannerUpdate);
|
||||||
|
|
||||||
|
const revealObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
entry.target.classList.add('is-visible');
|
||||||
|
revealObserver.unobserve(entry.target);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.16, rootMargin: '0px 0px -8% 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
revealItems.forEach((item) => revealObserver.observe(item));
|
||||||
|
} else {
|
||||||
|
revealItems.forEach((item) => item.classList.add('is-visible'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|||||||
<h3><a href="/about/cafe/">Cafe</a></h3>
|
<h3><a href="/about/cafe/">Cafe</a></h3>
|
||||||
<p>A friendly, dog-friendly cafe for visitors, walkers, pilots, and local families.</p>
|
<p>A friendly, dog-friendly cafe for visitors, walkers, pilots, and local families.</p>
|
||||||
</article>
|
</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">
|
<article class="card">
|
||||||
<h3><a href="/about/history/">History</a></h3>
|
<h3><a href="/about/history/">History</a></h3>
|
||||||
<p>The story of Swansea Airport, formerly RAF Fairwood Common.</p>
|
<p>The story of Swansea Airport, formerly RAF Fairwood Common.</p>
|
||||||
|
|||||||
+10
-9
@@ -6,13 +6,14 @@ import EventsList from '../components/EventsList.astro';
|
|||||||
import FacebookWidget from '../components/FacebookWidget.astro';
|
import FacebookWidget from '../components/FacebookWidget.astro';
|
||||||
import NewsFeed from '../components/NewsFeed.astro';
|
import NewsFeed from '../components/NewsFeed.astro';
|
||||||
import { getUpcomingEvents } from '../lib/events';
|
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(),
|
getFuelPrices(),
|
||||||
getEvents(),
|
getEvents(),
|
||||||
getNews(),
|
getNews(),
|
||||||
getHomepageBannerImages(),
|
getHomepageBannerImages(),
|
||||||
|
getHomepageVolunteerImages(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const upcomingEvents = getUpcomingEvents(events);
|
const upcomingEvents = getUpcomingEvents(events);
|
||||||
@@ -26,34 +27,34 @@ const businessPromos = [
|
|||||||
href: 'https://www.goskydive.com/dropzone/skydive-centre-swansea/',
|
href: 'https://www.goskydive.com/dropzone/skydive-centre-swansea/',
|
||||||
logo: '/images/goskydive.png',
|
logo: '/images/goskydive.png',
|
||||||
alt: 'Go Skydive logo',
|
alt: 'Go Skydive logo',
|
||||||
description: 'Tandem skydiving and experiences from Swansea.',
|
description: 'Tandem skydiving from Swansea',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Gower Flight Centre',
|
name: 'Gower Flight Centre',
|
||||||
href: 'https://www.gowerflightcentre.co.uk/',
|
href: 'https://www.gowerflightcentre.co.uk/',
|
||||||
logo: '/images/gowerflightcentre.jpg',
|
logo: '/images/gowerflightcentre.jpg',
|
||||||
alt: 'Gower Flight Centre logo',
|
alt: 'Gower Flight Centre logo',
|
||||||
description: 'Flying lessons, aircraft hire, and pilot training.',
|
description: 'Air experiences, and pilot training',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Fly A Spitfire',
|
name: 'Fly A Spitfire',
|
||||||
href: 'https://flyaspitfire.com/',
|
href: 'https://flyaspitfire.com/',
|
||||||
logo: '/images/flyaspitfire.png',
|
logo: '/images/flyaspitfire.png',
|
||||||
alt: 'Fly A Spitfire logo',
|
alt: 'Fly A Spitfire logo',
|
||||||
description: 'Spitfire flight experiences and aviation events.',
|
description: 'Spitfire flights',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'AeroSuperBatics',
|
name: 'AeroSuperBatics',
|
||||||
href: 'https://www.aerosuperbatics.com/',
|
href: 'https://www.aerosuperbatics.com/',
|
||||||
logo: '/images/aerosuperbatics.jpg',
|
logo: '/images/aerosuperbatics.jpg',
|
||||||
alt: 'AeroSuperBatics logo',
|
alt: 'AeroSuperBatics logo',
|
||||||
description: 'Wingwalking and aerobatic entertainment flights.',
|
description: 'Wingwalking and aerobatic entertainment flights',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Home" description="Fast, clear airfield information for pilots and visitors.">
|
<BaseLayout title="Home" description="Fast, clear airfield information for pilots and visitors.">
|
||||||
<BannerRotator images={bannerImages} />
|
<BannerRotator images={bannerImages} randomizeAfterFirst />
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container hero-stack">
|
<div class="container hero-stack">
|
||||||
@@ -74,7 +75,7 @@ const businessPromos = [
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="container stack">
|
<div class="container stack">
|
||||||
<FuelPricesWidget fuelPrices={fuelPrices} />
|
<FuelPricesWidget fuelPrices={fuelPrices} moreInfoHref="/pilot-info/#fuel" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="container business-promo">
|
<section class="container business-promo">
|
||||||
@@ -140,7 +141,7 @@ const businessPromos = [
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<figure class="story-image">
|
<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>
|
</figure>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getFuelPrices } from '../lib/directus';
|
|||||||
import { site } from '../lib/site';
|
import { site } from '../lib/site';
|
||||||
|
|
||||||
const runwayPattern = /^Runway\s+(\d+)\s+-\s+([^\s]+)\s+LDA\s+\(([^)]+)\)\s+Code\s+(\d+)\s+\(([^)]+)\)$/;
|
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
|
const runwayRows = site.runwayFacts
|
||||||
.map((fact) => {
|
.map((fact) => {
|
||||||
@@ -19,6 +20,10 @@ const runwayRows = site.runwayFacts
|
|||||||
|
|
||||||
const otherFacts = site.runwayFacts.filter((fact) => !fact.match(runwayPattern));
|
const otherFacts = site.runwayFacts.filter((fact) => !fact.match(runwayPattern));
|
||||||
const fuelPrices = await getFuelPrices();
|
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.">
|
<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>
|
<tbody>
|
||||||
{runwayRows.map((runway) => (
|
{runwayRows.map((runway) => (
|
||||||
<tr>
|
<tr>
|
||||||
<td>{runway.runway}</td>
|
<td data-label={runwayHeaders[0]}>{runway.runway}</td>
|
||||||
<td>{runway.lda}</td>
|
<td data-label={runwayHeaders[1]}>{runway.lda}</td>
|
||||||
<td>{runway.surface}</td>
|
<td data-label={runwayHeaders[2]}>{runway.surface}</td>
|
||||||
<td>{runway.code}</td>
|
<td data-label={runwayHeaders[3]}>{runway.code}</td>
|
||||||
<td>{runway.circuits}</td>
|
<td data-label={runwayHeaders[4]}>{runway.circuits}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -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
@@ -190,34 +190,91 @@ img {
|
|||||||
|
|
||||||
.mobile-nav > summary {
|
.mobile-nav > summary {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 2.75rem;
|
gap: 0.45rem;
|
||||||
min-width: 2.75rem;
|
min-height: 3rem;
|
||||||
padding: 0;
|
min-width: 5.5rem;
|
||||||
|
padding: 0 0.85rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.85rem;
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(228, 240, 252, 0.95));
|
background: var(--brand);
|
||||||
color: var(--brand);
|
color: #ffffff;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 1.4rem;
|
font-size: 0.96rem;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
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 {
|
.mobile-nav > summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav > summary::before {
|
.mobile-nav-icon {
|
||||||
content: '☰';
|
position: relative;
|
||||||
line-height: 1;
|
display: block;
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-block: 0.18rem solid currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav[open] > summary::before {
|
.mobile-nav-icon::before,
|
||||||
content: '✕';
|
.mobile-nav-icon::after {
|
||||||
font-size: 1.2rem;
|
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 {
|
.mobile-nav-panel {
|
||||||
@@ -406,12 +463,18 @@ section {
|
|||||||
.story-image img {
|
.story-image img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 4 / 3;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.story-image .banner-rotator {
|
||||||
|
min-height: 0;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.business-promo {
|
.business-promo {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -897,7 +960,108 @@ section {
|
|||||||
max-height: 2rem;
|
max-height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 390px) {
|
||||||
|
.topbar-inner {
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mobile img {
|
||||||
|
max-width: min(12rem, 58vw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.runway-facts-table {
|
.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user