From 49ac76c379afe4518b5cbe2ebe9ebfc3563b73ac Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 04:27:36 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Created=20Toast=20notification=20system?= =?UTF-8?q?=20and=20migrated=20Channels.tsx=20+=20Cha=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/components/Toast.tsx" - "src/frontend/src/App.tsx" - "src/frontend/src/pages/Channels.tsx" - "src/frontend/src/pages/ChannelDetail.tsx" - "src/frontend/src/styles/global.css" GSD-Task: S02/T04 --- src/frontend/src/App.tsx | 9 +- src/frontend/src/components/Toast.tsx | 92 +++++++++++ src/frontend/src/pages/ChannelDetail.tsx | 202 +++++------------------ src/frontend/src/pages/Channels.tsx | 114 ++----------- src/frontend/src/styles/global.css | 11 ++ 5 files changed, 164 insertions(+), 264 deletions(-) create mode 100644 src/frontend/src/components/Toast.tsx diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 9c29038..c2a64a7 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { Sidebar } from './components/Sidebar'; +import { ToastProvider } from './components/Toast'; import { Channels } from './pages/Channels'; import { ChannelDetail } from './pages/ChannelDetail'; import { Library } from './pages/Library'; @@ -37,8 +38,10 @@ function AuthenticatedLayout() { export function App() { return ( - - } /> - + + + } /> + + ); } diff --git a/src/frontend/src/components/Toast.tsx b/src/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..06665e5 --- /dev/null +++ b/src/frontend/src/components/Toast.tsx @@ -0,0 +1,92 @@ +import { createContext, useContext, useState, useCallback, useRef } from 'react'; +import type { ReactNode } from 'react'; + +// ── Types ── + +type ToastVariant = 'success' | 'error' | 'info'; + +interface ToastEntry { + id: number; + message: string; + variant: ToastVariant; +} + +interface ToastContextValue { + toast: (message: string, variant?: ToastVariant) => void; +} + +// ── Context ── + +const ToastContext = createContext(null); + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used inside '); + return ctx; +} + +// ── Provider ── + +const TOAST_DURATION = 5000; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const nextId = useRef(0); + + const toast = useCallback((message: string, variant: ToastVariant = 'info') => { + const id = ++nextId.current; + setToasts((prev) => [...prev, { id, message, variant }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, TOAST_DURATION); + }, []); + + const dismiss = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( + + {children} + {/* Toast container */} + {toasts.length > 0 && ( +
+ {toasts.map((t) => ( +
dismiss(t.id)} + > + {t.message} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 478459a..215945c 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -34,6 +34,7 @@ import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton'; import { ContentCard } from '../components/ContentCard'; import { Pagination } from '../components/Pagination'; import { Modal } from '../components/Modal'; +import { useToast } from '../components/Toast'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; import type { ContentItem, MonitoringMode } from '@shared/types/index'; @@ -143,12 +144,12 @@ export function ChannelDetail() { // ── Local state ── const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null); const [scanInProgress, setScanInProgress] = useState(false); const [expandedPlaylists, setExpandedPlaylists] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>(new Set()); const [localCheckInterval, setLocalCheckInterval] = useState(''); const [checkIntervalSaved, setCheckIntervalSaved] = useState(false); + const { toast } = useToast(); // Sync local check interval from channel data useEffect(() => { @@ -157,17 +158,10 @@ export function ChannelDetail() { } }, [channel?.checkInterval]); - // Auto-dismiss scan result toast after 5 seconds - useEffect(() => { - if (!scanResult) return; - const timer = setTimeout(() => setScanResult(null), 5000); - return () => clearTimeout(timer); - }, [scanResult]); - // Poll scan status when a scan is known to be in progress useScanStatus(channelId, scanInProgress, () => { setScanInProgress(false); - setScanResult({ message: 'Scan complete — content refreshed', isError: false }); + toast('Scan complete — content refreshed', 'success'); }); // On mount, check if a scan is already running (e.g. auto-scan after channel creation) @@ -184,6 +178,18 @@ export function ChannelDetail() { return () => { cancelled = true; }; }, [channelId]); + // Surface download errors via toast + useEffect(() => { + if (downloadContent.isError) { + toast( + downloadContent.error instanceof Error + ? downloadContent.error.message + : 'Failed to enqueue download', + 'error', + ); + } + }, [downloadContent.isError, downloadContent.error, toast]); + // ── Handlers ── const handleFormatProfileChange = useCallback( @@ -220,25 +226,22 @@ export function ChannelDetail() { if (result.status === 'already_running') { setScanInProgress(true); } else if (result.status === 'rate_limited') { - setScanResult({ message: 'Rate limited — try again later', isError: false }); + toast('Rate limited — try again later', 'info'); } else if (result.status === 'error') { - setScanResult({ message: 'Scan failed — check server logs', isError: true }); + toast('Scan failed — check server logs', 'error'); } else { // Scan completed synchronously (fast — small channel or cached) const msg = result.newItems > 0 ? `Found ${result.newItems} new item${result.newItems === 1 ? '' : 's'}` : 'No new content'; - setScanResult({ message: msg, isError: false }); + toast(msg, 'success'); } }, onError: (err) => { - setScanResult({ - message: err instanceof Error ? err.message : 'Scan failed', - isError: true, - }); + toast(err instanceof Error ? err.message : 'Scan failed', 'error'); }, }); - }, [scanChannel]); + }, [scanChannel, toast]); const handleCollect = useCallback(() => { collectMonitored.mutate(undefined, { @@ -248,30 +251,24 @@ export function ChannelDetail() { if (result.skipped > 0) parts.push(`${result.skipped} skipped`); if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`); const msg = parts.length > 0 ? parts.join(', ') : 'No items to collect'; - setScanResult({ message: msg, isError: result.errors > 0 }); + toast(msg, result.errors > 0 ? 'error' : 'success'); }, onError: (err) => { - setScanResult({ - message: err instanceof Error ? err.message : 'Collect failed', - isError: true, - }); + toast(err instanceof Error ? err.message : 'Collect failed', 'error'); }, }); - }, [collectMonitored]); + }, [collectMonitored, toast]); const handleRefreshPlaylists = useCallback(() => { refreshPlaylists.mutate(undefined, { onSuccess: () => { - setScanResult({ message: 'Playlists refreshed', isError: false }); + toast('Playlists refreshed', 'success'); }, onError: (err) => { - setScanResult({ - message: err instanceof Error ? err.message : 'Failed to refresh playlists', - isError: true, - }); + toast(err instanceof Error ? err.message : 'Failed to refresh playlists', 'error'); }, }); - }, [refreshPlaylists]); + }, [refreshPlaylists, toast]); const handleDelete = useCallback(() => { deleteChannel.mutate(channelId, { @@ -969,20 +966,8 @@ export function ChannelDetail() { onClick={handleScan} disabled={scanChannel.isPending || scanInProgress} title={scanInProgress ? 'Scan in progress…' : 'Refresh & Scan'} - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 'var(--space-1)', - padding: 'var(--space-2) var(--space-3)', - borderRadius: 'var(--radius-md)', - fontSize: 'var(--font-size-sm)', - fontWeight: 500, - backgroundColor: 'var(--bg-hover)', - color: 'var(--text-secondary)', - border: '1px solid var(--border)', - transition: 'all var(--transition-fast)', - opacity: (scanChannel.isPending || scanInProgress) ? 0.6 : 1, - }} + className="btn btn-ghost" + style={{ opacity: (scanChannel.isPending || scanInProgress) ? 0.6 : 1 }} > {(scanChannel.isPending || scanInProgress) ? ( @@ -997,20 +982,8 @@ export function ChannelDetail() { onClick={handleCollect} disabled={collectMonitored.isPending} title="Collect Monitored" - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 'var(--space-1)', - padding: 'var(--space-2) var(--space-3)', - borderRadius: 'var(--radius-md)', - fontSize: 'var(--font-size-sm)', - fontWeight: 500, - backgroundColor: 'var(--bg-hover)', - color: 'var(--text-secondary)', - border: '1px solid var(--border)', - transition: 'all var(--transition-fast)', - opacity: collectMonitored.isPending ? 0.6 : 1, - }} + className="btn btn-ghost" + style={{ opacity: collectMonitored.isPending ? 0.6 : 1 }} > {collectMonitored.isPending ? ( @@ -1026,20 +999,8 @@ export function ChannelDetail() { onClick={handleRefreshPlaylists} disabled={refreshPlaylists.isPending} title="Refresh Playlists" - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 'var(--space-1)', - padding: 'var(--space-2) var(--space-3)', - borderRadius: 'var(--radius-md)', - fontSize: 'var(--font-size-sm)', - fontWeight: 500, - backgroundColor: 'var(--bg-hover)', - color: 'var(--text-secondary)', - border: '1px solid var(--border)', - transition: 'all var(--transition-fast)', - opacity: refreshPlaylists.isPending ? 0.6 : 1, - }} + className="btn btn-ghost" + style={{ opacity: refreshPlaylists.isPending ? 0.6 : 1 }} > {refreshPlaylists.isPending ? ( @@ -1053,20 +1014,8 @@ export function ChannelDetail() { {/* Delete button */}