Files
AeroFetch/src/renderer/src/components/Sidebar.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

314 lines
9.3 KiB
TypeScript

import {
Caption1,
CounterBadge,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
ArrowDownloadRegular,
HistoryRegular,
LibraryRegular,
WindowConsoleRegular,
SettingsRegular,
WeatherMoonRegular,
WeatherSunnyRegular,
DesktopRegular,
PanelLeftContractRegular,
PanelLeftExpandRegular
} from '@fluentui/react-icons'
import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { MOTION, ICON } from './ui/tokens'
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
const useStyles = makeStyles({
root: {
width: '212px',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: '4px',
padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1,
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
// The app's one motion timing, gated globally on prefers-reduced-motion (UI26).
transition: `width ${MOTION.duration} ${MOTION.curve}`
},
rootCollapsed: {
width: '60px',
alignItems: 'center'
},
topBar: {
display: 'flex',
justifyContent: 'flex-end',
paddingBottom: '2px'
},
topBarCollapsed: {
justifyContent: 'center'
},
brand: {
display: 'flex',
alignItems: 'center',
gap: '11px',
padding: '0 10px 14px'
},
brandCollapsed: {
padding: '0 0 12px',
justifyContent: 'center'
},
mark: {
width: '36px',
height: '36px',
flexShrink: 0,
borderRadius: tokens.borderRadiusLarge,
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
fontSize: `${ICON.control}px`
},
brandText: {
display: 'flex',
flexDirection: 'column',
minWidth: 0
},
brandName: {
fontSize: tokens.fontSizeBase400,
fontWeight: tokens.fontWeightSemibold,
lineHeight: tokens.lineHeightBase400,
color: tokens.colorNeutralForeground1
},
nav: {
display: 'flex',
flexDirection: 'column',
gap: '3px',
alignSelf: 'stretch'
},
navItem: {
// relative so the collapsed count badge can pin to the icon's corner.
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: '11px',
width: '100%',
padding: '9px 12px',
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
// Expanded: the active-download count sits at the far right of the nav row.
navBadge: {
marginLeft: 'auto'
},
// Collapsed: pin the count to the top-right corner of the centered icon.
navBadgeCollapsed: {
position: 'absolute',
top: '4px',
right: '8px'
},
navItemCollapsed: {
justifyContent: 'center',
padding: '9px 0'
},
navItemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackground2
}
},
navIcon: {
fontSize: `${ICON.control}px`,
flexShrink: 0,
display: 'flex'
},
spacer: {
flexGrow: 1
}
})
const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
{ value: 'library', label: 'Library', icon: <LibraryRegular /> },
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
]
const THEMES: { value: ThemeMode; label: string; icon: React.JSX.Element }[] = [
{ value: 'light', label: 'Light', icon: <WeatherSunnyRegular /> },
{ value: 'dark', label: 'Dark', icon: <WeatherMoonRegular /> },
{ value: 'system', label: 'Auto', icon: <DesktopRegular /> }
]
interface SidebarProps {
tab: TabValue
onTabChange: (t: TabValue) => void
/** the current theme preference: explicit light/dark, or 'system' to follow the OS */
theme: ThemeMode
/** whether the resolved theme is currently dark (drives the collapsed toggle icon) */
isDark: boolean
onSetTheme: (mode: ThemeMode) => void
/** AeroFetch version string, e.g. '0.3.2' ('' until loaded) */
version: string
collapsed: boolean
onToggleCollapsed: () => void
/** Show the Terminal nav item (only when custom commands are enabled) */
showTerminal: boolean
/** Active (downloading + queued) count, badged on the Downloads nav item (UX9). */
downloadCount: number
}
export function Sidebar({
tab,
onTabChange,
theme,
isDark,
onSetTheme,
version,
collapsed,
onToggleCollapsed,
showTerminal,
downloadCount
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const text = useTextStyles()
// Collapsed view shows one button that cycles Light → Dark → Auto.
const order: ThemeMode[] = ['light', 'dark', 'system']
function cycleTheme(): void {
const next = order[(order.indexOf(theme) + 1) % order.length]
if (next) onSetTheme(next)
}
const themeIcon =
theme === 'system' ? (
<DesktopRegular fontSize={ICON.control} />
) : isDark ? (
<WeatherMoonRegular fontSize={ICON.control} />
) : (
<WeatherSunnyRegular fontSize={ICON.control} />
)
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
return (
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
<IconButton
icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
/>
</Hint>
</div>
<div className={mergeClasses(styles.brand, collapsed && styles.brandCollapsed)}>
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
{!collapsed && (
<div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span>
{/* Stable tagline so the caption never flips from text to a version
string once it loads (L66); the version shows on hover and in
Settings → About. */}
<Caption1 className={text.muted} title={version ? `Version ${version}` : undefined}>
Video downloader
</Caption1>
</div>
)}
</div>
<div className={styles.nav}>
{NAV.filter((n) => n.value !== 'terminal' || showTerminal).map((n) => {
const active = tab === n.value
const btn = (
<button
key={n.value}
type="button"
className={mergeClasses(
styles.navItem,
collapsed && styles.navItemCollapsed,
active && styles.navItemActive,
focus.focusRing
)}
style={
active && !collapsed
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
: undefined
}
onClick={() => onTabChange(n.value)}
aria-current={active ? 'page' : undefined}
aria-label={
n.value === 'downloads' && downloadCount > 0
? `${n.label}, ${downloadCount} active`
: n.label
}
>
<span className={styles.navIcon}>{n.icon}</span>
{!collapsed && n.label}
{n.value === 'downloads' && downloadCount > 0 && (
<CounterBadge
className={collapsed ? styles.navBadgeCollapsed : styles.navBadge}
count={downloadCount}
size="small"
color="brand"
aria-hidden
/>
)}
</button>
)
return collapsed ? (
<Hint key={n.value} label={n.label} placement="right">
{btn}
</Hint>
) : (
btn
)
})}
</div>
<div className={styles.spacer} />
{collapsed ? (
<Hint label={`Theme: ${themeLabel}`} placement="right">
<IconButton
icon={themeIcon}
onClick={cycleTheme}
aria-label={`Change theme (currently ${themeLabel})`}
/>
</Hint>
) : (
<SegmentedControl<ThemeMode>
fitted
value={theme}
options={THEMES}
onChange={onSetTheme}
ariaLabel="Theme"
/>
)}
</nav>
)
}