diff --git a/.vscode/settings.json b/.vscode/settings.json index 419c9f3..d2b121e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" + } +} \ No newline at end of file diff --git a/scripts/setup-dev-binaries.js b/scripts/setup-dev-binaries.js index 7ba6d4b..718f870 100755 --- a/scripts/setup-dev-binaries.js +++ b/scripts/setup-dev-binaries.js @@ -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) diff --git a/src/main/ipc/services/history-service.ts b/src/main/ipc/services/history-service.ts index f0f49d3..99e257f 100644 --- a/src/main/ipc/services/history-service.ts +++ b/src/main/ipc/services/history-service.ts @@ -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 diff --git a/src/main/lib/history-manager.ts b/src/main/lib/history-manager.ts index 8eb5416..95e3631 100644 --- a/src/main/lib/history-manager.ts +++ b/src/main/lib/history-manager.ts @@ -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() diff --git a/src/renderer/src/assets/theme.css b/src/renderer/src/assets/theme.css index 046ffc6..a4ce8bd 100644 --- a/src/renderer/src/assets/theme.css +++ b/src/renderer/src/assets/theme.css @@ -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); } diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index 2c91e13..5e8f8ac 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -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} @@ -638,18 +642,65 @@ export function DownloadItem({ download }: DownloadItemProps) { const hasMetadataDetails = metadataDetails.length > 0 + const isSelectedHistory = selectionEnabled && isSelected + return ( -
+
+ {isSelectedHistory && ( + + {/* Content */}
@@ -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} @@ -733,7 +784,7 @@ export function DownloadItem({ download }: DownloadItemProps) { + )} +
-
- {records.map((record) => ( -
- -
- ))} -
+ {!isExpanded && totalCount > 0 && ( +
+ +
+ )} + + {isExpanded && ( +
+ {records.map((record) => ( +
+ +
+ ))} +
+ )}
) } diff --git a/src/renderer/src/components/download/UnifiedDownloadHistory.tsx b/src/renderer/src/components/download/UnifiedDownloadHistory.tsx index 2f0dc59..6e2313b 100644 --- a/src/renderer/src/components/download/UnifiedDownloadHistory.tsx +++ b/src/renderer/src/components/download/UnifiedDownloadHistory.tsx @@ -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 +): Promise => { + 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('all') + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [confirmAction, setConfirmAction] = useState(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() + 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 ( -
+
0 && 'pb-20')}> -
- {filters.map((filter) => { - const isActive = statusFilter === filter.key - return ( - - ) - })} + {filter.label} + + {filter.count} + + + ) + })} +
+
@@ -144,6 +464,8 @@ export function UnifiedDownloadHistory() { ) } @@ -160,12 +482,71 @@ export function UnifiedDownloadHistory() { title={group.title} totalCount={group.totalCount} records={group.records} + selectedIds={selectedIds} + onToggleSelect={handleToggleSelect} + onDeletePlaylist={handleRequestDeletePlaylist} /> ) })}
)} + {selectedCount > 0 && ( +
+
+
+ {selectionSummary} +
+
+ + +
+
+
+ )} + { + if (!open && !confirmBusy) { + setConfirmAction(null) + } + }} + > + {confirmContent && ( + + + {confirmContent.title} + {confirmContent.description} + + + + + + + )} + ) } diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx index 29c9884..ee46d08 100644 --- a/src/renderer/src/components/ui/button.tsx +++ b/src/renderer/src/components/ui/button.tsx @@ -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' }, diff --git a/src/renderer/src/components/ui/card.tsx b/src/renderer/src/components/ui/card.tsx index 759defb..ee1e783 100644 --- a/src/renderer/src/components/ui/card.tsx +++ b/src/renderer/src/components/ui/card.tsx @@ -5,7 +5,7 @@ const Card = React.forwardRef (
) diff --git a/src/renderer/src/components/ui/input.tsx b/src/renderer/src/components/ui/input.tsx index e8930d0..2d42a1b 100644 --- a/src/renderer/src/components/ui/input.tsx +++ b/src/renderer/src/components/ui/input.tsx @@ -7,7 +7,7 @@ const Input = React.forwardRef>( 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} diff --git a/src/renderer/src/components/ui/switch.tsx b/src/renderer/src/components/ui/switch.tsx index f1ce3ca..c15ff24 100644 --- a/src/renderer/src/components/ui/switch.tsx +++ b/src/renderer/src/components/ui/switch.tsx @@ -8,7 +8,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 07a43dc..8147c3a 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -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", diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index 735ccf6..1d89fe6 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -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()) {