mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-30 04:19:28 +00:00
feat(ui): add bulk reordering in queue with auto-animate
This commit is contained in:
@@ -15,7 +15,7 @@ interface DownloadItemProps {
|
|||||||
handlePause: (id: string, skipConfirm?: boolean) => void;
|
handlePause: (id: string, skipConfirm?: boolean) => void;
|
||||||
handleResume: (item: DownloadItemType) => void;
|
handleResume: (item: DownloadItemType) => void;
|
||||||
getCategoryIcon: (category: string) => React.ReactNode;
|
getCategoryIcon: (category: string) => React.ReactNode;
|
||||||
isSelected: boolean;
|
selectedIds: Set<string>;
|
||||||
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
handlePause,
|
handlePause,
|
||||||
handleResume,
|
handleResume,
|
||||||
getCategoryIcon,
|
getCategoryIcon,
|
||||||
isSelected,
|
selectedIds,
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
|
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
|
||||||
@@ -83,7 +83,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${selectedIds.has(downloadId) ? 'is-selected' : ''}`}
|
||||||
style={{ gridTemplateColumns: tableGridTemplate }}
|
style={{ gridTemplateColumns: tableGridTemplate }}
|
||||||
onClick={(e) => onClick(e, download)}
|
onClick={(e) => onClick(e, download)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
@@ -200,7 +200,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
|
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => moveInQueue(download.id, 'up')}
|
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'up')}
|
||||||
disabled={queueIndex === 0}
|
disabled={queueIndex === 0}
|
||||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||||
title="Move Up"
|
title="Move Up"
|
||||||
@@ -208,7 +208,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
<ArrowUp size={14} />
|
<ArrowUp size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => moveInQueue(download.id, 'down')}
|
onClick={() => moveInQueue(selectedIds.has(download.id) ? Array.from(selectedIds) : download.id, 'down')}
|
||||||
disabled={queueIndex === queueItems.length - 1}
|
disabled={queueIndex === queueItems.length - 1}
|
||||||
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
className="app-icon-button h-7 w-7 disabled:opacity-40"
|
||||||
title="Move Down"
|
title="Move Down"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
|||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { SidebarFilter } from './Sidebar';
|
import { SidebarFilter } from './Sidebar';
|
||||||
|
import { useAutoAnimate } from '@formkit/auto-animate/react';
|
||||||
import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { DownloadItem as DownloadItemComponent } from './DownloadItem';
|
import { DownloadItem as DownloadItemComponent } from './DownloadItem';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
@@ -33,6 +34,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
const isMac = navigator.userAgent.includes('Mac');
|
const isMac = navigator.userAgent.includes('Mac');
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||||
|
const [animationParent] = useAutoAnimate<HTMLDivElement>();
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||||
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' } | null>({ column: 'Date Added', direction: 'desc' });
|
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' } | null>({ column: 'Date Added', direction: 'desc' });
|
||||||
@@ -477,7 +479,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="download-table-list">
|
<div className="download-table-list" ref={animationParent}>
|
||||||
{sortedDownloads.map((d, index) => (
|
{sortedDownloads.map((d, index) => (
|
||||||
<DownloadItemComponent
|
<DownloadItemComponent
|
||||||
key={d.id}
|
key={d.id}
|
||||||
@@ -488,7 +490,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
handlePause={handlePause}
|
handlePause={handlePause}
|
||||||
handleResume={handleResume}
|
handleResume={handleResume}
|
||||||
getCategoryIcon={getCategoryIcon}
|
getCategoryIcon={getCategoryIcon}
|
||||||
isSelected={selectedIds.has(d.id)}
|
selectedIds={selectedIds}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ interface DownloadState {
|
|||||||
registerBackendIds: (ids: string[]) => void;
|
registerBackendIds: (ids: string[]) => void;
|
||||||
unregisterBackendIds: (ids: string[]) => void;
|
unregisterBackendIds: (ids: string[]) => void;
|
||||||
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
||||||
moveInQueue: (id: string, direction: 'up' | 'down') => Promise<void>;
|
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
|
||||||
removeFromQueue: (id: string) => Promise<void>;
|
removeFromQueue: (id: string) => Promise<void>;
|
||||||
isAddModalOpen: boolean;
|
isAddModalOpen: boolean;
|
||||||
pendingAddUrls: string;
|
pendingAddUrls: string;
|
||||||
@@ -222,10 +222,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||||
pendingOrder: [],
|
pendingOrder: [],
|
||||||
setPendingOrder: (order) => set({ pendingOrder: order }),
|
setPendingOrder: (order) => set({ pendingOrder: order }),
|
||||||
moveInQueue: async (id, direction) => {
|
moveInQueue: async (idOrIds, direction) => {
|
||||||
const item = get().downloads.find(download => download.id === id);
|
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
||||||
if (!item) return;
|
if (ids.length === 0) return;
|
||||||
const queueId = item.queueId || MAIN_QUEUE_ID;
|
|
||||||
|
// Assume all items belong to the same queue as the first item
|
||||||
|
const firstItem = get().downloads.find(d => d.id === ids[0]);
|
||||||
|
if (!firstItem) return;
|
||||||
|
const queueId = firstItem.queueId || MAIN_QUEUE_ID;
|
||||||
|
|
||||||
const queueItems = get().downloads
|
const queueItems = get().downloads
|
||||||
.filter(download =>
|
.filter(download =>
|
||||||
(download.queueId || MAIN_QUEUE_ID) === queueId &&
|
(download.queueId || MAIN_QUEUE_ID) === queueId &&
|
||||||
@@ -233,11 +238,28 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
|
!(isActiveDownloadStatus(download.status) && download.status !== 'queued')
|
||||||
)
|
)
|
||||||
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
|
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
|
||||||
const index = queueItems.findIndex(download => download.id === id);
|
|
||||||
const target = direction === 'up' ? index - 1 : index + 1;
|
const selectedItems = queueItems.filter(item => ids.includes(item.id));
|
||||||
if (index < 0 || target < 0 || target >= queueItems.length) return;
|
if (selectedItems.length === 0) return;
|
||||||
const reordered = [...queueItems];
|
|
||||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
const unselectedItems = queueItems.filter(item => !ids.includes(item.id));
|
||||||
|
const selectedIndices = selectedItems.map(item => queueItems.findIndex(d => d.id === item.id));
|
||||||
|
|
||||||
|
let insertIndex = 0;
|
||||||
|
if (direction === 'up') {
|
||||||
|
const firstSelectedIndex = Math.min(...selectedIndices);
|
||||||
|
insertIndex = Math.max(0, firstSelectedIndex - 1);
|
||||||
|
} else {
|
||||||
|
const lastSelectedIndex = Math.max(...selectedIndices);
|
||||||
|
insertIndex = Math.min(unselectedItems.length, lastSelectedIndex - selectedItems.length + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reordered = [
|
||||||
|
...unselectedItems.slice(0, insertIndex),
|
||||||
|
...selectedItems,
|
||||||
|
...unselectedItems.slice(insertIndex)
|
||||||
|
];
|
||||||
|
|
||||||
const positions = new Map(reordered.map((download, position) => [download.id, position]));
|
const positions = new Map(reordered.map((download, position) => [download.id, position]));
|
||||||
const previousDownloads = get().downloads;
|
const previousDownloads = get().downloads;
|
||||||
set(state => ({
|
set(state => ({
|
||||||
@@ -246,12 +268,25 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
: download)
|
: download)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!get().backendRegisteredIds.has(id)) return;
|
const registeredIdsToMove = selectedItems
|
||||||
|
.filter(item => get().backendRegisteredIds.has(item.id))
|
||||||
|
.map(item => item.id);
|
||||||
|
|
||||||
|
if (registeredIdsToMove.length === 0) return;
|
||||||
|
|
||||||
|
// For backend sync, we must call move_in_queue in the correct order to maintain the block
|
||||||
|
const idsToMove = direction === 'up' ? registeredIdsToMove : [...registeredIdsToMove].reverse();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const order = await invoke('move_in_queue', { id, queueId, direction });
|
let order: string[] = [];
|
||||||
set({ pendingOrder: order as string[] });
|
for (const id of idsToMove) {
|
||||||
|
order = (await invoke('move_in_queue', { id, queueId, direction })) as string[];
|
||||||
|
}
|
||||||
|
if (order.length > 0) {
|
||||||
|
set({ pendingOrder: order });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to move item in queue:", e);
|
console.error("Failed to move in queue backend:", e);
|
||||||
set({ downloads: previousDownloads });
|
set({ downloads: previousDownloads });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user