* Add collapsed playlist progress * Improve history bulk actions and selection UX * Fix ffmpeg download resolution in setup script
This commit is contained in:
21
.vscode/settings.json
vendored
21
.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user