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 { Routes, Route, Navigate } from 'react-router-dom';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { ToastProvider } from './components/Toast';
import { Channels } from './pages/Channels'; import { Channels } from './pages/Channels';
import { ChannelDetail } from './pages/ChannelDetail'; import { ChannelDetail } from './pages/ChannelDetail';
import { Library } from './pages/Library'; import { Library } from './pages/Library';
@ -37,8 +38,10 @@ function AuthenticatedLayout() {
export function App() { export function App() {
return ( return (
<ToastProvider>
<Routes> <Routes>
<Route path="/*" element={<AuthenticatedLayout />} /> <Route path="/*" element={<AuthenticatedLayout />} />
</Routes> </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 { ContentCard } from '../components/ContentCard';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { Modal } from '../components/Modal'; import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast';
import { useDownloadProgress } from '../contexts/DownloadProgressContext'; import { useDownloadProgress } from '../contexts/DownloadProgressContext';
import type { ContentItem, MonitoringMode } from '@shared/types/index'; import type { ContentItem, MonitoringMode } from '@shared/types/index';
@ -143,12 +144,12 @@ export function ChannelDetail() {
// ── Local state ── // ── Local state ──
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [scanResult, setScanResult] = useState<{ message: string; isError: boolean } | null>(null);
const [scanInProgress, setScanInProgress] = useState(false); const [scanInProgress, setScanInProgress] = useState(false);
const [expandedPlaylists, setExpandedPlaylists] = useState<Set<number | 'uncategorized'>>(new Set()); const [expandedPlaylists, setExpandedPlaylists] = useState<Set<number | 'uncategorized'>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>(''); const [localCheckInterval, setLocalCheckInterval] = useState<number | ''>('');
const [checkIntervalSaved, setCheckIntervalSaved] = useState(false); const [checkIntervalSaved, setCheckIntervalSaved] = useState(false);
const { toast } = useToast();
// Sync local check interval from channel data // Sync local check interval from channel data
useEffect(() => { useEffect(() => {
@ -157,17 +158,10 @@ export function ChannelDetail() {
} }
}, [channel?.checkInterval]); }, [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 // Poll scan status when a scan is known to be in progress
useScanStatus(channelId, scanInProgress, () => { useScanStatus(channelId, scanInProgress, () => {
setScanInProgress(false); 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) // 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; }; return () => { cancelled = true; };
}, [channelId]); }, [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 ── // ── Handlers ──
const handleFormatProfileChange = useCallback( const handleFormatProfileChange = useCallback(
@ -220,25 +226,22 @@ export function ChannelDetail() {
if (result.status === 'already_running') { if (result.status === 'already_running') {
setScanInProgress(true); setScanInProgress(true);
} else if (result.status === 'rate_limited') { } 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') { } else if (result.status === 'error') {
setScanResult({ message: 'Scan failed — check server logs', isError: true }); toast('Scan failed — check server logs', 'error');
} else { } else {
// Scan completed synchronously (fast — small channel or cached) // Scan completed synchronously (fast — small channel or cached)
const msg = result.newItems > 0 const msg = result.newItems > 0
? `Found ${result.newItems} new item${result.newItems === 1 ? '' : 's'}` ? `Found ${result.newItems} new item${result.newItems === 1 ? '' : 's'}`
: 'No new content'; : 'No new content';
setScanResult({ message: msg, isError: false }); toast(msg, 'success');
} }
}, },
onError: (err) => { onError: (err) => {
setScanResult({ toast(err instanceof Error ? err.message : 'Scan failed', 'error');
message: err instanceof Error ? err.message : 'Scan failed',
isError: true,
});
}, },
}); });
}, [scanChannel]); }, [scanChannel, toast]);
const handleCollect = useCallback(() => { const handleCollect = useCallback(() => {
collectMonitored.mutate(undefined, { collectMonitored.mutate(undefined, {
@ -248,30 +251,24 @@ export function ChannelDetail() {
if (result.skipped > 0) parts.push(`${result.skipped} skipped`); if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`); if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`);
const msg = parts.length > 0 ? parts.join(', ') : 'No items to collect'; 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) => { onError: (err) => {
setScanResult({ toast(err instanceof Error ? err.message : 'Collect failed', 'error');
message: err instanceof Error ? err.message : 'Collect failed',
isError: true,
});
}, },
}); });
}, [collectMonitored]); }, [collectMonitored, toast]);
const handleRefreshPlaylists = useCallback(() => { const handleRefreshPlaylists = useCallback(() => {
refreshPlaylists.mutate(undefined, { refreshPlaylists.mutate(undefined, {
onSuccess: () => { onSuccess: () => {
setScanResult({ message: 'Playlists refreshed', isError: false }); toast('Playlists refreshed', 'success');
}, },
onError: (err) => { onError: (err) => {
setScanResult({ toast(err instanceof Error ? err.message : 'Failed to refresh playlists', 'error');
message: err instanceof Error ? err.message : 'Failed to refresh playlists',
isError: true,
});
}, },
}); });
}, [refreshPlaylists]); }, [refreshPlaylists, toast]);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
deleteChannel.mutate(channelId, { deleteChannel.mutate(channelId, {
@ -969,20 +966,8 @@ export function ChannelDetail() {
onClick={handleScan} onClick={handleScan}
disabled={scanChannel.isPending || scanInProgress} disabled={scanChannel.isPending || scanInProgress}
title={scanInProgress ? 'Scan in progress…' : 'Refresh & Scan'} title={scanInProgress ? 'Scan in progress…' : 'Refresh & Scan'}
style={{ className="btn btn-ghost"
display: 'inline-flex', style={{ opacity: (scanChannel.isPending || scanInProgress) ? 0.6 : 1 }}
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,
}}
> >
{(scanChannel.isPending || scanInProgress) ? ( {(scanChannel.isPending || scanInProgress) ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
@ -997,20 +982,8 @@ export function ChannelDetail() {
onClick={handleCollect} onClick={handleCollect}
disabled={collectMonitored.isPending} disabled={collectMonitored.isPending}
title="Collect Monitored" title="Collect Monitored"
style={{ className="btn btn-ghost"
display: 'inline-flex', style={{ opacity: collectMonitored.isPending ? 0.6 : 1 }}
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,
}}
> >
{collectMonitored.isPending ? ( {collectMonitored.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
@ -1026,20 +999,8 @@ export function ChannelDetail() {
onClick={handleRefreshPlaylists} onClick={handleRefreshPlaylists}
disabled={refreshPlaylists.isPending} disabled={refreshPlaylists.isPending}
title="Refresh Playlists" title="Refresh Playlists"
style={{ className="btn btn-ghost"
display: 'inline-flex', style={{ opacity: refreshPlaylists.isPending ? 0.6 : 1 }}
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,
}}
> >
{refreshPlaylists.isPending ? ( {refreshPlaylists.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
@ -1053,20 +1014,8 @@ export function ChannelDetail() {
{/* Delete button */} {/* Delete button */}
<button <button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
style={{ className="btn btn-danger"
display: 'inline-flex', style={{ marginLeft: 'auto' }}
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',
}}
> >
<Trash2 size={14} /> <Trash2 size={14} />
Delete Delete
@ -1208,18 +1157,8 @@ export function ChannelDetail() {
<button <button
onClick={() => refetchContent()} onClick={() => refetchContent()}
aria-label="Retry" aria-label="Retry"
style={{ className="btn btn-danger"
display: 'inline-flex', style={{ flexShrink: 0 }}
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,
}}
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
Retry Retry
@ -1374,52 +1313,6 @@ export function ChannelDetail() {
</div> </div>
) : null} ) : 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 */} {/* Delete confirmation modal */}
<Modal <Modal
title="Delete Channel" title="Delete Channel"
@ -1453,32 +1346,15 @@ export function ChannelDetail() {
<button <button
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
disabled={deleteChannel.isPending} disabled={deleteChannel.isPending}
style={{ className="btn btn-ghost"
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,
}}
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={deleteChannel.isPending} disabled={deleteChannel.isPending}
style={{ className="btn btn-danger"
display: 'inline-flex', style={{ opacity: deleteChannel.isPending ? 0.6 : 1 }}
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,
}}
> >
{deleteChannel.isPending ? ( {deleteChannel.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> <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 { useNavigate } from 'react-router-dom';
import { Plus, Loader, RefreshCw, Search } from 'lucide-react'; import { Plus, Loader, RefreshCw, Search } from 'lucide-react';
import { useChannels, useScanAllChannels } from '../api/hooks/useChannels'; import { useChannels, useScanAllChannels } from '../api/hooks/useChannels';
@ -9,6 +9,7 @@ import { StatusBadge } from '../components/StatusBadge';
import { ProgressBar } from '../components/ProgressBar'; import { ProgressBar } from '../components/ProgressBar';
import { AddChannelModal } from '../components/AddChannelModal'; import { AddChannelModal } from '../components/AddChannelModal';
import { SkeletonChannelsList } from '../components/Skeleton'; import { SkeletonChannelsList } from '../components/Skeleton';
import { useToast } from '../components/Toast';
import type { ChannelWithCounts } from '@shared/types/api'; import type { ChannelWithCounts } from '@shared/types/api';
// ── Helpers ── // ── Helpers ──
@ -31,19 +32,12 @@ function formatRelativeTime(dateStr: string | null): string {
export function Channels() { export function Channels() {
const navigate = useNavigate(); const navigate = useNavigate();
const [showAddModal, setShowAddModal] = useState(false); 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 { data: channels, isLoading, error, refetch } = useChannels();
const scanAll = useScanAllChannels(); const scanAll = useScanAllChannels();
const collectAll = useCollectAllMonitored(); 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(() => { const handleScanAll = useCallback(() => {
scanAll.mutate(undefined, { scanAll.mutate(undefined, {
onSuccess: (result) => { onSuccess: (result) => {
@ -51,16 +45,13 @@ export function Channels() {
if (result.summary.errors > 0) { if (result.summary.errors > 0) {
msg += ` (${result.summary.errors} error${result.summary.errors === 1 ? '' : 's'})`; 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) => { onError: (err) => {
setScanResult({ toast(err instanceof Error ? err.message : 'Scan failed', 'error');
message: err instanceof Error ? err.message : 'Scan failed',
isError: true,
});
}, },
}); });
}, [scanAll]); }, [scanAll, toast]);
const handleCollectAll = useCallback(() => { const handleCollectAll = useCallback(() => {
collectAll.mutate(undefined, { collectAll.mutate(undefined, {
@ -70,16 +61,13 @@ export function Channels() {
if (result.skipped > 0) parts.push(`${result.skipped} skipped`); if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`); if (result.errors > 0) parts.push(`${result.errors} error${result.errors === 1 ? '' : 's'}`);
const msg = parts.length > 0 ? parts.join(', ') : 'No items to collect'; 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) => { onError: (err) => {
setScanResult({ toast(err instanceof Error ? err.message : 'Collect failed', 'error');
message: err instanceof Error ? err.message : 'Collect failed',
isError: true,
});
}, },
}); });
}, [collectAll]); }, [collectAll, toast]);
const handleRowClick = useCallback( const handleRowClick = useCallback(
(channel: ChannelWithCounts) => { (channel: ChannelWithCounts) => {
@ -209,18 +197,7 @@ export function Channels() {
<button <button
onClick={() => refetch()} onClick={() => refetch()}
aria-label="Retry" aria-label="Retry"
style={{ className="btn btn-danger"
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,
}}
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
Retry Retry
@ -256,20 +233,8 @@ export function Channels() {
onClick={handleScanAll} onClick={handleScanAll}
disabled={scanAll.isPending} disabled={scanAll.isPending}
title="Refresh All" title="Refresh All"
style={{ className="btn btn-ghost"
display: 'inline-flex', style={{ opacity: scanAll.isPending ? 0.6 : 1 }}
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,
}}
> >
{scanAll.isPending ? ( {scanAll.isPending ? (
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} /> <Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
@ -283,20 +248,8 @@ export function Channels() {
onClick={handleCollectAll} onClick={handleCollectAll}
disabled={collectAll.isPending} disabled={collectAll.isPending}
title="Collect All Monitored" title="Collect All Monitored"
style={{ className="btn btn-ghost"
display: 'inline-flex', style={{ opacity: collectAll.isPending ? 0.6 : 1 }}
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,
}}
> >
{collectAll.isPending ? ( {collectAll.isPending ? (
<Loader size={16} style={{ animation: 'spin 1s linear infinite' }} /> <Loader size={16} style={{ animation: 'spin 1s linear infinite' }} />
@ -309,21 +262,8 @@ export function Channels() {
<button <button
onClick={() => setShowAddModal(true)} onClick={() => setShowAddModal(true)}
disabled={scanAll.isPending} disabled={scanAll.isPending}
style={{ className="btn btn-primary"
display: 'inline-flex', style={{ opacity: scanAll.isPending ? 0.6 : 1 }}
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)'; }}
> >
<Plus size={16} /> <Plus size={16} />
Add Channel Add Channel
@ -351,28 +291,6 @@ export function Channels() {
{/* Add Channel modal */} {/* Add Channel modal */}
<AddChannelModal open={showAddModal} onClose={() => setShowAddModal(false)} /> <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> </div>
); );
} }

View file

@ -341,6 +341,17 @@ div:hover > .card-checkbox,
} }
/* ── Responsive ── */ /* ── Responsive ── */
@keyframes toast-slide-in {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
:root { :root {
--sidebar-width: 0px; --sidebar-width: 0px;