feat: Refactored channel header into grouped control sections (Monitori…

- "src/frontend/src/pages/ChannelDetail.tsx"

GSD-Task: S03/T02
This commit is contained in:
jlightner 2026-04-03 04:40:36 +00:00
parent 49ac76c379
commit cca396a7e8

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
@ -7,6 +7,7 @@ import {
CheckCircle,
ChevronDown,
ChevronRight,
ChevronUp,
Download,
ExternalLink,
Film,
@ -17,7 +18,6 @@ import {
Music,
RefreshCw,
Save,
Search,
Trash2,
} from 'lucide-react';
import { useChannel, useUpdateChannel, useDeleteChannel, useScanChannel, useSetMonitoringMode, useScanStatus } from '../api/hooks/useChannels';
@ -151,6 +151,24 @@ export function ChannelDetail() {
const [checkIntervalSaved, setCheckIntervalSaved] = useState(false);
const { toast } = useToast();
// ── Collapsible header ──
const headerRef = useRef<HTMLDivElement>(null);
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
// Collapse when the header is mostly out of view
setIsHeaderCollapsed(!entry.isIntersecting);
},
{ threshold: 0, rootMargin: '-60px 0px 0px 0px' },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Sync local check interval from channel data
useEffect(() => {
if (channel?.checkInterval != null) {
@ -774,248 +792,347 @@ export function ChannelDetail() {
<ArrowLeft size={14} /> Back to Channels
</Link>
{/* Channel header */}
{/* Compact sticky bar — visible when full header scrolls out of view */}
<div
style={{
position: 'sticky',
top: 0,
zIndex: 100,
transform: isHeaderCollapsed ? 'translateY(0)' : 'translateY(-100%)',
opacity: isHeaderCollapsed ? 1 : 0,
pointerEvents: isHeaderCollapsed ? 'auto' : 'none',
transition: 'transform 0.25s ease, opacity 0.2s ease',
display: 'flex',
gap: 'var(--space-5)',
padding: 'var(--space-5)',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-2) var(--space-4)',
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
marginBottom: 'var(--space-6)',
alignItems: 'flex-start',
borderBottom: '1px solid var(--border)',
boxShadow: isHeaderCollapsed ? '0 2px 8px rgba(0,0,0,0.15)' : 'none',
flexWrap: 'wrap',
}}
aria-hidden={!isHeaderCollapsed}
>
{/* Avatar */}
{/* Identity — compact */}
<img
src={
channel.imageUrl ||
`https://ui-avatars.com/api/?name=${encodeURIComponent(channel.name)}&background=242731&color=e1e2e6&size=80`
`https://ui-avatars.com/api/?name=${encodeURIComponent(channel.name)}&background=242731&color=e1e2e6&size=32`
}
alt={`${channel.name} avatar`}
alt=""
style={{
width: 80,
height: 80,
width: 28,
height: 28,
borderRadius: '50%',
objectFit: 'cover',
backgroundColor: 'var(--bg-hover)',
flexShrink: 0,
}}
/>
<span style={{ fontWeight: 600, fontSize: 'var(--font-size-sm)', color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{channel.name}
</span>
<PlatformBadge platform={channel.platform} />
{/* Info */}
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
<h1
<div style={{ width: 1, height: 20, backgroundColor: 'var(--border)', flexShrink: 0 }} />
{/* Key actions — compact */}
<select
value={channel.monitoringMode}
onChange={handleMonitoringModeChange}
disabled={setMonitoringMode.isPending}
aria-label="Monitoring mode"
style={{ padding: '4px 8px', borderRadius: 'var(--radius-md)', fontSize: 'var(--font-size-xs)', minWidth: 110 }}
>
{MONITORING_MODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button
onClick={handleScan}
disabled={scanChannel.isPending || scanInProgress}
title={scanInProgress ? 'Scan in progress…' : 'Refresh & Scan'}
className="btn btn-ghost"
style={{ padding: '4px 10px', fontSize: 'var(--font-size-xs)', opacity: (scanChannel.isPending || scanInProgress) ? 0.6 : 1 }}
>
{(scanChannel.isPending || scanInProgress) ? (
<Loader size={12} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<RefreshCw size={12} />
)}
{scanInProgress ? 'Scanning…' : 'Scan'}
</button>
<button
onClick={handleCollect}
disabled={collectMonitored.isPending}
title="Collect Monitored"
className="btn btn-ghost"
style={{ padding: '4px 10px', fontSize: 'var(--font-size-xs)', opacity: collectMonitored.isPending ? 0.6 : 1 }}
>
{collectMonitored.isPending ? (
<Loader size={12} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<Download size={12} />
)}
Collect
</button>
<div style={{ flex: 1 }} />
{/* Scroll-to-top to reveal full header */}
<button
onClick={() => headerRef.current?.scrollIntoView({ behavior: 'smooth' })}
title="Show full header"
aria-label="Expand header"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-muted)',
cursor: 'pointer',
transition: 'color var(--transition-fast)',
flexShrink: 0,
}}
>
<ChevronUp size={16} />
</button>
</div>
{/* Full channel header — observed for collapse trigger */}
<div
ref={headerRef}
style={{
padding: 'var(--space-5)',
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
marginBottom: 'var(--space-6)',
}}
>
{/* Identity row */}
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'center', marginBottom: 'var(--space-5)' }}>
<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: 64,
height: 64,
borderRadius: '50%',
objectFit: 'cover',
backgroundColor: 'var(--bg-hover)',
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<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} />
</div>
<a
href={channel.url}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 'var(--font-size-2xl)',
fontWeight: 600,
color: 'var(--text-primary)',
margin: 0,
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}
>
{channel.name}
</h1>
<PlatformBadge platform={channel.platform} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{channel.url}</span>
<ExternalLink size={12} style={{ flexShrink: 0 }} />
</a>
</div>
</div>
{/* Control groups */}
<div
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 'var(--space-5)',
flexWrap: 'wrap',
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
}}
>
{/* Monitoring group */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Monitoring
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<select
value={channel.monitoringMode}
onChange={handleMonitoringModeChange}
disabled={setMonitoringMode.isPending}
aria-label="Monitoring mode"
style={{ padding: 'var(--space-2) var(--space-3)', borderRadius: 'var(--radius-md)', fontSize: 'var(--font-size-sm)', minWidth: 130 }}
>
{MONITORING_MODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
<input
type="number"
min={1}
value={localCheckInterval}
onChange={(e) => setLocalCheckInterval(e.target.value === '' ? '' : Number(e.target.value))}
aria-label="Check interval in minutes"
title="Check interval (minutes)"
style={{
width: 56,
padding: 'var(--space-2)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
}}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-xs)', whiteSpace: 'nowrap' }}>min</span>
<button
onClick={handleCheckIntervalSave}
disabled={
localCheckInterval === '' ||
Number(localCheckInterval) < 1 ||
Number(localCheckInterval) === channel.checkInterval ||
updateChannel.isPending
}
title={checkIntervalSaved ? 'Saved!' : 'Save check interval'}
aria-label="Save check interval"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--radius-sm)',
color: checkIntervalSaved
? 'var(--success)'
: localCheckInterval !== '' && Number(localCheckInterval) !== channel.checkInterval
? 'var(--accent)'
: 'var(--text-muted)',
cursor:
localCheckInterval === '' ||
Number(localCheckInterval) === channel.checkInterval ||
updateChannel.isPending
? 'not-allowed'
: 'pointer',
opacity:
localCheckInterval === '' ||
Number(localCheckInterval) === channel.checkInterval
? 0.4
: 1,
transition: 'color var(--transition-fast), opacity var(--transition-fast)',
}}
>
{updateChannel.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : checkIntervalSaved ? (
<CheckCircle size={14} />
) : (
<Save size={14} />
)}
</button>
</div>
</div>
</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: 'var(--space-1)',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={channel.url}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{channel.url}
{/* Format group */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Format
</span>
<ExternalLink size={12} style={{ flexShrink: 0 }} />
</a>
{/* Actions row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
marginTop: 'var(--space-4)',
flexWrap: 'wrap',
}}
>
{/* Monitoring mode dropdown */}
<select
value={channel.monitoringMode}
onChange={handleMonitoringModeChange}
disabled={setMonitoringMode.isPending}
aria-label="Monitoring mode"
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
minWidth: 140,
}}
>
{MONITORING_MODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{/* Format profile selector */}
<select
value={channel.formatProfileId ?? ''}
onChange={handleFormatProfileChange}
aria-label="Format profile"
style={{
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
minWidth: 140,
}}
style={{ padding: 'var(--space-2) var(--space-3)', borderRadius: 'var(--radius-md)', fontSize: 'var(--font-size-sm)', minWidth: 140 }}
>
<option value="">Default Profile</option>
{formatProfiles?.map((fp) => (
<option key={fp.id} value={fp.id}>
{fp.name}
</option>
<option key={fp.id} value={fp.id}>{fp.name}</option>
))}
</select>
</div>
{/* Per-channel check interval */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
<input
type="number"
min={1}
value={localCheckInterval}
onChange={(e) => setLocalCheckInterval(e.target.value === '' ? '' : Number(e.target.value))}
aria-label="Check interval in minutes"
title="Check interval (minutes)"
style={{
width: 64,
padding: 'var(--space-2) var(--space-2)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-sm)',
}}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-xs)', whiteSpace: 'nowrap' }}>min</span>
{/* Actions group */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Actions
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<button
onClick={handleCheckIntervalSave}
disabled={
localCheckInterval === '' ||
Number(localCheckInterval) < 1 ||
Number(localCheckInterval) === channel.checkInterval ||
updateChannel.isPending
}
title={checkIntervalSaved ? 'Saved!' : 'Save check interval'}
aria-label="Save check interval"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: 'var(--radius-sm)',
color: checkIntervalSaved
? 'var(--success)'
: localCheckInterval !== '' && Number(localCheckInterval) !== channel.checkInterval
? 'var(--accent)'
: 'var(--text-muted)',
cursor:
localCheckInterval === '' ||
Number(localCheckInterval) === channel.checkInterval ||
updateChannel.isPending
? 'not-allowed'
: 'pointer',
opacity:
localCheckInterval === '' ||
Number(localCheckInterval) === channel.checkInterval
? 0.4
: 1,
transition: 'color var(--transition-fast), opacity var(--transition-fast)',
}}
>
{updateChannel.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : checkIntervalSaved ? (
<CheckCircle size={14} />
) : (
<Save size={14} />
)}
</button>
</div>
{/* Refresh & Scan button */}
<button
onClick={handleScan}
disabled={scanChannel.isPending || scanInProgress}
title={scanInProgress ? 'Scan in progress…' : 'Refresh & Scan'}
className="btn btn-ghost"
style={{ opacity: (scanChannel.isPending || scanInProgress) ? 0.6 : 1 }}
>
{(scanChannel.isPending || scanInProgress) ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<RefreshCw size={14} />
)}
{scanInProgress ? 'Scanning…' : scanChannel.isPending ? 'Scanning…' : 'Refresh & Scan'}
</button>
{/* Collect Monitored button */}
<button
onClick={handleCollect}
disabled={collectMonitored.isPending}
title="Collect Monitored"
className="btn btn-ghost"
style={{ opacity: collectMonitored.isPending ? 0.6 : 1 }}
>
{collectMonitored.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<Search size={14} />
)}
{collectMonitored.isPending ? 'Collecting...' : 'Collect Monitored'}
</button>
{/* Refresh Playlists button (YouTube only) */}
{isYouTube ? (
<button
onClick={handleRefreshPlaylists}
disabled={refreshPlaylists.isPending}
title="Refresh Playlists"
onClick={handleScan}
disabled={scanChannel.isPending || scanInProgress}
title={scanInProgress ? 'Scan in progress…' : 'Refresh & Scan'}
className="btn btn-ghost"
style={{ opacity: refreshPlaylists.isPending ? 0.6 : 1 }}
style={{ opacity: (scanChannel.isPending || scanInProgress) ? 0.6 : 1 }}
>
{refreshPlaylists.isPending ? (
{(scanChannel.isPending || scanInProgress) ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<ListMusic size={14} />
<RefreshCw size={14} />
)}
{refreshPlaylists.isPending ? 'Refreshing...' : 'Playlists'}
{scanInProgress ? 'Scanning…' : scanChannel.isPending ? 'Scanning…' : 'Scan'}
</button>
) : null}
<button
onClick={handleCollect}
disabled={collectMonitored.isPending}
title="Collect Monitored"
className="btn btn-ghost"
style={{ opacity: collectMonitored.isPending ? 0.6 : 1 }}
>
{collectMonitored.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<Download size={14} />
)}
{collectMonitored.isPending ? 'Collecting…' : 'Collect'}
</button>
{isYouTube ? (
<button
onClick={handleRefreshPlaylists}
disabled={refreshPlaylists.isPending}
title="Refresh Playlists"
className="btn btn-ghost"
style={{ opacity: refreshPlaylists.isPending ? 0.6 : 1 }}
>
{refreshPlaylists.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<ListMusic size={14} />
)}
{refreshPlaylists.isPending ? 'Refreshing…' : 'Playlists'}
</button>
) : null}
</div>
</div>
{/* Delete button */}
{/* Spacer + Delete */}
<div style={{ marginLeft: 'auto' }}>
<button
onClick={() => setShowDeleteConfirm(true)}
className="btn btn-danger"
style={{ marginLeft: 'auto' }}
>
<Trash2 size={14} />
Delete