TranslateGemma-WebGPU / src /Translate.tsx
nico-martin's picture
nico-martin HF Staff
added hashchange listener for parent
d9f7125
import { useState, useEffect, useRef } from "react";
import { ArrowLeftRight, Copy, Check, Share2, Trash } from "lucide-react";
import { Textarea, Button, Loader } from "./theme";
import cn from "./utils/classnames.ts";
import { type LanguageCode, LANGUAGES } from "./constants";
import LanguageSelector from "./components/LanguageSelector";
import Translator from "./ai/Translator.ts";
import { formatTime, formatNumber } from "./utils/format";
import { countWords } from "./utils/countWords.ts";
const MAX_INPUT_LENGTH = 1000;
interface TranslateProps {
className?: string;
translator: Translator;
}
export default function Translate({
className = "",
translator,
}: TranslateProps) {
// Initialize from URL hash
const getInitialState = () => {
const hash = window.location.hash.slice(1); // Remove the # character
const params = new URLSearchParams(hash);
const sourceLang = params.get("sl");
const targetLang = params.get("tl");
const text = params.get("text");
// Validate language codes
const isValidLanguage = (code: string | null): code is LanguageCode => {
if (!code) return false;
return LANGUAGES.some((lang) => lang.code === code);
};
return {
sourceLanguage: isValidLanguage(sourceLang)
? sourceLang
: ("en" as LanguageCode),
targetLanguage: isValidLanguage(targetLang)
? targetLang
: ("de_DE" as LanguageCode),
sourceText: text ? decodeURIComponent(text) : "",
};
};
const initialState = getInitialState();
const [sourceText, setSourceText] = useState(initialState.sourceText);
const [targetText, setTargetText] = useState("");
const [sourceLanguage, setSourceLanguage] = useState<LanguageCode>(
initialState.sourceLanguage
);
const [targetLanguage, setTargetLanguage] = useState<LanguageCode>(
initialState.targetLanguage
);
const [translating, setTranslating] = useState<boolean>(false);
const [copied, setCopied] = useState<boolean>(false);
const [shared, setShared] = useState<boolean>(false);
const abortControllerRef = useRef<AbortController | null>(null);
const [translationTime, setTranslationTime] = useState<number>(0);
const [translationWords, setTranslationWords] = useState<number>(0);
const handleSwapLanguages = () => {
// Swap languages
setSourceLanguage(targetLanguage);
setTargetLanguage(sourceLanguage);
// Swap text content
setSourceText(targetText);
setTargetText(sourceText);
};
const handleCopy = async () => {
if (!targetText) return;
try {
await navigator.clipboard.writeText(targetText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
const handleShare = async () => {
try {
const currentUrl = window.location.href;
await navigator.clipboard.writeText(currentUrl);
setShared(true);
setTimeout(() => setShared(false), 2000);
} catch (error) {
console.error("Failed to share:", error);
}
};
const translate = async (
text: string,
sourceLang: LanguageCode,
targetLang: LanguageCode
) => {
if (!text.trim()) {
setTargetText("");
setTranslationTime(0);
setTranslationWords(0);
return;
}
const started = performance.now();
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const currentController = abortControllerRef.current;
setTranslating(true);
try {
const translation = await translator.translate(
text,
sourceLang,
targetLang
);
if (!currentController.signal.aborted) {
setTargetText(translation);
setTranslationTime(Math.round(performance.now() - started));
setTranslationWords(countWords(text));
}
} catch (error) {
if (!currentController.signal.aborted) {
console.error("Translation error:", error);
}
} finally {
if (!currentController.signal.aborted) {
setTranslating(false);
}
}
};
// Update URL hash when languages or text change
useEffect(() => {
const params = new URLSearchParams();
params.set("sl", sourceLanguage);
params.set("tl", targetLanguage);
if (sourceText) {
params.set("text", encodeURIComponent(sourceText));
}
window.location.hash = `#${params.toString()}`;
}, [sourceLanguage, targetLanguage, sourceText]);
useEffect(() => {
const timer = setTimeout(() => {
translate(sourceText, sourceLanguage, targetLanguage);
}, 500);
return () => {
clearTimeout(timer);
};
}, [sourceText, sourceLanguage, targetLanguage]);
return (
<div className={cn("max-w-6xl mx-auto p-2 md:p-4 relative", className)}>
<div className="flex flex-col md:flex-row w-full gap-4 md:gap-8">
<div className="flex flex-col gap-3 w-full md:w-1/2 relative">
<LanguageSelector
value={sourceLanguage}
onChange={setSourceLanguage}
/>
<Textarea
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
placeholder="Enter text to translate..."
className="h-48 md:h-70 pb-10"
variant="default"
maxLength={MAX_INPUT_LENGTH}
/>
<div className="p-2 flex justify-between items-center -mt-4">
<p className="text-xs text-muted-foreground opacity-70">
{formatNumber(sourceText.length)} /{" "}
{formatNumber(MAX_INPUT_LENGTH)}
</p>
<div className="flex gap-2">
<button
onClick={handleShare}
className="p-2 text-muted-foreground hover:text-primary hover:bg-muted rounded-md transition-colors"
aria-label="Share translation"
>
{shared ? (
<span className="flex text-xs gap-2">
link copied
<Check className="w-4 h-4 text-primary" />
</span>
) : (
<Share2 className="w-4 h-4" />
)}
</button>
<button
onClick={() => setSourceText("")}
className="p-2 text-muted-foreground hover:text-primary hover:bg-muted rounded-md transition-colors"
aria-label="clear text"
>
<Trash className="w-4 h-4" />
</button>
</div>
</div>
</div>
<div className="hidden md:block absolute left-1/2 top-5 -translate-x-1/2">
<Button
variant="ghost"
icon={ArrowLeftRight}
onClick={handleSwapLanguages}
aria-label="Swap languages"
/>
</div>
<div className="flex md:hidden justify-center -my-2">
<Button
variant="ghost"
icon={ArrowLeftRight}
onClick={handleSwapLanguages}
aria-label="Swap languages"
className="rotate-90"
/>
</div>
<div className="flex flex-col gap-3 w-full md:w-1/2">
<div className="flex items-center justify-between">
<LanguageSelector
value={targetLanguage}
onChange={setTargetLanguage}
/>
{translating && <Loader size={20} />}
</div>
<div>
<Textarea
value={targetText}
disabled
placeholder="Translation will appear here..."
className="h-48 md:h-70"
variant="default"
/>
<div className="p-2 flex justify-between items-center -mt-4">
{translationTime > 0 ? (
<p className="text-xs text-muted-foreground opacity-70">
Translated <b>{formatNumber(translationWords)} words</b> in{" "}
<b>{formatTime(translationTime)}</b>
</p>
) : (
<p />
)}
<div>
<button
onClick={handleCopy}
className="p-2 text-muted-foreground hover:text-primary hover:bg-muted rounded-md transition-colors"
aria-label="Copy translation"
>
{copied ? (
<span className="flex text-xs gap-2">
translation copied
<Check className="w-4 h-4 text-primary" />
</span>
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}