feat: add stacked practice cards
This commit is contained in:
@@ -92,6 +92,7 @@ export default function ProcessPurposePracticePage() {
|
||||
const isComposingRef = useRef(false)
|
||||
const latestInputRef = useRef<string[]>([])
|
||||
const answerTimerRef = useRef<number | null>(null)
|
||||
const deckScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const { practiceMode } = useAppStore()
|
||||
|
||||
const itemMap = useMemo(
|
||||
@@ -102,6 +103,11 @@ export default function ProcessPurposePracticePage() {
|
||||
const totalCount = progress.order.length
|
||||
const completedCount = progress.completedIds.length
|
||||
const isFinished = completedCount >= totalCount
|
||||
const visibleQuestionCards = progress.order
|
||||
.slice(0, progress.currentIndex + 1)
|
||||
.map((id, index) => ({ item: itemMap.get(id), index }))
|
||||
.filter((card): card is { item: NonNullable<typeof currentItem>; index: number } => Boolean(card.item))
|
||||
const nextPreviewItem = itemMap.get(progress.order[progress.currentIndex + 1])
|
||||
|
||||
const focusFirstEmptyInput = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
@@ -213,6 +219,15 @@ export default function ProcessPurposePracticePage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const deck = deckScrollRef.current
|
||||
if (!deck) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
deck.scrollTo({ top: deck.scrollHeight, behavior: 'smooth' })
|
||||
})
|
||||
}, [progress.currentIndex])
|
||||
|
||||
const moveToNextQuestion = useCallback(() => {
|
||||
setShowAnswer(false)
|
||||
setCorrectFeedback(false)
|
||||
@@ -413,8 +428,8 @@ export default function ProcessPurposePracticePage() {
|
||||
if (!currentItem) return null
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-900">
|
||||
<div className="sticky top-0 z-20 border-b border-gray-200 bg-white/90 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/90">
|
||||
<div className="flex h-[calc(100vh-3rem)] min-h-[620px] flex-col overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
<div className="z-20 shrink-0 border-b border-gray-200 bg-white/90 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/90">
|
||||
<div className="mx-auto max-w-5xl px-4 py-4">
|
||||
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
@@ -449,40 +464,104 @@ export default function ProcessPurposePracticePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8 pb-48">
|
||||
<section
|
||||
className={`rounded-2xl border bg-white p-6 shadow-sm transition-all duration-200 dark:bg-gray-800 ${
|
||||
correctFeedback
|
||||
? 'border-green-300 ring-2 ring-green-100 dark:border-green-700 dark:ring-green-900/40'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
<main className="mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col gap-4 overflow-hidden px-4 py-6">
|
||||
<div
|
||||
ref={deckScrollRef}
|
||||
className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain pr-1 scroll-smooth"
|
||||
>
|
||||
<div className="mb-4 flex min-h-9 flex-wrap items-center justify-between gap-3">
|
||||
<span className="rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
第 {Math.min(progress.currentIndex + 1, totalCount)} / {totalCount} 题
|
||||
</span>
|
||||
<AnimatePresence mode="wait">
|
||||
{showAnswer ? (
|
||||
<motion.span
|
||||
key="answer"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="rounded-xl border border-indigo-300 bg-indigo-50 px-3 py-1 text-base font-semibold leading-6 text-indigo-800 shadow-sm shadow-indigo-100 dark:border-indigo-500/70 dark:bg-indigo-950/50 dark:text-indigo-100 dark:shadow-none"
|
||||
>
|
||||
{currentItem.name}
|
||||
</motion.span>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<p className="text-xl leading-9 text-gray-900 dark:text-gray-100">
|
||||
{currentItem.purpose}
|
||||
</p>
|
||||
</section>
|
||||
<AnimatePresence initial={false}>
|
||||
{visibleQuestionCards.map(({ item, index }) => {
|
||||
const isCurrent = index === progress.currentIndex
|
||||
const isAnsweredHistory = index < progress.currentIndex
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
key={item.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 24, scale: 0.98 }}
|
||||
animate={{
|
||||
opacity: isCurrent ? 1 : 0.72,
|
||||
y: 0,
|
||||
scale: isCurrent ? 1 : 0.985,
|
||||
}}
|
||||
exit={{ opacity: 0, y: -16, scale: 0.98 }}
|
||||
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||
className={`rounded-2xl border bg-white p-6 shadow-sm transition-all duration-200 dark:bg-gray-800 ${
|
||||
isCurrent && correctFeedback
|
||||
? 'border-green-300 ring-2 ring-green-100 dark:border-green-700 dark:ring-green-900/40'
|
||||
: isCurrent
|
||||
? 'border-gray-200 dark:border-gray-700'
|
||||
: 'border-gray-100 dark:border-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex min-h-9 flex-wrap items-center justify-between gap-3">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
||||
isCurrent
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-700/70 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
第 {index + 1} / {totalCount} 题
|
||||
</span>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{isCurrent && showAnswer ? (
|
||||
<motion.span
|
||||
key="current-answer"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="rounded-xl border border-indigo-300 bg-indigo-50 px-3 py-1 text-base font-semibold leading-6 text-indigo-800 shadow-sm shadow-indigo-100 dark:border-indigo-500/70 dark:bg-indigo-950/50 dark:text-indigo-100 dark:shadow-none"
|
||||
>
|
||||
{item.name}
|
||||
</motion.span>
|
||||
) : isAnsweredHistory ? (
|
||||
<motion.span
|
||||
key="history-answer"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="rounded-full bg-green-50 px-3 py-1 text-sm font-medium text-green-700 dark:bg-green-900/30 dark:text-green-200"
|
||||
>
|
||||
{item.name}
|
||||
</motion.span>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`leading-9 text-gray-900 dark:text-gray-100 ${
|
||||
isCurrent ? 'text-xl' : 'text-base'
|
||||
}`}
|
||||
>
|
||||
{item.purpose}
|
||||
</p>
|
||||
</motion.section>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{!isFinished && nextPreviewItem && (
|
||||
<motion.section
|
||||
key={nextPreviewItem.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 0.58, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="pointer-events-none shrink-0 rounded-2xl border border-dashed border-gray-200 bg-white/70 p-5 shadow-sm blur-[0.4px] dark:border-gray-700 dark:bg-gray-800/55"
|
||||
>
|
||||
<div className="mb-3 inline-flex rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-500 dark:bg-gray-700/70 dark:text-gray-300">
|
||||
第 {progress.currentIndex + 2} / {totalCount} 题
|
||||
</div>
|
||||
<p className="line-clamp-2 select-none text-base leading-8 text-gray-500 blur-[1.5px] dark:text-gray-400">
|
||||
{nextPreviewItem.purpose}
|
||||
</p>
|
||||
</motion.section>
|
||||
)}
|
||||
|
||||
{isFinished && (
|
||||
<section className="mt-6 rounded-2xl border border-gray-200 bg-white p-10 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<section className="shrink-0 rounded-2xl border border-gray-200 bg-white p-10 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
已完成本轮练习
|
||||
</div>
|
||||
@@ -501,7 +580,7 @@ export default function ProcessPurposePracticePage() {
|
||||
</main>
|
||||
|
||||
{!isFinished && (
|
||||
<div className="sticky bottom-0 z-10 border-t border-gray-200 bg-white/70 pb-8 backdrop-blur-md dark:border-gray-700 dark:bg-gray-800/70">
|
||||
<div className="z-10 shrink-0 border-t border-gray-200 bg-white/70 pb-8 backdrop-blur-md dark:border-gray-700 dark:bg-gray-800/70">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="border-b border-gray-200/50 py-3 dark:border-gray-700/50">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user