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">
<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 class="drone-field">
@@ -39,7 +39,7 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
<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 />
<input type="text" id="flyer-id" name="flyer_id" autocomplete="off" autocapitalize="characters" class="uppercase-input" required />
</div>
<div class="drone-field">
@@ -402,13 +402,14 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
formData.forEach((value, key) => {
const fieldValue = String(value).trim();
if (!fieldValue) return;
const normalizedValue = key === 'operator_id' || key === 'flyer_id' ? fieldValue.toUpperCase() : fieldValue;
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') {
data[key] = Number.parseFloat(fieldValue);
data[key] = Number.parseFloat(normalizedValue);
} 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', () => {
initializeTimeDropdowns();
initializeUppercaseInputs();
setDefaultDateTime();
get('drone-form').addEventListener('submit', handleSubmit);
initializeMap().catch((error) => {
@@ -539,6 +555,10 @@ const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
resize: vertical;
}
.uppercase-input {
text-transform: uppercase;
}
.drone-field input:focus,
.drone-field select: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 detailHref = normalizedSlug ? `/events/${normalizedSlug}/` : undefined;
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 (
<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">
<p class="event-weekday">{formatWeekday(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>
<h3>{event.title}</h3>
+75 -2
View File
@@ -52,6 +52,22 @@ type DirectusFile = {
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 {
if (!directusToken) return undefined;
return { Authorization: `Bearer ${directusToken}` };
@@ -97,7 +113,7 @@ async function readDirectusEndpoint<T>(endpoint: URL): Promise<T[]> {
return payload.data ?? [];
}
function resolveDirectusAssetUrl(fileId: string): string {
export function resolveDirectusAssetUrl(fileId: string): string {
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 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) {
switch (collection) {
case 'news':
@@ -156,7 +222,14 @@ function fallbackFor(collection: CollectionName) {
}
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 getFuelPrices = () => readCollection<FuelPrice>('fuel_prices');
export const getDocuments = () => readCollection<DocumentItem>('documents');
+7 -2
View File
@@ -1,13 +1,18 @@
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 {
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;
}
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;
}
+4
View File
@@ -18,6 +18,7 @@ export type FuelPrice = {
};
export type EventItem = {
id?: number | string;
title: string;
slug: string;
summary?: string;
@@ -27,9 +28,12 @@ export type EventItem = {
location_text?: string;
registration_link?: string;
realimage?: string | { id?: string; filename_download?: string; title?: string };
logo?: string | { id?: string; filename_download?: string; title?: string };
status?: string;
is_featured?: boolean;
tags?: string[];
template_id?: number | string;
date_id?: number | string;
};
export type NewsItem = {
+8
View File
@@ -15,6 +15,10 @@ export function formatDateTime(value?: string) {
return 'To be confirmed';
}
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return formatDate(value);
}
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
@@ -39,6 +43,10 @@ export function formatTime(value?: string) {
return 'Time TBC';
}
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return '';
}
return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
+7 -16
View File
@@ -13,33 +13,24 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
</p>
<p>
You are likely aware of the map adjacent: the purple rectangles show the Runway Protection
Zones, the purple circle is the Flight Restriction Zone (FRZ), and the red circle is the
Parachuting Protection Zone.
You are likely aware that Swansea Airport has a Flight Restriction Zone (FRZ) and two Runway Protection Zones (RPZs) that are in place to protect the public and aircraft from the risk of an accident.
</p>
<p>
We ask all drone operators operating in the vicinity of the aerodrome to let us know with as
much notice as possible via email, with the following information:
<img src="/images/FRZ.png" alt="Swansea Airport Flight Restriction Zone and Runway Protection Zones map" loading="lazy" />
</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>
<ul>
<li>Date of flight</li>
<li>Estimated take-off time</li>
<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>
<center><a class="button primary" href="/drone-flight-request/">Request a Drone Flight</a></center>
</p>
<p>
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
us.
flight if it is within the FRZ or RPZs. We are not in the habit of declining, so please do submit your request, and we will do our best to accommodate you.
</p>
<p>
+28 -8
View File
@@ -1,7 +1,8 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
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';
type EventItem = Awaited<ReturnType<typeof getEvents>>[number];
@@ -37,29 +38,48 @@ function resolveEventImageSource(realimage: EventItem['realimage']): string | nu
}
export async function getStaticPaths() {
const events = await getEvents();
const paths = new Map<string, { params: { slug: string }; props: { item: EventItem } }>();
const events = getUpcomingEvents(await getEvents());
const paths = new Map<string, { params: { slug: string }; props: { item: EventItem; dates: EventItem[] } }>();
for (const item of events) {
const slug = normalizeSlug(item.slug);
if (!slug || paths.has(slug)) continue;
paths.set(slug, { params: { slug }, props: { item } });
if (!slug) continue;
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());
}
const { item } = Astro.props as { item: EventItem };
const { item, dates } = Astro.props as { item: EventItem; dates: EventItem[] };
const imageSrc = resolveEventImageSource(item.realimage);
const imageAlt = item.title;
const hasHtmlDescription = /<[^>]+>/.test(item.description);
---
<BaseLayout title={item.title} description={item.description}>
<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>
{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.registration_link && <p><a class="button primary" href={item.registration_link}>Register</a></p>}
</article>
+3 -1
View File
@@ -18,7 +18,9 @@ const [notices, fuelPrices, events, news, bannerImages] = await Promise.all([
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 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));
}
.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-date,
.event-time {