feat(download): add select all and clear history (#136)
* feat(download): add select all and clear history * fix(download): remove clear all history action * feat(download): clear selection on escape * feat(download): select full playlist groups * style(download): adjust playlist group spacing
This commit is contained in:
@@ -78,11 +78,11 @@ export function PlaylistDownloadGroup({
|
|||||||
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
|
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2 mx-6">
|
<div className="rounded-md bg-muted/30 mx-6">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-muted/40 active:bg-muted/60 -ml-1.5 -mr-1.5"
|
className="px-3 flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-muted/40 active:bg-muted/60"
|
||||||
onClick={() => setIsExpanded((prev) => !prev)}
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
@@ -118,7 +118,7 @@ export function PlaylistDownloadGroup({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1 pr-3">
|
||||||
{canDeletePlaylist && (
|
{canDeletePlaylist && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -153,17 +153,15 @@ export function PlaylistDownloadGroup({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-h-0">
|
<div className="min-h-0">
|
||||||
<div className="space-y-3 pt-1">
|
{records.map((record) => (
|
||||||
{records.map((record) => (
|
<div key={`${groupId}:${record.entryType}:${record.id}`}>
|
||||||
<div key={`${groupId}:${record.entryType}:${record.id}`}>
|
<DownloadItem
|
||||||
<DownloadItem
|
download={record}
|
||||||
download={record}
|
isSelected={selectedIds?.has(record.id) ?? false}
|
||||||
isSelected={selectedIds?.has(record.id) ?? false}
|
onToggleSelect={onToggleSelect}
|
||||||
onToggleSelect={onToggleSelect}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ const resolveDownloadExtension = (download: DownloadRecord): string => {
|
|||||||
return download.type === 'audio' ? 'mp3' : 'mp4'
|
return download.type === 'audio' ? 'mp3' : 'mp4'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEditableTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!target || !(target instanceof HTMLElement)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const tagName = target.tagName
|
||||||
|
return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'
|
||||||
|
}
|
||||||
|
|
||||||
interface UnifiedDownloadHistoryProps {
|
interface UnifiedDownloadHistoryProps {
|
||||||
onOpenSupportedSites?: () => void
|
onOpenSupportedSites?: () => void
|
||||||
onOpenSettings?: () => void
|
onOpenSettings?: () => void
|
||||||
@@ -163,6 +174,12 @@ export function UnifiedDownloadHistory({
|
|||||||
})
|
})
|
||||||
}, [allRecords, statusFilter])
|
}, [allRecords, statusFilter])
|
||||||
|
|
||||||
|
const visibleHistoryIds = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
|
||||||
|
[filteredRecords]
|
||||||
|
)
|
||||||
|
|
||||||
const filters: Array<{ key: StatusFilter; label: string; count: number }> = [
|
const filters: Array<{ key: StatusFilter; label: string; count: number }> = [
|
||||||
{ key: 'all', label: t('download.all'), count: downloadStats.total },
|
{ key: 'all', label: t('download.all'), count: downloadStats.total },
|
||||||
{ key: 'active', label: t('download.active'), count: downloadStats.active },
|
{ key: 'active', label: t('download.active'), count: downloadStats.active },
|
||||||
@@ -170,16 +187,34 @@ export function UnifiedDownloadHistory({
|
|||||||
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectableIds = useMemo(
|
const selectableIds = useMemo(() => {
|
||||||
() =>
|
if (visibleHistoryIds.length === 0) {
|
||||||
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
|
return []
|
||||||
[filteredRecords]
|
}
|
||||||
)
|
const ids = new Set(visibleHistoryIds)
|
||||||
|
const playlistIds = new Set(
|
||||||
|
filteredRecords
|
||||||
|
.filter((record) => record.entryType === 'history' && record.playlistId)
|
||||||
|
.map((record) => record.playlistId as string)
|
||||||
|
)
|
||||||
|
if (playlistIds.size === 0) {
|
||||||
|
return Array.from(ids)
|
||||||
|
}
|
||||||
|
for (const record of historyRecords) {
|
||||||
|
if (record.playlistId && playlistIds.has(record.playlistId)) {
|
||||||
|
ids.add(record.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(ids)
|
||||||
|
}, [filteredRecords, historyRecords, visibleHistoryIds])
|
||||||
const selectableCount = selectableIds.length
|
const selectableCount = selectableIds.length
|
||||||
|
const visibleSelectableCount = visibleHistoryIds.length
|
||||||
const selectionSummary =
|
const selectionSummary =
|
||||||
selectableCount === 0
|
selectableCount === 0
|
||||||
? t('history.selectedCount', { count: selectedCount })
|
? t('history.selectedCount', { count: selectedCount })
|
||||||
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
|
: selectableCount > visibleSelectableCount
|
||||||
|
? t('history.selectedCount', { count: selectedCount })
|
||||||
|
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIds.size === 0) {
|
if (selectedIds.size === 0) {
|
||||||
@@ -216,6 +251,13 @@ export function UnifiedDownloadHistory({
|
|||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectableIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedIds(new Set(selectableIds))
|
||||||
|
}
|
||||||
|
|
||||||
const handleRequestDeleteSelected = () => {
|
const handleRequestDeleteSelected = () => {
|
||||||
if (selectedIds.size === 0) {
|
if (selectedIds.size === 0) {
|
||||||
return
|
return
|
||||||
@@ -398,6 +440,41 @@ export function UnifiedDownloadHistory({
|
|||||||
return { order, groups }
|
return { order, groups }
|
||||||
}, [filteredRecords])
|
}, [filteredRecords])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isEditableTarget(event.target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (confirmAction) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(event.metaKey || event.ctrlKey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key.toLowerCase() !== 'a') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectableIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
setSelectedIds(new Set(selectableIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [confirmAction, selectableIds, selectedIds])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col h-full')}>
|
<div className={cn('flex flex-col h-full')}>
|
||||||
<CardHeader className="gap-4 p-0 px-6 py-4 z-50 bg-background backdrop-blur">
|
<CardHeader className="gap-4 p-0 px-6 py-4 z-50 bg-background backdrop-blur">
|
||||||
@@ -431,6 +508,15 @@ export function UnifiedDownloadHistory({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 rounded-full px-3"
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
disabled={selectableIds.length === 0}
|
||||||
|
>
|
||||||
|
{t('history.selectAll')}
|
||||||
|
</Button>
|
||||||
<DownloadDialog
|
<DownloadDialog
|
||||||
onOpenSupportedSites={onOpenSupportedSites}
|
onOpenSupportedSites={onOpenSupportedSites}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
|
|||||||
Reference in New Issue
Block a user