diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json
index fbc178a..33dd3c2 100644
--- a/src/renderer/src/locales/en.json
+++ b/src/renderer/src/locales/en.json
@@ -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": {
diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx
index 3716508..7465b71 100644
--- a/src/renderer/src/pages/Settings.tsx
+++ b/src/renderer/src/pages/Settings.tsx
@@ -433,6 +433,21 @@ export function Settings() {
+
+
+ -
+
+ {t('settings.enableAnalytics')}
+ {t('settings.enableAnalyticsDescription')}
+
+
+ handleSettingChange('enableAnalytics', value)}
+ />
+
+
+
diff --git a/src/renderer/src/pages/Subscriptions.tsx b/src/renderer/src/pages/Subscriptions.tsx
index 577bed7..5e61112 100644
--- a/src/renderer/src/pages/Subscriptions.tsx
+++ b/src/renderer/src/pages/Subscriptions.tsx
@@ -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 = {
- '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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {statusDescription}
+
+ {t('subscriptions.status.tooltip.updatedAt', { time: lastUpdatedLabel })}
+
+
+
+
+
+ {subscription.title || t('subscriptions.labels.unknown')}
+
+
+
+
+
+
+
+ {t('subscriptions.actions.refresh')}
+
+
+
+ {t('subscriptions.actions.edit')}
+
+
+ void handleToggleEnabled(checked)}
+ >
+
+ {t('subscriptions.fields.enabled')}
+
+
+ void handleRemove()} variant="destructive">
+
+ {t('subscriptions.actions.remove')}
+
+
+
+
+
+ >
+ )
+}
+
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('')
const sortedSubscriptions = useMemo(
() =>
@@ -103,37 +258,76 @@ export function Subscriptions() {
setAddDialogOpen(false)
}, [])
- const renderStatus = (subscription: SubscriptionRule) => {
- const meta = statusStyles[subscription.status]
- return (
-
- {t(`subscriptions.status.${subscription.status}`)}
-
- )
- }
+ // 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 (
-
-
- {sortedSubscriptions.length === 0 ? (
-
- {t('subscriptions.empty')}
+
+ {/* Channel Tabs Header */}
+ {sortedSubscriptions.length > 0 && (
+
+
+
+
+
+ {/* Subscription Channel Tabs */}
+ {sortedSubscriptions.map((subscription) => (
+ refreshSubscription(subscription.id)}
+ onRemove={() => removeSubscription(subscription.id)}
+ onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
+ />
+ ))}
+
+
+
- ) : (
-
- {sortedSubscriptions.map((subscription) => (
- refreshSubscription(subscription.id)}
- onRemove={() => removeSubscription(subscription.id)}
- onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
- renderStatus={() => renderStatus(subscription)}
- />
- ))}
-
- )}
-
+
+ )}
+
+ {/* Content Area */}
+
+
+ {sortedSubscriptions.length === 0 ? (
+
+ {t('subscriptions.empty')}
+
+ ) : !selectedTab ? (
+
+ {t('subscriptions.empty')}
+
+ ) : (
+
+ {displayedSubscriptions.map((subscription) => (
+
+ ))}
+
+ )}
+
+
{/* Floating Action Button */}