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,126 @@
'use client';
import { useState, useTransition } from 'react';
import { Button, Input } from '@/components/ui';
import { addGifMoodItem } from '@/actions/gif-mood';
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
interface GifMoodAddFormProps {
sessionId: string;
currentCount: number;
}
export function GifMoodAddForm({ sessionId, currentCount }: GifMoodAddFormProps) {
const [open, setOpen] = useState(false);
const [gifUrl, setGifUrl] = useState('');
const [note, setNote] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const remaining = GIF_MOOD_MAX_ITEMS - currentCount;
function handleUrlBlur() {
const trimmed = gifUrl.trim();
if (!trimmed) { setPreviewUrl(''); return; }
try { new URL(trimmed); setPreviewUrl(trimmed); setError(null); }
catch { setPreviewUrl(''); }
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const trimmed = gifUrl.trim();
if (!trimmed) { setError("L'URL est requise"); return; }
try { new URL(trimmed); } catch { setError('URL invalide'); return; }
startTransition(async () => {
const result = await addGifMoodItem(sessionId, {
gifUrl: trimmed,
note: note.trim() || undefined,
});
if (result.success) {
setGifUrl(''); setNote(''); setPreviewUrl(''); setOpen(false);
} else {
setError(result.error || "Erreur lors de l'ajout");
}
});
}
// Collapsed state — placeholder card
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/50 hover:border-primary/40 hover:bg-primary/5 min-h-[120px] transition-all duration-200 text-muted hover:text-primary w-full"
>
<span className="text-2xl opacity-40 group-hover:opacity-70 transition-opacity"></span>
<span className="text-xs font-medium">{remaining} slot{remaining !== 1 ? 's' : ''} restant{remaining !== 1 ? 's' : ''}</span>
</button>
);
}
// Expanded form
return (
<form
onSubmit={handleSubmit}
className="rounded-2xl border border-border bg-card shadow-sm p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">Ajouter un GIF</span>
<button
type="button"
onClick={() => { setOpen(false); setError(null); setPreviewUrl(''); }}
className="text-muted hover:text-foreground transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<Input
label="URL du GIF"
value={gifUrl}
onChange={(e) => setGifUrl(e.target.value)}
onBlur={handleUrlBlur}
placeholder="https://media.giphy.com/…"
disabled={isPending}
/>
{previewUrl && (
<div className="rounded-xl overflow-hidden border border-border/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt="Aperçu"
className="w-full object-contain max-h-40"
onError={() => setPreviewUrl('')}
/>
</div>
)}
<div>
<label className="block text-xs font-medium text-muted mb-1">
Note <span className="font-normal opacity-60">(optionnelle)</span>
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Ce que ce GIF exprime…"
rows={2}
disabled={isPending}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"
/>
</div>
<Button type="submit" loading={isPending} className="w-full" size="sm">
Ajouter
</Button>
</form>
);
}