3de59ccaaf
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
225 lines
9.1 KiB
TypeScript
225 lines
9.1 KiB
TypeScript
import { useMemo } from "react";
|
||
import {
|
||
useListCompareTools,
|
||
getListCompareToolsQueryKey,
|
||
type ToolWithStats,
|
||
} from "@workspace/api-client-react";
|
||
import { Link, useSearch } from "wouter";
|
||
import { Layout } from "@/components/layout";
|
||
import { useAuth } from "@/hooks/use-auth";
|
||
import { RatingStars } from "@/components/rating-stars";
|
||
import { GuideHelp } from "@/components/guide-help";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||
import { ShieldAlert, Trophy, Scale } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
function fmt(v: number | null | undefined): string {
|
||
return v != null ? v.toFixed(1) : "–";
|
||
}
|
||
|
||
export default function Compare() {
|
||
const { t } = useTranslation();
|
||
const { hasFeature, isLoading: authLoading } = useAuth();
|
||
const search = useSearch();
|
||
|
||
const params = useMemo(() => {
|
||
const ids = new URLSearchParams(search)
|
||
.get("ids")
|
||
?.split(",")
|
||
.map((s) => Number(s.trim()))
|
||
.filter((n) => Number.isInteger(n) && n > 0);
|
||
return ids && ids.length > 0 ? { ids: ids.join(",") } : undefined;
|
||
}, [search]);
|
||
|
||
const canCompare = hasFeature("compare");
|
||
|
||
const { data: tools, isLoading: loading } = useListCompareTools(
|
||
params ?? { ids: "" },
|
||
{
|
||
query: {
|
||
queryKey: getListCompareToolsQueryKey(params ?? { ids: "" }),
|
||
enabled: !!params && canCompare,
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!authLoading && !canCompare) {
|
||
return (
|
||
<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">{t("compare.requiresPremium")}</h2>
|
||
<p className="text-muted-foreground">{t("compare.requiresPremiumSub")}</p>
|
||
<Button variant="outline" asChild>
|
||
<a href="/tools">{t("common.backToTools")}</a>
|
||
</Button>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<Layout>
|
||
<div className="space-y-6">
|
||
<Skeleton className="h-8 w-48" />
|
||
<Skeleton className="h-64 w-full" />
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
const list = tools ?? [];
|
||
if (list.length === 0) {
|
||
return (
|
||
<Layout>
|
||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||
<Scale className="w-12 h-12 text-muted-foreground" />
|
||
<h2 className="text-2xl font-bold">{t("compare.nothingToCompare")}</h2>
|
||
<p className="text-muted-foreground">{t("compare.nothingToCompareSub")}</p>
|
||
<Button variant="outline" asChild>
|
||
<a href="/tools">{t("common.browseTools")}</a>
|
||
</Button>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
const bestRating = Math.max(...list.map((t) => t.avgCombined ?? 0));
|
||
const bestUsefulness = Math.max(...list.map((t) => t.avgUsefulness ?? 0));
|
||
const bestUsability = Math.max(...list.map((t) => t.avgUsability ?? 0));
|
||
const bestReviews = Math.max(...list.map((t) => t.ratingCount));
|
||
|
||
const isBest = (value: number | null, best: number) =>
|
||
value != null && value > 0 && value >= best && value === best;
|
||
|
||
return (
|
||
<Layout>
|
||
<div className="space-y-6 pb-10">
|
||
<div>
|
||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||
{t("compare.title")}
|
||
<GuideHelp guide="vergleichen" label={t("compare.title")} />
|
||
</h1>
|
||
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{t("compare.sideBySide")}</CardTitle>
|
||
<CardDescription>{t("compare.bestValue")}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-44">{t("compare.attribute")}</TableHead>
|
||
{list.map((t) => (
|
||
<TableHead key={t.id} className="min-w-[12rem]">
|
||
<div className="flex flex-col gap-1">
|
||
<Link to={`/tools/${t.id}`} className="font-semibold hover:text-primary hover:underline">
|
||
{t.name}
|
||
</Link>
|
||
<Badge variant="outline" className="w-fit text-[10px]">{t.category}</Badge>
|
||
</div>
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
<TableRow>
|
||
<TableCell className="font-medium">{t("compare.rating")}</TableCell>
|
||
{list.map((t) => (
|
||
<TableCell key={t.id} className={cn(isBest(t.avgCombined, bestRating) && "bg-primary/5")}>
|
||
<div className="flex items-center gap-2">
|
||
<RatingStars value={t.avgCombined ?? 0} size="sm" />
|
||
<span className="font-semibold tabular-nums">{fmt(t.avgCombined)}/5</span>
|
||
{isBest(t.avgCombined, bestRating) && <Trophy className="w-3.5 h-3.5 text-primary" />}
|
||
</div>
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
<TableRow>
|
||
<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>
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
<TableRow>
|
||
<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>
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
<TableRow>
|
||
<TableCell className="font-medium">{t("compare.reviews")}</TableCell>
|
||
{list.map((t) => (
|
||
<TableCell key={t.id} className={cn(isBest(t.ratingCount, bestReviews) && "bg-primary/5")}>
|
||
<span className="tabular-nums">{t.ratingCount}</span>
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
<TableRow>
|
||
<TableCell className="font-medium">{t("compare.description")}</TableCell>
|
||
{list.map((t) => (
|
||
<TableCell key={t.id} className="text-sm text-muted-foreground align-top">
|
||
{t.description}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
<TableRow>
|
||
<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">
|
||
{(t.features ?? []).map((f) => (
|
||
<Badge key={f} variant="secondary" className="text-[10px]">{f}</Badge>
|
||
))}
|
||
{(t.features?.length ?? 0) === 0 && <span className="text-muted-foreground">—</span>}
|
||
</div>
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
<TableRow>
|
||
<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">
|
||
{(t.tags ?? []).map((tag) => (
|
||
<Badge key={tag} variant="outline" className="text-[10px]">{tag}</Badge>
|
||
))}
|
||
{(t.tags?.length ?? 0) === 0 && <span className="text-muted-foreground">—</span>}
|
||
</div>
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
<TableRow>
|
||
<TableCell className="font-medium">{t("compare.lastUpdated")}</TableCell>
|
||
{list.map((t) => (
|
||
<TableCell key={t.id} className="text-sm text-muted-foreground">
|
||
{new Date(t.updatedAt).toLocaleDateString()}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Button variant="outline" asChild>
|
||
<Link to="/tools">{t("compare.backToBrowse")}</Link>
|
||
</Button>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|