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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user