refactor(ui): unify and modernize in-app toast notifications

- Created root-level ToastProvider and useToast hook
- Replaced scattered ad-hoc toast states with unified provider
- Updated Settings, Scheduler, Speed Limiter, App, and DownloadTable to use ToastContext
- Added variant support (success, info, warning, error) with distinct styling
- Refined toast animations and styling for modern aesthetics
- Adjusted auto-dismiss behavior to ignore actionable or important errors
This commit is contained in:
NimBold
2026-06-20 19:28:29 +03:30
parent 038f31b988
commit 2cc00e433e
8 changed files with 199 additions and 122 deletions
+4 -14
View File
@@ -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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ filter }) => {
</div>
)}
{interactionError && (
<div className="app-toast fixed bottom-5 left-1/2 z-50 -translate-x-1/2 px-4 py-2 text-[12px]">
{interactionError}
</div>
)}
</div>
);
};
+6 -16
View File
@@ -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<SchedulerSettings>(savedSettings);
const [toast, setToast] = useState('');
const { addToast } = useToast();
const [permissionMessage, setPermissionMessage] = useState('');
const [automationPermissionGranted, setAutomationPermissionGranted] = useState<boolean | null>(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() {
</section>
)}
</div>
{toast && (
<div className="app-toast pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 px-4 py-2 text-[12px] font-medium">
{toast}
</div>
)}
</div>
);
}
+15 -27
View File
@@ -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);
<div className="settings-view flex-1 flex flex-col relative h-full overflow-hidden">
<WindowDragRegion />
{/* Toast Notification */}
{toastMessage && (
<div className="app-toast absolute top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 text-[12px] font-medium">
{toastMessage}
</div>
)}
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
<div className="settings-toolbar">
<div className="settings-tab-strip flex items-stretch gap-1">
@@ -754,7 +742,7 @@ runEngineChecks(false);
<button
onClick={() => {
settings.resetCategoryLocations();
showToast("Reset category locations to default");
showToast("Reset category locations to default", 'success');
}}
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1"
>
@@ -789,7 +777,7 @@ runEngineChecks(false);
console.warn("Could not delete password from keychain:", e);
}
settings.removeSiteLogin(login.id);
showToast("Deleted credential");
showToast("Deleted credential", 'success');
}}
className="p-1.5 hover:bg-item-hover rounded-md text-text-muted hover:text-red-500"
title="Delete credential"
@@ -993,7 +981,7 @@ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-bg-modal hover:bg
<button
onClick={() => {
settings.regeneratePairingToken();
showToast("Pairing token regenerated");
showToast("Pairing token regenerated", 'success');
}}
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
>
+6 -12
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Gauge, Save, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
type SpeedUnit = 'KB/s' | 'MB/s';
@@ -25,7 +26,7 @@ export default function SpeedLimiterView() {
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
const [value, setValue] = useState(initial.value);
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
const [toast, setToast] = useState('');
const { addToast } = useToast();
useEffect(() => {
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
@@ -34,18 +35,16 @@ export default function SpeedLimiterView() {
setUnit(parsed.unit);
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
useEffect(() => {
if (!toast) return;
const timeout = window.setTimeout(() => setToast(''), 2200);
return () => window.clearTimeout(timeout);
}, [toast]);
const save = () => {
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : 10_485_760));
const valueKiB = Math.min(10_485_760, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
setLastCustomSpeedLimitKiB(valueKiB);
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
setToast(enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled');
addToast({
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
variant: 'success'
});
};
const preset = (presetValue: number) => {
@@ -129,11 +128,6 @@ export default function SpeedLimiterView() {
</section>
</div>
{toast && (
<div className="app-toast pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 px-4 py-2 text-[12px] font-medium">
{toast}
</div>
)}
</div>
);
}