Add bulk history actions and selection UX #37 (#42)

* Add collapsed playlist progress

* Improve history bulk actions and selection UX

* Fix ffmpeg download resolution in setup script
This commit is contained in:
Nexmoe
2025-12-20 12:07:13 +08:00
committed by GitHub
parent 58a4d2da36
commit dbcd77963f
15 changed files with 862 additions and 102 deletions

21
.vscode/settings.json vendored
View File

@@ -21,9 +21,20 @@
},
"typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
[
"cva\\(([^)]*)\\)",
"[\"'`]([^\"'`]*).*?[\"'`]"
],
[
"cn\\(([^)]*)\\)",
"(?:'|\"|`)([^']*)(?:'|\"|`)"
]
],
"i18n-ally.localesPaths": ["src/renderer/src/locales"],
"i18n-ally.keystyle": "nested"
}
"i18n-ally.localesPaths": [
"src/renderer/src/locales"
],
"i18n-ally.keystyle": "nested",
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

View File

@@ -15,6 +15,8 @@ const http = require('node:http')
// Configuration
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
const GITHUB_TOKEN =
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
// Platform configuration
const PLATFORM_CONFIG = {
@@ -27,7 +29,12 @@ const PLATFORM_CONFIG = {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
output: 'ffmpeg.exe',
extract: 'unzip'
extract: 'unzip',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
assetPattern: /win64.*gpl.*\.zip$/i,
binaryName: 'ffmpeg.exe'
}
}
},
darwin: {
@@ -41,13 +48,21 @@ const PLATFORM_CONFIG = {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
extract: 'unzip'
extract: 'unzip',
release: {
repo: 'eko5624/mpv-mac',
assetPattern: /ffmpeg-arm64.*\.zip$/i
}
},
x64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
extract: 'unzip'
extract: 'unzip',
release: {
repo: 'eko5624/mpv-mac',
assetPattern: /ffmpeg-x86_64.*\.zip$/i
}
}
}
},
@@ -60,7 +75,12 @@ const PLATFORM_CONFIG = {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
output: 'ffmpeg_linux',
extract: 'tar'
extract: 'tar',
release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
binaryName: 'ffmpeg'
}
}
}
}
@@ -117,6 +137,85 @@ function downloadFile(url, dest) {
})
}
function fetchJson(url) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const headers = {
'User-Agent': 'vidbee-setup',
Accept: 'application/vnd.github+json'
}
if (GITHUB_TOKEN) {
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
}
protocol
.get(url, { headers }, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
return fetchJson(response.headers.location).then(resolve).catch(reject)
}
if (response.statusCode !== 200) {
return reject(new Error(`Failed to fetch ${url}: ${response.statusCode}`))
}
let body = ''
response.on('data', (chunk) => {
body += chunk
})
response.on('end', () => {
try {
resolve(JSON.parse(body))
} catch (error) {
reject(new Error(`Failed to parse JSON from ${url}: ${error.message}`))
}
})
})
.on('error', (err) => {
reject(err)
})
})
}
function inferFfmpegInnerPath(assetName, binaryName) {
if (!assetName) {
return null
}
const match = assetName.match(/^(.*)\.(tar\.xz|zip)$/i)
if (!match) {
return null
}
return `${match[1]}/bin/${binaryName}`
}
async function resolveReleaseAsset(release) {
if (!release) {
return null
}
const repoCandidates = release.repos ?? (release.repo ? [release.repo] : [])
if (repoCandidates.length === 0) {
return null
}
let lastError
for (const repo of repoCandidates) {
try {
const data = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`)
const assets = Array.isArray(data.assets) ? data.assets : []
const match = assets.find((asset) => asset?.name && release.assetPattern.test(asset.name))
if (match?.browser_download_url) {
return { name: match.name, url: match.browser_download_url }
}
lastError = new Error(`No matching assets found in ${repo}`)
} catch (error) {
lastError = error
}
}
if (lastError) {
throw lastError
}
return null
}
function extractZip(zipPath, extractDir) {
const platform = os.platform()
ensureDir(extractDir)
@@ -186,7 +285,7 @@ async function downloadYtDlp(config) {
}
async function downloadFfmpegWindows(config) {
const { url, innerPath, output } = config.ffmpeg
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
@@ -197,9 +296,26 @@ async function downloadFfmpegWindows(config) {
log(`Downloading ffmpeg for Windows...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath
if (release) {
try {
const resolved = await resolveReleaseAsset(release)
if (resolved) {
downloadUrl = resolved.url
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg.exe')
if (inferred) {
innerPath = inferred
}
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
}
}
try {
await downloadFile(url, tempZip)
await downloadFile(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -229,7 +345,7 @@ async function downloadFfmpegMac(config) {
throw new Error(`Unsupported architecture: ${arch}`)
}
const { url, innerPath, output } = ffmpegConfig
const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
@@ -240,9 +356,21 @@ async function downloadFfmpegMac(config) {
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
if (release) {
try {
const resolved = await resolveReleaseAsset(release)
if (resolved) {
downloadUrl = resolved.url
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
}
}
try {
await downloadFile(url, tempZip)
await downloadFile(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -266,7 +394,7 @@ async function downloadFfmpegMac(config) {
}
async function downloadFfmpegLinux(config) {
const { url, innerPath, output } = config.ffmpeg
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
@@ -277,9 +405,26 @@ async function downloadFfmpegLinux(config) {
log(`Downloading ffmpeg for Linux...`, 'download')
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath
if (release) {
try {
const resolved = await resolveReleaseAsset(release)
if (resolved) {
downloadUrl = resolved.url
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg')
if (inferred) {
innerPath = inferred
}
}
} catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
}
}
try {
await downloadFile(url, tempTar)
await downloadFile(downloadUrl, tempTar)
log('Extracting ffmpeg...', 'info')
extractTarXz(tempTar, extractDir)

View File

@@ -25,6 +25,21 @@ class HistoryService extends IpcService {
return historyManager.removeHistoryItem(id)
}
@IpcMethod()
removeHistoryItems(_context: IpcContext, ids: string[]): number {
return historyManager.removeHistoryItems(ids)
}
@IpcMethod()
removeHistoryByPlaylistId(_context: IpcContext, playlistId: string): number {
return historyManager.removeHistoryByPlaylistId(playlistId)
}
@IpcMethod()
clearHistory(_context: IpcContext): void {
historyManager.clearHistory()
}
@IpcMethod()
getHistoryCount(_context: IpcContext): {
active: number

View File

@@ -1,7 +1,7 @@
import { existsSync, readFileSync, renameSync } from 'node:fs'
import { join } from 'node:path'
import DatabaseConstructor from 'better-sqlite3'
import { eq, sql } from 'drizzle-orm'
import { eq, inArray, sql } from 'drizzle-orm'
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
@@ -494,6 +494,61 @@ class HistoryManager {
}
}
removeHistoryItems(ids: string[]): number {
const uniqueIds = Array.from(new Set(ids)).filter((id) => id.trim().length > 0)
if (uniqueIds.length === 0) {
return 0
}
let removedCount = 0
try {
const database = this.getDatabase()
const result = database
.delete(downloadHistoryTable)
.where(inArray(downloadHistoryTable.id, uniqueIds))
.run()
for (const id of uniqueIds) {
if (this.history.delete(id)) {
removedCount++
}
}
if ((result.changes ?? 0) > removedCount) {
removedCount = result.changes ?? removedCount
}
return removedCount
} catch (error) {
logger.error('history-db failed to delete items', { count: uniqueIds.length, error })
return removedCount
}
}
removeHistoryByPlaylistId(playlistId: string): number {
const normalized = playlistId.trim()
if (!normalized) {
return 0
}
let removedCount = 0
try {
const database = this.getDatabase()
const result = database
.delete(downloadHistoryTable)
.where(eq(downloadHistoryTable.playlistId, normalized))
.run()
for (const [id, item] of this.history.entries()) {
if (item.playlistId === normalized) {
this.history.delete(id)
removedCount++
}
}
if ((result.changes ?? 0) > removedCount) {
removedCount = result.changes ?? removedCount
}
return removedCount
} catch (error) {
logger.error('history-db failed to delete playlist items', { playlistId: normalized, error })
return removedCount
}
}
clearHistory(): void {
try {
const database = this.getDatabase()

View File

@@ -90,20 +90,6 @@
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: #1da1f2;
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
}
@theme inline {
@@ -148,13 +134,4 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}

View File

@@ -1,5 +1,6 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import { Checkbox } from '@renderer/components/ui/checkbox'
import { Progress } from '@renderer/components/ui/progress'
import { RemoteImage } from '@renderer/components/ui/remote-image'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
@@ -150,6 +151,8 @@ const getCodecLabel = (download: DownloadRecord): string | undefined => {
interface DownloadItemProps {
download: DownloadRecord
isSelected?: boolean
onToggleSelect?: (id: string) => void
}
type MetadataDetail = {
@@ -192,7 +195,7 @@ const formatDateShort = (timestamp?: number) => {
})
}
export function DownloadItem({ download }: DownloadItemProps) {
export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) {
const { t } = useTranslation()
const settings = useAtomValue(settingsAtom)
const removeDownload = useSetAtom(removeDownloadAtom)
@@ -203,12 +206,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
const showActionsWithoutHover = isHistory || download.status === 'completed'
const actionsContainerBaseClass =
'flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
'relative z-20 flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
const actionsContainerClass = showActionsWithoutHover
? actionsContainerBaseClass
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
const resolvedExtension = resolveDownloadExtension(download)
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
const selectionEnabled = isHistory && Boolean(onToggleSelect)
// Track if the file exists
const [fileExists, setFileExists] = useState(false)
@@ -494,7 +498,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="wrap-break-word text-primary hover:underline"
className="relative z-20 wrap-break-word text-primary hover:underline"
>
{download.url}
</a>
@@ -638,18 +642,65 @@ export function DownloadItem({ download }: DownloadItemProps) {
const hasMetadataDetails = metadataDetails.length > 0
const isSelectedHistory = selectionEnabled && isSelected
return (
<div className="group relative w-full max-w-full overflow-hidden">
<div
className={`group relative w-full max-w-full overflow-hidden rounded-lg border border-transparent transition-colors ${
isSelectedHistory ? 'border-primary/60 bg-primary/10 ring-1 ring-primary/30' : ''
}`}
>
{isSelectedHistory && (
<div className="absolute left-0 top-0 h-full w-1 bg-primary/70" aria-hidden="true" />
)}
<button
type="button"
className={`absolute inset-0 z-10 rounded-lg bg-transparent ${
selectionEnabled ? 'cursor-pointer' : 'cursor-default'
} disabled:cursor-default disabled:opacity-100`}
aria-label={t('history.selectItem')}
disabled={!selectionEnabled}
onClick={() => onToggleSelect?.(download.id)}
/>
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
{/* Thumbnail */}
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
<button
type="button"
className={`relative z-20 shrink-0 overflow-hidden rounded-md border bg-background/60 w-32 h-20 disabled:opacity-100 disabled:cursor-default ${
selectionEnabled ? 'cursor-pointer' : 'cursor-default'
} ${isSelectedHistory ? 'border-primary/40' : 'border-border/60'}`}
aria-pressed={selectionEnabled ? Boolean(isSelected) : undefined}
onClick={(event) => {
event.stopPropagation()
if (selectionEnabled) {
onToggleSelect?.(download.id)
}
}}
disabled={!selectionEnabled}
>
{selectionEnabled && (
<div
className={`absolute left-1 top-1 rounded-md bg-background/80 p-0.5 shadow-sm transition ${
isSelected
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
}`}
>
<Checkbox
checked={Boolean(isSelected)}
onCheckedChange={() => onToggleSelect?.(download.id)}
onClick={(event) => event.stopPropagation()}
aria-label={t('history.selectItem')}
/>
</div>
)}
<RemoteImage
src={download.thumbnail}
alt={download.title}
className="w-full h-full object-cover"
fallbackIcon={<Play className="h-6 w-6" />}
/>
</div>
</button>
{/* Content */}
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
@@ -688,7 +739,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="max-w-[180px] truncate hover:text-primary transition-colors"
className="relative z-20 max-w-[180px] truncate hover:text-primary transition-colors"
>
{sourceDisplay || download.url}
</a>
@@ -733,7 +784,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
<Button
variant={detailsOpen ? 'default' : 'ghost'}
size="icon"
className="h-6 w-6 shrink-0"
className="relative z-20 h-6 w-6 shrink-0"
type="button"
onClick={() => setDetailsOpen((prev) => !prev)}
>

View File

@@ -1,5 +1,9 @@
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { DownloadRecord } from '../../store/downloads'
import { Button } from '../ui/button'
import { Progress } from '../ui/progress'
import { DownloadItem } from './DownloadItem'
interface PlaylistDownloadGroupProps {
@@ -7,15 +11,22 @@ interface PlaylistDownloadGroupProps {
title: string
records: DownloadRecord[]
totalCount: number
selectedIds?: Set<string>
onToggleSelect?: (id: string) => void
onDeletePlaylist?: (playlistId: string, title: string, ids: string[]) => void
}
export function PlaylistDownloadGroup({
groupId,
title,
records,
totalCount
totalCount,
selectedIds,
onToggleSelect,
onDeletePlaylist
}: PlaylistDownloadGroupProps) {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(true)
const completedCount = records.filter((record) => record.status === 'completed').length
const errorCount = records.filter((record) => record.status === 'error').length
@@ -24,15 +35,34 @@ export function PlaylistDownloadGroup({
).length
const displayTitle = title || t('playlist.untitled')
const historyRecords = records.filter((record) => record.entryType === 'history')
const canDeletePlaylist = historyRecords.length > 0 && Boolean(onDeletePlaylist)
const toggleLabel = isExpanded ? t('playlist.groupCollapse') : t('playlist.groupExpand')
const totalProgress = records.reduce((acc, record) => {
if (record.status === 'completed') {
return acc + 1
}
if (record.progress?.percent && record.progress.percent > 0) {
return acc + Math.min(record.progress.percent, 100) / 100
}
return acc
}, 0)
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
return (
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-3 rounded-md border border-border/50 bg-background/60 px-3 py-2.5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
<p className="text-xs text-muted-foreground">
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
</p>
{isExpanded ? (
<p className="text-xs text-muted-foreground">
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t('playlist.collapsedProgress', { completed: completedCount, total: totalCount })}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
@@ -41,19 +71,61 @@ export function PlaylistDownloadGroup({
{t('playlist.groupErrors', { count: errorCount })}
</span>
)}
{canDeletePlaylist && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
onDeletePlaylist?.(
groupId,
displayTitle,
historyRecords.map((record) => record.id)
)
}
aria-label={t('history.deletePlaylist')}
title={t('history.deletePlaylist')}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<button
type="button"
className="ml-1 inline-flex h-7 w-7 items-center justify-center rounded-full text-foreground/70 transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setIsExpanded((prev) => !prev)}
aria-expanded={isExpanded}
aria-label={toggleLabel}
title={toggleLabel}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
{records.map((record) => (
<div
key={`${groupId}:${record.entryType}:${record.id}`}
className="border-l border-border/50 pl-3"
>
<DownloadItem download={record} />
</div>
))}
</div>
{!isExpanded && totalCount > 0 && (
<div className="space-y-1.5">
<Progress value={aggregatePercent} className="h-1 w-full" />
</div>
)}
{isExpanded && (
<div className="space-y-2">
{records.map((record) => (
<div key={`${groupId}:${record.entryType}:${record.id}`}>
<DownloadItem
download={record}
isSelected={selectedIds?.has(record.id) ?? false}
onToggleSelect={onToggleSelect}
/>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,26 +1,138 @@
import { Button } from '@renderer/components/ui/button'
import { CardContent, CardHeader } from '@renderer/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@renderer/components/ui/dialog'
import { cn } from '@renderer/lib/utils'
import { useAtomValue } from 'jotai'
import { useAtomValue, useSetAtom } from 'jotai'
import { History as HistoryIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useHistorySync } from '../../hooks/use-history-sync'
import { ipcServices } from '../../lib/ipc'
import type { DownloadRecord } from '../../store/downloads'
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import {
clearHistoryRecordsAtom,
downloadStatsAtom,
downloadsArrayAtom,
removeHistoryRecordsAtom,
removeHistoryRecordsByPlaylistAtom
} from '../../store/downloads'
import { settingsAtom } from '../../store/settings'
import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
type ConfirmAction =
| { type: 'clear-all' }
| { type: 'delete-selected'; ids: string[] }
| { type: 'delete-playlist'; playlistId: string; title: string; ids: string[] }
const normalizeSavedFileName = (fileName?: string): string | undefined => {
if (!fileName) {
return undefined
}
const trimmed = fileName.trim()
if (!trimmed) {
return undefined
}
return trimmed.replace(/\.f\d+(?=\.[^.]+$)/i, '')
}
const generateFilePathCandidates = (
downloadPath: string,
title: string,
format: string,
savedFileName?: string
): string[] => {
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
const safeTitle = title.trim() || 'Unknown'
const savedNameCandidates: string[] = []
const trimmedSavedFileName = savedFileName?.trim()
if (trimmedSavedFileName) {
const normalized = normalizeSavedFileName(trimmedSavedFileName)
if (normalized) {
savedNameCandidates.push(normalized)
}
if (!normalized || normalized !== trimmedSavedFileName) {
savedNameCandidates.push(trimmedSavedFileName)
}
}
const candidateFileNames =
savedNameCandidates.length > 0
? savedNameCandidates
: [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]
return Array.from(
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
)
}
const tryFileOperation = async (
paths: string[],
operation: (filePath: string) => Promise<boolean>
): Promise<boolean> => {
for (const filePath of paths) {
const success = await operation(filePath)
if (success) {
return true
}
}
return false
}
const getSavedFileExtension = (fileName?: string): string | undefined => {
const normalized = normalizeSavedFileName(fileName)
if (!normalized) {
return undefined
}
if (!normalized.includes('.')) {
return undefined
}
const ext = normalized.split('.').pop()
return ext?.toLowerCase()
}
const resolveDownloadExtension = (download: DownloadRecord): string => {
const savedExt = getSavedFileExtension(download.savedFileName)
if (savedExt) {
return savedExt
}
const selectedExt = download.selectedFormat?.ext?.toLowerCase()
if (selectedExt) {
return selectedExt
}
return download.type === 'audio' ? 'mp3' : 'mp4'
}
export function UnifiedDownloadHistory() {
const { t } = useTranslation()
const allRecords = useAtomValue(downloadsArrayAtom)
const downloadStats = useAtomValue(downloadStatsAtom)
const clearHistoryRecords = useSetAtom(clearHistoryRecordsAtom)
const removeHistoryRecords = useSetAtom(removeHistoryRecordsAtom)
const removeHistoryRecordsByPlaylist = useSetAtom(removeHistoryRecordsByPlaylistAtom)
const settings = useAtomValue(settingsAtom)
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null)
const [confirmBusy, setConfirmBusy] = useState(false)
useHistorySync()
const historyRecords = useMemo(
() => allRecords.filter((record) => record.entryType === 'history'),
[allRecords]
)
const selectedCount = selectedIds.size
const filteredRecords = useMemo(() => {
return allRecords.filter((record) => {
switch (statusFilter) {
@@ -48,6 +160,203 @@ export function UnifiedDownloadHistory() {
{ key: 'error', label: t('download.error'), count: downloadStats.error }
]
const selectableIds = useMemo(
() =>
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
[filteredRecords]
)
const hasHistory = historyRecords.length > 0
const selectableCount = selectableIds.length
const selectionSummary =
selectableCount === 0
? t('history.selectedCount', { count: selectedCount })
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
useEffect(() => {
if (selectedIds.size === 0) {
return
}
const historyIdSet = new Set(historyRecords.map((record) => record.id))
setSelectedIds((prev) => {
let changed = false
const next = new Set<string>()
for (const id of prev) {
if (historyIdSet.has(id)) {
next.add(id)
} else {
changed = true
}
}
return changed ? next : prev
})
}, [historyRecords, selectedIds.size])
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleClearSelection = () => {
setSelectedIds(new Set())
}
const handleRequestClearAll = () => {
if (!hasHistory) {
return
}
setConfirmAction({ type: 'clear-all' })
}
const handleRequestDeleteSelected = () => {
if (selectedIds.size === 0) {
return
}
setConfirmAction({ type: 'delete-selected', ids: Array.from(selectedIds) })
}
const handleRequestDeletePlaylist = (playlistId: string, title: string, ids: string[]) => {
if (ids.length === 0) {
return
}
setConfirmAction({ type: 'delete-playlist', playlistId, title, ids })
}
const pruneSelectedIds = (ids: string[]) => {
if (ids.length === 0) {
return
}
setSelectedIds((prev) => {
const next = new Set(prev)
let changed = false
ids.forEach((id) => {
if (next.delete(id)) {
changed = true
}
})
return changed ? next : prev
})
}
const confirmContent = useMemo(() => {
if (!confirmAction) {
return null
}
switch (confirmAction.type) {
case 'clear-all': {
return {
title: t('history.confirmClearAllTitle'),
description: t('history.confirmClearAllDescription', { count: historyRecords.length }),
actionLabel: t('history.clearAllAction')
}
}
case 'delete-selected': {
return {
title: t('history.confirmDeleteSelectedTitle'),
description: t('history.confirmDeleteSelectedDescription', {
count: confirmAction.ids.length
}),
actionLabel: t('history.removeAction')
}
}
case 'delete-playlist': {
return {
title: t('history.confirmDeletePlaylistTitle'),
description: t('history.confirmDeletePlaylistDescription', {
count: confirmAction.ids.length,
title: confirmAction.title
}),
actionLabel: t('history.removeAction')
}
}
default:
return null
}
}, [confirmAction, historyRecords.length, t])
const deleteHistoryFiles = async (records: DownloadRecord[]) => {
const failedIds: string[] = []
for (const record of records) {
if (!record.title) {
continue
}
const downloadPath = record.downloadPath || settings.downloadPath
if (!downloadPath) {
continue
}
const formatForPath = resolveDownloadExtension(record)
const filePaths = generateFilePathCandidates(
downloadPath,
record.title,
formatForPath,
record.savedFileName
)
const deleted = await tryFileOperation(filePaths, (filePath) =>
ipcServices.fs.deleteFile(filePath)
)
if (!deleted) {
failedIds.push(record.id)
}
}
if (failedIds.length > 0) {
console.warn('Failed to delete some playlist files:', failedIds)
}
}
const handleConfirmAction = async () => {
if (!confirmAction) {
return
}
setConfirmBusy(true)
try {
if (confirmAction.type === 'clear-all') {
await ipcServices.history.clearHistory()
clearHistoryRecords()
setSelectedIds(new Set())
toast.success(t('notifications.historyCleared'))
}
if (confirmAction.type === 'delete-selected') {
await ipcServices.history.removeHistoryItems(confirmAction.ids)
removeHistoryRecords(confirmAction.ids)
pruneSelectedIds(confirmAction.ids)
toast.success(t('notifications.itemsRemoved', { count: confirmAction.ids.length }))
}
if (confirmAction.type === 'delete-playlist') {
const idSet = new Set(confirmAction.ids)
const playlistRecords = historyRecords.filter((record) => idSet.has(record.id))
await ipcServices.history.removeHistoryByPlaylistId(confirmAction.playlistId)
removeHistoryRecordsByPlaylist(confirmAction.playlistId)
await deleteHistoryFiles(playlistRecords)
pruneSelectedIds(confirmAction.ids)
toast.success(
t('notifications.playlistHistoryRemoved', { count: confirmAction.ids.length })
)
}
setConfirmAction(null)
} catch (error) {
if (confirmAction.type === 'clear-all') {
console.error('Failed to clear history:', error)
toast.error(t('notifications.historyClearFailed'))
}
if (confirmAction.type === 'delete-selected') {
console.error('Failed to remove selected history items:', error)
toast.error(t('notifications.itemsRemoveFailed'))
}
if (confirmAction.type === 'delete-playlist') {
console.error('Failed to remove playlist history:', error)
toast.error(t('notifications.playlistHistoryRemoveFailed'))
}
} finally {
setConfirmBusy(false)
}
}
const groupedView = useMemo(() => {
const groups = new Map<
string,
@@ -99,35 +408,46 @@ export function UnifiedDownloadHistory() {
}, [filteredRecords])
return (
<div className="space-y-4">
<div className={cn('space-y-4', selectedCount > 0 && 'pb-20')}>
<CardHeader className="gap-4 p-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{filters.map((filter) => {
const isActive = statusFilter === filter.key
return (
<Button
key={filter.key}
variant={isActive ? 'secondary' : 'ghost'}
size="sm"
className={
isActive
? 'h-8 rounded-full px-3 shadow-sm'
: 'h-8 rounded-full border border-border/60 px-3'
}
onClick={() => setStatusFilter(filter.key)}
>
<span>{filter.label}</span>
<span
className={cn(
'ml-1 min-w-5 rounded-full px-1 text-xs font-medium text-neutral-900',
isActive ? ' bg-neutral-100' : ' bg-neutral-200'
)}
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<div className="flex flex-wrap items-center gap-2">
{filters.map((filter) => {
const isActive = statusFilter === filter.key
return (
<Button
key={filter.key}
variant={isActive ? 'secondary' : 'ghost'}
size="sm"
className={
isActive
? 'h-8 rounded-full px-3 shadow-sm'
: 'h-8 rounded-full border border-border/60 px-3'
}
onClick={() => setStatusFilter(filter.key)}
>
{filter.count}
</span>
</Button>
)
})}
<span>{filter.label}</span>
<span
className={cn(
'ml-1 min-w-5 rounded-full px-1 text-xs font-medium text-neutral-900',
isActive ? ' bg-neutral-100' : ' bg-neutral-200'
)}
>
{filter.count}
</span>
</Button>
)
})}
</div>
<Button
variant="outline"
size="sm"
className="h-8 rounded-full px-3"
onClick={handleRequestClearAll}
disabled={!hasHistory}
>
{t('history.clearAll')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 p-0 overflow-hidden w-full">
@@ -144,6 +464,8 @@ export function UnifiedDownloadHistory() {
<DownloadItem
key={`${item.record.entryType}:${item.record.id}`}
download={item.record}
isSelected={selectedIds.has(item.record.id)}
onToggleSelect={handleToggleSelect}
/>
)
}
@@ -160,12 +482,71 @@ export function UnifiedDownloadHistory() {
title={group.title}
totalCount={group.totalCount}
records={group.records}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
onDeletePlaylist={handleRequestDeletePlaylist}
/>
)
})}
</div>
)}
</CardContent>
{selectedCount > 0 && (
<div className="fixed bottom-4 left-1/2 z-40 w-[calc(100%-2rem)] -translate-x-1/2 sm:left-auto sm:right-6 sm:translate-x-0 sm:w-auto">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-full border border-border/50 bg-background/80 pl-5 pr-2 py-2 shadow-lg backdrop-blur">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">{selectionSummary}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 rounded-full px-3"
onClick={handleClearSelection}
>
{t('history.clearSelection')}
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 rounded-full px-3"
onClick={handleRequestDeleteSelected}
>
{t('history.deleteSelected')}
</Button>
</div>
</div>
</div>
)}
<Dialog
open={Boolean(confirmAction)}
onOpenChange={(open) => {
if (!open && !confirmBusy) {
setConfirmAction(null)
}
}}
>
{confirmContent && (
<DialogContent>
<DialogHeader>
<DialogTitle>{confirmContent.title}</DialogTitle>
<DialogDescription>{confirmContent.description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmAction(null)}
disabled={confirmBusy}
>
{t('download.cancel')}
</Button>
<Button variant="destructive" onClick={handleConfirmAction} disabled={confirmBusy}>
{confirmContent.actionLabel}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
</div>
)
}

View File

@@ -8,10 +8,10 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},

View File

@@ -5,7 +5,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg bg-muted/30 text-card-foreground shadow', className)}
className={cn('rounded-lg bg-muted/30 text-card-foreground', className)}
{...props}
/>
)

View File

@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'flex h-9 w-full rounded-md border px-3 py-1 text-base bg-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}

View File

@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}

View File

@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>

View File

@@ -209,10 +209,22 @@
"clearCancelled": "Clear Cancelled",
"clearCompleted": "Clear Completed",
"clearErrors": "Clear Errors",
"clearAll": "Clear All History",
"clearAllAction": "Clear History",
"clearSelection": "Clear Selection",
"confirmClearAllTitle": "Clear all history?",
"confirmClearAllDescription": "Remove {{count}} items from your history. Files stay on disk.",
"confirmDeleteSelectedTitle": "Remove selected items?",
"confirmDeleteSelectedDescription": "Remove {{count}} items from your history. Files stay on disk.",
"confirmDeletePlaylistTitle": "Remove playlist history?",
"confirmDeletePlaylistDescription": "Remove {{count}} items from {{title}} and delete their files.",
"copyToClipboard": "Copy to clipboard",
"copyUrl": "Copy URL",
"date": "Date",
"deletePlaylist": "Remove Playlist",
"deleteSelected": "Remove Selected",
"description": "View and manage your download history",
"doneSelecting": "Done",
"duration": "Duration",
"fileSize": "File Size",
"filters": {
@@ -228,7 +240,14 @@
"openFileLocation": "Open File Location",
"openFolder": "Open Folder",
"openInBrowser": "Click to open in browser",
"removeAction": "Remove",
"removeItem": "Remove Item",
"select": "Select",
"selectAll": "Select All",
"selectVisible": "Select visible",
"selectItem": "Select item",
"selectedCount": "{{count}} selected",
"selectionSummary": "{{selected}} of {{total}} visible selected",
"stats": {
"cancelled": "Cancelled",
"completed": "Completed",
@@ -257,9 +276,15 @@
"downloadCompleted": "Download completed",
"downloadFailed": "Download failed",
"downloadStarted": "Download started",
"historyCleared": "History cleared",
"historyClearFailed": "Failed to clear history",
"itemRemoved": "Item removed",
"itemsRemoved": "Removed {{count}} items",
"itemsRemoveFailed": "Failed to remove selected items",
"openFileFailed": "Failed to open file",
"openFolderFailed": "Failed to open folder",
"playlistHistoryRemoved": "Playlist removed and files deleted",
"playlistHistoryRemoveFailed": "Failed to remove playlist history",
"removeFailed": "Failed to remove item",
"settingsSaved": "Settings saved",
"urlCopied": "URL copied to clipboard",
@@ -268,6 +293,7 @@
"playlist": {
"badgeLabel": "Playlist",
"clearPreview": "Clear preview",
"collapsedProgress": "Downloading playlist: {{completed}} / {{total}} completed",
"comingSoon": "Playlist download feature coming soon!",
"completed": "Playlist downloaded",
"description": "Download all videos from a YouTube playlist or channel",
@@ -283,7 +309,9 @@
"folderFormat": "Folder name format for playlists",
"foundVideos": "Found {{count}} videos in playlist",
"groupActive": "{{count}} active",
"groupCollapse": "Collapse",
"groupErrors": "{{count}} failed",
"groupExpand": "Expand",
"groupSummary": "{{completed}} / {{total}} completed",
"linkLabel": "Playlist URL",
"noEntries": "No videos were found in this playlist",

View File

@@ -96,6 +96,31 @@ export const removeHistoryRecordAtom = atom(null, (get, set, id: string) => {
set(downloadRecordsAtom, downloads)
})
export const removeHistoryRecordsAtom = atom(null, (get, set, ids: string[]) => {
if (!ids || ids.length === 0) {
return
}
const downloads = new Map(get(downloadRecordsAtom))
const uniqueIds = Array.from(new Set(ids))
uniqueIds.forEach((id) => {
downloads.delete(recordKey('history', id))
})
set(downloadRecordsAtom, downloads)
})
export const removeHistoryRecordsByPlaylistAtom = atom(null, (get, set, playlistId: string) => {
if (!playlistId) {
return
}
const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) {
if (item.entryType === 'history' && item.playlistId === playlistId) {
downloads.delete(key)
}
}
set(downloadRecordsAtom, downloads)
})
export const clearHistoryRecordsAtom = atom(null, (get, set) => {
const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) {