add drag and drop bookmark reordering

This commit is contained in:
Pas 2025-11-06 00:28:38 -07:00
parent 1b073006f4
commit c90e77ddf3
4 changed files with 447 additions and 89 deletions

View file

@ -0,0 +1,201 @@
import {
DragEndEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import React, { useEffect, useRef, useState } from "react";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { useBookmarkStore } from "@/stores/bookmarks";
import { MediaItem } from "@/utils/mediaTypes";
interface SortableMediaCardProps {
media: MediaItem;
closable?: boolean;
onClose?: () => void;
onShowDetails?: (media: MediaItem) => void;
editable?: boolean;
onEdit?: () => void;
isEditing?: boolean;
}
export function SortableMediaCard({
media,
closable,
onClose,
onShowDetails,
editable,
onEdit,
isEditing,
}: SortableMediaCardProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: media.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...(isEditing ? { ...attributes, ...listeners } : {})}
className={isEditing ? "cursor-grab active:cursor-grabbing" : ""}
>
<WatchedMediaCard
media={media}
closable={closable}
onClose={onClose}
onShowDetails={onShowDetails}
editable={editable}
onEdit={onEdit}
/>
</div>
);
}
interface UseBookmarkDragAndDropProps {
editing: boolean;
items: MediaItem[];
groupedItems: Record<string, MediaItem[]>;
}
export function useBookmarkDragAndDrop({
editing,
items,
groupedItems,
}: UseBookmarkDragAndDropProps) {
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const updateBookmarkOrder = useBookmarkStore((s) => s.updateBookmarkOrder);
// Drag and drop sensors
const sensors = useSensors(
useSensor(TouchSensor, {
activationConstraint: {
delay: 75,
tolerance: 1,
},
}),
useSensor(MouseSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Track order during editing
const [orderedItems, setOrderedItems] = useState<MediaItem[]>([]);
const [orderedGroupedItems, setOrderedGroupedItems] = useState<
Record<string, MediaItem[]>
>({});
const isApplyingOrderRef = useRef(false);
// Initialize ordered items when entering edit mode
useEffect(() => {
if (editing) {
setOrderedItems([...items]);
setOrderedGroupedItems({ ...groupedItems });
isApplyingOrderRef.current = false;
}
}, [editing, items, groupedItems]);
// Apply order when exiting edit mode
useEffect(() => {
if (!editing && orderedItems.length > 0 && !isApplyingOrderRef.current) {
isApplyingOrderRef.current = true;
// Apply order for regular items
const regularOrder = orderedItems
.filter((item) => {
const bookmark = bookmarks[item.id];
return !Array.isArray(bookmark?.group) || bookmark.group.length === 0;
})
.map((item) => item.id);
if (regularOrder.length > 0) {
updateBookmarkOrder(regularOrder);
}
// Apply order for grouped items
Object.entries(orderedGroupedItems).forEach(
([_groupName, groupItems]) => {
const groupOrderIds = groupItems.map((item) => item.id);
if (groupOrderIds.length > 0) {
updateBookmarkOrder(groupOrderIds);
}
},
);
// Reset ordered items after a short delay to allow state updates to complete
setTimeout(() => {
setOrderedItems([]);
setOrderedGroupedItems({});
isApplyingOrderRef.current = false;
}, 0);
}
}, [
editing,
orderedItems,
orderedGroupedItems,
bookmarks,
updateBookmarkOrder,
]);
const handleDragEnd = (event: DragEndEvent, groupName?: string) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
if (groupName) {
// Handle grouped items
const currentItems = orderedGroupedItems[groupName] || [];
const oldIndex = currentItems.findIndex((item) => item.id === active.id);
const newIndex = currentItems.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newItems = arrayMove(currentItems, oldIndex, newIndex);
setOrderedGroupedItems({
...orderedGroupedItems,
[groupName]: newItems,
});
}
} else {
// Handle regular items
const currentItems = orderedItems.filter((item) => {
const bookmark = bookmarks[item.id];
return !Array.isArray(bookmark?.group) || bookmark.group.length === 0;
});
const oldIndex = currentItems.findIndex((item) => item.id === active.id);
const newIndex = currentItems.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newItems = arrayMove(currentItems, oldIndex, newIndex);
// Update orderedItems with the new order
const otherItems = orderedItems.filter((item) => {
const bookmark = bookmarks[item.id];
return Array.isArray(bookmark?.group) && bookmark.group.length > 0;
});
setOrderedItems([...newItems, ...otherItems]);
}
}
};
return {
sensors,
orderedItems,
orderedGroupedItems,
handleDragEnd,
};
}

