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

@@ -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 // Week Rating Actions
// ============================================ // ============================================

View File

@@ -1,7 +1,22 @@
'use client'; 'use client';
import { useMemo, useState, useTransition } from 'react'; 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 { Avatar } from '@/components/ui/Avatar';
import { GifMoodCard } from './GifMoodCard'; import { GifMoodCard } from './GifMoodCard';
import { GifMoodAddForm } from './GifMoodAddForm'; 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 // Subtle accent colors for each user section
const SECTION_COLORS = ['#ec4899', '#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444']; const SECTION_COLORS = ['#ec4899', '#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
@@ -150,7 +216,20 @@ export function GifMoodBoard({
ratings, ratings,
canEdit, canEdit,
}: GifMoodBoardProps) { }: 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 allUsers = useMemo(() => {
const map = new Map<string, { id: string; name: string | null; email: string }>(); const map = new Map<string, { id: string; name: string | null; email: string }>();
@@ -179,6 +258,24 @@ export function GifMoodBoard({
}); });
}, [allUsers, currentUserId, owner.id]); }, [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 ( return (
<div className="space-y-10"> <div className="space-y-10">
{/* Column size control */} {/* Column size control */}
@@ -201,8 +298,10 @@ export function GifMoodBoard({
</div> </div>
</div> </div>
{sortedUsers.map((user, index) => { {sortedUsers.map((user, index) => {
const userItems = itemsByUser.get(user.id) ?? [];
const isCurrentUser = user.id === currentUserId; 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 canAdd = canEdit && isCurrentUser && userItems.length < GIF_MOOD_MAX_ITEMS;
const accentColor = SECTION_COLORS[index % SECTION_COLORS.length]; const accentColor = SECTION_COLORS[index % SECTION_COLORS.length];
const userRating = ratings.find((r) => r.userId === user.id)?.rating ?? null; const userRating = ratings.find((r) => r.userId === user.id)?.rating ?? null;
@@ -242,6 +341,38 @@ export function GifMoodBoard({
</div> </div>
{/* Grid */} {/* Grid */}
{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`}> <div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
{userItems.map((item) => ( {userItems.map((item) => (
<GifMoodCard <GifMoodCard
@@ -252,19 +383,13 @@ export function GifMoodBoard({
canEdit={canEdit} canEdit={canEdit}
/> />
))} ))}
{/* Add form slot */}
{canAdd && (
<GifMoodAddForm sessionId={sessionId} currentCount={userItems.length} />
)}
{/* Empty state */}
{!canAdd && userItems.length === 0 && ( {!canAdd && userItems.length === 0 && (
<div className="col-span-full flex items-center justify-center rounded-2xl border border-dashed border-border/60 py-10"> <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> <p className="text-sm text-muted/60">Aucun GIF pour le moment</p>
</div> </div>
)} )}
</div> </div>
)}
</section> </section>
); );
})} })}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { memo, useState, useTransition } from 'react'; import { memo, useRef, useEffect, useState, useTransition } from 'react';
import { updateGifMoodItem, deleteGifMoodItem } from '@/actions/gif-mood'; import { updateGifMoodItem, deleteGifMoodItem } from '@/actions/gif-mood';
import { IconClose } from '@/components/ui'; import { IconClose } from '@/components/ui';
@@ -26,6 +26,14 @@ export const GifMoodCard = memo(function GifMoodCard({
const [itemVersion, setItemVersion] = useState(item); const [itemVersion, setItemVersion] = useState(item);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}, [note]);
if (itemVersion !== item) { if (itemVersion !== item) {
setItemVersion(item); setItemVersion(item);
@@ -90,20 +98,21 @@ export const GifMoodCard = memo(function GifMoodCard({
{/* Note */} {/* Note */}
{canEditThis ? ( {canEditThis ? (
<div className="px-3 pt-2 pb-3 bg-card"> <div className="px-3 bg-card flex items-center justify-center min-h-[2.75rem]">
<textarea <textarea
ref={textareaRef}
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
onBlur={handleNoteBlur} onBlur={handleNoteBlur}
placeholder="Ajouter une note…" placeholder="Ajouter une note…"
rows={1} rows={1}
className="w-full text-foreground/70 bg-transparent resize-none outline-none placeholder:text-muted/40 leading-relaxed text-center" className="w-full text-foreground/70 bg-transparent resize-none outline-none placeholder:text-muted/40 leading-snug text-center overflow-hidden"
style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }} style={{ fontFamily: 'var(--font-caveat)', fontSize: '1rem' }}
/> />
</div> </div>
) : note ? ( ) : note ? (
<div className="px-3 py-2.5 bg-card"> <div className="px-3 bg-card flex items-center justify-center min-h-[2.75rem]">
<p className="text-foreground/70 leading-relaxed text-center" style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }}>{note}</p> <p className="text-foreground/70 leading-snug text-center" style={{ fontFamily: 'var(--font-caveat)', fontSize: '1rem' }}>{note}</p>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -66,7 +66,7 @@ const gifMoodByIdInclude = {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
items: { items: {
include: { user: { select: { id: true, name: true, email: true } } }, include: { user: { select: { id: true, name: true, email: true } } },
orderBy: { createdAt: 'asc' as const }, orderBy: { order: 'asc' as const },
}, },
shares: { include: { user: { select: { id: true, name: true, email: true } } } }, shares: { include: { user: { select: { id: true, name: true, email: true } } } },
ratings: { select: { userId: true, rating: true } }, ratings: { select: { userId: true, rating: true } },
@@ -186,6 +186,21 @@ export async function deleteGifMoodItem(itemId: string, userId: string) {
}); });
} }
export async function reorderGifMoodItems(
sessionId: string,
userId: string,
orderedIds: string[]
) {
return prisma.$transaction(
orderedIds.map((id, index) =>
prisma.gifMoodItem.updateMany({
where: { id, userId, sessionId },
data: { order: index },
})
)
);
}
export async function upsertGifMoodUserRating( export async function upsertGifMoodUserRating(
sessionId: string, sessionId: string,
userId: string, userId: string,