feat(i18n): full app + docs localization (de/en)
Build & Push Docker Image / build (push) Successful in 2m33s

- translate remaining pages/components (admin, analytics, redundancy,
  trash, compare, browse, watchlist, admin tools tab, category combobox,
  theme toggle, tool preview card, tool-new/edit) to t() calls
- locales: 453 keys each, parity verified
- docs: bilingual handbook + release notes via *.en.md variants,
  language-aware docs.tsx (markdown paths, nav titles, search index),
  LanguageSwitcher in docs header
- generate-docs: emit per-locale handbook/release/search output,
  localized index.json fields (fileEn/titleEn), en-aware snapshots
This commit is contained in:
opencode
2026-08-04 08:35:05 +02:00
parent a65e2eb92c
commit 3de59ccaaf
77 changed files with 3786 additions and 440 deletions
+55 -20
View File
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme-toggle";
import { LanguageSwitcher } from "@/components/language-switcher";
import {
Select,
SelectContent,
@@ -57,6 +58,20 @@ function docsFile(version: string | null, relPath: string) {
return version === null ? relPath : `versions/${version}/${relPath}`;
}
// Prefer the English file/title variant when the active UI language is English.
function useDocsLocale() {
const { i18n } = useTranslation();
return (i18n.language ?? "en").toLowerCase().startsWith("en") ? "en" : "de";
}
function localizedFile(file: string, fileEn: string | null, locale: "en" | "de"): string {
return locale === "en" && fileEn ? fileEn : file;
}
function localizedTitle(title: string, titleEn: string | null, locale: "en" | "de"): string {
return locale === "en" && titleEn ? titleEn : title;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -100,13 +115,22 @@ type Reference = { tags: TagGroup[]; schemas: SchemaModel[] };
type ReleaseDoc = {
version: string;
file: string;
fileEn: string | null;
title: string;
titleEn: string | null;
date: string | null;
hasReference: boolean;
hasHandbook: boolean;
};
type HandbookPage = { slug: string; file: string; title: string; order: number };
type HandbookPage = {
slug: string;
file: string;
fileEn: string | null;
title: string;
titleEn: string | null;
order: number;
};
type SearchEntry = { title: string; href: string; kind: string; text: string };
@@ -339,6 +363,7 @@ function DocsNav({
}) {
const [location] = useLocation();
const { t } = useTranslation();
const locale = useDocsLocale();
const navLink = (href: string) => {
const active = location === href || (href !== "/docs" && location.startsWith(href));
@@ -353,7 +378,7 @@ function DocsNav({
icon: BookOpen,
items: handbook.map((p) => ({
href: docsHref(version, `handbook/${p.slug}`),
label: p.title,
label: localizedTitle(p.title, p.titleEn, locale),
active: navLink(docsHref(version, `handbook/${p.slug}`)),
})),
});
@@ -536,7 +561,7 @@ function FieldTable({ fields }: { fields: Field[] }) {
</td>
<td className="px-3 py-2">
{f.required ? (
<Badge className="bg-primary/10 text-primary border-primary/20">required</Badge>
<Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
@@ -611,8 +636,8 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">Name</th>
<th className="px-3 py-2 font-semibold">In</th>
<th className="px-3 py-2 font-semibold">{t("docs.name")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.in")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.type")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.required")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
@@ -625,7 +650,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
<td className="px-3 py-1.5 text-muted-foreground">{p.in}</td>
<td className="px-3 py-1.5"><FieldTypeChip type={p.type} /></td>
<td className="px-3 py-1.5">
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">req</Badge> : ""}
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</Badge> : ""}
</td>
<td className="px-3 py-1.5 text-muted-foreground">
{p.description}
@@ -642,7 +667,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
{ep.requestBody && (
<div className="mb-3 rounded-lg border bg-muted/30 p-3">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Request Body {ep.requestBody.required && <Badge className="ml-1">required</Badge>}
{t("docs.requestBody")} {ep.requestBody.required && <Badge className="ml-1">{t("docs.required")}</Badge>}
</p>
<FieldTypeChip type={ep.requestBody.schema} />
</div>
@@ -679,6 +704,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
const [, setLocation] = useLocation();
const { t } = useTranslation();
const locale = useDocsLocale();
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
@@ -697,7 +723,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
<FileText className="h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{v.title}</span>
<span className="font-semibold">{localizedTitle(v.title, v.titleEn, locale)}</span>
<Badge variant="secondary">{v.version}</Badge>
</div>
{v.date && (
@@ -707,7 +733,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
</span>
)}
</div>
{v.hasReference && <Badge className="bg-primary/10 text-primary">API-Referenz</Badge>}
{v.hasReference && <Badge className="bg-primary/10 text-primary">{t("docs.reference")}</Badge>}
</button>
))}
</div>
@@ -717,7 +743,9 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
);
}
function ReleaseNoteView({ version }: { version: string }) {
function ReleaseNoteView({ version, doc }: { version: string; doc: ReleaseDoc | null }) {
const locale = useDocsLocale();
const file = doc ? `releases/${localizedFile(doc.file, doc.fileEn, locale)}` : `releases/${version}.md`;
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl">
@@ -733,16 +761,20 @@ function ReleaseNoteView({ version }: { version: string }) {
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<MarkdownView file={`releases/${version}.md`} />
<MarkdownView file={file} />
</div>
</div>
);
}
function HandbookView({ slug }: { slug: string }) {
function HandbookView({ slug, pages }: { slug: string; pages: HandbookPage[] | null }) {
const version = useDocsVersion();
const handbookFile = `${slug}.md`;
return <MarkdownView file={docsFile(version, `handbook/${handbookFile}`)} />;
const locale = useDocsLocale();
const page = pages?.find((p) => p.slug === slug);
const file = page
? docsFile(version, `handbook/${localizedFile(page.file, page.fileEn, locale)}`)
: docsFile(version, `handbook/${slug}.md`);
return <MarkdownView file={file} />;
}
// ---------------------------------------------------------------------------
@@ -750,7 +782,9 @@ function HandbookView({ slug }: { slug: string }) {
// ---------------------------------------------------------------------------
function useDocsSearch(query: string) {
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/search.json` : null);
const locale = useDocsLocale();
const indexFile = locale === "en" ? "search.en.json" : "search.json";
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/${indexFile}` : null);
const results = useMemo(() => {
if (!query.trim() || !data) return [];
const q = query.trim().toLowerCase();
@@ -879,21 +913,21 @@ export default function Docs() {
if (version !== null && path.length === 0) {
content =
handbook && handbook.length > 0 ? (
<HandbookView slug={handbook[0].slug} />
<HandbookView slug={handbook[0].slug} pages={handbook} />
) : (
<ReleaseNoteView version={version} />
<ReleaseNoteView version={version} doc={activeRelease} />
);
} else if (section === "home") {
content =
handbook && handbook.length > 0 ? (
<HandbookView slug={handbook[0].slug} />
<HandbookView slug={handbook[0].slug} pages={handbook} />
) : releases && releases.length > 0 ? (
<ReleasesView versions={releases} />
) : (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
);
} else if (section === "handbook" && param) {
content = <HandbookView slug={param} />;
content = <HandbookView slug={param} pages={handbook} />;
} else if (section === "reference" && param === "endpoints" && path[2]) {
const tag = reference?.tags.find(
(tg) => tg.name.toLowerCase() === path[2].toLowerCase(),
@@ -963,7 +997,7 @@ export default function Docs() {
</div>
);
} else if (section === "releases" && param) {
content = <ReleaseNoteView version={param} />;
content = <ReleaseNoteView version={param} doc={releases?.find((r) => r.version === param) ?? null} />;
} else if (section === "releases") {
content = releases ? <ReleasesView versions={releases} /> : <Skeleton className="h-64 w-full" />;
} else {
@@ -1014,6 +1048,7 @@ export default function Docs() {
</Link>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<LanguageSwitcher />
<ThemeToggle />
</div>
</div>