605 lines
25 KiB
TypeScript
605 lines
25 KiB
TypeScript
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useNavigate } from '@tanstack/react-router'
|
|
import { cn } from '@/lib/utils'
|
|
import { useAuthStore } from '@/store/auth-store'
|
|
import {
|
|
fetchFlashcardTerms,
|
|
fetchUserProgress,
|
|
upsertTermProgress,
|
|
startSession,
|
|
endSession,
|
|
logReview,
|
|
fetchFlashcardLists,
|
|
} from '../api/flashcard-api'
|
|
import type { FlashcardTerm, UserProgress } from '../api/flashcard-api'
|
|
import { EASE, type EaseKey } from '../lib/srs-intervals'
|
|
|
|
interface Props {
|
|
listId: number
|
|
}
|
|
|
|
type SessionStats = { known: number; learning: number; ignored: number }
|
|
|
|
function speak(word: string) {
|
|
try {
|
|
const u = new SpeechSynthesisUtterance(word)
|
|
u.lang = 'en-US'
|
|
u.rate = 0.9
|
|
speechSynthesis.cancel()
|
|
speechSynthesis.speak(u)
|
|
} catch { /* noop */ }
|
|
}
|
|
|
|
export function FlashCardLearnPage({ listId }: Props) {
|
|
const navigate = useNavigate()
|
|
const user = useAuthStore(s => s.user)
|
|
const queryClient = useQueryClient()
|
|
|
|
const [isFlipped, setIsFlipped] = useState(false)
|
|
const [currentIdx, setCurrentIdx] = useState(0)
|
|
const [sessionStats, setSessionStats] = useState<SessionStats>({ known: 0, learning: 0, ignored: 0 })
|
|
const [isDone, setIsDone] = useState(false)
|
|
const [fx, setFx] = useState<'known' | 'review' | null>(null)
|
|
|
|
// Bookmarks — per-list, persisted in localStorage
|
|
const bookmarkKey = `flashcard-bookmarks-${listId}`
|
|
const [bookmarks, setBookmarks] = useState<Set<number>>(() => {
|
|
try {
|
|
const raw = localStorage.getItem(bookmarkKey)
|
|
return raw ? new Set<number>(JSON.parse(raw)) : new Set()
|
|
} catch { return new Set() }
|
|
})
|
|
const toggleBookmark = useCallback((termId: number) => {
|
|
setBookmarks(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(termId)) next.delete(termId)
|
|
else next.add(termId)
|
|
try { localStorage.setItem(bookmarkKey, JSON.stringify([...next])) } catch { /* noop */ }
|
|
return next
|
|
})
|
|
}, [bookmarkKey])
|
|
|
|
// Refs for unmount cleanup so effects see fresh values
|
|
const sessionIdRef = useRef<number | null>(null)
|
|
const statsRef = useRef<SessionStats>(sessionStats)
|
|
const isDoneRef = useRef(false)
|
|
const newTermIdsAtStartRef = useRef<Set<number>>(new Set())
|
|
const answeredNewIdsRef = useRef<Set<number>>(new Set())
|
|
|
|
useEffect(() => { statsRef.current = sessionStats }, [sessionStats])
|
|
useEffect(() => { isDoneRef.current = isDone }, [isDone])
|
|
|
|
const { data: terms = [], isLoading: loadingTerms } = useQuery({
|
|
queryKey: ['flashcard-terms', listId],
|
|
queryFn: () => fetchFlashcardTerms(listId),
|
|
})
|
|
|
|
const { data: lists = [] } = useQuery({
|
|
queryKey: ['flashcard-lists'],
|
|
queryFn: fetchFlashcardLists,
|
|
staleTime: 5 * 60 * 1000,
|
|
})
|
|
const currentList = lists.find(l => l.id === listId)
|
|
|
|
const { data: progress = [] } = useQuery({
|
|
queryKey: ['flashcard-progress', user?.id, listId],
|
|
queryFn: () => fetchUserProgress(user!.id, listId),
|
|
enabled: !!user,
|
|
})
|
|
|
|
const progressMap = useMemo(() => {
|
|
const m: Record<number, UserProgress> = {}
|
|
progress.forEach(p => { m[p.term_id] = p })
|
|
return m
|
|
}, [progress])
|
|
|
|
// Session term ordering: prioritise due-for-review, then new, then known
|
|
const sessionTerms: FlashcardTerm[] = useMemo(() => {
|
|
if (!terms.length) return []
|
|
const now = Date.now()
|
|
const due: FlashcardTerm[] = []
|
|
const fresh: FlashcardTerm[] = []
|
|
const known: FlashcardTerm[] = []
|
|
for (const t of terms) {
|
|
const p = progressMap[t.id]
|
|
if (p?.status === 'ignored') continue
|
|
if (!p) { fresh.push(t); continue }
|
|
if (!p.next_review_at) { fresh.push(t); continue }
|
|
if (new Date(p.next_review_at).getTime() <= now) { due.push(t); continue }
|
|
if (p.status === 'known') known.push(t)
|
|
else fresh.push(t)
|
|
}
|
|
return [...due, ...fresh, ...known]
|
|
}, [terms, progressMap])
|
|
|
|
// Snapshot "new" term IDs at session start (runs once when data is loaded)
|
|
useEffect(() => {
|
|
if (newTermIdsAtStartRef.current.size === 0 && terms.length > 0) {
|
|
const newIds = new Set<number>()
|
|
for (const t of terms) {
|
|
const s = progressMap[t.id]?.status ?? 'new'
|
|
if (s === 'new') newIds.add(t.id)
|
|
}
|
|
newTermIdsAtStartRef.current = newIds
|
|
}
|
|
}, [terms, progressMap])
|
|
|
|
// Start session on mount (guarded against StrictMode double-invoke)
|
|
useEffect(() => {
|
|
if (!user || sessionIdRef.current !== null) return
|
|
let cancelled = false
|
|
startSession(user.id, listId)
|
|
.then(s => { if (!cancelled) sessionIdRef.current = s.id })
|
|
.catch(err => console.error('startSession failed:', err))
|
|
return () => { cancelled = true }
|
|
}, [user, listId])
|
|
|
|
// End session on unmount (if not already ended via done-screen effect)
|
|
useEffect(() => {
|
|
return () => {
|
|
const sid = sessionIdRef.current
|
|
if (sid === null || isDoneRef.current) return
|
|
const s = statsRef.current
|
|
const reviewed = s.known + s.learning + s.ignored
|
|
endSession(sid, reviewed, answeredNewIdsRef.current.size)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// End session when reaching done screen
|
|
useEffect(() => {
|
|
if (!isDone) return
|
|
const sid = sessionIdRef.current
|
|
if (sid === null) return
|
|
const s = statsRef.current
|
|
const reviewed = s.known + s.learning + s.ignored
|
|
endSession(sid, reviewed, answeredNewIdsRef.current.size)
|
|
}, [isDone])
|
|
|
|
const { mutate: saveAnswer } = useMutation({
|
|
mutationFn: async ({ termId, easeKey, reviewCount }: {
|
|
termId: number
|
|
easeKey: EaseKey
|
|
reviewCount: number
|
|
}) => {
|
|
if (!user) return
|
|
const sid = sessionIdRef.current
|
|
await Promise.all([
|
|
upsertTermProgress(user.id, termId, listId, easeKey, reviewCount),
|
|
sid !== null ? logReview(sid, user.id, termId, EASE[easeKey]) : Promise.resolve(),
|
|
])
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['flashcard-progress', user?.id, listId] })
|
|
},
|
|
})
|
|
|
|
const advance = useCallback(() => {
|
|
if (currentIdx + 1 >= sessionTerms.length) {
|
|
setIsDone(true)
|
|
} else {
|
|
setCurrentIdx(i => i + 1)
|
|
setIsFlipped(false)
|
|
}
|
|
setFx(null)
|
|
}, [currentIdx, sessionTerms.length])
|
|
|
|
const handleAnswer = useCallback((key: EaseKey) => {
|
|
const term = sessionTerms[currentIdx]
|
|
if (!term || !user) return
|
|
|
|
const currentProgress = progressMap[term.id]
|
|
const reviewCount = currentProgress?.review_count ?? 0
|
|
|
|
saveAnswer({ termId: term.id, easeKey: key, reviewCount })
|
|
|
|
if (newTermIdsAtStartRef.current.has(term.id)) {
|
|
answeredNewIdsRef.current.add(term.id)
|
|
}
|
|
|
|
setSessionStats(prev => ({
|
|
known: prev.known + (key === 'known' ? 1 : 0),
|
|
learning: prev.learning + (key === 'easy' || key === 'hard' ? 1 : 0),
|
|
ignored: prev.ignored + (key === 'ignored' ? 1 : 0),
|
|
}))
|
|
|
|
// Visual feedback: known swipes right, hard/ignored swipes left
|
|
if (key === 'known' || key === 'easy') {
|
|
setFx('known')
|
|
} else {
|
|
setFx('review')
|
|
}
|
|
setTimeout(advance, 450)
|
|
}, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance])
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (isDone || !sessionTerms[currentIdx]) return
|
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
e.preventDefault()
|
|
setIsFlipped(v => !v)
|
|
return
|
|
}
|
|
if (!isFlipped) return
|
|
if (e.key.toLowerCase() === 'j') handleAnswer('known')
|
|
else if (e.key.toLowerCase() === 'k') handleAnswer('hard')
|
|
else if (e.key.toLowerCase() === 'i') handleAnswer('ignored')
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer])
|
|
|
|
const total = sessionTerms.length
|
|
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
|
|
const current = sessionTerms[currentIdx]
|
|
|
|
if (loadingTerms) {
|
|
return (
|
|
<div className="atelier flex items-center justify-center min-h-screen">
|
|
<div className="w-8 h-8 border-2 border-[var(--at-line)] border-t-[var(--at-accent)] rounded-full animate-spin" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (sessionTerms.length === 0) {
|
|
return (
|
|
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-4 px-4">
|
|
<div className="at-serif text-5xl italic text-[var(--at-mute-2)]">All clear.</div>
|
|
<p className="text-[var(--at-mute)] text-center max-w-sm">
|
|
Không có thẻ nào cần học ngay bây giờ. Quay lại sau khi đến lịch ôn tập.
|
|
</p>
|
|
<button
|
|
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
|
className="mt-4 px-6 py-2.5 bg-[var(--at-ink)] text-[var(--at-paper)] rounded-xl text-sm font-semibold hover:opacity-90 transition"
|
|
>
|
|
Quay lại danh sách
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (isDone) {
|
|
return (
|
|
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-8 px-4">
|
|
<div className="text-center">
|
|
<div className="at-serif italic text-[var(--at-accent)] text-6xl mb-4">Bravo.</div>
|
|
<h2 className="at-serif text-3xl tracking-tight text-[var(--at-ink)] mb-2">Hoàn thành phiên học</h2>
|
|
<p className="text-[var(--at-mute)]">Bạn đã ôn xong {total} thẻ trong phiên này</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
|
|
<div className="at-serif text-3xl text-[var(--at-good)]">{sessionStats.known}</div>
|
|
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đã biết</div>
|
|
</div>
|
|
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
|
|
<div className="at-serif text-3xl text-[var(--at-accent)]">{sessionStats.learning}</div>
|
|
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đang học</div>
|
|
</div>
|
|
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
|
|
<div className="at-serif text-3xl text-[var(--at-mute-2)]">{sessionStats.ignored}</div>
|
|
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Bỏ qua</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => {
|
|
setCurrentIdx(0)
|
|
setIsFlipped(false)
|
|
setIsDone(false)
|
|
setSessionStats({ known: 0, learning: 0, ignored: 0 })
|
|
sessionIdRef.current = null
|
|
answeredNewIdsRef.current = new Set()
|
|
newTermIdsAtStartRef.current = new Set()
|
|
if (user) startSession(user.id, listId).then(s => { sessionIdRef.current = s.id })
|
|
}}
|
|
className="px-5 py-2.5 bg-[var(--at-ink)] text-[var(--at-paper)] rounded-xl text-sm font-semibold hover:opacity-90 transition"
|
|
>
|
|
Học lại
|
|
</button>
|
|
<button
|
|
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
|
className="px-5 py-2.5 border border-[var(--at-line)] text-[var(--at-ink-2)] rounded-xl text-sm font-semibold bg-white hover:border-[var(--at-ink)] transition"
|
|
>
|
|
Xem danh sách
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Jump to a specific card in the deck (no progress write — just navigate)
|
|
const jumpTo = (idx: number) => {
|
|
setCurrentIdx(idx)
|
|
setIsFlipped(false)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="atelier fixed top-16 right-0 left-0 lg:left-60 bottom-20 lg:bottom-0 flex flex-col px-4 lg:px-6 py-3 overflow-hidden"
|
|
style={{ background: 'var(--at-paper)' }}
|
|
>
|
|
{/* Header row: breadcrumb + serif title on left, actions on right */}
|
|
<div className="flex items-end justify-between gap-4 mb-4 flex-shrink-0 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 mb-1 text-[13px]" style={{ color: 'var(--at-mute)' }}>
|
|
<button
|
|
onClick={() => navigate({ to: '/flash-card' })}
|
|
className="hover:text-[var(--at-ink)] transition-colors"
|
|
>
|
|
Chủ đề
|
|
</button>
|
|
<span>/</span>
|
|
<button
|
|
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
|
className="hover:text-[var(--at-ink)] transition-colors truncate"
|
|
style={{ color: 'var(--at-ink-2)' }}
|
|
>
|
|
{currentList?.title ?? 'Bộ thẻ'}
|
|
</button>
|
|
</div>
|
|
<h1
|
|
className="at-serif tracking-tight"
|
|
style={{ fontSize: 40, fontWeight: 400, letterSpacing: '-0.025em', lineHeight: 1.05, color: 'var(--at-ink)' }}
|
|
>
|
|
Thẻ <i style={{ fontStyle: 'italic', color: 'var(--at-brand)' }}>{currentIdx + 1}</i>
|
|
<span className="at-serif italic" style={{ color: 'var(--at-mute-2)' }}> / {total}</span>
|
|
</h1>
|
|
</div>
|
|
<div className="flex gap-2 flex-shrink-0">
|
|
<button
|
|
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors hover:bg-[var(--at-line-2)]"
|
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>arrow_back</span>
|
|
Danh sách
|
|
</button>
|
|
<button
|
|
onClick={() => current && toggleBookmark(current.id)}
|
|
disabled={!current}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors hover:bg-[var(--at-line-2)]"
|
|
style={{
|
|
background: current && bookmarks.has(current.id) ? 'var(--at-warm-soft)' : 'var(--at-surface)',
|
|
border: '1px solid ' + (current && bookmarks.has(current.id) ? 'var(--at-warm)' : 'var(--at-line)'),
|
|
color: current && bookmarks.has(current.id) ? 'var(--at-warm-ink)' : 'var(--at-ink-2)',
|
|
}}
|
|
>
|
|
<span
|
|
className="material-symbols-outlined"
|
|
style={{
|
|
fontSize: 15,
|
|
fontVariationSettings: current && bookmarks.has(current.id) ? "'FILL' 1" : "'FILL' 0",
|
|
}}
|
|
>
|
|
bookmark
|
|
</span>
|
|
Đánh dấu
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body: card column + sidebar */}
|
|
<div
|
|
className="flex-1 min-h-0 lg:grid flex flex-col gap-5"
|
|
style={{ gridTemplateColumns: 'minmax(0, 1fr) 260px' }}
|
|
>
|
|
{/* Main: card + actions + progress */}
|
|
<div className="flex flex-col items-center justify-center min-h-0">
|
|
{/* Card */}
|
|
{current && (
|
|
<div className="at-card-outer" style={{ maxWidth: 420, flexShrink: 0 }}>
|
|
<div
|
|
className={cn('at-card', isFlipped && 'is-flipped', fx === 'known' && 'fx-known', fx === 'review' && 'fx-review')}
|
|
key={current.id}
|
|
onClick={() => setIsFlipped(v => !v)}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={isFlipped ? 'Lật để xem từ' : 'Lật để xem nghĩa'}
|
|
>
|
|
{/* FRONT */}
|
|
<div className="at-card-face" style={{ padding: '20px 24px' }}>
|
|
<div className="flex items-center justify-between">
|
|
<span className="at-chip">
|
|
<span className="at-chip-dot" />
|
|
{current.part_of_speech?.toUpperCase() ?? 'TỪ VỰNG'}
|
|
</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); speak(current.audio_tts_text ?? current.word) }}
|
|
className="w-9 h-9 rounded-lg grid place-items-center text-[var(--at-mute)] hover:bg-[var(--at-accent-soft)] hover:text-[var(--at-accent)] transition"
|
|
aria-label="Phát âm"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>volume_up</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col justify-center">
|
|
<div className="at-word" style={{ fontSize: 'clamp(40px, 5vw, 60px)' }}>{current.word}</div>
|
|
{(current.phonetic || current.part_of_speech) && (
|
|
<div className="at-mono text-sm text-[var(--at-mute)] mt-3">
|
|
{current.phonetic}
|
|
{current.part_of_speech && (
|
|
<span className="at-serif italic text-[var(--at-mute-2)]"> · {current.part_of_speech}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
|
|
<span className="at-kbd">Space</span>
|
|
<span>để lật thẻ</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* BACK */}
|
|
<div className="at-card-face at-card-back" style={{ padding: '20px 24px' }}>
|
|
<div className="flex items-center justify-between">
|
|
<span className="at-chip at-chip-mute">
|
|
<span className="at-chip-dot" />
|
|
NGHĨA
|
|
</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); speak(current.audio_tts_text ?? current.word) }}
|
|
className="w-9 h-9 rounded-lg grid place-items-center text-[var(--at-mute)] hover:bg-[var(--at-accent-soft)] hover:text-[var(--at-accent)] transition"
|
|
aria-label="Phát âm"
|
|
>
|
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>volume_up</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col justify-center gap-4">
|
|
<div className="at-meaning" style={{ fontSize: 22 }}>{current.definition ?? '—'}</div>
|
|
{current.example && (
|
|
<div className="at-example">
|
|
<div className="at-serif italic text-[14px] leading-[1.45] text-[var(--at-ink-2)]">
|
|
"{current.example}"
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
|
|
<span className="at-kbd">↵</span>
|
|
<span>lật lại</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="mt-4 w-full" style={{ maxWidth: 420 }}>
|
|
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
|
|
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
|
|
Bỏ qua <span className="at-kbd">I</span>
|
|
</button>
|
|
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}>
|
|
Cần ôn <span className="at-kbd">K</span>
|
|
</button>
|
|
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}>
|
|
Đã thuộc
|
|
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress */}
|
|
<div className="mt-3 w-full" style={{ maxWidth: 420 }}>
|
|
<div className="flex items-baseline justify-between mb-1.5 text-[12px] text-[var(--at-mute)]">
|
|
<span>
|
|
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total} ·{' '}
|
|
{sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ
|
|
</span>
|
|
<span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span>
|
|
</div>
|
|
<div className="at-progress-bar">
|
|
<span style={{ width: `${progressPct}%` }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right sidebar */}
|
|
<aside className="hidden lg:flex flex-col gap-3 min-h-0">
|
|
{/* Today stats */}
|
|
<div
|
|
className="rounded-2xl p-4 flex-shrink-0"
|
|
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
|
|
>
|
|
<div className="at-eyebrow mb-2" style={{ fontSize: 11 }}>Hôm nay</div>
|
|
<div className="grid grid-cols-2 gap-3 mt-1">
|
|
<div>
|
|
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
|
|
Đã học
|
|
</div>
|
|
<div
|
|
className="at-serif"
|
|
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}
|
|
>
|
|
{sessionStats.known + sessionStats.learning + sessionStats.ignored}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
|
|
Đúng
|
|
</div>
|
|
<div
|
|
className="at-serif"
|
|
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-good)' }}
|
|
>
|
|
{sessionStats.known}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cards in deck — compact rows (word only) */}
|
|
<div
|
|
className="rounded-2xl p-3 flex flex-col"
|
|
style={{
|
|
background: 'var(--at-surface)',
|
|
border: '1px solid var(--at-line)',
|
|
maxHeight: 'calc((100vh - 4rem) / 2)',
|
|
}}
|
|
>
|
|
<div className="at-eyebrow mb-2 px-1" style={{ fontSize: 11 }}>Trong bộ này</div>
|
|
<div className="flex-1 min-h-0 overflow-y-auto -mx-1 px-1">
|
|
{sessionTerms.map((t, i) => {
|
|
const p = progressMap[t.id]
|
|
const isActive = i === currentIdx
|
|
const isKnown = p?.status === 'known'
|
|
const isBookmarked = bookmarks.has(t.id)
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => jumpTo(i)}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left transition-colors"
|
|
style={{
|
|
background: isActive ? 'var(--at-brand-soft)' : 'transparent',
|
|
borderTop: i === 0 || isActive ? 'none' : '1px solid var(--at-line)',
|
|
}}
|
|
>
|
|
<span
|
|
className="at-serif italic flex-shrink-0 text-center"
|
|
style={{ fontSize: 13, color: 'var(--at-mute)', width: 20 }}
|
|
>
|
|
{i + 1}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div
|
|
className="text-[13px] font-bold truncate"
|
|
style={{ color: isActive ? 'var(--at-brand-ink)' : 'var(--at-ink)' }}
|
|
>
|
|
{t.word}
|
|
</div>
|
|
<div
|
|
className="text-[11.5px] truncate mt-0.5"
|
|
style={{ color: 'var(--at-mute)' }}
|
|
>
|
|
{t.definition ?? '—'}
|
|
</div>
|
|
</div>
|
|
{isBookmarked && (
|
|
<span
|
|
className="material-symbols-outlined flex-shrink-0"
|
|
style={{ fontSize: 13, color: 'var(--at-warm)', fontVariationSettings: "'FILL' 1" }}
|
|
>
|
|
bookmark
|
|
</span>
|
|
)}
|
|
{isKnown && (
|
|
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: 14, color: 'var(--at-good)' }}>
|
|
check
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|