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 { 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 (
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/*" element={<AuthenticatedLayout />} />
|
||||
</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 { 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' }} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue