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",