feat(rss): add enclosure support, refine thumbnail lookup and UI labels

feat(settings): add toggleable anonymous analytics collection and loader script
This commit is contained in:
Nexmoe
2025-11-11 23:11:31 +08:00
parent 0bd27a96c3
commit 41e4a4993c
11 changed files with 665 additions and 170 deletions

View File

@@ -27,6 +27,7 @@
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",

30
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.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-context-menu':
specifier: ^2.2.16
version: 2.2.16(@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-dialog':
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)
@@ -707,6 +710,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context-menu@2.2.16':
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
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-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
@@ -4053,6 +4069,20 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.2
'@radix-ui/react-context-menu@2.2.16(@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-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-menu': 2.1.16(@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-use-callback-ref': 1.1.1(@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)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.2.0)':
dependencies:
react: 19.2.0

View File

@@ -20,6 +20,7 @@ type ParserItem = {
youtubeId?: string
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
mediaContent?: Array<{ url?: string }> | { url?: string }
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
[key: string]: unknown
}
@@ -46,7 +47,8 @@ const parser = new Parser<{ item: ParserItem }>({
item: [
['yt:videoId', 'youtubeId'],
['media:thumbnail', 'mediaThumbnail'],
['media:content', 'mediaContent']
['media:content', 'mediaContent'],
['enclosure', 'enclosure']
]
}
})
@@ -316,20 +318,41 @@ export class SubscriptionScheduler extends EventEmitter {
}
private resolveThumbnail(item: ParserItem): string | undefined {
// Try media:thumbnail first
const thumbnail = item.mediaThumbnail
if (Array.isArray(thumbnail)) {
return thumbnail.find((entry) => entry?.url)?.url
const found = thumbnail.find((entry) => entry?.url)
if (found?.url) return found.url
}
if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) {
return thumbnail.url as string | undefined
}
// Try enclosure (for RSS feeds with image/jpeg type)
const enclosure = item.enclosure
if (Array.isArray(enclosure)) {
const imageEnclosure = enclosure.find(
(entry) => entry?.url && entry?.type?.startsWith('image/')
)
if (imageEnclosure?.url) return imageEnclosure.url
}
if (enclosure && typeof enclosure === 'object' && 'url' in enclosure) {
const enc = enclosure as { url?: string; type?: string }
if (enc.url && enc.type?.startsWith('image/')) {
return enc.url
}
}
// Try media:content as fallback
const mediaContent = item.mediaContent
if (Array.isArray(mediaContent)) {
return mediaContent.find((entry) => entry?.url)?.url
const found = mediaContent.find((entry) => entry?.url)
if (found?.url) return found.url
}
if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) {
return mediaContent.url as string | undefined
}
return undefined
}

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<title>VidBee</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self'" />
content="default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
</head>
<body>

View File

