const CONFIG_URL = '/services.json?v=1';
const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config';
const icons = {
shield: '',
folder: '',
lock: '',
film: '',
headphones: '',
book: '',
git: '',
chess: '',
user: ''
};
let cards = [];
let sections = [];
let config = null;
let favorites = [];
let activeFilter = 'all';
let activeView = window.localStorage.getItem('mischlabs.dashboard.view') || 'all';
let categoryById = new Map();
const searchInput = document.querySelector('#serviceSearch');
const segments = [...document.querySelectorAll('.segment')];
const viewOptions = [...document.querySelectorAll('.view-option')];
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');
const favoritesSections = document.querySelector('#favoritesSections');
const loginButton = document.querySelector('#dashboardLoginButton');
const logoutButton = document.querySelector('#dashboardLogoutButton');
const favoritesLink = document.querySelector('#favoritesLink');
const userBadge = document.querySelector('#dashboardUserBadge');
const dashboardMenuButton = document.querySelector('#dashboardMenuButton');
const dashboardMenuPanel = document.querySelector('#dashboardMenuPanel');
let adminPanelLink = null;
function normalize(value) {
return String(value || '').toLowerCase().trim();
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 isMobileDevice() {
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
}
function getMobilePlatform() {
const userAgent = navigator.userAgent || '';
if (/Android/i.test(userAgent)) return 'android';
if (/iPhone|iPad|iPod/i.test(userAgent)) return 'ios';
return 'other';
}
function getMobileLaunch(service) {
if (service.id === 'drive') {
const fallbackUrl = service.url;
if (getMobilePlatform() === 'android') {
return {
url: `intent://openFiles#Intent;scheme=nextcloud;package=com.nextcloud.client;S.browser_fallback_url=${encodeURIComponent(fallbackUrl)};end`,
fallbackUrl
};
}
return {
url: 'nextcloud://openFiles',
fallbackUrl
};
}
return null;
}
function openMobileLaunch(url, fallbackUrl) {
let didLeavePage = false;
let fallbackTimer = null;
const cleanup = () => {
window.removeEventListener('pagehide', markPageLeft);
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (fallbackTimer) window.clearTimeout(fallbackTimer);
};
const markPageLeft = () => {
didLeavePage = true;
cleanup();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
markPageLeft();
}
};
window.addEventListener('pagehide', markPageLeft, { once: true });
document.addEventListener('visibilitychange', handleVisibilityChange);
fallbackTimer = window.setTimeout(() => {
cleanup();
if (!didLeavePage && document.visibilityState === 'visible') {
window.location.href = fallbackUrl;
}
}, 2200);
if (/^https?:\/\//i.test(url) || /^intent:/i.test(url)) {
window.location.href = url;
return;
}
window.location.href = url;
}
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 = `
${category?.label || 'Dienst'}
${service.name}
${service.description}
${service.domain}
`;
const mobileLaunch = getMobileLaunch(service);
if (mobileLaunch) {
card.addEventListener('click', (e) => {
if (!isMobileDevice()) return;
e.preventDefault();
openMobileLaunch(mobileLaunch.url, mobileLaunch.fallbackUrl);
});
}
return card;
}
function createFavoriteCard(favorite) {
const card = document.createElement('a');
card.href = favorite.url;
card.target = '_blank';
card.rel = 'noopener';
card.className = 'card favorite-card';
card.style.setProperty('--accent', favorite.color || '#67e8f9');
card.dataset.name = [
favorite.name,
favorite.description,
favorite.group,
favorite.url
].join(' ');
const safeLogo = favorite.logoUrl ? escapeHtml(favorite.logoUrl) : '';
const safeName = escapeHtml(favorite.name);
const safeDescription = escapeHtml(favorite.description || favorite.group || 'Favorit');
const iconSvg = icons[favorite.icon] || icons.folder;
card.innerHTML = `
${safeLogo
? `

`
: `
`}
${escapeHtml(favorite.group || 'Favorit')}
${safeName}
${safeDescription}
${escapeHtml(new URL(favorite.url).hostname)}
`;
return card;
}
function renderFavorites() {
if (!favoritesSections) return;
if (!MischLabsAuth.isAuthenticated()) {
favoritesSections.innerHTML = `
Persoenlich
Deine Favoriten
Melde dich mit SSO an, um eigene Kacheln und Weblinks zu speichern.
`;
document.querySelector('#favoritesLoginCallout')?.addEventListener('click', () => MischLabsAuth.login());
updateVisibility();
return;
}
if (!favorites.length) {
favoritesSections.innerHTML = `
Noch keine Favoriten. Lege eigene Kacheln fuer YouTube, Notion, Tools oder beliebige Links an.
`;
updateVisibility();
return;
}
const groups = new Map();
favorites.forEach((favorite) => {
const group = favorite.group || 'Favoriten';
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(favorite);
});
favoritesSections.innerHTML = `
`;
const container = favoritesSections.querySelector('.favorite-groups');
[...groups.entries()].forEach(([group, items]) => {
const section = document.createElement('section');
section.className = 'favorite-group';
section.innerHTML = `
${escapeHtml(group)}
${items.length} ${items.length === 1 ? 'Link' : 'Links'}
`;
const grid = section.querySelector('.grid');
items.forEach((favorite) => grid.appendChild(createFavoriteCard(favorite)));
container.appendChild(section);
});
updateVisibility();
}
async function loadFavorites() {
if (!MischLabsAuth.isAuthenticated()) {
favorites = [];
renderFavorites();
return;
}
try {
const response = await fetch('/api/favorites', {
cache: 'no-store',
headers: MischLabsAuth.authHeader()
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
} catch {
favorites = [];
}
renderFavorites();
}
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 = `
${nextConfig.services.length} Dienste
`;
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;
let visibleFavorites = 0;
const showServices = activeView === 'all' || activeView === 'services';
const showFavorites = activeView === 'all' || activeView === 'favorites';
if (dashboardSections) {
dashboardSections.hidden = !showServices;
}
if (favoritesSections) {
favoritesSections.hidden = !showFavorites;
}
cards.forEach((card) => {
const categoryMatches = activeFilter === 'all' || card.dataset.category === activeFilter;
const searchMatches = !term || normalize(card.dataset.name).includes(term);
const isVisible = showServices && categoryMatches && searchMatches;
card.hidden = !isVisible;
if (isVisible) visibleCards += 1;
});
sections.forEach((section) => {
const hasVisibleCards = [...section.querySelectorAll('.service-card')].some((card) => !card.hidden);
section.hidden = !showServices || !hasVisibleCards;
});
document.querySelectorAll('.favorite-card').forEach((card) => {
const isVisible = showFavorites && (!term || normalize(card.dataset.name).includes(term));
card.hidden = !isVisible;
if (isVisible) visibleFavorites += 1;
});
document.querySelectorAll('.favorite-group').forEach((group) => {
const hasVisibleCards = [...group.querySelectorAll('.favorite-card')].some((card) => !card.hidden);
group.hidden = !showFavorites || !hasVisibleCards;
});
updateOverviewHeading(visibleCards);
const hasVisibleContent = visibleCards + visibleFavorites > 0;
emptyState.hidden = hasVisibleContent;
emptyState.textContent = term
? 'Kein Eintrag passt zur Suche.'
: 'In dieser Ansicht gibt es noch keine Eintraege.';
}
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();
}
function setActiveView(nextView) {
activeView = nextView;
window.localStorage.setItem('mischlabs.dashboard.view', nextView);
viewOptions.forEach((option) => {
const isActive = option.dataset.view === nextView;
option.classList.toggle('is-active', isActive);
option.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));
});
viewOptions.forEach((option) => {
option.addEventListener('click', () => setActiveView(option.dataset.view));
});
searchInput.addEventListener('input', updateVisibility);
function closeDashboardMenu() {
if (!dashboardMenuButton || !dashboardMenuPanel) return;
dashboardMenuButton.setAttribute('aria-expanded', 'false');
dashboardMenuButton.classList.remove('is-open');
dashboardMenuPanel.classList.remove('is-open');
dashboardMenuPanel.hidden = true;
}
function toggleDashboardMenu() {
if (!dashboardMenuButton || !dashboardMenuPanel) return;
const isOpen = dashboardMenuButton.getAttribute('aria-expanded') === 'true';
dashboardMenuButton.setAttribute('aria-expanded', String(!isOpen));
dashboardMenuButton.classList.toggle('is-open', !isOpen);
dashboardMenuPanel.classList.toggle('is-open', !isOpen);
dashboardMenuPanel.hidden = isOpen;
}
function hasAdminAccess(claims) {
const username = String(claims?.preferred_username || '').toLowerCase();
const email = String(claims?.email || '').toLowerCase();
return username === 'mrdiderot' || email === 'mail.misch@pm.me';
}
function ensureAdminPanelLink() {
if (adminPanelLink || !dashboardMenuPanel) return adminPanelLink;
adminPanelLink = document.createElement('a');
adminPanelLink.className = 'dashboard-menu-item';
adminPanelLink.id = 'adminPanelLink';
adminPanelLink.href = '/admin.html';
adminPanelLink.textContent = 'Admin Panel';
adminPanelLink.hidden = true;
dashboardMenuPanel.insertBefore(adminPanelLink, favoritesLink || loginButton || null);
return adminPanelLink;
}
function updateAuthNav() {
const authenticated = MischLabsAuth.isAuthenticated();
const claims = MischLabsAuth.claims();
const isAdmin = authenticated && hasAdminAccess(claims);
if (loginButton) loginButton.hidden = authenticated;
if (logoutButton) logoutButton.hidden = !authenticated;
if (favoritesLink) favoritesLink.hidden = !authenticated;
if (isAdmin) {
const link = ensureAdminPanelLink();
if (link) link.hidden = false;
} else if (adminPanelLink) {
adminPanelLink.remove();
adminPanelLink = null;
}
if (userBadge) {
userBadge.hidden = !authenticated;
userBadge.textContent = authenticated ? MischLabsAuth.displayName() : '';
}
}
dashboardMenuButton?.addEventListener('click', (event) => {
event.stopPropagation();
toggleDashboardMenu();
});
document.addEventListener('click', (event) => {
if (!dashboardMenuPanel || dashboardMenuPanel.hidden) return;
if (!event.target.closest('.dashboard-menu')) {
closeDashboardMenu();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeDashboardMenu();
}
});
loginButton?.addEventListener('click', () => MischLabsAuth.login());
logoutButton?.addEventListener('click', () => MischLabsAuth.logout());
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
MischLabsAuth.handleCallback()
.catch((error) => {
lastChecked.textContent = error.message;
})
.then(() => loadConfig())
.then((nextConfig) => {
config = nextConfig;
updateAuthNav();
renderDashboard(config);
setActiveView(activeView);
updateVisibility();
loadFavorites();
scheduleStatusRefresh();
})
.catch(() => {
dashboardSections.innerHTML = 'Dashboard-Konfiguration konnte nicht geladen werden.
';
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);
});
})();