Event templates
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/’/g, "'")
|
||||
.replace(/‘/g, "'")
|
||||
.replace(/“/g, '"')
|
||||
.replace(/”/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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user