Files
Momento/memento-note/components/flashcards/revision-view.tsx
Antigravity 0fa8978395
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped
feat: mobile app complet + flashcards fixes + drag handle améliorations
Mobile app:
- Révision flashcards : liste decks, session flip-card SM-2, couleurs harmonisées web
- Génération flashcards depuis note (FlashcardSheet + route /api/mobile/flashcards/generate)
- Audio Whisper : hook useAudioRecorder reécrit, MicButton avec erreurs
- IA : AISheet (améliorer/clarifier/résumer), TitleSheet (titre automatique)
- Suppression note (soft delete + confirmation Alert)
- Note du jour : titre lisible + HTML (plus JSON TipTap brut)
- Parser TipTap→HTML côté mobile (tipTapToHtml)
- Icône 🎓 dans header note → génération flashcards
- Endpoint flashcardGenerate dans config.ts

Web fixes:
- Bug flashcards groupées par carnet → deck par note (migration + schema)
- Bug filtre 'cartes dues' ignoré (suppression fallback buildSessionQueue)
- Suppression UI création deck manuelle (inutile)
- Fix setViewType is not defined dans home-client.tsx

Drag handle menu:
- Fix : clearNodes() avant transformation (heading→liste/code/citation)
- Ajout : option 'Texte' (paragraphe) dans Transformer en
- Ajout : Monter / Descendre le bloc
- Ajout : Copier le contenu du bloc
- Fix : sous-menu hover stable (délai 200ms)
- Fix : Supprimer en rouge via classe --danger (plus :first-child)
- i18n : nouvelles clés dans 15 locales

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 18:49:40 +00:00

1216 lines
52 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,
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[] {
const toReview = dueOnly ? cards.filter((c) => c.due) : 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)
// 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')
// Mode "due" mais aucune carte n'est due → afficher état "à jour" directement
if (shuffled.length === 0) {
setSessionCards([])
setCurrentIndex(0)
setIsFlipped(false)
setSessionGrades({})
setIsSessionActive(true)
setIsSessionFinished(true)
setActiveDeckId(deckId)
return
}
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()
}
}
// 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>
)}
{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>
)
}