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",
|
||||
"@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
30
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
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,
|
||||
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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user