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 = `

Persoenlich

Deine Favoriten

Favoriten erstellen

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 = `

Persoenlich

Deine Favoriten

Bearbeiten
`; 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 = `

Dashboard

Alle Dienste

${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; } const categoryFilters = document.querySelector('#categoryFilters'); if (categoryFilters) { categoryFilters.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); }); })();