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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aerofetch",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"description": "A yt-dlp frontend for Windows",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "AeroFetch",
|
||||
|
||||
@@ -151,6 +151,8 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
|
||||
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
||||
const api = {
|
||||
/** AeroFetch's own version string (e.g. '0.3.1'). */
|
||||
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
|
||||
|
||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
||||
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
||||
import { Sidebar, type TabValue } from './components/Sidebar'
|
||||
import { DownloadsView } from './components/DownloadsView'
|
||||
@@ -35,6 +35,24 @@ function App(): React.JSX.Element {
|
||||
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
|
||||
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
|
||||
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
|
||||
// by a one-frame flash of the (default-false) onboarding state.
|
||||
const loaded = useSettings((s) => s.loaded)
|
||||
@@ -60,9 +78,12 @@ function App(): React.JSX.Element {
|
||||
<Sidebar
|
||||
tab={tab}
|
||||
onTabChange={setTab}
|
||||
theme={theme}
|
||||
isDark={isDark}
|
||||
followingSystem={theme === 'system'}
|
||||
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
|
||||
onSetTheme={(mode) => updateSettings({ theme: mode })}
|
||||
version={version}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={toggleCollapsed}
|
||||
/>
|
||||
|
||||
<main className={styles.content}>
|
||||
|
||||
@@ -30,13 +30,15 @@ const useStyles = makeStyles({
|
||||
},
|
||||
top: { bottom: '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 },
|
||||
alignEnd: { right: 0 }
|
||||
})
|
||||
|
||||
interface HintProps {
|
||||
label: string
|
||||
placement?: 'top' | 'bottom'
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
align?: 'start' | 'end'
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -56,8 +58,16 @@ export function Hint({
|
||||
aria-hidden
|
||||
className={mergeClasses(
|
||||
styles.bubble,
|
||||
placement === 'bottom' ? styles.bottom : styles.top,
|
||||
align === 'end' ? styles.alignEnd : styles.alignStart
|
||||
placement === 'bottom'
|
||||
? 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}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Caption1,
|
||||
Switch,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import {
|
||||
ArrowDownloadFilled,
|
||||
ArrowDownloadRegular,
|
||||
@@ -13,8 +6,13 @@ import {
|
||||
LibraryRegular,
|
||||
SettingsRegular,
|
||||
WeatherMoonRegular,
|
||||
WeatherSunnyRegular
|
||||
WeatherSunnyRegular,
|
||||
DesktopRegular,
|
||||
PanelLeftContractRegular,
|
||||
PanelLeftExpandRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { ThemeMode } from '@shared/ipc'
|
||||
import { Hint } from './Hint'
|
||||
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
|
||||
|
||||
@@ -27,13 +25,48 @@ const useStyles = makeStyles({
|
||||
gap: '4px',
|
||||
padding: '16px 12px',
|
||||
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: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '11px',
|
||||
padding: '6px 10px 14px'
|
||||
padding: '0 10px 14px'
|
||||
},
|
||||
brandCollapsed: {
|
||||
padding: '0 0 12px',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
mark: {
|
||||
width: '36px',
|
||||
@@ -64,7 +97,8 @@ const useStyles = makeStyles({
|
||||
nav: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '3px'
|
||||
gap: '3px',
|
||||
alignSelf: 'stretch'
|
||||
},
|
||||
navItem: {
|
||||
display: 'flex',
|
||||
@@ -84,6 +118,10 @@ const useStyles = makeStyles({
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
navItemCollapsed: {
|
||||
justifyContent: 'center',
|
||||
padding: '9px 0'
|
||||
},
|
||||
navItemActive: {
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2,
|
||||
@@ -94,22 +132,46 @@ const useStyles = makeStyles({
|
||||
},
|
||||
navIcon: {
|
||||
fontSize: '18px',
|
||||
flexShrink: 0
|
||||
flexShrink: 0,
|
||||
display: 'flex'
|
||||
},
|
||||
spacer: {
|
||||
flexGrow: 1
|
||||
},
|
||||
themeRow: {
|
||||
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
|
||||
themeGroup: {
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
color: tokens.colorNeutralForeground3
|
||||
width: '100%',
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
overflow: 'hidden'
|
||||
},
|
||||
themeLabel: {
|
||||
themeSeg: {
|
||||
flex: 1,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground2,
|
||||
display: 'flex',
|
||||
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 /> }
|
||||
]
|
||||
|
||||
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
|
||||
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
|
||||
followingSystem: boolean
|
||||
onToggleTheme: () => void
|
||||
onSetTheme: (mode: ThemeMode) => void
|
||||
/** AeroFetch version string, e.g. '0.3.2' ('' until loaded) */
|
||||
version: string
|
||||
collapsed: boolean
|
||||
onToggleCollapsed: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
tab,
|
||||
onTabChange,
|
||||
theme,
|
||||
isDark,
|
||||
followingSystem,
|
||||
onToggleTheme
|
||||
onSetTheme,
|
||||
version,
|
||||
collapsed,
|
||||
onToggleCollapsed
|
||||
}: SidebarProps): React.JSX.Element {
|
||||
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 (
|
||||
<nav className={styles.root}>
|
||||
<div className={styles.brand}>
|
||||
<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">
|
||||
<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}>
|
||||
<ArrowDownloadFilled />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className={styles.brandText}>
|
||||
<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 className={styles.nav}>
|
||||
{NAV.map((n) => {
|
||||
const active = tab === n.value
|
||||
return (
|
||||
const btn = (
|
||||
<button
|
||||
key={n.value}
|
||||
type="button"
|
||||
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
|
||||
className={mergeClasses(
|
||||
styles.navItem,
|
||||
collapsed && styles.navItemCollapsed,
|
||||
active && styles.navItemActive
|
||||
)}
|
||||
style={
|
||||
active
|
||||
active && !collapsed
|
||||
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onTabChange(n.value)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-label={n.label}
|
||||
>
|
||||
<span className={styles.navIcon}>{n.icon}</span>
|
||||
{n.label}
|
||||
{!collapsed && n.label}
|
||||
</button>
|
||||
)
|
||||
return collapsed ? (
|
||||
<Hint key={n.value} label={n.label} placement="right">
|
||||
{btn}
|
||||
</Hint>
|
||||
) : (
|
||||
btn
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.spacer} />
|
||||
|
||||
<div className={styles.themeRow}>
|
||||
<span className={styles.themeLabel}>
|
||||
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
|
||||
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
|
||||
</span>
|
||||
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
|
||||
{collapsed ? (
|
||||
<Hint label={`Theme: ${themeLabel}`} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
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>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ if (import.meta.env.DEV && !window.api) {
|
||||
]
|
||||
|
||||
window.api = {
|
||||
getAppVersion: async () => '0.3.2-preview',
|
||||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||
probe: async (url: string) => {
|
||||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
export const IpcChannels = {
|
||||
/** the AeroFetch app version (package.json / app.getVersion) */
|
||||
appVersion: 'app:version',
|
||||
ytdlpVersion: 'ytdlp:version',
|
||||
probe: 'media:probe',
|
||||
downloadStart: 'download:start',
|
||||
|
||||
Reference in New Issue
Block a user