Files
server-configs/httpserver/data/services-loader.js
2026-03-22 00:54:28 -07:00

287 lines
11 KiB
JavaScript

/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Escape for HTML attribute values (e.g. href)
function escapeAttr(str) {
if (typeof str !== 'string') return '';
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 += `
<div class="group${showArchivedStyle ? ' archived-group' : ''}">
<h3>${escapeHtml(category)}</h3>
<ul>`;
services.forEach(service => {
html += `
<li class="service-link${isFlaggedDown(service) ? ' status-down' : ''}">
<a href="${escapeAttr(service.url)}" target="_blank" rel="noreferrer" class="service-name">${escapeHtml(service.name)}</a>`;
if (service.meta && service.meta.length > 0) {
html += ` <span class="service-meta">`;
service.meta.forEach((meta, index) => {
html += `${index > 0 ? ' | ' : '| '}<a href="${escapeAttr(meta.url)}" target="_blank" rel="noreferrer">[${escapeHtml(meta.label)}]</a>`;
});
html += `</span>`;
}
html += `
</li>`;
});
html += `
</ul>
</div>`;
}
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 += `<h3 class="services-section-title">Active <span class="hosts-note">(Ugh · GW)</span></h3>`;
html += renderServiceGroupHtml(groupByCategory(active));
}
if (archived.length > 0) {
html += `<details class="services-archived">
<summary><span class="archived-summary">Archived services</span></summary>
<div class="grid services archived-grid">`;
html += renderServiceGroupHtml(groupByCategory(archived), { showArchivedStyle: true });
html += `</div></details>`;
}
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 += `
<div class="service-group">
<h3>${escapeHtml(services[0].icon || '')} ${escapeHtml(category)}</h3>
<ul>`;
services.forEach(service => {
html += `
<li class="${isFlaggedDown(service) ? 'status-down' : ''}">
<a href="${escapeAttr(service.url)}" target="_blank" rel="noreferrer">${escapeHtml(service.name)}</a>`;
// Add meta links if they exist
if (service.meta && service.meta.length > 0) {
service.meta.forEach(meta => {
html += `
<a href="${escapeAttr(meta.url)}" target="_blank" rel="noreferrer" class="${String(meta.label || '').toLowerCase().replace(/[^a-z0-9-]/g, '-')}">[${escapeHtml(meta.label)}]</a>`;
});
}
html += `
</li>`;
});
html += `
</ul>
</div>`;
}
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 = '<h2>SERVICE DIRECTORY</h2>';
// 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 += `
<div class="future-dropdown" id="${id}">
<button type="button" class="future-dropdown-trigger" aria-expanded="false" aria-controls="${id}-content">
<span class="cat-icon">${icon}</span>
<span class="cat-name">${escapeHtml(category)}</span>
<span class="cat-count">${services.length}</span>
<span class="chevron" aria-hidden="true"></span>
</button>
<div class="future-dropdown-content" id="${id}-content">
<div class="future-dropdown-inner">`;
services.forEach(service => {
html += `
<div class="future-service${isFlaggedDown(service) ? ' status-down' : ''}">
<a href="${escapeAttr(service.url)}" target="_blank" rel="noreferrer" class="service-name">${escapeHtml(service.name)}</a>`;
if (service.meta && service.meta.length > 0) {
html += '<div class="future-service-meta">';
service.meta.forEach(meta => {
html += `<a href="${escapeAttr(meta.url)}" target="_blank" rel="noreferrer">${escapeHtml(meta.label)}</a>`;
});
html += '</div>';
}
html += '</div>';
});
html += `
</div>
</div>
</div>`;
}
// Archived services
if (archived.length > 0) {
html += `<details class="future-archived">
<summary>ARCHIVED SERVICES</summary>
<div class="future-dropdowns">`;
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 += `
<div class="future-dropdown" id="${id}">
<button type="button" class="future-dropdown-trigger" aria-expanded="false" aria-controls="${id}-content">
<span class="cat-icon">${icon}</span>
<span class="cat-name">${escapeHtml(category)}</span>
<span class="cat-count">${services.length}</span>
<span class="chevron" aria-hidden="true"></span>
</button>
<div class="future-dropdown-content" id="${id}-content">
<div class="future-dropdown-inner">`;
services.forEach(service => {
html += `
<div class="future-service${isFlaggedDown(service) ? ' status-down' : ''}">
<a href="${escapeAttr(service.url)}" target="_blank" rel="noreferrer" class="service-name">${escapeHtml(service.name)}</a>`;
if (service.meta && service.meta.length > 0) {
html += '<div class="future-service-meta">';
service.meta.forEach(meta => {
html += `<a href="${escapeAttr(meta.url)}" target="_blank" rel="noreferrer">${escapeHtml(meta.label)}</a>`;
});
html += '</div>';
}
html += '</div>';
});
html += `
</div>
</div>
</div>`;
}
html += `</div></details>`;
}
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
};
}