Files
AeroFetch/src/renderer/src/components/LibraryView.tsx
T
debont80 661a4e7572 feat(audit): Batch 11 — UX behavior gaps (toast surface, sidebar badge)
A new in-app toast surface is the batch's spine, making the silent-failure and
global-status gaps fixable:

- Toast infra: store/toasts.ts (useToasts + toast(); auto-dismiss) + ui/Toaster.tsx
  (non-portal bottom-center stack, avoids the Fluent-portal GPU flicker). Unit-
  tested (test/toasts.test.ts). Mounted at the app root beside LiveRegion.
- UX6: shared renderer reveal.ts revealFile('open'|'folder') awaits the IPC result
  and toasts the main-process reason instead of silently no-oping on a moved/typed-
  out file. safeShowInFolder upgraded to return an error string like safeOpenPath
  (preload/mock/Api types updated).
- UX9 / UI25: a Fluent CounterBadge on the sidebar Downloads nav item shows the
  active (downloading + queued) count from any tab, via a count-only App selector
  that doesn't re-render on progress ticks.
- UX15: cancelAll store action + a "Cancel all (N)" header button.
- UX24: the disabled "Check watched" button uses disabledFocusable + a Hint that
  explains it needs a watched source.
- UX12: the Terminal gate gains an inline "Enable custom commands" button, so it's
  no longer a dead-end pointing at another screen.
- L144: the DownloadBar duplicate guard now also checks history — a URL downloaded
  earlier warns "Already downloaded: …" (dupInHistory) vs "Already in your queue: …".

Deferred with rationale (CODE-AUDIT.md): UX7/UX8 (Settings IA redesign — visual),
UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs live taskbar),
L142 (history/templates/sources IPC-return reconciliation — a focused own PR).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:05:26 -04:00

