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 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:32:21 +01:00
parent dc1cc47f18
commit ab00627a09
4 changed files with 221 additions and 33 deletions

View File

@@ -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<HTMLDivElement>) {
return (
<div
{...props}
className="absolute top-2 left-2 z-10 p-1.5 rounded-full bg-black/50 text-white opacity-0 group-hover/card:opacity-100 cursor-grab active:cursor-grabbing backdrop-blur-sm transition-opacity"
title="Réorganiser"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden>
<circle cx="4" cy="2.5" r="1.2" />
<circle cx="8" cy="2.5" r="1.2" />
<circle cx="4" cy="6" r="1.2" />
<circle cx="8" cy="6" r="1.2" />
<circle cx="4" cy="9.5" r="1.2" />
<circle cx="8" cy="9.5" r="1.2" />
</svg>
</div>
);
}
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 (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={`group/card relative ${isDragging ? 'opacity-50 z-50' : ''}`}
>
<DragHandle {...attributes} {...listeners} />
<GifMoodCard
sessionId={sessionId}
item={item}
currentUserId={currentUserId}
canEdit={canEdit}
/>
</div>
);
}
// 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<GifMoodItem[]>([]);
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<string, { id: string; name: string | null; email: string }>();
@@ -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 (
<div className="space-y-10">
{/* Column size control */}
@@ -201,8 +298,10 @@ export function GifMoodBoard({
</div>
</div>
{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({
</div>
{/* Grid */}
<div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
{userItems.map((item) => (
<GifMoodCard
key={item.id}
sessionId={sessionId}
item={item}
currentUserId={currentUserId}
canEdit={canEdit}
/>
))}
{/* Add form slot */}
{canAdd && (
<GifMoodAddForm sessionId={sessionId} currentCount={userItems.length} />
)}
{/* Empty state */}
{!canAdd && userItems.length === 0 && (
<div className="col-span-full flex items-center justify-center rounded-2xl border border-dashed border-border/60 py-10">
<p className="text-sm text-muted/60">Aucun GIF pour le moment</p>
</div>
)}
</div>
{isCurrentUser && canEdit ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={userItems.map((i) => i.id)}
strategy={rectSortingStrategy}
>
<div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
{userItems.map((item) => (
<SortableGifMoodCard
key={item.id}
sessionId={sessionId}
item={item}
currentUserId={currentUserId}
canEdit={canEdit}
/>
))}
{canAdd && (
<GifMoodAddForm sessionId={sessionId} currentCount={userItems.length} />
)}
{!canAdd && userItems.length === 0 && (
<div className="col-span-full flex items-center justify-center rounded-2xl border border-dashed border-border/60 py-10">
<p className="text-sm text-muted/60">Aucun GIF pour le moment</p>
</div>
)}
</div>
</SortableContext>
</DndContext>
) : (
<div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
{userItems.map((item) => (
<GifMoodCard
key={item.id}
sessionId={sessionId}
item={item}
currentUserId={currentUserId}
canEdit={canEdit}
/>
))}
{!canAdd && userItems.length === 0 && (
<div className="col-span-full flex items-center justify-center rounded-2xl border border-dashed border-border/60 py-10">
<p className="text-sm text-muted/60">Aucun GIF pour le moment</p>
</div>
)}
</div>
)}
</section>
);
})}