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