Files
egfh-website/src/lib/directus.ts
T
2026-06-29 16:14:34 -04:00

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(/&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.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');