Compare commits

..

4 Commits

Author SHA1 Message Date
jamesp 5e77741907 Merge pull request 'jun-22-updates' (#3) from jun-22-updates into main
Reviewed-on: #3
2026-06-22 06:54:46 -04:00
jamesp 29092b467f Cafe and History tweaks 2026-06-22 06:53:23 -04:00
jamesp d18f75b144 Banner rotator respects tags 2026-06-22 06:40:49 -04:00
jamesp d1f41d91bb Drone scroll tweak 2026-06-22 06:00:04 -04:00
7 changed files with 217 additions and 21 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+15 -2
View File
@@ -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');
+21 -2
View File
@@ -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
View File
@@ -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 {
+1 -5
View File
@@ -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);
+117 -7
View File
@@ -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>
+1 -1
View File
@@ -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">