Compare commits
28 Commits
ca88b6cdcc
...
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 | |||
| dbd177871e | |||
| 2fcdb5c033 | |||
| bae649ef13 |
@@ -4,8 +4,6 @@ Production-ready airfield website stack built with Astro, Directus, PostgreSQL,
|
|||||||
|
|
||||||
## Local setup
|
## Local setup
|
||||||
|
|
||||||
Making a change here, what happens?
|
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env` and fill in the values.
|
1. Copy `.env.example` to `.env` and fill in the values.
|
||||||
2. Start the stack with Docker Compose.
|
2. Start the stack with Docker Compose.
|
||||||
3. Point your external Caddy instance at the frontend and Directus ports defined in `.env`.
|
3. Point your external Caddy instance at the frontend and Directus ports defined in `.env`.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 252 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
---
|
||||||
|
const configuredApiBase = import.meta.env.PUBLIC_PPR_API_BASE ?? 'https://ppr.swansea-airport.wales/api/v1';
|
||||||
|
const pprApiBase = configuredApiBase.replace(/\/$/, '');
|
||||||
|
const contactRequestEndpoint = `${pprApiBase}/contact-requests/public`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="contact-shell surface" aria-labelledby="contact-form-heading">
|
||||||
|
<div class="contact-head">
|
||||||
|
<p class="eyebrow">General enquiries</p>
|
||||||
|
<h2 id="contact-form-heading" class="section-title">Send us a message</h2>
|
||||||
|
<p class="section-copy">
|
||||||
|
Use this form for general airport, business, visiting, and community enquiries. For flight
|
||||||
|
requests, please use the dedicated PPR or drone request forms.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="contact-form" class="contact-form">
|
||||||
|
<div class="contact-honeypot" aria-hidden="true">
|
||||||
|
<label for="website">Website</label>
|
||||||
|
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-grid">
|
||||||
|
<div class="contact-field">
|
||||||
|
<label for="contact-name">Name <span aria-hidden="true">*</span></label>
|
||||||
|
<input type="text" id="contact-name" name="name" autocomplete="name" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-field">
|
||||||
|
<label for="contact-email">Email Address <span aria-hidden="true">*</span></label>
|
||||||
|
<input type="email" id="contact-email" name="email" autocomplete="email" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-field">
|
||||||
|
<label for="contact-phone">Phone Number</label>
|
||||||
|
<input type="tel" id="contact-phone" name="phone" autocomplete="tel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-field">
|
||||||
|
<label for="contact-enquiry-type">Enquiry Type <span aria-hidden="true">*</span></label>
|
||||||
|
<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="events">Events and visits</option>
|
||||||
|
<option value="community">Community or local resident</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-field contact-full">
|
||||||
|
<label for="contact-subject">Subject <span aria-hidden="true">*</span></label>
|
||||||
|
<input type="text" id="contact-subject" name="subject" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-field contact-full">
|
||||||
|
<label for="contact-message">Message <span aria-hidden="true">*</span></label>
|
||||||
|
<textarea id="contact-message" name="message" rows="6" required></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-actions">
|
||||||
|
<button type="submit" class="button primary" id="contact-submit-btn">Send Message</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="contact-loading" id="contact-loading" role="status" aria-live="polite">
|
||||||
|
<span class="contact-spinner" aria-hidden="true"></span>
|
||||||
|
Sending your message...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-success notice" id="contact-success-message" role="status" aria-live="polite">
|
||||||
|
<h3>Message sent.</h3>
|
||||||
|
<p>Thanks for getting in touch. The airport team will review your message and respond where needed.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="contact-notification" class="contact-notification" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script define:vars={{ contactRequestEndpoint }}>
|
||||||
|
(() => {
|
||||||
|
const CONTACT_REQUEST_ENDPOINT = contactRequestEndpoint;
|
||||||
|
const get = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function showNotification(message, isError = false) {
|
||||||
|
const notification = get('contact-notification');
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.classList.toggle('error', isError);
|
||||||
|
notification.classList.add('show');
|
||||||
|
window.setTimeout(() => notification.classList.remove('show'), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContactRequestData(form) {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: String(formData.get('name') || '').trim(),
|
||||||
|
email: String(formData.get('email') || '').trim(),
|
||||||
|
phone: String(formData.get('phone') || '').trim(),
|
||||||
|
enquiry_type: String(formData.get('enquiry_type') || '').trim(),
|
||||||
|
subject: String(formData.get('subject') || '').trim(),
|
||||||
|
message: String(formData.get('message') || '').trim(),
|
||||||
|
source_page: window.location.pathname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const submitButton = get('contact-submit-btn');
|
||||||
|
const loading = get('contact-loading');
|
||||||
|
const successMessage = get('contact-success-message');
|
||||||
|
|
||||||
|
if (form.website?.value) {
|
||||||
|
form.reset();
|
||||||
|
successMessage.style.display = 'block';
|
||||||
|
form.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.textContent = 'Sending...';
|
||||||
|
loading.style.display = 'flex';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(CONTACT_REQUEST_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(buildContactRequestData(form)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.style.display = 'none';
|
||||||
|
successMessage.style.display = 'block';
|
||||||
|
showNotification('Message sent successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting contact request:', error);
|
||||||
|
showNotification(`Error sending message: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.textContent = 'Send Message';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
get('contact-form').addEventListener('submit', handleSubmit);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.contact-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-block: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-head,
|
||||||
|
.contact-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-field label {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-field label span {
|
||||||
|
color: var(--critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-field input,
|
||||||
|
.contact-field select,
|
||||||
|
.contact-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.85rem;
|
||||||
|
border: 1px solid rgba(16, 34, 51, 0.16);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.7rem 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-field textarea {
|
||||||
|
min-height: 9rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-field input:focus,
|
||||||
|
.contact-field select:focus,
|
||||||
|
.contact-field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand-2);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(29, 118, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions .button {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions .button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-loading {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
color: var(--brand);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-spinner {
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
border: 3px solid rgba(29, 118, 184, 0.18);
|
||||||
|
border-top-color: var(--brand-2);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: contact-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-success {
|
||||||
|
display: none;
|
||||||
|
border-left-color: #257b4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 60;
|
||||||
|
max-width: min(24rem, calc(100vw - 2rem));
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: #257b4c;
|
||||||
|
color: white;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-weight: 800;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-0.65rem);
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-notification.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-notification.error {
|
||||||
|
background: var(--critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-honeypot {
|
||||||
|
position: absolute;
|
||||||
|
left: -100vw;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contact-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.contact-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions,
|
||||||
|
.contact-actions .button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ type Props = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { events, title = 'Upcoming events', description = 'A quick scan list for pilots, visitors, and local supporters.' } = Astro.props as Props;
|
const { events, title = 'Upcoming events', } = Astro.props as Props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading eyebrow="Events" title={title} description={description} />
|
<SectionHeading eyebrow="Events" title={title} />
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
{events.length > 0 ? (
|
{events.length > 0 ? (
|
||||||
events.map((event) => {
|
events.map((event) => {
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
@@ -84,7 +87,7 @@ const pageTitle = title ? `${title} · ${site.name}` : site.name;
|
|||||||
<p class="eyebrow">Phone</p>
|
<p class="eyebrow">Phone</p>
|
||||||
<p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p>
|
<p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p>
|
||||||
<p class="eyebrow">Email</p>
|
<p class="eyebrow">Email</p>
|
||||||
<p><a href="mailto:info@swansea-airport.wales">info@swansea-airport.wales</a></p>
|
<p><a href="mailto:tower@swansea-airport.wales">tower@swansea-airport.wales</a></p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<p class="eyebrow">Contact</p>
|
<p class="eyebrow">Contact</p>
|
||||||
|
|||||||
+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',
|
||||||
@@ -138,7 +152,7 @@ export const fallbackContacts: ContactItem[] = [
|
|||||||
{
|
{
|
||||||
name: 'Airport office',
|
name: 'Airport office',
|
||||||
role: 'General enquiries',
|
role: 'General enquiries',
|
||||||
email: 'info@swansea-airport.wales',
|
email: 'tower@swansea-airport.wales',
|
||||||
phone: '01792 687 042',
|
phone: '01792 687 042',
|
||||||
is_public: true,
|
is_public: true,
|
||||||
order: 1,
|
order: 1,
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
+122
-12
@@ -3,47 +3,157 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Drones" description="Drone operating guidance and coordination process for Swansea Airport.">
|
<BaseLayout title="Drones" description="Drone operating guidance and coordination process for Swansea Airport.">
|
||||||
<section class="container prose">
|
<script is:inline>
|
||||||
<p class="eyebrow">About</p>
|
document.documentElement.classList.add('js');
|
||||||
<h1 class="section-title">Drones</h1>
|
</script>
|
||||||
|
|
||||||
<p>
|
<section class="container prose drones-page">
|
||||||
|
<figure class="drone-banner" data-drone-banner>
|
||||||
|
<img src="/images/avata2.jpg" alt="Drone aerial view over Swansea Airport" />
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<p class="eyebrow reveal-item" data-reveal>About</p>
|
||||||
|
<h1 class="section-title reveal-item" data-reveal>Drones</h1>
|
||||||
|
|
||||||
|
<p class="reveal-item" data-reveal>
|
||||||
Firstly, please be assured that Swansea Airport likes to help our drone flying colleagues
|
Firstly, please be assured that Swansea Airport likes to help our drone flying colleagues
|
||||||
where we possibly can.
|
where we possibly can.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
You are likely aware that Swansea Airport has a Flight Restriction Zone (FRZ) and two Runway Protection Zones (RPZs) that are in place to protect the public and aircraft from the risk of an accident.
|
You are likely aware that Swansea Airport has a Flight Restriction Zone (FRZ) and two Runway Protection Zones (RPZs) that are in place to protect the public and aircraft from the risk of an accident.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
<img src="/images/FRZ.png" alt="Swansea Airport Flight Restriction Zone and Runway Protection Zones map" loading="lazy" />
|
<img src="/images/FRZ.png" alt="Swansea Airport Flight Restriction Zone and Runway Protection Zones map" loading="lazy" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
It is a requirement to obtain permission from Swansea Airport before flying a drone within the FRZ or RPZs. This is to ensure that we can coordinate your flight with any other aircraft that may be operating in the area, and to ensure that you are aware of any operational considerations that may affect your flight.
|
It is a requirement to obtain permission from Swansea Airport before flying a drone within the FRZ or RPZs. This is to ensure that we can coordinate your flight with any other aircraft that may be operating in the area, and to ensure that you are aware of any operational considerations that may affect your flight.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
<center><a class="button primary" href="/drone-flight-request/">Request a Drone Flight</a></center>
|
<center><a class="button primary" href="/drone-flight-request/">Request a Drone Flight</a></center>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
We will then reply with any operational considerations and, if required, approve or decline a
|
We will then reply with any operational considerations and, if required, approve or decline a
|
||||||
flight if it is within the FRZ or RPZs. We are not in the habit of declining, so please do submit your request, and we will do our best to accommodate you.
|
flight if it is within the FRZ or RPZs. We are not in the habit of declining, so please do submit your request, and we will do our best to accommodate you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
After this, all we ask is that on the day, you call us in tower on
|
After this, all we ask is that on the day, you call us in tower on
|
||||||
<a href="tel:01792687042">01792 687042</a> twenty minutes prior to launch, so we can give
|
<a href="tel:01792687042">01792 687042</a> twenty minutes prior to launch, so we can give
|
||||||
you any operational updates and also broadcast a warning on our frequency to any aircraft.
|
you any operational updates and also broadcast a warning on our frequency to any aircraft.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Parachuting</h2>
|
<h2 class="reveal-item" data-reveal>Parachuting</h2>
|
||||||
<p>
|
<p class="reveal-item" data-reveal>
|
||||||
Parachuting tends to take place Friday to Sunday from Spring to Winter. This can involve free
|
Parachuting tends to take place Friday to Sunday from Spring to Winter. This can involve free
|
||||||
fall descents from 15,000' AMSL. We recommend avoiding these times due to the requirement to
|
fall descents from 15,000' AMSL. We recommend avoiding these times due to the requirement to
|
||||||
ensure the safety of descending skydivers.
|
ensure the safety of descending skydivers.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.drone-banner {
|
||||||
|
--drone-parallax: 0px;
|
||||||
|
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
aspect-ratio: 21 / 9;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drone-banner img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% + 6rem);
|
||||||
|
margin-top: -3rem;
|
||||||
|
object-fit: cover;
|
||||||
|
transform: translate3d(0, var(--drone-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) {
|
||||||
|
.drone-banner {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.drone-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-drone-banner]');
|
||||||
|
const revealItems = Array.from(document.querySelectorAll('[data-reveal]'));
|
||||||
|
|
||||||
|
if (!motionQuery.matches) {
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
const updateBanner = () => {
|
||||||
|
if (!banner) 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('--drone-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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import ContactForm from '../components/ContactForm.astro';
|
||||||
import ContactList from '../components/ContactList.astro';
|
import ContactList from '../components/ContactList.astro';
|
||||||
import { getContacts } from '../lib/directus';
|
import { getContacts } from '../lib/directus';
|
||||||
import { site } from '../lib/site';
|
import { site } from '../lib/site';
|
||||||
@@ -9,13 +10,9 @@ const contacts = await getContacts();
|
|||||||
|
|
||||||
<BaseLayout title="Contact" description="How to reach Swansea Airport and its public contacts.">
|
<BaseLayout title="Contact" description="How to reach Swansea Airport and its public contacts.">
|
||||||
<div class="container stack">
|
<div class="container stack">
|
||||||
<section class="prose">
|
|
||||||
<p class="eyebrow">Contact</p>
|
|
||||||
<h1 class="section-title">Reach the airport team</h1>
|
|
||||||
<p>{site.address}</p>
|
|
||||||
<p><a href={`tel:${site.phone.replace(/\s+/g, '')}`}>{site.phone}</a></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
|
<ContactForm />
|
||||||
<ContactList contacts={contacts} />
|
<ContactList contacts={contacts} />
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
+97
-19
@@ -1,21 +1,19 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import BannerRotator from '../components/BannerRotator.astro';
|
import BannerRotator from '../components/BannerRotator.astro';
|
||||||
import NoticeBanner from '../components/NoticeBanner.astro';
|
|
||||||
import FuelPricesWidget from '../components/FuelPricesWidget.astro';
|
import FuelPricesWidget from '../components/FuelPricesWidget.astro';
|
||||||
import EventsList from '../components/EventsList.astro';
|
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 { homepageHighlights } from '../lib/site';
|
import { getEvents, getFuelPrices, getHomepageBannerImages, getHomepageVolunteerImages, getNews } from '../lib/directus';
|
||||||
import { getEvents, getFuelPrices, getHomepageBannerImages, getNews, getNotices } from '../lib/directus';
|
|
||||||
|
|
||||||
const [notices, fuelPrices, events, news, bannerImages] = await Promise.all([
|
const [fuelPrices, events, news, bannerImages, volunteerImages] = await Promise.all([
|
||||||
getNotices(),
|
|
||||||
getFuelPrices(),
|
getFuelPrices(),
|
||||||
getEvents(),
|
getEvents(),
|
||||||
getNews(),
|
getNews(),
|
||||||
getHomepageBannerImages(),
|
getHomepageBannerImages(),
|
||||||
|
getHomepageVolunteerImages(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const upcomingEvents = getUpcomingEvents(events);
|
const upcomingEvents = getUpcomingEvents(events);
|
||||||
@@ -29,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">
|
||||||
@@ -75,10 +73,11 @@ const businessPromos = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="container stack">
|
|
||||||
|
|
||||||
<FuelPricesWidget fuelPrices={fuelPrices} />
|
<div class="container stack">
|
||||||
</div>
|
<FuelPricesWidget fuelPrices={fuelPrices} moreInfoHref="/pilot-info/#fuel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="container business-promo">
|
<section class="container business-promo">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -101,6 +100,30 @@ const businessPromos = [
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="aviation-base">
|
||||||
|
<div class="container aviation-base-inner">
|
||||||
|
<figure class="aviation-base-image">
|
||||||
|
<img src="/images/runway.jpeg" alt="Runway view at Swansea Airport" loading="lazy" />
|
||||||
|
</figure>
|
||||||
|
<div class="aviation-base-copy">
|
||||||
|
<p class="eyebrow">Aviation businesses</p>
|
||||||
|
<h2 class="section-title">Base your next chapter at Swansea Airport</h2>
|
||||||
|
<p>
|
||||||
|
With the new operators having secured a long-term lease, Swansea Airport is entering a
|
||||||
|
more stable period for investment, growth, and aviation activity.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We are keen to hear from interested parties with aviation businesses who want to explore
|
||||||
|
basing themselves at Swansea, from flight training and maintenance to aircraft services,
|
||||||
|
experiences, hangarage, and complementary airfield operations.
|
||||||
|
</p>
|
||||||
|
<div class="aviation-base-actions">
|
||||||
|
<a class="button primary" href="/contact/">Start a conversation</a>
|
||||||
|
<a class="button secondary" href="/about/">Learn about the airport</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="container story-flow">
|
<section class="container story-flow">
|
||||||
<article class="story-copy">
|
<article class="story-copy">
|
||||||
@@ -118,17 +141,72 @@ 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>
|
||||||
|
|
||||||
<div class="container stack">
|
<div class="container stack">
|
||||||
<NoticeBanner notices={notices} />
|
<EventsList events={featuredEvents} title="Upcoming events" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<EventsList events={featuredEvents} title="Upcoming events" description="Featured events are surfaced here first for quick scanning." />
|
|
||||||
<NewsFeed news={latestNews} />
|
<NewsFeed news={latestNews} />
|
||||||
<FacebookWidget />
|
<FacebookWidget />
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.aviation-base {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
border-block: 1px solid var(--line);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(11, 79, 122, 0.09), transparent 42%),
|
||||||
|
linear-gradient(315deg, rgba(246, 181, 56, 0.16), transparent 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aviation-base-inner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||||
|
gap: clamp(1.5rem, 4vw, 3rem);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aviation-base-image {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aviation-base-image img {
|
||||||
|
width: 100%;
|
||||||
|
min-height: clamp(18rem, 28vw, 25rem);
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aviation-base .section-title {
|
||||||
|
max-width: 18ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aviation-base-copy {
|
||||||
|
max-width: 58ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aviation-base-copy p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aviation-base-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.aviation-base-inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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