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
+72 -71
View File
@@ -29,8 +29,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench, Server } from "lucide-react";
import { format } from "date-fns";
import { AdminToolsTab } from "@/components/admin-tools-tab";
import { useTranslation } from "react-i18next";
export default function Admin() {
const { t } = useTranslation();
const [, setLocation] = useLocation();
const { user, isAdmin, isLoading: authLoading } = useAuth();
const { toast } = useToast();
@@ -68,9 +70,9 @@ export default function Admin() {
<Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4">
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">Admin Access Required</h2>
<p className="text-muted-foreground">You need admin rights to view this page.</p>
<Button variant="outline" onClick={() => setLocation("/")}>Go Home</Button>
<h2 className="text-2xl font-bold">{t("admin.accessRequired")}</h2>
<p className="text-muted-foreground">{t("admin.accessRequiredSub")}</p>
<Button variant="outline" onClick={() => setLocation("/")}>{t("admin.goHome")}</Button>
</div>
</Layout>
);
@@ -82,7 +84,7 @@ export default function Admin() {
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } },
{
onSuccess: () => {
toast({ title: "User created", description: `${newUsername} has been created.` });
toast({ title: t("admin.toastUserCreated"), description: t("admin.toastUserCreatedSub", { username: newUsername }) });
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setCreateOpen(false);
setNewUsername("");
@@ -91,7 +93,7 @@ export default function Admin() {
setNewRole("user");
},
onError: (err) => {
toast({ title: "Failed to create user", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("admin.toastUserCreateFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -103,12 +105,12 @@ export default function Admin() {
{ id: editUser.id, data: { role: editUser.role as "admin" | "user", tier: editUser.tier as "free" | "premium" | "enterprise" } },
{
onSuccess: () => {
toast({ title: "User updated" });
toast({ title: t("admin.toastUserUpdated") });
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setEditUser(null);
},
onError: (err) => {
toast({ title: "Failed to update user", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("admin.toastUserUpdateFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -117,18 +119,18 @@ export default function Admin() {
const handleSetUserPassword = () => {
if (!editUser || !editPassword) return;
if (editPassword.length < 6) {
toast({ title: "Password too short", description: "Minimum 6 characters.", variant: "destructive" });
toast({ title: t("admin.toastPwTooShort"), description: t("admin.toastPwTooShortSub"), variant: "destructive" });
return;
}
setUserPassword.mutate(
{ id: editUser.id, data: { password: editPassword } },
{
onSuccess: () => {
toast({ title: "Password updated", description: `Password for ${editUser.username} has been set.` });
toast({ title: t("admin.toastPwUpdated"), description: t("admin.toastPwUpdatedSub", { username: editUser.username }) });
setEditPassword("");
},
onError: (err) => {
toast({ title: "Failed to set password", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
toast({ title: t("admin.toastPwSetFailed"), description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -140,12 +142,12 @@ export default function Admin() {
{ id: deleteConfirm.id },
{
onSuccess: () => {
toast({ title: "User deleted", description: `${deleteConfirm.username} has been removed.` });
toast({ title: t("admin.toastUserDeleted"), description: t("admin.toastUserDeletedSub", { username: deleteConfirm.username }) });
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setDeleteConfirm(null);
},
onError: (err) => {
toast({ title: "Failed to delete user", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
toast({ title: t("admin.toastUserDeleteFailed"), description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -163,11 +165,11 @@ export default function Admin() {
<div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
<p className="text-muted-foreground">Manage users and review system changes.</p>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("admin.panel")}</h1>
<p className="text-muted-foreground">{t("admin.panelSub")}</p>
</div>
<Button asChild variant="outline" size="sm" className="gap-2">
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> Redundancy Dashboard</Link>
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> {t("admin.redundancyDashboard")}</Link>
</Button>
</div>
</div>
@@ -175,16 +177,16 @@ export default function Admin() {
<Tabs defaultValue="users">
<TabsList className="mb-4">
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" /> Users
<Users className="w-4 h-4" /> {t("admin.tabUsers")}
</TabsTrigger>
<TabsTrigger value="tools" className="gap-2">
<Wrench className="w-4 h-4" /> Tools
<Wrench className="w-4 h-4" /> {t("admin.tabTools")}
</TabsTrigger>
<TabsTrigger value="audit" className="gap-2">
<ScrollText className="w-4 h-4" /> Audit Log
<ScrollText className="w-4 h-4" /> {t("admin.tabAudit")}
</TabsTrigger>
<TabsTrigger value="system" className="gap-2">
<Server className="w-4 h-4" /> System
<Server className="w-4 h-4" /> {t("admin.tabSystem")}
</TabsTrigger>
</TabsList>
@@ -192,11 +194,11 @@ export default function Admin() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Local Users</CardTitle>
<CardDescription>Manage accounts for local authentication.</CardDescription>
<CardTitle>{t("admin.localUsers")}</CardTitle>
<CardDescription>{t("admin.localUsersSub")}</CardDescription>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" /> Add User
<Plus className="w-4 h-4 mr-2" /> {t("admin.addUser")}
</Button>
</CardHeader>
<CardContent>
@@ -248,7 +250,7 @@ export default function Admin() {
</div>
))}
{(!users || users.length === 0) && (
<p className="text-sm text-muted-foreground py-4 text-center">No users yet.</p>
<p className="text-sm text-muted-foreground py-4 text-center">{t("admin.noUsersYet")}</p>
)}
</div>
)}
@@ -263,8 +265,8 @@ export default function Admin() {
<TabsContent value="audit">
<Card>
<CardHeader>
<CardTitle>Audit Log</CardTitle>
<CardDescription>All create, update and delete operations tracked by the system.</CardDescription>
<CardTitle>{t("admin.auditLog")}</CardTitle>
<CardDescription>{t("admin.auditLogSub")}</CardDescription>
</CardHeader>
<CardContent>
{loadingLogs ? (
@@ -289,8 +291,7 @@ export default function Admin() {
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>by <span className="font-medium text-foreground">{log.username}</span></span>
{log.changes && (
<span>{t("admin.by")} <span className="font-medium text-foreground">{log.username}</span></span> {log.changes && (
<span className="truncate max-w-[400px] font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">
{log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes}
</span>
@@ -299,7 +300,7 @@ export default function Admin() {
</div>
))}
{(!auditLogs || auditLogs.length === 0) && (
<p className="text-sm text-muted-foreground py-4 text-center">No audit entries yet.</p>
<p className="text-sm text-muted-foreground py-4 text-center">{t("admin.noAuditEntries")}</p>
)}
</div>
)}
@@ -310,17 +311,17 @@ export default function Admin() {
<TabsContent value="system">
<Card>
<CardHeader>
<CardTitle>System</CardTitle>
<CardDescription>Build information of the currently live deployment.</CardDescription>
<CardTitle>{t("admin.system")}</CardTitle>
<CardDescription>{t("admin.systemSub")}</CardDescription>
</CardHeader>
<CardContent>
<div className="divide-y">
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm text-muted-foreground">{t("admin.version")}</span>
<span className="text-sm font-medium">{versionInfo?.version || "dev"}</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Commit</span>
<span className="text-sm text-muted-foreground">{t("admin.commit")}</span>
{versionInfo?.commitSha ? (
<a
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${versionInfo.commitSha}`}
@@ -336,17 +337,17 @@ export default function Admin() {
)}
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Build date</span>
<span className="text-sm text-muted-foreground">{t("admin.buildDate")}</span>
<span className="text-sm">
{versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"}
</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Trash retention</span>
<span className="text-sm text-muted-foreground">{t("admin.trashRetention")}</span>
<span className="text-sm">
{(versionInfo?.trashRetentionDays ?? 0) > 0
? `${versionInfo?.trashRetentionDays} days`
: "Keep forever"}
? `${versionInfo?.trashRetentionDays} ${t("admin.days")}`
: t("admin.keepForever")}
</span>
</div>
</div>
@@ -359,51 +360,51 @@ export default function Admin() {
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
<DialogTitle>{t("admin.createNewUser")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Username</Label>
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="username" />
<Label>{t("admin.username")}</Label>
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder={t("admin.usernamePlaceholder")} />
</div>
<div className="space-y-2">
<Label>Password</Label>
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
<Label>{t("admin.password")}</Label>
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder={t("admin.minPasswordPlaceholder")} />
</div>
<div className="space-y-2">
<Label>Email (Optional)</Label>
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="user@example.com" />
<Label>{t("admin.emailOptional")}</Label>
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder={t("admin.emailPlaceholder")} />
</div>
<div className="space-y-2">
<Label>Role</Label>
<Label>{t("admin.role")}</Label>
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Label>{t("admin.plan")}</Label>
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="premium">Premium</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setCreateOpen(false)}>{t("common.cancel")}</Button>
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
{createUser.isPending ? "Creating" : "Create User"}
{createUser.isPending ? t("admin.creating") : t("admin.createUser")}
</Button>
</DialogFooter>
</DialogContent>
@@ -412,11 +413,11 @@ export default function Admin() {
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Edit User {editUser?.username}</DialogTitle>
<DialogTitle>{t("admin.editUser", { username: editUser?.username })}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Role</Label>
<Label>{t("admin.role")}</Label>
<Select
value={editUser?.role || "user"}
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
@@ -425,13 +426,13 @@ export default function Admin() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Label>{t("admin.plan")}</Label>
<Select
value={editUser?.tier || "free"}
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
@@ -440,36 +441,36 @@ export default function Admin() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="premium">Premium</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
</SelectContent>
</Select>
</div>
{editUser?.authProvider !== "oidc" ? (
<div className="space-y-2 border-t pt-4">
<Label>Set Password</Label>
<Label>{t("admin.setPassword")}</Label>
<PasswordInput
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
placeholder="min. 6 characters"
placeholder={t("admin.minPasswordPlaceholder")}
data-testid="input-set-password"
/>
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
<p className="text-xs text-muted-foreground">{t("admin.resetsPassword")}</p>
</div>
) : (
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
Password is managed by the identity provider (Keycloak). Reset it there.
{t("admin.idpManaged")}
</div>
)}
</div>
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>{t("common.cancel")}</Button>
<Button
onClick={handleUpdateUser}
disabled={updateUser.isPending}
>
Save
{t("common.save")}
</Button>
{editUser?.authProvider !== "oidc" && (
<Button
@@ -477,7 +478,7 @@ export default function Admin() {
onClick={handleSetUserPassword}
disabled={setUserPassword.isPending || !editPassword}
>
{setUserPassword.isPending ? "Setting" : "Set Password"}
{setUserPassword.isPending ? t("admin.setting") : t("admin.setPassword")}
</Button>
)}
</DialogFooter>
@@ -487,15 +488,15 @@ export default function Admin() {
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogTitle>{t("admin.deleteUser")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground py-2">
Are you sure you want to delete <span className="font-medium text-foreground">{deleteConfirm?.username}</span>? This cannot be undone.
{t("admin.deleteUserConfirm", { username: deleteConfirm?.username })}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>{t("common.cancel")}</Button>
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
{deleteUser.isPending ? "Deleting" : "Delete"}
{deleteUser.isPending ? t("detail.deleting") : t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
+18 -16
View File
@@ -5,6 +5,7 @@ import {
useGetRatingDistribution,
GetTopToolsMetric
} from "@workspace/api-client-react";
import { useTranslation } from "react-i18next";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -17,6 +18,7 @@ import {
import { BarChart3, TrendingUp, Layers, Activity } from "lucide-react";
export default function Analytics() {
const { t } = useTranslation();
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({
@@ -50,10 +52,10 @@ export default function Analytics() {
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
Platform Analytics
<GuideHelp guide="analytics" label="Platform Analytics" />
{t("analytics.title")}
<GuideHelp guide="analytics" label={t("analytics.title")} />
</h1>
<p className="text-muted-foreground">Macro-level insights into tool performance and community engagement.</p>
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
</div>
{/* Top KPI Cards */}
@@ -61,7 +63,7 @@ export default function Analytics() {
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-muted-foreground">Total Tools Indexed</span>
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalToolsIndexed")}</span>
<Layers className="w-4 h-4 text-muted-foreground" />
</div>
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
@@ -73,7 +75,7 @@ export default function Analytics() {
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-muted-foreground">Total Ratings Cast</span>
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalRatingsCast")}</span>
<Activity className="w-4 h-4 text-muted-foreground" />
</div>
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
@@ -85,7 +87,7 @@ export default function Analytics() {
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-muted-foreground">Active Categories</span>
<span className="text-sm font-medium text-muted-foreground">{t("analytics.activeCategories")}</span>
<BarChart3 className="w-4 h-4 text-muted-foreground" />
</div>
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
@@ -97,7 +99,7 @@ export default function Analytics() {
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-muted-foreground">Avg Global Score</span>
<span className="text-sm font-medium text-muted-foreground">{t("analytics.avgGlobalScore")}</span>
<TrendingUp className="w-4 h-4 text-muted-foreground" />
</div>
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
@@ -112,8 +114,8 @@ export default function Analytics() {
{/* Top Tools Chart */}
<Card className="col-span-1 lg:col-span-2">
<CardHeader>
<CardTitle>Top 8 Tools by Combined Score</CardTitle>
<CardDescription>Highest rated tools across the platform</CardDescription>
<CardTitle>{t("analytics.top8Tools")}</CardTitle>
<CardDescription>{t("analytics.top8ToolsSub")}</CardDescription>
</CardHeader>
<CardContent>
{loadingTopTools ? (
@@ -140,8 +142,8 @@ export default function Analytics() {
{/* Category Breakdown */}
<Card>
<CardHeader>
<CardTitle>Tools by Category</CardTitle>
<CardDescription>Distribution of tools across categories</CardDescription>
<CardTitle>{t("analytics.toolsByCategory")}</CardTitle>
<CardDescription>{t("analytics.toolsByCategorySub")}</CardDescription>
</CardHeader>
<CardContent>
{loadingCategories ? (
@@ -153,7 +155,7 @@ export default function Analytics() {
<PolarGrid stroke="hsl(var(--border))" />
<PolarAngleAxis dataKey="category" tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={false} axisLine={false} />
<Radar name="Tools" dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
<Radar name={t("analytics.radarTools")} dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
<RechartsTooltip />
</RadarChart>
</ResponsiveContainer>
@@ -165,8 +167,8 @@ export default function Analytics() {
{/* Rating Distributions */}
<Card>
<CardHeader>
<CardTitle>Global Rating Distribution</CardTitle>
<CardDescription>How users are voting across all tools</CardDescription>
<CardTitle>{t("analytics.globalRatingDistribution")}</CardTitle>
<CardDescription>{t("analytics.globalRatingDistributionSub")}</CardDescription>
</CardHeader>
<CardContent>
{loadingDistribution ? (
@@ -174,7 +176,7 @@ export default function Analytics() {
) : (
<div className="space-y-5">
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usefulness</span>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usefulness")}</span>
<div className="h-[110px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
@@ -188,7 +190,7 @@ export default function Analytics() {
</div>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usability</span>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usability")}</span>
<div className="h-[110px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
+4 -4
View File
@@ -145,7 +145,7 @@ export default function Compare() {
))}
</TableRow>
<TableRow>
<TableCell className="font-medium">Usefulness</TableCell>
<TableCell className="font-medium">{t("detail.usefulness")}</TableCell>
{list.map((t) => (
<TableCell key={t.id} className={cn(isBest(t.avgUsefulness, bestUsefulness) && "bg-primary/5")}>
<span className="tabular-nums">{fmt(t.avgUsefulness)}/5</span>
@@ -153,7 +153,7 @@ export default function Compare() {
))}
</TableRow>
<TableRow>
<TableCell className="font-medium">Usability</TableCell>
<TableCell className="font-medium">{t("detail.usability")}</TableCell>
{list.map((t) => (
<TableCell key={t.id} className={cn(isBest(t.avgUsability, bestUsability) && "bg-primary/5")}>
<span className="tabular-nums">{fmt(t.avgUsability)}/5</span>
@@ -177,7 +177,7 @@ export default function Compare() {
))}
</TableRow>
<TableRow>
<TableCell className="font-medium">Features</TableCell>
<TableCell className="font-medium">{t("filter.features")}</TableCell>
{list.map((t) => (
<TableCell key={t.id} className="align-top">
<div className="flex flex-wrap gap-1">
@@ -190,7 +190,7 @@ export default function Compare() {
))}
</TableRow>
<TableRow>
<TableCell className="font-medium">Tags</TableCell>
<TableCell className="font-medium">{t("filter.tags")}</TableCell>
{list.map((t) => (
<TableCell key={t.id} className="align-top">
<div className="flex flex-wrap gap-1">
+55 -20
View File
@@ -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>
+16 -14
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Layout } from "@/components/layout";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -11,6 +12,7 @@ import { customFetch } from "@workspace/api-client-react";
import { useToast } from "@/hooks/use-toast";
export default function RedundancyPage() {
const { t } = useTranslation();
const [data, setData] = useState<any[] | null>(null);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
@@ -28,10 +30,10 @@ export default function RedundancyPage() {
method: "POST",
body: JSON.stringify({ toolId, relatedToolId, betterToolId }),
});
toast({ title: "Evaluation saved" });
toast({ title: t("redundancy.toastEvalSaved") });
setData(await customFetch<any[]>("/api/admin/redundancy"));
} catch {
toast({ title: "Failed to save evaluation", variant: "destructive" });
toast({ title: t("redundancy.toastEvalFailed"), variant: "destructive" });
}
}
@@ -44,10 +46,10 @@ export default function RedundancyPage() {
<div className="space-y-6">
<div className="flex items-center gap-3">
<AlertTriangle className="w-6 h-6 text-amber-500" />
<h1 className="text-3xl font-bold">Tool Analysis & Recommendations</h1>
<h1 className="text-3xl font-bold">{t("redundancy.title")}</h1>
</div>
<p className="text-muted-foreground">
Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice.
{t("redundancy.subtitle")}
</p>
{loading ? (
@@ -61,12 +63,12 @@ export default function RedundancyPage() {
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
<p className="text-xs text-muted-foreground mt-0.5">{group.tools.length} tools, {group.pairs.length} comparisons</p>
<p className="text-xs text-muted-foreground mt-0.5">{t("redundancy.toolsComparisons", { tools: group.tools.length, pairs: group.pairs.length })}</p>
</div>
{group.totalMonthlyCost > 0 && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<DollarSign className="w-3 h-3" />
{group.totalMonthlyCost.toFixed(2)}/mo total
{group.totalMonthlyCost.toFixed(2)}{t("redundancy.totalMonthly")}
</span>
)}
</div>
@@ -82,12 +84,12 @@ export default function RedundancyPage() {
<span className="font-medium">{tool.name}</span>
{tool.costs?.length > 0 && tool.totalMonthly > 0 && (
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
{tool.totalMonthly.toFixed(2)}/mo
{tool.totalMonthly.toFixed(2)}{t("redundancy.perMonth")}
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
<span>{tool.ratingCount} reviews</span>
<span>{tool.ratingCount} {t("redundancy.reviews")}</span>
{tool.avgCombined != null && (
<>
<span>·</span>
@@ -104,7 +106,7 @@ export default function RedundancyPage() {
))}
</div>
</div>
<Badge variant="outline" className="shrink-0">{tool.features.length} features</Badge>
<Badge variant="outline" className="shrink-0">{tool.features.length} {t("redundancy.features")}</Badge>
</div>
</CardContent>
</Card>
@@ -114,7 +116,7 @@ export default function RedundancyPage() {
{group.pairs.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Comparisons & Recommendations</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t("redundancy.comparisonsTitle")}</h3>
{group.pairs.map((pair: any, i: number) => (
<Card key={i} className={pair.recommendation.certainty === "high" ? "border-green-300" : pair.recommendation.certainty === "medium" ? "border-amber-200" : ""}>
<CardContent className="p-4">
@@ -126,11 +128,11 @@ export default function RedundancyPage() {
</span>
<span className="text-[10px] text-muted-foreground">
{pair.a.avgCombined != null ? pair.a.avgCombined.toFixed(1) : "N/A"}
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}/mo` : ""}
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}${t("redundancy.perMonth")}` : ""}
</span>
</div>
<div className="text-center shrink-0">
<div className="text-xs text-muted-foreground font-medium">vs</div>
<div className="text-xs text-muted-foreground font-medium">{t("redundancy.vs")}</div>
<div className="flex items-center gap-1 justify-center mt-0.5">
<Progress value={pair.overlap} className="w-12 h-1.5" />
<span className="text-[10px] text-muted-foreground">{pair.overlap}%</span>
@@ -142,7 +144,7 @@ export default function RedundancyPage() {
</span>
<span className="text-[10px] text-muted-foreground">
{pair.b.avgCombined != null ? pair.b.avgCombined.toFixed(1) : "N/A"}
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}/mo` : ""}
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}${t("redundancy.perMonth")}` : ""}
</span>
</div>
</div>
@@ -188,7 +190,7 @@ export default function RedundancyPage() {
))}
</div>
) : (
<p className="text-muted-foreground py-8 text-center">No tools found.</p>
<p className="text-muted-foreground py-8 text-center">{t("redundancy.noTools")}</p>
)}
</div>
</Layout>
+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>
+54 -48
View File
@@ -32,28 +32,34 @@ import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
import { useTranslation } from "react-i18next";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
description: z.string().min(10, "Description must be at least 10 characters"),
category: z.string().min(2, "Category is required"),
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(),
});
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
type ToolFormValues = z.infer<typeof toolSchema>;
function buildToolSchema(t: (key: string) => string) {
return z.object({
name: z.string().min(2, t("toolForm.nameMin")),
description: z.string().min(10, t("toolForm.descriptionMin")),
category: z.string().min(2, t("toolForm.categoryRequired")),
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(),
});
}
export default function ToolEdit() {
const [match, params] = useRoute("/tools/:id/edit");
const [, setLocation] = useLocation();
const id = parseInt(params?.id || "0", 10);
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const updateTool = useUpdateTool();
const { isAuthenticated, isAdmin } = useAuth();
const toolSchema = buildToolSchema(t);
const { data: tool, isLoading } = useGetTool(id, {
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) },
});
@@ -108,7 +114,7 @@ export default function ToolEdit() {
{ id, data: payload },
{
onSuccess: () => {
toast({ title: "Tool updated", description: "Changes saved successfully." });
toast({ title: t("toolForm.toastUpdated"), description: t("toolForm.toastUpdatedSub") });
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
@@ -120,8 +126,8 @@ export default function ToolEdit() {
},
onError: (err) => {
toast({
title: "Failed to update tool",
description: err.data?.error || err.message || "An unexpected error occurred.",
title: t("toolForm.toastUpdateFailed"),
description: err.data?.error || err.message || t("detail.unexpectedError"),
variant: "destructive",
});
},
@@ -138,13 +144,13 @@ export default function ToolEdit() {
<div className="max-w-3xl mx-auto space-y-6 pb-10">
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
<Link href={`/tools/${id}`}>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to tool
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToTool")}
</Link>
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Edit Tool</h1>
<p className="text-muted-foreground">Update tool details and metadata.</p>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.editTitle")}</h1>
<p className="text-muted-foreground">{t("toolForm.editSubtitle")}</p>
</div>
{isLoading ? (
@@ -160,10 +166,10 @@ export default function ToolEdit() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-primary" />
Tool Details
<GuideHelp guide="tool-bearbeiten" label="Tool Details" />
{t("toolForm.toolDetails")}
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
</CardTitle>
<CardDescription>Modify the tool information below.</CardDescription>
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
@@ -175,11 +181,11 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
{t("toolForm.name")}
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="Tool name" {...field} />
<Input placeholder={t("toolForm.name")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -191,8 +197,8 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
{t("toolForm.category")}
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox value={field.value} onChange={field.onChange} />
@@ -209,11 +215,11 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
{t("toolForm.websiteUrlOptional")}
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} />
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -226,8 +232,8 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
{t("toolForm.iconUrlOptional")}
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
@@ -235,16 +241,16 @@ export default function ToolEdit() {
{field.value ? (
<img
src={field.value}
alt="icon preview"
alt={t("toolForm.iconPreview")}
className="w-full h-full object-contain p-0.5"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="text-xs text-muted-foreground">img</span>
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
)}
</div>
<Input
placeholder="https://example.com/logo.png"
placeholder={t("toolForm.logoPlaceholder")}
type="url"
{...field}
className="flex-1"
@@ -262,12 +268,12 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
{t("toolForm.description")}
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
placeholder="What does this tool do?"
placeholder={t("toolForm.descriptionPlaceholderEdit")}
className="min-h-[120px] resize-none"
{...field}
/>
@@ -281,13 +287,13 @@ export default function ToolEdit() {
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
{t("toolForm.features")}
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
<p className="text-sm text-muted-foreground">{t("toolForm.featuresEditSub")}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
<Plus className="w-4 h-4 mr-2" /> Add Feature
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
</Button>
</div>
<div className="space-y-3">
@@ -302,7 +308,7 @@ export default function ToolEdit() {
<FeatureInput
value={field.value ?? ""}
onChange={field.onChange}
placeholder="e.g. Real-time collaboration"
placeholder={t("toolForm.featurePlaceholder")}
/>
</FormControl>
<Button
@@ -319,7 +325,7 @@ export default function ToolEdit() {
/>
))}
{featureFields.length === 0 && (
<p className="text-sm text-muted-foreground italic">No features added.</p>
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
)}
</div>
</div>
@@ -328,13 +334,13 @@ export default function ToolEdit() {
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
{t("toolForm.tags")}
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
<p className="text-sm text-muted-foreground">{t("toolForm.tagsEditSub")}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
<Plus className="w-4 h-4 mr-2" /> Add Tag
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
@@ -347,7 +353,7 @@ export default function ToolEdit() {
<FormItem className="flex items-center space-y-0 relative w-[200px]">
<FormControl>
<TagInput
placeholder="Tag"
placeholder={t("toolForm.tag")}
className="pr-8 h-9 text-sm"
onChange={field.onChange}
value={field.value ?? ""}
@@ -371,10 +377,10 @@ export default function ToolEdit() {
<div className="pt-6 border-t flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href={`/tools/${id}`}>Cancel</Link>
<Link href={`/tools/${id}`}>{t("common.cancel")}</Link>
</Button>
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
{updateTool.isPending ? "Saving" : "Save Changes"}
{updateTool.isPending ? t("toolForm.saving") : t("toolForm.saveChanges")}
</Button>
</div>
</form>
+54 -48
View File
@@ -20,26 +20,32 @@ import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
import { useTranslation } from "react-i18next";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
description: z.string().min(10, "Description must be at least 10 characters"),
category: z.string().min(2, "Category is required"),
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(),
});
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
type ToolFormValues = z.infer<typeof toolSchema>;
function buildToolSchema(t: (key: string) => string) {
return z.object({
name: z.string().min(2, t("toolForm.nameMin")),
description: z.string().min(10, t("toolForm.descriptionMin")),
category: z.string().min(2, t("toolForm.categoryRequired")),
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(),
});
}
export default function ToolNew() {
const [location, setLocation] = useLocation();
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const createTool = useCreateTool();
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
const toolSchema = buildToolSchema(t);
const form = useForm<ToolFormValues>({
resolver: zodResolver(toolSchema),
defaultValues: {
@@ -76,7 +82,7 @@ export default function ToolNew() {
{ data: payload },
{
onSuccess: (newTool) => {
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
toast({ title: t("toolForm.toastAdded"), description: t("toolForm.toastAddedSub") });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
@@ -86,7 +92,7 @@ export default function ToolNew() {
setLocation(`/tools/${newTool.id}`);
},
onError: () => {
toast({ title: "Failed to add tool", description: "An unexpected error occurred.", variant: "destructive" });
toast({ title: t("toolForm.toastAddFailed"), description: t("detail.unexpectedError"), variant: "destructive" });
},
},
);
@@ -97,24 +103,24 @@ export default function ToolNew() {
<div className="max-w-3xl mx-auto space-y-6 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
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToBrowse")}
</Link>
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Add a New Tool</h1>
<p className="text-muted-foreground">Submit a tool you use to let the community rate and review it.</p>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.addTitle")}</h1>
<p className="text-muted-foreground">{t("toolForm.addSubtitle")}</p>
</div>
{!authLoading && !isAuthenticated && (
<div className="flex items-center gap-4 rounded-md border border-primary/20 bg-primary/5 px-4 py-3">
<LogIn className="w-5 h-5 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Sign in required</p>
<p className="text-xs text-muted-foreground">You must be signed in to submit a tool.</p>
<p className="text-sm font-medium">{t("toolForm.signInRequired")}</p>
<p className="text-xs text-muted-foreground">{t("toolForm.signInRequiredSub")}</p>
</div>
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
Sign in
{t("auth.signIn")}
</Button>
</div>
)}
@@ -123,10 +129,10 @@ export default function ToolNew() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
Tool Details
<GuideHelp guide="tool-anlegen" label="Tool Details" />
{t("toolForm.toolDetails")}
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
</CardTitle>
<CardDescription>Provide the basic information about the tool.</CardDescription>
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
@@ -138,11 +144,11 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
{t("toolForm.name")}
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
</FormControl>
<FormMessage />
</FormItem>
@@ -155,8 +161,8 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
{t("toolForm.category")}
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox
@@ -176,11 +182,11 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
{t("toolForm.websiteUrlOptional")}
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
</FormControl>
<FormMessage />
</FormItem>
@@ -193,8 +199,8 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
{t("toolForm.iconUrlOptional")}
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
@@ -202,16 +208,16 @@ export default function ToolNew() {
{field.value ? (
<img
src={field.value}
alt="icon preview"
alt={t("toolForm.iconPreview")}
className="w-full h-full object-contain p-0.5"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="text-xs text-muted-foreground">img</span>
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
)}
</div>
<Input
placeholder="https://example.com/logo.png"
placeholder={t("toolForm.logoPlaceholder")}
type="url"
{...field}
data-testid="input-tool-icon-url"
@@ -230,12 +236,12 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
{t("toolForm.description")}
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
placeholder="What does this tool do? Why do people use it?"
placeholder={t("toolForm.descriptionPlaceholderNew")}
className="min-h-[120px] resize-none"
{...field}
data-testid="input-tool-description"
@@ -250,10 +256,10 @@ export default function ToolNew() {
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
{t("toolForm.features")}
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
</div>
<Button
type="button"
@@ -262,7 +268,7 @@ export default function ToolNew() {
onClick={() => appendFeature({ value: "" })}
data-testid="button-add-feature"
>
<Plus className="w-4 h-4 mr-2" /> Add Feature
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
</Button>
</div>
@@ -278,7 +284,7 @@ export default function ToolNew() {
<FeatureInput
value={field.value ?? ""}
onChange={field.onChange}
placeholder="e.g. Real-time collaboration"
placeholder={t("toolForm.featurePlaceholder")}
data-testid={`input-feature-${index}`}
/>
</FormControl>
@@ -297,7 +303,7 @@ export default function ToolNew() {
/>
))}
{featureFields.length === 0 && (
<p className="text-sm text-muted-foreground italic">No features added.</p>
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
)}
</div>
</div>
@@ -306,10 +312,10 @@ export default function ToolNew() {
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
{t("toolForm.tags")}
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
</div>
<Button
type="button"
@@ -318,7 +324,7 @@ export default function ToolNew() {
onClick={() => appendTag({ value: "" })}
data-testid="button-add-tag"
>
<Plus className="w-4 h-4 mr-2" /> Add Tag
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
</Button>
</div>
@@ -332,7 +338,7 @@ export default function ToolNew() {
<FormItem className="flex items-center space-y-0 relative w-[200px]">
<FormControl>
<TagInput
placeholder="Tag"
placeholder={t("toolForm.tagPlaceholder")}
className="pr-8 h-9 text-sm"
onChange={field.onChange}
value={field.value ?? ""}
@@ -362,7 +362,7 @@ export default function ToolsBrowse() {
<button
onClick={() => setFeatures(features.filter((x) => x !== f))}
className="rounded-sm hover:bg-muted p-0.5"
aria-label={`Remove feature ${f}`}
aria-label={t("filter.removeFeature", { feature: f })}
>
<X className="w-3.5 h-3.5" />
</button>
@@ -374,14 +374,14 @@ export default function ToolsBrowse() {
<button
onClick={() => setMinRating(null)}
className="rounded-sm hover:bg-muted p-0.5"
aria-label="Remove min rating"
aria-label={t("filter.removeMinRating")}
>
<X className="w-3.5 h-3.5" />
</button>
</Badge>
)}
<Button variant="link" size="sm" className="px-1 text-muted-foreground" onClick={clearFilters}>
Clear all
{t("common.clearAll")}
</Button>
</div>
)}
@@ -521,15 +521,15 @@ export default function ToolsBrowse() {
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Compare is a Premium feature</AlertDialogTitle>
<AlertDialogTitle>{t("browse.comparePremiumTitle")}</AlertDialogTitle>
<AlertDialogDescription>
Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.
{t("browse.comparePremiumSub")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Not now</AlertDialogCancel>
<AlertDialogCancel>{t("browse.notNow")}</AlertDialogCancel>
<AlertDialogAction asChild>
<a href="/admin?tab=plan">Upgrade to Premium</a>
<a href="/admin?tab=plan">{t("browse.upgradePremium")}</a>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+29 -30
View File
@@ -11,7 +11,6 @@ import {
getListAllTagsQueryKey,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
type Tool,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
@@ -91,12 +90,12 @@ export default function Trash() {
{ data: { ids } },
{
onSuccess: (res) => {
toast({ title: "Tools restored", description: `${res.restored ?? ids.length} tool(s) restored.` });
toast({ title: t("trash.toastRestored"), description: t("trash.toastRestoredSub", { count: res.restored ?? ids.length }) });
setSelected(new Set());
invalidate();
},
onError: (err) => {
toast({ title: "Failed to restore", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("trash.toastRestoreFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -107,13 +106,13 @@ export default function Trash() {
{ data: { ids } },
{
onSuccess: () => {
toast({ title: "Tools deleted", description: `${ids.length} tool(s) permanently removed.` });
toast({ title: t("trash.toastDeleted"), description: t("trash.toastDeletedSub", { count: ids.length }) });
setSelected(new Set());
setConfirmDelete(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to delete", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("trash.toastDeleteFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -124,13 +123,13 @@ export default function Trash() {
undefined,
{
onSuccess: (res) => {
toast({ title: "Trash emptied", description: `${res.deleted ?? 0} tool(s) permanently removed.` });
toast({ title: t("trash.toastEmptied"), description: t("trash.toastEmptiedSub", { count: res.deleted ?? 0 }) });
setSelected(new Set());
setConfirmEmpty(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to empty trash", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("trash.toastEmptyFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -227,48 +226,48 @@ export default function Trash() {
<Checkbox
checked={selected.size === trashed.length && trashed.length > 0}
onCheckedChange={toggleAll}
aria-label="Select all"
aria-label={t("trash.selectAll")}
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>{t("trash.name")}</TableHead>
<TableHead>{t("trash.category")}</TableHead>
<TableHead>{t("trash.deletedAt")}</TableHead>
<TableHead>{t("trash.deletedBy")}</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead className="text-right">{t("trash.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{trashed.map((t: Tool) => (
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
{trashed.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("trash.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.deletedAt ? format(new Date(t.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
{tool.deletedAt ? format(new Date(tool.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.deletedBy ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{tool.deletedBy ?? "—"}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleRestore([t.id])} disabled={restore.isPending}>
<RotateCcw className="w-3.5 h-3.5" /> Restore
<Button variant="ghost" size="sm" onClick={() => handleRestore([tool.id])} disabled={restore.isPending}>
<RotateCcw className="w-3.5 h-3.5" /> {t("trash.restoreAction")}
</Button>
{isAdmin && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleDeletePermanent([t.id])}
onClick={() => handleDeletePermanent([tool.id])}
disabled={deletePermanent.isPending}
>
<TrashIcon className="w-3.5 h-3.5" /> Delete
<TrashIcon className="w-3.5 h-3.5" /> {t("trash.deleteAction")}
</Button>
)}
</div>
@@ -285,13 +284,13 @@ export default function Trash() {
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedIds.length} tool(s) permanently?</AlertDialogTitle>
<AlertDialogTitle>{t("trash.deleteConfirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.
{t("trash.deleteConfirmSub")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleDeletePermanent(selectedIds)}
@@ -305,13 +304,13 @@ export default function Trash() {
<AlertDialog open={confirmEmpty} onOpenChange={setConfirmEmpty}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Empty the trash?</AlertDialogTitle>
<AlertDialogTitle>{t("trash.emptyConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes all {trashed.length} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone.
{t("trash.emptyConfirmSub", { count: trashed.length })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleEmpty}
+1 -1
View File
@@ -62,7 +62,7 @@ export default function Watchlist() {
{t("watchlist.emptySub")}
</p>
<Button variant="outline" asChild>
<a href="/tools">Browse tools</a>
<a href="/tools">{t("common.browseTools")}</a>
</Button>
</div>
) : (