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