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
|
// Week Rating Actions
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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,29 +341,55 @@ export function GifMoodBoard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
|
{isCurrentUser && canEdit ? (
|
||||||
{userItems.map((item) => (
|
<DndContext
|
||||||
<GifMoodCard
|
sensors={sensors}
|
||||||
key={item.id}
|
collisionDetection={closestCenter}
|
||||||
sessionId={sessionId}
|
onDragEnd={handleDragEnd}
|
||||||
item={item}
|
>
|
||||||
currentUserId={currentUserId}
|
<SortableContext
|
||||||
canEdit={canEdit}
|
items={userItems.map((i) => i.id)}
|
||||||
/>
|
strategy={rectSortingStrategy}
|
||||||
))}
|
>
|
||||||
|
<div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
|
||||||
{/* Add form slot */}
|
{userItems.map((item) => (
|
||||||
{canAdd && (
|
<SortableGifMoodCard
|
||||||
<GifMoodAddForm sessionId={sessionId} currentCount={userItems.length} />
|
key={item.id}
|
||||||
)}
|
sessionId={sessionId}
|
||||||
|
item={item}
|
||||||
{/* Empty state */}
|
currentUserId={currentUserId}
|
||||||
{!canAdd && userItems.length === 0 && (
|
canEdit={canEdit}
|
||||||
<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>
|
{canAdd && (
|
||||||
)}
|
<GifMoodAddForm sessionId={sessionId} currentCount={userItems.length} />
|
||||||
</div>
|
)}
|
||||||
|
{!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>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user