Merge Batch 17 — layout & virtualization (L161, L104)
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ui",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "ui", "--", "--port", "4321", "--strictPort"],
|
||||||
|
"port": 4321
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+18
-12
@@ -992,13 +992,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
`<Spinner size="tiny">` (Fetch/Index/Check-for-new/yt-dlp update/app-update/PO-mint/version-check); the only
|
`<Spinner size="tiny">` (Fetch/Index/Check-for-new/yt-dlp update/app-update/PO-mint/version-check); the only
|
||||||
remaining `<Spinner>`s are standalone status-row indicators (the "Fetching…"/"Waiting to start…"/indexing
|
remaining `<Spinner>`s are standalone status-row indicators (the "Fetching…"/"Waiting to start…"/indexing
|
||||||
rows), not a button + adjacent spinner. No separate-adjacent-spinner pattern survives.*
|
rows), not a button + adjacent spinner. No separate-adjacent-spinner pattern survives.*
|
||||||
- [ ] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`.
|
- [x] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`.
|
||||||
*Deferred (bounded; needs the L161 layout rework + live verification): History is capped at 500 rows (L141),
|
*Fixed (Batch 17, with L161): HistoryView renders through the shared `VirtualList` inside the new `fill`
|
||||||
so the un-virtualized render is bounded — not a real perf problem. Virtualizing it requires giving the list a
|
screen shell — the list owns an internal scroll region (flex + 160px floor, DownloadsView's contract). The
|
||||||
fixed-height internal scroll container (the same `height:100%`/`overflow` rework L161 flags as needing
|
container-level Ctrl+A moved onto a wrapper around the virtualized scroller (key events bubble up from the
|
||||||
live verification at multiple window sizes) *and* re-homing its container-level Ctrl+A and per-row Delete
|
rows), and per-row Delete stays on each row so it works wherever the row mounts. Verified live in an
|
||||||
keyboard handling onto the virtualized scroller. That's a layout+interaction change best done watched, so it
|
Electron probe against the vite preview at 1100×720 and 800×520: rows render via `[data-index]` virtual
|
||||||
rides with L161 rather than shipping unattended for a list that's already capped.*
|
wrappers, the list scrolls internally, and `<main>`/body never scroll (probe metrics + screenshots).*
|
||||||
- [x] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and
|
- [x] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and
|
||||||
[store/downloads.ts](src/renderer/src/store/downloads.ts); components import it from both. Single source it.
|
[store/downloads.ts](src/renderer/src/store/downloads.ts); components import it from both. Single source it.
|
||||||
*Fixed: the store now imports `MediaKind` from `@shared/ipc` and re-exports it, so the duplicate local
|
*Fixed: the store now imports `MediaKind` from `@shared/ipc` and re-exports it, so the duplicate local
|
||||||
@@ -1301,12 +1301,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
8px) instead of style classes (extends L5). *Fixed: the margin literals now draw from `SPACE`
|
8px) instead of style classes (extends L5). *Fixed: the margin literals now draw from `SPACE`
|
||||||
(`hairline`/`xtight`/`tight`) — same values, one source. (The broader inline-`style` vs makeStyles split is
|
(`hairline`/`xtight`/`tight`) — same values, one source. (The broader inline-`style` vs makeStyles split is
|
||||||
still L5.)*
|
still L5.)*
|
||||||
- [ ] **L161 — `height: 100%` screens inside a padded scroll container.** Downloads/Terminal set
|
- [x] **L161 — `height: 100%` screens inside a padded scroll container.** Downloads/Terminal set
|
||||||
`height: 100%` while App's `<main>` is `overflowY: auto` + 24/28px padding — a fragile coupling that can
|
`height: 100%` while App's `<main>` is `overflowY: auto` + 24/28px padding — a fragile coupling that can
|
||||||
double-scroll or misalign the virtualized list's viewport. *Deferred (layout, needs live verification): it
|
double-scroll or misalign the virtualized list's viewport. *Fixed (Batch 17): `<main>` no longer scrolls or
|
||||||
works today (the virtualized queue scrolls correctly), and reworking the `<main>` scroll/height contract is
|
pads — it's a plain flex slot, and each screen owns its scrolling through two shared shells in
|
||||||
a layout change best validated with the app open at several window sizes — a watched pass, not an unattended
|
[ui/Screen.tsx](src/renderer/src/components/ui/Screen.tsx): `page` (document screens — Library, Settings,
|
||||||
edit.*
|
empty History: the shell scrolls) and `fill` (internally-scrolling screens — Downloads, Terminal, History:
|
||||||
|
the screen's list/log is the scroller, with the shell scrolling only as the short-window fallback). The
|
||||||
|
`height:100%` couplings are gone (`flexGrow:1; minHeight:0` inside the shell instead). The rework surfaced a
|
||||||
|
real pre-existing edge: on a very short window the flexed list collapsed to 0px — now floored at 160px with
|
||||||
|
the shell absorbing the overflow. Verified with an Electron probe at 1100×720 and 800×520 per screen:
|
||||||
|
exactly one active scroller per screen in the normal case, `<main>`/body never scroll, floors hold
|
||||||
|
(probe metrics + screenshots).*
|
||||||
- [ ] **L162 — Per-thumbnail store subscription.** Every `MediaThumb` calls `useResolvedDark()` (two store
|
- [ ] **L162 — Per-thumbnail store subscription.** Every `MediaThumb` calls `useResolvedDark()` (two store
|
||||||
subscriptions) instead of receiving a resolved theme prop — N subscriptions per list. *Deferred — same as
|
subscriptions) instead of receiving a resolved theme prop — N subscriptions per list. *Deferred — same as
|
||||||
PERF6: virtualization/the 500-cap bounds the mounted count, and a prop conflicts with PERF5's hoisted
|
PERF6: virtualization/the 500-cap bounds the mounted count, and a prop conflicts with PERF5's hoisted
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
|||||||
import { LiveRegion } from './components/LiveRegion'
|
import { LiveRegion } from './components/LiveRegion'
|
||||||
import { Toaster } from './components/ui/Toaster'
|
import { Toaster } from './components/ui/Toaster'
|
||||||
import { getTheme, pageBackground } from './theme'
|
import { getTheme, pageBackground } from './theme'
|
||||||
import { SPACE } from './components/ui/tokens'
|
|
||||||
import { useSettings } from './store/settings'
|
import { useSettings } from './store/settings'
|
||||||
import { useNav } from './store/nav'
|
import { useNav } from './store/nav'
|
||||||
import { useDownloads } from './store/downloads'
|
import { useDownloads } from './store/downloads'
|
||||||
@@ -55,9 +54,13 @@ const useStyles = makeStyles({
|
|||||||
content: {
|
content: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
overflowY: 'auto',
|
// L161: <main> no longer scrolls or pads — each screen owns its scroll region
|
||||||
// Symmetric page padding (UI4): the old 28px horizontal appeared nowhere else.
|
// via the shared `page`/`fill` shells (ui/Screen.tsx), so a page scroll and an
|
||||||
padding: SPACE.page
|
// internal list scroll can never stack. Padding (SPACE.page, UI4) lives on the
|
||||||
|
// shells.
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,15 @@ const renderQueueRow = (item: DownloadItem): React.JSX.Element => <QueueItem ite
|
|||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
// Fill the scroll area so the queue list below can flex to the remaining
|
// Fill the `fill` shell (L161) so the queue list below can flex to the
|
||||||
// height and scroll internally (it's virtualized), instead of the whole page
|
// remaining height and scroll internally (it's virtualized), instead of the
|
||||||
// growing to thousands of rows.
|
// whole page growing to thousands of rows.
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
// One section gap on every screen (UI2).
|
// One section gap on every screen (UI2).
|
||||||
gap: SPACE.section,
|
gap: SPACE.section,
|
||||||
height: '100%'
|
flexGrow: 1,
|
||||||
|
minHeight: 0
|
||||||
},
|
},
|
||||||
queueHeader: {
|
queueHeader: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -63,9 +64,10 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
listScroll: {
|
listScroll: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
// min-height:0 lets this flex child shrink below its content height so it,
|
// The floor keeps the virtualized queue visible on a very short window (the
|
||||||
// not the page, owns the scrolling.
|
// `fill` shell scrolls the overflow); above it, the list shrinks below its
|
||||||
minHeight: 0
|
// content height so it, not the page, owns the scrolling.
|
||||||
|
minHeight: '160px'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -95,70 +97,77 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
const cancelable = items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
|
const cancelable = items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={mergeClasses(styles.root, screen.width)}>
|
<div className={screen.fill}>
|
||||||
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." />
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
|
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." />
|
||||||
|
|
||||||
<DownloadBar />
|
<DownloadBar />
|
||||||
|
|
||||||
{summary.active && (
|
{summary.active && (
|
||||||
<div className={styles.summary}>
|
<div className={styles.summary}>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
className={styles.summaryBar}
|
className={styles.summaryBar}
|
||||||
value={summary.progress}
|
value={summary.progress}
|
||||||
thickness="large"
|
thickness="large"
|
||||||
aria-label="Overall download progress"
|
aria-label="Overall download progress"
|
||||||
|
/>
|
||||||
|
<Caption1 className={styles.summaryText}>
|
||||||
|
{summary.downloading} downloading
|
||||||
|
{summary.queued ? `, ${summary.queued} queued` : ''}
|
||||||
|
{summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''}
|
||||||
|
{summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.queueHeader}>
|
||||||
|
<Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
{summary.failed > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowClockwiseRegular />}
|
||||||
|
onClick={retryAll}
|
||||||
|
>
|
||||||
|
Retry all failed ({summary.failed})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{cancelable > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={cancelAll}
|
||||||
|
>
|
||||||
|
Cancel all ({cancelable})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasFinished && (
|
||||||
|
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
||||||
|
Clear finished
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ArrowDownloadRegular fontSize={ICON.hero} />}
|
||||||
|
message="Nothing queued yet. Paste a URL above, then click Download."
|
||||||
/>
|
/>
|
||||||
<Caption1 className={styles.summaryText}>
|
) : (
|
||||||
{summary.downloading} downloading
|
<VirtualList
|
||||||
{summary.queued ? `, ${summary.queued} queued` : ''}
|
items={items}
|
||||||
{summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''}
|
className={styles.listScroll}
|
||||||
{summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''}
|
gap={10}
|
||||||
</Caption1>
|
overscan={6}
|
||||||
</div>
|
estimateSize={estimateRowSize}
|
||||||
)}
|
getKey={getItemKey}
|
||||||
|
renderItem={renderQueueRow}
|
||||||
<div className={styles.queueHeader}>
|
/>
|
||||||
<Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
|
)}
|
||||||
<div className={styles.headerActions}>
|
|
||||||
{summary.failed > 0 && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<ArrowClockwiseRegular />}
|
|
||||||
onClick={retryAll}
|
|
||||||
>
|
|
||||||
Retry all failed ({summary.failed})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{cancelable > 0 && (
|
|
||||||
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={cancelAll}>
|
|
||||||
Cancel all ({cancelable})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{hasFinished && (
|
|
||||||
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
|
||||||
Clear finished
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={<ArrowDownloadRegular fontSize={ICON.hero} />}
|
|
||||||
message="Nothing queued yet. Paste a URL above, then click Download."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VirtualList
|
|
||||||
items={items}
|
|
||||||
className={styles.listScroll}
|
|
||||||
gap={10}
|
|
||||||
overscan={6}
|
|
||||||
estimateSize={estimateRowSize}
|
|
||||||
getKey={getItemKey}
|
|
||||||
renderItem={renderQueueRow}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { thumbUrl } from '../thumb'
|
|||||||
import { MediaThumb } from './MediaThumb'
|
import { MediaThumb } from './MediaThumb'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
|
import { VirtualList } from './VirtualList'
|
||||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||||
import { EmptyState } from './ui/EmptyState'
|
import { EmptyState } from './ui/EmptyState'
|
||||||
import { useFocusStyles } from './ui/focusRing'
|
import { useFocusStyles } from './ui/focusRing'
|
||||||
@@ -43,6 +44,21 @@ const useStyles = makeStyles({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: SPACE.section
|
gap: SPACE.section
|
||||||
},
|
},
|
||||||
|
// Fills the `fill` shell (L161) so the virtualized list below owns the scroll.
|
||||||
|
fill: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0
|
||||||
|
},
|
||||||
|
listScroll: {
|
||||||
|
flexGrow: 1,
|
||||||
|
// The floor keeps the virtualized list visible on a very short window (the
|
||||||
|
// `fill` shell scrolls the overflow); above it, the list shrinks below its
|
||||||
|
// content height so it, not the page, owns the scrolling (same contract as
|
||||||
|
// DownloadsView).
|
||||||
|
minHeight: '160px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -65,11 +81,6 @@ const useStyles = makeStyles({
|
|||||||
spacer: {
|
spacer: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
},
|
},
|
||||||
list: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '8px'
|
|
||||||
},
|
|
||||||
row: {
|
row: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -127,6 +138,11 @@ const KIND_FILTER_OPTIONS = [
|
|||||||
{ value: 'audio', label: 'Audio' }
|
{ value: 'audio', label: 'Audio' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Hoisted so the virtualizer gets stable function identities (PERF5). The row
|
||||||
|
// renderer itself stays a component-scoped closure — it needs select-mode state.
|
||||||
|
const estimateRowSize = (): number => 76
|
||||||
|
const getEntryKey = (h: HistoryEntry): string => h.id
|
||||||
|
|
||||||
export function HistoryView(): React.JSX.Element {
|
export function HistoryView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const screen = useScreenStyles()
|
const screen = useScreenStyles()
|
||||||
@@ -214,230 +230,246 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
exitSelectMode()
|
exitSelectMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One history row; rendered through the virtualized list (L104). The per-row
|
||||||
|
// Delete key handling stays on the row itself, so it works wherever the row is
|
||||||
|
// mounted in the window.
|
||||||
|
function renderRow(h: HistoryEntry): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={mergeClasses(styles.row, focus.focusRing)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Delete removes the focused row (not when a child button is focused).
|
||||||
|
if (e.key === 'Delete' && e.target === e.currentTarget) {
|
||||||
|
e.preventDefault()
|
||||||
|
remove(h.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectMode && (
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(h.id)}
|
||||||
|
onChange={(_, d) => toggleSelected(h.id, !!d.checked)}
|
||||||
|
aria-label={`Select ${h.title}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MediaThumb
|
||||||
|
className={styles.thumb}
|
||||||
|
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
|
||||||
|
kind={h.kind}
|
||||||
|
iconSize={22}
|
||||||
|
/>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<Text className={text.title}>{h.title}</Text>
|
||||||
|
<Caption1 className={text.muted}>
|
||||||
|
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)].filter(Boolean).join(META_SEP)}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Hint label="Re-download" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowClockwiseRegular />}
|
||||||
|
onClick={() => redownload(h)}
|
||||||
|
aria-label="Re-download"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
<Hint label="Open file" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<OpenRegular />}
|
||||||
|
onClick={() => openFile(h.id)}
|
||||||
|
aria-label="Open file"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
<Hint label="Show in folder" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<FolderRegular />}
|
||||||
|
onClick={() => showInFolder(h.id)}
|
||||||
|
aria-label="Show in folder"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
<Hint label="Remove from history" placement="top" align="end">
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => remove(h.id)}
|
||||||
|
aria-label="Remove from history"
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={mergeClasses(styles.root, screen.width)}>
|
<div className={screen.page}>
|
||||||
<ScreenHeader title="History" description="Files you've finished downloading." />
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
<EmptyState
|
<ScreenHeader title="History" description="Files you've finished downloading." />
|
||||||
icon={
|
<EmptyState
|
||||||
<div
|
icon={
|
||||||
className={styles.emptyBadge}
|
<div
|
||||||
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
className={styles.emptyBadge}
|
||||||
>
|
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
||||||
<HistoryRegular />
|
>
|
||||||
</div>
|
<HistoryRegular />
|
||||||
}
|
</div>
|
||||||
message="No downloads yet."
|
}
|
||||||
hint="Finished downloads will show up here."
|
message="No downloads yet."
|
||||||
/>
|
hint="Finished downloads will show up here."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={mergeClasses(styles.root, screen.width)}>
|
<div className={screen.fill}>
|
||||||
<ScreenHeader title="History" description="Files you've finished downloading." />
|
<div className={mergeClasses(styles.root, styles.fill, screen.width)}>
|
||||||
<div className={styles.header}>
|
<ScreenHeader title="History" description="Files you've finished downloading." />
|
||||||
{selectMode ? (
|
<div className={styles.header}>
|
||||||
<>
|
{selectMode ? (
|
||||||
<Caption1 className={styles.count}>{selected.size} selected</Caption1>
|
<>
|
||||||
<Button size="small" appearance="subtle" onClick={toggleAll}>
|
<Caption1 className={styles.count}>{selected.size} selected</Caption1>
|
||||||
{allFilteredSelected ? 'Select none' : 'Select all'}
|
<Button size="small" appearance="subtle" onClick={toggleAll}>
|
||||||
</Button>
|
{allFilteredSelected ? 'Select none' : 'Select all'}
|
||||||
<div className={styles.spacer} />
|
</Button>
|
||||||
{confirmDelete ? (
|
<div className={styles.spacer} />
|
||||||
<>
|
{confirmDelete ? (
|
||||||
<Caption1 className={styles.count}>
|
<>
|
||||||
Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
|
<Caption1 className={styles.count}>
|
||||||
</Caption1>
|
Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
|
||||||
<Button
|
</Caption1>
|
||||||
size="small"
|
<Button
|
||||||
appearance="primary"
|
size="small"
|
||||||
icon={<DeleteRegular />}
|
appearance="primary"
|
||||||
onClick={deleteSelected}
|
icon={<DeleteRegular />}
|
||||||
>
|
onClick={deleteSelected}
|
||||||
Delete
|
>
|
||||||
</Button>
|
Delete
|
||||||
<Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}>
|
</Button>
|
||||||
Cancel
|
<Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}>
|
||||||
</Button>
|
Cancel
|
||||||
</>
|
</Button>
|
||||||
) : (
|
</>
|
||||||
<>
|
) : (
|
||||||
<Button
|
<>
|
||||||
size="small"
|
<Button
|
||||||
appearance="primary"
|
size="small"
|
||||||
icon={<DeleteRegular />}
|
appearance="primary"
|
||||||
onClick={() => setConfirmDelete(true)}
|
icon={<DeleteRegular />}
|
||||||
disabled={selected.size === 0}
|
onClick={() => setConfirmDelete(true)}
|
||||||
>
|
disabled={selected.size === 0}
|
||||||
Delete selected
|
>
|
||||||
</Button>
|
Delete selected
|
||||||
<Button
|
</Button>
|
||||||
size="small"
|
<Button
|
||||||
appearance="subtle"
|
size="small"
|
||||||
icon={<DismissRegular />}
|
appearance="subtle"
|
||||||
onClick={exitSelectMode}
|
icon={<DismissRegular />}
|
||||||
>
|
onClick={exitSelectMode}
|
||||||
Cancel
|
>
|
||||||
</Button>
|
Cancel
|
||||||
</>
|
</Button>
|
||||||
)}
|
</>
|
||||||
</>
|
)}
|
||||||
) : (
|
</>
|
||||||
<>
|
) : (
|
||||||
<Caption1 className={styles.count}>
|
<>
|
||||||
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
|
<Caption1 className={styles.count}>
|
||||||
</Caption1>
|
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
|
||||||
<Input
|
</Caption1>
|
||||||
className={styles.search}
|
<Input
|
||||||
input={{ 'aria-label': 'Search history' }}
|
className={styles.search}
|
||||||
size="small"
|
input={{ 'aria-label': 'Search history' }}
|
||||||
value={query}
|
size="small"
|
||||||
onChange={(_, d) => setQuery(d.value)}
|
value={query}
|
||||||
placeholder="Search title, channel, URL…"
|
onChange={(_, d) => setQuery(d.value)}
|
||||||
contentBefore={<SearchRegular />}
|
placeholder="Search title, channel, URL…"
|
||||||
/>
|
contentBefore={<SearchRegular />}
|
||||||
<Select
|
/>
|
||||||
className={styles.kindFilter}
|
<Select
|
||||||
aria-label="Filter by type"
|
className={styles.kindFilter}
|
||||||
value={kindFilter}
|
aria-label="Filter by type"
|
||||||
options={KIND_FILTER_OPTIONS}
|
value={kindFilter}
|
||||||
onChange={(v) => setKindFilter(v as 'all' | MediaKind)}
|
options={KIND_FILTER_OPTIONS}
|
||||||
/>
|
onChange={(v) => setKindFilter(v as 'all' | MediaKind)}
|
||||||
<div className={styles.spacer} />
|
/>
|
||||||
<Button
|
<div className={styles.spacer} />
|
||||||
size="small"
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<CheckmarkSquareRegular />}
|
|
||||||
onClick={() => setSelectMode(true)}
|
|
||||||
>
|
|
||||||
Select
|
|
||||||
</Button>
|
|
||||||
{confirmClear ? (
|
|
||||||
<>
|
|
||||||
<Caption1 className={styles.count}>
|
|
||||||
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
|
|
||||||
</Caption1>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
appearance="primary"
|
|
||||||
icon={<DeleteRegular />}
|
|
||||||
onClick={() => {
|
|
||||||
clear()
|
|
||||||
setConfirmClear(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</Button>
|
|
||||||
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
icon={<DeleteRegular />}
|
icon={<CheckmarkSquareRegular />}
|
||||||
onClick={() => setConfirmClear(true)}
|
onClick={() => setSelectMode(true)}
|
||||||
>
|
>
|
||||||
Clear history
|
Select
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{confirmClear ? (
|
||||||
</>
|
<>
|
||||||
|
<Caption1 className={styles.count}>
|
||||||
|
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
|
||||||
|
</Caption1>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="primary"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => {
|
||||||
|
clear()
|
||||||
|
setConfirmClear(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
>
|
||||||
|
Clear history
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={styles.listScroll}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
|
||||||
|
// select mode if needed). Scoped to the list, so it never hijacks
|
||||||
|
// Ctrl+A in the search field, which lives in the header above. Lives
|
||||||
|
// on this wrapper (key events bubble up from the rows) because the
|
||||||
|
// virtualized scroller below renders rows through a render prop (L104).
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectMode(true)
|
||||||
|
setSelected(new Set(filtered.map((x) => x.id)))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VirtualList
|
||||||
|
items={filtered}
|
||||||
|
className={styles.listScroll}
|
||||||
|
gap={8}
|
||||||
|
overscan={6}
|
||||||
|
estimateSize={estimateRowSize}
|
||||||
|
getKey={getEntryKey}
|
||||||
|
renderItem={renderRow}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={styles.list}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
|
|
||||||
// select mode if needed). Scoped to the list, so it never hijacks
|
|
||||||
// Ctrl+A in the search field, which lives in the header above.
|
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
|
||||||
e.preventDefault()
|
|
||||||
setSelectMode(true)
|
|
||||||
setSelected(new Set(filtered.map((x) => x.id)))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filtered.map((h: HistoryEntry) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={h.id}
|
|
||||||
className={mergeClasses(styles.row, focus.focusRing)}
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// Delete removes the focused row (not when a child button is focused).
|
|
||||||
if (e.key === 'Delete' && e.target === e.currentTarget) {
|
|
||||||
e.preventDefault()
|
|
||||||
remove(h.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectMode && (
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.has(h.id)}
|
|
||||||
onChange={(_, d) => toggleSelected(h.id, !!d.checked)}
|
|
||||||
aria-label={`Select ${h.title}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MediaThumb
|
|
||||||
className={styles.thumb}
|
|
||||||
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
|
|
||||||
kind={h.kind}
|
|
||||||
iconSize={22}
|
|
||||||
/>
|
|
||||||
<div className={styles.body}>
|
|
||||||
<Text className={text.title}>{h.title}</Text>
|
|
||||||
<Caption1 className={text.muted}>
|
|
||||||
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(META_SEP)}
|
|
||||||
</Caption1>
|
|
||||||
</div>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<Hint label="Re-download" placement="top" align="end">
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<ArrowClockwiseRegular />}
|
|
||||||
onClick={() => redownload(h)}
|
|
||||||
aria-label="Re-download"
|
|
||||||
/>
|
|
||||||
</Hint>
|
|
||||||
<Hint label="Open file" placement="top" align="end">
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<OpenRegular />}
|
|
||||||
onClick={() => openFile(h.id)}
|
|
||||||
aria-label="Open file"
|
|
||||||
/>
|
|
||||||
</Hint>
|
|
||||||
<Hint label="Show in folder" placement="top" align="end">
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<FolderRegular />}
|
|
||||||
onClick={() => showInFolder(h.id)}
|
|
||||||
aria-label="Show in folder"
|
|
||||||
/>
|
|
||||||
</Hint>
|
|
||||||
<Hint label="Remove from history" placement="top" align="end">
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<DeleteRegular />}
|
|
||||||
onClick={() => remove(h.id)}
|
|
||||||
aria-label="Remove from history"
|
|
||||||
/>
|
|
||||||
</Hint>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,266 +505,270 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={mergeClasses(styles.root, screen.width)}>
|
<div className={screen.page}>
|
||||||
<ScreenHeader
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
title="Library"
|
<ScreenHeader
|
||||||
description="Add a channel or playlist once, then download its videos into organized folders."
|
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 && (
|
<div className={styles.addRow}>
|
||||||
<LinkSuggestion
|
<Input
|
||||||
prefix="Use copied link? "
|
className={styles.addInput}
|
||||||
link={clip.suggestion}
|
input={{ 'aria-label': 'Channel or playlist URL' }}
|
||||||
onAccept={() => {
|
value={url}
|
||||||
const link = clip.accept()
|
onChange={(_, d) => setUrl(d.value)}
|
||||||
if (link) setUrl(link)
|
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
||||||
}}
|
placeholder="Paste a channel or playlist URL to add it…"
|
||||||
onDismiss={clip.dismiss}
|
size="large"
|
||||||
dismissLabel="Dismiss suggested link"
|
contentBefore={<LibraryRegular />}
|
||||||
/>
|
disabled={indexing.active}
|
||||||
)}
|
|
||||||
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{scheduled && (
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
size="small"
|
|
||||||
value={syncTime}
|
|
||||||
onChange={(_, d) => onSyncTimeChange(d.value)}
|
|
||||||
onBlur={onSyncTimeCommit}
|
|
||||||
aria-label="Daily sync time"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{indexing.active && (
|
|
||||||
<div className={styles.progress}>
|
|
||||||
<Spinner size="tiny" />
|
|
||||||
<Text>
|
|
||||||
{indexing.message ?? 'Adding…'}
|
|
||||||
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
|
|
||||||
</Text>
|
|
||||||
{/* B2: a big channel's index walk can take minutes — let the user abort it. */}
|
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="large"
|
||||||
appearance="subtle"
|
appearance="primary"
|
||||||
icon={<DismissRegular />}
|
icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
|
||||||
onClick={cancelIndexing}
|
onClick={onIndex}
|
||||||
|
disabled={!url.trim() || indexing.active}
|
||||||
>
|
>
|
||||||
Cancel
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{error && <Caption1 className={styles.error}>{error}</Caption1>}
|
|
||||||
|
|
||||||
{sources.length === 0 && !indexing.active ? (
|
{clip.suggestion && (
|
||||||
<EmptyState
|
<LinkSuggestion
|
||||||
icon={<LibraryRegular fontSize={ICON.hero} />}
|
prefix="Use copied link? "
|
||||||
message="No channels or playlists yet. Paste one above to add it."
|
link={clip.suggestion}
|
||||||
/>
|
onAccept={() => {
|
||||||
) : (
|
const link = clip.accept()
|
||||||
<div className={styles.list}>
|
if (link) setUrl(link)
|
||||||
{sources.map((src) => (
|
}}
|
||||||
<SourceCard
|
onDismiss={clip.dismiss}
|
||||||
key={src.id}
|
dismissLabel="Dismiss suggested link"
|
||||||
styles={styles}
|
/>
|
||||||
source={src}
|
)}
|
||||||
expanded={selectedSourceId === src.id}
|
|
||||||
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
|
<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}
|
||||||
>
|
>
|
||||||
<div className={styles.detail}>
|
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
|
||||||
<div className={styles.actionRow}>
|
</Button>
|
||||||
<Checkbox
|
</Hint>
|
||||||
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
|
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
|
||||||
onChange={(_, d) => toggleAll(!!d.checked)}
|
<div className={styles.toolbarSpacer} />
|
||||||
label="Select all"
|
<span className={styles.switchRow}>
|
||||||
disabled={actionableItems.length === 0}
|
<Caption1>Auto-download new</Caption1>
|
||||||
/>
|
<Switch
|
||||||
<Caption1 className={text.muted}>
|
checked={autoDownloadNew}
|
||||||
{items.length} videos{META_SEP}
|
onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
|
||||||
{pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
|
aria-label="Auto-download new uploads"
|
||||||
</Caption1>
|
/>
|
||||||
<div className={styles.actionSpacer} />
|
</span>
|
||||||
<SegmentedControl<MediaKind>
|
<span className={styles.switchRow}>
|
||||||
value={enqueueKind}
|
<Caption1>Daily sync</Caption1>
|
||||||
options={[
|
<Switch
|
||||||
{ value: 'video', label: 'Video' },
|
checked={scheduled}
|
||||||
{ value: 'audio', label: 'Audio' }
|
onChange={(_, d) => toggleScheduled(d.checked)}
|
||||||
]}
|
aria-label="Daily background sync"
|
||||||
onChange={setEnqueueKind}
|
/>
|
||||||
ariaLabel="Download as video or audio"
|
{scheduled && (
|
||||||
/>
|
<Input
|
||||||
{selected.size > 0 ? (
|
type="time"
|
||||||
<>
|
size="small"
|
||||||
<Button
|
value={syncTime}
|
||||||
size="small"
|
onChange={(_, d) => onSyncTimeChange(d.value)}
|
||||||
appearance="subtle"
|
onBlur={onSyncTimeCommit}
|
||||||
onClick={() => setSelected(new Set())}
|
aria-label="Daily sync time"
|
||||||
>
|
/>
|
||||||
Clear
|
)}
|
||||||
</Button>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{indexing.active && (
|
||||||
|
<div className={styles.progress}>
|
||||||
|
<Spinner size="tiny" />
|
||||||
|
<Text>
|
||||||
|
{indexing.message ?? 'Adding…'}
|
||||||
|
{indexing.current && indexing.total
|
||||||
|
? ` (${indexing.current}/${indexing.total})`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
{/* B2: a big channel's index walk can take minutes — let the user abort it. */}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={cancelIndexing}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</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
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
icon={<ArrowDownloadRegular />}
|
icon={<ArrowDownloadRegular />}
|
||||||
onClick={downloadSelected}
|
onClick={downloadPending}
|
||||||
disabled={selectedActionable.length === 0}
|
disabled={pendingItems.length === 0}
|
||||||
>
|
>
|
||||||
Download {selectedActionable.length} selected
|
Download {pendingItems.length} pending
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
) : (
|
<span className={styles.switchRow}>
|
||||||
<Button
|
<Caption1>Watch</Caption1>
|
||||||
size="small"
|
<Switch
|
||||||
appearance="primary"
|
checked={!!src.watched}
|
||||||
icon={<ArrowDownloadRegular />}
|
onChange={(_, d) => setWatched(src.id, !!d.checked)}
|
||||||
onClick={downloadPending}
|
aria-label={`Watch ${src.title} for new uploads`}
|
||||||
disabled={pendingItems.length === 0}
|
/>
|
||||||
>
|
</span>
|
||||||
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
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
icon={<DeleteRegular />}
|
icon={<ArrowSyncRegular />}
|
||||||
onClick={() => setConfirmRemoveId(src.id)}
|
onClick={() => reindexSource(src.id)}
|
||||||
|
disabled={indexing.active}
|
||||||
>
|
>
|
||||||
Remove
|
Refresh
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
</SourceCard>
|
||||||
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
|
))}
|
||||||
|
</div>
|
||||||
{flatRows.length > VIRTUALIZE_AT ? (
|
)}
|
||||||
// Big source: a fixed-height, internally-scrolling virtualized
|
</div>
|
||||||
// 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,36 +66,41 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean)
|
const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={mergeClasses(styles.root, screen.width)}>
|
<div className={screen.page}>
|
||||||
<ScreenHeader title="Settings" description="Folders, formats, network, cookies, and more." />
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
<div>
|
<ScreenHeader
|
||||||
<Input
|
title="Settings"
|
||||||
input={{ 'aria-label': 'Search settings' }}
|
description="Folders, formats, network, cookies, and more."
|
||||||
value={search}
|
|
||||||
onChange={(_, d) => setSearch(d.value)}
|
|
||||||
placeholder="Search settings…"
|
|
||||||
contentBefore={<SearchRegular />}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
{noResults && (
|
<div>
|
||||||
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: SPACE.tight }}>
|
<Input
|
||||||
No settings match "{search}".
|
input={{ 'aria-label': 'Search settings' }}
|
||||||
</Caption1>
|
value={search}
|
||||||
)}
|
onChange={(_, d) => setSearch(d.value)}
|
||||||
</div>
|
placeholder="Search settings…"
|
||||||
|
contentBefore={<SearchRegular />}
|
||||||
{CARDS.map((Card, i) => (
|
style={{ width: '100%' }}
|
||||||
<div
|
/>
|
||||||
// Static array, never reordered/filtered (cards only hide) — index key is stable.
|
{noResults && (
|
||||||
key={i}
|
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: SPACE.tight }}>
|
||||||
ref={(el) => {
|
No settings match "{search}".
|
||||||
cardRefs.current[i] = el
|
</Caption1>
|
||||||
}}
|
)}
|
||||||
style={hidden[i] ? { display: 'none' } : undefined}
|
|
||||||
>
|
|
||||||
<Card />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
{CARDS.map((Card, i) => (
|
||||||
|
<div
|
||||||
|
// Static array, never reordered/filtered (cards only hide) — index key is stable.
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
cardRefs.current[i] = el
|
||||||
|
}}
|
||||||
|
style={hidden[i] ? { display: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
<Card />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { SPACE } from './ui/tokens'
|
|||||||
import { useTerminalRun, type LineKind } from './useTerminalRun'
|
import { useTerminalRun, type LineKind } from './useTerminalRun'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
|
// Fills the `fill` shell (L161); the log <pre> below is the screen's scroller.
|
||||||
|
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, flexGrow: 1, minHeight: 0 },
|
||||||
gate: {
|
gate: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -86,87 +87,96 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={mergeClasses(styles.root, screen.width)}>
|
<div className={screen.fill}>
|
||||||
<ScreenHeader
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
title="Terminal"
|
<ScreenHeader
|
||||||
description={
|
title="Terminal"
|
||||||
<>
|
description={
|
||||||
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
<>
|
||||||
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
||||||
</>
|
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
||||||
}
|
</>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{!customCommandEnabled && (
|
{!customCommandEnabled && (
|
||||||
<div className={styles.gate}>
|
<div className={styles.gate}>
|
||||||
<Body1>
|
<Body1>
|
||||||
The terminal runs the bundled yt-dlp with your own arguments — part of custom commands,
|
The terminal runs the bundled yt-dlp with your own arguments — part of custom
|
||||||
which can pass arbitrary yt-dlp flags. Turn it on to use the terminal.
|
commands, which can pass arbitrary yt-dlp flags. Turn it on to use the terminal.
|
||||||
</Body1>
|
</Body1>
|
||||||
<Button
|
|
||||||
appearance="primary"
|
|
||||||
size="small"
|
|
||||||
onClick={() => updateSettings({ customCommandEnabled: true })}
|
|
||||||
>
|
|
||||||
Enable custom commands
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.inputRow}>
|
|
||||||
<div className={styles.inputCol}>
|
|
||||||
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
|
|
||||||
<Textarea
|
|
||||||
textarea={{ 'aria-label': 'yt-dlp arguments' }}
|
|
||||||
value={args}
|
|
||||||
onChange={(_, d) => setArgs(d.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault()
|
|
||||||
run()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="--version (Ctrl+Enter to run)"
|
|
||||||
resize="vertical"
|
|
||||||
disabled={!customCommandEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.buttons}>
|
|
||||||
{running ? (
|
|
||||||
<Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
icon={<PlayRegular />}
|
size="small"
|
||||||
onClick={run}
|
onClick={() => updateSettings({ customCommandEnabled: true })}
|
||||||
disabled={!customCommandEnabled || !args.trim()}
|
|
||||||
>
|
>
|
||||||
Run
|
Enable custom commands
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<DeleteRegular />}
|
|
||||||
onClick={clearLines}
|
|
||||||
disabled={lines.length === 0}
|
|
||||||
aria-label="Clear output"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<pre className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)} ref={logRef}>
|
|
||||||
{lines.length === 0 ? (
|
|
||||||
<EmptyState compact message="No output yet." hint="Run yt-dlp above to see its output." />
|
|
||||||
) : (
|
|
||||||
lines.map((l) => (
|
|
||||||
<div key={l.id} className={lineClass(l.kind)}>
|
|
||||||
{l.text}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</pre>
|
|
||||||
|
<div className={styles.inputRow}>
|
||||||
|
<div className={styles.inputCol}>
|
||||||
|
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
|
||||||
|
<Textarea
|
||||||
|
textarea={{ 'aria-label': 'yt-dlp arguments' }}
|
||||||
|
value={args}
|
||||||
|
onChange={(_, d) => setArgs(d.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="--version (Ctrl+Enter to run)"
|
||||||
|
resize="vertical"
|
||||||
|
disabled={!customCommandEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
{running ? (
|
||||||
|
<Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
icon={<PlayRegular />}
|
||||||
|
onClick={run}
|
||||||
|
disabled={!customCommandEnabled || !args.trim()}
|
||||||
|
>
|
||||||
|
Run
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={clearLines}
|
||||||
|
disabled={lines.length === 0}
|
||||||
|
aria-label="Clear output"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre
|
||||||
|
className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)}
|
||||||
|
ref={logRef}
|
||||||
|
>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
compact
|
||||||
|
message="No output yet."
|
||||||
|
hint="Run yt-dlp above to see its output."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
lines.map((l) => (
|
||||||
|
<div key={l.id} className={lineClass(l.kind)}>
|
||||||
|
{l.text}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-components'
|
import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-components'
|
||||||
|
import { SPACE } from './tokens'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One shared content max-width for every screen (UI1).
|
* One shared content max-width for every screen (UI1), plus the screen scroll
|
||||||
|
* contract (L161).
|
||||||
*
|
*
|
||||||
* Settings used to be a 640px column while the list screens (Downloads, Library,
|
* Settings used to be a 640px column while the list screens (Downloads, Library,
|
||||||
* History, Terminal) were full-width, so on a wide window Settings read as an odd
|
* History, Terminal) were full-width, so on a wide window Settings read as an odd
|
||||||
@@ -10,7 +12,19 @@ import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-compone
|
|||||||
* is already under the cap, so the list screens are visually unchanged — only the
|
* is already under the cap, so the list screens are visually unchanged — only the
|
||||||
* wide-window upper bound is now shared.
|
* wide-window upper bound is now shared.
|
||||||
*
|
*
|
||||||
* Merge `screen.width` onto each screen's existing root with `mergeClasses`.
|
* Scroll contract (L161): App's `<main>` is a non-scrolling flex column — each
|
||||||
|
* screen owns its scrolling through one of two shells, so a page scroll and an
|
||||||
|
* internal list scroll can never stack:
|
||||||
|
*
|
||||||
|
* - `page` — document screens (Library, Settings, empty History): the shell
|
||||||
|
* scrolls; content is natural height.
|
||||||
|
* - `fill` — internally-scrolling screens (Downloads, Terminal, History): the
|
||||||
|
* shell fills the viewport and hides overflow; the screen's own list/log
|
||||||
|
* region (VirtualList, the terminal <pre>) is the only scroller.
|
||||||
|
*
|
||||||
|
* Usage: shell div outside, `mergeClasses(styles.root, screen.width)` inside.
|
||||||
|
* Inside a `fill` shell the inner root uses `flexGrow: 1; minHeight: 0` (not
|
||||||
|
* `height: 100%`, which is what coupled screens to `<main>`'s padding before).
|
||||||
*/
|
*/
|
||||||
export const useScreenStyles = makeStyles({
|
export const useScreenStyles = makeStyles({
|
||||||
width: {
|
width: {
|
||||||
@@ -18,6 +32,25 @@ export const useScreenStyles = makeStyles({
|
|||||||
maxWidth: '1200px',
|
maxWidth: '1200px',
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
marginRight: 'auto'
|
marginRight: 'auto'
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: SPACE.page
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
// Normally content fits and only the screen's internal list/log scrolls; on a
|
||||||
|
// very short window the fixed-height rows above the list (header, download
|
||||||
|
// bar) plus the list's min-height floor overflow, and the shell scrolls as
|
||||||
|
// the fallback instead of the floor collapsing to 0px.
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
padding: SPACE.page
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user