commit 4a87d77991448f942afecc00f06b4171c05adb20 Author: Ben Date: Sat Dec 27 03:34:59 2025 +0000 Upload files to "/" diff --git a/README.md b/README.md new file mode 100644 index 0000000..d34258d --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Home Dashboard + +A beautiful, self-hosted dashboard for your home services. + +![Dashboard Preview](screenshot.png) + +## Features + +- 🎨 **Modern Dark Theme** - Glassmorphism design with animated gradient background +- 📊 **Live Status** - Real-time online/offline indicators for each service +- 🔍 **Search** - Filter services with Ctrl+K or click the search box +- 📱 **Responsive** - Works on desktop, tablet, and mobile +- ⚡ **Zero Dependencies** - Pure HTML/CSS/JS, no build step required + +## Quick Start + +### Option 1: Open Directly +Just double-click `index.html` to open in your browser. + +### Option 2: Python HTTP Server +```bash +cd "U:\AntiGravity\Self-Hosted Dashboard" +python -m http.server 8080 +``` +Then visit: http://localhost:8080 + +### Option 3: Nginx +Copy all files to your nginx web root (usually `/var/www/html/dashboard/`). + +## Configuration + +Edit `config.js` to customize your dashboard: + +```javascript +const DASHBOARD_CONFIG = { + title: "My Home Lab", // Dashboard title + subtitle: "All my services", // Subtitle text + enableStatusCheck: true, // Live status checking + statusCheckInterval: 60000, // Check every 60 seconds + + services: [ + { + name: "Plex", + description: "Media streaming", + url: "http://192.168.1.100:32400/web", + icon: "plex", + color: "orange" + }, + // Add more services here... + ] +}; +``` + +### Available Icons +`plex`, `pihole`, `jellyfin`, `sonarr`, `radarr`, `homeassistant`, `portainer`, `nextcloud` + +### Available Colors +`orange`, `blue`, `green`, `purple`, `red`, `cyan`, `pink`, `yellow`, `teal`, `indigo` + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `Ctrl+K` | Focus search | +| `Escape` | Clear search | +| `R` | Refresh status | + +## Adding New Services + +1. Open `config.js` +2. Add a new object to the `services` array +3. Save and refresh your browser + +## Files + +``` +├── index.html # Main page +├── styles.css # All styling +├── app.js # Dashboard logic +├── config.js # Your configuration +└── README.md # This file +``` + +--- +Made with ❤️ for self-hosters diff --git a/app.js b/app.js new file mode 100644 index 0000000..6b3f33c --- /dev/null +++ b/app.js @@ -0,0 +1,1517 @@ +/** + * 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()); diff --git a/config.js b/config.js new file mode 100644 index 0000000..a34a917 --- /dev/null +++ b/config.js @@ -0,0 +1,106 @@ +/** + * Dashboard Configuration + * + * Edit this file to customize your dashboard! + * Add, remove, or modify services as needed. + */ + +const DASHBOARD_CONFIG = { + // Dashboard branding + title: "Home Dashboard", + subtitle: "Your self-hosted services at a glance", + + // Status check settings + enableStatusCheck: true, // Set to false to disable status checking + statusCheckInterval: 60000, // How often to check status (in milliseconds) + statusTimeout: 5000, // Timeout for status checks (in milliseconds) + + // Quick Links - appear as buttons at the top + quickLinks: [ + { name: "Router", url: "http://192.168.1.1", icon: "🌐" }, + { name: "Speed Test", url: "https://fast.com", icon: "⚡" }, + { name: "GitHub", url: "https://github.com/kiyreload27", icon: "💻" } + ], + + // Your services - organize by category! + // Categories: media, network, storage, automation, security, monitoring, development, other + services: [ + // === MEDIA === + { + name: "Plex", + description: "Stream your personal media library anywhere", + url: "http://192.168.1.196:32400/web", + icon: "plex", + color: "orange", + category: "media" + }, + { + name: "Jellyfin", + description: "Free software media system - Plex alternative", + url: "http://localhost:8096", + icon: "jellyfin", + color: "purple", + category: "media" + }, + + // === NETWORK === + + // === STORAGE === + { + name: "TrueNAS", + description: "Self-hosted cloud storage and Application Server", + url: "http://192.168.1.196", + icon: "trueNAS", + color: "blue", + category: "storage" + }, + + // === MONITORING === + { + name: "Portainer", + description: "Docker container management and monitoring", + url: "http://localhost:9000", + icon: "portainer", + color: "cyan", + category: "monitoring" + } + ] +}; + +/** + * Service Icons (SVG) + * Add custom icons for your services here + */ +const SERVICE_ICONS = { + plex: ``, + + pihole: ``, + + jellyfin: ``, + + sonarr: ``, + + radarr: ``, + + homeassistant: ``, + + portainer: ``, + + nextcloud: ``, + + grafana: ``, + + transmission: ``, + + vpn: ``, + + nginx: ``, + + trueNAS: ``, + + unraid: ``, + + proxmox: ``, + + default: `` +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..2502794 --- /dev/null +++ b/index.html @@ -0,0 +1,583 @@ + + + + + + + + Home Dashboard + + + + + + + + + + +
+
+
+
+
+
+
+ +
+ +
+
+

