feat: Created Toast notification system and migrated Channels.tsx + Cha…

- "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
This commit is contained in:
jlightner 2026-04-03 04:27:36 +00:00
parent 538f9ec69b
commit 49ac76c379
5 changed files with 164 additions and 264 deletions

View file

@ -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 (
<Routes>
<Route path="/*" element={<AuthenticatedLayout />} />
</Routes>
<ToastProvider>
<Routes>
<Route path="/*" element={<AuthenticatedLayout />} />
</Routes>
</ToastProvider>
);
}

View file

@ -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<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
return ctx;
}
// ── Provider ──
const TOAST_DURATION = 5000;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastEntry[]>([]);
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 (
<ToastContext.Provider value={{ toast }}>
{children}
{/* Toast container */}
{toasts.length > 0 && (
<div
aria-live="polite"
style={{
position: 'fixed',
bottom: 'var(--space-6)',
right: 'var(--space-6)',
zIndex: 1100,
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-2)',
pointerEvents: 'none',
}}
>
{toasts.map((t) => (
<div
key={t.id}
role={t.variant === 'error' ? 'alert' : 'status'}
className="toast-enter"
style={{
padding: 'var(--space-3) var(--space-4)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
boxShadow: 'var(--shadow-lg)',
pointerEvents: 'auto',
cursor: 'pointer',
backgroundColor: t.variant === 'error' ? 'var(--danger-bg)' : t.variant === 'success' ? 'var(--success-bg)' : 'var(--bg-card)',
border: `1px solid ${t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--border)'}`,
color: t.variant === 'error' ? 'var(--danger)' : t.variant === 'success' ? 'var(--success)' : 'var(--text-primary)',
animation: 'toast-slide-in 0.25s ease-out',
}}
onClick={() => dismiss(t.id)}
>
{t.message}
</div>
))}
</div>
)}
</ToastContext.Provider>
);
}

View file

@ -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<Set<number | 'uncategorized'>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
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) ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
@ -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 ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
@ -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 ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
@ -1053,20 +1014,8 @@ export function ChannelDetail() {
{/* Delete button */}
<button
onClick={() => 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' }}
>
<Trash2 size={14} />
Delete
@ -1208,18 +1157,8 @@ export function ChannelDetail() {
<button
onClick={() => 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 }}
>
<RefreshCw size={14} />
Retry
@ -1374,52 +1313,6 @@ export function ChannelDetail() {
</div>
) : null}
{/* Download error toast */}
{downloadContent.isError ? (
<div
role="alert"
style={{
position: 'fixed',
bottom: 'var(--space-6)',
right: 'var(--space-6)',
padding: 'var(--space-3) var(--space-4)',
backgroundColor: 'var(--danger-bg)',
border: '1px solid var(--danger)',
borderRadius: 'var(--radius-md)',
color: 'var(--danger)',
fontSize: 'var(--font-size-sm)',
boxShadow: 'var(--shadow-lg)',
zIndex: 1001,
}}
>
{downloadContent.error instanceof Error
? downloadContent.error.message
: 'Failed to enqueue download'}
</div>
) : null}
{/* Scan result toast */}
{scanResult ? (
<div
role="status"
style={{
position: 'fixed',
bottom: downloadContent.isError ? 'calc(var(--space-6) + 52px)' : 'var(--space-6)',
right: 'var(--space-6)',
padding: 'var(--space-3) var(--space-4)',
backgroundColor: scanResult.isError ? 'var(--danger-bg)' : 'var(--success-bg)',
border: `1px solid ${scanResult.isError ? 'var(--danger)' : 'var(--success)'}`,
borderRadius: 'var(--radius-md)',
color: scanResult.isError ? 'var(--danger)' : 'var(--success)',
fontSize: 'var(--font-size-sm)',
boxShadow: 'var(--shadow-lg)',
zIndex: 1001,
}}
>
{scanResult.message}
</div>
) : null}
{/* Delete confirmation modal */}
<Modal
title="Delete Channel"
@ -1453,32 +1346,15 @@ export function ChannelDetail() {
<button
onClick={() => 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
</button>
<button
onClick={handleDelete}
disabled={deleteChannel.isPending}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--danger)',
color: '#fff',
fontSize: 'var(--font-size-sm)',
fontWeight: 600,
opacity: deleteChannel.isPending ? 0.6 : 1,
}}
className="btn btn-danger"
style={{ opacity: deleteChannel.isPending ? 0.6 : 1 }}
>
{deleteChannel.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />

View file

@ -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() {
<button
onClick={() => 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"
>
<RefreshCw size={14} />
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 ? (
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
@ -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 ? (
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
@ -309,21 +262,8 @@ export function Channels() {
<button
onClick={() => 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 }}
>
<Plus size={16} />
Add Channel
@ -351,28 +291,6 @@ export function Channels() {
{/* Add Channel modal */}
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} />
{/* Scan result toast */}
{scanResult && (
<div
role="status"
style={{
position: 'fixed',
bottom: 'var(--space-6)',
right: 'var(--space-6)',
padding: 'var(--space-3) var(--space-4)',
backgroundColor: scanResult.isError ? 'var(--danger-bg)' : 'var(--success-bg)',
border: `1px solid ${scanResult.isError ? 'var(--danger)' : 'var(--success)'}`,
borderRadius: 'var(--radius-md)',
color: scanResult.isError ? 'var(--danger)' : 'var(--success)',
fontSize: 'var(--font-size-sm)',
boxShadow: 'var(--shadow-lg)',
zIndex: 1001,
}}
>
{scanResult.message}
</div>
)}
</div>
);
}

View file

@ -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;