Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/modules/library/api/useLibraryContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export function useLibraryContent({
const selectMode = useLibrarySelectionStore((s) => s.selectMode);
const isSearching = searchQuery.length > 0;
const isPrioritySort = sort.field === "priority";
const dndDisabled =
isSearching || isPatcherActive || !isPrioritySort || hasActiveFilters || selectMode;
const dndDisabled = isSearching || isPatcherActive || hasActiveFilters || selectMode;
const reorderDisabled = !isPrioritySort;
const isFlatMode = isSearching || hasActiveFilters;

const folderMap = useMemo(() => {
Expand Down Expand Up @@ -140,6 +140,7 @@ export function useLibraryContent({
return {
viewMode,
dndDisabled,
reorderDisabled,
selectMode,
contentView,
detailsMod,
Expand Down
29 changes: 26 additions & 3 deletions src/modules/library/api/useReorderMods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ import { unwrapForQuery } from "@/utils/query";
import { libraryKeys } from "./keys";

/**
* Hook to reorder enabled mods in the active profile.
* Hook to reorder mods in the active profile.
* Accepts a partial list of mod IDs (e.g. root mods only) and automatically
* appends any remaining mods from the cache so the backend receives the full set.
* Uses optimistic updates for instant UI feedback.
*/
export function useReorderMods() {
const queryClient = useQueryClient();

return useMutation<void, AppError, string[], { previous?: InstalledMod[] }>({
mutationFn: async (modIds) => {
const result = await api.reorderMods(modIds);
const allMods = queryClient.getQueryData<InstalledMod[]>(libraryKeys.mods());
const fullOrder = buildFullOrder(modIds, allMods);
const result = await api.reorderMods(fullOrder);
return unwrapForQuery(result);
},
onMutate: async (modIds) => {
Expand All @@ -26,7 +30,18 @@ export function useReorderMods() {
if (!old) return old;

const modMap = new Map(old.map((m) => [m.id, m]));
return modIds.map((id) => modMap.get(id)).filter(Boolean) as InstalledMod[];
const reorderedSet = new Set(modIds);
const folderMods = old.filter((m) => !reorderedSet.has(m.id));
const reorderedMods = modIds
.map((id) => {
const mod = modMap.get(id);
if (!mod) {
console.warn(`[useReorderMods] mod ID "${id}" not found in cache, skipping`);
}
return mod;
})
.filter(Boolean) as InstalledMod[];
return [...reorderedMods, ...folderMods];
});

return { previous };
Expand All @@ -41,3 +56,11 @@ export function useReorderMods() {
},
});
}

/** Build the full mod ID list the backend expects: reordered IDs first, then any remaining. */
function buildFullOrder(reorderedIds: string[], allMods: InstalledMod[] | undefined): string[] {
if (!allMods) return reorderedIds;
const reorderedSet = new Set(reorderedIds);
const remaining = allMods.filter((m) => !reorderedSet.has(m.id)).map((m) => m.id);
return [...reorderedIds, ...remaining];
}
47 changes: 29 additions & 18 deletions src/modules/library/api/useRootModDnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import { useMoveModToFolder } from "./useMoveMod";
interface UseRootModDndArgs {
rootMods: InstalledMod[];
onReorder: (modIds: string[]) => void;
reorderDisabled: boolean;
}

export function useRootModDnd({ rootMods, onReorder }: UseRootModDndArgs) {
export function useRootModDnd({ rootMods, onReorder, reorderDisabled }: UseRootModDndArgs) {
const moveModToFolder = useMoveModToFolder();

const rootModIds = useMemo(() => rootMods.map((m) => m.id), [rootMods]);
Expand All @@ -21,6 +22,11 @@ export function useRootModDnd({ rootMods, onReorder }: UseRootModDndArgs) {
const [activeId, setActiveId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<string[]>(rootModIds);
const lastPropsOrder = useRef<string[]>(rootModIds);
const localOrderRef = useRef<string[]>(localOrder);

useEffect(() => {
localOrderRef.current = localOrder;
}, [localOrder]);

useEffect(() => {
if (hasOrderChanged(rootModIds, lastPropsOrder.current)) {
Expand All @@ -40,20 +46,24 @@ export function useRootModDnd({ rootMods, onReorder }: UseRootModDndArgs) {
setActiveId(event.active.id as string);
}, []);

const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;

const overId = over.id as string;
if (resolveFolderId(overId)) return;

setLocalOrder((prev) => {
const oldIndex = prev.indexOf(active.id as string);
const newIndex = prev.indexOf(overId);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}, []);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;

const overId = over.id as string;
if (resolveFolderId(overId)) return;
if (reorderDisabled) return;

setLocalOrder((prev) => {
const oldIndex = prev.indexOf(active.id as string);
const newIndex = prev.indexOf(overId);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
},
[reorderDisabled],
);

const handleDragEnd = useCallback(
(event: DragEndEvent) => {
Expand All @@ -68,11 +78,12 @@ export function useRootModDnd({ rootMods, onReorder }: UseRootModDndArgs) {
}
}

if (hasOrderChanged(localOrder, rootModIds)) {
onReorder(localOrder);
const currentOrder = localOrderRef.current;
if (!reorderDisabled && hasOrderChanged(currentOrder, rootModIds)) {
onReorder(currentOrder);
}
},
[localOrder, rootModIds, onReorder, moveModToFolder],
[rootModIds, onReorder, moveModToFolder, reorderDisabled],
);

const handleDragCancel = useCallback(() => {
Expand Down
40 changes: 25 additions & 15 deletions src/modules/library/api/useSortableModDnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ interface UseSortableModDndArgs {
mods: InstalledMod[];
onReorder: (modIds: string[]) => void;
folderId?: string;
reorderDisabled?: boolean;
}

export function useSortableModDnd({ mods, onReorder, folderId }: UseSortableModDndArgs) {
export function useSortableModDnd({
mods,
onReorder,
folderId,
reorderDisabled,
}: UseSortableModDndArgs) {
const moveModToFolder = useMoveModToFolder();

const propsOrder = useMemo(() => mods.map((m) => m.id), [mods]);
Expand Down Expand Up @@ -43,18 +49,22 @@ export function useSortableModDnd({ mods, onReorder, folderId }: UseSortableModD
setActiveId(event.active.id as string);
}, []);

const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
if (over.id === REMOVE_FROM_FOLDER_ID) return;

setLocalOrder((prev) => {
const oldIndex = prev.indexOf(active.id as string);
const newIndex = prev.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}, []);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
if (over.id === REMOVE_FROM_FOLDER_ID) return;
if (reorderDisabled) return;

setLocalOrder((prev) => {
const oldIndex = prev.indexOf(active.id as string);
const newIndex = prev.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
},
[reorderDisabled],
);

const handleDragEnd = useCallback(
(event: DragEndEvent) => {
Expand All @@ -65,11 +75,11 @@ export function useSortableModDnd({ mods, onReorder, folderId }: UseSortableModD
return;
}

if (localOrder.join() !== propsOrder.join()) {
if (!reorderDisabled && localOrder.join() !== propsOrder.join()) {
onReorder(localOrder);
}
},
[folderId, localOrder, propsOrder, onReorder, moveModToFolder],
[folderId, localOrder, propsOrder, onReorder, moveModToFolder, reorderDisabled],
);

const handleDragCancel = useCallback(() => {
Expand Down
65 changes: 60 additions & 5 deletions src/modules/library/api/useUnifiedDnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ interface UseUnifiedDndArgs {
rootMods: InstalledMod[];
modsByFolder: Map<string, InstalledMod[]>;
onReorder: (modIds: string[]) => void;
reorderDisabled: boolean;
}

export function useUnifiedDnd({ folders, rootMods, modsByFolder, onReorder }: UseUnifiedDndArgs) {
export function useUnifiedDnd({
folders,
rootMods,
modsByFolder,
onReorder,
reorderDisabled,
}: UseUnifiedDndArgs) {
const {
localOrder: modLocalOrder,
orderedRootMods,
Expand All @@ -35,7 +42,7 @@ export function useUnifiedDnd({ folders, rootMods, modsByFolder, onReorder }: Us
handleDragOver: handleModDragOver,
handleDragEnd: handleModDragEnd,
handleDragCancel: handleModDragCancel,
} = useRootModDnd({ rootMods, onReorder });
} = useRootModDnd({ rootMods, onReorder, reorderDisabled });

const {
folderLocalOrder,
Expand Down Expand Up @@ -134,6 +141,7 @@ export function useUnifiedDnd({ folders, rootMods, modsByFolder, onReorder }: Us

const overFolderMod = folderModLookup.get(overId);
if (overFolderMod && overFolderMod.folderId === activeFolderModSource) {
if (reorderDisabled) return;
const currentOrder = (modsByFolder.get(activeFolderModSource) ?? []).map((m) => m.id);
const oldIndex = currentOrder.indexOf(id);
const newIndex = currentOrder.indexOf(overId);
Expand All @@ -159,6 +167,7 @@ export function useUnifiedDnd({ folders, rootMods, modsByFolder, onReorder }: Us
modsByFolder,
moveModToFolder,
reorderFolderMods,
reorderDisabled,
],
);

Expand Down Expand Up @@ -186,12 +195,58 @@ export function useUnifiedDnd({ folders, rootMods, modsByFolder, onReorder }: Us
const withoutSource = args.droppableContainers.filter(
(c) => c.id !== `sortable-folder:${activeSourceFolderId}`,
);
return closestCenter({ ...args, droppableContainers: withoutSource });
if (reorderDisabled) {
const hits = pointerWithin({ ...args, droppableContainers: withoutSource });
return hits
.map((hit) => {
const folderMod = folderModLookup.get(hit.id as string);
if (folderMod && folderMod.folderId !== activeSourceFolderId) {
return { ...hit, id: `sortable-folder:${folderMod.folderId}` };
}
if (folderMod && folderMod.folderId === activeSourceFolderId) {
return null;
}
return hit;
})
.filter(Boolean) as ReturnType<CollisionDetection>;
}
const folderHit = pointerWithin({ ...args, droppableContainers: withoutSource }).find((c) =>
parseSortableFolderId(c.id as string),
);
if (folderHit) return [folderHit];

const siblingsOnly = withoutSource.filter((c) => {
const mod = folderModLookup.get(c.id as string);
return mod && mod.folderId === activeSourceFolderId;
});
return closestCenter({ ...args, droppableContainers: siblingsOnly });
}

return closestCenter(args);
if (reorderDisabled) {
const hits = pointerWithin(args);
return hits.map((hit) => {
const folderMod = folderModLookup.get(hit.id as string);
if (folderMod) {
return { ...hit, id: `sortable-folder:${folderMod.folderId}` };
}
return hit;
});
}

const withoutFolderMods = args.droppableContainers.filter(
(c) => !folderModLookup.has(c.id as string),
);
// Use pointerWithin for folder drops (requires pointer inside folder),
// closestCenter for mod reorder (works by proximity to center)
const folderHit = pointerWithin({ ...args, droppableContainers: withoutFolderMods }).find(
(c) => parseSortableFolderId(c.id as string),
);
if (folderHit) return [folderHit];

const rootModsOnly = withoutFolderMods.filter((c) => !parseSortableFolderId(c.id as string));
return closestCenter({ ...args, droppableContainers: rootModsOnly });
},
[folderModLookup],
[folderModLookup, reorderDisabled],
);

return {
Expand Down
3 changes: 3 additions & 0 deletions src/modules/library/components/LibraryContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function LibraryContent({
const {
viewMode,
dndDisabled,
reorderDisabled,
selectMode,
contentView,
detailsMod,
Expand Down Expand Up @@ -117,6 +118,7 @@ export function LibraryContent({
reorderFolderMods.mutate({ folderId: contentView.folder.id, modIds })
}
disabled={dndDisabled}
reorderDisabled={reorderDisabled}
onViewDetails={setDetailsMod}
onEditMetadata={setEditMod}
className={`${gridClass(viewMode)} stagger-enter mt-4`}
Expand Down Expand Up @@ -150,6 +152,7 @@ export function LibraryContent({
modsByFolder={contentView.modsByFolder}
viewMode={viewMode}
dndDisabled={dndDisabled}
reorderDisabled={reorderDisabled}
onReorder={(modIds) => reorderMods.mutate(modIds)}
onViewDetails={setDetailsMod}
onEditMetadata={setEditMod}
Expand Down
20 changes: 11 additions & 9 deletions src/modules/library/components/SortableFolderRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ export function SortableFolderRow({
<div className="absolute inset-0 rounded-lg border-2 border-dashed border-accent-500/40 bg-accent-500/5" />
)}
<div className={`flex items-start ${isDragging ? "invisible" : ""}`}>
<div
className={`flex shrink-0 items-center px-2 py-2.5 text-surface-500 opacity-30 transition-opacity group-hover/sortable-folder:opacity-100 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
data-no-toggle
onClick={(e) => e.stopPropagation()}
{...attributes}
{...listeners}
>
<GripVertical className="h-5 w-5" />
</div>
{!sortDisabled && (
<div
className={`flex shrink-0 items-center px-2 py-2.5 text-surface-500 opacity-30 transition-opacity group-hover/sortable-folder:opacity-100 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
data-no-toggle
onClick={(e) => e.stopPropagation()}
{...attributes}
{...listeners}
>
<GripVertical className="h-5 w-5" />
</div>
)}
<div className="min-w-0 flex-1">
<FolderRow
folder={folder}
Expand Down
3 changes: 3 additions & 0 deletions src/modules/library/components/SortableModCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,21 @@ import { ModCard } from "./ModCard";
interface SortableModCardProps {
mod: InstalledMod;
viewMode: "grid" | "list";
reorderDisabled?: boolean;
onViewDetails?: (mod: InstalledMod) => void;
onEditMetadata?: (mod: InstalledMod) => void;
}

export function SortableModCard({
mod,
viewMode,
reorderDisabled,
onViewDetails,
onEditMetadata,
}: SortableModCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: mod.id,
disabled: reorderDisabled ? { droppable: true } : false,
});
const { data: patcherStatus } = usePatcherStatus();
const disabled = patcherStatus?.running ?? false;
Expand Down
Loading
Loading