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:
parent
538f9ec69b
commit
49ac76c379
5 changed files with 164 additions and 264 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
src/frontend/src/components/Toast.tsx
Normal file
92
src/frontend/src/components/Toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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' }} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue