H1: decompose SettingsView.tsx into one component per card
Split the 1104-line SettingsView (~11 cards, ~30 selector subs, ~20 useState) into components/settings/*.tsx, each owning its own store subscriptions, local state, and handlers: - DownloadsCard, AppearanceCard, PostProcessingCard, NetworkCard, CookiesCard, CustomCommandsCard, FilenamesCard, BackupCard, DiagnosticsCard, SoftwareUpdateCard, AboutCard - settingsStyles.ts holds the shared makeStyles hook. SettingsView is now a ~90-line shell: the search box + rootRef filter and the card list. Each card still renders exactly one <Card> DOM node, so the display-toggle search filter is unchanged. Behaviour identical; 248 tests + typecheck + lint green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Field,
|
||||
Switch,
|
||||
Button,
|
||||
Card,
|
||||
Subtitle2,
|
||||
Caption1,
|
||||
Text,
|
||||
Spinner,
|
||||
tokens
|
||||
} from '@fluentui/react-components'
|
||||
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
|
||||
import {
|
||||
type YtdlpVersionResult,
|
||||
type YtdlpUpdateChannel,
|
||||
type YtdlpUpdateResult,
|
||||
type FfmpegVersionResult
|
||||
} from '@shared/ipc'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { Select } from '../Select'
|
||||
import { useErrorTextStyles } from '../ui/errorText'
|
||||
import { logError } from '../../reportError'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
const UPDATE_CHANNEL_OPTIONS = [
|
||||
{ value: 'stable', label: 'Stable' },
|
||||
{ value: 'nightly', label: 'Nightly' }
|
||||
]
|
||||
|
||||
export function AboutCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const errText = useErrorTextStyles()
|
||||
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
|
||||
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
|
||||
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
|
||||
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Show the current yt-dlp version without a manual click, and reflect a
|
||||
// background auto-update (which may run on launch) live in this panel.
|
||||
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
|
||||
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
|
||||
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
|
||||
return window.api.onYtdlpAutoUpdateStatus((s) => {
|
||||
if (s.phase === 'checking') {
|
||||
setChecking(true)
|
||||
return
|
||||
}
|
||||
setChecking(false)
|
||||
if (s.version) setVersion({ ok: true, version: s.version })
|
||||
if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
|
||||
if (s.phase === 'updated') {
|
||||
setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
|
||||
} else if (s.phase === 'error' && s.error) {
|
||||
setUpdateResult({ ok: false, error: s.error })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function checkVersion(): Promise<void> {
|
||||
setChecking(true)
|
||||
setVersion(null)
|
||||
try {
|
||||
setVersion(await window.api.getYtdlpVersion())
|
||||
} catch (e) {
|
||||
setVersion({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function runUpdate(): Promise<void> {
|
||||
setUpdating(true)
|
||||
setUpdateResult(null)
|
||||
try {
|
||||
const result = await window.api.updateYtdlp(ytdlpChannel)
|
||||
setUpdateResult(result)
|
||||
if (result.ok) setVersion(null) // stale -- prompt a re-check rather than show a wrong version
|
||||
} catch (e) {
|
||||
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<InfoRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>About</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
|
||||
yt-dlp, keeping it up to date automatically.
|
||||
</Caption1>
|
||||
|
||||
<Field
|
||||
label="Keep yt-dlp updated automatically"
|
||||
hint="Checks once a day on launch and updates the downloader in the background, so site changes don't start breaking your downloads."
|
||||
>
|
||||
<Switch
|
||||
checked={autoUpdateYtdlp}
|
||||
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
|
||||
label={autoUpdateYtdlp ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{version?.ok && (
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
|
||||
)}
|
||||
{version && !version.ok && <Caption1 className={errText.errorPre}>{version.error}</Caption1>}
|
||||
{ffmpeg && (
|
||||
<div className={styles.folderRow}>
|
||||
<div>
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
|
||||
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
|
||||
ffprobe {ffmpeg.ffprobe ?? 'not found'}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() =>
|
||||
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
|
||||
}
|
||||
aria-label="Re-check ffmpeg versions"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{ytdlpLastUpdateCheck > 0 && (
|
||||
<Caption1 className={styles.hint}>
|
||||
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
|
||||
</Caption1>
|
||||
)}
|
||||
<div className={styles.folderRow}>
|
||||
<Button onClick={checkVersion} disabled={checking}>
|
||||
Check yt-dlp version
|
||||
</Button>
|
||||
{checking && <Spinner size="tiny" />}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Update channel"
|
||||
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
|
||||
>
|
||||
<Select
|
||||
aria-label="Update channel"
|
||||
value={ytdlpChannel}
|
||||
options={UPDATE_CHANNEL_OPTIONS}
|
||||
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.folderRow}>
|
||||
<Button
|
||||
icon={updating ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
||||
onClick={runUpdate}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? 'Updating…' : 'Update yt-dlp'}
|
||||
</Button>
|
||||
</div>
|
||||
{updateResult?.ok && (
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace, whiteSpace: 'pre-wrap' }}>
|
||||
{updateResult.output || 'yt-dlp is already up to date.'}
|
||||
</Text>
|
||||
)}
|
||||
{updateResult && !updateResult.ok && (
|
||||
<Caption1 className={errText.errorPre}>{updateResult.error}</Caption1>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Field, Button, Card, Subtitle2, Caption1, mergeClasses } from '@fluentui/react-components'
|
||||
import { PaintBucketRegular, AccessibilityRegular } from '@fluentui/react-icons'
|
||||
import { type ThemeMode, type AccentColor } from '@shared/ipc'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { useSystemTheme } from '../../store/systemTheme'
|
||||
import { Select } from '../Select'
|
||||
import { ACCENT_OPTIONS } from '../../theme'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
const THEME_MODE_OPTIONS = [
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'system', label: 'Follow system' }
|
||||
]
|
||||
|
||||
export function AppearanceCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const theme = useSettings((s) => s.theme)
|
||||
const accentColor = useSettings((s) => s.accentColor)
|
||||
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<PaintBucketRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Appearance</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field label="Theme">
|
||||
<Select
|
||||
aria-label="Theme"
|
||||
value={theme}
|
||||
options={THEME_MODE_OPTIONS}
|
||||
onChange={(v) => update({ theme: v as ThemeMode })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Accent color">
|
||||
<div className={styles.swatchRow}>
|
||||
{ACCENT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={mergeClasses(styles.swatch, accentColor === opt.value && styles.swatchActive)}
|
||||
style={{ backgroundColor: opt.swatch }}
|
||||
onClick={() => update({ accentColor: opt.value as AccentColor })}
|
||||
aria-label={`${opt.label}${accentColor === opt.value ? ' (selected)' : ''}`}
|
||||
title={opt.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Caption1 className={styles.hint}>
|
||||
{highContrast
|
||||
? 'A Windows high-contrast theme is active -- AeroFetch follows your system colors.'
|
||||
: 'AeroFetch automatically follows a Windows high-contrast theme if you turn one on.'}
|
||||
</Caption1>
|
||||
{highContrast && (
|
||||
<div className={styles.folderRow}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<AccessibilityRegular />}
|
||||
onClick={() => window.api.openHighContrastSettings()}
|
||||
>
|
||||
Open accessibility settings
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||
import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons'
|
||||
import { type BackupExportResult, type BackupImportResult } from '@shared/ipc'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { useTemplates } from '../../store/templates'
|
||||
import { useErrorTextStyles } from '../ui/errorText'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
export function BackupCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const errText = useErrorTextStyles()
|
||||
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
|
||||
|
||||
async function exportBackup(): Promise<void> {
|
||||
setExporting(true)
|
||||
setExportResult(null)
|
||||
try {
|
||||
setExportResult(await window.api.exportBackup())
|
||||
} catch (e) {
|
||||
setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function importBackup(): Promise<void> {
|
||||
setImporting(true)
|
||||
setImportResult(null)
|
||||
try {
|
||||
const result = await window.api.importBackup()
|
||||
setImportResult(result)
|
||||
if (result.ok) {
|
||||
// Settings + templates changed underneath the stores -- reload both.
|
||||
const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
|
||||
useSettings.setState({ ...s, loaded: true })
|
||||
useTemplates.setState({ templates: t })
|
||||
}
|
||||
} catch (e) {
|
||||
setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Backup & restore</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Save your settings and custom-command templates to a JSON file, or restore them on another
|
||||
machine. Does not include download history or credentials (proxy, API tokens -- re-enter
|
||||
those after import).
|
||||
</Caption1>
|
||||
|
||||
<div className={styles.folderRow}>
|
||||
<Button icon={<DocumentArrowDownRegular />} onClick={exportBackup} disabled={exporting}>
|
||||
{exporting ? 'Exporting…' : 'Export backup…'}
|
||||
</Button>
|
||||
<Button icon={<DocumentArrowUpRegular />} onClick={importBackup} disabled={importing}>
|
||||
{importing ? 'Importing…' : 'Import backup…'}
|
||||
</Button>
|
||||
</div>
|
||||
{exportResult?.ok && exportResult.path && (
|
||||
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
|
||||
)}
|
||||
{exportResult && !exportResult.ok && exportResult.error && (
|
||||
<Caption1 className={errText.error}>{exportResult.error}</Caption1>
|
||||
)}
|
||||
{importResult?.ok && (
|
||||
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
|
||||
)}
|
||||
{importResult && !importResult.ok && importResult.error && (
|
||||
<Caption1 className={errText.error}>{importResult.error}</Caption1>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Field, Input, Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||
import { CookiesRegular } from '@fluentui/react-icons'
|
||||
import {
|
||||
COOKIE_BROWSERS,
|
||||
type CookieSource,
|
||||
type CookieBrowser,
|
||||
type CookiesStatus
|
||||
} from '@shared/ipc'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { Select } from '../Select'
|
||||
import { useErrorTextStyles } from '../ui/errorText'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
const COOKIE_SOURCE_OPTIONS = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'browser', label: "From a browser's cookie store" },
|
||||
{ value: 'login', label: 'Sign-in window (built into AeroFetch)' }
|
||||
]
|
||||
|
||||
const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
|
||||
value: b,
|
||||
label: b.charAt(0).toUpperCase() + b.slice(1)
|
||||
}))
|
||||
|
||||
export function CookiesCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const errText = useErrorTextStyles()
|
||||
const cookieSource = useSettings((s) => s.cookieSource)
|
||||
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
|
||||
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
const [loginError, setLoginError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.api.cookiesStatus().then(setCookiesStatus)
|
||||
}, [])
|
||||
|
||||
async function signIn(): Promise<void> {
|
||||
setSigningIn(true)
|
||||
setLoginError(null)
|
||||
try {
|
||||
const result = await window.api.cookiesLogin(loginUrl)
|
||||
if (result.ok && result.cookieCount === 0) {
|
||||
// Window closed without capturing anything -- don't imply success (L50).
|
||||
setLoginError('No cookies were captured -- did you sign in before closing the window?')
|
||||
} else if (result.ok) {
|
||||
setCookiesStatus(await window.api.cookiesStatus())
|
||||
} else {
|
||||
setLoginError(result.error ?? 'Sign-in failed.')
|
||||
}
|
||||
} catch (e) {
|
||||
setLoginError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSavedCookies(): Promise<void> {
|
||||
await window.api.cookiesClear()
|
||||
setCookiesStatus({ exists: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<CookiesRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Cookies</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
|
||||
session. Supply cookies so yt-dlp can act like one.
|
||||
</Caption1>
|
||||
|
||||
<Field label="Cookie source">
|
||||
<Select
|
||||
aria-label="Cookie source"
|
||||
value={cookieSource}
|
||||
options={COOKIE_SOURCE_OPTIONS}
|
||||
onChange={(v) => update({ cookieSource: v as CookieSource })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{cookieSource === 'browser' && (
|
||||
<Field
|
||||
label="Browser"
|
||||
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
|
||||
>
|
||||
<Select
|
||||
aria-label="Browser"
|
||||
value={cookiesBrowser}
|
||||
options={COOKIE_BROWSER_OPTIONS}
|
||||
onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{cookieSource === 'login' && (
|
||||
<>
|
||||
<Field
|
||||
label="Site to sign in to"
|
||||
hint="Opens a sign-in window. Log in, then close the window -- your cookies are saved automatically."
|
||||
>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
className={styles.folderInput}
|
||||
value={loginUrl}
|
||||
placeholder="https://www.youtube.com"
|
||||
onChange={(_, d) => setLoginUrl(d.value)}
|
||||
/>
|
||||
<Button appearance="primary" onClick={signIn} disabled={signingIn}>
|
||||
{signingIn ? 'Signing in…' : 'Sign in…'}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className={styles.folderRow}>
|
||||
<Caption1 className={styles.hint}>
|
||||
{cookiesStatus?.exists
|
||||
? `Cookies saved ${new Date(cookiesStatus.savedAt!).toLocaleString()}.`
|
||||
: 'Not signed in yet.'}
|
||||
</Caption1>
|
||||
{cookiesStatus?.exists && (
|
||||
<Button size="small" onClick={clearSavedCookies}>
|
||||
Clear saved cookies
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loginError && <Caption1 className={errText.error}>{loginError}</Caption1>}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Field, Switch, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||
import { CodeRegular } from '@fluentui/react-icons'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { useTemplates } from '../../store/templates'
|
||||
import { Select } from '../Select'
|
||||
import { TemplateManager } from '../TemplateManager'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
export function CustomCommandsCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
||||
const update = useSettings((s) => s.update)
|
||||
const templates = useTemplates((s) => s.templates)
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<CodeRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Custom commands</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
||||
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other
|
||||
option, so a template flag can override a setting above it.
|
||||
</Caption1>
|
||||
|
||||
<Field
|
||||
label="Run custom command"
|
||||
hint="Applies the default template below to every new download (still overridable per download)."
|
||||
>
|
||||
<Switch
|
||||
checked={customCommandEnabled}
|
||||
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
|
||||
label={customCommandEnabled ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{customCommandEnabled && (
|
||||
<Field label="Default template">
|
||||
<Select
|
||||
aria-label="Default template"
|
||||
value={defaultTemplateId ?? 'none'}
|
||||
options={[
|
||||
{ value: 'none', label: 'None' },
|
||||
...templates.map((t) => ({ value: t.id, label: t.name }))
|
||||
]}
|
||||
onChange={(v) => update({ defaultTemplateId: v === 'none' ? null : v })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<TemplateManager />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components'
|
||||
import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons'
|
||||
import { useErrorLog } from '../../store/errorlog'
|
||||
import { useErrorTextStyles } from '../ui/errorText'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
export function DiagnosticsCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const errText = useErrorTextStyles()
|
||||
const errorEntries = useErrorLog((s) => s.entries)
|
||||
const clearErrorLog = useErrorLog((s) => s.clear)
|
||||
|
||||
const [confirmClearLog, setConfirmClearLog] = useState(false)
|
||||
|
||||
function copyErrorReport(): void {
|
||||
// The button is disabled when there are no entries, so `report` is always
|
||||
// non-empty here -- no need for an unreachable empty-report fallback (L159).
|
||||
const report = errorEntries
|
||||
.map((e) =>
|
||||
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
|
||||
)
|
||||
.join('\n\n---\n\n')
|
||||
navigator.clipboard.writeText(report).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<BugRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Diagnostics</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Failed downloads are logged here even after you clear the queue, so you can copy the details
|
||||
into a bug report.
|
||||
</Caption1>
|
||||
|
||||
<div className={styles.folderRow}>
|
||||
<Button icon={<CopyRegular />} onClick={copyErrorReport} disabled={errorEntries.length === 0}>
|
||||
Copy full report
|
||||
</Button>
|
||||
{confirmClearLog ? (
|
||||
<>
|
||||
<Caption1>
|
||||
Clear all {errorEntries.length} {errorEntries.length === 1 ? 'error' : 'errors'}?
|
||||
</Caption1>
|
||||
<Button
|
||||
icon={<DeleteRegular />}
|
||||
appearance="primary"
|
||||
onClick={() => {
|
||||
clearErrorLog()
|
||||
setConfirmClearLog(false)
|
||||
}}
|
||||
>
|
||||
Clear log
|
||||
</Button>
|
||||
<Button appearance="subtle" onClick={() => setConfirmClearLog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => setConfirmClearLog(true)}
|
||||
disabled={errorEntries.length === 0}
|
||||
>
|
||||
Clear log
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorEntries.length === 0 ? (
|
||||
<Caption1 className={styles.hint}>No errors yet.</Caption1>
|
||||
) : (
|
||||
<div className={styles.errorList}>
|
||||
{errorEntries.slice(0, 20).map((e) => (
|
||||
<div key={e.id} className={styles.errorRow}>
|
||||
<div className={styles.errorRowHeader}>
|
||||
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
||||
<Caption1 className={styles.hint}>{new Date(e.occurredAt).toLocaleString()}</Caption1>
|
||||
</div>
|
||||
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
|
||||
</div>
|
||||
))}
|
||||
{errorEntries.length > 20 && (
|
||||
<Caption1 className={styles.hint}>Showing 20 of {errorEntries.length} errors.</Caption1>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Field, Input, SpinButton, Switch, Button, Card, Subtitle2 } from '@fluentui/react-components'
|
||||
import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
|
||||
import { type MediaKind } from '@shared/ipc'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { useDownloads } from '../../store/downloads'
|
||||
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
||||
import { Select } from '../Select'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
// Combined "Type -- Quality" options for the default-format dropdown.
|
||||
const FORMAT_OPTIONS = [
|
||||
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video -- ${q}` })),
|
||||
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio -- ${q}` }))
|
||||
]
|
||||
|
||||
export function DownloadsCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const videoDir = useSettings((s) => s.videoDir)
|
||||
const audioDir = useSettings((s) => s.audioDir)
|
||||
const chooseDir = useSettings((s) => s.chooseDir)
|
||||
const clearDir = useSettings((s) => s.clearDir)
|
||||
const defaultKind = useSettings((s) => s.defaultKind)
|
||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||
const maxConcurrent = useSettings((s) => s.maxConcurrent)
|
||||
const clipboardWatch = useSettings((s) => s.clipboardWatch)
|
||||
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
||||
const minimizeToTray = useSettings((s) => s.minimizeToTray)
|
||||
const launchAtStartup = useSettings((s) => s.launchAtStartup)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
|
||||
const formatValue = `${defaultKind}|${formatQuality}`
|
||||
|
||||
function onFormatSelect(value: string): void {
|
||||
const sep = value.indexOf('|')
|
||||
const kind: MediaKind = sep >= 0 && value.slice(0, sep) === 'audio' ? 'audio' : 'video'
|
||||
const quality = sep >= 0 ? value.slice(sep + 1) : value
|
||||
update(
|
||||
kind === 'audio'
|
||||
? { defaultKind: 'audio', defaultAudioQuality: quality }
|
||||
: { defaultKind: 'video', defaultVideoQuality: quality }
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<ArrowDownloadRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Downloads</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Video folder"
|
||||
hint="Where video downloads are saved. Leave blank to use Documents\Video."
|
||||
>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
className={styles.folderInput}
|
||||
value={videoDir}
|
||||
placeholder="Documents\Video (default)"
|
||||
contentBefore={<FolderRegular />}
|
||||
onChange={(_, d) => update({ videoDir: d.value })}
|
||||
/>
|
||||
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||
Browse
|
||||
</Button>
|
||||
{videoDir && (
|
||||
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Audio folder"
|
||||
hint="Where audio downloads are saved. Leave blank to use Documents\Audio."
|
||||
>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
className={styles.folderInput}
|
||||
value={audioDir}
|
||||
placeholder="Documents\Audio (default)"
|
||||
contentBefore={<FolderRegular />}
|
||||
onChange={(_, d) => update({ audioDir: d.value })}
|
||||
/>
|
||||
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
|
||||
Browse
|
||||
</Button>
|
||||
{audioDir && (
|
||||
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="Default format">
|
||||
<Select
|
||||
aria-label="Default format"
|
||||
value={formatValue}
|
||||
options={FORMAT_OPTIONS}
|
||||
onChange={onFormatSelect}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Maximum simultaneous downloads"
|
||||
hint="How many downloads run at once. 2-3 is a good balance."
|
||||
>
|
||||
<SpinButton
|
||||
value={maxConcurrent}
|
||||
min={1}
|
||||
max={5}
|
||||
onChange={(_, d) => {
|
||||
const v = d.value ?? Number(d.displayValue)
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
update({ maxConcurrent: Math.min(5, Math.max(1, Math.round(v))) })
|
||||
// Raising the cap should start queued items right away.
|
||||
useDownloads.getState().pump()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Detect links from clipboard"
|
||||
hint="When AeroFetch gains focus, offer a video link you've just copied."
|
||||
>
|
||||
<Switch
|
||||
checked={clipboardWatch}
|
||||
onChange={(_, d) => update({ clipboardWatch: d.checked })}
|
||||
label={clipboardWatch ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Notify when downloads finish"
|
||||
hint="Shows a native Windows notification when a download completes or fails."
|
||||
>
|
||||
<Switch
|
||||
checked={notifyOnComplete}
|
||||
onChange={(_, d) => update({ notifyOnComplete: d.checked })}
|
||||
label={notifyOnComplete ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Keep running in the tray"
|
||||
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting -- so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)"
|
||||
>
|
||||
<Switch
|
||||
checked={minimizeToTray}
|
||||
onChange={(_, d) => update({ minimizeToTray: d.checked })}
|
||||
label={minimizeToTray ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Start with Windows"
|
||||
hint="Launch AeroFetch automatically when you sign in -- useful with auto-download so watched channels stay current in the background."
|
||||
>
|
||||
<Switch
|
||||
checked={launchAtStartup}
|
||||
onChange={(_, d) => update({ launchAtStartup: d.checked })}
|
||||
label={launchAtStartup ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Field, Input, Switch, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||
import { DocumentRegular } from '@fluentui/react-icons'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
export function FilenamesCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
||||
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
||||
const downloadArchive = useSettings((s) => s.downloadArchive)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<DocumentRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Filenames</Subtitle2>
|
||||
</div>
|
||||
<Field
|
||||
label="Filename template"
|
||||
hint="Controls how saved files are named. Use %(title)s for the video title, %(ext)s for the extension, and similar tokens."
|
||||
>
|
||||
<Input value={filenameTemplate} onChange={(_, d) => update({ filenameTemplate: d.value })} />
|
||||
</Field>
|
||||
<Caption1 className={styles.hint}>
|
||||
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
|
||||
</Caption1>
|
||||
|
||||
<Field
|
||||
label="Restrict filenames"
|
||||
hint="Sanitize titles to plain ASCII letters/digits, no spaces -- safer for old filesystems and shells."
|
||||
>
|
||||
<Switch
|
||||
checked={restrictFilenames}
|
||||
onChange={(_, d) => update({ restrictFilenames: d.checked })}
|
||||
label={restrictFilenames ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Skip already-downloaded videos"
|
||||
hint="Keeps a record of completed downloads (--download-archive) and skips them on repeat playlist/channel runs."
|
||||
>
|
||||
<Switch
|
||||
checked={downloadArchive}
|
||||
onChange={(_, d) => update({ downloadArchive: d.checked })}
|
||||
label={downloadArchive ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import { Field, Input, Switch, Button, Card, Subtitle2, Spinner } from '@fluentui/react-components'
|
||||
import { GlobeRegular } from '@fluentui/react-icons'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
export function NetworkCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const proxy = useSettings((s) => s.proxy)
|
||||
const rateLimit = useSettings((s) => s.rateLimit)
|
||||
const useAria2c = useSettings((s) => s.useAria2c)
|
||||
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
|
||||
const youtubePoToken = useSettings((s) => s.youtubePoToken)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
const [mintingPot, setMintingPot] = useState(false)
|
||||
const [potHint, setPotHint] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<GlobeRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Network</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Proxy"
|
||||
hint="HTTP/HTTPS/SOCKS proxy URL, e.g. socks5://127.0.0.1:1080. Leave blank to use the system default."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={proxy}
|
||||
placeholder="socks5://127.0.0.1:1080"
|
||||
onChange={(_, d) => update({ proxy: d.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Rate limit"
|
||||
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
|
||||
>
|
||||
<Input value={rateLimit} placeholder="2M" onChange={(_, d) => update({ rateLimit: d.value })} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Use aria2c downloader"
|
||||
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
|
||||
>
|
||||
<Switch
|
||||
checked={useAria2c}
|
||||
onChange={(_, d) => update({ useAria2c: d.checked })}
|
||||
label={useAria2c ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="YouTube client (advanced)"
|
||||
hint="Override how AeroFetch identifies itself to YouTube when downloads start failing. Try web_safari, tv, or mweb. Leave blank for the automatic default."
|
||||
>
|
||||
<datalist id="yt-client-list">
|
||||
{['web', 'web_safari', 'web_embedded', 'mweb', 'tv', 'ios', 'android'].map((c) => (
|
||||
<option key={c} value={c} />
|
||||
))}
|
||||
</datalist>
|
||||
<Input
|
||||
value={youtubePlayerClient}
|
||||
placeholder="web_safari"
|
||||
list="yt-client-list"
|
||||
onChange={(_, d) => update({ youtubePlayerClient: d.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="YouTube PO Token (advanced)"
|
||||
hint={
|
||||
potHint ??
|
||||
'A token that helps get past YouTube\'s bot check. Click "Fetch" to grab one automatically, or paste it manually. Signing in with cookies (above) is usually the easier fix.'
|
||||
}
|
||||
>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
type="password"
|
||||
style={{ flexGrow: 1 }}
|
||||
value={youtubePoToken}
|
||||
placeholder="(none)"
|
||||
onChange={(_, d) => {
|
||||
update({ youtubePoToken: d.value })
|
||||
setPotHint(null)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={mintingPot}
|
||||
icon={mintingPot ? <Spinner size="tiny" /> : undefined}
|
||||
onClick={async () => {
|
||||
setMintingPot(true)
|
||||
setPotHint(null)
|
||||
try {
|
||||
const token = await window.api.mintPoToken()
|
||||
if (token) {
|
||||
setPotHint('Token saved.')
|
||||
} else {
|
||||
setPotHint('Token not found -- try signing into YouTube first via Cookies above.')
|
||||
}
|
||||
} catch {
|
||||
setPotHint('Failed to open YouTube window.')
|
||||
} finally {
|
||||
setMintingPot(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mintingPot ? 'Fetching…' : 'Fetch'}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||
import { OptionsRegular } from '@fluentui/react-icons'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { DownloadOptionsForm } from '../DownloadOptionsForm'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
export function PostProcessingCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const defaultKind = useSettings((s) => s.defaultKind)
|
||||
const downloadOptions = useSettings((s) => s.downloadOptions)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<OptionsRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Format & post-processing</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
|
||||
</Caption1>
|
||||
|
||||
<DownloadOptionsForm
|
||||
value={downloadOptions}
|
||||
kind={defaultKind}
|
||||
onChange={(o) => update({ downloadOptions: o })}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
Button,
|
||||
Card,
|
||||
Subtitle2,
|
||||
Caption1,
|
||||
Text,
|
||||
Spinner,
|
||||
ProgressBar,
|
||||
tokens
|
||||
} from '@fluentui/react-components'
|
||||
import { ArrowSyncRegular, ArrowDownloadRegular, OpenRegular } from '@fluentui/react-icons'
|
||||
import { type AppUpdateInfo } from '@shared/ipc'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { useErrorTextStyles } from '../ui/errorText'
|
||||
import { logError } from '../../reportError'
|
||||
import { useSettingsStyles } from './settingsStyles'
|
||||
|
||||
export function SoftwareUpdateCard(): React.JSX.Element {
|
||||
const styles = useSettingsStyles()
|
||||
const errText = useErrorTextStyles()
|
||||
const updateToken = useSettings((s) => s.updateToken)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null)
|
||||
const [appChecking, setAppChecking] = useState(false)
|
||||
const [appDownloading, setAppDownloading] = useState(false)
|
||||
const [appFraction, setAppFraction] = useState<number | undefined>(undefined)
|
||||
const [appUpdError, setAppUpdError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
|
||||
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
|
||||
}, [])
|
||||
|
||||
async function checkAppUpdate(): Promise<void> {
|
||||
setAppChecking(true)
|
||||
setAppUpd(null)
|
||||
setAppUpdError(null)
|
||||
try {
|
||||
const info = await window.api.checkForAppUpdate()
|
||||
// Funnel a failed check into the single error slot rather than storing a
|
||||
// second not-ok AppUpdateInfo, so only one error message can ever render.
|
||||
if (info.ok) setAppUpd(info)
|
||||
else setAppUpdError(info.error ?? 'Update check failed.')
|
||||
} catch (e) {
|
||||
setAppUpdError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setAppChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function installAppUpdate(): Promise<void> {
|
||||
if (!appUpd?.downloadUrl) return
|
||||
setAppDownloading(true)
|
||||
setAppFraction(undefined)
|
||||
setAppUpdError(null)
|
||||
try {
|
||||
const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
|
||||
if (!dl.ok || !dl.filePath) {
|
||||
setAppUpdError(dl.error ?? 'Download failed.')
|
||||
return
|
||||
}
|
||||
const run = await window.api.runAppUpdate(dl.filePath)
|
||||
// On success the app quits as the installer launches; only errors return here.
|
||||
if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
|
||||
} catch (e) {
|
||||
setAppUpdError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setAppDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<ArrowSyncRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Software update</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
{appVersion
|
||||
? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.`
|
||||
: 'Checking your AeroFetch version…'}
|
||||
</Caption1>
|
||||
|
||||
<div className={styles.folderRow}>
|
||||
<Button
|
||||
icon={appChecking ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
||||
onClick={checkAppUpdate}
|
||||
disabled={appChecking || appDownloading}
|
||||
>
|
||||
{appChecking ? 'Checking…' : 'Check for updates'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(updateToken.trim() !== '' || !!appUpdError) && (
|
||||
<Field
|
||||
label="Update access token"
|
||||
hint="Only needed if the update server requires sign-in. Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={updateToken}
|
||||
placeholder="Optional -- access token"
|
||||
onChange={(_, d) => update({ updateToken: d.value })}
|
||||
contentBefore={<ArrowSyncRegular />}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{appUpd?.ok && appUpd.available && (
|
||||
<>
|
||||
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
||||
Version {appUpd.latestVersion} is available -- here's what changed:
|
||||
</Text>
|
||||
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
|
||||
<div className={styles.folderRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={installAppUpdate}
|
||||
disabled={appDownloading || !appUpd.downloadUrl}
|
||||
>
|
||||
{appDownloading ? 'Downloading…' : 'Update now'}
|
||||
</Button>
|
||||
{appUpd.htmlUrl && (
|
||||
<Button
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => void window.api?.openUrl?.(appUpd.htmlUrl ?? '')}
|
||||
>
|
||||
View release
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!appUpd.downloadUrl && (
|
||||
<Caption1 className={styles.hint}>
|
||||
This release has no installer attached -- use "View release" to download it manually.
|
||||
</Caption1>
|
||||
)}
|
||||
{appDownloading && (
|
||||
<ProgressBar value={appFraction} aria-label="App update download progress" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{appUpd?.ok && !appUpd.available && (
|
||||
<Text>You're up to date -- v{appUpd.currentVersion} is the latest.</Text>
|
||||
)}
|
||||
|
||||
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
|
||||
|
||||
// Shared styles for the Settings cards. Each card renders a single <Card> so it
|
||||
// stays one direct child of SettingsView's root -- the search filter toggles
|
||||
// `display` per child, so the one-DOM-node-per-card shape must be preserved.
|
||||
export const useSettingsStyles = makeStyles({
|
||||
card: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
padding: '20px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusXLarge)
|
||||
},
|
||||
notesBox: {
|
||||
maxHeight: '180px',
|
||||
overflowY: 'auto',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
lineHeight: tokens.lineHeightBase300
|
||||
},
|
||||
sectionHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px'
|
||||
},
|
||||
sectionIcon: {
|
||||
fontSize: '20px',
|
||||
color: tokens.colorCompoundBrandForeground1
|
||||
},
|
||||
folderRow: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-end'
|
||||
},
|
||||
folderInput: {
|
||||
flexGrow: 1
|
||||
},
|
||||
hint: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
swatchRow: {
|
||||
display: 'flex',
|
||||
gap: '10px'
|
||||
},
|
||||
swatch: {
|
||||
boxSizing: 'border-box',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
flexShrink: 0,
|
||||
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||
...shorthands.border('2px', 'solid', 'transparent'),
|
||||
padding: 0,
|
||||
cursor: 'pointer'
|
||||
},
|
||||
swatchActive: {
|
||||
...shorthands.borderColor(tokens.colorNeutralForeground1)
|
||||
},
|
||||
errorList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px'
|
||||
},
|
||||
errorRow: {
|
||||
padding: '10px 12px',
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
errorRowHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
},
|
||||
errorRowTitle: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user