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>
127 lines
4.3 KiB
TypeScript
127 lines
4.3 KiB
TypeScript
'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>
|
||
);
|
||
}
|