Show app version in sidebar, make the sidebar collapsible, and add a Light/Dark/Auto theme switch

- Expose the AeroFetch version over IPC (app:version → app.getVersion); the
  sidebar brand now shows "v<version>" beneath the name.
- Sidebar can collapse to a 60px icon-only rail via a toggle button; nav items
  and the theme control fall back to icon + tooltip when collapsed. The state is
  persisted in localStorage so it survives restarts. Hint gains left/right
  placements for the collapsed-rail tooltips.
- Replace the binary dark-mode Switch with an explicit 3-way Light / Dark / Auto
  segmented control (Auto follows the OS). Collapsed, it becomes a single button
  that cycles the three modes.
- Bump version to 0.3.2.

Verified in the browser UI preview: version label, collapse/expand, dark mode,
and the theme switch all render correctly with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 06:43:44 -04:00
parent 37687f9870
commit 2718624828
8 changed files with 232 additions and 49 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aerofetch", "name": "aerofetch",
"version": "0.3.1", "version": "0.3.2",
"description": "A yt-dlp frontend for Windows", "description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "AeroFetch", "author": "AeroFetch",
+2
View File
@@ -151,6 +151,8 @@ function createWindow(): void {
} }
function registerIpcHandlers(): void { function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion()) ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url)) ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
+3
View File
@@ -28,6 +28,9 @@ import {
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC. // The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
const api = { const api = {
/** AeroFetch's own version string (e.g. '0.3.1'). */
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
getYtdlpVersion: (): Promise<YtdlpVersionResult> => getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion), ipcRenderer.invoke(IpcChannels.ytdlpVersion),
+24 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components' import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar, type TabValue } from './components/Sidebar' import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView' import { DownloadsView } from './components/DownloadsView'
@@ -35,6 +35,24 @@ function App(): React.JSX.Element {
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors) const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark' const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
const [tab, setTab] = useState<TabValue>('downloads') const [tab, setTab] = useState<TabValue>('downloads')
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('')
useEffect(() => {
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
}, [])
// Sidebar collapse, persisted across launches in localStorage.
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem('aerofetch.sidebarCollapsed') === '1'
)
function toggleCollapsed(): void {
setCollapsed((c) => {
const next = !c
localStorage.setItem('aerofetch.sidebarCollapsed', next ? '1' : '0')
return next
})
}
// Gate on `loaded` so a returning user's real settings never get clobbered // Gate on `loaded` so a returning user's real settings never get clobbered
// by a one-frame flash of the (default-false) onboarding state. // by a one-frame flash of the (default-false) onboarding state.
const loaded = useSettings((s) => s.loaded) const loaded = useSettings((s) => s.loaded)
@@ -60,9 +78,12 @@ function App(): React.JSX.Element {
<Sidebar <Sidebar
tab={tab} tab={tab}
onTabChange={setTab} onTabChange={setTab}
theme={theme}
isDark={isDark} isDark={isDark}
followingSystem={theme === 'system'} onSetTheme={(mode) => updateSettings({ theme: mode })}
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })} version={version}
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
/> />
<main className={styles.content}> <main className={styles.content}>
+13 -3
View File
@@ -30,13 +30,15 @@ const useStyles = makeStyles({
}, },
top: { bottom: 'calc(100% + 6px)' }, top: { bottom: 'calc(100% + 6px)' },
bottom: { top: 'calc(100% + 6px)' }, bottom: { top: 'calc(100% + 6px)' },
right: { left: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
left: { right: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
alignStart: { left: 0 }, alignStart: { left: 0 },
alignEnd: { right: 0 } alignEnd: { right: 0 }
}) })
interface HintProps { interface HintProps {
label: string label: string
placement?: 'top' | 'bottom' placement?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'end' align?: 'start' | 'end'
children: React.ReactNode children: React.ReactNode
} }
@@ -56,8 +58,16 @@ export function Hint({
aria-hidden aria-hidden
className={mergeClasses( className={mergeClasses(
styles.bubble, styles.bubble,
placement === 'bottom' ? styles.bottom : styles.top, placement === 'bottom'
align === 'end' ? styles.alignEnd : styles.alignStart ? styles.bottom
: placement === 'right'
? styles.right
: placement === 'left'
? styles.left
: styles.top,
// start/end alignment only applies to vertical (top/bottom) placements
(placement === 'top' || placement === 'bottom') &&
(align === 'end' ? styles.alignEnd : styles.alignStart)
)} )}
> >
{label} {label}
+182 -38
View File
@@ -1,11 +1,4 @@
import { import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
Caption1,
Switch,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import { import {
ArrowDownloadFilled, ArrowDownloadFilled,
ArrowDownloadRegular, ArrowDownloadRegular,
@@ -13,8 +6,13 @@ import {
LibraryRegular, LibraryRegular,
SettingsRegular, SettingsRegular,
WeatherMoonRegular, WeatherMoonRegular,
WeatherSunnyRegular WeatherSunnyRegular,
DesktopRegular,
PanelLeftContractRegular,
PanelLeftExpandRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint'
export type TabValue = 'downloads' | 'library' | 'history' | 'settings' export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
@@ -27,13 +25,48 @@ const useStyles = makeStyles({
gap: '4px', gap: '4px',
padding: '16px 12px', padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1, backgroundColor: tokens.colorNeutralBackground1,
borderRight: `1px solid ${tokens.colorNeutralStroke2}` borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
transition: 'width 0.15s ease'
},
rootCollapsed: {
width: '60px',
alignItems: 'center'
},
topBar: {
display: 'flex',
justifyContent: 'flex-end',
paddingBottom: '2px'
},
topBarCollapsed: {
justifyContent: 'center'
},
iconBtn: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
fontSize: '18px',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
}
}, },
brand: { brand: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '11px', gap: '11px',
padding: '6px 10px 14px' padding: '0 10px 14px'
},
brandCollapsed: {
padding: '0 0 12px',
justifyContent: 'center'
}, },
mark: { mark: {
width: '36px', width: '36px',
@@ -64,7 +97,8 @@ const useStyles = makeStyles({
nav: { nav: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '3px' gap: '3px',
alignSelf: 'stretch'
}, },
navItem: { navItem: {
display: 'flex', display: 'flex',
@@ -84,6 +118,10 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1Hover backgroundColor: tokens.colorNeutralBackground1Hover
} }
}, },
navItemCollapsed: {
justifyContent: 'center',
padding: '9px 0'
},
navItemActive: { navItemActive: {
backgroundColor: tokens.colorBrandBackground2, backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2, color: tokens.colorBrandForeground2,
@@ -94,22 +132,46 @@ const useStyles = makeStyles({
}, },
navIcon: { navIcon: {
fontSize: '18px', fontSize: '18px',
flexShrink: 0 flexShrink: 0,
display: 'flex'
}, },
spacer: { spacer: {
flexGrow: 1 flexGrow: 1
}, },
themeRow: { // --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
themeGroup: {
alignSelf: 'stretch',
display: 'flex', display: 'flex',
alignItems: 'center', width: '100%',
justifyContent: 'space-between', border: `1px solid ${tokens.colorNeutralStroke1}`,
padding: '8px 12px', ...shorthands.borderRadius(tokens.borderRadiusMedium),
color: tokens.colorNeutralForeground3 overflow: 'hidden'
}, },
themeLabel: { themeSeg: {
flex: 1,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '9px' justifyContent: 'center',
gap: '5px',
padding: '7px 4px',
fontSize: tokens.fontSizeBase200,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
themeSegActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
}
} }
}) })
@@ -120,67 +182,149 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> } { 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 { interface SidebarProps {
tab: TabValue tab: TabValue
onTabChange: (t: TabValue) => void 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 isDark: boolean
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */ onSetTheme: (mode: ThemeMode) => void
followingSystem: boolean /** AeroFetch version string, e.g. '0.3.2' ('' until loaded) */
onToggleTheme: () => void version: string
collapsed: boolean
onToggleCollapsed: () => void
} }
export function Sidebar({ export function Sidebar({
tab, tab,
onTabChange, onTabChange,
theme,
isDark, isDark,
followingSystem, onSetTheme,
onToggleTheme version,
collapsed,
onToggleCollapsed
}: SidebarProps): React.JSX.Element { }: SidebarProps): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
// Collapsed view shows one button that cycles Light → Dark → Auto.
const order: ThemeMode[] = ['light', 'dark', 'system']
function cycleTheme(): void {
onSetTheme(order[(order.indexOf(theme) + 1) % order.length])
}
const themeIcon =
theme === 'system' ? (
<DesktopRegular fontSize={18} />
) : isDark ? (
<WeatherMoonRegular fontSize={18} />
) : (
<WeatherSunnyRegular fontSize={18} />
)
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
return ( return (
<nav className={styles.root}> <nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
<div className={styles.brand}> <div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
<button
type="button"
className={styles.iconBtn}
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
>
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
</button>
</Hint>
</div>
<div className={mergeClasses(styles.brand, collapsed && styles.brandCollapsed)}>
<div className={styles.mark}> <div className={styles.mark}>
<ArrowDownloadFilled /> <ArrowDownloadFilled />
</div> </div>
{!collapsed && (
<div className={styles.brandText}> <div className={styles.brandText}>
<span className={styles.brandName}>AeroFetch</span> <span className={styles.brandName}>AeroFetch</span>
<Caption1 className={styles.caption}>yt-dlp frontend</Caption1> <Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
</div> </div>
)}
</div> </div>
<div className={styles.nav}> <div className={styles.nav}>
{NAV.map((n) => { {NAV.map((n) => {
const active = tab === n.value const active = tab === n.value
return ( const btn = (
<button <button
key={n.value} key={n.value}
type="button" type="button"
className={mergeClasses(styles.navItem, active && styles.navItemActive)} className={mergeClasses(
styles.navItem,
collapsed && styles.navItemCollapsed,
active && styles.navItemActive
)}
style={ style={
active active && !collapsed
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` } ? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
: undefined : undefined
} }
onClick={() => onTabChange(n.value)} onClick={() => onTabChange(n.value)}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
aria-label={n.label}
> >
<span className={styles.navIcon}>{n.icon}</span> <span className={styles.navIcon}>{n.icon}</span>
{n.label} {!collapsed && n.label}
</button> </button>
) )
return collapsed ? (
<Hint key={n.value} label={n.label} placement="right">
{btn}
</Hint>
) : (
btn
)
})} })}
</div> </div>
<div className={styles.spacer} /> <div className={styles.spacer} />
<div className={styles.themeRow}> {collapsed ? (
<span className={styles.themeLabel}> <Hint label={`Theme: ${themeLabel}`} placement="right">
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />} <button
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')} type="button"
</span> className={styles.iconBtn}
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" /> onClick={cycleTheme}
aria-label={`Theme: ${themeLabel}. Click to change.`}
>
{themeIcon}
</button>
</Hint>
) : (
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
{THEMES.map((t) => {
const on = theme === t.value
return (
<button
key={t.value}
type="button"
role="radio"
aria-checked={on}
className={mergeClasses(styles.themeSeg, on && styles.themeSegActive)}
onClick={() => onSetTheme(t.value)}
>
<span className={styles.navIcon}>{t.icon}</span>
{t.label}
</button>
)
})}
</div> </div>
)}
</nav> </nav>
) )
} }
+1
View File
@@ -45,6 +45,7 @@ if (import.meta.env.DEV && !window.api) {
] ]
window.api = { window.api = {
getAppVersion: async () => '0.3.2-preview',
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }), getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
probe: async (url: string) => { probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency await new Promise((r) => setTimeout(r, 700)) // simulate network latency
+2
View File
@@ -4,6 +4,8 @@
*/ */
export const IpcChannels = { export const IpcChannels = {
/** the AeroFetch app version (package.json / app.getVersion) */
appVersion: 'app:version',
ytdlpVersion: 'ytdlp:version', ytdlpVersion: 'ytdlp:version',
probe: 'media:probe', probe: 'media:probe',
downloadStart: 'download:start', downloadStart: 'download:start',