mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-26 02:27:14 +00:00
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:
+46
-41
@@ -15,10 +15,11 @@ import { isPermissionGranted, requestPermission, sendNotification } from '@tauri
|
|||||||
import SchedulerView from "./components/SchedulerView";
|
import SchedulerView from "./components/SchedulerView";
|
||||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||||
import DiagnosticsView from "./components/DiagnosticsView";
|
import DiagnosticsView from "./components/DiagnosticsView";
|
||||||
|
import { useToast } from "./contexts/ToastContext";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||||
const [pairingTokenChanged, setPairingTokenChanged] = useState(false);
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||||
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
||||||
@@ -41,7 +42,6 @@ function App() {
|
|||||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||||
|
|
||||||
const acknowledgePairingTokenChange = () => {
|
const acknowledgePairingTokenChange = () => {
|
||||||
setPairingTokenChanged(false);
|
|
||||||
invoke('acknowledge_pairing_token_change').catch(error => {
|
invoke('acknowledge_pairing_token_change').catch(error => {
|
||||||
console.error('Failed to acknowledge pairing token migration notice:', 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));
|
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
|
||||||
}, [sidebarWidth]);
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useDownloadStore.getState().initDB();
|
useDownloadStore.getState().initDB();
|
||||||
useSettingsStore.getState().hydratePairingToken()
|
useSettingsStore.getState().hydratePairingToken()
|
||||||
.then(setPairingTokenChanged)
|
.then(changed => {
|
||||||
|
if (changed) {
|
||||||
|
addToast({
|
||||||
|
variant: 'warning',
|
||||||
|
isActionable: true,
|
||||||
|
message: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>Browser extension disconnected because its pairing token changed.</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
|
||||||
|
onClick={() => {
|
||||||
|
const token = useSettingsStore.getState().extensionPairingToken;
|
||||||
|
if (token) {
|
||||||
|
void navigator.clipboard.writeText(token);
|
||||||
|
}
|
||||||
|
acknowledgePairingTokenChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy token
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
|
||||||
|
onClick={() => {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
settings.setActiveSettingsTab('integrations');
|
||||||
|
settings.setActiveView('settings');
|
||||||
|
acknowledgePairingTokenChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Integrations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Failed to hydrate extension pairing token:', error);
|
console.error('Failed to hydrate extension pairing token:', error);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [addToast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
||||||
@@ -303,43 +344,7 @@ function App() {
|
|||||||
<AddDownloadsModal />
|
<AddDownloadsModal />
|
||||||
<PropertiesModal />
|
<PropertiesModal />
|
||||||
<DeleteConfirmationModal />
|
<DeleteConfirmationModal />
|
||||||
{pairingTokenChanged && (
|
|
||||||
<div
|
|
||||||
className="app-toast fixed bottom-5 right-5 z-[80] max-w-[420px] p-4 text-[12px]"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<p className="font-medium">
|
|
||||||
Browser extension disconnected because its pairing token changed.
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="app-button px-3 py-1.5"
|
|
||||||
onClick={() => {
|
|
||||||
const token = useSettingsStore.getState().extensionPairingToken;
|
|
||||||
if (token) {
|
|
||||||
void navigator.clipboard.writeText(token);
|
|
||||||
}
|
|
||||||
acknowledgePairingTokenChange();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy new token
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="app-button px-3 py-1.5"
|
|
||||||
onClick={() => {
|
|
||||||
const settings = useSettingsStore.getState();
|
|
||||||
settings.setActiveSettingsTab('integrations');
|
|
||||||
settings.setActiveView('settings');
|
|
||||||
acknowledgePairingTokenChange();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open Integrations
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { SidebarFilter } from './Sidebar';
|
import { SidebarFilter } from './Sidebar';
|
||||||
import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command } from 'lucide-react';
|
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 }) => {
|
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||||
const { downloads, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
|
const { downloads, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
|
||||||
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
|
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
const isMac = navigator.userAgent.includes('Mac');
|
const isMac = navigator.userAgent.includes('Mac');
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
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 [columnWidths, setColumnWidths] = useState([340, 100, 220, 100, 80, 170]);
|
||||||
const columnMinimums = [0, 58, 92, 58, 48, 112];
|
const columnMinimums = [0, 58, 92, 58, 48, 112];
|
||||||
const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' ');
|
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);
|
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 showInteractionError = (message: string, error: unknown) => {
|
||||||
const detail = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
setInteractionError(`${message}: ${detail}`);
|
addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDownloadPath = async (item: DownloadItem) => {
|
const getDownloadPath = async (item: DownloadItem) => {
|
||||||
@@ -440,12 +436,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
|
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
|
||||||
const days = [
|
const days = [
|
||||||
{ value: 0, label: 'Su' },
|
{ value: 0, label: 'Su' },
|
||||||
@@ -55,7 +56,7 @@ export default function SchedulerView() {
|
|||||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||||
const setScheduler = useSettingsStore(state => state.setScheduler);
|
const setScheduler = useSettingsStore(state => state.setScheduler);
|
||||||
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
|
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
|
||||||
const [toast, setToast] = useState('');
|
const { addToast } = useToast();
|
||||||
const [permissionMessage, setPermissionMessage] = useState('');
|
const [permissionMessage, setPermissionMessage] = useState('');
|
||||||
const [automationPermissionGranted, setAutomationPermissionGranted] = useState<boolean | null>(null);
|
const [automationPermissionGranted, setAutomationPermissionGranted] = useState<boolean | null>(null);
|
||||||
const isMac = navigator.userAgent.includes('Mac');
|
const isMac = navigator.userAgent.includes('Mac');
|
||||||
@@ -64,11 +65,6 @@ export default function SchedulerView() {
|
|||||||
setDraft(savedSettings);
|
setDraft(savedSettings);
|
||||||
}, [savedSettings]);
|
}, [savedSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!toast) return;
|
|
||||||
const timeout = window.setTimeout(() => setToast(''), 2200);
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
|
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
|
||||||
|
|
||||||
@@ -94,23 +90,23 @@ export default function SchedulerView() {
|
|||||||
};
|
};
|
||||||
setScheduler(normalized);
|
setScheduler(normalized);
|
||||||
setDraft(normalized);
|
setDraft(normalized);
|
||||||
setToast('Scheduler settings saved');
|
addToast({ message: 'Scheduler settings saved', variant: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const runNow = async () => {
|
const runNow = async () => {
|
||||||
const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
|
const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
useSettingsStore.getState().setSchedulerRunning(true);
|
useSettingsStore.getState().setSchedulerRunning(true);
|
||||||
setToast(`Started ${count} download${count === 1 ? '' : 's'}`);
|
addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' });
|
||||||
} else {
|
} else {
|
||||||
setToast('No paused or failed downloads to start');
|
addToast({ message: 'No paused or failed downloads to start', variant: 'info' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const pauseNow = async () => {
|
const pauseNow = async () => {
|
||||||
const count = await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
|
const count = await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
|
||||||
useSettingsStore.getState().setSchedulerRunning(false);
|
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) => {
|
const refreshPermissionStatus = useCallback(async (showMessage = false) => {
|
||||||
@@ -324,12 +320,6 @@ export default function SchedulerView() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
|
|
||||||
|
import { useToast, ToastVariant } from '../contexts/ToastContext';
|
||||||
import type { EngineStatusItem } from '../bindings/EngineStatusItem';
|
import type { EngineStatusItem } from '../bindings/EngineStatusItem';
|
||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import appIcon from '../assets/app-icon.png';
|
import appIcon from '../assets/app-icon.png';
|
||||||
@@ -117,16 +119,9 @@ const [appVersion, setAppVersion] = useState('0.7.3');
|
|||||||
const [loginError, setLoginError] = useState('');
|
const [loginError, setLoginError] = useState('');
|
||||||
|
|
||||||
// Toast notifications
|
// Toast notifications
|
||||||
const [toastMessage, setToastMessage] = useState('');
|
const { addToast } = useToast();
|
||||||
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
|
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (toastMessage) {
|
|
||||||
const t = setTimeout(() => setToastMessage(''), 2000);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}
|
|
||||||
}, [toastMessage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVersion().then(setAppVersion).catch(() => undefined);
|
getVersion().then(setAppVersion).catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -174,8 +169,8 @@ runEngineChecks(false);
|
|||||||
}
|
}
|
||||||
}, [settings.activeView, activeTab, runEngineChecks]);
|
}, [settings.activeView, activeTab, runEngineChecks]);
|
||||||
|
|
||||||
const showToast = (msg: string) => {
|
const showToast = (msg: string, variant: ToastVariant = 'info') => {
|
||||||
setToastMessage(msg);
|
addToast({ message: msg, variant });
|
||||||
};
|
};
|
||||||
|
|
||||||
const findEngine = (kind: string) => engineStatus?.find(e => e.kind === kind) ?? null;
|
const findEngine = (kind: string) => engineStatus?.find(e => e.kind === kind) ?? null;
|
||||||
@@ -234,14 +229,14 @@ runEngineChecks(false);
|
|||||||
const result = await invoke('check_for_updates');
|
const result = await invoke('check_for_updates');
|
||||||
|
|
||||||
if (result.type === 'UpToDate') {
|
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') {
|
} else if (result.type === 'UpdateAvailable') {
|
||||||
showToast(`Firelink ${result.update.version} is available`);
|
showToast(`Firelink ${result.update.version} is available`, 'info');
|
||||||
} else {
|
} else {
|
||||||
showToast('The update check returned an unexpected response');
|
showToast('The update check returned an unexpected response', 'warning');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`Update check failed: ${String(error)}`);
|
showToast(`Update check failed: ${String(error)}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsCheckingForUpdates(false);
|
setIsCheckingForUpdates(false);
|
||||||
}
|
}
|
||||||
@@ -291,7 +286,7 @@ runEngineChecks(false);
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to create directories on disk:", e);
|
console.error("Failed to create directories on disk:", e);
|
||||||
}
|
}
|
||||||
showToast("Base download folder updated");
|
showToast("Base download folder updated", 'success');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to browse base path:", e);
|
console.error("Failed to browse base path:", e);
|
||||||
@@ -324,12 +319,12 @@ runEngineChecks(false);
|
|||||||
setLoginUser('');
|
setLoginUser('');
|
||||||
setLoginPass('');
|
setLoginPass('');
|
||||||
setLoginError('');
|
setLoginError('');
|
||||||
showToast("Added site credential");
|
showToast("Added site credential", 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToken = () => {
|
const copyToken = () => {
|
||||||
navigator.clipboard.writeText(settings.extensionPairingToken);
|
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';
|
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">
|
<div className="settings-view flex-1 flex flex-col relative h-full overflow-hidden">
|
||||||
<WindowDragRegion />
|
<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 */}
|
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
|
||||||
<div className="settings-toolbar">
|
<div className="settings-toolbar">
|
||||||
<div className="settings-tab-strip flex items-stretch gap-1">
|
<div className="settings-tab-strip flex items-stretch gap-1">
|
||||||
@@ -754,7 +742,7 @@ runEngineChecks(false);
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
settings.resetCategoryLocations();
|
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"
|
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);
|
console.warn("Could not delete password from keychain:", e);
|
||||||
}
|
}
|
||||||
settings.removeSiteLogin(login.id);
|
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"
|
className="p-1.5 hover:bg-item-hover rounded-md text-text-muted hover:text-red-500"
|
||||||
title="Delete credential"
|
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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
settings.regeneratePairingToken();
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Gauge, Save, Zap } from 'lucide-react';
|
import { Gauge, Save, Zap } from 'lucide-react';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
|
||||||
type SpeedUnit = 'KB/s' | 'MB/s';
|
type SpeedUnit = 'KB/s' | 'MB/s';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export default function SpeedLimiterView() {
|
|||||||
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
|
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
|
||||||
const [value, setValue] = useState(initial.value);
|
const [value, setValue] = useState(initial.value);
|
||||||
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
|
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
|
||||||
const [toast, setToast] = useState('');
|
const { addToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
|
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
|
||||||
@@ -34,18 +35,16 @@ export default function SpeedLimiterView() {
|
|||||||
setUnit(parsed.unit);
|
setUnit(parsed.unit);
|
||||||
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
|
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!toast) return;
|
|
||||||
const timeout = window.setTimeout(() => setToast(''), 2200);
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : 10_485_760));
|
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));
|
const valueKiB = Math.min(10_485_760, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
|
||||||
setLastCustomSpeedLimitKiB(valueKiB);
|
setLastCustomSpeedLimitKiB(valueKiB);
|
||||||
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
|
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) => {
|
const preset = (presetValue: number) => {
|
||||||
@@ -129,11 +128,6 @@ export default function SpeedLimiterView() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ToastMessage, 'id'>) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
|
||||||
|
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 (
|
||||||
|
<ToastContext.Provider value={{ addToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role={role}
|
||||||
|
className={`app-toast-item pointer-events-auto flex items-center justify-between gap-4 rounded-lg border p-4 shadow-lg backdrop-blur-[20px] transition-all duration-300 motion-reduce:transition-none motion-reduce:animation-none text-[12px] ${style}`}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
onFocus={() => setIsHovered(true)}
|
||||||
|
onBlur={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{toast.message}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeToast(toast.id)}
|
||||||
|
className="shrink-0 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToastContainer: React.FC<{ toasts: ToastMessage[]; removeToast: (id: string) => void }> = ({ toasts, removeToast }) => {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-5 right-5 z-[100] flex max-w-[420px] flex-col gap-2 pointer-events-none">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<ToastItem key={toast.id} toast={toast} removeToast={removeToast} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
-11
@@ -875,15 +875,8 @@
|
|||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-toast {
|
.app-toast-item {
|
||||||
border: 1px solid hsl(var(--border-modal));
|
animation: toast-in 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
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-shell {
|
.app-shell {
|
||||||
@@ -1792,8 +1785,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes toast-in {
|
@keyframes toast-in {
|
||||||
from { opacity: 0; transform: translate(-50%, 8px); }
|
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||||
to { opacity: 1; transform: translate(-50%, 0); }
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
+4
-1
@@ -3,13 +3,16 @@ import { createRoot } from "react-dom/client";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
|
import { ToastProvider } from "./contexts/ToastContext";
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (rootElement) {
|
if (rootElement) {
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user