View file

@ -1,3 +1,5 @@
import { DndContext, closestCenter } from "@dnd-kit/core";
import { SortableContext, rectSortingStrategy } from "@dnd-kit/sortable";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -7,7 +9,6 @@ import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
import { Item } from "@/components/form/SortableList"; import { Item } from "@/components/form/SortableList";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading"; import { SectionHeading } from "@/components/layout/SectionHeading";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal"; import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal";
import { EditGroupModal } from "@/components/overlays/EditGroupModal"; import { EditGroupModal } from "@/components/overlays/EditGroupModal";
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
@ -15,6 +16,10 @@ import { useModal } from "@/components/overlays/Modal";
import { UserIcon, UserIcons } from "@/components/UserIcon"; import { UserIcon, UserIcons } from "@/components/UserIcon";
import { Flare } from "@/components/utils/Flare"; import { Flare } from "@/components/utils/Flare";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import {
SortableMediaCard,
useBookmarkDragAndDrop,
} from "@/hooks/useBookmarkDragAndDrop";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
@ -181,6 +186,14 @@ export function BookmarksCarousel({
return { groupedItems: grouped, regularItems: regular }; return { groupedItems: grouped, regularItems: regular };
}, [items, bookmarks, progressItems]); }, [items, bookmarks, progressItems]);
// Drag and drop hook
const { sensors, orderedItems, orderedGroupedItems, handleDragEnd } =
useBookmarkDragAndDrop({
editing,
items,
groupedItems,
});
// group sorting // group sorting
const allGroups = useMemo(() => { const allGroups = useMemo(() => {
const groups = new Set<string>(); const groups = new Set<string>();
@ -435,27 +448,50 @@ export function BookmarksCarousel({
> >
<div className="md:w-12" /> <div className="md:w-12" />
{section.items <DndContext
.slice(0, MAX_ITEMS_PER_SECTION) sensors={sensors}
.map((media) => ( collisionDetection={closestCenter}
<div onDragEnd={(e) => handleDragEnd(e, section.group)}
key={media.id} >
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => <SortableContext
e.preventDefault() items={
} editing && orderedGroupedItems[section.group || ""]
className="relative mt-4 group cursor-pointer rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" ? orderedGroupedItems[section.group || ""]
> .slice(0, MAX_ITEMS_PER_SECTION)
<WatchedMediaCard .map((item) => item.id)
key={media.id} : section.items
media={media} .slice(0, MAX_ITEMS_PER_SECTION)
onShowDetails={onShowDetails} .map((item) => item.id)
closable={editing} }
onClose={() => removeBookmark(media.id)} strategy={rectSortingStrategy}
editable={editing} >
onEdit={() => handleEditBookmark(media.id)} {(editing && orderedGroupedItems[section.group || ""]
/> ? orderedGroupedItems[section.group || ""]
</div> : section.items
))} )
.slice(0, MAX_ITEMS_PER_SECTION)
.map((media) => (
<div
key={media.id}
onContextMenu={(
e: React.MouseEvent<HTMLDivElement>,
) => e.preventDefault()}
className="relative mt-4 group cursor-pointer rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
>
<SortableMediaCard
key={media.id}
media={media}
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
editable={editing}
onEdit={() => handleEditBookmark(media.id)}
isEditing={editing}
/>
</div>
))}
</SortableContext>
</DndContext>
{section.items.length > MAX_ITEMS_PER_SECTION && ( {section.items.length > MAX_ITEMS_PER_SECTION && (
<MoreBookmarksCard /> <MoreBookmarksCard />
@ -509,33 +545,71 @@ export function BookmarksCarousel({
> >
<div className="md:w-12" /> <div className="md:w-12" />
{section.items.length > 0 {section.items.length > 0 ? (
? section.items <DndContext
.slice(0, MAX_ITEMS_PER_SECTION) sensors={sensors}
.map((media) => ( collisionDetection={closestCenter}
<div onDragEnd={(e) => handleDragEnd(e)}
key={media.id} >
onContextMenu={( <SortableContext
e: React.MouseEvent<HTMLDivElement>, items={
) => e.preventDefault()} editing
className="relative mt-4 group cursor-pointer rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" ? orderedItems
> .filter((item) => {
<WatchedMediaCard const bookmark = bookmarks[item.id];
return (
!Array.isArray(bookmark?.group) ||
bookmark.group.length === 0
);
})
.slice(0, MAX_ITEMS_PER_SECTION)
.map((item) => item.id)
: section.items
.slice(0, MAX_ITEMS_PER_SECTION)
.map((item) => item.id)
}
strategy={rectSortingStrategy}
>
{(editing
? orderedItems.filter((item) => {
const bookmark = bookmarks[item.id];
return (
!Array.isArray(bookmark?.group) ||
bookmark.group.length === 0
);
})
: section.items
)
.slice(0, MAX_ITEMS_PER_SECTION)
.map((media) => (
<div
key={media.id} key={media.id}
media={media} onContextMenu={(
onShowDetails={onShowDetails} e: React.MouseEvent<HTMLDivElement>,
closable={editing} ) => e.preventDefault()}
onClose={() => removeBookmark(media.id)} className="relative mt-4 group cursor-pointer rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
editable={editing} >
onEdit={() => handleEditBookmark(media.id)} <SortableMediaCard
/> key={media.id}
</div> media={media}
)) onShowDetails={onShowDetails}
: Array.from({ length: SKELETON_COUNT }).map(() => ( closable={editing}
<MediaCardSkeleton onClose={() => removeBookmark(media.id)}
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`} editable={editing}
/> onEdit={() => handleEditBookmark(media.id)}
))} isEditing={editing}
/>
</div>
))}
</SortableContext>
</DndContext>
) : (
Array.from({ length: SKELETON_COUNT }).map(() => (
<MediaCardSkeleton
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
/>
))
)}
{section.items.length > MAX_ITEMS_PER_SECTION && ( {section.items.length > MAX_ITEMS_PER_SECTION && (
<MoreBookmarksCard /> <MoreBookmarksCard />

View file

@ -1,3 +1,5 @@
import { DndContext, closestCenter } from "@dnd-kit/core";
import { SortableContext, rectSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -8,13 +10,16 @@ import { Item } from "@/components/form/SortableList";
import { Icons } from "@/components/Icon"; import { Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading"; import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid"; import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal"; import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal";
import { EditGroupModal } from "@/components/overlays/EditGroupModal"; import { EditGroupModal } from "@/components/overlays/EditGroupModal";
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
import { useModal } from "@/components/overlays/Modal"; import { useModal } from "@/components/overlays/Modal";
import { UserIcon, UserIcons } from "@/components/UserIcon"; import { UserIcon, UserIcons } from "@/components/UserIcon";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import {
SortableMediaCard,
useBookmarkDragAndDrop,
} from "@/hooks/useBookmarkDragAndDrop";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useBookmarkStore } from "@/stores/bookmarks"; import { useBookmarkStore } from "@/stores/bookmarks";
import { useGroupOrderStore } from "@/stores/groupOrder"; import { useGroupOrderStore } from "@/stores/groupOrder";
@ -120,6 +125,14 @@ export function BookmarksPart({
return { groupedItems: grouped, regularItems: regular }; return { groupedItems: grouped, regularItems: regular };
}, [items, bookmarks, progressItems]); }, [items, bookmarks, progressItems]);
// Drag and drop hook
const { sensors, orderedItems, orderedGroupedItems, handleDragEnd } =
useBookmarkDragAndDrop({
editing,
items,
groupedItems,
});
// group sorting // group sorting
const allGroups = useMemo(() => { const allGroups = useMemo(() => {
const groups = new Set<string>(); const groups = new Set<string>();
@ -338,26 +351,47 @@ export function BookmarksPart({
/> />
</div> </div>
</SectionHeading> </SectionHeading>
<MediaGrid> <DndContext
{section.items.map((v) => ( sensors={sensors}
<div collisionDetection={closestCenter}
key={v.id} onDragEnd={(e) => handleDragEnd(e, section.group)}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => >
e.preventDefault() <SortableContext
} items={
className="relative group" editing && orderedGroupedItems[section.group || ""]
> ? orderedGroupedItems[section.group || ""].map(
<WatchedMediaCard (item) => item.id,
media={v} )
closable={editing} : section.items.map((item) => item.id)
onClose={() => removeBookmark(v.id)} }
onShowDetails={onShowDetails} strategy={rectSortingStrategy}
editable={editing} >
onEdit={() => handleEditBookmark(v.id)} <MediaGrid>
/> {(editing && orderedGroupedItems[section.group || ""]
</div> ? orderedGroupedItems[section.group || ""]
))} : section.items
</MediaGrid> ).map((v) => (
<div
key={v.id}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
className="relative group"
>
<SortableMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
editable={editing}
onEdit={() => handleEditBookmark(v.id)}
isEditing={editing}
/>
</div>
))}
</MediaGrid>
</SortableContext>
</DndContext>
</div> </div>
); );
} // regular items } // regular items
@ -384,26 +418,59 @@ export function BookmarksPart({
/> />
</div> </div>
</SectionHeading> </SectionHeading>
<MediaGrid ref={gridRef}> <DndContext
{section.items.map((v) => ( sensors={sensors}
<div collisionDetection={closestCenter}
key={v.id} onDragEnd={(e) => handleDragEnd(e)}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => >
e.preventDefault() <SortableContext
} items={
className="relative group" editing
> ? orderedItems
<WatchedMediaCard .filter((item) => {
media={v} const bookmark = bookmarks[item.id];
closable={editing} return (
onClose={() => removeBookmark(v.id)} !Array.isArray(bookmark?.group) ||
onShowDetails={onShowDetails} bookmark.group.length === 0
editable={editing} );
onEdit={() => handleEditBookmark(v.id)} })
/> .map((item) => item.id)
</div> : section.items.map((item) => item.id)
))} }
</MediaGrid> strategy={rectSortingStrategy}
>
<MediaGrid ref={gridRef}>
{(editing
? orderedItems.filter((item) => {
const bookmark = bookmarks[item.id];
return (
!Array.isArray(bookmark?.group) ||
bookmark.group.length === 0
);
})
: section.items
).map((v) => (
<div
key={v.id}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
className="relative group"
>
<SortableMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
editable={editing}
onEdit={() => handleEditBookmark(v.id)}
isEditing={editing}
/>
</div>
))}
</MediaGrid>
</SortableContext>
</DndContext>
</div> </div>
); );
})} })}

View file

@ -54,6 +54,7 @@ export interface BookmarkStore {
modifyBookmarksByGroup( modifyBookmarksByGroup(
options: BulkGroupModificationOptions, options: BulkGroupModificationOptions,
): BookmarkModificationResult; ): BookmarkModificationResult;
updateBookmarkOrder(bookmarkIds: string[]): void;
clear(): void; clear(): void;
clearUpdateQueue(): void; clearUpdateQueue(): void;
removeUpdateItem(id: string): void; removeUpdateItem(id: string): void;
@ -277,6 +278,21 @@ export const useBookmarkStore = create(
return result; return result;
}, },
updateBookmarkOrder(bookmarkIds: string[]) {
set((s) => {
const baseTime = Date.now();
bookmarkIds.forEach((bookmarkId, index) => {
const bookmark = s.bookmarks[bookmarkId];
if (bookmark) {
// Update timestamp to reflect order (earlier items have higher timestamps)
// This ensures they appear first when sorted by date descending
// Note: We don't add to update queue here to avoid quota errors.
// Order is persisted locally via updatedAt timestamps.
bookmark.updatedAt = baseTime - index;
}
});
});
},
})), })),
{ {
name: "__MW::bookmarks", name: "__MW::bookmarks",