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
This commit is contained in:
Nexmoe
2025-12-26 16:46:55 +08:00
committed by GitHub
parent b229225b6e
commit e8413aefae
11 changed files with 479 additions and 188 deletions

View File

@@ -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"
}
}

View File

@@ -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",

60
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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)

View File

@@ -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<HTMLScriptElement | null>(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 (
<div className="flex flex-row h-screen">

View File

@@ -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<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -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
<div className="flex-1" />
{/* Language Selector */}
<div className="flex flex-col items-center gap-1">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="no-drag rounded-2xl w-12 h-12">
<MingcuteGlobeLine className="h-5! w-5!" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">
<p>{t('settings.language')}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="right" align="end">
{languageOptions.map((option) => {
const isActive = option.value === currentLanguage.value
return (
<DropdownMenuItem
key={option.value}
onClick={() => void handleLanguageChange(option.value)}
className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined}
aria-current={isActive}
>
<div className="flex items-center gap-2">
<span className={`${option.flag} rounded-xs text-base`} aria-hidden="true" />
<span lang={option.hreflang}>{option.name}</span>
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 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 (
<div key={item.id} className="flex flex-col items-center gap-1">
@@ -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' : ''
}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
{showUpdateDot ? (
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-red-500" />
) : null}
</Button>
</TooltipTrigger>
<TooltipContent side="right">

View File

@@ -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.",

View File

@@ -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<string>('—')
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(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<AboutResource[]>(
() => [
{
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<AboutResource[]>(
() => [
{
@@ -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() {
<div className="container mx-auto max-w-5xl p-6 space-y-6">
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<img src="./app-icon.png" alt="VidBee" className="h-16 w-16 rounded-2xl" />
<div className="space-y-2">
<div>
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
</Badge>
{latestVersionState ? (
<div className="flex flex-wrap items-center gap-2">
{latestVersionBadgeText ? (
<Badge variant="outline">{latestVersionBadgeText}</Badge>
) : null}
{latestVersionStatusText ? (
<span className={`text-sm ${latestVersionStatusClass}`}>
{latestVersionStatusText}
</span>
) : null}
</div>
) : null}
</div>
{updateDownloadProgress !== null && (
<div className="space-y-2 w-full">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
{t('about.downloadingUpdate')}
</span>
<span className="text-sm font-medium">
{updateDownloadProgress.toFixed(1)}%
</span>
</div>
<Progress value={updateDownloadProgress} className="h-2" />
<img src="./app-icon.png" alt="VidBee" className="h-18 w-18 rounded-2xl" />
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
</Badge>
{latestVersionState ? (
<div className="flex flex-wrap items-center gap-2">
{latestVersionBadgeText ? (
<Badge variant="outline">{latestVersionBadgeText}</Badge>
) : null}
{latestVersionStatusText ? (
<span className={`text-sm ${latestVersionStatusClass}`}>
{latestVersionStatusText}
</span>
) : null}
</div>
) : null}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a
href="https://github.com/nexmoe/vidbee"
target="_blank"
rel="noreferrer"
aria-label={t('about.actions.openRepo')}
>
<Github className="h-3.5 w-3.5" />
</a>
</Button>
{updateReady.ready ? (
<Button
onClick={handleRestartToUpdate}
variant="default"
size="sm"
className="gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
{t('about.notifications.restartNowAction')}
</Button>
) : null}
{latestVersionState?.status === 'available' ? (
<Button
onClick={handleGoToDownload}
variant="default"
size="sm"
className="gap-2"
>
<Download className="h-3.5 w-3.5" />
{t('about.actions.goToDownload')}
</Button>
) : null}
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
<RefreshCw className="h-3.5 w-3.5" />
{t('about.actions.checkUpdates')}
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<a
href="https://github.com/nexmoe/vidbee"
target="_blank"
rel="noreferrer"
aria-label={t('about.actions.openRepo')}
>
<Github className="h-4 w-4" />
</a>
</Button>
{latestVersionState?.status === 'available' ? (
<Button onClick={handleGoToDownload} variant="default" className="gap-2">
<Download className="h-4 w-4" />
{t('about.actions.goToDownload')}
</Button>
) : null}
<Button onClick={handleCheckForUpdates} className="gap-2">
<RefreshCw className="h-4 w-4" />
{t('about.actions.checkUpdates')}
</Button>
</div>
</div>
{updateDownloadProgress !== null && (
<div className="flex flex-col gap-3 pt-4">
<div className="space-y-2 w-full">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
{t('about.downloadingUpdate')}
</span>
<span className="text-sm font-medium">
{updateDownloadProgress.toFixed(1)}%
</span>
</div>
<Progress value={updateDownloadProgress} className="h-2" />
</div>
</div>
)}
<div className="flex items-center justify-between gap-4 pt-6">
<div className="space-y-1">
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
@@ -361,7 +420,6 @@ export function About() {
<Card>
<CardHeader>
<CardTitle>{t('about.shareTitle')}</CardTitle>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -408,6 +466,45 @@ export function About() {
<Card>
<CardContent className="p-0">
<div className="flex flex-col divide-y">
{/* Feedback section - merged into one row */}
<div className="flex items-center justify-between gap-4 px-6 py-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/60">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-medium leading-none">{t('about.resources.feedback')}</p>
<p className="text-sm text-muted-foreground">
{t('about.resources.feedbackDescription')}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{feedbackResources.map((resource) => {
const Icon = resource.icon
return resource.href ? (
<Button key={resource.label} variant="outline" size="sm" asChild>
<a href={resource.href} target="_blank" rel="noreferrer" className="gap-2">
<Icon className="h-4 w-4" />
{resource.label}
</a>
</Button>
) : (
<Button
key={resource.label}
variant="outline"
size="sm"
onClick={resource.onClick}
className="gap-2"
>
<Icon className="h-4 w-4" />
{resource.label}
</Button>
)
})}
</div>
</div>
{/* Other resources */}
{aboutResources.map((resource) => {
const Icon = resource.icon
return (

View File

@@ -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 (
<div className="h-full bg-background">
<div className="container mx-auto max-w-4xl p-6 space-y-6">
@@ -185,6 +201,53 @@ export function Settings() {
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.language')}</ItemTitle>
<ItemDescription>{t('settings.languageDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={currentLanguage.value}
onValueChange={(value) => void handleLanguageChange(value as LanguageCode)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder={currentLanguage.name}>
<div className="flex items-center gap-2">
<span
className={`${currentLanguage.flag} rounded-xs text-base`}
aria-hidden="true"
/>
<span lang={currentLanguage.hreflang}>{currentLanguage.name}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{languageOptions.map((option) => {
const isActive = option.value === currentLanguage.value
return (
<SelectItem
key={option.value}
value={option.value}
className={isActive ? 'font-semibold bg-muted' : undefined}
>
<div className="flex items-center gap-2">
<span
className={`${option.flag} rounded-xs text-base`}
aria-hidden="true"
/>
<span lang={option.hreflang}>{option.name}</span>
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,21 @@
import { atom } from 'jotai'
type UpdateReadyState = {
ready: boolean
version?: string
}
type UpdateAvailableState = {
available: boolean
version?: string
}
export const updateReadyAtom = atom<UpdateReadyState>({
ready: false,
version: undefined
})
export const updateAvailableAtom = atom<UpdateAvailableState>({
available: false,
version: undefined
})