dramaling-vocab-learning/frontend/components/ClickableTextV2.tsx

303 lines
10 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
interface WordAnalysis {
word: string
translation: string
definition: string
partOfSpeech: string
pronunciation: string
synonyms: string[]
antonyms?: string[]
isPhrase: boolean
isHighValue: boolean
learningPriority: 'high' | 'medium' | 'low'
phraseInfo?: {
phrase: string
meaning: string
warning: string
colorCode: string
}
difficultyLevel: string
costIncurred?: number
}
interface ClickableTextProps {
text: string
analysis?: Record<string, WordAnalysis>
onWordClick?: (word: string, analysis: WordAnalysis) => void
onSaveWord?: (word: string, analysis: WordAnalysis) => Promise<void>
remainingUsage?: number
showPhrasesInline?: boolean
}
const POPUP_CONFIG = {
WIDTH: 320,
HEIGHT: 400,
PADDING: 16,
MOBILE_BREAKPOINT: 640
} as const
export function ClickableTextV2({
text,
analysis,
onWordClick,
onSaveWord,
remainingUsage = 5,
showPhrasesInline = true
}: ClickableTextProps) {
const [selectedWord, setSelectedWord] = useState<string | null>(null)
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0, showBelow: false })
const [isSavingWord, setIsSavingWord] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const getCEFRColor = (level: string) => {
switch (level) {
case 'A1': return 'bg-green-100 text-green-700 border-green-200'
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200'
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200'
case 'C1': return 'bg-red-100 text-red-700 border-red-200'
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200'
default: return 'bg-gray-100 text-gray-700 border-gray-200'
}
}
const getWordProperty = (wordData: any, propName: string) => {
if (!wordData) return undefined;
const variations = [
propName,
propName.toLowerCase(),
propName.charAt(0).toUpperCase() + propName.slice(1),
propName.charAt(0).toLowerCase() + propName.slice(1)
];
for (const variation of variations) {
if (wordData[variation] !== undefined) {
return wordData[variation];
}
}
if (propName === 'synonyms') {
return [];
}
return undefined;
}
const findWordAnalysis = (word: string) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
return analysis?.[cleanWord] || analysis?.[word] || analysis?.[word.toLowerCase()] || null
}
const getLevelIndex = (level: string): number => {
const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
return levels.indexOf(level)
}
const getWordClass = (word: string) => {
const wordAnalysis = findWordAnalysis(word)
const baseClass = "cursor-pointer transition-all duration-200 rounded-lg relative mx-0.5 my-1 px-2 py-1 inline-flex items-center gap-1"
if (wordAnalysis) {
const isPhrase = getWordProperty(wordAnalysis, 'isPhrase')
const difficultyLevel = getWordProperty(wordAnalysis, 'difficultyLevel') || 'A1'
const userLevel = typeof window !== 'undefined' ? localStorage.getItem('userEnglishLevel') || 'A2' : 'A2'
// 如果是片語,跳過標記
if (isPhrase) {
return ""
}
// 直接進行CEFR等級比較
const userIndex = getLevelIndex(userLevel)
const wordIndex = getLevelIndex(difficultyLevel)
if (userIndex > wordIndex) {
// 簡單詞彙:學習者程度 > 詞彙程度
return `${baseClass} bg-gray-50 border border-dashed border-gray-300 hover:bg-gray-100 hover:border-gray-400 text-gray-600 opacity-80`
} else if (userIndex === wordIndex) {
// 適中詞彙:學習者程度 = 詞彙程度
return `${baseClass} bg-green-50 border border-green-200 hover:bg-green-100 hover:shadow-lg transform hover:-translate-y-0.5 text-green-700 font-medium`
} else {
// 艱難詞彙:學習者程度 < 詞彙程度
return `${baseClass} bg-orange-50 border border-orange-200 hover:bg-orange-100 hover:shadow-lg transform hover:-translate-y-0.5 text-orange-700 font-medium`
}
} else {
return ""
}
}
const getWordIcon = (word: string) => {
// 移除所有圖標,保持簡潔設計
return null
}
const words = text.split(/(\s+|[.,!?;:])/g)
const handleWordClick = async (word: string, event: React.MouseEvent) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = findWordAnalysis(word)
if (!wordAnalysis) return
const rect = event.currentTarget.getBoundingClientRect()
const position = {
x: rect.left + rect.width / 2,
y: rect.bottom + 10,
showBelow: true
}
setPopupPosition(position)
setSelectedWord(cleanWord)
onWordClick?.(cleanWord, wordAnalysis)
}
const closePopup = () => {
setSelectedWord(null)
}
const handleSaveWord = async () => {
if (!selectedWord || !analysis?.[selectedWord] || !onSaveWord) return
setIsSavingWord(true)
try {
await onSaveWord(selectedWord, analysis[selectedWord])
setSelectedWord(null)
} catch (error) {
console.error('Save word error:', error)
alert(`保存詞彙失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
} finally {
setIsSavingWord(false)
}
}
const VocabPopup = () => {
if (!selectedWord || !analysis?.[selectedWord] || !mounted) return null
return createPortal(
<>
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={closePopup}
/>
<div
className="fixed z-50 bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden"
style={{
left: `${popupPosition.x}px`,
top: `${popupPosition.y}px`,
transform: 'translate(-50%, 8px)',
maxHeight: '85vh',
overflowY: 'auto'
}}
>
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 border-b border-blue-200">
<div className="flex justify-end mb-3">
<button
onClick={closePopup}
className="text-gray-400 hover:text-gray-600 w-6 h-6 rounded-full bg-white bg-opacity-80 hover:bg-opacity-100 transition-all flex items-center justify-center"
>
</button>
</div>
<div className="mb-3">
<h3 className="text-2xl font-bold text-gray-900">{getWordProperty(analysis[selectedWord], 'word')}</h3>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded-full">
{getWordProperty(analysis[selectedWord], 'partOfSpeech')}
</span>
<span className="text-base text-gray-600">{getWordProperty(analysis[selectedWord], 'pronunciation')}</span>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getCEFRColor(getWordProperty(analysis[selectedWord], 'difficultyLevel'))}`}>
{getWordProperty(analysis[selectedWord], 'difficultyLevel')}
</span>
</div>
</div>
<div className="p-4 space-y-4">
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
<h4 className="font-semibold text-green-900 mb-2 text-left text-sm"></h4>
<p className="text-green-800 font-medium text-left">{getWordProperty(analysis[selectedWord], 'translation')}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<h4 className="font-semibold text-gray-900 mb-2 text-left text-sm"></h4>
<p className="text-gray-700 text-left text-sm leading-relaxed">{getWordProperty(analysis[selectedWord], 'definition')}</p>
</div>
{(() => {
const example = getWordProperty(analysis[selectedWord], 'example');
return example && example !== 'null' && example !== 'undefined';
})() && (
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
<h4 className="font-semibold text-blue-900 mb-2 text-left text-sm"></h4>
<div className="space-y-2">
<p className="text-blue-800 text-left text-sm italic">
"{getWordProperty(analysis[selectedWord], 'example')}"
</p>
<p className="text-blue-700 text-left text-sm">
{getWordProperty(analysis[selectedWord], 'exampleTranslation')}
</p>
</div>
</div>
)}
</div>
{onSaveWord && (
<div className="p-4 pt-2">
<button
onClick={handleSaveWord}
disabled={isSavingWord}
className="w-full bg-primary text-white py-3 rounded-lg font-medium hover:bg-primary-hover transition-colors flex items-center justify-center gap-2"
>
<span className="font-medium">{isSavingWord ? '保存中...' : '保存到詞卡'}</span>
</button>
</div>
)}
</div>
</>,
document.body
)
}
return (
<div className="relative">
<div className="text-lg" leading-relaxed>
{words.map((word, index) => {
if (word.trim() === '' || /^[.,!?;:\s]+$/.test(word)) {
return <span key={index}>{word}</span>
}
const className = getWordClass(word)
const icon = getWordIcon(word)
return (
<span
key={index}
className={className}
onClick={(e) => handleWordClick(word, e)}
>
{word}
{icon}
</span>
)
})}
</div>
<VocabPopup />
</div>
)
}