/**
* 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 `
`;
}
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 = `
`;
return;
}
this.rssFeedContainer.innerHTML = '';
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 = `
`;
}
}
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 = `
`;
return;
}
this.rssFeedContainer.innerHTML = items.map(item => {
const date = new Date(item.date);
const timeAgo = this.getTimeAgo(date.getTime());
return `
`;
}).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 => `
`).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());