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>
1216 lines
52 KiB
TypeScript
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>
|
|
)
|
|
}
|