/** * Home Dashboard Application * Full-featured dashboard with favorites, themes, particles, and more */ class Dashboard { constructor() { this.initElements(); this.state = { services: [], favorites: new Set(), notes: {}, serviceStatuses: new Map(), pingTimes: new Map(), uptimeHistory: {}, statusCheckInterval: null, currentCategory: 'all', currentDetailService: null, maintenance: {}, // { serviceName: { until: timestamp|null, reason: string } } recentlyAccessed: [], // [{ name: string, timestamp: number }] rssItems: [] // Cached RSS items }; this.settings = this.loadSettings(); this.init(); } initElements() { // Core this.grid = document.getElementById('services-grid'); this.favoritesGrid = document.getElementById('favorites-grid'); this.favoritesSection = document.getElementById('favorites-section'); this.searchInput = document.getElementById('search-input'); this.refreshBtn = document.getElementById('refresh-btn'); this.fullscreenBtn = document.getElementById('fullscreen-btn'); this.addServiceBtn = document.getElementById('add-service-btn'); this.lastUpdatedEl = document.getElementById('last-updated'); this.titleEl = document.getElementById('dashboard-title'); // Clock & Weather this.greetingEl = document.getElementById('greeting'); this.dateDisplayEl = document.getElementById('date-display'); this.clockTimeEl = document.getElementById('clock-time'); this.clockSecondsEl = document.getElementById('clock-seconds'); this.weatherIcon = document.getElementById('weather-icon'); this.weatherTemp = document.getElementById('weather-temp'); this.weatherDesc = document.getElementById('weather-desc'); // Theme & Canvas this.themeToggle = document.getElementById('theme-toggle'); this.particlesCanvas = document.getElementById('particles-canvas'); this.customBg = document.getElementById('custom-bg'); // Stats this.statOnline = document.getElementById('stat-online'); this.statOffline = document.getElementById('stat-offline'); this.statTotal = document.getElementById('stat-total'); this.statUptime = document.getElementById('stat-uptime'); // Categories & Quick Links this.categoryTabs = document.getElementById('category-tabs'); this.quickLinksEl = document.getElementById('quick-links'); // Modals this.settingsModal = document.getElementById('settings-modal'); this.addServiceModal = document.getElementById('add-service-modal'); this.serviceDetailModal = document.getElementById('service-detail-modal'); this.importModal = document.getElementById('import-modal'); // Toast this.toastContainer = document.getElementById('toast-container'); // Recently Accessed this.recentlyAccessedSection = document.getElementById('recently-accessed-section'); this.recentlyAccessedGrid = document.getElementById('recently-accessed-grid'); // RSS Widget this.rssWidget = document.getElementById('rss-widget'); this.rssFeedContainer = document.getElementById('rss-feed-container'); this.rssRefreshBtn = document.getElementById('rss-refresh-btn'); } init() { // Load data this.loadUserData(); this.state.services = [...(DASHBOARD_CONFIG.services || [])]; // Apply settings this.applySettings(); // Set branding this.titleEl.textContent = DASHBOARD_CONFIG.title; document.title = DASHBOARD_CONFIG.title; // Start clock this.updateClock(); setInterval(() => this.updateClock(), 1000); // Update greeting this.updateGreeting(); setInterval(() => this.updateGreeting(), 60000); // Load weather this.loadWeather(); // Initialize particles if (this.settings.particlesEnabled) { this.initParticles(); } // Render UI this.renderCategories(); this.renderQuickLinks(); this.renderFavorites(); this.renderServices(); this.updateStats(); // Setup event listeners this.setupEventListeners(); // Initial status check if (DASHBOARD_CONFIG.enableStatusCheck) { this.checkAllStatuses(); this.startStatusInterval(); } // Request notification permission if (this.settings.notificationsEnabled) { this.requestNotificationPermission(); } // Render Recently Accessed this.renderRecentlyAccessed(); // Load RSS feeds this.loadRSSFeeds(); // Check and clear expired maintenance this.checkExpiredMaintenance(); } // ========================================== // Data Persistence // ========================================== loadSettings() { const saved = localStorage.getItem('dashboard-settings'); return saved ? JSON.parse(saved) : { weatherLocation: '', tempUnit: 'celsius', statusInterval: 60000, theme: 'dark', accent: 'indigo', cardSize: 'normal', bgStyle: 'default', customBgUrl: '', particlesEnabled: true, notificationsEnabled: false, soundEnabled: false, rssFeeds: [ { name: 'Hacker News', url: 'https://hnrss.org/frontpage' }, { name: 'r/selfhosted', url: 'https://www.reddit.com/r/selfhosted/.rss' } ] }; } saveSettings() { localStorage.setItem('dashboard-settings', JSON.stringify(this.settings)); } loadUserData() { const favs = localStorage.getItem('dashboard-favorites'); if (favs) this.state.favorites = new Set(JSON.parse(favs)); const notes = localStorage.getItem('dashboard-notes'); if (notes) this.state.notes = JSON.parse(notes); const history = localStorage.getItem('dashboard-uptime'); if (history) this.state.uptimeHistory = JSON.parse(history); const maintenance = localStorage.getItem('dashboard-maintenance'); if (maintenance) this.state.maintenance = JSON.parse(maintenance); const recent = localStorage.getItem('dashboard-recent'); if (recent) this.state.recentlyAccessed = JSON.parse(recent); const customs = localStorage.getItem('dashboard-custom-services'); if (customs) { const customServices = JSON.parse(customs); // Merge with config services DASHBOARD_CONFIG.services = [...(DASHBOARD_CONFIG.services || []), ...customServices]; } } saveUserData() { localStorage.setItem('dashboard-favorites', JSON.stringify([...this.state.favorites])); localStorage.setItem('dashboard-notes', JSON.stringify(this.state.notes)); localStorage.setItem('dashboard-uptime', JSON.stringify(this.state.uptimeHistory)); localStorage.setItem('dashboard-maintenance', JSON.stringify(this.state.maintenance)); localStorage.setItem('dashboard-recent', JSON.stringify(this.state.recentlyAccessed)); } applySettings() { document.documentElement.setAttribute('data-theme', this.settings.theme); document.documentElement.setAttribute('data-accent', this.settings.accent); document.documentElement.setAttribute('data-card-size', this.settings.cardSize); document.documentElement.setAttribute('data-bg', this.settings.bgStyle); if (this.settings.customBgUrl) { this.customBg.style.backgroundImage = `url(${this.settings.customBgUrl})`; } } // ========================================== // Particles // ========================================== initParticles() { const canvas = this.particlesCanvas; const ctx = canvas.getContext('2d'); const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }; resize(); window.addEventListener('resize', resize); const particles = []; const particleCount = 50; for (let i = 0; i < particleCount; i++) { particles.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5, size: Math.random() * 2 + 1, opacity: Math.random() * 0.5 + 0.1 }); } const animate = () => { if (!this.settings.particlesEnabled) { ctx.clearRect(0, 0, canvas.width, canvas.height); return; } ctx.clearRect(0, 0, canvas.width, canvas.height); const theme = document.documentElement.getAttribute('data-theme'); const color = theme === 'light' ? '0, 0, 0' : '255, 255, 255'; particles.forEach(p => { p.x += p.vx; p.y += p.vy; if (p.x < 0 || p.x > canvas.width) p.vx *= -1; if (p.y < 0 || p.y > canvas.height) p.vy *= -1; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fillStyle = `rgba(${color}, ${p.opacity})`; ctx.fill(); }); // Draw connections particles.forEach((p1, i) => { particles.slice(i + 1).forEach(p2 => { const dx = p1.x - p2.x; const dy = p1.y - p2.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 150) { ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.strokeStyle = `rgba(${color}, ${0.1 * (1 - dist / 150)})`; ctx.stroke(); } }); }); requestAnimationFrame(animate); }; animate(); } // ========================================== // Theme // ========================================== toggleTheme() { const themes = ['dark', 'light', 'nord', 'dracula']; const current = this.settings.theme; const next = themes[(themes.indexOf(current) + 1) % themes.length]; this.settings.theme = next; document.documentElement.setAttribute('data-theme', next); this.saveSettings(); this.showToast(`Switched to ${next} theme`, 'info'); } // ========================================== // Clock & Greeting // ========================================== updateClock() { const now = new Date(); const hours = now.getHours().toString().padStart(2, '0'); const minutes = now.getMinutes().toString().padStart(2, '0'); const seconds = now.getSeconds().toString().padStart(2, '0'); this.clockTimeEl.textContent = `${hours}:${minutes}`; this.clockSecondsEl.textContent = `:${seconds}`; } updateGreeting() { const now = new Date(); const hour = now.getHours(); let greeting; if (hour >= 5 && hour < 12) greeting = '☀️ Good morning'; else if (hour >= 12 && hour < 17) greeting = '🌤️ Good afternoon'; else if (hour >= 17 && hour < 21) greeting = '🌅 Good evening'; else greeting = '🌙 Good night'; this.greetingEl.textContent = greeting; const options = { weekday: 'long', month: 'long', day: 'numeric' }; this.dateDisplayEl.textContent = now.toLocaleDateString('en-US', options); } // ========================================== // Weather // ========================================== async loadWeather() { const location = this.settings.weatherLocation || 'auto'; try { const url = location === 'auto' || location === '' ? 'https://wttr.in/?format=j1' : `https://wttr.in/${encodeURIComponent(location)}?format=j1`; const response = await fetch(url); if (!response.ok) throw new Error('Weather fetch failed'); const data = await response.json(); const current = data.current_condition[0]; const temp = this.settings.tempUnit === 'fahrenheit' ? current.temp_F : current.temp_C; const unit = this.settings.tempUnit === 'fahrenheit' ? 'F' : 'C'; this.weatherIcon.textContent = this.getWeatherEmoji(current.weatherCode); this.weatherTemp.textContent = `${temp}°${unit}`; this.weatherDesc.textContent = current.weatherDesc[0].value; } catch (error) { this.weatherIcon.textContent = '🌐'; this.weatherTemp.textContent = '--°'; this.weatherDesc.textContent = 'Unavailable'; } } getWeatherEmoji(code) { const codeNum = parseInt(code); if (codeNum === 113) return '☀️'; if (codeNum === 116) return '⛅'; if (codeNum === 119 || codeNum === 122) return '☁️'; if (codeNum >= 176 && codeNum <= 202) return '🌧️'; if (codeNum >= 200 && codeNum <= 232) return '⛈️'; if (codeNum >= 227 && codeNum <= 260) return '🌨️'; if (codeNum >= 263 && codeNum <= 356) return '🌧️'; if (codeNum >= 359 && codeNum <= 395) return '🌨️'; return '🌤️'; } // ========================================== // Categories // ========================================== renderCategories() { const services = this.state.services; const categories = new Set(['all']); services.forEach(s => { if (s.category) categories.add(s.category); }); const icons = { 'all': '🏠', 'media': '🎬', 'network': '🌐', 'storage': '💾', 'automation': '⚙️', 'security': '🔒', 'monitoring': '📊', 'development': '💻', 'other': '📦' }; this.categoryTabs.innerHTML = Array.from(categories).map(cat => { const count = cat === 'all' ? services.length : services.filter(s => s.category === cat).length; const icon = icons[cat] || '📦'; const label = cat === 'all' ? 'All' : this.capitalize(cat); return ` `; }).join(''); this.categoryTabs.querySelectorAll('.category-tab').forEach(tab => { tab.addEventListener('click', () => this.selectCategory(tab.dataset.category)); }); } selectCategory(category) { this.state.currentCategory = category; this.categoryTabs.querySelectorAll('.category-tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.category === category); }); this.filterServices(this.searchInput.value, category); } // ========================================== // Quick Links // ========================================== renderQuickLinks() { const links = DASHBOARD_CONFIG.quickLinks || []; if (links.length === 0) { this.quickLinksEl.style.display = 'none'; return; } this.quickLinksEl.innerHTML = links.map(link => ` ${link.icon ? `${link.icon}` : ''} ${link.name} `).join(''); } // ========================================== // Favorites // ========================================== renderFavorites() { const favorites = this.state.services.filter(s => this.state.favorites.has(s.name)); if (favorites.length === 0) { this.favoritesSection.classList.remove('has-favorites'); return; } this.favoritesSection.classList.add('has-favorites'); this.favoritesGrid.innerHTML = favorites.map((s, i) => this.createServiceCard(s, i, true)).join(''); this.attachCardListeners(this.favoritesGrid); } toggleFavorite(serviceName) { if (this.state.favorites.has(serviceName)) { this.state.favorites.delete(serviceName); this.showToast(`Removed ${serviceName} from favorites`, 'info'); } else { this.state.favorites.add(serviceName); this.showToast(`Added ${serviceName} to favorites`, 'success'); } this.saveUserData(); this.renderFavorites(); this.renderServices(); } // ========================================== // Services // ========================================== renderServices() { const services = this.state.services; if (!services || services.length === 0) { this.grid.innerHTML = this.createEmptyState(); return; } this.grid.innerHTML = services.map((s, i) => this.createServiceCard(s, i, false)).join(''); this.attachCardListeners(this.grid); this.statTotal.textContent = services.length; } createServiceCard(service, index, isFavorite = false) { const icon = SERVICE_ICONS[service.icon] || SERVICE_ICONS.default; const inMaintenance = this.isInMaintenance(service.name); const statusClass = inMaintenance ? 'maintenance' : (DASHBOARD_CONFIG.enableStatusCheck ? 'checking' : ''); const favClass = this.state.favorites.has(service.name) ? 'is-favorite' : ''; const maintenanceClass = inMaintenance ? 'in-maintenance' : ''; const ping = this.state.pingTimes.get(service.name); const maintenanceInfo = this.state.maintenance[service.name]; return `
${icon}
${inMaintenance ? `
Maintenance ${maintenanceInfo.reason ? `${maintenanceInfo.reason}` : ''}
` : (DASHBOARD_CONFIG.enableStatusCheck ? `
Checking ${ping ? `${ping}ms` : ''}
` : '')}

${service.name}

${service.description}

${this.formatUrl(service.url)}
`; } attachCardListeners(container) { container.querySelectorAll('.favorite-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.toggleFavorite(btn.dataset.service); }); }); container.querySelectorAll('.info-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.openServiceDetail(btn.dataset.service); }); }); // Track clicks for Recently Accessed container.querySelectorAll('.card-link').forEach(link => { link.addEventListener('click', () => { const card = link.closest('.service-card'); if (card) { this.trackServiceAccess(card.dataset.name, card.dataset.url); } }); }); } createEmptyState() { return `

No Services Configured

Click the + button to add your first service, or edit config.js directly.

`; } formatUrl(url) { try { const parsed = new URL(url); return parsed.host + (parsed.pathname !== '/' ? parsed.pathname : ''); } catch { return url; } } // ========================================== // Service Detail Modal // ========================================== openServiceDetail(serviceName) { const service = this.state.services.find(s => s.name === serviceName); if (!service) return; this.state.currentDetailService = serviceName; document.getElementById('detail-service-name').textContent = serviceName; document.getElementById('detail-notes').value = this.state.notes[serviceName] || ''; // Status & Ping const inMaintenance = this.isInMaintenance(serviceName); const status = inMaintenance ? 'maintenance' : (this.state.serviceStatuses.get(serviceName) || 'checking'); const ping = this.state.pingTimes.get(serviceName); document.getElementById('detail-status').className = `detail-status-indicator ${status}`; document.getElementById('detail-ping').textContent = ping ? `${ping}ms` : '-- ms'; // Maintenance mode fields const maintenanceEnabled = document.getElementById('maintenance-enabled'); const maintenanceUntil = document.getElementById('maintenance-until'); const maintenanceReason = document.getElementById('maintenance-reason'); const maintenanceOptions = document.getElementById('maintenance-options'); const maintenanceData = this.state.maintenance[serviceName]; maintenanceEnabled.checked = !!maintenanceData; maintenanceOptions.classList.toggle('visible', !!maintenanceData); if (maintenanceData) { if (maintenanceData.until) { const date = new Date(maintenanceData.until); maintenanceUntil.value = date.toISOString().slice(0, 16); } else { maintenanceUntil.value = ''; } maintenanceReason.value = maintenanceData.reason || ''; } else { maintenanceUntil.value = ''; maintenanceReason.value = ''; } // Uptime history this.renderUptimeHistory(serviceName); this.serviceDetailModal.classList.add('active'); } renderUptimeHistory(serviceName) { const bar = document.getElementById('uptime-bar'); const history = this.state.uptimeHistory[serviceName] || []; // Show last 24 segments (hours) const segments = []; for (let i = 0; i < 24; i++) { const status = history[history.length - 24 + i] || 'unknown'; segments.push(`
`); } bar.innerHTML = segments.join(''); } saveServiceNotes() { const notes = document.getElementById('detail-notes').value; const serviceName = this.state.currentDetailService; // Save notes if (notes.trim()) { this.state.notes[serviceName] = notes; } else { delete this.state.notes[serviceName]; } // Save maintenance mode const maintenanceEnabled = document.getElementById('maintenance-enabled').checked; const maintenanceUntil = document.getElementById('maintenance-until').value; const maintenanceReason = document.getElementById('maintenance-reason').value; this.toggleMaintenance(serviceName, maintenanceEnabled, maintenanceUntil, maintenanceReason); this.saveUserData(); this.serviceDetailModal.classList.remove('active'); this.showToast('Settings saved', 'success'); } deleteService(serviceName) { if (!confirm(`Delete ${serviceName}?`)) return; this.state.services = this.state.services.filter(s => s.name !== serviceName); this.state.favorites.delete(serviceName); delete this.state.notes[serviceName]; // Save custom services (exclude config ones) const configNames = new Set((DASHBOARD_CONFIG.services || []).map(s => s.name)); const customServices = this.state.services.filter(s => !configNames.has(s.name)); localStorage.setItem('dashboard-custom-services', JSON.stringify(customServices)); this.saveUserData(); this.serviceDetailModal.classList.remove('active'); this.renderCategories(); this.renderFavorites(); this.renderServices(); this.showToast(`Deleted ${serviceName}`, 'info'); } // ========================================== // Add Service Modal // ========================================== openAddServiceModal() { // Reset form document.getElementById('service-name').value = ''; document.getElementById('service-url').value = ''; document.getElementById('service-desc').value = ''; document.getElementById('service-category').value = 'other'; document.getElementById('service-icon').value = 'default'; document.getElementById('service-notes').value = ''; // Reset color selection this.addServiceModal.querySelectorAll('.color-option').forEach(c => c.classList.remove('active')); this.addServiceModal.querySelector('.color-option[data-color="cyan"]').classList.add('active'); this.addServiceModal.classList.add('active'); } saveNewService() { const name = document.getElementById('service-name').value.trim(); const url = document.getElementById('service-url').value.trim(); const desc = document.getElementById('service-desc').value.trim(); const category = document.getElementById('service-category').value; const icon = document.getElementById('service-icon').value; const notes = document.getElementById('service-notes').value.trim(); const colorBtn = this.addServiceModal.querySelector('.color-option.active'); const color = colorBtn ? colorBtn.dataset.color : 'cyan'; if (!name || !url) { this.showToast('Name and URL are required', 'error'); return; } // Check for duplicate if (this.state.services.some(s => s.name.toLowerCase() === name.toLowerCase())) { this.showToast('A service with this name already exists', 'error'); return; } const newService = { name, url, description: desc || 'No description', category, icon, color }; this.state.services.push(newService); if (notes) this.state.notes[name] = notes; // Save to localStorage const configNames = new Set((DASHBOARD_CONFIG.services || []).map(s => s.name)); const customServices = this.state.services.filter(s => !configNames.has(s.name)); localStorage.setItem('dashboard-custom-services', JSON.stringify(customServices)); this.saveUserData(); this.addServiceModal.classList.remove('active'); this.renderCategories(); this.renderServices(); this.checkAllStatuses(); this.showToast(`Added ${name}`, 'success'); } // ========================================== // Stats // ========================================== updateStats() { let online = 0, offline = 0; this.state.serviceStatuses.forEach(status => { if (status === 'online') online++; else if (status === 'offline') offline++; }); this.statOnline.textContent = online; this.statOffline.textContent = offline; const total = this.state.services.length; const uptime = total > 0 ? Math.round((online / total) * 100) : 0; this.statUptime.textContent = `${uptime}%`; } // ========================================== // Event Listeners // ========================================== setupEventListeners() { // Search this.searchInput.addEventListener('input', (e) => { this.filterServices(e.target.value, this.state.currentCategory); }); // Buttons this.refreshBtn.addEventListener('click', () => this.checkAllStatuses()); this.fullscreenBtn.addEventListener('click', () => this.toggleFullscreen()); this.addServiceBtn.addEventListener('click', () => this.openAddServiceModal()); this.themeToggle.addEventListener('click', () => this.toggleTheme()); // Settings modal document.getElementById('settings-link').addEventListener('click', (e) => { e.preventDefault(); this.openSettings(); }); document.getElementById('modal-close').addEventListener('click', () => this.closeSettings()); document.getElementById('settings-cancel').addEventListener('click', () => this.closeSettings()); document.getElementById('settings-save').addEventListener('click', () => this.saveSettingsFromModal()); this.settingsModal.addEventListener('click', (e) => { if (e.target === this.settingsModal) this.closeSettings(); }); // Settings tabs document.querySelectorAll('.modal-tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.modal-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); tab.classList.add('active'); document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); }); }); // Setting toggles document.querySelectorAll('.setting-toggle[data-unit]').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.setting-toggle[data-unit]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); document.querySelectorAll('.setting-toggle[data-size]').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.setting-toggle[data-size]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); // Theme options document.querySelectorAll('.theme-option').forEach(opt => { opt.addEventListener('click', () => { document.querySelectorAll('.theme-option').forEach(o => o.classList.remove('active')); opt.classList.add('active'); }); }); // Accent colors this.settingsModal.querySelectorAll('.color-option[data-accent]').forEach(opt => { opt.addEventListener('click', () => { this.settingsModal.querySelectorAll('.color-option[data-accent]').forEach(o => o.classList.remove('active')); opt.classList.add('active'); }); }); // Add service modal colors this.addServiceModal.querySelectorAll('.color-option').forEach(opt => { opt.addEventListener('click', () => { this.addServiceModal.querySelectorAll('.color-option').forEach(o => o.classList.remove('active')); opt.classList.add('active'); }); }); // Background options document.querySelectorAll('.bg-option').forEach(opt => { opt.addEventListener('click', () => { document.querySelectorAll('.bg-option').forEach(o => o.classList.remove('active')); opt.classList.add('active'); }); }); // Custom background upload document.getElementById('bg-upload').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (ev) => { this.settings.customBgUrl = ev.target.result; this.customBg.style.backgroundImage = `url(${ev.target.result})`; this.showToast('Background uploaded', 'success'); }; reader.readAsDataURL(file); } }); // Add service modal document.getElementById('add-modal-close').addEventListener('click', () => this.addServiceModal.classList.remove('active')); document.getElementById('add-service-cancel').addEventListener('click', () => this.addServiceModal.classList.remove('active')); document.getElementById('add-service-save').addEventListener('click', () => this.saveNewService()); this.addServiceModal.addEventListener('click', (e) => { if (e.target === this.addServiceModal) this.addServiceModal.classList.remove('active'); }); // Service detail modal document.getElementById('detail-modal-close').addEventListener('click', () => this.serviceDetailModal.classList.remove('active')); document.getElementById('detail-cancel').addEventListener('click', () => this.serviceDetailModal.classList.remove('active')); document.getElementById('save-notes-btn').addEventListener('click', () => this.saveServiceNotes()); document.getElementById('delete-service-btn').addEventListener('click', () => this.deleteService(this.state.currentDetailService)); this.serviceDetailModal.addEventListener('click', (e) => { if (e.target === this.serviceDetailModal) this.serviceDetailModal.classList.remove('active'); }); // Import/Export document.getElementById('export-btn').addEventListener('click', () => this.exportConfig()); document.getElementById('import-btn').addEventListener('click', () => this.importModal.classList.add('active')); document.getElementById('import-modal-close').addEventListener('click', () => this.importModal.classList.remove('active')); document.getElementById('import-cancel').addEventListener('click', () => this.importModal.classList.remove('active')); document.getElementById('import-confirm').addEventListener('click', () => this.importConfig()); this.importModal.addEventListener('click', (e) => { if (e.target === this.importModal) this.importModal.classList.remove('active'); }); // Maintenance mode toggle document.getElementById('maintenance-enabled').addEventListener('change', (e) => { document.getElementById('maintenance-options').classList.toggle('visible', e.target.checked); }); // RSS widget refresh button this.rssRefreshBtn.addEventListener('click', () => { this.rssRefreshBtn.classList.add('spinning'); this.loadRSSFeeds().then(() => { this.rssRefreshBtn.classList.remove('spinning'); }); }); // RSS settings - add feed document.getElementById('add-feed-btn').addEventListener('click', () => { const name = document.getElementById('new-feed-name').value.trim(); const url = document.getElementById('new-feed-url').value.trim(); this.addRSSFeed(name, url); document.getElementById('new-feed-name').value = ''; document.getElementById('new-feed-url').value = ''; }); // RSS settings - default feeds document.querySelectorAll('.default-feeds button[data-feed]').forEach(btn => { btn.addEventListener('click', () => this.addDefaultFeed(btn.dataset.feed)); }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { const inInput = document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA'; if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); this.searchInput.focus(); } if (e.key === 'Escape') { this.closeAllModals(); this.searchInput.value = ''; this.filterServices('', this.state.currentCategory); } if (!inInput) { if (e.key === 'r') this.checkAllStatuses(); if (e.key === 'f') this.toggleFullscreen(); if (e.key === 't') this.toggleTheme(); if (e.key === 'a') this.openAddServiceModal(); } }); } closeAllModals() { this.settingsModal.classList.remove('active'); this.addServiceModal.classList.remove('active'); this.serviceDetailModal.classList.remove('active'); this.importModal.classList.remove('active'); } // ========================================== // Filtering // ========================================== filterServices(query, category = 'all') { const cards = this.grid.querySelectorAll('.service-card'); const normalizedQuery = query.toLowerCase().trim(); cards.forEach(card => { const name = card.dataset.name.toLowerCase(); const cardCategory = card.dataset.category; const matchesSearch = name.includes(normalizedQuery); const matchesCategory = category === 'all' || cardCategory === category; card.classList.toggle('hidden', !(matchesSearch && matchesCategory)); }); } // ========================================== // Status Checking // ========================================== async checkAllStatuses() { if (!DASHBOARD_CONFIG.enableStatusCheck) return; this.refreshBtn.classList.add('spinning'); const cards = [...this.grid.querySelectorAll('.service-card'), ...this.favoritesGrid.querySelectorAll('.service-card')]; const uniqueServices = new Map(); cards.forEach(c => uniqueServices.set(c.dataset.name, c)); await Promise.all(Array.from(uniqueServices.entries()).map(([name, card]) => this.checkStatus(card))); this.updateStats(); this.updateLastChecked(); this.refreshBtn.classList.remove('spinning'); const online = Array.from(this.state.serviceStatuses.values()).filter(s => s === 'online').length; this.showToast(`${online}/${this.state.serviceStatuses.size} services online`, 'success'); } async checkStatus(card) { const url = card.dataset.url; const name = card.dataset.name; // Skip check if in maintenance if (this.isInMaintenance(name)) return; const indicators = document.querySelectorAll(`.service-card[data-name="${name}"] .status-indicator`); indicators.forEach(ind => { ind.className = 'status-indicator checking'; ind.querySelector('.status-text').textContent = 'Checking'; }); const startTime = performance.now(); let status = 'online'; let ping = null; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), DASHBOARD_CONFIG.statusTimeout || 5000); await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: controller.signal }); clearTimeout(timeoutId); ping = Math.round(performance.now() - startTime); } catch (error) { if (error.name === 'AbortError') { status = 'offline'; } else { ping = Math.round(performance.now() - startTime); } } // Update all cards with this service name indicators.forEach(ind => { ind.className = `status-indicator ${status}`; ind.querySelector('.status-text').textContent = status === 'online' ? 'Online' : 'Offline'; const pingEl = ind.querySelector('.ping-time'); if (ping && status === 'online') { if (pingEl) pingEl.textContent = `${ping}ms`; else ind.insertAdjacentHTML('beforeend', `${ping}ms`); } }); const prevStatus = this.state.serviceStatuses.get(name); this.state.serviceStatuses.set(name, status); this.state.pingTimes.set(name, ping); // Record uptime history if (!this.state.uptimeHistory[name]) this.state.uptimeHistory[name] = []; this.state.uptimeHistory[name].push(status); if (this.state.uptimeHistory[name].length > 168) this.state.uptimeHistory[name].shift(); // Keep 7 days this.saveUserData(); // Notify if went offline if (prevStatus === 'online' && status === 'offline' && this.settings.notificationsEnabled) { this.sendNotification(`${name} went offline`, `The service at ${url} is no longer responding.`); } } updateLastChecked() { const now = new Date(); this.lastUpdatedEl.textContent = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } startStatusInterval() { if (this.state.statusCheckInterval) clearInterval(this.state.statusCheckInterval); const interval = this.settings.statusInterval || DASHBOARD_CONFIG.statusCheckInterval || 60000; if (interval > 0) { this.state.statusCheckInterval = setInterval(() => this.checkAllStatuses(), interval); } } // ========================================== // Notifications // ========================================== requestNotificationPermission() { if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } } sendNotification(title, body) { if ('Notification' in window && Notification.permission === 'granted') { new Notification(title, { body, icon: '/favicon.ico' }); } } // ========================================== // Fullscreen // ========================================== toggleFullscreen() { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(() => { }); } else { document.exitFullscreen().catch(() => { }); } } // ========================================== // Settings Modal // ========================================== openSettings() { document.getElementById('weather-location').value = this.settings.weatherLocation || ''; document.getElementById('status-interval').value = this.settings.statusInterval || 60000; document.getElementById('particles-enabled').checked = this.settings.particlesEnabled !== false; document.getElementById('notifications-enabled').checked = this.settings.notificationsEnabled || false; document.getElementById('sound-enabled').checked = this.settings.soundEnabled || false; // Temp unit document.querySelectorAll('.setting-toggle[data-unit]').forEach(btn => { btn.classList.toggle('active', btn.dataset.unit === (this.settings.tempUnit || 'celsius')); }); // Card size document.querySelectorAll('.setting-toggle[data-size]').forEach(btn => { btn.classList.toggle('active', btn.dataset.size === (this.settings.cardSize || 'normal')); }); // Theme document.querySelectorAll('.theme-option').forEach(opt => { opt.classList.toggle('active', opt.dataset.theme === (this.settings.theme || 'dark')); }); // Accent this.settingsModal.querySelectorAll('.color-option[data-accent]').forEach(opt => { opt.classList.toggle('active', opt.dataset.accent === (this.settings.accent || 'indigo')); }); // Background document.querySelectorAll('.bg-option').forEach(opt => { opt.classList.toggle('active', opt.dataset.bg === (this.settings.bgStyle || 'default')); }); // RSS feeds list this.renderRSSFeedsList(); this.settingsModal.classList.add('active'); } closeSettings() { this.settingsModal.classList.remove('active'); } saveSettingsFromModal() { this.settings.weatherLocation = document.getElementById('weather-location').value; this.settings.statusInterval = parseInt(document.getElementById('status-interval').value); this.settings.particlesEnabled = document.getElementById('particles-enabled').checked; this.settings.notificationsEnabled = document.getElementById('notifications-enabled').checked; this.settings.soundEnabled = document.getElementById('sound-enabled').checked; const tempUnit = document.querySelector('.setting-toggle[data-unit].active'); this.settings.tempUnit = tempUnit ? tempUnit.dataset.unit : 'celsius'; const cardSize = document.querySelector('.setting-toggle[data-size].active'); this.settings.cardSize = cardSize ? cardSize.dataset.size : 'normal'; const theme = document.querySelector('.theme-option.active'); this.settings.theme = theme ? theme.dataset.theme : 'dark'; const accent = this.settingsModal.querySelector('.color-option[data-accent].active'); this.settings.accent = accent ? accent.dataset.accent : 'indigo'; const bgStyle = document.querySelector('.bg-option.active'); this.settings.bgStyle = bgStyle ? bgStyle.dataset.bg : 'default'; this.saveSettings(); this.applySettings(); this.loadWeather(); this.startStatusInterval(); if (this.settings.notificationsEnabled) this.requestNotificationPermission(); if (this.settings.particlesEnabled) this.initParticles(); this.closeSettings(); this.showToast('Settings saved', 'success'); } // ========================================== // Import/Export // ========================================== exportConfig() { const data = { services: this.state.services, favorites: [...this.state.favorites], notes: this.state.notes, settings: this.settings, exportedAt: new Date().toISOString() }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `dashboard-config-${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); this.showToast('Configuration exported', 'success'); } importConfig() { const data = document.getElementById('import-data').value.trim(); try { const parsed = JSON.parse(data); if (parsed.services) { localStorage.setItem('dashboard-custom-services', JSON.stringify(parsed.services)); } if (parsed.favorites) { localStorage.setItem('dashboard-favorites', JSON.stringify(parsed.favorites)); } if (parsed.notes) { localStorage.setItem('dashboard-notes', JSON.stringify(parsed.notes)); } if (parsed.settings) { localStorage.setItem('dashboard-settings', JSON.stringify(parsed.settings)); } this.importModal.classList.remove('active'); this.showToast('Configuration imported! Reloading...', 'success'); setTimeout(() => location.reload(), 1500); } catch (e) { this.showToast('Invalid JSON format', 'error'); } } // ========================================== // Toast Notifications // ========================================== showToast(message, type = 'info') { const toast = document.createElement('div'); toast.className = `toast ${type}`; const icons = { success: '', error: '', info: '' }; toast.innerHTML = `${icons[type]}${message}`; this.toastContainer.appendChild(toast); setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, 3000); } // ========================================== // Utilities // ========================================== capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); } // ========================================== // Maintenance Mode // ========================================== isInMaintenance(serviceName) { const m = this.state.maintenance[serviceName]; if (!m) return false; // Check if maintenance has expired if (m.until && new Date(m.until) < new Date()) { delete this.state.maintenance[serviceName]; this.saveUserData(); return false; } return true; } checkExpiredMaintenance() { const now = new Date(); let changed = false; Object.keys(this.state.maintenance).forEach(name => { const m = this.state.maintenance[name]; if (m.until && new Date(m.until) < now) { delete this.state.maintenance[name]; changed = true; } }); if (changed) { this.saveUserData(); this.renderServices(); } } toggleMaintenance(serviceName, enabled, until = null, reason = '') { if (enabled) { this.state.maintenance[serviceName] = { until: until ? new Date(until).getTime() : null, reason: reason || 'Scheduled maintenance' }; this.showToast(`${serviceName} set to maintenance mode`, 'info'); } else { delete this.state.maintenance[serviceName]; this.showToast(`${serviceName} removed from maintenance`, 'success'); } this.saveUserData(); this.renderFavorites(); this.renderServices(); this.updateStats(); } // ========================================== // Recently Accessed // ========================================== trackServiceAccess(serviceName, serviceUrl) { // Remove existing entry for this service if exists this.state.recentlyAccessed = this.state.recentlyAccessed.filter(r => r.name !== serviceName); // Add at the beginning this.state.recentlyAccessed.unshift({ name: serviceName, url: serviceUrl, timestamp: Date.now() }); // Keep only the last 6 this.state.recentlyAccessed = this.state.recentlyAccessed.slice(0, 6); this.saveUserData(); this.renderRecentlyAccessed(); } renderRecentlyAccessed() { const recent = this.state.recentlyAccessed; if (!recent || recent.length === 0) { this.recentlyAccessedSection.classList.remove('has-recent'); return; } this.recentlyAccessedSection.classList.add('has-recent'); this.recentlyAccessedGrid.innerHTML = recent.map(r => { const service = this.state.services.find(s => s.name === r.name); const icon = service ? (SERVICE_ICONS[service.icon] || SERVICE_ICONS.default) : SERVICE_ICONS.default; const timeAgo = this.getTimeAgo(r.timestamp); return `
${icon}
${r.name} ${timeAgo}
`; }).join(''); } getTimeAgo(timestamp) { const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 60) return 'Just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; return new Date(timestamp).toLocaleDateString(); } // ========================================== // RSS Feeds // ========================================== async loadRSSFeeds() { const feeds = this.settings.rssFeeds || []; if (feeds.length === 0) { this.rssFeedContainer.innerHTML = `

No RSS feeds configured

Add feeds in Settings → RSS Feeds

`; return; } this.rssFeedContainer.innerHTML = '
Loading feeds...
'; try { const allItems = []; for (const feed of feeds) { try { const items = await this.fetchRSSFeed(feed.url, feed.name); allItems.push(...items); } catch (e) { console.warn(`Failed to load feed ${feed.name}:`, e); } } // Sort by date (newest first) allItems.sort((a, b) => new Date(b.date) - new Date(a.date)); // Take top 10 items this.state.rssItems = allItems.slice(0, 10); this.renderRSSWidget(); } catch (error) { console.error('Error loading RSS feeds:', error); this.rssFeedContainer.innerHTML = `

Failed to load feeds

`; } } async fetchRSSFeed(url, sourceName) { // Use rss2json API to convert RSS to JSON (works around CORS) const apiUrl = `https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(url)}`; const response = await fetch(apiUrl); if (!response.ok) throw new Error('Feed fetch failed'); const data = await response.json(); if (data.status !== 'ok') throw new Error('RSS parse failed'); return data.items.slice(0, 5).map(item => ({ title: item.title, link: item.link, date: item.pubDate, source: sourceName })); } renderRSSWidget() { const items = this.state.rssItems; if (items.length === 0) { this.rssFeedContainer.innerHTML = `

No news items found

`; return; } this.rssFeedContainer.innerHTML = items.map(item => { const date = new Date(item.date); const timeAgo = this.getTimeAgo(date.getTime()); return `
📰
${this.escapeHtml(item.title)}
${item.source} ${timeAgo}
`; }).join(''); } escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } addRSSFeed(name, url) { if (!name || !url) { this.showToast('Name and URL are required', 'error'); return; } // Check for duplicates if (this.settings.rssFeeds.some(f => f.url === url)) { this.showToast('This feed already exists', 'error'); return; } this.settings.rssFeeds.push({ name, url }); this.saveSettings(); this.renderRSSFeedsList(); this.loadRSSFeeds(); this.showToast(`Added ${name} feed`, 'success'); } removeRSSFeed(url) { this.settings.rssFeeds = this.settings.rssFeeds.filter(f => f.url !== url); this.saveSettings(); this.renderRSSFeedsList(); this.loadRSSFeeds(); this.showToast('Feed removed', 'info'); } renderRSSFeedsList() { const list = document.getElementById('rss-feeds-list'); if (!list) return; const feeds = this.settings.rssFeeds || []; if (feeds.length === 0) { list.innerHTML = '

No feeds added yet

'; return; } list.innerHTML = feeds.map(feed => `
${feed.name} ${feed.url}
`).join(''); // Attach remove listeners list.querySelectorAll('.rss-feed-remove').forEach(btn => { btn.addEventListener('click', () => this.removeRSSFeed(btn.dataset.url)); }); } addDefaultFeed(feedType) { const defaults = { hackernews: { name: 'Hacker News', url: 'https://hnrss.org/frontpage' }, selfhosted: { name: 'r/selfhosted', url: 'https://www.reddit.com/r/selfhosted/.rss' }, techmeme: { name: 'Techmeme', url: 'https://www.techmeme.com/feed.xml' } }; const feed = defaults[feedType]; if (feed) { this.addRSSFeed(feed.name, feed.url); } } } // Initialize document.addEventListener('DOMContentLoaded', () => new Dashboard());