Spaces:
Sleeping
Sleeping
| // ================ PAGE NAVIGATION ================ | |
| const pages = { | |
| 'page-a': document.getElementById('page-a'), | |
| 'page-b': document.getElementById('page-b'), | |
| 'page-c': document.getElementById('page-c'), // Achievement | |
| 'page-d': document.getElementById('page-d'), // Records | |
| 'page-e': document.getElementById('page-e'), // Customise | |
| 'page-f': document.getElementById('page-f') // Help | |
| }; | |
| function showPage(pageId) { | |
| for (const key in pages) { | |
| pages[key].classList.add('hidden'); | |
| } | |
| if (pages[pageId]) { | |
| pages[pageId].classList.remove('hidden'); | |
| } | |
| } | |
| // ================ VIDEO MANAGER CLASS ================ | |
| class VideoManager { | |
| constructor() { | |
| this.videoElement = null; | |
| this.canvasElement = null; | |
| this.ctx = null; | |
| this.captureCanvas = null; | |
| this.captureCtx = null; | |
| this.stream = null; | |
| this.ws = null; | |
| this.isStreaming = false; | |
| this.sessionId = null; | |
| this.frameRate = 30; | |
| this.frameInterval = null; | |
| this.renderLoopId = null; | |
| // Status smoothing for stable display | |
| this.currentStatus = false; // Default: not focused | |
| this.previousStatus = false; // Track previous status to detect changes | |
| this.statusBuffer = []; // Buffer for last N frames | |
| this.bufferSize = 5; // Number of frames to average (smaller = more responsive) | |
| // Latest detection data for rendering | |
| this.latestDetectionData = null; | |
| this.lastConfidence = 0; | |
| this.detectionHoldMs = 30; | |
| } | |
| async initCamera() { | |
| try { | |
| this.stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| width: { ideal: 640 }, | |
| height: { ideal: 480 }, | |
| facingMode: 'user' | |
| } | |
| }); | |
| this.videoElement = document.createElement('video'); | |
| this.videoElement.srcObject = this.stream; | |
| this.videoElement.autoplay = true; | |
| this.videoElement.playsInline = true; | |
| this.canvasElement = document.createElement('canvas'); | |
| this.canvasElement.width = 640; | |
| this.canvasElement.height = 480; | |
| this.ctx = this.canvasElement.getContext('2d'); | |
| this.captureCanvas = document.createElement('canvas'); | |
| this.captureCanvas.width = 640; | |
| this.captureCanvas.height = 480; | |
| this.captureCtx = this.captureCanvas.getContext('2d'); | |
| const displayArea = document.getElementById('display-area'); | |
| displayArea.innerHTML = ''; | |
| displayArea.appendChild(this.canvasElement); | |
| await this.videoElement.play(); | |
| this.startRenderLoop(); | |
| return true; | |
| } catch (error) { | |
| console.error('Camera init error:', error); | |
| throw error; | |
| } | |
| } | |
| connectWebSocket() { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws/video`; | |
| this.ws = new WebSocket(wsUrl); | |
| this.ws.onopen = () => { | |
| console.log('WebSocket connected'); | |
| this.startSession(); | |
| }; | |
| this.ws.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| this.handleServerMessage(data); | |
| }; | |
| this.ws.onerror = (error) => { | |
| console.error('WebSocket error:', error); | |
| }; | |
| this.ws.onclose = () => { | |
| console.log('WebSocket closed'); | |
| }; | |
| } | |
| startStreaming() { | |
| this.isStreaming = true; | |
| this.connectWebSocket(); | |
| this.frameInterval = setInterval(() => { | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.captureAndSendFrame(); | |
| } | |
| }, 1000 / this.frameRate); | |
| } | |
| captureAndSendFrame() { | |
| if (!this.videoElement || !this.captureCanvas || !this.captureCtx) return; | |
| this.captureCtx.drawImage(this.videoElement, 0, 0, 640, 480); | |
| const imageData = this.captureCanvas.toDataURL('image/jpeg', 0.8); | |
| const base64Data = imageData.split(',')[1]; | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.ws.send(JSON.stringify({ | |
| type: 'frame', | |
| image: base64Data | |
| })); | |
| } | |
| } | |
| handleServerMessage(data) { | |
| switch (data.type) { | |
| case 'detection': | |
| // Update status with smoothing | |
| this.updateStatus(data.focused); | |
| // Render with smoothed status | |
| this.renderDetections(data); | |
| // Update timeline and notifications with smoothed status | |
| timeline.addEvent(this.currentStatus); | |
| checkDistraction(this.currentStatus); | |
| break; | |
| case 'session_started': | |
| this.sessionId = data.session_id; | |
| console.log('Session started:', this.sessionId); | |
| break; | |
| case 'session_ended': | |
| console.log('Session ended:', data.summary); | |
| showSessionSummary(data.summary); | |
| break; | |
| case 'ack': | |
| // Frame acknowledged but not processed | |
| break; | |
| case 'error': | |
| console.error('Server error:', data.message); | |
| break; | |
| } | |
| } | |
| updateStatus(newFocused) { | |
| // Add to buffer | |
| this.statusBuffer.push(newFocused); | |
| // Keep buffer size limited | |
| if (this.statusBuffer.length > this.bufferSize) { | |
| this.statusBuffer.shift(); | |
| } | |
| // Don't update status until buffer is full (prevents initial flickering) | |
| if (this.statusBuffer.length < this.bufferSize) { | |
| return false; // Status hasn't changed | |
| } | |
| // Calculate majority vote with moderate thresholds for better responsiveness | |
| const focusedCount = this.statusBuffer.filter(f => f).length; | |
| const focusedRatio = focusedCount / this.statusBuffer.length; | |
| // Store previous status | |
| this.previousStatus = this.currentStatus; | |
| // Moderate thresholds: quicker to change, still avoids rapid flipping | |
| // For 8 frames: need 6+ focused to become FOCUSED, or 6+ not focused to become NOT FOCUSED | |
| if (focusedRatio >= 0.75) { | |
| this.currentStatus = true; | |
| } else if (focusedRatio <= 0.25) { | |
| this.currentStatus = false; | |
| } | |
| // Between 0.25-0.75: keep current status (hysteresis to avoid jitter) | |
| // Log only when status actually changes | |
| const statusChanged = this.currentStatus !== this.previousStatus; | |
| if (statusChanged) { | |
| console.log(`Status changed: ${this.previousStatus ? 'FOCUSED' : 'NOT FOCUSED'} -> ${this.currentStatus ? 'FOCUSED' : 'NOT FOCUSED'} (ratio: ${focusedRatio.toFixed(2)})`); | |
| } | |
| // Return whether status changed | |
| return statusChanged; | |
| } | |
| renderDetections(data) { | |
| this.latestDetectionData = { | |
| detections: data.detections || [], | |
| confidence: data.confidence || 0, | |
| focused: data.focused, | |
| timestamp: performance.now() | |
| }; | |
| this.lastConfidence = data.confidence || 0; | |
| } | |
| startRenderLoop() { | |
| if (this.renderLoopId) return; | |
| const render = () => { | |
| if (this.videoElement && this.ctx) { | |
| this.ctx.drawImage(this.videoElement, 0, 0, 640, 480); | |
| const now = performance.now(); | |
| const latest = this.latestDetectionData; | |
| const hasFresh = latest && (now - latest.timestamp) <= this.detectionHoldMs; | |
| // Draw detection boxes using last known data (prevents flicker) | |
| if (hasFresh && latest.detections.length > 0) { | |
| latest.detections.forEach(det => { | |
| const [x1, y1, x2, y2] = det.bbox; | |
| this.ctx.strokeStyle = this.currentStatus ? '#00FF00' : '#FF0000'; | |
| this.ctx.lineWidth = 3; | |
| this.ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); | |
| this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000'; | |
| this.ctx.font = '16px Nunito'; | |
| const label = `${det.class_name} ${(det.confidence * 100).toFixed(1)}%`; | |
| this.ctx.fillText(label, x1, y1 - 5); | |
| }); | |
| } | |
| const statusText = this.currentStatus ? 'FOCUSED' : 'NOT FOCUSED'; | |
| this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000'; | |
| this.ctx.font = 'bold 24px Nunito'; | |
| this.ctx.fillText(statusText, 10, 30); | |
| this.ctx.font = '16px Nunito'; | |
| this.ctx.fillText(`Confidence: ${(this.lastConfidence * 100).toFixed(1)}%`, 10, 55); | |
| } | |
| this.renderLoopId = requestAnimationFrame(render); | |
| }; | |
| this.renderLoopId = requestAnimationFrame(render); | |
| } | |
| stopRenderLoop() { | |
| if (this.renderLoopId) { | |
| cancelAnimationFrame(this.renderLoopId); | |
| this.renderLoopId = null; | |
| } | |
| } | |
| startSession() { | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.ws.send(JSON.stringify({ type: 'start_session' })); | |
| } | |
| } | |
| stopStreaming() { | |
| this.isStreaming = false; | |
| this.stopRenderLoop(); | |
| if (this.frameInterval) { | |
| clearInterval(this.frameInterval); | |
| this.frameInterval = null; | |
| } | |
| if (this.ws) { | |
| this.ws.send(JSON.stringify({ type: 'end_session' })); | |
| this.ws.close(); | |
| this.ws = null; | |
| } | |
| if (this.stream) { | |
| this.stream.getTracks().forEach(track => track.stop()); | |
| this.stream = null; | |
| } | |
| if (this.canvasElement && this.ctx) { | |
| this.ctx.clearRect(0, 0, 640, 480); | |
| } | |
| // Reset status | |
| this.currentStatus = false; | |
| this.statusBuffer = []; | |
| this.latestDetectionData = null; | |
| this.lastConfidence = 0; | |
| } | |
| setFrameRate(rate) { | |
| this.frameRate = Math.max(1, Math.min(60, rate)); | |
| if (this.isStreaming && this.frameInterval) { | |
| clearInterval(this.frameInterval); | |
| this.frameInterval = setInterval(() => { | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.captureAndSendFrame(); | |
| } | |
| }, 1000 / this.frameRate); | |
| } | |
| } | |
| } | |
| // ================ TIMELINE MANAGER CLASS ================ | |
| class TimelineManager { | |
| constructor(maxEvents = 60) { | |
| this.events = []; | |
| this.maxEvents = maxEvents; | |
| this.container = document.getElementById('timeline-visuals'); | |
| } | |
| addEvent(isFocused) { | |
| const timestamp = Date.now(); | |
| this.events.push({ timestamp, isFocused }); | |
| if (this.events.length > this.maxEvents) { | |
| this.events.shift(); | |
| } | |
| this.render(); | |
| } | |
| render() { | |
| if (!this.container) return; | |
| this.container.innerHTML = ''; | |
| this.events.forEach((event, index) => { | |
| const block = document.createElement('div'); | |
| block.className = 'timeline-block'; | |
| block.style.backgroundColor = event.isFocused ? '#00FF00' : '#FF0000'; | |
| block.style.width = '10px'; | |
| block.style.height = '20px'; | |
| block.style.display = 'inline-block'; | |
| block.style.marginRight = '2px'; | |
| block.title = event.isFocused ? 'Focused' : 'Distracted'; | |
| this.container.appendChild(block); | |
| }); | |
| } | |
| clear() { | |
| this.events = []; | |
| this.render(); | |
| } | |
| } | |
| // ================ NOTIFICATION SYSTEM ================ | |
| let distractionStartTime = null; | |
| let notificationTimeout = null; | |
| let currentSettings = null; | |
| async function loadCurrentSettings() { | |
| try { | |
| const response = await fetch('/api/settings'); | |
| currentSettings = await response.json(); | |
| } catch (error) { | |
| console.error('Failed to load settings:', error); | |
| currentSettings = { | |
| notification_enabled: true, | |
| notification_threshold: 30 | |
| }; | |
| } | |
| } | |
| function checkDistraction(isFocused) { | |
| if (!currentSettings || !currentSettings.notification_enabled) return; | |
| if (!isFocused) { | |
| if (!distractionStartTime) { | |
| distractionStartTime = Date.now(); | |
| } | |
| const distractionDuration = (Date.now() - distractionStartTime) / 1000; | |
| if (distractionDuration >= currentSettings.notification_threshold && !notificationTimeout) { | |
| sendNotification('Focus Guard Alert', 'You seem distracted. Time to refocus!'); | |
| notificationTimeout = setTimeout(() => { | |
| notificationTimeout = null; | |
| }, 60000); | |
| } | |
| } else { | |
| distractionStartTime = null; | |
| } | |
| } | |
| async function sendNotification(title, body) { | |
| if ('Notification' in window) { | |
| if (Notification.permission === 'granted') { | |
| new Notification(title, { body }); | |
| } else if (Notification.permission !== 'denied') { | |
| const permission = await Notification.requestPermission(); | |
| if (permission === 'granted') { | |
| new Notification(title, { body }); | |
| } | |
| } | |
| } | |
| } | |
| // ================ SESSION SUMMARY MODAL ================ | |
| function showSessionSummary(summary) { | |
| const modal = document.createElement('div'); | |
| modal.className = 'modal-overlay'; | |
| modal.innerHTML = ` | |
| <div class="modal-content"> | |
| <h2>Session Complete!</h2> | |
| <div class="summary-stats"> | |
| <div class="summary-item"> | |
| <span class="summary-label">Duration:</span> | |
| <span class="summary-value">${formatDuration(summary.duration_seconds)}</span> | |
| </div> | |
| <div class="summary-item"> | |
| <span class="summary-label">Focus Score:</span> | |
| <span class="summary-value">${(summary.focus_score * 100).toFixed(1)}%</span> | |
| </div> | |
| <div class="summary-item"> | |
| <span class="summary-label">Total Frames:</span> | |
| <span class="summary-value">${summary.total_frames}</span> | |
| </div> | |
| <div class="summary-item"> | |
| <span class="summary-label">Focused Frames:</span> | |
| <span class="summary-value">${summary.focused_frames}</span> | |
| </div> | |
| </div> | |
| <button class="btn-main" onclick="closeModal()">Close</button> | |
| </div> | |
| `; | |
| document.body.appendChild(modal); | |
| } | |
| function closeModal() { | |
| const modal = document.querySelector('.modal-overlay'); | |
| if (modal) { | |
| modal.remove(); | |
| } | |
| } | |
| function formatDuration(seconds) { | |
| const hours = Math.floor(seconds / 3600); | |
| const minutes = Math.floor((seconds % 3600) / 60); | |
| const secs = seconds % 60; | |
| if (hours > 0) { | |
| return `${hours}h ${minutes}m ${secs}s`; | |
| } else if (minutes > 0) { | |
| return `${minutes}m ${secs}s`; | |
| } else { | |
| return `${secs}s`; | |
| } | |
| } | |
| // ================ GLOBAL INSTANCES ================ | |
| const videoManager = new VideoManager(); | |
| const timeline = new TimelineManager(); | |
| // ================ EVENT LISTENERS ================ | |
| // Page navigation | |
| document.getElementById('menu-start').addEventListener('click', () => showPage('page-b')); | |
| document.getElementById('menu-achievement').addEventListener('click', () => { | |
| showPage('page-c'); | |
| loadAchievements(); | |
| }); | |
| document.getElementById('menu-records').addEventListener('click', () => { | |
| showPage('page-d'); | |
| loadRecords('today'); | |
| }); | |
| document.getElementById('menu-customise').addEventListener('click', () => { | |
| showPage('page-e'); | |
| loadSettings(); | |
| }); | |
| document.getElementById('menu-help').addEventListener('click', () => showPage('page-f')); | |
| document.getElementById('start-button').addEventListener('click', () => showPage('page-b')); | |
| // Page B controls | |
| document.getElementById('btn-cam-start').addEventListener('click', async () => { | |
| try { | |
| await videoManager.initCamera(); | |
| videoManager.startStreaming(); | |
| timeline.clear(); | |
| await loadCurrentSettings(); | |
| } catch (error) { | |
| console.error('Failed to start camera:', error); | |
| alert('Camera access denied. Please allow camera permissions and ensure you are using HTTPS or localhost.'); | |
| } | |
| }); | |
| document.getElementById('btn-cam-stop').addEventListener('click', () => { | |
| videoManager.stopStreaming(); | |
| }); | |
| document.getElementById('btn-floating').addEventListener('click', () => { | |
| alert('Floating window feature coming soon!'); | |
| }); | |
| document.getElementById('btn-models').addEventListener('click', () => { | |
| alert('Model selection feature coming soon!'); | |
| }); | |
| // Frame control | |
| const frameSlider = document.getElementById('frame-slider'); | |
| const frameInput = document.getElementById('frame-input'); | |
| frameSlider.addEventListener('input', (e) => { | |
| const rate = parseInt(e.target.value); | |
| frameInput.value = rate; | |
| videoManager.setFrameRate(rate); | |
| }); | |
| frameInput.addEventListener('input', (e) => { | |
| const rate = parseInt(e.target.value); | |
| frameSlider.value = rate; | |
| videoManager.setFrameRate(rate); | |
| }); | |
| // ================ ACHIEVEMENT PAGE ================ | |
| async function loadAchievements() { | |
| try { | |
| const response = await fetch('/api/stats/summary'); | |
| const stats = await response.json(); | |
| document.getElementById('total-sessions').textContent = stats.total_sessions; | |
| document.getElementById('total-hours').textContent = | |
| (stats.total_focus_time / 3600).toFixed(1) + 'h'; | |
| document.getElementById('avg-focus').textContent = | |
| (stats.avg_focus_score * 100).toFixed(1) + '%'; | |
| document.getElementById('current-streak').textContent = stats.streak_days; | |
| loadBadges(stats); | |
| } catch (error) { | |
| console.error('Failed to load achievements:', error); | |
| } | |
| } | |
| function loadBadges(stats) { | |
| const badges = [ | |
| { name: 'First Session', condition: stats.total_sessions >= 1, icon: '' }, | |
| { name: '10 Sessions', condition: stats.total_sessions >= 10, icon: '' }, | |
| { name: '50 Sessions', condition: stats.total_sessions >= 50, icon: '' }, | |
| { name: '10 Hour Focus', condition: stats.total_focus_time >= 36000, icon: '' }, | |
| { name: '7 Day Streak', condition: stats.streak_days >= 7, icon: '' }, | |
| { name: '90% Avg Focus', condition: stats.avg_focus_score >= 0.9, icon: '' } | |
| ]; | |
| const container = document.getElementById('badges-container'); | |
| container.innerHTML = ''; | |
| badges.forEach(badge => { | |
| const badgeEl = document.createElement('div'); | |
| badgeEl.className = 'badge ' + (badge.condition ? 'earned' : 'locked'); | |
| badgeEl.innerHTML = ` | |
| <div class="badge-icon">${badge.icon}</div> | |
| <div class="badge-name">${badge.name}</div> | |
| `; | |
| container.appendChild(badgeEl); | |
| }); | |
| } | |
| // ================ RECORDS PAGE ================ | |
| async function loadRecords(filter = 'today') { | |
| try { | |
| const response = await fetch(`/api/sessions?filter=${filter}`); | |
| const sessions = await response.json(); | |
| renderSessionsTable(sessions); | |
| renderChart(sessions); | |
| } catch (error) { | |
| console.error('Failed to load records:', error); | |
| } | |
| } | |
| function renderSessionsTable(sessions) { | |
| const tbody = document.getElementById('sessions-tbody'); | |
| tbody.innerHTML = ''; | |
| sessions.forEach(session => { | |
| const row = document.createElement('tr'); | |
| const date = new Date(session.start_time).toLocaleString(); | |
| const duration = formatDuration(session.duration_seconds); | |
| const score = (session.focus_score * 100).toFixed(1) + '%'; | |
| row.innerHTML = ` | |
| <td>${date}</td> | |
| <td>${duration}</td> | |
| <td>${score}</td> | |
| <td><button class="btn-view" onclick="viewSessionDetails(${session.id})">View</button></td> | |
| `; | |
| tbody.appendChild(row); | |
| }); | |
| if (sessions.length === 0) { | |
| const row = document.createElement('tr'); | |
| row.innerHTML = '<td colspan="4" style="text-align: center;">No sessions found</td>'; | |
| tbody.appendChild(row); | |
| } | |
| } | |
| function renderChart(sessions) { | |
| const canvas = document.getElementById('focus-chart'); | |
| const ctx = canvas.getContext('2d'); | |
| canvas.width = 800; | |
| canvas.height = 300; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| if (sessions.length === 0) { | |
| ctx.fillStyle = '#888'; | |
| ctx.font = '20px Nunito'; | |
| ctx.fillText('No data available', canvas.width / 2 - 80, canvas.height / 2); | |
| return; | |
| } | |
| const barWidth = Math.min((canvas.width - 40) / sessions.length - 10, 80); | |
| const maxScore = 1.0; | |
| sessions.forEach((session, index) => { | |
| const x = index * (barWidth + 10) + 20; | |
| const barHeight = (session.focus_score / maxScore) * (canvas.height - 60); | |
| const y = canvas.height - barHeight - 30; | |
| ctx.fillStyle = session.focus_score > 0.7 ? '#28a745' : | |
| session.focus_score > 0.4 ? '#ffc107' : '#dc3545'; | |
| ctx.fillRect(x, y, barWidth, barHeight); | |
| ctx.fillStyle = '#333'; | |
| ctx.font = '12px Nunito'; | |
| const scoreText = (session.focus_score * 100).toFixed(0) + '%'; | |
| ctx.fillText(scoreText, x + barWidth / 2 - 15, y - 5); | |
| }); | |
| } | |
| function viewSessionDetails(sessionId) { | |
| alert(`Session details for ID ${sessionId} - Feature coming soon!`); | |
| } | |
| // Filter buttons | |
| document.getElementById('filter-today').addEventListener('click', () => { | |
| setActiveFilter('filter-today'); | |
| loadRecords('today'); | |
| }); | |
| document.getElementById('filter-week').addEventListener('click', () => { | |
| setActiveFilter('filter-week'); | |
| loadRecords('week'); | |
| }); | |
| document.getElementById('filter-month').addEventListener('click', () => { | |
| setActiveFilter('filter-month'); | |
| loadRecords('month'); | |
| }); | |
| document.getElementById('filter-all').addEventListener('click', () => { | |
| setActiveFilter('filter-all'); | |
| loadRecords('all'); | |
| }); | |
| function setActiveFilter(activeId) { | |
| document.querySelectorAll('.filter-btn').forEach(btn => { | |
| btn.classList.remove('active'); | |
| }); | |
| document.getElementById(activeId).classList.add('active'); | |
| } | |
| // ================ SETTINGS PAGE ================ | |
| async function loadSettings() { | |
| try { | |
| const response = await fetch('/api/settings'); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const settings = await response.json(); | |
| console.log('Loaded settings:', settings); | |
| // Apply settings with fallback to defaults | |
| document.getElementById('sensitivity-slider').value = settings.sensitivity || 6; | |
| document.getElementById('sensitivity-value').textContent = settings.sensitivity || 6; | |
| document.getElementById('default-framerate').value = settings.frame_rate || 30; | |
| document.getElementById('framerate-value').textContent = settings.frame_rate || 30; | |
| document.getElementById('enable-notifications').checked = settings.notification_enabled !== false; | |
| document.getElementById('notification-threshold').value = settings.notification_threshold || 30; | |
| } catch (error) { | |
| console.error('Failed to load settings:', error); | |
| alert('Failed to load settings: ' + error.message); | |
| } | |
| } | |
| async function saveSettings() { | |
| const settings = { | |
| sensitivity: parseInt(document.getElementById('sensitivity-slider').value), | |
| frame_rate: parseInt(document.getElementById('default-framerate').value), | |
| notification_enabled: document.getElementById('enable-notifications').checked, | |
| notification_threshold: parseInt(document.getElementById('notification-threshold').value) | |
| }; | |
| console.log('Saving settings:', settings); | |
| try { | |
| const response = await fetch('/api/settings', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(settings) | |
| }); | |
| if (response.ok) { | |
| const result = await response.json(); | |
| console.log('Settings saved:', result); | |
| alert('Settings saved successfully!'); | |
| await loadCurrentSettings(); | |
| } else { | |
| const error = await response.text(); | |
| console.error('Save failed with status:', response.status, error); | |
| alert(`Failed to save settings: ${response.status} ${response.statusText}`); | |
| } | |
| } catch (error) { | |
| console.error('Failed to save settings:', error); | |
| alert('Failed to save settings: ' + error.message); | |
| } | |
| } | |
| // Settings UI handlers | |
| document.getElementById('sensitivity-slider').addEventListener('input', (e) => { | |
| document.getElementById('sensitivity-value').textContent = e.target.value; | |
| }); | |
| document.getElementById('default-framerate').addEventListener('input', (e) => { | |
| document.getElementById('framerate-value').textContent = e.target.value; | |
| }); | |
| document.getElementById('save-settings').addEventListener('click', saveSettings); | |
| document.getElementById('export-data').addEventListener('click', async () => { | |
| try { | |
| const response = await fetch('/api/sessions?filter=all'); | |
| const sessions = await response.json(); | |
| const dataStr = JSON.stringify(sessions, null, 2); | |
| const dataBlob = new Blob([dataStr], { type: 'application/json' }); | |
| const url = URL.createObjectURL(dataBlob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = `focus-guard-data-${new Date().toISOString().split('T')[0]}.json`; | |
| link.click(); | |
| URL.revokeObjectURL(url); | |
| } catch (error) { | |
| console.error('Failed to export data:', error); | |
| alert('Failed to export data'); | |
| } | |
| }); | |
| document.getElementById('clear-history').addEventListener('click', async () => { | |
| if (confirm('Are you sure you want to clear all history? This cannot be undone.')) { | |
| alert('Clear history feature requires backend implementation'); | |
| } | |
| }); | |
| // ================ INITIALIZATION ================ | |
| // Request notification permission on load | |
| if ('Notification' in window && Notification.permission === 'default') { | |
| Notification.requestPermission(); | |
| } | |
| // Load settings on startup | |
| loadCurrentSettings(); | |
| console.log(' Focus Guard initialized'); | |