diff --git a/app.js b/app.js index a0f5da9..505b5ea 100644 --- a/app.js +++ b/app.js @@ -52,6 +52,7 @@ class ValidatorDutiesTracker { this.loadCachedDuties(); this.initializeNotifications(); this.initializeDashboard(); + this.initializeDarkMode(); this.startCountdownTimer(); } @@ -149,6 +150,9 @@ class ValidatorDutiesTracker { // Dashboard mode toggle document.getElementById('dashboardModeToggle').addEventListener('click', () => this.toggleDashboardMode()); + // Dark mode toggle + document.getElementById('darkModeToggle').addEventListener('click', () => this.toggleDarkMode()); + // Dashboard exit button document.getElementById('dashboardExitBtn').addEventListener('click', () => this.exitDashboardMode()); @@ -163,6 +167,13 @@ class ValidatorDutiesTracker { document.getElementById('enableTelegramNotifications').addEventListener('click', () => this.enableTelegramNotifications()); document.getElementById('updateTelegramSubscription').addEventListener('click', () => this.updateTelegramSubscription()); + // Test notification button - will be created dynamically + document.addEventListener('click', (e) => { + if (e.target.id === 'testBrowserNotification') { + this.testBrowserNotification(); + } + }); + // Load and apply notification settings this.loadNotificationSettings(); } @@ -191,8 +202,8 @@ class ValidatorDutiesTracker { async enableBrowserNotifications() { try { - if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - throw new Error('Push notifications not supported'); + if (!('Notification' in window)) { + throw new Error('Desktop notifications not supported in this browser'); } const permission = await Notification.requestPermission(); @@ -200,44 +211,122 @@ class ValidatorDutiesTracker { throw new Error('Notification permission denied'); } - // Register the service worker and wait for it to be ready - const registration = await navigator.serviceWorker.register('/sw.js'); - - // Wait for the service worker to be ready - await navigator.serviceWorker.ready; - - // Ensure we have VAPID key - if (!this.vapidPublicKey) { - throw new Error('VAPID public key not available. Please check server configuration.'); + // Enable push notifications if available + if ('serviceWorker' in navigator && 'PushManager' in window) { + try { + // Register the service worker and wait for it to be ready + const registration = await navigator.serviceWorker.register('/sw.js'); + + // Wait for the service worker to be ready + await navigator.serviceWorker.ready; + + // Ensure we have VAPID key + if (this.vapidPublicKey) { + // Check if we already have a subscription + let subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + // Create a new subscription + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) + }); + } + + this.pushSubscription = subscription; + + await fetch(`${this.serverUrl}/api/notifications/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscription, + validators: this.validators.map(v => v.id || v) + }) + }); + } + } catch (pushError) { + console.warn('Push notifications not available, using standard notifications:', pushError); + } } - // Check if we already have a subscription - let subscription = await registration.pushManager.getSubscription(); + this.showNotificationStatus('browser', 'Desktop notifications enabled', true); + sessionStorage.setItem('browserNotifications', 'true'); - if (!subscription) { - // Create a new subscription - subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) - }); + // Show test button + const testBtn = document.getElementById('testBrowserNotification'); + if (testBtn) testBtn.style.display = 'inline-block'; + } catch (error) { + console.error('Browser notification error:', error); + this.showNotificationStatus('browser', error.message, false); + } + } + + async testBrowserNotification() { + try { + if (!('Notification' in window)) { + this.showNotificationStatus('browser', 'Notifications not supported in this browser', false); + return; } - this.pushSubscription = subscription; + if (Notification.permission !== 'granted') { + this.showNotificationStatus('browser', 'Notification permission not granted. Please enable desktop notifications first.', false); + return; + } - await fetch(`${this.serverUrl}/api/notifications/subscribe`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - subscription, - validators: this.validators.map(v => v.id || v) - }) + const iconUrl = window.location.origin + '/favicon.ico'; + const notification = new Notification('🎉 ETH Duties Test Notification', { + body: 'Desktop notifications are working! You will receive alerts for your validator duties.', + icon: iconUrl, + badge: iconUrl, + tag: 'test-notification-' + Date.now(), + requireInteraction: document.getElementById('desktopPersistent')?.checked !== false, + vibrate: [200, 100, 200], + silent: false, + renotify: true }); - this.showNotificationStatus('browser', 'Browser notifications enabled', true); - sessionStorage.setItem('browserNotifications', 'true'); + notification.onclick = () => { + window.focus(); + notification.close(); + }; + + notification.onshow = () => { + this.showNotificationStatus('browser', 'Test notification sent! Check your desktop.', true); + }; + + notification.onerror = (error) => { + console.error('Test notification error:', error); + this.showNotificationStatus('browser', 'Failed to show notification. Check browser settings.', false); + }; + + // Play sound if enabled + if (document.getElementById('desktopSound')?.checked !== false) { + try { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + // Retro test sound - short blip + oscillator.type = 'square'; + oscillator.frequency.value = 392; // G4 note + + // Subtle volume with envelope + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime(0.03, audioContext.currentTime + 0.01); + gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.1); + + oscillator.start(); + oscillator.stop(audioContext.currentTime + 0.1); + } catch (soundError) { + console.error('Failed to play test sound:', soundError); + } + } } catch (error) { - console.error('Browser notification error:', error); - this.showNotificationStatus('browser', error.message, false); + console.error('Test notification error:', error); + this.showNotificationStatus('browser', `Error: ${error.message}`, false); } } @@ -765,26 +854,97 @@ class ValidatorDutiesTracker { console.log('Telegram notification not sent - conditions not met'); } - // Send browser notification - if (browserEnabled && this.pushSubscription) { - try { - await fetch(`${this.serverUrl}/api/notify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: 'Block Confirmed', - validator: validator, - validatorDisplay, - duty: { - slot: slot, - timeUntil: 'confirmed', - blockDetails: details - }, - urgency: 'success' - }) - }); - } catch (error) { - console.error('Error sending browser block details notification:', error); + // Send desktop notification + if (browserEnabled) { + // Get validator label for display (without pubkey for concise desktop notifications) + const validatorLabel = this.getValidatorLabel(validator); + + const title = '🎉💰 BLOCK CONFIRMED! 🎉💰'; + let body = `Validator: ${validatorLabel}\nSlot: ${slot}\n\n${details.totalReward.toFixed(3)} ETH earned!\n${details.txCount} transactions`; + + // Add graffiti if available + if (details.graffiti && details.graffiti.trim()) { + body += `\n\nGraffiti: "${details.graffiti.trim()}"`; + } + + // Show desktop notification + if ('Notification' in window && Notification.permission === 'granted') { + try { + const iconUrl = window.location.origin + '/favicon.ico'; + + const notification = new Notification(title, { + body: body, + icon: iconUrl, + badge: iconUrl, + tag: `block-${slot}`, + requireInteraction: document.getElementById('desktopPersistent')?.checked !== false, + vibrate: [200, 100, 200, 100, 200], + silent: false, + renotify: true + }); + + notification.onclick = () => { + window.open(`https://beaconcha.in/slot/${slot}`, '_blank'); + notification.close(); + }; + + notification.onerror = (error) => { + console.error('Block notification error:', error); + }; + + // Play sound if enabled + if (document.getElementById('desktopSound')?.checked !== false) { + try { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + // Retro celebration sound - 8-bit style ascending tones + oscillator.type = 'square'; // 8-bit style waveform + oscillator.frequency.setValueAtTime(440, audioContext.currentTime); + oscillator.frequency.exponentialRampToValueAtTime(659.25, audioContext.currentTime + 0.15); + oscillator.frequency.exponentialRampToValueAtTime(880, audioContext.currentTime + 0.25); + + // Subtle volume with envelope + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime(0.05, audioContext.currentTime + 0.01); + gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.3); + + oscillator.start(); + oscillator.stop(audioContext.currentTime + 0.3); + } catch (soundError) { + console.error('Failed to play block confirmation sound:', soundError); + } + } + } catch (notifError) { + console.error('Failed to create block notification:', notifError); + } + } + + // Also send push notification if available + if (this.pushSubscription) { + try { + await fetch(`${this.serverUrl}/api/notify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'Block Confirmed', + validator: validator, + validatorDisplay, + duty: { + slot: slot, + timeUntil: 'confirmed', + blockDetails: details + }, + urgency: 'success' + }) + }); + } catch (error) { + console.error('Error sending push notification for block:', error); + } } } } @@ -915,33 +1075,119 @@ class ValidatorDutiesTracker { // Send browser notification if enabled const browserEnabled = sessionStorage.getItem('browserNotifications') === 'true'; - if (browserEnabled && this.pushSubscription) { + if (browserEnabled) { try { console.log('Sending browser notification for validator:', validator); - // Format validator display similar to Telegram - let validatorDisplay = validator; - if (duty.pubkey) { - validatorDisplay = `${validator} (${duty.pubkey.slice(0, 10)})`; + // Format validator display (without pubkey for concise desktop notifications) + const validatorLabel = this.getValidatorLabel(validator); + + // Create notification title and body using Telegram format but without pubkey + let title, body; + if (type === 'Proposer') { + title = '🎉💰 BLOCK PROPOSAL! 🎉💰'; + body = `In ${minutesUntil} minute${minutesUntil === 1 ? '' : 's'}\n\nValidator: ${validatorLabel}\nSlot: ${duty.slot}`; + } else if (type === 'Attester') { + title = '📝 Attestation Duty'; + body = `In ${minutesUntil} minute${minutesUntil === 1 ? '' : 's'}\n\nValidator: ${validatorLabel}\nSlot: ${duty.slot}`; + } else if (type === 'Sync Committee') { + title = '🔐💎 SYNC COMMITTEE 💎🔐'; + body = `Validator: ${validatorLabel}\n${duty.period === 'current' ? 'Currently active' : 'Starting soon'}\n~27 hours of enhanced rewards`; + } else if (type === 'Next Sync Committee') { + title = '🔮💎 NEXT SYNC COMMITTEE 💎🔮'; + body = `Starting in ${minutesUntil} minute${minutesUntil === 1 ? '' : 's'}\n\nValidator: ${validatorLabel}\n~27 hours of enhanced rewards ahead!`; + } else { + title = `${type} Duty`; + body = `In ${minutesUntil} minute${minutesUntil === 1 ? '' : 's'}\n\nValidator: ${validatorLabel}\nSlot: ${duty.slot}`; } - const response = await fetch(`${this.serverUrl}/api/notify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type, - validator: validator, // Send the raw index, not the label - validatorDisplay, // Send the formatted display - duty: { - slot: duty.slot, - timeUntil: this.formatTimeUntil(timeUntil) - }, - urgency - }) - }); + // Show desktop notification + if ('Notification' in window && Notification.permission === 'granted') { + try { + const iconUrl = window.location.origin + '/favicon.ico'; + + const notification = new Notification(title, { + body: body, + icon: iconUrl, + badge: iconUrl, + tag: dutyKey, + requireInteraction: document.getElementById('desktopPersistent')?.checked !== false, + vibrate: [200, 100, 200], + silent: false, + renotify: true + }); + + notification.onclick = () => { + window.focus(); + notification.close(); + if (validator) { + window.open(`https://beaconcha.in/validator/${validator}`, '_blank'); + } + }; + + notification.onerror = (error) => { + console.error('Notification error:', error); + }; + + } catch (notifError) { + console.error('Failed to create notification:', notifError); + } + + // Play sound if enabled + if (document.getElementById('desktopSound')?.checked !== false) { + try { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + // Retro duty notification sound - different pitched square waves + oscillator.type = 'square'; + const baseFreq = urgency === 'critical' ? 523.25 : urgency === 'urgent' ? 440 : 349.23; // C5, A4, F4 + oscillator.frequency.value = baseFreq; + + // Subtle volume with envelope + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime(0.03, audioContext.currentTime + 0.01); + gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + (urgency === 'critical' ? 0.25 : 0.15)); + + oscillator.start(); + oscillator.stop(audioContext.currentTime + (urgency === 'critical' ? 0.25 : 0.15)); + + console.log('Notification sound played'); + } catch (soundError) { + console.error('Failed to play sound:', soundError); + } + } + } else { + console.log('Desktop notifications not available or not granted:', { + hasNotification: 'Notification' in window, + permission: Notification.permission + }); + } - if (!response.ok) { - console.error('Browser notification response not ok:', await response.text()); + // Also send push notification if available + if (this.pushSubscription) { + const response = await fetch(`${this.serverUrl}/api/notify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type, + validator: validator, + validatorDisplay, + duty: { + slot: duty.slot, + timeUntil: this.formatTimeUntil(timeUntil) + }, + urgency + }) + }); + + if (!response.ok) { + console.error('Push notification response not ok:', await response.text()); + } } } catch (error) { console.error('Browser notification error:', error); @@ -1410,7 +1656,12 @@ class ValidatorDutiesTracker { // Since we now always store indices, all validators should be indices let indexDisplay = ` ${displayLabel} - + `; let pubkeyPreview = `Loading...`; @@ -1553,8 +1804,11 @@ class ValidatorDutiesTracker { if (labelDisplay && labelInput) { labelDisplay.style.display = 'none'; labelInput.style.display = 'inline'; - labelInput.focus(); - labelInput.select(); + // Small delay to ensure the input is visible before focusing + setTimeout(() => { + labelInput.focus(); + labelInput.select(); + }, 10); } } } @@ -2386,6 +2640,10 @@ class ValidatorDutiesTracker { if (this.duties.sync.length === 0) { panel.innerHTML = ` +
+

Current/Next Sync Committee member

+

Sync Committee

+

No sync committee duties found for your validators

Sync committees are selected every ~27 hours

@@ -2395,7 +2653,7 @@ class ValidatorDutiesTracker { } const currentEpoch = Math.floor(this.getCurrentSlotSync() / 32); - const html = this.duties.sync.map(duty => { + const dutyHtml = this.duties.sync.map(duty => { let dutyInfo = ''; let timeDisplay = ''; @@ -2421,17 +2679,25 @@ class ValidatorDutiesTracker { ${label}
- Sync Committee + ${dutyInfo} ${timeDisplay}
-
${dutyInfo}
+
Sync Committee
`; }).join(''); + const html = ` +
+

Current/Next Sync Committee member

+

Sync Committee

+
+ ${dutyHtml} + `; + panel.innerHTML = html; } @@ -2465,11 +2731,12 @@ class ValidatorDutiesTracker { const timeElapsed = epochsElapsed * 32 * 12 * 1000; // epochs * slots * seconds * ms // Count tracked validators in sync committees + const validatorIds = this.validators.map(v => v.id || v); const trackedInCurrent = this.networkOverview.currentSyncCommittee.filter(v => - this.validators.includes(v) || this.validators.includes(v.toString()) + validatorIds.includes(v) || validatorIds.includes(v.toString()) ); const trackedInNext = this.networkOverview.nextSyncCommittee.filter(v => - this.validators.includes(v) || this.validators.includes(v.toString()) + validatorIds.includes(v) || validatorIds.includes(v.toString()) ); let html = ` @@ -2696,7 +2963,7 @@ class ValidatorDutiesTracker { `; validators.forEach(validatorIndex => { - const isTracked = this.validators.includes(validatorIndex.toString()); + const isTracked = this.validators.some(v => (v.id || v) === validatorIndex.toString()); const color = isTracked ? this.getValidatorColor(validatorIndex.toString()) : '#6b7280'; html += ` @@ -2724,7 +2991,9 @@ class ValidatorDutiesTracker { notifyAttester: sessionStorage.getItem('notifyAttester') !== 'false', notifySync: sessionStorage.getItem('notifySync') !== 'false', notifyMissed: sessionStorage.getItem('notifyMissed') === 'true', // Default to false - notifyMinutes: sessionStorage.getItem('notifyMinutes') || '10' + notifyMinutes: sessionStorage.getItem('notifyMinutes') || '10', + desktopSound: sessionStorage.getItem('desktopSound') !== 'false', + desktopPersistent: sessionStorage.getItem('desktopPersistent') !== 'false' }; // Apply settings to UI @@ -2734,6 +3003,18 @@ class ValidatorDutiesTracker { document.getElementById('notifyMissed').checked = settings.notifyMissed; document.getElementById('notifyMinutes').value = settings.notifyMinutes; + // Apply desktop settings if elements exist + if (document.getElementById('desktopSound')) { + document.getElementById('desktopSound').checked = settings.desktopSound; + document.getElementById('desktopPersistent').checked = settings.desktopPersistent; + } + + // Check if browser notifications are enabled and show settings + if (sessionStorage.getItem('browserNotifications') === 'true') { + const testBtn = document.getElementById('testBrowserNotification'); + if (testBtn) testBtn.style.display = 'inline-block'; + } + // Save settings on change ['notifyProposer', 'notifyAttester', 'notifySync'].forEach(id => { document.getElementById(id).addEventListener('change', (e) => { @@ -2742,12 +3023,48 @@ class ValidatorDutiesTracker { }); }); + // Save desktop settings on change + ['desktopSound', 'desktopPersistent'].forEach(id => { + if (document.getElementById(id)) { + document.getElementById(id).addEventListener('change', (e) => { + sessionStorage.setItem(id, e.target.checked); + }); + } + }); + // Don't add event listener for disabled notifyMissed checkbox document.getElementById('notifyMinutes').addEventListener('change', (e) => { sessionStorage.setItem('notifyMinutes', e.target.value); this.sendNotificationSettingsUpdate(); }); + + // Also add event listeners for Telegram settings + ['notifyProposerTelegram', 'notifyAttesterTelegram', 'notifySyncTelegram'].forEach(id => { + const elem = document.getElementById(id); + if (elem) { + elem.addEventListener('change', (e) => { + const baseId = id.replace('Telegram', ''); + sessionStorage.setItem(baseId, e.target.checked); + this.sendNotificationSettingsUpdate(); + }); + // Sync initial state with regular settings + const baseId = id.replace('Telegram', ''); + elem.checked = sessionStorage.getItem(baseId) !== 'false'; + } + }); + + const telegramMinutesElem = document.getElementById('notifyMinutesTelegram'); + if (telegramMinutesElem) { + telegramMinutesElem.addEventListener('change', (e) => { + sessionStorage.setItem('notifyMinutes', e.target.value); + // Sync both dropdowns + document.getElementById('notifyMinutes').value = e.target.value; + this.sendNotificationSettingsUpdate(); + }); + // Sync initial value + telegramMinutesElem.value = settings.notifyMinutes; + } } cacheDuties() { @@ -3187,6 +3504,24 @@ class ValidatorDutiesTracker { } } + initializeDarkMode() { + // Load saved theme preference or default to light + const savedTheme = sessionStorage.getItem('darkMode') || 'light'; + document.documentElement.setAttribute('data-theme', savedTheme); + + // Update toggle button icons + const sunIcon = document.querySelector('#darkModeToggle .sun-icon'); + const moonIcon = document.querySelector('#darkModeToggle .moon-icon'); + + if (savedTheme === 'dark') { + sunIcon.classList.add('hidden'); + moonIcon.classList.remove('hidden'); + } else { + sunIcon.classList.remove('hidden'); + moonIcon.classList.add('hidden'); + } + } + toggleDashboardMode() { this.isDashboardMode = !this.isDashboardMode; sessionStorage.setItem('dashboardMode', this.isDashboardMode.toString()); @@ -3247,6 +3582,27 @@ class ValidatorDutiesTracker { this.switchToNormal(); } + toggleDarkMode() { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + const newTheme = isDark ? 'light' : 'dark'; + + // Update theme + document.documentElement.setAttribute('data-theme', newTheme); + sessionStorage.setItem('darkMode', newTheme); + + // Update toggle button icons + const sunIcon = document.querySelector('#darkModeToggle .sun-icon'); + const moonIcon = document.querySelector('#darkModeToggle .moon-icon'); + + if (newTheme === 'dark') { + sunIcon.classList.add('hidden'); + moonIcon.classList.remove('hidden'); + } else { + sunIcon.classList.remove('hidden'); + moonIcon.classList.add('hidden'); + } + } + startDashboardUpdates() { if (this.dashboardUpdateInterval) { clearInterval(this.dashboardUpdateInterval); diff --git a/index.html b/index.html index 3f72154..622f5ce 100644 --- a/index.html +++ b/index.html @@ -20,9 +20,24 @@

Ethereum Validator Duties Tracker

@@ -131,27 +146,83 @@

Beacon Node Configuration

Notifications

-
-
-

Browser Notifications

- -

-
- -
+
+ +

Telegram Notifications

Start a chat with @EthDuties_bot and send /start to get your chat ID
- +

-
-

Notification Settings

+
+

Desktop Notifications

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

+
+ + +
+

Telegram Duty Settings

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

Desktop Duty Settings

+ + +
+
+

Frequently Asked Questions

+ +
+

What is ETH Duties Tracker?

+

ETH Duties Tracker is a web application that helps Ethereum validators monitor their upcoming duties including block proposals, attestations, and sync committee participation. It provides real-time notifications and a dashboard view of all validator activities.

+
+ +
+

How do I add my validators?

+

You can add validators by entering either their validator index (e.g., 123456) or public key (0x...) in the "Add Validators" section. You can also add multiple validators at once by separating them with commas, or import them from a JSON file.

+
+ +
+

What types of duties are tracked?

+

The tracker monitors three types of validator duties:

+
    +
  • Proposer Duties: When your validator is selected to propose a block
  • +
  • Attester Duties: Regular attestation duties that happen every epoch
  • +
  • Sync Committee: Special duties when your validator is part of the sync committee
  • +
+
+ +
+

How do notifications work?

+

ETH Duties Tracker supports two types of notifications:

+
    +
  • Desktop Notifications: Browser-based notifications that appear on your desktop when duties are approaching
  • +
  • Telegram Notifications: Get alerts sent directly to your Telegram messenger
  • +
+

You can configure when to receive notifications (5 minutes to 1 hour before duties) and which types of duties to be notified about.

+
+ +
+

How do I set up Telegram notifications?

+

1. Start a chat with @EthDuties_bot
+ 2. Send /start to get your chat ID
+ 3. Enter your chat ID in the Settings page
+ 4. Click "Enable Telegram Notifications"

+
+ +
+

What is Dashboard Mode?

+

Dashboard Mode provides a full-screen, real-time view of your validators, upcoming duties, network status, and recent blocks. It's perfect for displaying on a dedicated monitor or TV screen. Access it by clicking the "Dashboard Mode" button at the bottom of the page.

+
+ +
+

Can I use a public beacon node?

+

Yes! The app comes with several public beacon nodes pre-configured. You can select one from the dropdown in Settings, or enter your own beacon node URL if you're running a local node. Note that public nodes may have rate limits.

+
+ +
+

How do I customize validator labels?

+

Click on any validator's public key (the shortened address like "0x1234...abcd") to edit its label. You can give your validators meaningful names like "Main Validator" or "Node 1" to easily identify them.

+
+ +
+

Is my data stored securely?

+

All data is stored locally in your browser's session storage. No validator information is sent to external servers except for the beacon node queries and optional notification services. The data is cleared when you close your browser session.

+
+ +
+

What do the duty countdown colors mean?

+

The countdown timers use colors to indicate urgency:

+
    +
  • Red: Less than 1 minute (critical)
  • +
  • Orange: 1-5 minutes (urgent)
  • +
  • Blue: More than 5 minutes (normal)
  • +
+
+ +
+

Can I export/import my validator list?

+

Yes! Use the Export button to save your validator list as a JSON file, and Import to load validators from a previously exported file. This is useful for backing up your configuration or sharing it between devices.

+
+ +
+

Need more help?

+

For bug reports, feature requests, or contributions, visit the GitHub repository. You can also reach out on Twitter @sbetamc.

+
+
+
@@ -269,22 +425,42 @@

🌐 Network State

diff --git a/package-lock.json b/package-lock.json index 1611509..dccb793 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eth-duties-tracker", - "version": "1.0.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eth-duties-tracker", - "version": "1.0.0", + "version": "1.2.1", "dependencies": { "cors": "^2.8.5", "dotenv": "^16.3.1", diff --git a/package.json b/package.json index 306ea82..04907fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eth-duties-tracker", - "version": "1.2.1", + "version": "1.2.5", "description": "Ethereum Validator Duties Tracker with notifications", "main": "server.js", "scripts": { diff --git a/styles.css b/styles.css index 4eb47e7..204d240 100644 --- a/styles.css +++ b/styles.css @@ -72,6 +72,8 @@ header { /* Navigation */ .main-nav { display: flex; + justify-content: space-between; + align-items: center; gap: 10px; margin-bottom: 30px; background: var(--card-bg); @@ -80,6 +82,17 @@ header { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } +.nav-left { + display: flex; + gap: 10px; + flex: 1; +} + +.nav-right { + display: flex; + align-items: center; +} + .nav-btn { flex: 1; padding: 12px 24px; @@ -441,6 +454,8 @@ input[type="checkbox"] { font-size: 1rem; width: 80px; font-weight: 600; + position: relative; + z-index: 10; } .remove-validator-compact { @@ -752,6 +767,159 @@ footer a:hover { text-decoration: underline; } +.notification-grid-2x2 { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + gap: 20px; +} + +.grid-cell { + background: var(--bg-color); + padding: 20px; + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.grid-cell h3 { + font-size: 1.1rem; + margin-bottom: 15px; + color: var(--primary-color); +} + +.platform-controls { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.desktop-options { + margin-bottom: 15px; + padding: 15px; + background: rgba(0, 0, 0, 0.03); + border-radius: 6px; +} + + +.telegram-setup { + grid-column: 1; + grid-row: 1; +} + +.desktop-setup { + grid-column: 2; + grid-row: 1; +} + +.telegram-duties { + grid-column: 1; + grid-row: 2; +} + +.desktop-duties { + grid-column: 2; + grid-row: 2; +} + +@media (max-width: 768px) { + .notification-grid-2x2 { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto auto; + } + + .telegram-setup { + grid-column: 1; + grid-row: 1; + } + + .desktop-setup { + grid-column: 1; + grid-row: 2; + } + + .telegram-duties { + grid-column: 1; + grid-row: 3; + } + + .desktop-duties { + grid-column: 1; + grid-row: 4; + } +} + +/* Panel Header Styles */ +.panel-header { + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border-color); +} + +.panel-header h3 { + font-size: 1.2rem; + color: var(--primary-color); + margin-bottom: 5px; +} + +.panel-subtitle { + font-size: 0.9rem; + color: var(--text-secondary); + margin: 0; +} + +/* FAQ Styles */ +.faq-section { + background: var(--card-bg); + padding: 30px; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.faq-item { + margin-bottom: 25px; + padding-bottom: 25px; + border-bottom: 1px solid var(--border-color); +} + +.faq-item:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.faq-item h3 { + color: var(--primary-color); + font-size: 1.2rem; + margin-bottom: 10px; + font-weight: 600; +} + +.faq-item p { + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 10px; +} + +.faq-item ul { + margin-left: 20px; + margin-top: 10px; + color: var(--text-secondary); +} + +.faq-item ul li { + margin-bottom: 8px; + line-height: 1.6; +} + +.faq-item a { + color: var(--secondary-color); + text-decoration: none; +} + +.faq-item a:hover { + text-decoration: underline; +} + /* Modal Styles */ .modal { display: none; @@ -1453,22 +1621,74 @@ footer { } .footer-content { - display: flex; - justify-content: space-between; - align-items: center; max-width: 1200px; margin: 0 auto; - padding: 20px; + padding: 20px 20px 10px 20px; } .footer-info { - flex: 1; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.footer-row { + width: 100%; + margin: 5px 0; } -.footer-controls { +.footer-row p { + margin: 0; +} + +.footer-social { display: flex; + justify-content: center; align-items: center; - gap: 15px; + gap: 30px; +} + +.social-item { + display: flex; + align-items: center; +} + +.social-icon { + color: var(--text-secondary); + transition: color 0.2s ease, transform 0.2s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.social-icon:hover { + color: var(--secondary-color); + transform: translateY(-2px); + text-decoration: none; +} + +.social-icon span { + font-size: 0.9rem; +} + +.footer-version { + color: var(--text-secondary); + font-size: 0.9rem; + text-decoration: none; + transition: color 0.2s ease; +} + +.footer-version:hover { + color: var(--secondary-color); + text-decoration: underline; +} + +.footer-bottom { + padding: 10px 20px 20px 20px; + text-align: center; + border-top: 1px solid var(--border-color); + margin-top: 10px; } .dashboard-toggle { @@ -2099,9 +2319,11 @@ footer { } .footer-content { - flex-direction: column; - gap: 15px; - text-align: center; + padding: 15px 15px 10px 15px; + } + + .footer-bottom { + padding: 10px 15px 15px 15px; } .dashboard-mode .footer-content { @@ -2542,3 +2764,275 @@ footer { .disabled-option input[disabled] { cursor: not-allowed; } + +/* Dark Mode Toggle */ +.dark-mode-toggle { + display: flex; + align-items: center; +} + +.dark-mode-btn { + background: transparent; + border: 2px solid var(--text-secondary); + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + color: var(--text-secondary); + position: relative; +} + +.dark-mode-btn:hover { + border-color: var(--secondary-color); + color: var(--secondary-color); + transform: scale(1.1); +} + +.dark-mode-btn .sun-icon, +.dark-mode-btn .moon-icon { + transition: all 0.3s ease; + position: absolute; +} + +.dark-mode-btn .moon-icon.hidden, +.dark-mode-btn .sun-icon.hidden { + opacity: 0; + transform: rotate(180deg); +} + +/* Dark Mode Styles */ +[data-theme="dark"] { + --primary-color: #3b82f6; + --secondary-color: #60a5fa; + --success-color: #34d399; + --warning-color: #fbbf24; + --error-color: #f87171; + --bg-color: #1f2937; + --card-bg: #374151; + --text-primary: #f9fafb; + --text-secondary: #d1d5db; + --border-color: #4b5563; +} + +[data-theme="dark"] body { + background-color: var(--bg-color); + color: var(--text-primary); +} + +[data-theme="dark"] .main-nav { + background: var(--card-bg); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .nav-btn:hover { + background-color: var(--bg-color); +} + +[data-theme="dark"] input[type="text"], +[data-theme="dark"] select { + background-color: var(--bg-color); + color: var(--text-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] input[type="text"]:focus, +[data-theme="dark"] select:focus { + border-color: var(--secondary-color); +} + +[data-theme="dark"] .add-validator-form { + background-color: var(--bg-color); + border-color: var(--border-color); +} + +[data-theme="dark"] .validators-list li { + background-color: var(--bg-color); +} + +[data-theme="dark"] .duty-item { + background-color: var(--bg-color); +} + +[data-theme="dark"] .status-message.error { + background-color: var(--error-color); + border-color: #dc2626; +} + +[data-theme="dark"] .status-message.success { + background-color: var(--success-color); + border-color: #059669; +} + +[data-theme="dark"] .status-message.info { + background-color: var(--secondary-color); + border-color: #2563eb; +} + +[data-theme="dark"] .grid-cell { + background: var(--bg-color); + border-color: var(--border-color); +} + +[data-theme="dark"] .desktop-options { + background: rgba(255, 255, 255, 0.05); +} + +[data-theme="dark"] .notification-type { + background-color: var(--bg-color); +} + +[data-theme="dark"] .notification-settings { + background-color: var(--bg-color); +} + +[data-theme="dark"] .modal-content { + background-color: var(--card-bg); + color: var(--text-primary); +} + +[data-theme="dark"] .modal-header { + border-color: var(--border-color); +} + +[data-theme="dark"] .modal-close { + color: var(--text-secondary); +} + +[data-theme="dark"] .modal-close:hover { + color: var(--text-primary); +} + +[data-theme="dark"] .beacon-error-banner { + background: #7f1d1d; + border-bottom-color: #dc2626; +} + +[data-theme="dark"] .error-content { + color: #f87171; +} + +/* Footer version row */ +.footer-version-row { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; +} + +/* Dashboard view dark mode styles */ +[data-theme="dark"] .dashboard-metrics { + background-color: var(--card-bg); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .metric-item { + background-color: var(--bg-color); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .metric-title { + color: var(--text-secondary); +} + +[data-theme="dark"] .metric-value { + color: var(--text-primary); +} + +[data-theme="dark"] .dashboard-overview { + background-color: var(--card-bg); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .dashboard-panel { + background-color: var(--bg-color); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .panel-header { + background-color: var(--card-bg); + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); +} + +[data-theme="dark"] .panel-content { + background-color: var(--bg-color); + color: var(--text-primary); +} + +[data-theme="dark"] .network-metrics-grid { + background-color: var(--bg-color); +} + +[data-theme="dark"] .dashboard-duties { + background-color: var(--card-bg); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .upcoming-duties { + background-color: var(--bg-color); +} + +[data-theme="dark"] .dashboard-duty-item { + background-color: var(--card-bg); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .duty-countdown { + color: var(--text-secondary); +} + +[data-theme="dark"] .dashboard-sync-committee { + background-color: var(--card-bg); + border: 1px solid var(--border-color); +} + +[data-theme="dark"] .sync-committee-summary { + background-color: var(--bg-color); +} + +[data-theme="dark"] .sync-summary-item { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +[data-theme="dark"] .sync-summary-item:hover { + background-color: var(--bg-color); + border-color: var(--secondary-color); +} + +[data-theme="dark"] .sync-summary-header { + color: var(--text-primary); +} + +[data-theme="dark"] .sync-summary-content { + color: var(--text-secondary); +} + +[data-theme="dark"] .validator-count-tag { + background-color: var(--secondary-color); + color: white; +} + +[data-theme="dark"] .dashboard-page { + background-color: var(--bg-color); + color: var(--text-primary); +} + +[data-theme="dark"] .dashboard-header { + background-color: var(--card-bg); + border-bottom: 1px solid var(--border-color); +} + +[data-theme="dark"] .dashboard-exit-btn { + background-color: var(--error-color); + border: 1px solid #dc2626; +} + +[data-theme="dark"] .dashboard-exit-btn:hover { + background-color: #dc2626; +}