Spaces:
Sleeping
Sleeping
| import React, { useEffect, useMemo, useRef, useState } from 'react'; | |
| import SyntaxReorderer from '../components/SyntaxReorderer'; | |
| import { api } from '../services/api'; | |
| import { | |
| DocumentTextIcon, | |
| WrenchScrewdriverIcon, | |
| ArrowTopRightOnSquareIcon, | |
| ClipboardIcon, | |
| ArrowsRightLeftIcon | |
| } from '@heroicons/react/24/outline'; | |
| type ToolKey = 'quality-lens' | 'mymemory' | 'dictionary' | 'syntax-reorderer' | 'mt' | 'links'; | |
| interface MyMemoryResponse { | |
| responseData?: { | |
| translatedText?: string; | |
| match?: number; | |
| }; | |
| matches?: Array<{ | |
| translation: string; | |
| quality?: string | number; | |
| match?: number; | |
| segment?: string; | |
| reference?: string; | |
| 'created-by'?: string; | |
| }>; | |
| } | |
| const languages = [ | |
| { label: 'English', code: 'en' }, | |
| { label: 'Chinese', code: 'zh-CN' } | |
| ]; | |
| type DictEntry = { | |
| word: string; | |
| entries: Array<{ | |
| language: { code: string; name: string }; | |
| partOfSpeech?: string; | |
| pronunciations?: Array<{ type?: string; text?: string; tags?: string[] }>; | |
| senses?: Array<{ | |
| definition?: string; | |
| examples?: string[]; | |
| translations?: Array<{ language: { code: string; name: string }; word: string }>; | |
| }>; | |
| }>; | |
| source?: { url?: string; license?: { name?: string; url?: string } }; | |
| }; | |
| const TOOL_URLS: Record<ToolKey, string> = { | |
| 'quality-lens': 'https://linguabot-quality-lens.hf.space', | |
| 'mymemory': '', | |
| 'dictionary': '', | |
| 'syntax-reorderer': '', | |
| 'mt': '', | |
| 'links': '' | |
| }; | |
| // Provided by user for MyMemory API | |
| const MYMEMORY_KEY = '64031566ea7d91c9fe6b'; | |
| // Allowed by user (MT page hidden from visitors) | |
| const DEEPL_KEY = '9dcb19a8-2c97-42e3-96c0-76c066822750:fx'; | |
| const GOOGLE_KEY = 'AIzaSyBPyhVuTBBUAM-yvvfO8FmxzyJPpFPZDDU'; | |
| const Toolkit: React.FC = () => { | |
| const [links, setLinks] = useState<Array<{ _id?: string; title: string; url: string; desc?: string; order?: number }>>([]); | |
| const [linksLoading, setLinksLoading] = useState(false); | |
| const [linksError, setLinksError] = useState(''); | |
| const [isAdmin, setIsAdmin] = useState(false); | |
| const [editItem, setEditItem] = useState<{ _id?: string; title?: string; url?: string; desc?: string; order?: number } | null>(null); | |
| const [selectedTool, setSelectedTool] = useState<ToolKey>(() => { | |
| return (localStorage.getItem('selectedToolkitTool') as ToolKey) || 'quality-lens'; | |
| }); | |
| const [isToolTransitioning, setIsToolTransitioning] = useState(false); | |
| const [iframeLoading, setIframeLoading] = useState(true); | |
| const [isVisitor, setIsVisitor] = useState<boolean>(true); | |
| // MyMemory state | |
| const [mmSource, setMmSource] = useState<string>(() => localStorage.getItem('mm_source') || ''); | |
| const [mmFrom, setMmFrom] = useState<string>(() => localStorage.getItem('mm_from') || 'en'); | |
| const [mmTo, setMmTo] = useState<string>(() => localStorage.getItem('mm_to') || 'zh-CN'); | |
| const [mmLoading, setMmLoading] = useState(false); | |
| const [mmError, setMmError] = useState<string>(''); | |
| const [mmResults, setMmResults] = useState<Array<{ translation: string; score: number; source?: string }>>([]); | |
| // Dictionary state (EN ⇄ ZH) | |
| const [dictWord, setDictWord] = useState<string>(''); | |
| const [dictFrom, setDictFrom] = useState<'en' | 'zh'>('en'); | |
| const [dictTo, setDictTo] = useState<'en' | 'zh'>('zh'); | |
| const [dictLoading, setDictLoading] = useState<boolean>(false); | |
| const [dictError, setDictError] = useState<string>(''); | |
| const [dictResults, setDictResults] = useState<DictEntry | null>(null); | |
| const iframeRef = useRef<HTMLIFrameElement | null>(null); | |
| const [showWantWords, setShowWantWords] = useState<boolean>(false); | |
| // MT state | |
| const [mtSource, setMtSource] = useState<string>(''); | |
| const [mtFrom, setMtFrom] = useState<string>('en'); | |
| const [mtTo, setMtTo] = useState<string>('zh-CN'); | |
| const [mtProvider, setMtProvider] = useState<'deepl' | 'google'>('deepl'); | |
| const [mtLoading, setMtLoading] = useState(false); | |
| const [mtError, setMtError] = useState<string>(''); | |
| const [mtResult, setMtResult] = useState<string>(''); | |
| useEffect(() => { | |
| localStorage.setItem('selectedToolkitTool', selectedTool); | |
| }, [selectedTool]); | |
| useEffect(() => { | |
| try { | |
| const u = localStorage.getItem('user'); | |
| const role = u ? (JSON.parse(u)?.role || 'visitor') : 'visitor'; | |
| setIsVisitor(role === 'visitor'); | |
| setIsAdmin(role === 'admin'); | |
| } catch { | |
| setIsVisitor(true); | |
| setIsAdmin(false); | |
| } | |
| }, []); | |
| const tools = useMemo(() => { | |
| const base = [ | |
| { key: 'quality-lens' as ToolKey, name: 'Quality Lens', desc: 'BLASER/COMET QE + Hallucination', type: 'iframe' }, | |
| { key: 'mymemory' as ToolKey, name: 'MyMemory', desc: 'Public MT memory lookup', type: 'native' }, | |
| { key: 'dictionary' as ToolKey, name: 'Dictionary (EN⇄ZH)', desc: 'Iciba suggest', type: 'native' }, | |
| { key: 'syntax-reorderer' as ToolKey, name: 'Syntax Reorderer', desc: 'EN↔ZH structural practice', type: 'native' }, | |
| { key: 'links' as ToolKey, name: 'Useful Links', desc: 'Curated external resources', type: 'native' } | |
| ]; | |
| if (!isVisitor) base.splice(2, 0, { key: 'mt' as ToolKey, name: 'MT (DeepL/Google)', desc: 'Translate with MT engines', type: 'native' }); | |
| return base; | |
| }, [isVisitor]); | |
| // Useful Links: load on tab open | |
| useEffect(() => { | |
| const loadLinks = async () => { | |
| try { | |
| setLinksLoading(true); setLinksError(''); | |
| const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); | |
| const resp = await fetch(`${base}/api/links`, { method: 'GET' }); | |
| if (!resp.ok) throw new Error('Failed'); | |
| const data = await resp.json(); | |
| setLinks(Array.isArray(data) ? data : []); | |
| } catch { | |
| setLinksError('Failed to load links'); | |
| } finally { | |
| setLinksLoading(false); | |
| } | |
| }; | |
| if (selectedTool === 'links') loadLinks(); | |
| }, [selectedTool]); | |
| const handleToolChange = async (tool: ToolKey) => { | |
| if (tool === selectedTool) return; | |
| setIsToolTransitioning(true); | |
| setSelectedTool(tool); | |
| // small delay for smooth spinner; iframe will clear its own loading when onLoad fires | |
| await new Promise(res => setTimeout(res, 200)); | |
| setIsToolTransitioning(false); | |
| if (tool === 'quality-lens') { | |
| setIframeLoading(true); | |
| } | |
| }; | |
| const fetchMyMemory = async () => { | |
| setMmError(''); | |
| setMmResults([]); | |
| if (!mmSource.trim()) { | |
| setMmError('Please enter source text'); | |
| return; | |
| } | |
| try { | |
| setMmLoading(true); | |
| localStorage.setItem('mm_source', mmSource); | |
| localStorage.setItem('mm_from', mmFrom); | |
| localStorage.setItem('mm_to', mmTo); | |
| const q = encodeURIComponent(mmSource.trim()); | |
| // Request MT+matches and DB-only in parallel for robustness | |
| const urlMt = `https://api.mymemory.translated.net/get?q=${q}&langpair=${encodeURIComponent(mmFrom)}|${encodeURIComponent(mmTo)}&mt=1&key=${encodeURIComponent(MYMEMORY_KEY)}`; | |
| const urlDb = `https://api.mymemory.translated.net/get?q=${q}&langpair=${encodeURIComponent(mmFrom)}|${encodeURIComponent(mmTo)}&mt=0&key=${encodeURIComponent(MYMEMORY_KEY)}`; | |
| const [resMt, resDb] = await Promise.all([ | |
| fetch(urlMt, { method: 'GET' }), | |
| fetch(urlDb, { method: 'GET' }) | |
| ]); | |
| const dataMt: MyMemoryResponse = await resMt.json(); | |
| const dataDb: MyMemoryResponse = await resDb.json(); | |
| // MT suggestion | |
| const mtSuggestion = dataMt.responseData?.translatedText?.trim() || ''; | |
| const mtScore = typeof dataMt.responseData?.match === 'number' ? dataMt.responseData?.match : 0; | |
| // Collect DB matches from db-only response (preferred), fallback to mt response | |
| const sourceMatches = Array.isArray(dataDb.matches) && dataDb.matches.length > 0 | |
| ? dataDb.matches | |
| : (Array.isArray(dataMt.matches) ? dataMt.matches : []); | |
| const machineRe = /(google|bing|mt)/i; | |
| const seen = new Set<string>(); | |
| const dbMatches: Array<{ translation: string; score: number; source?: string }> = []; | |
| sourceMatches.forEach((m) => { | |
| const t = (m.translation || '').trim(); | |
| if (!t) return; | |
| if (mtSuggestion && t === mtSuggestion) return; // skip identical to MT | |
| if (seen.has(t)) return; | |
| // Skip machine-created entries when possible | |
| const createdBy = (m['created-by'] as unknown as string) || ''; | |
| if (machineRe.test(createdBy)) return; | |
| seen.add(t); | |
| const score = typeof m.match === 'number' ? m.match : (typeof m.quality === 'number' ? Number(m.quality) : 0); | |
| dbMatches.push({ translation: t, score, source: m.reference || createdBy || 'MyMemory' }); | |
| }); | |
| dbMatches.sort((a, b) => (b.score || 0) - (a.score || 0)); | |
| const top5 = dbMatches.slice(0, 5); | |
| const combined: Array<{ translation: string; score: number; source?: string }> = []; | |
| if (mtSuggestion) { | |
| combined.push({ translation: mtSuggestion, score: mtScore, source: 'Machine Translation' }); | |
| } | |
| combined.push(...top5); | |
| setMmResults(combined); | |
| } catch (e: any) { | |
| setMmError('Failed to fetch suggestions. Please try again.'); | |
| } finally { | |
| setMmLoading(false); | |
| } | |
| }; | |
| const copyToClipboard = async (text: string) => { | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| } catch {} | |
| }; | |
| const translateMT = async () => { | |
| setMtError(''); setMtResult(''); | |
| const text = mtSource.trim(); | |
| if (!text) { setMtError('Please enter source text'); return; } | |
| try { | |
| setMtLoading(true); | |
| const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); | |
| const url = `${base}/api/mt/${mtProvider}`; | |
| const body: any = { text, source: mtFrom, target: mtTo }; | |
| if (mtProvider === 'deepl') body.key = DEEPL_KEY; | |
| if (mtProvider === 'google') body.key = GOOGLE_KEY; | |
| const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); | |
| const data = await resp.json().catch(() => ({})); | |
| if (!resp.ok) throw new Error(data?.error || 'MT failed'); | |
| setMtResult((data?.translation || '').trim()); | |
| } catch (e:any) { | |
| setMtError('Failed to translate'); | |
| } finally { | |
| setMtLoading(false); | |
| } | |
| }; | |
| const IcibaSuggest: React.FC<{ term: string }> = ({ term }) => { | |
| const [items, setItems] = useState<Array<{ key: string; paraphrase?: string }>>([]); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState(''); | |
| const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, ''); | |
| useEffect(() => { | |
| const t = term.trim(); | |
| if (!t) { setItems([]); setError(''); return; } | |
| let aborted = false; | |
| (async () => { | |
| try { | |
| setLoading(true); setError(''); | |
| const url = `${base}/api/iciba/suggest?word=${encodeURIComponent(t)}`; | |
| const resp = await fetch(url); | |
| const data = await resp.json().catch(() => ({})); | |
| if (aborted) return; | |
| const list = Array.isArray(data.message) ? data.message : []; | |
| setItems(list.map((it: any) => ({ key: it.key, paraphrase: it.paraphrase }))); | |
| } catch (e) { | |
| if (!aborted) setError(''); | |
| } finally { | |
| if (!aborted) setLoading(false); | |
| } | |
| })(); | |
| return () => { aborted = true; }; | |
| }, [term]); | |
| if (!term.trim()) return null; | |
| if (loading) return <div className="text-sm text-gray-500">Loading…</div>; | |
| if (error) return null; | |
| if (!items.length) return <div className="text-sm text-gray-500">No suggestions.</div>; | |
| return ( | |
| <div className="space-y-2"> | |
| {items.slice(0, 6).map((it, idx) => ( | |
| <div key={idx} className="flex items-center justify-between bg-white rounded border border-gray-200 px-3 py-2"> | |
| <div className="text-gray-900"> | |
| <span className="font-medium mr-2">{it.key}</span> | |
| <span className="text-sm text-gray-600">{it.paraphrase}</span> | |
| </div> | |
| <button onClick={() => setDictWord(it.key)} className="text-xs text-gray-600 hover:text-gray-900">Use</button> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| }; | |
| const detectLang = (text: string): 'en' | 'zh' => /[\u4e00-\u9fff]/.test(text) ? 'zh' : 'en'; | |
| const codeMatches = (code: string | undefined, target: 'en' | 'zh') => { | |
| if (!code) return false; | |
| const lower = code.toLowerCase(); | |
| if (target === 'zh') return lower === 'zh' || lower.startsWith('zh'); | |
| return lower === 'en' || lower.startsWith('en'); | |
| }; | |
| const fetchDictionary = async () => { | |
| setDictError(''); | |
| setDictResults(null); | |
| const term = dictWord.trim(); | |
| if (!term) { setDictError('Please enter a word'); return; } | |
| try { | |
| setDictLoading(true); | |
| // If user didn’t set explicitly, attempt auto-detect from input once | |
| const detected = detectLang(term); | |
| const from = dictFrom || detected; | |
| // Use backend proxy to avoid CORS and CDN edge issues | |
| const BACKEND_BASE = ((api.defaults as any)?.baseURL as string) || ''; | |
| const base = BACKEND_BASE.replace(/\/$/, ''); | |
| const urlPrimary = `${base}/api/dictionary/${encodeURIComponent(from)}/${encodeURIComponent(term)}?translations=true`; | |
| const resPrimary = await fetch(urlPrimary, { method: 'GET' }); | |
| let data: DictEntry | null = null; | |
| if (resPrimary.ok) { | |
| try { data = await resPrimary.json(); } catch { data = null; } | |
| } | |
| // Fallback to language-agnostic search if nothing found | |
| const hasEntries = !!(data && Array.isArray((data as any).entries) && (data as any).entries.length > 0); | |
| if (!hasEntries) { | |
| const urlAll = `${base}/api/dictionary/all/${encodeURIComponent(term)}?translations=true`; | |
| const resAll = await fetch(urlAll, { method: 'GET' }); | |
| if (resAll.ok) { | |
| try { data = await resAll.json(); } catch { data = null; } | |
| } | |
| } | |
| if (!data || !Array.isArray((data as any).entries) || (data as any).entries.length === 0) { | |
| throw new Error('No results'); | |
| } | |
| setDictResults(data as DictEntry); | |
| } catch (e: any) { | |
| setDictError('No results found or service unavailable'); | |
| } finally { | |
| setDictLoading(false); | |
| } | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-gray-50 py-8"> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
| {/* Header */} | |
| <div className="mb-8"> | |
| <div className="flex items-center mb-4"> | |
| <WrenchScrewdriverIcon className="h-8 w-8 text-indigo-900 mr-3" /> | |
| <h1 className="text-3xl font-bold text-gray-900">Toolkit</h1> | |
| </div> | |
| <p className="text-gray-600">Helpful tools for evaluation and reference. Pick a tool below.</p> | |
| </div> | |
| {/* Tool Selector */} | |
| <div className="mb-6"> | |
| <div className="flex space-x-2 overflow-x-auto pb-2"> | |
| {tools.map(t => ( | |
| <button | |
| key={t.key} | |
| onClick={() => handleToolChange(t.key)} | |
| className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-all duration-200 ease-in-out ${ | |
| selectedTool === t.key | |
| ? 'bg-indigo-600 text-white' | |
| : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300' | |
| }`} | |
| > | |
| {t.name} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Transition Spinner */} | |
| {isToolTransitioning && ( | |
| <div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50"> | |
| <div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3"> | |
| <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div> | |
| <span className="text-gray-700 font-medium">Loading...</span> | |
| </div> | |
| </div> | |
| )} | |
| {!isToolTransitioning && ( | |
| <> | |
| {/* Content Panel */} | |
| <div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 shadow-sm"> | |
| {selectedTool === 'quality-lens' && ( | |
| <div> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div className="flex items-center space-x-3"> | |
| <div className="bg-indigo-600 rounded-lg p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-white" /> | |
| </div> | |
| <div> | |
| <h3 className="text-indigo-900 font-semibold text-xl">Quality Lens</h3> | |
| <p className="text-gray-600 text-sm">BLASER/COMET Quality Estimation and Hallucination detection</p> | |
| </div> | |
| </div> | |
| <a | |
| href={TOOL_URLS['quality-lens']} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-indigo-700 hover:text-indigo-900 text-sm inline-flex items-center" | |
| > | |
| Open in new tab <ArrowTopRightOnSquareIcon className="h-4 w-4 ml-1" /> | |
| </a> | |
| </div> | |
| <div className="border rounded-lg overflow-hidden"> | |
| {iframeLoading && ( | |
| <div className="p-4 flex items-center space-x-3"> | |
| <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-indigo-600"></div> | |
| <span className="text-gray-600 text-sm">Loading tool…</span> | |
| </div> | |
| )} | |
| <iframe | |
| ref={iframeRef} | |
| src={TOOL_URLS['quality-lens']} | |
| title="Quality Lens" | |
| className="w-full" | |
| style={{ minHeight: '1100px', border: '0' }} | |
| onLoad={() => setIframeLoading(false)} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {selectedTool === 'mymemory' && ( | |
| <div> | |
| <div className="flex items-center space-x-3 mb-4"> | |
| <div className="bg-purple-600 rounded-lg p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-white" /> | |
| </div> | |
| <div> | |
| <h3 className="text-purple-900 font-semibold text-xl">MyMemory</h3> | |
| <p className="text-gray-600 text-sm">Lookup translation memory suggestions</p> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"> | |
| <div className="md:col-span-2"> | |
| <label className="block text-sm font-medium text-gray-700 mb-1">Source Text</label> | |
| <textarea | |
| value={mmSource} | |
| onChange={(e) => setMmSource(e.target.value)} | |
| className="w-full p-3 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-purple-500 focus:border-purple-500" | |
| rows={6} | |
| placeholder="Enter text to translate..." | |
| /> | |
| </div> | |
| <div> | |
| <div className="mb-3"> | |
| <label className="block text-sm font-medium text-gray-700 mb-1">Languages</label> | |
| <div className="relative grid grid-cols-1 sm:grid-cols-2 gap-6"> | |
| <select | |
| value={mmFrom} | |
| onChange={(e) => setMmFrom(e.target.value)} | |
| className="w-full pl-3 pr-10 py-2 border border-gray-300 rounded-md bg-white" | |
| > | |
| {languages.map(l => <option key={l.code} value={l.code}>{l.label}</option>)} | |
| </select> | |
| <select | |
| value={mmTo} | |
| onChange={(e) => setMmTo(e.target.value)} | |
| className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md bg-white" | |
| > | |
| {languages.map(l => <option key={l.code} value={l.code}>{l.label}</option>)} | |
| </select> | |
| <button | |
| type="button" | |
| onClick={()=>{ const a = mmFrom; setMmFrom(mmTo); setMmTo(a); }} | |
| className="hidden sm:inline-flex items-center justify-center absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full border border-gray-300 bg-white shadow hover:bg-gray-50" | |
| aria-label="Swap languages" | |
| > | |
| <ArrowsRightLeftIcon className="w-4 h-4 text-gray-600" /> | |
| </button> | |
| </div> | |
| </div> | |
| <button | |
| onClick={fetchMyMemory} | |
| disabled={mmLoading} | |
| className="w-full bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg" | |
| > | |
| {mmLoading ? 'Getting suggestion…' : 'Get suggestion'} | |
| </button> | |
| </div> | |
| </div> | |
| {mmError && ( | |
| <div className="mb-3 p-3 bg-red-50 text-red-700 rounded border border-red-200 text-sm">{mmError}</div> | |
| )} | |
| <div className="space-y-3"> | |
| {mmResults.length > 0 ? ( | |
| mmResults.map((r, idx) => ( | |
| <div key={idx} className={`p-4 rounded-lg border ${idx === 0 ? 'border-purple-300 bg-purple-50' : 'border-gray-200 bg-white'} flex items-start justify-between`}> | |
| <div> | |
| <div className="text-gray-900 mb-2">{r.translation}</div> | |
| <div className="text-xs text-gray-600 space-x-2"> | |
| <span>Score: {(r.score * 100).toFixed(0)}%</span> | |
| {r.source && <span>Source: {r.source}</span>} | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => copyToClipboard(r.translation)} | |
| className="text-gray-600 hover:text-gray-900 flex items-center text-xs" | |
| > | |
| <ClipboardIcon className="h-4 w-4 mr-1" /> Copy | |
| </button> | |
| </div> | |
| )) | |
| ) : ( | |
| <div className="text-sm text-gray-500">No results yet. Enter text and click Get suggestion.</div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {selectedTool === 'mt' && !isVisitor && ( | |
| <div> | |
| <div className="flex items-center space-x-3 mb-4"> | |
| <div className="bg-indigo-600 rounded-lg p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-white" /> | |
| </div> | |
| <div> | |
| <h3 className="text-indigo-900 font-semibold text-xl">Machine Translation</h3> | |
| <p className="text-gray-600 text-sm">Translate between English and Chinese with MT.</p> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"> | |
| <div className="md:col-span-2"> | |
| <label className="block text-sm font-medium text-gray-700 mb-1">Source Text</label> | |
| <textarea | |
| value={mtSource} | |
| onChange={(e) => setMtSource(e.target.value)} | |
| className="w-full p-3 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" | |
| rows={6} | |
| placeholder="Enter text to translate..." | |
| /> | |
| </div> | |
| <div> | |
| <div className="mb-3"> | |
| <label className="block text-sm font-medium text-gray-700 mb-1">Languages</label> | |
| <div className="relative grid grid-cols-1 sm:grid-cols-2 gap-6"> | |
| <select value={mtFrom} onChange={(e)=>setMtFrom(e.target.value)} className="w-full pl-3 pr-10 py-2 border border-gray-300 rounded-md bg-white"> | |
| {languages.map(l => <option key={l.code} value={l.code}>{l.label}</option>)} | |
| </select> | |
| <select value={mtTo} onChange={(e)=>setMtTo(e.target.value)} className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md bg-white"> | |
| {languages.map(l => <option key={l.code} value={l.code}>{l.label}</option>)} | |
| </select> | |
| <button type="button" onClick={()=>{ setMtFrom(mtTo); setMtTo(mtFrom); }} className="hidden sm:inline-flex items-center justify-center absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full border border-gray-300 bg-white shadow hover:bg-gray-50" aria-label="Swap languages"> | |
| <ArrowsRightLeftIcon className="w-4 h-4 text-gray-600" /> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="mb-3"> | |
| <label className="block text-sm font-medium text-gray-700 mb-1">Provider</label> | |
| <select value={mtProvider} onChange={(e)=>setMtProvider(e.target.value as any)} className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white"> | |
| <option value="deepl">DeepL</option> | |
| <option value="google">Google</option> | |
| </select> | |
| </div> | |
| <button | |
| onClick={translateMT} | |
| disabled={mtLoading} | |
| className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg" | |
| > | |
| {mtLoading ? 'Translating…' : 'Translate'} | |
| </button> | |
| </div> | |
| </div> | |
| {mtError && ( | |
| <div className="mb-3 p-3 bg-red-50 text-red-700 rounded border border-red-200 text-sm">{mtError}</div> | |
| )} | |
| <div className="space-y-3"> | |
| {mtResult ? ( | |
| <div className="p-4 rounded-lg border border-gray-200 bg-white flex items-start justify-between"> | |
| <div className="text-gray-900 mb-2 whitespace-pre-wrap break-words">{mtResult}</div> | |
| <button onClick={()=>copyToClipboard(mtResult)} className="text-gray-600 hover:text-gray-900 flex items-center text-xs"><ClipboardIcon className="h-4 w-4 mr-1"/> Copy</button> | |
| </div> | |
| ) : ( | |
| <div className="text-sm text-gray-500">No translation yet. Enter text and click Translate.</div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {selectedTool === 'syntax-reorderer' && ( | |
| <div> | |
| <div className="flex items-center space-x-3 mb-4"> | |
| <div className="bg-amber-600 rounded-lg p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-white" /> | |
| </div> | |
| <div> | |
| <h3 className="text-amber-900 font-semibold text-xl">Syntax Reorderer (EN↔ZH)</h3> | |
| <p className="text-gray-600 text-sm">Label chunks, assign roles, and reorder to practice structural shifts.</p> | |
| </div> | |
| </div> | |
| <div className="border rounded-lg overflow-hidden"> | |
| <SyntaxReorderer /> | |
| </div> | |
| </div> | |
| )} | |
| {selectedTool === 'dictionary' && ( | |
| <div> | |
| <div className="flex items-center space-x-3 mb-4"> | |
| <div className="bg-teal-600 rounded-lg p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-white" /> | |
| </div> | |
| <div> | |
| <h3 className="text-teal-900 font-semibold text-xl">Dictionary (EN⇄ZH)</h3> | |
| <p className="text-gray-600 text-sm">Lookup words and get quick suggestions.</p> | |
| </div> | |
| </div> | |
| <div className="mb-4"> | |
| <div className="max-w-xl"> | |
| <input | |
| type="text" | |
| value={dictWord} | |
| onChange={(e) => setDictWord(e.target.value)} | |
| className="w-full p-3 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-teal-500 focus:border-teal-500" | |
| placeholder="Enter an English or Chinese word" | |
| /> | |
| </div> | |
| </div> | |
| {/* Remove Wiktionary block; rely on Iciba for now */} | |
| {dictError && ( | |
| <div className="mb-3 p-3 bg-red-50 text-red-700 rounded border border-red-200 text-sm">{dictError}</div> | |
| )} | |
| {/* Iciba suggestions (optional helper) */} | |
| <div className="mt-6"> | |
| <div className="text-sm text-gray-600 mb-2">Suggestions (Iciba)</div> | |
| <IcibaSuggest term={dictWord} /> | |
| </div> | |
| {/* Reverse Dictionary: WantWords (additive, non-invasive) */} | |
| <div className="mt-8"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <div> | |
| <div className="text-sm text-gray-600">Reverse Dictionary</div> | |
| <div className="text-lg font-medium text-gray-900">WantWords</div> | |
| </div> | |
| <a | |
| href="https://wantwords.net/" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-teal-700 hover:text-teal-900 text-sm inline-flex items-center" | |
| > | |
| Open in new tab <ArrowTopRightOnSquareIcon className="h-4 w-4 ml-1" /> | |
| </a> | |
| </div> | |
| <div className="text-sm text-gray-600 mb-3"> | |
| Find words by description. This complements direct dictionary lookups. | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => setShowWantWords(v => !v)} | |
| className="px-3 py-2 rounded-md border border-gray-300 text-sm text-gray-800 bg-white hover:bg-gray-50" | |
| > | |
| {showWantWords ? 'Hide embedded WantWords' : 'Show embedded WantWords'} | |
| </button> | |
| {showWantWords && ( | |
| <div className="mt-4 border rounded-lg overflow-hidden"> | |
| <iframe | |
| src={`https://wantwords.net/`} | |
| title="WantWords Reverse Dictionary" | |
| className="w-full" | |
| style={{ minHeight: '900px', border: '0' }} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {selectedTool === 'links' && ( | |
| <div> | |
| <div className="flex items-center space-x-3 mb-4"> | |
| <div className="bg-slate-600 rounded-lg p-2"> | |
| <DocumentTextIcon className="h-5 w-5 text-white" /> | |
| </div> | |
| <div> | |
| <h3 className="text-slate-900 font-semibold text-xl">Useful Links</h3> | |
| <p className="text-gray-600 text-sm">Short descriptions with quick access to external resources.</p> | |
| </div> | |
| </div> | |
| {isAdmin && ( | |
| <div className="mb-4 p-4 border border-gray-200 rounded-lg bg-gray-50"> | |
| <div className="font-medium text-gray-800 mb-2">Manage Links</div> | |
| <div className="grid grid-cols-1 md:grid-cols-4 gap-2 items-end"> | |
| <input value={editItem?.title || ''} onChange={(e)=>setEditItem({ ...(editItem||{}), title: e.target.value })} placeholder="Title" className="px-3 py-2 border border-gray-300 rounded-md bg-white" /> | |
| <input value={editItem?.url || ''} onChange={(e)=>setEditItem({ ...(editItem||{}), url: e.target.value })} placeholder="https://..." className="px-3 py-2 border border-gray-300 rounded-md bg-white" /> | |
| <input value={editItem?.desc || ''} onChange={(e)=>setEditItem({ ...(editItem||{}), desc: e.target.value })} placeholder="Short description" className="px-3 py-2 border border-gray-300 rounded-md bg-white" /> | |
| <div className="flex gap-2"> | |
| <button onClick={async()=>{ try{ const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const bodyObj:any={ title: editItem?.title||'', url: editItem?.url||'', desc: editItem?.desc||'' }; const method = editItem?._id ? 'PUT' : 'POST'; const url = editItem?._id ? `${base}/api/links/${encodeURIComponent(editItem._id)}` : `${base}/api/links`; const headers:any={ 'Content-Type':'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')||''}`, 'user-role': 'admin', 'user-info': localStorage.getItem('user')||'' }; const resp = await fetch(url,{ method, headers, body: JSON.stringify(bodyObj) }); const data = await resp.json().catch(()=>({})); if(!resp.ok) throw new Error(data?.error||'Save failed'); setEditItem(null); const reload = await fetch(`${base}/api/links`); const l = await reload.json(); setLinks(Array.isArray(l)?l:[]);}catch{}}} className="px-3 py-2 bg-indigo-600 text-white rounded-md">{editItem?._id?'Update':'Add'}</button> | |
| {editItem?._id && <button onClick={()=>setEditItem(null)} className="px-3 py-2 border border-gray-300 rounded-md bg-white">Cancel</button>} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {linksLoading && <div className="text-sm text-gray-600">Loading…</div>} | |
| {linksError && <div className="text-sm text-red-600">{linksError}</div>} | |
| <div className="space-y-3"> | |
| {links.map((l) => ( | |
| <div key={l._id || l.url} className="p-4 rounded-lg border border-gray-200 bg-white flex items-start justify-between"> | |
| <div> | |
| <a href={l.url} target="_blank" rel="noopener noreferrer" className="text-indigo-700 hover:text-indigo-900 font-medium inline-flex items-center"> | |
| {l.title} | |
| <ArrowTopRightOnSquareIcon className="h-4 w-4 ml-1" /> | |
| </a> | |
| {l.desc && <div className="text-sm text-gray-600 mt-1">{l.desc}</div>} | |
| </div> | |
| <div className="flex gap-3"> | |
| <a href={l.url} target="_blank" rel="noopener noreferrer" className="text-indigo-700 hover:text-indigo-900 text-sm inline-flex items-center">Open</a> | |
| {isAdmin && ( | |
| <> | |
| <button onClick={()=>setEditItem(l)} className="text-sm text-gray-700 hover:text-gray-900">Edit</button> | |
| <button onClick={async()=>{ try{ const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const headers:any={ 'Authorization': `Bearer ${localStorage.getItem('token')||''}`, 'user-role': 'admin', 'user-info': localStorage.getItem('user')||'' }; const resp=await fetch(`${base}/api/links/${encodeURIComponent(String(l._id))}`,{ method:'DELETE', headers }); if(!resp.ok) throw new Error('Delete failed'); setLinks((prev)=>prev.filter(it=>it._id!==l._id)); }catch{}}} className="text-sm text-red-600 hover:text-red-800">Delete</button> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default Toolkit; | |