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> = { news: '-publish_date', events: '-start_datetime', notices: '-priority', fuel_prices: 'fuel_type', documents: '-uploaded_at', contacts: 'order', }; declare const process: { env: Record; }; 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 | 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(collection: CollectionName): Promise { 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(endpoint: URL): Promise { 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 { 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(endpoint); return folders[0] ?? null; } async function getImagesFromFolder( folderName: string, fallbackImages: HomepageBannerImage[], options: ImageFolderOptions = {}, ): Promise { 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(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 { 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(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('news'); export async function getEvents(): Promise { try { const recurringEvents = await getRecurringEvents(); return recurringEvents.length > 0 ? recurringEvents : readCollection('events'); } catch { return readCollection('events'); } } export const getNotices = () => readCollection('notices'); export const getFuelPrices = () => readCollection('fuel_prices'); export const getDocuments = () => readCollection('documents'); export const getContacts = () => readCollection('contacts');