jun-22-updates #3
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
@@ -3,13 +3,14 @@ import type { HomepageBannerImage } from '../lib/fallback-data';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
images: HomepageBannerImage[];
|
images: HomepageBannerImage[];
|
||||||
|
randomizeAfterFirst?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { images } = Astro.props;
|
const { images, randomizeAfterFirst = false } = Astro.props;
|
||||||
const slides = images.length > 0 ? images : [{ src: '/images/banner.png', alt: 'Swansea Airport banner' }];
|
const slides = images.length > 0 ? images : [{ src: '/images/banner.png', alt: 'Swansea Airport banner' }];
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="banner-rotator" data-banner-rotator>
|
<div class="banner-rotator" data-banner-rotator data-randomize-after-first={randomizeAfterFirst ? 'true' : undefined}>
|
||||||
{slides.map((image, index) => (
|
{slides.map((image, index) => (
|
||||||
<img
|
<img
|
||||||
class:list={['banner-slide', { active: index === 0 }]}
|
class:list={['banner-slide', { active: index === 0 }]}
|
||||||
@@ -31,6 +32,18 @@ const slides = images.length > 0 ? images : [{ src: '/images/banner.png', alt: '
|
|||||||
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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|||||||
+62
-4
@@ -59,6 +59,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 +176,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 +249,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 +266,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,7 +288,11 @@ 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 getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages);
|
export const getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages);
|
||||||
|
|
||||||
function stripHtml(value = ''): string {
|
function stripHtml(value = ''): string {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -52,10 +52,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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -53,7 +53,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.">
|
||||||
<BannerRotator images={bannerImages} />
|
<BannerRotator images={bannerImages} randomizeAfterFirst />
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container hero-stack">
|
<div class="container hero-stack">
|
||||||
|
|||||||
Reference in New Issue
Block a user