feat(i18n): full app + docs localization (de/en)
Build & Push Docker Image / build (push) Successful in 2m33s
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user