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>
1248 lines
54 KiB
TypeScript
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>
|
|
)
|
|
}
|