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
+66 -66
View File
@@ -121,24 +121,24 @@ export default function ToolDetail() {
method: "POST",
body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }),
});
toast({ title: "Relation created" });
toast({ title: t("detail.toastRelationCreated") });
setLinkDialogOpen(false);
setLinkToolId("");
setLinkNotes("");
setLinkType("similar");
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
} catch (err: any) {
toast({ title: "Failed to create relation", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("detail.toastRelationFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
}
}
async function handleDeleteRelation(relationId: number) {
try {
await customFetch(`/api/tools/relations/${relationId}`, { method: "DELETE" });
toast({ title: "Relation deleted" });
toast({ title: t("detail.toastRelationDeleted") });
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
} catch {
toast({ title: "Failed to delete relation", variant: "destructive" });
toast({ title: t("detail.toastRelationDeleteFailed"), variant: "destructive" });
}
}
@@ -163,12 +163,12 @@ export default function ToolDetail() {
if (costAmount) body.cost = costAmount;
try {
await customFetch(url, { method, body: JSON.stringify(body) });
toast({ title: editCost ? "Cost updated" : "Cost added" });
toast({ title: editCost ? t("detail.toastCostUpdated") : t("detail.toastCostAdded") });
setCostDialogOpen(false);
resetCostForm();
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
} catch (err: any) {
toast({ title: "Failed to save cost", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("detail.toastCostFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
}
}
@@ -194,10 +194,10 @@ export default function ToolDetail() {
async function handleDeleteCost(costId: number) {
try {
await customFetch(`/api/costs/${costId}`, { method: "DELETE" });
toast({ title: "Cost deleted" });
toast({ title: t("detail.toastCostDeleted") });
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
} catch {
toast({ title: "Failed to delete cost", variant: "destructive" });
toast({ title: t("detail.toastCostDeleteFailed"), variant: "destructive" });
}
}
@@ -237,8 +237,8 @@ export default function ToolDetail() {
createRating.mutate({ id, data }, {
onSuccess: () => {
toast({
title: "Rating submitted",
description: "Thank you for your feedback!",
title: t("detail.toastRatingSubmitted"),
description: t("detail.toastRatingThanks"),
});
setIsReviewFormOpen(false);
form.reset();
@@ -252,8 +252,8 @@ export default function ToolDetail() {
},
onError: (error) => {
toast({
title: "Failed to submit rating",
description: error.data?.error || error.message || "An unexpected error occurred.",
title: t("detail.toastRatingFailed"),
description: error.data?.error || error.message || t("detail.unexpectedError"),
variant: "destructive"
});
}
@@ -264,9 +264,9 @@ export default function ToolDetail() {
return (
<Layout>
<div className="flex flex-col items-center justify-center py-20">
<h2 className="text-2xl font-bold">Invalid Tool ID</h2>
<h2 className="text-2xl font-bold">{t("detail.invalidToolId")}</h2>
<Button variant="link" asChild className="mt-4">
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to tools</Link>
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
</Button>
</div>
</Layout>
@@ -288,7 +288,7 @@ export default function ToolDetail() {
{ id },
{
onSuccess: () => {
toast({ title: "Tool deleted" });
toast({ title: t("detail.toastToolDeleted") });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
@@ -297,7 +297,7 @@ export default function ToolDetail() {
setLocation("/tools");
},
onError: (err) => {
toast({ title: "Failed to delete", description: err.data?.error || err.message, variant: "destructive" });
toast({ title: t("detail.toastDeleteFailed"), description: err.data?.error || err.message, variant: "destructive" });
setDeleteOpen(false);
},
},
@@ -309,7 +309,7 @@ export default function ToolDetail() {
<Layout>
<div className="space-y-6 max-w-5xl mx-auto pb-10">
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to browse</Link>
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
</Button>
{/* Header Section */}
@@ -408,7 +408,7 @@ export default function ToolDetail() {
{tool.features && tool.features.length > 0 && (
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold mb-3">Key Features</h3>
<h3 className="text-lg font-semibold mb-3">{t("detail.keyFeatures")}</h3>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{tool.features.map((feature, i) => (
<li key={i} className="flex items-start gap-2">
@@ -423,17 +423,17 @@ export default function ToolDetail() {
)}
</div>
) : (
<div className="text-center py-10">Tool not found.</div>
<div className="text-center py-10">{t("detail.toolNotFound")}</div>
)}
{/* Similar Tools Section */}
{tool && (
<div className="space-y-4 mt-8">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold">Similar Tools</h3>
<h3 className="text-xl font-bold">{t("detail.similarTools")}</h3>
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => setLinkDialogOpen(true)} className="gap-2">
<LinkIcon className="w-4 h-4" /> Link Tool
<LinkIcon className="w-4 h-4" /> {t("detail.linkTool")}
</Button>
)}
</div>
@@ -501,7 +501,7 @@ export default function ToolDetail() {
</>
)}
<span>·</span>
<span>Score: {item.score}</span>
<span>{t("detail.score")}: {item.score}</span>
</div>
</div>
</CardContent>
@@ -510,7 +510,7 @@ export default function ToolDetail() {
))}
</div>
) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">No similar tools found.</p>
<p className="text-sm text-muted-foreground py-4">{t("detail.noSimilarTools")}</p>
) : null}
</div>
)}
@@ -519,44 +519,44 @@ export default function ToolDetail() {
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Link Similar Tool</DialogTitle>
<DialogDescription>Manually link this tool to another tool.</DialogDescription>
<DialogTitle>{t("detail.linkSimilarTool")}</DialogTitle>
<DialogDescription>{t("detail.linkSimilarToolSub")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">Tool ID</label>
<label className="text-sm font-medium">{t("detail.toolId")}</label>
<Input
type="number"
placeholder="Enter target tool ID"
placeholder={t("detail.toolIdPlaceholder")}
value={linkToolId}
onChange={(e) => setLinkToolId(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Relation Type</label>
<label className="text-sm font-medium">{t("detail.relationType")}</label>
<Select value={linkType} onValueChange={setLinkType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="similar">Similar</SelectItem>
<SelectItem value="replaces">Replaces</SelectItem>
<SelectItem value="superseded_by">Superseded By</SelectItem>
<SelectItem value="similar">{t("detail.relationSimilar")}</SelectItem>
<SelectItem value="replaces">{t("detail.relationReplaces")}</SelectItem>
<SelectItem value="superseded_by">{t("detail.relationSupersededBy")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Notes (Optional)</label>
<label className="text-sm font-medium">{t("detail.notesOptional")}</label>
<Textarea
placeholder="Why are these tools related?"
placeholder={t("detail.notesPlaceholder")}
value={linkNotes}
onChange={(e) => setLinkNotes(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreateRelation} disabled={!linkToolId}>Create Link</Button>
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>{t("common.cancel")}</Button>
<Button onClick={handleCreateRelation} disabled={!linkToolId}>{t("detail.createLink")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -587,7 +587,7 @@ export default function ToolDetail() {
{c.billingPeriod && <span className="text-[10px] text-muted-foreground uppercase">{c.billingPeriod}</span>}
</div>
<div className="text-lg font-bold">
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : t("detail.licenseFree")}
</div>
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
</div>
@@ -607,7 +607,7 @@ export default function ToolDetail() {
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4">No cost information added yet.</p>
<p className="text-sm text-muted-foreground py-4">{t("detail.noCostInfo")}</p>
)}
</div>
)}
@@ -620,41 +620,41 @@ export default function ToolDetail() {
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
<GuideHelp guide="kosten" label={t("detail.costs")} />
</DialogTitle>
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">License Type</label>
<label className="text-sm font-medium">{t("detail.licenseType")}</label>
<Select value={costLicenseType} onValueChange={setCostLicenseType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="subscription">Subscription</SelectItem>
<SelectItem value="one_time">One-Time</SelectItem>
<SelectItem value="usage_based">Usage-Based</SelectItem>
<SelectItem value="free">{t("detail.licenseFree")}</SelectItem>
<SelectItem value="subscription">{t("detail.licenseSubscription")}</SelectItem>
<SelectItem value="one_time">{t("detail.licenseOneTime")}</SelectItem>
<SelectItem value="usage_based">{t("detail.licenseUsageBased")}</SelectItem>
</SelectContent>
</Select>
</div>
{costLicenseType === "subscription" && (
<div className="space-y-2">
<label className="text-sm font-medium">Billing Period</label>
<label className="text-sm font-medium">{t("detail.billingPeriod")}</label>
<Select value={costBillingPeriod} onValueChange={setCostBillingPeriod}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="monthly">{t("detail.billingMonthly")}</SelectItem>
<SelectItem value="quarterly">{t("detail.billingQuarterly")}</SelectItem>
<SelectItem value="yearly">{t("detail.billingYearly")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Cost</label>
<label className="text-sm font-medium">{t("detail.costLabel")}</label>
<Input type="number" step="0.01" placeholder="0.00" value={costAmount} onChange={(e) => setCostAmount(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Currency</label>
<label className="text-sm font-medium">{t("detail.currency")}</label>
<Select value={costCurrency} onValueChange={setCostCurrency}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -667,13 +667,13 @@ export default function ToolDetail() {
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Notes</label>
<Textarea placeholder="Billing details, contract info..." value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
<label className="text-sm font-medium">{t("detail.costNotes")}</label>
<Textarea placeholder={t("detail.costNotesPlaceholder")} value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveCost}>Save</Button>
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>{t("common.cancel")}</Button>
<Button onClick={handleSaveCost}>{t("common.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -744,9 +744,9 @@ export default function ToolDetail() {
<YAxis domain={[0, 5]} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} axisLine={false} tickLine={false} />
<Tooltip labelFormatter={(_, payload) => (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="combined" name="Combined" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="usefulness" name="Usefulness" stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="usability" name="Usability" stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="combined" name={t("detail.score")} stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="usefulness" name={t("detail.usefulness")} stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="usability" name={t("detail.usability")} stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
</LineChart>
</ResponsiveContainer>
</div>
@@ -773,7 +773,7 @@ export default function ToolDetail() {
{t("detail.addReview")}
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
</CardTitle>
<CardDescription>Share your experience with {tool.name}</CardDescription>
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
@@ -829,12 +829,12 @@ export default function ToolDetail() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Comment (Optional)
<FieldHelp schema="RatingInput" field="comment">Comment</FieldHelp>
{t("detail.commentOptional")}
<FieldHelp schema="RatingInput" field="comment">{t("detail.commentLabel")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
placeholder="What do you think about this tool?"
placeholder={t("detail.commentPlaceholder")}
className="resize-none min-h-[100px]"
{...field}
/>
@@ -850,11 +850,11 @@ export default function ToolDetail() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Name (Optional)
<FieldHelp schema="RatingInput" field="reviewerName">Name</FieldHelp>
{t("detail.nameOptional")}
<FieldHelp schema="RatingInput" field="reviewerName">{t("detail.nameLabel")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="Anonymous" {...field} />
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -868,7 +868,7 @@ export default function ToolDetail() {
onClick={() => setIsReviewFormOpen(false)}
disabled={createRating.isPending}
>
Cancel
{t("common.cancel")}
</Button>
<Button type="submit" disabled={createRating.isPending}>
{createRating.isPending ? t("common.loading") : t("detail.submit")}
@@ -891,7 +891,7 @@ export default function ToolDetail() {
<CardContent className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<span className="font-semibold">{rating.reviewerName || "Anonymous Engineer"}</span>
<span className="font-semibold">{rating.reviewerName || t("detail.anonymousEngineer")}</span>
<span className="text-muted-foreground text-sm ml-2">
{format(new Date(rating.createdAt), "MMM d, yyyy")}
</span>
@@ -921,7 +921,7 @@ export default function ToolDetail() {
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
<h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
<p className="text-muted-foreground mt-1">{t("detail.beFirstToReview")}</p>
</div>
)}
</div>