Files
Momento/memento-note/components/flashcards/revision-view.tsx
Antigravity 0784c94242
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note)
avec activation guidée, tableau éditable, kanban et suppression de colonnes.
Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN.
Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la
robustesse du serveur MCP (config, validation, rate-limit, métriques).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:03:16 +00:00

1248 lines
54 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import {
GraduationCap,
Layers,
ArrowLeft,
ChevronLeft,
ChevronRight,
Calendar,
Plus,
BarChart3,
Loader2,
BookOpen,
Check,
ChevronDown,
Trash2,
Pencil,
X,
BookMarked,
Clock,
Timer,
Flame,
TrendingUp,
} from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { NoteChart } from '@/components/note-chart'
import { RevisionHeatmap } from './revision-heatmap'
import { RetentionCurve } from './retention-curve'
import { toast } from 'sonner'
interface DeckSummary {
id: string
name: string
notebookId: string | null
notebookName: string | null
totalCards: number
dueCount: number
masteredCount: number
lastReviewedAt: string | null
nextReviewAt: string | null
}
interface FlashcardItem {
id: string
front: string
back: string
type: string
due: boolean
mastered: boolean
}
interface StatsResponse {
heatmap: { date: string; count: number }[]
retentionRate: number
retentionByWeek: { week: string; rate: number; total: number }[]
difficultCards: { id: string; front: string; easinessFactor: number; deckId: string; deckName: string }[]
totalReviews: number
streak: number
totalCards: number
masteredCount: number
}
type PageTab = 'decks' | 'progress'
type SessionGrade = 1 | 2 | 3 | 4
type ReviewMode = 'due' | 'all'
function shuffleCards<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
function buildSessionQueue(cards: FlashcardItem[], dueOnly: boolean): FlashcardItem[] {
let toReview = dueOnly ? cards.filter((c) => c.due) : cards
if (toReview.length === 0) toReview = cards
return shuffleCards(toReview)
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
if (m === 0) return `${s}s`
return `${m}m ${s}s`
}
const GRADE_OPTIONS: {
grade: SessionGrade
idle: string
selected: string
ring: string
}[] = [
{
grade: 1,
idle: 'bg-red-500/10 text-red-600 border-red-500/20 hover:bg-red-500/15',
selected: 'bg-red-500 text-white border-red-500 shadow-lg shadow-red-500/25',
ring: 'ring-red-400/50',
},
{
grade: 2,
idle: 'bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/15',
selected: 'bg-amber-500 text-white border-amber-500 shadow-lg shadow-amber-500/25',
ring: 'ring-amber-400/50',
},
{
grade: 3,
idle: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/15',
selected: 'bg-emerald-500 text-white border-emerald-500 shadow-lg shadow-emerald-500/25',
ring: 'ring-emerald-400/50',
},
{
grade: 4,
idle: 'bg-brand-accent/10 text-brand-accent border-brand-accent/30 hover:bg-brand-accent/15',
selected: 'bg-brand-accent text-white border-brand-accent shadow-lg shadow-brand-accent/25',
ring: 'ring-brand-accent/50',
},
]
const TYPE_LABEL: Record<string, string> = {
qa: 'Q&A',
cloze: 'Cloze',
concept: 'Concept',
}
/** Inline card editor inside the expanded deck list */
function CardEditRow({
card,
onSaved,
onDeleted,
}: {
card: FlashcardItem
onSaved: (id: string, front: string, back: string) => void
onDeleted: (id: string) => void
}) {
const { t } = useLanguage()
const [editing, setEditing] = useState(false)
const [front, setFront] = useState(card.front)
const [back, setBack] = useState(card.back)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const handleSave = async () => {
setSaving(true)
try {
const res = await fetch(`/api/flashcards/${card.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ front: front.trim(), back: back.trim() }),
})
if (!res.ok) throw new Error()
onSaved(card.id, front.trim(), back.trim())
setEditing(false)
toast.success(t('flashcards.cardSaved'))
} catch {
toast.error(t('flashcards.deleteFailed'))
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
setDeleting(true)
try {
const res = await fetch(`/api/flashcards/${card.id}`, { method: 'DELETE' })
if (!res.ok) throw new Error()
onDeleted(card.id)
toast.success(t('flashcards.cardDeleted'))
} catch {
toast.error(t('flashcards.deleteFailed'))
} finally {
setDeleting(false)
setConfirmDelete(false)
}
}
if (editing) {
return (
<li className="p-3 rounded-xl border border-brand-accent/30 bg-brand-accent/5 space-y-2">
<input
value={front}
onChange={(e) => setFront(e.target.value)}
className="w-full text-sm font-medium bg-transparent border-b border-border/50 pb-1 outline-none"
placeholder={t('flashcards.frontPlaceholder')}
autoFocus
/>
<textarea
value={back}
onChange={(e) => setBack(e.target.value)}
rows={2}
className="w-full text-xs text-muted-foreground bg-transparent outline-none resize-none"
placeholder={t('flashcards.backPlaceholder')}
/>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={handleSave}
disabled={saving || !front.trim() || !back.trim()}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand-accent text-white text-[10px] font-bold disabled:opacity-50"
>
{saving ? <Loader2 size={10} className="animate-spin" /> : <Check size={10} />}
{t('general.save') || 'Save'}
</button>
<button
type="button"
onClick={() => { setEditing(false); setFront(card.front); setBack(card.back) }}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-border text-[10px] font-bold"
>
<X size={10} /> {t('general.cancel')}
</button>
</div>
</li>
)
}
return (
<li className="p-3 rounded-xl border border-border/60 bg-paper/40 dark:bg-background/40 space-y-1.5">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug line-clamp-2">{card.front}</p>
<div className="flex shrink-0 items-center gap-1">
{card.due && (
<span className="px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-600 text-[9px] font-bold uppercase">
{t('flashcards.dueBadge')}
</span>
)}
{card.mastered && (
<span className="px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-700 text-[9px] font-bold uppercase">
{t('flashcards.masteredBadge')}
</span>
)}
{/* Gap 7 — type badge */}
<span className="px-1.5 py-0.5 rounded-full border border-border text-[9px] font-mono text-concrete">
{TYPE_LABEL[card.type] ?? card.type}
</span>
{/* Gap 5 — edit button */}
<button
type="button"
onClick={() => setEditing(true)}
aria-label={t('flashcards.editCard')}
className="p-1 rounded-md hover:bg-black/5 dark:hover:bg-white/5 text-concrete hover:text-foreground transition-colors"
>
<Pencil size={11} />
</button>
{/* Gap 5 — delete button */}
{confirmDelete ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="px-2 py-0.5 rounded-md bg-red-500 text-white text-[9px] font-bold"
>
{deleting ? <Loader2 size={9} className="animate-spin inline" /> : t('general.confirm') || 'OK'}
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="px-2 py-0.5 rounded-md border border-border text-[9px] font-bold"
>
<X size={9} />
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
aria-label={t('flashcards.deleteCard')}
className="p-1 rounded-md hover:bg-red-500/10 text-concrete hover:text-red-500 transition-colors"
>
<Trash2 size={11} />
</button>
)}
</div>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">{card.back}</p>
</li>
)
}
export function RevisionView() {
const { t } = useLanguage()
const searchParams = useSearchParams()
const initialDeckId = searchParams.get('deckId')
const [tab, setTab] = useState<PageTab>('decks')
const [decks, setDecks] = useState<DeckSummary[]>([])
const [loadingDecks, setLoadingDecks] = useState(true)
const [stats, setStats] = useState<StatsResponse | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
const [activeDeckId, setActiveDeckId] = useState<string | null>(null)
const [deckCards, setDeckCards] = useState<FlashcardItem[]>([])
const [deckStats, setDeckStats] = useState({ total: 0, due: 0, mastered: 0 })
const [loadingDeck, setLoadingDeck] = useState(false)
// Gap 2 — review mode toggle
const [reviewMode, setReviewMode] = useState<ReviewMode>('due')
const [isSessionActive, setIsSessionActive] = useState(false)
const [isSessionFinished, setIsSessionFinished] = useState(false)
const [sessionCards, setSessionCards] = useState<FlashcardItem[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [isFlipped, setIsFlipped] = useState(false)
const [sessionGrades, setSessionGrades] = useState<Record<string, SessionGrade>>({})
const [reviewing, setReviewing] = useState(false)
const [highlightedGrade, setHighlightedGrade] = useState<SessionGrade | null>(null)
// Gap 3 — session timer
const [sessionStartTime, setSessionStartTime] = useState<number | null>(null)
const [sessionDurationSeconds, setSessionDurationSeconds] = useState(0)
const sessionTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [newDeckName, setNewDeckName] = useState('')
const [creatingDeck, setCreatingDeck] = useState(false)
// Gap 8 — deck deletion state
const [deletingDeckId, setDeletingDeckId] = useState<string | null>(null)
const [confirmDeleteDeckId, setConfirmDeleteDeckId] = useState<string | null>(null)
const initialSessionBootstrappedRef = useRef<string | null>(null)
const deckCardRefs = useRef<Record<string, HTMLDivElement | null>>({})
const [expandedDeckId, setExpandedDeckId] = useState<string | null>(null)
const [loadingExpandedDeckId, setLoadingExpandedDeckId] = useState<string | null>(null)
const loadDecks = useCallback(async () => {
setLoadingDecks(true)
try {
const res = await fetch('/api/flashcards/decks')
const data = await res.json()
if (res.ok) setDecks(data.decks || [])
} finally {
setLoadingDecks(false)
}
}, [])
const loadStats = useCallback(async () => {
setLoadingStats(true)
try {
const res = await fetch('/api/flashcards/stats')
const data = await res.json()
if (res.ok) setStats(data)
} finally {
setLoadingStats(false)
}
}, [])
const loadDeck = useCallback(async (deckId: string) => {
setLoadingDeck(true)
try {
const res = await fetch(`/api/flashcards/decks/${deckId}`)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.loadDeckFailed'))
return
}
setActiveDeckId(deckId)
setDeckCards(data.deck.cards || [])
setDeckStats(data.stats)
} finally {
setLoadingDeck(false)
}
}, [t])
useEffect(() => {
loadDecks()
}, [loadDecks])
useEffect(() => {
if (tab === 'progress') loadStats()
}, [tab, loadStats])
// Gap 3 — session timer management
const startTimer = useCallback(() => {
const start = Date.now()
setSessionStartTime(start)
setSessionDurationSeconds(0)
if (sessionTimerRef.current) clearInterval(sessionTimerRef.current)
sessionTimerRef.current = setInterval(() => {
setSessionDurationSeconds(Math.floor((Date.now() - start) / 1000))
}, 1000)
}, [])
const stopTimer = useCallback(() => {
if (sessionTimerRef.current) {
clearInterval(sessionTimerRef.current)
sessionTimerRef.current = null
}
}, [])
useEffect(() => () => stopTimer(), [stopTimer])
const startSession = useCallback((deckId: string, cardsInput: FlashcardItem[], mode: ReviewMode = reviewMode) => {
if (cardsInput.length === 0) {
void loadDeck(deckId)
return
}
const shuffled = buildSessionQueue(cardsInput, mode === 'due')
setSessionCards(shuffled)
setCurrentIndex(0)
setIsFlipped(false)
setSessionGrades({})
setIsSessionActive(true)
setIsSessionFinished(false)
setActiveDeckId(deckId)
startTimer()
}, [loadDeck, reviewMode, startTimer])
const toggleDeckDetails = useCallback(async (deckId: string) => {
if (expandedDeckId === deckId) {
setExpandedDeckId(null)
return
}
setExpandedDeckId(deckId)
setLoadingExpandedDeckId(deckId)
try {
const res = await fetch(`/api/flashcards/decks/${deckId}`)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.loadDeckFailed'))
setExpandedDeckId(null)
return
}
const cards: FlashcardItem[] = data.deck?.cards || []
setActiveDeckId(deckId)
setDeckCards(cards)
setDeckStats(data.stats || { total: 0, due: 0, mastered: 0 })
window.requestAnimationFrame(() => {
deckCardRefs.current[deckId]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
} catch {
toast.error(t('flashcards.loadDeckFailed'))
setExpandedDeckId(null)
} finally {
setLoadingExpandedDeckId(null)
}
}, [expandedDeckId, t])
useEffect(() => {
if (!initialDeckId) return
if (initialSessionBootstrappedRef.current === initialDeckId) return
let cancelled = false
;(async () => {
const res = await fetch(`/api/flashcards/decks/${initialDeckId}`)
const data = await res.json()
if (cancelled || !res.ok) return
const cards: FlashcardItem[] = data.deck?.cards || []
initialSessionBootstrappedRef.current = initialDeckId
setActiveDeckId(initialDeckId)
setDeckCards(cards)
setDeckStats(data.stats || { total: 0, due: 0, mastered: 0 })
startSession(initialDeckId, cards)
})()
return () => {
cancelled = true
}
}, [initialDeckId, startSession])
const activeDeck = useMemo(
() => decks.find((d) => d.id === activeDeckId),
[decks, activeDeckId],
)
// Gap 3 — session summary stats
const sessionSummary = useMemo(() => {
const reviewed = Object.keys(sessionGrades).length
const newMastered = Object.values(sessionGrades).filter((g) => g >= 3).length
return { reviewed, newMastered }
}, [sessionGrades])
const sessionChartData = useMemo(() => {
const counts = { 1: 0, 2: 0, 3: 0, 4: 0 }
Object.values(sessionGrades).forEach((g) => { counts[g] += 1 })
return [
{ label: t('flashcards.grade.hard'), value: counts[1] },
{ label: t('flashcards.grade.difficult'), value: counts[2] },
{ label: t('flashcards.grade.good'), value: counts[3] },
{ label: t('flashcards.grade.easy'), value: counts[4] },
]
}, [sessionGrades, t])
useEffect(() => {
setHighlightedGrade(null)
}, [currentIndex])
const handleEvaluate = async (grade: SessionGrade) => {
const card = sessionCards[currentIndex]
if (!card || reviewing || highlightedGrade) return
setHighlightedGrade(grade)
setReviewing(true)
setSessionGrades((prev) => ({ ...prev, [card.id]: grade }))
const feedbackDelay = new Promise<void>((resolve) => {
window.setTimeout(resolve, 650)
})
try {
await Promise.all([
fetch(`/api/flashcards/${card.id}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grade }),
}),
feedbackDelay,
])
} catch {
toast.error(t('flashcards.reviewFailed'))
} finally {
setReviewing(false)
}
if (currentIndex < sessionCards.length - 1) {
setCurrentIndex((i) => i + 1)
setIsFlipped(false)
setHighlightedGrade(null)
} else {
stopTimer()
setIsSessionFinished(true)
setHighlightedGrade(null)
}
}
useEffect(() => {
if (!isSessionActive || isSessionFinished) return
const onKey = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault()
setIsFlipped((f) => !f)
} else if (isFlipped) {
if (e.key === '1') handleEvaluate(1)
if (e.key === '2') handleEvaluate(2)
if (e.key === '3') handleEvaluate(3)
if (e.key === '4') handleEvaluate(4)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isSessionActive, isSessionFinished, isFlipped, currentIndex, reviewing, highlightedGrade])
const exitSession = () => {
stopTimer()
setIsSessionActive(false)
setIsSessionFinished(false)
if (activeDeckId) {
loadDeck(activeDeckId)
loadDecks()
}
}
const createDeck = async () => {
const name = newDeckName.trim()
if (!name) return
setCreatingDeck(true)
try {
const res = await fetch('/api/flashcards/decks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const data = await res.json()
if (res.ok) {
setNewDeckName('')
await loadDecks()
toast.success(t('flashcards.deckCreated'))
if (data.deck?.id) setActiveDeckId(data.deck.id)
}
} finally {
setCreatingDeck(false)
}
}
// Gap 8 — delete a deck
const handleDeleteDeck = async (deckId: string) => {
setDeletingDeckId(deckId)
try {
const res = await fetch(`/api/flashcards/decks/${deckId}`, { method: 'DELETE' })
if (!res.ok) throw new Error()
setDecks((prev) => prev.filter((d) => d.id !== deckId))
if (activeDeckId === deckId) {
setActiveDeckId(null)
setDeckCards([])
}
if (expandedDeckId === deckId) setExpandedDeckId(null)
toast.success(t('flashcards.deckDeleted'))
} catch {
toast.error(t('flashcards.deleteFailed'))
} finally {
setDeletingDeckId(null)
setConfirmDeleteDeckId(null)
}
}
const formatNextReview = (isoStr: string | null): string => {
if (!isoStr) return ''
const diff = new Date(isoStr).getTime() - Date.now()
if (diff <= 0) return t('flashcards.dueToday')
const days = Math.ceil(diff / (24 * 60 * 60 * 1000))
return t('flashcards.nextReviewIn', { days })
}
const formatDue = (dueCount: number) => {
if (dueCount > 0) return t('flashcards.dueCount', { count: dueCount })
return t('flashcards.upToDate')
}
// Gap 5 — update card in local state after edit
const handleCardSaved = useCallback((id: string, front: string, back: string) => {
setDeckCards((prev) => prev.map((c) => c.id === id ? { ...c, front, back } : c))
}, [])
// Gap 5 — remove card from local state after delete
const handleCardDeleted = useCallback((id: string) => {
setDeckCards((prev) => prev.filter((c) => c.id !== id))
setDeckStats((prev) => ({ ...prev, total: Math.max(0, prev.total - 1) }))
}, [])
return (
<div className="h-full flex flex-col bg-memento-paper dark:bg-background overflow-y-auto">
<div className="px-6 sm:px-10 py-5 flex items-center justify-between sticky top-0 bg-memento-paper/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border/40">
<div className="flex items-center gap-3">
{isSessionActive ? (
<button
type="button"
onClick={exitSession}
className="flex items-center gap-2 text-concrete hover:text-foreground transition-colors"
>
<ArrowLeft size={16} />
<span className="text-xs font-bold uppercase tracking-widest">{t('flashcards.exitSession')}</span>
</button>
) : (
<div className="flex items-center gap-2">
<GraduationCap className="text-brand-accent" size={20} />
<h1 className="text-sm font-black tracking-widest uppercase">{t('nav.revision')}</h1>
</div>
)}
</div>
{!isSessionActive && (
<div className="flex gap-1 p-1 rounded-lg bg-black/[0.04] dark:bg-white/[0.04]">
{(['decks', 'progress'] as const).map((id) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn(
'px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-colors',
tab === id ? 'bg-white dark:bg-card shadow-sm text-brand-accent' : 'text-concrete',
)}
>
{id === 'decks' ? t('flashcards.tabDecks') : t('flashcards.tabProgress')}
</button>
))}
</div>
)}
</div>
<div className="flex-1 max-w-5xl mx-auto w-full p-6 sm:p-10">
<AnimatePresence mode="wait">
{isSessionActive && !isSessionFinished && sessionCards[currentIndex] && (
<motion.div
key="session"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="max-w-2xl mx-auto flex flex-col items-center gap-8"
>
<div className="w-full flex items-center justify-between text-xs text-concrete">
<button
type="button"
disabled={currentIndex === 0}
onClick={() => { setCurrentIndex((i) => i - 1); setIsFlipped(false) }}
className="flex items-center gap-1 disabled:opacity-30"
>
<ChevronLeft size={16} /> {t('flashcards.previous')}
</button>
<div className="flex items-center gap-3">
{sessionStartTime !== null && (
<span className="font-mono text-[10px] text-concrete/60 flex items-center gap-1">
<Timer size={10} />
{formatDuration(sessionDurationSeconds)}
</span>
)}
<span className="font-mono font-bold px-3 py-1 rounded-full bg-black/[0.04] dark:bg-white/[0.06]">
{currentIndex + 1} / {sessionCards.length}
</span>
</div>
<button
type="button"
disabled={currentIndex >= sessionCards.length - 1}
onClick={() => { setCurrentIndex((i) => i + 1); setIsFlipped(false) }}
className="flex items-center gap-1 disabled:opacity-30"
>
{t('flashcards.next')} <ChevronRight size={16} />
</button>
</div>
<button
type="button"
onClick={() => {
if (highlightedGrade || reviewing) return
setIsFlipped((f) => !f)
}}
className={cn(
'w-full max-w-md min-h-[240px] [perspective:1000px] select-none',
highlightedGrade || reviewing ? 'cursor-default' : 'cursor-pointer',
)}
>
<div
key={sessionCards[currentIndex].id}
className={cn(
'relative w-full min-h-[240px] transition-transform duration-500 [transform-style:preserve-3d]',
isFlipped && '[transform:rotateY(180deg)]',
)}
>
<div className="absolute inset-0 [backface-visibility:hidden] rounded-2xl border border-border bg-card p-8 flex flex-col shadow-sm">
<span className="text-[10px] uppercase tracking-widest text-concrete mb-4">{t('flashcards.front')}</span>
<p className="flex-1 flex items-center justify-center text-center text-lg font-serif font-semibold">
{sessionCards[currentIndex].front}
</p>
<span className="text-[10px] text-concrete/60 text-center">{t('flashcards.tapToFlip')}</span>
</div>
<div
className={cn(
'absolute inset-0 [backface-visibility:hidden] [transform:rotateY(180deg)] rounded-2xl border-2 p-8 flex flex-col shadow-md transition-all duration-300',
!highlightedGrade && 'border-brand-accent/30 bg-brand-accent/5',
highlightedGrade === 1 && 'border-red-500 bg-red-500/[0.07] shadow-lg shadow-red-500/10',
highlightedGrade === 2 && 'border-amber-500 bg-amber-500/[0.07] shadow-lg shadow-amber-500/10',
highlightedGrade === 3 && 'border-emerald-500 bg-emerald-500/[0.07] shadow-lg shadow-emerald-500/10',
highlightedGrade === 4 && 'border-brand-accent bg-brand-accent/10 shadow-lg shadow-brand-accent/10',
)}
>
<span className="text-[10px] uppercase tracking-widest text-brand-accent mb-4">{t('flashcards.back')}</span>
<p className="flex-1 flex items-center justify-center text-center text-sm leading-relaxed overflow-y-auto">
{sessionCards[currentIndex].back}
</p>
</div>
</div>
</button>
{isFlipped && (
<div className="w-full max-w-md space-y-3">
<AnimatePresence mode="wait">
{highlightedGrade ? (
<motion.div
key="grade-confirmed"
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4 }}
transition={{ type: 'spring', stiffness: 400, damping: 28 }}
className={cn(
'flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl border text-xs font-bold',
GRADE_OPTIONS.find((o) => o.grade === highlightedGrade)?.selected,
GRADE_OPTIONS.find((o) => o.grade === highlightedGrade)?.ring,
'ring-2',
)}
>
<Check size={14} className="shrink-0" />
{t('flashcards.gradeSelected', {
label: t(`flashcards.grade.${['hard', 'difficult', 'good', 'easy'][highlightedGrade - 1]}`),
})}
</motion.div>
) : (
<motion.p
key="grade-hint"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-center text-[11px] text-concrete font-medium"
>
{t('flashcards.ratePrompt')}
</motion.p>
)}
</AnimatePresence>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{GRADE_OPTIONS.map(({ grade, idle, selected, ring }) => {
const label = t(`flashcards.grade.${['hard', 'difficult', 'good', 'easy'][grade - 1]}`)
const isSelected = highlightedGrade === grade
const isDimmed = highlightedGrade !== null && !isSelected
return (
<motion.button
key={grade}
type="button"
disabled={reviewing}
onClick={() => handleEvaluate(grade)}
animate={{
scale: isSelected ? 1.06 : isDimmed ? 0.92 : 1,
opacity: isDimmed ? 0.35 : 1,
}}
transition={{ type: 'spring', stiffness: 420, damping: 26 }}
className={cn(
'py-3 px-2 rounded-xl border text-[10px] font-bold uppercase tracking-wide',
'flex flex-col items-center justify-center gap-1 min-h-[52px]',
isSelected ? cn(selected, 'ring-2', ring) : idle,
)}
>
{isSelected && (
<motion.span
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 22 }}
>
<Check size={14} strokeWidth={3} />
</motion.span>
)}
<span>{label}</span>
</motion.button>
)
})}
</div>
</div>
)}
</motion.div>
)}
{/* Gap 3 — enriched session summary */}
{isSessionFinished && (
<motion.div
key="finished"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-lg mx-auto text-center space-y-6"
>
<GraduationCap size={40} className="mx-auto text-brand-accent" />
<h2 className="text-2xl font-serif font-bold">{t('flashcards.sessionComplete')}</h2>
{/* Stats summary row */}
<div className="grid grid-cols-3 gap-3">
<div className="p-3 rounded-xl border border-border bg-card/50 text-center">
<p className="text-[10px] uppercase tracking-widest text-concrete mb-1">{t('flashcards.sessionReviewed')}</p>
<p className="text-2xl font-serif font-bold">{sessionSummary.reviewed}</p>
</div>
<div className="p-3 rounded-xl border border-emerald-500/20 bg-emerald-500/5 text-center">
<p className="text-[10px] uppercase tracking-widest text-concrete mb-1">{t('flashcards.sessionNewMastered')}</p>
<p className="text-2xl font-serif font-bold text-emerald-600">{sessionSummary.newMastered}</p>
</div>
<div className="p-3 rounded-xl border border-border bg-card/50 text-center">
<p className="text-[10px] uppercase tracking-widest text-concrete mb-1 flex items-center justify-center gap-1">
<Clock size={9} />{t('flashcards.sessionDuration')}
</p>
<p className="text-2xl font-serif font-bold font-mono">{formatDuration(sessionDurationSeconds)}</p>
</div>
</div>
<div className="h-48">
<NoteChart type="bar" data={sessionChartData} height={180} />
</div>
<button
type="button"
onClick={exitSession}
className="px-6 py-2.5 rounded-xl bg-brand-accent text-white text-xs font-bold uppercase tracking-wider"
>
{t('flashcards.backToDecks')}
</button>
</motion.div>
)}
{!isSessionActive && tab === 'decks' && (
<motion.div key="decks" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-8">
{activeDeckId && deckCards.length > 0 && (
<div className="p-5 rounded-2xl border border-brand-accent/25 bg-brand-accent/5 space-y-3">
<h3 className="font-serif font-semibold text-lg">{activeDeck?.name || t('flashcards.activeDeck')}</h3>
<div className="flex flex-wrap gap-3 text-xs text-concrete">
<span>{t('flashcards.statTotal', { count: deckStats.total })}</span>
<span>{t('flashcards.statDue', { count: deckStats.due })}</span>
<span>{t('flashcards.statMastered', { count: deckStats.mastered })}</span>
</div>
{/* Gap 2 — review mode toggle */}
<div className="flex gap-1 p-1 rounded-lg bg-black/[0.04] dark:bg-white/[0.04] w-fit">
{(['due', 'all'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setReviewMode(mode)}
className={cn(
'px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-colors',
reviewMode === mode ? 'bg-white dark:bg-card shadow-sm text-brand-accent' : 'text-concrete',
)}
>
{mode === 'due' ? t('flashcards.reviewModeDue') : t('flashcards.reviewModeAll')}
</button>
))}
</div>
<button
type="button"
onClick={() => startSession(activeDeckId, deckCards, reviewMode)}
disabled={loadingDeck}
className="px-4 py-2 rounded-lg bg-brand-accent text-white text-xs font-bold uppercase tracking-wider flex items-center gap-2"
>
{loadingDeck ? <Loader2 size={14} className="animate-spin" /> : <GraduationCap size={14} />}
{t('flashcards.startReview')}
</button>
</div>
)}
<div className="flex flex-wrap gap-2 items-center">
<input
value={newDeckName}
onChange={(e) => setNewDeckName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void createDeck() }}
placeholder={t('flashcards.newDeckPlaceholder')}
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg border border-border text-sm bg-transparent"
/>
<button
type="button"
onClick={createDeck}
disabled={creatingDeck || !newDeckName.trim()}
className="px-3 py-2 rounded-lg border border-border text-xs font-bold flex items-center gap-1 disabled:opacity-50"
>
{creatingDeck ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
{t('flashcards.createDeck')}
</button>
</div>
{loadingDecks ? (
<div className="flex justify-center py-16"><Loader2 className="animate-spin text-concrete" /></div>
) : decks.length === 0 ? (
<div className="text-center py-16 border border-dashed border-border rounded-2xl space-y-3">
<GraduationCap size={32} className="mx-auto text-concrete/40" />
<p className="text-sm text-concrete">{t('flashcards.emptyDecks')}</p>
<p className="text-xs text-concrete/60 max-w-md mx-auto">{t('flashcards.emptyDecksHint')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{decks.map((deck) => {
const isExpanded = expandedDeckId === deck.id
const isLoadingDetails = loadingExpandedDeckId === deck.id
const showDetails = isExpanded && activeDeckId === deck.id
const isConfirmingDelete = confirmDeleteDeckId === deck.id
const isDeletingThis = deletingDeckId === deck.id
return (
<div
key={deck.id}
ref={(el) => {
deckCardRefs.current[deck.id] = el
}}
className={cn(
'p-5 rounded-2xl border bg-card/50 transition-colors flex flex-col gap-4 group',
isExpanded
? 'border-brand-accent/40 ring-1 ring-brand-accent/15 shadow-sm'
: 'border-border/60 hover:border-brand-accent/30',
)}
>
<div>
<div className="flex items-start justify-between gap-2">
<div className="space-y-1 min-w-0">
<h3 className="font-serif font-semibold truncate group-hover:text-brand-accent transition-colors">{deck.name}</h3>
<p className="text-xs text-concrete flex items-center gap-1">
<Layers size={12} />
{t('flashcards.cardCountLabel', { count: deck.totalCards })}
</p>
</div>
{/* Cercle SVG de progression (maîtrise) — comme dans le prototype */}
{deck.totalCards > 0 && (() => {
const masteryScore = deck.masteredCount / deck.totalCards
const r = 19
const circ = 2 * Math.PI * r
return (
<div className="relative w-12 h-12 flex items-center justify-center shrink-0">
<svg className="w-full h-full -rotate-90">
<circle cx="24" cy="24" r={r} stroke="currentColor" strokeWidth="2" className="text-border" fill="transparent" />
<circle cx="24" cy="24" r={r} stroke="currentColor" strokeWidth="3" className="text-emerald-500" fill="transparent"
strokeDasharray={circ}
strokeDashoffset={circ * (1 - masteryScore)}
/>
</svg>
<span className="absolute text-[10px] font-mono font-black text-emerald-600 dark:text-emerald-400">
{Math.round(masteryScore * 100)}%
</span>
</div>
)
})()}
{/* Bouton supprimer */}
{isConfirmingDelete ? (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => handleDeleteDeck(deck.id)}
disabled={isDeletingThis}
className="px-2 py-0.5 rounded-md bg-red-500 text-white text-[9px] font-bold flex items-center gap-0.5"
>
{isDeletingThis ? <Loader2 size={9} className="animate-spin" /> : null}
{t('general.confirm') || 'OK'}
</button>
<button
type="button"
onClick={() => setConfirmDeleteDeckId(null)}
className="p-1 rounded-md border border-border text-[9px]"
>
<X size={10} />
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDeleteDeckId(deck.id)}
aria-label={t('flashcards.deleteDeck')}
className="p-1 rounded-md hover:bg-red-500/10 text-concrete hover:text-red-500 transition-colors shrink-0"
>
<Trash2 size={13} />
</button>
)}
</div>
{/* Notebook badge */}
{deck.notebookName && (
<div className="mt-1">
<span className="flex items-center gap-1 text-[10px] text-concrete/70 border border-border/50 rounded-full px-2 py-0.5 w-fit">
<BookMarked size={9} />
{deck.notebookName}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-2 text-[10px]">
{deck.dueCount > 0 ? (
<span className="px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold border border-amber-500/15 animate-pulse">
{formatDue(deck.dueCount)}
</span>
) : (
<span className="px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 font-bold border border-emerald-500/15">
{t('flashcards.upToDate')}
</span>
)}
{deck.nextReviewAt && (
<span className="px-2.5 py-1 rounded-full border border-border flex items-center gap-1 font-mono text-concrete">
<Calendar size={10} />
{t('flashcards.nextReviewLabel')} : {formatNextReview(deck.nextReviewAt)}
</span>
)}
</div>
<div className="flex gap-2 pt-2 border-t border-border/40">
<button
type="button"
onClick={() => void toggleDeckDetails(deck.id)}
className={cn(
'flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border rounded-lg flex items-center justify-center gap-1 transition-colors',
isExpanded
? 'border-brand-accent/40 bg-brand-accent/10 text-brand-accent'
: 'border-border hover:bg-black/[0.03] dark:hover:bg-white/[0.04]',
)}
>
{isLoadingDetails ? (
<Loader2 size={12} className="animate-spin" />
) : (
<ChevronDown
size={12}
className={cn('transition-transform', isExpanded && 'rotate-180')}
/>
)}
{isExpanded ? t('flashcards.hideDeck') : t('flashcards.viewDeck')}
</button>
<button
type="button"
onClick={async () => {
setLoadingDeck(true)
try {
const res = await fetch(`/api/flashcards/decks/${deck.id}`)
const data = await res.json()
if (res.ok) {
setActiveDeckId(deck.id)
setDeckCards(data.deck.cards || [])
setDeckStats(data.stats)
setExpandedDeckId(null)
startSession(deck.id, data.deck.cards || [], reviewMode)
}
} finally {
setLoadingDeck(false)
}
}}
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider bg-brand-accent text-white rounded-lg flex items-center justify-center gap-1"
>
<GraduationCap size={12} />
{t('flashcards.review')}
</button>
</div>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
key={`deck-details-${deck.id}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="pt-1 space-y-3 border-t border-border/40">
{isLoadingDetails ? (
<div className="flex justify-center py-6">
<Loader2 size={18} className="animate-spin text-concrete" />
</div>
) : showDetails && deckCards.length === 0 ? (
<p className="text-xs text-concrete py-4 text-center">
{t('flashcards.deckCardsEmpty')}
</p>
) : showDetails ? (
<>
<div className="flex flex-wrap gap-2 text-[10px] text-concrete pt-2">
<span>{t('flashcards.statTotal', { count: deckStats.total })}</span>
<span>{t('flashcards.statDue', { count: deckStats.due })}</span>
<span>{t('flashcards.statMastered', { count: deckStats.mastered })}</span>
</div>
{/* Gap 2 — mode toggle inside deck details too */}
<div className="flex gap-1 p-1 rounded-lg bg-black/[0.04] dark:bg-white/[0.04] w-fit">
{(['due', 'all'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setReviewMode(mode)}
className={cn(
'px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-colors',
reviewMode === mode ? 'bg-white dark:bg-card shadow-sm text-brand-accent' : 'text-concrete',
)}
>
{mode === 'due' ? t('flashcards.reviewModeDue') : t('flashcards.reviewModeAll')}
</button>
))}
</div>
<ul className="max-h-64 overflow-y-auto custom-scrollbar space-y-2 pr-1">
{/* Gap 5, 7 — CardEditRow with edit/delete + type badge */}
{deckCards.map((card) => (
<CardEditRow
key={card.id}
card={card}
onSaved={handleCardSaved}
onDeleted={handleCardDeleted}
/>
))}
</ul>
<button
type="button"
onClick={() => startSession(deck.id, deckCards, reviewMode)}
className="w-full py-2.5 rounded-lg bg-brand-accent text-white text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1.5"
>
<GraduationCap size={12} />
{t('flashcards.startReview')}
</button>
</>
) : null}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
)}
</motion.div>
)}
{!isSessionActive && tab === 'progress' && (
<motion.div key="progress" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-8">
{loadingStats ? (
<div className="flex justify-center py-16"><Loader2 className="animate-spin text-concrete" /></div>
) : stats ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl border border-border bg-card/50">
<p className="text-[10px] uppercase tracking-widest text-concrete">{t('flashcards.retentionRate')}</p>
<p className="text-3xl font-serif font-bold mt-1">{stats.retentionRate}%</p>
</div>
<div className="p-4 rounded-xl border border-border bg-card/50 sm:col-span-2">
<p className="text-[10px] uppercase tracking-widest text-concrete mb-2 flex items-center gap-1">
<BarChart3 size={12} /> {t('flashcards.retentionCurve')}
</p>
<RetentionCurve data={stats.retentionByWeek} />
</div>
</div>
<RevisionHeatmap data={stats.heatmap} />
{stats.difficultCards.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold uppercase tracking-widest text-concrete flex items-center gap-2">
<BookOpen size={14} /> {t('flashcards.difficultCards')}
</h3>
<span className="text-[10px] text-concrete/60">{stats.difficultCards.length} cartes</span>
</div>
<ul className="space-y-1.5 max-h-80 overflow-y-auto custom-scrollbar pr-1">
{stats.difficultCards.map((c) => (
<li key={c.id} className="p-3 rounded-xl border border-border/60 flex items-center justify-between gap-3 group hover:border-brand-accent/30 transition-colors">
<div className="min-w-0 flex-1">
<p className="text-sm truncate">{c.front}</p>
<p className="text-[10px] text-concrete/60 mt-0.5">{c.deckName}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-concrete">EF {c.easinessFactor.toFixed(2)}</span>
<button
type="button"
onClick={async () => {
setLoadingDeck(true)
setTab('decks')
try {
const res = await fetch(`/api/flashcards/decks/${c.deckId}`)
const data = await res.json()
if (res.ok) {
setActiveDeckId(c.deckId)
setDeckCards(data.deck.cards || [])
setDeckStats(data.stats)
startSession(c.deckId, data.deck.cards || [], reviewMode)
}
} finally {
setLoadingDeck(false)
}
}}
className="opacity-0 group-hover:opacity-100 transition-opacity px-2 py-1 rounded-lg bg-brand-accent text-white text-[9px] font-bold uppercase tracking-wider flex items-center gap-1"
>
<GraduationCap size={10} />
{t('flashcards.review')}
</button>
</div>
</li>
))}
</ul>
</div>
)}
</>
) : null}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}