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:
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(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 ? (
|
||||
<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
|
||||
ref={textareaRef}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onBlur={handleNoteBlur}
|
||||
placeholder="Ajouter une note…"
|
||||
rows={1}
|
||||
className="w-full text-foreground/70 bg-transparent resize-none outline-none placeholder:text-muted/40 leading-relaxed text-center"
|
||||
style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }}
|
||||
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: '1rem' }}
|
||||
/>
|
||||
</div>
|
||||
) : note ? (
|
||||
<div className="px-3 py-2.5 bg-card">
|
||||
<p className="text-foreground/70 leading-relaxed text-center" style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }}>{note}</p>
|
||||
<div className="px-3 bg-card flex items-center justify-center min-h-[2.75rem]">
|
||||
<p className="text-foreground/70 leading-snug text-center" style={{ fontFamily: 'var(--font-caveat)', fontSize: '1rem' }}>{note}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@ const gifMoodByIdInclude = {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
items: {
|
||||
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 } } } },
|
||||
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(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
|
||||
Reference in New Issue
Block a user