mirror of
https://github.com/R0m1k3/Reviflow.git
synced 2026-10-21 05:58:09 +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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user