mirror of
https://github.com/R0m1k3/Reviflow.git
synced 2026-10-17 20:29:54 +02:00
Sauvegarde de l'état (réparation)
This commit is contained in:
commit
7c04fffe77
434 files changed
+83923
No files matched your search
@@ -0,0 +1 @@
|
||||
/* App.css is deprecated. Use Tailwind classes in components. */
|
||||
@@ -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.
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© 2026 Reviflow. Tous droits réservés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
© 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 © 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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}));
|
||||
Reference in new issue
Block a user