Sauvegarde de l'état (réparation)

This commit is contained in:
Reviflow Bot committed 2026-01-31 11:00:30 +01:00
commit 7c04fffe77
434 files changed
+83923

No files matched your search

+1
View File
@@ -0,0 +1 @@
/* App.css is deprecated. Use Tailwind classes in components. */
+164
View File
@@ -0,0 +1,164 @@
import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './stores/useAuth';
import { LoginPage } from './features/auth/LoginPage';
import { RegisterPage } from './features/auth/RegisterPage';
import { SettingsPage } from './features/settings/SettingsPage';
import { NewRevisionPage } from './features/ingest/NewRevisionPage';
import { AnalyzePage } from './features/ingest/AnalyzePage';
import { ReviewPage } from './features/ingest/ReviewPage';
import { QuizPage } from './features/quiz/QuizPage';
import { RevisionViewPage } from './features/quiz/RevisionViewPage';
import DashboardPage from './pages/DashboardPage';
import { ParentalGate } from './features/auth/ParentalGate';
import { ProfileSelectionPage } from './features/auth/ProfileSelectionPage';
import { ParentHubPage } from './pages/ParentHubPage';
import { HistoryPage } from './features/quiz/HistoryPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, checkAuth } = useAuth();
useEffect(() => {
checkAuth();
}, []);
if (isLoading) return (
<div className="flex justify-center items-center h-screen bg-[#FDFCF8]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
if (!isAuthenticated) return <Navigate to="/login" />;
return <>{children}</>;
}
function MainRedirect() {
const { user, activeLearner } = useAuth();
// 1. If parent and no learner selected -> Parent Hub
if (user?.role === 'parent' && !activeLearner) {
return <Navigate to="/parent-hub" replace />;
}
// 2. If learner (auto-active) or parent with selection -> Dashboard
return <Navigate to="/dashboard" replace />;
}
function LearnerProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, activeLearner, isLoading } = useAuth();
if (isLoading) return null; // Or spinner
if (!isAuthenticated) return <Navigate to="/login" />;
if (!activeLearner) return <Navigate to="/select-profile" />;
return <>{children}</>;
}
function ParentalProtectedRoute({ children }: { children: React.ReactNode }) {
const { isParentalModeActive } = useAuth();
const [showGate, setShowGate] = React.useState(!isParentalModeActive);
if (isParentalModeActive) return <>{children}</>;
if (showGate) {
return (
<ParentalGate
onSuccess={() => setShowGate(false)}
onCancel={() => window.history.back()}
/>
);
}
return <>{children}</>;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/select-profile" element={
<ProtectedRoute>
<ProfileSelectionPage />
</ProtectedRoute>
} />
<Route path="/dashboard" element={
<ProtectedRoute>
<LearnerProtectedRoute>
<DashboardPage />
</LearnerProtectedRoute>
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute>
<ParentalProtectedRoute>
<SettingsPage />
</ParentalProtectedRoute>
</ProtectedRoute>
} />
<Route path="/new" element={
<ProtectedRoute>
<LearnerProtectedRoute>
<NewRevisionPage />
</LearnerProtectedRoute>
</ProtectedRoute>
} />
<Route path="/analyze" element={
<ProtectedRoute>
<LearnerProtectedRoute>
<AnalyzePage />
</LearnerProtectedRoute>
</ProtectedRoute>
} />
<Route path="/review" element={
<ProtectedRoute>
<LearnerProtectedRoute>
<ReviewPage />
</LearnerProtectedRoute>
</ProtectedRoute>
} />
<Route path="/quiz" element={
<ProtectedRoute>
<LearnerProtectedRoute>
<QuizPage />
</LearnerProtectedRoute>
</ProtectedRoute>
} />
<Route path="/revision/:id" element={
<ProtectedRoute>
<LearnerProtectedRoute>
<RevisionViewPage />
</LearnerProtectedRoute>
</ProtectedRoute>
} />
<Route path="/play/:revisionId" element={
<ProtectedRoute>
<LearnerProtectedRoute>
<QuizPage />
</LearnerProtectedRoute>
</ProtectedRoute>
} />
<Route path="/parent-hub" element={
<ProtectedRoute>
<ParentalProtectedRoute>
<ParentHubPage />
</ParentalProtectedRoute>
</ProtectedRoute>
} />
<Route path="/history" element={
<ProtectedRoute>
<LearnerProtectedRoute>
<HistoryPage />
</LearnerProtectedRoute>
</ProtectedRoute>
} />
<Route path="/" element={<MainRedirect />} />
</Routes>
</BrowserRouter>
);
}
export default App;
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,19 @@
import { useAccessibility } from '../stores/useAccessibility';
export function DyslexiaToggle() {
const { isDyslexic, toggleDyslexic } = useAccessibility();
return (
<button
onClick={toggleDyslexic}
className={`flex items-center px-3 py-2 border rounded-md text-sm font-medium transition-colors ${isDyslexic
? 'bg-indigo-100 text-indigo-700 border-indigo-200'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
title="Activer la police adaptée à la dyslexie"
>
<span className="mr-2 text-lg">Aa</span>
{isDyslexic ? 'Mode Dyslexie ON' : 'Mode Dyslexie'}
</button>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { Fragment, type ReactNode } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { useAccessibility } from '../../stores/useAccessibility';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
icon?: ReactNode;
}
export function Modal({ isOpen, onClose, title, children, icon }: ModalProps) {
const { isDyslexic } = useAccessibility();
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-indigo-900/40 backdrop-blur-sm" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className={`w-full max-w-md transform overflow-hidden rounded-[2.5rem] bg-white p-6 text-left align-middle shadow-xl transition-all border-4 border-indigo-50 ${isDyslexic ? 'font-dyslexic' : 'font-sans'}`}>
<div className="flex flex-col items-center justify-center text-center">
{icon && (
<div className="mb-4 text-5xl animate-bounce">
{icon}
</div>
)}
{title && (
<Dialog.Title
as="h3"
className="text-2xl font-black leading-6 text-gray-900 mb-2"
>
{title}
</Dialog.Title>
)}
<div className="mt-2 w-full">
{children}
</div>
<div className="mt-8">
<button
type="button"
className="inline-flex justify-center rounded-2xl border border-transparent bg-indigo-600 px-8 py-3 text-lg font-bold text-white hover:bg-indigo-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 active:scale-95 transition-all shadow-lg shadow-indigo-100"
onClick={onClose}
>
Génial !
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "../../lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-gray-200/80", className)}
{...props}
/>
);
}
export { Skeleton };
@@ -0,0 +1,46 @@
import { Check } from 'lucide-react';
const AVATAR_SEEDS = [
'Felix', 'Aneka', 'Zoe', 'Marc', 'Trouble', 'Coco', 'Sasha', 'Jack'
];
interface AvatarSelectorProps {
selectedAvatar: string;
onSelect: (url: string) => void;
}
export function AvatarSelector({ selectedAvatar, onSelect }: AvatarSelectorProps) {
// We use DiceBear "Fun Emoji" style for kid-friendly avatars
const getAvatarUrl = (seed: string) => `https://api.dicebear.com/7.x/fun-emoji/svg?seed=${seed}`;
return (
<div className="grid grid-cols-4 gap-4 p-4">
{AVATAR_SEEDS.map((seed) => {
const url = getAvatarUrl(seed);
const isSelected = selectedAvatar === url;
return (
<button
key={seed}
type="button"
onClick={() => onSelect(url)}
className={`relative group rounded-full p-1 transition-all duration-300 ${isSelected ? 'ring-4 ring-indigo-500 scale-110' : 'hover:scale-105 hover:ring-2 hover:ring-indigo-200'}`}
>
<div className="w-16 h-16 rounded-full overflow-hidden bg-indigo-50 border-2 border-white shadow-sm">
<img
src={url}
alt={seed}
className="w-full h-full object-cover"
/>
</div>
{isSelected && (
<div className="absolute -top-1 -right-1 bg-indigo-600 text-white rounded-full p-1 shadow-md animate-in zoom-in">
<Check className="w-3 h-3" />
</div>
)}
</button>
);
})}
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useAuth } from '../../stores/useAuth';
import { useNavigate, Link } from 'react-router-dom';
import { useState } from 'react';
import { Eye, EyeOff, Loader2, Lock, Mail } from 'lucide-react';
const loginSchema = z.object({
identifier: z.string().min(1, "L'identifiant ou l'email est requis"),
password: z.string().min(4, "Le mot de passe/PIN doit contenir au moins 4 chiffres"),
});
type LoginForm = z.infer<typeof loginSchema>;
export const LoginPage = () => {
const { login } = useAuth();
const navigate = useNavigate();
const [isParentMode, setIsParentMode] = useState(true);
const [globalError, setGlobalError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const { register, handleSubmit, formState: { errors, isSubmitting }, reset } = useForm<LoginForm>({
resolver: zodResolver(loginSchema)
});
const toggleMode = (mode: boolean) => {
setIsParentMode(mode);
setGlobalError(null);
reset();
};
const onSubmit = async (data: LoginForm) => {
try {
setGlobalError(null);
const loginData = isParentMode
? { email: data.identifier, password: data.password }
: { username: data.identifier, password: data.password };
await login(loginData);
navigate('/');
} catch (err) {
setGlobalError("Identifiants invalides ou erreur de connexion");
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#FDFCF8] to-[#F1F5F9] p-4 sm:p-6 lg:p-8">
<div className="w-full max-w-md">
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center p-3 mb-4 rounded-2xl bg-indigo-600 shadow-lg shadow-indigo-200">
<Lock className="h-8 w-8 text-white" />
</div>
<h2 className="text-4xl font-extrabold tracking-tight text-slate-900">
Reviflow
</h2>
<p className="mt-3 text-slate-500 font-medium">
Heureux de vous revoir ! Connectez-vous.
</p>
</div>
<div className="bg-white/80 backdrop-blur-xl border border-white/20 py-8 px-6 shadow-2xl shadow-indigo-100 sm:rounded-3xl sm:px-12">
{/* Role Toggle */}
<div className="flex p-1 bg-slate-100 rounded-xl mb-8">
<button
type="button"
onClick={() => toggleMode(true)}
className={`flex-1 py-2 px-4 text-sm font-bold rounded-lg transition-all ${isParentMode ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Parent
</button>
<button
type="button"
onClick={() => toggleMode(false)}
className={`flex-1 py-2 px-4 text-sm font-bold rounded-lg transition-all ${!isParentMode ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Apprenti
</button>
</div>
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
{globalError && (
<div className="bg-red-50 border border-red-100 text-red-600 p-4 rounded-xl text-sm font-medium flex items-center">
<span className="mr-2">⚠️</span> {globalError}
</div>
)}
<div className="space-y-1">
<label htmlFor="identifier" className="block text-sm font-semibold text-slate-700 ml-1">
{isParentMode ? 'Adresse Email' : 'Identifiant'}
</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-indigo-500 transition-colors">
{isParentMode ? <Mail className="h-5 w-5" /> : <Mail className="h-5 w-5" />}
</div>
<input
id="identifier"
type={isParentMode ? "email" : "text"}
placeholder={isParentMode ? "michael@exemple.com" : "Nom d'apprenti"}
{...register('identifier')}
className="block w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all duration-200 sm:text-sm"
/>
</div>
{errors.identifier && <p className="text-red-500 text-xs font-medium mt-1 ml-1">{errors.identifier.message}</p>}
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-sm font-semibold text-slate-700 ml-1">
{isParentMode ? 'Mot de passe' : 'Code PIN'}
</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-indigo-500 transition-colors">
<Lock className="h-5 w-5" />
</div>
<input
id="password"
type={showPassword ? "text" : "password"}
placeholder={isParentMode ? "••••••••" : "••••"}
{...register('password')}
className="block w-full pl-11 pr-12 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all duration-200 sm:text-sm"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-indigo-600 transition-colors"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errors.password && <p className="text-red-500 text-xs font-medium mt-1 ml-1">{errors.password.message}</p>}
</div>
<div className="pt-2">
<button
type="submit"
disabled={isSubmitting}
className="w-full h-12 flex items-center justify-center rounded-2xl bg-indigo-600 px-4 text-sm font-bold text-white shadow-lg shadow-indigo-200 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.98]"
>
{isSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
'Se connecter'
)}
</button>
</div>
</form>
<div className="mt-8 pt-8 border-t border-slate-100">
<p className="text-center text-sm text-slate-500">
Pas encore de compte ?{' '}
<Link to="/register" className="font-bold text-indigo-600 hover:text-indigo-700">
Créer un accès parent
</Link>
</p>
</div>
</div>
<p className="mt-8 text-center text-xs text-slate-400">
&copy; 2026 Reviflow. Tous droits réservés.
</p>
</div>
</div>
);
};
+134
View File
@@ -0,0 +1,134 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { useAuth } from '../../stores/useAuth';
import { Lock, ShieldCheck, AlertCircle } from 'lucide-react';
interface ParentalGateProps {
onSuccess: () => void;
onCancel: () => void;
}
export const ParentalGate: React.FC<ParentalGateProps> = ({ onSuccess, onCancel }) => {
const { user, verifyParentalGate } = useAuth();
const [pin, setPin] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [mode, setMode] = useState<'pin' | 'password'>(user?.has_parental_pin ? 'pin' : 'password');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await verifyParentalGate(mode === 'pin' ? { pin } : { password });
onSuccess();
} catch (err: any) {
setError(err.message || 'Vérification échouée');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
<div className="relative w-full max-w-md bg-white/90 backdrop-blur-md rounded-2xl shadow-2xl border border-white/20 overflow-hidden animate-in zoom-in-95 duration-300">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500"></div>
<div className="p-8">
<div className="flex justify-center mb-6">
<div className="p-4 bg-indigo-50 rounded-full">
<Lock className="w-8 h-8 text-indigo-600" />
</div>
</div>
<h2 className="text-2xl font-bold text-center text-gray-900 mb-2">
Accès Parent Requis
</h2>
<p className="text-center text-gray-600 mb-8 px-4">
Veuillez entrer votre {mode === 'pin' ? 'code PIN' : 'mot de passe'} pour accéder aux paramètres.
</p>
<form onSubmit={handleSubmit} className="space-y-6">
{mode === 'pin' ? (
<div className="flex justify-center space-x-3">
<input
type="password"
maxLength={4}
value={pin}
onChange={(e) => setPin(e.target.value)}
className="w-32 text-center text-3xl tracking-[0.5em] font-mono border-b-2 border-indigo-200 focus:border-indigo-600 outline-none bg-transparent py-2 transition-colors uppercase"
placeholder="••••"
autoFocus
disabled={isSubmitting}
/>
</div>
) : (
<div className="space-y-1">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all text-gray-900"
placeholder="Mot de passe du compte parent"
autoFocus
disabled={isSubmitting}
/>
</div>
)}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-600 rounded-lg animate-in slide-in-from-top-2">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium">{error}</span>
</div>
)}
<div className="flex flex-col gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="w-full py-3 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition-all shadow-lg shadow-indigo-200 flex items-center justify-center gap-2"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
) : (
<>
<ShieldCheck className="w-5 h-5" />
Confirmer
</>
)}
</button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="w-full py-2 text-sm font-black text-gray-500 hover:text-indigo-600 transition-colors uppercase tracking-widest"
>
Retour
</motion.button>
</div>
</form>
{user?.has_parental_pin && (
<div className="mt-8 pt-6 border-t border-gray-100 flex justify-center">
<button
type="button"
onClick={() => setMode(mode === 'pin' ? 'password' : 'pin')}
className="text-xs font-semibold text-indigo-600 hover:underline uppercase tracking-wider"
>
Utiliser le {mode === 'pin' ? 'mot de passe' : 'code PIN'}
</button>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,198 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../stores/useAuth';
import { UserPlus, Sparkles, LogOut, X } from 'lucide-react';
import { AvatarSelector } from './AvatarSelector';
import { Skeleton } from '../../components/ui/Skeleton';
export const ProfileSelectionPage: React.FC = () => {
const { user, learnerProfiles, fetchProfiles, selectProfile, createChildAccount, logout } = useAuth();
const navigate = useNavigate();
const [isCreating, setIsCreating] = React.useState(false);
const [newProfileName, setNewProfileName] = React.useState('');
const [newProfilePin, setNewProfilePin] = React.useState('1234');
const [newProfileAvatar, setNewProfileAvatar] = React.useState('https://api.dicebear.com/7.x/fun-emoji/svg?seed=Felix');
useEffect(() => {
fetchProfiles();
}, []);
const handleCreate = async () => {
if (!newProfileName.trim()) return;
try {
await createChildAccount({
username: newProfileName.toLowerCase().replace(/\s/g, ''),
password: newProfilePin,
first_name: newProfileName,
avatar_url: newProfileAvatar
});
setIsCreating(false);
setNewProfileName('');
} catch (err) {
alert("Erreur lors de la création");
}
};
const handleSelect = async (id: string) => {
try {
await selectProfile(id);
navigate('/dashboard');
} catch (err) {
console.error("Selection failed", err);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-[#f8fafc] via-[#f1f5f9] to-[#e2e8f0] flex flex-col items-center justify-center p-6 sm:p-12 relative overflow-hidden">
{/* Background Decorations */}
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo-200/20 rounded-full blur-[120px] pointer-events-none"></div>
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-purple-200/20 rounded-full blur-[120px] pointer-events-none"></div>
<div className="w-full max-w-5xl relative z-10">
<div className="text-center mb-16 animate-in fade-in slide-in-from-top-4 duration-700">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white/50 border border-indigo-100 rounded-full mb-6 shadow-sm">
<Sparkles className="w-4 h-4 text-indigo-500" />
<span className="text-sm font-semibold text-indigo-600 tracking-wide uppercase">Reviflow</span>
</div>
<h1 className="text-4xl sm:text-5xl font-extrabold text-gray-900 mb-6 tracking-tight">
Quel enfant va réviser <span className="text-indigo-600 italic">aujourd'hui ?</span>
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto font-medium">
Bonjour {user?.first_name || 'Parent'}. Sélectionnez le profil de votre enfant pour commencer la session.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{learnerProfiles.length === 0 ? (
// Skeleton Loading State
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex flex-col items-center">
<Skeleton className="w-32 h-32 sm:w-40 sm:h-40 rounded-3xl mb-4" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
))
) : (
learnerProfiles.map((profile, idx) => (
<button
key={profile.id}
onClick={() => handleSelect(profile.id)}
style={{ animationDelay: `${idx * 100}ms` }}
className="group flex flex-col items-center animate-in fade-in zoom-in-95 duration-500 fill-mode-backwards"
>
<div className="relative mb-4">
<div className="absolute inset-0 bg-indigo-500 rounded-3xl blur-[20px] opacity-0 group-hover:opacity-40 transition-opacity duration-500"></div>
<div className="relative w-32 h-32 sm:w-40 sm:h-40 bg-white rounded-3xl shadow-xl border-4 border-white group-hover:border-indigo-100 transition-all duration-500 overflow-hidden ring-1 ring-black/5">
{profile.avatar_url ? (
<img src={profile.avatar_url} alt={profile.first_name} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
) : (
<div className="w-full h-full bg-gradient-to-br from-indigo-50 to-purple-50 flex items-center justify-center">
<span className="text-4xl font-bold text-indigo-300">
{profile.first_name.charAt(0)}
</span>
</div>
)}
</div>
<div className="absolute -bottom-2 -right-2 bg-white rounded-xl p-2 shadow-lg border border-gray-100 opacity-0 group-hover:opacity-100 transform translate-y-2 group-hover:translate-y-0 transition-all duration-300">
<Sparkles className="w-4 h-4 text-indigo-500" />
</div>
</div>
<span className="text-xl font-bold text-gray-800 group-hover:text-indigo-600 transition-colors uppercase tracking-wide">
{profile.first_name}
</span>
</button>
))
)}
{/* Add Profile Placeholder */}
<button
onClick={() => setIsCreating(true)}
className="group flex flex-col items-center animate-in fade-in zoom-in-95 duration-500 fill-mode-backwards"
style={{ animationDelay: `${learnerProfiles.length * 100}ms` }}
>
<div className="w-32 h-32 sm:w-40 sm:h-40 rounded-3xl border-4 border-dashed border-gray-300 group-hover:border-indigo-300 group-hover:bg-indigo-50 transition-all duration-500 flex items-center justify-center mb-4">
<UserPlus className="w-10 h-10 text-gray-400 group-hover:text-indigo-400 transition-colors" />
</div>
<span className="text-xl font-bold text-gray-400 group-hover:text-indigo-400 transition-colors uppercase tracking-wide">
Nouveau
</span>
</button>
</div>
<div className="mt-20 flex justify-center">
<button
onClick={logout}
className="flex items-center gap-2 px-6 py-3 text-gray-500 hover:text-indigo-600 font-semibold transition-colors duration-300 hover:bg-white/50 rounded-2xl"
>
<LogOut className="w-5 h-5" />
Se déconnecter
</button>
</div>
</div>
{/* Create Profile Modal */}
{isCreating && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md p-8 relative animate-in zoom-in-95 duration-300">
<button
onClick={() => setIsCreating(false)}
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<UserPlus className="w-6 h-6 text-indigo-500" />
Nouveau Profil
</h2>
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Prénom</label>
<input
type="text"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border-2 border-gray-100 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 transition-all font-semibold outline-none"
placeholder="Ex: Léo"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Code PIN (4 chiffres)</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={newProfilePin}
onChange={(e) => setNewProfilePin(e.target.value.replace(/\D/g, ''))}
className="w-full px-4 py-3 rounded-xl border-2 border-gray-100 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 transition-all font-mono text-center text-2xl tracking-[0.5em] font-bold outline-none"
placeholder="1234"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">Avatar</label>
<div className="bg-gray-50 rounded-2xl border-2 border-gray-100 p-2">
<AvatarSelector
selectedAvatar={newProfileAvatar}
onSelect={setNewProfileAvatar}
/>
</div>
</div>
<button
onClick={handleCreate}
disabled={!newProfileName.trim()}
className="w-full py-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold rounded-xl shadow-lg shadow-indigo-200 transition-all active:scale-[0.98]"
>
Créer le profil
</button>
</div>
</div>
</div>
)}
</div>
);
};
+135
View File
@@ -0,0 +1,135 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useAuth } from '../../stores/useAuth';
import { useNavigate, Link } from 'react-router-dom';
import { useState } from 'react';
const registerSchema = z.object({
email: z.string().email("Adresse email invalide"),
password: z.string().min(6, "Le mot de passe doit contenir au moins 6 caractères"),
confirmPassword: z.string().min(6, "Le mot de passe doit contenir au moins 6 caractères"),
}).refine((data) => data.password === data.confirmPassword, {
message: "Les mots de passe ne correspondent pas",
path: ["confirmPassword"],
});
type RegisterForm = z.infer<typeof registerSchema>;
export const RegisterPage = () => {
const { register: registerUser, login } = useAuth();
const navigate = useNavigate();
const [globalError, setGlobalError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<RegisterForm>({
resolver: zodResolver(registerSchema)
});
const onSubmit = async (data: RegisterForm) => {
try {
setGlobalError(null);
await registerUser({ email: data.email, password: data.password });
setSuccess(true);
// Auto-login after successful registration
await login({ email: data.email, password: data.password });
navigate('/');
} catch (err: any) {
const detail = err.response?.data?.detail;
if (Array.isArray(detail)) {
setGlobalError(detail.map((e: any) => `${e.loc.join('.')}: ${e.msg}`).join(', '));
} else {
setGlobalError(detail || "Échec de l'inscription. Cet email est peut-être déjà utilisé.");
}
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#FDFCF8] to-[#F1F5F9] p-4 sm:p-6 lg:p-8">
<div className="w-full max-w-md">
<div className="text-center mb-10">
<h2 className="text-4xl font-extrabold tracking-tight text-slate-900">
Créer un compte
</h2>
<p className="mt-3 text-slate-500 font-medium">
Ou{' '}
<Link to="/login" className="font-bold text-indigo-600 hover:text-indigo-700 underline-offset-4 hover:underline">
connectez-vous à votre compte existant
</Link>
</p>
</div>
<div className="bg-white/80 backdrop-blur-xl border border-white/20 py-8 px-6 shadow-2xl shadow-indigo-100 sm:rounded-3xl sm:px-12">
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
{globalError && (
<div className="bg-red-50 border border-red-100 text-red-600 p-4 rounded-xl text-sm font-medium flex items-center">
<span className="mr-2">⚠️</span> {globalError}
</div>
)}
{success && (
<div className="bg-emerald-50 border border-emerald-100 text-emerald-600 p-4 rounded-xl text-sm font-medium flex items-center">
<span className="mr-2">✅</span> Compte créé avec succès ! Connexion en cours...
</div>
)}
<div className="space-y-1">
<label htmlFor="email" className="block text-sm font-semibold text-slate-700 ml-1">
Adresse Email
</label>
<input
id="email"
type="email"
placeholder="michael@exemple.com"
{...register('email')}
className="block w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all duration-200"
/>
{errors.email && <p className="text-red-500 text-xs font-medium mt-1 ml-1">{errors.email.message}</p>}
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-sm font-semibold text-slate-700 ml-1">
Mot de passe
</label>
<input
id="password"
type="password"
placeholder="••••••••"
{...register('password')}
className="block w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all duration-200"
/>
{errors.password && <p className="text-red-500 text-xs font-medium mt-1 ml-1">{errors.password.message}</p>}
</div>
<div className="space-y-1">
<label htmlFor="confirmPassword" className="block text-sm font-semibold text-slate-700 ml-1">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
type="password"
placeholder="••••••••"
{...register('confirmPassword')}
className="block w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all duration-200"
/>
{errors.confirmPassword && <p className="text-red-500 text-xs font-medium mt-1 ml-1">{errors.confirmPassword.message}</p>}
</div>
<div className="pt-2">
<button
type="submit"
disabled={isSubmitting}
className="w-full h-12 flex items-center justify-center rounded-2xl bg-indigo-600 px-4 text-sm font-bold text-white shadow-lg shadow-indigo-200 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.98]"
>
{isSubmitting ? 'Création en cours...' : 'Créer mon accès parent'}
</button>
</div>
</form>
</div>
<p className="mt-8 text-center text-xs text-slate-400">
&copy; 2026 Reviflow. Tous droits réservés.
</p>
</div>
</div>
);
};
@@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import { api } from '../../lib/api';
import { Clock, BookOpen, CheckCircle, Calendar } from 'lucide-react';
import { Skeleton } from '../../components/ui/Skeleton';
interface ActivityItem {
type: 'REVISION' | 'QUIZ';
id: string;
revision_id?: string;
topic: string;
created_at: string;
minutes: number;
details: string;
}
interface DailyActivity {
date: string; // YYYY-MM-DD
total_minutes: number;
items: ActivityItem[];
}
interface ActivityStats {
summary: {
today_minutes: number;
week_minutes: number;
total_quizzes: number;
total_revisions: number;
};
history: DailyActivity[];
}
interface ActivityTimelineProps {
learnerId?: string;
}
export function ActivityTimeline({ learnerId }: ActivityTimelineProps) {
const [data, setData] = useState<ActivityStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const url = learnerId
? `/quiz/stats/activity?learner_id=${learnerId}`
: `/quiz/stats/activity`;
api.get(url)
.then(res => {
setData(res.data);
setLoading(false);
})
.catch(err => {
console.error("Failed to fetch activity stats", err);
setLoading(false);
});
}, [learnerId]);
if (loading) {
return (
<div className="space-y-6">
<div className="flex gap-4">
<Skeleton className="h-24 flex-1 rounded-2xl" />
<Skeleton className="h-24 flex-1 rounded-2xl" />
</div>
<Skeleton className="h-40 w-full rounded-2xl" />
</div>
);
}
if (!data || !data.summary) return <div className="text-center py-8 text-slate-400">Aucune donnée disponible</div>;
return (
<div className="space-y-8 animate-in fade-in duration-500">
{/* Summary Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-indigo-50 p-4 rounded-xl text-center border border-indigo-100">
<p className="text-xs font-bold text-indigo-400 uppercase tracking-widest mb-1">Aujourd'hui</p>
<p className="text-2xl font-black text-indigo-600">{data.summary.today_minutes} <span className="text-sm font-bold opacity-70">min</span></p>
</div>
<div className="bg-emerald-50 p-4 rounded-xl text-center border border-emerald-100">
<p className="text-xs font-bold text-emerald-400 uppercase tracking-widest mb-1">Cette semaine</p>
<p className="text-2xl font-black text-emerald-600">{data.summary.week_minutes} <span className="text-sm font-bold opacity-70">min</span></p>
</div>
<div className="bg-amber-50 p-4 rounded-xl text-center border border-amber-100">
<p className="text-xs font-bold text-amber-400 uppercase tracking-widest mb-1">Total Quiz</p>
<p className="text-2xl font-black text-amber-600">{data.summary.total_quizzes}</p>
</div>
<div className="bg-blue-50 p-4 rounded-xl text-center border border-blue-100">
<p className="text-xs font-bold text-blue-400 uppercase tracking-widest mb-1">Total Révisions</p>
<p className="text-2xl font-black text-blue-600">{data.summary.total_revisions}</p>
</div>
</div>
{/* Timeline */}
<div>
<h3 className="text-xl font-black text-slate-900 mb-6 flex items-center gap-2">
<Calendar className="w-5 h-5 text-slate-400" />
Historique
</h3>
<div className="max-h-[400px] overflow-y-auto pr-4 custom-scrollbar scroll-smooth">
<div className="space-y-8 pl-4 border-l-2 border-slate-100 ml-2">
{data.history.map((day) => (
<div key={day.date} className="relative">
{/* Date Marker */}
<div className="absolute -left-[21px] top-0 w-4 h-4 rounded-full bg-slate-200 border-2 border-white"></div>
<div className="mb-4">
<h4 className="font-extrabold text-slate-700 capitalize">
{new Date(day.date).toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
</h4>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">{day.total_minutes} min d'activité</p>
</div>
<div className="space-y-3">
{day.items.map((item, idx) => (
<div
key={idx}
onClick={() => {
if (item.type === 'REVISION') {
// Assuming we have useNavigate or just window.location for now,
// but let's use a simple anchor or navigate prop if passed,
// defaulting to direct window location as fail safe or refactor to useLink
window.location.href = `/revision/${item.id}`;
}
}}
className={`bg-white border border-slate-200 p-4 rounded-lg shadow-sm flex items-center gap-4 hover:border-slate-300 transition-colors ${item.type === 'REVISION' ? 'cursor-pointer hover:bg-slate-50' : ''}`}
>
<div className={`p-3 rounded-md ${item.type === 'QUIZ' ? 'bg-amber-50 text-amber-500' : 'bg-blue-50 text-blue-500'}`}>
{item.type === 'QUIZ' ? <CheckCircle className="w-5 h-5" /> : <BookOpen className="w-5 h-5" />}
</div>
<div className="flex-1">
<p className="font-bold text-slate-900">{item.topic}</p>
<p className="text-xs text-slate-500 font-medium">{item.details}</p>
</div>
<div className="text-right">
<p className="font-bold text-slate-900 text-sm">{new Date(item.created_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest flex items-center justify-end gap-1">
<Clock className="w-3 h-3" /> {item.minutes} min
</p>
</div>
</div>
))}
</div>
</div>
))}
{data.history.length === 0 && (
<div className="text-center py-8 text-slate-400 italic">
Aucune activité récente.
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { useAuth } from '../../stores/useAuth';
import { Trophy, Star, TrendingUp } from 'lucide-react';
export function GamificationWidget() {
const { activeLearner } = useAuth();
if (!activeLearner) return null;
const level = activeLearner.level || 1;
const currentXP = activeLearner.xp || 0;
// Formula inverse: XP = 50 * (Level-1)^2
// But easier: Calculate bounds for current level to show progress bar
// Level N starts at: 50 * (N-1)^2
// Level N+1 starts at: 50 * N^2
const currentLevelStartXP = 50 * Math.pow(level - 1, 2);
const nextLevelStartXP = 50 * Math.pow(level, 2);
const xpNeededForLevel = nextLevelStartXP - currentLevelStartXP;
const xpProgressInLevel = currentXP - currentLevelStartXP;
// Calculate percentage
const progressPercent = Math.min(100, Math.max(0, (xpProgressInLevel / xpNeededForLevel) * 100));
// Level Titles (Flavor text)
const getLevelTitle = (lvl: number) => {
if (lvl >= 50) return "Légende Vivante";
if (lvl >= 30) return "Grand Maître";
if (lvl >= 20) return "Érudit";
if (lvl >= 10) return "Explorateur";
if (lvl >= 5) return "Apprenti";
return "Novice";
};
return (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm relative overflow-hidden">
{/* Background Decorations */}
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-purple-500/20 rounded-full blur-2xl -ml-5 -mb-5"></div>
<div className="relative z-10">
<div className="flex justify-between items-start mb-4">
<div>
<div className="text-indigo-600 text-xs font-bold uppercase tracking-widest mb-1">Niveau {level}</div>
<h3 className="text-2xl font-black text-gray-900">{getLevelTitle(level)}</h3>
</div>
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center border border-indigo-100 shadow-sm">
<Trophy className="w-6 h-6 text-yellow-500 fill-yellow-500" />
</div>
</div>
<div className="mb-2 flex justify-between text-xs font-bold text-gray-500">
<span className="flex items-center gap-1"><Star className="w-3 h-3 text-amber-500" /> {currentXP} XP Totaux</span>
<span className="flex items-center gap-1">Prochain niveau: {Math.round(nextLevelStartXP - currentXP)} XP <TrendingUp className="w-3 h-3 text-indigo-500" /></span>
</div>
{/* Progress Bar Container */}
<div className="h-4 bg-gray-100 rounded-full p-1 border border-gray-200">
{/* Active Progress */}
<div
className="h-full bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full shadow-sm transition-all duration-1000 ease-out relative group"
style={{ width: `${progressPercent}%` }}
>
{/* Shimmer Effect */}
<div className="absolute top-0 left-0 w-full h-full bg-white/30 rounded-full animate-[shimmer_2s_infinite] opacity-0 group-hover:opacity-100"></div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../lib/api';
import { useAuth } from '../../stores/useAuth';
import { Skeleton } from '../../components/ui/Skeleton';
interface Score {
id: string;
topic: string;
score: number;
total_questions: number;
created_at: string;
revision_id?: string;
}
export function HistoryWidget() {
const [history, setHistory] = useState<Score[]>([]);
const [loading, setLoading] = useState(true);
const { activeLearner } = useAuth();
useEffect(() => {
const fetchHistory = async () => {
try {
const params = activeLearner ? { learner_id: activeLearner.id } : {};
const response = await api.get<Score[]>('/quiz/history', { params });
setHistory(response.data);
} catch (error) {
console.error("Failed to load history", error);
} finally {
setLoading(false);
}
};
fetchHistory();
}, [activeLearner]);
if (loading) {
return (
<div className="bg-white shadow overflow-hidden sm:rounded-lg h-64 p-6">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-6 w-32 bg-gray-100" />
<Skeleton className="h-4 w-16 bg-gray-100" />
</div>
<div className="space-y-6">
{[1, 2, 3].map((i) => (
<div key={i} className="flex justify-between items-center">
<div className="space-y-2">
<Skeleton className="h-5 w-48 bg-gray-100" />
<Skeleton className="h-3 w-24 bg-gray-50" />
</div>
<Skeleton className="h-10 w-10 rounded-full bg-gray-100" />
</div>
))}
</div>
</div>
);
}
if (history.length === 0) {
return (
<div className="bg-white rounded-[2.5rem] shadow-xl shadow-slate-100/50 border border-slate-50 h-[400px] flex flex-col items-center justify-center p-10 text-center">
<div className="w-16 h-16 bg-slate-50 rounded-2xl flex items-center justify-center text-3xl mb-6">📚</div>
<p className="text-slate-900 font-black text-xl mb-2">Aucune révision pour le moment</p>
<p className="text-slate-400 font-medium">Scannez un cours pour commencer à apprendre !</p>
</div>
);
}
return (
<div className="bg-white rounded-[2.5rem] shadow-xl shadow-slate-100/50 border border-slate-50 overflow-hidden">
<div className="px-8 py-6 border-b border-slate-50 flex justify-between items-center">
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tight">Révisions Récentes</h3>
<span className="px-3 py-1 bg-indigo-50 text-indigo-600 text-xs font-black rounded-full">Total: {history.length}</span>
</div>
<div className="max-h-[330px] overflow-y-auto scrollbar-hide">
<ul className="divide-y divide-slate-50">
{history.map((item) => (
<li key={item.id} className="px-8 py-5 hover:bg-slate-50 transition-colors group">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<p className="text-lg font-black text-slate-800 group-hover:text-indigo-600 transition-colors leading-tight">{item.topic}</p>
<p className="text-xs font-bold text-slate-400 mt-1 flex items-center gap-2 uppercase tracking-widest">
<span className="w-1.5 h-1.5 bg-indigo-400 rounded-full"></span>
{new Date(item.created_at).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-4">
{item.revision_id && (
<Link
to={`/revision/${item.revision_id}`}
className="hidden group-hover:block px-3 py-1.5 bg-indigo-50 text-indigo-600 text-xs font-bold rounded-lg hover:bg-indigo-100 transition-colors"
>
Revoir
</Link>
)}
<div className={`flex items-center justify-center w-14 h-14 rounded-2xl border-4 text-sm font-black transition-all group-hover:scale-110 ${(item.score / item.total_questions) >= 0.8 ? 'border-emerald-100 text-emerald-600 bg-emerald-50 shadow-lg shadow-emerald-100/50' :
(item.score / item.total_questions) >= 0.5 ? 'border-amber-100 text-amber-600 bg-amber-50 shadow-lg shadow-amber-100/50' :
'border-red-100 text-red-600 bg-red-50 shadow-lg shadow-red-100/50'
}`}>
{item.score}/{item.total_questions}
</div>
</div>
</div>
</li>
))}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,156 @@
import { useEffect, useState } from 'react';
import { useAuth } from '../../stores/useAuth';
import { api } from '../../lib/api';
import { Clock } from 'lucide-react';
import { Skeleton } from '../../components/ui/Skeleton';
interface MasteryItem {
topic: string;
mastery_score: number;
quizzes_count: number;
pending_errors: number;
status: 'LEARNING' | 'REVIEWING' | 'MASTERED';
last_activity: string;
}
interface MasteryWidgetProps {
learnerId?: string;
}
export function MasteryWidget({ learnerId }: MasteryWidgetProps = {}) {
const { activeLearner } = useAuth();
const targetId = learnerId || activeLearner?.id;
const [stats, setStats] = useState<MasteryItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!targetId) return;
setLoading(true);
api.get(`/quiz/stats/mastery?learner_id=${targetId}`)
.then(res => {
setStats(res.data);
setLoading(false);
})
.catch(err => {
console.error(err);
setLoading(false);
});
}, [targetId]);
if (!targetId) return null;
if (loading) {
return (
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-200 h-[400px] relative overflow-hidden">
{/* Background Decorations */}
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-purple-500/20 rounded-full blur-2xl -ml-5 -mb-5"></div>
<div className="relative z-10">
<Skeleton className="h-8 w-48 mb-8 bg-gray-100" />
<div className="space-y-6">
{[1, 2, 3].map((i) => (
<div key={i} className="flex justify-between items-center">
<div className="space-y-2">
<Skeleton className="h-5 w-32 bg-gray-100" />
<Skeleton className="h-3 w-20 bg-gray-50" />
</div>
<Skeleton className="h-12 w-12 rounded-full bg-gray-100" />
</div>
))}
</div>
</div>
</div>
);
}
if (stats.length === 0) {
return (
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-200 text-center flex flex-col items-center justify-center opacity-75">
<div className="bg-gray-100 p-4 rounded-full mb-3">
<span className="text-2xl">📊</span>
</div>
<h3 className="text-gray-900 font-bold mb-1">Pas encore de résultats</h3>
<p className="text-sm text-gray-500">Termine un quiz pour voir tes statistiques !</p>
</div>
);
}
return (
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-200 relative overflow-hidden">
{/* Background Decorations */}
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-purple-500/20 rounded-full blur-2xl -ml-5 -mb-5"></div>
<div className="relative z-10">
<h3 className="text-xl font-bold text-gray-900 border-b border-gray-100 pb-3 mb-4">Maîtrise des Sujets 🧠</h3>
<div className="space-y-4">
{stats.map((item, index) => (
<div key={index} className="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 transition-colors group">
<div className="flex-1">
<h4 className="font-bold text-gray-800 text-sm">{item.topic}</h4>
<div className="flex items-center gap-2 text-xs text-gray-400 mt-1">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {item.last_activity ? new Date(item.last_activity).toLocaleDateString() : '—'}</span>
<span>•</span>
<span>{item.quizzes_count || 0} quiz</span>
</div>
</div>
<div className="flex items-center gap-4">
{/* Status Label */}
<div className={`px-2 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider hidden sm:block
${item.status === 'MASTERED' ? 'bg-emerald-100 text-emerald-600' :
item.status === 'REVIEWING' ? 'bg-blue-100 text-blue-600' :
'bg-amber-100 text-amber-600'
}
`}>
{item.status === 'MASTERED' ? 'Maîtrisé' :
item.status === 'REVIEWING' ? 'En cours' :
'Débutant'}
</div>
{/* Circular Progress (Minimal CSS) */}
<div className="relative w-10 h-10 flex items-center justify-center">
<svg className="transform -rotate-90 w-full h-full">
<circle
cx="20"
cy="20"
r="16"
stroke="currentColor"
strokeWidth="3"
fill="transparent"
className="text-gray-100"
/>
<circle
cx="20"
cy="20"
r="16"
stroke="currentColor"
strokeWidth="3"
fill="transparent"
strokeDasharray={100.5} // 2 * pi * 16
strokeDashoffset={100.5 - (100.5 * item.mastery_score) / 100}
className={`transition-all duration-1000 ease-out
${item.mastery_score >= 80 ? 'text-emerald-500' :
item.mastery_score >= 50 ? 'text-blue-500' :
'text-amber-500'}
`}
strokeLinecap="round"
/>
</svg>
<span className="absolute text-[9px] font-bold text-gray-600">{Math.round(item.mastery_score || 0)}%</span>
</div>
</div>
</div>
))}
</div>
<button className="w-full mt-4 py-2 text-sm font-bold text-gray-600 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
Voir tous les sujets
</button>
</div>
</div>
);
}
@@ -0,0 +1,534 @@
import { useNavigate } from 'react-router-dom';
import { Play, BookOpen, ArrowRight, Book, AlertTriangle, X, Trash2, Sparkles, Lightbulb, Brain } from 'lucide-react';
import { useAuth } from '../../stores/useAuth';
import { api } from '../../lib/api';
import { useEffect, useState } from 'react';
import { Skeleton } from '../../components/ui/Skeleton';
import { AnimatePresence, motion } from 'framer-motion';
interface RevisionItem {
id: string;
topic: string;
subject?: string;
created_at: string;
status: string;
current_series: number;
total_series: number;
pending_errors?: number;
}
export const RecentRevisionsWidget = () => {
const { activeLearner } = useAuth();
const navigate = useNavigate();
const [revisions, setRevisions] = useState<RevisionItem[]>([]);
const [loading, setLoading] = useState(true);
const [remediationLoadingId, setRemediationLoadingId] = useState<string | null>(null);
// Restart Modal State
const [restartModal, setRestartModal] = useState<{ isOpen: boolean, revisionId: string | null }>({
isOpen: false,
revisionId: null
});
const [isRestarting, setIsRestarting] = useState(false);
// Delete Modal State
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean, revisionId: string | null }>({
isOpen: false,
revisionId: null
});
const [isDeleting, setIsDeleting] = useState(false);
// Summary Modal State
const [summaryModal, setSummaryModal] = useState<{
isOpen: boolean,
data: any | null,
loading: boolean
}>({
isOpen: false,
data: null,
loading: false
});
useEffect(() => {
if (!activeLearner) return;
api.get<any>(`/quiz/stats/activity?learner_id=${activeLearner.id}`)
.then(res => {
const allRevisions = res.data.history
.flatMap((day: any) => day.items)
.filter((item: any) => item.type === 'REVISION');
setRevisions(allRevisions);
setLoading(false);
})
.catch(() => setLoading(false));
}, [activeLearner]);
const getSubjectStyle = (subject?: string) => {
if (subject === 'Mathématiques') return { color: 'text-blue-500', bg: 'bg-blue-50', icon: <BookOpen className="w-5 h-5" /> };
if (subject === 'Histoire') return { color: 'text-amber-600', bg: 'bg-amber-50', icon: <Book className="w-5 h-5" /> };
return { color: 'text-indigo-500', bg: 'bg-indigo-50', icon: <BookOpen className="w-5 h-5" /> };
};
const handleRemediation = async (e: React.MouseEvent, revisionId: string) => {
e.stopPropagation();
if (!activeLearner) return;
setRemediationLoadingId(revisionId);
try {
const res = await api.post('/quiz/remediation/generate', {
learner_id: activeLearner.id,
revision_id: revisionId
});
const quizData = res.data;
navigate('/quiz', { state: { quizData: quizData, textContent: "REMEDIATION_MODE", revisionId: revisionId } });
} catch (e) {
console.error(e);
alert("Impossible de générer la révision des erreurs.");
} finally {
setRemediationLoadingId(null);
}
};
const confirmRestart = async () => {
if (!restartModal.revisionId) return;
setIsRestarting(true);
try {
const res = await api.post('/quiz/reset', { revision_id: restartModal.revisionId });
navigate('/quiz', { state: { quizData: res.data, revisionId: res.data.revision_id } });
} catch (err) {
alert("Erreur lors du redémarrage.");
setIsRestarting(false);
setRestartModal({ isOpen: false, revisionId: null });
}
};
const confirmDelete = async () => {
if (!deleteModal.revisionId) return;
setIsDeleting(true);
try {
await api.delete(`/quiz/revision/${deleteModal.revisionId}`);
// Remove from UI
setRevisions(prev => prev.filter(r => r.id !== deleteModal.revisionId));
setDeleteModal({ isOpen: false, revisionId: null });
} catch (err) {
alert("Erreur lors de la suppression.");
} finally {
setIsDeleting(false);
}
};
const openSummary = async (e: React.MouseEvent, revisionId: string) => {
e.stopPropagation();
setSummaryModal(prev => ({ ...prev, isOpen: true, loading: true }));
try {
const res = await api.get(`/quiz/review/${revisionId}`);
setSummaryModal({ isOpen: true, data: res.data, loading: false });
} catch (err) {
console.error(err);
setSummaryModal({ isOpen: false, data: null, loading: false });
}
};
if (loading) return <Skeleton className="h-48 w-full rounded-[2rem]" />;
if (revisions.length === 0) return null;
return (
<>
<div className="bg-white rounded-[2rem] p-6 shadow-xl shadow-indigo-100/20 border border-slate-100 relative overflow-hidden">
{/* Background Decorations */}
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-indigo-500/10 rounded-full blur-2xl -ml-5 -mb-5"></div>
<div className="relative z-10">
<div className="flex justify-between items-center mb-6">
<h3 className="font-black text-lg text-slate-800 flex items-center gap-2">
Mes Cours
</h3>
</div>
<div className="space-y-4">
{revisions.slice(0, 4).map(rev => {
const style = getSubjectStyle(rev.subject);
return (
<motion.div
key={rev.id}
whileHover={{ y: -4 }}
whileTap={{ scale: 0.99 }}
className="group p-5 bg-slate-50/80 border border-slate-100/80 rounded-[2rem] hover:bg-white hover:border-indigo-100 shadow-sm hover:shadow-xl hover:shadow-indigo-500/10 transition-all cursor-pointer relative"
onClick={() => navigate(`/play/${rev.id}`)}
>
{/* Delete Button - Subtle top right */}
<motion.button
whileTap={{ scale: 0.8 }}
onClick={(e) => {
e.stopPropagation();
setDeleteModal({ isOpen: true, revisionId: rev.id });
}}
className="absolute top-4 right-4 p-2 text-slate-200 hover:text-red-500 hover:bg-red-50 transition-all rounded-xl z-20"
title="Supprimer"
>
<Trash2 className="w-5 h-5" />
</motion.button>
{/* Top Section: Icon + Title Group */}
<div className="flex items-start gap-3 mb-4">
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => openSummary(e, rev.id)}
className="relative shrink-0 cursor-help"
title="Voir le résumé"
>
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center ${style.bg} ${style.color} border-2 border-white shadow-sm`}>
<div className="scale-110">{style.icon}</div>
</div>
{rev.status === 'COMPLETED' && (
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-emerald-500 rounded-full border-2 border-white flex items-center justify-center shadow-sm">
<span className="text-[10px] scale-90">✅</span>
</div>
)}
</motion.div>
<div className="flex-1 min-w-0 pr-8">
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-[10px] font-black uppercase tracking-widest ${style.color}`}>
{rev.subject || 'Général'}
</span>
</div>
<h4 className="font-semibold text-slate-800 text-sm sm:text-base leading-tight">
{rev.topic}
</h4>
</div>
</div>
{/* Bottom Section: Progress + Primary Actions */}
<div className="flex items-end justify-between pt-3 border-t border-slate-100">
<div className="space-y-1.5 min-w-0">
<p className="text-[11px] font-bold text-slate-400 truncate">
Ajouté le {new Date(rev.created_at).toLocaleDateString()}
</p>
<p className="text-[11px] font-black text-indigo-400 uppercase tracking-tight">
Progression: <span className="text-indigo-600 bg-indigo-50 px-1.5 py-0.5 rounded-lg border border-indigo-100/50">{rev.current_series}/{rev.total_series} QUIZZ</span>
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
{rev.pending_errors && rev.pending_errors > 0 ? (
<motion.button
whileTap={{ scale: 0.9 }}
onClick={(e) => handleRemediation(e, rev.id)}
className="h-14 px-5 bg-amber-50 text-amber-700 border-2 border-amber-100 rounded-2xl flex items-center gap-3 shadow-sm hover:bg-amber-100 transition-colors"
>
{remediationLoadingId === rev.id ? (
<span className="animate-spin w-4 h-4 border-2 border-amber-600 border-t-transparent rounded-full block"></span>
) : (
<span className="flex items-center gap-2">
<span className="text-[10px] font-black uppercase tracking-widest">Corriger</span>
<span className="bg-white px-2 py-0.5 rounded-lg text-xs font-black border border-amber-200 shadow-sm">{rev.pending_errors}</span>
</span>
)}
</motion.button>
) : null}
{rev.status === 'COMPLETED' ? (
<motion.button
whileTap={{ scale: 0.9 }}
onClick={(e) => {
e.stopPropagation();
setRestartModal({ isOpen: true, revisionId: rev.id });
}}
className="w-14 h-14 rounded-2xl bg-slate-50 border-2 border-slate-100 flex items-center justify-center text-slate-400 hover:bg-green-500 hover:text-white hover:border-transparent transition-all shadow-sm group/btn"
title="Recommencer"
>
<span className="text-2xl group-hover/btn:rotate-180 transition-transform duration-500">🔄</span>
</motion.button>
) : (
<motion.button
whileTap={{ scale: 0.9 }}
className="w-14 h-14 rounded-2xl bg-indigo-600 border-2 border-indigo-500 flex items-center justify-center text-white shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all"
>
<Play className="w-6 h-6 ml-1" />
</motion.button>
)}
</div>
</div>
</motion.div>
);
})}
</div>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => navigate('/history')}
className="w-full mt-6 py-4 px-6 bg-white border-2 border-slate-100 text-indigo-600 rounded-2xl font-black text-sm flex items-center justify-center gap-3 shadow-sm hover:border-indigo-200 hover:bg-indigo-50/30 transition-all active:bg-indigo-50"
>
<span>Tout voir</span>
<ArrowRight className="w-5 h-5" />
</motion.button>
</div>
</div>
{/* Restart Confirmation Modal */}
<AnimatePresence>
{restartModal.isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={() => setRestartModal({ isOpen: false, revisionId: null })}
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-2xl p-6 shadow-2xl max-w-sm w-full relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-4">
<button
onClick={() => setRestartModal({ isOpen: false, revisionId: null })}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col items-center text-center">
<div className="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mb-4 text-amber-600">
<AlertTriangle className="w-8 h-8" />
</div>
<h3 className="text-xl font-bold text-slate-800 mb-2">Recommencer le cours ?</h3>
<p className="text-slate-500 text-sm mb-6 leading-relaxed">
Vous allez recommencer ce cours depuis le début. Votre progression actuelle (série en cours) sera réinitialisée.
</p>
<div className="flex gap-3 w-full">
<button
onClick={() => setRestartModal({ isOpen: false, revisionId: null })}
className="flex-1 py-3 px-4 rounded-xl font-bold text-slate-600 bg-slate-100 hover:bg-slate-200 transition-colors"
>
Annuler
</button>
<button
onClick={confirmRestart}
disabled={isRestarting}
className="flex-1 py-3 px-4 rounded-xl font-bold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-200 flex items-center justify-center gap-2"
>
{isRestarting ? (
<span className="animate-spin w-4 h-4 border-2 border-white/30 border-t-white rounded-full"></span>
) : (
<>
<span>Recommencer</span>
<span className="text-indigo-200">🔄</span>
</>
)}
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirmation Modal */}
<AnimatePresence>
{deleteModal.isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={() => setDeleteModal({ isOpen: false, revisionId: null })}
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-2xl p-6 shadow-2xl max-w-sm w-full relative overflow-hidden ring-4 ring-red-50"
>
<div className="absolute top-0 right-0 p-4">
<button
onClick={() => setDeleteModal({ isOpen: false, revisionId: null })}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col items-center text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4 text-red-600">
<Trash2 className="w-8 h-8" />
</div>
<h3 className="text-xl font-bold text-slate-800 mb-2">Supprimer ce cours ?</h3>
<p className="text-slate-500 text-sm mb-6 leading-relaxed">
Cette action est irréversible. Toutes les données (progression, quiz, notes) seront effacées.
</p>
<div className="flex gap-3 w-full">
<button
onClick={() => setDeleteModal({ isOpen: false, revisionId: null })}
className="flex-1 py-3 px-4 rounded-xl font-bold text-slate-600 bg-slate-100 hover:bg-slate-200 transition-colors"
>
Annuler
</button>
<button
onClick={confirmDelete}
disabled={isDeleting}
className="flex-1 py-3 px-4 rounded-xl font-bold text-white bg-red-600 hover:bg-red-700 transition-colors shadow-lg shadow-red-200 flex items-center justify-center gap-2"
>
{isDeleting ? (
<span className="animate-spin w-4 h-4 border-2 border-white/30 border-t-white rounded-full"></span>
) : (
<>
<span>Supprimer</span>
<span className="text-red-200">❌</span>
</>
)}
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Summary Modal */}
<AnimatePresence>
{summaryModal.isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 overflow-y-auto"
onClick={() => setSummaryModal(prev => ({ ...prev, isOpen: false }))}
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
onClick={(e) => e.stopPropagation()}
className="bg-[#FDFCF8] rounded-[2.5rem] p-8 shadow-2xl max-w-2xl w-full my-auto relative border border-white/50"
>
<div className="absolute top-6 right-8">
<button
onClick={() => setSummaryModal(prev => ({ ...prev, isOpen: false }))}
className="w-10 h-10 bg-white shadow-sm border border-slate-100 rounded-full flex items-center justify-center text-slate-400 hover:text-slate-600 transition-all hover:scale-110 active:scale-95"
>
<X className="w-5 h-5" />
</button>
</div>
{summaryModal.loading ? (
<div className="py-20 flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-indigo-100 border-t-indigo-600 rounded-full animate-spin"></div>
<p className="text-slate-400 font-bold animate-pulse">Chargement du résumé...</p>
</div>
) : summaryModal.data ? (
<div className="animate-in fade-in zoom-in-95 duration-300">
<div className="mb-8 border-b border-indigo-100/30 pb-6 pr-10">
<div className="flex items-center gap-2 mb-2">
<span className="px-3 py-1 bg-indigo-50 text-indigo-600 rounded-full text-[10px] font-black uppercase tracking-widest border border-indigo-100">Résumé du cours</span>
</div>
<h3 className="text-2xl font-black text-slate-800 leading-tight">
{summaryModal.data.topic}
</h3>
</div>
<div className="space-y-8 max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
{/* Synthesis Section (Magic Summary) */}
{summaryModal.data.synthesis && (
<div className="bg-indigo-50 rounded-[2rem] p-6 border border-indigo-100/50 shadow-sm">
<h4 className="flex items-center gap-2 text-indigo-900 font-black mb-4 uppercase tracking-tighter text-sm">
<Sparkles className="w-5 h-5 text-indigo-500 animate-pulse" />
Résumé Magique
</h4>
<div className="text-slate-700 leading-relaxed font-medium italic whitespace-pre-wrap">
{summaryModal.data.synthesis}
</div>
</div>
)}
{/* Tips & Mnemonics Split Section */}
{summaryModal.data.study_tips && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{(() => {
try {
const allTips: string[] = JSON.parse(summaryModal.data.study_tips);
if (!Array.isArray(allTips)) return null;
const mnemonics = allTips.filter(tip =>
tip.toLowerCase().includes('moyen') ||
tip.toLowerCase().includes('mémo') ||
tip.toLowerCase().includes('phrase') ||
tip.length < 50
);
const regularTips = allTips.filter(tip => !mnemonics.includes(tip));
return (
<>
{/* Regular Tips Column */}
<div className="space-y-4">
<h4 className="flex items-center gap-2 text-amber-700 font-black uppercase tracking-tighter text-xs">
<Lightbulb className="w-4 h-4 text-amber-500" />
Astuces
</h4>
<div className="space-y-3">
{regularTips.length > 0 ? regularTips.map((tip, idx) => (
<div key={idx} className="p-4 bg-amber-50/40 border border-amber-100/50 rounded-2xl text-amber-900/80 text-xs font-bold leading-relaxed shadow-sm italic">
"{tip}"
</div>
)) : (
<p className="text-xs text-slate-400 italic">Aucun conseil particulier.</p>
)}
</div>
</div>
{/* Mnemonics Column */}
<div className="space-y-4">
<h4 className="flex items-center gap-2 text-purple-700 font-black uppercase tracking-tighter text-xs">
<Brain className="w-4 h-4 text-purple-500" />
Mémos
</h4>
<div className="space-y-3">
{mnemonics.length > 0 ? mnemonics.map((memo, idx) => (
<div key={idx} className="p-4 bg-purple-50/40 border border-purple-100/50 rounded-2xl text-purple-900/80 text-xs font-black leading-relaxed shadow-sm">
{memo}
</div>
)) : (
<p className="text-xs text-slate-400 italic">Pas de mémotechnique.</p>
)}
</div>
</div>
</>
);
} catch (e) { return null; }
})()}
</div>
)}
</div>
<div className="mt-8 pt-6 border-t border-slate-100 flex justify-center">
<button
onClick={() => {
setSummaryModal(prev => ({ ...prev, isOpen: false }));
navigate(`/play/${summaryModal.data.id}`);
}}
className="px-8 py-4 bg-indigo-600 text-white font-black rounded-2xl shadow-xl shadow-indigo-100 hover:bg-indigo-700 hover:scale-105 transition-all active:scale-95 flex items-center gap-3"
>
<Play className="w-5 h-5 ml-1" />
<span>Commencer le Quiz</span>
</button>
</div>
</div>
) : null}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
@@ -0,0 +1,55 @@
import { useState, useEffect } from 'react';
import { Clock, Calendar } from 'lucide-react';
export function TimeWidget() {
const [date, setDate] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setDate(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// Format Date: "Lundi 30 Janvier"
const dateString = date.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long'
});
// Format Time: "14:05"
const timeString = date.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
});
return (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm relative overflow-hidden group hover:shadow-md transition-shadow">
{/* Background Decorations */}
<div className="absolute top-0 right-0 w-24 h-24 bg-blue-400/10 rounded-full blur-2xl -mr-5 -mt-5"></div>
<div className="absolute bottom-0 left-0 w-20 h-20 bg-indigo-400/10 rounded-full blur-xl -ml-5 -mb-5"></div>
<div className="relative z-10 flex flex-col items-center text-center">
<div className="flex items-center gap-2 text-indigo-500 mb-1">
<Calendar className="w-4 h-4" />
<span className="text-sm font-bold uppercase tracking-wider text-indigo-900/60 first-letter:uppercase">
{dateString}
</span>
</div>
<div className="flex items-baseline gap-1 mt-1">
<h2 className="text-5xl font-black text-gray-900 tracking-tighter tabular-nums">
{timeString}
</h2>
{/* Animated seconds indicator or simple colon blinking (optional, keeping clean for now) */}
</div>
<div className="mt-3 inline-flex items-center gap-2 px-3 py-1 rounded-full bg-indigo-50 border border-indigo-100">
<Clock className="w-3 h-3 text-indigo-500 animate-pulse" />
<span className="text-[10px] font-bold text-indigo-700 uppercase tracking-wide">
Temps Réel
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { useAuth } from '../../stores/useAuth';
export function UsageWidget() {
const { user } = useAuth();
// Safety check
if (!user) return null;
const totalTokens = user.total_tokens_used || 0;
const totalCost = user.total_cost_usd || 0;
return (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm relative overflow-hidden">
{/* Background Decorations */}
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-purple-500/20 rounded-full blur-2xl -ml-5 -mb-5"></div>
<div className="relative z-10">
<div className="mb-6">
<h3 className="text-xl font-bold text-gray-900">Ma Consommation</h3>
<p className="text-xs font-bold text-gray-400 mt-1 uppercase tracking-widest">Aperçu de l'utilisation de l'IA.</p>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center px-4 py-3 bg-gray-50 rounded-xl border border-gray-100 transition-all hover:bg-white hover:shadow-sm">
<dt className="text-sm font-bold text-gray-500 uppercase tracking-widest">Tokens</dt>
<dd className="text-lg font-black text-gray-900">
{totalTokens.toLocaleString()}
</dd>
</div>
<div className="flex justify-between items-center px-4 py-3 bg-emerald-50/50 rounded-xl border border-emerald-100 transition-all hover:bg-emerald-50 hover:shadow-sm">
<dt className="text-sm font-bold text-emerald-700/70 uppercase tracking-widest">Coût</dt>
<dd className="text-lg font-black text-emerald-600">
{totalCost.toFixed(4)} <span className="text-[10px] uppercase ml-1">USD</span>
</dd>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { type LucideIcon, Trophy, Flame, Star, Zap, Award, BookOpen, Clock } from 'lucide-react';
import { motion } from 'framer-motion';
interface BadgeIconProps {
code: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
unlocked?: boolean;
}
// Badge Definitions
const BADGE_CONFIG: Record<string, { icon: LucideIcon; color: string; bg: string; tier: 'bronze' | 'silver' | 'gold' }> = {
'FIRST_STEPS': { icon: Trophy, color: 'text-amber-700', bg: 'bg-gradient-to-br from-amber-100 to-amber-200', tier: 'bronze' },
'ON_FIRE': { icon: Flame, color: 'text-orange-600', bg: 'bg-gradient-to-br from-orange-100 to-orange-200', tier: 'gold' },
'MATH_CHAMP': { icon: Zap, color: 'text-blue-600', bg: 'bg-gradient-to-br from-blue-100 to-blue-200', tier: 'silver' },
'NIGHT_OWL': { icon: Clock, color: 'text-indigo-600', bg: 'bg-gradient-to-br from-indigo-100 to-indigo-200', tier: 'bronze' },
'PERFECT_SCORE': { icon: Star, color: 'text-yellow-600', bg: 'bg-gradient-to-br from-yellow-100 to-yellow-200', tier: 'gold' },
'BOOKWORM': { icon: BookOpen, color: 'text-emerald-600', bg: 'bg-gradient-to-br from-emerald-100 to-emerald-200', tier: 'silver' },
};
const SIZES = {
sm: 'w-8 h-8 text-xs',
md: 'w-12 h-12 text-base',
lg: 'w-16 h-16 text-xl',
xl: 'w-24 h-24 text-3xl'
};
const BORDERS = {
bronze: 'border-amber-300',
silver: 'border-slate-300',
gold: 'border-yellow-300'
};
export function BadgeIcon({ code, size = 'md', unlocked = true }: BadgeIconProps) {
const config = BADGE_CONFIG[code] || { icon: Award, color: 'text-gray-500', bg: 'bg-gray-100', tier: 'silver' };
const Icon = config.icon;
if (!unlocked) {
return (
<div className={`${SIZES[size]} rounded-full bg-gray-100 border-2 border-dashed border-gray-300 flex items-center justify-center text-gray-300`}>
<Icon className="w-1/2 h-1/2" />
</div>
);
}
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 20 }}
className={`
${SIZES[size]}
rounded-full
flex items-center justify-center
${config.bg}
${config.color}
border-2 ${BORDERS[config.tier]}
shadow-sm
relative
group
`}>
{/* Shine effect */}
<div className="absolute inset-0 rounded-full bg-white/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<Icon className="w-1/2 h-1/2 relative z-10 drop-shadow-sm" />
{/* Tier Star (Optional tiny indicator) */}
{config.tier === 'gold' && (
<Star className="absolute -top-1 -right-1 w-[30%] h-[30%] text-yellow-500 fill-yellow-500 stroke-white stroke-2" />
)}
</motion.div>
);
}
@@ -0,0 +1,220 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
interface ProgressStep {
step: string;
message: string;
progress: number;
result?: AnalysisResult;
}
interface AnalysisResult {
title: string;
subject: string;
raw_text: string;
synthesis: string;
study_tips: string[];
is_math_content: boolean;
math_safety_triggered: boolean;
}
const STEP_ICONS: Record<string, string> = {
uploading: '📤',
reading: '👁️',
analyzing: '🧠',
synthesizing: '✨',
complete: '✅',
error: '❌'
};
const STEP_LABELS: Record<string, string> = {
uploading: 'Envoi',
reading: 'Lecture',
analyzing: 'Analyse',
synthesizing: 'Synthèse',
complete: 'Terminé',
error: 'Erreur'
};
export const AnalyzePage = () => {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState<ProgressStep | null>(null);
const [result, setResult] = useState<AnalysisResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const docs = sessionStorage.getItem('pendingDocuments');
if (!docs) {
navigate('/new');
return;
}
try {
const imagesBase64 = JSON.parse(docs);
startAnalysis(imagesBase64);
} catch (e) {
console.error('Error parsing pending documents:', e);
navigate('/new');
}
}, []);
const startAnalysis = async (imagesBase64: string[]) => {
try {
const token = localStorage.getItem('access_token');
const response = await fetch('/api/ingest/analyze-stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ images_base64: imagesBase64 })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Analyse échouée');
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('Pas de corps de réponse');
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6)) as ProgressStep;
setCurrentStep(data);
if (data.step === 'complete' && data.result) {
setResult(data.result);
sessionStorage.setItem('analysisResult', JSON.stringify(data.result));
sessionStorage.removeItem('pendingDocuments');
} else if (data.step === 'error') {
setError(data.message);
}
}
}
}
} catch (err: any) {
setError(err.message || 'Analyse échouée');
}
};
const handleContinue = () => {
navigate('/review');
};
const handleRetry = () => {
sessionStorage.removeItem('pendingDocuments');
navigate('/new');
};
if (error) {
return (
<div className="min-h-screen bg-[#FDFCF8] flex items-center justify-center p-6">
<div className="bg-white rounded-[2.5rem] shadow-2xl shadow-red-100 border border-red-50 p-12 max-w-md w-full text-center">
<div className="w-20 h-20 bg-red-50 rounded-full flex items-center justify-center text-4xl mx-auto mb-6">❌</div>
<h2 className="text-3xl font-black text-slate-900 mb-4">Erreur d'analyse</h2>
<p className="text-slate-500 font-medium mb-10">{error}</p>
<button
onClick={handleRetry}
className="w-full py-4 bg-slate-900 text-white font-black rounded-2xl hover:bg-slate-800 transition-all active:scale-95 shadow-xl shadow-slate-200"
>
Réessayer
</button>
</div>
</div>
);
}
if (result) {
return (
<div className="min-h-screen bg-[#FDFCF8] flex items-center justify-center p-6">
<div className="bg-white rounded-[2.5rem] shadow-2xl shadow-indigo-100 border border-slate-50 p-12 max-w-md w-full text-center animate-in zoom-in-95 duration-500">
<div className="w-20 h-20 bg-emerald-50 rounded-full flex items-center justify-center text-4xl mx-auto mb-6">✅</div>
<h2 className="text-3xl font-black text-slate-900 mb-6">Analyse terminée !</h2>
<div className="space-y-4 mb-10 p-6 bg-slate-50 rounded-2xl text-left">
<p className="text-sm font-bold text-slate-400 uppercase tracking-widest">Détails de la leçon</p>
<p className="text-slate-700 font-black">
{result.title}
</p>
<div className="inline-flex px-3 py-1 bg-indigo-100 text-indigo-700 text-xs font-black rounded-full uppercase tracking-tighter">
{result.subject}
</div>
</div>
{result.math_safety_triggered && (
<div className="mb-8 p-4 bg-amber-50 rounded-2xl text-amber-700 text-sm font-bold border border-amber-100 flex items-center gap-3">
<span className="text-xl">⚠️</span>
Mode mathématique activé
</div>
)}
<button
onClick={handleContinue}
className="w-full py-4 bg-indigo-600 text-white font-black rounded-2xl hover:bg-indigo-700 transition-all active:scale-95 shadow-xl shadow-indigo-100"
>
Continuer
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#FDFCF8] flex items-center justify-center p-6 text-center">
<div className="max-w-md w-full">
<div className="mb-12 relative">
<div className="w-32 h-32 bg-indigo-600 rounded-[2.5rem] flex items-center justify-center text-5xl mx-auto shadow-2xl shadow-indigo-200 animate-bounce">
{STEP_ICONS[currentStep?.step || 'uploading'] || '⏳'}
</div>
</div>
<h2 className="text-3xl font-black text-slate-900 mb-4 animate-pulse">
{currentStep?.message || "Analyse en cours..."}
</h2>
<p className="text-slate-500 font-medium mb-12">L'intelligence artificielle transforme votre cours en quiz interactif.</p>
{/* Progress Bar */}
<div className="mb-12 p-1 bg-white rounded-full shadow-inner border border-slate-50">
<div className="h-4 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-indigo-500 to-purple-500 transition-all duration-1000 ease-out rounded-full"
style={{ width: `${currentStep?.progress || 0}%` }}
/>
</div>
</div>
{/* Step Indicators */}
<div className="flex justify-between relative px-2">
<div className="absolute top-1/2 left-0 right-0 h-1 bg-slate-100 -translate-y-1/2 -z-10"></div>
{['uploading', 'reading', 'analyzing', 'synthesizing'].map((step, i) => (
<div
key={step}
className={`flex flex-col items-center gap-2 transition-all duration-500 ${(currentStep?.progress || 0) >= (i + 1) * 25
? 'scale-110 opacity-100'
: 'opacity-40 grayscale scale-90'
}`}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl ${(currentStep?.progress || 0) >= (i + 1) * 25 ? 'bg-indigo-600 shadow-lg shadow-indigo-100 text-white' : 'bg-white border-2 border-slate-100 text-slate-300'}`}>
{STEP_ICONS[step]}
</div>
<span className="text-[10px] font-black uppercase tracking-tighter text-slate-600">{STEP_LABELS[step]}</span>
</div>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,219 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import * as pdfjs from 'pdfjs-dist';
import { X, Plus, FileText, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
// Initialize PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
interface DocumentItem {
id: string;
url: string;
type: 'image' | 'pdf_page';
name: string;
}
const convertPdfToImages = async (file: File): Promise<DocumentItem[]> => {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
const items: DocumentItem[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('No 2d context');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({
canvasContext: context,
viewport: viewport,
canvas: canvas
}).promise;
items.push({
id: `${file.name}-p${i}-${Date.now()}`,
url: canvas.toDataURL('image/jpeg', 0.8),
type: 'pdf_page',
name: `${file.name} (Page ${i})`
});
}
return items;
};
export const NewRevisionPage = () => {
const navigate = useNavigate();
const [documents, setDocuments] = useState<DocumentItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const handleFiles = async (files: FileList) => {
setIsProcessing(true);
const newDocs: DocumentItem[] = [];
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type === 'application/pdf') {
const pdfPages = await convertPdfToImages(file);
newDocs.push(...pdfPages);
} else if (file.type.startsWith('image/')) {
const url = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
newDocs.push({
id: `${file.name}-${Date.now()}`,
url,
type: 'image',
name: file.name
});
}
}
setDocuments(prev => [...prev, ...newDocs]);
} catch (error) {
console.error('Error processing files:', error);
alert('Erreur lors du traitement de certains fichiers.');
} finally {
setIsProcessing(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) handleFiles(e.target.files);
};
const removeDocument = (id: string) => {
setDocuments(prev => prev.filter(doc => doc.id !== id));
};
const handleAnalyze = () => {
if (documents.length === 0) return;
// Store all images for analysis
const imagesBase64 = documents.map(doc => doc.url);
sessionStorage.setItem('pendingDocuments', JSON.stringify(imagesBase64));
navigate('/analyze');
};
return (
<div className="min-h-screen bg-slate-50 py-12 px-4">
<div className="max-w-5xl mx-auto">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => navigate(-1)}
className="mb-8 flex items-center gap-3 px-4 py-2 bg-white border-2 border-slate-100 text-slate-500 font-bold rounded-xl hover:text-indigo-600 hover:border-indigo-100 transition-all shadow-sm group"
>
<ArrowLeft className="w-5 h-5" />
<span>Retour</span>
</motion.button>
<header className="mb-12 text-center">
<h1 className="text-5xl font-black text-slate-900 tracking-tight mb-4">
Nouveau Cours
</h1>
<p className="text-xl text-slate-500 font-medium">
Ajoutez vos documents (PDF ou Photos) pour créer vos fiches de révision.
</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Upload Section */}
<div className="lg:col-span-1">
<div className="bg-white rounded-[2rem] shadow-xl shadow-slate-200/50 p-8 border border-slate-100 sticky top-8">
<label className="block w-full cursor-pointer">
<input
type="file"
multiple
accept="image/*,.pdf"
onChange={handleFileChange}
className="hidden"
/>
<div className="border-4 border-dashed border-slate-100 rounded-3xl p-8 text-center hover:border-indigo-400 hover:bg-slate-50 transition-all group">
<div className="w-16 h-16 bg-indigo-50 rounded-2xl flex items-center justify-center text-indigo-500 mx-auto mb-4 group-hover:scale-110 transition-transform">
<Plus className="w-8 h-8" />
</div>
<p className="font-black text-slate-900">Ajouter des fichiers</p>
<p className="text-xs text-slate-400 mt-2">PDF ou Photos (JPG, PNG)</p>
</div>
</label>
<div className="mt-8 pt-8 border-t border-slate-50">
<button
onClick={handleAnalyze}
disabled={documents.length === 0 || isProcessing}
className="w-full py-4 bg-indigo-600 text-white rounded-2xl font-black shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all disabled:opacity-50 disabled:shadow-none flex items-center justify-center gap-2"
>
{isProcessing ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>Lancer l'analyse ({documents.length})</>
)}
</button>
{documents.length > 0 && (
<button
onClick={() => setDocuments([])}
className="w-full mt-4 py-2 text-slate-400 hover:text-red-500 font-bold transition-colors"
>
Tout effacer
</button>
)}
</div>
</div>
</div>
{/* Gallery Section */}
<div className="lg:col-span-2">
{documents.length === 0 ? (
<div className="bg-white rounded-[2rem] border-4 border-dashed border-slate-100 flex flex-col items-center justify-center p-20 text-slate-300">
<FileText className="w-20 h-20 mb-4 opacity-20" />
<p className="text-xl font-bold">Aucun document ajouté</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6">
{documents.map((doc) => (
<div
key={doc.id}
className="group relative aspect-[3/4] bg-white rounded-3xl shadow-md overflow-hidden border border-slate-100 hover:shadow-xl transition-all"
>
<img
src={doc.url}
alt={doc.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center p-4">
<button
onClick={() => removeDocument(doc.id)}
className="absolute top-3 right-3 w-8 h-8 bg-white/20 hover:bg-red-500 text-white rounded-full flex items-center justify-center backdrop-blur-md transition-all"
>
<X className="w-5 h-5" />
</button>
<div className="text-white text-center">
{doc.type === 'pdf_page' ? (
<FileText className="w-8 h-8 mx-auto mb-2" />
) : (
<ImageIcon className="w-8 h-8 mx-auto mb-2" />
)}
<p className="text-[10px] font-bold line-clamp-2 px-2 leading-tight">
{doc.name}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
};
+236
View File
@@ -0,0 +1,236 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../lib/api';
import { useAuth } from '../../stores/useAuth';
import { DyslexiaToggle } from '../../components/DyslexiaToggle';
import { useAccessibility } from '../../stores/useAccessibility';
import { motion } from 'framer-motion';
interface AnalysisResult {
title: string;
subject: string;
raw_text: string;
synthesis: string;
study_tips?: string[];
is_math_content: boolean;
math_safety_triggered: boolean;
}
const SUBJECTS = [
'Histoire',
'Géographie',
'Français',
'Mathématiques',
'Sciences',
'Physique-Chimie',
'SVT',
'Anglais',
'Espagnol',
'Allemand',
'Arts Plastiques',
'Musique',
'EPS',
'Technologie',
'Autre'
];
export const ReviewPage = () => {
const navigate = useNavigate();
const { isDyslexic } = useAccessibility();
const { activeLearner } = useAuth();
const [result, setResult] = useState<AnalysisResult | null>(null);
const [title, setTitle] = useState('');
const [subject, setSubject] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const storedResult = sessionStorage.getItem('analysisResult');
if (!storedResult) {
navigate('/new');
return;
}
const parsed = JSON.parse(storedResult) as AnalysisResult;
setResult(parsed);
setTitle(parsed.title);
setSubject(parsed.subject);
}, []);
const handleSubmit = async () => {
if (!result) return;
setIsSubmitting(true);
setError(null);
const updatedResult = { ...result, title, subject };
try {
await api.post('/quiz/generate', {
text_content: updatedResult.raw_text,
difficulty: 'medium',
title: updatedResult.title,
subject: updatedResult.subject,
synthesis: updatedResult.synthesis,
study_tips: updatedResult.study_tips,
learner_id: activeLearner?.id
});
sessionStorage.removeItem('analysisResult');
sessionStorage.removeItem('lessonData');
navigate('/dashboard');
} catch (err: any) {
console.error(err);
setError("Erreur lors de la sauvegarde : " + (err.response?.data?.detail || err.message));
setIsSubmitting(false);
}
};
const handleBack = () => {
sessionStorage.removeItem('analysisResult');
navigate('/new');
};
if (!result) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-gray-500">Chargement...</p>
</div>
);
}
return (
<div className={`min-h-screen bg-[#FDFCF8] py-12 ${isDyslexic ? 'font-dyslexic' : ''}`}>
<div className="max-w-4xl mx-auto px-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-12">
<div>
<h1 className="text-4xl font-black text-slate-900 tracking-tight">
Vérifiez l'analyse
</h1>
<p className="text-slate-500 font-medium mt-2">Ajustez les détails avant de générer votre quiz.</p>
</div>
<DyslexiaToggle />
</div>
{/* Math Safety Warning */}
{result.math_safety_triggered && (
<div className="mb-8 p-6 bg-amber-50 border border-amber-100 rounded-[2rem] flex items-center gap-6 shadow-sm">
<div className="w-16 h-16 bg-white rounded-2xl flex items-center justify-center text-3xl shadow-sm">⚠️</div>
<div>
<h3 className="font-black text-amber-900 uppercase tracking-wider text-sm mb-1">
Contenu mathématique détecté
</h3>
<p className="text-amber-700 font-medium">
Le mode "Extraction Stricte" est activé. Les équations ont été extraites sans résolution.
</p>
</div>
</div>
)}
{/* Editable Fields */}
<div className="bg-white rounded-[2.5rem] shadow-xl shadow-indigo-100/50 border border-slate-50 p-10 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-10">
<div className="space-y-2 md:col-span-2">
<label className="block text-sm font-black text-slate-700 uppercase tracking-widest ml-1">
Titre de la leçon
</label>
<textarea
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-5 py-4 bg-slate-50 border-2 border-transparent rounded-2xl focus:border-indigo-500 focus:bg-white transition-all font-bold outline-none text-slate-900 resize-none h-auto min-h-[5rem]"
placeholder="Ex: La Révolution Française"
rows={2}
/>
</div>
<div className="space-y-2 md:col-span-2">
<label className="block text-sm font-black text-slate-700 uppercase tracking-widest ml-1">
Matière
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-5 py-4 bg-slate-50 border-2 border-transparent rounded-2xl focus:border-indigo-500 focus:bg-white transition-all font-bold outline-none text-slate-900 appearance-none"
>
{SUBJECTS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
{/* Synthesis */}
<div className="mb-10">
<label className="block text-sm font-black text-slate-700 uppercase tracking-widest ml-1 mb-3">
Résumé magique ✨
</label>
<div className="p-8 bg-indigo-50/30 border border-indigo-50 rounded-[2rem] whitespace-pre-wrap text-slate-700 font-medium leading-relaxed italic">
{result.synthesis}
</div>
</div>
{/* Study Tips */}
{result.study_tips && result.study_tips.length > 0 && (
<div className="mb-10">
<label className="block text-sm font-black text-slate-700 uppercase tracking-widest ml-1 mb-3">
Conseils Métho 💡
</label>
<div className="grid gap-4 md:grid-cols-3">
{result.study_tips.map((tip, idx) => (
<div key={idx} className="p-6 bg-emerald-50/50 border border-emerald-100 rounded-[1.5rem] shadow-sm">
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center text-emerald-600 font-black text-sm mb-3 shadow-sm border border-emerald-50">
{idx + 1}
</div>
<p className="text-slate-700 font-bold text-sm leading-relaxed">
{tip}
</p>
</div>
))}
</div>
</div>
)}
{/* Raw Text (collapsed by default) */}
<details className="group">
<summary className="cursor-pointer text-sm font-black text-indigo-500 uppercase tracking-widest hover:text-indigo-600 transition-colors list-none flex items-center gap-2">
<span className="group-open:rotate-180 transition-transform">📄</span>
Voir le texte extrait complet
</summary>
<div className="mt-6 p-6 bg-slate-50 rounded-2xl whitespace-pre-wrap text-slate-400 font-mono text-xs max-h-64 overflow-y-auto border border-slate-100">
{result.raw_text}
</div>
</details>
</div>
{/* Actions */}
<div className="flex gap-4">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleBack}
className="flex-1 py-5 px-6 bg-white border-2 border-slate-100 rounded-2xl text-slate-600 font-black hover:bg-slate-50 hover:border-slate-200 transition-all shadow-sm flex items-center justify-center gap-3"
>
<span>← Retour</span>
</motion.button>
<button
onClick={handleSubmit}
disabled={isSubmitting || !title.trim()}
className="flex-1 py-5 px-6 bg-indigo-600 text-white rounded-2xl font-black shadow-xl shadow-indigo-100 hover:bg-indigo-700 transition-all disabled:opacity-50 active:scale-95 flex items-center justify-center gap-3"
>
{isSubmitting ? (
<>
<span className="animate-spin text-xl">⏳</span>
Sauvegarde...
</>
) : 'Valider & Sauvegarder'}
</button>
{error && (
<div className="md:col-span-2 text-red-500 font-bold text-center mt-4 bg-red-50 p-4 rounded-xl w-full">
{error}
</div>
)}
</div>
</div>
</div>
);
};
+173
View File
@@ -0,0 +1,173 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { api } from '../../lib/api';
import { useAuth } from '../../stores/useAuth';
import {
BookOpen,
Calendar,
ChevronLeft,
ArrowRight,
Search,
Book,
Lightbulb,
Atom,
Globe,
Music,
Palette
} from 'lucide-react';
import { motion } from 'framer-motion';
// reuse styles from widget
const SUBJECT_STYLES: Record<string, { icon: React.ReactNode, bg: string, color: string }> = {
'Mathématiques': { icon: <Atom className="w-5 h-5" />, bg: 'bg-blue-100', color: 'text-blue-600' },
'Français': { icon: <Book className="w-5 h-5" />, bg: 'bg-orange-100', color: 'text-orange-600' },
'Sciences': { icon: <Lightbulb className="w-5 h-5" />, bg: 'bg-emerald-100', color: 'text-emerald-600' },
'Histoire-Géo': { icon: <Globe className="w-5 h-5" />, bg: 'bg-purple-100', color: 'text-purple-600' },
'Musique': { icon: <Music className="w-5 h-5" />, bg: 'bg-pink-100', color: 'text-pink-600' },
'Arts': { icon: <Palette className="w-5 h-5" />, bg: 'bg-amber-100', color: 'text-amber-600' },
'default': { icon: <BookOpen className="w-5 h-5" />, bg: 'bg-slate-100', color: 'text-slate-600' }
};
interface Revision {
id: string;
topic: string;
subject: string;
created_at: string;
current_series: number;
total_series: number;
status: string;
pending_errors?: number;
}
export const HistoryPage: React.FC = () => {
const navigate = useNavigate();
const { activeLearner } = useAuth();
const [revisions, setRevisions] = useState<Revision[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const fetchAll = async () => {
if (!activeLearner) return;
try {
const res = await api.get(`/quiz/revisions?learner_id=${activeLearner.id}`);
setRevisions(res.data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
fetchAll();
}, [activeLearner]);
const filteredRevisions = revisions.filter(rev =>
(rev.topic?.toLowerCase().includes(searchTerm.toLowerCase())) ||
(rev.subject?.toLowerCase().includes(searchTerm.toLowerCase()))
);
const getSubjectStyle = (subject: string) => SUBJECT_STYLES[subject] || SUBJECT_STYLES['default'];
return (
<div className="min-h-screen bg-[#f8fafc] font-sans text-gray-900 pb-20">
{/* Nav */}
<nav className="bg-white/80 backdrop-blur-md border-b border-gray-200 sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 px-4 py-2 bg-white border-2 border-slate-100 text-slate-500 font-bold rounded-xl hover:text-indigo-600 hover:border-indigo-100 transition-all shadow-sm"
>
<ChevronLeft className="w-5 h-5" />
<span>Retour</span>
</motion.button>
<h1 className="text-xl font-black tracking-tight text-gray-900">Mes Cours</h1>
<div className="w-20"></div> {/* spacer */}
</div>
</div>
</nav>
<main className="max-w-4xl mx-auto py-10 px-4">
{/* Search Bar */}
<div className="relative mb-10">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 w-5 h-5" />
<input
type="text"
placeholder="Rechercher un cours ou une matière..."
className="w-full pl-12 pr-6 py-4 bg-white border border-slate-200 rounded-2xl shadow-sm focus:ring-4 focus:ring-indigo-50 focus:border-indigo-200 transition-all outline-none font-medium"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
<p className="font-bold text-slate-400">Chargement de tes cours...</p>
</div>
) : filteredRevisions.length > 0 ? (
<div className="grid grid-cols-1 gap-4">
{filteredRevisions.map((rev) => {
const style = getSubjectStyle(rev.subject);
return (
<motion.div
key={rev.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
onClick={() => navigate(`/play/${rev.id}`)}
className="group bg-white border border-slate-200 p-5 rounded-2xl hover:border-indigo-200 hover:shadow-xl hover:shadow-indigo-50/50 transition-all cursor-pointer flex items-center gap-6"
>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center shrink-0 ${style.bg} ${style.color}`}>
{style.icon}
</div>
<div className="flex-1 min-w-0">
<div className={`text-[11px] font-black uppercase tracking-widest mb-1 ${style.color}`}>
{rev.subject || 'Général'}
</div>
<h3 className="text-lg font-bold text-slate-800 group-hover:text-indigo-600 transition-colors truncate">
{rev.topic}
</h3>
<div className="flex items-center gap-3 mt-1.5">
<div className="flex items-center gap-1 text-xs font-bold text-slate-400 bg-slate-50 px-2 py-0.5 rounded-md">
<Calendar className="w-3 h-3" />
{new Date(rev.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</div>
<span className="text-slate-300">•</span>
<div className="text-xs font-black text-indigo-400 uppercase tracking-tight">
Quizz {rev.current_series}/{rev.total_series}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{rev.status === 'COMPLETED' && (
<span className="hidden sm:flex px-3 py-1.5 bg-green-50 text-green-600 text-[10px] font-black uppercase tracking-widest rounded-lg border border-green-100 items-center gap-2">
<span>✅</span> Terminé
</span>
)}
<div className="w-10 h-10 rounded-full bg-slate-50 flex items-center justify-center text-slate-300 group-hover:bg-indigo-600 group-hover:text-white transition-all">
<ArrowRight className="w-5 h-5" />
</div>
</div>
</motion.div>
);
})}
</div>
) : (
<div className="text-center py-20 bg-white rounded-[2rem] border border-dashed border-slate-200">
<div className="text-4xl mb-4">🔍</div>
<h3 className="text-xl font-bold text-slate-800">Aucun cours trouvé</h3>
<p className="text-slate-500 mt-2">Essaie une autre recherche ou crée un nouveau cours !</p>
<Link to="/new" className="mt-6 inline-flex px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all active:scale-95 shadow-lg shadow-indigo-100">
Créer un cours
</Link>
</div>
)}
</main>
</div>
);
};
+117
View File
@@ -0,0 +1,117 @@
import { useState, useEffect } from 'react';
interface QuestionCardProps {
question: string;
options: string[];
selectedOption: number | null;
correctAnswer: number;
onSelectOption: (index: number) => void;
currentStep: number;
totalSteps: number;
explanation?: string;
}
export function QuestionCard({
question,
options,
selectedOption,
correctAnswer,
onSelectOption,
currentStep,
totalSteps,
explanation
}: QuestionCardProps) {
const [animation, setAnimation] = useState<'pop' | 'shake' | null>(null);
useEffect(() => {
if (selectedOption !== null) {
if (selectedOption === correctAnswer) {
setAnimation('pop');
} else {
setAnimation('shake');
}
// Removed auto-hide timer for animation to keep state clear,
// but we might want to keep the effect.
// The user wants "time to read", so we shouldn't auto-advance too fast in parent.
const timer = setTimeout(() => setAnimation(null), 500);
return () => clearTimeout(timer);
}
}, [selectedOption, correctAnswer]);
const isCorrect = selectedOption === correctAnswer;
return (
<div className={`bg-white rounded-xl border border-slate-200 shadow-soft-indigo overflow-hidden max-w-2xl mx-auto mt-8 p-8 transition-transform duration-300 ${animation === 'pop' ? 'scale-105' : animation === 'shake' ? 'animate-shake' : ''
}`}>
<div className="mb-10">
<div className="flex justify-between items-center mb-3">
<span className="text-xs font-black text-indigo-400 uppercase tracking-[0.2em]">Question {currentStep} / {totalSteps}</span>
<span className="text-xs font-black text-slate-400">{Math.round((currentStep / totalSteps) * 100)}%</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2.5 p-0.5">
<div
className="bg-gradient-to-r from-indigo-500 to-purple-500 h-1.5 rounded-full transition-all duration-700 ease-out shadow-sm"
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
></div>
</div>
</div>
<h2 className="text-3xl font-black text-slate-900 mb-10 leading-tight tracking-tight">{question}</h2>
<div className="grid grid-cols-1 gap-4 mb-4">
{options.map((option, index) => (
<button
key={index}
disabled={selectedOption !== null}
onClick={() => onSelectOption(index)}
className={`w-full text-left p-5 border-2 rounded-xl transition-all duration-200 group relative ${selectedOption === null
? 'border-slate-100 hover:border-soft-indigo hover:bg-indigo-50/30'
: selectedOption === index
? index === correctAnswer
? 'border-green-500 bg-green-50 text-green-700'
: 'border-orange-400 bg-orange-50 text-orange-700'
: index === correctAnswer
? 'border-green-500 bg-green-50/50'
: 'border-slate-100 opacity-50'
}`}
>
<div className="flex items-center">
<span className={`flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-full border-2 text-sm font-bold transition-colors ${selectedOption === null
? 'border-slate-200 text-slate-400 group-hover:border-soft-indigo group-hover:text-soft-indigo'
: selectedOption === index
? index === correctAnswer
? 'border-green-500 bg-green-500 text-white'
: 'border-orange-400 bg-orange-400 text-white'
: index === correctAnswer
? 'border-green-500 text-green-500'
: 'border-slate-200 text-slate-300'
}`}>
{String.fromCharCode(65 + index)}
</span>
<span className="ml-4 text-lg font-medium">{option}</span>
</div>
</button>
))}
</div>
{selectedOption !== null && (
<div className={`mt-6 p-6 rounded-2xl animate-in fade-in slide-in-from-top-2 duration-300 ${isCorrect ? 'bg-green-50 border border-green-100' : 'bg-orange-50 border border-orange-100'}`}>
<div className="flex items-start gap-4">
<div className={`text-2xl ${isCorrect ? 'grayscale-0' : 'grayscale-0'}`}>
{isCorrect ? '🎉' : '💡'}
</div>
<div>
<h4 className={`font-black uppercase tracking-wider text-xs mb-1 ${isCorrect ? 'text-green-600' : 'text-orange-600'}`}>
{isCorrect ? 'Bonne réponse !' : 'Oups, pas tout à fait...'}
</h4>
<p className="text-slate-800 font-medium leading-relaxed">
{explanation || "Pas d'explication disponible."}
</p>
</div>
</div>
</div>
)}
</div>
);
}
+364
View File
@@ -0,0 +1,364 @@
import { useEffect, useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { api } from '../../lib/api';
import { QuestionCard } from './QuestionCard';
import { ResultCard } from './ResultCard';
import { DyslexiaToggle } from '../../components/DyslexiaToggle';
import { useAccessibility } from '../../stores/useAccessibility';
import { useAuth } from '../../stores/useAuth';
import { Skeleton } from '../../components/ui/Skeleton';
import { Modal } from '../../components/ui/Modal';
interface Question {
id: number;
question: string;
options: string[];
correct_answer: number;
explanation: string;
}
interface QuizResponse {
topic: string;
questions: Question[];
series_info?: {
current: number;
total: number;
};
}
export function QuizPage() {
const location = useLocation();
const navigate = useNavigate();
const { isDyslexic } = useAccessibility();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [quizData, setQuizData] = useState<QuizResponse | null>(null);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [selectedOption, setSelectedOption] = useState<number | null>(null);
const [score, setScore] = useState(0);
const [answers, setAnswers] = useState<any[]>([]); // Track details
const [isFinished, setIsFinished] = useState(false);
const [revisionId, setRevisionId] = useState<string | null>(null); // New state
// Badge Modal State
const [showBadgeModal, setShowBadgeModal] = useState(false);
const [newBadges, setNewBadges] = useState<string[]>([]);
// Get text content from navigation state OR params
const textContent = location.state?.textContent;
const { revisionId: paramRevisionId } = useParams();
useEffect(() => {
const loadQuiz = async () => {
setLoading(true);
try {
// Scenario 0: Quiz Data passed via State (e.g. Remediation)
if (location.state?.quizData) {
setQuizData(location.state.quizData);
if (location.state.revisionId) {
setRevisionId(location.state.revisionId);
} else if (location.state.quizData.revision_id) {
setRevisionId(location.state.quizData.revision_id);
}
// If it's pure remediation, we might not want to save a new Revision in backend,
// but we do want to track progress?
// For now, simple display is enough.
}
// Scenario A: Loading existing revision
else if (paramRevisionId) {
setRevisionId(paramRevisionId);
const res = await api.get(`/quiz/review/${paramRevisionId}`);
// Revision has quiz_data as string
if (res.data.quiz_data) {
const parsedQuiz = JSON.parse(res.data.quiz_data);
// Inject series info and completion status
(parsedQuiz as any).series_info = {
current: res.data.current_series,
total: res.data.total_series,
completed: res.data.completed_series || 0
};
setQuizData(parsedQuiz);
// Checks for "Between Series" state
if (res.data.progress_state) {
const progress = JSON.parse(res.data.progress_state);
setCurrentQuestionIndex(progress.current_index || 0);
setScore(progress.score || 0);
setAnswers(progress.answers || []);
} else if (res.data.completed_series >= res.data.current_series && res.data.current_series < res.data.total_series) {
// User finished this part but left before starting the next.
setIsFinished(true);
}
} else {
setError("Ce quiz ne contient pas de données valides.");
}
}
// Scenario B: Generating new quiz
else if (textContent) {
const response = await api.post<QuizResponse & { revision_id: string }>('/quiz/generate', {
text_content: textContent,
difficulty: 'medium',
title: location.state?.title,
subject: location.state?.subject,
synthesis: location.state?.synthesis,
study_tips: location.state?.study_tips,
learner_id: useAuth.getState().activeLearner?.id
});
setQuizData(response.data);
if (response.data.revision_id) {
setRevisionId(response.data.revision_id);
}
} else {
setError("Aucun contenu fourni. Veuillez sélectionner une révision ou en créer une nouvelle.");
}
} catch (err: any) {
console.error("Quiz error:", err);
setError(err.response?.data?.detail || "Erreur lors du chargement du quiz.");
} finally {
setLoading(false);
}
};
loadQuiz();
}, [textContent, paramRevisionId]);
// Manual advance handler
const handleNext = () => {
if (currentQuestionIndex < (quizData?.questions.length || 0) - 1) {
setCurrentQuestionIndex(prev => prev + 1);
setSelectedOption(null);
} else {
setIsFinished(true);
}
};
const handleOptionSelect = (index: number) => {
if (selectedOption !== null) return; // Prevent changing answer
setSelectedOption(index);
if (!quizData) return;
const currentQ = quizData.questions[currentQuestionIndex];
const isCorrect = index === currentQ.correct_answer;
if (isCorrect) {
setScore(prev => prev + 1);
}
// Record answer details
setAnswers(prev => [...prev, {
question: currentQ.question,
user_answer: currentQ.options[index],
correct_answer: currentQ.options[currentQ.correct_answer],
is_correct: isCorrect,
original_content: null
}]);
};
// Save score when quiz is finished
const { activeLearner, checkAuth } = useAuth();
const hasSaved = useRef(false);
useEffect(() => {
if (isFinished && quizData && !hasSaved.current) {
hasSaved.current = true;
// Trigger Confetti if score is good (> 50%)
if (activeLearner && score / quizData.questions.length > 0.5) {
import('canvas-confetti').then((confetti) => {
confetti.default({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
colors: ['#6366f1', '#a855f7', '#ec4899', '#fwd700']
});
});
}
const saveScore = async () => {
try {
const response = await api.post('/quiz/score', {
topic: quizData.topic,
score: score,
total_questions: quizData.questions.length,
learner_id: activeLearner?.id,
details: answers,
revision_id: revisionId
});
// Refresh profile to get updated badges/streaks
await checkAuth(true);
// Check for new badges
if (response.data.new_badges && response.data.new_badges.length > 0) {
setNewBadges(response.data.new_badges);
setShowBadgeModal(true);
}
} catch (e) {
console.error("Failed to save score", e);
hasSaved.current = false;
}
};
saveScore();
}
}, [isFinished, quizData, score, activeLearner, checkAuth, answers, revisionId]);
const handlePause = async () => {
if (!revisionId) {
navigate('/dashboard');
return;
}
try {
await api.post('/quiz/progress/save', {
revision_id: revisionId,
current_index: currentQuestionIndex,
answers: answers,
score: score
});
navigate('/dashboard');
} catch (e) {
console.error("Failed to save progress", e);
navigate('/dashboard');
}
};
const handleNextSeries = async () => {
if (!revisionId) return;
setLoading(true);
try {
const res = await api.post('/quiz/next-series', { revision_id: revisionId });
setQuizData(res.data);
setCurrentQuestionIndex(0);
setScore(0);
setAnswers([]);
setSelectedOption(null); // Fix: Reset selected option to prevent "Ghost Answer" on Q1
setIsFinished(false);
setLoading(false);
} catch (e) {
console.error("Failed to load next series", e);
setError("Impossible de charger la suite du cours. Veuillez réessayer.");
setLoading(false);
}
};
return (
<div className={`min-h-screen bg-cream py-8 px-4 sm:px-6 lg:px-8 ${isDyslexic ? 'font-dyslexic' : 'font-sans'}`}>
<div className="max-w-screen-sm mx-auto relative content-center">
{/* Pause Button */}
{!loading && !error && !isFinished && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handlePause}
className="absolute -top-2 right-0 text-slate-400 hover:text-indigo-600 font-bold text-sm bg-white px-4 py-2 rounded-full shadow-sm hover:shadow-md transition-all border border-slate-100 flex items-center gap-2"
>
<span>⏸ Pause & Quitter</span>
</motion.button>
)}
{loading && (
<div className="w-full max-w-2xl mx-auto bg-white rounded-3xl shadow-xl p-8 animate-pulse">
<div className="flex justify-between items-center mb-8">
<Skeleton className="h-4 w-24 bg-indigo-50" />
<Skeleton className="h-4 w-32 bg-indigo-50" />
</div>
<Skeleton className="h-8 w-3/4 mb-4 bg-gray-200" />
<Skeleton className="h-8 w-1/2 mb-12 bg-gray-200" />
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl bg-gray-100" />
))}
</div>
<div className="mt-8 text-center">
<p className="text-indigo-600 font-medium animate-pulse">Génération de votre quiz en cours...</p>
</div>
</div>
)}
{error && (
<div className="text-center text-red-600 bg-red-50 p-8 rounded-[2.5rem] mt-10 border-2 border-red-100 shadow-xl shadow-red-50 animate-in zoom-in duration-300">
<div className="text-5xl mb-4">⚠️</div>
<h3 className="text-2xl font-black mb-2">Une erreur est survenue</h3>
<p className="font-medium text-red-500 opacity-80">{error}</p>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => navigate('/dashboard')}
className="mt-8 px-8 py-4 bg-white border-2 border-red-100 text-red-600 font-black rounded-2xl hover:bg-red-50 transition-all shadow-lg shadow-red-50 flex items-center justify-center gap-3"
>
Retour au tableau de bord
</motion.button>
</div>
)}
{!loading && !error && quizData && !isFinished && (
<div className="flex flex-col items-center w-full">
<QuestionCard
question={quizData.questions[currentQuestionIndex].question}
options={quizData.questions[currentQuestionIndex].options}
selectedOption={selectedOption}
correctAnswer={quizData.questions[currentQuestionIndex].correct_answer}
onSelectOption={handleOptionSelect}
currentStep={currentQuestionIndex + 1}
totalSteps={quizData.questions.length}
explanation={quizData.questions[currentQuestionIndex].explanation}
/>
{/* NEXT BUTTON */}
{selectedOption !== null && (
<button
onClick={handleNext}
className="mt-8 bg-indigo-600 text-white font-black text-lg py-4 px-12 rounded-2xl shadow-xl shadow-indigo-200 hover:bg-indigo-700 hover:scale-105 hover:shadow-2xl transition-all duration-300 animate-in fade-in slide-in-from-bottom-4 flex items-center gap-3 group"
>
<span>Question Suivante</span>
<span className="text-2xl group-hover:translate-x-1 transition-transform">➜</span>
</button>
)}
</div>
)}
{isFinished && quizData && (
<ResultCard
score={score}
totalQuestions={quizData.questions.length}
topic={quizData.topic}
onRetry={() => window.location.reload()}
onNextSeries={handleNextSeries}
seriesInfo={(quizData as any).series_info}
/>
)}
<div className="mt-8 flex justify-center">
<DyslexiaToggle />
</div>
<Modal
isOpen={showBadgeModal}
onClose={() => setShowBadgeModal(false)}
title="Nouveau Badge Débloqué !"
icon="🏆"
>
<div className="text-gray-600 text-lg">
<p className="mb-4">Félicitations ! Vous avez gagné de nouveaux badges :</p>
<div className="flex flex-wrap gap-2 justify-center">
{newBadges.map((badge, idx) => (
<span key={idx} className="bg-indigo-100 text-indigo-800 px-4 py-2 rounded-xl font-bold">
{badge.replace('_', ' ')}
</span>
))}
</div>
</div>
</Modal>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import confetti from 'canvas-confetti';
interface ResultCardProps {
score: number;
totalQuestions: number;
topic: string;
onRetry: () => void;
onNextSeries?: () => void;
seriesInfo?: { current: number, total: number, completed?: number };
}
export function ResultCard({ score, totalQuestions, topic, onRetry, onNextSeries, seriesInfo }: ResultCardProps) {
const percentage = Math.round((score / totalQuestions) * 100);
useEffect(() => {
if (percentage >= 80) {
const duration = 3 * 1000;
const animationEnd = Date.now() + duration;
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
const interval: any = setInterval(function () {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 50 * (timeLeft / duration);
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
}, 250);
return () => clearInterval(interval);
}
}, [percentage]);
let message = "Continue tes efforts !";
let color = "text-orange-500";
let bgColor = "bg-orange-50";
// Scenario: Resuming between series (Score is 0 because not re-fetched, but we want to encourage continuing)
const isResumingSeries = seriesInfo && seriesInfo.completed && seriesInfo.completed >= seriesInfo.current;
if (isResumingSeries) {
message = "Prêt pour la suite ?";
bgColor = "bg-blue-50";
color = "text-blue-600";
}
else if (percentage >= 80) {
message = "Excellent travail !";
color = "text-green-600";
bgColor = "bg-green-50";
} else if (percentage >= 50) {
message = "Bravo, bel effort !";
color = "text-soft-indigo";
bgColor = "bg-indigo-50";
}
return (
<div className="bg-white rounded-[3rem] shadow-2xl shadow-indigo-100 border border-slate-50 overflow-hidden max-w-xl mx-auto mt-10 p-12 text-center animate-in zoom-in-95 duration-500">
<h2 className="text-4xl font-black text-slate-900 mb-2 tracking-tight">Quiz Terminé !</h2>
<p className="text-slate-400 mb-10 font-bold uppercase tracking-widest text-xs">Sujet : {topic}</p>
<div className={`mb-10 p-8 rounded-2xl ${bgColor}`}>
<div className={`text-7xl font-black ${color} mb-4`}>{percentage}%</div>
<p className="text-slate-700 text-xl font-semibold">{score} sur {totalQuestions} corrects</p>
<p className={`text-lg mt-3 font-medium ${color}`}>{message}</p>
</div>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<button
onClick={onRetry}
className="flex-1 inline-flex justify-center items-center px-6 py-3 border-2 border-soft-indigo text-soft-indigo rounded-xl hover:bg-indigo-50 font-bold transition-all"
>
Recommencer
</button>
{onNextSeries && seriesInfo && seriesInfo.current < seriesInfo.total && (
<button
onClick={onNextSeries}
className="flex-1 inline-flex justify-center items-center px-6 py-3 border-2 border-transparent bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 font-bold shadow-md shadow-indigo-200 transition-all animate-pulse"
>
Suite (Partie {seriesInfo.current + 1}/{seriesInfo.total})
</button>
)}
<Link
to="/dashboard"
className="flex-1 inline-flex justify-center items-center px-6 py-3 border-2 border-transparent bg-soft-indigo text-white rounded-xl hover:opacity-90 font-bold shadow-md shadow-indigo-200 transition-all"
>
Tableau de bord
</Link>
</div>
</div>
);
}
@@ -0,0 +1,150 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '../../lib/api';
import { DyslexiaToggle } from '../../components/DyslexiaToggle';
import { useAccessibility } from '../../stores/useAccessibility';
import { motion } from 'framer-motion';
interface Revision {
id: string;
topic: string;
text_content: string;
synthesis?: string;
study_tips?: string;
created_at: string;
}
export function RevisionViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isDyslexic } = useAccessibility();
const [revision, setRevision] = useState<Revision | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchRevision = async () => {
try {
const response = await api.get<Revision>(`/quiz/review/${id}`);
setRevision(response.data);
} catch (err) {
console.error("Failed to load revision", err);
setError("Impossible de charger la révision.");
} finally {
setLoading(false);
}
};
if (id) fetchRevision();
}, [id]);
const handleRetry = () => {
if (!revision) return;
// Navigate to quiz with original content to re-generate/take
navigate('/quiz', {
state: {
textContent: revision.text_content,
// We could pass topic too if QuizPage supports it, but textContent is key
}
});
};
if (loading) {
return (
<div className="min-h-screen bg-[#FDFCF8] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
if (error || !revision) {
return (
<div className="min-h-screen bg-[#FDFCF8] flex items-center justify-center">
<div className="text-center p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Oups !</h2>
<p className="text-gray-600 mb-6">{error || "Révision introuvable."}</p>
<button onClick={() => navigate('/dashboard')} className="text-indigo-600 font-bold hover:underline">
Retour au tableau de bord
</button>
</div>
</div>
);
}
return (
<div className={`min-h-screen bg-[#FDFCF8] py-12 ${isDyslexic ? 'font-dyslexic' : ''}`}>
<div className="max-w-4xl mx-auto px-4">
<div className="flex justify-between items-center mb-10">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 px-4 py-2 bg-white border-2 border-slate-100 text-slate-500 font-bold rounded-xl hover:text-indigo-600 hover:border-indigo-100 transition-all shadow-sm"
>
<span>← Retour</span>
</motion.button>
<DyslexiaToggle />
</div>
<div className="bg-white rounded-[2.5rem] shadow-xl shadow-indigo-100/50 border border-slate-50 p-10 mb-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="mb-8 border-b border-slate-100 pb-6">
<h1 className="text-4xl font-black text-slate-900 mb-2">{revision.topic}</h1>
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">
Révision du {new Date(revision.created_at).toLocaleDateString()}
</p>
</div>
<div className="prose prose-indigo max-w-none text-slate-600 leading-relaxed whitespace-pre-wrap font-medium">
{/* Synthesis Section */}
{revision.synthesis && (
<div className="mb-10">
<h3 className="text-lg font-black text-indigo-900 mb-4 flex items-center gap-2">
<span className="text-2xl">✨</span> Résumé Magique
</h3>
<div className="p-6 bg-indigo-50/50 border border-indigo-100 rounded-2xl italic text-slate-700">
{revision.synthesis}
</div>
</div>
)}
{/* Tips Section */}
{revision.study_tips && (
<div className="mb-10">
<h3 className="text-lg font-black text-emerald-900 mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span> Conseils Métho
</h3>
<div className="grid gap-4 md:grid-cols-2">
{(() => {
try {
const tips = JSON.parse(revision.study_tips);
return Array.isArray(tips) ? tips.map((tip: string, idx: number) => (
<div key={idx} className="p-4 bg-emerald-50 border border-emerald-100 rounded-xl text-emerald-800 text-sm font-bold flex gap-3">
<span className="w-6 h-6 bg-white rounded-full flex items-center justify-center text-xs shadow-sm shrink-0">{idx + 1}</span>
{tip}
</div>
)) : null;
} catch (e) { return null; }
})()}
</div>
</div>
)}
<h3 className="text-lg font-black text-slate-900 mb-4 uppercase tracking-widest text-sm">Contenu du Cours</h3>
{revision.text_content}
</div>
</div>
<div className="flex justify-center">
<button
onClick={handleRetry}
className="px-8 py-4 bg-indigo-600 text-white font-black rounded-2xl shadow-xl shadow-indigo-200 hover:bg-indigo-700 hover:scale-105 transition-all active:scale-95"
>
📝 Refaire le Quiz
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,170 @@
import { useForm } from 'react-hook-form';
import { useAuth } from '../../stores/useAuth';
import { api } from '../../lib/api';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
interface SettingsForm {
openrouter_api_key: string;
}
interface ValidationStatus {
valid: boolean;
error?: string;
}
export const SettingsPage = () => {
const { user, checkAuth } = useAuth();
const navigate = useNavigate();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [validationStatus, setValidationStatus] = useState<ValidationStatus | null>(null);
const [isValidating, setIsValidating] = useState(false);
const { register, handleSubmit, formState: { isSubmitting } } = useForm<SettingsForm>({
defaultValues: {
openrouter_api_key: user?.openrouter_api_key || ''
}
});
// Check validation status on mount if key exists
useEffect(() => {
if (user?.openrouter_api_key) {
validateApiKey();
}
}, []);
const validateApiKey = async () => {
setIsValidating(true);
try {
const response = await api.get<ValidationStatus>('/auth/validate-api-key');
setValidationStatus(response.data);
} catch (err) {
setValidationStatus({ valid: false, error: 'Failed to validate' });
} finally {
setIsValidating(false);
}
};
const onSubmit = async (data: SettingsForm) => {
try {
await api.patch('/auth/users/me', data);
await checkAuth();
setSuccessMessage("API Key saved successfully!");
setTimeout(() => setSuccessMessage(null), 3000);
// Validate after save
if (data.openrouter_api_key) {
validateApiKey();
} else {
setValidationStatus(null);
}
} catch (err) {
console.error("Failed to update settings", err);
}
};
return (
<div className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-4xl font-black text-slate-900 tracking-tight">Paramètres</h1>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => navigate('/parent-hub')}
className="inline-flex items-center px-6 py-3 bg-white border-2 border-slate-100 text-slate-600 font-bold rounded-2xl hover:bg-slate-50 hover:border-slate-200 transition-all shadow-sm"
>
<svg className="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Retour
</motion.button>
</div>
<div className="bg-white rounded-[2.5rem] shadow-xl shadow-slate-200/50 border border-slate-50 overflow-hidden">
<div className="p-8 md:p-12">
<div className="flex items-center gap-4 mb-8">
<div className="p-4 bg-indigo-50 rounded-2xl text-indigo-600">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<div>
<h3 className="text-2xl font-black text-slate-900">Configuration IA</h3>
<p className="text-slate-500 font-medium">Configurez l'intelligence artificielle pour activer les fonctions de révision.</p>
</div>
</div>
{validationStatus && (
<div className={`mb-8 p-4 rounded-2xl border-2 ${validationStatus.valid ? 'bg-emerald-50 border-emerald-100' : 'bg-red-50 border-red-100'}`}>
<div className="flex items-center">
{validationStatus.valid ? (
<>
<div className="p-2 bg-emerald-500 rounded-lg text-white mr-3">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<span className="text-emerald-700 font-black uppercase tracking-wider text-sm">Clé API Valide</span>
</>
) : (
<>
<div className="p-2 bg-red-500 rounded-lg text-white mr-3">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<span className="text-red-700 font-black uppercase tracking-wider text-sm">
{validationStatus.error === 'Failed to validate' ? 'Erreur de connexion' : 'Clé API Invalide'}
</span>
</>
)}
</div>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="api_key" className="block text-sm font-bold text-slate-700 mb-2 ml-1 uppercase tracking-wider">Clé API OpenRouter</label>
<div className="flex flex-col md:flex-row gap-4">
<input
type="password"
id="api_key"
{...register('openrouter_api_key')}
className="flex-1 px-5 py-4 rounded-2xl bg-slate-50 border-2 border-transparent focus:border-indigo-500 focus:bg-white transition-all font-bold outline-none text-slate-900 placeholder:text-slate-300"
placeholder="sk-or-v1-..."
/>
<div className="flex gap-3">
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center px-8 py-4 bg-indigo-600 text-white font-black rounded-2xl hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 disabled:opacity-50 active:scale-95 whitespace-nowrap"
>
{isSubmitting ? 'Enregistrement...' : 'Enregistrer'}
</button>
<button
type="button"
onClick={validateApiKey}
disabled={isValidating}
className="inline-flex items-center px-8 py-4 bg-white border-2 border-slate-100 text-slate-600 font-black rounded-2xl hover:bg-slate-50 transition-all disabled:opacity-50 active:scale-95 whitespace-nowrap"
>
{isValidating ? 'Test en cours...' : 'Tester la connexion'}
</button>
</div>
</div>
</div>
</form>
{successMessage && (
<div className="mt-6 flex items-center gap-2 text-emerald-600 font-bold bg-emerald-50 py-3 px-5 rounded-xl border border-emerald-100 animate-in slide-in-from-top-2">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"></path></svg>
Clé API enregistrée avec succès !
</div>
)}
</div>
</div>
</div>
);
};
+66
View File
@@ -0,0 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: theme('fontFamily.sans');
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background-color: #FDFCF8;
/* cream */
color: #1E293B;
/* slate-800 */
}
@font-face {
font-family: 'OpenDyslexic';
src: url('./assets/fonts/OpenDyslexic-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
.font-dyslexic {
font-family: 'OpenDyslexic', sans-serif !important;
line-height: 1.6 !important;
}
.font-dyslexic p,
.font-dyslexic h1,
.font-dyslexic h2,
.font-dyslexic h3,
.font-dyslexic span,
.font-dyslexic div,
.font-dyslexic button,
.font-dyslexic a {
font-family: 'OpenDyslexic', sans-serif !important;
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #E2E8F0;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #CBD5E1;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+550
View File
@@ -0,0 +1,550 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../stores/useAuth';
import { UsageWidget } from '../features/dashboard/UsageWidget';
import { GamificationWidget } from '../features/dashboard/GamificationWidget';
import { MasteryWidget } from '../features/dashboard/MasteryWidget';
import { ActivityTimeline } from '../features/dashboard/ActivityTimeline';
import { RecentRevisionsWidget } from '../features/dashboard/RecentRevisionsWidget';
import { LogOut, Flame } from 'lucide-react';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
// --- Avatar Configuration (Original Fun-Emoji Style) ---
const AVATAR_SEEDS = [
'Felix', 'Aneka', 'Zoe', 'Marc', 'Trouble', 'Coco', 'Sasha', 'Jack',
'Lulu', 'Bibi', 'Mimi', 'Titi', 'Gigi', 'Nana', 'Kiki', 'Pupu',
'Riri', 'Fifi', 'Loulou', 'Pipo', 'Pepa', 'Papi', 'Pomo', 'Pumi'
];
// --- Badges Configuration (Modern 30+ System) ---
type BadgeRarity = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM';
interface BadgeDef {
code: string;
label: string;
description: string;
icon: string;
rarity: BadgeRarity;
category: string;
}
const ALL_BADGES: BadgeDef[] = [
// --- Les Débuts ---
{ code: 'FIRST_STEPS', label: 'Premiers Pas', description: 'Ton tout premier quiz terminé !', icon: '🌱', rarity: 'BRONZE', category: 'Débuts' },
{ code: 'PROFILE_MASTER', label: 'Bien chez soi', description: 'Personnalisation de ton avatar réussie.', icon: '🖼️', rarity: 'BRONZE', category: 'Débuts' },
{ code: 'QUICK_START', label: 'Vite fait, bien fait', description: 'Premier exercice complété en moins de 2 minutes.', icon: '⚡', rarity: 'BRONZE', category: 'Débuts' },
// --- L'Érudit (Matières) ---
{ code: 'MATH_CHAMP', label: 'Génie Maths', description: 'Atteins 80% de maîtrise en Mathématiques.', icon: '📐', rarity: 'SILVER', category: 'Savoir' },
{ code: 'FRANCAIS_EXPERT', label: 'Molière en herbe', description: 'Maîtrise parfaite des bases du Français.', icon: '📚', rarity: 'SILVER', category: 'Savoir' },
{ code: 'SCIENCE_STAR', label: 'P’tit Scientifique', description: 'Exploration des sciences complétée avec succès.', icon: '🧪', rarity: 'SILVER', category: 'Savoir' },
{ code: 'GLOBAL_MASTER', label: 'Savant Fou', description: 'Atteins le niveau 20 dans toutes les matières.', icon: '🌎', rarity: 'GOLD', category: 'Savoir' },
{ code: 'ENCYCLOPEDIE', label: 'L’Encyclopédie', description: 'Réponds correctement à 500 questions.', icon: '📖', rarity: 'PLATINUM', category: 'Savoir' },
// --- Le Marathonien (Assiduité) ---
{ code: 'STREAK_3', label: 'Le Triplé', description: 'Révise pendant 3 jours consécutifs.', icon: '🥉', rarity: 'BRONZE', category: 'Marathon' },
{ code: 'STREAK_7', label: 'Une Semaine au Top', description: '7 jours de révisions sans interruption !', icon: '🥈', rarity: 'SILVER', category: 'Marathon' },
{ code: 'STREAK_30', label: 'Légende Mensuelle', description: '30 jours de folie ! Rien ne t’arrête.', icon: '🥇', rarity: 'GOLD', category: 'Marathon' },
{ code: 'WEEKENDER', label: 'Guerrier du WE', description: 'Révise même le samedi et le dimanche.', icon: '📅', rarity: 'SILVER', category: 'Marathon' },
{ code: 'EARLY_BIRD', label: 'Lève-tôt', description: 'Fais un quiz avant 8h du matin.', icon: '🌅', rarity: 'BRONZE', category: 'Marathon' },
{ code: 'NIGHT_OWL', label: 'Oiseau de Nuit', description: 'Révise après 20h le soir.', icon: '🦉', rarity: 'BRONZE', category: 'Marathon' },
// --- L'Éclair (Performance) ---
{ code: 'ON_FIRE', label: 'On Fire', description: 'Enchaîne 10 bonnes réponses d’affilée.', icon: '🔥', rarity: 'SILVER', category: 'Performance' },
{ code: 'PERFECT_SCORE', label: 'Sans Faute', description: 'Obtiens 100% à un quiz difficile.', icon: '🎯', rarity: 'GOLD', category: 'Performance' },
{ code: 'SPEED_DEMON', label: 'Éclair Argenté', description: 'Termine un quiz en un temps record.', icon: '🏎️', rarity: 'SILVER', category: 'Performance' },
{ code: 'UNSTOPPABLE', label: 'Invincible', description: '50 bonnes réponses sans aucune erreur.', icon: '💎', rarity: 'GOLD', category: 'Performance' },
{ code: 'SNIPER', label: 'Sniper Or', description: 'Précision de 95% sur l’ensemble de la semaine.', icon: '🏹', rarity: 'GOLD', category: 'Performance' },
// --- Social & Fun ---
{ code: 'FEEDBACK_FAN', label: 'Curieux', description: 'Consulte tes erreurs après un quiz.', icon: '🧐', rarity: 'BRONZE', category: 'Découverte' },
{ code: 'ZEN_MASTER', label: 'Maître Zen', description: 'Passe 1 heure totale en mode concentration.', icon: '🧘', rarity: 'SILVER', category: 'Découverte' },
{ code: 'LEVEL_UP_FAST', label: 'Ascension Fulgurante', description: 'Gagne 2 niveaux en une seule journée.', icon: '🚀', rarity: 'GOLD', category: 'Performance' },
{ code: 'LUCKY_STAR', label: 'Étoile Chanceuse', description: 'Débloque un badge caché.', icon: '✨', rarity: 'SILVER', category: 'Découverte' },
{ code: 'TREASURE_HUNTER', label: 'Chasseur de Trésors', description: 'Débloque 15 badges différents.', icon: '🏴‍☠️', rarity: 'GOLD', category: 'Collection' },
{ code: 'TRUE_LEGEND', label: 'La Vraie Légende', description: 'Atteins le niveau 50.', icon: '👑', rarity: 'PLATINUM', category: 'Collection' },
{ code: 'COMPLETIONIST', label: 'Le Collectionneur', description: 'Obtiens tous les badges du jeu.', icon: '🏆', rarity: 'PLATINUM', category: 'Collection' },
// Extra variety
{ code: 'BRAIN_POWER', label: 'Super Cerveau', description: 'Réponds à 20 questions de logique.', icon: '🧠', rarity: 'SILVER', category: 'Savoir' },
{ code: 'LITERATURE_LOVER', label: 'Plume d’Or', description: 'Excellence en littérature.', icon: '✒️', rarity: 'GOLD', category: 'Savoir' },
{ code: 'HISTORY_BUFF', label: 'Voyageur Temporel', description: 'Expert en Histoire.', icon: '⏳', rarity: 'SILVER', category: 'Savoir' },
{ code: 'ART_CRITIC', label: 'Petit Artiste', description: 'Premier badge en Arts.', icon: '🎨', rarity: 'BRONZE', category: 'Savoir' },
{ code: 'MUSIC_MAESTRO', label: 'Mélomane', description: 'Rythme de révision parfait.', icon: '🎵', rarity: 'BRONZE', category: 'Performance' },
];
function BadgeGalleryModal({ isOpen, onClose, unlockedBadges }: { isOpen: boolean, onClose: () => void, unlockedBadges: string[] }) {
const categories = Array.from(new Set(ALL_BADGES.map(b => b.category)));
// Gamified Medal Styles
const getBadgeStyle = (_rarity: BadgeRarity, isUnlocked: boolean) => {
const base = "relative flex flex-col items-center text-center transition-all duration-300 transform active:scale-95 group";
if (!isUnlocked) {
return `${base} opacity-60 grayscale scale-95`;
}
// Add hover:z-50 to ensure the badge and its tooltip appear on top of siblings
return `${base} hover:scale-110 hover:-translate-y-1 hover:brightness-110 hover:z-50`;
};
const getMedalClass = (rarity: BadgeRarity, isUnlocked: boolean) => {
const base = "relative w-16 h-18 sm:w-20 sm:h-22 p-4 flex items-center justify-center mb-2 shadow-lg transition-all duration-500 overflow-hidden";
// Custom "Medal" shape using clip-path or complex rounding
// Shield shape approximation with border-radius
const shape = "rounded-[20%_20%_50%_50%]";
if (!isUnlocked) {
return `${base} ${shape} bg-slate-200 border-2 border-slate-300`;
}
switch (rarity) {
case 'PLATINUM':
return `${base} ${shape} bg-gradient-to-b from-indigo-100 via-purple-100 to-indigo-300 border-2 border-white shadow-indigo-200/50 ring-2 ring-purple-400/20`;
case 'GOLD':
return `${base} ${shape} bg-gradient-to-b from-yellow-200 via-amber-300 to-orange-400 border-2 border-white shadow-amber-200/50 ring-2 ring-yellow-400/30`;
case 'SILVER':
return `${base} ${shape} bg-gradient-to-b from-slate-100 via-blue-100 to-slate-400 border-2 border-white shadow-blue-100/50 ring-2 ring-blue-300/20`;
default: // BRONZE
return `${base} ${shape} bg-gradient-to-b from-orange-100 via-orange-300 to-red-400 border-2 border-white shadow-orange-200/50 ring-2 ring-orange-400/20`;
}
};
const getRarityLabel = (rarity: BadgeRarity) => {
switch (rarity) {
case 'PLATINUM': return "PLATINE";
case 'GOLD': return "OR";
case 'SILVER': return "ARGENT";
default: return "BRONZE";
}
};
const getRarityColor = (rarity: BadgeRarity) => {
switch (rarity) {
case 'PLATINUM': return "text-indigo-600";
case 'GOLD': return "text-amber-600";
case 'SILVER': return "text-blue-600";
default: return "text-orange-700";
}
};
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-gray-900/40 backdrop-blur-md">
<div className="absolute inset-0" onClick={onClose} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 30 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 30 }}
className="relative z-10 bg-white/95 rounded-[2.5rem] shadow-2xl w-full max-w-4xl max-h-[85vh] overflow-hidden flex flex-col border border-white/20"
>
{/* Header */}
<div className="p-8 border-b border-gray-100 flex justify-between items-center bg-white/80 sticky top-0 z-20 backdrop-blur-sm">
<div>
<h3 className="text-3xl font-black text-gray-900 tracking-tight">Ma Collection 🏆</h3>
<div className="mt-2 flex items-center gap-3">
<div className="h-2 w-48 bg-gray-100 rounded-full overflow-hidden border border-gray-200">
<div
className="h-full bg-gradient-to-r from-indigo-500 to-purple-600 transition-all duration-1000"
style={{ width: `${(unlockedBadges.length / ALL_BADGES.length) * 100}%` }}
/>
</div>
<span className="text-xs font-black text-indigo-600">
{unlockedBadges.length} DE {ALL_BADGES.length} DÉBLOQUÉS
</span>
</div>
</div>
<button onClick={onClose} className="p-3 bg-gray-50 hover:bg-gray-100 rounded-2xl transition-all active:scale-95 text-gray-400">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8 space-y-12 scrollbar-hide">
{categories.map(category => (
<div key={category} className="space-y-6">
<h4 className="flex items-center gap-3 text-sm font-black uppercase tracking-[0.2em] text-gray-400">
<span className="h-px flex-1 bg-gray-100"></span>
{category}
<span className="h-px flex-1 bg-gray-100"></span>
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6 gap-y-10">
{ALL_BADGES.filter(b => b.category === category).map(badge => {
const isUnlocked = unlockedBadges.includes(badge.code);
return (
<div
key={badge.code}
className={getBadgeStyle(badge.rarity, isUnlocked)}
>
{/* Medal Body */}
<div className={getMedalClass(badge.rarity, isUnlocked)}>
{/* Inner Glossy Effect */}
{isUnlocked && <div className="absolute top-0 left-0 w-full h-1/2 bg-white/20 -skew-x-12 translate-x-[-20%] pointer-events-none" />}
<span className={`text-4xl sm:text-5xl drop-shadow-md select-none ${!isUnlocked ? 'filter-none grayscale' : ''}`}>
{badge.icon}
</span>
</div>
{/* Badge Info */}
<div className="space-y-1 mt-1">
<h5 className="text-[11px] font-black leading-tight text-gray-900 uppercase tracking-tight">
{badge.label}
</h5>
<span className={`text-[8px] font-black uppercase tracking-widest ${getRarityColor(badge.rarity)}`}>
{getRarityLabel(badge.rarity)}
</span>
</div>
{/* Gamified Hover Tooltip (Top) */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-32 opacity-0 group-hover:opacity-100 transition-all pointer-events-none z-50 transform translate-y-2 group-hover:translate-y-0">
<div className="bg-gray-900 text-white p-3 rounded-xl shadow-2xl border border-white/10 text-center ring-4 ring-black/5 relative">
<p className="text-[10px] font-bold leading-relaxed">{badge.description}</p>
{/* Arrow pointing down */}
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-gray-900 rotate-45" />
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
function getAvatarUrl(seed: string) {
return `https://api.dicebear.com/7.x/fun-emoji/svg?seed=${seed}&radius=50`;
}
function AvatarSelector({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) {
const { updateProfile } = useAuth();
const [sending, setSending] = useState(false);
const handleSelect = async (seed: string) => {
setSending(true);
const url = getAvatarUrl(seed);
try {
await updateProfile({ avatar_url: url });
onClose();
} catch (e) {
console.error(e);
} finally {
setSending(false);
}
};
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="absolute inset-0" onClick={onClose} />
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative z-10 bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden border border-gray-100"
>
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-gray-900">Choisis ton Avatar</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div className="grid grid-cols-4 gap-3 max-h-[400px] overflow-y-auto p-1 scrollbar-hide">
{AVATAR_SEEDS.map((seed) => (
<button
key={seed}
onClick={() => handleSelect(seed)}
disabled={sending}
className="aspect-square flex items-center justify-center p-2 bg-gray-50 rounded-full hover:bg-indigo-50 hover:scale-110 active:scale-95 transition-all outline-none border-2 border-transparent hover:border-indigo-200"
>
<img src={getAvatarUrl(seed)} alt={seed} className="w-full h-full object-contain" />
</button>
))}
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
// Function to render avatar content
function AvatarDisplay({ url, size = "w-9 h-9", fontSize = "text-xl" }: { url: string | null, size?: string, fontSize?: string }) {
if (!url) return <div className={`${size} rounded-full bg-gray-200 flex items-center justify-center text-gray-400`}>?</div>;
// Check if it's a URL or a plain emoji
const isEmoji = !url.startsWith('http') && !url.startsWith('data:') && !url.includes('/');
return (
<div className={`${size} rounded-full bg-white border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm`}>
{isEmoji ? (
<span className={fontSize}>{url}</span>
) : (
<img src={url} alt="Avatar" className="w-full h-full object-cover" />
)}
</div>
);
}
// --- Dyslexia Toggle Button ---
function DyslexiaToggle() {
const [isDyslexic, setIsDyslexic] = useState(() => {
return localStorage.getItem('dyslexic-mode') === 'true';
});
useEffect(() => {
if (isDyslexic) {
document.body.classList.add('font-dyslexic');
} else {
document.body.classList.remove('font-dyslexic');
}
localStorage.setItem('dyslexic-mode', String(isDyslexic));
}, [isDyslexic]);
return (
<button
onClick={() => setIsDyslexic(!isDyslexic)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-bold transition-all ${isDyslexic
? 'bg-indigo-100 text-indigo-700 border-indigo-200 shadow-inner'
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
}`}
title="Activer la police adaptée pour la dyslexie"
>
<span className="text-lg leading-none">Aa</span>
<span className="hidden sm:inline">{isDyslexic ? 'Mode Lecture' : 'Dyslexie'}</span>
</button>
);
}
export default function DashboardPage() {
const { user, activeLearner, logout } = useAuth();
const [isAvatarPickerOpen, setIsAvatarPickerOpen] = useState(false);
const [isBadgeGalleryOpen, setIsBadgeGalleryOpen] = useState(false);
// Get recently unlocked badges (last 4)
const recentBadges = activeLearner?.badges?.slice(-4).reverse() || [];
const unlockedBadgeCodes = activeLearner?.badges?.map(b => b.badge_code) || [];
// Auto-generated avatar based on name
const currentAvatarUrl = activeLearner?.avatar_url;
return (
<div className="min-h-screen bg-[#f8fafc] flex flex-col font-sans text-gray-900 overflow-x-hidden">
<AvatarSelector isOpen={isAvatarPickerOpen} onClose={() => setIsAvatarPickerOpen(false)} />
<BadgeGalleryModal
isOpen={isBadgeGalleryOpen}
onClose={() => setIsBadgeGalleryOpen(false)}
unlockedBadges={unlockedBadgeCodes}
/>
{/* Navigation */}
<nav className="bg-white/80 backdrop-blur-md border-b border-gray-200 sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center gap-6">
<Link to="/dashboard" className="flex items-center gap-3">
<img src="/logo.png" alt="Reviflow" className="w-8 h-8 rounded-lg shadow-sm object-cover bg-white" />
<span className="text-lg font-bold tracking-tight text-gray-900">Reviflow</span>
</Link>
<div className="hidden sm:flex items-center space-x-1">
<Link to="/dashboard" className="px-3 py-2 text-gray-900 bg-gray-100/50 rounded-md text-sm font-medium">
Tableau de bord
</Link>
{user?.role === 'parent' && (
<>
<Link to="/parent-hub" className="px-3 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-50 rounded-md text-sm font-medium transition-colors">
Espace Parent
</Link>
</>
)}
</div>
</div>
{/* Center: Date/Time */}
<div className="hidden lg:flex flex-col items-center justify-center">
<span className="text-sm font-black text-gray-900 capitalize">
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
</span>
<span className="text-xs font-bold text-indigo-500 bg-indigo-50 px-2 py-0.5 rounded-full">
{new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex items-center gap-4">
{/* Dyslexia Toggle */}
<DyslexiaToggle />
<div className="h-6 w-px bg-gray-200 mx-1"></div>
{/* Profile Info */}
<div className="flex items-center gap-3">
<div className="hidden md:block text-right">
<p className="text-sm font-bold text-gray-900 leading-none">
{activeLearner?.first_name || user?.first_name}
</p>
</div>
<button
onClick={() => user?.role === 'learner' && setIsAvatarPickerOpen(true)}
className="outline-none"
disabled={user?.role !== 'learner'}
>
<AvatarDisplay url={currentAvatarUrl || null} />
</button>
</div>
<button
onClick={logout}
className="p-2 text-gray-400 hover:text-red-900 transition-colors"
title="Se déconnecter"
>
<LogOut className="w-4 h-4 ml-1" />
</button>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="flex-grow py-8 px-4 sm:px-6">
<div className="max-w-6xl mx-auto">
{/* Header Section */}
<div className="mb-10 flex flex-col md:flex-row md:items-end justify-between gap-6 animate-in fade-in slide-in-from-top-2 duration-500">
<div>
<div className="flex flex-wrap gap-2 mb-2">
{activeLearner && (activeLearner.streak_current || 0) > 0 && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 bg-orange-50 border border-orange-100 rounded-full text-orange-700">
<Flame className="w-3 h-3 fill-orange-500 text-orange-500" />
<span className="text-[10px] font-bold uppercase tracking-wide">
{activeLearner.streak_current} jours
</span>
</div>
)}
</div>
<h2 className="text-3xl font-bold text-gray-900 tracking-tight">
Bonjour {activeLearner?.first_name}.
</h2>
<p className="text-gray-500 mt-1">Prêt à avancer sur tes objectifs ?</p>
</div>
<div className="flex gap-3">
<Link
to="/new"
className="inline-flex items-center px-6 py-3 bg-gray-900 hover:bg-black text-white font-medium rounded-xl shadow-lg shadow-gray-200 hover:shadow-xl transition-all active:scale-95"
>
<span className="mr-2 text-lg">+</span>
Ajouter un cours
</Link>
</div>
</div>
{/* Grid Layout */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="grid grid-cols-1 lg:grid-cols-12 gap-6"
>
{/* Left Column: Stats */}
<div className="lg:col-span-4 space-y-6">
<GamificationWidget />
{/* TimeWidget Removed */}
<RecentRevisionsWidget />
{user?.role === 'parent' && <UsageWidget />}
{user?.role === 'parent' && (
<Link to="/parent-hub" className="block p-6 bg-indigo-900 rounded-xl text-white shadow-xl shadow-indigo-100 relative overflow-hidden group">
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-indigo-500/30 rounded-full blur-2xl -ml-5 -mb-5"></div>
<div className="relative z-10">
<h3 className="font-bold text-lg mb-1 group-hover:translate-x-1 transition-transform">Espace Parent</h3>
<p className="text-indigo-200 text-sm mb-4">Gérer les comptes et abonnements.</p>
<div className="flex items-center text-sm font-bold text-indigo-100">
Accéder <span className="ml-2">→</span>
</div>
</div>
</Link>
)}
</div>
{/* Right Column: Mastery & Activity */}
<div className="lg:col-span-8 space-y-6">
{/* Badges - Compact */}
<div className="bg-white rounded-xl p-6 border border-gray-200 shadow-sm relative overflow-hidden">
{/* Background Decorations */}
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-purple-500/20 rounded-full blur-2xl -ml-5 -mb-5"></div>
<div className="relative z-10">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900">Succès Récents</h3>
<button
onClick={() => setIsBadgeGalleryOpen(true)}
className="text-[10px] font-black text-indigo-500 hover:text-indigo-700 uppercase tracking-widest bg-indigo-50 px-2 py-1 rounded-md transition-colors"
>
Collection →
</button>
</div>
<div className="flex gap-3 overflow-x-auto pb-4 scrollbar-hide">
{recentBadges.length > 0 ? (
recentBadges.map((badge) => {
const badgeDef = ALL_BADGES.find(b => b.code === badge.badge_code);
return (
<div key={badge.badge_code} className="flex-shrink-0 flex items-center gap-3 px-4 py-2 rounded-full border border-indigo-100 bg-white text-indigo-900 shadow-md hover:scale-105 transition-all cursor-pointer ring-1 ring-indigo-50">
<span className="text-xl drop-shadow-sm">{badgeDef?.icon || '🏆'}</span>
<div className="flex flex-col">
<span className="text-[10px] font-black leading-tight uppercase tracking-tight">{badgeDef?.label || badge.badge_code}</span>
</div>
</div>
);
})
) : (
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-dashed border-gray-200 bg-gray-50/50 text-gray-400 w-full justify-center">
<span className="text-sm font-bold uppercase tracking-widest opacity-60">En attente de succès... 🚀</span>
</div>
)}
</div>
</div>
</div>
<MasteryWidget />
<div className="bg-white rounded-xl p-6 border border-gray-200 shadow-sm min-h-[300px] relative overflow-hidden">
{/* Background Decorations */}
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full blur-3xl -mr-10 -mt-10"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-purple-500/20 rounded-full blur-2xl -ml-5 -mb-5"></div>
<div className="relative z-10">
<div className="flex items-center justify-between mb-6">
<h3 className="font-bold text-gray-900">Activités Récentes</h3>
</div>
<ActivityTimeline learnerId={activeLearner?.id} />
</div>
</div>
</div>
</motion.div>
</div>
</main>
</div>
);
}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useState } from 'react';
import { useAuth } from '../stores/useAuth';
import { Users, Plus, TrendingUp, Settings as SettingsIcon, LogOut, X } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { api } from '../lib/api';
import { MasteryWidget } from '../features/dashboard/MasteryWidget';
import { ActivityTimeline } from '../features/dashboard/ActivityTimeline';
export const ParentHubPage = () => {
const [isCreating, setIsCreating] = useState(false);
const [selectedChildId, setSelectedChildId] = useState<string | null>(null); // For modal
const [newProfileName, setNewProfileName] = useState('');
const [newProfileUsername, setNewProfileUsername] = useState('');
const [newProfilePin, setNewProfilePin] = useState('');
const { user, fetchProfiles, learnerProfiles, logout, createChildAccount } = useAuth();
const [stats, setStats] = useState({ total_tokens: 0, total_cost: 0 });
const navigate = useNavigate();
useEffect(() => {
fetchProfiles();
// Fetch Children Usage Stats
const fetchUsage = async () => {
if (!user) return;
try {
const res = await api.get('/auth/children');
const children = res.data;
let tokens = user.total_tokens_used || 0;
let cost = user.total_cost_usd || 0;
children.forEach((child: any) => {
tokens += child.total_tokens_used || 0;
cost += child.total_cost_usd || 0;
});
setStats({
total_tokens: tokens,
total_cost: cost
});
} catch (err) {
console.error("Failed to fetch children usage", err);
}
};
fetchUsage();
}, [user, fetchProfiles]);
const handleLogout = async () => {
await logout();
navigate('/login');
};
const handleCreate = async () => {
if (!newProfileName.trim() || !newProfileUsername.trim()) return;
try {
await createChildAccount({
username: newProfileUsername,
password: newProfilePin,
first_name: newProfileName,
avatar_url: `https://api.dicebear.com/7.x/fun-emoji/svg?seed=${newProfileName}`
});
setIsCreating(false);
setNewProfileName('');
setNewProfileUsername('');
setNewProfilePin('');
} catch (err) {
alert("Erreur lors de la création du compte enfant");
}
};
return (
<div className="min-h-screen bg-[#FDFCF8] flex flex-col">
{/* Navigation */}
<nav className="bg-white border-b border-gray-100 sticky top-0 z-40 shadow-sm/5">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link to="/parent-hub" className="flex items-center group">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center text-white mr-3 shadow-indigo-100 shadow-lg group-hover:scale-110 transition-transform">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<span className="text-xl font-black tracking-tight text-gray-900">Reviflow</span>
</Link>
<div className="hidden sm:ml-8 sm:flex sm:space-x-4">
<Link to="/parent-hub" className="flex items-center gap-2 px-3 py-2 text-indigo-600 bg-indigo-50 rounded-lg text-sm font-bold transition-all">
<Users className="w-4 h-4" />
Espace Parent
</Link>
<Link to="/settings" className="flex items-center gap-2 px-3 py-2 text-gray-500 hover:text-indigo-600 hover:bg-gray-50 rounded-lg text-sm font-medium transition-all">
<SettingsIcon className="w-4 h-4" />
Paramètres
</Link>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 pl-4 border-l border-gray-100">
<div className="text-right">
<p className="text-sm font-bold text-gray-900 leading-none mb-1">{user?.first_name || 'Parent'}</p>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Administrateur</p>
</div>
</div>
<button
onClick={handleLogout}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all"
>
<LogOut className="w-5 h-5" />
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
<header className="mb-10">
<h1 className="text-4xl font-black text-slate-900 tracking-tight">Bonjour, {user?.first_name || 'Parent'} ! 👋</h1>
<p className="text-slate-500 mt-2 text-lg font-medium">Suivez les progrès de vos apprentis en toute sérénité.</p>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div className="bg-white p-6 rounded-[2rem] shadow-xl shadow-slate-200/50 border border-slate-50">
<div className="flex items-center space-x-4">
<div className="p-4 bg-indigo-50 rounded-2xl text-indigo-600">
<Users className="h-6 w-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Apprentis</p>
<p className="text-3xl font-black text-slate-900">{learnerProfiles.length}</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] shadow-xl shadow-slate-200/50 border border-slate-50">
<div className="flex items-center space-x-4">
<div className="p-4 bg-amber-50 rounded-2xl text-amber-600">
<TrendingUp className="h-6 w-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Jetons AI</p>
<p className="text-3xl font-black text-slate-900">{stats.total_tokens.toLocaleString()}</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] shadow-xl shadow-slate-200/50 border border-slate-50">
<div className="flex items-center space-x-4">
<div className="p-4 bg-emerald-50 rounded-2xl text-emerald-600">
<div className="h-6 w-6 font-black flex items-center justify-center">€</div>
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Coût estimé</p>
<p className="text-3xl font-black text-slate-900">{stats.total_cost.toFixed(2)}</p>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-black text-slate-900 tracking-tight border-b-4 border-indigo-100 pb-1">Mes Apprentis</h2>
<button
onClick={() => setIsCreating(true)}
className="inline-flex items-center px-6 py-3 bg-indigo-600 text-white font-bold rounded-2xl hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-100 hover:-translate-y-1 active:scale-95"
>
<Plus className="h-5 w-5 mr-2" />
Nouvel accès enfant
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{learnerProfiles.map(child => (
<div key={child.id} className="bg-white rounded-[2.5rem] p-8 shadow-xl shadow-slate-200/50 border border-slate-50 hover:shadow-2xl hover:shadow-indigo-100 transition-all group">
<div className="flex items-center space-x-5 mb-6">
<div className="h-16 w-16 rounded-[1.5rem] bg-indigo-50 border-2 border-white shadow-md flex items-center justify-center text-2xl font-black text-indigo-400 overflow-hidden group-hover:scale-110 transition-transform">
{child.avatar_url && child.avatar_url.startsWith('http') ? (
<img src={child.avatar_url} alt="" className="h-full w-full object-cover" />
) : (
<span className="text-4xl">{child.avatar_url || child.first_name[0].toUpperCase()}</span>
)}
</div>
<div>
<h3 className="text-xl font-black text-slate-900">{child.first_name}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Niveau {child.level || 1}</p>
</div>
</div>
<div className="space-y-4 pt-6 border-t border-slate-100">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-slate-500">Série actuelle</span>
<span className="px-3 py-1 bg-orange-50 text-orange-600 text-xs font-black rounded-full border border-orange-100">
{child.streak_current || 0} JOURS 🔥
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-slate-500 font-medium">Expérience</span>
<span className="font-bold text-slate-900">{child.xp || 0} XP</span>
</div>
<button
onClick={() => setSelectedChildId(child.id)}
className="w-full py-4 mt-2 bg-slate-50 text-slate-600 font-bold rounded-2xl hover:bg-indigo-50 hover:text-indigo-600 transition-all active:scale-95"
>
Détails de progression
</button>
</div>
</div>
))}
{learnerProfiles.length === 0 && (
<div className="col-span-full py-20 text-center bg-slate-50/50 rounded-[3rem] border-4 border-dashed border-slate-100">
<div className="p-6 bg-white rounded-full w-24 h-24 flex items-center justify-center mx-auto mb-6 shadow-lg">
<Users className="h-12 w-12 text-slate-200" />
</div>
<p className="text-slate-400 text-lg font-bold">Aucun compte apprenti créé pour le moment.</p>
<button
onClick={() => setIsCreating(true)}
className="mt-6 text-indigo-600 font-black text-lg hover:underline underline-offset-8"
>
Créer maintenant →
</button>
</div>
)}
</div>
{/* Create Profile Modal */}
{isCreating && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-md p-8 relative animate-in zoom-in-95 duration-300">
<button
onClick={() => setIsCreating(false)}
className="absolute top-6 right-6 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
<h2 className="text-3xl font-black text-slate-900 mb-8 flex items-center gap-3">
<div className="p-2 bg-indigo-50 rounded-xl">
<Plus className="w-6 h-6 text-indigo-600" />
</div>
Nouvel Apprenti
</h2>
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 ml-1 uppercase tracking-wider">Prénom de l'enfant</label>
<input
type="text"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
className="w-full px-5 py-4 rounded-2xl bg-slate-50 border-2 border-transparent focus:border-indigo-500 focus:bg-white transition-all font-bold outline-none text-slate-900 placeholder:text-slate-300"
placeholder="Ex: Léo"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 ml-1 uppercase tracking-wider">Identifiant (Login)</label>
<input
type="text"
value={newProfileUsername}
onChange={(e) => setNewProfileUsername(e.target.value.toLowerCase().replace(/\s/g, ''))}
className="w-full px-5 py-4 rounded-2xl bg-slate-50 border-2 border-transparent focus:border-indigo-500 focus:bg-white transition-all font-bold outline-none text-slate-900 placeholder:text-slate-300"
placeholder="leo2026"
/>
<p className="text-[10px] text-slate-400 mt-2 ml-1 font-bold uppercase tracking-widest">Utilisé pour se connecter sans email</p>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 ml-1 uppercase tracking-wider">Code PIN ou Mot de passe (4+ car.)</label>
<input
type="text"
value={newProfilePin}
onChange={(e) => setNewProfilePin(e.target.value)}
className="w-full px-5 py-4 rounded-2xl bg-slate-50 border-2 border-transparent focus:border-indigo-500 focus:bg-white transition-all font-bold text-center text-3xl tracking-widest outline-none text-indigo-600"
placeholder="1234"
/>
</div>
<button
onClick={handleCreate}
disabled={!newProfileName.trim() || !newProfileUsername.trim() || newProfilePin.length < 4}
className="w-full py-5 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-lg font-black rounded-2xl shadow-xl shadow-indigo-100 transition-all active:scale-[0.98] mt-4"
>
Créer le compte
</button>
</div>
</div>
</div>
)}
{/* Stats Modal */}
{selectedChildId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-2xl p-8 relative animate-in zoom-in-95 duration-300 max-h-[90vh] overflow-y-auto">
<button
onClick={() => setSelectedChildId(null)}
className="absolute top-6 right-6 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors"
>
<X className="w-6 h-6" />
</button>
<h2 className="text-3xl font-black text-slate-900 mb-8 flex items-center gap-3">
📊 Progression
</h2>
<MasteryWidget learnerId={selectedChildId} />
<div className="my-8 border-t border-slate-100"></div>
<ActivityTimeline learnerId={selectedChildId} />
<div className="mt-8 text-center">
<button
onClick={() => setSelectedChildId(null)}
className="text-slate-500 font-bold hover:text-slate-800"
>
Fermer
</button>
</div>
</div>
</div>
)}
</main>
<footer className="mt-auto py-12 border-t border-slate-100 bg-white">
<div className="max-w-7xl mx-auto px-8 flex flex-col md:flex-row justify-between items-center gap-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-200 rounded-lg"></div>
<span className="text-slate-400 font-black uppercase tracking-widest text-sm">Reviflow &copy; 2026</span>
</div>
<div className="flex gap-10">
<a href="#" className="text-slate-400 text-xs font-bold uppercase tracking-widest hover:text-indigo-600 transition-colors">Documentation</a>
<a href="#" className="text-slate-400 text-xs font-bold uppercase tracking-widest hover:text-indigo-600 transition-colors">Privacy</a>
</div>
</div>
</footer>
</div>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AccessibilityState {
isDyslexic: boolean;
toggleDyslexic: () => void;
}
export const useAccessibility = create<AccessibilityState>()(
persist(
(set) => ({
isDyslexic: false,
toggleDyslexic: () => set((state) => ({ isDyslexic: !state.isDyslexic })),
}),
{
name: 'accessibility-storage',
}
)
);
+224
View File
@@ -0,0 +1,224 @@
import { create } from 'zustand';
import { api } from '../lib/api';
interface User {
id: string;
email: string;
username?: string;
is_active: boolean;
is_superuser: boolean;
role: 'parent' | 'learner';
first_name?: string;
openrouter_api_key?: string;
total_tokens_used?: number;
total_cost_usd?: number;
parent_id?: string;
has_parental_pin?: boolean;
learner_profile?: LearnerProfile;
}
interface LearnerProfile {
id: string;
first_name: string;
avatar_url: string | null;
streak_current?: number;
streak_max?: number;
xp?: number;
level?: number;
badges?: Badge[];
}
export interface Badge {
id: string;
badge_code: string;
earned_at: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isParentalModeActive: boolean;
learnerProfiles: LearnerProfile[];
activeLearner: LearnerProfile | null;
isLoading: boolean;
error: string | null;
// Actions
login: (credentials: any) => Promise<void>;
register: (credentials: any) => Promise<void>;
logout: () => Promise<void>;
checkAuth: (refresh?: boolean) => Promise<void>;
verifyParentalGate: (pinOrPassword: { pin?: string; password?: string }) => Promise<void>;
fetchProfiles: () => Promise<void>;
selectProfile: (learnerId: string) => Promise<void>;
updateProfile: (data: { first_name?: string; avatar_url?: string }) => Promise<void>;
createChildAccount: (data: { username: string; password?: string; first_name: string; avatar_url: string }) => Promise<void>;
}
export const useAuth = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isParentalModeActive: false,
learnerProfiles: [],
activeLearner: null,
isLoading: true,
error: null,
login: async (credentials) => {
set({ isLoading: true, error: null });
try {
// 1. Login (Get Token/Cookie)
const formData = new URLSearchParams();
// In our system, 'username' in credentials can be Email OR Username
formData.append('username', credentials.username || credentials.email);
formData.append('password', credentials.password);
const loginResponse = await api.post('/auth/jwt/login', formData, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
// Store the JWT token
const token = loginResponse.data.access_token;
if (token) {
localStorage.setItem('access_token', token);
}
// 2. Fetch User Profile
const userResponse = await api.get<User>('/auth/users/me');
const user = userResponse.data;
set({ user, isAuthenticated: true, isLoading: false, isParentalModeActive: user.role === 'parent' });
// 3. Auto-select profile if it's a learner
if (user.role === 'learner' && user.learner_profile) {
set({ activeLearner: user.learner_profile });
localStorage.setItem('active_learner_id', user.learner_profile.id);
} else {
// If parent, fetch all supervised child profiles
await get().fetchProfiles();
}
} catch (err: any) {
set({ error: 'Invalid credentials', isLoading: false });
throw err;
}
},
register: async (credentials) => {
set({ isLoading: true, error: null });
try {
await api.post('/auth/register', {
email: credentials.email,
password: credentials.password,
role: 'parent'
});
// Auto-login after register would happen here if desired
set({ isLoading: false });
} catch (err: any) {
set({ error: err.response?.data?.detail || 'Registration failed', isLoading: false });
throw err;
}
},
logout: async () => {
localStorage.removeItem('access_token');
set({ user: null, isAuthenticated: false, activeLearner: null });
},
checkAuth: async (refresh = false) => {
if (!refresh) set({ isLoading: true });
try {
const userResponse = await api.get<User>('/auth/users/me');
const user = userResponse.data;
set({ user, isAuthenticated: true, isParentalModeActive: user.role === 'parent' });
if (user.role === 'learner' && user.learner_profile) {
set({ activeLearner: user.learner_profile });
} else {
// Restore profiles for parent
const profileResponse = await api.get<LearnerProfile[]>('/auth/profiles');
const profiles = profileResponse.data;
set({ learnerProfiles: profiles });
// Restore active learner from localStorage (if parent previously selected one)
const storedLearnerId = localStorage.getItem('active_learner_id');
if (storedLearnerId) {
const active = profiles.find(p => p.id === storedLearnerId);
if (active) {
set({ activeLearner: active });
}
}
}
if (!refresh) set({ isLoading: false });
} catch (err) {
set({ user: null, isAuthenticated: false, isLoading: false, activeLearner: null });
}
},
verifyParentalGate: async (pinOrPassword) => {
try {
const response = await api.post('/auth/verify-parental-gate', pinOrPassword);
if (response.data.success) {
set({ isParentalModeActive: true });
} else {
throw new Error(response.data.error || 'Verification failed');
}
} catch (err: any) {
console.error("Parental verification failed", err);
throw err;
}
},
fetchProfiles: async () => {
try {
const response = await api.get<LearnerProfile[]>('/auth/profiles');
set({ learnerProfiles: response.data });
} catch (err) {
console.error("Failed to fetch profiles", err);
}
},
selectProfile: async (learnerId) => {
try {
const response = await api.post(`/auth/select-profile/${learnerId}`);
if (response.data.success) {
set({ activeLearner: response.data.profile });
// Store in local storage to persist choice
localStorage.setItem('active_learner_id', learnerId);
}
} catch (err) {
console.error("Failed to select profile", err);
throw err;
}
},
updateProfile: async (data: { first_name?: string; avatar_url?: string }) => {
try {
const response = await api.patch('/auth/profiles/me', data);
const updatedProfile = response.data;
set((state) => ({
user: state.user ? {
...state.user,
learner_profile: state.user.role === 'learner' ? updatedProfile : state.user.learner_profile
} : null,
activeLearner: state.activeLearner?.id === updatedProfile.id ? updatedProfile : state.activeLearner,
learnerProfiles: state.learnerProfiles.map(p => p.id === updatedProfile.id ? updatedProfile : p)
}));
} catch (err) {
console.error("Failed to update profile", err);
throw err;
}
},
createChildAccount: async (data) => {
try {
const response = await api.post('/auth/profiles', data);
if (response.data.success) {
// Refresh profiles
await get().fetchProfiles();
}
} catch (err) {
console.error("Failed to create child account", err);
throw err;
}
}
}));