| | import React, { useState, useEffect } from 'react'; |
| | import { |
| | MagnifyingGlassIcon, |
| | GlobeAltIcon, |
| | AcademicCapIcon, |
| | FunnelIcon, |
| | PencilIcon, |
| | CheckCircleIcon, |
| | XCircleIcon, |
| | ClockIcon |
| | } from '@heroicons/react/24/outline'; |
| | import { api } from '../services/api'; |
| |
|
| | interface SearchFilters { |
| | sourceLanguage: string; |
| | } |
| |
|
| | interface UserSubmission { |
| | _id: string; |
| | transcreation: string; |
| | explanation: string; |
| | status: 'pending' | 'approved' | 'rejected' | 'submitted'; |
| | createdAt: string; |
| | score?: number; |
| | voteCounts?: { |
| | 1: number; |
| | 2: number; |
| | 3: number; |
| | }; |
| | } |
| |
|
| | const SearchTexts: React.FC = () => { |
| | const [filters, setFilters] = useState<SearchFilters>({ |
| | sourceLanguage: '' |
| | }); |
| | const [isSearching, setIsSearching] = useState(false); |
| | const [searchResults, setSearchResults] = useState<any[]>([]); |
| | const [error, setError] = useState(''); |
| | const [translations, setTranslations] = useState<{[key: string]: string}>({}); |
| | const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({}); |
| | const [loadingSubmissions, setLoadingSubmissions] = useState(false); |
| |
|
| | |
| | useEffect(() => { |
| | if (searchResults.length > 0) { |
| | fetchUserSubmissions(); |
| | } |
| | }, [searchResults]); |
| |
|
| | const fetchUserSubmissions = async () => { |
| | setLoadingSubmissions(true); |
| | try { |
| | const token = localStorage.getItem('token'); |
| | if (!token) return; |
| |
|
| | const response = await fetch('/api/submissions/my-submissions', { |
| | headers: { |
| | 'Authorization': `Bearer ${token}`, |
| | 'Content-Type': 'application/json' |
| | } |
| | }); |
| |
|
| | if (response.ok) { |
| | const data = await response.json(); |
| | const submissionsByExample: {[key: string]: UserSubmission[]} = {}; |
| | |
| | data.submissions.forEach((submission: UserSubmission & { sourceTextId: any }) => { |
| | const exampleId = submission.sourceTextId?._id || submission.sourceTextId; |
| | if (exampleId) { |
| | if (!submissionsByExample[exampleId]) { |
| | submissionsByExample[exampleId] = []; |
| | } |
| | submissionsByExample[exampleId].push(submission); |
| | } |
| | }); |
| | |
| | setUserSubmissions(submissionsByExample); |
| | } |
| | } catch (error) { |
| | console.error('Error fetching submissions:', error); |
| | } finally { |
| | setLoadingSubmissions(false); |
| | } |
| | }; |
| |
|
| | const handleSearch = async () => { |
| | setIsSearching(true); |
| | setError(''); |
| | |
| | try { |
| | const response = await api.post('/search/auto-search', filters); |
| | setSearchResults(response.data.results || []); |
| | } catch (error: any) { |
| | console.error('Search error:', error); |
| | if (error.response?.data?.error) { |
| | setError(error.response.data.error); |
| | } else { |
| | setError('Search failed. Please try again.'); |
| | } |
| | } finally { |
| | setIsSearching(false); |
| | } |
| | }; |
| |
|
| | const handleFilterChange = (field: keyof SearchFilters, value: string) => { |
| | setFilters(prev => ({ |
| | ...prev, |
| | [field]: value |
| | })); |
| | |
| | if (error) setError(''); |
| | }; |
| |
|
| | const handleTranslationChange = (id: string, value: string) => { |
| | setTranslations(prev => ({ |
| | ...prev, |
| | [id]: value |
| | })); |
| | }; |
| |
|
| | const handleTranscreate = async (id: string) => { |
| | const translation = translations[id]; |
| | if (!translation || !translation.trim()) { |
| | alert('Please enter a translation before submitting.'); |
| | return; |
| | } |
| |
|
| | try { |
| | const token = localStorage.getItem('token'); |
| | if (!token) { |
| | alert('Please log in to submit translations.'); |
| | return; |
| | } |
| |
|
| | const result = searchResults.find(r => r.id === id); |
| | if (!result) { |
| | alert('Example not found.'); |
| | return; |
| | } |
| |
|
| | |
| | const response = await fetch('/api/submissions', { |
| | method: 'POST', |
| | headers: { |
| | 'Authorization': `Bearer ${token}`, |
| | 'Content-Type': 'application/json' |
| | }, |
| | body: JSON.stringify({ |
| | sourceTextId: id, |
| | targetCulture: result.sourceCulture, |
| | targetLanguage: result.sourceLanguage === 'English' ? 'Chinese' : 'English', |
| | transcreation: translation.trim(), |
| | explanation: 'Practice translation submission', |
| | isAnonymous: true |
| | }) |
| | }); |
| |
|
| | if (!response.ok) { |
| | throw new Error(`HTTP error! status: ${response.status}`); |
| | } |
| |
|
| | const data = await response.json(); |
| | |
| | |
| | setTranslations(prev => ({ |
| | ...prev, |
| | [id]: '' |
| | })); |
| |
|
| | |
| | await fetchUserSubmissions(); |
| |
|
| | alert('Translation submitted successfully! It will be available for voting.'); |
| | } catch (error) { |
| | console.error('Error submitting translation:', error); |
| | alert('Failed to submit translation. Please try again.'); |
| | } |
| | }; |
| |
|
| | return ( |
| | <div className="px-4 sm:px-6 lg:px-8"> |
| | <div className="mb-8"> |
| | <h1 className="text-2xl font-bold text-gray-900">Practice</h1> |
| | <p className="mt-2 text-gray-600"> |
| | In-class practice examples for puns and wordplay in English and Chinese |
| | </p> |
| | </div> |
| | |
| | {/* Search Filters */} |
| | <div className="bg-white rounded-lg shadow p-6 mb-8"> |
| | <div className="flex items-center mb-4"> |
| | <FunnelIcon className="h-5 w-5 text-indigo-400 mr-2" /> |
| | <h2 className="text-lg font-medium text-gray-900">Language Filter</h2> |
| | </div> |
| | |
| | <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> |
| | <div> |
| | <label htmlFor="sourceLanguage" className="block text-sm font-medium text-gray-700 mb-2"> |
| | Language |
| | </label> |
| | <select |
| | id="sourceLanguage" |
| | value={filters.sourceLanguage} |
| | onChange={(e) => handleFilterChange('sourceLanguage', e.target.value)} |
| | className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" |
| | > |
| | <option value="">All Languages</option> |
| | <option value="English">English</option> |
| | <option value="Chinese">Chinese</option> |
| | </select> |
| | </div> |
| | </div> |
| | |
| | <div className="mt-6"> |
| | <button |
| | onClick={handleSearch} |
| | disabled={isSearching} |
| | className="w-full md:w-auto bg-indigo-600 text-white px-6 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center" |
| | > |
| | {isSearching ? ( |
| | <> |
| | <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> |
| | Loading... |
| | </> |
| | ) : ( |
| | <> |
| | <MagnifyingGlassIcon className="h-4 w-4 mr-2" /> |
| | Show Examples |
| | </> |
| | )} |
| | </button> |
| | </div> |
| | </div> |
| |
|
| | {} |
| | {error && ( |
| | <div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6"> |
| | <div className="flex"> |
| | <div className="flex-shrink-0"> |
| | <svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> |
| | <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" /> |
| | </svg> |
| | </div> |
| | <div className="ml-3"> |
| | <h3 className="text-sm font-medium text-red-800">Search Error</h3> |
| | <div className="mt-2 text-sm text-red-700">{error}</div> |
| | </div> |
| | </div> |
| | </div> |
| | )} |
| |
|
| | {} |
| | {searchResults.length > 0 && ( |
| | <div className="bg-white rounded-lg shadow"> |
| | <div className="px-6 py-4 border-b border-gray-200"> |
| | <div className="flex items-center justify-between"> |
| | <h3 className="text-lg font-medium text-gray-900"> |
| | Practice Examples ({searchResults.length}) |
| | </h3> |
| | </div> |
| | </div> |
| | |
| | <div className="divide-y divide-gray-200"> |
| | {searchResults.map((result) => ( |
| | <div key={result.id} className="p-6"> |
| | <div className="flex-1"> |
| | <div className="text-gray-700 mb-4 whitespace-pre-wrap text-lg leading-relaxed"> |
| | {result.content} |
| | </div> |
| | |
| | {/* Translation Input - Only show if user hasn't submitted yet */} |
| | {loadingSubmissions ? ( |
| | <div className="mb-4 p-4 bg-gray-50 rounded-md"> |
| | <div className="flex items-center"> |
| | <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 mr-2"></div> |
| | <span className="text-sm text-gray-600">Loading your submissions...</span> |
| | </div> |
| | </div> |
| | ) : (!userSubmissions[result.id] || userSubmissions[result.id].length === 0) ? ( |
| | <div className="mb-4"> |
| | <label htmlFor={`translation-${result.id}`} className="block text-sm font-medium text-gray-700 mb-2"> |
| | Your Translation: |
| | </label> |
| | <textarea |
| | id={`translation-${result.id}`} |
| | value={translations[result.id] || ''} |
| | onChange={(e) => handleTranslationChange(result.id, e.target.value)} |
| | placeholder="Enter your translation here..." |
| | className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" |
| | rows={3} |
| | /> |
| | </div> |
| | ) : ( |
| | <div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-md"> |
| | <div className="flex items-center"> |
| | <CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" /> |
| | <span className="text-sm font-medium text-green-800">Translation Submitted</span> |
| | </div> |
| | <p className="text-sm text-green-600 mt-1"> |
| | You have already submitted a translation for this example. |
| | </p> |
| | </div> |
| | )} |
| | |
| | {/* Transcreate Button - Only show if user hasn't submitted yet */} |
| | {!loadingSubmissions && (!userSubmissions[result.id] || userSubmissions[result.id].length === 0) && ( |
| | <button |
| | onClick={() => handleTranscreate(result.id)} |
| | className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 flex items-center" |
| | > |
| | <PencilIcon className="h-4 w-4 mr-2" /> |
| | Transcreate It |
| | </button> |
| | )} |
| | |
| | {/* User's Previous Translations */} |
| | {!loadingSubmissions && userSubmissions[result.id] && userSubmissions[result.id].length > 0 && ( |
| | <div className="mt-6 border-t border-gray-200 pt-4"> |
| | <h4 className="text-sm font-medium text-gray-900 mb-3">Your Translation:</h4> |
| | <div className="space-y-3"> |
| | {userSubmissions[result.id].map((submission) => ( |
| | <div key={submission._id} className="bg-indigo-50 rounded-lg p-4 border border-indigo-200"> |
| | <div className="flex items-start justify-between"> |
| | <div className="flex-1"> |
| | <p className="text-gray-900 mb-2 font-medium">{submission.transcreation}</p> |
| | <div className="flex items-center space-x-4 text-sm text-gray-500"> |
| | <span className="flex items-center"> |
| | {submission.status === 'approved' && ( |
| | <CheckCircleIcon className="h-4 w-4 text-green-500 mr-1" /> |
| | )} |
| | {submission.status === 'rejected' && ( |
| | <XCircleIcon className="h-4 w-4 text-red-500 mr-1" /> |
| | )} |
| | {submission.status === 'pending' && ( |
| | <ClockIcon className="h-4 w-4 text-yellow-500 mr-1" /> |
| | )} |
| | {submission.status === 'submitted' && ( |
| | <ClockIcon className="h-4 w-4 text-blue-500 mr-1" /> |
| | )} |
| | {submission.status.charAt(0).toUpperCase() + submission.status.slice(1)} |
| | </span> |
| | |
| | {submission.voteCounts && ( |
| | <span className="bg-gray-100 text-gray-800 px-2 py-1 rounded text-xs font-medium"> |
| | Votes: {submission.voteCounts[1] || 0} 1st, {submission.voteCounts[2] || 0} 2nd, {submission.voteCounts[3] || 0} 3rd |
| | </span> |
| | )} |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| | ))} |
| | </div> |
| | </div> |
| | )} |
| | </div> |
| | </div> |
| | ))} |
| | </div> |
| | </div> |
| | )} |
| |
|
| | {} |
| | {searchResults.length === 0 && !isSearching && !error && ( |
| | <div className="bg-white rounded-lg shadow p-8 text-center"> |
| | <AcademicCapIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" /> |
| | <h3 className="text-lg font-medium text-gray-900 mb-2">No examples found</h3> |
| | <p className="text-gray-600 mb-4"> |
| | Try adjusting your language filter or click "Show Examples" to see all available practice examples. |
| | </p> |
| | <button |
| | onClick={handleSearch} |
| | className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" |
| | > |
| | Show All Examples |
| | </button> |
| | </div> |
| | )} |
| | </div> |
| | ); |
| | }; |
| |
|
| | export default SearchTexts; |