From 0bd27a96c3df8c66a73a8536e766551746384a40 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Mon, 10 Nov 2025 22:05:10 +0800 Subject: [PATCH] refactor(dialog): convert to functional wrappers, simplify overlay and --- package.json | 1 + pnpm-lock.yaml | 8 + src/main/lib/subscription-manager.ts | 5 + src/renderer/src/assets/global.css | 2 + src/renderer/src/components/ui/dialog.tsx | 181 ++--- src/renderer/src/locales/en.json | 7 +- src/renderer/src/pages/Subscriptions.tsx | 778 +++++++++++++--------- src/shared/types/index.ts | 3 + 8 files changed, 579 insertions(+), 406 deletions(-) diff --git a/package.json b/package.json index 5630e17..b69758b 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", + "tw-animate-css": "^1.4.0", "yt-dlp-wrap-plus": "^2.3.20", "zod": "^4.1.11" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65767ff..2238a5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: tailwindcss: specifier: ^4.1.13 version: 4.1.15 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 yt-dlp-wrap-plus: specifier: ^2.3.20 version: 2.3.20 @@ -3303,6 +3306,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -6689,6 +6695,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} + type-fest@0.13.1: optional: true diff --git a/src/main/lib/subscription-manager.ts b/src/main/lib/subscription-manager.ts index 133389e..ba79d24 100644 --- a/src/main/lib/subscription-manager.ts +++ b/src/main/lib/subscription-manager.ts @@ -208,6 +208,11 @@ export class SubscriptionManager extends EventEmitter { updatedAt: Date.now() } + // If sourceUrl is updated but title is not explicitly set, update title to match sourceUrl + if (updates.sourceUrl && !updates.title && updates.sourceUrl !== existing.sourceUrl) { + next.title = updates.sourceUrl + } + if (updates.namingTemplate) { next.namingTemplate = sanitizeFilenameTemplate(updates.namingTemplate) } diff --git a/src/renderer/src/assets/global.css b/src/renderer/src/assets/global.css index b8840ac..5c28867 100644 --- a/src/renderer/src/assets/global.css +++ b/src/renderer/src/assets/global.css @@ -1,5 +1,7 @@ @import 'tailwindcss'; @import './theme.css'; +@import "tw-animate-css"; + @layer base { * { diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index 71d9761..036abba 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -1,101 +1,126 @@ import * as DialogPrimitive from '@radix-ui/react-dialog' import { cn } from '@renderer/lib/utils' -import { X } from 'lucide-react' -import * as React from 'react' +import { XIcon } from 'lucide-react' +import type * as React from 'react' -const Dialog = DialogPrimitive.Root +function Dialog({ ...props }: React.ComponentProps) { + return +} -const DialogTrigger = DialogPrimitive.Trigger +function DialogTrigger({ ...props }: React.ComponentProps) { + return +} -const DialogPortal = DialogPrimitive.Portal +function DialogPortal({ ...props }: React.ComponentProps) { + return +} -const DialogClose = DialogPrimitive.Close +function DialogClose({ ...props }: React.ComponentProps) { + return +} -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - ) { + return ( + - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName + /> + ) +} -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = 'DialogHeader' +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} -const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = 'DialogFooter' +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} export { Dialog, - DialogPortal, - DialogOverlay, DialogClose, - DialogTrigger, DialogContent, - DialogHeader, + DialogDescription, DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, DialogTitle, - DialogDescription + DialogTrigger } diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 613edb0..fbc178a 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -379,6 +379,7 @@ }, "subscriptions": { "title": "Subscriptions", + "subtitle": "{{count}} subscription{{count, plural, one {} other {s}}}", "description": "Automatically monitor RSS feeds and queue new downloads without manual work.", "defaults": { "title": "Automation defaults", @@ -416,7 +417,8 @@ "selectDirectory": "Browse" }, "items": { - "title": "Latest uploads", + "title": "Latest uploads ({{count}})", + "count": "{{count}} items", "empty": "No recent feed items found.", "queued": "Queued", "notQueued": "Not queued", @@ -436,7 +438,8 @@ "refreshStarted": "Refresh started", "removed": "Subscription removed", "updated": "Subscription updated", - "openLinkError": "Failed to open the video link." + "openLinkError": "Failed to open the video link.", + "resolveError": "Failed to resolve RSS feed URL." }, "detectedFeed": "Detected {{platform}} feed -> {{feed}}", "detecting": "Detecting feed...", diff --git a/src/renderer/src/pages/Subscriptions.tsx b/src/renderer/src/pages/Subscriptions.tsx index 77c0cda..577bed7 100644 --- a/src/renderer/src/pages/Subscriptions.tsx +++ b/src/renderer/src/pages/Subscriptions.tsx @@ -1,12 +1,5 @@ import { Badge } from '@renderer/components/ui/badge' import { Button } from '@renderer/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from '@renderer/components/ui/card' import { Dialog, DialogContent, @@ -37,19 +30,16 @@ import type { } from '@shared/types' import dayjs from 'dayjs' import { useAtom, useSetAtom } from 'jotai' -import { ExternalLink } from 'lucide-react' -import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { ExternalLink, Plus } 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'], - { label: string; emoji: string; color: string } -> = { - 'up-to-date': { label: 'Up to date', emoji: '✅', color: 'text-emerald-500' }, - checking: { label: 'Checking', emoji: '🔄', color: 'text-blue-500' }, - failed: { label: 'Failed', emoji: '⚠️', color: 'text-amber-500' }, - idle: { label: 'Idle', emoji: '⏸️', color: 'text-muted-foreground' } +const statusStyles: Record = { + '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) => @@ -62,12 +52,282 @@ const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-') export function Subscriptions() { const { t } = useTranslation() - const [settings] = useAtom(settingsAtom) const [subscriptions] = useAtom(subscriptionsAtom) - const createSubscription = useSetAtom(createSubscriptionAtom) const updateSubscription = useSetAtom(updateSubscriptionAtom) const removeSubscription = useSetAtom(removeSubscriptionAtom) const refreshSubscription = useSetAtom(refreshSubscriptionAtom) + + const [addDialogOpen, setAddDialogOpen] = useState(false) + + const sortedSubscriptions = useMemo( + () => + [...subscriptions].sort( + (a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0) + ), + [subscriptions] + ) + + const resolveFeed = useSetAtom(resolveFeedAtom) + const handleUpdateSubscription = useCallback( + async (id: string, data: SubscriptionRuleUpdateForm) => { + const updatePayload: Parameters[0]['data'] = { + keywords: data.keywords, + tags: data.tags, + onlyDownloadLatest: data.onlyDownloadLatest, + downloadDirectory: data.downloadDirectory, + namingTemplate: data.namingTemplate, + enabled: data.enabled + } + + // If URL is provided, resolve it and include sourceUrl, feedUrl, and platform + if (data.url) { + try { + const resolved = await resolveFeed(data.url) + updatePayload.sourceUrl = resolved.sourceUrl + updatePayload.feedUrl = resolved.feedUrl + updatePayload.platform = resolved.platform + } catch (error) { + console.error('Failed to resolve feed URL:', error) + toast.error(t('subscriptions.notifications.resolveError')) + return + } + } + + await updateSubscription({ id, data: updatePayload }) + await refreshSubscription(id) + }, + [refreshSubscription, updateSubscription, resolveFeed, t] + ) + + const handleCreateSubscription = useCallback(async () => { + setAddDialogOpen(false) + }, []) + + const renderStatus = (subscription: SubscriptionRule) => { + const meta = statusStyles[subscription.status] + return ( + + {t(`subscriptions.status.${subscription.status}`)} + + ) + } + + return ( +
+
+ {sortedSubscriptions.length === 0 ? ( +
+ {t('subscriptions.empty')} +
+ ) : ( +
+ {sortedSubscriptions.map((subscription) => ( + refreshSubscription(subscription.id)} + onRemove={() => removeSubscription(subscription.id)} + onUpdate={(data) => handleUpdateSubscription(subscription.id, data)} + renderStatus={() => renderStatus(subscription)} + /> + ))} +
+ )} +
+ + {/* Floating Action Button */} + + + + setAddDialogOpen(false)} + /> + +
+ ) +} + +interface SubscriptionCardProps { + subscription: SubscriptionRule + renderStatus: () => React.ReactNode + onRefresh: () => Promise + onRemove: () => Promise + onUpdate: (data: SubscriptionRuleUpdateForm) => Promise +} + +interface SubscriptionRuleUpdateForm { + url?: string + keywords?: string[] + tags?: string[] + onlyDownloadLatest?: boolean + downloadDirectory?: string + namingTemplate?: string + enabled?: boolean +} + +function SubscriptionCard({ + subscription, + renderStatus, + onRefresh, + onRemove, + onUpdate +}: SubscriptionCardProps) { + 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) + } catch (error) { + console.error('Failed to open subscription item link:', error) + toast.error(t('subscriptions.notifications.openLinkError')) + } + } + + const thumbnail = subscription.coverUrl + const lastCheckedLabel = subscription.lastCheckedAt + ? dayjs(subscription.lastCheckedAt).format('YYYY-MM-DD HH:mm') + : t('subscriptions.never') + + return ( +
+
+
+
+
+ +
+
+
+

+ {subscription.title || t('subscriptions.labels.unknown')} +

+ {(subscription.tags ?? []).map((tag) => ( + + {tag} + + ))} +
+ {subscription.latestVideoTitle && ( +

+ {subscription.latestVideoTitle} +

+ )} +
+ {t('subscriptions.lastChecked', { time: lastCheckedLabel })} + {renderStatus()} +
+
+
+
+
+ {t('subscriptions.fields.enabled')} + void handleToggleEnabled(checked)} + /> +
+ + + + + + { + await onUpdate(data) + toast.success(t('subscriptions.notifications.updated')) + setEditOpen(false) + }} + /> + + +
+
+
+ {feedItems.length > 0 && ( +
+ {feedItems.map((item) => ( +
+
+

+ {item.title} +

+

+ {dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')} +

+
+
+ + {item.addedToQueue + ? t('subscriptions.items.queued') + : t('subscriptions.items.notQueued')} + + +
+
+ ))} +
+ )} +
+ ) +} + +interface SubscriptionAddDialogProps { + open: boolean + onCreate: () => Promise + onClose: () => void +} + +function SubscriptionAddDialog({ open, onCreate, onClose }: SubscriptionAddDialogProps) { + const { t } = useTranslation() + const [settings] = useAtom(settingsAtom) + const createSubscription = useSetAtom(createSubscriptionAtom) const resolveFeed = useSetAtom(resolveFeedAtom) const [url, setUrl] = useState('') @@ -83,6 +343,24 @@ export function Subscriptions() { const prevDefaultPathRef = useRef(settings.downloadPath) const urlInputId = useId() + // Reset form when dialog closes + useEffect(() => { + if (!open) { + setUrl('') + setKeywords('') + setTags('') + setDetectedFeed(null) + setOnlyLatest(settings.subscriptionOnlyLatestDefault) + setCustomDownloadDirectory(settings.downloadPath) + setNamingTemplate(settings.subscriptionFilenameTemplate) + } + }, [ + open, + settings.subscriptionOnlyLatestDefault, + settings.downloadPath, + settings.subscriptionFilenameTemplate + ]) + useEffect(() => { const newPath = settings.downloadPath setCustomDownloadDirectory((prev) => { @@ -132,14 +410,6 @@ export function Subscriptions() { } }, [url, resolveFeed]) - const sortedSubscriptions = useMemo( - () => - [...subscriptions].sort( - (a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0) - ), - [subscriptions] - ) - const handleSelectDirectory = async () => { try { const path = await ipcServices.fs.selectDirectory() @@ -172,314 +442,93 @@ export function Subscriptions() { setKeywords('') setTags('') setDetectedFeed(null) + await onCreate() } catch (error) { console.error('Failed to create subscription:', error) toast.error(t('subscriptions.notifications.createError')) } } - const renderStatus = (subscription: SubscriptionRule) => { - const meta = statusStyles[subscription.status] - return ( -
- {meta.emoji} - - {t(`subscriptions.status.${subscription.status}`)} - -
- ) - } - return ( -
-
-

{t('subscriptions.title')}

-

{t('subscriptions.description')}

-
- - - - {t('subscriptions.add.title')} - {t('subscriptions.add.description')} - - + + + {t('subscriptions.add.title')} + {t('subscriptions.add.description')} + +
+
+ + setUrl(event.target.value)} + /> + {detectedFeed && ( + + {t('subscriptions.detectedFeed', { + platform: detectedFeed.platform, + feed: detectedFeed.feedUrl + })} + + )} + {detectingFeed && ( +

{t('subscriptions.detecting')}

+ )} +
+
- + setUrl(event.target.value)} + value={keywords} + onChange={(event) => setKeywords(event.target.value)} + placeholder="AI, tutorial" /> - {detectedFeed && ( - - {t('subscriptions.detectedFeed', { - platform: detectedFeed.platform, - feed: detectedFeed.feedUrl - })} - - )} - {detectingFeed && ( -

{t('subscriptions.detecting')}

- )}
-
-
- - setKeywords(event.target.value)} - placeholder="AI, tutorial" - /> -
-
- - setTags(event.target.value)} - placeholder="YouTube, AI" - /> -
-
-
-
- -
- - -
-
-
- - setNamingTemplate(sanitizeTemplateInput(event.target.value))} - placeholder="%(uploader)s - %(title)s.%(ext)s" - /> -
-
-
-
-

