diff --git a/src/App.tsx b/src/App.tsx index 316db91..ed02447 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,10 +15,11 @@ import { isPermissionGranted, requestPermission, sendNotification } from '@tauri import SchedulerView from "./components/SchedulerView"; import SpeedLimiterView from "./components/SpeedLimiterView"; import DiagnosticsView from "./components/DiagnosticsView"; +import { useToast } from "./contexts/ToastContext"; function App() { const [filter, setFilter] = useState('all'); - const [pairingTokenChanged, setPairingTokenChanged] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(() => { const stored = Number(window.localStorage.getItem('firelink-sidebar-width')); return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220; @@ -41,7 +42,6 @@ function App() { const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const acknowledgePairingTokenChange = () => { - setPairingTokenChanged(false); invoke('acknowledge_pairing_token_change').catch(error => { console.error('Failed to acknowledge pairing token migration notice:', error); }); @@ -73,14 +73,55 @@ function App() { window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth)); }, [sidebarWidth]); + const { addToast } = useToast(); + useEffect(() => { useDownloadStore.getState().initDB(); useSettingsStore.getState().hydratePairingToken() - .then(setPairingTokenChanged) + .then(changed => { + if (changed) { + addToast({ + variant: 'warning', + isActionable: true, + message: ( +
+

Browser extension disconnected because its pairing token changed.

