feat: add GIF Mood Board workshop
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m5s

- New workshop where each team member shares up to 5 GIFs with notes to express their weekly mood
- Per-user week rating (1-5 stars) visible next to each member's section
- Masonry-style grid with adjustable column count (3/4/5) toggle
- Handwriting font (Caveat) for GIF notes
- Full real-time collaboration via SSE
- Clean migration (add_gif_mood_workshop) safe for production deploy
- DB backup via cp before each migration in docker-entrypoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 10:04:56 +01:00
parent 7c68fb81e3
commit 766f3d5a59
21 changed files with 2032 additions and 15 deletions

View File

@@ -0,0 +1,273 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { setGifMoodUserRating } 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 }[];
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>
);
}
// Subtle accent colors for each user section
const SECTION_COLORS = ['#ec4899', '#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
const GRID_COLS: Record<number, string> = {
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
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',
};
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(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]);
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">
{[3, 4, 5].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 userItems = itemsByUser.get(user.id) ?? [];
const isCurrentUser = user.id === currentUserId;
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;
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>
</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>
</section>
);
})}
</div>
);
}