271 lines
9.1 KiB
TypeScript
271 lines
9.1 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
||
import AudioPlayer from '@/components/AudioPlayer'
|
||
|
||
interface FlipMemoryTestProps {
|
||
word: string
|
||
definition: string
|
||
example: string
|
||
exampleTranslation: string
|
||
pronunciation?: string
|
||
synonyms?: string[]
|
||
difficultyLevel: string
|
||
onConfidenceSubmit: (level: number) => void
|
||
onReportError: () => void
|
||
disabled?: boolean
|
||
}
|
||
|
||
export const FlipMemoryTest: React.FC<FlipMemoryTestProps> = ({
|
||
word,
|
||
definition,
|
||
example,
|
||
exampleTranslation,
|
||
pronunciation,
|
||
synonyms = [],
|
||
difficultyLevel,
|
||
onConfidenceSubmit,
|
||
onReportError,
|
||
disabled = false
|
||
}) => {
|
||
const [isFlipped, setIsFlipped] = useState(false)
|
||
const [selectedConfidence, setSelectedConfidence] = useState<number | null>(null)
|
||
const [cardHeight, setCardHeight] = useState<number>(400)
|
||
const frontRef = useRef<HTMLDivElement>(null)
|
||
const backRef = useRef<HTMLDivElement>(null)
|
||
|
||
useEffect(() => {
|
||
const updateCardHeight = () => {
|
||
if (backRef.current) {
|
||
const backHeight = backRef.current.scrollHeight
|
||
|
||
// 響應式最小高度設定
|
||
const minHeightByScreen = window.innerWidth <= 480 ? 300 :
|
||
window.innerWidth <= 768 ? 350 : 400
|
||
|
||
// 以背面內容高度為準,不設最大高度限制
|
||
const finalHeight = Math.max(minHeightByScreen, backHeight)
|
||
setCardHeight(finalHeight)
|
||
}
|
||
}
|
||
|
||
// 延遲執行以確保內容已渲染
|
||
const timer = setTimeout(updateCardHeight, 100)
|
||
|
||
window.addEventListener('resize', updateCardHeight)
|
||
return () => {
|
||
clearTimeout(timer)
|
||
window.removeEventListener('resize', updateCardHeight)
|
||
}
|
||
}, [word, definition, example, synonyms])
|
||
|
||
const handleFlip = () => {
|
||
if (!disabled) setIsFlipped(!isFlipped)
|
||
}
|
||
|
||
const handleConfidenceSelect = (level: number) => {
|
||
if (disabled) return
|
||
setSelectedConfidence(level)
|
||
onConfidenceSubmit(level)
|
||
}
|
||
|
||
const confidenceLabels = {
|
||
1: '完全不懂',
|
||
2: '模糊',
|
||
3: '一般',
|
||
4: '熟悉',
|
||
5: '非常熟悉'
|
||
}
|
||
|
||
return (
|
||
<div className="relative">
|
||
{/* 錯誤回報按鈕 */}
|
||
<div className="flex justify-end mb-2">
|
||
<button
|
||
onClick={onReportError}
|
||
className="px-3 py-2 rounded-md transition-colors text-gray-600 hover:text-gray-900"
|
||
>
|
||
🚩 回報錯誤
|
||
</button>
|
||
</div>
|
||
|
||
{/* 翻卡容器 */}
|
||
<div
|
||
className={`card-container ${disabled ? 'pointer-events-none opacity-75' : 'cursor-pointer'}`}
|
||
onClick={handleFlip}
|
||
style={{ perspective: '1000px', height: `${cardHeight}px` }}
|
||
>
|
||
<div
|
||
className={`card bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-600 ${isFlipped ? 'rotate-y-180' : ''}`}
|
||
style={{ transformStyle: 'preserve-3d', height: '100%' }}
|
||
>
|
||
{/* 正面 */}
|
||
<div
|
||
ref={frontRef}
|
||
className="card-face card-front absolute w-full h-full"
|
||
style={{ backfaceVisibility: 'hidden' }}
|
||
>
|
||
<div className="p-8 h-full">
|
||
<div className="flex justify-between items-start mb-6">
|
||
<h2 className="text-2xl font-bold text-gray-900">翻卡記憶</h2>
|
||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||
{difficultyLevel}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<p className="text-lg text-gray-700 mb-6 text-left">
|
||
點擊卡片翻面,根據你對單字的熟悉程度進行自我評估:
|
||
</p>
|
||
|
||
<div className="flex-1 flex items-center justify-center mt-6">
|
||
<div className="bg-gray-50 rounded-lg p-8 w-full text-center">
|
||
<h3 className="text-4xl font-bold text-gray-900 mb-6">{word}</h3>
|
||
<div className="flex items-center justify-center gap-3">
|
||
{pronunciation && (
|
||
<span className="text-lg text-gray-500">{pronunciation}</span>
|
||
)}
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<AudioPlayer text={word} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 背面 */}
|
||
<div
|
||
ref={backRef}
|
||
className="card-face card-back absolute w-full h-full"
|
||
style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}
|
||
>
|
||
<div className="p-8 h-full">
|
||
<div className="flex justify-between items-start mb-6">
|
||
<h2 className="text-2xl font-bold text-gray-900">翻卡記憶</h2>
|
||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||
{difficultyLevel}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-4 pb-6">
|
||
{/* 定義區塊 */}
|
||
<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">{definition}</p>
|
||
</div>
|
||
|
||
{/* 例句區塊 */}
|
||
<div className="bg-gray-50 rounded-lg p-4">
|
||
<h3 className="font-semibold text-gray-900 mb-2 text-left">例句</h3>
|
||
<div className="relative">
|
||
<p className="text-gray-700 italic mb-2 text-left pr-12">{example}</p>
|
||
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
|
||
<AudioPlayer text={example} />
|
||
</div>
|
||
</div>
|
||
<p className="text-gray-600 text-sm text-left">{exampleTranslation}</p>
|
||
</div>
|
||
|
||
{/* 同義詞區塊 */}
|
||
{synonyms.length > 0 && (
|
||
<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">
|
||
{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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 信心等級評估區 - 裸露在背景上 */}
|
||
{(
|
||
<div className="mt-6">
|
||
<div className="grid grid-cols-5 gap-3">
|
||
{[1, 2, 3, 4, 5].map(level => (
|
||
<button
|
||
key={level}
|
||
onClick={() => handleConfidenceSelect(level)}
|
||
disabled={disabled || selectedConfidence !== null}
|
||
className={`py-4 px-3 border-2 rounded-lg transition-all text-center font-medium ${
|
||
selectedConfidence === level
|
||
? 'bg-blue-500 text-white border-blue-500 shadow-lg'
|
||
: 'bg-white border-gray-300 text-gray-700 hover:bg-blue-50 hover:border-blue-400 shadow-sm'
|
||
} ${disabled || selectedConfidence !== null ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md'}`}
|
||
>
|
||
<div className="text-lg font-semibold">
|
||
{confidenceLabels[level as keyof typeof confidenceLabels]}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<style jsx>{`
|
||
.card-container {
|
||
perspective: 1000px;
|
||
transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||
min-height: 400px;
|
||
}
|
||
|
||
.card {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
transform-style: preserve-3d;
|
||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.rotate-y-180 {
|
||
transform: rotateY(180deg);
|
||
}
|
||
|
||
.card-face {
|
||
position: absolute;
|
||
width: 100%;
|
||
height: 100%;
|
||
backface-visibility: hidden;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.card-front .p-8 {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100%;
|
||
}
|
||
|
||
.card-back .p-8 {
|
||
overflow: visible;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.card-container {
|
||
min-height: 350px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.card-container {
|
||
min-height: 300px;
|
||
}
|
||
|
||
.card-face .p-8 {
|
||
padding: 1rem;
|
||
}
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)
|
||
} |