/** * La Roue du Débat — v3 * Ajout : Historique de session * - Chaque sujet généré est sauvegardé automatiquement * - Panneau accordéon en bas de page (badge compteur) * - Horodatage, catégorie colorée, niveau, copie presse-papiers * - Bouton "Effacer l'historique" */ import { useEffect, useRef, useState, useCallback } from "react"; import { motion, AnimatePresence } from "motion/react"; import { GoogleGenAI } from "@google/genai"; // ─── Types ──────────────────────────────────────────────────────────────────── interface Category { name: string; emoji: string; color: string; } interface HistoryEntry { id: number; category: Category; topic: string; level: string; time: string; // "14:32" } type AppState = "idle" | "spinning" | "slot-spinning" | "loading" | "result"; // ─── Data ───────────────────────────────────────────────────────────────────── const ALL_CATEGORIES: Category[] = [ { name: "La technologie", emoji: "💻", color: "#FF595E" }, { name: "Le sport", emoji: "⚽", color: "#FF924C" }, { name: "Le temps libre", emoji: "🎮", color: "#FFCA3A" }, { name: "La mode", emoji: "👗", color: "#C77DFF" }, { name: "La beauté", emoji: "💄", color: "#FF70A6" }, { name: "L'écologie", emoji: "🌿", color: "#8AC926" }, { name: "Le travail", emoji: "💼", color: "#6A4C93" }, { name: "Les livres", emoji: "📚", color: "#1982C4" }, { name: "Les études", emoji: "🎓", color: "#4361EE" }, { name: "La nourriture", emoji: "🍕", color: "#FB5607" }, { name: "La santé", emoji: "❤️", color: "#E63946" }, { name: "La culture française", emoji: "🇫🇷", color: "#457B9D" }, { name: "Les relations humaines", emoji: "🤝", color: "#E9A010" }, { name: "L'art", emoji: "🎨", color: "#E76F51" }, { name: "La ville & la campagne", emoji: "🏡", color: "#2A9D8F" }, { name: "Les films", emoji: "🎬", color: "#264653" }, { name: "La musique", emoji: "🎵", color: "#7B2D8B" }, { name: "Les réseaux sociaux", emoji: "📱", color: "#00B4D8" }, { name: "Le logement", emoji: "🏠", color: "#52B788" }, { name: "Le shopping", emoji: "🛍️", color: "#F4A261" }, { name: "Les clichés sur la France", emoji: "🥐", color: "#D4A017" }, { name: "Les émotions", emoji: "💭", color: "#9B5DE5" }, { name: "Le caractère", emoji: "🧠", color: "#F15BB5" }, { name: "Les animaux", emoji: "🐾", color: "#06A77D" }, { name: "Les voyages", emoji: "✈️", color: "#F72585" }, { name: "Le développement perso", emoji: "🌱", color: "#06D6A0" }, { name: "La météo", emoji: "☀️", color: "#F5A623" }, { name: "La routine", emoji: "📅", color: "#118AB2" }, { name: "Surprise !", emoji: "✨", color: "#B5179E" }, ]; const LEVELS = ["A1", "A2", "B1", "B2", "C1+"]; const LEVEL_HINTS: Record = { "A1": "vocabulaire très simple, phrases courtes, présent uniquement", "A2": "phrases simples, quotidien, un peu de passé composé", "B1": "phrases élaborées, opinions personnelles, connecteurs simples", "B2": "nuances, argumentation, subjonctif possible", "C1+": "registres variés, ironie possible, concepts abstraits, débat complexe", }; // ─── Gemini ─────────────────────────────────────────────────────────────────── async function generateTopic(categoryName: string, level: string): Promise { const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) throw new Error("No API key"); const ai = new GoogleGenAI({ apiKey }); const prompt = `Tu es un prof de français sympa et créatif qui parle à ses étudiants de manière décontractée. Génère UNE SEULE question de discussion pour un étudiant slovaque qui apprend le français. Catégorie : "${categoryName}" Niveau CECRL : ${level} (${LEVEL_HINTS[level]}) Règles OBLIGATOIRES : - Tutoie l'étudiant : utilise "tu", jamais "vous" - Maximum 20 mots, question courte et percutante - Ton naturel, authentique — comme entre amis, pas dans un manuel - Varie le type : opinion, débat, imaginaire, réflexion personnelle, anecdote - Ne commence JAMAIS par "Quelle est votre..." ou "Pensez-vous que..." - Adapte le vocabulaire et la complexité au niveau ${level} Réponds UNIQUEMENT avec la question, sans guillemets ni ponctuation finale, sans aucune explication.`; const response = await ai.models.generateContent({ model: "gemini-2.0-flash", contents: prompt, }); const text = response.text?.trim(); if (!text) throw new Error("Empty response"); return text; } // ─── Canvas Wheel ───────────────────────────────────────────────────────────── function drawWheel( ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, angleOffset: number, cats: Category[] ) { ctx.clearRect(0, 0, canvas.width, canvas.height); const cx = canvas.width / 2; const cy = canvas.height / 2; const radius = Math.min(cx, cy) - 8; const n = cats.length; const arc = (Math.PI * 2) / n; const fontSize = n > 8 ? 12 : n > 5 ? 14 : 17; for (let i = 0; i < n; i++) { const start = angleOffset + i * arc; const end = start + arc; const mid = start + arc / 2; ctx.beginPath(); ctx.fillStyle = cats[i].color; ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, start, end, false); ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = "#ffffff"; ctx.stroke(); ctx.save(); ctx.translate( cx + Math.cos(mid) * radius * 0.63, cy + Math.sin(mid) * radius * 0.63 ); ctx.rotate(mid + Math.PI / 2); ctx.fillStyle = "white"; ctx.shadowColor = "rgba(0,0,0,0.4)"; ctx.shadowBlur = 4; ctx.textAlign = "center"; ctx.textBaseline = "middle"; if (n <= 10) { ctx.font = `${fontSize + 6}px Arial`; ctx.fillText(cats[i].emoji, 0, -(fontSize + 4)); } ctx.font = `bold ${fontSize}px Nunito, sans-serif`; ctx.shadowBlur = 3; const shortName = cats[i].name.replace(/^(La |Le |Les |L')/, ""); const words = shortName.split(" "); const lineH = fontSize + 3; const yStart = n <= 10 ? 2 : -(((words.length - 1) * lineH) / 2); if (words.length <= 2 || n > 12) { ctx.fillText(shortName.substring(0, 14), 0, yStart); } else { ctx.fillText(words.slice(0, 2).join(" "), 0, yStart); ctx.fillText(words.slice(2).join(" "), 0, yStart + lineH); } ctx.restore(); } const hub = radius * 0.12; const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, hub); g.addColorStop(0, "#ffffff"); g.addColorStop(1, "#e0e0e0"); ctx.beginPath(); ctx.fillStyle = g; ctx.arc(cx, cy, hub, 0, Math.PI * 2); ctx.fill(); ctx.lineWidth = 2.5; ctx.strokeStyle = "#2b2d42"; ctx.stroke(); } // ─── History Entry Component ────────────────────────────────────────────────── function HistoryItem({ entry, index, }: { entry: HistoryEntry; index: number; }) { const [copied, setCopied] = useState(false); const copy = () => { navigator.clipboard.writeText(entry.topic).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1800); }); }; return ( {/* Color bar + number */}
{/* Content */}
{entry.category.emoji} {entry.category.name.replace(/^(La |Le |Les |L')/, "")} {entry.level} {entry.time}

{entry.topic}

{/* Copy button */} ); } // ─── App ────────────────────────────────────────────────────────────────────── export default function App() { const canvasRef = useRef(null); const angleRef = useRef(0); const animRef = useRef(0); const historyIdRef = useRef(0); const [appState, setAppState] = useState("idle"); const [selectedCats, setSelectedCats] = useState( ALL_CATEGORIES.slice(0, 8).map((c) => c.name) ); const [level, setLevel] = useState("B1"); const [topic, setTopic] = useState(""); const [resultCategory, setResultCategory] = useState(null); const [geminiError, setGeminiError] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const [slotEmoji, setSlotEmoji] = useState("✨"); const [slotLabel, setSlotLabel] = useState("..."); // ── History state ────────────────────────────────────────────────────────── const [history, setHistory] = useState([]); const [historyOpen, setHistoryOpen] = useState(false); const addToHistory = useCallback( (category: Category, generatedTopic: string, currentLevel: string) => { const now = new Date(); const time = now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", }); setHistory((prev) => [ { id: ++historyIdRef.current, category, topic: generatedTopic, level: currentLevel, time, }, ...prev, // newest first ]); }, [] ); const clearHistory = () => { setHistory([]); setHistoryOpen(false); }; // ─── Canvas ──────────────────────────────────────────────────────────────── const activeCats = ALL_CATEGORIES.filter((c) => selectedCats.includes(c.name)); const isSingleMode = activeCats.length === 1; const isWheelMode = activeCats.length >= 2; const redraw = useCallback( (angle: number) => { const canvas = canvasRef.current; if (!canvas || activeCats.length < 2) return; const ctx = canvas.getContext("2d"); if (!ctx) return; drawWheel(ctx, canvas, angle, activeCats); }, [activeCats] ); useEffect(() => { if (isWheelMode && appState === "idle") redraw(angleRef.current); }, [activeCats, redraw, isWheelMode, appState]); // ─── Slot spin ───────────────────────────────────────────────────────────── const spinSlot = useCallback(() => { const cat = activeCats[0]; let count = 0; const maxCount = 22; const interval = setInterval(() => { const rand = ALL_CATEGORIES[Math.floor(Math.random() * ALL_CATEGORIES.length)]; setSlotEmoji(rand.emoji); setSlotLabel(rand.name.replace(/^(La |Le |Les |L')/, "")); count++; if (count >= maxCount) { clearInterval(interval); setSlotEmoji(cat.emoji); setSlotLabel(cat.name); setResultCategory(cat); setAppState("loading"); generateTopic(cat.name, level) .then((t) => { setTopic(t); setGeminiError(false); addToHistory(cat, t, level); // ← historique setAppState("result"); }) .catch(() => { const fallback = "C'est quoi ta passion en ce moment ?"; setTopic(fallback); setGeminiError(true); addToHistory(cat, fallback, level); setAppState("result"); }); } }, 75); }, [activeCats, level, addToHistory]); // ─── Wheel spin ──────────────────────────────────────────────────────────── const spinWheel = useCallback(() => { const spinDur = 3500 + Math.random() * 1500; const totalRot = (Math.random() * 8 + 10) * Math.PI; const startAngle = angleRef.current; const startTime = performance.now(); const arc = (Math.PI * 2) / activeCats.length; const animate = (now: number) => { const elapsed = now - startTime; if (elapsed >= spinDur) { const final = startAngle + totalRot; angleRef.current = final; redraw(final); const normalized = final % (Math.PI * 2); const topAngle = (1.5 * Math.PI - normalized + Math.PI * 2) % (Math.PI * 2); const idx = Math.floor(topAngle / arc) % activeCats.length; const cat = activeCats[idx]; setResultCategory(cat); setAppState("loading"); generateTopic(cat.name, level) .then((t) => { setTopic(t); setGeminiError(false); addToHistory(cat, t, level); // ← historique setAppState("result"); }) .catch(() => { const fallback = "C'est quoi ta passion en ce moment ?"; setTopic(fallback); setGeminiError(true); addToHistory(cat, fallback, level); setAppState("result"); }); return; } const t = elapsed / spinDur; const ease = 1 - Math.pow(1 - t, 4); angleRef.current = startAngle + totalRot * ease; redraw(angleRef.current); animRef.current = requestAnimationFrame(animate); }; animRef.current = requestAnimationFrame(animate); }, [activeCats, level, redraw, addToHistory]); // ─── Main spin handler ───────────────────────────────────────────────────── const spin = useCallback(() => { if (appState !== "idle" || selectedCats.length === 0) return; setGeminiError(false); setDropdownOpen(false); if (isSingleMode) { setAppState("slot-spinning"); spinSlot(); } else { setAppState("spinning"); spinWheel(); } }, [appState, selectedCats, isSingleMode, spinSlot, spinWheel]); // ─── Regenerate ──────────────────────────────────────────────────────────── const regenerate = useCallback(async () => { if (!resultCategory) return; setAppState("loading"); setGeminiError(false); try { const t = await generateTopic(resultCategory.name, level); setTopic(t); addToHistory(resultCategory, t, level); // ← historique } catch { const fallback = "C'est quoi ta passion en ce moment ?"; setTopic(fallback); setGeminiError(true); addToHistory(resultCategory, fallback, level); } setAppState("result"); }, [resultCategory, level, addToHistory]); // ─── Reset ───────────────────────────────────────────────────────────────── const reset = useCallback(() => { cancelAnimationFrame(animRef.current); setAppState("idle"); setResultCategory(null); setTopic(""); setGeminiError(false); if (isSingleMode && activeCats[0]) { setSlotEmoji(activeCats[0].emoji); setSlotLabel(activeCats[0].name); } setTimeout(() => redraw(angleRef.current), 50); }, [isSingleMode, activeCats, redraw]); // ─── Category helpers ────────────────────────────────────────────────────── const toggleCat = (name: string) => setSelectedCats((prev) => prev.includes(name) ? prev.filter((c) => c !== name) : [...prev, name] ); const selectAll = () => setSelectedCats(ALL_CATEGORIES.map((c) => c.name)); const clearAll = () => setSelectedCats([]); // ─── Render ──────────────────────────────────────────────────────────────── return (
{/* Background */}
{/* Header */}

🎡 La Roue du Débat

Génère ton sujet et prends la parole !

{/* ── CONTROLS ── */} {appState === "idle" && ( {/* Category dropdown */}
{dropdownOpen && (
{ALL_CATEGORIES.map((cat) => { const checked = selectedCats.includes(cat.name); return ( ); })}
)}
{/* Level pills */}
📊 Niveau : {LEVELS.map((l) => ( ))}
{selectedCats.length === 0 && (
⚠️ Sélectionne au moins une catégorie pour commencer !
)} {isSingleMode && ( 🎰 Mode machine à sous activé — une seule catégorie )}
)}
{/* ── WHEEL ── */} {isWheelMode && (appState === "idle" || appState === "spinning") && (
setDropdownOpen(false)} >
{appState === "spinning" ? "⏳ En cours…" : "Tourner la roue !"} )} {/* ── SLOT — IDLE ── */} {isSingleMode && appState === "idle" && activeCats[0] && (
{activeCats[0].emoji}
{activeCats[0].name}
Niveau {level}
🎰 Générer un sujet !
)}
{/* ── SLOT — SPINNING ── */} {appState === "slot-spinning" && (
{slotEmoji} {slotLabel}
)}
{/* ── LOADING ── */} {appState === "loading" && resultCategory && (
{resultCategory.emoji} {resultCategory.name}

Gemini génère ton sujet niveau {level}…

)}
{/* ── RESULT ── */} {appState === "result" && resultCategory && (
{resultCategory.emoji}

Catégorie · Niveau {level}

{resultCategory.name}

{geminiError && ( hors-ligne )}
"{topic}"
✨ Nouveau sujet IA {isSingleMode ? "🎰 Rejouer" : "🎡 Retourner la roue"}
)}
{/* ════════════════════════════════════════════════════════════════ ── HISTORIQUE DE SESSION ────────────────────────────────────── ════════════════════════════════════════════════════════════════ */} {history.length > 0 && ( {/* Accordion toggle */} )}
{/* Accordion content */} {historyOpen && (
{history.map((entry, i) => ( ))}
{/* Summary stats */}
📊 Session : {history.length} sujet{history.length > 1 ? "s" : ""} généré {history.length > 1 ? "s" : ""} {[...new Set(history.map((h) => h.category.name))].length} catégorie {[...new Set(history.map((h) => h.category.name))].length > 1 ? "s" : ""} Niveaux :{" "} {[...new Set(history.map((h) => h.level))].join(", ")}
)}
)}
{/* Footer */} Propulsé par Gemini AI · Pour les apprenants de français 🇫🇷
); }