+
+ + +
+
+ ) + }); + } + }) .catch(error => { console.error('Failed to hydrate extension pairing token:', error); }); - }, []); + }, [addToast]); useEffect(() => { window.document.documentElement.setAttribute('data-font-size', appFontSize); @@ -303,43 +344,7 @@ function App() { - {pairingTokenChanged && ( -
-

- Browser extension disconnected because its pairing token changed. -

-
- - -
-
- )} + ); } diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 6f34dd2..a78d105 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useDownloadStore, DownloadItem } from '../store/useDownloadStore'; +import { useToast } from '../contexts/ToastContext'; import { useSettingsStore } from '../store/useSettingsStore'; import { SidebarFilter } from './Sidebar'; import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command } from 'lucide-react'; @@ -17,11 +18,11 @@ interface DownloadTableProps { export const DownloadTable: React.FC = ({ filter }) => { const { downloads, toggleAddModal, openDeleteModal, redownload } = useDownloadStore(); const { isSidebarVisible, toggleSidebar } = useSettingsStore(); + const { addToast } = useToast(); const isMac = navigator.userAgent.includes('Mac'); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); - const [interactionError, setInteractionError] = useState(''); const [columnWidths, setColumnWidths] = useState([340, 100, 220, 100, 80, 170]); const columnMinimums = [0, 58, 92, 58, 48, 112]; const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' '); @@ -54,15 +55,10 @@ export const DownloadTable: React.FC = ({ filter }) => { return () => window.removeEventListener('click', handleCloseMenu); }, []); - useEffect(() => { - if (!interactionError) return; - const timeout = window.setTimeout(() => setInteractionError(''), 5000); - return () => window.clearTimeout(timeout); - }, [interactionError]); const showInteractionError = (message: string, error: unknown) => { - const detail = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error); - setInteractionError(`${message}: ${detail}`); + const detail = error instanceof Error ? error.message : String(error); + addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true }); }; const getDownloadPath = async (item: DownloadItem) => { @@ -440,12 +436,6 @@ export const DownloadTable: React.FC = ({ filter }) => { )} - {interactionError && ( -
- {interactionError} -
- )} - ); }; diff --git a/src/components/SchedulerView.tsx b/src/components/SchedulerView.tsx index 586ade1..deb433c 100644 --- a/src/components/SchedulerView.tsx +++ b/src/components/SchedulerView.tsx @@ -7,6 +7,7 @@ import { import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore'; import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore'; import { WindowDragRegion } from './WindowDragRegion'; +import { useToast } from '../contexts/ToastContext'; const days = [ { value: 0, label: 'Su' }, @@ -55,7 +56,7 @@ export default function SchedulerView() { const schedulerRunning = useSettingsStore(state => state.schedulerRunning); const setScheduler = useSettingsStore(state => state.setScheduler); const [draft, setDraft] = useState(savedSettings); - const [toast, setToast] = useState(''); + const { addToast } = useToast(); const [permissionMessage, setPermissionMessage] = useState(''); const [automationPermissionGranted, setAutomationPermissionGranted] = useState(null); const isMac = navigator.userAgent.includes('Mac'); @@ -64,11 +65,6 @@ export default function SchedulerView() { setDraft(savedSettings); }, [savedSettings]); - useEffect(() => { - if (!toast) return; - const timeout = window.setTimeout(() => setToast(''), 2200); - return () => window.clearTimeout(timeout); - }, [toast]); const nextRun = useMemo(() => nextScheduledRun(draft), [draft]); @@ -94,23 +90,23 @@ export default function SchedulerView() { }; setScheduler(normalized); setDraft(normalized); - setToast('Scheduler settings saved'); + addToast({ message: 'Scheduler settings saved', variant: 'success' }); }; const runNow = async () => { const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID); if (count > 0) { useSettingsStore.getState().setSchedulerRunning(true); - setToast(`Started ${count} download${count === 1 ? '' : 's'}`); + addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' }); } else { - setToast('No paused or failed downloads to start'); + addToast({ message: 'No paused or failed downloads to start', variant: 'info' }); } }; const pauseNow = async () => { const count = await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID); useSettingsStore.getState().setSchedulerRunning(false); - setToast(count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads'); + addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' }); }; const refreshPermissionStatus = useCallback(async (showMessage = false) => { @@ -324,12 +320,6 @@ export default function SchedulerView() { )} - - {toast && ( -
- {toast} -
- )} ); } diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 2dbd6ef..93b6c52 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -12,6 +12,8 @@ import { import { open } from '@tauri-apps/plugin-dialog'; import { getVersion } from '@tauri-apps/api/app'; import { invokeCommand as invoke } from '../ipc'; + +import { useToast, ToastVariant } from '../contexts/ToastContext'; import type { EngineStatusItem } from '../bindings/EngineStatusItem'; import { WindowDragRegion } from './WindowDragRegion'; import appIcon from '../assets/app-icon.png'; @@ -117,16 +119,9 @@ const [appVersion, setAppVersion] = useState('0.7.3'); const [loginError, setLoginError] = useState(''); // Toast notifications - const [toastMessage, setToastMessage] = useState(''); + const { addToast } = useToast(); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); - useEffect(() => { - if (toastMessage) { - const t = setTimeout(() => setToastMessage(''), 2000); - return () => clearTimeout(t); - } - }, [toastMessage]); - useEffect(() => { getVersion().then(setAppVersion).catch(() => undefined); }, []); @@ -174,8 +169,8 @@ runEngineChecks(false); } }, [settings.activeView, activeTab, runEngineChecks]); - const showToast = (msg: string) => { - setToastMessage(msg); + const showToast = (msg: string, variant: ToastVariant = 'info') => { + addToast({ message: msg, variant }); }; const findEngine = (kind: string) => engineStatus?.find(e => e.kind === kind) ?? null; @@ -234,14 +229,14 @@ runEngineChecks(false); const result = await invoke('check_for_updates'); if (result.type === 'UpToDate') { - showToast(`Firelink ${result.latest_version} is up to date`); + showToast(`Firelink ${result.latest_version} is up to date`, 'success'); } else if (result.type === 'UpdateAvailable') { - showToast(`Firelink ${result.update.version} is available`); + showToast(`Firelink ${result.update.version} is available`, 'info'); } else { - showToast('The update check returned an unexpected response'); + showToast('The update check returned an unexpected response', 'warning'); } } catch (error) { - showToast(`Update check failed: ${String(error)}`); + showToast(`Update check failed: ${String(error)}`, 'error'); } finally { setIsCheckingForUpdates(false); } @@ -291,7 +286,7 @@ runEngineChecks(false); } catch (e) { console.error("Failed to create directories on disk:", e); } - showToast("Base download folder updated"); + showToast("Base download folder updated", 'success'); } } catch (e) { console.error("Failed to browse base path:", e); @@ -324,12 +319,12 @@ runEngineChecks(false); setLoginUser(''); setLoginPass(''); setLoginError(''); - showToast("Added site credential"); + showToast("Added site credential", 'success'); }; const copyToken = () => { navigator.clipboard.writeText(settings.extensionPairingToken); - showToast("Token copied to clipboard!"); + showToast("Token copied to clipboard!", 'success'); }; const activeTabLabel = settingsTabs.find(tab => tab.type === activeTab)?.label ?? 'Downloads'; @@ -357,13 +352,6 @@ runEngineChecks(false);
- {/* Toast Notification */} - {toastMessage && ( -
- {toastMessage} -
- )} - {/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
@@ -754,7 +742,7 @@ runEngineChecks(false);
- {toast && ( -
- {toast} -
- )}
); } diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..9d91683 --- /dev/null +++ b/src/contexts/ToastContext.tsx @@ -0,0 +1,114 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'; + +export type ToastVariant = 'success' | 'info' | 'warning' | 'error'; + +export interface ToastMessage { + id: string; + message: React.ReactNode; + variant?: ToastVariant; + duration?: number; + isActionable?: boolean; +} + +interface ToastContextType { + addToast: (toast: Omit) => void; + removeToast: (id: string) => void; +} + +const ToastContext = createContext(undefined); + +export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((toast: Omit) => { + setToasts(prev => [...prev, { ...toast, id: Math.random().toString(36).substring(2, 9) }]); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, []); + + return ( + + {children} + + + ); +}; + +export const useToast = () => { + const context = useContext(ToastContext); + if (!context) throw new Error('useToast must be used within ToastProvider'); + return context; +}; + +const ToastItem: React.FC<{ toast: ToastMessage; removeToast: (id: string) => void }> = ({ toast, removeToast }) => { + const [isHovered, setIsHovered] = useState(false); + + useEffect(() => { + // Determine duration + let timeoutDuration = toast.duration ?? 5000; + if (timeoutDuration < 5000) timeoutDuration = 5000; // at least 5 seconds + + // Don't auto-dismiss actionable or important errors (unless they specified a duration) + // Actually, "Do not auto-dismiss actionable or important errors" + if (toast.isActionable || (toast.variant === 'error' && !toast.duration)) { + return; + } + + if (isHovered) { + return; + } + + const timer = setTimeout(() => { + removeToast(toast.id); + }, timeoutDuration); + + return () => clearTimeout(timer); + }, [toast, isHovered, removeToast]); + + const role = toast.variant === 'error' ? 'alert' : 'status'; + + // Variant styling + const variantStyles = { + success: 'border-green-500/50 bg-green-500/10 text-green-700 dark:text-green-400', + info: 'border-[hsl(var(--border-modal))] bg-[hsl(var(--surface-overlay))] text-[hsl(var(--text-primary))]', + warning: 'border-yellow-500/50 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400', + error: 'border-red-500/50 bg-red-500/10 text-red-700 dark:text-red-400', + }; + + const style = variantStyles[toast.variant || 'info']; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onFocus={() => setIsHovered(true)} + onBlur={() => setIsHovered(false)} + > +
{toast.message}
+ +
+ ); +}; + +const ToastContainer: React.FC<{ toasts: ToastMessage[]; removeToast: (id: string) => void }> = ({ toasts, removeToast }) => { + return ( +
+ {toasts.map(toast => ( + + ))} +
+ ); +}; diff --git a/src/index.css b/src/index.css index a2698b8..115b778 100644 --- a/src/index.css +++ b/src/index.css @@ -875,15 +875,8 @@ filter: none; } - .app-toast { - border: 1px solid hsl(var(--border-modal)); - border-radius: 8px; - background: hsl(var(--surface-overlay)); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - color: hsl(var(--text-primary)); - box-shadow: 0 4px 12px hsl(var(--shadow-color)); - animation: toast-in 200ms ease-out; + .app-toast-item { + animation: toast-in 300ms cubic-bezier(0.2, 0.8, 0.2, 1); } .app-shell { @@ -1792,8 +1785,8 @@ } @keyframes toast-in { - from { opacity: 0; transform: translate(-50%, 8px); } - to { opacity: 1; transform: translate(-50%, 0); } + from { opacity: 0; transform: translateY(12px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } } @media (prefers-reduced-motion: reduce) { diff --git a/src/main.tsx b/src/main.tsx index de26a20..46024b2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,13 +3,16 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App"; import { ErrorBoundary } from "./ErrorBoundary"; +import { ToastProvider } from "./contexts/ToastContext"; const rootElement = document.getElementById("root"); if (rootElement) { createRoot(rootElement).render( - + + + , );