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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,575 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import {
GraduationCap,
Layers,
ArrowLeft,
ChevronLeft,
ChevronRight,
Calendar,
Plus,
BarChart3,
Loader2,
BookOpen,
} from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { NoteChart } from '@/components/note-chart'
import { RevisionHeatmap } from './revision-heatmap'
import { toast } from 'sonner'
interface DeckSummary {
id: string
name: string
notebookId: string | null
totalCards: number
dueCount: number
masteredCount: number
lastReviewedAt: string | null
}
interface FlashcardItem {
id: string
front: string
back: string
type: string
due: boolean
mastered: boolean
}
interface StatsResponse {
heatmap: { date: string; count: number }[]
retentionRate: number
retentionByWeek: { week: string; rate: number }[]
difficultCards: { id: string; front: string; easinessFactor: number; deckName: string }[]
}
type PageTab = 'decks' | 'progress'
type SessionGrade = 1 | 2 | 3 | 4
export function RevisionView() {
const { t } = useLanguage()
const searchParams = useSearchParams()
const initialDeckId = searchParams.get('deckId')
const [tab, setTab] = useState<PageTab>('decks')
const [decks, setDecks] = useState<DeckSummary[]>([])
const [loadingDecks, setLoadingDecks] = useState(true)
const [stats, setStats] = useState<StatsResponse | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
const [activeDeckId, setActiveDeckId] = useState<string | null>(null)
const [deckCards, setDeckCards] = useState<FlashcardItem[]>([])
const [deckStats, setDeckStats] = useState({ total: 0, due: 0, mastered: 0 })
const [loadingDeck, setLoadingDeck] = useState(false)
const [isSessionActive, setIsSessionActive] = useState(false)
const [isSessionFinished, setIsSessionFinished] = useState(false)
const [sessionCards, setSessionCards] = useState<FlashcardItem[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [isFlipped, setIsFlipped] = useState(false)
const [sessionGrades, setSessionGrades] = useState<Record<string, SessionGrade>>({})
const [reviewing, setReviewing] = useState(false)
const [newDeckName, setNewDeckName] = useState('')
const [creatingDeck, setCreatingDeck] = useState(false)
const loadDecks = useCallback(async () => {
setLoadingDecks(true)
try {
const res = await fetch('/api/flashcards/decks')
const data = await res.json()
if (res.ok) setDecks(data.decks || [])
} finally {
setLoadingDecks(false)
}
}, [])
const loadStats = useCallback(async () => {
setLoadingStats(true)
try {
const res = await fetch('/api/flashcards/stats')
const data = await res.json()
if (res.ok) setStats(data)
} finally {
setLoadingStats(false)
}
}, [])
const loadDeck = useCallback(async (deckId: string) => {
setLoadingDeck(true)
try {
const res = await fetch(`/api/flashcards/decks/${deckId}`)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.loadDeckFailed'))
return
}
setActiveDeckId(deckId)
setDeckCards(data.deck.cards || [])
setDeckStats(data.stats)
} finally {
setLoadingDeck(false)
}
}, [t])
useEffect(() => {
loadDecks()
}, [loadDecks])
useEffect(() => {
if (tab === 'progress') loadStats()
}, [tab, loadStats])
useEffect(() => {
if (initialDeckId && !activeDeckId) {
loadDeck(initialDeckId)
}
}, [initialDeckId, activeDeckId, loadDeck])
const activeDeck = useMemo(
() => decks.find((d) => d.id === activeDeckId),
[decks, activeDeckId],
)
const sessionChartData = useMemo(() => {
const counts = { 1: 0, 2: 0, 3: 0, 4: 0 }
Object.values(sessionGrades).forEach((g) => { counts[g] += 1 })
return [
{ label: t('flashcards.grade.hard'), value: counts[1] },
{ label: t('flashcards.grade.difficult'), value: counts[2] },
{ label: t('flashcards.grade.good'), value: counts[3] },
{ label: t('flashcards.grade.easy'), value: counts[4] },
]
}, [sessionGrades, t])
const startSession = useCallback((deckId: string, cardsInput?: FlashcardItem[], dueOnly = true) => {
const cards = cardsInput ?? (activeDeckId === deckId ? deckCards : [])
if (cards.length === 0) {
void loadDeck(deckId)
return
}
let toReview = dueOnly ? cards.filter((c) => c.due) : cards
if (toReview.length === 0) toReview = cards
const shuffled = [...toReview].sort(() => Math.random() - 0.5)
setSessionCards(shuffled)
setCurrentIndex(0)
setIsFlipped(false)
setSessionGrades({})
setIsSessionActive(true)
setIsSessionFinished(false)
setActiveDeckId(deckId)
}, [activeDeckId, deckCards, loadDeck])
useEffect(() => {
if (!initialDeckId) return
let cancelled = false
;(async () => {
const res = await fetch(`/api/flashcards/decks/${initialDeckId}`)
const data = await res.json()
if (cancelled || !res.ok) return
const cards: FlashcardItem[] = data.deck?.cards || []
setActiveDeckId(initialDeckId)
setDeckCards(cards)
setDeckStats(data.stats || { total: 0, due: 0, mastered: 0 })
startSession(initialDeckId, cards)
})()
return () => { cancelled = true }
}, [initialDeckId, startSession])
const handleEvaluate = async (grade: SessionGrade) => {
const card = sessionCards[currentIndex]
if (!card || reviewing) return
setReviewing(true)
setSessionGrades((prev) => ({ ...prev, [card.id]: grade }))
try {
await fetch(`/api/flashcards/${card.id}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grade }),
})
} catch {
toast.error(t('flashcards.reviewFailed'))
} finally {
setReviewing(false)
}
if (currentIndex < sessionCards.length - 1) {
setTimeout(() => {
setCurrentIndex((i) => i + 1)
setIsFlipped(false)
}, 250)
} else {
setTimeout(() => setIsSessionFinished(true), 250)
}
}
useEffect(() => {
if (!isSessionActive || isSessionFinished) return
const onKey = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault()
setIsFlipped((f) => !f)
} else if (isFlipped) {
if (e.key === '1') handleEvaluate(1)
if (e.key === '2') handleEvaluate(2)
if (e.key === '3') handleEvaluate(3)
if (e.key === '4') handleEvaluate(4)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isSessionActive, isSessionFinished, isFlipped, currentIndex, reviewing])
const exitSession = () => {
setIsSessionActive(false)
setIsSessionFinished(false)
if (activeDeckId) {
loadDeck(activeDeckId)
loadDecks()
}
}
const createDeck = async () => {
const name = newDeckName.trim()
if (!name) return
setCreatingDeck(true)
try {
const res = await fetch('/api/flashcards/decks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const data = await res.json()
if (res.ok) {
setNewDeckName('')
await loadDecks()
toast.success(t('flashcards.deckCreated'))
if (data.deck?.id) setActiveDeckId(data.deck.id)
}
} finally {
setCreatingDeck(false)
}
}
const formatDue = (dueCount: number) => {
if (dueCount > 0) return t('flashcards.dueCount', { count: dueCount })
return t('flashcards.upToDate')
}
return (
<div className="h-full flex flex-col bg-memento-paper dark:bg-background overflow-y-auto">
<div className="px-6 sm:px-10 py-5 flex items-center justify-between sticky top-0 bg-memento-paper/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border/40">
<div className="flex items-center gap-3">
{isSessionActive ? (
<button
type="button"
onClick={exitSession}
className="flex items-center gap-2 text-concrete hover:text-foreground transition-colors"
>
<ArrowLeft size={16} />
<span className="text-xs font-bold uppercase tracking-widest">{t('flashcards.exitSession')}</span>
</button>
) : (
<div className="flex items-center gap-2">
<GraduationCap className="text-brand-accent" size={20} />
<h1 className="text-sm font-black tracking-widest uppercase">{t('nav.revision')}</h1>
</div>
)}
</div>
{!isSessionActive && (
<div className="flex gap-1 p-1 rounded-lg bg-black/[0.04] dark:bg-white/[0.04]">
{(['decks', 'progress'] as const).map((id) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn(
'px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-colors',
tab === id ? 'bg-white dark:bg-card shadow-sm text-brand-accent' : 'text-concrete',
)}
>
{id === 'decks' ? t('flashcards.tabDecks') : t('flashcards.tabProgress')}
</button>
))}
</div>
)}
</div>
<div className="flex-1 max-w-5xl mx-auto w-full p-6 sm:p-10">
<AnimatePresence mode="wait">
{isSessionActive && !isSessionFinished && sessionCards[currentIndex] && (
<motion.div
key="session"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="max-w-2xl mx-auto flex flex-col items-center gap-8"
>
<div className="w-full flex items-center justify-between text-xs text-concrete">
<button
type="button"
disabled={currentIndex === 0}
onClick={() => { setCurrentIndex((i) => i - 1); setIsFlipped(false) }}
className="flex items-center gap-1 disabled:opacity-30"
>
<ChevronLeft size={16} /> {t('flashcards.previous')}
</button>
<span className="font-mono font-bold px-3 py-1 rounded-full bg-black/[0.04] dark:bg-white/[0.06]">
{currentIndex + 1} / {sessionCards.length}
</span>
<button
type="button"
disabled={currentIndex >= sessionCards.length - 1}
onClick={() => { setCurrentIndex((i) => i + 1); setIsFlipped(false) }}
className="flex items-center gap-1 disabled:opacity-30"
>
{t('flashcards.next')} <ChevronRight size={16} />
</button>
</div>
<button
type="button"
onClick={() => setIsFlipped((f) => !f)}
className="w-full max-w-md min-h-[240px] [perspective:1000px] cursor-pointer select-none"
>
<div
className={cn(
'relative w-full min-h-[240px] transition-transform duration-500 [transform-style:preserve-3d]',
isFlipped && '[transform:rotateY(180deg)]',
)}
>
<div className="absolute inset-0 [backface-visibility:hidden] rounded-2xl border border-border bg-card p-8 flex flex-col shadow-sm">
<span className="text-[10px] uppercase tracking-widest text-concrete mb-4">{t('flashcards.front')}</span>
<p className="flex-1 flex items-center justify-center text-center text-lg font-serif font-semibold">
{sessionCards[currentIndex].front}
</p>
<span className="text-[10px] text-concrete/60 text-center">{t('flashcards.tapToFlip')}</span>
</div>
<div className="absolute inset-0 [backface-visibility:hidden] [transform:rotateY(180deg)] rounded-2xl border border-brand-accent/30 bg-brand-accent/5 p-8 flex flex-col shadow-md">
<span className="text-[10px] uppercase tracking-widest text-brand-accent mb-4">{t('flashcards.back')}</span>
<p className="flex-1 flex items-center justify-center text-center text-sm leading-relaxed overflow-y-auto">
{sessionCards[currentIndex].back}
</p>
</div>
</div>
</button>
{isFlipped && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 w-full max-w-md">
{([
[1, t('flashcards.grade.hard'), 'bg-red-500/10 text-red-600 border-red-500/20'],
[2, t('flashcards.grade.difficult'), 'bg-amber-500/10 text-amber-700 border-amber-500/20'],
[3, t('flashcards.grade.good'), 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20'],
[4, t('flashcards.grade.easy'), 'bg-brand-accent/10 text-brand-accent border-brand-accent/30'],
] as const).map(([grade, label, cls]) => (
<button
key={grade}
type="button"
disabled={reviewing}
onClick={() => handleEvaluate(grade as SessionGrade)}
className={cn('py-2.5 px-2 rounded-xl border text-[10px] font-bold uppercase tracking-wide', cls)}
>
{label}
</button>
))}
</div>
)}
</motion.div>
)}
{isSessionFinished && (
<motion.div
key="finished"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-lg mx-auto text-center space-y-6"
>
<GraduationCap size={40} className="mx-auto text-brand-accent" />
<h2 className="text-2xl font-serif font-bold">{t('flashcards.sessionComplete')}</h2>
<div className="h-48">
<NoteChart type="bar" data={sessionChartData} height={180} />
</div>
<button
type="button"
onClick={exitSession}
className="px-6 py-2.5 rounded-xl bg-brand-accent text-white text-xs font-bold uppercase tracking-wider"
>
{t('flashcards.backToDecks')}
</button>
</motion.div>
)}
{!isSessionActive && tab === 'decks' && (
<motion.div key="decks" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-8">
{activeDeckId && deckCards.length > 0 && (
<div className="p-5 rounded-2xl border border-brand-accent/25 bg-brand-accent/5 space-y-3">
<h3 className="font-serif font-semibold text-lg">{activeDeck?.name || t('flashcards.activeDeck')}</h3>
<div className="flex flex-wrap gap-3 text-xs text-concrete">
<span>{t('flashcards.statTotal', { count: deckStats.total })}</span>
<span>{t('flashcards.statDue', { count: deckStats.due })}</span>
<span>{t('flashcards.statMastered', { count: deckStats.mastered })}</span>
</div>
<button
type="button"
onClick={() => startSession(activeDeckId)}
disabled={loadingDeck}
className="px-4 py-2 rounded-lg bg-brand-accent text-white text-xs font-bold uppercase tracking-wider flex items-center gap-2"
>
{loadingDeck ? <Loader2 size={14} className="animate-spin" /> : <GraduationCap size={14} />}
{t('flashcards.startReview')}
</button>
</div>
)}
<div className="flex flex-wrap gap-2 items-center">
<input
value={newDeckName}
onChange={(e) => setNewDeckName(e.target.value)}
placeholder={t('flashcards.newDeckPlaceholder')}
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg border border-border text-sm bg-transparent"
/>
<button
type="button"
onClick={createDeck}
disabled={creatingDeck || !newDeckName.trim()}
className="px-3 py-2 rounded-lg border border-border text-xs font-bold flex items-center gap-1 disabled:opacity-50"
>
{creatingDeck ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
{t('flashcards.createDeck')}
</button>
</div>
{loadingDecks ? (
<div className="flex justify-center py-16"><Loader2 className="animate-spin text-concrete" /></div>
) : decks.length === 0 ? (
<div className="text-center py-16 border border-dashed border-border rounded-2xl space-y-3">
<GraduationCap size={32} className="mx-auto text-concrete/40" />
<p className="text-sm text-concrete">{t('flashcards.emptyDecks')}</p>
<p className="text-xs text-concrete/60 max-w-md mx-auto">{t('flashcards.emptyDecksHint')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{decks.map((deck) => (
<div
key={deck.id}
className="p-5 rounded-2xl border border-border/60 bg-card/50 hover:border-brand-accent/30 transition-colors flex flex-col gap-4"
>
<div>
<h3 className="font-serif font-semibold truncate">{deck.name}</h3>
<p className="text-xs text-concrete flex items-center gap-1 mt-1">
<Layers size={12} />
{t('flashcards.cardCountLabel', { count: deck.totalCards })}
</p>
</div>
<div className="flex flex-wrap gap-2 text-[10px]">
{deck.dueCount > 0 ? (
<span className="px-2 py-1 rounded-full bg-red-500/10 text-red-600 font-bold border border-red-500/15">
{formatDue(deck.dueCount)}
</span>
) : (
<span className="px-2 py-1 rounded-full bg-emerald-500/10 text-emerald-700 font-bold border border-emerald-500/15">
{t('flashcards.upToDate')}
</span>
)}
<span className="px-2 py-1 rounded-full border border-border flex items-center gap-1 font-mono">
<Calendar size={10} />
{deck.masteredCount}/{deck.totalCards} {t('flashcards.masteredShort')}
</span>
</div>
<div className="flex gap-2 pt-2 border-t border-border/40">
<button
type="button"
onClick={() => loadDeck(deck.id)}
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-border rounded-lg hover:bg-black/[0.03]"
>
{t('flashcards.viewDeck')}
</button>
<button
type="button"
onClick={async () => {
setLoadingDeck(true)
try {
const res = await fetch(`/api/flashcards/decks/${deck.id}`)
const data = await res.json()
if (res.ok) {
setActiveDeckId(deck.id)
setDeckCards(data.deck.cards || [])
setDeckStats(data.stats)
startSession(deck.id, data.deck.cards || [])
}
} finally {
setLoadingDeck(false)
}
}}
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider bg-brand-accent text-white rounded-lg flex items-center justify-center gap-1"
>
<GraduationCap size={12} />
{t('flashcards.review')}
</button>
</div>
</div>
))}
</div>
)}
</motion.div>
)}
{!isSessionActive && tab === 'progress' && (
<motion.div key="progress" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-8">
{loadingStats ? (
<div className="flex justify-center py-16"><Loader2 className="animate-spin text-concrete" /></div>
) : stats ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl border border-border bg-card/50">
<p className="text-[10px] uppercase tracking-widest text-concrete">{t('flashcards.retentionRate')}</p>
<p className="text-3xl font-serif font-bold mt-1">{stats.retentionRate}%</p>
</div>
<div className="p-4 rounded-xl border border-border bg-card/50 sm:col-span-2">
<p className="text-[10px] uppercase tracking-widest text-concrete mb-2 flex items-center gap-1">
<BarChart3 size={12} /> {t('flashcards.retentionCurve')}
</p>
<NoteChart
type="line"
data={stats.retentionByWeek.map((w) => ({
label: w.week.slice(5),
value: w.rate,
}))}
height={120}
/>
</div>
</div>
<RevisionHeatmap data={stats.heatmap} />
{stats.difficultCards.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-bold uppercase tracking-widest text-concrete flex items-center gap-2">
<BookOpen size={14} /> {t('flashcards.difficultCards')}
</h3>
<ul className="space-y-2">
{stats.difficultCards.map((c) => (
<li key={c.id} className="p-3 rounded-xl border border-border/60 text-sm flex justify-between gap-4">
<span className="truncate">{c.front}</span>
<span className="text-[10px] font-mono text-concrete shrink-0">EF {c.easinessFactor.toFixed(2)}</span>
</li>
))}
</ul>
</div>
)}
</>
) : null}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -313,9 +313,19 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}, [content, isMarkdown])
const resolveImagesForSave = useCallback((contentToSave: string): string[] => {
const extracted = !isMarkdown ? extractImagesFromHTML(contentToSave) : []
return Array.from(new Set([...images, ...extracted]))
}, [images, isMarkdown])
if (!contentToSave) return []
if (!isMarkdown) {
return extractImagesFromHTML(contentToSave)
} else {
const urls = new Set<string>()
const matches = contentToSave.matchAll(/!\[.*?\]\((.*?)\)/g)
for (const match of matches) {
const src = match[1]?.trim()
if (src) urls.add(src)
}
return Array.from(urls)
}
}, [isMarkdown])
const handleGenerateTitles = async () => {
const fullContentForAI = [
@@ -613,8 +623,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (contentToSave !== content) setContent(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
const deletedImages = Array.from(new Set([
...removedImageUrls,
...images.filter(url => !imagesToSave.includes(url))
]))
if (deletedImages.length > 0) {
cleanupOrphanedImages(deletedImages, note.id).catch(() => {})
setRemovedImageUrls([])
}
await refreshLabels()
onNoteSaved?.(result)
@@ -732,8 +747,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (contentToSave !== content) setContent(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
const deletedImages = Array.from(new Set([
...removedImageUrls,
...images.filter(url => !imagesToSave.includes(url))
]))
if (deletedImages.length > 0) {
cleanupOrphanedImages(deletedImages, note.id).catch(() => {})
setRemovedImageUrls([])
}
await refreshLabels()
onNoteSaved?.(result)

View File

@@ -18,8 +18,9 @@ import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2, Share2, Wind, Paperclip
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap
} from 'lucide-react'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
import { NoteShareDialog } from './note-share-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
@@ -41,6 +42,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
const { t } = useLanguage()
const [isConverting, setIsConverting] = useState(false)
const [shareOpen, setShareOpen] = useState(false)
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
@@ -169,6 +171,17 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<Wind size={16} />
</button>
{!readOnly && (
<button
title={t('flashcards.toolbarGenerate')}
aria-label={t('flashcards.toolbarGenerate')}
onClick={() => setFlashcardsOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<GraduationCap size={16} />
</button>
)}
{!readOnly && onToggleAttachments && (
<button
title={t('notes.attachments') || 'Attachments'}
@@ -247,6 +260,16 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
/>
)}
<FlashcardGenerateDialog
open={flashcardsOpen}
onClose={() => setFlashcardsOpen(false)}
noteId={note.id}
noteTitle={state.title || note.title || 'Untitled'}
onSaved={(deckId) => {
window.open(`/revision?deckId=${encodeURIComponent(deckId)}`, '_self')
}}
/>
<button
aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}

View File

@@ -508,7 +508,6 @@ export function NoteNetworkTab({ noteId, noteTitle }: NoteNetworkTabProps) {
})
return nodes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortedSemantic, outbound, backlinks, embedHosts, notebooks, t])
const orbitNodes = graphNodes.slice(0, MAX_GRAPH_NODES)

View File

@@ -959,7 +959,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
{ id: 'graph', icon: Network, label: t('nav.graphView'), onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => setActiveView('revision'), isActive: activeView === 'revision' },
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => router.push('/revision'), isActive: pathname === '/revision' },
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
@@ -1258,27 +1258,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</motion.div>
)}
{/* ── Vue Révisions (placeholder en attendant US-FLASHCARDS) ── */}
{activeView === 'revision' && (
<motion.div
key="revision"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
className="px-4"
>
<div className="flex items-center gap-1.5 mb-4">
<GraduationCap size={13} className="text-brand-accent" />
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">Révisions</p>
</div>
<div className="flex flex-col items-center justify-center text-center p-6 border border-dashed border-border/50 rounded-2xl bg-paper/20 space-y-3">
<GraduationCap size={24} className="text-concrete/40" />
<p className="text-[11px] font-medium text-concrete/70">Flashcards bientôt disponibles</p>
<p className="text-[10px] text-concrete/50">Les decks de révision IA (SM-2) arrivent dans la prochaine itération.</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>

View File

@@ -44,7 +44,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
}
})
.catch(() => setIsOffline(true))
}, [sourceNoteId, blockId]) // eslint-disable-line react-hooks/exhaustive-deps
}, [sourceNoteId, blockId])
// Listen for real-time block update events
useEffect(() => {