diff --git a/frontend/app/flashcards/[id]/page.tsx b/frontend/app/flashcards/[id]/page.tsx index 9436151..f7de256 100644 --- a/frontend/app/flashcards/[id]/page.tsx +++ b/frontend/app/flashcards/[id]/page.tsx @@ -15,8 +15,8 @@ import { FlashcardContentBlocks } from '@/components/flashcards/FlashcardContent import { FlashcardInfoBlock } from '@/components/flashcards/FlashcardInfoBlock' import { FlashcardActions } from '@/components/flashcards/FlashcardActions' import { EditingControls } from '@/components/flashcards/EditingControls' -import { LoadingState } from '@/components/flashcards/LoadingState' -import { ErrorState } from '@/components/flashcards/ErrorState' +import { LoadingState } from '@/components/shared/LoadingState' +import { ErrorState } from '@/components/shared/ErrorState' interface FlashcardDetailPageProps { params: Promise<{ diff --git a/frontend/app/flashcards/page.tsx b/frontend/app/flashcards/page.tsx index 76eb7a3..b2644bb 100644 --- a/frontend/app/flashcards/page.tsx +++ b/frontend/app/flashcards/page.tsx @@ -5,6 +5,9 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { ProtectedRoute } from '@/components/shared/ProtectedRoute' import { Navigation } from '@/components/shared/Navigation' +import { LoadingState } from '@/components/shared/LoadingState' +import { ErrorState } from '@/components/shared/ErrorState' +import { TabNavigation } from '@/components/shared/TabNavigation' import { FlashcardForm } from '@/components/flashcards/FlashcardForm' import { useToast } from '@/components/shared/Toast' import { flashcardsService, type Flashcard } from '@/lib/services/flashcards' @@ -115,19 +118,11 @@ function FlashcardsContent({ showForm, setShowForm }: { showForm: boolean; setSh // 載入狀態處理 if (searchState.loading && searchState.isInitialLoad) { - return ( -
-
載入中...
-
- ) + return } if (searchState.error) { - return ( -
-
{searchState.error}
-
- ) + return } return ( @@ -159,32 +154,25 @@ function FlashcardsContent({ showForm, setShowForm }: { showForm: boolean; setSh {/* Tabs */} -
- - -
+ setActiveTab(key as 'all-cards' | 'favorites')} + className="mb-6" + /> {/* Search Controls */} - 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 +import { useState, useEffect, useMemo } from 'react' +import { WordPopup } from '@/components/word/WordPopup' +import { useWordAnalysis } from '@/components/word/hooks/useWordAnalysis' +import type { ClickableTextProps, WordAnalysis } from '@/components/word/types' export function ClickableTextV2({ text, analysis, onWordClick, onSaveWord, - remainingUsage = 5, + remainingUsage, showIdiomsInline = true }: ClickableTextProps) { const [selectedWord, setSelectedWord] = useState(null) - const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0, showBelow: false }) const [isSavingWord, setIsSavingWord] = useState(false) const [mounted, setMounted] = useState(false) + const { findWordAnalysis, getWordClass, shouldShowStar } = useWordAnalysis() 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 handleWordClick = (word: string) => { + const wordAnalysis = findWordAnalysis(word, analysis) + if (wordAnalysis) { + setSelectedWord(word) + onWordClick?.(word, wordAnalysis) } - }, []) - - 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 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 cefr = getWordProperty(wordAnalysis, 'cefr') || 'A1' - const userLevel = typeof window !== 'undefined' ? localStorage.getItem('userEnglishLevel') || 'A2' : 'A2' - - const userIndex = getLevelIndex(userLevel) - const wordIndex = getLevelIndex(cefr) - - 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]) - - 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(() => { + const closePopup = () => { setSelectedWord(null) - }, []) + } - const handleSaveWord = useCallback(async () => { - if (!selectedWord || !analysis?.[selectedWord] || !onSaveWord) return + const handleSaveWord = async (word: string, wordAnalysis: WordAnalysis) => { + if (!onSaveWord) return { success: false } setIsSavingWord(true) try { - const result = await onSaveWord(selectedWord, analysis[selectedWord]) - if (result?.success) { - setSelectedWord(null) - } else { - console.error('Save word error:', result?.error || '保存失敗') + const result = await onSaveWord(word, wordAnalysis) + if (result.success) { + closePopup() } - } catch (error) { - console.error('Save word error:', error) + return result } finally { setIsSavingWord(false) } - }, [selectedWord, analysis, onSaveWord]) - - const VocabPopup = () => { - if (!selectedWord || !analysis?.[selectedWord] || !mounted) return null - - return createPortal( - <> -
- -
-
-
- -
- -
-

{getWordProperty(analysis[selectedWord], 'word')}

-
- -
-
- - {getWordProperty(analysis[selectedWord], 'partOfSpeech')} - -
- {getWordProperty(analysis[selectedWord], 'pronunciation')} - -
-
- - - {getWordProperty(analysis[selectedWord], 'cefr')} - -
-
- -
-
-

中文翻譯

-

{getWordProperty(analysis[selectedWord], 'translation')}

-
- -
-

英文定義

-

{getWordProperty(analysis[selectedWord], 'definition')}

-
- - {(() => { - const example = getWordProperty(analysis[selectedWord], 'example'); - return example && example !== 'null' && example !== 'undefined'; - })() && ( -
-

例句

-
-

- "{getWordProperty(analysis[selectedWord], 'example')}" -

-

- {getWordProperty(analysis[selectedWord], 'exampleTranslation')} -

-
-
- )} - - {(() => { - const synonyms = getWordProperty(analysis[selectedWord], 'synonyms'); - return synonyms && Array.isArray(synonyms) && synonyms.length > 0; - })() && ( -
-

同義詞

-
- {getWordProperty(analysis[selectedWord], 'synonyms')?.map((synonym: string, index: number) => ( - - {synonym} - - ))} -
-
- )} -
- - {onSaveWord && ( -
- -
- )} -
- , - document.body - ) } + const words = useMemo(() => { + const tokens = text.split(/(\s+)/) + return tokens.map((token, index) => { + const cleanToken = token.replace(/[^\w']/g, '') + if (!cleanToken || /^\s+$/.test(token)) { + return ( + + {token} + + ) + } + + const wordAnalysis = findWordAnalysis(cleanToken, analysis) + if (!wordAnalysis) { + return ( + + {token} + + ) + } + + return ( + + handleWordClick(cleanToken)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + handleWordClick(cleanToken) + } + }} + > + {token} + + {shouldShowStar(wordAnalysis) && ( + + ⭐ + + )} + + ) + }) + }, [text, analysis, findWordAnalysis, getWordClass, shouldShowStar]) + return (
-
- {words.map((word, index) => { - if (word.trim() === '' || /^[.,!?;:\s]+$/.test(word)) { - return {word} - } - - const className = getWordClass(word) - const icon = getWordIcon(word) - const showStar = shouldShowStar(word) - - return ( - handleWordClick(word, e)} - > - {word} - {icon} - {showStar && ( - - ⭐ - - )} - - ) - })} +
+ {words}
- +
) } \ No newline at end of file diff --git a/frontend/components/generate/VocabularyStatsGrid.tsx b/frontend/components/generate/VocabularyStatsGrid.tsx new file mode 100644 index 0000000..9bb6457 --- /dev/null +++ b/frontend/components/generate/VocabularyStatsGrid.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { StatisticsCard } from '@/components/shared/StatisticsCard' + +interface VocabularyStats { + simpleCount: number + moderateCount: number + difficultCount: number + idiomCount: number +} + +interface VocabularyStatsGridProps { + stats: VocabularyStats + className?: string +} + +export const VocabularyStatsGrid: React.FC = ({ + stats, + className = '' +}) => { + return ( +
+ + + + +
+ ) +} \ No newline at end of file diff --git a/frontend/components/shared/ContentBlock.tsx b/frontend/components/shared/ContentBlock.tsx new file mode 100644 index 0000000..d958d9a --- /dev/null +++ b/frontend/components/shared/ContentBlock.tsx @@ -0,0 +1,75 @@ +import React from 'react' + +type ContentBlockVariant = 'green' | 'gray' | 'blue' | 'purple' | 'yellow' | 'red' | 'orange' + +interface ContentBlockProps { + title: string + variant: ContentBlockVariant + children: React.ReactNode + className?: string + titleActions?: React.ReactNode +} + +const variantStyles: Record = { + green: { + bg: 'bg-green-50', + border: 'border-green-200', + title: 'text-green-900' + }, + gray: { + bg: 'bg-gray-50', + border: 'border-gray-200', + title: 'text-gray-900' + }, + blue: { + bg: 'bg-blue-50', + border: 'border-blue-200', + title: 'text-blue-900' + }, + purple: { + bg: 'bg-purple-50', + border: 'border-purple-200', + title: 'text-purple-900' + }, + yellow: { + bg: 'bg-yellow-50', + border: 'border-yellow-200', + title: 'text-yellow-900' + }, + red: { + bg: 'bg-red-50', + border: 'border-red-200', + title: 'text-red-900' + }, + orange: { + bg: 'bg-orange-50', + border: 'border-orange-200', + title: 'text-orange-900' + } +} + +export const ContentBlock: React.FC = ({ + title, + variant, + children, + className = '', + titleActions +}) => { + const styles = variantStyles[variant] + + return ( +
+
+

+ {title} +

+ {titleActions && ( +
+ {titleActions} +
+ )} +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/frontend/components/flashcards/ErrorState.tsx b/frontend/components/shared/ErrorState.tsx similarity index 100% rename from frontend/components/flashcards/ErrorState.tsx rename to frontend/components/shared/ErrorState.tsx diff --git a/frontend/components/flashcards/LoadingState.tsx b/frontend/components/shared/LoadingState.tsx similarity index 100% rename from frontend/components/flashcards/LoadingState.tsx rename to frontend/components/shared/LoadingState.tsx diff --git a/frontend/components/shared/StatisticsCard.tsx b/frontend/components/shared/StatisticsCard.tsx new file mode 100644 index 0000000..bc4c31a --- /dev/null +++ b/frontend/components/shared/StatisticsCard.tsx @@ -0,0 +1,44 @@ +import React from 'react' + +type StatisticsVariant = 'gray' | 'green' | 'orange' | 'blue' | 'purple' | 'red' + +interface StatisticsCardProps { + count: number + label: string + variant: StatisticsVariant + className?: string + icon?: React.ReactNode +} + +const variantStyles: Record = { + gray: 'bg-gray-50 border-gray-200 text-gray-700', + green: 'bg-green-50 border-green-200 text-green-700', + orange: 'bg-orange-50 border-orange-200 text-orange-700', + blue: 'bg-blue-50 border-blue-200 text-blue-700', + purple: 'bg-purple-50 border-purple-200 text-purple-700', + red: 'bg-red-50 border-red-200 text-red-700' +} + +export const StatisticsCard: React.FC = ({ + count, + label, + variant, + className = '', + icon +}) => { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +
+ {count} +
+
+ {label} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/components/shared/TabNavigation.tsx b/frontend/components/shared/TabNavigation.tsx new file mode 100644 index 0000000..acb7a47 --- /dev/null +++ b/frontend/components/shared/TabNavigation.tsx @@ -0,0 +1,44 @@ +import React from 'react' + +interface TabItem { + key: string + label: string + count?: number + icon?: string +} + +interface TabNavigationProps { + items: TabItem[] + activeTab: string + onTabChange: (key: string) => void + className?: string +} + +export const TabNavigation: React.FC = ({ + items, + activeTab, + onTabChange, + className = '' +}) => { + return ( +
+ {items.map((item) => ( + + ))} +
+ ) +} \ No newline at end of file diff --git a/frontend/components/shared/ValidatedTextInput.tsx b/frontend/components/shared/ValidatedTextInput.tsx new file mode 100644 index 0000000..102c467 --- /dev/null +++ b/frontend/components/shared/ValidatedTextInput.tsx @@ -0,0 +1,69 @@ +import React from 'react' + +interface ValidatedTextInputProps { + value: string + onChange: (value: string) => void + maxLength: number + placeholder: string + rows?: number + className?: string + showCounter?: boolean + warningThreshold?: number + errorThreshold?: number +} + +export const ValidatedTextInput: React.FC = ({ + value, + onChange, + maxLength, + placeholder, + rows = 4, + className = '', + showCounter = true, + warningThreshold = 0.8, + errorThreshold = 0.95 +}) => { + const currentLength = value.length + const isNearLimit = currentLength >= maxLength * warningThreshold + const isAtLimit = currentLength >= maxLength * errorThreshold + + const getBorderColor = () => { + if (isAtLimit) return 'border-red-300 focus:ring-red-500' + if (isNearLimit) return 'border-yellow-300 focus:ring-yellow-500' + return 'border-gray-300 focus:ring-blue-500' + } + + const getCounterColor = () => { + if (isAtLimit) return 'text-red-600' + if (isNearLimit) return 'text-yellow-600' + return 'text-gray-500' + } + + return ( +
+