375 lines
14 KiB
TypeScript
375 lines
14 KiB
TypeScript
'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>
|
||
)
|
||
} |