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 */} setShowDeleteConfirm(true)} - 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, - color: 'var(--danger)', - backgroundColor: 'var(--danger-bg)', - border: '1px solid var(--danger)', - transition: 'opacity var(--transition-fast)', - marginLeft: 'auto', - }} + className="btn btn-danger" + style={{ marginLeft: 'auto' }} > Delete @@ -1208,18 +1157,8 @@ export function ChannelDetail() { refetchContent()} aria-label="Retry" - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 'var(--space-2)', - padding: 'var(--space-2) var(--space-3)', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--danger)', - color: '#fff', - fontSize: 'var(--font-size-sm)', - fontWeight: 600, - flexShrink: 0, - }} + className="btn btn-danger" + style={{ flexShrink: 0 }} > Retry @@ -1374,52 +1313,6 @@ export function ChannelDetail() { ) : null} - {/* Download error toast */} - {downloadContent.isError ? ( - - {downloadContent.error instanceof Error - ? downloadContent.error.message - : 'Failed to enqueue download'} - - ) : null} - - {/* Scan result toast */} - {scanResult ? ( - - {scanResult.message} - - ) : null} - {/* Delete confirmation modal */} setShowDeleteConfirm(false)} disabled={deleteChannel.isPending} - style={{ - padding: 'var(--space-2) var(--space-4)', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--bg-hover)', - color: 'var(--text-secondary)', - fontSize: 'var(--font-size-sm)', - fontWeight: 500, - }} + className="btn btn-ghost" > Cancel {deleteChannel.isPending ? ( diff --git a/src/frontend/src/pages/Channels.tsx b/src/frontend/src/pages/Channels.tsx index db1bab8..98293a8 100644 --- a/src/frontend/src/pages/Channels.tsx +++ b/src/frontend/src/pages/Channels.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Plus, Loader, RefreshCw, Search } from 'lucide-react'; import { useChannels, useScanAllChannels } from '../api/hooks/useChannels'; @@ -9,6 +9,7 @@ import { StatusBadge } from '../components/StatusBadge'; import { ProgressBar } from '../components/ProgressBar'; import { AddChannelModal } from '../components/AddChannelModal'; import { SkeletonChannelsList } from '../components/Skeleton'; +import { useToast } from '../components/Toast'; import type { ChannelWithCounts } from '@shared/types/api'; // ── Helpers ── @@ -31,19 +32,12 @@ function formatRelativeTime(dateStr: string | null): string { export function Channels() { const navigate = useNavigate(); const [showAddModal, setShowAddModal] = useState(false); - const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null); + const { toast } = useToast(); const { data: channels, isLoading, error, refetch } = useChannels(); const scanAll = useScanAllChannels(); const collectAll = useCollectAllMonitored(); - // Auto-dismiss scan result toast after 5 seconds - useEffect(() => { - if (!scanResult) return; - const timer = setTimeout(() => setScanResult(null), 5000); - return () => clearTimeout(timer); - }, [scanResult]); - const handleScanAll = useCallback(() => { scanAll.mutate(undefined, { onSuccess: (result) => { @@ -51,16 +45,13 @@ export function Channels() { if (result.summary.errors > 0) { msg += ` (${result.summary.errors} error${result.summary.errors === 1 ? '' : 's'})`; } - setScanResult({ message: msg, isError: result.summary.errors > 0 }); + toast(msg, result.summary.errors > 0 ? 'error' : 'success'); }, onError: (err) => { - setScanResult({ - message: err instanceof Error ? err.message : 'Scan failed', - isError: true, - }); + toast(err instanceof Error ? err.message : 'Scan failed', 'error'); }, }); - }, [scanAll]); + }, [scanAll, toast]); const handleCollectAll = useCallback(() => { collectAll.mutate(undefined, { @@ -70,16 +61,13 @@ export function Channels() { 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'); }, }); - }, [collectAll]); + }, [collectAll, toast]); const handleRowClick = useCallback( (channel: ChannelWithCounts) => { @@ -209,18 +197,7 @@ export function Channels() { refetch()} aria-label="Retry" - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 'var(--space-2)', - padding: 'var(--space-2) var(--space-3)', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--danger)', - color: '#fff', - fontSize: 'var(--font-size-sm)', - fontWeight: 600, - flexShrink: 0, - }} + className="btn btn-danger" > Retry @@ -256,20 +233,8 @@ export function Channels() { onClick={handleScanAll} disabled={scanAll.isPending} title="Refresh All" - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 'var(--space-2)', - padding: 'var(--space-2) var(--space-4)', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--bg-hover)', - color: 'var(--text-secondary)', - border: '1px solid var(--border)', - fontSize: 'var(--font-size-sm)', - fontWeight: 600, - transition: 'all var(--transition-fast)', - opacity: scanAll.isPending ? 0.6 : 1, - }} + className="btn btn-ghost" + style={{ opacity: scanAll.isPending ? 0.6 : 1 }} > {scanAll.isPending ? ( @@ -283,20 +248,8 @@ export function Channels() { onClick={handleCollectAll} disabled={collectAll.isPending} title="Collect All Monitored" - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 'var(--space-2)', - padding: 'var(--space-2) var(--space-4)', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--bg-hover)', - color: 'var(--text-secondary)', - border: '1px solid var(--border)', - fontSize: 'var(--font-size-sm)', - fontWeight: 600, - transition: 'all var(--transition-fast)', - opacity: collectAll.isPending ? 0.6 : 1, - }} + className="btn btn-ghost" + style={{ opacity: collectAll.isPending ? 0.6 : 1 }} > {collectAll.isPending ? ( @@ -309,21 +262,8 @@ export function Channels() { setShowAddModal(true)} disabled={scanAll.isPending} - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: 'var(--space-2)', - padding: 'var(--space-2) var(--space-4)', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--accent)', - color: 'var(--text-inverse)', - fontSize: 'var(--font-size-sm)', - fontWeight: 600, - transition: 'background-color var(--transition-fast)', - opacity: scanAll.isPending ? 0.6 : 1, - }} - onMouseEnter={(e) => { if (!scanAll.isPending) e.currentTarget.style.backgroundColor = 'var(--accent-hover)'; }} - onMouseLeave={(e) => { if (!scanAll.isPending) e.currentTarget.style.backgroundColor = 'var(--accent)'; }} + className="btn btn-primary" + style={{ opacity: scanAll.isPending ? 0.6 : 1 }} > Add Channel @@ -351,28 +291,6 @@ export function Channels() { {/* Add Channel modal */} setShowAddModal(false)} /> - - {/* Scan result toast */} - {scanResult && ( - - {scanResult.message} - - )} ); } diff --git a/src/frontend/src/styles/global.css b/src/frontend/src/styles/global.css index 437b000..eb1bc1f 100644 --- a/src/frontend/src/styles/global.css +++ b/src/frontend/src/styles/global.css @@ -341,6 +341,17 @@ div:hover > .card-checkbox, } /* ── Responsive ── */ +@keyframes toast-slide-in { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + @media (max-width: 768px) { :root { --sidebar-width: 0px;