From ab00627a09eb16541e34c82a469893178fd8e1bc Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Wed, 25 Mar 2026 16:32:21 +0100 Subject: [PATCH] feat(gif-mood): drag-and-drop reorder + note footer centering - Add drag-and-drop reordering (dnd-kit/sortable) for current user's GIFs with optimistic updates - Add reorderGifMoodItems service + action with SSE broadcast - Fix item sort order: orderBy order asc instead of createdAt - Note footer: auto-resize textarea, vertically centered (1 or 2 lines), default 4 columns Co-Authored-By: Claude Sonnet 4.6 --- src/actions/gif-mood.ts | 39 +++++ src/components/gif-mood/GifMoodBoard.tsx | 177 +++++++++++++++++++---- src/components/gif-mood/GifMoodCard.tsx | 21 ++- src/services/gif-mood.ts | 17 ++- 4 files changed, 221 insertions(+), 33 deletions(-) diff --git a/src/actions/gif-mood.ts b/src/actions/gif-mood.ts index 3c665b1..4028f6d 100644 --- a/src/actions/gif-mood.ts +++ b/src/actions/gif-mood.ts @@ -228,6 +228,45 @@ export async function deleteGifMoodItem(sessionId: string, itemId: string) { } } +export async function reorderGifMoodItems(sessionId: string, orderedIds: string[]) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await gifMoodService.reorderGifMoodItems(sessionId, authSession.user.id, orderedIds); + + const user = await getUserById(authSession.user.id); + if (user) { + const event = await gifMoodService.createGifMoodSessionEvent( + sessionId, + authSession.user.id, + 'GIF_UPDATED', + { orderedIds } + ); + broadcastToGifMoodSession(sessionId, { + type: 'GIF_UPDATED', + payload: { orderedIds }, + userId: authSession.user.id, + user: { id: user.id, name: user.name, email: user.email }, + timestamp: event.createdAt, + }); + } + + revalidatePath(`/gif-mood/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error reordering gif mood items:', error); + return { success: false, error: 'Erreur lors de la réorganisation' }; + } +} + // ============================================ // Week Rating Actions // ============================================ diff --git a/src/components/gif-mood/GifMoodBoard.tsx b/src/components/gif-mood/GifMoodBoard.tsx index 04919b9..471e069 100644 --- a/src/components/gif-mood/GifMoodBoard.tsx +++ b/src/components/gif-mood/GifMoodBoard.tsx @@ -1,7 +1,22 @@ 'use client'; import { useMemo, useState, useTransition } from 'react'; -import { setGifMoodUserRating } from '@/actions/gif-mood'; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + rectSortingStrategy, + useSortable, + arrayMove, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { setGifMoodUserRating, reorderGifMoodItems } from '@/actions/gif-mood'; import { Avatar } from '@/components/ui/Avatar'; import { GifMoodCard } from './GifMoodCard'; import { GifMoodAddForm } from './GifMoodAddForm'; @@ -115,6 +130,57 @@ function WeekRating({ ); } +function DragHandle(props: React.HTMLAttributes) { + return ( +
+ + + + + + + + +
+ ); +} + +function SortableGifMoodCard({ + sessionId, + item, + currentUserId, + canEdit, +}: { + sessionId: string; + item: GifMoodItem; + currentUserId: string; + canEdit: boolean; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: item.id, + }); + + return ( +
+ + +
+ ); +} + // Subtle accent colors for each user section const SECTION_COLORS = ['#ec4899', '#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444']; @@ -150,7 +216,20 @@ export function GifMoodBoard({ ratings, canEdit, }: GifMoodBoardProps) { - const [cols, setCols] = useState(5); + const [cols, setCols] = useState(4); + const [, startReorderTransition] = useTransition(); + + // Optimistic reorder state for the current user's items + const [optimisticItems, setOptimisticItems] = useState([]); + const [prevPropsItems, setPrevPropsItems] = useState(items); + if (prevPropsItems !== items) { + setPrevPropsItems(items); + setOptimisticItems([]); + } + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) + ); const allUsers = useMemo(() => { const map = new Map(); @@ -179,6 +258,24 @@ export function GifMoodBoard({ }); }, [allUsers, currentUserId, owner.id]); + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const currentItems = + optimisticItems.length > 0 ? optimisticItems : (itemsByUser.get(currentUserId) ?? []); + const oldIndex = currentItems.findIndex((i) => i.id === active.id); + const newIndex = currentItems.findIndex((i) => i.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + + const reordered = arrayMove(currentItems, oldIndex, newIndex); + setOptimisticItems(reordered); + + startReorderTransition(async () => { + await reorderGifMoodItems(sessionId, reordered.map((i) => i.id)); + }); + } + return (
{/* Column size control */} @@ -201,8 +298,10 @@ export function GifMoodBoard({
{sortedUsers.map((user, index) => { - const userItems = itemsByUser.get(user.id) ?? []; const isCurrentUser = user.id === currentUserId; + const serverItems = itemsByUser.get(user.id) ?? []; + const userItems = + isCurrentUser && optimisticItems.length > 0 ? optimisticItems : serverItems; const canAdd = canEdit && isCurrentUser && userItems.length < GIF_MOOD_MAX_ITEMS; const accentColor = SECTION_COLORS[index % SECTION_COLORS.length]; const userRating = ratings.find((r) => r.userId === user.id)?.rating ?? null; @@ -242,29 +341,55 @@ export function GifMoodBoard({ {/* Grid */} -
- {userItems.map((item) => ( - - ))} - - {/* Add form slot */} - {canAdd && ( - - )} - - {/* Empty state */} - {!canAdd && userItems.length === 0 && ( -
-

Aucun GIF pour le moment

-
- )} -
+ {isCurrentUser && canEdit ? ( + + i.id)} + strategy={rectSortingStrategy} + > +
+ {userItems.map((item) => ( + + ))} + {canAdd && ( + + )} + {!canAdd && userItems.length === 0 && ( +
+

Aucun GIF pour le moment

+
+ )} +
+
+
+ ) : ( +
+ {userItems.map((item) => ( + + ))} + {!canAdd && userItems.length === 0 && ( +
+

Aucun GIF pour le moment

+
+ )} +
+ )} ); })} diff --git a/src/components/gif-mood/GifMoodCard.tsx b/src/components/gif-mood/GifMoodCard.tsx index 2dffbb1..e860721 100644 --- a/src/components/gif-mood/GifMoodCard.tsx +++ b/src/components/gif-mood/GifMoodCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { memo, useState, useTransition } from 'react'; +import { memo, useRef, useEffect, useState, useTransition } from 'react'; import { updateGifMoodItem, deleteGifMoodItem } from '@/actions/gif-mood'; import { IconClose } from '@/components/ui'; @@ -26,6 +26,14 @@ export const GifMoodCard = memo(function GifMoodCard({ const [itemVersion, setItemVersion] = useState(item); const [isPending, startTransition] = useTransition(); const [imgError, setImgError] = useState(false); + const textareaRef = useRef(null); + + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = el.scrollHeight + 'px'; + }, [note]); if (itemVersion !== item) { setItemVersion(item); @@ -90,20 +98,21 @@ export const GifMoodCard = memo(function GifMoodCard({ {/* Note */} {canEditThis ? ( -
+