KI-Übersetzung
Mit KI übersetzen und prüfen. Original und Übersetzung nebeneinander mit Spracherkennung und Tauschen, andere Formulierungen je Satz mit markierten Unsicherheiten, ein Glossar fester Begriffe mit der Anrede und ganze Dokumente in mehrere Sprachen. Jede Ansicht sagt, dass eine Maschine übersetzt hat.
Schnell übersetzen, gezielt prüfen
Maschinelle Übersetzung geht schnell, die Arbeit liegt im Prüfen. Diese vier Screens machen das Prüfen leicht, sie markieren unsichere Sätze, halten feste Begriffe jedes Mal gleich und sagen in jeder Ansicht, dass eine Maschine übersetzt hat.
„Nebeneinander“ hat zwei Flächen mit den Sprachen darüber und Tauschen in der Mitte, die Ausgangssprache wird mit ihrer Sicherheit erkannt, Zeichen werden gezählt und die Übersetzung lässt sich mit einem Klick kopieren. „Formulierungen je Satz“ stellt Original und Übersetzung Satz für Satz nebeneinander, Zeigen hebt das Gegenstück hervor, und jeder Satz öffnet andere Formulierungen mit Hinweis, unsichere bleiben markiert, bis du wählst. „Glossar und Anrede“ führt links feste Begriffe, die immer gleich oder gar nicht übersetzt werden, markiert jeden angewendeten Begriff und wechselt zwischen Sie und Du. „Dokumente“ übersetzt ganze Dateien in mehrere Sprachen auf einmal, mit Seiten, Wörtern und Fortschritt je Datei und gezählten Stellen zum Prüfen.
Leg zuerst das Glossar an, Produktnamen und Schlüsselbegriffe, dann beginnt jede Übersetzung einheitlich. Entscheide die Anrede einmal je Sprache und bleib dabei. Lass einen Menschen die markierten Stellen prüfen, bevor etwas live geht.
Variants
Details
Was KI-Übersetzung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Schreiben und Erstellen
- Bereich
- App
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/ai-translation.tsx
- Bringt mit
- utilskiticonrevealmotion
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 60 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AiTranslationProps aus components/ui/ai-translation.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalAiTranslationVariantside-by-sidesentence-alternativesglossarydocument
titleoptionalstringdescriptionoptionalstringlanguagesoptionalTranslationLanguage[]fromoptionalstringSource language code, "auto" detects it
tooptionalstringdetectedoptional{ code: string; confidence: number }The detected source language with the share it is sure of, in percent
sourceoptionalstringside-by-side, glossary: source text
targetoptionalstringside-by-side, glossary: translated text
targetInformaloptionalstringglossary: the text in the informal form
sentencesoptionalTranslationSentence[]sentence-alternatives: the text sentence by sentence
glossaryoptionalGlossaryTerm[]filesoptionalTranslationFile[]document: files and target languages
targetsoptionalstring[]maxCharsoptionalnumberMost characters per text
modeloptionalstringe.g. "Nova Translate"
noteoptionalstringonTranslateoptional(text: string, from: string, to: string) => string | Promise<string>Connect a real translation. Return the text, or a promise of it. Without it the block shows the target text it was given.
Eingebaute Texte
Diese Texte schreibt KI-Übersetzung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 60 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| from | Englisch Translate from | Deutsch Übersetzen aus |
| to | Englisch Translate into | Deutsch Übersetzen in |
| detect | Englisch Detect | Deutsch Erkennen |
| detected | Englisch Detected: {lang} | Deutsch Erkannt: {lang} |
| detectedSure | Englisch {v} sure | Deutsch {v} sicher |
| swap | Englisch Swap languages | Deutsch Sprachen tauschen |
| source | Englisch Text to translate | Deutsch Text zum Übersetzen |
| target | Englisch Translation | Deutsch Übersetzung |
| placeholder | Englisch Type or paste text | Deutsch Text eingeben oder einfügen |
| chars | Englisch {n} of {max} characters | Deutsch {n} von {max} Zeichen |
| translate | Englisch Translate | Deutsch Übersetzen |
| translating | Englisch Translating | Deutsch Wird übersetzt |
| copy | Englisch Copy translation | Deutsch Übersetzung kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| clear | Englisch Clear text | Deutsch Text leeren |
| empty | Englisch The translation appears here. | Deutsch Hier erscheint die Übersetzung. |
| machine | Englisch Translated by AI. Check important texts before you publish them. | Deutsch Von KI übersetzt. Prüfe wichtige Texte, bevor du sie veröffentlichst. |
| sentenceHint | Englisch Choose a sentence for other wordings | Deutsch Wähle einen Satz für andere Formulierungen |
| alternatives | Englisch Other wordings | Deutsch Andere Formulierungen |
| original | Englisch In the source | Deutsch Im Original |
| use | Englisch Use this wording | Deutsch Diese Formulierung nehmen |
| current | Englisch In use | Deutsch Verwendet |
| check | Englisch Please check | Deutsch Bitte prüfen |
| checkN | Englisch {n} sentences to check | Deutsch {n} Sätze zum Prüfen |
| check1 | Englisch 1 sentence to check | Deutsch 1 Satz zum Prüfen |
| glossary | Englisch Glossary | Deutsch Glossar |
| glossaryHint | Englisch Terms that are always translated the same way, or not at all. | Deutsch Begriffe, die immer gleich oder gar nicht übersetzt werden. |
| term | Englisch Term | Deutsch Begriff |
| termTarget | Englisch Translation | Deutsch Übersetzung |
| keep | Englisch Keep as is | Deutsch Nicht übersetzen |
| useRule | Englisch Always as | Deutsch Immer als |
| add | Englisch Add term | Deutsch Begriff hinzufügen |
| remove | Englisch Remove {term} | Deutsch {term} entfernen |
| applied | Englisch {n} terms applied | Deutsch {n} Begriffe angewendet |
| applied1 | Englisch 1 term applied | Deutsch 1 Begriff angewendet |
| fromGlossary | Englisch From the glossary | Deutsch Aus dem Glossar |
| form | Englisch Form of address | Deutsch Anrede |
| formal | Englisch Formal | Deutsch Sie |
| informal | Englisch Informal | Deutsch Du |
| documents | Englisch Documents | Deutsch Dokumente |
| targets | Englisch Target languages | Deutsch Zielsprachen |
| choose | Englisch Choose files | Deutsch Dateien wählen |
| drop | Englisch Word, PDF and slides, layout stays as it is | Deutsch Word, PDF und Folien, das Layout bleibt erhalten |
| pages | Englisch {n} pages | Deutsch {n} Seiten |
| page1 | Englisch 1 page | Deutsch 1 Seite |
| words | Englisch {n} words | Deutsch {n} Wörter |
| done | Englisch Done | Deutsch Fertig |
| translatingFile | Englisch Translating | Deutsch Wird übersetzt |
| queued | Englisch Waiting | Deutsch Wartet |
| review | Englisch Ready to check | Deutsch Bereit zum Prüfen |
| failed | Englisch Failed | Deutsch Fehlgeschlagen |
| flagged | Englisch {n} passages to check | Deutsch {n} Stellen zum Prüfen |
| flagged1 | Englisch 1 passage to check | Deutsch 1 Stelle zum Prüfen |
| download | Englisch Download {name} | Deutsch {name} herunterladen |
| open | Englisch Check | Deutsch Prüfen |
| retry | Englisch Try again | Deutsch Neu versuchen |
| cancel | Englisch Cancel {name} | Deutsch {name} abbrechen |
| removeLang | Englisch Remove {lang} | Deutsch {lang} entfernen |
| addLang | Englisch Add language | Deutsch Sprache hinzufügen |
| total | Englisch {done} of {total} done | Deutsch {done} von {total} fertig |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- KI-Übersetzung reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-busy, aria-checked, aria-controls, aria-expanded, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, radio und radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 857 Zeilen
'use client';
import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
export type AiTranslationVariant = 'side-by-side' | 'sentence-alternatives' | 'glossary' | 'document';
export interface TranslationLanguage {
code: string;
label: string;
}
export interface TranslationAlternative {
id: string;
text: string;
/** What makes it different, e.g. "More formal" */
note?: string;
}
export interface TranslationSentence {
id: string;
source: string;
target: string;
alternatives?: TranslationAlternative[];
/** low: shown as "please check" */
confidence?: 'high' | 'medium' | 'low';
}
export interface GlossaryTerm {
id: string;
source: string;
/** The fixed translation, leave out with rule "keep" */
target?: string;
/** use: always this translation, keep: never translate */
rule: 'use' | 'keep';
}
export interface TranslationFile {
id: string;
name: string;
pages?: number;
words?: number;
status: 'done' | 'translating' | 'queued' | 'review' | 'failed';
/** 0 to 100 while translating */
progress?: number;
/** review: passages to check */
flagged?: number;
error?: string;
}
export interface AiTranslationProps {
variant?: AiTranslationVariant;
title?: string;
description?: string;
languages?: TranslationLanguage[];
/** Source language code, "auto" detects it */
from?: string;
to?: string;
/** The detected source language with the share it is sure of, in percent */
detected?: { code: string; confidence: number };
/** side-by-side, glossary: source text */
source?: string;
/** side-by-side, glossary: translated text */
target?: string;
/** glossary: the text in the informal form */
targetInformal?: string;
/** sentence-alternatives: the text sentence by sentence */
sentences?: TranslationSentence[];
glossary?: GlossaryTerm[];
/** document: files and target languages */
files?: TranslationFile[];
targets?: string[];
/** Most characters per text */
maxChars?: number;
/** e.g. "Nova Translate" */
model?: string;
note?: string;
/**
* Connect a real translation. Return the text, or a promise of it. Without
* it the block shows the target text it was given.
*/
onTranslate?: (text: string, from: string, to: string) => string | Promise<string>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
from: 'Translate from',
to: 'Translate into',
detect: 'Detect',
detected: 'Detected: {lang}',
detectedSure: '{v} sure',
swap: 'Swap languages',
source: 'Text to translate',
target: 'Translation',
placeholder: 'Type or paste text',
chars: '{n} of {max} characters',
translate: 'Translate',
translating: 'Translating',
copy: 'Copy translation',
copied: 'Copied',
clear: 'Clear text',
empty: 'The translation appears here.',
machine: 'Translated by AI. Check important texts before you publish them.',
sentenceHint: 'Choose a sentence for other wordings',
alternatives: 'Other wordings',
original: 'In the source',
use: 'Use this wording',
current: 'In use',
check: 'Please check',
checkN: '{n} sentences to check',
check1: '1 sentence to check',
glossary: 'Glossary',
glossaryHint: 'Terms that are always translated the same way, or not at all.',
term: 'Term',
termTarget: 'Translation',
keep: 'Keep as is',
useRule: 'Always as',
add: 'Add term',
remove: 'Remove {term}',
applied: '{n} terms applied',
applied1: '1 term applied',
fromGlossary: 'From the glossary',
form: 'Form of address',
formal: 'Formal',
informal: 'Informal',
documents: 'Documents',
targets: 'Target languages',
choose: 'Choose files',
drop: 'Word, PDF and slides, layout stays as it is',
pages: '{n} pages',
page1: '1 page',
words: '{n} words',
done: 'Done',
translatingFile: 'Translating',
queued: 'Waiting',
review: 'Ready to check',
failed: 'Failed',
flagged: '{n} passages to check',
flagged1: '1 passage to check',
download: 'Download {name}',
open: 'Check',
retry: 'Try again',
cancel: 'Cancel {name}',
removeLang: 'Remove {lang}',
addLang: 'Add language',
total: '{done} of {total} done',
},
de: {
from: 'Übersetzen aus',
to: 'Übersetzen in',
detect: 'Erkennen',
detected: 'Erkannt: {lang}',
detectedSure: '{v} sicher',
swap: 'Sprachen tauschen',
source: 'Text zum Übersetzen',
target: 'Übersetzung',
placeholder: 'Text eingeben oder einfügen',
chars: '{n} von {max} Zeichen',
translate: 'Übersetzen',
translating: 'Wird übersetzt',
copy: 'Übersetzung kopieren',
copied: 'Kopiert',
clear: 'Text leeren',
empty: 'Hier erscheint die Übersetzung.',
machine: 'Von KI übersetzt. Prüfe wichtige Texte, bevor du sie veröffentlichst.',
sentenceHint: 'Wähle einen Satz für andere Formulierungen',
alternatives: 'Andere Formulierungen',
original: 'Im Original',
use: 'Diese Formulierung nehmen',
current: 'Verwendet',
check: 'Bitte prüfen',
checkN: '{n} Sätze zum Prüfen',
check1: '1 Satz zum Prüfen',
glossary: 'Glossar',
glossaryHint: 'Begriffe, die immer gleich oder gar nicht übersetzt werden.',
term: 'Begriff',
termTarget: 'Übersetzung',
keep: 'Nicht übersetzen',
useRule: 'Immer als',
add: 'Begriff hinzufügen',
remove: '{term} entfernen',
applied: '{n} Begriffe angewendet',
applied1: '1 Begriff angewendet',
fromGlossary: 'Aus dem Glossar',
form: 'Anrede',
formal: 'Sie',
informal: 'Du',
documents: 'Dokumente',
targets: 'Zielsprachen',
choose: 'Dateien wählen',
drop: 'Word, PDF und Folien, das Layout bleibt erhalten',
pages: '{n} Seiten',
page1: '1 Seite',
words: '{n} Wörter',
done: 'Fertig',
translatingFile: 'Wird übersetzt',
queued: 'Wartet',
review: 'Bereit zum Prüfen',
failed: 'Fehlgeschlagen',
flagged: '{n} Stellen zum Prüfen',
flagged1: '1 Stelle zum Prüfen',
download: '{name} herunterladen',
open: 'Prüfen',
retry: 'Neu versuchen',
cancel: '{name} abbrechen',
removeLang: '{lang} entfernen',
addLang: 'Sprache hinzufügen',
total: '{done} von {total} fertig',
},
};
type Texts = (typeof TEXTS)['en'];
type P = AiTranslationProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
const [state, setState] = useState(value);
const key = JSON.stringify(value);
const seen = useRef(key);
if (seen.current !== key) {
seen.current = key;
setState(value);
}
return [state, setState] as const;
}
const CSS =
'@keyframes atr-up{from{opacity:0;transform:translateY(6px)}}@keyframes atr-in{from{opacity:0;filter:blur(3px)}}@keyframes atr-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}' +
'@media (prefers-reduced-motion:no-preference){.atr-up{animation:atr-up .4s var(--ease-kit) var(--atr-d,0ms) both}.atr-in{animation:atr-in .5s var(--ease-kit) var(--atr-d,0ms) both}.atr-shimmer{animation:atr-shimmer 1.6s linear infinite}}';
function Style() {
return (
<style href="kit-ai-translation" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const btn = 'inline-flex h-11 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3.5 text-[13px] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 sm:h-9';
const primaryBtn = cn(btn, 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]');
const quietBtn = cn(btn, 'border border-border bg-card text-foreground hover:bg-accent');
const iconBtn = 'grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-9';
function Stage({ title, description, children, className, wide = true }: { title?: string; description?: string; children: React.ReactNode; className?: string; wide?: boolean }) {
return (
<section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
<Style />
<div className="kit-container">
<div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[52rem]')}>
{(title || description) && (
<Reveal className="mb-6 sm:mb-8">
{title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
{description && <p className="mt-2 max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
</Reveal>
)}
{children}
</div>
</div>
</section>
);
}
function Machine({ t, model, children }: { t: Texts; model?: string; children?: React.ReactNode }) {
return (
<p className="mt-4 flex items-start gap-2 px-1 text-[12.5px] leading-snug text-muted-foreground">
<Icon name="languages" size={14} className="mt-px shrink-0" />
<span>
{children ?? t.machine}
{model && <span className="text-subtle-foreground"> · {model}</span>}
</span>
</p>
);
}
/** A native select that looks like the kit, with a chevron */
function LangSelect({ id, label, value, onChange, options, auto, t }: { id: string; label: string; value: string; onChange: (v: string) => void; options: TranslationLanguage[]; auto?: boolean; t: Texts }) {
return (
<span className="relative inline-flex min-w-0">
<label htmlFor={id} className="sr-only">
{label}
</label>
<select id={id} value={value} onChange={(e) => onChange(e.target.value)} className="h-11 min-w-0 max-w-full cursor-pointer appearance-none truncate rounded-lg bg-transparent py-0 pl-2.5 pr-8 text-[14px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9">
{auto && <option value="auto">{t.detect}</option>}
{options.map((o) => (
<option key={o.code} value={o.code}>
{o.label}
</option>
))}
</select>
<Icon name="chevron-down" size={15} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
</span>
);
}
function useCopy() {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const id = window.setTimeout(() => setCopied(false), 1800);
return () => window.clearTimeout(id);
}, [copied]);
return [copied, (text: string) => (navigator.clipboard?.writeText(text).catch(() => undefined), setCopied(true))] as const;
}
const langLabel = (languages: TranslationLanguage[], code?: string) => languages.find((l) => l.code === code)?.label ?? code ?? '';
/* ------------------------------------------------------------------ */
/* side-by-side */
/* ------------------------------------------------------------------ */
function SideBySide({ t, title, description, languages = [], from: from0 = 'auto', to: to0 = 'en', detected, source = '', target = '', maxChars = 5000, model, note, onTranslate, className }: P) {
const locale = useLocale();
const uid = useId();
const [from, setFrom] = useState(from0);
const [to, setTo] = useState(to0);
const [text, setText] = useSynced(source);
const [out, setOut] = useSynced(target);
const [busy, setBusy] = useState(false);
const [stale, setStale] = useState(false);
const [spin, setSpin] = useState(0);
const [copied, copy] = useCopy();
const fromCode = from === 'auto' ? detected?.code ?? '' : from;
const run = async () => {
if (!text.trim()) return;
setBusy(true);
const next = onTranslate ? await onTranslate(text, fromCode, to) : text === source ? target : out;
window.setTimeout(() => (setOut(next), setBusy(false), setStale(false)), prefersReducedMotion() ? 0 : 700);
};
const swap = () => {
setSpin((n) => n + 1);
setFrom(to);
setTo(fromCode || to);
setText(out);
setOut(text);
};
return (
<Stage title={title} description={description} className={className}>
<Reveal className={cn(card, 'overflow-hidden')}>
{/* Language bar with swap in the middle */}
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-1 border-b border-border px-2 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<LangSelect id={`${uid}-f`} label={t.from} value={from} onChange={(v) => (setFrom(v), setStale(true))} options={languages} auto t={t} />
</div>
<button type="button" aria-label={t.swap} title={t.swap} disabled={busy} onClick={swap} className={cn(iconBtn, 'border border-border bg-card')}>
<Icon name="arrows-left-right" size={16} className="transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `rotate(${spin * 180}deg)` }} />
</button>
<div className="flex min-w-0 justify-end md:justify-start md:pl-3">
<LangSelect id={`${uid}-t`} label={t.to} value={to} onChange={(v) => (setTo(v), setStale(true))} options={languages.filter((l) => l.code !== fromCode)} t={t} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="relative flex min-h-[15rem] flex-col border-border transition-shadow focus-within:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] max-md:border-b md:border-r">
{from === 'auto' && detected && (
<p className="atr-up mx-4 mt-3 inline-flex w-fit items-center gap-1.5 rounded-md bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] px-2 py-1 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
<Icon name="sparkle" size={12} />
{fill(t.detected, { lang: langLabel(languages, detected.code) })}
<span className="font-normal text-muted-foreground">· {fill(t.detectedSure, { v: formatPercent(detected.confidence, locale) })}</span>
</p>
)}
<label htmlFor={`${uid}-s`} className="sr-only">
{t.source}
</label>
<textarea
id={`${uid}-s`}
value={text}
maxLength={maxChars}
onChange={(e) => (setText(e.target.value), setStale(true))}
placeholder={t.placeholder}
rows={7}
className="block w-full flex-1 resize-none bg-transparent px-4 py-3 text-[16px] leading-relaxed text-foreground placeholder:text-subtle-foreground focus-visible:outline-none sm:text-[17px]"
/>
<div className="flex items-center gap-2 px-2 pb-2">
<button type="button" aria-label={t.clear} title={t.clear} disabled={!text} onClick={() => (setText(''), setStale(true))} className={iconBtn}>
<Icon name="x" size={16} />
</button>
<span className="min-w-0 flex-1 truncate text-[12px] tabular-nums text-subtle-foreground">{fill(t.chars, { n: text.length.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US'), max: maxChars.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US') })}</span>
<button type="button" disabled={busy || !text.trim()} onClick={run} className={cn(stale ? primaryBtn : quietBtn)}>
<Icon name={busy ? 'loader' : 'languages'} size={15} className={busy ? 'animate-spin motion-reduce:animate-none' : undefined} />
{busy ? t.translating : t.translate}
</button>
</div>
</div>
<div className="relative flex min-h-[15rem] flex-col bg-[color-mix(in_oklab,var(--foreground)_2.5%,var(--card))]">
<p className="sr-only" id={`${uid}-o`}>
{t.target}
</p>
<div aria-labelledby={`${uid}-o`} aria-live="polite" aria-busy={busy} className={cn('flex-1 px-4 py-3 text-[16px] leading-relaxed sm:text-[17px]', from === 'auto' && detected && 'md:pt-[3.25rem]', stale && !busy ? 'text-muted-foreground' : 'text-foreground')}>
{busy ? (
<span aria-hidden className="block space-y-2.5 pt-1.5">
{[92, 78, 86, 54].map((w, i) => (
<span key={i} className="atr-shimmer block h-3.5 rounded-md bg-[linear-gradient(90deg,var(--track),color-mix(in_oklab,var(--primary)_20%,var(--track)),var(--track))] bg-[length:200%_100%]" style={{ width: `${w}%` }} />
))}
</span>
) : out ? (
<span key={out} className="atr-in block whitespace-pre-wrap">
{out}
</span>
) : (
<span className="text-subtle-foreground">{t.empty}</span>
)}
</div>
<div className="flex items-center justify-end gap-1 px-2 pb-2">
<button type="button" disabled={!out || busy} onClick={() => copy(out)} className={cn(btn, 'text-muted-foreground hover:bg-accent hover:text-foreground')} aria-live="polite">
<Icon name={copied ? 'check' : 'copy'} size={15} className={copied ? 'text-success-foreground' : undefined} />
{copied ? t.copied : t.copy}
</button>
</div>
</div>
</div>
</Reveal>
<Machine t={t} model={model}>
{note}
</Machine>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* sentence-alternatives */
/* ------------------------------------------------------------------ */
function SentenceAlternatives({ t, title, description, languages = [], from = 'de', to = 'en', sentences: initial = [], model, note, className }: P) {
const uid = useId();
const [sentences, setSentences] = useSynced(initial);
const [hover, setHover] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(initial.find((s) => s.confidence === 'low')?.id ?? null);
const [picked, setPicked] = useState<Record<string, string>>({});
const toCheck = sentences.filter((s) => s.confidence === 'low' && !picked[s.id]).length;
const choose = (s: TranslationSentence, alt: TranslationAlternative) => {
setSentences((list) => list.map((x) => (x.id === s.id ? { ...x, target: alt.text, alternatives: [{ id: `${s.id}-was`, text: s.target }, ...(x.alternatives ?? []).filter((a) => a.id !== alt.id)] } : x)));
setPicked((p) => ({ ...p, [s.id]: alt.id }));
};
return (
<Stage title={title} description={description} className={className}>
<Reveal className={cn(card, 'overflow-hidden')}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<p className="flex items-center gap-2 text-[13px] font-medium text-foreground">
{langLabel(languages, from)}
<Icon name="arrow-right" size={14} className="text-muted-foreground" />
{langLabel(languages, to)}
</p>
<p className={cn('text-[12.5px]', toCheck ? 'text-warning-foreground' : 'text-success-foreground')} aria-live="polite">
{toCheck ? (toCheck === 1 ? t.check1 : fill(t.checkN, { n: toCheck })) : <span className="inline-flex items-center gap-1.5"><Icon name="check-circle" size={14} />{t.current}</span>}
</p>
</div>
<div aria-hidden className="hidden grid-cols-2 border-b border-border text-[12px] font-medium text-muted-foreground md:grid">
<span className="border-r border-border px-4 py-2">{t.original}</span>
<span className="px-4 py-2">{t.target}</span>
</div>
<Stagger as="ol" step={60}>
{sentences.map((s) => {
const on = open === s.id;
const lit = hover === s.id || on;
const low = s.confidence === 'low' && !picked[s.id];
return (
<li key={s.id} className="grid grid-cols-1 border-b border-border last:border-b-0 md:grid-cols-2" onPointerEnter={() => setHover(s.id)} onPointerLeave={() => setHover(null)}>
<p className={cn('border-border px-4 pb-1 pt-3 text-[14.5px] leading-relaxed transition-colors md:border-r md:pb-3', lit ? 'bg-[color-mix(in_oklab,var(--primary)_7%,transparent)] text-foreground' : 'text-muted-foreground')}>
<span className="sr-only">{t.original}: </span>
{s.source}
</p>
<div className={cn('px-2 pb-2 pt-0 transition-colors md:pt-2', lit && 'bg-[color-mix(in_oklab,var(--primary)_7%,transparent)]')}>
<button
type="button"
aria-expanded={on}
aria-controls={`${uid}-${s.id}`}
onClick={() => setOpen(on ? null : s.id)}
onFocus={() => setHover(s.id)}
onBlur={() => setHover(null)}
className="group flex min-h-11 w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)]"
>
<span className={cn('min-w-0 flex-1 text-[14.5px] leading-relaxed text-foreground', low && 'underline decoration-[color-mix(in_oklab,var(--warning)_85%,transparent)] decoration-dotted decoration-2 underline-offset-[5px]')}>
<span key={s.target} className="atr-in">
{s.target}
</span>
{low && <span className="ml-2 inline-flex h-5 items-center rounded-[5px] bg-[color-mix(in_oklab,var(--warning)_16%,transparent)] px-1.5 align-[0.1em] text-[11px] font-medium text-warning-foreground no-underline">{t.check}</span>}
</span>
{s.alternatives?.length ? <Icon name="chevron-down" size={15} className={cn('mt-1 shrink-0 text-subtle-foreground transition-transform duration-300 motion-reduce:transition-none', on && 'rotate-180 text-foreground')} /> : null}
</button>
{on && s.alternatives?.length ? (
<div id={`${uid}-${s.id}`} role="group" aria-label={t.alternatives} className="atr-up mt-1 space-y-1.5 px-2 pb-1">
<p className="text-[12px] font-medium text-muted-foreground">{t.alternatives}</p>
{s.alternatives.map((a) => (
<button key={a.id} type="button" onClick={() => choose(s, a)} className="flex w-full items-start gap-2.5 rounded-lg border border-border bg-card px-3 py-2.5 text-left transition-colors hover:border-border-strong hover:bg-accent">
<Icon name="repeat" size={14} className="mt-1 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block text-[14px] leading-relaxed text-foreground">{a.text}</span>
{a.note && <span className="mt-0.5 block text-[12px] text-muted-foreground">{a.note}</span>}
</span>
<span className="sr-only">{t.use}</span>
</button>
))}
</div>
) : null}
</div>
</li>
);
})}
</Stagger>
</Reveal>
<Machine t={t} model={model}>
{note}
</Machine>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* glossary */
/* ------------------------------------------------------------------ */
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** The text with every glossary term in it marked */
function WithTerms({ text, terms, t }: { text: string; terms: GlossaryTerm[]; t: Texts }) {
const words = terms.map((g) => (g.rule === 'keep' ? g.source : g.target ?? '')).filter(Boolean);
if (!words.length) return <>{text}</>;
const re = new RegExp(`(${words.sort((a, b) => b.length - a.length).map(escape).join('|')})`, 'g');
return (
<>
{text.split(re).map((part, i) =>
words.includes(part) ? (
<mark key={i} title={t.fromGlossary} className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-foreground shadow-[inset_0_-1.5px_0_color-mix(in_oklab,var(--primary)_60%,transparent)]">
{part}
</mark>
) : (
<Fragment key={i}>{part}</Fragment>
),
)}
</>
);
}
function Glossary({ t, title, description, languages = [], from = 'de', to = 'en', source = '', target = '', targetInformal, glossary: initial = [], model, note, className }: P) {
const uid = useId();
const [terms, setTerms] = useSynced(initial);
const [form, setForm] = useState<'formal' | 'informal'>('formal');
const [src, setSrc] = useState('');
const [dst, setDst] = useState('');
const [rule, setRule] = useState<'use' | 'keep'>('use');
const text = form === 'informal' && targetInformal ? targetInformal : target;
const applied = terms.filter((g) => text.includes(g.rule === 'keep' ? g.source : g.target ?? '\u0000')).length;
const add = () => {
if (!src.trim() || (rule === 'use' && !dst.trim())) return;
setTerms((list) => [...list, { id: `g-${list.length}-${src}`, source: src.trim(), target: rule === 'use' ? dst.trim() : undefined, rule }]);
setSrc('');
setDst('');
};
const forms = [
{ id: 'formal' as const, label: t.formal },
{ id: 'informal' as const, label: t.informal },
];
return (
<Stage title={title} description={description} className={className}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start lg:gap-5">
<Reveal className={cn(card, 'p-4')}>
<p className="flex items-center gap-1.5 text-[13px] font-medium text-foreground">
<Icon name="book" size={15} />
{t.glossary}
</p>
<p className="mt-1 text-[12.5px] leading-snug text-muted-foreground">{t.glossaryHint}</p>
<ul className="mt-3 divide-y divide-border rounded-xl border border-border">
{terms.map((g) => (
<li key={g.id} className="atr-up flex items-center gap-2 py-1.5 pl-3 pr-1">
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-medium text-foreground">{g.source}</span>
<span className="flex items-center gap-1 text-[12px] text-muted-foreground">
{g.rule === 'keep' ? (
<>
<Icon name="lock" size={11} />
{t.keep}
</>
) : (
<>
<Icon name="arrow-right" size={11} />
<span className="truncate">{g.target}</span>
</>
)}
</span>
</span>
<button type="button" aria-label={fill(t.remove, { term: g.source })} onClick={() => setTerms((list) => list.filter((x) => x.id !== g.id))} className={iconBtn}>
<Icon name="x" size={14} />
</button>
</li>
))}
</ul>
<form
className="mt-3 space-y-2"
onSubmit={(e) => {
e.preventDefault();
add();
}}
>
<div className="grid grid-cols-2 gap-2">
<label className="min-w-0">
<span className="mb-1 block text-[12px] text-muted-foreground">{t.term}</span>
<input value={src} onChange={(e) => setSrc(e.target.value)} className="h-11 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-[14px] text-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] sm:h-9" />
</label>
<label className="min-w-0">
<span className="mb-1 block text-[12px] text-muted-foreground">{t.termTarget}</span>
<input value={rule === 'keep' ? src : dst} disabled={rule === 'keep'} onChange={(e) => setDst(e.target.value)} className="h-11 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-[14px] text-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] disabled:opacity-60 sm:h-9" />
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex min-h-11 cursor-pointer items-center gap-2 text-[12.5px] font-medium text-foreground sm:min-h-9">
<input type="checkbox" checked={rule === 'keep'} onChange={(e) => setRule(e.target.checked ? 'keep' : 'use')} className="size-4 accent-[var(--primary)]" />
{t.keep}
</label>
<span className="flex-1" />
<button type="submit" disabled={!src.trim() || (rule === 'use' && !dst.trim())} className={quietBtn}>
<Icon name="plus" size={15} />
{t.add}
</button>
</div>
</form>
</Reveal>
<Reveal delay={100} className={cn(card, 'overflow-hidden')}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2.5">
<p className="flex items-center gap-2 text-[13px] font-medium text-foreground">
{langLabel(languages, from)}
<Icon name="arrow-right" size={14} className="text-muted-foreground" />
{langLabel(languages, to)}
</p>
{targetInformal && (
<div role="radiogroup" aria-label={t.form} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1">
{forms.map((f) => (
<button key={f.id} type="button" role="radio" data-f={f.id} aria-checked={form === f.id} tabIndex={form === f.id ? 0 : -1} onClick={() => setForm(f.id)} onKeyDown={(e) => ['ArrowLeft', 'ArrowRight'].includes(e.key) && (e.preventDefault(), setForm(f.id === 'formal' ? 'informal' : 'formal'), (e.currentTarget.parentElement?.querySelector(`[data-f="${f.id === 'formal' ? 'informal' : 'formal'}"]`) as HTMLElement | null)?.focus())} className={cn('inline-flex h-11 min-w-11 items-center justify-center rounded-md px-3 text-[12.5px] font-medium transition-colors sm:h-8', form === f.id ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
{f.label}
</button>
))}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="border-border px-4 py-4 max-md:border-b md:border-r">
<p className="mb-2 text-[12px] font-medium text-muted-foreground">{t.original}</p>
<p className="whitespace-pre-wrap text-[14.5px] leading-relaxed text-muted-foreground">{source}</p>
</div>
<div className="px-4 py-4">
<p className="mb-2 text-[12px] font-medium text-muted-foreground" id={`${uid}-o`}>
{t.target}
</p>
<p key={`${form}-${terms.length}`} aria-labelledby={`${uid}-o`} className="atr-in whitespace-pre-wrap text-[14.5px] leading-relaxed text-foreground">
<WithTerms text={text} terms={terms} t={t} />
</p>
</div>
</div>
<p className="flex items-center gap-1.5 border-t border-border px-4 py-2.5 text-[12.5px] text-muted-foreground" aria-live="polite">
<span aria-hidden className="size-2.5 rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_35%,transparent)]" />
{applied === 1 ? t.applied1 : fill(t.applied, { n: applied })}
</p>
</Reveal>
</div>
<Machine t={t} model={model}>
{note}
</Machine>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* document */
/* ------------------------------------------------------------------ */
const FILE_TONE: Record<TranslationFile['status'], { text: string; dot: string }> = {
done: { text: 'text-success-foreground', dot: 'bg-success' },
translating: { text: 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]', dot: 'bg-primary' },
queued: { text: 'text-muted-foreground', dot: 'bg-muted-foreground' },
review: { text: 'text-warning-foreground', dot: 'bg-warning' },
failed: { text: 'text-destructive', dot: 'bg-destructive' },
};
function DocumentTranslation({ t, title, description, languages = [], files: initial = [], targets: targets0 = [], model, note, className }: P) {
const locale = useLocale();
const uid = useId();
const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US');
const [files, setFiles] = useSynced(initial);
const [targets, setTargets] = useState(targets0);
const [ref, inView] = useInView<HTMLDivElement>();
const input = useRef<HTMLInputElement>(null);
const running = files.some((f) => f.status === 'translating');
const queued = files.some((f) => f.status === 'queued');
useEffect(() => {
if (!inView || prefersReducedMotion() || (!running && !queued)) return;
const id = window.setInterval(() => {
setFiles((cur) => {
let next = cur.map((f) => (f.status === 'translating' ? { ...f, progress: Math.min(100, (f.progress ?? 0) + 1) } : f));
const done = next.findIndex((f) => f.status === 'translating' && (f.progress ?? 0) >= 100);
if (done >= 0) next = next.map((f, i) => (i === done ? { ...f, status: f.flagged ? ('review' as const) : ('done' as const), progress: undefined } : f));
if (!next.some((f) => f.status === 'translating')) {
const q = next.findIndex((f) => f.status === 'queued');
if (q >= 0) next = next.map((f, i) => (i === q ? { ...f, status: 'translating' as const, progress: 0 } : f));
}
return next;
});
}, 140);
return () => window.clearInterval(id);
}, [inView, running, queued, setFiles]);
const statusLabel = (f: TranslationFile) => (f.status === 'translating' ? t.translatingFile : t[f.status]);
const finished = files.filter((f) => f.status === 'done' || f.status === 'review').length;
const free = languages.filter((l) => !targets.includes(l.code));
return (
<Stage title={title} description={description} className={className} wide={false}>
<div ref={ref}>
<Reveal className={cn(card, 'p-4 sm:p-5')}>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[13px] font-medium text-foreground">{t.targets}</p>
<p className="text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
{fill(t.total, { done: finished, total: files.length })}
</p>
</div>
<ul className="mt-2.5 flex flex-wrap items-center gap-1.5">
{targets.map((code) => (
<li key={code} className="atr-up inline-flex h-11 items-center gap-1 rounded-lg border border-border bg-surface pl-3 pr-1 text-[13px] font-medium text-foreground sm:h-9">
{langLabel(languages, code)}
<button type="button" aria-label={fill(t.removeLang, { lang: langLabel(languages, code) })} disabled={targets.length < 2} onClick={() => setTargets((x) => x.filter((c) => c !== code))} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-7">
<Icon name="x" size={13} />
</button>
</li>
))}
{free.length > 0 && (
<li className="relative">
<label htmlFor={`${uid}-add`} className="sr-only">
{t.addLang}
</label>
<select id={`${uid}-add`} value="" onChange={(e) => e.target.value && setTargets((x) => [...x, e.target.value])} className="h-11 cursor-pointer appearance-none rounded-lg border border-dashed border-border-strong bg-transparent pl-8 pr-3 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-9">
<option value="">{t.addLang}</option>
{free.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
<Icon name="plus" size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
</li>
)}
</ul>
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-dashed border-border-strong p-3">
<span aria-hidden className="grid size-10 place-items-center rounded-xl bg-surface-2 text-muted-foreground">
<Icon name="cloud-upload" size={18} />
</span>
<p className="min-w-0 flex-1 text-[13px] leading-snug text-muted-foreground">{t.drop}</p>
<input ref={input} type="file" multiple accept=".doc,.docx,.pdf,.ppt,.pptx,.odt" className="sr-only" tabIndex={-1} onChange={(e) => {
const list = Array.from(e.target.files ?? []);
setFiles((cur) => [...cur, ...list.map((f, i) => ({ id: `new-${cur.length + i}`, name: f.name, status: 'queued' as const, words: Math.round(f.size / 7) }))]);
e.target.value = '';
}} />
<button type="button" onClick={() => input.current?.click()} className={cn(quietBtn, 'max-sm:w-full')}>
<Icon name="paperclip" size={15} />
{t.choose}
</button>
</div>
<ul aria-label={t.documents} className="mt-4 divide-y divide-border">
{files.map((f) => {
const tone = FILE_TONE[f.status];
return (
<li key={f.id} className={cn('flex items-center gap-x-3 gap-y-2 py-3 sm:flex-nowrap', f.status === 'translating' || f.status === 'queued' ? 'flex-nowrap' : 'flex-wrap')}>
<span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
<Icon name={(f.name.endsWith('.pptx') ? 'presentation' : 'file') as IconName} size={17} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-foreground">{f.name}</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[12px] text-muted-foreground">
<span className={cn('inline-flex items-center gap-1.5 font-medium', tone.text)}>
<span aria-hidden className={cn('size-1.5 rounded-full', tone.dot, f.status === 'translating' && 'animate-kit-pulse motion-reduce:animate-none')} />
{statusLabel(f)}
{f.status === 'translating' && <span className="tabular-nums text-muted-foreground">{formatPercent(Math.round(f.progress ?? 0), locale)}</span>}
</span>
{f.pages ? <span className="tabular-nums">· {f.pages === 1 ? t.page1 : fill(t.pages, { n: f.pages })}</span> : null}
{f.words ? <span className="tabular-nums max-sm:hidden">· {fill(t.words, { n: nf.format(f.words) })}</span> : null}
</p>
{f.status === 'translating' && (
<span aria-hidden className="mt-2 block h-1 overflow-hidden rounded-full bg-track">
<span className="block h-full origin-left rounded-full bg-primary transition-transform duration-200" style={{ transform: `scaleX(${(f.progress ?? 0) / 100})` }} />
</span>
)}
{f.status === 'review' && f.flagged ? <p className="mt-1 text-[12px] text-warning-foreground">{f.flagged === 1 ? t.flagged1 : fill(t.flagged, { n: f.flagged })}</p> : null}
{f.status === 'failed' && f.error ? <p className="mt-1 text-[12px] leading-snug text-destructive">{f.error}</p> : null}
</div>
<div className={cn('flex shrink-0 items-center gap-1.5', f.status !== 'translating' && f.status !== 'queued' && 'max-sm:w-full max-sm:pl-[3.25rem]')}>
{f.status === 'review' && (
<button type="button" className={cn(quietBtn, 'max-sm:flex-1')}>
<Icon name="eye" size={15} />
{t.open}
</button>
)}
{(f.status === 'done' || f.status === 'review') && (
<button type="button" aria-label={fill(t.download, { name: f.name })} className={cn(f.status === 'done' ? primaryBtn : quietBtn, 'max-sm:flex-1')}>
<Icon name="download" size={15} />
<span className="tabular-nums">{targets.map((c) => c.toUpperCase()).join(' · ')}</span>
</button>
)}
{f.status === 'failed' && (
<button type="button" onClick={() => setFiles((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: 'queued', error: undefined } : x)))} className={cn(quietBtn, 'max-sm:flex-1')}>
<Icon name="refresh" size={15} />
{t.retry}
</button>
)}
{(f.status === 'translating' || f.status === 'queued') && (
<button type="button" aria-label={fill(t.cancel, { name: f.name })} onClick={() => setFiles((cur) => cur.filter((x) => x.id !== f.id))} className={iconBtn}>
<Icon name="x" size={15} />
</button>
)}
</div>
</li>
);
})}
</ul>
</Reveal>
</div>
<Machine t={t} model={model}>
{note}
</Machine>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* AI translation: source and target side by side with language detection
* and swap, every sentence with other wordings to choose and the ones to
* check marked, a glossary of fixed terms with the form of address, and whole
* documents in several languages with progress and review. Always says that a
* machine translated and that a person should check.
*/
export function AiTranslation({ variant = 'side-by-side', labels, ...props }: AiTranslationProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
switch (variant) {
case 'sentence-alternatives':
return <SentenceAlternatives {...p} />;
case 'glossary':
return <Glossary {...p} />;
case 'document':
return <DocumentTranslation {...p} />;
default:
return <SideBySide {...p} />;
}
}Häufige Fragen
Wie installiere ich KI-Übersetzung?
Mit aiw add @ai-website/ai-translation. Der Befehl legt die Datei components/ui/ai-translation.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal und motion. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat KI-Übersetzung?
4 Varianten, Nebeneinander, Formulierungen je Satz, Glossar und Anrede und Dokumente. Du wählst sie mit der Prop variant, zum Beispiel variant="sentence-alternatives".
Kann ich die Texte von KI-Übersetzung übersetzen?
Ja. KI-Übersetzung bringt 60 eigene Texte auf Deutsch und Englisch mit, etwa „Übersetzen aus“, „Übersetzen in“ und „Erkennen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent KI-Übersetzung einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini KI-Übersetzung mit aiw_components und installieren sie mit aiw_add @ai-website/ai-translation. Nenn im Prompt die Variante, etwa variant="side-by-side", dann schreibt der Assistent deine Inhalte direkt in die Props.