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

375 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from 'react'
// 更新的詞彙分析介面
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>
highValueWords?: string[] // 高價值詞彙列表
phrasesDetected?: Array<{
phrase: string
words: string[]
colorCode: string
}>
onWordClick?: (word: string, analysis: WordAnalysis) => void
onWordCostConfirm?: (word: string, cost: number) => Promise<boolean> // 收費確認
remainingUsage?: number // 剩餘使用次數
}
export function ClickableTextV2({
text,
analysis,
highValueWords = [],
phrasesDetected = [],
onWordClick,
onWordCostConfirm,
remainingUsage = 5
}: ClickableTextProps) {
const [selectedWord, setSelectedWord] = useState<string | null>(null)
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 })
const [showCostConfirm, setShowCostConfirm] = useState<{
word: string
cost: number
position: { x: number, y: number }
} | null>(null)
// 將文字分割成單字
const words = text.split(/(\s+|[.,!?;:])/g).filter(word => word.trim())
const handleWordClick = async (word: string, event: React.MouseEvent) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
if (wordAnalysis) {
const rect = event.currentTarget.getBoundingClientRect()
const position = {
x: rect.left + rect.width / 2,
y: rect.top - 10
}
// 檢查是否為高價值詞彙(免費)
if (wordAnalysis.isHighValue) {
setPopupPosition(position)
setSelectedWord(cleanWord)
onWordClick?.(cleanWord, wordAnalysis)
} else {
// 低價值詞彙需要收費確認
setShowCostConfirm({
word: cleanWord,
cost: 1,
position
})
}
}
}
const handleCostConfirm = async () => {
if (!showCostConfirm) return
const confirmed = await onWordCostConfirm?.(showCostConfirm.word, showCostConfirm.cost)
if (confirmed) {
const wordAnalysis = analysis?.[showCostConfirm.word]
if (wordAnalysis) {
setPopupPosition(showCostConfirm.position)
setSelectedWord(showCostConfirm.word)
onWordClick?.(showCostConfirm.word, wordAnalysis)
}
}
setShowCostConfirm(null)
}
const closePopup = () => {
setSelectedWord(null)
}
const getWordClass = (word: string) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
if (!wordAnalysis) return "cursor-default"
const baseClass = "cursor-pointer transition-all duration-200 rounded relative mx-0.5 px-1 py-0.5"
// 高價值片語(黃色系)
if (wordAnalysis.isHighValue && wordAnalysis.isPhrase) {
return `${baseClass} bg-yellow-100 border-2 border-yellow-400 hover:bg-yellow-200 hover:shadow-sm transform hover:-translate-y-0.5`
}
// 高價值單字(綠色系)
if (wordAnalysis.isHighValue && !wordAnalysis.isPhrase) {
return `${baseClass} bg-green-100 border-2 border-green-400 hover:bg-green-200 hover:shadow-sm transform hover:-translate-y-0.5`
}
// 普通單字(藍色系)
return `${baseClass} border-b border-blue-300 hover:bg-blue-100 hover:border-blue-400`
}
const renderWordWithStar = (word: string, className: string) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
const isHighValue = wordAnalysis?.isHighValue
return (
<span
className={`${className} ${isHighValue ? 'relative' : ''}`}
onClick={(e) => handleWordClick(word, e)}
>
{word}
{isHighValue && (
<span className="absolute -top-1 -right-1 text-xs"></span>
)}
</span>
)
}
return (
<div className="relative">
{/* 點擊區域遮罩 */}
{selectedWord && (
<div
className="fixed inset-0 z-10"
onClick={closePopup}
/>
)}
{/* 文字內容 */}
<div className="text-lg leading-relaxed">
{words.map((word, index) => {
if (word.trim() === '') return <span key={index}>{word}</span>
return renderWordWithStar(word, getWordClass(word))
})}
</div>
{/* 單字資訊彈窗 */}
{selectedWord && analysis?.[selectedWord] && (
<div
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-80 max-w-sm"
style={{
left: `${popupPosition.x}px`,
top: `${popupPosition.y}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div className="space-y-3">
{/* 標題 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-900">
{analysis[selectedWord].word}
</h3>
<button
onClick={closePopup}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
{/* 高價值標記 */}
{analysis[selectedWord].isHighValue && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<div className="text-green-600 text-lg"></div>
<div className="text-sm font-medium text-green-800">
</div>
<div className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
{analysis[selectedWord].learningPriority === 'high' ? '⭐⭐⭐⭐⭐' :
analysis[selectedWord].learningPriority === 'medium' ? '⭐⭐⭐' : '⭐'}
</div>
</div>
</div>
)}
{/* 片語警告 */}
{analysis[selectedWord].isPhrase && analysis[selectedWord].phraseInfo && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="text-yellow-600 text-lg"></div>
<div>
<div className="text-sm font-medium text-yellow-800">
</div>
<div className="text-sm text-yellow-700 mt-1">
<strong></strong>{analysis[selectedWord].phraseInfo.phrase}
</div>
<div className="text-sm text-yellow-700">
<strong></strong>{analysis[selectedWord].phraseInfo.meaning}
</div>
<div className="text-xs text-yellow-600 mt-2 italic">
{analysis[selectedWord].phraseInfo.warning}
</div>
</div>
</div>
</div>
)}
{/* 詞性和發音 */}
<div className="flex items-center gap-4">
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
{analysis[selectedWord].partOfSpeech}
</span>
<span className="text-sm text-gray-600">
{analysis[selectedWord].pronunciation}
</span>
<button className="text-blue-600 hover:text-blue-800">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
</button>
</div>
{/* 翻譯 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-base text-gray-900">{analysis[selectedWord].translation}</div>
</div>
{/* 定義 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-sm text-gray-600">{analysis[selectedWord].definition}</div>
</div>
{/* 同義詞 */}
{analysis[selectedWord].synonyms.length > 0 && (
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="flex flex-wrap gap-1 mt-1">
{analysis[selectedWord].synonyms.map((synonym, idx) => (
<span
key={idx}
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full"
>
{synonym}
</span>
))}
</div>
</div>
)}
{/* 反義詞 */}
{analysis[selectedWord].antonyms && analysis[selectedWord].antonyms.length > 0 && (
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="flex flex-wrap gap-1 mt-1">
{analysis[selectedWord].antonyms.map((antonym, idx) => (
<span
key={idx}
className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded-full"
>
{antonym}
</span>
))}
</div>
</div>
)}
{/* 難度等級 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="inline-flex items-center gap-1 mt-1">
<span className={`text-xs px-2 py-1 rounded-full ${
analysis[selectedWord].difficultyLevel === 'A1' || analysis[selectedWord].difficultyLevel === 'A2' ? 'bg-green-100 text-green-700' :
analysis[selectedWord].difficultyLevel === 'B1' || analysis[selectedWord].difficultyLevel === 'B2' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
CEFR {analysis[selectedWord].difficultyLevel}
</span>
<span className="text-xs text-gray-500">
({analysis[selectedWord].difficultyLevel === 'A1' || analysis[selectedWord].difficultyLevel === 'A2' ? '基礎' :
analysis[selectedWord].difficultyLevel === 'B1' || analysis[selectedWord].difficultyLevel === 'B2' ? '中級' : '高級'})
</span>
</div>
</div>
</div>
</div>
)}
{/* 收費確認對話框 */}
{showCostConfirm && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowCostConfirm(null)} />
<div
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-72"
style={{
left: `${showCostConfirm.position.x}px`,
top: `${showCostConfirm.position.y}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-900">
{showCostConfirm.word}
</h3>
<button
onClick={() => setShowCostConfirm(null)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="text-orange-600 text-lg">💰</div>
<div>
<div className="text-sm font-medium text-orange-800">
</div>
<div className="text-sm text-orange-700 mt-1">
<strong>{showCostConfirm.cost} </strong> 使
</div>
<div className="text-sm text-orange-600 mt-1">
<strong>{remainingUsage}</strong>
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleCostConfirm}
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
</button>
<button
onClick={() => setShowCostConfirm(null)}
className="flex-1 bg-gray-200 text-gray-700 py-2 px-4 rounded-lg text-sm font-medium hover:bg-gray-300 transition-colors"
>
</button>
</div>
</div>
</div>
</>
)}
</div>
)
}