diff --git a/.gitignore b/.gitignore index 4f17e96..242f55e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,4 @@ src-tauri/src/lib/bindings/ .mcp.json # Windows -nul \ No newline at end of file +nul diff --git a/src/modules/library/api/index.ts b/src/modules/library/api/index.ts index 813a1e3..9d32455 100644 --- a/src/modules/library/api/index.ts +++ b/src/modules/library/api/index.ts @@ -1,6 +1,8 @@ export { libraryKeys } from "./keys"; export { useAnalyzeModWads } from "./useAnalyzeModWads"; export { useBulkInstallMods } from "./useBulkInstallMods"; +export type { BulkUninstallResult } from "./useBulkUninstallMods"; +export { useBulkUninstallMods } from "./useBulkUninstallMods"; export { useCreateProfile } from "./useCreateProfile"; export { useDeleteProfile } from "./useDeleteProfile"; export { useEditMod } from "./useEditMod"; diff --git a/src/modules/library/api/useBulkUninstallMods.ts b/src/modules/library/api/useBulkUninstallMods.ts new file mode 100644 index 0000000..9949f8b --- /dev/null +++ b/src/modules/library/api/useBulkUninstallMods.ts @@ -0,0 +1,56 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { api, type AppError, type InstalledMod } from "@/lib/tauri"; +import { isOk } from "@/utils/result"; + +import { libraryKeys } from "./keys"; + +export interface BulkUninstallResult { + succeeded: string[]; + failed: Array<{ id: string; error: string }>; +} + +export function useBulkUninstallMods() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (modIds) => { + const settled = await Promise.allSettled(modIds.map((id) => api.uninstallMod(id))); + + const succeeded: string[] = []; + const failed: Array<{ id: string; error: string }> = []; + + settled.forEach((outcome, index) => { + const id = modIds[index]; + if (outcome.status === "rejected") { + failed.push({ id, error: String(outcome.reason) }); + return; + } + if (isOk(outcome.value)) { + succeeded.push(id); + } else { + failed.push({ id, error: outcome.value.error.message }); + } + }); + + return { succeeded, failed }; + }, + onMutate: async (modIds) => { + await queryClient.cancelQueries({ queryKey: libraryKeys.mods() }); + const previous = queryClient.getQueryData(libraryKeys.mods()); + const idSet = new Set(modIds); + queryClient.setQueryData(libraryKeys.mods(), (old) => + old?.filter((mod) => !idSet.has(mod.id)), + ); + return { previous }; + }, + onError: (_error, _variables, context) => { + if (context?.previous) { + queryClient.setQueryData(libraryKeys.mods(), context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: libraryKeys.mods() }); + }, + }); +} diff --git a/src/modules/library/api/useLibraryActions.ts b/src/modules/library/api/useLibraryActions.ts index 879d5d2..95728d1 100644 --- a/src/modules/library/api/useLibraryActions.ts +++ b/src/modules/library/api/useLibraryActions.ts @@ -2,7 +2,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { useState } from "react"; import { useToast } from "@/components"; -import { api, type BulkInstallResult, unwrap } from "@/lib/tauri"; +import { api, type BulkInstallResult, type InstalledMod, unwrap } from "@/lib/tauri"; import { checkModForSkinhack } from "@/modules/library/utils/skinhackCheck"; import { useBulkInstallMods } from "./useBulkInstallMods"; @@ -113,6 +113,22 @@ export function useLibraryActions() { ); } + function handleSetEnabledForMods(mods: InstalledMod[], enabled: boolean) { + const targets = mods.filter((m) => m.enabled !== enabled); + if (targets.length === 0) return; + + for (const mod of targets) { + toggleMod.mutate( + { modId: mod.id, enabled }, + { + onError: (error) => { + console.error("Failed to toggle mod:", error.message); + }, + }, + ); + } + } + function handleUninstallMod(modId: string) { uninstallMod.mutate(modId, { onError: (error) => { @@ -141,9 +157,11 @@ export function useLibraryActions() { return { installMod, bulkInstallMods, + toggleMod, handleInstallMod, handleBulkInstallFiles, handleToggleMod, + handleSetEnabledForMods, handleUninstallMod, handleReorder, handleOpenStorageDirectory, diff --git a/src/modules/library/api/useLibraryContent.ts b/src/modules/library/api/useLibraryContent.ts index 56afc41..8b90b4b 100644 --- a/src/modules/library/api/useLibraryContent.ts +++ b/src/modules/library/api/useLibraryContent.ts @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import type { InstalledMod, LibraryFolder } from "@/lib/tauri"; import { sortFolders, sortModsByFolder } from "@/modules/library/utils"; import { usePatcherStatus } from "@/modules/patcher"; -import { useHasActiveFilters, useLibraryFilterStore } from "@/stores"; +import { useHasActiveFilters, useLibraryFilterStore, useLibrarySelectionStore } from "@/stores"; import { useLibraryViewStore } from "@/stores/libraryView"; import { useFolderOrder, useFolders } from "./queries"; @@ -58,9 +58,11 @@ export function useLibraryContent({ cleanupStaleFolders(validIds); }, [folders, cleanupStaleFolders]); + const selectMode = useLibrarySelectionStore((s) => s.selectMode); const isSearching = searchQuery.length > 0; const isPrioritySort = sort.field === "priority"; - const dndDisabled = isSearching || isPatcherActive || !isPrioritySort || hasActiveFilters; + const dndDisabled = + isSearching || isPatcherActive || !isPrioritySort || hasActiveFilters || selectMode; const isFlatMode = isSearching || hasActiveFilters; const folderMap = useMemo(() => { diff --git a/src/modules/library/components/BulkUninstallDialog.tsx b/src/modules/library/components/BulkUninstallDialog.tsx new file mode 100644 index 0000000..df7f93e --- /dev/null +++ b/src/modules/library/components/BulkUninstallDialog.tsx @@ -0,0 +1,92 @@ +import { TriangleAlert } from "lucide-react"; + +import { Button, Dialog } from "@/components"; +import type { InstalledMod } from "@/lib/tauri"; + +interface BulkUninstallDialogProps { + open: boolean; + mods: InstalledMod[]; + isPending: boolean; + onClose: () => void; + onConfirm: () => void; +} + +const PREVIEW_LIMIT = 5; + +export function BulkUninstallDialog({ + open, + mods, + isPending, + onClose, + onConfirm, +}: BulkUninstallDialogProps) { + const count = mods.length; + const preview = mods.slice(0, PREVIEW_LIMIT); + const overflow = Math.max(0, count - PREVIEW_LIMIT); + + return ( + !next && onClose()}> + + + + + + Uninstall {count} mod{count === 1 ? "" : "s"}? + + + + + +
+ +
+

