/**
* 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 */}
{/* 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 (
{entry.category.emoji}
{entry.category.name.replace(/^(La |Le |Les |L')/, "")}
{entry.level}
{entry.time}
{entry.topic}
{/* Background */}
{/* Header */}
{/* ── CONTROLS ── */}
{appState === "idle" && (
{/* Category dropdown */}
{dropdownOpen && (
)}
{/* Level pills */}
🎰 Mode machine à sous activé — une seule catégorie
)}
)}
{/* ── WHEEL ── */}
{isWheelMode && (appState === "idle" || appState === "spinning") && (
{appState === "spinning" ? "⏳ En cours…" : "Tourner la roue !"}
)}
{/* ── SLOT — IDLE ── */}
{isSingleMode && appState === "idle" && activeCats[0] && (
🎰 Générer un sujet !
)}
{/* ── SLOT — SPINNING ── */}
{appState === "slot-spinning" && (
{slotEmoji}
{slotLabel}
)}
{/* ── LOADING ── */}
{appState === "loading" && resultCategory && (
)}
{/* ── RESULT ── */}
{appState === "result" && resultCategory && (
"{topic}"
✨ Nouveau sujet IA
{isSingleMode ? "🎰 Rejouer" : "🎡 Retourner la roue"}
)}
{/* ════════════════════════════════════════════════════════════════
── HISTORIQUE DE SESSION ──────────────────────────────────────
════════════════════════════════════════════════════════════════ */}
{history.length > 0 && (
{/* Accordion toggle */}
{/* Accordion content */}
{historyOpen && (
)}
)}
{/* Footer */}
Propulsé par Gemini AI · Pour les apprenants de français 🇫🇷
);
}
🎡 La Roue du Débat
Génère ton sujet et prends la parole !
{ALL_CATEGORIES.map((cat) => {
const checked = selectedCats.includes(cat.name);
return (
);
})}
📊 Niveau :
{LEVELS.map((l) => (
))}
{selectedCats.length === 0 && (
⚠️ Sélectionne au moins une catégorie pour commencer !
)}
{isSingleMode && (
setDropdownOpen(false)}
>
{activeCats[0].emoji}
{activeCats[0].name}
Niveau {level}
{resultCategory.emoji} {resultCategory.name}
Gemini génère ton sujet niveau {level}…
{resultCategory.emoji}
{geminiError && (
hors-ligne
)}
Catégorie · Niveau {level}
{resultCategory.name}
{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(", ")}

