| import React, { useEffect, useMemo, useRef, useState } from 'react'; |
| |
| import Refinity from '../components/Refinity'; |
| import { api } from '../services/api'; |
| import { |
| DocumentTextIcon, |
| WrenchScrewdriverIcon, |
| ArrowTopRightOnSquareIcon, |
| ClipboardIcon, |
| ArrowsRightLeftIcon |
| } from '@heroicons/react/24/outline'; |
|
|
| type ToolKey = 'quality-lens' | 'mymemory' | 'dictionary' | 'mt' | 'links' | 'refinity'; |
|
|
| 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': '', |
| 'mt': '', |
| 'links': '', |
| 'refinity': '' |
| }; |
|
|
| |
| const MYMEMORY_KEY = '64031566ea7d91c9fe6b'; |
| |
| 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>(() => { |
| const raw = (localStorage.getItem('selectedToolkitTool') as ToolKey) || 'refinity'; |
| const allowed = new Set(['quality-lens','mymemory','dictionary','mt','links','refinity']); |
| return (allowed.has(raw as any) ? raw : 'refinity') as ToolKey; |
| }); |
| const [isToolTransitioning, setIsToolTransitioning] = useState(false); |
| const [iframeLoading, setIframeLoading] = useState(true); |
| const [isVisitor, setIsVisitor] = useState<boolean>(true); |
|
|
| |
| 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 }>>([]); |
|
|
| |
| 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); |
|
|
| |
| 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'; |
| const viewMode = (localStorage.getItem('viewMode') || 'auto'); |
| setIsVisitor(role === 'visitor'); |
| setIsAdmin(viewMode === 'student' ? false : 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: 'refinity' as ToolKey, name: 'Deep Revision', desc: 'Version flow, compare, and revise', 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]); |
|
|
| |
| 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); |
| |
| 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()); |
| |
| 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(); |
|
|
| |
| const mtSuggestion = dataMt.responseData?.translatedText?.trim() || ''; |
| const mtScore = typeof dataMt.responseData?.match === 'number' ? dataMt.responseData?.match : 0; |
|
|
| |
| 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; |
| if (seen.has(t)) return; |
| |
| 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); |
| |
| const detected = detectLang(term); |
| const from = dictFrom || detected; |
| |
| 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; } |
| } |
|
|
| |
| 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-white 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"> |
| <img src="/icons/toolkit.svg" alt="Toolkit" className="h-8 w-8 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-3 overflow-x-auto pb-2"> |
| {tools.map(t => { |
| const isActive = selectedTool === t.key; |
| return ( |
| <button |
| key={t.key} |
| onClick={() => handleToolChange(t.key)} |
| className={`relative inline-flex items-center justify-center rounded-2xl px-4 py-1.5 whitespace-nowrap transition-all duration-300 ease-out ring-1 ring-inset ${isActive ? 'ring-white/50 backdrop-brightness-110 backdrop-saturate-150' : 'ring-white/30'} backdrop-blur-md isolate shadow-[inset_0_0.5px_0_rgba(255,255,255,0.5),inset_0_-1px_1.5px_rgba(0,0,0,0.12)]`} |
| style={{ background: 'rgba(255,255,255,0.10)' }} |
| > |
| {/* Rim washes */} |
| <div className="pointer-events-none absolute inset-0 rounded-2xl opacity-60 [background:linear-gradient(to_bottom,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%),linear-gradient(to_right,rgba(255,255,255,0.35),rgba(255,255,255,0)_28%)]" /> |
| {/* Soft glossy wash */} |
| <div className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-tr from-white/30 via-white/10 to-transparent opacity-50" /> |
| {/* Tiny hotspot near TL */} |
| <div className="pointer-events-none absolute rounded-full" style={{ width: '28px', height: '28px', left: '8px', top: '6px', background: 'radial-gradient(16px_16px_at_10px_10px,rgba(255,255,255,0.5),rgba(255,255,255,0)_60%)', opacity: 0.45 }} /> |
| {/* Center darken for depth on unselected only */} |
| {!isActive && ( |
| <div className="pointer-events-none absolute inset-0 rounded-2xl" style={{ background: 'radial-gradient(120%_120%_at_50%_55%,rgba(0,0,0,0.04),rgba(0,0,0,0)_60%)', opacity: 0.9 }} /> |
| )} |
| {/* Tint overlay: Indigo family to match Toolkit */} |
| <div className={`pointer-events-none absolute inset-0 rounded-2xl ${isActive ? 'bg-indigo-600/70 mix-blend-normal opacity-100' : 'bg-indigo-500/30 mix-blend-overlay opacity-35'}`} /> |
| <span className={`relative z-10 text-sm font-medium ${isActive ? 'text-white' : 'text-black'}`}>{t.name}</span> |
| </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> |
| )} |
| |
| {/* Syntax Reorderer removed */} |
| |
| {selectedTool === 'refinity' && ( |
| <div> |
| <div className="flex items-center space-x-3 mb-4"> |
| <div className="bg-indigo-600 rounded-lg p-2"> |
| <WrenchScrewdriverIcon className="h-5 w-5 text-white" /> |
| </div> |
| <div> |
| <h3 className="text-indigo-900 font-semibold text-xl">Deep Revision</h3> |
| <p className="text-gray-600 text-sm">Manage versions, compare, and export with track changes.</p> |
| </div> |
| </div> |
| <div className="border rounded-lg overflow-visible"> |
| <div className="mx-auto w-full max-w-5xl"> |
| <Refinity /> |
| </div> |
| </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; |
|
|
|
|
|
|
|
|
|
|