update
This commit is contained in:
@@ -1,24 +1,36 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
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 } from '../api/flashcard-api'
|
||||
import {
|
||||
fetchFlashcardTerms,
|
||||
fetchUserProgress,
|
||||
upsertTermProgress,
|
||||
startSession,
|
||||
endSession,
|
||||
logReview,
|
||||
fetchFlashcardLists,
|
||||
} from '../api/flashcard-api'
|
||||
import type { FlashcardTerm, UserProgress } from '../api/flashcard-api'
|
||||
|
||||
const EASE = {
|
||||
ignored: -1,
|
||||
hard: 0.1,
|
||||
easy: 0.65,
|
||||
known: 1.0,
|
||||
} as const
|
||||
|
||||
type EaseKey = keyof typeof EASE
|
||||
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)
|
||||
@@ -26,51 +38,165 @@ export function FlashCardLearnPage({ listId }: Props) {
|
||||
|
||||
const [isFlipped, setIsFlipped] = useState(false)
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
const [sessionStats, setSessionStats] = useState({ known: 0, learning: 0, ignored: 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: Record<number, UserProgress> = {}
|
||||
progress.forEach(p => { progressMap[p.term_id] = p })
|
||||
const progressMap = useMemo(() => {
|
||||
const m: Record<number, UserProgress> = {}
|
||||
progress.forEach(p => { m[p.term_id] = p })
|
||||
return m
|
||||
}, [progress])
|
||||
|
||||
// Prioritize: new + learning terms first, then known
|
||||
const sessionTerms: FlashcardTerm[] = [
|
||||
...terms.filter(t => {
|
||||
const s = progressMap[t.id]?.status ?? 'new'
|
||||
return s === 'new' || s === 'learning'
|
||||
}),
|
||||
...terms.filter(t => progressMap[t.id]?.status === 'known'),
|
||||
]
|
||||
// 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])
|
||||
|
||||
const { mutate: saveProgress } = useMutation({
|
||||
mutationFn: ({ termId, status, easeFactor }: { termId: number; status: UserProgress['status']; easeFactor: number }) =>
|
||||
upsertTermProgress(user!.id, termId, listId, status, easeFactor),
|
||||
// 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 easeFactor = EASE[key]
|
||||
const status: UserProgress['status'] =
|
||||
key === 'known' ? 'known' :
|
||||
key === 'ignored' ? 'ignored' :
|
||||
'learning'
|
||||
const currentProgress = progressMap[term.id]
|
||||
const reviewCount = currentProgress?.review_count ?? 0
|
||||
|
||||
saveProgress({ termId: term.id, status, easeFactor })
|
||||
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),
|
||||
@@ -78,13 +204,32 @@ export function FlashCardLearnPage({ listId }: Props) {
|
||||
ignored: prev.ignored + (key === 'ignored' ? 1 : 0),
|
||||
}))
|
||||
|
||||
if (currentIdx + 1 >= sessionTerms.length) {
|
||||
setIsDone(true)
|
||||
// Visual feedback: known swipes right, hard/ignored swipes left
|
||||
if (key === 'known' || key === 'easy') {
|
||||
setFx('known')
|
||||
} else {
|
||||
setCurrentIdx(i => i + 1)
|
||||
setIsFlipped(false)
|
||||
setFx('review')
|
||||
}
|
||||
}, [currentIdx, sessionTerms, user, saveProgress])
|
||||
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
|
||||
@@ -92,23 +237,24 @@ export function FlashCardLearnPage({ listId }: Props) {
|
||||
|
||||
if (loadingTerms) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin" />
|
||||
<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="min-h-screen flex flex-col items-center justify-center gap-4 px-4">
|
||||
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 56, fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
<h2 className="text-xl font-bold text-slate-700">Không có thẻ nào để học!</h2>
|
||||
<p className="text-slate-400 text-sm text-center">Bộ thẻ này chưa có từ nào. Vui lòng thêm từ trước.</p>
|
||||
<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-2 px-6 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
|
||||
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
|
||||
Quay lại danh sách
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -116,24 +262,24 @@ export function FlashCardLearnPage({ listId }: Props) {
|
||||
|
||||
if (isDone) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-6 px-4">
|
||||
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-8 px-4">
|
||||
<div className="text-center">
|
||||
<span className="material-symbols-outlined text-emerald-500 block mb-3" style={{ fontSize: 64, fontVariationSettings: "'FILL' 1" }}>celebration</span>
|
||||
<h2 className="text-2xl font-extrabold text-slate-800 mb-1">Hoàn thành phiên học!</h2>
|
||||
<p className="text-slate-500">Bạn đã ôn xong {total} thẻ trong phiên này</p>
|
||||
<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-4">
|
||||
<div className="text-center px-5 py-3 bg-emerald-50 rounded-xl border border-emerald-100">
|
||||
<div className="text-2xl font-extrabold text-emerald-600">{sessionStats.known}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Đã biết</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="text-center px-5 py-3 bg-blue-50 rounded-xl border border-blue-100">
|
||||
<div className="text-2xl font-extrabold text-blue-600">{sessionStats.learning}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Đang học</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="text-center px-5 py-3 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<div className="text-2xl font-extrabold text-slate-500">{sessionStats.ignored}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Bỏ qua</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">
|
||||
@@ -143,17 +289,19 @@ export function FlashCardLearnPage({ listId }: Props) {
|
||||
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="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
|
||||
className="px-5 py-2.5 bg-[var(--at-ink)] text-[var(--at-paper)] rounded-xl text-sm font-semibold hover:opacity-90 transition"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>
|
||||
Học lại
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 text-slate-600 rounded-xl text-sm font-semibold hover:bg-slate-50 transition-colors"
|
||||
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"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>list</span>
|
||||
Xem danh sách
|
||||
</button>
|
||||
</div>
|
||||
@@ -161,141 +309,296 @@ export function FlashCardLearnPage({ listId }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
// Jump to a specific card in the deck (no progress write — just navigate)
|
||||
const jumpTo = (idx: number) => {
|
||||
setCurrentIdx(idx)
|
||||
setIsFlipped(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-slate-50">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-xl border-b border-slate-100">
|
||||
<div className="max-w-3xl mx-auto px-6 py-3.5 flex items-center justify-between">
|
||||
<span className="text-base font-bold text-slate-800">Phiên học từ vựng</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-500 font-medium">{currentIdx + 1} / {total} từ</span>
|
||||
<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="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 transition-colors"
|
||||
className="hover:text-[var(--at-ink)] transition-colors truncate"
|
||||
style={{ color: 'var(--at-ink-2)' }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-slate-500" style={{ fontSize: 20 }}>close</span>
|
||||
{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>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-slate-100 h-1 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all duration-500"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 max-w-3xl mx-auto w-full px-4 py-10 flex flex-col items-center">
|
||||
{/* Session stats pills */}
|
||||
<div className="flex items-center gap-3 mb-10">
|
||||
<div className="px-4 py-1.5 rounded-full bg-emerald-50 text-emerald-700 text-sm font-semibold flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
{sessionStats.known} biết
|
||||
</div>
|
||||
<div className="px-4 py-1.5 rounded-full bg-blue-50 text-blue-700 text-sm font-semibold flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
{sessionStats.learning} đang học
|
||||
</div>
|
||||
<div className="px-4 py-1.5 rounded-full bg-slate-100 text-slate-500 text-sm font-semibold flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400" />
|
||||
{sessionStats.ignored} bỏ qua
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flashcard */}
|
||||
{current && (
|
||||
<div className="relative group w-full max-w-xl">
|
||||
<div className="absolute -inset-4 bg-blue-600/5 blur-3xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
|
||||
<div
|
||||
className="relative w-full min-h-[280px] bg-white rounded-2xl shadow-lg border border-slate-200/60 flex flex-col items-center justify-between py-8 px-8 cursor-pointer hover:scale-[1.01] hover:border-blue-200 transition-all duration-300 select-none"
|
||||
onClick={() => setIsFlipped(v => !v)}
|
||||
role="button"
|
||||
aria-label={isFlipped ? 'Nhấp để xem từ' : 'Nhấp để xem nghĩa'}
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
{!isFlipped ? (
|
||||
<>
|
||||
<div className="text-xs font-bold tracking-widest text-slate-400 uppercase">TIẾNG ANH</div>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-5xl font-extrabold tracking-tight text-blue-600">{current.word}</h1>
|
||||
{current.phonetic && (
|
||||
<span className="text-slate-500 italic font-light text-lg">{current.phonetic}</span>
|
||||
)}
|
||||
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="mt-3 px-3 py-1 bg-blue-50 text-blue-600 text-[11px] font-bold uppercase tracking-wider rounded-lg">
|
||||
{current.part_of_speech}
|
||||
</span>
|
||||
<span className="at-serif italic text-[var(--at-mute-2)]"> · {current.part_of_speech}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>flip</span>
|
||||
<span className="text-sm font-medium">Nhấp để xem nghĩa</span>
|
||||
)}
|
||||
</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 className="text-xs font-bold tracking-widest text-slate-400 uppercase">NGHĨA</div>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<p className="text-2xl font-bold text-slate-800">{current.definition ?? '—'}</p>
|
||||
{current.example && (
|
||||
<p className="text-sm text-slate-500 italic bg-slate-50 rounded-xl px-4 py-2.5 border border-slate-100 max-w-sm">
|
||||
"{current.example}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>flip</span>
|
||||
<span className="text-sm font-medium">Nhấp để xem từ</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-12 flex flex-col items-center gap-4 w-full max-w-xl">
|
||||
<div className={cn(
|
||||
'flex items-stretch gap-3 w-full h-14 transition-all duration-300',
|
||||
!isFlipped && 'opacity-40 pointer-events-none',
|
||||
)}>
|
||||
<button
|
||||
onClick={() => handleAnswer('ignored')}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-2xl border-2 border-slate-200 text-slate-500 font-bold text-sm hover:bg-slate-100 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>close</span>
|
||||
Bỏ qua
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAnswer('hard')}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-2xl bg-amber-500 text-white font-bold text-sm shadow-md shadow-amber-500/20 hover:bg-amber-600 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>warning</span>
|
||||
Khó
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAnswer('easy')}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-2xl bg-blue-600 text-white font-bold text-sm shadow-md shadow-blue-600/20 hover:bg-blue-700 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>thumb_up</span>
|
||||
Dễ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAnswer('known')}
|
||||
className="flex-1 flex items-center justify-center gap-2 rounded-2xl bg-emerald-600 text-white font-bold text-sm shadow-md shadow-emerald-600/20 hover:bg-emerald-700 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
Đã biết
|
||||
</button>
|
||||
</div>
|
||||
{!isFlipped && (
|
||||
<p className="text-sm text-slate-400 font-medium">Lật thẻ trước khi đánh giá</p>
|
||||
)}
|
||||
{isFlipped && (
|
||||
<p className="text-sm text-slate-400 font-medium">Còn {total - currentIdx - 1} thẻ trong phiên này</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user