{t('subscriptions.fields.onlyLatest')}

-

- {t('subscriptions.fields.onlyLatestDescription')} -

-
- -
-
- -
- - - -
-
-

{t('subscriptions.title')}

-

{t('subscriptions.description')}

-
- {sortedSubscriptions.length === 0 ? ( - - - {t('subscriptions.empty')} - - - ) : ( -
- {sortedSubscriptions.map((subscription) => ( - refreshSubscription(subscription.id)} - onRemove={() => removeSubscription(subscription.id)} - onUpdate={(data) => updateSubscription({ id: subscription.id, data })} - renderStatus={() => renderStatus(subscription)} - /> - ))} -
- )} -
-
- ) -} - -interface SubscriptionCardProps { - subscription: SubscriptionRule - renderStatus: () => React.ReactNode - onRefresh: () => Promise - onRemove: () => Promise - onUpdate: (data: SubscriptionRuleUpdateForm) => Promise -} - -interface SubscriptionRuleUpdateForm { - keywords?: string[] - tags?: string[] - onlyDownloadLatest?: boolean - downloadDirectory?: string - namingTemplate?: string - enabled?: boolean -} - -function SubscriptionCard({ - subscription, - renderStatus, - onRefresh, - onRemove, - onUpdate -}: SubscriptionCardProps) { - const { t } = useTranslation() - const [editOpen, setEditOpen] = useState(false) - const feedItems: SubscriptionFeedItem[] = subscription.items ?? [] - - const handleToggleEnabled = async (checked: boolean) => { - await onUpdate({ enabled: checked }) - } - - const handleToggleMode = async (checked: boolean) => { - await onUpdate({ onlyDownloadLatest: 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) - } catch (error) { - console.error('Failed to open subscription item link:', error) - toast.error(t('subscriptions.notifications.openLinkError')) - } - } - - const thumbnail = subscription.coverUrl - const lastCheckedLabel = subscription.lastCheckedAt - ? dayjs(subscription.lastCheckedAt).format('YYYY-MM-DD HH:mm') - : t('subscriptions.never') - - return ( - - -
-
-
- -
-
-
- - {subscription.title || t('subscriptions.labels.unknown')} - - {(subscription.tags ?? []).map((tag) => ( - - {tag} - - ))} -
- {subscription.latestVideoTitle && ( - - {t('subscriptions.latestVideo', { title: subscription.latestVideoTitle })} - - )} -

- {t('subscriptions.lastChecked', { time: lastCheckedLabel })} -

-
{renderStatus()}
-
-
-
-
- {t('subscriptions.fields.enabled')} - void handleToggleEnabled(checked)} - /> -
-
- {t('subscriptions.fields.onlyLatestShort')} - void handleToggleMode(checked)} - /> -
- - - - - - { - await onUpdate(data) - toast.success(t('subscriptions.notifications.updated')) - setEditOpen(false) - }} - /> - - -
-
-
- -
-

