Ready
Network activity
0 requests
Connected - models run on-device
No recent network activity
0 groups0 total requests
Browse blocks
Translate
Translate text between 24 language pairs, swap the direction with one click to carry the result back into the input, and copy or clear as you go. It uses your browser's built-in translator when available, or a downloadable model otherwise. Nothing downloads until you ask.
Install this block
npx shadcn@latest add @localmode/ui/blocks/writing-tools/translateTranslate - 24 offline Opus-MT pairs. Models load only behind an explicit action.
Chrome Translator (en→de) is not available in this browser. Using Transformers.js (Opus-MT) instead.
Resolving provider…
Works offlineRuns locally24 pairsOpus-MT · ~80 MB per pair
From
To
English0 chars
German
Translation will appear here…
'use client';/** * @file translate.tsx * @description Translate block — 24 directed offline Opus-MT pairs (English-centric, every model id verified) with swap-carries-output, per-panel char counts, copy, cancel, and truthful badging (Chrome Translator API ⇄ Opus-MT fallback). */import { useEffect, useState } from 'react';import { useTranslate, useProviderFallback, toAppError, providerName, type ResolvedModel,} from '@localmode/react';import type { TranslationModel } from '@localmode/core';import { LanguagePairSelector } from '@/components/language-pair-selector';import { ProviderBadge } from '@/components/provider-badge';import { ChromeAIDownloadGate } from '@/components/chrome-ai-download-gate';import { CopyButton } from '@/components/copy-button';import { ErrorAlert } from '@/components/error-alert';import { cn } from '@/lib/utils';interface Language { code: string; name: string; flag: string;}const LANGUAGES: Language[] = [ { code: 'en', name: 'English', flag: '🇬🇧' }, { code: 'de', name: 'German', flag: '🇩🇪' }, { code: 'fr', name: 'French', flag: '🇫🇷' }, { code: 'es', name: 'Spanish', flag: '🇪🇸' }, { code: 'it', name: 'Italian', flag: '🇮🇹' }, { code: 'nl', name: 'Dutch', flag: '🇳🇱' }, { code: 'ru', name: 'Russian', flag: '🇷🇺' }, { code: 'zh', name: 'Chinese', flag: '🇨🇳' }, { code: 'ar', name: 'Arabic', flag: '🇸🇦' }, { code: 'hi', name: 'Hindi', flag: '🇮🇳' }, { code: 'fi', name: 'Finnish', flag: '🇫🇮' }, { code: 'uk', name: 'Ukrainian', flag: '🇺🇦' }, { code: 'sv', name: 'Swedish', flag: '🇸🇪' },];const NON_ENGLISH_CODES = LANGUAGES.filter((l) => l.code !== 'en').map((l) => l.code);const DIRECTED_PAIR_COUNT = NON_ENGLISH_CODES.length * 2;const OPUS_MT_SIZE = '~80 MB per pair';function opusMtModelId(source: string, target: string): string { return `Xenova/opus-mt-${source}-${target}`;}function languageByCode(code: string): Language { return LANGUAGES.find((l) => l.code === code) ?? { code, name: code, flag: '' };}const TRANSLATE_SAMPLE = 'The weather is nice today and I want to go for a walk.';const FIRST_NON_EN = LANGUAGES.find((l) => l.code !== 'en')!.code;export function TranslateBlock() { const [source, setSource] = useState('en'); const [target, setTarget] = useState('de'); const [input, setInput] = useState(''); const [resolved, setResolved] = useState<ResolvedModel<TranslationModel> | null>(null); const { resolveTranslator, chromeAvailability, refreshChromeAvailability, requestChromeDownload, chromeDownloadProgress, downloadingCapability, error: providerError, } = useProviderFallback({ loadChromeAI: () => import('@localmode/chrome-ai'), loadTransformers: () => import('@localmode/transformers'), }); const { data, error, isLoading, execute, cancel, reset } = useTranslate({ model: resolved?.model as TranslationModel, }); const translateAvailability = chromeAvailability.translate ?? 'unsupported'; useEffect(() => { void refreshChromeAvailability('translate', { source, target }); }, [source, target, refreshChromeAvailability]); useEffect(() => { let alive = true; setResolved(null); void resolveTranslator({ source, target, fallbackModelId: opusMtModelId(source, target), }).then((r) => { if (alive) setResolved(r); }); return () => { alive = false; }; }, [source, target, resolveTranslator, translateAvailability]); const output = data?.translation ?? ''; const ready = !!resolved; const appErr = toAppError(error); const modelId = data?.response.modelId ?? resolved?.modelId ?? null; const selectSource = (code: string) => { reset(); if (code === 'en') { setSource('en'); if (target === 'en') setTarget(FIRST_NON_EN); } else { setSource(code); setTarget('en'); } }; const selectTarget = (code: string) => { reset(); if (code === 'en') { setTarget('en'); if (source === 'en') setSource(FIRST_NON_EN); } else { setTarget(code); setSource('en'); } }; const swap = () => { const carried = output; reset(); setSource(target); setTarget(source); if (carried) setInput(carried); }; const translate = () => { if (!input.trim() || !ready || isLoading) return; void execute({ text: input, sourceLanguage: source, targetLanguage: target }); }; const targetName = languageByCode(target).name; return ( <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Translate - 24 offline Opus-MT pairs. Models load only behind an explicit action. </p> <ChromeAIDownloadGate availability={translateAvailability} label={`Chrome Translator (${source}\u2192${target})`} size="one language pack" isDownloading={downloadingCapability === 'translate'} progress={chromeDownloadProgress?.progress} error={providerError?.message ?? null} fallbackLabel="Transformers.js (Opus-MT)" onDownload={() => { void requestChromeDownload('translate', { source, target }); }} /> <div className="flex flex-wrap items-center justify-between gap-2"> <span data-provider={resolved?.provider ?? 'resolving'}> <ProviderBadge providerName={resolved ? providerName(resolved.provider) : null} tier={resolved?.tier ?? 'download'} modelId={modelId} /> </span> <span data-model-id={modelId ?? ''} className="sr-only"> {modelId ?? ''} </span> <div className="flex flex-wrap items-center gap-1.5 text-[10px]"> {['Works offline', 'Runs locally', `${DIRECTED_PAIR_COUNT} pairs`, `Opus-MT · ${OPUS_MT_SIZE}`].map( (b) => ( <span key={b} className="rounded-full border border-border px-2 py-0.5 text-muted-foreground"> {b} </span> ), )} </div> </div> {} <div className="flex items-center gap-2"> <LanguagePairSelector languages={LANGUAGES} sourceCode={source} targetCode={target} onSelectSource={selectSource} onSelectTarget={selectTarget} onSwap={swap} /> </div> <div className="grid gap-3 sm:grid-cols-2"> {} <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between text-xs text-muted-foreground"> <span>{languageByCode(source).name}</span> <span>{input.length} chars</span> </div> <textarea aria-label="Text to translate" dir="auto" value={input} onChange={(e) => setInput(e.target.value)} disabled={isLoading} rows={6} placeholder="Enter text to translate…" className="w-full resize-y rounded-md border border-border bg-background p-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60" /> <button type="button" onClick={() => setInput(TRANSLATE_SAMPLE)} disabled={isLoading} className="self-start rounded text-xs text-muted-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" > Load sample </button> </div> {} <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between text-xs text-muted-foreground"> <span>{targetName}</span> {output && <span>{output.length} chars</span>} </div> <div data-provider={resolved?.provider ?? ''} role="region" aria-label="Translation" aria-live="polite" dir="auto" className="min-h-[8.5rem] whitespace-pre-wrap rounded-md border border-border bg-muted/30 p-2 text-sm" > {isLoading ? ( <span className="flex flex-col gap-2" aria-label="Translating…"> {[0, 1, 2].map((i) => ( <span key={i} className="h-3 animate-pulse rounded bg-muted" style={{ width: `${90 - i * 15}%` }} /> ))} </span> ) : output ? ( output ) : ( <span className="text-muted-foreground">Translation will appear here…</span> )} </div> {output && !isLoading && ( <span className="self-start"> <CopyButton value={output} /> </span> )} </div> </div> <div className="flex items-center gap-2"> <button type="button" data-running={isLoading} onClick={isLoading ? cancel : translate} disabled={!ready || (!input.trim() && !isLoading)} className={cn( 'inline-flex h-8 items-center rounded-md px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50', isLoading ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : 'bg-primary text-primary-foreground hover:bg-primary/90', )} > {isLoading ? 'Stop' : ready ? `Translate to ${targetName}` : 'Preparing…'} </button> </div> {appErr && ( <span> <ErrorAlert message={appErr.message} onRetry={translate} onDismiss={reset} /> </span> )} </div> );}