changes made
This commit is contained in:
@@ -1,105 +1,534 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
// ── Mobile Nav Toggle ──
|
||||
const navToggle = document.getElementById('nav-toggle');
|
||||
const nav = document.getElementById('nav');
|
||||
// ── Config Loader ──
|
||||
async function loadConfig() {
|
||||
const res = await fetch('/services.json');
|
||||
if (!res.ok) throw new Error(`Failed to load services.json: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
navToggle.addEventListener('click', () => {
|
||||
const open = nav.classList.toggle('open');
|
||||
navToggle.setAttribute('aria-expanded', open);
|
||||
navToggle.querySelector('i').className = open ? 'ri-close-line' : 'ri-menu-line';
|
||||
});
|
||||
|
||||
// Close nav on link click (mobile)
|
||||
nav.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
nav.classList.remove('open');
|
||||
navToggle.setAttribute('aria-expanded', 'false');
|
||||
navToggle.querySelector('i').className = 'ri-menu-line';
|
||||
// ── Nav Renderer ──
|
||||
function renderNav(config) {
|
||||
const nav = document.getElementById('nav');
|
||||
config.categories.filter(c => c.showInNav).forEach(cat => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `#${cat.id}`;
|
||||
a.textContent = cat.navLabel;
|
||||
nav.appendChild(a);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Header Scroll Effect ──
|
||||
const header = document.getElementById('header');
|
||||
let ticking = false;
|
||||
// Search button in nav
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'nav-search-btn';
|
||||
btn.setAttribute('aria-label', 'Search services');
|
||||
btn.innerHTML = '<i class="ri-search-line"></i>';
|
||||
btn.addEventListener('click', () => SearchOverlay.open());
|
||||
nav.appendChild(btn);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
header.classList.toggle('scrolled', window.scrollY > 50);
|
||||
ticking = false;
|
||||
// ── Services Renderer ──
|
||||
function renderServices(config) {
|
||||
const container = document.getElementById('services-container');
|
||||
const grouped = {};
|
||||
config.categories.forEach(cat => { grouped[cat.id] = []; });
|
||||
config.services.forEach(svc => {
|
||||
if (grouped[svc.category]) grouped[svc.category].push(svc);
|
||||
});
|
||||
|
||||
config.categories.forEach(cat => {
|
||||
const services = grouped[cat.id];
|
||||
if (!services || services.length === 0) return;
|
||||
|
||||
const section = document.createElement('section');
|
||||
section.id = cat.id;
|
||||
section.className = 'category';
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'container';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'section-title';
|
||||
title.innerHTML = `<i class="${cat.icon}"></i> ${cat.title}`;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'card-grid';
|
||||
|
||||
services.forEach((svc, i) => {
|
||||
const card = document.createElement('a');
|
||||
card.href = svc.url;
|
||||
card.className = 'service-card';
|
||||
card.dataset.name = svc.name.toLowerCase();
|
||||
card.dataset.category = cat.id;
|
||||
card.style.animationDelay = `${(i + 1) * 0.02}s`;
|
||||
|
||||
// Status dot
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'card-status-dot unknown';
|
||||
card.appendChild(dot);
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = svc.icon;
|
||||
card.appendChild(icon);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'card-name';
|
||||
name.textContent = svc.name;
|
||||
card.appendChild(name);
|
||||
|
||||
if (svc.sub) {
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'card-sub';
|
||||
sub.textContent = svc.sub;
|
||||
card.appendChild(sub);
|
||||
}
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
});
|
||||
|
||||
inner.appendChild(title);
|
||||
inner.appendChild(grid);
|
||||
section.appendChild(inner);
|
||||
container.appendChild(section);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Status Bar Renderer ──
|
||||
function renderStatusBar(config) {
|
||||
const grid = document.getElementById('status-grid');
|
||||
config.statusMonitors.forEach(mon => {
|
||||
const card = document.createElement('a');
|
||||
card.href = mon.url;
|
||||
card.className = 'status-card';
|
||||
card.id = mon.id;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="status-dot loading"></div>
|
||||
<div class="status-info">
|
||||
<span class="status-label">${mon.label}</span>
|
||||
<span class="status-value">Checking...</span>
|
||||
</div>`;
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Status Monitor ──
|
||||
class StatusMonitor {
|
||||
constructor() {
|
||||
this.services = [
|
||||
{
|
||||
id: 'status-glances',
|
||||
url: '/proxy/glances/api/4/quicklook',
|
||||
parse: (data) => `CPU ${Math.round(data.cpu_percent)}% | RAM ${Math.round(data.mem_percent)}%`
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
|
||||
// Rich monitor parsers
|
||||
this.parsers = {
|
||||
glances: (data) => `CPU ${Math.round(data.cpu_percent)}% | RAM ${Math.round(data.mem_percent)}%`,
|
||||
uptime: (data) => {
|
||||
const groups = data.heartbeatList || {};
|
||||
const monitors = Object.values(groups);
|
||||
const total = monitors.length;
|
||||
const up = monitors.filter(beats => beats.length > 0 && beats[beats.length - 1].status === 1).length;
|
||||
return `${up}/${total} services up`;
|
||||
},
|
||||
{
|
||||
id: 'status-uptime',
|
||||
url: '/proxy/uptime/api/status-page/heartbeat/default',
|
||||
parse: (data) => {
|
||||
const groups = data.heartbeatList || {};
|
||||
const monitors = Object.values(groups);
|
||||
const total = monitors.length;
|
||||
const up = monitors.filter(beats => beats.length > 0 && beats[beats.length - 1].status === 1).length;
|
||||
return `${up}/${total} services up`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'status-cadvisor',
|
||||
url: '/proxy/cadvisor/api/v1.0/machine',
|
||||
parse: (data) => {
|
||||
const cores = data.num_cores || '?';
|
||||
const ramGB = data.memory_capacity ? (data.memory_capacity / 1073741824).toFixed(0) : '?';
|
||||
return `${cores} cores | ${ramGB} GB RAM`;
|
||||
}
|
||||
cadvisor: (data) => {
|
||||
const cores = data.num_cores || '?';
|
||||
const ramGB = data.memory_capacity ? (data.memory_capacity / 1073741824).toFixed(0) : '?';
|
||||
return `${cores} cores | ${ramGB} GB RAM`;
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
this.poll();
|
||||
setInterval(() => this.poll(), 60000);
|
||||
this.pollRichStatus();
|
||||
this.pollHealthChecks();
|
||||
setInterval(() => this.pollRichStatus(), 60000);
|
||||
setInterval(() => this.pollHealthChecks(), 120000);
|
||||
}
|
||||
|
||||
async poll() {
|
||||
this.services.forEach(svc => this.check(svc));
|
||||
// ── Rich status monitors (3 detailed cards) ──
|
||||
async pollRichStatus() {
|
||||
this.config.statusMonitors.forEach(mon => this.checkRich(mon));
|
||||
}
|
||||
|
||||
async check(svc) {
|
||||
const card = document.getElementById(svc.id);
|
||||
async checkRich(mon) {
|
||||
const card = document.getElementById(mon.id);
|
||||
if (!card) return;
|
||||
|
||||
const dot = card.querySelector('.status-dot');
|
||||
const value = card.querySelector('.status-value');
|
||||
|
||||
dot.className = 'status-dot loading';
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const res = await fetch(svc.url, { signal: controller.signal });
|
||||
const res = await fetch(mon.apiUrl, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
dot.className = 'status-dot ok';
|
||||
value.textContent = svc.parse(data);
|
||||
value.textContent = this.parsers[mon.parseType](data);
|
||||
} catch {
|
||||
dot.className = 'status-dot error';
|
||||
value.textContent = 'Unreachable';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-card health checks (simple up/down) ──
|
||||
async pollHealthChecks() {
|
||||
const services = this.config.services.filter(s => s.healthCheckUrl);
|
||||
const batchSize = 6;
|
||||
|
||||
for (let i = 0; i < services.length; i += batchSize) {
|
||||
const batch = services.slice(i, i + batchSize);
|
||||
await Promise.allSettled(batch.map(svc => this.checkHealth(svc)));
|
||||
if (i + batchSize < services.length) {
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth(svc) {
|
||||
const card = document.querySelector(`.service-card[data-name="${svc.name.toLowerCase()}"]`);
|
||||
if (!card) return;
|
||||
|
||||
const dot = card.querySelector('.card-status-dot');
|
||||
if (!dot) return;
|
||||
|
||||
dot.className = 'card-status-dot loading';
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const url = svc.healthCheckUrl;
|
||||
const isRelative = url.startsWith('/');
|
||||
const isHttps = url.startsWith('https://');
|
||||
|
||||
// Relative URLs and HTTPS: normal fetch. HTTP cross-origin: no-cors.
|
||||
const fetchOpts = { signal: controller.signal };
|
||||
if (!isRelative && !isHttps) {
|
||||
fetchOpts.mode = 'no-cors';
|
||||
}
|
||||
|
||||
await fetch(url, fetchOpts);
|
||||
clearTimeout(timeout);
|
||||
dot.className = 'card-status-dot ok';
|
||||
} catch {
|
||||
dot.className = 'card-status-dot error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new StatusMonitor();
|
||||
// ── Search Overlay ──
|
||||
const SearchOverlay = {
|
||||
overlay: null,
|
||||
input: null,
|
||||
results: null,
|
||||
services: [],
|
||||
selectedIndex: -1,
|
||||
isOpen: false,
|
||||
|
||||
init(config) {
|
||||
this.overlay = document.getElementById('search-overlay');
|
||||
this.input = document.getElementById('search-input');
|
||||
this.results = document.getElementById('search-results');
|
||||
this.services = config.services;
|
||||
|
||||
// Close on backdrop click
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) this.close();
|
||||
});
|
||||
|
||||
// Input handler
|
||||
this.input.addEventListener('input', () => {
|
||||
this.selectedIndex = -1;
|
||||
this.renderResults(this.input.value.trim());
|
||||
this.filterCards(this.input.value.trim());
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
this.input.addEventListener('keydown', (e) => {
|
||||
const items = this.results.querySelectorAll('.search-result-item');
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
|
||||
this.highlightItem(items);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.highlightItem(items);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
|
||||
window.location.href = items[this.selectedIndex].dataset.url;
|
||||
} else if (items.length === 1) {
|
||||
window.location.href = items[0].dataset.url;
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Global '/' shortcut
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !this.isOpen && !isInputFocused()) {
|
||||
e.preventDefault();
|
||||
this.open();
|
||||
} else if (e.key === 'Escape' && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.overlay.classList.remove('hidden');
|
||||
this.input.value = '';
|
||||
this.results.innerHTML = '';
|
||||
this.selectedIndex = -1;
|
||||
this.clearCardFilters();
|
||||
setTimeout(() => this.input.focus(), 50);
|
||||
},
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.overlay.classList.add('hidden');
|
||||
this.input.value = '';
|
||||
this.results.innerHTML = '';
|
||||
this.selectedIndex = -1;
|
||||
this.clearCardFilters();
|
||||
},
|
||||
|
||||
fuzzyMatch(query, text) {
|
||||
query = query.toLowerCase();
|
||||
text = text.toLowerCase();
|
||||
let qi = 0;
|
||||
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
|
||||
if (text[ti] === query[qi]) qi++;
|
||||
}
|
||||
return qi === query.length;
|
||||
},
|
||||
|
||||
renderResults(query) {
|
||||
this.results.innerHTML = '';
|
||||
if (!query) return;
|
||||
|
||||
const matches = this.services.filter(svc => this.fuzzyMatch(query, svc.name));
|
||||
|
||||
matches.forEach((svc, i) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'search-result-item' + (i === this.selectedIndex ? ' active' : '');
|
||||
item.dataset.url = svc.url;
|
||||
|
||||
item.innerHTML = `<i class="${svc.icon}"></i><span>${svc.name}</span>`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
window.location.href = svc.url;
|
||||
});
|
||||
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectedIndex = i;
|
||||
this.highlightItem(this.results.querySelectorAll('.search-result-item'));
|
||||
});
|
||||
|
||||
this.results.appendChild(item);
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'search-result-empty';
|
||||
empty.textContent = 'No services found';
|
||||
this.results.appendChild(empty);
|
||||
}
|
||||
},
|
||||
|
||||
highlightItem(items) {
|
||||
items.forEach((item, i) => {
|
||||
item.classList.toggle('active', i === this.selectedIndex);
|
||||
});
|
||||
if (items[this.selectedIndex]) {
|
||||
items[this.selectedIndex].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
},
|
||||
|
||||
filterCards(query) {
|
||||
const allCards = document.querySelectorAll('.service-card');
|
||||
const allSections = document.querySelectorAll('.category');
|
||||
|
||||
if (!query) {
|
||||
this.clearCardFilters();
|
||||
return;
|
||||
}
|
||||
|
||||
allCards.forEach(card => {
|
||||
const name = card.dataset.name || '';
|
||||
const match = this.fuzzyMatch(query, name);
|
||||
card.classList.toggle('search-hidden', !match);
|
||||
card.classList.toggle('search-highlight', match);
|
||||
});
|
||||
|
||||
// Hide sections with no visible cards
|
||||
allSections.forEach(section => {
|
||||
const visibleCards = section.querySelectorAll('.service-card:not(.search-hidden)');
|
||||
section.classList.toggle('search-hidden', visibleCards.length === 0);
|
||||
});
|
||||
},
|
||||
|
||||
clearCardFilters() {
|
||||
document.querySelectorAll('.search-hidden').forEach(el => el.classList.remove('search-hidden'));
|
||||
document.querySelectorAll('.search-highlight').forEach(el => el.classList.remove('search-highlight'));
|
||||
}
|
||||
};
|
||||
|
||||
function isInputFocused() {
|
||||
const el = document.activeElement;
|
||||
return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
|
||||
}
|
||||
|
||||
// ── Hero Widget (Clock / Weather / Greeting) ──
|
||||
class HeroWidget {
|
||||
constructor(config) {
|
||||
this.el = document.getElementById('hero-widget');
|
||||
this.city = config.weather?.city || 'Copenhagen';
|
||||
this.weatherData = null;
|
||||
|
||||
this.render();
|
||||
this.updateTime();
|
||||
this.fetchWeather();
|
||||
|
||||
setInterval(() => this.updateTime(), 1000);
|
||||
setInterval(() => this.fetchWeather(), 900000); // 15 min
|
||||
}
|
||||
|
||||
render() {
|
||||
this.el.innerHTML = `
|
||||
<p class="hero-greeting" id="hero-greeting"></p>
|
||||
<p class="hero-clock" id="hero-clock"></p>
|
||||
<p class="hero-date" id="hero-date"></p>
|
||||
<p class="hero-weather" id="hero-weather"><i class="ri-loader-4-line ri-spin"></i> Loading weather...</p>`;
|
||||
}
|
||||
|
||||
updateTime() {
|
||||
const now = new Date();
|
||||
const hour = now.getHours();
|
||||
|
||||
let greeting;
|
||||
if (hour < 5) greeting = 'Good Night';
|
||||
else if (hour < 12) greeting = 'Good Morning';
|
||||
else if (hour < 17) greeting = 'Good Afternoon';
|
||||
else if (hour < 21) greeting = 'Good Evening';
|
||||
else greeting = 'Good Night';
|
||||
|
||||
const greetEl = document.getElementById('hero-greeting');
|
||||
const clockEl = document.getElementById('hero-clock');
|
||||
const dateEl = document.getElementById('hero-date');
|
||||
|
||||
if (greetEl) greetEl.textContent = greeting;
|
||||
if (clockEl) clockEl.textContent = now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
if (dateEl) dateEl.textContent = now.toLocaleDateString('en-GB', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
async fetchWeather() {
|
||||
const weatherEl = document.getElementById('hero-weather');
|
||||
if (!weatherEl) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://wttr.in/${encodeURIComponent(this.city)}?format=j1`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const current = data.current_condition?.[0];
|
||||
if (!current) throw new Error('No data');
|
||||
|
||||
const temp = current.temp_C;
|
||||
const desc = current.weatherDesc?.[0]?.value || '';
|
||||
const code = parseInt(current.weatherCode, 10);
|
||||
const iconClass = this.weatherIcon(code);
|
||||
|
||||
weatherEl.innerHTML = `<i class="${iconClass}"></i> ${temp}°C — ${desc}`;
|
||||
this.weatherData = current;
|
||||
} catch {
|
||||
weatherEl.innerHTML = `<i class="ri-cloud-off-line"></i> Weather unavailable`;
|
||||
}
|
||||
}
|
||||
|
||||
weatherIcon(code) {
|
||||
// wttr.in weather codes to Remix Icon
|
||||
if (code === 113) return 'ri-sun-line'; // Clear/Sunny
|
||||
if (code === 116) return 'ri-sun-cloudy-line'; // Partly cloudy
|
||||
if (code === 119 || code === 122) return 'ri-cloudy-line'; // Cloudy / Overcast
|
||||
if (code === 143 || code === 248 || code === 260) return 'ri-mist-line'; // Fog/Mist
|
||||
if ([176, 263, 266, 293, 296, 299, 302, 305, 308, 353, 356, 359].includes(code)) return 'ri-rainy-line'; // Rain
|
||||
if ([179, 182, 185, 227, 230, 323, 326, 329, 332, 335, 338, 368, 371, 374, 377].includes(code)) return 'ri-snowy-line'; // Snow
|
||||
if ([200, 386, 389, 392, 395].includes(code)) return 'ri-thunderstorms-line'; // Thunder
|
||||
if ([281, 284, 311, 314, 317, 320, 350, 362, 365].includes(code)) return 'ri-drizzle-line'; // Sleet/Freezing
|
||||
return 'ri-cloudy-line';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mobile Nav Toggle (event delegation for dynamic links) ──
|
||||
function initNavToggle() {
|
||||
const navToggle = document.getElementById('nav-toggle');
|
||||
const nav = document.getElementById('nav');
|
||||
|
||||
navToggle.addEventListener('click', () => {
|
||||
const open = nav.classList.toggle('open');
|
||||
navToggle.setAttribute('aria-expanded', open);
|
||||
navToggle.querySelector('i').className = open ? 'ri-close-line' : 'ri-menu-line';
|
||||
});
|
||||
|
||||
// Event delegation for dynamically added nav links
|
||||
nav.addEventListener('click', (e) => {
|
||||
if (e.target.closest('a')) {
|
||||
nav.classList.remove('open');
|
||||
navToggle.setAttribute('aria-expanded', 'false');
|
||||
navToggle.querySelector('i').className = 'ri-menu-line';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Header Scroll Effect ──
|
||||
function initScrollEffect() {
|
||||
const header = document.getElementById('header');
|
||||
let ticking = false;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
header.classList.toggle('scrolled', window.scrollY > 50);
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
async function init() {
|
||||
// These work independently of config
|
||||
initNavToggle();
|
||||
initScrollEffect();
|
||||
|
||||
try {
|
||||
const config = await loadConfig();
|
||||
|
||||
renderNav(config);
|
||||
renderStatusBar(config);
|
||||
renderServices(config);
|
||||
|
||||
new StatusMonitor(config);
|
||||
SearchOverlay.init(config);
|
||||
new HeroWidget(config);
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize dashboard:', err);
|
||||
const container = document.getElementById('services-container');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="container" style="padding: 60px 24px; text-align: center;">
|
||||
<i class="ri-error-warning-line" style="font-size: 48px; color: var(--red); margin-bottom: 16px;"></i>
|
||||
<h2 style="color: var(--text-heading); margin-bottom: 8px;">Failed to load services</h2>
|
||||
<p style="color: var(--text-muted);">Could not load services.json. Check the console for details.</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user