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:
@@ -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">“{inputValue.trim()}”</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user