From c90e77ddf34c4bcb7036b353e411fe215fbc17ba Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Thu, 6 Nov 2025 00:28:38 -0700 Subject: [PATCH] add drag and drop bookmark reordering --- src/hooks/useBookmarkDragAndDrop.tsx | 201 +++++++++++++++++++++ src/pages/parts/home/BookmarksCarousel.tsx | 170 ++++++++++++----- src/pages/parts/home/BookmarksPart.tsx | 149 ++++++++++----- src/stores/bookmarks/index.ts | 16 ++ 4 files changed, 447 insertions(+), 89 deletions(-) create mode 100644 src/hooks/useBookmarkDragAndDrop.tsx diff --git a/src/hooks/useBookmarkDragAndDrop.tsx b/src/hooks/useBookmarkDragAndDrop.tsx new file mode 100644 index 00000000..248cad9d --- /dev/null +++ b/src/hooks/useBookmarkDragAndDrop.tsx @@ -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 ( +
+ +
+ ); +} + +interface UseBookmarkDragAndDropProps { + editing: boolean; + items: MediaItem[]; + groupedItems: Record; +} + +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([]); + const [orderedGroupedItems, setOrderedGroupedItems] = useState< + Record + >({}); + 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, + }; +} diff --git a/src/pages/parts/home/BookmarksCarousel.tsx b/src/pages/parts/home/BookmarksCarousel.tsx index 1498d544..58cea30d 100644 --- a/src/pages/parts/home/BookmarksCarousel.tsx +++ b/src/pages/parts/home/BookmarksCarousel.tsx @@ -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 { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; @@ -7,7 +9,6 @@ import { EditButtonWithText } from "@/components/buttons/EditButtonWithText"; import { Item } from "@/components/form/SortableList"; import { Icon, Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; -import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal"; import { EditGroupModal } from "@/components/overlays/EditGroupModal"; import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; @@ -15,6 +16,10 @@ import { useModal } from "@/components/overlays/Modal"; import { UserIcon, UserIcons } from "@/components/UserIcon"; import { Flare } from "@/components/utils/Flare"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; +import { + SortableMediaCard, + useBookmarkDragAndDrop, +} from "@/hooks/useBookmarkDragAndDrop"; import { useIsMobile } from "@/hooks/useIsMobile"; import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; import { useAuthStore } from "@/stores/auth"; @@ -181,6 +186,14 @@ export function BookmarksCarousel({ return { groupedItems: grouped, regularItems: regular }; }, [items, bookmarks, progressItems]); + // Drag and drop hook + const { sensors, orderedItems, orderedGroupedItems, handleDragEnd } = + useBookmarkDragAndDrop({ + editing, + items, + groupedItems, + }); + // group sorting const allGroups = useMemo(() => { const groups = new Set(); @@ -435,27 +448,50 @@ export function BookmarksCarousel({ >
- {section.items - .slice(0, MAX_ITEMS_PER_SECTION) - .map((media) => ( -
) => - 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" - > - removeBookmark(media.id)} - editable={editing} - onEdit={() => handleEditBookmark(media.id)} - /> -
- ))} + handleDragEnd(e, section.group)} + > + item.id) + : section.items + .slice(0, MAX_ITEMS_PER_SECTION) + .map((item) => item.id) + } + strategy={rectSortingStrategy} + > + {(editing && orderedGroupedItems[section.group || ""] + ? orderedGroupedItems[section.group || ""] + : section.items + ) + .slice(0, MAX_ITEMS_PER_SECTION) + .map((media) => ( +
, + ) => 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" + > + removeBookmark(media.id)} + editable={editing} + onEdit={() => handleEditBookmark(media.id)} + isEditing={editing} + /> +
+ ))} +
+
{section.items.length > MAX_ITEMS_PER_SECTION && ( @@ -509,33 +545,71 @@ export function BookmarksCarousel({ >
- {section.items.length > 0 - ? section.items - .slice(0, MAX_ITEMS_PER_SECTION) - .map((media) => ( -
, - ) => 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" - > - 0 ? ( + handleDragEnd(e)} + > + { + 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) => ( +
removeBookmark(media.id)} - editable={editing} - onEdit={() => handleEditBookmark(media.id)} - /> -
- )) - : Array.from({ length: SKELETON_COUNT }).map(() => ( - - ))} + onContextMenu={( + e: React.MouseEvent, + ) => 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" + > + removeBookmark(media.id)} + editable={editing} + onEdit={() => handleEditBookmark(media.id)} + isEditing={editing} + /> +
+ ))} + + + ) : ( + Array.from({ length: SKELETON_COUNT }).map(() => ( + + )) + )} {section.items.length > MAX_ITEMS_PER_SECTION && ( diff --git a/src/pages/parts/home/BookmarksPart.tsx b/src/pages/parts/home/BookmarksPart.tsx index 97227bf9..84d942d7 100644 --- a/src/pages/parts/home/BookmarksPart.tsx +++ b/src/pages/parts/home/BookmarksPart.tsx @@ -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 { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -8,13 +10,16 @@ import { Item } from "@/components/form/SortableList"; import { Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { MediaGrid } from "@/components/media/MediaGrid"; -import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal"; import { EditGroupModal } from "@/components/overlays/EditGroupModal"; import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; import { useModal } from "@/components/overlays/Modal"; import { UserIcon, UserIcons } from "@/components/UserIcon"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; +import { + SortableMediaCard, + useBookmarkDragAndDrop, +} from "@/hooks/useBookmarkDragAndDrop"; import { useAuthStore } from "@/stores/auth"; import { useBookmarkStore } from "@/stores/bookmarks"; import { useGroupOrderStore } from "@/stores/groupOrder"; @@ -120,6 +125,14 @@ export function BookmarksPart({ return { groupedItems: grouped, regularItems: regular }; }, [items, bookmarks, progressItems]); + // Drag and drop hook + const { sensors, orderedItems, orderedGroupedItems, handleDragEnd } = + useBookmarkDragAndDrop({ + editing, + items, + groupedItems, + }); + // group sorting const allGroups = useMemo(() => { const groups = new Set(); @@ -338,26 +351,47 @@ export function BookmarksPart({ />
- - {section.items.map((v) => ( -
) => - e.preventDefault() - } - className="relative group" - > - removeBookmark(v.id)} - onShowDetails={onShowDetails} - editable={editing} - onEdit={() => handleEditBookmark(v.id)} - /> -
- ))} -
+ handleDragEnd(e, section.group)} + > + item.id, + ) + : section.items.map((item) => item.id) + } + strategy={rectSortingStrategy} + > + + {(editing && orderedGroupedItems[section.group || ""] + ? orderedGroupedItems[section.group || ""] + : section.items + ).map((v) => ( +
) => + e.preventDefault() + } + className="relative group" + > + removeBookmark(v.id)} + onShowDetails={onShowDetails} + editable={editing} + onEdit={() => handleEditBookmark(v.id)} + isEditing={editing} + /> +
+ ))} +
+
+
); } // regular items @@ -384,26 +418,59 @@ export function BookmarksPart({ /> - - {section.items.map((v) => ( -
) => - e.preventDefault() - } - className="relative group" - > - removeBookmark(v.id)} - onShowDetails={onShowDetails} - editable={editing} - onEdit={() => handleEditBookmark(v.id)} - /> -
- ))} -
+ handleDragEnd(e)} + > + { + const bookmark = bookmarks[item.id]; + return ( + !Array.isArray(bookmark?.group) || + bookmark.group.length === 0 + ); + }) + .map((item) => item.id) + : section.items.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 + ).map((v) => ( +
) => + e.preventDefault() + } + className="relative group" + > + removeBookmark(v.id)} + onShowDetails={onShowDetails} + editable={editing} + onEdit={() => handleEditBookmark(v.id)} + isEditing={editing} + /> +
+ ))} +
+
+
); })} diff --git a/src/stores/bookmarks/index.ts b/src/stores/bookmarks/index.ts index 9e048fc3..7df352c4 100644 --- a/src/stores/bookmarks/index.ts +++ b/src/stores/bookmarks/index.ts @@ -54,6 +54,7 @@ export interface BookmarkStore { modifyBookmarksByGroup( options: BulkGroupModificationOptions, ): BookmarkModificationResult; + updateBookmarkOrder(bookmarkIds: string[]): void; clear(): void; clearUpdateQueue(): void; removeUpdateItem(id: string): void; @@ -277,6 +278,21 @@ export const useBookmarkStore = create( 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",