All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState, useTransition } from 'react';
|
|
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, setGifMoodUserHidden } from '@/actions/gif-mood';
|
|
import { Avatar } from '@/components/ui/Avatar';
|
|
import { GifMoodCard } from './GifMoodCard';
|
|
import { GifMoodAddForm } from './GifMoodAddForm';
|
|
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
|
|
|
|
interface GifMoodItem {
|
|
id: string;
|
|
gifUrl: string;
|
|
note: string | null;
|
|
order: number;
|
|
userId: string;
|
|
user: {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
};
|
|
}
|
|
|
|
interface Share {
|
|
id: string;
|
|
userId: string;
|
|
user: {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
};
|
|
}
|
|
|
|
interface GifMoodBoardProps {
|
|
sessionId: string;
|
|
currentUserId: string;
|
|
items: GifMoodItem[];
|
|
shares: Share[];
|
|
owner: {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
};
|
|
ratings: { userId: string; rating: number | null; hidden: boolean }[];
|
|
canEdit: boolean;
|
|
}
|
|
|
|
function WeekRating({
|
|
sessionId,
|
|
isCurrentUser,
|
|
canEdit,
|
|
initialRating,
|
|
}: {
|
|
sessionId: string;
|
|
isCurrentUser: boolean;
|
|
canEdit: boolean;
|
|
initialRating: number | null;
|
|
}) {
|
|
const [prevInitialRating, setPrevInitialRating] = useState(initialRating);
|
|
const [rating, setRating] = useState<number | null>(initialRating);
|
|
const [hovered, setHovered] = useState<number | null>(null);
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
if (prevInitialRating !== initialRating) {
|
|
setPrevInitialRating(initialRating);
|
|
setRating(initialRating);
|
|
}
|
|
|
|
const interactive = isCurrentUser && canEdit;
|
|
const display = hovered ?? rating;
|
|
|
|
function handleClick(n: number) {
|
|
if (!interactive) return;
|
|
setRating(n);
|
|
startTransition(async () => {
|
|
await setGifMoodUserRating(sessionId, n);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex items-center gap-0.5"
|
|
onMouseLeave={() => setHovered(null)}
|
|
>
|
|
{[1, 2, 3, 4, 5].map((n) => {
|
|
const filled = display !== null && n <= display;
|
|
return (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
onClick={() => handleClick(n)}
|
|
onMouseEnter={() => interactive && setHovered(n)}
|
|
disabled={!interactive || isPending}
|
|
className={`transition-all duration-100 ${interactive ? 'cursor-pointer hover:scale-125' : 'cursor-default'}`}
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
className={`transition-colors duration-100 ${
|
|
filled ? 'text-amber-400' : 'text-border'
|
|
}`}
|
|
>
|
|
<path
|
|
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
|
fill="currentColor"
|
|
stroke="currentColor"
|
|
strokeWidth="1"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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'];
|
|
|
|
const GRID_COLS: Record<number, string> = {
|
|
4: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
|
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5',
|
|
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6',
|
|
};
|
|
|
|
function GridIcon({ cols }: { cols: number }) {
|
|
return (
|
|
<svg width="20" height="14" viewBox="0 0 20 14" fill="none" aria-hidden>
|
|
{Array.from({ length: cols }).map((_, i) => {
|
|
const w = (20 - (cols - 1) * 2) / cols;
|
|
const x = i * (w + 2);
|
|
return (
|
|
<g key={i}>
|
|
<rect x={x} y={0} width={w} height={6} rx={1} fill="currentColor" opacity={0.7} />
|
|
<rect x={x} y={8} width={w} height={6} rx={1} fill="currentColor" opacity={0.4} />
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function GifMoodBoard({
|
|
sessionId,
|
|
currentUserId,
|
|
items,
|
|
shares,
|
|
owner,
|
|
ratings,
|
|
canEdit,
|
|
}: GifMoodBoardProps) {
|
|
const [cols, setCols] = useState(4);
|
|
const [, startReorderTransition] = useTransition();
|
|
const [, startHiddenTransition] = 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 }>();
|
|
map.set(owner.id, owner);
|
|
shares.forEach((s) => map.set(s.userId, s.user));
|
|
return Array.from(map.values());
|
|
}, [owner, shares]);
|
|
|
|
const itemsByUser = useMemo(() => {
|
|
const map = new Map<string, GifMoodItem[]>();
|
|
items.forEach((item) => {
|
|
const existing = map.get(item.userId) ?? [];
|
|
existing.push(item);
|
|
map.set(item.userId, existing);
|
|
});
|
|
return map;
|
|
}, [items]);
|
|
|
|
const sortedUsers = useMemo(() => {
|
|
return [...allUsers].sort((a, b) => {
|
|
if (a.id === currentUserId) return -1;
|
|
if (b.id === currentUserId) return 1;
|
|
if (a.id === owner.id) return -1;
|
|
if (b.id === owner.id) return 1;
|
|
return (a.name || a.email).localeCompare(b.name || b.email, 'fr');
|
|
});
|
|
}, [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 */}
|
|
<div className="flex justify-end">
|
|
<div className="inline-flex items-center gap-1 rounded-xl border border-border bg-card p-1">
|
|
{[4, 5, 6].map((n) => (
|
|
<button
|
|
key={n}
|
|
onClick={() => setCols(n)}
|
|
className={`flex items-center justify-center rounded-lg px-2.5 py-1.5 transition-all ${
|
|
cols === n
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
: 'text-muted hover:text-foreground hover:bg-card-hover'
|
|
}`}
|
|
title={`${n} colonnes`}
|
|
>
|
|
<GridIcon cols={n} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{sortedUsers.map((user, index) => {
|
|
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 userRatingEntry = ratings.find((r) => r.userId === user.id);
|
|
const userRating = userRatingEntry?.rating ?? null;
|
|
const isHidden = userRatingEntry?.hidden ?? false;
|
|
const showHidden = !isCurrentUser && isHidden;
|
|
|
|
// Skip participants who haven't added any GIFs yet (except current user)
|
|
if (!isCurrentUser && userItems.length === 0) return null;
|
|
|
|
return (
|
|
<section key={user.id}>
|
|
{/* Section header */}
|
|
<div className="flex items-center gap-4 mb-5">
|
|
{/* Colored accent bar */}
|
|
<div className="w-1 h-8 rounded-full shrink-0" style={{ backgroundColor: accentColor }} />
|
|
|
|
<Avatar email={user.email} name={user.name} size={36} />
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-semibold text-foreground truncate">
|
|
{user.name || user.email}
|
|
</span>
|
|
{isCurrentUser && (
|
|
<span className="text-xs text-muted bg-card-hover px-2 py-0.5 rounded-full border border-border">
|
|
vous
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-0.5">
|
|
<p className="text-xs text-muted">
|
|
{userItems.length} / {GIF_MOOD_MAX_ITEMS} GIF{userItems.length !== 1 ? 's' : ''}
|
|
</p>
|
|
<WeekRating
|
|
sessionId={sessionId}
|
|
isCurrentUser={isCurrentUser}
|
|
canEdit={canEdit}
|
|
initialRating={userRating}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hide/reveal toggle — current user only */}
|
|
{isCurrentUser && canEdit && userItems.length > 0 && (
|
|
<button
|
|
onClick={() => {
|
|
startHiddenTransition(async () => {
|
|
await setGifMoodUserHidden(sessionId, !isHidden);
|
|
});
|
|
}}
|
|
className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border transition-all ${
|
|
isHidden
|
|
? 'border-primary bg-primary/10 text-primary'
|
|
: 'border-border text-muted hover:text-foreground hover:border-foreground/30'
|
|
}`}
|
|
title={isHidden ? 'Révéler mes GIFs' : 'Cacher mes GIFs'}
|
|
>
|
|
{isHidden ? (
|
|
<>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
<circle cx="12" cy="12" r="3"/>
|
|
</svg>
|
|
Révéler
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
|
|
<line x1="1" y1="1" x2="23" y2="23"/>
|
|
</svg>
|
|
Cacher
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Hidden placeholder for other users */}
|
|
{showHidden ? (
|
|
<div className="flex items-center justify-center rounded-2xl border border-dashed border-border/60 py-10 gap-3">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted/50" aria-hidden>
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
</svg>
|
|
<p className="text-sm text-muted/60">
|
|
{userItems.length > 0
|
|
? `${userItems.length} GIF${userItems.length !== 1 ? 's' : ''} caché${userItems.length !== 1 ? 's' : ''}`
|
|
: 'GIFs cachés'}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Grid */}
|
|
{!showHidden && 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>
|
|
) : !showHidden ? (
|
|
<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>
|
|
) : null}
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|