238 lines
8.0 KiB
TypeScript
238 lines
8.0 KiB
TypeScript
import { useState, useRef, useEffect, memo, useCallback } from 'react'
|
||
import AudioPlayer from '@/components/AudioPlayer'
|
||
import {
|
||
ErrorReportButton,
|
||
ConfidenceButtons,
|
||
TestHeader,
|
||
HintPanel
|
||
} from '@/components/review/shared'
|
||
import { ConfidenceTestProps } from '@/types/review'
|
||
|
||
interface FlipMemoryTestProps extends ConfidenceTestProps {
|
||
// FlipMemoryTest specific props (if any)
|
||
}
|
||
|
||
const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||
cardData,
|
||
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)
|
||
}
|
||
}, [cardData.word, cardData.definition, cardData.example, cardData.synonyms])
|
||
|
||
const handleFlip = useCallback(() => {
|
||
if (!disabled) setIsFlipped(!isFlipped)
|
||
}, [disabled, isFlipped])
|
||
|
||
const handleConfidenceSelect = useCallback((level: number) => {
|
||
if (disabled) return
|
||
setSelectedConfidence(level)
|
||
onConfidenceSubmit(level)
|
||
}, [disabled, onConfidenceSubmit])
|
||
|
||
|
||
return (
|
||
<div className="relative">
|
||
<div className="flex justify-end mb-2">
|
||
<ErrorReportButton onClick={onReportError} />
|
||
</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">
|
||
<TestHeader
|
||
title="翻卡記憶"
|
||
difficultyLevel={cardData.difficultyLevel}
|
||
/>
|
||
</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">{cardData.word}</h3>
|
||
<div className="flex items-center justify-center gap-3">
|
||
{cardData.pronunciation && (
|
||
<span className="text-lg text-gray-500">{cardData.pronunciation}</span>
|
||
)}
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<AudioPlayer text={cardData.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">
|
||
<TestHeader
|
||
title="翻卡記憶"
|
||
difficultyLevel={cardData.difficultyLevel}
|
||
/>
|
||
</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">{cardData.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">{cardData.example}</p>
|
||
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
|
||
<AudioPlayer text={cardData.example} />
|
||
</div>
|
||
</div>
|
||
<p className="text-gray-600 text-sm text-left">{cardData.exampleTranslation}</p>
|
||
</div>
|
||
|
||
{/* 同義詞區塊 */}
|
||
{cardData.synonyms && cardData.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">
|
||
{cardData.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">
|
||
<ConfidenceButtons
|
||
selectedLevel={selectedConfidence}
|
||
onSelect={handleConfidenceSelect}
|
||
disabled={disabled}
|
||
/>
|
||
</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>
|
||
)
|
||
}
|
||
|
||
export const FlipMemoryTest = memo(FlipMemoryTestComponent)
|
||
FlipMemoryTest.displayName = 'FlipMemoryTest' |