Good evening

+

+
+
+
+ 00:00 + :00 +
+
+
🌙
+
+ --° + Loading... +
+
+ +
+
+ + +
+
+
+ + + + +
+
+ 0 + Online +
+
+
+
+ + + + + +
+
+ 0 + Offline +
+
+
+
+ + + + + + +
+
+ 0 + Services +
+
+
+
+ + + +
+
+ --% + Uptime +
+
+
+ + +
+
+ + + + + +
+ K +
+
+
+ + + +
+
+ + + + + + + + +
+
+

⭐ Favorites

+
+
+ +
+
+ + +
+
+

🕐 Recently Accessed

+
+
+ +
+
+ + +
+ +
+ + +
+
+

📰 News Feed

+ +
+
+
Loading feeds...
+
+
+ + +
+ + + +
+
+ + + + + + + + + + + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..94def45 --- /dev/null +++ b/styles.css @@ -0,0 +1,2395 @@ +/* ============================================ + CSS Custom Properties (Themes) + ============================================ */ +:root { + /* Accent Colors - Can be overridden by data-accent */ + --accent-primary: #6366f1; + --accent-secondary: #8b5cf6; + --accent-glow: rgba(99, 102, 241, 0.4); + + /* Status Colors */ + --status-online: #22c55e; + --status-offline: #ef4444; + --status-checking: #f59e0b; + + /* Sizing */ + --card-radius: 20px; + --border-radius: 12px; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-normal: 300ms ease; + --transition-slow: 500ms ease; +} + +/* Dark Theme (Default) */ +[data-theme="dark"] { + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --bg-card: rgba(20, 20, 30, 0.6); + --bg-card-hover: rgba(30, 30, 45, 0.8); + + --text-primary: #ffffff; + --text-secondary: #a0a0b0; + --text-muted: #6a6a7a; + + --border-color: rgba(255, 255, 255, 0.08); + --border-hover: rgba(255, 255, 255, 0.15); + + --shadow-card: 0 8px 32px rgba(0, 0, 0, 0.4); + --shadow-hover: 0 16px 48px rgba(0, 0, 0, 0.5); + + --orb-opacity: 0.5; +} + +/* Light Theme */ +[data-theme="light"] { + --bg-primary: #f5f5f7; + --bg-secondary: #ffffff; + --bg-card: rgba(255, 255, 255, 0.8); + --bg-card-hover: rgba(255, 255, 255, 0.95); + + --text-primary: #1a1a2e; + --text-secondary: #4a4a5a; + --text-muted: #8a8a9a; + + --border-color: rgba(0, 0, 0, 0.08); + --border-hover: rgba(0, 0, 0, 0.15); + + --shadow-card: 0 8px 32px rgba(0, 0, 0, 0.1); + --shadow-hover: 0 16px 48px rgba(0, 0, 0, 0.15); + + --orb-opacity: 0.3; +} + +/* Nord Theme */ +[data-theme="nord"] { + --bg-primary: #2e3440; + --bg-secondary: #3b4252; + --bg-card: rgba(59, 66, 82, 0.8); + --bg-card-hover: rgba(67, 76, 94, 0.9); + + --text-primary: #eceff4; + --text-secondary: #d8dee9; + --text-muted: #a0a8b7; + + --border-color: rgba(216, 222, 233, 0.1); + --border-hover: rgba(216, 222, 233, 0.2); + + --accent-primary: #88c0d0; + --accent-secondary: #81a1c1; + --accent-glow: rgba(136, 192, 208, 0.3); + + --shadow-card: 0 8px 32px rgba(0, 0, 0, 0.3); + --shadow-hover: 0 16px 48px rgba(0, 0, 0, 0.4); + + --orb-opacity: 0.3; +} + +/* Dracula Theme */ +[data-theme="dracula"] { + --bg-primary: #282a36; + --bg-secondary: #1e1f29; + --bg-card: rgba(40, 42, 54, 0.8); + --bg-card-hover: rgba(68, 71, 90, 0.9); + + --text-primary: #f8f8f2; + --text-secondary: #bd93f9; + --text-muted: #6272a4; + + --border-color: rgba(189, 147, 249, 0.15); + --border-hover: rgba(189, 147, 249, 0.3); + + --accent-primary: #ff79c6; + --accent-secondary: #bd93f9; + --accent-glow: rgba(255, 121, 198, 0.3); + + --shadow-card: 0 8px 32px rgba(0, 0, 0, 0.4); + --shadow-hover: 0 16px 48px rgba(0, 0, 0, 0.5); + + --orb-opacity: 0.4; +} + +/* Accent Color Overrides */ +[data-accent="indigo"] { + --accent-primary: #6366f1; + --accent-secondary: #8b5cf6; + --accent-glow: rgba(99, 102, 241, 0.4); +} + +[data-accent="purple"] { + --accent-primary: #8b5cf6; + --accent-secondary: #a78bfa; + --accent-glow: rgba(139, 92, 246, 0.4); +} + +[data-accent="pink"] { + --accent-primary: #ec4899; + --accent-secondary: #f472b6; + --accent-glow: rgba(236, 72, 153, 0.4); +} + +[data-accent="red"] { + --accent-primary: #ef4444; + --accent-secondary: #f87171; + --accent-glow: rgba(239, 68, 68, 0.4); +} + +[data-accent="orange"] { + --accent-primary: #f97316; + --accent-secondary: #fb923c; + --accent-glow: rgba(249, 115, 22, 0.4); +} + +[data-accent="yellow"] { + --accent-primary: #eab308; + --accent-secondary: #facc15; + --accent-glow: rgba(234, 179, 8, 0.4); +} + +[data-accent="green"] { + --accent-primary: #22c55e; + --accent-secondary: #4ade80; + --accent-glow: rgba(34, 197, 94, 0.4); +} + +[data-accent="teal"] { + --accent-primary: #14b8a6; + --accent-secondary: #2dd4bf; + --accent-glow: rgba(20, 184, 166, 0.4); +} + +[data-accent="cyan"] { + --accent-primary: #06b6d4; + --accent-secondary: #22d3ee; + --accent-glow: rgba(6, 182, 212, 0.4); +} + +[data-accent="blue"] { + --accent-primary: #3b82f6; + --accent-secondary: #60a5fa; + --accent-glow: rgba(59, 130, 246, 0.4); +} + +/* Card Size Variants */ +[data-card-size="compact"] .service-card { + padding: 1rem; +} + +[data-card-size="compact"] .card-icon { + width: 40px; + height: 40px; +} + +[data-card-size="compact"] .card-icon svg { + width: 20px; + height: 20px; +} + +[data-card-size="compact"] .service-name { + font-size: 1rem; +} + +[data-card-size="compact"] .service-description { + font-size: 0.85rem; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +[data-card-size="large"] .service-card { + padding: 2rem; +} + +[data-card-size="large"] .card-icon { + width: 72px; + height: 72px; +} + +[data-card-size="large"] .card-icon svg { + width: 36px; + height: 36px; +} + +[data-card-size="large"] .service-name { + font-size: 1.5rem; +} + +[data-card-size="large"] .service-description { + font-size: 1.05rem; +} + +/* ============================================ + Reset & Base Styles + ============================================ */ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + line-height: 1.6; + overflow-x: hidden; + transition: background var(--transition-normal), color var(--transition-normal); +} + +/* ============================================ + Particles Canvas + ============================================ */ +#particles-canvas { + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; +} + +/* ============================================ + Background Effects + ============================================ */ +.background-effects { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + pointer-events: none; +} + +.gradient-orb { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: var(--orb-opacity, 0.5); + animation: float 20s ease-in-out infinite; + transition: opacity var(--transition-slow); +} + +.orb-1 { + width: 600px; + height: 600px; + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + top: -200px; + left: -100px; + animation-delay: 0s; +} + +.orb-2 { + width: 500px; + height: 500px; + background: linear-gradient(135deg, #ec4899, #8b5cf6); + bottom: -150px; + right: -100px; + animation-delay: -7s; +} + +.orb-3 { + width: 400px; + height: 400px; + background: linear-gradient(135deg, #06b6d4, #3b82f6); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation-delay: -14s; +} + +.grid-overlay { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: 50px 50px; + mask-image: radial-gradient(ellipse at center, black 0%, transparent 70%); +} + +.custom-bg { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + opacity: 0.3; +} + +@keyframes float { + + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + + 33% { + transform: translate(30px, -30px) scale(1.05); + } + + 66% { + transform: translate(-20px, 20px) scale(0.95); + } +} + +/* Background style variants */ +[data-bg="minimal"] .gradient-orb, +[data-bg="minimal"] .grid-overlay { + display: none; +} + +[data-bg="gradient"] .grid-overlay { + display: none; +} + +[data-bg="particles"] .gradient-orb { + opacity: 0.3; +} + +/* ============================================ + Container + ============================================ */ +.container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* ============================================ + Top Bar + ============================================ */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.greeting-section { + flex: 1; + min-width: 250px; +} + +.greeting { + font-size: clamp(1.75rem, 4vw, 2.5rem); + font-weight: 700; + background: linear-gradient(135deg, var(--text-primary), var(--accent-primary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.02em; + animation: fadeIn 0.6s ease; +} + +.date-display { + color: var(--text-secondary); + font-size: 1rem; + font-weight: 400; + margin-top: 0.25rem; +} + +.clock-weather { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.clock { + font-family: 'JetBrains Mono', monospace; + display: flex; + align-items: baseline; +} + +.clock-time { + font-size: 2.5rem; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.clock-seconds { + font-size: 1.25rem; + font-weight: 400; + color: var(--text-muted); + animation: pulse 1s ease-in-out infinite; +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} + +.weather-widget { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + backdrop-filter: blur(20px); +} + +.weather-icon { + font-size: 1.75rem; + line-height: 1; +} + +.weather-info { + display: flex; + flex-direction: column; +} + +.weather-temp { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + line-height: 1.2; +} + +.weather-desc { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: capitalize; +} + +.theme-toggle { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + color: var(--text-secondary); + cursor: pointer; + backdrop-filter: blur(20px); + transition: var(--transition-normal); +} + +.theme-toggle svg { + width: 20px; + height: 20px; + transition: var(--transition-normal); +} + +.theme-toggle:hover { + background: var(--bg-card-hover); + border-color: var(--border-hover); + color: var(--text-primary); +} + +.icon-sun { + display: none; +} + +.icon-moon { + display: block; +} + +[data-theme="light"] .icon-sun { + display: block; +} + +[data-theme="light"] .icon-moon { + display: none; +} + +/* ============================================ + Stats Panel + ============================================ */ +.stats-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} + +.stat-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + backdrop-filter: blur(20px); + transition: var(--transition-normal); +} + +.stat-card:hover { + border-color: var(--border-hover); + transform: translateY(-2px); +} + +.stat-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + position: relative; +} + +.stat-icon::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + opacity: 0.15; + background: currentColor; +} + +.stat-icon svg { + width: 24px; + height: 24px; + position: relative; + z-index: 1; +} + +.stat-icon.online { + color: var(--status-online); +} + +.stat-icon.offline { + color: var(--status-offline); +} + +.stat-icon.total { + color: var(--accent-primary); +} + +.stat-icon.uptime { + color: #06b6d4; +} + +.stat-content { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; +} + +.stat-label { + font-size: 0.875rem; + color: var(--text-muted); +} + +/* ============================================ + Actions Bar + ============================================ */ +.actions-bar { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.search-container { + position: relative; + display: flex; + align-items: center; + flex: 1; + min-width: 280px; + max-width: 500px; +} + +.search-icon { + position: absolute; + left: 1rem; + width: 20px; + height: 20px; + color: var(--text-muted); + pointer-events: none; +} + +.search-input { + width: 100%; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 0.875rem 1rem 0.875rem 2.75rem; + font-size: 0.95rem; + color: var(--text-primary); + backdrop-filter: blur(20px); + transition: var(--transition-normal); +} + +.search-input::placeholder { + color: var(--text-muted); +} + +.search-input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.search-shortcut { + position: absolute; + right: 1rem; + display: flex; + gap: 0.25rem; + pointer-events: none; +} + +.search-shortcut kbd { + padding: 0.25rem 0.5rem; + font-size: 0.7rem; + font-family: inherit; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-muted); +} + +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + color: var(--text-secondary); + cursor: pointer; + backdrop-filter: blur(20px); + transition: var(--transition-normal); +} + +.action-btn svg { + width: 22px; + height: 22px; +} + +.action-btn:hover { + background: var(--bg-card-hover); + border-color: var(--border-hover); + color: var(--text-primary); +} + +.action-btn.spinning svg { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* ============================================ + Quick Links + ============================================ */ +.quick-links { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.quick-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + border: none; + border-radius: 20px; + color: white; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: var(--transition-normal); + box-shadow: 0 4px 15px var(--accent-glow); +} + +.quick-link:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px var(--accent-glow); +} + +.quick-link svg { + width: 16px; + height: 16px; +} + +/* ============================================ + Category Tabs + ============================================ */ +.category-tabs { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +.category-tab { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + background: transparent; + border: 1px solid transparent; + border-radius: 10px; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition-normal); +} + +.category-tab:hover { + background: var(--bg-card); + color: var(--text-primary); +} + +.category-tab.active { + background: var(--bg-card); + border-color: var(--border-hover); + color: var(--text-primary); +} + +.tab-icon { + font-size: 1rem; +} + +.tab-count { + padding: 0.125rem 0.5rem; + background: var(--bg-secondary); + border-radius: 10px; + font-size: 0.75rem; + color: var(--text-muted); +} + +/* ============================================ + Favorites Section + ============================================ */ +.favorites-section { + display: none; +} + +.favorites-section.has-favorites { + display: block; +} + +.section-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.section-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-secondary); +} + +.favorites-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +/* ============================================ + Services Grid + ============================================ */ +.services-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.5rem; + flex: 1; +} + +/* ============================================ + Service Card + ============================================ */ +.service-card { + position: relative; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--card-radius); + padding: 1.75rem; + backdrop-filter: blur(20px); + box-shadow: var(--shadow-card); + cursor: pointer; + transition: all var(--transition-normal); + overflow: hidden; + text-decoration: none; + color: inherit; + display: block; +} + +.service-card::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, transparent 0%, rgba(255, 255, 255, 0.03) 100%); + opacity: 0; + transition: var(--transition-normal); +} + +.service-card::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--card-accent, var(--accent-primary)), var(--accent-secondary)); + opacity: 0; + transition: var(--transition-normal); +} + +.service-card:hover { + transform: translateY(-8px) scale(1.02); + border-color: var(--border-hover); + box-shadow: var(--shadow-hover); +} + +.service-card:hover::before { + opacity: 1; +} + +.service-card:hover::after { + opacity: 1; +} + +.service-card.hidden { + display: none; +} + +/* Favorite indicator */ +.service-card.is-favorite::before { + content: '⭐'; + position: absolute; + top: 0.5rem; + right: 0.5rem; + font-size: 1rem; + opacity: 1; + background: none; +} + +.card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1rem; +} + +.card-icon { + width: 56px; + height: 56px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + position: relative; + overflow: hidden; + transition: var(--transition-normal); +} + +.service-card:hover .card-icon { + transform: scale(1.1); +} + +.card-icon::before { + content: ''; + position: absolute; + inset: 0; + opacity: 0.15; + background: currentColor; +} + +.card-icon svg { + width: 28px; + height: 28px; + position: relative; + z-index: 1; +} + +.card-icon img { + width: 32px; + height: 32px; + object-fit: contain; + position: relative; + z-index: 1; +} + +/* Status Indicator */ +.status-indicator { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: var(--transition-normal); +} + +.status-indicator.online { + background: rgba(34, 197, 94, 0.15); + color: var(--status-online); +} + +.status-indicator.offline { + background: rgba(239, 68, 68, 0.15); + color: var(--status-offline); +} + +.status-indicator.checking { + background: rgba(245, 158, 11, 0.15); + color: var(--status-checking); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.status-indicator.online .status-dot { + animation: pulse-online 2s ease-in-out infinite; +} + +.status-indicator.checking .status-dot { + animation: pulse-checking 1s ease-in-out infinite; +} + +@keyframes pulse-online { + + 0%, + 100% { + opacity: 1; + box-shadow: 0 0 0 0 currentColor; + } + + 50% { + opacity: 0.8; + box-shadow: 0 0 0 4px transparent; + } +} + +@keyframes pulse-checking { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } +} + +.ping-time { + font-size: 0.7rem; + color: var(--text-muted); + margin-left: 0.25rem; +} + +.card-body { + position: relative; + z-index: 1; +} + +.service-name { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.service-description { + font-size: 0.95rem; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 1rem; +} + +.service-url { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--text-muted); + word-break: break-all; +} + +.service-url svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +/* Card Actions */ +.card-actions { + position: absolute; + top: 0.75rem; + right: 0.75rem; + display: flex; + gap: 0.25rem; + opacity: 0; + transition: var(--transition-normal); + z-index: 20; +} + +.service-card:hover .card-actions { + opacity: 1; +} + +.card-action-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + transition: var(--transition-fast); +} + +.card-action-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.card-action-btn svg { + width: 14px; + height: 14px; +} + +/* Card accent colors */ +.service-card[data-color="orange"] { + --card-accent: #f97316; +} + +.service-card[data-color="orange"] .card-icon { + color: #f97316; +} + +.service-card[data-color="blue"] { + --card-accent: #3b82f6; +} + +.service-card[data-color="blue"] .card-icon { + color: #3b82f6; +} + +.service-card[data-color="green"] { + --card-accent: #22c55e; +} + +.service-card[data-color="green"] .card-icon { + color: #22c55e; +} + +.service-card[data-color="purple"] { + --card-accent: #8b5cf6; +} + +.service-card[data-color="purple"] .card-icon { + color: #8b5cf6; +} + +.service-card[data-color="red"] { + --card-accent: #ef4444; +} + +.service-card[data-color="red"] .card-icon { + color: #ef4444; +} + +.service-card[data-color="cyan"] { + --card-accent: #06b6d4; +} + +.service-card[data-color="cyan"] .card-icon { + color: #06b6d4; +} + +.service-card[data-color="pink"] { + --card-accent: #ec4899; +} + +.service-card[data-color="pink"] .card-icon { + color: #ec4899; +} + +.service-card[data-color="yellow"] { + --card-accent: #eab308; +} + +.service-card[data-color="yellow"] .card-icon { + color: #eab308; +} + +.service-card[data-color="teal"] { + --card-accent: #14b8a6; +} + +.service-card[data-color="teal"] .card-icon { + color: #14b8a6; +} + +.service-card[data-color="indigo"] { + --card-accent: #6366f1; +} + +.service-card[data-color="indigo"] .card-icon { + color: #6366f1; +} + +/* ============================================ + Footer + ============================================ */ +.footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); + margin-top: auto; +} + +.footer-brand { + font-weight: 600; + color: var(--text-secondary); +} + +.footer-center p { + color: var(--text-muted); + font-size: 0.875rem; +} + +.footer-right { + display: flex; + gap: 0.5rem; +} + +.footer-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: transparent; + border: none; + color: var(--text-muted); + border-radius: 8px; + cursor: pointer; + transition: var(--transition-normal); +} + +.footer-btn:hover { + background: var(--bg-card); + color: var(--text-primary); +} + +.footer-btn svg { + width: 20px; + height: 20px; +} + +/* ============================================ + Modal + ============================================ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: var(--transition-normal); + z-index: 1000; + padding: 1rem; +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--card-radius); + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow: hidden; + transform: scale(0.9) translateY(20px); + transition: var(--transition-normal); + display: flex; + flex-direction: column; +} + +.modal-large { + max-width: 560px; +} + +.modal-overlay.active .modal { + transform: scale(1) translateY(0); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2 { + font-size: 1.25rem; + font-weight: 600; +} + +.modal-close { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 8px; + color: var(--text-muted); + cursor: pointer; + transition: var(--transition-normal); +} + +.modal-close:hover { + background: var(--bg-card); + color: var(--text-primary); +} + +.modal-close svg { + width: 20px; + height: 20px; +} + +.modal-tabs { + display: flex; + padding: 0 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.modal-tab { + padding: 1rem 1.25rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition-normal); +} + +.modal-tab:hover { + color: var(--text-primary); +} + +.modal-tab.active { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); +} + +.modal-body { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; + overflow-y: auto; + flex: 1; +} + +.tab-content { + display: none; + flex-direction: column; + gap: 1.25rem; +} + +.tab-content.active { + display: flex; +} + +.setting-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.setting-label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.setting-hint { + font-size: 0.8rem; + color: var(--text-muted); +} + +.setting-input, +.setting-select, +.setting-textarea { + padding: 0.75rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + font-size: 0.95rem; + color: var(--text-primary); + font-family: inherit; + transition: var(--transition-normal); +} + +.setting-textarea { + resize: vertical; + min-height: 80px; +} + +.import-textarea { + min-height: 150px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.85rem; +} + +.setting-input:focus, +.setting-select:focus, +.setting-textarea:focus { + outline: none; + border-color: var(--accent-primary); +} + +.setting-toggle-group { + display: flex; + gap: 0.5rem; +} + +.setting-toggle { + flex: 1; + padding: 0.75rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + font-size: 0.95rem; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + transition: var(--transition-normal); +} + +.setting-toggle:hover { + border-color: var(--border-hover); + color: var(--text-primary); +} + +.setting-toggle.active { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: white; +} + +/* Theme Grid */ +.theme-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +.theme-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: var(--bg-card); + border: 2px solid var(--border-color); + border-radius: var(--border-radius); + cursor: pointer; + transition: var(--transition-normal); +} + +.theme-option:hover { + border-color: var(--border-hover); +} + +.theme-option.active { + border-color: var(--accent-primary); +} + +.theme-option span { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.theme-preview { + width: 100%; + height: 40px; + border-radius: 6px; +} + +.dark-preview { + background: linear-gradient(135deg, #0a0a0f, #12121a); +} + +.light-preview { + background: linear-gradient(135deg, #f5f5f7, #ffffff); + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.nord-preview { + background: linear-gradient(135deg, #2e3440, #4c566a); +} + +.dracula-preview { + background: linear-gradient(135deg, #282a36, #44475a); +} + +/* Color Picker */ +.color-picker { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.color-option { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--preview-color); + border: 3px solid transparent; + cursor: pointer; + transition: var(--transition-normal); + position: relative; +} + +.color-option:hover { + transform: scale(1.1); +} + +.color-option.active { + border-color: var(--text-primary); +} + +.color-option.active::after { + content: '✓'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 0.9rem; + font-weight: bold; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); +} + +/* Background Options */ +.bg-options { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.bg-option { + padding: 0.5rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 20px; + font-size: 0.85rem; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition-normal); +} + +.bg-option:hover { + border-color: var(--border-hover); + color: var(--text-primary); +} + +.bg-option.active { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: white; +} + +.custom-bg-upload { + margin-top: 0.75rem; +} + +.upload-label { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--bg-card); + border: 1px dashed var(--border-color); + border-radius: var(--border-radius); + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition-normal); +} + +.upload-label:hover { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1.5rem; + border-top: 1px solid var(--border-color); +} + +.btn-primary, +.btn-secondary, +.btn-danger { + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition-normal); + border: none; +} + +.btn-primary { + background: var(--accent-primary); + color: white; +} + +.btn-primary:hover { + background: var(--accent-secondary); +} + +.btn-secondary { + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-secondary); +} + +.btn-secondary:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.btn-danger { + background: var(--status-offline); + color: white; +} + +.btn-danger:hover { + background: #dc2626; +} + +/* Service Detail Modal */ +.service-detail-status { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--bg-card); + border-radius: var(--border-radius); +} + +.detail-status-indicator { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.detail-status-indicator.online { + background: var(--status-online); +} + +.detail-status-indicator.offline { + background: var(--status-offline); +} + +.detail-ping { + font-family: 'JetBrains Mono', monospace; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +/* Uptime History Bar */ +.uptime-history { + margin-top: 0.5rem; +} + +.uptime-bar { + display: flex; + gap: 2px; + height: 24px; + margin-top: 0.5rem; +} + +.uptime-segment { + flex: 1; + border-radius: 2px; + background: var(--bg-card); + transition: var(--transition-fast); +} + +.uptime-segment.online { + background: var(--status-online); +} + +.uptime-segment.offline { + background: var(--status-offline); +} + +.uptime-segment:hover { + transform: scaleY(1.2); +} + +/* ============================================ + Toast Notifications + ============================================ */ +.toast-container { + position: fixed; + bottom: 2rem; + right: 2rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + z-index: 1001; +} + +.toast { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); + animation: slideIn 0.3s ease; +} + +.toast.success { + border-left: 4px solid var(--status-online); +} + +.toast.error { + border-left: 4px solid var(--status-offline); +} + +.toast.info { + border-left: 4px solid var(--accent-primary); +} + +.toast-icon { + width: 20px; + height: 20px; +} + +.toast.success .toast-icon { + color: var(--status-online); +} + +.toast.error .toast-icon { + color: var(--status-offline); +} + +.toast.info .toast-icon { + color: var(--accent-primary); +} + +.toast-message { + font-size: 0.9rem; + color: var(--text-primary); +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(100%); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideOut { + from { + opacity: 1; + transform: translateX(0); + } + + to { + opacity: 0; + transform: translateX(100%); + } +} + +/* ============================================ + Empty State + ============================================ */ +.empty-state { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; +} + +.empty-state svg { + width: 80px; + height: 80px; + color: var(--text-muted); + margin-bottom: 1.5rem; + opacity: 0.5; +} + +.empty-state h3 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.empty-state p { + color: var(--text-muted); + max-width: 400px; +} + +/* ============================================ + Responsive Design + ============================================ */ +@media (max-width: 768px) { + .container { + padding: 1.5rem; + } + + .top-bar { + flex-direction: column; + align-items: flex-start; + } + + .clock-weather { + width: 100%; + justify-content: space-between; + } + + .stats-panel { + grid-template-columns: repeat(2, 1fr); + } + + .actions-bar { + flex-direction: column; + } + + .search-container { + max-width: none; + width: 100%; + } + + .action-buttons { + align-self: flex-end; + } + + .services-grid { + grid-template-columns: 1fr; + } + + .favorites-grid { + grid-template-columns: 1fr; + } + + .footer { + flex-direction: column; + gap: 1rem; + text-align: center; + } + + .theme-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .container { + padding: 1rem; + } + + .greeting { + font-size: 1.5rem; + } + + .clock-time { + font-size: 1.75rem; + } + + .stats-panel { + grid-template-columns: 1fr; + } + + .category-tabs { + overflow-x: auto; + flex-wrap: nowrap; + padding-bottom: 0.75rem; + -webkit-overflow-scrolling: touch; + } + + .category-tab { + flex-shrink: 0; + } + + .service-card { + padding: 1.25rem; + } + + .card-icon { + width: 48px; + height: 48px; + } + + .card-icon svg, + .card-icon img { + width: 24px; + height: 24px; + } + + .search-shortcut { + display: none; + } +} + +/* ============================================ + Animations + ============================================ */ +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.service-card { + animation: fadeInUp 0.5s ease forwards; + opacity: 0; +} + +.service-card:nth-child(1) { + animation-delay: 0.05s; +} + +.service-card:nth-child(2) { + animation-delay: 0.1s; +} + +.service-card:nth-child(3) { + animation-delay: 0.15s; +} + +.service-card:nth-child(4) { + animation-delay: 0.2s; +} + +.service-card:nth-child(5) { + animation-delay: 0.25s; +} + +.service-card:nth-child(6) { + animation-delay: 0.3s; +} + +.service-card:nth-child(7) { + animation-delay: 0.35s; +} + +.service-card:nth-child(8) { + animation-delay: 0.4s; +} + +.stat-card { + animation: fadeInUp 0.4s ease forwards; + opacity: 0; +} + +.stat-card:nth-child(1) { + animation-delay: 0s; +} + +.stat-card:nth-child(2) { + animation-delay: 0.05s; +} + +.stat-card:nth-child(3) { + animation-delay: 0.1s; +} + +.stat-card:nth-child(4) { + animation-delay: 0.15s; +} + +/* ============================================ + Scrollbar + ============================================ */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border-hover); +} + +/* Checkbox styling */ +input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent-primary); + cursor: pointer; +} + +/* ============================================ + Maintenance Mode Styles + ============================================ */ +.status-indicator.maintenance { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.status-indicator.maintenance .status-dot { + background: #f59e0b; + animation: none; +} + +.service-card.in-maintenance { + border-color: rgba(245, 158, 11, 0.3); +} + +.service-card.in-maintenance::before { + content: '🔧'; + position: absolute; + top: 0.5rem; + left: 0.5rem; + font-size: 1rem; + z-index: 10; +} + +.maintenance-group .setting-hint { + margin-bottom: 0.75rem; +} + +.maintenance-options { + display: none; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: var(--border-radius); + margin-top: 0.75rem; +} + +.maintenance-options.visible { + display: flex; +} + +.maintenance-until, +.maintenance-reason { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.maintenance-until label, +.maintenance-reason label { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.maintenance-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: rgba(245, 158, 11, 0.2); + color: #f59e0b; + border-radius: 6px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} + +.detail-status-indicator.maintenance { + background: rgba(245, 158, 11, 0.15); + border-color: rgba(245, 158, 11, 0.3); +} + +/* ============================================ + Recently Accessed Section + ============================================ */ +.recently-accessed-section { + display: none; +} + +.recently-accessed-section.has-recent { + display: block; +} + +.recently-accessed-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.recent-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + text-decoration: none; + color: var(--text-primary); + backdrop-filter: blur(20px); + transition: var(--transition-normal); +} + +.recent-card:hover { + background: var(--bg-card-hover); + border-color: var(--accent-primary); + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.recent-card .recent-icon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + border-radius: 10px; + flex-shrink: 0; +} + +.recent-card .recent-icon svg { + width: 18px; + height: 18px; + color: white; +} + +.recent-card .recent-info { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.recent-card .recent-name { + font-weight: 500; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recent-card .recent-time { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* ============================================ + RSS Widget Styles + ============================================ */ +.rss-widget { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--card-radius); + padding: 1.5rem; + backdrop-filter: blur(20px); +} + +.rss-widget .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.rss-refresh-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition-normal); +} + +.rss-refresh-btn svg { + width: 18px; + height: 18px; +} + +.rss-refresh-btn:hover { + background: var(--bg-secondary); + border-color: var(--border-hover); + color: var(--text-primary); +} + +.rss-refresh-btn.spinning svg { + animation: spin 1s linear infinite; +} + +.rss-feed-container { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-height: 400px; + overflow-y: auto; +} + +.rss-loading { + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +.rss-item { + display: flex; + gap: 1rem; + padding: 0.875rem; + background: var(--bg-secondary); + border-radius: var(--border-radius); + text-decoration: none; + color: var(--text-primary); + transition: var(--transition-normal); +} + +.rss-item:hover { + background: var(--bg-card-hover); + transform: translateX(4px); +} + +.rss-item-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + border-radius: 10px; + flex-shrink: 0; + font-size: 1.25rem; +} + +.rss-item-content { + display: flex; + flex-direction: column; + gap: 0.25rem; + overflow: hidden; +} + +.rss-item-title { + font-weight: 500; + font-size: 0.9rem; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.rss-item-meta { + display: flex; + gap: 0.75rem; + font-size: 0.75rem; + color: var(--text-muted); +} + +.rss-item-source { + color: var(--accent-primary); + font-weight: 500; +} + +.rss-empty { + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +.rss-empty svg { + width: 48px; + height: 48px; + margin-bottom: 0.75rem; + opacity: 0.5; +} + +/* RSS Settings Tab Styles */ +.rss-feeds-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + max-height: 200px; + overflow-y: auto; +} + +.rss-feed-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + background: var(--bg-secondary); + border-radius: var(--border-radius); +} + +.rss-feed-item-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.rss-feed-item-name { + font-weight: 500; + font-size: 0.9rem; +} + +.rss-feed-item-url { + font-size: 0.75rem; + color: var(--text-muted); + max-width: 250px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rss-feed-remove { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + transition: var(--transition-fast); +} + +.rss-feed-remove:hover { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.rss-feed-remove svg { + width: 16px; + height: 16px; +} + +.add-feed-form { + display: grid; + grid-template-columns: 1fr 2fr auto; + gap: 0.5rem; + align-items: center; +} + +.default-feeds { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.default-feeds button { + font-size: 0.85rem; + padding: 0.5rem 1rem; +} \ No newline at end of file