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 { 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<string>();
@ -435,27 +448,50 @@ export function BookmarksCarousel({
>
<div className="md:w-12" />
{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"
>
<WatchedMediaCard
key={media.id}
media={media}
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
editable={editing}
onEdit={() => handleEditBookmark(media.id)}
/>
</div>
))}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => handleDragEnd(e, section.group)}
>
<SortableContext
items={
editing && orderedGroupedItems[section.group || ""]
? orderedGroupedItems[section.group || ""]
.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 && orderedGroupedItems[section.group || ""]
? orderedGroupedItems[section.group || ""]
: 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 && (
<MoreBookmarksCard />
@ -509,33 +545,71 @@ export function BookmarksCarousel({
>
<div className="md:w-12" />
{section.items.length > 0
? 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"
>
<WatchedMediaCard
{section.items.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => handleDragEnd(e)}
>
<SortableContext
items={
editing
? orderedItems
.filter((item) => {
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}
media={media}
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
editable={editing}
onEdit={() => handleEditBookmark(media.id)}
/>
</div>
))
: Array.from({ length: SKELETON_COUNT }).map(() => (
<MediaCardSkeleton
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
/>
))}
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>
) : (
Array.from({ length: SKELETON_COUNT }).map(() => (
<MediaCardSkeleton
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
/>
))
)}
{section.items.length > MAX_ITEMS_PER_SECTION && (
<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 { 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<string>();
@ -338,26 +351,47 @@ export function BookmarksPart({
/>
</div>
</SectionHeading>
<MediaGrid>
{section.items.map((v) => (
<div
key={v.id}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
className="relative group"
>
<WatchedMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
editable={editing}
onEdit={() => handleEditBookmark(v.id)}
/>
</div>
))}
</MediaGrid>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => handleDragEnd(e, section.group)}
>
<SortableContext
items={
editing && orderedGroupedItems[section.group || ""]
? orderedGroupedItems[section.group || ""].map(
(item) => item.id,
)
: section.items.map((item) => item.id)
}
strategy={rectSortingStrategy}
>
<MediaGrid>
{(editing && orderedGroupedItems[section.group || ""]
? orderedGroupedItems[section.group || ""]
: 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>
);
} // regular items
@ -384,26 +418,59 @@ export function BookmarksPart({
/>
</div>
</SectionHeading>
<MediaGrid ref={gridRef}>
{section.items.map((v) => (
<div
key={v.id}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
className="relative group"
>
<WatchedMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
editable={editing}
onEdit={() => handleEditBookmark(v.id)}
/>
</div>
))}
</MediaGrid>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => handleDragEnd(e)}
>
<SortableContext
items={
editing
? orderedItems
.filter((item) => {
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}
>
<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>
);
})}

View file

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