pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add drag and drop bookmark reordering

Pas c90e77dd 1b073006

+447 -89
+201
src/hooks/useBookmarkDragAndDrop.tsx
··· 1 + import { 2 + DragEndEvent, 3 + KeyboardSensor, 4 + MouseSensor, 5 + TouchSensor, 6 + useSensor, 7 + useSensors, 8 + } from "@dnd-kit/core"; 9 + import { 10 + arrayMove, 11 + sortableKeyboardCoordinates, 12 + useSortable, 13 + } from "@dnd-kit/sortable"; 14 + import { CSS } from "@dnd-kit/utilities"; 15 + import React, { useEffect, useRef, useState } from "react"; 16 + 17 + import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 18 + import { useBookmarkStore } from "@/stores/bookmarks"; 19 + import { MediaItem } from "@/utils/mediaTypes"; 20 + 21 + interface SortableMediaCardProps { 22 + media: MediaItem; 23 + closable?: boolean; 24 + onClose?: () => void; 25 + onShowDetails?: (media: MediaItem) => void; 26 + editable?: boolean; 27 + onEdit?: () => void; 28 + isEditing?: boolean; 29 + } 30 + 31 + export function SortableMediaCard({ 32 + media, 33 + closable, 34 + onClose, 35 + onShowDetails, 36 + editable, 37 + onEdit, 38 + isEditing, 39 + }: SortableMediaCardProps): JSX.Element { 40 + const { 41 + attributes, 42 + listeners, 43 + setNodeRef, 44 + transform, 45 + transition, 46 + isDragging, 47 + } = useSortable({ id: media.id }); 48 + 49 + const style = { 50 + transform: CSS.Transform.toString(transform), 51 + transition, 52 + opacity: isDragging ? 0.5 : 1, 53 + }; 54 + 55 + return ( 56 + <div 57 + ref={setNodeRef} 58 + style={style} 59 + {...(isEditing ? { ...attributes, ...listeners } : {})} 60 + className={isEditing ? "cursor-grab active:cursor-grabbing" : ""} 61 + > 62 + <WatchedMediaCard 63 + media={media} 64 + closable={closable} 65 + onClose={onClose} 66 + onShowDetails={onShowDetails} 67 + editable={editable} 68 + onEdit={onEdit} 69 + /> 70 + </div> 71 + ); 72 + } 73 + 74 + interface UseBookmarkDragAndDropProps { 75 + editing: boolean; 76 + items: MediaItem[]; 77 + groupedItems: Record<string, MediaItem[]>; 78 + } 79 + 80 + export function useBookmarkDragAndDrop({ 81 + editing, 82 + items, 83 + groupedItems, 84 + }: UseBookmarkDragAndDropProps) { 85 + const bookmarks = useBookmarkStore((s) => s.bookmarks); 86 + const updateBookmarkOrder = useBookmarkStore((s) => s.updateBookmarkOrder); 87 + 88 + // Drag and drop sensors 89 + const sensors = useSensors( 90 + useSensor(TouchSensor, { 91 + activationConstraint: { 92 + delay: 75, 93 + tolerance: 1, 94 + }, 95 + }), 96 + useSensor(MouseSensor), 97 + useSensor(KeyboardSensor, { 98 + coordinateGetter: sortableKeyboardCoordinates, 99 + }), 100 + ); 101 + 102 + // Track order during editing 103 + const [orderedItems, setOrderedItems] = useState<MediaItem[]>([]); 104 + const [orderedGroupedItems, setOrderedGroupedItems] = useState< 105 + Record<string, MediaItem[]> 106 + >({}); 107 + const isApplyingOrderRef = useRef(false); 108 + 109 + // Initialize ordered items when entering edit mode 110 + useEffect(() => { 111 + if (editing) { 112 + setOrderedItems([...items]); 113 + setOrderedGroupedItems({ ...groupedItems }); 114 + isApplyingOrderRef.current = false; 115 + } 116 + }, [editing, items, groupedItems]); 117 + 118 + // Apply order when exiting edit mode 119 + useEffect(() => { 120 + if (!editing && orderedItems.length > 0 && !isApplyingOrderRef.current) { 121 + isApplyingOrderRef.current = true; 122 + 123 + // Apply order for regular items 124 + const regularOrder = orderedItems 125 + .filter((item) => { 126 + const bookmark = bookmarks[item.id]; 127 + return !Array.isArray(bookmark?.group) || bookmark.group.length === 0; 128 + }) 129 + .map((item) => item.id); 130 + if (regularOrder.length > 0) { 131 + updateBookmarkOrder(regularOrder); 132 + } 133 + 134 + // Apply order for grouped items 135 + Object.entries(orderedGroupedItems).forEach( 136 + ([_groupName, groupItems]) => { 137 + const groupOrderIds = groupItems.map((item) => item.id); 138 + if (groupOrderIds.length > 0) { 139 + updateBookmarkOrder(groupOrderIds); 140 + } 141 + }, 142 + ); 143 + 144 + // Reset ordered items after a short delay to allow state updates to complete 145 + setTimeout(() => { 146 + setOrderedItems([]); 147 + setOrderedGroupedItems({}); 148 + isApplyingOrderRef.current = false; 149 + }, 0); 150 + } 151 + }, [ 152 + editing, 153 + orderedItems, 154 + orderedGroupedItems, 155 + bookmarks, 156 + updateBookmarkOrder, 157 + ]); 158 + 159 + const handleDragEnd = (event: DragEndEvent, groupName?: string) => { 160 + const { active, over } = event; 161 + if (!over || active.id === over.id) return; 162 + 163 + if (groupName) { 164 + // Handle grouped items 165 + const currentItems = orderedGroupedItems[groupName] || []; 166 + const oldIndex = currentItems.findIndex((item) => item.id === active.id); 167 + const newIndex = currentItems.findIndex((item) => item.id === over.id); 168 + if (oldIndex !== -1 && newIndex !== -1) { 169 + const newItems = arrayMove(currentItems, oldIndex, newIndex); 170 + setOrderedGroupedItems({ 171 + ...orderedGroupedItems, 172 + [groupName]: newItems, 173 + }); 174 + } 175 + } else { 176 + // Handle regular items 177 + const currentItems = orderedItems.filter((item) => { 178 + const bookmark = bookmarks[item.id]; 179 + return !Array.isArray(bookmark?.group) || bookmark.group.length === 0; 180 + }); 181 + const oldIndex = currentItems.findIndex((item) => item.id === active.id); 182 + const newIndex = currentItems.findIndex((item) => item.id === over.id); 183 + if (oldIndex !== -1 && newIndex !== -1) { 184 + const newItems = arrayMove(currentItems, oldIndex, newIndex); 185 + // Update orderedItems with the new order 186 + const otherItems = orderedItems.filter((item) => { 187 + const bookmark = bookmarks[item.id]; 188 + return Array.isArray(bookmark?.group) && bookmark.group.length > 0; 189 + }); 190 + setOrderedItems([...newItems, ...otherItems]); 191 + } 192 + } 193 + }; 194 + 195 + return { 196 + sensors, 197 + orderedItems, 198 + orderedGroupedItems, 199 + handleDragEnd, 200 + }; 201 + }
+122 -48
src/pages/parts/home/BookmarksCarousel.tsx
··· 1 + import { DndContext, closestCenter } from "@dnd-kit/core"; 2 + import { SortableContext, rectSortingStrategy } from "@dnd-kit/sortable"; 1 3 import React, { useMemo, useState } from "react"; 2 4 import { useTranslation } from "react-i18next"; 3 5 import { Link } from "react-router-dom"; ··· 7 9 import { Item } from "@/components/form/SortableList"; 8 10 import { Icon, Icons } from "@/components/Icon"; 9 11 import { SectionHeading } from "@/components/layout/SectionHeading"; 10 - import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 11 12 import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal"; 12 13 import { EditGroupModal } from "@/components/overlays/EditGroupModal"; 13 14 import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; ··· 15 16 import { UserIcon, UserIcons } from "@/components/UserIcon"; 16 17 import { Flare } from "@/components/utils/Flare"; 17 18 import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 19 + import { 20 + SortableMediaCard, 21 + useBookmarkDragAndDrop, 22 + } from "@/hooks/useBookmarkDragAndDrop"; 18 23 import { useIsMobile } from "@/hooks/useIsMobile"; 19 24 import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; 20 25 import { useAuthStore } from "@/stores/auth"; ··· 180 185 181 186 return { groupedItems: grouped, regularItems: regular }; 182 187 }, [items, bookmarks, progressItems]); 188 + 189 + // Drag and drop hook 190 + const { sensors, orderedItems, orderedGroupedItems, handleDragEnd } = 191 + useBookmarkDragAndDrop({ 192 + editing, 193 + items, 194 + groupedItems, 195 + }); 183 196 184 197 // group sorting 185 198 const allGroups = useMemo(() => { ··· 435 448 > 436 449 <div className="md:w-12" /> 437 450 438 - {section.items 439 - .slice(0, MAX_ITEMS_PER_SECTION) 440 - .map((media) => ( 441 - <div 442 - key={media.id} 443 - onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 444 - e.preventDefault() 445 - } 446 - 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" 447 - > 448 - <WatchedMediaCard 449 - key={media.id} 450 - media={media} 451 - onShowDetails={onShowDetails} 452 - closable={editing} 453 - onClose={() => removeBookmark(media.id)} 454 - editable={editing} 455 - onEdit={() => handleEditBookmark(media.id)} 456 - /> 457 - </div> 458 - ))} 451 + <DndContext 452 + sensors={sensors} 453 + collisionDetection={closestCenter} 454 + onDragEnd={(e) => handleDragEnd(e, section.group)} 455 + > 456 + <SortableContext 457 + items={ 458 + editing && orderedGroupedItems[section.group || ""] 459 + ? orderedGroupedItems[section.group || ""] 460 + .slice(0, MAX_ITEMS_PER_SECTION) 461 + .map((item) => item.id) 462 + : section.items 463 + .slice(0, MAX_ITEMS_PER_SECTION) 464 + .map((item) => item.id) 465 + } 466 + strategy={rectSortingStrategy} 467 + > 468 + {(editing && orderedGroupedItems[section.group || ""] 469 + ? orderedGroupedItems[section.group || ""] 470 + : section.items 471 + ) 472 + .slice(0, MAX_ITEMS_PER_SECTION) 473 + .map((media) => ( 474 + <div 475 + key={media.id} 476 + onContextMenu={( 477 + e: React.MouseEvent<HTMLDivElement>, 478 + ) => e.preventDefault()} 479 + 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" 480 + > 481 + <SortableMediaCard 482 + key={media.id} 483 + media={media} 484 + onShowDetails={onShowDetails} 485 + closable={editing} 486 + onClose={() => removeBookmark(media.id)} 487 + editable={editing} 488 + onEdit={() => handleEditBookmark(media.id)} 489 + isEditing={editing} 490 + /> 491 + </div> 492 + ))} 493 + </SortableContext> 494 + </DndContext> 459 495 460 496 {section.items.length > MAX_ITEMS_PER_SECTION && ( 461 497 <MoreBookmarksCard /> ··· 509 545 > 510 546 <div className="md:w-12" /> 511 547 512 - {section.items.length > 0 513 - ? section.items 514 - .slice(0, MAX_ITEMS_PER_SECTION) 515 - .map((media) => ( 516 - <div 517 - key={media.id} 518 - onContextMenu={( 519 - e: React.MouseEvent<HTMLDivElement>, 520 - ) => e.preventDefault()} 521 - 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" 522 - > 523 - <WatchedMediaCard 548 + {section.items.length > 0 ? ( 549 + <DndContext 550 + sensors={sensors} 551 + collisionDetection={closestCenter} 552 + onDragEnd={(e) => handleDragEnd(e)} 553 + > 554 + <SortableContext 555 + items={ 556 + editing 557 + ? orderedItems 558 + .filter((item) => { 559 + const bookmark = bookmarks[item.id]; 560 + return ( 561 + !Array.isArray(bookmark?.group) || 562 + bookmark.group.length === 0 563 + ); 564 + }) 565 + .slice(0, MAX_ITEMS_PER_SECTION) 566 + .map((item) => item.id) 567 + : section.items 568 + .slice(0, MAX_ITEMS_PER_SECTION) 569 + .map((item) => item.id) 570 + } 571 + strategy={rectSortingStrategy} 572 + > 573 + {(editing 574 + ? orderedItems.filter((item) => { 575 + const bookmark = bookmarks[item.id]; 576 + return ( 577 + !Array.isArray(bookmark?.group) || 578 + bookmark.group.length === 0 579 + ); 580 + }) 581 + : section.items 582 + ) 583 + .slice(0, MAX_ITEMS_PER_SECTION) 584 + .map((media) => ( 585 + <div 524 586 key={media.id} 525 - media={media} 526 - onShowDetails={onShowDetails} 527 - closable={editing} 528 - onClose={() => removeBookmark(media.id)} 529 - editable={editing} 530 - onEdit={() => handleEditBookmark(media.id)} 531 - /> 532 - </div> 533 - )) 534 - : Array.from({ length: SKELETON_COUNT }).map(() => ( 535 - <MediaCardSkeleton 536 - key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`} 537 - /> 538 - ))} 587 + onContextMenu={( 588 + e: React.MouseEvent<HTMLDivElement>, 589 + ) => e.preventDefault()} 590 + 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" 591 + > 592 + <SortableMediaCard 593 + key={media.id} 594 + media={media} 595 + onShowDetails={onShowDetails} 596 + closable={editing} 597 + onClose={() => removeBookmark(media.id)} 598 + editable={editing} 599 + onEdit={() => handleEditBookmark(media.id)} 600 + isEditing={editing} 601 + /> 602 + </div> 603 + ))} 604 + </SortableContext> 605 + </DndContext> 606 + ) : ( 607 + Array.from({ length: SKELETON_COUNT }).map(() => ( 608 + <MediaCardSkeleton 609 + key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`} 610 + /> 611 + )) 612 + )} 539 613 540 614 {section.items.length > MAX_ITEMS_PER_SECTION && ( 541 615 <MoreBookmarksCard />
+108 -41
src/pages/parts/home/BookmarksPart.tsx
··· 1 + import { DndContext, closestCenter } from "@dnd-kit/core"; 2 + import { SortableContext, rectSortingStrategy } from "@dnd-kit/sortable"; 1 3 import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 4 import { useEffect, useMemo, useState } from "react"; 3 5 import { useTranslation } from "react-i18next"; ··· 8 10 import { Icons } from "@/components/Icon"; 9 11 import { SectionHeading } from "@/components/layout/SectionHeading"; 10 12 import { MediaGrid } from "@/components/media/MediaGrid"; 11 - import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 12 13 import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal"; 13 14 import { EditGroupModal } from "@/components/overlays/EditGroupModal"; 14 15 import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; 15 16 import { useModal } from "@/components/overlays/Modal"; 16 17 import { UserIcon, UserIcons } from "@/components/UserIcon"; 17 18 import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 19 + import { 20 + SortableMediaCard, 21 + useBookmarkDragAndDrop, 22 + } from "@/hooks/useBookmarkDragAndDrop"; 18 23 import { useAuthStore } from "@/stores/auth"; 19 24 import { useBookmarkStore } from "@/stores/bookmarks"; 20 25 import { useGroupOrderStore } from "@/stores/groupOrder"; ··· 119 124 120 125 return { groupedItems: grouped, regularItems: regular }; 121 126 }, [items, bookmarks, progressItems]); 127 + 128 + // Drag and drop hook 129 + const { sensors, orderedItems, orderedGroupedItems, handleDragEnd } = 130 + useBookmarkDragAndDrop({ 131 + editing, 132 + items, 133 + groupedItems, 134 + }); 122 135 123 136 // group sorting 124 137 const allGroups = useMemo(() => { ··· 338 351 /> 339 352 </div> 340 353 </SectionHeading> 341 - <MediaGrid> 342 - {section.items.map((v) => ( 343 - <div 344 - key={v.id} 345 - onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 346 - e.preventDefault() 347 - } 348 - className="relative group" 349 - > 350 - <WatchedMediaCard 351 - media={v} 352 - closable={editing} 353 - onClose={() => removeBookmark(v.id)} 354 - onShowDetails={onShowDetails} 355 - editable={editing} 356 - onEdit={() => handleEditBookmark(v.id)} 357 - /> 358 - </div> 359 - ))} 360 - </MediaGrid> 354 + <DndContext 355 + sensors={sensors} 356 + collisionDetection={closestCenter} 357 + onDragEnd={(e) => handleDragEnd(e, section.group)} 358 + > 359 + <SortableContext 360 + items={ 361 + editing && orderedGroupedItems[section.group || ""] 362 + ? orderedGroupedItems[section.group || ""].map( 363 + (item) => item.id, 364 + ) 365 + : section.items.map((item) => item.id) 366 + } 367 + strategy={rectSortingStrategy} 368 + > 369 + <MediaGrid> 370 + {(editing && orderedGroupedItems[section.group || ""] 371 + ? orderedGroupedItems[section.group || ""] 372 + : section.items 373 + ).map((v) => ( 374 + <div 375 + key={v.id} 376 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 377 + e.preventDefault() 378 + } 379 + className="relative group" 380 + > 381 + <SortableMediaCard 382 + media={v} 383 + closable={editing} 384 + onClose={() => removeBookmark(v.id)} 385 + onShowDetails={onShowDetails} 386 + editable={editing} 387 + onEdit={() => handleEditBookmark(v.id)} 388 + isEditing={editing} 389 + /> 390 + </div> 391 + ))} 392 + </MediaGrid> 393 + </SortableContext> 394 + </DndContext> 361 395 </div> 362 396 ); 363 397 } // regular items ··· 384 418 /> 385 419 </div> 386 420 </SectionHeading> 387 - <MediaGrid ref={gridRef}> 388 - {section.items.map((v) => ( 389 - <div 390 - key={v.id} 391 - onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 392 - e.preventDefault() 393 - } 394 - className="relative group" 395 - > 396 - <WatchedMediaCard 397 - media={v} 398 - closable={editing} 399 - onClose={() => removeBookmark(v.id)} 400 - onShowDetails={onShowDetails} 401 - editable={editing} 402 - onEdit={() => handleEditBookmark(v.id)} 403 - /> 404 - </div> 405 - ))} 406 - </MediaGrid> 421 + <DndContext 422 + sensors={sensors} 423 + collisionDetection={closestCenter} 424 + onDragEnd={(e) => handleDragEnd(e)} 425 + > 426 + <SortableContext 427 + items={ 428 + editing 429 + ? orderedItems 430 + .filter((item) => { 431 + const bookmark = bookmarks[item.id]; 432 + return ( 433 + !Array.isArray(bookmark?.group) || 434 + bookmark.group.length === 0 435 + ); 436 + }) 437 + .map((item) => item.id) 438 + : section.items.map((item) => item.id) 439 + } 440 + strategy={rectSortingStrategy} 441 + > 442 + <MediaGrid ref={gridRef}> 443 + {(editing 444 + ? orderedItems.filter((item) => { 445 + const bookmark = bookmarks[item.id]; 446 + return ( 447 + !Array.isArray(bookmark?.group) || 448 + bookmark.group.length === 0 449 + ); 450 + }) 451 + : section.items 452 + ).map((v) => ( 453 + <div 454 + key={v.id} 455 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 456 + e.preventDefault() 457 + } 458 + className="relative group" 459 + > 460 + <SortableMediaCard 461 + media={v} 462 + closable={editing} 463 + onClose={() => removeBookmark(v.id)} 464 + onShowDetails={onShowDetails} 465 + editable={editing} 466 + onEdit={() => handleEditBookmark(v.id)} 467 + isEditing={editing} 468 + /> 469 + </div> 470 + ))} 471 + </MediaGrid> 472 + </SortableContext> 473 + </DndContext> 407 474 </div> 408 475 ); 409 476 })}
+16
src/stores/bookmarks/index.ts
··· 54 54 modifyBookmarksByGroup( 55 55 options: BulkGroupModificationOptions, 56 56 ): BookmarkModificationResult; 57 + updateBookmarkOrder(bookmarkIds: string[]): void; 57 58 clear(): void; 58 59 clearUpdateQueue(): void; 59 60 removeUpdateItem(id: string): void; ··· 276 277 }); 277 278 278 279 return result; 280 + }, 281 + updateBookmarkOrder(bookmarkIds: string[]) { 282 + set((s) => { 283 + const baseTime = Date.now(); 284 + bookmarkIds.forEach((bookmarkId, index) => { 285 + const bookmark = s.bookmarks[bookmarkId]; 286 + if (bookmark) { 287 + // Update timestamp to reflect order (earlier items have higher timestamps) 288 + // This ensures they appear first when sorted by date descending 289 + // Note: We don't add to update queue here to avoid quota errors. 290 + // Order is persisted locally via updatedAt timestamps. 291 + bookmark.updatedAt = baseTime - index; 292 + } 293 + }); 294 + }); 279 295 }, 280 296 })), 281 297 {