287 lines
11 KiB
JavaScript
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, '&').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, '<').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 += `
|
|
<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
|
|
};
|
|
}
|