ux: 實現翻卡模式動態高度適配與版面優化

- 新增動態高度計算系統,解決翻轉時卡片大小變化問題
- 實現正面背面高度自動匹配,確保翻轉體驗一致
- 優化翻卡模式設計:移除CEFR標籤、簡化正面佈局
- 改善背面內容組織:灰底區分、左對齊文字、移除冗餘元素
- 修復背面底部空白問題,提升視覺整潔度
- 添加平滑高度過渡動畫,增強用戶體驗

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-19 23:09:45 +08:00
parent 31e3fe9fa8
commit c1e296c860
1 changed files with 109 additions and 83 deletions

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef, useLayoutEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Navigation } from '@/components/Navigation'
import AudioPlayer from '@/components/AudioPlayer'
@ -24,6 +24,12 @@ export default function LearnPage() {
const [reportingCard, setReportingCard] = useState<any>(null)
const [showComplete, setShowComplete] = useState(false)
const [quizOptions, setQuizOptions] = useState<string[]>(['brought', 'determine', 'achieve', 'consider'])
const [cardHeight, setCardHeight] = useState<number>(400)
// Refs for measuring card content heights
const cardFrontRef = useRef<HTMLDivElement>(null)
const cardBackRef = useRef<HTMLDivElement>(null)
const cardContainerRef = useRef<HTMLDivElement>(null)
// Mock data with real example images
const cards = [
@ -70,6 +76,37 @@ export default function LearnPage() {
const currentCard = cards[currentCardIndex]
// Calculate optimal card height based on content (only when card changes)
const calculateCardHeight = () => {
if (!cardFrontRef.current || !cardBackRef.current) return 400;
// Get the scroll heights to measure actual content
const frontHeight = cardFrontRef.current.scrollHeight;
const backHeight = cardBackRef.current.scrollHeight;
console.log('Heights calculated:', { frontHeight, backHeight }); // Debug log
// Use the maximum height with padding
const maxHeight = Math.max(frontHeight, backHeight);
const paddedHeight = maxHeight + 40; // Add padding for visual spacing
// Ensure minimum height for visual consistency
return Math.max(paddedHeight, 450);
};
// Update card height only when card content changes (not on flip)
useLayoutEffect(() => {
if (mounted && cardFrontRef.current && cardBackRef.current) {
// Wait for DOM to be fully rendered
const timer = setTimeout(() => {
const newHeight = calculateCardHeight();
setCardHeight(newHeight);
}, 50);
return () => clearTimeout(timer);
}
}, [currentCardIndex, mounted]);
// Client-side mounting and quiz options generation
useEffect(() => {
setMounted(true)
@ -102,6 +139,7 @@ export default function LearnPage() {
setShowResult(false)
setFillAnswer('')
setShowHint(false)
// Height will be recalculated in useLayoutEffect
} else {
setShowComplete(true)
}
@ -115,6 +153,7 @@ export default function LearnPage() {
setShowResult(false)
setFillAnswer('')
setShowHint(false)
// Height will be recalculated in useLayoutEffect
}
}
@ -305,95 +344,64 @@ export default function LearnPage() {
</button>
</div>
<div className="card-container" onClick={handleFlip}>
<div
ref={cardContainerRef}
className="card-container"
onClick={handleFlip}
style={{ height: `${cardHeight}px` }}
>
<div className={`card ${isFlipped ? 'flipped' : ''}`}>
{/* Front */}
<div className="card-front">
<div className="bg-white rounded-xl shadow-lg p-8 text-center cursor-pointer hover:shadow-xl transition-shadow">
<div className="mb-4">
<span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}
</span>
</div>
<h2 className="text-4xl font-bold text-gray-900 mb-4">
<div
ref={cardFrontRef}
className="bg-white rounded-xl shadow-lg text-center cursor-pointer hover:shadow-xl transition-shadow"
>
<h2 className="text-4xl font-bold text-gray-900 mb-6">
{currentCard.word}
</h2>
<p className="text-gray-600 mb-4">
{currentCard.partOfSpeech} {currentCard.pronunciation}
</p>
<AudioPlayer text={currentCard.word} />
<p className="text-sm text-gray-500 mt-4">
</p>
<div className="flex items-center justify-center gap-3">
<span className="text-lg text-gray-500">
{currentCard.pronunciation}
</span>
<AudioPlayer text={currentCard.word} />
</div>
</div>
</div>
{/* Back */}
<div className="card-back">
<div className="bg-white rounded-xl shadow-lg p-8 cursor-pointer hover:shadow-xl transition-shadow">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Text Content */}
<div>
<div className="mb-4">
<span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{currentCard.difficulty}
</span>
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-2">
{currentCard.word}
</h2>
<p className="text-lg text-blue-600 font-semibold mb-4">
{currentCard.translation}
</p>
<p className="text-gray-600 mb-4">
{currentCard.partOfSpeech} {currentCard.pronunciation}
</p>
<div className="mb-4">
<AudioPlayer text={currentCard.word} />
</div>
<div className="mb-4">
<h3 className="font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-700">{currentCard.definition}</p>
</div>
<div className="mb-4">
<h3 className="font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-700 italic mb-1">"{currentCard.example}"</p>
<p className="text-gray-600 text-sm">"{currentCard.exampleTranslation}"</p>
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-2"></h3>
<div className="flex flex-wrap gap-2">
{currentCard.synonyms.map((synonym, index) => (
<span
key={index}
className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-sm"
>
{synonym}
</span>
))}
</div>
</div>
<div
ref={cardBackRef}
className="bg-white rounded-xl shadow-lg cursor-pointer hover:shadow-xl transition-shadow"
>
{/* Content Sections */}
<div className="space-y-4">
{/* Definition */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<p className="text-gray-700 text-left">{currentCard.definition}</p>
</div>
{/* Right Column - Image */}
<div className="flex items-center justify-center">
<div className="relative">
<img
src={currentCard.exampleImage}
alt={`Example for ${currentCard.word}`}
className="rounded-lg shadow-md max-w-full h-auto cursor-pointer hover:shadow-lg transition-shadow"
onClick={(e) => {
e.stopPropagation()
setModalImage(currentCard.exampleImage)
}}
/>
<div className="absolute top-2 right-2 bg-black bg-opacity-50 text-white text-xs px-2 py-1 rounded">
</div>
{/* Example */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<p className="text-gray-700 italic mb-2 text-left">"{currentCard.example}"</p>
<p className="text-gray-600 text-sm text-left">"{currentCard.exampleTranslation}"</p>
</div>
{/* Synonyms */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<div className="flex flex-wrap gap-2">
{currentCard.synonyms.map((synonym, index) => (
<span
key={index}
className="bg-white text-gray-700 px-3 py-1 rounded-full text-sm border border-gray-200"
>
{synonym}
</span>
))}
</div>
</div>
</div>
@ -935,7 +943,9 @@ export default function LearnPage() {
<style jsx>{`
.card-container {
perspective: 1000px;
height: 400px;
transition: height 0.3s ease;
overflow: visible;
position: relative;
}
.card {
@ -943,7 +953,7 @@ export default function LearnPage() {
width: 100%;
height: 100%;
text-align: center;
transition: transform 0.6s;
transition: transform 0.6s ease;
transform-style: preserve-3d;
}
@ -957,18 +967,34 @@ export default function LearnPage() {
height: 100%;
backface-visibility: hidden;
display: flex;
align-items: center;
align-items: stretch;
justify-content: center;
top: 0;
left: 0;
}
.card-back {
transform: rotateY(180deg);
}
.card-front > div, .card-back > div {
.card-front > div {
width: 100%;
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem;
box-sizing: border-box;
}
.card-back > div {
width: 100%;
padding: 1.5rem;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
`}</style>
</div>