diff --git a/src/modules/library/api/useLibraryContent.ts b/src/modules/library/api/useLibraryContent.ts index 8b90b4bc..d40ee1a1 100644 --- a/src/modules/library/api/useLibraryContent.ts +++ b/src/modules/library/api/useLibraryContent.ts @@ -140,6 +140,7 @@ export function useLibraryContent({ return { viewMode, dndDisabled, + selectMode, contentView, detailsMod, setDetailsMod, diff --git a/src/modules/library/components/LibraryContent.tsx b/src/modules/library/components/LibraryContent.tsx index 5a9fc249..8588bb18 100644 --- a/src/modules/library/components/LibraryContent.tsx +++ b/src/modules/library/components/LibraryContent.tsx @@ -24,17 +24,30 @@ export function LibraryContent({ error, folderId, }: LibraryContentProps) { - const { viewMode, dndDisabled, contentView, detailsMod, setDetailsMod, editMod, setEditMod } = - useLibraryContent({ - mods, - searchQuery, - isLoading, - hasError: error !== null, - folderId, - }); + const { + viewMode, + dndDisabled, + selectMode, + contentView, + detailsMod, + setDetailsMod, + editMod, + setEditMod, + } = useLibraryContent({ + mods, + searchQuery, + isLoading, + hasError: error !== null, + folderId, + }); const reorderMods = useReorderMods(); const reorderFolderMods = useReorderFolderMods(); + // Extra bottom padding in select mode so the floating action bar never covers the last row. + const scrollClass = selectMode + ? "flex-1 overflow-auto px-6 pt-6 pb-28" + : "flex-1 overflow-auto p-6"; + if (contentView.type === "loading") { return (
@@ -63,7 +76,7 @@ export function LibraryContent({ return ( <> -
+
-
+
-
+
} > - {selectMode ? "Done" : "Select"} + Select @@ -210,7 +209,6 @@ export function LibraryToolbar({ )}
- {selectMode && }
); } diff --git a/src/modules/library/components/ModCard.tsx b/src/modules/library/components/ModCard.tsx index b321a5c5..b5bb89d5 100644 --- a/src/modules/library/components/ModCard.tsx +++ b/src/modules/library/components/ModCard.tsx @@ -53,6 +53,7 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar const selectMode = useLibrarySelectionStore((s) => s.selectMode); const isSelected = useLibrarySelectionStore((s) => s.selectedIds.has(mod.id)); const toggleSelection = useLibrarySelectionStore((s) => s.toggle); + const selectRangeTo = useLibrarySelectionStore((s) => s.selectRangeTo); const { isFlagged, @@ -112,7 +113,8 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar return; } if (selectMode) { - toggleSelection(mod.id); + if (e.shiftKey) selectRangeTo(mod.id); + else toggleSelection(mod.id); return; } if (disabled) return; @@ -154,11 +156,11 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar )} > {selectMode && ( -
e.stopPropagation()} className="shrink-0"> +
toggleSelection(mod.id)} + tabIndex={-1} aria-label={`Select ${mod.displayName}`} />
@@ -318,15 +320,11 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar )} > {selectMode && ( -
e.stopPropagation()} - > +
toggleSelection(mod.id)} + tabIndex={-1} aria-label={`Select ${mod.displayName}`} className="shadow-lg backdrop-blur-sm" /> diff --git a/src/modules/library/components/SelectionActionBar.tsx b/src/modules/library/components/SelectionActionBar.tsx index 32aed2b7..46df09a2 100644 --- a/src/modules/library/components/SelectionActionBar.tsx +++ b/src/modules/library/components/SelectionActionBar.tsx @@ -1,7 +1,8 @@ -import { CheckSquare, Trash2, X } from "lucide-react"; +import { Trash2, X } from "lucide-react"; import { useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; -import { Button, useToast } from "@/components"; +import { Button, Checkbox, IconButton, Tooltip, useToast } from "@/components"; import type { InstalledMod } from "@/lib/tauri"; import { useBulkUninstallMods, useInstalledMods } from "@/modules/library/api"; import { usePatcherStatus } from "@/modules/patcher"; @@ -15,7 +16,9 @@ interface SelectionActionBarProps { export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) { const selectedIds = useLibrarySelectionStore((s) => s.selectedIds); - const selectAll = useLibrarySelectionStore((s) => s.selectAll); + const addMany = useLibrarySelectionStore((s) => s.addMany); + const removeMany = useLibrarySelectionStore((s) => s.removeMany); + const setSelection = useLibrarySelectionStore((s) => s.setSelection); const clear = useLibrarySelectionStore((s) => s.clear); const exitSelectMode = useLibrarySelectionStore((s) => s.exitSelectMode); @@ -28,28 +31,28 @@ export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) { const { data: allMods = [] } = useInstalledMods(); const [dialogOpen, setDialogOpen] = useState(false); + // Snapshot of the mods to uninstall, frozen at confirm-dialog open so the optimistic + // cache update (which empties the live selection mid-mutation) can't blank the preview. + const [pendingMods, setPendingMods] = useState([]); const selectedMods = useMemo( () => allMods.filter((m) => selectedIds.has(m.id)), [allMods, selectedIds], ); - const selectedCount = selectedIds.size; + const selectedCount = selectedMods.length; const visibleIds = useMemo(() => visibleMods.map((m) => m.id), [visibleMods]); - const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id)); - - function handleSelectAllVisible() { - const union = new Set(selectedIds); - for (const id of visibleIds) union.add(id); - selectAll([...union]); - } - - function handleClear() { - clear(); - } + const visibleSelectedCount = useMemo( + () => visibleMods.reduce((n, m) => n + (selectedIds.has(m.id) ? 1 : 0), 0), + [visibleMods, selectedIds], + ); + const allVisibleSelected = visibleIds.length > 0 && visibleSelectedCount === visibleIds.length; + const someVisibleSelected = visibleSelectedCount > 0 && !allVisibleSelected; + const hiddenCount = selectedCount - visibleSelectedCount; function handleOpenDialog() { if (selectedCount === 0) return; + setPendingMods(selectedMods); setDialogOpen(true); } @@ -58,8 +61,21 @@ export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) { setDialogOpen(false); } + useHotkeys("escape", () => exitSelectMode(), { enabled: !dialogOpen }, [dialogOpen]); + useHotkeys( + "ctrl+a, meta+a", + (e) => { + e.preventDefault(); + addMany(visibleIds); + }, + { enabled: !dialogOpen, preventDefault: true }, + [dialogOpen, visibleIds], + ); + async function handleConfirmUninstall() { - const ids = [...selectedIds]; + const ids = pendingMods.map((m) => m.id); + if (ids.length === 0) return; + try { const result = await bulkUninstall.mutateAsync(ids); setDialogOpen(false); @@ -69,19 +85,20 @@ export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) { "Mods uninstalled", `${result.succeeded.length} mod${result.succeeded.length === 1 ? "" : "s"} removed`, ); + exitSelectMode(); } else if (result.succeeded.length === 0) { toast.error( "Uninstall failed", `All ${result.failed.length} mod${result.failed.length === 1 ? "" : "s"} failed to uninstall`, ); + setSelection(result.failed.map((f) => f.id)); } else { toast.warning( "Uninstall completed with errors", `${result.succeeded.length} removed, ${result.failed.length} failed`, ); + setSelection(result.failed.map((f) => f.id)); } - - exitSelectMode(); } catch (error: unknown) { toast.error("Uninstall failed", error instanceof Error ? error.message : String(error)); } @@ -89,28 +106,49 @@ export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) { return ( <> -
- - {selectedCount} selected - - -
- - - - - -
+
+
+ + } + variant="ghost" + size="sm" + onClick={exitSelectMode} + disabled={bulkUninstall.isPending} + aria-label="Exit select mode" + /> + + + + {selectedCount} selected + {hiddenCount > 0 && ( + · {hiddenCount} hidden + )} + + +
+ + (checked ? addMany(visibleIds) : removeMany(visibleIds))} + label={`Select all visible (${visibleIds.length})`} + className="items-center rounded-lg px-2 py-1.5 whitespace-nowrap transition-colors hover:bg-surface-700" + /> + + + +
+ - -
m.enabled); const visibleMods = useFilteredMods(mods, searchQuery); + const selectMode = useLibrarySelectionStore((s) => s.selectMode); + const setOrderedIds = useLibrarySelectionStore((s) => s.setOrderedIds); + useEffect(() => { + setOrderedIds(visibleMods.map((m) => m.id)); + }, [visibleMods, setOrderedIds]); + useHotkeys("ctrl+i", () => actions.handleInstallMod(), { preventDefault: true, enabled: !isPatcherActive, @@ -159,6 +167,7 @@ export function Library({ folderId }: LibraryProps = {}) { error={error} folderId={folderId} /> + {selectMode && } ; + /** Visual order of the currently selectable mod ids, used to resolve shift-click ranges. */ + orderedIds: string[]; + /** Id of the last mod toggled without shift — the anchor for range selection. */ + anchorId: string | null; enterSelectMode: () => void; exitSelectMode: () => void; + setOrderedIds: (ids: string[]) => void; toggle: (id: string) => void; - selectAll: (ids: string[]) => void; + selectRangeTo: (id: string) => void; + addMany: (ids: string[]) => void; + removeMany: (ids: string[]) => void; + setSelection: (ids: Iterable) => void; clear: () => void; } export const useLibrarySelectionStore = create()((set) => ({ selectMode: false, selectedIds: new Set(), + orderedIds: [], + anchorId: null, enterSelectMode: () => set({ selectMode: true }), - exitSelectMode: () => set({ selectMode: false, selectedIds: new Set() }), + exitSelectMode: () => set({ selectMode: false, selectedIds: new Set(), anchorId: null }), + setOrderedIds: (ids) => + set((state) => (sameOrder(state.orderedIds, ids) ? state : { orderedIds: ids })), toggle: (id) => set((state) => { const next = new Set(state.selectedIds); if (next.has(id)) next.delete(id); else next.add(id); + return { selectedIds: next, anchorId: id }; + }), + selectRangeTo: (id) => + set((state) => { + const { orderedIds, anchorId } = state; + const from = anchorId === null ? -1 : orderedIds.indexOf(anchorId); + const to = orderedIds.indexOf(id); + const next = new Set(state.selectedIds); + if (from === -1 || to === -1) { + next.add(id); + return { selectedIds: next, anchorId: id }; + } + const [start, end] = from <= to ? [from, to] : [to, from]; + for (let i = start; i <= end; i++) next.add(orderedIds[i]); + return { selectedIds: next, anchorId: id }; + }), + addMany: (ids) => + set((state) => { + const next = new Set(state.selectedIds); + for (const id of ids) next.add(id); return { selectedIds: next }; }), - selectAll: (ids) => set({ selectedIds: new Set(ids) }), - clear: () => set({ selectedIds: new Set() }), + removeMany: (ids) => + set((state) => { + const next = new Set(state.selectedIds); + for (const id of ids) next.delete(id); + return { selectedIds: next }; + }), + setSelection: (ids) => set({ selectedIds: new Set(ids), anchorId: null }), + clear: () => set({ selectedIds: new Set(), anchorId: null }), })); + +function sameOrder(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/src/styles/animations.css b/src/styles/animations.css index 6251d37c..59e6a396 100644 --- a/src/styles/animations.css +++ b/src/styles/animations.css @@ -140,3 +140,7 @@ .animate-slide-down { animation: slide-down var(--duration-005) var(--ease-out); } + +.animate-slide-up { + animation: slide-up var(--duration-005) var(--ease-out); +}