feat: compare (premium), rating-history trend, hover previews
Build & Push Docker Image / build (push) Successful in 2m47s
Build & Push Docker Image / build (push) Successful in 2m47s
This commit is contained in:
@@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express";
|
|||||||
|
|
||||||
const TIER_FEATURES: Record<string, string[]> = {
|
const TIER_FEATURES: Record<string, string[]> = {
|
||||||
free: ["browse", "rate", "search"],
|
free: ["browse", "rate", "search"],
|
||||||
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash"],
|
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist"],
|
||||||
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "sso", "audit-export", "api-access"],
|
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "sso", "audit-export", "api-access"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, desc, sql, and, not, isNull, inArray } from "drizzle-orm";
|
import { eq, desc, asc, sql, and, not, isNull, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
||||||
import {
|
import {
|
||||||
@@ -232,6 +232,65 @@ router.get("/tools/:id", async (req, res): Promise<void> => {
|
|||||||
res.json(buildToolWithStats(tool, ratings));
|
res.json(buildToolWithStats(tool, ratings));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/tools/:id/rating-history", async (req, res): Promise<void> => {
|
||||||
|
const params = GetToolParams.safeParse(req.params);
|
||||||
|
if (!params.success) {
|
||||||
|
res.status(400).json({ error: params.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ratings = await db
|
||||||
|
.select({
|
||||||
|
date: ratingsTable.createdAt,
|
||||||
|
usefulness: ratingsTable.usefulness,
|
||||||
|
usability: ratingsTable.usability,
|
||||||
|
})
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(eq(ratingsTable.toolId, params.data.id))
|
||||||
|
.orderBy(asc(ratingsTable.createdAt));
|
||||||
|
res.json(
|
||||||
|
ratings.map((r) => ({
|
||||||
|
date: r.date,
|
||||||
|
usefulness: r.usefulness,
|
||||||
|
usability: r.usability,
|
||||||
|
combined: (r.usefulness + r.usability) / 2,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/compare", requireFeature("compare"), async (req, res): Promise<void> => {
|
||||||
|
const ids = String(req.query.ids ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => Number.isInteger(n) && n > 0);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
res.status(400).json({ error: "Provide at least one ids value, e.g. ?ids=1,2,3" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ids.length > 8) {
|
||||||
|
res.status(400).json({ error: "Maximum of 8 tools can be compared" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tools = await db.select().from(toolsTable).where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids)));
|
||||||
|
if (tools.length === 0) {
|
||||||
|
res.status(404).json({ error: "No tools found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toolIds = tools.map((t) => t.id);
|
||||||
|
const ratings = await db
|
||||||
|
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(inArray(ratingsTable.toolId, toolIds));
|
||||||
|
const byTool = new Map<number, { usefulness: number; usability: number }[]>();
|
||||||
|
for (const r of ratings) {
|
||||||
|
const list = byTool.get(r.toolId) ?? [];
|
||||||
|
list.push({ usefulness: r.usefulness, usability: r.usability });
|
||||||
|
byTool.set(r.toolId, list);
|
||||||
|
}
|
||||||
|
const byId = new Map(tools.map((t) => [t.id, t]));
|
||||||
|
const ordered = ids.filter((id) => byId.has(id)).map((id) => buildToolWithStats(byId.get(id)!, byTool.get(id) ?? []));
|
||||||
|
res.json(ordered);
|
||||||
|
});
|
||||||
|
|
||||||
router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||||
const params = UpdateToolParams.safeParse(req.params);
|
const params = UpdateToolParams.safeParse(req.params);
|
||||||
if (!params.success) {
|
if (!params.success) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Analytics from "@/pages/analytics";
|
|||||||
import Admin from "@/pages/admin";
|
import Admin from "@/pages/admin";
|
||||||
import Redundancy from "@/pages/redundancy";
|
import Redundancy from "@/pages/redundancy";
|
||||||
import Trash from "@/pages/trash";
|
import Trash from "@/pages/trash";
|
||||||
|
import Compare from "@/pages/compare";
|
||||||
import Login from "@/pages/login";
|
import Login from "@/pages/login";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ function Router() {
|
|||||||
<Route path="/tools/new" component={ToolNew} />
|
<Route path="/tools/new" component={ToolNew} />
|
||||||
<Route path="/tools/:id/edit" component={ToolEdit} />
|
<Route path="/tools/:id/edit" component={ToolEdit} />
|
||||||
<Route path="/tools/:id" component={ToolDetail} />
|
<Route path="/tools/:id" component={ToolDetail} />
|
||||||
|
<Route path="/compare" component={Compare} />
|
||||||
<Route path="/analytics" component={Analytics} />
|
<Route path="/analytics" component={Analytics} />
|
||||||
<Route path="/admin" component={Admin} />
|
<Route path="/admin" component={Admin} />
|
||||||
<Route path="/admin/redundancy" component={Redundancy} />
|
<Route path="/admin/redundancy" component={Redundancy} />
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { X, Scale, Lock } from "lucide-react";
|
||||||
|
import type { ToolWithStats } from "@workspace/api-client-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function CompareBar({
|
||||||
|
tools,
|
||||||
|
onRemove,
|
||||||
|
onClear,
|
||||||
|
canCompare,
|
||||||
|
onCompare,
|
||||||
|
onUpgrade,
|
||||||
|
}: {
|
||||||
|
tools: ToolWithStats[];
|
||||||
|
onRemove: (id: number) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
canCompare: boolean;
|
||||||
|
onCompare: () => void;
|
||||||
|
onUpgrade: () => void;
|
||||||
|
}) {
|
||||||
|
if (tools.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 w-[min(96vw,42rem)]">
|
||||||
|
<div className="rounded-xl border bg-popover/95 backdrop-blur shadow-lg p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Scale className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
|
||||||
|
{tools.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t.id}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||||||
|
"bg-primary/5 border-primary/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="max-w-[8rem] truncate">{t.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(t.id)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`Remove ${t.name}`}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground whitespace-nowrap px-1"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{canCompare ? (
|
||||||
|
<Button size="sm" onClick={onCompare} disabled={tools.length < 2}>
|
||||||
|
Compare ({tools.length})
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={onUpgrade} title="Compare is a Premium feature">
|
||||||
|
<Lock className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Compare
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Star, MessageSquare, Wrench, ChevronRight } from "lucide-react";
|
import { Star, MessageSquare, Wrench, ChevronRight, Scale } from "lucide-react";
|
||||||
import { ToolWithStats } from "@workspace/api-client-react";
|
import { ToolWithStats } from "@workspace/api-client-react";
|
||||||
import { MiniBarStack } from "@/components/mini-bars";
|
import { MiniBarStack } from "@/components/mini-bars";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function ToolCardWide({ tool, density }: { tool: ToolWithStats; density: "cozy" | "compact" }) {
|
export function ToolCardWide({
|
||||||
|
tool,
|
||||||
|
density,
|
||||||
|
compare,
|
||||||
|
}: {
|
||||||
|
tool: ToolWithStats;
|
||||||
|
density: "cozy" | "compact";
|
||||||
|
compare?: { selected: boolean; onToggle: () => void };
|
||||||
|
}) {
|
||||||
const compact = density === "compact";
|
const compact = density === "compact";
|
||||||
return (
|
return (
|
||||||
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50">
|
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50">
|
||||||
@@ -62,6 +71,25 @@ export function ToolCardWide({ tool, density }: { tool: ToolWithStats; density:
|
|||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
{!compact && <MiniBarStack usefulness={tool.avgUsefulness} usability={tool.avgUsability} />}
|
{!compact && <MiniBarStack usefulness={tool.avgUsefulness} usability={tool.avgUsability} />}
|
||||||
|
{compare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
compare.onToggle();
|
||||||
|
}}
|
||||||
|
title={compare.selected ? "Remove from compare" : "Add to compare"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-7 h-7 rounded-md border flex items-center justify-center transition-colors",
|
||||||
|
compare.selected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scale className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { ToolWithStats } from "@workspace/api-client-react";
|
import { ToolWithStats } from "@workspace/api-client-react";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Star, MessageSquare, Wrench } from "lucide-react";
|
import { Star, MessageSquare, Wrench, Scale } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { MiniBars } from "@/components/mini-bars";
|
import { MiniBars } from "@/components/mini-bars";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function ToolCard({ tool }: { tool: ToolWithStats }) {
|
export function ToolCard({
|
||||||
|
tool,
|
||||||
|
compare,
|
||||||
|
}: {
|
||||||
|
tool: ToolWithStats;
|
||||||
|
compare?: { selected: boolean; onToggle: () => void };
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="hover-elevate transition-all flex flex-col h-full cursor-pointer hover:border-primary/50">
|
<Card className="hover-elevate transition-all flex flex-col h-full cursor-pointer hover:border-primary/50">
|
||||||
<Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0">
|
<Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0">
|
||||||
@@ -63,7 +70,28 @@ export function ToolCard({ tool }: { tool: ToolWithStats }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MiniBars usefulness={tool.avgUsefulness} usability={tool.avgUsability} />
|
<div className={cn("items-center gap-3", compare ? "flex" : "")}>
|
||||||
|
<MiniBars usefulness={tool.avgUsefulness} usability={tool.avgUsability} className={compare ? "flex-1" : undefined} />
|
||||||
|
{compare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
compare.onToggle();
|
||||||
|
}}
|
||||||
|
title={compare.selected ? "Remove from compare" : "Add to compare"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-8 h-8 rounded-md border flex items-center justify-center transition-colors",
|
||||||
|
compare.selected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scale className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Link>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Link } from "wouter";
|
||||||
|
import type { ToolWithStats } from "@workspace/api-client-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RatingStars } from "@/components/rating-stars";
|
||||||
|
import { MiniBars } from "@/components/mini-bars";
|
||||||
|
|
||||||
|
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold leading-tight truncate">{tool.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{tool.category}</div>
|
||||||
|
</div>
|
||||||
|
<RatingStars value={tool.avgCombined ?? 0} size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-3">{tool.description}</p>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">{tool.ratingCount} review{tool.ratingCount === 1 ? "" : "s"}</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "–"}/5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<MiniBars usefulness={tool.avgUsefulness} usability={tool.avgUsability} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tool.tags && tool.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tool.tags.slice(0, 4).map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{tool.tags.length > 4 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">+{tool.tags.length - 4}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/tools/${tool.id}`}
|
||||||
|
className="block w-full rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-center text-xs font-medium text-primary hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
View details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Star, MessageSquare, Wrench } from "lucide-react";
|
import { Star, MessageSquare, Wrench, Scale } from "lucide-react";
|
||||||
import { ToolWithStats } from "@workspace/api-client-react";
|
import { ToolWithStats } from "@workspace/api-client-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MiniBarStack } from "@/components/mini-bars";
|
import { MiniBarStack } from "@/components/mini-bars";
|
||||||
@@ -12,10 +12,12 @@ export function ToolRow({
|
|||||||
tool,
|
tool,
|
||||||
density,
|
density,
|
||||||
className,
|
className,
|
||||||
|
compare,
|
||||||
}: {
|
}: {
|
||||||
tool: ToolWithStats;
|
tool: ToolWithStats;
|
||||||
density: "cozy" | "compact";
|
density: "cozy" | "compact";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
compare?: { selected: boolean; onToggle: () => void };
|
||||||
}) {
|
}) {
|
||||||
const compact = density === "compact";
|
const compact = density === "compact";
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +30,25 @@ export function ToolRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{compare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
compare.onToggle();
|
||||||
|
}}
|
||||||
|
title={compare.selected ? "Remove from compare" : "Add to compare"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-6 h-6 rounded border flex items-center justify-center transition-colors",
|
||||||
|
compare.selected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scale className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="shrink-0 w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden">
|
<div className="shrink-0 w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden">
|
||||||
{tool.iconUrl ? (
|
{tool.iconUrl ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
function fmt(v: number | null | undefined): string {
|
||||||
|
return v != null ? v.toFixed(1) : "–";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Compare() {
|
||||||
|
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">Compare requires a higher tier</h2>
|
||||||
|
<p className="text-muted-foreground">Compare tools side-by-side is available to Premium and Enterprise users.</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">Back to tools</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">Nothing to compare</h2>
|
||||||
|
<p className="text-muted-foreground">Select at least one tool to compare and come back here.</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">Browse tools</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">Compare Tools</h1>
|
||||||
|
<p className="text-muted-foreground">Comparing {list.length} tool(s) side-by-side.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Side-by-side comparison</CardTitle>
|
||||||
|
<CardDescription>Best value in each row is highlighted.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-44">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">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">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">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">Last updated</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">Back to browse</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
getListToolRatingsQueryKey,
|
getListToolRatingsQueryKey,
|
||||||
useGetRatingDistribution,
|
useGetRatingDistribution,
|
||||||
getGetRatingDistributionQueryKey,
|
getGetRatingDistributionQueryKey,
|
||||||
|
useGetToolRatingHistory,
|
||||||
|
getGetToolRatingHistoryQueryKey,
|
||||||
useCreateRating,
|
useCreateRating,
|
||||||
getGetTopToolsQueryKey,
|
getGetTopToolsQueryKey,
|
||||||
getGetAnalyticsSummaryQueryKey,
|
getGetAnalyticsSummaryQueryKey,
|
||||||
@@ -38,7 +40,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend } from "recharts";
|
||||||
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro } from "lucide-react";
|
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
@@ -203,6 +205,10 @@ export default function ToolDetail() {
|
|||||||
query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }
|
query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: ratingHistory, isLoading: loadingRatingHistory } = useGetToolRatingHistory(id, {
|
||||||
|
query: { enabled: !!id, queryKey: getGetToolRatingHistoryQueryKey(id) }
|
||||||
|
});
|
||||||
|
|
||||||
const createRating = useCreateRating();
|
const createRating = useCreateRating();
|
||||||
|
|
||||||
const form = useForm<RatingFormValues>({
|
const form = useForm<RatingFormValues>({
|
||||||
@@ -695,6 +701,34 @@ export default function ToolDetail() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Score Trend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingRatingHistory ? (
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
) : ratingHistory && ratingHistory.length > 1 ? (
|
||||||
|
<div className="h-48">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={ratingHistory} margin={{ top: 5, right: 10, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
|
||||||
|
<XAxis dataKey="date" hide />
|
||||||
|
<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" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Not enough ratings yet to show a trend.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Reviews */}
|
{/* Right Column: Reviews */}
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import { Layout } from "@/components/layout";
|
|||||||
import { ToolCard } from "@/components/tool-card";
|
import { ToolCard } from "@/components/tool-card";
|
||||||
import { ToolCardWide } from "@/components/tool-card-wide";
|
import { ToolCardWide } from "@/components/tool-card-wide";
|
||||||
import { ToolRow, TABLE_GRID } from "@/components/tool-row";
|
import { ToolRow, TABLE_GRID } from "@/components/tool-row";
|
||||||
|
import { ToolPreviewCard } from "@/components/tool-preview-card";
|
||||||
|
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||||
import { ViewToggle, type ViewMode } from "@/components/view-toggle";
|
import { ViewToggle, type ViewMode } from "@/components/view-toggle";
|
||||||
import { DensityToggle, type Density } from "@/components/density-toggle";
|
import { DensityToggle, type Density } from "@/components/density-toggle";
|
||||||
import { FilterPopover } from "@/components/filter-popover";
|
import { FilterPopover } from "@/components/filter-popover";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-preferences";
|
import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-preferences";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { CompareBar } from "@/components/compare-bar";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -81,6 +86,14 @@ export default function ToolsBrowse() {
|
|||||||
: ListToolsSort.newest;
|
: ListToolsSort.newest;
|
||||||
|
|
||||||
const { serverView, serverDensity, localView, localDensity, persist } = useBrowsePreferences();
|
const { serverView, serverDensity, localView, localDensity, persist } = useBrowsePreferences();
|
||||||
|
const { hasFeature } = useAuth();
|
||||||
|
|
||||||
|
const [compareIds, setCompareIds] = useState<number[]>([]);
|
||||||
|
const [compareUpsellOpen, setCompareUpsellOpen] = useState(false);
|
||||||
|
|
||||||
|
function toggleCompare(id: number) {
|
||||||
|
setCompareIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
}
|
||||||
|
|
||||||
const [search, setSearch] = useState(initialSearch);
|
const [search, setSearch] = useState(initialSearch);
|
||||||
const [searchInput, setSearchInput] = useState(initialSearch);
|
const [searchInput, setSearchInput] = useState(initialSearch);
|
||||||
@@ -385,7 +398,11 @@ export default function ToolsBrowse() {
|
|||||||
view === "grid" ? (
|
view === "grid" ? (
|
||||||
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", gridPad)}>
|
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", gridPad)}>
|
||||||
{allTools.map((tool) => (
|
{allTools.map((tool) => (
|
||||||
<ToolCard key={tool.id} tool={tool} />
|
<ToolCard
|
||||||
|
key={tool.id}
|
||||||
|
tool={tool}
|
||||||
|
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : view === "rows" ? (
|
) : view === "rows" ? (
|
||||||
@@ -399,7 +416,11 @@ export default function ToolsBrowse() {
|
|||||||
className="absolute top-0 left-0 w-full"
|
className="absolute top-0 left-0 w-full"
|
||||||
style={{ transform: `translateY(${vi.start}px)` }}
|
style={{ transform: `translateY(${vi.start}px)` }}
|
||||||
>
|
>
|
||||||
<ToolCardWide tool={allTools[vi.index]} density={density} />
|
<ToolCardWide
|
||||||
|
tool={allTools[vi.index]}
|
||||||
|
density={density}
|
||||||
|
compare={{ selected: compareIds.includes(allTools[vi.index].id), onToggle: () => toggleCompare(allTools[vi.index].id) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -437,7 +458,20 @@ export default function ToolsBrowse() {
|
|||||||
className="absolute top-0 left-0 w-full"
|
className="absolute top-0 left-0 w-full"
|
||||||
style={{ transform: `translateY(${vi.start}px)` }}
|
style={{ transform: `translateY(${vi.start}px)` }}
|
||||||
>
|
>
|
||||||
<ToolRow tool={tool} density={density} />
|
<HoverCard openDelay={250} closeDelay={100}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<ToolRow
|
||||||
|
tool={tool}
|
||||||
|
density={density}
|
||||||
|
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="right" align="start" className="w-72" collisionPadding={16}>
|
||||||
|
<ToolPreviewCard tool={tool} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -467,6 +501,32 @@ export default function ToolsBrowse() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CompareBar
|
||||||
|
tools={allTools.filter((t) => compareIds.includes(t.id))}
|
||||||
|
onRemove={(id) => toggleCompare(id)}
|
||||||
|
onClear={() => setCompareIds([])}
|
||||||
|
canCompare={hasFeature("compare")}
|
||||||
|
onCompare={() => navigate(`/compare?ids=${compareIds.join(",")}`)}
|
||||||
|
onUpgrade={() => setCompareUpsellOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Compare is a Premium feature</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Not now</AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild>
|
||||||
|
<a href="/admin?tab=plan">Upgrade to Premium</a>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ export interface Tool {
|
|||||||
deletedBy?: string | null;
|
deletedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RatingHistoryItem {
|
||||||
|
date: string;
|
||||||
|
usefulness: number;
|
||||||
|
usability: number;
|
||||||
|
combined: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolWithStats {
|
export interface ToolWithStats {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -366,6 +373,13 @@ export const ListToolsSort = {
|
|||||||
recently_updated: 'recently_updated',
|
recently_updated: 'recently_updated',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type ListCompareToolsParams = {
|
||||||
|
/**
|
||||||
|
* Comma-separated tool ids
|
||||||
|
*/
|
||||||
|
ids: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ListTrashedToolsParams = {
|
export type ListTrashedToolsParams = {
|
||||||
search?: string;
|
search?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ import type {
|
|||||||
GetTopToolsParams,
|
GetTopToolsParams,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
ListAuditLogsParams,
|
ListAuditLogsParams,
|
||||||
|
ListCompareToolsParams,
|
||||||
ListToolsParams,
|
ListToolsParams,
|
||||||
ListTrashedToolsParams,
|
ListTrashedToolsParams,
|
||||||
LocalLoginInput,
|
LocalLoginInput,
|
||||||
Rating,
|
Rating,
|
||||||
RatingDistribution,
|
RatingDistribution,
|
||||||
|
RatingHistoryItem,
|
||||||
RatingInput,
|
RatingInput,
|
||||||
RestoreTools200,
|
RestoreTools200,
|
||||||
Tool,
|
Tool,
|
||||||
@@ -375,6 +377,167 @@ export const useCreateTool = <TError = ErrorType<ErrorResponse>,
|
|||||||
return useMutation(getCreateToolMutationOptions(options));
|
return useMutation(getCreateToolMutationOptions(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getListCompareToolsUrl = (params: ListCompareToolsParams,) => {
|
||||||
|
const normalizedParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(params || {}).forEach(([key, value]) => {
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
normalizedParams.append(key, value === null ? 'null' : value.toString())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stringifiedParams = normalizedParams.toString();
|
||||||
|
|
||||||
|
return stringifiedParams.length > 0 ? `/api/compare?${stringifiedParams}` : `/api/compare`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Compare tools side by side (premium)
|
||||||
|
*/
|
||||||
|
export const listCompareTools = async (params: ListCompareToolsParams, options?: RequestInit): Promise<ToolWithStats[]> => {
|
||||||
|
|
||||||
|
return customFetch<ToolWithStats[]>(getListCompareToolsUrl(params),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListCompareToolsQueryKey = (params?: ListCompareToolsParams,) => {
|
||||||
|
return [
|
||||||
|
`/api/compare`, ...(params ? [params] : [])
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListCompareToolsQueryOptions = <TData = Awaited<ReturnType<typeof listCompareTools>>, TError = ErrorType<ErrorResponse>>(params: ListCompareToolsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listCompareTools>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListCompareToolsQueryKey(params);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listCompareTools>>> = ({ signal }) => listCompareTools(params, { signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listCompareTools>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListCompareToolsQueryResult = NonNullable<Awaited<ReturnType<typeof listCompareTools>>>
|
||||||
|
export type ListCompareToolsQueryError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Compare tools side by side (premium)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListCompareTools<TData = Awaited<ReturnType<typeof listCompareTools>>, TError = ErrorType<ErrorResponse>>(
|
||||||
|
params: ListCompareToolsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listCompareTools>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getListCompareToolsQueryOptions(params,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetToolRatingHistoryUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/tools/${id}/rating-history`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get a tool's rating history over time
|
||||||
|
*/
|
||||||
|
export const getToolRatingHistory = async (id: number, options?: RequestInit): Promise<RatingHistoryItem[]> => {
|
||||||
|
|
||||||
|
return customFetch<RatingHistoryItem[]>(getGetToolRatingHistoryUrl(id),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetToolRatingHistoryQueryKey = (id: number,) => {
|
||||||
|
return [
|
||||||
|
`/api/tools/${id}/rating-history`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetToolRatingHistoryQueryOptions = <TData = Awaited<ReturnType<typeof getToolRatingHistory>>, TError = ErrorType<ErrorResponse>>(id: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetToolRatingHistoryQueryKey(id);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getToolRatingHistory>>> = ({ signal }) => getToolRatingHistory(id, { signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetToolRatingHistoryQueryResult = NonNullable<Awaited<ReturnType<typeof getToolRatingHistory>>>
|
||||||
|
export type GetToolRatingHistoryQueryError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get a tool's rating history over time
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetToolRatingHistory<TData = Awaited<ReturnType<typeof getToolRatingHistory>>, TError = ErrorType<ErrorResponse>>(
|
||||||
|
id: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getGetToolRatingHistoryQueryOptions(id,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getGetToolUrl = (id: number,) => {
|
export const getGetToolUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,67 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/compare:
|
||||||
|
get:
|
||||||
|
operationId: listCompareTools
|
||||||
|
tags: [tools]
|
||||||
|
summary: Compare tools side by side (premium)
|
||||||
|
parameters:
|
||||||
|
- name: ids
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Comma-separated tool ids
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Tools in requested order
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ToolWithStats"
|
||||||
|
"401":
|
||||||
|
description: Authentication required
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"403":
|
||||||
|
description: Premium feature required
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/tools/{id}/rating-history:
|
||||||
|
get:
|
||||||
|
operationId: getToolRatingHistory
|
||||||
|
tags: [tools]
|
||||||
|
summary: Get a tool's rating history over time
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Rating history
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/RatingHistoryItem"
|
||||||
|
"400":
|
||||||
|
description: Invalid id
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/tools/{id}:
|
/tools/{id}:
|
||||||
get:
|
get:
|
||||||
operationId: getTool
|
operationId: getTool
|
||||||
@@ -875,6 +936,20 @@ components:
|
|||||||
deletedBy:
|
deletedBy:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
|
||||||
|
RatingHistoryItem:
|
||||||
|
type: object
|
||||||
|
required: [date, usefulness, usability, combined]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
usefulness:
|
||||||
|
type: number
|
||||||
|
usability:
|
||||||
|
type: number
|
||||||
|
combined:
|
||||||
|
type: number
|
||||||
|
|
||||||
ToolWithStats:
|
ToolWithStats:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name, description, category, createdAt, updatedAt, ratingCount, avgUsefulness, avgUsability, avgCombined]
|
required: [id, name, description, category, createdAt, updatedAt, ratingCount, avgUsefulness, avgUsability, avgCombined]
|
||||||
|
|||||||
@@ -85,6 +85,49 @@ export const CreateToolBody = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Compare tools side by side (premium)
|
||||||
|
*/
|
||||||
|
export const ListCompareToolsQueryParams = zod.object({
|
||||||
|
"ids": zod.coerce.string().describe('Comma-separated tool ids')
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ListCompareToolsResponseItem = zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"name": zod.string(),
|
||||||
|
"description": zod.string(),
|
||||||
|
"category": zod.string(),
|
||||||
|
"websiteUrl": zod.string().nullish(),
|
||||||
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
|
"features": zod.array(zod.string()).optional(),
|
||||||
|
"tags": zod.array(zod.string()).optional(),
|
||||||
|
"createdAt": zod.coerce.date(),
|
||||||
|
"updatedAt": zod.coerce.date(),
|
||||||
|
"ratingCount": zod.number(),
|
||||||
|
"avgUsefulness": zod.number().nullable(),
|
||||||
|
"avgUsability": zod.number().nullable(),
|
||||||
|
"avgCombined": zod.number().nullable()
|
||||||
|
})
|
||||||
|
export const ListCompareToolsResponse = zod.array(ListCompareToolsResponseItem)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get a tool's rating history over time
|
||||||
|
*/
|
||||||
|
export const GetToolRatingHistoryParams = zod.object({
|
||||||
|
"id": zod.coerce.number()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GetToolRatingHistoryResponseItem = zod.object({
|
||||||
|
"date": zod.coerce.date(),
|
||||||
|
"usefulness": zod.number(),
|
||||||
|
"usability": zod.number(),
|
||||||
|
"combined": zod.number()
|
||||||
|
})
|
||||||
|
export const GetToolRatingHistoryResponse = zod.array(GetToolRatingHistoryResponseItem)
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get a tool by ID
|
* @summary Get a tool by ID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ export * from './getTopToolsMetric';
|
|||||||
export * from './getTopToolsParams';
|
export * from './getTopToolsParams';
|
||||||
export * from './healthStatus';
|
export * from './healthStatus';
|
||||||
export * from './listAuditLogsParams';
|
export * from './listAuditLogsParams';
|
||||||
|
export * from './listCompareToolsParams';
|
||||||
export * from './listToolsParams';
|
export * from './listToolsParams';
|
||||||
export * from './listToolsSort';
|
export * from './listToolsSort';
|
||||||
export * from './listTrashedToolsParams';
|
export * from './listTrashedToolsParams';
|
||||||
export * from './localLoginInput';
|
export * from './localLoginInput';
|
||||||
export * from './rating';
|
export * from './rating';
|
||||||
export * from './ratingDistribution';
|
export * from './ratingDistribution';
|
||||||
|
export * from './ratingHistoryItem';
|
||||||
export * from './ratingInput';
|
export * from './ratingInput';
|
||||||
export * from './restoreTools200';
|
export * from './restoreTools200';
|
||||||
export * from './scoreBucket';
|
export * from './scoreBucket';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ListCompareToolsParams = {
|
||||||
|
/**
|
||||||
|
* Comma-separated tool ids
|
||||||
|
*/
|
||||||
|
ids: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RatingHistoryItem {
|
||||||
|
date: Date;
|
||||||
|
usefulness: number;
|
||||||
|
usability: number;
|
||||||
|
combined: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user