Files
tool-evaluator/artifacts/toolrate/src/pages/compare.tsx
T
opencode 2f38889726
Build & Push Docker Image / build (push) Successful in 2m31s
fix: compact filter popover; feat: i18n (de/en)
2026-08-02 13:42:53 +02:00

221 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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">{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">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">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">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">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>
);
}