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

415 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Play } from 'lucide-react'
interface WordAnalysis {
word: string
translation: string
definition: string
partOfSpeech: string
pronunciation: string
synonyms: string[]
antonyms?: string[]
isIdiom: boolean
isHighValue?: boolean
learningPriority?: 'high' | 'medium' | 'low'
idiomInfo?: {
idiom: string
meaning: string
warning: string
colorCode: string
}
difficultyLevel: string
frequency?: string // 新增頻率屬性:'high' | 'medium' | 'low'
costIncurred?: number
example?: string
exampleTranslation?: string
}
interface ClickableTextProps {
text: string
analysis?: Record<string, WordAnalysis>
onWordClick?: (word: string, analysis: WordAnalysis) => void
onSaveWord?: (word: string, analysis: WordAnalysis) => Promise<{ success: boolean; error?: string }>
remainingUsage?: number
showIdiomsInline?: boolean
}
const POPUP_CONFIG = {
WIDTH: 320,
HEIGHT: 400,
PADDING: 16,
MOBILE_BREAKPOINT: 640
} as const
export function ClickableTextV2({
text,
analysis,
onWordClick,
onSaveWord,
remainingUsage = 5,
showIdiomsInline = 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 = useCallback((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 = useCallback((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 = useCallback((word: string) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const capitalizedWord = word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
return analysis?.[word] ||
analysis?.[capitalizedWord] ||
analysis?.[cleanWord] ||
analysis?.[word.toLowerCase()] ||
analysis?.[word.toUpperCase()] ||
null
}, [analysis])
const getLevelIndex = useCallback((level: string): number => {
const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
return levels.indexOf(level)
}, [])
const getWordClass = useCallback((word: string) => {
const wordAnalysis = findWordAnalysis(word)
const baseClass = "cursor-pointer transition-all duration-200 rounded relative mx-0.5 px-1 py-0.5"
if (!wordAnalysis) return ""
const isIdiom = getWordProperty(wordAnalysis, 'isIdiom')
if (isIdiom) return ""
const difficultyLevel = getWordProperty(wordAnalysis, 'difficultyLevel') || 'A1'
const userLevel = typeof window !== 'undefined' ? localStorage.getItem('userEnglishLevel') || 'A2' : 'A2'
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`
}
}, [findWordAnalysis, getWordProperty, getLevelIndex])
const getWordIcon = (word: string) => {
// 移除所有圖標,保持簡潔設計
return null
}
const shouldShowStar = useCallback((word: string) => {
try {
const wordAnalysis = findWordAnalysis(word)
if (!wordAnalysis) return false
const frequency = getWordProperty(wordAnalysis, 'frequency')
const wordCefr = getWordProperty(wordAnalysis, 'cefrLevel')
// 只有當詞彙為常用且不是簡單詞彙時才顯示星星
// 簡單詞彙定義學習者CEFR > 詞彙CEFR
const isHighFrequency = frequency === 'high'
const isNotSimpleWord = !compareCEFRLevels(userLevel, wordCefr, '>')
return isHighFrequency && isNotSimpleWord
} catch (error) {
console.warn('Error checking word frequency for star display:', error)
return false
}
}, [findWordAnalysis, getWordProperty, userLevel])
const words = useMemo(() => text.split(/(\s+|[.,!?;:])/g), [text])
const calculatePopupPosition = useCallback((rect: DOMRect) => {
const popupWidth = 320 // w-80 = 320px
const popupHeight = 400 // estimated popup height
const margin = 16
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = rect.left + rect.width / 2
let y = rect.bottom + 10
let showBelow = true
// Check if popup would go off right edge
if (x + popupWidth / 2 > viewportWidth - margin) {
x = viewportWidth - popupWidth / 2 - margin
}
// Check if popup would go off left edge
if (x - popupWidth / 2 < margin) {
x = popupWidth / 2 + margin
}
// Check if popup would go off bottom edge
if (y + popupHeight > viewportHeight - margin) {
y = rect.top - 10
showBelow = false
}
// Check if popup would go off top edge (when showing above)
if (!showBelow && y - popupHeight < margin) {
y = rect.bottom + 10
showBelow = true
}
return { x, y, showBelow }
}, [])
const handleWordClick = useCallback(async (word: string, event: React.MouseEvent) => {
const wordAnalysis = findWordAnalysis(word)
if (!wordAnalysis) return
// 找到實際在analysis中的key
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const capitalizedWord = word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
let actualKey = ''
if (analysis?.[word]) actualKey = word
else if (analysis?.[capitalizedWord]) actualKey = capitalizedWord
else if (analysis?.[cleanWord]) actualKey = cleanWord
else if (analysis?.[word.toLowerCase()]) actualKey = word.toLowerCase()
else if (analysis?.[word.toUpperCase()]) actualKey = word.toUpperCase()
const rect = event.currentTarget.getBoundingClientRect()
const position = calculatePopupPosition(rect)
setPopupPosition(position)
setSelectedWord(actualKey) // 使用實際的key
onWordClick?.(actualKey, wordAnalysis)
}, [findWordAnalysis, onWordClick, calculatePopupPosition, analysis])
const closePopup = useCallback(() => {
setSelectedWord(null)
}, [])
const handleSaveWord = useCallback(async () => {
if (!selectedWord || !analysis?.[selectedWord] || !onSaveWord) return
setIsSavingWord(true)
try {
const result = await onSaveWord(selectedWord, analysis[selectedWord])
if (result?.success) {
setSelectedWord(null)
} else {
console.error('Save word error:', result?.error || '保存失敗')
}
} catch (error) {
console.error('Save word error:', error)
} finally {
setIsSavingWord(false)
}
}, [selectedWord, analysis, onSaveWord])
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-80 sm:w-96 max-w-[90vw] sm:max-w-md overflow-hidden"
style={{
left: `${popupPosition.x}px`,
top: `${popupPosition.y}px`,
transform: popupPosition.showBelow ? 'translate(-50%, 8px)' : 'translate(-50%, -100%)',
maxHeight: '85vh',
overflowY: 'auto'
}}
>
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-4 sm: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-xl sm:text-2xl font-bold text-gray-900 break-words">{getWordProperty(analysis[selectedWord], 'word')}</h3>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm bg-gray-100 text-gray-700 px-2 sm:px-3 py-1 rounded-full w-fit">
{getWordProperty(analysis[selectedWord], 'partOfSpeech')}
</span>
<div className="flex items-center gap-2">
<span className="text-sm sm:text-base text-gray-600 break-all">{getWordProperty(analysis[selectedWord], 'pronunciation')}</span>
<button
onClick={() => {
const word = getWordProperty(analysis[selectedWord], 'word') || selectedWord;
const utterance = new SpeechSynthesisUtterance(word);
utterance.lang = 'en-US';
utterance.rate = 0.8;
speechSynthesis.speak(utterance);
}}
className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 hover:bg-blue-700 text-white transition-colors"
title="播放發音"
>
<Play size={16} />
</button>
</div>
</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-3 sm:p-4 space-y-3 sm: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>
)}
{(() => {
const synonyms = getWordProperty(analysis[selectedWord], 'synonyms');
return synonyms && Array.isArray(synonyms) && synonyms.length > 0;
})() && (
<div className="bg-purple-50 rounded-lg p-3 border border-purple-200">
<h4 className="font-semibold text-purple-900 mb-2 text-left text-sm"></h4>
<div className="flex flex-wrap gap-2">
{getWordProperty(analysis[selectedWord], 'synonyms')?.map((synonym: string, index: number) => (
<span
key={index}
className="bg-purple-100 text-purple-700 px-2 py-1 rounded-full text-xs font-medium"
>
{synonym}
</span>
))}
</div>
</div>
)}
</div>
{onSaveWord && (
<div className="p-3 sm: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" style={{lineHeight: '2.5'}}>
{words.map((word, index) => {
if (word.trim() === '' || /^[.,!?;:\s]+$/.test(word)) {
return <span key={index}>{word}</span>
}
const className = getWordClass(word)
const icon = getWordIcon(word)
const showStar = shouldShowStar(word)
return (
<span
key={index}
className={`${className} ${showStar ? 'relative' : ''}`}
onClick={(e) => handleWordClick(word, e)}
>
{word}
{icon}
{showStar && (
<span
className="absolute -top-1 -right-1 text-xs pointer-events-none z-10"
style={{ fontSize: '10px', lineHeight: 1 }}
>
</span>
)}
</span>
)
})}
</div>
<VocabPopup />
</div>
)
}