Event templates

This commit is contained in:
2026-06-20 17:37:29 -04:00
parent 17b2a5d835
commit 569c8cf80d
11 changed files with 173 additions and 35 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+25 -5
View File
@@ -29,7 +29,7 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
<div class="drone-field"> <div class="drone-field">
<label for="operator-id">Operator ID <span aria-hidden="true">*</span></label> <label for="operator-id">Operator ID <span aria-hidden="true">*</span></label>
<input type="text" id="operator-id" name="operator_id" autocomplete="off" required /> <input type="text" id="operator-id" name="operator_id" autocomplete="off" autocapitalize="characters" class="uppercase-input" required />
</div> </div>
<div class="drone-field"> <div class="drone-field">
@@ -39,7 +39,7 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
<div class="drone-field"> <div class="drone-field">
<label for="flyer-id">Flyer ID <span aria-hidden="true">*</span></label> <label for="flyer-id">Flyer ID <span aria-hidden="true">*</span></label>
<input type="text" id="flyer-id" name="flyer_id" autocomplete="off" required /> <input type="text" id="flyer-id" name="flyer_id" autocomplete="off" autocapitalize="characters" class="uppercase-input" required />
</div> </div>
<div class="drone-field"> <div class="drone-field">
@@ -402,13 +402,14 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
formData.forEach((value, key) => { formData.forEach((value, key) => {
const fieldValue = String(value).trim(); const fieldValue = String(value).trim();
if (!fieldValue) return; if (!fieldValue) return;
const normalizedValue = key === 'operator_id' || key === 'flyer_id' ? fieldValue.toUpperCase() : fieldValue;
if (key === 'maximum_elevation_ft_amsl') { if (key === 'maximum_elevation_ft_amsl') {
data[key] = Number.parseInt(fieldValue, 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(fieldValue); data[key] = Number.parseFloat(normalizedValue);
} else { } else {
data[key] = fieldValue; data[key] = normalizedValue;
} }
}); });
@@ -459,8 +460,23 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
} }
} }
function initializeUppercaseInputs() {
['operator-id', 'flyer-id'].forEach((id) => {
const input = get(id);
if (!input) return;
input.addEventListener('input', () => {
const cursorStart = input.selectionStart;
const cursorEnd = input.selectionEnd;
input.value = input.value.toUpperCase();
input.setSelectionRange(cursorStart, cursorEnd);
});
});
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initializeTimeDropdowns(); initializeTimeDropdowns();
initializeUppercaseInputs();
setDefaultDateTime(); setDefaultDateTime();
get('drone-form').addEventListener('submit', handleSubmit); get('drone-form').addEventListener('submit', handleSubmit);
initializeMap().catch((error) => { initializeMap().catch((error) => {
@@ -539,6 +555,10 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
resize: vertical; resize: vertical;
} }
.uppercase-input {
text-transform: uppercase;
}
.drone-field input:focus, .drone-field input:focus,
.drone-field select:focus, .drone-field select:focus,
.drone-field textarea:focus { .drone-field textarea:focus {
+4 -1
View File
@@ -21,6 +21,8 @@ const { events, title = 'Upcoming events', description = 'A quick scan list for
const normalizedSlug = normalizeSlug(event.slug); const normalizedSlug = normalizeSlug(event.slug);
const detailHref = normalizedSlug ? `/events/${normalizedSlug}/` : undefined; const detailHref = normalizedSlug ? `/events/${normalizedSlug}/` : undefined;
const summary = event.summary?.trim() || event.description; const summary = event.summary?.trim() || event.description;
const time = formatTime(event.start_datetime);
const logoSrc = typeof event.logo === 'string' ? event.logo : event.logo?.id;
return ( return (
<article class="card event-card"> <article class="card event-card">
@@ -29,7 +31,8 @@ const { events, title = 'Upcoming events', description = 'A quick scan list for
<div class="event-date-block"> <div class="event-date-block">
<p class="event-weekday">{formatWeekday(event.start_datetime)}</p> <p class="event-weekday">{formatWeekday(event.start_datetime)}</p>
<p class="event-date">{formatDate(event.start_datetime)}</p> <p class="event-date">{formatDate(event.start_datetime)}</p>
<p class="event-time">{formatTime(event.start_datetime)}</p> {time && <p class="event-time">{time}</p>}
{logoSrc && <img class="event-logo" src={logoSrc} alt={`${event.title} logo`} loading="lazy" />}
</div> </div>
<div> <div>
<h3>{event.title}</h3> <h3>{event.title}</h3>
+75 -2
View File
@@ -52,6 +52,22 @@ type DirectusFile = {
type?: string; type?: string;
}; };
type EventTemplateRecord = {
id: number;
title?: string;
slug?: string;
description?: string;
image?: string;
logo?: string;
booking_url?: string;
};
type EventDateRecord = {
id: number;
date?: string;
template?: EventTemplateRecord | number | null;
};
function directusHeaders(): Record<string, string> | undefined { function directusHeaders(): Record<string, string> | undefined {
if (!directusToken) return undefined; if (!directusToken) return undefined;
return { Authorization: `Bearer ${directusToken}` }; return { Authorization: `Bearer ${directusToken}` };
@@ -97,7 +113,7 @@ async function readDirectusEndpoint<T>(endpoint: URL): Promise<T[]> {
return payload.data ?? []; return payload.data ?? [];
} }
function resolveDirectusAssetUrl(fileId: string): string { export function resolveDirectusAssetUrl(fileId: string): string {
return new URL(`/assets/${fileId}`, directusPublicUrl).toString(); return new URL(`/assets/${fileId}`, directusPublicUrl).toString();
} }
@@ -138,6 +154,56 @@ async function getImagesFromFolder(folderName: string, fallbackImages: HomepageB
export const getHomepageBannerImages = () => getImagesFromFolder(homepageBannerFolder, fallbackHomepageBannerImages); export const getHomepageBannerImages = () => getImagesFromFolder(homepageBannerFolder, fallbackHomepageBannerImages);
export const getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages); export const getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages);
function stripHtml(value = ''): string {
return value
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&rsquo;/g, "'")
.replace(/&lsquo;/g, "'")
.replace(/&ldquo;/g, '"')
.replace(/&rdquo;/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
function mapEventDateToEventItem(eventDate: EventDateRecord): EventItem | null {
if (!eventDate.date || !eventDate.template || typeof eventDate.template === 'number') return null;
const template = eventDate.template;
if (!template.title || !template.slug) return null;
const description = template.description ?? '';
const summary = stripHtml(description).slice(0, 180);
return {
id: `${template.id}-${eventDate.id}`,
date_id: eventDate.id,
template_id: template.id,
title: template.title,
slug: template.slug,
summary: summary ? `${summary}${summary.length === 180 ? '...' : ''}` : undefined,
description,
start_datetime: eventDate.date,
realimage: template.image,
logo: template.logo ? resolveDirectusAssetUrl(template.logo) : undefined,
registration_link: template.booking_url,
};
}
async function getRecurringEvents(): Promise<EventItem[]> {
const endpoint = new URL('/items/event_dates', directusUrl);
endpoint.searchParams.set('limit', '100');
endpoint.searchParams.set('sort', 'date');
endpoint.searchParams.set(
'fields',
'id,date,template.id,template.title,template.slug,template.description,template.image,template.logo,template.booking_url',
);
const eventDates = await readDirectusEndpoint<EventDateRecord>(endpoint);
return eventDates.map(mapEventDateToEventItem).filter((event): event is EventItem => event !== null);
}
function fallbackFor(collection: CollectionName) { function fallbackFor(collection: CollectionName) {
switch (collection) { switch (collection) {
case 'news': case 'news':
@@ -156,7 +222,14 @@ function fallbackFor(collection: CollectionName) {
} }
export const getNews = () => readCollection<NewsItem>('news'); export const getNews = () => readCollection<NewsItem>('news');
export const getEvents = () => readCollection<EventItem>('events'); export async function getEvents(): Promise<EventItem[]> {
try {
const recurringEvents = await getRecurringEvents();
return recurringEvents.length > 0 ? recurringEvents : readCollection<EventItem>('events');
} catch {
return readCollection<EventItem>('events');
}
}
export const getNotices = () => readCollection<Notice>('notices'); export const getNotices = () => readCollection<Notice>('notices');
export const getFuelPrices = () => readCollection<FuelPrice>('fuel_prices'); export const getFuelPrices = () => readCollection<FuelPrice>('fuel_prices');
export const getDocuments = () => readCollection<DocumentItem>('documents'); export const getDocuments = () => readCollection<DocumentItem>('documents');
+7 -2
View File
@@ -1,13 +1,18 @@
import type { EventItem } from './fallback-data'; import type { EventItem } from './fallback-data';
function isDateOnly(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(value);
}
function getEventEndTime(event: EventItem): number { function getEventEndTime(event: EventItem): number {
const value = event.end_datetime || event.start_datetime; const value = event.end_datetime || event.start_datetime;
const timestamp = new Date(value).getTime(); const timestamp = new Date(isDateOnly(value) ? `${value}T23:59:59` : value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0; return Number.isFinite(timestamp) ? timestamp : 0;
} }
function getEventStartTime(event: EventItem): number { function getEventStartTime(event: EventItem): number {
const timestamp = new Date(event.start_datetime).getTime(); const value = event.start_datetime;
const timestamp = new Date(isDateOnly(value) ? `${value}T00:00:00` : value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0; return Number.isFinite(timestamp) ? timestamp : 0;
} }
+4
View File
@@ -18,6 +18,7 @@ export type FuelPrice = {
}; };
export type EventItem = { export type EventItem = {
id?: number | string;
title: string; title: string;
slug: string; slug: string;
summary?: string; summary?: string;
@@ -27,9 +28,12 @@ export type EventItem = {
location_text?: string; location_text?: string;
registration_link?: string; registration_link?: string;
realimage?: string | { id?: string; filename_download?: string; title?: string }; realimage?: string | { id?: string; filename_download?: string; title?: string };
logo?: string | { id?: string; filename_download?: string; title?: string };
status?: string; status?: string;
is_featured?: boolean; is_featured?: boolean;
tags?: string[]; tags?: string[];
template_id?: number | string;
date_id?: number | string;
}; };
export type NewsItem = { export type NewsItem = {
+8
View File
@@ -15,6 +15,10 @@ export function formatDateTime(value?: string) {
return 'To be confirmed'; return 'To be confirmed';
} }
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return formatDate(value);
}
return new Intl.DateTimeFormat('en-GB', { return new Intl.DateTimeFormat('en-GB', {
day: '2-digit', day: '2-digit',
month: 'short', month: 'short',
@@ -39,6 +43,10 @@ export function formatTime(value?: string) {
return 'Time TBC'; return 'Time TBC';
} }
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return '';
}
return new Intl.DateTimeFormat('en-GB', { return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
+7 -16
View File
@@ -13,33 +13,24 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
</p> </p>
<p> <p>
You are likely aware of the map adjacent: the purple rectangles show the Runway Protection 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.
Zones, the purple circle is the Flight Restriction Zone (FRZ), and the red circle is the
Parachuting Protection Zone.
</p> </p>
<p> <p>
We ask all drone operators operating in the vicinity of the aerodrome to let us know with as <img src="/images/FRZ.png" alt="Swansea Airport Flight Restriction Zone and Runway Protection Zones map" loading="lazy" />
much notice as possible via email, with the following information:
</p> </p>
<p> <p>
<a class="button primary" href="/drone-flight-request/">Request a Drone Flight</a> 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>
<ul> <p>
<li>Date of flight</li> <center><a class="button primary" href="/drone-flight-request/">Request a Drone Flight</a></center>
<li>Estimated take-off time</li> </p>
<li>Estimated completion time</li>
<li>Location</li>
<li>Maximum elevation (feet above mean sea level)</li>
<li>Details of the Operator and Flyer ID</li>
</ul>
<p> <p>
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 email 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.
us.
</p> </p>
<p> <p>
+28 -8
View File
@@ -1,7 +1,8 @@
--- ---
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import { getEvents } from '../../lib/directus'; import { getEvents } from '../../lib/directus';
import { formatDateTime } from '../../lib/format'; import { getUpcomingEvents } from '../../lib/events';
import { formatDate, formatDateTime } from '../../lib/format';
import { normalizeSlug } from '../../lib/slug'; import { normalizeSlug } from '../../lib/slug';
type EventItem = Awaited<ReturnType<typeof getEvents>>[number]; type EventItem = Awaited<ReturnType<typeof getEvents>>[number];
@@ -37,29 +38,48 @@ function resolveEventImageSource(realimage: EventItem['realimage']): string | nu
} }
export async function getStaticPaths() { export async function getStaticPaths() {
const events = await getEvents(); const events = getUpcomingEvents(await getEvents());
const paths = new Map<string, { params: { slug: string }; props: { item: EventItem } }>(); const paths = new Map<string, { params: { slug: string }; props: { item: EventItem; dates: EventItem[] } }>();
for (const item of events) { for (const item of events) {
const slug = normalizeSlug(item.slug); const slug = normalizeSlug(item.slug);
if (!slug || paths.has(slug)) continue; if (!slug) continue;
paths.set(slug, { params: { slug }, props: { item } });
const existing = paths.get(slug);
if (existing) {
existing.props.dates.push(item);
continue;
}
paths.set(slug, { params: { slug }, props: { item, dates: [item] } });
} }
return Array.from(paths.values()); return Array.from(paths.values());
} }
const { item } = Astro.props as { item: EventItem }; const { item, dates } = Astro.props as { item: EventItem; dates: EventItem[] };
const imageSrc = resolveEventImageSource(item.realimage); const imageSrc = resolveEventImageSource(item.realimage);
const imageAlt = item.title; const imageAlt = item.title;
const hasHtmlDescription = /<[^>]+>/.test(item.description);
--- ---
<BaseLayout title={item.title} description={item.description}> <BaseLayout title={item.title} description={item.description}>
<article class="container prose"> <article class="container prose">
<p class="meta">{formatDateTime(item.start_datetime)}</p> <p class="meta">{dates.length === 1 ? formatDateTime(item.start_datetime) : `${dates.length} upcoming dates`}</p>
<h1 class="section-title">{item.title}</h1> <h1 class="section-title">{item.title}</h1>
{imageSrc && <p><img src={imageSrc} alt={imageAlt} loading="lazy" /></p>} {imageSrc && <p><img src={imageSrc} alt={imageAlt} loading="lazy" /></p>}
{item.description && <p class="text-multiline">{item.description}</p>} {item.description && hasHtmlDescription && <div class="text-multiline" set:html={item.description} />}
{item.description && !hasHtmlDescription && <p class="text-multiline">{item.description}</p>}
{dates.length > 0 && (
<>
<h2>Upcoming dates</h2>
<ul>
{dates.map((date) => (
<li>{formatDate(date.start_datetime)}</li>
))}
</ul>
</>
)}
{item.location_text && <p><strong>Location:</strong> {item.location_text}</p>} {item.location_text && <p><strong>Location:</strong> {item.location_text}</p>}
{item.registration_link && <p><a class="button primary" href={item.registration_link}>Register</a></p>} {item.registration_link && <p><a class="button primary" href={item.registration_link}>Register</a></p>}
</article> </article>
+3 -1
View File
@@ -18,7 +18,9 @@ const [notices, fuelPrices, events, news, bannerImages] = await Promise.all([
getHomepageBannerImages(), getHomepageBannerImages(),
]); ]);
const featuredEvents = getUpcomingEvents(events).filter((event) => event.is_featured).slice(0, 3); const upcomingEvents = getUpcomingEvents(events);
const highlightedEvents = upcomingEvents.filter((event) => event.is_featured);
const featuredEvents = (highlightedEvents.length > 0 ? highlightedEvents : upcomingEvents).slice(0, 3);
const latestNews = news.slice(0, 3); const latestNews = news.slice(0, 3);
const businessPromos = [ const businessPromos = [
+12
View File
@@ -680,6 +680,18 @@ section {
background: linear-gradient(180deg, rgba(11, 79, 122, 0.1), rgba(29, 118, 184, 0.06)); background: linear-gradient(180deg, rgba(11, 79, 122, 0.1), rgba(29, 118, 184, 0.06));
} }
.event-logo {
width: 100%;
height: 3.6rem;
object-fit: contain;
margin-top: 0.85rem;
padding: 0.45rem;
border: 1px solid rgba(16, 34, 51, 0.1);
border-radius: 0.65rem;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 8px 18px rgba(16, 34, 51, 0.08);
}
.event-weekday, .event-weekday,
.event-date, .event-date,
.event-time { .event-time {