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:
@@ -27,6 +27,7 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
|||||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@radix-ui/react-checkbox':
|
'@radix-ui/react-checkbox':
|
||||||
specifier: ^1.3.3
|
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)
|
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':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.15
|
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)
|
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':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-context@1.1.2':
|
||||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4053,6 +4069,20 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.2
|
'@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)':
|
'@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type ParserItem = {
|
|||||||
youtubeId?: string
|
youtubeId?: string
|
||||||
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
||||||
mediaContent?: Array<{ url?: string }> | { url?: string }
|
mediaContent?: Array<{ url?: string }> | { url?: string }
|
||||||
|
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ const parser = new Parser<{ item: ParserItem }>({
|
|||||||
item: [
|
item: [
|
||||||
['yt:videoId', 'youtubeId'],
|
['yt:videoId', 'youtubeId'],
|
||||||
['media:thumbnail', 'mediaThumbnail'],
|
['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 {
|
private resolveThumbnail(item: ParserItem): string | undefined {
|
||||||
|
// Try media:thumbnail first
|
||||||
const thumbnail = item.mediaThumbnail
|
const thumbnail = item.mediaThumbnail
|
||||||
if (Array.isArray(thumbnail)) {
|
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) {
|
if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) {
|
||||||
return thumbnail.url as string | undefined
|
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
|
const mediaContent = item.mediaContent
|
||||||
if (Array.isArray(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) {
|
if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) {
|
||||||
return mediaContent.url as string | undefined
|
return mediaContent.url as string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>VidBee</title>
|
<title>VidBee</title>
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Sidebar } from '@renderer/components/ui/sidebar'
|
|||||||
import { Toaster } from '@renderer/components/ui/sonner'
|
import { Toaster } from '@renderer/components/ui/sonner'
|
||||||
import { TitleBar } from '@renderer/components/ui/title-bar'
|
import { TitleBar } from '@renderer/components/ui/title-bar'
|
||||||
import type { SubscriptionRule } from '@shared/types'
|
import type { SubscriptionRule } from '@shared/types'
|
||||||
import { useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -14,6 +14,7 @@ import { Home } from './pages/Home'
|
|||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
import { Subscriptions } from './pages/Subscriptions'
|
import { Subscriptions } from './pages/Subscriptions'
|
||||||
import { SupportedSites } from './pages/SupportedSites'
|
import { SupportedSites } from './pages/SupportedSites'
|
||||||
|
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||||
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||||
|
|
||||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
||||||
@@ -23,8 +24,15 @@ function AppContent() {
|
|||||||
const [platform, setPlatform] = useState<string>('')
|
const [platform, setPlatform] = useState<string>('')
|
||||||
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
||||||
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||||
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const updateDownloadInProgressRef = useRef(false)
|
const updateDownloadInProgressRef = useRef(false)
|
||||||
|
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings()
|
||||||
|
}, [loadSettings])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSubscriptions()
|
loadSubscriptions()
|
||||||
@@ -43,6 +51,43 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}, [loadSubscriptions, setSubscriptions])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
// Get platform info to determine if we should show title bar
|
// Get platform info to determine if we should show title bar
|
||||||
const getPlatform = async () => {
|
const getPlatform = async () => {
|
||||||
|
|||||||
221
src/renderer/src/components/ui/context-menu.tsx
Normal file
221
src/renderer/src/components/ui/context-menu.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
active: Newspaper,
|
active: Newspaper,
|
||||||
inactive: Newspaper
|
inactive: Newspaper
|
||||||
},
|
},
|
||||||
label: t('menu.subscriptions')
|
label: t('menu.rss')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sites',
|
id: 'sites',
|
||||||
@@ -138,7 +138,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
return (
|
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">
|
<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 */}
|
{/* 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">
|
<div className="w-12 h-12 flex items-center justify-center">
|
||||||
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
|
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -246,6 +246,7 @@
|
|||||||
"about": "About",
|
"about": "About",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"playlist": "Download Playlist",
|
"playlist": "Download Playlist",
|
||||||
|
"rss": "RSS",
|
||||||
"subscriptions": "Subscriptions",
|
"subscriptions": "Subscriptions",
|
||||||
"preferences": "Preferences",
|
"preferences": "Preferences",
|
||||||
"supportedSites": "Supported Sites",
|
"supportedSites": "Supported Sites",
|
||||||
@@ -338,6 +339,8 @@
|
|||||||
"light": "Light",
|
"light": "Light",
|
||||||
"hideDockIcon": "Hide Dock icon",
|
"hideDockIcon": "Hide Dock icon",
|
||||||
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
"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",
|
"maxConcurrentDownloads": "Maximum number of active downloads",
|
||||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
@@ -403,6 +406,7 @@
|
|||||||
"onlyLatest": "Download only the latest video",
|
"onlyLatest": "Download only the latest video",
|
||||||
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
|
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
"onlyLatestShort": "Only latest"
|
"onlyLatestShort": "Only latest"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -422,13 +426,15 @@
|
|||||||
"empty": "No recent feed items found.",
|
"empty": "No recent feed items found.",
|
||||||
"queued": "Queued",
|
"queued": "Queued",
|
||||||
"notQueued": "Not queued",
|
"notQueued": "Not queued",
|
||||||
|
"fromChannel": "From {{channel}}",
|
||||||
"actions": {
|
"actions": {
|
||||||
"open": "Open in browser"
|
"open": "Open in browser"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
"unknown": "Unknown subscription"
|
"unknown": "Unknown subscription",
|
||||||
|
"noThumbnail": "No thumbnail"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"directoryError": "Failed to open the directory picker.",
|
"directoryError": "Failed to open the directory picker.",
|
||||||
@@ -452,10 +458,14 @@
|
|||||||
"description": "Tweak filters, tags, and overrides for this feed."
|
"description": "Tweak filters, tags, and overrides for this feed."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
"title": "Status",
|
||||||
"up-to-date": "Up to date",
|
"up-to-date": "Up to date",
|
||||||
"checking": "Checking",
|
"checking": "Checking",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"idle": "Idle"
|
"idle": "Idle",
|
||||||
|
"tooltip": {
|
||||||
|
"updatedAt": "Updated: {{time}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
|
|||||||
@@ -433,6 +433,21 @@ export function Settings() {
|
|||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
</ItemGroup>
|
</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>
|
||||||
|
|
||||||
<TabsContent value="rss" className="space-y-4 mt-2">
|
<TabsContent value="rss" className="space-y-4 mt-2">
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import { Badge } from '@renderer/components/ui/badge'
|
import { Badge } from '@renderer/components/ui/badge'
|
||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger
|
||||||
|
} from '@renderer/components/ui/context-menu'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle
|
||||||
DialogTrigger
|
|
||||||
} from '@renderer/components/ui/dialog'
|
} from '@renderer/components/ui/dialog'
|
||||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
|
||||||
import { Input } from '@renderer/components/ui/input'
|
import { Input } from '@renderer/components/ui/input'
|
||||||
import { Label } from '@renderer/components/ui/label'
|
import { Label } from '@renderer/components/ui/label'
|
||||||
|
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||||
import { Switch } from '@renderer/components/ui/switch'
|
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 { ipcServices } from '@renderer/lib/ipc'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
import { settingsAtom } from '@renderer/store/settings'
|
import { settingsAtom } from '@renderer/store/settings'
|
||||||
import {
|
import {
|
||||||
createSubscriptionAtom,
|
createSubscriptionAtom,
|
||||||
@@ -30,18 +40,11 @@ import type {
|
|||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useAtom, useSetAtom } from 'jotai'
|
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 { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
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) =>
|
const sanitizeCommaList = (value: string) =>
|
||||||
value
|
value
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -50,6 +53,157 @@ const sanitizeCommaList = (value: string) =>
|
|||||||
|
|
||||||
const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-')
|
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() {
|
export function Subscriptions() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [subscriptions] = useAtom(subscriptionsAtom)
|
const [subscriptions] = useAtom(subscriptionsAtom)
|
||||||
@@ -58,6 +212,7 @@ export function Subscriptions() {
|
|||||||
const refreshSubscription = useSetAtom(refreshSubscriptionAtom)
|
const refreshSubscription = useSetAtom(refreshSubscriptionAtom)
|
||||||
|
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
|
const [selectedTab, setSelectedTab] = useState<string>('')
|
||||||
|
|
||||||
const sortedSubscriptions = useMemo(
|
const sortedSubscriptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -103,37 +258,76 @@ export function Subscriptions() {
|
|||||||
setAddDialogOpen(false)
|
setAddDialogOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const renderStatus = (subscription: SubscriptionRule) => {
|
// Filter subscriptions based on selected tab
|
||||||
const meta = statusStyles[subscription.status]
|
const displayedSubscriptions = useMemo(() => {
|
||||||
return (
|
if (!selectedTab) {
|
||||||
<span className={`text-xs ${meta.color}`}>
|
return []
|
||||||
{t(`subscriptions.status.${subscription.status}`)}
|
}
|
||||||
</span>
|
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 (
|
return (
|
||||||
<div className="relative space-y-8 p-6">
|
<div className="relative">
|
||||||
<section className="space-y-4">
|
{/* Channel Tabs Header */}
|
||||||
{sortedSubscriptions.length === 0 ? (
|
{sortedSubscriptions.length > 0 && (
|
||||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
<div className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
|
||||||
{t('subscriptions.empty')}
|
<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>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-3">
|
)}
|
||||||
{sortedSubscriptions.map((subscription) => (
|
|
||||||
<SubscriptionCard
|
{/* Content Area */}
|
||||||
key={subscription.id}
|
<div className="relative space-y-8 p-6">
|
||||||
subscription={subscription}
|
<section className="space-y-4">
|
||||||
onRefresh={() => refreshSubscription(subscription.id)}
|
{sortedSubscriptions.length === 0 ? (
|
||||||
onRemove={() => removeSubscription(subscription.id)}
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
|
{t('subscriptions.empty')}
|
||||||
renderStatus={() => renderStatus(subscription)}
|
</div>
|
||||||
/>
|
) : !selectedTab ? (
|
||||||
))}
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
</div>
|
{t('subscriptions.empty')}
|
||||||
)}
|
</div>
|
||||||
</section>
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{displayedSubscriptions.map((subscription) => (
|
||||||
|
<SubscriptionCard key={subscription.id} subscription={subscription} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Floating Action Button */}
|
{/* Floating Action Button */}
|
||||||
<Button
|
<Button
|
||||||
@@ -156,9 +350,9 @@ export function Subscriptions() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubscriptionCardProps {
|
interface SubscriptionTabProps {
|
||||||
subscription: SubscriptionRule
|
subscription: SubscriptionRule
|
||||||
renderStatus: () => React.ReactNode
|
isActive: boolean
|
||||||
onRefresh: () => Promise<void>
|
onRefresh: () => Promise<void>
|
||||||
onRemove: () => Promise<void>
|
onRemove: () => Promise<void>
|
||||||
onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>
|
onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>
|
||||||
@@ -174,31 +368,10 @@ interface SubscriptionRuleUpdateForm {
|
|||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscriptionCard({
|
function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) {
|
||||||
subscription,
|
|
||||||
renderStatus,
|
|
||||||
onRefresh,
|
|
||||||
onRemove,
|
|
||||||
onUpdate
|
|
||||||
}: SubscriptionCardProps) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
|
||||||
const feedItems: SubscriptionFeedItem[] = subscription.items ?? []
|
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) => {
|
const handleOpenItem = async (url: string) => {
|
||||||
try {
|
try {
|
||||||
await ipcServices.fs.openExternal(url)
|
await ipcServices.fs.openExternal(url)
|
||||||
@@ -208,112 +381,87 @@ function SubscriptionCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumbnail = subscription.coverUrl
|
if (feedItems.length === 0) {
|
||||||
const lastCheckedLabel = subscription.lastCheckedAt
|
return (
|
||||||
? dayjs(subscription.lastCheckedAt).format('YYYY-MM-DD HH:mm')
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
: t('subscriptions.never')
|
{t('subscriptions.items.empty')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card">
|
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||||
<div className="border-b p-4">
|
{feedItems.map((item) => (
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
<article key={`${subscription.id}-${item.id}`} className="group transition-all">
|
||||||
<div className="flex w-full items-start gap-3">
|
<div className="relative w-full overflow-hidden bg-muted aspect-video overflow-hidden rounded-2xl">
|
||||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded bg-muted">
|
{item.thumbnail ? (
|
||||||
<ImageWithPlaceholder
|
<RemoteImage
|
||||||
src={thumbnail}
|
src={item.thumbnail}
|
||||||
alt={subscription.title}
|
alt={item.title}
|
||||||
className="h-full w-full object-cover"
|
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="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{t('subscriptions.labels.noThumbnail')}
|
||||||
<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>
|
</div>
|
||||||
{subscription.latestVideoTitle && (
|
)}
|
||||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/5 to-transparent" />
|
||||||
{subscription.latestVideoTitle}
|
<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">
|
||||||
</p>
|
{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 className="max-w-[10rem] truncate text-xs">
|
||||||
<span>{t('subscriptions.lastChecked', { time: lastCheckedLabel })}</span>
|
{subscription.title || t('subscriptions.labels.unknown')}
|
||||||
{renderStatus()}
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="absolute bottom-3 left-3 text-xs font-medium text-white">
|
||||||
<div className="flex flex-wrap items-center gap-2 md:justify-end">
|
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
|
||||||
<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>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => void handleRefresh()}>
|
<Badge
|
||||||
{t('subscriptions.actions.refresh')}
|
variant={item.addedToQueue ? 'default' : 'secondary'}
|
||||||
</Button>
|
className={cn(
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
'absolute bottom-3 right-3 rounded-full text-xs text-white backdrop-blur',
|
||||||
<DialogTrigger asChild>
|
item.addedToQueue ? 'bg-emerald-500' : 'bg-black/70'
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
{item.addedToQueue
|
||||||
<p className="truncate text-sm" title={item.title}>
|
? t('subscriptions.items.queued')
|
||||||
{item.title}
|
: t('subscriptions.items.notQueued')}
|
||||||
</p>
|
</Badge>
|
||||||
<p className="text-xs text-muted-foreground">
|
</div>
|
||||||
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
|
<div className="flex flex-col gap-4 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</p>
|
<div className="space-y-1">
|
||||||
</div>
|
<p
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
className="text-base font-semibold leading-snug text-card-foreground"
|
||||||
<Badge variant={item.addedToQueue ? 'default' : 'outline'} className="text-xs">
|
title={item.title}
|
||||||
{item.addedToQueue
|
>
|
||||||
? t('subscriptions.items.queued')
|
{item.title}
|
||||||
: t('subscriptions.items.notQueued')}
|
</p>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ export interface AppSettings {
|
|||||||
subscriptionFilenameTemplate: string
|
subscriptionFilenameTemplate: string
|
||||||
subscriptionOnlyLatestDefault: boolean
|
subscriptionOnlyLatestDefault: boolean
|
||||||
subscriptionCheckIntervalHours: number
|
subscriptionCheckIntervalHours: number
|
||||||
|
enableAnalytics: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSettings: AppSettings = {
|
export const defaultSettings: AppSettings = {
|
||||||
@@ -292,5 +293,6 @@ export const defaultSettings: AppSettings = {
|
|||||||
autoUpdate: true,
|
autoUpdate: true,
|
||||||
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
|
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
|
||||||
subscriptionOnlyLatestDefault: true,
|
subscriptionOnlyLatestDefault: true,
|
||||||
subscriptionCheckIntervalHours: 3
|
subscriptionCheckIntervalHours: 3,
|
||||||
|
enableAnalytics: true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user