794 lines
28 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import {
Caption1,
Text,
Input,
Button,
Checkbox,
Switch,
Spinner,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
AddRegular,
ArrowSyncRegular,
DeleteRegular,
ArrowDownloadRegular,
ChevronDownRegular,
ChevronRightRegular,
AppsListRegular,
VideoClipMultipleRegular,
AlertRegular,
LibraryRegular
} from '@fluentui/react-icons'
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { logError } from '../reportError'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { relTime } from '../datetime'
import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList'
import { Hint } from './Hint'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { StatusChip } from './ui/StatusChip'
import { SegmentedControl } from './ui/SegmentedControl'
import { EmptyState } from './ui/EmptyState'
import { LinkSuggestion } from './ui/LinkSuggestion'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_XS } from '../thumbSizes'
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending'
/**
* A flattened row for the virtualized item list: either a playlist header or a
* single video. Flattening the groups into one list lets a 1000s-video source
* window cleanly (one virtualizer over a flat array, headers included).
*/
type LibRow =
{ kind: 'header'; title: string; items: MediaItem[] } | { kind: 'item'; item: MediaItem }
/** Above this many flattened rows, the item list switches to a virtualized panel. */
const VIRTUALIZE_AT = 100
// When a URL appears more than once in the queue ("Download anyway" duplicates),
// the library row should reflect the most meaningful state: a finished copy wins,
// then anything in flight, then terminal failures (M17).
const STATUS_PRIORITY: Record<DownloadStatus, number> = {
completed: 6,
downloading: 5,
queued: 4,
saved: 3,
paused: 2,
error: 1,
canceled: 0
}
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
addRow: { display: 'flex', gap: '8px' },
addInput: { flexGrow: 1 },
toolbar: {
display: 'flex',
alignItems: 'center',
gap: '14px',
flexWrap: 'wrap',
paddingTop: '2px'
},
toolbarSpacer: { flexGrow: 1 },
switchRow: {
display: 'flex',
alignItems: 'center',
gap: '2px',
color: tokens.colorNeutralForeground2
},
progress: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase200
},
error: { color: tokens.colorPaletteRedForeground1 },
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
card: {
border: `1px solid ${tokens.colorNeutralStroke2}`,
...shorthands.borderRadius(tokens.borderRadiusLarge),
backgroundColor: tokens.colorNeutralBackground1,
overflow: 'hidden'
},
cardHead: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 14px',
width: '100%',
border: 'none',
backgroundColor: 'transparent',
// A native <button> doesn't inherit color; set it so the chevron (currentColor)
// matches the page foreground in dark mode instead of UA ButtonText.
color: tokens.colorNeutralForeground1,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer',
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
},
srcIcon: {
width: '34px',
height: '34px',
flexShrink: 0,
borderRadius: tokens.borderRadiusMedium,
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
// Source tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.control}px`
},
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
srcTitle: { fontWeight: tokens.fontWeightSemibold, color: tokens.colorNeutralForeground1 },
watchBadge: {
flexShrink: 0,
fontSize: tokens.fontSizeBase100,
padding: '0 7px',
...shorthands.borderRadius(tokens.borderRadiusCircular),
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
detail: {
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: '12px'
},
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
actionSpacer: { flexGrow: 1 },
groupHead: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '2px 4px'
},
groupToggle: {
display: 'flex',
alignItems: 'center',
gap: '8px',
flexGrow: 1,
minWidth: 0,
padding: '4px 4px',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
cursor: 'pointer',
fontFamily: tokens.fontFamilyBase,
fontSize: tokens.fontSizeBase300,
textAlign: 'left',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
},
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
row: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '5px 4px 5px 18px'
},
plainList: { display: 'flex', flexDirection: 'column' },
rowThumb: {
width: `${THUMB_XS.w}px`,
height: `${THUMB_XS.h}px`,
// One thumbnail radius app-wide (UI6): control tier / Medium.
...shorthands.borderRadius(RADIUS.control)
},
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }
})
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[] }[] {
const map = new Map<string, MediaItem[]>()
for (const it of items) {
const arr = map.get(it.playlistTitle) ?? []
arr.push(it)
map.set(it.playlistTitle, arr)
}
const groups = [...map.entries()].map(([title, its]) => ({
title,
items: [...its].sort((a, b) => a.playlistIndex - b.playlistIndex)
}))
groups.sort(
(a, b) =>
(a.title === 'Uploads' ? 1 : 0) - (b.title === 'Uploads' ? 1 : 0) ||
a.title.localeCompare(b.title)
)
return groups
}
export function LibraryView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const focus = useFocusStyles()
const text = useTextStyles()
const sources = useSources((s) => s.sources)
const itemsBySource = useSources((s) => s.itemsBySource)
const selectedSourceId = useSources((s) => s.selectedSourceId)
const indexing = useSources((s) => s.indexing)
const selectSource = useSources((s) => s.selectSource)
const indexSource = useSources((s) => s.indexSource)
const reindexSource = useSources((s) => s.reindexSource)
const removeSource = useSources((s) => s.removeSource)
const enqueueItems = useSources((s) => s.enqueueItems)
const setWatched = useSources((s) => s.setWatched)
const syncWatched = useSources((s) => s.syncWatched)
const syncing = useSources((s) => s.syncing)
const downloadItems = useDownloads((s) => s.items)
const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
const updateSettings = useSettings((s) => s.update)
const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null)
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
// A channel/playlist URL handed over from the Downloads bar (UX3): pre-fill the
// add field with it once, so the user lands here ready to add it.
const pendingLibraryUrl = useNav((s) => s.pendingLibraryUrl)
const consumeLibraryUrl = useNav((s) => s.consumeLibraryUrl)
useEffect(() => {
if (pendingLibraryUrl === null) return
const handed = consumeLibraryUrl()
if (handed) setUrl(handed)
}, [pendingLibraryUrl, consumeLibraryUrl])
// Reset any pending Remove confirmation when the expanded source changes so a
// stale confirm can't reappear after navigating between sources.
useEffect(() => {
setConfirmRemoveId(null)
}, [selectedSourceId])
// Offer a freshly-copied link the way the Downloads tab does, but skip single
// videos -- a library source is a channel/playlist to sync, not a one-off.
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
const [selected, setSelected] = useState<Set<string>>(new Set())
// Per-batch video/audio choice for queueing items, seeded from the global
// default so behavior is unchanged until the user flips it (M27).
const [enqueueKind, setEnqueueKind] = useState<MediaKind>(
() => useSettings.getState().defaultKind
)
const [batchNote, setBatchNote] = useState<string | null>(null)
const [syncNote, setSyncNote] = useState<string | null>(null)
// Which playlist groups are expanded. Empty = all collapsed (the default), so
// an expanded source shows just its group headers until one is opened.
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const [scheduled, setScheduled] = useState(false)
const watchedCount = sources.filter((s) => s.watched).length
// Load the current scheduled-sync (Task Scheduler) state once.
useEffect(() => {
if (PREVIEW) return
window.api
.getScheduledSync()
.then((s) => setScheduled(s.enabled))
.catch(logError('getScheduledSync'))
}, [])
async function onCheckNew(): Promise<void> {
setSyncNote(null)
const n = await syncWatched()
setSyncNote(n > 0 ? `Found ${n} new video${n === 1 ? '' : 's'}.` : 'No new videos.')
}
async function toggleScheduled(next: boolean): Promise<void> {
setScheduled(next) // optimistic
if (PREVIEW) return
const res = await window.api.setScheduledSync(next)
setScheduled(res.enabled)
if (res.error) setError(res.error)
}
// Reset the selection (and any batch note) whenever the expanded source changes.
useEffect(() => {
setSelected(new Set())
setBatchNote(null)
setExpandedGroups(new Set())
}, [selectedSourceId])
// Live per-URL queue status so a video row reflects its real download state.
const statusByUrl = useMemo(() => {
const m = new Map<string, DownloadStatus>()
for (const d of downloadItems) {
const prev = m.get(d.url)
// Keep the highest-priority status when a URL has duplicate queue entries.
if (prev === undefined || STATUS_PRIORITY[d.status] > STATUS_PRIORITY[prev]) {
m.set(d.url, d.status)
}
}
return m
}, [downloadItems])
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
const groups = useMemo(() => groupByPlaylist(items), [items])
// A group is shown when toggled open, or auto-expanded when it's the only group
// (a single "Uploads" channel shouldn't need a second click to reach its videos).
const isGroupOpen = (title: string): boolean => groups.length === 1 || expandedGroups.has(title)
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
const flatRows = useMemo<LibRow[]>(() => {
const rows: LibRow[] = []
for (const g of groups) {
rows.push({ kind: 'header', title: g.title, items: g.items })
if (groups.length === 1 || expandedGroups.has(g.title)) {
for (const it of g.items) rows.push({ kind: 'item', item: it })
}
}
return rows
}, [groups, expandedGroups])
const effStatus = (it: MediaItem): ItemStatus =>
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
// An item is worth (re)queuing when it isn't already downloaded or in flight:
// pending, or a previous failure/cancel to retry. Skipping completed/queued/
// downloading items keeps "Select all → Download" from enqueuing duplicates,
// since addFromUrl doesn't dedupe by URL.
const actionable = (it: MediaItem): boolean => {
const st = effStatus(it)
return st === 'pending' || st === 'error' || st === 'canceled'
}
const actionableItems = items.filter(actionable)
const selectedActionable = actionableItems.filter((it) => selected.has(it.id))
const allActionableSelected =
actionableItems.length > 0 && actionableItems.every((it) => selected.has(it.id))
async function onIndex(): Promise<void> {
setError(null)
const u = url.trim()
if (!u || indexing.active) return
const res = await indexSource(u)
if (res.ok) setUrl('')
else setError(res.error ?? 'Could not add that link.')
}
function toggle(id: string, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
}
function toggleGroupExpand(title: string): void {
setExpandedGroups((prev) => {
const next = new Set(prev)
if (next.has(title)) next.delete(title)
else next.add(title)
return next
})
}
function toggleGroup(groupItems: MediaItem[], on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
for (const it of groupItems) {
// Only actionable rows are selectable, so the selection count can never
// exceed what "Download N selected" will actually queue (M36).
if (!actionable(it)) continue
if (on) next.add(it.id)
else next.delete(it.id)
}
return next
})
}
// Select / clear every downloadable item across all groups in this source.
function toggleAll(on: boolean): void {
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
}
// One click queues every chosen item -- maxConcurrent gates how many actually
// run, the rest wait in the queue. Selection clears since nothing is held back.
function downloadSelected(): void {
if (!selectedSourceId || selectedActionable.length === 0) return
const n = enqueueItems(selectedSourceId, selectedActionable, enqueueKind)
setSelected(new Set())
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
function downloadPending(): void {
if (!selectedSourceId || pendingItems.length === 0) return
const n = enqueueItems(selectedSourceId, pendingItems, enqueueKind)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
// Queue every actionable (pending/failed/canceled) video in one playlist group,
// so "download this whole playlist" is a single click.
function downloadGroup(groupItems: MediaItem[]): void {
if (!selectedSourceId) return
const toQueue = groupItems.filter(actionable)
if (toQueue.length === 0) return
const n = enqueueItems(selectedSourceId, toQueue, enqueueKind)
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
// One row of the item list -- a playlist header or a video -- shared by the
// inline (small source) and virtualized (large source) render paths.
function rowKey(row: LibRow): string {
return row.kind === 'header' ? `h:${row.title}` : row.item.id
}
function renderRow(row: LibRow): React.JSX.Element {
if (row.kind === 'header') {
const open = isGroupOpen(row.title)
// "All on" is judged over the ACTIONABLE rows only -- downloaded rows aren't
// selectable, so they must not keep the group from reading as fully selected (M36).
const groupActionableItems = row.items.filter(actionable)
const groupActionable = groupActionableItems.length
const allOn = groupActionable > 0 && groupActionableItems.every((it) => selected.has(it.id))
return (
<div className={styles.groupHead}>
<button
type="button"
className={mergeClasses(styles.groupToggle, focus.focusRing)}
onClick={() => toggleGroupExpand(row.title)}
aria-expanded={open}
aria-label={`${row.title}, ${row.items.length} items`}
>
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
<AppsListRegular />
<Text className={styles.groupTitle}>{row.title}</Text>
<Caption1 className={text.muted}>{row.items.length}</Caption1>
</button>
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
{allOn ? 'None' : 'All'}
</Button>
<Button
size="small"
appearance="subtle"
icon={<ArrowDownloadRegular />}
disabled={groupActionable === 0}
onClick={() => downloadGroup(row.items)}
>
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
</Button>
</div>
)
}
const it = row.item
const status = effStatus(it)
return (
<div className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
disabled={!actionable(it)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
<MediaThumb
className={styles.rowThumb}
src={thumbUrl({ url: it.url, videoId: it.videoId })}
kind="video"
iconSize={16}
/>
<div className={styles.rowMain}>
<Text className={text.truncate}>
{it.playlistIndex}. {it.title}
</Text>
{it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
</div>
<StatusChip status={status} />
</div>
)
}
return (
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader
title="Library"
description="Add a channel or playlist once, then download its videos into organized folders."
/>
<div className={styles.addRow}>
<Input
className={styles.addInput}
input={{ 'aria-label': 'Channel or playlist URL' }}
value={url}
onChange={(_, d) => setUrl(d.value)}
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
placeholder="Paste a channel or playlist URL to add it…"
size="large"
contentBefore={<LibraryRegular />}
disabled={indexing.active}
/>
<Button
size="large"
appearance="primary"
icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
onClick={onIndex}
disabled={!url.trim() || indexing.active}
>
Add
</Button>
</div>
{clip.suggestion && (
<LinkSuggestion
prefix="Use copied link? "
link={clip.suggestion}
onAccept={() => {
const link = clip.accept()
if (link) setUrl(link)
}}
onDismiss={clip.dismiss}
dismissLabel="Dismiss suggested link"
/>
)}
<div className={styles.toolbar}>
<Hint
label={
watchedCount === 0
? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
: 'Check your watched sources for new uploads'
}
placement="top"
align="start"
>
<Button
size="small"
appearance="subtle"
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={onCheckNew}
// disabledFocusable (not disabled) when nothing is watched, so the
// button still shows its explanatory tooltip on hover/focus (UX24).
disabled={syncing}
disabledFocusable={watchedCount === 0}
>
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
</Button>
</Hint>
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
<div className={styles.toolbarSpacer} />
<span className={styles.switchRow}>
<Caption1>Auto-download new</Caption1>
<Switch
checked={autoDownloadNew}
onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
aria-label="Auto-download new uploads"
/>
</span>
<span className={styles.switchRow}>
<Caption1>Daily sync</Caption1>
<Switch
checked={scheduled}
onChange={(_, d) => toggleScheduled(d.checked)}
aria-label="Daily background sync"
/>
</span>
</div>
{indexing.active && (
<div className={styles.progress}>
<Spinner size="tiny" />
<Text>
{indexing.message ?? 'Adding…'}
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
</Text>
</div>
)}
{error && <Caption1 className={styles.error}>{error}</Caption1>}
{sources.length === 0 && !indexing.active ? (
<EmptyState
icon={<LibraryRegular fontSize={ICON.hero} />}
message="No channels or playlists yet. Paste one above to add it."
/>
) : (
<div className={styles.list}>
{sources.map((src) => (
<SourceCard
key={src.id}
styles={styles}
source={src}
expanded={selectedSourceId === src.id}
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
>
<div className={styles.detail}>
<div className={styles.actionRow}>
<Checkbox
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
onChange={(_, d) => toggleAll(!!d.checked)}
label="Select all"
disabled={actionableItems.length === 0}
/>
<Caption1 className={text.muted}>
{items.length} videos{META_SEP}
{pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
</Caption1>
<div className={styles.actionSpacer} />
<SegmentedControl<MediaKind>
value={enqueueKind}
options={[
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]}
onChange={setEnqueueKind}
ariaLabel="Download as video or audio"
/>
{selected.size > 0 ? (
<>
<Button
size="small"
appearance="subtle"
onClick={() => setSelected(new Set())}
>
Clear
</Button>
<Button
size="small"
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={downloadSelected}
disabled={selectedActionable.length === 0}
>
Download {selectedActionable.length} selected
</Button>
</>
) : (
<Button
size="small"
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={downloadPending}
disabled={pendingItems.length === 0}
>
Download {pendingItems.length} pending
</Button>
)}
<span className={styles.switchRow}>
<Caption1>Watch</Caption1>
<Switch
checked={!!src.watched}
onChange={(_, d) => setWatched(src.id, !!d.checked)}
aria-label={`Watch ${src.title} for new uploads`}
/>
</span>
<Button
size="small"
appearance="subtle"
icon={<ArrowSyncRegular />}
onClick={() => reindexSource(src.id)}
disabled={indexing.active}
>
Refresh
</Button>
{confirmRemoveId === src.id ? (
<>
<Caption1 className={text.muted}>Remove {src.title}?</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
removeSource(src.id)
setConfirmRemoveId(null)
}}
>
Remove
</Button>
<Button
size="small"
appearance="subtle"
onClick={() => setConfirmRemoveId(null)}
>
Cancel
</Button>
</>
) : (
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setConfirmRemoveId(src.id)}
>
Remove
</Button>
)}
</div>
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
{flatRows.length > VIRTUALIZE_AT ? (
// Big source: a fixed-height, internally-scrolling virtualized
// panel so 1000s of rows stay light. A definite height (not
// max-height) gives the virtualizer a viewport to measure.
<VirtualList
items={flatRows}
style={{ height: '58vh' }}
overscan={10}
estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
getKey={(row) => rowKey(row)}
renderItem={(row) => renderRow(row)}
/>
) : (
// Small source: render inline so the card grows naturally.
<div className={styles.plainList}>
{flatRows.map((row) => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>
)}
</div>
</SourceCard>
))}
</div>
)}
</div>
)
}
/** One collapsible source card (header always shown; children render when expanded). */
function SourceCard({
styles,
source,
expanded,
onToggleExpand,
children
}: {
styles: ReturnType<typeof useStyles>
source: Source
expanded: boolean
onToggleExpand: () => void
children: React.ReactNode
}): React.JSX.Element {
const focus = useFocusStyles()
const text = useTextStyles()
return (
<div className={styles.card}>
<button
type="button"
className={mergeClasses(styles.cardHead, focus.focusRing)}
onClick={onToggleExpand}
aria-expanded={expanded}
aria-label={source.title}
>
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
<div className={styles.srcIcon}>
<VideoClipMultipleRegular />
</div>
<div className={styles.srcMeta}>
<span className={styles.srcTitleRow}>
<Text className={styles.srcTitle}>{source.title}</Text>
{source.watched && (
<span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching
</span>
)}
</span>
<Caption1 className={text.muted}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
{META_SEP}
{source.itemCount} videos
{source.channel && source.channel !== source.title
? `${META_SEP}${source.channel}`
: ''}
</Caption1>
</div>
</button>
{expanded && children}
</div>
)
}