661a4e7572
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>
314 lines
9.3 KiB
TypeScript
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>
|
|
)
|
|
}
|