mirror of
https://github.com/xxnuo/MTranServer.git
synced 2026-09-03 06:35:20 +08:00
feat(ui): more features
This commit is contained in:
467
ui/src/App.tsx
467
ui/src/App.tsx
@@ -1,51 +1,52 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowRightLeft, Copy, Volume2, X, Upload, History } from 'lucide-react'
|
||||
import { History, BookOpen, Maximize2, Minimize2, Plus } from 'lucide-react'
|
||||
import { SettingsMenu } from '@/components/SettingsMenu'
|
||||
import { HistorySheet } from '@/components/HistorySheet'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { getSortedLanguages } from '@/lib/languages'
|
||||
import { TranslationPanel } from '@/components/TranslationPanel'
|
||||
import { useHistory } from '@/hooks/use-history'
|
||||
|
||||
interface TranslateRequest {
|
||||
from: string
|
||||
to: string
|
||||
text: string
|
||||
html: boolean
|
||||
}
|
||||
|
||||
interface TranslateResponse {
|
||||
result: string
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const isMobile = useIsMobile()
|
||||
const { t } = useTranslation()
|
||||
const [languages, setLanguages] = useState<string[]>([])
|
||||
const [sourceLang, setSourceLang] = useState('auto')
|
||||
const [targetLang, setTargetLang] = useState('zh-Hans')
|
||||
const [sourceText, setSourceText] = useState('')
|
||||
const [translatedText, setTranslatedText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingLanguages, setLoadingLanguages] = useState(true)
|
||||
const [autoTranslate, setAutoTranslate] = useState(false)
|
||||
const [widescreen, setWidescreen] = useState(() => localStorage.getItem('widescreen') === 'true')
|
||||
const [showTokenDialog, setShowTokenDialog] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const translateTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
const [backendVersion, setBackendVersion] = useState('')
|
||||
|
||||
const [panels, setPanels] = useState<string[]>(() => {
|
||||
const saved = localStorage.getItem('translationPanels')
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved panels', e)
|
||||
}
|
||||
}
|
||||
return ['panel-' + Date.now()]
|
||||
})
|
||||
|
||||
const { history, addToHistory, clearHistory, deleteItem } = useHistory()
|
||||
|
||||
const sortedLanguages = useMemo(() => {
|
||||
return getSortedLanguages(languages, i18n.language)
|
||||
}, [languages, i18n.language])
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const response = await fetch('/version')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.version) {
|
||||
setBackendVersion(data.version)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLanguages = useCallback(async () => {
|
||||
try {
|
||||
@@ -75,6 +76,14 @@ function App() {
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('widescreen', String(widescreen))
|
||||
}, [widescreen])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('translationPanels', JSON.stringify(panels))
|
||||
}, [panels])
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tokenFromUrl = urlParams.get('token')
|
||||
@@ -82,147 +91,57 @@ function App() {
|
||||
localStorage.setItem('apiToken', tokenFromUrl)
|
||||
}
|
||||
fetchLanguages()
|
||||
fetchVersion()
|
||||
}, [fetchLanguages])
|
||||
|
||||
const handleTranslate = useCallback(async (text?: string, showToast = true) => {
|
||||
const textToTranslate = text ?? sourceText
|
||||
if (!textToTranslate.trim()) {
|
||||
if (showToast) {
|
||||
toast.error(t('enterTextError'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setTranslatedText('')
|
||||
|
||||
try {
|
||||
const request: TranslateRequest = {
|
||||
from: sourceLang,
|
||||
to: targetLang,
|
||||
text: textToTranslate,
|
||||
html: false
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
const apiToken = localStorage.getItem('apiToken')
|
||||
if (apiToken) {
|
||||
headers['Authorization'] = `Bearer ${apiToken}`
|
||||
}
|
||||
|
||||
const response = await fetch('/translate', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(request)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error(t('apiTokenPlaceholder'))
|
||||
}
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || t('translationFailed'))
|
||||
}
|
||||
|
||||
const data: TranslateResponse = await response.json()
|
||||
setTranslatedText(data.result)
|
||||
|
||||
addToHistory({
|
||||
from: sourceLang,
|
||||
to: targetLang,
|
||||
sourceText: textToTranslate,
|
||||
translatedText: data.result
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success(t('translationCompleted'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Translation error:', error)
|
||||
if (showToast) {
|
||||
toast.error(error instanceof Error ? error.message : t('translationFailed'))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sourceLang, targetLang, sourceText, t])
|
||||
|
||||
const handleSourceTextChange = (text: string) => {
|
||||
setSourceText(text)
|
||||
|
||||
if (autoTranslate && text.trim()) {
|
||||
if (translateTimeoutRef.current) {
|
||||
clearTimeout(translateTimeoutRef.current)
|
||||
}
|
||||
|
||||
translateTimeoutRef.current = window.setTimeout(() => {
|
||||
handleTranslate(text, false)
|
||||
}, 800)
|
||||
}
|
||||
const addPanel = () => {
|
||||
setPanels(prev => [...prev, 'panel-' + Date.now()])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (translateTimeoutRef.current) {
|
||||
window.clearTimeout(translateTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSwapLanguages = () => {
|
||||
setSourceLang(targetLang)
|
||||
setTargetLang(sourceLang)
|
||||
setSourceText(translatedText)
|
||||
setTranslatedText(sourceText)
|
||||
}
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success(t('copied'))
|
||||
} catch (err) {
|
||||
toast.error(t('copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSpeak = (text: string, lang: string) => {
|
||||
if (!text) return
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
utterance.lang = lang === 'auto' ? 'en-US' : lang
|
||||
window.speechSynthesis.speak(utterance)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setSourceText('')
|
||||
setTranslatedText('')
|
||||
}
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result
|
||||
if (typeof content === 'string') {
|
||||
setSourceText(content)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
const removePanel = (id: string) => {
|
||||
if (panels.length <= 1) return
|
||||
setPanels(prev => prev.filter(p => p !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-3 sm:p-4 md:p-8 flex flex-col">
|
||||
<div className="max-w-6xl mx-auto flex-1 w-full">
|
||||
<div className={`mx-auto flex-1 w-full transition-all duration-300 ${widescreen ? 'max-w-full' : 'max-w-[90rem]'}`}>
|
||||
<div className="flex justify-between items-center mb-4 sm:mb-6">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-foreground">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-foreground">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-9 w-9"
|
||||
title="API Documentation"
|
||||
>
|
||||
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={addPanel}
|
||||
title={t('addPanel') || "Add Translation Panel"}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setWidescreen(!widescreen)}
|
||||
title={widescreen ? "Standard View" : "Widescreen View"}
|
||||
>
|
||||
{widescreen ? (
|
||||
<Minimize2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Maximize2 className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -231,7 +150,7 @@ function App() {
|
||||
>
|
||||
<History className="h-5 w-5" />
|
||||
</Button>
|
||||
<SettingsMenu
|
||||
<SettingsMenu
|
||||
showTokenDialog={showTokenDialog}
|
||||
setShowTokenDialog={setShowTokenDialog}
|
||||
onTokenSaved={fetchLanguages}
|
||||
@@ -239,204 +158,35 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-lg">
|
||||
<CardContent className="pt-4 sm:pt-6 space-y-3 sm:space-y-4">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-2 justify-between">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Select
|
||||
value={sourceLang}
|
||||
onValueChange={setSourceLang}
|
||||
disabled={loadingLanguages}
|
||||
>
|
||||
<SelectTrigger className={isMobile ? "flex-1" : "w-[140px]"}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('autoDetect')}</SelectItem>
|
||||
{sortedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwapLanguages}
|
||||
disabled={loadingLanguages || sourceLang === 'auto'}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
value={targetLang}
|
||||
onValueChange={setTargetLang}
|
||||
disabled={loadingLanguages}
|
||||
>
|
||||
<SelectTrigger className={isMobile ? "flex-1" : "w-[140px]"}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 justify-end sm:justify-start">
|
||||
<Switch
|
||||
id="auto-translate"
|
||||
checked={autoTranslate}
|
||||
onCheckedChange={setAutoTranslate}
|
||||
/>
|
||||
<Label htmlFor="auto-translate" className="text-xs cursor-pointer whitespace-nowrap">
|
||||
{t('autoTranslate')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 items-stretch">
|
||||
{panels.map((id, index) => (
|
||||
<div key={id} className={`flex-1 min-w-[350px] max-w-full ${panels.length === 1 ? 'w-full' : ''}`}>
|
||||
<TranslationPanel
|
||||
id={id}
|
||||
isPrimary={index === 0}
|
||||
languages={languages}
|
||||
loadingLanguages={loadingLanguages}
|
||||
addToHistory={addToHistory}
|
||||
onDelete={() => removePanel(id)}
|
||||
canDelete={panels.length > 1}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="relative group h-full">
|
||||
<Textarea
|
||||
id="source-text"
|
||||
placeholder={t('enterText')}
|
||||
value={sourceText}
|
||||
onChange={(e) => handleSourceTextChange(e.target.value)}
|
||||
className={`${isMobile ? 'min-h-[200px]' : 'min-h-[300px]'} h-full resize-none text-base pr-10 pb-10`}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{sourceText && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={handleClear}
|
||||
title={t('clear')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 left-2 text-xs text-muted-foreground pointer-events-none">
|
||||
{sourceText.length}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 right-2 flex gap-1">
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept=".txt,.md,.json,.js,.ts,.go,.py,.java,.c,.cpp,.h,.hpp"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="file-upload"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-8 w-8 cursor-pointer"
|
||||
title="Upload file"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</Label>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleSpeak(sourceText, sourceLang)}
|
||||
disabled={!sourceText}
|
||||
title="Listen"
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleCopy(sourceText)}
|
||||
disabled={!sourceText}
|
||||
title="Copy"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full">
|
||||
<Textarea
|
||||
id="translated-text"
|
||||
placeholder={t('translationWillAppear')}
|
||||
value={translatedText}
|
||||
readOnly
|
||||
className={`${isMobile ? 'min-h-[200px]' : 'min-h-[300px]'} h-full resize-none text-base bg-muted pb-10`}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-2 right-2 flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleSpeak(translatedText, targetLang)}
|
||||
disabled={!translatedText}
|
||||
title="Listen"
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleCopy(translatedText)}
|
||||
disabled={!translatedText}
|
||||
title="Copy"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!autoTranslate && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={() => handleTranslate()}
|
||||
disabled={loading || loadingLanguages || !sourceText.trim()}
|
||||
className={isMobile ? "w-full" : "min-w-[200px]"}
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
{t('translating')}
|
||||
</>
|
||||
) : (
|
||||
t('translate')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<footer className="max-w-6xl mx-auto w-full mt-8 py-4 text-center">
|
||||
<a
|
||||
href="https://github.com/xxnuo/MTranServer"
|
||||
target="_blank"
|
||||
<footer className="w-full mt-8 py-4 text-center">
|
||||
<a
|
||||
href="https://github.com/xxnuo/MTranServer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>MTranServer</span>
|
||||
<span>MTranServer {backendVersion && <span className="text-xs ml-1 opacity-75">v{backendVersion}</span>}</span>
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
@@ -445,10 +195,9 @@ function App() {
|
||||
onOpenChange={setShowHistory}
|
||||
history={history}
|
||||
onSelect={(item) => {
|
||||
setSourceLang(item.from)
|
||||
setTargetLang(item.to)
|
||||
setSourceText(item.sourceText)
|
||||
setTranslatedText(item.translatedText)
|
||||
const event = new CustomEvent('loadHistoryItem', { detail: item });
|
||||
window.dispatchEvent(event);
|
||||
setShowHistory(false);
|
||||
}}
|
||||
onClear={clearHistory}
|
||||
onDelete={deleteItem}
|
||||
@@ -457,4 +206,4 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
432
ui/src/components/TranslationPanel.tsx
Normal file
432
ui/src/components/TranslationPanel.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowRightLeft, Copy, Volume2, X, Upload } from 'lucide-react'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { getSortedLanguages } from '@/lib/languages'
|
||||
|
||||
interface TranslateRequest {
|
||||
from: string
|
||||
to: string
|
||||
text: string
|
||||
html: boolean
|
||||
}
|
||||
|
||||
interface TranslateResponse {
|
||||
result: string
|
||||
}
|
||||
|
||||
interface TranslationPanelProps {
|
||||
id: string
|
||||
isPrimary: boolean
|
||||
languages: string[]
|
||||
loadingLanguages: boolean
|
||||
addToHistory: (item: any) => void
|
||||
onDelete: () => void
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export function TranslationPanel({
|
||||
id,
|
||||
isPrimary,
|
||||
languages,
|
||||
loadingLanguages,
|
||||
addToHistory,
|
||||
onDelete,
|
||||
canDelete
|
||||
}: TranslationPanelProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const storageKey = (key: string) => `panel_${id}_${key}`
|
||||
|
||||
const [sourceLang, setSourceLang] = useState(() => localStorage.getItem(storageKey('sourceLang')) || 'auto')
|
||||
const [targetLang, setTargetLang] = useState(() => localStorage.getItem(storageKey('targetLang')) || 'zh-Hans')
|
||||
const [sourceText, setSourceText] = useState('')
|
||||
const [translatedText, setTranslatedText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [autoTranslate, setAutoTranslate] = useState(() => localStorage.getItem(storageKey('autoTranslate')) === 'true')
|
||||
|
||||
const translateTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
const sortedLanguages = useMemo(() => {
|
||||
return getSortedLanguages(languages, i18n.language)
|
||||
}, [languages, i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey('sourceLang'), sourceLang)
|
||||
}, [sourceLang, id])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey('targetLang'), targetLang)
|
||||
}, [targetLang, id])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey('autoTranslate'), String(autoTranslate))
|
||||
}, [autoTranslate, id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPrimary) return
|
||||
|
||||
const handleLoadHistory = (event: Event) => {
|
||||
const customEvent = event as CustomEvent
|
||||
const item = customEvent.detail
|
||||
if (item) {
|
||||
setSourceLang(item.from)
|
||||
setTargetLang(item.to)
|
||||
setSourceText(item.sourceText)
|
||||
setTranslatedText(item.translatedText)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('loadHistoryItem', handleLoadHistory)
|
||||
return () => {
|
||||
window.removeEventListener('loadHistoryItem', handleLoadHistory)
|
||||
}
|
||||
}, [isPrimary])
|
||||
|
||||
const handleTranslate = useCallback(async (text?: string, showToast = true) => {
|
||||
const textToTranslate = text ?? sourceText
|
||||
if (!textToTranslate.trim()) {
|
||||
if (showToast) {
|
||||
toast.error(t('enterTextError'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setTranslatedText('')
|
||||
|
||||
try {
|
||||
const request: TranslateRequest = {
|
||||
from: sourceLang,
|
||||
to: targetLang,
|
||||
text: textToTranslate,
|
||||
html: false
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
const apiToken = localStorage.getItem('apiToken')
|
||||
if (apiToken) {
|
||||
headers['Authorization'] = `Bearer ${apiToken}`
|
||||
}
|
||||
|
||||
const response = await fetch('/translate', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(request)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error(t('apiTokenPlaceholder'))
|
||||
}
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || t('translationFailed'))
|
||||
}
|
||||
|
||||
const data: TranslateResponse = await response.json()
|
||||
setTranslatedText(data.result)
|
||||
|
||||
addToHistory({
|
||||
from: sourceLang,
|
||||
to: targetLang,
|
||||
sourceText: textToTranslate,
|
||||
translatedText: data.result
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success(t('translationCompleted'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Translation error:', error)
|
||||
if (showToast) {
|
||||
toast.error(error instanceof Error ? error.message : t('translationFailed'))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sourceLang, targetLang, sourceText, t, addToHistory])
|
||||
|
||||
const handleSourceTextChange = (text: string) => {
|
||||
setSourceText(text)
|
||||
|
||||
if (autoTranslate && text.trim()) {
|
||||
if (translateTimeoutRef.current) {
|
||||
clearTimeout(translateTimeoutRef.current)
|
||||
}
|
||||
|
||||
translateTimeoutRef.current = window.setTimeout(() => {
|
||||
handleTranslate(text, false)
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (translateTimeoutRef.current) {
|
||||
window.clearTimeout(translateTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSwapLanguages = () => {
|
||||
setSourceLang(targetLang)
|
||||
setTargetLang(sourceLang)
|
||||
setSourceText(translatedText)
|
||||
setTranslatedText(sourceText)
|
||||
}
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success(t('copied'))
|
||||
} catch (err) {
|
||||
toast.error(t('copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSpeak = (text: string, lang: string) => {
|
||||
if (!text) return
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
utterance.lang = lang === 'auto' ? 'en-US' : lang
|
||||
window.speechSynthesis.speak(utterance)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setSourceText('')
|
||||
setTranslatedText('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleTranslate()
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result
|
||||
if (typeof content === 'string') {
|
||||
setSourceText(content)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg h-full flex flex-col">
|
||||
<CardContent className="pt-1 sm:pt-1 space-y-3 sm:space-y-4 flex-1 flex flex-col">
|
||||
<div className="flex flex-wrap items-center gap-3 justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[280px]">
|
||||
<Select
|
||||
value={sourceLang}
|
||||
onValueChange={setSourceLang}
|
||||
disabled={loadingLanguages}
|
||||
>
|
||||
<SelectTrigger className="w-[110px] sm:w-[140px] flex-shrink-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('autoDetect')}</SelectItem>
|
||||
{sortedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwapLanguages}
|
||||
disabled={loadingLanguages || sourceLang === 'auto'}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
value={targetLang}
|
||||
onValueChange={setTargetLang}
|
||||
disabled={loadingLanguages}
|
||||
>
|
||||
<SelectTrigger className="w-[110px] sm:w-[140px] flex-shrink-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 justify-end flex-shrink-0 ml-auto sm:ml-0">
|
||||
<Switch
|
||||
id={`auto-translate-${id}`}
|
||||
checked={autoTranslate}
|
||||
onCheckedChange={setAutoTranslate}
|
||||
/>
|
||||
<Label htmlFor={`auto-translate-${id}`} className="text-xs cursor-pointer whitespace-nowrap">
|
||||
{t('autoTranslate')}
|
||||
</Label>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onDelete}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 ml-2 shrink-0"
|
||||
title={t('closePanel') || "Close Panel"}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4 flex-1">
|
||||
<div className="relative group h-full flex flex-col">
|
||||
<Textarea
|
||||
id={`source-text-${id}`}
|
||||
placeholder={t('enterText')}
|
||||
value={sourceText}
|
||||
onChange={(e) => handleSourceTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`${isMobile ? 'min-h-[200px]' : 'min-h-[300px]'} h-full resize-none text-base pr-10 pb-10 flex-1`}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{sourceText && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={handleClear}
|
||||
title={t('clear')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 left-2 text-xs text-muted-foreground pointer-events-none">
|
||||
{sourceText.length}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 right-2 flex gap-1">
|
||||
<input
|
||||
type="file"
|
||||
id={`file-upload-${id}`}
|
||||
className="hidden"
|
||||
accept=".txt,.md,.json,.js,.ts,.go,.py,.java,.c,.cpp,.h,.hpp"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`file-upload-${id}`}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-8 w-8 cursor-pointer"
|
||||
title="Upload file"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</Label>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleSpeak(sourceText, sourceLang)}
|
||||
disabled={!sourceText}
|
||||
title="Listen"
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleCopy(sourceText)}
|
||||
disabled={!sourceText}
|
||||
title="Copy"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full flex flex-col">
|
||||
<Textarea
|
||||
id={`translated-text-${id}`}
|
||||
placeholder={t('translationWillAppear')}
|
||||
value={translatedText}
|
||||
readOnly
|
||||
className={`${isMobile ? 'min-h-[200px]' : 'min-h-[300px]'} h-full resize-none text-base bg-muted pb-10 flex-1`}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-2 right-2 flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleSpeak(translatedText, targetLang)}
|
||||
disabled={!translatedText}
|
||||
title="Listen"
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleCopy(translatedText)}
|
||||
disabled={!translatedText}
|
||||
title="Copy"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!autoTranslate && (
|
||||
<div className="flex justify-center mt-auto pt-2">
|
||||
<Button
|
||||
onClick={() => handleTranslate()}
|
||||
disabled={loading || loadingLanguages || !sourceText.trim()}
|
||||
className={isMobile ? "w-full" : "min-w-[200px]"}
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
{t('translating')}
|
||||
</>
|
||||
) : (
|
||||
t('translate')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:8989',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/version': {
|
||||
target: 'http://localhost:8989',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user