/** * Service Data Loader * This script loads services from services-data.json and provides helper functions * to render them in different HTML structures for gw.html and ugh.html * * NOTE: Services are loaded from JSON at runtime (expected to be served over HTTP). * Edit `services-data.json` to update the service directory. */ // Capture data-services-url at load time (for archived pages in subdirs) const SERVICES_JSON_URL = (function () { const s = document.currentScript; return (s && s.dataset.servicesUrl) ? s.dataset.servicesUrl : 'services-data.json'; })(); // Load and cache the services data let servicesData = null; async function loadServicesData() { if (servicesData) return servicesData; try { const response = await fetch(SERVICES_JSON_URL); if (response.ok) { servicesData = await response.json(); return servicesData; } console.warn(`Failed to load ${SERVICES_JSON_URL}: HTTP ${response.status}`); } catch (err) { console.warn(`Failed to load ${SERVICES_JSON_URL}:`, err); } // No data available (e.g. file:// protocol or missing JSON) servicesData = { services: [] }; return servicesData; } // Group services by category function groupByCategory(services) { const grouped = {}; services.forEach(service => { if (!grouped[service.category]) { grouped[service.category] = []; } grouped[service.category].push(service); }); return grouped; } // Split services into active (Ugh, GS, GW) and archived function splitActiveArchived(services) { const active = []; const archived = []; services.forEach(service => { if (service.active !== false) { active.push(service); } else { archived.push(service); } }); return { active, archived }; } // Escape for HTML text content (defense-in-depth against XSS from service data) function escapeHtml(str) { if (typeof str !== 'string') return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // Escape for HTML attribute values (e.g. href) function escapeAttr(str) { if (typeof str !== 'string') return ''; return str.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); } function isFlaggedDown(service) { return service && (service.status === 'down' || service.status === 'maintenance'); } // Render one service list (used for both active and archived blocks) function renderServiceGroupHtml(grouped, options = {}) { let html = ''; const { showArchivedStyle = false } = options; for (const [category, services] of Object.entries(grouped)) { html += `

${escapeHtml(category)}

`; } return html; } // Render services for gw.html (minimalist style): Active first, then Archived function renderServicesForGW(containerId) { loadServicesData().then(data => { const container = document.getElementById(containerId); if (!container) return; const { active, archived } = splitActiveArchived(data.services); let html = ''; if (active.length > 0) { html += `

Active (Ugh ยท GW)

`; html += renderServiceGroupHtml(groupByCategory(active)); } if (archived.length > 0) { html += `
Archived services
`; html += renderServiceGroupHtml(groupByCategory(archived), { showArchivedStyle: true }); html += `
`; } container.innerHTML = html; }); } // Render services for ugh.html (retro style) โ€” active only function renderServicesForUGH(containerId) { loadServicesData().then(data => { const container = document.getElementById(containerId); if (!container) return; const { active } = splitActiveArchived(data.services); const grouped = groupByCategory(active); let html = ''; for (const [category, services] of Object.entries(grouped)) { html += `

${escapeHtml(services[0].icon || '')} ${escapeHtml(category)}

`; } container.innerHTML = html; }); } // Render services for future.html (dropdown menu, futuristic theme) function renderServicesForFuture(containerId) { loadServicesData().then(data => { const container = document.getElementById(containerId); if (!container) return; container.classList.add('loading'); const { active, archived } = splitActiveArchived(data.services); const activeGrouped = groupByCategory(active); const archivedGrouped = groupByCategory(archived); let html = '

SERVICE DIRECTORY

'; // Active services as dropdowns for (const [category, services] of Object.entries(activeGrouped)) { const icon = escapeHtml(services[0].icon || 'โ—†'); const id = 'fd-' + category.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, '-').toLowerCase(); html += `
`; services.forEach(service => { html += `
${escapeHtml(service.name)}`; if (service.meta && service.meta.length > 0) { html += '
'; service.meta.forEach(meta => { html += `${escapeHtml(meta.label)}`; }); html += '
'; } html += '
'; }); html += `
`; } // Archived services if (archived.length > 0) { html += `
ARCHIVED SERVICES
`; for (const [category, services] of Object.entries(archivedGrouped)) { const icon = escapeHtml(services[0].icon || 'โ—†'); const id = 'fd-arch-' + category.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, '-').toLowerCase(); html += `
`; services.forEach(service => { html += `
${escapeHtml(service.name)}`; if (service.meta && service.meta.length > 0) { html += '
'; service.meta.forEach(meta => { html += `${escapeHtml(meta.label)}`; }); html += '
'; } html += '
'; }); html += `
`; } html += `
`; } container.innerHTML = html; container.classList.remove('loading'); // Attach click handlers for dropdowns container.querySelectorAll('.future-dropdown-trigger').forEach(btn => { btn.addEventListener('click', function () { const dd = this.closest('.future-dropdown'); dd.classList.toggle('open'); btn.setAttribute('aria-expanded', dd.classList.contains('open')); }); }); }); } // Export functions for use in other scripts if (typeof module !== 'undefined' && module.exports) { module.exports = { loadServicesData, groupByCategory, renderServicesForGW, renderServicesForUGH, renderServicesForFuture }; }