431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
'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
|
||
|
||
const compareCEFRLevels = (level1: string, level2: string, operator: '>' | '<' | '==='): boolean => {
|
||
const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
|
||
const index1 = levels.indexOf(level1)
|
||
const index2 = levels.indexOf(level2)
|
||
|
||
if (index1 === -1 || index2 === -1) return false
|
||
|
||
switch (operator) {
|
||
case '>': return index1 > index2
|
||
case '<': return index1 < index2
|
||
case '===': return index1 === index2
|
||
default: return false
|
||
}
|
||
}
|
||
|
||
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')
|
||
const userLevel = typeof window !== 'undefined' ? localStorage.getItem('userEnglishLevel') || 'A2' : 'A2'
|
||
|
||
// 只有當詞彙為常用且不是簡單詞彙時才顯示星星
|
||
// 簡單詞彙定義:學習者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])
|
||
|
||
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>
|
||
)
|
||
} |