diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 29dec7c72..225d6de97 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer } from 'lucide-react'; +import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { api } from '../api/client'; import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client'; @@ -12,19 +12,20 @@ import { AddNotificationModal } from '../components/AddNotificationModal'; import { SpoolmanSettings } from '../components/SpoolmanSettings'; import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout'; import { availableLanguages } from '../i18n'; -import { useState, useEffect } from 'react'; +import { useToast } from '../contexts/ToastContext'; +import { useState, useEffect, useRef, useCallback } from 'react'; export function SettingsPage() { const queryClient = useQueryClient(); const { t, i18n } = useTranslation(); + const { showToast } = useToast(); const [localSettings, setLocalSettings] = useState(null); - const [hasChanges, setHasChanges] = useState(false); - const [showSaved, setShowSaved] = useState(false); const [showPlugModal, setShowPlugModal] = useState(false); const [editingPlug, setEditingPlug] = useState(null); const [showNotificationModal, setShowNotificationModal] = useState(false); const [editingProvider, setEditingProvider] = useState(null); const [defaultView, setDefaultViewState] = useState(getDefaultView()); + const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general'); const handleDefaultViewChange = (path: string) => { setDefaultViewState(path); @@ -87,31 +88,18 @@ export function SettingsPage() { }, }); + // Ref for debounce timeout + const saveTimeoutRef = useRef | null>(null); + const isInitialLoadRef = useRef(true); + // Sync local state when settings load useEffect(() => { if (settings && !localSettings) { setLocalSettings(settings); - } - }, [settings, localSettings]); - - // Track changes - useEffect(() => { - if (settings && localSettings) { - const changed = - settings.auto_archive !== localSettings.auto_archive || - settings.save_thumbnails !== localSettings.save_thumbnails || - settings.capture_finish_photo !== localSettings.capture_finish_photo || - settings.default_filament_cost !== localSettings.default_filament_cost || - settings.currency !== localSettings.currency || - settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh || - settings.energy_tracking_mode !== localSettings.energy_tracking_mode || - settings.check_updates !== localSettings.check_updates || - settings.notification_language !== localSettings.notification_language || - settings.ams_humidity_good !== localSettings.ams_humidity_good || - settings.ams_humidity_fair !== localSettings.ams_humidity_fair || - settings.ams_temp_good !== localSettings.ams_temp_good || - settings.ams_temp_fair !== localSettings.ams_temp_fair; - setHasChanges(changed); + // Mark initial load complete after a short delay + setTimeout(() => { + isInitialLoadRef.current = false; + }, 100); } }, [settings, localSettings]); @@ -119,26 +107,63 @@ export function SettingsPage() { mutationFn: api.updateSettings, onSuccess: (data) => { queryClient.setQueryData(['settings'], data); - setLocalSettings(data); - setHasChanges(false); - setShowSaved(true); - setTimeout(() => setShowSaved(false), 2000); // Invalidate archive stats to reflect energy tracking mode change queryClient.invalidateQueries({ queryKey: ['archiveStats'] }); + showToast('Settings saved', 'success'); + }, + onError: (error: Error) => { + showToast(`Failed to save: ${error.message}`, 'error'); }, }); - const handleSave = () => { - if (localSettings) { - updateMutation.mutate(localSettings); + // Debounced auto-save when localSettings change + useEffect(() => { + // Skip if initial load or no settings + if (isInitialLoadRef.current || !localSettings || !settings) { + return; } - }; - const updateSetting = (key: K, value: AppSettings[K]) => { - if (localSettings) { - setLocalSettings({ ...localSettings, [key]: value }); + // Check if there are actual changes + const hasChanges = + settings.auto_archive !== localSettings.auto_archive || + settings.save_thumbnails !== localSettings.save_thumbnails || + settings.capture_finish_photo !== localSettings.capture_finish_photo || + settings.default_filament_cost !== localSettings.default_filament_cost || + settings.currency !== localSettings.currency || + settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh || + settings.energy_tracking_mode !== localSettings.energy_tracking_mode || + settings.check_updates !== localSettings.check_updates || + settings.notification_language !== localSettings.notification_language || + settings.ams_humidity_good !== localSettings.ams_humidity_good || + settings.ams_humidity_fair !== localSettings.ams_humidity_fair || + settings.ams_temp_good !== localSettings.ams_temp_good || + settings.ams_temp_fair !== localSettings.ams_temp_fair; + + if (!hasChanges) { + return; } - }; + + // Clear existing timeout + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + // Set new debounced save (500ms delay) + saveTimeoutRef.current = setTimeout(() => { + updateMutation.mutate(localSettings); + }, 500); + + // Cleanup on unmount or when localSettings changes again + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, [localSettings, settings, updateMutation]); + + const updateSetting = useCallback((key: K, value: AppSettings[K]) => { + setLocalSettings(prev => prev ? { ...prev, [key]: value } : null); + }, []); if (isLoading || !localSettings) { return ( @@ -150,32 +175,59 @@ export function SettingsPage() { return (
-
-
-

Settings

-

Configure Bambusy

-
- +
+

Settings

+

Configure Bambusy

- {updateMutation.isError && ( -
- Failed to save settings: {(updateMutation.error as Error).message} -
- )} + {/* Tab Navigation */} +
+ + + +
+ {/* General Tab */} + {activeTab === 'general' && (
{/* Left Column - General Settings */}
@@ -386,7 +438,10 @@ export function SettingsPage() {
+
+ {/* Second Column - AMS & Spoolman */} +

AMS Display Thresholds

@@ -489,12 +544,12 @@ export function SettingsPage() {
+ +
- {/* Second Column - Spoolman & Updates */} -
- - + {/* Third Column - Updates */} +

Updates

@@ -617,90 +672,103 @@ export function SettingsPage() {
+
+ )} - {/* Third Column - Smart Plugs */} -
- - -
-
- -

Smart Plugs

-
- +
+ + {plugsLoading ? ( +
+ +
+ ) : smartPlugs && smartPlugs.length > 0 ? ( +
+ {smartPlugs.map((plug) => ( + { + setEditingPlug(p); setShowPlugModal(true); }} - > - - Add - -
-
- -

- Connect Tasmota-based smart plugs to automate power control for your printers. -

- {plugsLoading ? ( -
- + /> + ))} +
+ ) : ( + + +
+ +

No smart plugs configured

+

Add a Tasmota-based smart plug to track energy usage and automate power control.

+
- ) : smartPlugs && smartPlugs.length > 0 ? ( -
- {smartPlugs.map((plug) => ( - { - setEditingPlug(p); - setShowPlugModal(true); - }} - /> - ))} -
- ) : ( -
- -

No smart plugs configured

-

Add a Tasmota plug to get started

-
- )} -
-
+
+
+ )}
+ )} - {/* Fourth Column - Notifications */} -
- - -
-
- -

Notifications

-
- -
-
- -

- Get notified about print events via WhatsApp, Telegram, Email, and more. + {/* Notifications Tab */} + {activeTab === 'notifications' && ( +

+
+
+

+ + Notifications +

+

+ Get notified about print events via WhatsApp, Telegram, Email, Discord, and more.

+
+ +
- {/* Notification Language */} -
+ {/* Notification Language Setting */} + + +
-

{t('settings.notificationLanguage')}

+

{t('settings.notificationLanguage')}

{t('settings.notificationLanguageDescription')}