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
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "wouter";
import {
useListTools,
@@ -10,7 +11,6 @@ import {
getListAllTagsQueryKey,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
type ToolWithStats,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
@@ -27,6 +27,7 @@ import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
import { format } from "date-fns";
export function AdminToolsTab() {
const { t } = useTranslation();
const { isAdmin, isLoading: authLoading } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -82,20 +83,20 @@ export function AdminToolsTab() {
{ data: { ids } },
{
onSuccess: (res) => {
toast({ title: "Tools moved to trash", description: `${res.trashed ?? ids.length} tool(s) moved to trash.` });
toast({ title: t("adminTools.toastMoved"), description: t("adminTools.toastMovedSub", { count: res.trashed ?? ids.length }) });
setSelected(new Set());
setConfirmTrash(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to move to trash", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("adminTools.toastMoveFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
}
if (!authLoading && !isAdmin) {
return <p className="text-sm text-muted-foreground py-8 text-center">Admin access required.</p>;
return <p className="text-sm text-muted-foreground py-8 text-center">{t("adminTools.accessRequired")}</p>;
}
const selectedIds = [...selected];
@@ -104,9 +105,9 @@ export function AdminToolsTab() {
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
<div className="space-y-1.5">
<CardTitle>All Tools</CardTitle>
<CardTitle>{t("adminTools.allTools")}</CardTitle>
<CardDescription>
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${allTools.length} tool(s)`}
{selectedIds.length > 0 ? t("adminTools.selected", { count: selectedIds.length }) : t("adminTools.toolCount", { count: allTools.length })}
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
@@ -114,7 +115,7 @@ export function AdminToolsTab() {
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8 w-56"
placeholder="Search tools…"
placeholder={t("adminTools.searchPlaceholder")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
@@ -125,7 +126,7 @@ export function AdminToolsTab() {
disabled={selectedIds.length === 0 || trash.isPending}
onClick={() => setConfirmTrash(true)}
>
<Trash2 className="w-4 h-4 mr-2" /> Move to trash ({selectedIds.length})
<Trash2 className="w-4 h-4 mr-2" /> {t("adminTools.moveToTrash", { count: selectedIds.length })}
</Button>
</div>
</CardHeader>
@@ -135,7 +136,7 @@ export function AdminToolsTab() {
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : allTools.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">{search ? "No tools match your search." : "No tools yet."}</p>
<p className="text-sm text-muted-foreground py-8 text-center">{search ? t("adminTools.noMatch") : t("adminTools.noToolsYet")}</p>
) : (
<Table>
<TableHeader>
@@ -144,58 +145,58 @@ export function AdminToolsTab() {
<Checkbox
checked={selected.size === allTools.length && allTools.length > 0}
onCheckedChange={toggleAll}
aria-label="Select all"
aria-label={t("adminTools.selectAll")}
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Rating</TableHead>
<TableHead>Created by</TableHead>
<TableHead>Created at</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t("adminTools.name")}</TableHead>
<TableHead>{t("adminTools.category")}</TableHead>
<TableHead>{t("adminTools.rating")}</TableHead>
<TableHead>{t("adminTools.createdBy")}</TableHead>
<TableHead>{t("adminTools.createdAt")}</TableHead>
<TableHead className="text-right">{t("adminTools.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allTools.map((t: ToolWithStats) => (
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
{allTools.map((tool) => (
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
<TableCell>
<Checkbox
checked={selected.has(t.id)}
onCheckedChange={() => toggle(t.id)}
aria-label={`Select ${t.name}`}
checked={selected.has(tool.id)}
onCheckedChange={() => toggle(tool.id)}
aria-label={t("adminTools.selectName", { name: tool.name })}
/>
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="font-medium">{tool.name}</TableCell>
<TableCell>
<Badge variant="outline">{t.category}</Badge>
<Badge variant="outline">{tool.category}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{t.ratingCount > 0 ? (
{tool.ratingCount > 0 ? (
<span className="inline-flex items-center gap-1">
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
{t.avgCombined != null ? t.avgCombined.toFixed(1) : "—"} ({t.ratingCount})
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "—"} ({tool.ratingCount})
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.createdBy ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{tool.createdBy ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(t.createdAt), "dd.MM.yyyy")}
{format(new Date(tool.createdAt), "dd.MM.yyyy")}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/tools/${t.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
<Link href={`/tools/${tool.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/tools/${t.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
<Link href={`/tools/${tool.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => handleTrash([t.id])}
onClick={() => handleTrash([tool.id])}
disabled={trash.isPending}
>
<Trash2 className="w-3.5 h-3.5" />
@@ -212,18 +213,18 @@ export function AdminToolsTab() {
<AlertDialog open={confirmTrash} onOpenChange={setConfirmTrash}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Move {selectedIds.length} tool(s) to trash?</AlertDialogTitle>
<AlertDialogTitle>{t("adminTools.confirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
<AlertDialogDescription>
The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted.
{t("adminTools.confirmSub")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleTrash(selectedIds)}
>
Move to trash
{t("adminTools.moveToTrashAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -23,7 +24,8 @@ interface CategoryComboboxProps {
placeholder?: string;
}
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
export function CategoryCombobox({ value, onChange, placeholder }: CategoryComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
@@ -49,6 +51,8 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
(c) => c.toLowerCase() === inputValue.toLowerCase()
);
const resolvedPlaceholder = placeholder ?? t("category.placeholder");
function select(val: string) {
onChange(val);
setInputValue(val);
@@ -66,7 +70,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
data-testid="button-category-combobox"
>
<span className={cn(!value && "text-muted-foreground")}>
{value || placeholder}
{value || resolvedPlaceholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -74,7 +78,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search or enter new category..."
placeholder={t("category.searchPlaceholder")}
value={inputValue}
onValueChange={(v) => {
setInputValue(v);
@@ -84,10 +88,10 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
/>
<CommandList>
{filtered.length === 0 && !showCreateOption && (
<CommandEmpty>No categories found.</CommandEmpty>
<CommandEmpty>{t("category.noCategories")}</CommandEmpty>
)}
{filtered.length > 0 && (
<CommandGroup heading="Known categories">
<CommandGroup heading={t("category.knownCategories")}>
{filtered.map((cat) => (
<CommandItem
key={cat}
@@ -104,13 +108,13 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
</CommandGroup>
)}
{showCreateOption && (
<CommandGroup heading="Create new">
<CommandGroup heading={t("category.createNew")}>
<CommandItem
value={inputValue}
onSelect={() => select(inputValue.trim())}
data-testid="item-category-create-new"
>
<span className="text-primary font-medium">+ Create</span>
<span className="text-primary font-medium">{t("category.create")}</span>
<span className="ml-2 text-muted-foreground">&ldquo;{inputValue.trim()}&rdquo;</span>
</CommandItem>
</CommandGroup>
@@ -1,4 +1,5 @@
import { ChevronDown, Lightbulb, LightbulbOff, Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -11,6 +12,7 @@ import {
import { useTheme, type Theme } from "@/hooks/use-theme";
export function ThemeToggle() {
const { t } = useTranslation();
const { theme, setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
@@ -25,8 +27,8 @@ export function ThemeToggle() {
size="icon"
className="h-9 w-9"
onClick={quickToggle}
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
title={isDark ? t("theme.switchLight") : t("theme.switchDark")}
aria-label={isDark ? t("theme.switchLight") : t("theme.switchDark")}
data-testid="button-theme-quick-toggle"
>
{isDark ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
@@ -37,27 +39,27 @@ export function ThemeToggle() {
variant="ghost"
size="icon"
className="h-9 w-9"
aria-label="Choose theme"
title="Choose theme"
aria-label={t("theme.chooseTitle")}
title={t("theme.chooseTitle")}
data-testid="button-theme-menu"
>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuLabel>{t("theme.title")}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
<DropdownMenuRadioItem value="light">
<Sun className="w-4 h-4 mr-2" />
Light
{t("theme.light")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="w-4 h-4 mr-2" />
Dark
{t("theme.dark")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<Monitor className="w-4 h-4 mr-2" />
System
{t("theme.system")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
@@ -1,4 +1,5 @@
import { Link } from "wouter";
import { useTranslation } from "react-i18next";
import type { ToolWithStats } from "@workspace/api-client-react";
import { Badge } from "@/components/ui/badge";
@@ -6,6 +7,7 @@ import { RatingStars } from "@/components/rating-stars";
import { MiniBars } from "@/components/mini-bars";
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
const { t } = useTranslation();
return (
<div className="space-y-3">
<div>
@@ -22,7 +24,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{tool.ratingCount} review{tool.ratingCount === 1 ? "" : "s"}</span>
<span className="text-muted-foreground">{t("browse.reviewCount", { count: tool.ratingCount })}</span>
<span className="font-semibold tabular-nums">
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : ""}/5
</span>
@@ -47,8 +49,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
href={`/tools/${tool.id}`}
className="block w-full rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-center text-xs font-medium text-primary hover:bg-primary/10"
>
View details
</Link>
</div>
{t("common.viewDetails")}
</Link> </div>
);
}