feat(flashcards): révision SM-2, génération IA et page /revision
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:
221
memento-note/components/flashcards/flashcard-generate-dialog.tsx
Normal file
221
memento-note/components/flashcards/flashcard-generate-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
memento-note/components/flashcards/revision-heatmap.tsx
Normal file
69
memento-note/components/flashcards/revision-heatmap.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
575
memento-note/components/flashcards/revision-view.tsx
Normal file
575
memento-note/components/flashcards/revision-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user