1518 lines
62 KiB
JavaScript
1518 lines
62 KiB
JavaScript
/**
|
|
* 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 `
|
|
<button class="category-tab ${cat === 'all' ? 'active' : ''}" data-category="${cat}">
|
|
<span class="tab-icon">${icon}</span>
|
|
${label}
|
|
<span class="tab-count">${count}</span>
|
|
</button>
|
|
`;
|
|
}).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 => `
|
|
<a href="${link.url}" class="quick-link" target="_blank" rel="noopener noreferrer">
|
|
${link.icon ? `<span>${link.icon}</span>` : ''} ${link.name}
|
|
</a>
|
|
`).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 `
|
|
<div class="service-card ${favClass} ${maintenanceClass}"
|
|
data-color="${service.color || 'indigo'}"
|
|
data-name="${service.name}"
|
|
data-url="${service.url}"
|
|
data-category="${service.category || 'other'}"
|
|
style="animation-delay: ${index * 0.05}s">
|
|
<div class="card-actions">
|
|
<button class="card-action-btn favorite-btn" data-service="${service.name}" title="Toggle favorite">
|
|
<svg viewBox="0 0 24 24" fill="${this.state.favorites.has(service.name) ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2">
|
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
|
</svg>
|
|
</button>
|
|
<button class="card-action-btn info-btn" data-service="${service.name}" title="Details & Notes">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<a href="${service.url}" target="_blank" rel="noopener noreferrer" class="card-link">
|
|
<div class="card-header">
|
|
<div class="card-icon">${icon}</div>
|
|
${inMaintenance ? `
|
|
<div class="status-indicator maintenance">
|
|
<span class="status-dot"></span>
|
|
<span class="status-text">Maintenance</span>
|
|
${maintenanceInfo.reason ? `<span class="maintenance-badge">${maintenanceInfo.reason}</span>` : ''}
|
|
</div>
|
|
` : (DASHBOARD_CONFIG.enableStatusCheck ? `
|
|
<div class="status-indicator ${statusClass}">
|
|
<span class="status-dot"></span>
|
|
<span class="status-text">Checking</span>
|
|
${ping ? `<span class="ping-time">${ping}ms</span>` : ''}
|
|
</div>
|
|
` : '')}
|
|
</div>
|
|
<div class="card-body">
|
|
<h3 class="service-name">${service.name}</h3>
|
|
<p class="service-description">${service.description}</p>
|
|
<div class="service-url">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
</svg>
|
|
<span>${this.formatUrl(service.url)}</span>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="empty-state">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 8v8m-4-4h8"/>
|
|
</svg>
|
|
<h3>No Services Configured</h3>
|
|
<p>Click the + button to add your first service, or edit config.js directly.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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(`<div class="uptime-segment ${status}" title="Hour ${i + 1}"></div>`);
|
|
}
|
|
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', `<span class="ping-time">${ping}ms</span>`);
|
|
}
|
|
});
|
|
|
|
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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
|
|
error: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
|
|
info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>'
|
|
};
|
|
|
|
toast.innerHTML = `<span class="toast-icon">${icons[type]}</span><span class="toast-message">${message}</span>`;
|
|
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 `
|
|
<a href="${r.url}" target="_blank" rel="noopener noreferrer" class="recent-card" data-name="${r.name}">
|
|
<div class="recent-icon">${icon}</div>
|
|
<div class="recent-info">
|
|
<span class="recent-name">${r.name}</span>
|
|
<span class="recent-time">${timeAgo}</span>
|
|
</div>
|
|
</a>
|
|
`;
|
|
}).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 = `
|
|
<div class="rss-empty">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/>
|
|
<circle cx="5" cy="19" r="1"/>
|
|
</svg>
|
|
<p>No RSS feeds configured</p>
|
|
<p style="font-size: 0.85rem; margin-top: 0.5rem;">Add feeds in Settings → RSS Feeds</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
this.rssFeedContainer.innerHTML = '<div class="rss-loading">Loading feeds...</div>';
|
|
|
|
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 = `
|
|
<div class="rss-empty">
|
|
<p>Failed to load feeds</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<div class="rss-empty">
|
|
<p>No news items found</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
this.rssFeedContainer.innerHTML = items.map(item => {
|
|
const date = new Date(item.date);
|
|
const timeAgo = this.getTimeAgo(date.getTime());
|
|
|
|
return `
|
|
<a href="${item.link}" target="_blank" rel="noopener noreferrer" class="rss-item">
|
|
<div class="rss-item-icon">📰</div>
|
|
<div class="rss-item-content">
|
|
<div class="rss-item-title">${this.escapeHtml(item.title)}</div>
|
|
<div class="rss-item-meta">
|
|
<span class="rss-item-source">${item.source}</span>
|
|
<span>${timeAgo}</span>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
`;
|
|
}).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 = '<p style="color: var(--text-muted); padding: 0.5rem;">No feeds added yet</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = feeds.map(feed => `
|
|
<div class="rss-feed-item">
|
|
<div class="rss-feed-item-info">
|
|
<span class="rss-feed-item-name">${feed.name}</span>
|
|
<span class="rss-feed-item-url">${feed.url}</span>
|
|
</div>
|
|
<button class="rss-feed-remove" data-url="${feed.url}" title="Remove feed">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`).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());
|