From 41e4a4993c40ebb2b4607d34d57e2249eac5c2bd Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Tue, 11 Nov 2025 23:11:31 +0800 Subject: [PATCH] feat(rss): add enclosure support, refine thumbnail lookup and UI labels feat(settings): add toggleable anonymous analytics collection and loader script --- package.json | 1 + pnpm-lock.yaml | 30 ++ src/main/lib/subscription-scheduler.ts | 29 +- src/renderer/index.html | 2 +- src/renderer/src/App.tsx | 47 +- .../src/components/ui/context-menu.tsx | 221 +++++++++ src/renderer/src/components/ui/sidebar.tsx | 4 +- src/renderer/src/locales/en.json | 14 +- src/renderer/src/pages/Settings.tsx | 15 + src/renderer/src/pages/Subscriptions.tsx | 468 ++++++++++++------ src/shared/types/index.ts | 4 +- 11 files changed, 665 insertions(+), 170 deletions(-) create mode 100644 src/renderer/src/components/ui/context-menu.tsx diff --git a/package.json b/package.json index b69758b..9915c9b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2238a5e..ec29391 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/main/lib/subscription-scheduler.ts b/src/main/lib/subscription-scheduler.ts index 580177b..b9dfa79 100644 --- a/src/main/lib/subscription-scheduler.ts +++ b/src/main/lib/subscription-scheduler.ts @@ -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 } diff --git a/src/renderer/index.html b/src/renderer/index.html index 41cbb87..0988ce5 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,7 @@ VidBee + 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" /> diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 865c149..232a3cb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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('') 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(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 () => { diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..2b65854 --- /dev/null +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -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) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuGroup({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuPortal({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuSub({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup +} diff --git a/src/renderer/src/components/ui/sidebar.tsx b/src/renderer/src/components/ui/sidebar.tsx index 8312d52..d5d2319 100644 --- a/src/renderer/src/components/ui/sidebar.tsx +++ b/src/renderer/src/components/ui/sidebar.tsx @@ -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 (