{t('subscriptions.items.title')}

-
- {feedItems.length === 0 ? ( -

{t('subscriptions.items.empty')}

- ) : (
- {feedItems.map((item) => ( -
-
-

- {item.title} -

-

- {dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')} -

-
-
- - {item.addedToQueue - ? t('subscriptions.items.queued') - : t('subscriptions.items.notQueued')} - - -
-
- ))} + + setTags(event.target.value)} + placeholder="YouTube, AI" + />
- )} -
-
+
+
+
+ +
+ + +
+
+
+ + setNamingTemplate(sanitizeTemplateInput(event.target.value))} + placeholder="%(uploader)s - %(title)s.%(ext)s" + /> +
+
+
+
+

{t('subscriptions.fields.onlyLatest')}

+
+ +
+
+ + + + + ) } @@ -490,10 +539,48 @@ interface SubscriptionEditDialogProps { function SubscriptionEditDialog({ subscription, onSave }: SubscriptionEditDialogProps) { const { t } = useTranslation() + const resolveFeed = useSetAtom(resolveFeedAtom) + const [url, setUrl] = useState(subscription.feedUrl) const [keywords, setKeywords] = useState(subscription.keywords.join(', ')) const [tags, setTags] = useState(subscription.tags.join(', ')) const [downloadDirectory, setDownloadDirectory] = useState(subscription.downloadDirectory || '') const [namingTemplate, setNamingTemplate] = useState(subscription.namingTemplate || '') + const [onlyDownloadLatest, setOnlyDownloadLatest] = useState(subscription.onlyDownloadLatest) + const [detectedFeed, setDetectedFeed] = useState(null) + const [detectingFeed, setDetectingFeed] = useState(false) + + const detectTimeout = useRef(null) + const urlInputId = useId() + + useEffect(() => { + if (!url.trim() || url.trim() === subscription.feedUrl) { + setDetectedFeed(null) + return + } + + if (detectTimeout.current) { + clearTimeout(detectTimeout.current) + } + + detectTimeout.current = setTimeout(async () => { + setDetectingFeed(true) + try { + const result = await resolveFeed(url.trim()) + setDetectedFeed(result) + } catch (error) { + console.error('Failed to resolve feed:', error) + setDetectedFeed(null) + } finally { + setDetectingFeed(false) + } + }, 500) + + return () => { + if (detectTimeout.current) { + clearTimeout(detectTimeout.current) + } + } + }, [url, resolveFeed, subscription.feedUrl]) const handleSelectDirectory = async () => { try { @@ -508,21 +595,56 @@ function SubscriptionEditDialog({ subscription, onSave }: SubscriptionEditDialog } const handleSave = async () => { - await onSave({ + const updateData: SubscriptionRuleUpdateForm = { keywords: sanitizeCommaList(keywords), tags: sanitizeCommaList(tags), downloadDirectory: downloadDirectory || undefined, - namingTemplate: namingTemplate || undefined - }) + namingTemplate: namingTemplate || undefined, + onlyDownloadLatest + } + + // If feed URL changed, resolve it to validate and include in update + if (url.trim() && url.trim() !== subscription.feedUrl) { + try { + await resolveFeed(url.trim()) + updateData.url = url.trim() + } catch (error) { + console.error('Failed to resolve feed:', error) + toast.error(t('subscriptions.notifications.resolveError')) + return + } + } + + await onSave(updateData) } return ( - + {t('subscriptions.edit.title', { name: subscription.title })} {t('subscriptions.edit.description')}
+
+ + setUrl(event.target.value)} + /> + {detectedFeed && ( + + {t('subscriptions.detectedFeed', { + platform: detectedFeed.platform, + feed: detectedFeed.feedUrl + })} + + )} + {detectingFeed && ( +

{t('subscriptions.detecting')}

+ )} +
setKeywords(event.target.value)} /> @@ -547,6 +669,10 @@ function SubscriptionEditDialog({ subscription, onSave }: SubscriptionEditDialog onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))} />
+
+

{t('subscriptions.fields.onlyLatest')}

+ +
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index e0afe93..637f3cc 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -236,6 +236,9 @@ export interface SubscriptionCreatePayload { export interface SubscriptionUpdatePayload { title?: string + sourceUrl?: string + feedUrl?: string + platform?: SubscriptionPlatform keywords?: string[] tags?: string[] onlyDownloadLatest?: boolean