feat: Reordered Add Channel modal fields to URL → Monitoring Mode → For…

- "src/frontend/src/components/AddChannelModal.tsx"
- "src/frontend/src/api/hooks/useChannels.ts"

GSD-Task: S02/T01
This commit is contained in:
jlightner 2026-04-03 06:34:20 +00:00
parent 6a5402ce8d
commit ab7ab3634b
4 changed files with 237 additions and 199 deletions

View file

@ -39,8 +39,6 @@ interface CreateChannelInput {
monitoringEnabled?: boolean; monitoringEnabled?: boolean;
monitoringMode?: string; monitoringMode?: string;
formatProfileId?: number; formatProfileId?: number;
grabAll?: boolean;
grabAllOrder?: 'newest' | 'oldest';
} }
/** Create a new channel by URL (resolves metadata via backend). */ /** Create a new channel by URL (resolves metadata via backend). */

View file

@ -51,8 +51,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
const [checkInterval, setCheckInterval] = useState(''); const [checkInterval, setCheckInterval] = useState('');
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined); const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
const [monitoringMode, setMonitoringMode] = useState<string>('all'); const [monitoringMode, setMonitoringMode] = useState<string>('all');
const [grabAll, setGrabAll] = useState(false);
const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest');
const createChannel = useCreateChannel(); const createChannel = useCreateChannel();
const { data: platformSettingsList } = usePlatformSettings(); const { data: platformSettingsList } = usePlatformSettings();
@ -82,16 +80,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
if (settings.defaultMonitoringMode) { if (settings.defaultMonitoringMode) {
setMonitoringMode(settings.defaultMonitoringMode); setMonitoringMode(settings.defaultMonitoringMode);
} }
// Pre-fill grab-all defaults for YouTube
if (detectedPlatform === 'youtube') {
if (settings.grabAllEnabled) {
setGrabAll(true);
}
if (settings.grabAllOrder) {
setGrabAllOrder(settings.grabAllOrder);
}
}
}, [detectedPlatform, platformSettingsList]); // eslint-disable-line react-hooks/exhaustive-deps }, [detectedPlatform, platformSettingsList]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@ -104,8 +92,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined, checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
monitoringMode, monitoringMode,
formatProfileId: formatProfileId ?? undefined, formatProfileId: formatProfileId ?? undefined,
grabAll: detectedPlatform === 'youtube' ? grabAll : undefined,
grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined,
}, },
{ {
onSuccess: (newChannel) => { onSuccess: (newChannel) => {
@ -128,8 +114,6 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
setCheckInterval(''); setCheckInterval('');
setFormatProfileId(undefined); setFormatProfileId(undefined);
setMonitoringMode('all'); setMonitoringMode('all');
setGrabAll(false);
setGrabAllOrder('newest');
createChannel.reset(); createChannel.reset();
}; };
@ -142,7 +126,29 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
return ( return (
<Modal title="Add Channel" open={open} onClose={handleClose}> <Modal title="Add Channel" open={open} onClose={handleClose}>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} style={{ position: 'relative' }}>
{/* Loading overlay */}
{createChannel.isPending && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 10,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-3)',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 'var(--radius-md)',
}}
>
<Loader size={28} style={{ animation: 'spin 1s linear infinite', color: 'var(--accent)' }} />
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 500, color: 'var(--text-primary)' }}>
Resolving channel
</span>
</div>
)}
{/* URL input */} {/* URL input */}
<div style={{ marginBottom: 'var(--space-4)' }}> <div style={{ marginBottom: 'var(--space-4)' }}>
<label <label
@ -188,31 +194,34 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
)} )}
</div> </div>
{/* Check interval (optional) */} {/* Monitoring Mode — shown when platform detected */}
<div style={{ marginBottom: 'var(--space-4)' }}> {detectedPlatform && (
<label <div style={{ marginBottom: 'var(--space-4)' }}>
htmlFor="check-interval" <label
style={{ htmlFor="monitoring-mode"
display: 'block', style={{
marginBottom: 'var(--space-1)', display: 'block',
fontSize: 'var(--font-size-sm)', marginBottom: 'var(--space-1)',
fontWeight: 500, fontSize: 'var(--font-size-sm)',
color: 'var(--text-secondary)', fontWeight: 500,
}} color: 'var(--text-secondary)',
> }}
Check Interval (minutes) >
</label> Monitoring Mode
<input </label>
id="check-interval" <select
type="number" id="monitoring-mode"
min={1} value={monitoringMode}
value={checkInterval} onChange={(e) => setMonitoringMode(e.target.value)}
onChange={(e) => setCheckInterval(e.target.value)} disabled={createChannel.isPending}
placeholder="360 (default: 6 hours)" style={{ width: '100%' }}
disabled={createChannel.isPending} >
style={{ width: '100%' }} <option value="all">Monitor All</option>
/> <option value="future">Future Only</option>
</div> <option value="none">None</option>
</select>
</div>
)}
{/* Format Profile (optional, shown when platform detected) */} {/* Format Profile (optional, shown when platform detected) */}
{detectedPlatform && formatProfiles && formatProfiles.length > 0 && ( {detectedPlatform && formatProfiles && formatProfiles.length > 0 && (
@ -248,105 +257,31 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
</div> </div>
)} )}
{/* Monitoring Mode — shown when platform detected */} {/* Check interval (optional) */}
{detectedPlatform && ( <div style={{ marginBottom: 'var(--space-4)' }}>
<div style={{ marginBottom: 'var(--space-4)' }}> <label
<label htmlFor="check-interval"
htmlFor="monitoring-mode" style={{
style={{ display: 'block',
display: 'block', marginBottom: 'var(--space-1)',
marginBottom: 'var(--space-1)', fontSize: 'var(--font-size-sm)',
fontSize: 'var(--font-size-sm)', fontWeight: 500,
fontWeight: 500, color: 'var(--text-secondary)',
color: 'var(--text-secondary)', }}
}} >
> Check Interval (minutes)
Monitoring Mode </label>
</label> <input
<select id="check-interval"
id="monitoring-mode" type="number"
value={monitoringMode} min={1}
onChange={(e) => setMonitoringMode(e.target.value)} value={checkInterval}
disabled={createChannel.isPending} onChange={(e) => setCheckInterval(e.target.value)}
style={{ width: '100%' }} placeholder="360 (default: 6 hours)"
> disabled={createChannel.isPending}
<option value="all">Monitor All</option> style={{ width: '100%' }}
<option value="future">Future Only</option> />
<option value="none">None</option> </div>
</select>
</div>
)}
{/* Grab All — YouTube only */}
{detectedPlatform === 'youtube' && (
<>
<div
style={{
marginBottom: grabAll ? 'var(--space-3)' : 'var(--space-4)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
}}
>
<input
id="grab-all"
type="checkbox"
checked={grabAll}
onChange={(e) => setGrabAll(e.target.checked)}
disabled={createChannel.isPending}
style={{ width: 'auto' }}
/>
<label
htmlFor="grab-all"
style={{
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
>
Grab all existing content?
</label>
</div>
{/* Download order — shown when grab-all enabled */}
{grabAll && (
<div style={{ marginBottom: 'var(--space-4)', paddingLeft: 'var(--space-5)' }}>
<label
htmlFor="grab-all-order"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Download Order
</label>
<select
id="grab-all-order"
value={grabAllOrder}
onChange={(e) => setGrabAllOrder(e.target.value as 'newest' | 'oldest')}
disabled={createChannel.isPending}
style={{ width: '100%' }}
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
<p
style={{
margin: 'var(--space-1) 0 0',
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
}}
>
Back-catalog items will be enqueued at low priority.
</p>
</div>
)}
</>
)}
{/* Error display */} {/* Error display */}
{createChannel.isError && ( {createChannel.isError && (
@ -362,9 +297,12 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
color: 'var(--danger)', color: 'var(--danger)',
}} }}
> >
{createChannel.error instanceof Error {createChannel.error instanceof Error &&
? createChannel.error.message createChannel.error.message.toLowerCase().includes('already exists')
: 'Failed to add channel'} ? 'This channel has already been added.'
: createChannel.error instanceof Error
? createChannel.error.message
: 'Failed to add channel'}
</div> </div>
)} )}

