From e8413aefaeb54fcda6b7b3783c744e5b2f0e0987 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:46:55 +0800 Subject: [PATCH] feat(ui): show update indicator on About (#58) * feat(ui): add update indicator for about * fix(ci): remove unused import * fix(ci): retry binary downloads --- conductor.json | 2 +- package.json | 2 + pnpm-lock.yaml | 60 +++++ scripts/setup-dev-binaries.js | 98 +++++--- src/renderer/src/App.tsx | 28 ++- src/renderer/src/components/ui/popover.tsx | 39 +++ src/renderer/src/components/ui/sidebar.tsx | 78 +----- src/renderer/src/locales/en.json | 9 +- src/renderer/src/pages/About.tsx | 265 ++++++++++++++------- src/renderer/src/pages/Settings.tsx | 65 ++++- src/renderer/src/store/update.ts | 21 ++ 11 files changed, 479 insertions(+), 188 deletions(-) create mode 100644 src/renderer/src/components/ui/popover.tsx create mode 100644 src/renderer/src/store/update.ts diff --git a/conductor.json b/conductor.json index 1a1b483..a5487d9 100644 --- a/conductor.json +++ b/conductor.json @@ -1,6 +1,6 @@ { "scripts": { - "setup": "cp -r $CONDUCTOR_ROOT_PATH/resources resources/ && pnpm install", + "setup": "cp -r $CONDUCTOR_ROOT_PATH/resources resources && pnpm install", "run": "pnpm run dev" } } diff --git a/package.json b/package.json index 59a6960..e51da5d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -47,6 +48,7 @@ "better-sqlite3": "^12.4.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "dayjs": "^1.11.18", "drizzle-orm": "^0.44.7", "electron-ipc-decorator": "^0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1682c00..f0697d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-progress': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -80,6 +83,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) dayjs: specifier: ^1.11.18 version: 1.11.18 @@ -999,6 +1005,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -1896,6 +1915,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -4481,6 +4506,29 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -5429,6 +5477,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 diff --git a/scripts/setup-dev-binaries.js b/scripts/setup-dev-binaries.js index 776a6f8..b648be9 100755 --- a/scripts/setup-dev-binaries.js +++ b/scripts/setup-dev-binaries.js @@ -104,40 +104,80 @@ function ensureDir(dir) { } } +function safeUnlink(filePath) { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } +} + +function getDownloadHeaders(url) { + const headers = { + 'User-Agent': 'vidbee-setup', + Accept: '*/*' + } + if (GITHUB_TOKEN && /github\.com|githubusercontent\.com/.test(url)) { + headers.Authorization = `Bearer ${GITHUB_TOKEN}` + } + return headers +} + function downloadFile(url, dest) { return new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http const file = fs.createWriteStream(dest) - protocol - .get(url, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { - // Handle redirect - file.close() - fs.unlinkSync(dest) - return downloadFile(response.headers.location, dest).then(resolve).catch(reject) - } - - if (response.statusCode !== 200) { - file.close() - fs.unlinkSync(dest) - return reject(new Error(`Failed to download: ${response.statusCode}`)) - } - - response.pipe(file) - file.on('finish', () => { - file.close() - resolve() - }) - }) - .on('error', (err) => { + const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // Handle redirect file.close() - fs.unlinkSync(dest) - reject(err) + safeUnlink(dest) + return downloadFile(response.headers.location, dest).then(resolve).catch(reject) + } + + if (response.statusCode !== 200) { + file.close() + safeUnlink(dest) + return reject(new Error(`Failed to download: ${response.statusCode}`)) + } + + response.pipe(file) + file.on('finish', () => { + file.close() + resolve() }) + }) + + request.setTimeout(30000, () => { + request.destroy(new Error('Download timeout')) + }) + + request.on('error', (err) => { + file.close() + safeUnlink(dest) + reject(err) + }) }) } +async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) { + let lastError + for (let attempt = 1; attempt <= retries; attempt += 1) { + try { + await downloadFile(url, dest) + return + } catch (error) { + lastError = error + safeUnlink(dest) + if (attempt < retries) { + const backoff = delayMs * attempt + log(`Download failed (attempt ${attempt}/${retries}): ${error.message}`, 'warn') + await new Promise((resolve) => setTimeout(resolve, backoff)) + } + } + } + throw lastError +} + function fetchJson(url) { return new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http @@ -299,7 +339,7 @@ async function downloadYtDlp(config) { const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`) try { - await downloadFile(url, tempPath) + await downloadFileWithRetry(url, tempPath) fs.renameSync(tempPath, outputPath) setExecutable(outputPath) log(`Downloaded ${output} successfully`, 'success') @@ -342,7 +382,7 @@ async function downloadFfmpegWindows(config) { } try { - await downloadFile(downloadUrl, tempZip) + await downloadFileWithRetry(downloadUrl, tempZip) log('Extracting ffmpeg...', 'info') extractZip(tempZip, extractDir) @@ -397,7 +437,7 @@ async function downloadFfmpegMac(config) { } try { - await downloadFile(downloadUrl, tempZip) + await downloadFileWithRetry(downloadUrl, tempZip) log('Extracting ffmpeg...', 'info') extractZip(tempZip, extractDir) @@ -451,7 +491,7 @@ async function downloadFfmpegLinux(config) { } try { - await downloadFile(downloadUrl, tempTar) + await downloadFileWithRetry(downloadUrl, tempTar) log('Extracting ffmpeg...', 'info') extractTarXz(tempTar, extractDir) @@ -498,7 +538,7 @@ async function downloadDenoRuntime() { const downloadUrl = `${DENO_BASE_URL}/${assetName}` try { - await downloadFile(downloadUrl, tempZip) + await downloadFileWithRetry(downloadUrl, tempZip) log('Extracting Deno runtime...', 'info') extractZip(tempZip, extractDir) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6642d2d..5bf885c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -16,6 +16,7 @@ import { Settings } from './pages/Settings' import { Subscriptions } from './pages/Subscriptions' import { loadSettingsAtom, settingsAtom } from './store/settings' import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions' +import { updateAvailableAtom, updateReadyAtom } from './store/update' type Page = 'home' | 'subscriptions' | 'settings' | 'about' @@ -51,6 +52,8 @@ function AppContent() { const setSubscriptions = useSetAtom(setSubscriptionsAtom) const [settings] = useAtom(settingsAtom) const loadSettings = useSetAtom(loadSettingsAtom) + const setUpdateReady = useSetAtom(updateReadyAtom) + const setUpdateAvailable = useSetAtom(updateAvailableAtom) const { t } = useTranslation() const updateDownloadInProgressRef = useRef(false) const analyticsScriptRef = useRef(null) @@ -174,8 +177,25 @@ function AppContent() { } } - const handleUpdateDownloaded = (_rawInfo: unknown) => { + const handleUpdateAvailable = (rawInfo: unknown) => { + const info = (rawInfo ?? {}) as { version?: string } + setUpdateAvailable({ + available: true, + version: info.version + }) + } + + const handleUpdateDownloaded = (rawInfo: unknown) => { + const info = (rawInfo ?? {}) as { version?: string } resetDownloadState() + setUpdateReady({ + ready: true, + version: info.version + }) + setUpdateAvailable({ + available: true, + version: info.version + }) } const handleUpdateError = (rawMessage: unknown) => { @@ -211,19 +231,21 @@ function AppContent() { } // Only listen to update events that should be shown globally - // update:available is handled in About page only + // update:available shows a visual indicator in the sidebar + ipcEvents.on('update:available', handleUpdateAvailable) ipcEvents.on('update:downloaded', handleUpdateDownloaded) ipcEvents.on('update:error', handleUpdateError) ipcEvents.on('update:download-progress', handleDownloadProgress) ipcEvents.on('update:show-notification', handleUpdateNotification) return () => { + ipcEvents.removeListener('update:available', handleUpdateAvailable) ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded) ipcEvents.removeListener('update:error', handleUpdateError) ipcEvents.removeListener('update:download-progress', handleDownloadProgress) ipcEvents.removeListener('update:show-notification', handleUpdateNotification) } - }, [t]) + }, [setUpdateAvailable, setUpdateReady, t]) return (
diff --git a/src/renderer/src/components/ui/popover.tsx b/src/renderer/src/components/ui/popover.tsx new file mode 100644 index 0000000..0a79876 --- /dev/null +++ b/src/renderer/src/components/ui/popover.tsx @@ -0,0 +1,39 @@ +import * as PopoverPrimitive from '@radix-ui/react-popover' +import { cn } from '@renderer/lib/utils' +import type * as React from 'react' + +function Popover({ ...props }: React.ComponentProps) { + return +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/src/renderer/src/components/ui/sidebar.tsx b/src/renderer/src/components/ui/sidebar.tsx index 4353c1a..ec23d8a 100644 --- a/src/renderer/src/components/ui/sidebar.tsx +++ b/src/renderer/src/components/ui/sidebar.tsx @@ -1,22 +1,13 @@ import { Button } from '@renderer/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@renderer/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip' -import { saveSettingAtom } from '@renderer/store/settings' -import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages' -import { useSetAtom } from 'jotai' +import { useAtom } from 'jotai' import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' import '../../assets/title-bar.css' +import { updateAvailableAtom } from '@renderer/store/update' import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill' import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line' import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill' import MingcuteDownload3Line from '~icons/mingcute/download-3-line' -import MingcuteGlobeLine from '~icons/mingcute/globe-2-line' import MingcuteInformationFill from '~icons/mingcute/information-fill' import MingcuteInformationLine from '~icons/mingcute/information-line' import MingcuteRssFill from '~icons/mingcute/rss-fill' @@ -53,10 +44,8 @@ interface SidebarProps { } export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) { - const { t, i18n } = useTranslation() - const saveSetting = useSetAtom(saveSettingAtom) - - const languageOptions = languageList + const { t } = useTranslation() + const [updateAvailable] = useAtom(updateAvailableAtom) const navigationItems: NavigationItem[] = [ { @@ -105,20 +94,6 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid } ] - const activeLanguageCode = normalizeLanguageCode(i18n.language) - const currentLanguage = - languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0] - - const handleLanguageChange = async (value: LanguageCode) => { - if (activeLanguageCode === value) { - return - } - - await saveSetting({ key: 'language', value }) - await i18n.changeLanguage(value) - toast.success(t('notifications.settingsSaved')) - } - const renderNavigationItem = (item: NavigationItem, showLabel = true) => { const isActive = item.id !== 'supported-sites' && currentPage === item.id const IconComponent = isActive ? item.icon.active : item.icon.inactive @@ -161,47 +136,11 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
- {/* Language Selector */} -
- - - - - - - - -

{t('settings.language')}

-
-
- - {languageOptions.map((option) => { - const isActive = option.value === currentLanguage.value - - return ( - void handleLanguageChange(option.value)} - className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined} - aria-current={isActive} - > -
-
-
- ) - })} -
-
-
- {/* Bottom Navigation Items */} {bottomNavigationItems.map((item) => { const isActive = currentPage === item.id const IconComponent = isActive ? item.icon.active : item.icon.inactive + const showUpdateDot = item.id === 'about' && updateAvailable.available return (
@@ -211,9 +150,14 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid variant="ghost" size="icon" onClick={() => onPageChange(item.id)} - className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`} + className={`no-drag rounded-2xl w-12 h-12 relative ${ + isActive ? 'bg-primary/10' : '' + }`} > + {showUpdateDot ? ( + + ) : null} diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 00ceb13..ee87bbc 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -50,10 +50,12 @@ "documentation": "Help center", "documentationDescription": "Guides, FAQs, and common workflows.", "feedback": "Feedback & issues", - "feedbackDescription": "Share ideas or report issues on GitHub.", - "githubIssues": "GitHub Issues", + "feedbackDescription": "Share ideas, report issues, or provide feedback through multiple channels.", + "githubIssues": "GitHub", "githubIssuesDescription": "Report bugs or request features on GitHub.", - "discord": "Discord Community", + "xFeedback": "Twitter", + "xFeedbackDescription": "Share feedback or suggestions on X by mentioning @nexmoex.", + "discord": "Discord", "discordDescription": "Join our Discord community for discussions and support.", "license": "License", "licenseDescription": "Review the open-source license terms.", @@ -379,6 +381,7 @@ "fileSelectError": "Failed to select file", "general": "General", "language": "Language", + "languageDescription": "Choose your preferred language for the application interface", "light": "Light", "hideDockIcon": "Hide Dock icon", "hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.", diff --git a/src/renderer/src/pages/About.tsx b/src/renderer/src/pages/About.tsx index 83f18d6..149c04c 100644 --- a/src/renderer/src/pages/About.tsx +++ b/src/renderer/src/pages/About.tsx @@ -1,12 +1,6 @@ import { Badge } from '@renderer/components/ui/badge' import { Button } from '@renderer/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from '@renderer/components/ui/card' +import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card' import { Progress } from '@renderer/components/ui/progress' import { Switch } from '@renderer/components/ui/switch' import { useAtom, useSetAtom } from 'jotai' @@ -18,14 +12,16 @@ import { Github, Link as LinkIcon, MessageCircle, + MessageSquare, RefreshCw, Twitter } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ipcEvents, ipcServices } from '../lib/ipc' import { saveSettingAtom, settingsAtom } from '../store/settings' +import { updateAvailableAtom, updateReadyAtom } from '../store/update' interface AboutResource { icon: LucideIcon @@ -45,6 +41,8 @@ type LatestVersionState = export function About() { const { t } = useTranslation() const [settings, _setSettings] = useAtom(settingsAtom) + const [updateReady] = useAtom(updateReadyAtom) + const setUpdateAvailable = useSetAtom(updateAvailableAtom) const [appVersion, setAppVersion] = useState('—') const [latestVersionState, setLatestVersionState] = useState(null) const [updateDownloadProgress, setUpdateDownloadProgress] = useState(null) @@ -88,6 +86,10 @@ export function About() { status: 'available', version: versionLabel }) + setUpdateAvailable({ + available: true, + version: versionLabel + }) // Reset download progress when new update is available setUpdateDownloadProgress(0) } @@ -113,7 +115,7 @@ export function About() { ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress) ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded) } - }, [t]) + }, [setUpdateAvailable, t]) const handleSettingChange = async ( key: keyof typeof settings, @@ -135,6 +137,10 @@ export function About() { status: 'available', version: result.version ?? '' }) + setUpdateAvailable({ + available: true, + version: result.version + }) } else if (result.error) { toast.error(t('about.notifications.updateError', { error: result.error })) setLatestVersionState({ @@ -147,6 +153,10 @@ export function About() { status: 'uptodate', version: result.version ?? appVersion }) + setUpdateAvailable({ + available: false, + version: undefined + }) } } catch (error) { console.error('Failed to check for updates:', error) @@ -159,6 +169,10 @@ export function About() { openShareUrl('https://vidbee.org/download/') } + const handleRestartToUpdate = () => { + void ipcServices.update.quitAndInstall() + } + const handleCheckForUpdates = async () => { try { toast.info(t('about.notifications.checkingUpdates')) @@ -170,6 +184,10 @@ export function About() { status: 'available', version: result.version ?? '' }) + setUpdateAvailable({ + available: true, + version: result.version + }) } else if (result.error) { toast.error(t('about.notifications.updateError', { error: result.error })) setLatestVersionState({ @@ -182,6 +200,10 @@ export function About() { status: 'uptodate', version: result.version ?? appVersion }) + setUpdateAvailable({ + available: false, + version: undefined + }) } } catch (error) { console.error('Failed to check for updates:', error) @@ -202,13 +224,13 @@ export function About() { } }, [t]) - const openShareUrl = (url: string) => { + const openShareUrl = useCallback((url: string) => { if (typeof window === 'undefined') { return } window.open(url, '_blank', 'noopener,noreferrer') - } + }, []) const handleShareTwitter = () => { openShareUrl(shareLinks.twitter) @@ -243,6 +265,39 @@ export function About() { : 'text-muted-foreground' const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null + const handleXFeedback = useCallback(() => { + const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee' + const tweetText = encodeURIComponent(`@nexmoex${versionText}`) + openShareUrl(`https://x.com/intent/tweet?text=${tweetText}`) + }, [appVersion, openShareUrl]) + + const feedbackResources = useMemo( + () => [ + { + icon: Github, + label: t('about.resources.githubIssues'), + description: t('about.resources.githubIssuesDescription'), + actionLabel: t('about.actions.feedback'), + href: 'https://github.com/nexmoe/VidBee/issues/new/choose' + }, + { + icon: Twitter, + label: t('about.resources.xFeedback'), + description: t('about.resources.xFeedbackDescription'), + actionLabel: t('about.actions.feedback'), + onClick: handleXFeedback + }, + { + icon: MessageCircle, + label: t('about.resources.discord'), + description: t('about.resources.discordDescription'), + actionLabel: t('about.actions.visit'), + href: 'https://discord.gg/uBqXV6QPdm' + } + ], + [t, handleXFeedback] + ) + const aboutResources = useMemo( () => [ { @@ -258,20 +313,6 @@ export function About() { description: t('about.resources.changelogDescription'), actionLabel: t('about.actions.view'), href: 'https://github.com/nexmoe/VidBee/releases' - }, - { - icon: Github, - label: t('about.resources.githubIssues'), - description: t('about.resources.githubIssuesDescription'), - actionLabel: t('about.actions.feedback'), - href: 'https://github.com/nexmoe/VidBee/issues/new/choose' - }, - { - icon: MessageCircle, - label: t('about.resources.discord'), - description: t('about.resources.discordDescription'), - actionLabel: t('about.actions.visit'), - href: 'https://discord.gg/uBqXV6QPdm' } ], [t] @@ -282,69 +323,87 @@ export function About() {
-
+
- VidBee -
-
-

{t('about.appName')}

-

{t('about.description')}

-
-
- - {t('about.versionLabel', { version: appVersion })} - - {latestVersionState ? ( -
- {latestVersionBadgeText ? ( - {latestVersionBadgeText} - ) : null} - {latestVersionStatusText ? ( - - {latestVersionStatusText} - - ) : null} -
- ) : null} -
- {updateDownloadProgress !== null && ( -
-
- - {t('about.downloadingUpdate')} - - - {updateDownloadProgress.toFixed(1)}% - -
- + VidBee +
+
+
+

{t('about.appName')}

+ + {t('about.versionLabel', { version: appVersion })} + + {latestVersionState ? ( +
+ {latestVersionBadgeText ? ( + {latestVersionBadgeText} + ) : null} + {latestVersionStatusText ? ( + + {latestVersionStatusText} + + ) : null} +
+ ) : null}
- )} +
+ + {updateReady.ready ? ( + + ) : null} + {latestVersionState?.status === 'available' ? ( + + ) : null} + +
+
+

{t('about.description')}

-
- - {latestVersionState?.status === 'available' ? ( - - ) : null} - -
+ {updateDownloadProgress !== null && ( +
+
+
+ + {t('about.downloadingUpdate')} + + + {updateDownloadProgress.toFixed(1)}% + +
+ +
+
+ )}

{t('about.autoUpdateTitle')}

@@ -361,7 +420,6 @@ export function About() { {t('about.shareTitle')} - {t('about.shareDescription')}
@@ -408,6 +466,45 @@ export function About() {
+ {/* Feedback section - merged into one row */} +
+
+
+ +
+
+

{t('about.resources.feedback')}

+

+ {t('about.resources.feedbackDescription')} +

+
+
+
+ {feedbackResources.map((resource) => { + const Icon = resource.icon + return resource.href ? ( + + ) : ( + + ) + })} +
+
+ {/* Other resources */} {aboutResources.map((resource) => { const Icon = resource.icon return ( diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 7388f30..b79f7ce 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -18,6 +18,7 @@ import { } from '@renderer/components/ui/select' import { Switch } from '@renderer/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' +import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages' import type { OneClickQualityPreset } from '@shared/types' import { useAtom, useSetAtom } from 'jotai' import { useTheme } from 'next-themes' @@ -35,7 +36,7 @@ const clampSubscriptionInterval = (value: string) => { } export function Settings() { - const { t } = useTranslation() + const { t, i18n: i18nInstance } = useTranslation() const { theme, setTheme } = useTheme() const [settings, _setSettings] = useAtom(settingsAtom) const loadSettings = useSetAtom(loadSettingsAtom) @@ -131,6 +132,21 @@ export function Settings() { await handleSettingChange('theme', value) } + const languageOptions = languageList + const activeLanguageCode = normalizeLanguageCode(i18nInstance.language) + const currentLanguage = + languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0] + + const handleLanguageChange = async (value: LanguageCode) => { + if (activeLanguageCode === value) { + return + } + + await saveSetting({ key: 'language', value }) + await i18nInstance.changeLanguage(value) + toast.success(t('notifications.settingsSaved')) + } + return (
@@ -185,6 +201,53 @@ export function Settings() { + + + + + + {t('settings.language')} + {t('settings.languageDescription')} + + + + + diff --git a/src/renderer/src/store/update.ts b/src/renderer/src/store/update.ts new file mode 100644 index 0000000..f533fa7 --- /dev/null +++ b/src/renderer/src/store/update.ts @@ -0,0 +1,21 @@ +import { atom } from 'jotai' + +type UpdateReadyState = { + ready: boolean + version?: string +} + +type UpdateAvailableState = { + available: boolean + version?: string +} + +export const updateReadyAtom = atom({ + ready: false, + version: undefined +}) + +export const updateAvailableAtom = atom({ + available: false, + version: undefined +})