feat(audit): Batch 19 — Settings information architecture (UX7, UX8, UX23)
UX7: 11 cards grouped into five sections (Downloads, Appearance, Network & accounts, Advanced, App & maintenance) with section headings and a jump nav under the search box; search hides empty sections and the nav; heading hierarchy corrected (h1 page, h2 section, h3 card). UX8: post-processing card co-located under the Downloads card and renamed 'Post-processing' with a defaults-vs-advanced hint. UX23: closed as stale duplicate of the already-fixed L71 (editable, validated, reconciled folder inputs) — verified in the preview probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
|
Button,
|
||||||
Caption1,
|
Caption1,
|
||||||
|
Subtitle2,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
SkeletonItem,
|
SkeletonItem,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
@@ -29,26 +31,56 @@ const useStyles = makeStyles({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: SPACE.section
|
gap: SPACE.section
|
||||||
|
},
|
||||||
|
// The section jump nav (UX7): a row of anchor buttons under the search box.
|
||||||
|
sectionNav: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: SPACE.xtight,
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
},
|
||||||
|
sectionHeading: {
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
paddingTop: SPACE.tight
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: SPACE.section,
|
||||||
|
// Anchor scrolling lands the heading just below the screen padding.
|
||||||
|
scrollMarginTop: SPACE.section
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// The cards, in display order. Rendered inside React-owned wrappers so the search
|
type CardComponent = () => React.JSX.Element
|
||||||
// filter controls each card's visibility via state instead of mutating the card's
|
|
||||||
// own DOM `style` (M14) — which broke if a card ever set its own inline style.
|
/**
|
||||||
const CARDS = [
|
* The cards, grouped into stable sections (UX7) with a jump nav — Settings used
|
||||||
DownloadsCard,
|
* to be one unsegmented ~11-card scroll. Related cards are co-located: the
|
||||||
AppearanceCard,
|
* Downloads section holds the format defaults AND the post-processing card so
|
||||||
PostProcessingCard,
|
* the two halves of "format" sit together (UX8). Cards render inside
|
||||||
NetworkCard,
|
* React-owned wrappers so the search filter controls each card's visibility via
|
||||||
CookiesCard,
|
* state instead of mutating the card's own DOM `style` (M14).
|
||||||
CustomCommandsCard,
|
*/
|
||||||
FilenamesCard,
|
const SECTIONS: { id: string; title: string; cards: CardComponent[] }[] = [
|
||||||
BackupCard,
|
{ id: 'downloads', title: 'Downloads', cards: [DownloadsCard, PostProcessingCard, FilenamesCard] },
|
||||||
DiagnosticsCard,
|
{ id: 'appearance', title: 'Appearance', cards: [AppearanceCard] },
|
||||||
SoftwareUpdateCard,
|
{ id: 'network', title: 'Network & accounts', cards: [NetworkCard, CookiesCard] },
|
||||||
AboutCard
|
{ id: 'advanced', title: 'Advanced', cards: [CustomCommandsCard] },
|
||||||
|
{
|
||||||
|
id: 'app',
|
||||||
|
title: 'App & maintenance',
|
||||||
|
cards: [BackupCard, DiagnosticsCard, SoftwareUpdateCard, AboutCard]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Flat list in render order — the search filter indexes cards globally.
|
||||||
|
const CARDS: CardComponent[] = SECTIONS.flatMap((s) => s.cards)
|
||||||
|
// Each section's [start, end) range into CARDS.
|
||||||
|
const SECTION_RANGES = SECTIONS.map((s, si) => {
|
||||||
|
const start = SECTIONS.slice(0, si).reduce((n, x) => n + x.cards.length, 0)
|
||||||
|
return { start, end: start + s.cards.length }
|
||||||
|
})
|
||||||
|
|
||||||
export function SettingsView(): React.JSX.Element {
|
export function SettingsView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const screen = useScreenStyles()
|
const screen = useScreenStyles()
|
||||||
@@ -62,6 +94,7 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
// visibility, rather than us writing `display:none` onto React's own card nodes (M14).
|
// visibility, rather than us writing `display:none` onto React's own card nodes (M14).
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
|
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
const sectionRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||||
const [hidden, setHidden] = useState<boolean[]>(() => CARDS.map(() => false))
|
const [hidden, setHidden] = useState<boolean[]>(() => CARDS.map(() => false))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,6 +109,11 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
}, [search])
|
}, [search])
|
||||||
|
|
||||||
const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean)
|
const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean)
|
||||||
|
const searching = search.trim() !== ''
|
||||||
|
|
||||||
|
function jumpTo(si: number): void {
|
||||||
|
sectionRefs.current[si]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={screen.page}>
|
<div className={screen.page}>
|
||||||
@@ -100,6 +138,17 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Section jump nav (UX7). Hidden while searching — the filter is the finder then. */}
|
||||||
|
{loaded && !searching && (
|
||||||
|
<nav className={styles.sectionNav} aria-label="Settings sections">
|
||||||
|
{SECTIONS.map((s, si) => (
|
||||||
|
<Button key={s.id} size="small" appearance="subtle" onClick={() => jumpTo(si)}>
|
||||||
|
{s.title}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
{!loaded ? (
|
{!loaded ? (
|
||||||
// Boot skeleton (UX26): three placeholder cards while persisted settings
|
// Boot skeleton (UX26): three placeholder cards while persisted settings
|
||||||
// load, instead of a flash of fallback values.
|
// load, instead of a flash of fallback values.
|
||||||
@@ -109,10 +158,29 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
) : (
|
) : (
|
||||||
CARDS.map((Card, i) => (
|
SECTIONS.map((section, si) => {
|
||||||
|
const range = SECTION_RANGES[si]!
|
||||||
|
// A section disappears with all of its cards during a search.
|
||||||
|
const sectionHidden = section.cards.every((_, ci) => hidden[range.start + ci])
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
// Static array, never reordered/filtered (cards only hide) — index key is stable.
|
key={section.id}
|
||||||
key={i}
|
ref={(el) => {
|
||||||
|
sectionRefs.current[si] = el
|
||||||
|
}}
|
||||||
|
className={styles.section}
|
||||||
|
style={sectionHidden ? { display: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
<Subtitle2 as="h2" className={styles.sectionHeading}>
|
||||||
|
{section.title}
|
||||||
|
</Subtitle2>
|
||||||
|
{section.cards.map((Card, ci) => {
|
||||||
|
const i = range.start + ci
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
// Static arrays, never reordered/filtered (cards only hide) —
|
||||||
|
// index keys are stable.
|
||||||
|
key={ci}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
cardRefs.current[i] = el
|
cardRefs.current[i] = el
|
||||||
}}
|
}}
|
||||||
@@ -120,7 +188,11 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<Card />
|
<Card />
|
||||||
</div>
|
</div>
|
||||||
))
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function AboutCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<InfoRegular className={styles.sectionIcon} />
|
<InfoRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">About</Subtitle2>
|
<Subtitle2 as="h3">About</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
|
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function AppearanceCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<PaintBucketRegular className={styles.sectionIcon} />
|
<PaintBucketRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Appearance</Subtitle2>
|
<Subtitle2 as="h3">Appearance</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Theme">
|
<Field label="Theme">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function BackupCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Backup & restore</Subtitle2>
|
<Subtitle2 as="h3">Backup & restore</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Save your settings and custom-command templates to a JSON file, or restore them on another
|
Save your settings and custom-command templates to a JSON file, or restore them on another
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function CookiesCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<CookiesRegular className={styles.sectionIcon} />
|
<CookiesRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Cookies</Subtitle2>
|
<Subtitle2 as="h3">Cookies</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
|
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function CustomCommandsCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<CodeRegular className={styles.sectionIcon} />
|
<CodeRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Custom commands</Subtitle2>
|
<Subtitle2 as="h3">Custom commands</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function DiagnosticsCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<BugRegular className={styles.sectionIcon} />
|
<BugRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Diagnostics</Subtitle2>
|
<Subtitle2 as="h3">Diagnostics</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Failed downloads are logged here even after you clear the queue, so you can copy the details
|
Failed downloads are logged here even after you clear the queue, so you can copy the details
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function DownloadsCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<ArrowDownloadRegular className={styles.sectionIcon} />
|
<ArrowDownloadRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Downloads</Subtitle2>
|
<Subtitle2 as="h3">Downloads</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function FilenamesCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<DocumentRegular className={styles.sectionIcon} />
|
<DocumentRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Filenames</Subtitle2>
|
<Subtitle2 as="h3">Filenames</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
label="Filename template"
|
label="Filename template"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function NetworkCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<GlobeRegular className={styles.sectionIcon} />
|
<GlobeRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Network</Subtitle2>
|
<Subtitle2 as="h3">Network</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ export function PostProcessingCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<OptionsRegular className={styles.sectionIcon} />
|
<OptionsRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Format & post-processing</Subtitle2>
|
<Subtitle2 as="h3">Post-processing</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
|
{/* UX8: this card is the advanced half — the default format itself (video/
|
||||||
|
audio + quality) lives in the Downloads card, co-located just above. */}
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
|
Advanced conversion applied to every new download — container, codec, subtitles,
|
||||||
|
SponsorBlock (yt-dlp and ffmpeg do the work). The default format itself is set in the
|
||||||
|
Downloads card above.
|
||||||
</Caption1>
|
</Caption1>
|
||||||
|
|
||||||
<DownloadOptionsForm
|
<DownloadOptionsForm
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function SoftwareUpdateCard(): React.JSX.Element {
|
|||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<ArrowSyncRegular className={styles.sectionIcon} />
|
<ArrowSyncRegular className={styles.sectionIcon} />
|
||||||
<Subtitle2 as="h2">Software update</Subtitle2>
|
<Subtitle2 as="h3">Software update</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
{appVersion
|
{appVersion
|
||||||
|
|||||||
Reference in New Issue
Block a user