387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import {
|
|
fallbackCafePageImages,
|
|
fallbackContacts,
|
|
fallbackDocuments,
|
|
fallbackEvents,
|
|
fallbackFuelPrices,
|
|
fallbackGiftShopImages,
|
|
fallbackHomepageBannerImages,
|
|
fallbackHomepageVolunteerImages,
|
|
fallbackNews,
|
|
fallbackNotices,
|
|
type ContactItem,
|
|
type DocumentItem,
|
|
type EventItem,
|
|
type FuelPrice,
|
|
type HomepageBannerImage,
|
|
type NewsItem,
|
|
type Notice,
|
|
} from './fallback-data';
|
|
|
|
type CollectionName = 'news' | 'events' | 'notices' | 'fuel_prices' | 'documents' | 'contacts';
|
|
|
|
const defaultSortByCollection: Partial<Record<CollectionName, string>> = {
|
|
news: '-publish_date',
|
|
events: '-start_datetime',
|
|
notices: '-priority',
|
|
fuel_prices: 'fuel_type',
|
|
documents: '-uploaded_at',
|
|
contacts: 'order',
|
|
};
|
|
|
|
declare const process: {
|
|
env: Record<string, string | undefined>;
|
|
};
|
|
|
|
const directusUrl = process.env.DIRECTUS_URL ?? 'http://directus:8055';
|
|
const directusPublicUrl = process.env.DIRECTUS_PUBLIC_URL && !process.env.DIRECTUS_PUBLIC_URL.includes('example.com')
|
|
? process.env.DIRECTUS_PUBLIC_URL
|
|
: directusUrl;
|
|
const directusAssetBaseUrl = process.env.DIRECTUS_ASSET_BASE_URL && !process.env.DIRECTUS_ASSET_BASE_URL.includes('example.com')
|
|
? process.env.DIRECTUS_ASSET_BASE_URL
|
|
: undefined;
|
|
const directusAssetUrlTemplate =
|
|
process.env.DIRECTUS_ASSET_URL_TEMPLATE && !process.env.DIRECTUS_ASSET_URL_TEMPLATE.includes('example.com')
|
|
? process.env.DIRECTUS_ASSET_URL_TEMPLATE
|
|
: undefined;
|
|
const directusToken = process.env.DIRECTUS_TOKEN ?? process.env.DIRECTUS_ADMIN_TOKEN;
|
|
const directusDebug = ['1', 'true', 'yes', 'on'].includes((process.env.DIRECTUS_DEBUG ?? '').toLowerCase());
|
|
const homepageBannerFolder = process.env.DIRECTUS_HOMEPAGE_BANNER_FOLDER ?? 'homepage-banners';
|
|
const homepageVolunteersFolder = process.env.DIRECTUS_HOMEPAGE_VOLUNTEERS_FOLDER ?? 'homepage-volunteers';
|
|
const cafePageFolder = process.env.DIRECTUS_CAFE_PAGE_FOLDER ?? 'cafe-page';
|
|
const giftShopFolder = process.env.DIRECTUS_GIFT_SHOP_FOLDER ?? 'gift-shop';
|
|
|
|
type DirectusFolder = {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
|
|
type DirectusFile = {
|
|
id: string;
|
|
title?: string;
|
|
description?: string;
|
|
filename_download?: string;
|
|
filename_disk?: string;
|
|
type?: string;
|
|
tags?: string[] | string | null;
|
|
};
|
|
|
|
type ImageFolderOptions = {
|
|
firstTag?: string;
|
|
shuffleRest?: boolean;
|
|
};
|
|
|
|
type EventTemplateRecord = {
|
|
id: number;
|
|
title?: string;
|
|
slug?: string;
|
|
description?: string;
|
|
image?: string | DirectusFile;
|
|
logo?: string | DirectusFile;
|
|
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}` };
|
|
}
|
|
|
|
function directusLog(message: string): void {
|
|
if (!directusDebug) return;
|
|
console.warn(`[directus] ${message}`);
|
|
}
|
|
|
|
function directusEndpointLabel(endpoint: URL): string {
|
|
return `${endpoint.pathname}${endpoint.search}`;
|
|
}
|
|
|
|
async function readCollection<T>(collection: CollectionName): Promise<T[]> {
|
|
const endpoint = new URL(`/items/${collection}`, directusUrl);
|
|
endpoint.searchParams.set('limit', '100');
|
|
const sort = defaultSortByCollection[collection];
|
|
if (sort) {
|
|
endpoint.searchParams.set('sort', sort);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
headers: directusHeaders(),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Directus responded with ${response.status}`);
|
|
}
|
|
|
|
const payload = (await response.json()) as { data?: T[] };
|
|
const data = payload.data ?? [];
|
|
directusLog(`collection ${collection}: read ${data.length} item(s)`);
|
|
return data;
|
|
} catch (error) {
|
|
directusLog(`collection ${collection}: using fallback after ${error instanceof Error ? error.message : String(error)}`);
|
|
return fallbackFor(collection) as T[];
|
|
}
|
|
}
|
|
|
|
async function readDirectusEndpoint<T>(endpoint: URL): Promise<T[]> {
|
|
directusLog(`GET ${directusEndpointLabel(endpoint)}`);
|
|
let response = await fetch(endpoint, {
|
|
headers: directusHeaders(),
|
|
});
|
|
|
|
if (response.status === 403 && directusToken) {
|
|
directusLog(`GET ${directusEndpointLabel(endpoint)} returned 403 with token; retrying without token`);
|
|
response = await fetch(endpoint);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let detail = '';
|
|
try {
|
|
const payload = (await response.json()) as { errors?: Array<{ message?: string; extensions?: { code?: string; reason?: string } }> };
|
|
const firstError = payload.errors?.[0];
|
|
detail = firstError?.extensions?.reason || firstError?.message || '';
|
|
} catch {
|
|
detail = '';
|
|
}
|
|
|
|
throw new Error(`Directus responded with ${response.status}${detail ? `: ${detail}` : ''}`);
|
|
}
|
|
|
|
const payload = (await response.json()) as { data?: T[] };
|
|
const data = payload.data ?? [];
|
|
directusLog(`GET ${directusEndpointLabel(endpoint)} returned ${data.length} item(s)`);
|
|
return data;
|
|
}
|
|
|
|
function extensionFromFilename(filename?: string): string {
|
|
if (!filename) return '';
|
|
|
|
const lastSegment = filename.split('/').pop() ?? '';
|
|
const dotIndex = lastSegment.lastIndexOf('.');
|
|
|
|
if (dotIndex <= 0 || dotIndex === lastSegment.length - 1) return '';
|
|
return lastSegment.slice(dotIndex);
|
|
}
|
|
|
|
function directusFileId(file: string | DirectusFile): string {
|
|
return typeof file === 'string' ? file : file.id;
|
|
}
|
|
|
|
function directusObjectKey(file: string | DirectusFile): string {
|
|
if (typeof file !== 'string' && file.filename_disk) {
|
|
return file.filename_disk;
|
|
}
|
|
|
|
const fileId = directusFileId(file);
|
|
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 {
|
|
const fileId = directusFileId(file);
|
|
const r2ObjectKey = directusObjectKey(file);
|
|
const encodedObjectKey = encodeURIComponent(r2ObjectKey);
|
|
|
|
if (directusAssetUrlTemplate) {
|
|
return directusAssetUrlTemplate
|
|
.replaceAll('{fileId}', encodedObjectKey)
|
|
.replaceAll('{id}', encodedObjectKey)
|
|
.replaceAll('{key}', encodedObjectKey);
|
|
}
|
|
|
|
if (directusAssetBaseUrl) {
|
|
const baseUrl = directusAssetBaseUrl.endsWith('/') ? directusAssetBaseUrl : `${directusAssetBaseUrl}/`;
|
|
return new URL(encodedObjectKey, baseUrl).toString();
|
|
}
|
|
|
|
return new URL(`/assets/${fileId}`, directusPublicUrl).toString();
|
|
}
|
|
|
|
async function findFolderByName(name: string): Promise<DirectusFolder | null> {
|
|
const endpoint = new URL('/folders', directusUrl);
|
|
endpoint.searchParams.set('limit', '1');
|
|
endpoint.searchParams.set('filter[name][_eq]', name);
|
|
endpoint.searchParams.set('fields', 'id,name');
|
|
|
|
const folders = await readDirectusEndpoint<DirectusFolder>(endpoint);
|
|
return folders[0] ?? null;
|
|
}
|
|
|
|
async function getImagesFromFolder(
|
|
folderName: string,
|
|
fallbackImages: HomepageBannerImage[],
|
|
options: ImageFolderOptions = {},
|
|
): Promise<HomepageBannerImage[]> {
|
|
try {
|
|
const folder = await findFolderByName(folderName);
|
|
if (!folder) {
|
|
directusLog(`folder "${folderName}": not found; using ${fallbackImages.length} fallback image(s)`);
|
|
return fallbackImages;
|
|
}
|
|
|
|
const endpoint = new URL('/files', directusUrl);
|
|
endpoint.searchParams.set('limit', '20');
|
|
endpoint.searchParams.set('sort', '-uploaded_on');
|
|
endpoint.searchParams.set('filter[folder][_eq]', folder.id);
|
|
endpoint.searchParams.set('filter[type][_starts_with]', 'image/');
|
|
endpoint.searchParams.set('fields', 'id,title,description,filename_download,filename_disk,type,tags');
|
|
|
|
const files = await readDirectusEndpoint<DirectusFile>(endpoint);
|
|
const orderedFiles = orderImageFiles(files, options);
|
|
const images = orderedFiles.map((file) => ({
|
|
src: resolveDirectusAssetUrl(file),
|
|
alt: file.description || file.title || file.filename_download || 'Swansea Airport',
|
|
}));
|
|
|
|
if (images.length === 0) {
|
|
directusLog(`folder "${folderName}": no images found; using ${fallbackImages.length} fallback image(s)`);
|
|
return fallbackImages;
|
|
}
|
|
|
|
directusLog(`folder "${folderName}": using ${images.length} Directus image(s)`);
|
|
return images;
|
|
} catch (error) {
|
|
directusLog(`folder "${folderName}": using fallback after ${error instanceof Error ? error.message : String(error)}`);
|
|
return fallbackImages;
|
|
}
|
|
}
|
|
|
|
export const getHomepageBannerImages = () =>
|
|
getImagesFromFolder(homepageBannerFolder, fallbackHomepageBannerImages, {
|
|
firstTag: 'first',
|
|
shuffleRest: true,
|
|
});
|
|
export const getHomepageVolunteerImages = () =>
|
|
getImagesFromFolder(homepageVolunteersFolder, fallbackHomepageVolunteerImages, {
|
|
shuffleRest: true,
|
|
});
|
|
export const getCafePageImages = () => getImagesFromFolder(cafePageFolder, fallbackCafePageImages);
|
|
export const getGiftShopImages = () => getImagesFromFolder(giftShopFolder, fallbackGiftShopImages);
|
|
|
|
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.id,template.image.filename_download,template.image.filename_disk,template.logo.id,template.logo.filename_download,template.logo.filename_disk,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':
|
|
return fallbackNews;
|
|
case 'events':
|
|
return fallbackEvents;
|
|
case 'notices':
|
|
return fallbackNotices;
|
|
case 'fuel_prices':
|
|
return fallbackFuelPrices;
|
|
case 'documents':
|
|
return fallbackDocuments;
|
|
case 'contacts':
|
|
return fallbackContacts;
|
|
}
|
|
}
|
|
|
|
export const getNews = () => readCollection<NewsItem>('news');
|
|
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');
|
|
export const getContacts = () => readCollection<ContactItem>('contacts');
|