All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
299 lines
11 KiB
JavaScript
299 lines
11 KiB
JavaScript
const CONFIG_URL = '/services.json?v=1';
|
|
const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config';
|
|
|
|
const icons = {
|
|
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path><path d="M9 12l2 2 4-4"></path>',
|
|
folder: '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>',
|
|
lock: '<rect x="3" y="11" width="18" height="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path><circle cx="12" cy="16" r="1"></circle>',
|
|
film: '<rect x="2" y="2" width="20" height="20" rx="2.2"></rect><line x1="7" y1="2" x2="7" y2="22"></line><line x1="17" y1="2" x2="17" y2="22"></line><line x1="2" y1="12" x2="22" y2="12"></line>',
|
|
headphones: '<path d="M3 18v-6a9 9 0 0 1 18 0v6"></path><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path>',
|
|
book: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>',
|
|
git: '<circle cx="12" cy="12" r="4"></circle><line x1="1.05" y1="12" x2="7" y2="12"></line><line x1="17.01" y1="12" x2="22.96" y2="12"></line><path d="M12 1.05V7"></path><path d="M12 17.01v5.95"></path>',
|
|
chess: '<path d="M8 16l-1 4h10l-1-4"></path><path d="M6.5 16h11"></path><path d="M12 2a2 2 0 0 1 2 2c0 1.1-.9 2-2 3-1.1-1-2-1.9-2-3a2 2 0 0 1 2-2z"></path><path d="M9 7 6 10.5c-.6.7-.2 1.5.7 1.5h10.6c.9 0 1.3-.8.7-1.5L15 7"></path><path d="M9 12v4"></path><path d="M15 12v4"></path>',
|
|
user: '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle>'
|
|
};
|
|
|
|
let cards = [];
|
|
let sections = [];
|
|
let config = null;
|
|
let activeFilter = 'all';
|
|
let categoryById = new Map();
|
|
|
|
const searchInput = document.querySelector('#serviceSearch');
|
|
const segments = [...document.querySelectorAll('.segment')];
|
|
const emptyState = document.querySelector('#emptyState');
|
|
const onlineCount = document.querySelector('#onlineCount');
|
|
const serviceCount = document.querySelector('#serviceCount');
|
|
const lastChecked = document.querySelector('#lastChecked');
|
|
const dashboardSections = document.querySelector('#dashboardSections');
|
|
|
|
function normalize(value) {
|
|
return String(value || '').toLowerCase().trim();
|
|
}
|
|
|
|
function getLocalConfig() {
|
|
try {
|
|
const raw = window.localStorage.getItem(LOCAL_CONFIG_KEY);
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadConfig() {
|
|
const response = await fetch(CONFIG_URL, { cache: 'no-store' });
|
|
const baseConfig = await response.json();
|
|
return getLocalConfig() || baseConfig;
|
|
}
|
|
|
|
function serviceSearchText(service) {
|
|
return [
|
|
service.name,
|
|
service.description,
|
|
service.domain,
|
|
service.category,
|
|
service.keywords
|
|
].join(' ');
|
|
}
|
|
|
|
function createServiceCard(service) {
|
|
const category = categoryById.get(service.category);
|
|
const card = document.createElement('a');
|
|
card.href = service.url;
|
|
card.target = '_blank';
|
|
card.rel = 'noopener';
|
|
card.className = 'card service-card';
|
|
card.dataset.category = service.category;
|
|
card.dataset.url = service.url;
|
|
card.dataset.name = serviceSearchText(service);
|
|
card.innerHTML = `
|
|
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
<div class="card-icon" style="--accent: ${service.accent};">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
${icons[service.icon] || icons.folder}
|
|
</svg>
|
|
</div>
|
|
<div class="card-content">
|
|
<span class="card-category">${category?.label || 'Dienst'}</span>
|
|
<h3>${service.name}</h3>
|
|
<p>${service.description}</p>
|
|
</div>
|
|
<span class="card-domain">${service.domain}</span>
|
|
`;
|
|
return card;
|
|
}
|
|
|
|
function renderDashboard(nextConfig) {
|
|
dashboardSections.innerHTML = '';
|
|
serviceCount.textContent = String(nextConfig.services.length);
|
|
categoryById = new Map(nextConfig.categories.map((category) => [category.id, category]));
|
|
|
|
const section = document.createElement('section');
|
|
section.className = 'section service-overview';
|
|
section.innerHTML = `
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="section-kicker" id="overviewKicker">Dashboard</p>
|
|
<h2 id="overviewTitle">Alle Dienste</h2>
|
|
</div>
|
|
<span class="section-count" id="overviewCount">${nextConfig.services.length} Dienste</span>
|
|
</div>
|
|
<div class="grid"></div>
|
|
`;
|
|
|
|
const grid = section.querySelector('.grid');
|
|
nextConfig.services.forEach((service) => grid.appendChild(createServiceCard(service)));
|
|
dashboardSections.appendChild(section);
|
|
|
|
cards = [...document.querySelectorAll('.service-card')];
|
|
sections = [...document.querySelectorAll('.section')];
|
|
updateOverviewHeading(nextConfig.services.length);
|
|
}
|
|
|
|
function updateOverviewHeading(visibleCards) {
|
|
const activeCategory = categoryById.get(activeFilter);
|
|
const term = normalize(searchInput.value);
|
|
const kicker = document.querySelector('#overviewKicker');
|
|
const title = document.querySelector('#overviewTitle');
|
|
const count = document.querySelector('#overviewCount');
|
|
if (!kicker || !title || !count) return;
|
|
|
|
kicker.textContent = activeCategory ? activeCategory.label : 'Dashboard';
|
|
title.textContent = activeCategory ? activeCategory.title : 'Alle Dienste';
|
|
count.textContent = `${visibleCards} ${visibleCards === 1 ? 'Dienst' : 'Dienste'}${term ? ' gefunden' : ''}`;
|
|
}
|
|
|
|
function updateVisibility() {
|
|
const term = normalize(searchInput.value);
|
|
let visibleCards = 0;
|
|
|
|
cards.forEach((card) => {
|
|
const categoryMatches = activeFilter === 'all' || card.dataset.category === activeFilter;
|
|
const searchMatches = !term || normalize(card.dataset.name).includes(term);
|
|
const isVisible = categoryMatches && searchMatches;
|
|
card.hidden = !isVisible;
|
|
if (isVisible) visibleCards += 1;
|
|
});
|
|
|
|
sections.forEach((section) => {
|
|
const hasVisibleCards = [...section.querySelectorAll('.service-card')].some((card) => !card.hidden);
|
|
section.hidden = !hasVisibleCards;
|
|
});
|
|
|
|
updateOverviewHeading(visibleCards);
|
|
emptyState.hidden = visibleCards !== 0;
|
|
}
|
|
|
|
function setActiveFilter(nextFilter) {
|
|
activeFilter = nextFilter;
|
|
segments.forEach((segment) => {
|
|
const isActive = segment.dataset.filter === nextFilter;
|
|
segment.classList.toggle('is-active', isActive);
|
|
segment.setAttribute('aria-selected', String(isActive));
|
|
});
|
|
updateVisibility();
|
|
}
|
|
|
|
async function probeService(card) {
|
|
const controller = new AbortController();
|
|
const timeout = window.setTimeout(() => controller.abort(), 4500);
|
|
|
|
try {
|
|
await fetch(card.dataset.url, {
|
|
method: 'GET',
|
|
mode: 'no-cors',
|
|
cache: 'no-store',
|
|
signal: controller.signal
|
|
});
|
|
card.classList.add('is-online');
|
|
card.classList.remove('is-offline');
|
|
card.classList.remove('is-unknown');
|
|
card.querySelector('.status-dot').setAttribute('aria-label', 'Online');
|
|
return true;
|
|
} catch (error) {
|
|
const isTimeout = error.name === 'AbortError';
|
|
card.classList.toggle('is-offline', isTimeout);
|
|
card.classList.toggle('is-unknown', !isTimeout);
|
|
card.classList.remove('is-online');
|
|
card.querySelector('.status-dot').setAttribute(
|
|
'aria-label',
|
|
isTimeout ? 'Nicht erreichbar' : 'Im Browser nicht pruefbar'
|
|
);
|
|
return isTimeout ? false : null;
|
|
} finally {
|
|
window.clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function refreshStatus() {
|
|
const results = await Promise.allSettled(cards.map(probeService));
|
|
const online = results.filter((result) => result.status === 'fulfilled' && result.value).length;
|
|
const unknown = results.filter((result) => result.status === 'fulfilled' && result.value === null).length;
|
|
onlineCount.textContent = String(online);
|
|
lastChecked.textContent = `Status geprueft: ${new Date().toLocaleTimeString('de-DE', {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}${unknown ? `, ${unknown} im Browser nicht pruefbar` : ''}`;
|
|
}
|
|
|
|
function scheduleStatusRefresh() {
|
|
const run = () => refreshStatus().catch(() => {
|
|
onlineCount.textContent = '--';
|
|
lastChecked.textContent = 'Status aktuell nicht verfuegbar.';
|
|
});
|
|
|
|
if ('requestIdleCallback' in window) {
|
|
window.requestIdleCallback(run, { timeout: 2000 });
|
|
} else {
|
|
window.setTimeout(run, 600);
|
|
}
|
|
}
|
|
|
|
segments.forEach((segment) => {
|
|
segment.addEventListener('click', () => setActiveFilter(segment.dataset.filter));
|
|
});
|
|
|
|
searchInput.addEventListener('input', updateVisibility);
|
|
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
});
|
|
}
|
|
|
|
loadConfig()
|
|
.then((nextConfig) => {
|
|
config = nextConfig;
|
|
renderDashboard(config);
|
|
updateVisibility();
|
|
scheduleStatusRefresh();
|
|
})
|
|
.catch(() => {
|
|
dashboardSections.innerHTML = '<p class="empty-state">Dashboard-Konfiguration konnte nicht geladen werden.</p>';
|
|
onlineCount.textContent = '--';
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Peeking Bongo Cat Easter Egg Logic
|
|
// ==========================================================================
|
|
(function initPeekingCat() {
|
|
const peekingCat = document.querySelector('#peekingCat');
|
|
const searchInput = document.querySelector('#serviceSearch');
|
|
|
|
if (!peekingCat) return;
|
|
|
|
let tapTimeout = null;
|
|
|
|
// Function to trigger heart popup on click
|
|
function createHeart(x, y) {
|
|
const heart = document.createElement('div');
|
|
heart.className = 'cat-heart';
|
|
heart.innerHTML = '❤️';
|
|
heart.style.left = `${x - 12}px`;
|
|
heart.style.top = `${y - 20}px`;
|
|
|
|
// Set random rotation angle in CSS custom variable
|
|
const randomRot = Math.random() * 40 - 20;
|
|
heart.style.setProperty('--rot', `${randomRot}deg`);
|
|
|
|
document.body.appendChild(heart);
|
|
|
|
// Cleanup heart after animation completes
|
|
setTimeout(() => heart.remove(), 800);
|
|
}
|
|
|
|
// Focus search box triggers peeking active state
|
|
if (searchInput) {
|
|
searchInput.addEventListener('focus', () => {
|
|
peekingCat.classList.add('is-active');
|
|
});
|
|
|
|
searchInput.addEventListener('blur', () => {
|
|
peekingCat.classList.remove('is-active');
|
|
peekingCat.classList.remove('is-tapping');
|
|
});
|
|
|
|
// Keyboard typing triggers tapping animation!
|
|
searchInput.addEventListener('input', () => {
|
|
peekingCat.classList.add('is-active');
|
|
peekingCat.classList.add('is-tapping');
|
|
|
|
if (tapTimeout) clearTimeout(tapTimeout);
|
|
tapTimeout = setTimeout(() => {
|
|
peekingCat.classList.remove('is-tapping');
|
|
}, 350); // Stop tapping 350ms after typing stops
|
|
});
|
|
}
|
|
|
|
// Click on cat triggers tapping + heart animation!
|
|
peekingCat.addEventListener('click', (e) => {
|
|
peekingCat.classList.add('is-tapping');
|
|
createHeart(e.clientX, e.clientY);
|
|
|
|
if (tapTimeout) clearTimeout(tapTimeout);
|
|
tapTimeout = setTimeout(() => {
|
|
peekingCat.classList.remove('is-tapping');
|
|
}, 800);
|
|
});
|
|
})();
|