View file

@ -62,20 +62,27 @@ export function SkeletonChannelHeader() {
return ( return (
<div <div
style={{ style={{
display: 'flex',
gap: 'var(--space-5)',
padding: 'var(--space-5)',
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
marginBottom: 'var(--space-6)', marginBottom: 'var(--space-6)',
overflow: 'hidden',
}} }}
> >
<Skeleton width={80} height={80} borderRadius="50%" /> {/* Banner placeholder */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}> <Skeleton width="100%" height={160} borderRadius="0" />
<Skeleton width={200} height={24} /> {/* Identity + controls */}
<Skeleton width={300} height={14} /> <div style={{ padding: 'var(--space-5)', paddingTop: 'var(--space-4)' }}>
<div style={{ display: 'flex', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}> <div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'center', marginBottom: 'var(--space-4)' }}>
<Skeleton width={64} height={64} borderRadius="50%" style={{ marginTop: -32, border: '3px solid var(--bg-card)', flexShrink: 0 }} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<Skeleton width={200} height={24} />
<Skeleton width={120} height={14} />
</div>
</div>
<Skeleton width="80%" height={14} style={{ marginBottom: 'var(--space-2)' }} />
<Skeleton width="50%" height={14} style={{ marginBottom: 'var(--space-4)' }} />
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" /> <Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
<Skeleton width={120} height={32} borderRadius="var(--radius-md)" /> <Skeleton width={120} height={32} borderRadius="var(--radius-md)" />
<Skeleton width={100} height={32} borderRadius="var(--radius-md)" /> <Skeleton width={100} height={32} borderRadius="var(--radius-md)" />

View file

@ -19,6 +19,7 @@ import {
RefreshCw, RefreshCw,
Save, Save,
Trash2, Trash2,
Users,
} from 'lucide-react'; } from 'lucide-react';
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels'; import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels';
import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent'; import { useChannelContentPaginated, useDownloadContent, useToggleMonitored, useBulkMonitored, useCollectMonitored, type ChannelContentFilters } from '../api/hooks/useContent';
@ -86,6 +87,14 @@ function formatRelativeTime(isoString: string | null): string {
return `${years}y ago`; return `${years}y ago`;
} }
function formatSubscriberCount(count: number | null): string | null {
if (count == null) return null;
if (count < 1000) return `${count}`;
if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K`;
if (count < 1_000_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
return `${(count / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
}
const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [ const MONITORING_MODE_OPTIONS: { value: MonitoringMode; label: string }[] = [
{ value: 'all', label: 'All Content' }, { value: 'all', label: 'All Content' },
{ value: 'future', label: 'Future Only' }, { value: 'future', label: 'Future Only' },
@ -144,6 +153,7 @@ export function ChannelDetail() {
// ── Local state ── // ── Local state ──
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showFullDescription, setShowFullDescription] = useState(false);
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());
@ -833,6 +843,11 @@ export function ChannelDetail() {
{channel.name} {channel.name}
</span> </span>
<PlatformBadge platform={channel.platform} /> <PlatformBadge platform={channel.platform} />
{channel.subscriberCount != null && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{formatSubscriberCount(channel.subscriberCount)} subscribers
</span>
)}
<div style={{ width: 1, height: 20, backgroundColor: 'var(--border)', flexShrink: 0 }} /> <div style={{ width: 1, height: 20, backgroundColor: 'var(--border)', flexShrink: 0 }} />
@ -907,62 +922,141 @@ export function ChannelDetail() {
<div <div
ref={headerRef} ref={headerRef}
style={{ style={{
padding: 'var(--space-5)',
backgroundColor: 'var(--bg-card)', backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)', borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
marginBottom: 'var(--space-6)', marginBottom: 'var(--space-6)',
overflow: 'hidden',
}} }}
> >
{/* Identity row */} {/* Banner area */}
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'center', marginBottom: 'var(--space-5)' }}> {channel.bannerUrl ? (
<img <div
src={
channel.imageUrl ||
`https://ui-avatars.com/api/?name=${encodeURIComponent(channel.name)}&background=242731&color=e1e2e6&size=80`
}
alt={`${channel.name} avatar`}
style={{ style={{
width: 64, width: '100%',
height: 64, height: 180,
borderRadius: '50%', backgroundImage: `url(${channel.bannerUrl})`,
objectFit: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--bg-hover)', backgroundColor: 'var(--bg-hover)',
flexShrink: 0,
}} }}
/> />
<div style={{ minWidth: 0, flex: 1 }}> ) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}> <div
<h1 style={{ fontSize: 'var(--font-size-xl)', fontWeight: 600, color: 'var(--text-primary)', margin: 0 }}> style={{
{channel.name} width: '100%',
</h1> height: 80,
<PlatformBadge platform={channel.platform} /> background: 'linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-card) 100%)',
</div> }}
<a />
href={channel.url} )}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
color: 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
marginTop: 2,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={channel.url}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{channel.url}</span>
<ExternalLink size={12} style={{ flexShrink: 0 }} />
</a>
</div>
</div>
{/* Control groups */} <div style={{ padding: 'var(--space-5)', paddingTop: 0 }}>
{/* Identity row — avatar overlaps banner */}
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'flex-end', marginBottom: 'var(--space-4)' }}>
<img
src={
channel.imageUrl ||
`https://ui-avatars.com/api/?name=${encodeURIComponent(channel.name)}&background=242731&color=e1e2e6&size=80`
}
alt={`${channel.name} avatar`}
style={{
width: 72,
height: 72,
borderRadius: '50%',
objectFit: 'cover',
backgroundColor: 'var(--bg-hover)',
flexShrink: 0,
marginTop: -36,
border: '3px solid var(--bg-card)',
}}
/>
<div style={{ minWidth: 0, flex: 1, paddingTop: 'var(--space-3)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
<h1 style={{ fontSize: 'var(--font-size-xl)', fontWeight: 600, color: 'var(--text-primary)', margin: 0 }}>
{channel.name}
</h1>
<PlatformBadge platform={channel.platform} />
{channel.subscriberCount != null && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
color: 'var(--text-secondary)',
fontWeight: 500,
}}
>
<Users size={14} />
{formatSubscriberCount(channel.subscriberCount)} subscribers
</span>
)}
</div>
<a
href={channel.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
color: 'var(--text-secondary)',
fontSize: 'var(--font-size-sm)',
marginTop: 2,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={channel.url}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{channel.url}</span>
<ExternalLink size={12} style={{ flexShrink: 0 }} />
</a>
</div>
</div>
{/* Description — collapsible */}
{channel.description && (
<div style={{ marginBottom: 'var(--space-4)' }}>
<p
style={{
margin: 0,
fontSize: 'var(--font-size-sm)',
color: 'var(--text-secondary)',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
...(
!showFullDescription && channel.description.length > 150
? { overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const }
: {}
),
}}
>
{channel.description}
</p>
{channel.description.length > 150 && (
<button
onClick={() => setShowFullDescription((prev) => !prev)}
style={{
display: 'inline',
padding: 0,
border: 'none',
background: 'none',
color: 'var(--accent)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
cursor: 'pointer',
marginTop: 'var(--space-1)',
}}
>
{showFullDescription ? 'Show less' : 'Show more'}
</button>
)}
</div>
)}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@ -1139,6 +1233,7 @@ export function ChannelDetail() {
</button> </button>
</div> </div>
</div> </div>
</div>
</div> </div>
{/* Content table / playlist groups */} {/* Content table / playlist groups */}