feat(flashcards): révision SM-2, génération IA et page /revision
Some checks failed
CI / Lint, Test & Build (push) Failing after 32s
CI / Deploy production (on server) (push) Has been skipped

Livre US-FLASHCARDS avec decks, session de révision, stats et migration Prisma. Finalise le Web Clipper (i18n 15 langues) et corrige les erreurs ESLint bloquant la CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-05-24 19:22:20 +00:00
parent 8697ae244f
commit 36336e6b0d
50 changed files with 7687 additions and 98 deletions

View File

@@ -0,0 +1,221 @@
'use client'
import { useState, useCallback } from 'react'
import { GraduationCap, Loader2, Sparkles, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export type FlashcardStyle = 'qa' | 'cloze' | 'concept'
export interface PreviewCard {
front: string
back: string
type: FlashcardStyle
}
interface FlashcardGenerateDialogProps {
open: boolean
onClose: () => void
noteId: string
noteTitle: string
onSaved?: (deckId: string) => void
}
export function FlashcardGenerateDialog({
open,
onClose,
noteId,
noteTitle,
onSaved,
}: FlashcardGenerateDialogProps) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [count, setCount] = useState(10)
const [style, setStyle] = useState<FlashcardStyle>('qa')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [cards, setCards] = useState<PreviewCard[] | null>(null)
const handleGenerate = useCallback(async () => {
if (!(await requestAiConsent())) return
setLoading(true)
try {
const res = await fetch('/api/flashcards/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId, count, style }),
})
const data = await res.json()
if (!res.ok) {
if (data.errorKey) {
toast.error(t(data.errorKey) || data.error)
} else {
toast.error(data.error || t('flashcards.generateFailed'))
}
return
}
setCards(data.cards || [])
} catch {
toast.error(t('flashcards.generateFailed'))
} finally {
setLoading(false)
}
}, [count, noteId, requestAiConsent, style, t])
const handleSave = useCallback(async () => {
if (!cards?.length) return
setSaving(true)
try {
const res = await fetch('/api/flashcards/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId, cards }),
})
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.saveFailed'))
return
}
toast.success(t('flashcards.savedCount', { count: data.savedCount }))
onSaved?.(data.deckId)
onClose()
setCards(null)
} catch {
toast.error(t('flashcards.saveFailed'))
} finally {
setSaving(false)
}
}, [cards, noteId, onClose, onSaved, t])
const updateCard = (index: number, field: 'front' | 'back', value: string) => {
setCards((prev) => {
if (!prev) return prev
const next = [...prev]
next[index] = { ...next[index], [field]: value }
return next
})
}
if (!open) return null
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={onClose}
>
<div
className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border/60">
<div className="flex items-center gap-2">
<GraduationCap size={18} className="text-brand-accent" />
<div>
<h2 className="text-sm font-semibold">{t('flashcards.generateTitle')}</h2>
<p className="text-[11px] text-muted-foreground truncate max-w-[280px]">{noteTitle}</p>
</div>
</div>
<button type="button" onClick={onClose} className="p-1.5 rounded-lg hover:bg-black/5">
<X size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{!cards ? (
<>
<div>
<label className="text-[11px] font-bold uppercase tracking-wider text-concrete block mb-2">
{t('flashcards.cardCount')} ({count})
</label>
<input
type="range"
min={5}
max={20}
value={count}
onChange={(e) => setCount(Number(e.target.value))}
className="w-full accent-brand-accent"
/>
</div>
<div>
<label className="text-[11px] font-bold uppercase tracking-wider text-concrete block mb-2">
{t('flashcards.styleLabel')}
</label>
<div className="flex flex-wrap gap-2">
{(['qa', 'cloze', 'concept'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setStyle(s)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
style === s
? 'bg-brand-accent/10 border-brand-accent/40 text-brand-accent'
: 'border-border text-muted-foreground hover:border-brand-accent/30',
)}
>
{t(`flashcards.style.${s}`)}
</button>
))}
</div>
</div>
</>
) : (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t('flashcards.previewHint')}</p>
{cards.map((card, i) => (
<div key={i} className="p-3 rounded-xl border border-border/60 space-y-2 bg-paper/30">
<input
value={card.front}
onChange={(e) => updateCard(i, 'front', e.target.value)}
className="w-full text-sm font-medium bg-transparent border-b border-border/40 pb-1 outline-none"
placeholder={t('flashcards.frontPlaceholder')}
/>
<textarea
value={card.back}
onChange={(e) => updateCard(i, 'back', e.target.value)}
rows={2}
className="w-full text-xs text-muted-foreground bg-transparent outline-none resize-none"
placeholder={t('flashcards.backPlaceholder')}
/>
</div>
))}
</div>
)}
</div>
<div className="px-5 py-4 border-t border-border/60 flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs font-medium rounded-lg border border-border hover:bg-black/[0.03]"
>
{t('general.cancel')}
</button>
{!cards ? (
<button
type="button"
onClick={handleGenerate}
disabled={loading}
className="px-4 py-2 text-xs font-bold rounded-lg bg-brand-accent text-white flex items-center gap-1.5 disabled:opacity-60"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
{t('flashcards.generateAction')}
</button>
) : (
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-xs font-bold rounded-lg bg-brand-accent text-white flex items-center gap-1.5 disabled:opacity-60"
>
{saving && <Loader2 size={14} className="animate-spin" />}
{t('flashcards.confirmSave')}
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface HeatmapDay {
date: string
count: number
}
interface RevisionHeatmapProps {
data: HeatmapDay[]
className?: string
}
function intensityClass(count: number, max: number): string {
if (count <= 0) return 'bg-black/[0.04] dark:bg-white/[0.06]'
const ratio = count / Math.max(max, 1)
if (ratio >= 0.75) return 'bg-brand-accent'
if (ratio >= 0.5) return 'bg-brand-accent/70'
if (ratio >= 0.25) return 'bg-brand-accent/40'
return 'bg-brand-accent/20'
}
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
const { t } = useLanguage()
const { cells, maxCount } = useMemo(() => {
const map = new Map(data.map((d) => [d.date, d.count]))
const today = new Date()
const cells: { date: string; count: number; label: string }[] = []
for (let i = 89; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const key = d.toISOString().slice(0, 10)
cells.push({
date: key,
count: map.get(key) || 0,
label: d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }),
})
}
const maxCount = Math.max(1, ...cells.map((c) => c.count))
return { cells, maxCount }
}, [data])
return (
<div className={cn('space-y-3', className)}>
<div className="flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{t('flashcards.heatmapTitle')}
</p>
<span className="text-[10px] text-concrete/60">{t('flashcards.heatmapLast90')}</span>
</div>
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
{cells.map((cell) => (
<div
key={cell.date}
title={`${cell.label}: ${cell.count}`}
className={cn(
'aspect-square rounded-[3px] transition-colors',
intensityClass(cell.count, maxCount),
)}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,575 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import {
GraduationCap,
Layers,
ArrowLeft,
ChevronLeft,
ChevronRight,
Calendar,
Plus,
BarChart3,
Loader2,
BookOpen,
} 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 { toast } from 'sonner'
interface DeckSummary {
id: string
name: string
notebookId: string | null
totalCards: number
dueCount: number
masteredCount: number
lastReviewedAt: 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 }[]
difficultCards: { id: string; front: string; easinessFactor: number; deckName: string }[]
}
type PageTab = 'decks' | 'progress'
type SessionGrade = 1 | 2 | 3 | 4
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)
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 [newDeckName, setNewDeckName] = useState('')
const [creatingDeck, setCreatingDeck] = useState(false)
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])
useEffect(() => {
if (initialDeckId && !activeDeckId) {
loadDeck(initialDeckId)
}
}, [initialDeckId, activeDeckId, loadDeck])
const activeDeck = useMemo(
() => decks.find((d) => d.id === activeDeckId),
[decks, activeDeckId],
)
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])
const startSession = useCallback((deckId: string, cardsInput?: FlashcardItem[], dueOnly = true) => {
const cards = cardsInput ?? (activeDeckId === deckId ? deckCards : [])
if (cards.length === 0) {
void loadDeck(deckId)
return
}
let toReview = dueOnly ? cards.filter((c) => c.due) : cards
if (toReview.length === 0) toReview = cards
const shuffled = [...toReview].sort(() => Math.random() - 0.5)
setSessionCards(shuffled)
setCurrentIndex(0)
setIsFlipped(false)
setSessionGrades({})
setIsSessionActive(true)
setIsSessionFinished(false)
setActiveDeckId(deckId)
}, [activeDeckId, deckCards, loadDeck])
useEffect(() => {
if (!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 || []
setActiveDeckId(initialDeckId)
setDeckCards(cards)
setDeckStats(data.stats || { total: 0, due: 0, mastered: 0 })
startSession(initialDeckId, cards)
})()
return () => { cancelled = true }
}, [initialDeckId, startSession])
const handleEvaluate = async (grade: SessionGrade) => {
const card = sessionCards[currentIndex]
if (!card || reviewing) return
setReviewing(true)
setSessionGrades((prev) => ({ ...prev, [card.id]: grade }))
try {
await fetch(`/api/flashcards/${card.id}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grade }),
})
} catch {
toast.error(t('flashcards.reviewFailed'))
} finally {
setReviewing(false)
}
if (currentIndex < sessionCards.length - 1) {
setTimeout(() => {
setCurrentIndex((i) => i + 1)
setIsFlipped(false)
}, 250)
} else {
setTimeout(() => setIsSessionFinished(true), 250)
}
}
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])
const exitSession = () => {
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)
}
}
const formatDue = (dueCount: number) => {
if (dueCount > 0) return t('flashcards.dueCount', { count: dueCount })
return t('flashcards.upToDate')
}
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>
<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>
<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={() => setIsFlipped((f) => !f)}
className="w-full max-w-md min-h-[240px] [perspective:1000px] cursor-pointer select-none"
>
<div
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="absolute inset-0 [backface-visibility:hidden] [transform:rotateY(180deg)] rounded-2xl border border-brand-accent/30 bg-brand-accent/5 p-8 flex flex-col shadow-md">
<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="grid grid-cols-2 sm:grid-cols-4 gap-2 w-full max-w-md">
{([
[1, t('flashcards.grade.hard'), 'bg-red-500/10 text-red-600 border-red-500/20'],
[2, t('flashcards.grade.difficult'), 'bg-amber-500/10 text-amber-700 border-amber-500/20'],
[3, t('flashcards.grade.good'), 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20'],
[4, t('flashcards.grade.easy'), 'bg-brand-accent/10 text-brand-accent border-brand-accent/30'],
] as const).map(([grade, label, cls]) => (
<button
key={grade}
type="button"
disabled={reviewing}
onClick={() => handleEvaluate(grade as SessionGrade)}
className={cn('py-2.5 px-2 rounded-xl border text-[10px] font-bold uppercase tracking-wide', cls)}
>
{label}
</button>
))}
</div>
)}
</motion.div>
)}
{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>
<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>
<button
type="button"
onClick={() => startSession(activeDeckId)}
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)}
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) => (
<div
key={deck.id}
className="p-5 rounded-2xl border border-border/60 bg-card/50 hover:border-brand-accent/30 transition-colors flex flex-col gap-4"
>
<div>
<h3 className="font-serif font-semibold truncate">{deck.name}</h3>
<p className="text-xs text-concrete flex items-center gap-1 mt-1">
<Layers size={12} />
{t('flashcards.cardCountLabel', { count: deck.totalCards })}
</p>
</div>
<div className="flex flex-wrap gap-2 text-[10px]">
{deck.dueCount > 0 ? (
<span className="px-2 py-1 rounded-full bg-red-500/10 text-red-600 font-bold border border-red-500/15">
{formatDue(deck.dueCount)}
</span>
) : (
<span className="px-2 py-1 rounded-full bg-emerald-500/10 text-emerald-700 font-bold border border-emerald-500/15">
{t('flashcards.upToDate')}
</span>
)}
<span className="px-2 py-1 rounded-full border border-border flex items-center gap-1 font-mono">
<Calendar size={10} />
{deck.masteredCount}/{deck.totalCards} {t('flashcards.masteredShort')}
</span>
</div>
<div className="flex gap-2 pt-2 border-t border-border/40">
<button
type="button"
onClick={() => loadDeck(deck.id)}
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-border rounded-lg hover:bg-black/[0.03]"
>
{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)
startSession(deck.id, data.deck.cards || [])
}
} 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>
</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>
<NoteChart
type="line"
data={stats.retentionByWeek.map((w) => ({
label: w.week.slice(5),
value: w.rate,
}))}
height={120}
/>
</div>
</div>
<RevisionHeatmap data={stats.heatmap} />
{stats.difficultCards.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-bold uppercase tracking-widest text-concrete flex items-center gap-2">
<BookOpen size={14} /> {t('flashcards.difficultCards')}
</h3>
<ul className="space-y-2">
{stats.difficultCards.map((c) => (
<li key={c.id} className="p-3 rounded-xl border border-border/60 text-sm flex justify-between gap-4">
<span className="truncate">{c.front}</span>
<span className="text-[10px] font-mono text-concrete shrink-0">EF {c.easinessFactor.toFixed(2)}</span>
</li>
))}
</ul>
</div>
)}
</>
) : null}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}