feat: add GIF Mood Board workshop
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m5s
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:
126
src/components/gif-mood/GifMoodAddForm.tsx
Normal file
126
src/components/gif-mood/GifMoodAddForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user