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 {
|
||||
Input,
|
||||
Button,
|
||||
Caption1,
|
||||
Subtitle2,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
makeStyles,
|
||||
@@ -29,26 +31,56 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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
|
||||
// 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 = [
|
||||
DownloadsCard,
|
||||
AppearanceCard,
|
||||
PostProcessingCard,
|
||||
NetworkCard,
|
||||
CookiesCard,
|
||||
CustomCommandsCard,
|
||||
FilenamesCard,
|
||||
BackupCard,
|
||||
DiagnosticsCard,
|
||||
SoftwareUpdateCard,
|
||||
AboutCard
|
||||
type CardComponent = () => React.JSX.Element
|
||||
|
||||
/**
|
||||
* The cards, grouped into stable sections (UX7) with a jump nav — Settings used
|
||||
* to be one unsegmented ~11-card scroll. Related cards are co-located: the
|
||||
* Downloads section holds the format defaults AND the post-processing card so
|
||||
* the two halves of "format" sit together (UX8). Cards render inside
|
||||
* React-owned wrappers so the search filter controls each card's visibility via
|
||||
* state instead of mutating the card's own DOM `style` (M14).
|
||||
*/
|
||||
const SECTIONS: { id: string; title: string; cards: CardComponent[] }[] = [
|
||||
{ id: 'downloads', title: 'Downloads', cards: [DownloadsCard, PostProcessingCard, FilenamesCard] },
|
||||
{ id: 'appearance', title: 'Appearance', cards: [AppearanceCard] },
|
||||
{ id: 'network', title: 'Network & accounts', cards: [NetworkCard, CookiesCard] },
|
||||
{ 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 {
|
||||
const styles = useStyles()
|
||||
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).
|
||||
const [search, setSearch] = useState('')
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const sectionRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [hidden, setHidden] = useState<boolean[]>(() => CARDS.map(() => false))
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,6 +109,11 @@ export function SettingsView(): React.JSX.Element {
|
||||
}, [search])
|
||||
|
||||
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 (
|
||||
<div className={screen.page}>
|
||||
@@ -100,6 +138,17 @@ export function SettingsView(): React.JSX.Element {
|
||||
)}
|
||||
</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 ? (
|
||||
// Boot skeleton (UX26): three placeholder cards while persisted settings
|
||||
// load, instead of a flash of fallback values.
|
||||
@@ -109,18 +158,41 @@ export function SettingsView(): React.JSX.Element {
|
||||
))}
|
||||
</Skeleton>
|
||||
) : (
|
||||
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>
|
||||
))
|
||||
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
|
||||
key={section.id}
|
||||
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) => {
|
||||
cardRefs.current[i] = el
|
||||
}}
|
||||
style={hidden[i] ? { display: 'none' } : undefined}
|
||||
>
|
||||
<Card />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function AboutCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<InfoRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">About</Subtitle2>
|
||||
<Subtitle2 as="h3">About</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
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}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<PaintBucketRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Appearance</Subtitle2>
|
||||
<Subtitle2 as="h3">Appearance</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field label="Theme">
|
||||
|
||||
@@ -16,7 +16,7 @@ export function BackupCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Backup & restore</Subtitle2>
|
||||
<Subtitle2 as="h3">Backup & restore</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
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}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<CookiesRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Cookies</Subtitle2>
|
||||
<Subtitle2 as="h3">Cookies</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
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}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<CodeRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Custom commands</Subtitle2>
|
||||
<Subtitle2 as="h3">Custom commands</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
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}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<BugRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Diagnostics</Subtitle2>
|
||||
<Subtitle2 as="h3">Diagnostics</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
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}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<ArrowDownloadRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Downloads</Subtitle2>
|
||||
<Subtitle2 as="h3">Downloads</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
|
||||
@@ -14,7 +14,7 @@ export function FilenamesCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<DocumentRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Filenames</Subtitle2>
|
||||
<Subtitle2 as="h3">Filenames</Subtitle2>
|
||||
</div>
|
||||
<Field
|
||||
label="Filename template"
|
||||
|
||||
@@ -30,7 +30,7 @@ export function NetworkCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<GlobeRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Network</Subtitle2>
|
||||
<Subtitle2 as="h3">Network</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
|
||||
@@ -14,10 +14,14 @@ export function PostProcessingCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<OptionsRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Format & post-processing</Subtitle2>
|
||||
<Subtitle2 as="h3">Post-processing</Subtitle2>
|
||||
</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}>
|
||||
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>
|
||||
|
||||
<DownloadOptionsForm
|
||||
|
||||
@@ -45,7 +45,7 @@ export function SoftwareUpdateCard(): React.JSX.Element {
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<ArrowSyncRegular className={styles.sectionIcon} />
|
||||
<Subtitle2 as="h2">Software update</Subtitle2>
|
||||
<Subtitle2 as="h3">Software update</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
{appVersion
|
||||
|
||||
Reference in New Issue
Block a user