@@ -3,7 +3,7 @@ import { Sidebar } from '@renderer/components/ui/sidebar'
import { Toaster } from '@renderer/components/ui/sonner'
import { TitleBar } from '@renderer/components/ui/title-bar'
import type { SubscriptionRule } from '@shared/types'
import { useSetAtom } from 'jotai'
import { useAtom, useSetAtom } from 'jotai'
import { ThemeProvider } from 'next-themes'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -14,6 +14,7 @@ import { Home } from './pages/Home'
import { Settings } from './pages/Settings'
import { Subscriptions } from './pages/Subscriptions'
import { SupportedSites } from './pages/SupportedSites'
import { loadSettingsAtom, settingsAtom } from './store/settings'
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
@@ -23,8 +24,15 @@ function AppContent() {
const [platform, setPlatform] = useState<string>('')
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
const [settings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const { t } = useTranslation()
const updateDownloadInProgressRef = useRef(false)
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
useEffect(() => {
loadSettings()
}, [loadSettings])
useEffect(() => {
loadSubscriptions()
@@ -43,6 +51,43 @@ function AppContent() {
}
}, [loadSubscriptions, setSubscriptions])
// Load or remove analytics script based on settings
useEffect(() => {
const scriptId = 'analytics-script'
const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
if (settings.enableAnalytics) {
// Remove existing script if it exists
if (existingScript) {
existingScript.remove()
}
// Create and append new script
const script = document.createElement('script')
script.id = scriptId
script.src = 'https://rybbit.102417.xyz/api/script.js'
script.setAttribute('data-site-id', '7bc6f6d625a4')
script.defer = true
script.async = true
document.head.appendChild(script)
analyticsScriptRef.current = script
} else {
// Remove script if analytics is disabled
if (existingScript) {
existingScript.remove()
analyticsScriptRef.current = null
}
}
return () => {
// Cleanup on unmount
const script = document.getElementById(scriptId)
if (script) {
script.remove()
}
}
}, [settings.enableAnalytics])
useEffect(() => {
// Get platform info to determine if we should show title bar
const getPlatform = async () => {

View File

@@ -0,0 +1,221 @@
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { cn } from '@renderer/lib/utils'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import type * as React from 'react'
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
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 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
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 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup
}

View File

@@ -60,7 +60,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
active: Newspaper,
inactive: Newspaper
},
label: t('menu.subscriptions')
label: t('menu.rss')
},
{
id: 'sites',
@@ -138,7 +138,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
return (
<aside className="drag-region w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
{/* App Logo */}
<div className="flex flex-col items-center gap-1 py-4 mt-2">
<div className="flex flex-col items-center gap-1 py-3 mt-4">
<div className="w-12 h-12 flex items-center justify-center">
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
</div>

View File

@@ -246,6 +246,7 @@
"about": "About",
"download": "Download",
"playlist": "Download Playlist",
"rss": "RSS",
"subscriptions": "Subscriptions",
"preferences": "Preferences",
"supportedSites": "Supported Sites",
@@ -338,6 +339,8 @@
"light": "Light",
"hideDockIcon": "Hide Dock icon",
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
"enableAnalytics": "Help improve VidBee",
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
"maxConcurrentDownloads": "Maximum number of active downloads",
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
"none": "None",
@@ -403,6 +406,7 @@
"onlyLatest": "Download only the latest video",
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
"enabled": "Enabled",
"disabled": "Disabled",
"onlyLatestShort": "Only latest"
},
"placeholders": {
@@ -422,13 +426,15 @@
"empty": "No recent feed items found.",
"queued": "Queued",
"notQueued": "Not queued",
"fromChannel": "From {{channel}}",
"actions": {
"open": "Open in browser"
}
},
"labels": {
"subscription": "Subscription",
"unknown": "Unknown subscription"
"unknown": "Unknown subscription",
"noThumbnail": "No thumbnail"
},
"notifications": {
"directoryError": "Failed to open the directory picker.",
@@ -452,10 +458,14 @@
"description": "Tweak filters, tags, and overrides for this feed."
},
"status": {
"title": "Status",
"up-to-date": "Up to date",
"checking": "Checking",
"failed": "Failed",
"idle": "Idle"
"idle": "Idle",
"tooltip": {
"updatedAt": "Updated: {{time}}"
}
}
},
"sites": {

View File

@@ -433,6 +433,21 @@ export function Settings() {
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.enableAnalytics')}</ItemTitle>
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.enableAnalytics}
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
/>
</ItemActions>
</Item>
</ItemGroup>
</TabsContent>
<TabsContent value="rss" className="space-y-4 mt-2">

View File

@@ -1,19 +1,29 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@renderer/components/ui/context-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
DialogTitle
} from '@renderer/components/ui/dialog'
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import { RemoteImage } from '@renderer/components/ui/remote-image'
import { Switch } from '@renderer/components/ui/switch'
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { ipcServices } from '@renderer/lib/ipc'
import { cn } from '@renderer/lib/utils'
import { settingsAtom } from '@renderer/store/settings'
import {
createSubscriptionAtom,
@@ -30,18 +40,11 @@ import type {
} from '@shared/types'
import dayjs from 'dayjs'
import { useAtom, useSetAtom } from 'jotai'
import { ExternalLink, Plus } from 'lucide-react'
import { Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const statusStyles: Record<SubscriptionRule['status'], { color: string }> = {
'up-to-date': { color: 'text-emerald-600' },
checking: { color: 'text-blue-600' },
failed: { color: 'text-amber-600' },
idle: { color: 'text-muted-foreground' }
}
const sanitizeCommaList = (value: string) =>
value
.split(',')
@@ -50,6 +53,157 @@ const sanitizeCommaList = (value: string) =>
const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-')
const statusStyles: Record<
SubscriptionRule['status'],
{ dotClass: string; textClass: string; label: string }
> = {
'up-to-date': {
dotClass: 'bg-emerald-500',
textClass: 'text-emerald-600',
label: 'subscriptions.status.up-to-date'
},
checking: {
dotClass: 'bg-sky-500',
textClass: 'text-sky-600',
label: 'subscriptions.status.checking'
},
failed: {
dotClass: 'bg-red-500',
textClass: 'text-red-600',
label: 'subscriptions.status.failed'
},
idle: {
dotClass: 'bg-muted-foreground',
textClass: 'text-muted-foreground',
label: 'subscriptions.status.idle'
}
}
const disabledStatusStyle = {
dotClass: 'bg-zinc-400',
textClass: 'text-muted-foreground',
label: 'subscriptions.fields.disabled'
}
function SubscriptionTab({
subscription,
onRefresh,
onRemove,
onUpdate,
isActive
}: SubscriptionTabProps) {
const { t } = useTranslation()
const [editOpen, setEditOpen] = useState(false)
const isDisabled = !subscription.enabled
const statusMeta = isDisabled ? disabledStatusStyle : statusStyles[subscription.status]
const statusDescription =
subscription.status === 'failed' && subscription.lastError
? subscription.lastError
: t(statusStyles[subscription.status].label)
const lastUpdatedTimestamp =
subscription.lastCheckedAt ?? subscription.updatedAt ?? subscription.createdAt ?? null
const lastUpdatedLabel = lastUpdatedTimestamp
? dayjs(lastUpdatedTimestamp).format('YYYY-MM-DD HH:mm')
: t('subscriptions.never')
const handleToggleEnabled = async (checked: boolean) => {
await onUpdate({ enabled: checked })
}
const handleRefresh = async () => {
await onRefresh()
toast.success(t('subscriptions.notifications.refreshStarted'))
}
const handleRemove = async () => {
await onRemove()
toast.success(t('subscriptions.notifications.removed'))
}
const handleEdit = () => {
setEditOpen(true)
}
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<TabsTrigger
value={subscription.id}
className={cn(
'flex h-auto w-20 flex-col rounded-sm! items-center gap-1 px-2 py-2 transition-all hover:opacity-80',
isActive && 'bg-neutral-100'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="relative h-12 w-12 shrink-0 overflow-hidden transition-colors">
<RemoteImage
src={subscription.coverUrl}
alt={subscription.title || t('subscriptions.labels.unknown')}
className="h-full w-full object-cover rounded-full overflow-hidden"
/>
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-background transition-colors',
statusMeta.dotClass
)}
/>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs space-y-1">
<p className="text-xs">{statusDescription}</p>
<p className="text-xs">
{t('subscriptions.status.tooltip.updatedAt', { time: lastUpdatedLabel })}
</p>
</TooltipContent>
</Tooltip>
<div className="flex w-full flex-col items-center text-center">
<span className="w-full truncate text-xs font-medium">
{subscription.title || t('subscriptions.labels.unknown')}
</span>
</div>
</TabsTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleRefresh}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('subscriptions.actions.refresh')}
</ContextMenuItem>
<ContextMenuItem onClick={handleEdit}>
<Edit className="mr-2 h-4 w-4" />
{t('subscriptions.actions.edit')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuCheckboxItem
checked={subscription.enabled}
onCheckedChange={(checked) => void handleToggleEnabled(checked)}
>
<Power className="mr-2 h-4 w-4" />
{t('subscriptions.fields.enabled')}
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => void handleRemove()} variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
{t('subscriptions.actions.remove')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<SubscriptionEditDialog
subscription={subscription}
onSave={async (data) => {
await onUpdate(data)
toast.success(t('subscriptions.notifications.updated'))
setEditOpen(false)
}}
/>
</Dialog>
</>
)
}
export function Subscriptions() {
const { t } = useTranslation()
const [subscriptions] = useAtom(subscriptionsAtom)
@@ -58,6 +212,7 @@ export function Subscriptions() {
const refreshSubscription = useSetAtom(refreshSubscriptionAtom)
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [selectedTab, setSelectedTab] = useState<string>('')
const sortedSubscriptions = useMemo(
() =>
@@ -103,37 +258,76 @@ export function Subscriptions() {
setAddDialogOpen(false)
}, [])
const renderStatus = (subscription: SubscriptionRule) => {
const meta = statusStyles[subscription.status]
return (
<span className={`text-xs ${meta.color}`}>
{t(`subscriptions.status.${subscription.status}`)}
</span>
)
}
// Filter subscriptions based on selected tab
const displayedSubscriptions = useMemo(() => {
if (!selectedTab) {
return []
}
return sortedSubscriptions.filter((sub) => sub.id === selectedTab)
}, [selectedTab, sortedSubscriptions])
// Set default tab to first subscription if available
useEffect(() => {
if (!selectedTab && sortedSubscriptions.length > 0) {
// Set to first subscription if no tab is selected
setSelectedTab(sortedSubscriptions[0].id)
} else if (selectedTab && !sortedSubscriptions.find((s) => s.id === selectedTab)) {
// If selected subscription no longer exists, switch to first available
if (sortedSubscriptions.length > 0) {
setSelectedTab(sortedSubscriptions[0].id)
} else {
setSelectedTab('')
}
}
}, [selectedTab, sortedSubscriptions])
return (
<div className="relative space-y-8 p-6">
<section className="space-y-4">
{sortedSubscriptions.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
{t('subscriptions.empty')}
<div className="relative">
{/* Channel Tabs Header */}
{sortedSubscriptions.length > 0 && (
<div className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<div className="overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="w-full">
<TabsList className="h-auto w-full justify-start rounded-none border-none bg-transparent p-0">
<div className="flex gap-0 px-6 pb-6">
{/* Subscription Channel Tabs */}
{sortedSubscriptions.map((subscription) => (
<SubscriptionTab
key={subscription.id}
subscription={subscription}
isActive={subscription.id === selectedTab}
onRefresh={() => refreshSubscription(subscription.id)}
onRemove={() => removeSubscription(subscription.id)}
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
/>
))}
</div>
</TabsList>
</Tabs>
</div>
) : (
<div className="space-y-3">
{sortedSubscriptions.map((subscription) => (
<SubscriptionCard
key={subscription.id}
subscription={subscription}
onRefresh={() => refreshSubscription(subscription.id)}
onRemove={() => removeSubscription(subscription.id)}
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
renderStatus={() => renderStatus(subscription)}
/>
))}
</div>
)}
</section>
</div>
)}
{/* Content Area */}
<div className="relative space-y-8 p-6">
<section className="space-y-4">
{sortedSubscriptions.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
{t('subscriptions.empty')}
</div>
) : !selectedTab ? (
<div className="py-12 text-center text-sm text-muted-foreground">
{t('subscriptions.empty')}
</div>
) : (
<div className="space-y-3">
{displayedSubscriptions.map((subscription) => (
<SubscriptionCard key={subscription.id} subscription={subscription} />
))}
</div>
)}
</section>
</div>
{/* Floating Action Button */}
<Button
@@ -156,9 +350,9 @@ export function Subscriptions() {
)
}
interface SubscriptionCardProps {
interface SubscriptionTabProps {
subscription: SubscriptionRule
renderStatus: () => React.ReactNode
isActive: boolean
onRefresh: () => Promise<void>
onRemove: () => Promise<void>
onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>
@@ -174,31 +368,10 @@ interface SubscriptionRuleUpdateForm {
enabled?: boolean
}
function SubscriptionCard({
subscription,
renderStatus,
onRefresh,
onRemove,
onUpdate
}: SubscriptionCardProps) {
function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) {
const { t } = useTranslation()
const [editOpen, setEditOpen] = useState(false)
const feedItems: SubscriptionFeedItem[] = subscription.items ?? []
const handleToggleEnabled = async (checked: boolean) => {
await onUpdate({ enabled: checked })
}
const handleRefresh = async () => {
await onRefresh()
toast.success(t('subscriptions.notifications.refreshStarted'))
}
const handleRemove = async () => {
await onRemove()
toast.success(t('subscriptions.notifications.removed'))
}
const handleOpenItem = async (url: string) => {
try {
await ipcServices.fs.openExternal(url)
@@ -208,112 +381,87 @@ function SubscriptionCard({
}
}
const thumbnail = subscription.coverUrl
const lastCheckedLabel = subscription.lastCheckedAt
? dayjs(subscription.lastCheckedAt).format('YYYY-MM-DD HH:mm')
: t('subscriptions.never')
if (feedItems.length === 0) {
return (
<div className="py-12 text-center text-sm text-muted-foreground">
{t('subscriptions.items.empty')}
</div>
)
}
return (
<div className="rounded-lg border bg-card">
<div className="border-b p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex w-full items-start gap-3">
<div className="h-12 w-12 shrink-0 overflow-hidden rounded bg-muted">
<ImageWithPlaceholder
src={thumbnail}
alt={subscription.title}
className="h-full w-full object-cover"
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
{feedItems.map((item) => (
<article key={`${subscription.id}-${item.id}`} className="group transition-all">
<div className="relative w-full overflow-hidden bg-muted aspect-video overflow-hidden rounded-2xl">
{item.thumbnail ? (
<RemoteImage
src={item.thumbnail}
alt={item.title}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-medium leading-tight">
{subscription.title || t('subscriptions.labels.unknown')}
</h3>
{(subscription.tags ?? []).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
) : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{t('subscriptions.labels.noThumbnail')}
</div>
{subscription.latestVideoTitle && (
<p className="text-sm text-muted-foreground line-clamp-1">
{subscription.latestVideoTitle}
</p>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/5 to-transparent" />
<div className="absolute top-3 left-3 flex items-center gap-2 rounded-full bg-black/60 pr-3 pl-1 py-1 text-xs font-medium text-white backdrop-blur">
{subscription.coverUrl ? (
<div className="h-6 w-6 overflow-hidden rounded-full border border-white/40">
<RemoteImage
src={subscription.coverUrl}
alt={subscription.title || t('subscriptions.labels.unknown')}
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-white/40 bg-white/10 text-[10px] font-semibold uppercase text-white">
{(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{t('subscriptions.lastChecked', { time: lastCheckedLabel })}</span>
{renderStatus()}
</div>
<span className="max-w-[10rem] truncate text-xs">
{subscription.title || t('subscriptions.labels.unknown')}
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 md:justify-end">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('subscriptions.fields.enabled')}</span>
<Switch
checked={subscription.enabled}
onCheckedChange={(checked) => void handleToggleEnabled(checked)}
/>
<div className="absolute bottom-3 left-3 text-xs font-medium text-white">
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
</div>
<Button variant="ghost" size="sm" onClick={() => void handleRefresh()}>
{t('subscriptions.actions.refresh')}
</Button>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
{t('subscriptions.actions.edit')}
</Button>
</DialogTrigger>
<SubscriptionEditDialog
subscription={subscription}
onSave={async (data) => {
await onUpdate(data)
toast.success(t('subscriptions.notifications.updated'))
setEditOpen(false)
}}
/>
</Dialog>
<Button variant="ghost" size="sm" onClick={() => void handleRemove()}>
{t('subscriptions.actions.remove')}
</Button>
</div>
</div>
</div>
{feedItems.length > 0 && (
<div className="space-y-1.5 p-4">
{feedItems.map((item) => (
<div
key={`${subscription.id}-${item.id}`}
className="flex items-center justify-between gap-3 rounded-md border bg-muted/30 px-3 py-2 transition-colors hover:bg-muted/50"
<Badge
variant={item.addedToQueue ? 'default' : 'secondary'}
className={cn(
'absolute bottom-3 right-3 rounded-full text-xs text-white backdrop-blur',
item.addedToQueue ? 'bg-emerald-500' : 'bg-black/70'
)}
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm" title={item.title}>
{item.title}
</p>
<p className="text-xs text-muted-foreground">
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={item.addedToQueue ? 'default' : 'outline'} className="text-xs">
{item.addedToQueue
? t('subscriptions.items.queued')
: t('subscriptions.items.notQueued')}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => void handleOpenItem(item.url)}
title={t('subscriptions.items.actions.open')}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
{item.addedToQueue
? t('subscriptions.items.queued')
: t('subscriptions.items.notQueued')}
</Badge>
</div>
<div className="flex flex-col gap-4 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p
className="text-base font-semibold leading-snug text-card-foreground"
title={item.title}
>
{item.title}
</p>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
className="rounded-full px-4"
onClick={() => void handleOpenItem(item.url)}
title={t('subscriptions.items.actions.open')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
</article>
))}
</div>
)
}

View File

@@ -271,6 +271,7 @@ export interface AppSettings {
subscriptionFilenameTemplate: string
subscriptionOnlyLatestDefault: boolean
subscriptionCheckIntervalHours: number
enableAnalytics: boolean
}
export const defaultSettings: AppSettings = {
@@ -292,5 +293,6 @@ export const defaultSettings: AppSettings = {
autoUpdate: true,
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
subscriptionOnlyLatestDefault: true,
subscriptionCheckIntervalHours: 3
subscriptionCheckIntervalHours: 3,
enableAnalytics: true
}