+ This will permanently delete the selected mod files from disk. +

+

+ You’ll need to re-import them from their original archives to use them + again. +

+

This action cannot be undone.

+
+
+ + {preview.length > 0 && ( +
+

+ To be removed +

+
    + {preview.map((mod) => ( +
  • + • {mod.displayName} +
  • + ))} +
+ {overflow > 0 && ( +

+ + {overflow} more mod{overflow === 1 ? "" : "s"} +

+ )} +
+ )} +
+ + + + + +
+
+
+ ); +} diff --git a/src/modules/library/components/LibraryToolbar.tsx b/src/modules/library/components/LibraryToolbar.tsx index bf511e5..cf30b89 100644 --- a/src/modules/library/components/LibraryToolbar.tsx +++ b/src/modules/library/components/LibraryToolbar.tsx @@ -1,13 +1,15 @@ -import { Grid3X3, List, Play, Plus, Search } from "lucide-react"; +import { CheckCheck, CheckSquare, Grid3X3, List, Play, Plus, Search, X } from "lucide-react"; import { Button, IconButton, Kbd, Tooltip } from "@/components"; -import type { PatcherStatus } from "@/lib/tauri"; +import type { InstalledMod, PatcherStatus } from "@/lib/tauri"; import type { FilterOptions } from "@/modules/library/api"; import type { useLibraryActions } from "@/modules/library/api"; import { useLibraryViewMode } from "@/modules/library/api"; +import { useLibrarySelectionStore } from "@/stores"; import { ActiveFilterChips } from "./ActiveFilterChips"; import { FilterPopover } from "./FilterPopover"; +import { SelectionActionBar } from "./SelectionActionBar"; import { SortDropdown } from "./SortDropdown"; interface PatcherProps { @@ -27,6 +29,7 @@ interface LibraryToolbarProps { isLoading: boolean; isPatcherActive: boolean; filterOptions: FilterOptions; + visibleMods: InstalledMod[]; } export function LibraryToolbar({ @@ -38,8 +41,16 @@ export function LibraryToolbar({ isLoading, isPatcherActive, filterOptions, + visibleMods, }: LibraryToolbarProps) { const { viewMode, setViewMode } = useLibraryViewMode(); + const selectMode = useLibrarySelectionStore((s) => s.selectMode); + const enterSelectMode = useLibrarySelectionStore((s) => s.enterSelectMode); + const exitSelectMode = useLibrarySelectionStore((s) => s.exitSelectMode); + const visibleEnabledCount = visibleMods.reduce((n, m) => n + (m.enabled ? 1 : 0), 0); + const canEnableAll = visibleMods.length > 0 && visibleEnabledCount < visibleMods.length; + const canDisableAll = visibleEnabledCount > 0; + const bulkDisabled = isPatcherActive || isLoading || actions.toggleMod.isPending; return (
@@ -80,6 +91,49 @@ export function LibraryToolbar({
+ {/* Bulk toggle */} +
+ + } + variant="ghost" + size="sm" + onClick={() => actions.handleSetEnabledForMods(visibleMods, true)} + disabled={bulkDisabled || !canEnableAll} + aria-label="Enable all visible mods" + /> + + + } + variant="ghost" + size="sm" + onClick={() => actions.handleSetEnabledForMods(visibleMods, false)} + disabled={bulkDisabled || !canDisableAll} + aria-label="Disable all visible mods" + /> + +
+ + {/* Select mode toggle */} + + + + {/* Actions */} + {selectMode && } ); } diff --git a/src/modules/library/components/ModCard.tsx b/src/modules/library/components/ModCard.tsx index 2d6229c..b321a5c 100644 --- a/src/modules/library/components/ModCard.tsx +++ b/src/modules/library/components/ModCard.tsx @@ -11,8 +11,10 @@ import { Trash2, } from "lucide-react"; import { useState } from "react"; +import { twMerge } from "tailwind-merge"; +import { match } from "ts-pattern"; -import { Dialog, IconButton, Menu, Switch, Tooltip, useToast } from "@/components"; +import { Checkbox, Dialog, IconButton, Menu, Switch, Tooltip, useToast } from "@/components"; import type { InstalledMod, ModLayer } from "@/lib/tauri"; import { useEnableModWithLayers, @@ -22,6 +24,7 @@ import { useUninstallMod, } from "@/modules/library/api"; import { usePatcherStatus } from "@/modules/patcher"; +import { useLibrarySelectionStore } from "@/stores"; const ROOT_FOLDER_ID = "root"; import { useModThumbnail } from "@/modules/library/api/useModThumbnail"; @@ -47,6 +50,10 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar const { data: patcherStatus } = usePatcherStatus(); const [pickerOpen, setPickerOpen] = useState(false); + const selectMode = useLibrarySelectionStore((s) => s.selectMode); + const isSelected = useLibrarySelectionStore((s) => s.selectedIds.has(mod.id)); + const toggleSelection = useLibrarySelectionStore((s) => s.toggle); + const { isFlagged, reason: skinhackReason, @@ -55,6 +62,7 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar } = useSkinhackFlag(mod); const patcherRunning = patcherStatus?.running ?? false; const disabled = isFlagged || patcherRunning; + const interactionsDisabled = disabled || selectMode; const isInUserFolder = mod.folderId != null && mod.folderId !== ROOT_FOLDER_ID; const isMultiLayer = mod.layers.length > 1; @@ -100,25 +108,61 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar } function handleCardClick(e: React.MouseEvent) { - if (disabled) return; if ((e.target as HTMLElement).closest("[data-no-toggle]")) { return; } + if (selectMode) { + toggleSelection(mod.id); + return; + } + if (disabled) return; handleToggle(mod.id, !mod.enabled); } + const inSelectedState = selectMode && isSelected; + const inEnabledState = mod.enabled && !isFlagged; + const isInteractive = !isFlagged && (selectMode || !disabled); + + const cursorClass = match({ isFlagged, isInteractive }) + .with({ isFlagged: true }, () => "cursor-default opacity-50") + .with({ isInteractive: true }, () => "cursor-pointer") + .otherwise(() => "cursor-default"); + if (viewMode === "list") { + const stateClass = match({ isSelected: inSelectedState, isEnabled: inEnabledState }) + .with( + { isSelected: true }, + () => "border-accent-500 bg-surface-800 ring-2 ring-accent-400/60", + ) + .with( + { isEnabled: true }, + () => + "border-accent-500/40 bg-surface-800 shadow-[0_0_15px_-3px] shadow-accent-500/30 hover:-translate-y-px", + ) + .otherwise( + () => + "border-surface-700 bg-surface-900 hover:-translate-y-px hover:border-surface-600 hover:bg-surface-800/80 hover:shadow-md", + ); + return (
+ {selectMode && ( +
e.stopPropagation()} className="shrink-0"> + toggleSelection(mod.id)} + aria-label={`Select ${mod.displayName}`} + /> +
+ )}
{thumbnailUrl ? ( ) : ( handleToggle(mod.id, checked)} /> @@ -180,13 +224,13 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar
e.stopPropagation()}> } variant="ghost" size="md" - disabled={patcherRunning} + disabled={interactionsDisabled} /> } /> @@ -237,7 +281,7 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar } variant="danger" - disabled={patcherRunning} + disabled={interactionsDisabled} onClick={handleUninstall} > Uninstall @@ -252,17 +296,42 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar ); } + const stateClass = match({ isSelected: inSelectedState, isEnabled: inEnabledState }) + .with({ isSelected: true }, () => "border-accent-500 bg-surface-800 ring-2 ring-accent-400/60") + .with( + { isEnabled: true }, + () => + "border-accent-500/40 bg-surface-800 shadow-[0_0_20px_-5px] shadow-accent-500/40 hover:-translate-y-px hover:shadow-[0_0_20px_-3px,0_4px_6px_-1px] hover:shadow-accent-500/40", + ) + .otherwise( + () => + "border-surface-600 bg-surface-800 hover:-translate-y-px hover:border-surface-400 hover:bg-surface-700/80 hover:shadow-md", + ); + return (
+ {selectMode && ( +
e.stopPropagation()} + > + toggleSelection(mod.id)} + aria-label={`Select ${mod.displayName}`} + className="shadow-lg backdrop-blur-sm" + /> +
+ )}
) : ( handleToggle(mod.id, checked)} className="shadow-lg data-[unchecked]:bg-surface-600/80 data-[unchecked]:backdrop-blur-sm" @@ -335,13 +404,13 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar
e.stopPropagation()}> } variant="ghost" size="md" - disabled={patcherRunning} + disabled={interactionsDisabled} /> } /> @@ -395,7 +464,7 @@ export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCar } variant="danger" - disabled={patcherRunning} + disabled={interactionsDisabled} onClick={handleUninstall} > Uninstall diff --git a/src/modules/library/components/SelectionActionBar.tsx b/src/modules/library/components/SelectionActionBar.tsx new file mode 100644 index 0000000..32aed2b --- /dev/null +++ b/src/modules/library/components/SelectionActionBar.tsx @@ -0,0 +1,147 @@ +import { CheckSquare, Trash2, X } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { Button, useToast } from "@/components"; +import type { InstalledMod } from "@/lib/tauri"; +import { useBulkUninstallMods, useInstalledMods } from "@/modules/library/api"; +import { usePatcherStatus } from "@/modules/patcher"; +import { useLibrarySelectionStore } from "@/stores"; + +import { BulkUninstallDialog } from "./BulkUninstallDialog"; + +interface SelectionActionBarProps { + visibleMods: InstalledMod[]; +} + +export function SelectionActionBar({ visibleMods }: SelectionActionBarProps) { + const selectedIds = useLibrarySelectionStore((s) => s.selectedIds); + const selectAll = useLibrarySelectionStore((s) => s.selectAll); + const clear = useLibrarySelectionStore((s) => s.clear); + const exitSelectMode = useLibrarySelectionStore((s) => s.exitSelectMode); + + const { data: patcherStatus } = usePatcherStatus(); + const patcherRunning = patcherStatus?.running ?? false; + + const bulkUninstall = useBulkUninstallMods(); + const toast = useToast(); + + const { data: allMods = [] } = useInstalledMods(); + + const [dialogOpen, setDialogOpen] = useState(false); + + const selectedMods = useMemo( + () => allMods.filter((m) => selectedIds.has(m.id)), + [allMods, selectedIds], + ); + const selectedCount = selectedIds.size; + + 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(); + } + + function handleOpenDialog() { + if (selectedCount === 0) return; + setDialogOpen(true); + } + + function handleCloseDialog() { + if (bulkUninstall.isPending) return; + setDialogOpen(false); + } + + async function handleConfirmUninstall() { + const ids = [...selectedIds]; + try { + const result = await bulkUninstall.mutateAsync(ids); + setDialogOpen(false); + + if (result.failed.length === 0) { + toast.success( + "Mods uninstalled", + `${result.succeeded.length} mod${result.succeeded.length === 1 ? "" : "s"} removed`, + ); + } else if (result.succeeded.length === 0) { + toast.error( + "Uninstall failed", + `All ${result.failed.length} mod${result.failed.length === 1 ? "" : "s"} failed to uninstall`, + ); + } else { + toast.warning( + "Uninstall completed with errors", + `${result.succeeded.length} removed, ${result.failed.length} failed`, + ); + } + + exitSelectMode(); + } catch (error: unknown) { + toast.error("Uninstall failed", error instanceof Error ? error.message : String(error)); + } + } + + return ( + <> +
+ + {selectedCount} selected + + +
+ + + + + +
+ + + +
+
+ + + + ); +} diff --git a/src/modules/library/components/index.ts b/src/modules/library/components/index.ts index 0ce792c..c6305a2 100644 --- a/src/modules/library/components/index.ts +++ b/src/modules/library/components/index.ts @@ -1,6 +1,7 @@ export * from "./ActiveFilterChips"; export * from "./BulkInstallProgress"; export * from "./BulkInstallResults"; +export * from "./BulkUninstallDialog"; export * from "./CreateFolderDialog"; export * from "./DndDragOverlay"; export * from "./DragDropOverlay"; @@ -26,6 +27,7 @@ export * from "./ModCard"; export * from "./ModDetailsDialog"; export * from "./ProfileSelector"; export * from "./RemoveFromFolderZone"; +export * from "./SelectionActionBar"; export * from "./SortableFolderCard"; export * from "./SortableFolderRow"; export * from "./SortableModCard"; diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index eaf8903..56523ff 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -10,6 +10,7 @@ import { ImportProgressDialog, LibraryContent, LibraryToolbar, + useFilteredMods, useFilterOptions, useInstalledMods, useLibraryActions, @@ -54,6 +55,7 @@ export function Library({ folderId }: LibraryProps = {}) { const filterOptions = useFilterOptions(mods); const hasEnabledMods = mods.some((m) => m.enabled); + const visibleMods = useFilteredMods(mods, searchQuery); useHotkeys("ctrl+i", () => actions.handleInstallMod(), { preventDefault: true, @@ -147,6 +149,7 @@ export function Library({ folderId }: LibraryProps = {}) { isLoading={isLoading} isPatcherActive={isPatcherActive} filterOptions={filterOptions} + visibleMods={visibleMods} /> ; + enterSelectMode: () => void; + exitSelectMode: () => void; + toggle: (id: string) => void; + selectAll: (ids: string[]) => void; + clear: () => void; +} + +export const useLibrarySelectionStore = create()((set) => ({ + selectMode: false, + selectedIds: new Set(), + enterSelectMode: () => set({ selectMode: true }), + exitSelectMode: () => set({ selectMode: false, selectedIds: new Set() }), + toggle: (id) => + set((state) => { + const next = new Set(state.selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + return { selectedIds: next }; + }), + selectAll: (ids) => set({ selectedIds: new Set(ids) }), + clear: () => set({ selectedIds: new Set() }), +}));