Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
666 lines
30 KiB
TypeScript
666 lines
30 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
||
import { motion, AnimatePresence } from 'motion/react';
|
||
import {
|
||
GraduationCap,
|
||
Layers,
|
||
ArrowLeft,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
RotateCcw,
|
||
CheckCircle2,
|
||
X,
|
||
Inbox,
|
||
BookOpen,
|
||
Calendar,
|
||
Sparkles,
|
||
Award
|
||
} from 'lucide-react';
|
||
import { Note, Flashcard, FlashcardDeck, FlashcardEvaluation } from '../types';
|
||
|
||
interface RevisionViewProps {
|
||
notes: Note[];
|
||
flashcards: Flashcard[];
|
||
onUpdateFlashcards: (updated: Flashcard[]) => void;
|
||
onSelectNote: (noteId: string) => void;
|
||
onOpenSidebar?: () => void;
|
||
initialActiveDeckId?: string | null;
|
||
onClearActiveDeckId?: () => void;
|
||
}
|
||
|
||
export const RevisionView: React.FC<RevisionViewProps> = ({
|
||
notes,
|
||
flashcards,
|
||
onUpdateFlashcards,
|
||
onSelectNote,
|
||
onOpenSidebar,
|
||
initialActiveDeckId,
|
||
onClearActiveDeckId
|
||
}) => {
|
||
// Active states
|
||
const [activeDeckId, setActiveDeckId] = useState<string | null>(initialActiveDeckId || null);
|
||
const [isSessionActive, setIsSessionActive] = useState(false);
|
||
const [isSessionFinished, setIsSessionFinished] = useState(false);
|
||
|
||
// Active review states
|
||
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
||
const [isFlipped, setIsFlipped] = useState(false);
|
||
const [sessionCards, setSessionCards] = useState<Flashcard[]>([]);
|
||
const [sessionHistory, setSessionHistory] = useState<Record<string, FlashcardEvaluation>>({});
|
||
const [onlyFailedCardsSession, setOnlyFailedCardsSession] = useState(false);
|
||
|
||
// Sync initial deck selection from outer prop/reminder
|
||
useEffect(() => {
|
||
if (initialActiveDeckId) {
|
||
setActiveDeckId(initialActiveDeckId);
|
||
// Auto-trigger session
|
||
const deckCards = flashcards.filter(c => c.noteId === initialActiveDeckId);
|
||
if (deckCards.length > 0) {
|
||
setSessionCards([...deckCards]);
|
||
setCurrentCardIndex(0);
|
||
setIsFlipped(false);
|
||
setIsSessionActive(true);
|
||
setIsSessionFinished(false);
|
||
setSessionHistory({});
|
||
}
|
||
}
|
||
}, [initialActiveDeckId, flashcards]);
|
||
|
||
// Compute Decks based on current flashcards and notes
|
||
const decks = useMemo(() => {
|
||
const deckMap = new Map<string, Flashcard[]>();
|
||
flashcards.forEach(card => {
|
||
if (!deckMap.has(card.noteId)) {
|
||
deckMap.set(card.noteId, []);
|
||
}
|
||
deckMap.get(card.noteId)!.push(card);
|
||
});
|
||
|
||
const list: FlashcardDeck[] = [];
|
||
deckMap.forEach((cardsInDeck, noteId) => {
|
||
const parentNote = notes.find(n => n.id === noteId);
|
||
if (!parentNote || parentNote.isDeleted) return;
|
||
|
||
// Find min nextReviewDate
|
||
let minDate = cardsInDeck[0]?.nextReviewDate || new Date().toISOString();
|
||
cardsInDeck.forEach(c => {
|
||
if (c.nextReviewDate < minDate) {
|
||
minDate = c.nextReviewDate;
|
||
}
|
||
});
|
||
|
||
// Mastery score: portion of mastered/sure cards in last evaluation
|
||
const totalCards = cardsInDeck.length;
|
||
let masteredCount = 0;
|
||
cardsInDeck.forEach(c => {
|
||
if (c.mastered) masteredCount++;
|
||
});
|
||
|
||
list.push({
|
||
noteId,
|
||
title: parentNote.title,
|
||
cardsCount: totalCards,
|
||
nextReviewDate: minDate,
|
||
masteryScore: totalCards > 0 ? masteredCount / totalCards : 0,
|
||
cards: cardsInDeck
|
||
});
|
||
});
|
||
|
||
// Sort decks: first those that need review (past nextReviewDate), then alphabetical
|
||
const nowStr = new Date().toISOString();
|
||
return list.sort((a, b) => {
|
||
const aNeeds = a.nextReviewDate <= nowStr;
|
||
const bNeeds = b.nextReviewDate <= nowStr;
|
||
if (aNeeds && !bNeeds) return -1;
|
||
if (!aNeeds && bNeeds) return 1;
|
||
return a.title.localeCompare(b.title);
|
||
});
|
||
}, [flashcards, notes]);
|
||
|
||
const activeDeck = useMemo(() => {
|
||
return decks.find(d => d.noteId === activeDeckId);
|
||
}, [decks, activeDeckId]);
|
||
|
||
// Launch review session for a deck
|
||
const handleStartReview = (noteId: string, failedOnly = false) => {
|
||
const deck = decks.find(d => d.noteId === noteId);
|
||
if (!deck) return;
|
||
|
||
let cardsToReview = [...deck.cards];
|
||
if (failedOnly) {
|
||
// Filter for cards graded as 'fail' or 'hesitant' in session history, or simply subset of session cards
|
||
const failedIds = Object.keys(sessionHistory).filter(id => sessionHistory[id] === 'fail');
|
||
cardsToReview = deck.cards.filter(c => failedIds.includes(c.id));
|
||
if (cardsToReview.length === 0) {
|
||
// Fallback to active session's rated fail
|
||
cardsToReview = deck.cards.filter(c => sessionHistory[c.id] === 'fail');
|
||
}
|
||
setOnlyFailedCardsSession(true);
|
||
} else {
|
||
setOnlyFailedCardsSession(false);
|
||
}
|
||
|
||
if (cardsToReview.length === 0) return;
|
||
|
||
// Shuffle cards for better learning cognitive effect
|
||
const shuffled = [...cardsToReview].sort(() => Math.random() - 0.5);
|
||
|
||
setActiveDeckId(noteId);
|
||
setSessionCards(shuffled);
|
||
setCurrentCardIndex(0);
|
||
setIsFlipped(false);
|
||
setIsSessionActive(true);
|
||
setIsSessionFinished(false);
|
||
setSessionHistory({});
|
||
};
|
||
|
||
const handleCardFlip = () => {
|
||
setIsFlipped(!isFlipped);
|
||
};
|
||
|
||
// Keyboard support during review
|
||
useEffect(() => {
|
||
if (!isSessionActive || isSessionFinished) return;
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.code === 'Space') {
|
||
e.preventDefault();
|
||
handleCardFlip();
|
||
} else if (isFlipped) {
|
||
if (e.key === '1') {
|
||
handleEvaluate('fail');
|
||
} else if (e.key === '2') {
|
||
handleEvaluate('hesitant');
|
||
} else if (e.key === '3') {
|
||
handleEvaluate('sure');
|
||
}
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [isSessionActive, isSessionFinished, isFlipped, currentCardIndex, sessionCards]);
|
||
|
||
// Simple Spaced Repetition Logic (Leitner system variation)
|
||
const handleEvaluate = (evaluation: FlashcardEvaluation) => {
|
||
const currentCard = sessionCards[currentCardIndex];
|
||
if (!currentCard) return;
|
||
|
||
// Record evaluation in session context
|
||
setSessionHistory(prev => ({
|
||
...prev,
|
||
[currentCard.id]: evaluation
|
||
}));
|
||
|
||
// Calculate new intervals
|
||
let interval = currentCard.intervalDays || 1;
|
||
let ease = currentCard.easeFactor || 2.5;
|
||
let mastered = currentCard.mastered || false;
|
||
|
||
if (evaluation === 'fail') {
|
||
interval = 1; // back to review tomorrow
|
||
ease = Math.max(1.3, ease - 0.2);
|
||
mastered = false;
|
||
} else if (evaluation === 'hesitant') {
|
||
interval = Math.max(2, Math.floor(interval * 1.2));
|
||
mastered = false;
|
||
} else { // sure
|
||
interval = Math.ceil(interval * ease);
|
||
ease = Math.min(3.5, ease + 0.15);
|
||
mastered = true;
|
||
}
|
||
|
||
// Calculate next review date
|
||
const nextDate = new Date();
|
||
nextDate.setDate(nextDate.getDate() + interval);
|
||
|
||
// Build historical entry
|
||
const historyItem = {
|
||
reviewedAt: new Date().toISOString(),
|
||
evaluation
|
||
};
|
||
|
||
const updatedCard: Flashcard = {
|
||
...currentCard,
|
||
intervalDays: interval,
|
||
nextReviewDate: nextDate.toISOString(),
|
||
easeFactor: ease,
|
||
mastered,
|
||
history: [...(currentCard.history || []), historyItem]
|
||
};
|
||
|
||
// Propagate up to global storage
|
||
const updatedGlobal = flashcards.map(c => c.id === currentCard.id ? updatedCard : c);
|
||
onUpdateFlashcards(updatedGlobal);
|
||
|
||
// Update in-place session cards to preserve intermediate updates
|
||
setSessionCards(prev => prev.map((c, i) => i === currentCardIndex ? updatedCard : c));
|
||
|
||
// Progress flow
|
||
if (currentCardIndex < sessionCards.length - 1) {
|
||
setTimeout(() => {
|
||
setCurrentCardIndex(prev => prev + 1);
|
||
setIsFlipped(false);
|
||
}, 300);
|
||
} else {
|
||
setTimeout(() => {
|
||
setIsSessionFinished(true);
|
||
}, 300);
|
||
}
|
||
};
|
||
|
||
const handleNext = () => {
|
||
if (currentCardIndex < sessionCards.length - 1) {
|
||
setCurrentCardIndex(currentCardIndex + 1);
|
||
setIsFlipped(false);
|
||
}
|
||
};
|
||
|
||
const handlePrev = () => {
|
||
if (currentCardIndex > 0) {
|
||
setCurrentCardIndex(currentCardIndex - 1);
|
||
setIsFlipped(false);
|
||
}
|
||
};
|
||
|
||
const handleExitSession = () => {
|
||
setIsSessionActive(false);
|
||
setIsSessionFinished(false);
|
||
onClearActiveDeckId?.();
|
||
};
|
||
|
||
// Statistics summaries
|
||
const finishedStats = useMemo(() => {
|
||
if (sessionCards.length === 0) return { sureCount: 0, hesitantCount: 0, failCount: 0, percentage: 0 };
|
||
|
||
let sureCount = 0;
|
||
let hesitantCount = 0;
|
||
let failCount = 0;
|
||
|
||
sessionCards.forEach(c => {
|
||
const evaluation = sessionHistory[c.id];
|
||
if (evaluation === 'sure') sureCount++;
|
||
else if (evaluation === 'hesitant') hesitantCount++;
|
||
else if (evaluation === 'fail') failCount++;
|
||
});
|
||
|
||
const totalRated = Object.keys(sessionHistory).length || 1;
|
||
const percentage = Math.round((sureCount / totalRated) * 100);
|
||
|
||
return {
|
||
sureCount,
|
||
hesitantCount,
|
||
failCount,
|
||
percentage
|
||
};
|
||
}, [sessionCards, sessionHistory]);
|
||
|
||
const formattingDate = (isoStr: string) => {
|
||
const diff = new Date(isoStr).getTime() - Date.now();
|
||
if (diff <= 0) return 'Dû aujourd\'hui';
|
||
const days = Math.ceil(diff / (24 * 60 * 60 * 1000));
|
||
return `Dans ${days}j`;
|
||
};
|
||
|
||
return (
|
||
<div className="h-full flex flex-col bg-white dark:bg-dark-paper overflow-y-auto w-full transition-colors duration-500">
|
||
|
||
{/* 1. Header Toolbar */}
|
||
<div className="px-6 sm:px-12 py-6 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-dark-paper/90 backdrop-blur-sm z-40 border-b border-border gap-4">
|
||
<div className="flex items-center gap-4">
|
||
{onOpenSidebar && (
|
||
<button
|
||
onClick={onOpenSidebar}
|
||
className="lg:hidden p-2 -ml-2 text-ink dark:text-dark-ink hover:bg-black/5 rounded-lg transition-colors"
|
||
>
|
||
<ChevronLeft size={20} />
|
||
</button>
|
||
)}
|
||
|
||
{isSessionActive ? (
|
||
<button
|
||
onClick={handleExitSession}
|
||
className="flex items-center gap-2 text-concrete hover:text-ink dark:text-dark-concrete dark:hover:text-dark-ink transition-colors"
|
||
>
|
||
<ArrowLeft size={16} />
|
||
<span className="text-xs font-bold uppercase tracking-widest">Abandonner</span>
|
||
</button>
|
||
) : (
|
||
<div className="flex items-center gap-2.5">
|
||
<GraduationCap className="text-accent shrink-0" size={20} />
|
||
<h2 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Focal de Révision</h2>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isSessionActive && activeDeck && (
|
||
<div className="text-[11px] font-mono bg-paper dark:bg-dark-paper text-concrete border border-border px-3 py-1.5 rounded-full lowercase tracking-wider">
|
||
deck : <span className="font-bold text-accent">{activeDeck.title}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 2. Main Display Area */}
|
||
<div className="flex-1 flex flex-col items-center justify-center p-6 md:p-12 max-w-5xl mx-auto w-full">
|
||
<AnimatePresence mode="wait">
|
||
|
||
{/* SCREEN A: Decks Collection list view */}
|
||
{!isSessionActive && (
|
||
<motion.div
|
||
key="decks-list"
|
||
initial={{ opacity: 0, y: 15 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -15 }}
|
||
className="w-full space-y-10"
|
||
>
|
||
<div className="space-y-2">
|
||
<h1 className="text-4xl font-serif font-black text-ink dark:text-dark-ink">Decks de Révision Active</h1>
|
||
<p className="text-sm font-light text-muted-ink dark:text-dark-muted max-w-2xl">
|
||
Révisez vos connaissances de manière ciblée grâce au système d'espacement algorithmique Leitner. L’apprentissage actif commence ici.
|
||
</p>
|
||
</div>
|
||
|
||
{decks.length > 0 ? (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{decks.map(deck => {
|
||
const nowStr = new Date().toISOString();
|
||
const dueCount = deck.cards.filter(c => c.nextReviewDate <= nowStr).length;
|
||
|
||
return (
|
||
<div
|
||
key={deck.noteId}
|
||
id={`deck-card-${deck.noteId}`}
|
||
className="p-6 bg-[#FCFCFA] dark:bg-white/[0.02] border border-border/60 hover:border-accent/40 rounded-2xl flex flex-col justify-between gap-5 transition-all shadow-sm hover:shadow-xs relative group"
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="flex justify-between items-start gap-3">
|
||
<div className="space-y-1 truncate">
|
||
<h3 className="text-lg font-serif font-semibold text-ink dark:text-dark-ink truncate group-hover:text-accent transition-colors">
|
||
{deck.title}
|
||
</h3>
|
||
<p className="text-xs text-concrete flex items-center gap-1">
|
||
<Layers size={12} />
|
||
<span>{deck.cardsCount} cartes de mémoire</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* Circular progress bar rendering */}
|
||
<div className="relative w-12 h-12 flex items-center justify-center shrink-0">
|
||
<svg className="w-full h-full transform -rotate-90">
|
||
<circle cx="24" cy="24" r="19" stroke="currentColor" strokeWidth="2" className="text-zinc-100 dark:text-zinc-800" fill="transparent" />
|
||
<circle cx="24" cy="24" r="19" stroke="currentColor" strokeWidth="3" className="text-sage" fill="transparent" strokeDasharray={2 * Math.PI * 19} strokeDashoffset={2 * Math.PI * 19 * (1 - deck.masteryScore)} />
|
||
</svg>
|
||
<span className="absolute text-[10px] font-mono font-black text-sage">
|
||
{Math.round(deck.masteryScore * 100)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2 items-center text-[10.5px] font-medium text-concrete pt-1">
|
||
{dueCount > 0 ? (
|
||
<span className="bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/15 px-2.5 py-1 rounded-full flex items-center gap-1 font-bold animate-pulse">
|
||
{dueCount} à réviser
|
||
</span>
|
||
) : (
|
||
<span className="bg-sage/10 text-sage dark:text-sage border border-sage/15 px-2.5 py-1 rounded-full flex items-center gap-1 font-bold">
|
||
À jour
|
||
</span>
|
||
)}
|
||
<span className="bg-slate-500/5 dark:bg-white/5 border border-border px-2.5 py-1 rounded-full flex items-center gap-1 font-mono">
|
||
<Calendar size={10} />
|
||
Prochain : {formattingDate(deck.nextReviewDate)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2 pt-1 border-t border-border/40">
|
||
<button
|
||
onClick={() => onSelectNote(deck.noteId)}
|
||
className="flex-1 h-9 flex items-center justify-center text-[10.5px] uppercase tracking-wider font-bold text-muted-ink hover:text-ink dark:text-dark-muted dark:hover:text-dark-ink border border-border rounded-lg bg-white/50 dark:bg-transparent hover:bg-slate-50 dark:hover:bg-white/5 transition-colors"
|
||
>
|
||
Ouvrir note
|
||
</button>
|
||
<button
|
||
onClick={() => handleStartReview(deck.noteId)}
|
||
className="flex-1 h-9 flex items-center justify-center bg-accent text-white text-[10.5px] uppercase tracking-wider font-bold rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1.5 shadow-sm shadow-accent/10"
|
||
>
|
||
<GraduationCap size={14} />
|
||
Réviser
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center text-center p-16 border border-dashed border-border/60 rounded-3xl bg-[#FAF9F6]/30 py-24">
|
||
<div className="w-16 h-16 rounded-2xl bg-accent/5 text-accent flex items-center justify-center mb-6">
|
||
<GraduationCap size={32} />
|
||
</div>
|
||
<h3 className="text-2xl font-serif font-black text-ink dark:text-dark-ink mb-2">Aucun deck de flashcards</h3>
|
||
<p className="text-sm text-concrete max-w-md font-light mb-8">
|
||
Démarrez votre apprentissage en générant des flashcards à l'aide de l'IA directement depuis la barre d'outils de vos notes architecturales.
|
||
</p>
|
||
<button
|
||
onClick={() => onSelectNote('n1')}
|
||
className="h-11 px-6 bg-ink dark:bg-ochre text-paper dark:text-ink rounded-xl text-xs font-bold uppercase tracking-widest hover:opacity-90 transition-all flex items-center gap-2"
|
||
>
|
||
<BookOpen size={15} />
|
||
Essayer sur la Note "Grid Systems"
|
||
</button>
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* SCREEN B: Active Deck session review state */}
|
||
{isSessionActive && !isSessionFinished && (
|
||
<motion.div
|
||
key="active-session"
|
||
initial={{ opacity: 0, scale: 0.98 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.98 }}
|
||
className="w-full max-w-2xl flex flex-col items-center gap-10"
|
||
>
|
||
{/* Header navigation bar */}
|
||
<div className="w-full flex items-center justify-between px-2 text-xs font-medium text-concrete">
|
||
<button
|
||
onClick={handlePrev}
|
||
disabled={currentCardIndex === 0}
|
||
className="flex items-center gap-1 hover:text-ink dark:hover:text-dark-ink disabled:opacity-30 transition-colors cursor-pointer"
|
||
>
|
||
<ChevronLeft size={16} />
|
||
<span>Précédent</span>
|
||
</button>
|
||
|
||
<div className="px-3.5 py-1.5 bg-slate-100 dark:bg-white/5 border border-border/40 text-[11px] font-mono tracking-widest font-bold rounded-full text-ink dark:text-dark-ink">
|
||
{currentCardIndex + 1} / {sessionCards.length}
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleNext}
|
||
disabled={currentCardIndex === sessionCards.length - 1}
|
||
className="flex items-center gap-1 hover:text-ink dark:hover:text-dark-ink disabled:opacity-30 transition-colors cursor-pointer"
|
||
>
|
||
<span>Suivant</span>
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Centered Flashcard */}
|
||
<div
|
||
id="flashcard-container"
|
||
onClick={handleCardFlip}
|
||
className="w-[480px] h-[280px] cursor-pointer select-none perspective group"
|
||
>
|
||
<div className={`relative w-full h-full transition-transform duration-500 transform-style preserve-3d ${isFlipped ? 'rotate-y-180' : ''}`}>
|
||
|
||
{/* RECTO - Front */}
|
||
<div className="absolute inset-0 w-full h-full backface-hidden bg-[#FAF9F5] dark:bg-slate-900 border border-border hover:border-accent/40 rounded-2xl p-8 flex flex-col justify-between shadow-md transition-colors">
|
||
<div className="flex justify-between items-start text-[10px] font-mono text-concrete/75 uppercase tracking-widest">
|
||
<span>Recto : Question</span>
|
||
<span className="bg-slate-200/50 dark:bg-white/10 px-2 py-0.5 rounded text-[8.5px]">Cliquer pour tourner</span>
|
||
</div>
|
||
|
||
<div className="flex-1 flex items-center justify-center p-2 text-center">
|
||
<p className="text-xl font-serif font-black text-ink dark:text-dark-ink leading-relaxed">
|
||
{sessionCards[currentCardIndex]?.question}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="text-[10px] text-center text-concrete italic font-light pt-2 shrink-0 border-t border-border/10">
|
||
Raccourci : [Espace] pour révéler la réponse
|
||
</div>
|
||
</div>
|
||
|
||
{/* VERSO - Back */}
|
||
<div className="absolute inset-0 w-full h-full backface-hidden rotate-y-180 bg-white dark:bg-paper dark:text-ink border border-border rounded-2xl p-8 flex flex-col justify-between shadow-xl">
|
||
<div className="flex justify-between items-start text-[10px] font-mono text-concrete/75 uppercase tracking-widest">
|
||
<span className="text-accent font-bold">Verso : Réponse</span>
|
||
<span className="bg-accent/10 px-2 py-0.5 rounded text-[8.5px] text-accent">Duo Mémoire</span>
|
||
</div>
|
||
|
||
<div className="flex-1 flex items-center justify-center p-2 text-center overflow-y-auto max-h-[160px] custom-scrollbar">
|
||
<p className="text-sm font-light text-ink leading-relaxed">
|
||
{sessionCards[currentCardIndex]?.answer}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="text-[10px] text-center text-concrete/60 italic font-light pt-2 shrink-0 border-t border-border/15">
|
||
Raccourcis : [1] Raté, [2] Hésitant, [3] Sûr
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* Grading Buttons - Rendered after Verso is revealed */}
|
||
<div className="h-16 flex items-center justify-center w-full">
|
||
<AnimatePresence mode="wait">
|
||
{isFlipped ? (
|
||
<motion.div
|
||
key="grading-expanded"
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
className="flex items-center gap-4 w-full justify-center max-w-md"
|
||
>
|
||
<button
|
||
id="grade-btn-fail"
|
||
onClick={(e) => { e.stopPropagation(); handleEvaluate('fail'); }}
|
||
className="flex-1 flex flex-col items-center justify-center h-14 rounded-2xl border border-rust/10 bg-rust/10 font-black text-rust cursor-pointer hover:bg-rust/15 transition-all text-xs"
|
||
>
|
||
<span className="text-sm">Raté</span>
|
||
<span className="opacity-70 text-[9px] font-light font-mono mt-0.5">Touche 1</span>
|
||
</button>
|
||
|
||
<button
|
||
id="grade-btn-hesitant"
|
||
onClick={(e) => { e.stopPropagation(); handleEvaluate('hesitant'); }}
|
||
className="flex-1 flex flex-col items-center justify-center h-14 rounded-2xl border border-ochre/15 bg-ochre/10 font-black text-ochre cursor-pointer hover:bg-ochre/15 transition-all text-xs"
|
||
>
|
||
<span className="text-sm">Hésitant</span>
|
||
<span className="opacity-70 text-[9px] font-light font-mono mt-0.5">Touche 2</span>
|
||
</button>
|
||
|
||
<button
|
||
id="grade-btn-sure"
|
||
onClick={(e) => { e.stopPropagation(); handleEvaluate('sure'); }}
|
||
className="flex-1 flex flex-col items-center justify-center h-14 rounded-2xl border border-sage/15 bg-sage/10 font-black text-sage cursor-pointer hover:bg-sage/15 transition-all text-xs"
|
||
>
|
||
<span className="text-sm">Sûr</span>
|
||
<span className="opacity-70 text-[9px] font-light font-mono mt-0.5">Touche 3</span>
|
||
</button>
|
||
</motion.div>
|
||
) : (
|
||
<motion.button
|
||
key="reveal-btn"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={handleCardFlip}
|
||
className="h-12 px-8 bg-ink dark:bg-dark-ink text-paper dark:text-dark-paper text-xs uppercase font-bold tracking-widest rounded-xl hover:opacity-90 active:scale-98 transition-all shadow-md shrink-0 cursor-pointer"
|
||
>
|
||
Révéler la réponse (Espace)
|
||
</motion.button>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* SCREEN C: Finishing dashboard view with Donut Chart and actions */}
|
||
{isSessionFinished && (
|
||
<motion.div
|
||
key="finished-stats"
|
||
initial={{ opacity: 0, scale: 0.96 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.96 }}
|
||
className="w-full max-w-lg flex flex-col items-center text-center gap-8"
|
||
>
|
||
<div className="space-y-2">
|
||
<div className="w-12 h-12 rounded-full bg-sage/10 text-sage flex items-center justify-center mx-auto mb-3">
|
||
<Award size={26} />
|
||
</div>
|
||
<h1 className="text-3xl font-serif font-black text-ink dark:text-dark-ink">Félicitations !</h1>
|
||
<p className="text-sm font-light text-muted-ink dark:text-dark-muted">
|
||
Vous venez de finir votre session de révision de la note active.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Custom SVG Donut Chart showing score */}
|
||
<div className="relative w-44 h-44 flex items-center justify-center my-2">
|
||
<svg className="w-full h-full transform -rotate-90">
|
||
<circle cx="88" cy="88" r="64" stroke="currentColor" strokeWidth="12" className="text-zinc-100 dark:text-zinc-900" fill="transparent" />
|
||
<circle cx="88" cy="88" r="64" stroke="currentColor" strokeWidth="12" className="text-sage" fill="transparent" strokeDasharray={2 * Math.PI * 64} strokeDashoffset={2 * Math.PI * 64 * (1 - (finishedStats.percentage / 100))} strokeLinecap="round" />
|
||
</svg>
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||
<span className="text-4.5xl font-serif font-black text-ink dark:text-dark-ink leading-none">
|
||
{finishedStats.percentage}%
|
||
</span>
|
||
<p className="text-[10px] uppercase font-bold tracking-widest text-concrete mt-1">Sûr de soi</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Core Analytics parameters (Stats) */}
|
||
<div className="grid grid-cols-3 gap-4 w-full border-t border-b border-border py-6 select-none bg-[#FCFCFA] dark:bg-white/[0.01] rounded-2xl px-6">
|
||
<div className="space-y-1">
|
||
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink">{sessionCards.length}</p>
|
||
<p className="text-[10px] uppercase tracking-wide font-medium text-concrete">Révisées</p>
|
||
</div>
|
||
<div className="space-y-1 border-l border-r border-border/60">
|
||
<p className="text-2xl font-serif font-bold text-rust">{finishedStats.failCount}</p>
|
||
<p className="text-[10px] uppercase tracking-wide font-medium text-concrete">À revoir</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<p className="text-2xl font-serif font-bold text-sage">{finishedStats.sureCount}</p>
|
||
<p className="text-[10px] uppercase tracking-wide font-medium text-concrete">Maîtrisées</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row gap-3 w-full">
|
||
<button
|
||
onClick={handleExitSession}
|
||
className="flex-1 h-11 border border-border text-ink dark:text-dark-ink rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-slate-50 dark:hover:bg-white/5 transition-all cursor-pointer"
|
||
>
|
||
Retour aux decks
|
||
</button>
|
||
{finishedStats.failCount > 0 && (
|
||
<button
|
||
onClick={() => handleStartReview(activeDeckId!, true)}
|
||
className="flex-1 h-11 bg-[#8F4C38] text-white rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-[#8F4C38]/95 transition-all flex items-center justify-center gap-1.5 shadow-sm cursor-pointer"
|
||
>
|
||
<RotateCcw size={14} />
|
||
Ré-réviser les ratées
|
||
</button>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
};
|