Files
tool-evaluator/artifacts/toolrate/src/pages/tools-browse.tsx
T
opencode 78244c3197
Build & Push Docker Image / build (push) Successful in 2m25s
feat: user-chosen browse views with grid/list toggle and profile sync
- view modes grid | table | rows + density cozy/compact, persisted via
  localStorage and shareable ?view=?density= URL params (URL wins)
- table view: sortable columns (name, rating, reviews), new sort options
  name_asc/name_desc/recently_updated (backend enum + handler)
- live debounced search, removable filter chips, '/' focuses search
- virtualization via @tanstack/react-virtual for table and rows views
- profile sync: users.preferences jsonb + GET/PUT /api/auth/me/preferences;
  preference precedence URL > server profile > localStorage > default
- add local rollup/lightningcss/tailwindcss oxide native binaries for macos dev
2026-08-02 09:34:53 +02:00

429 lines
16 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 { useState, useEffect, useRef, useMemo } from "react";
import {
useListTools,
useListCategories,
ListToolsSort,
} from "@workspace/api-client-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Layout } from "@/components/layout";
import { ToolCard } from "@/components/tool-card";
import { ToolCardWide } from "@/components/tool-card-wide";
import { ToolRow, TABLE_GRID } from "@/components/tool-row";
import { ViewToggle, type ViewMode } from "@/components/view-toggle";
import { DensityToggle, type Density } from "@/components/density-toggle";
import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-preferences";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Search, Wrench, X, ArrowUp, ArrowDown, ArrowUpDown, SlidersHorizontal } from "lucide-react";
import { Link, useLocation, useSearch } from "wouter";
import { cn } from "@/lib/utils";
const SORT_VALUES = new Set<string>(Object.values(ListToolsSort));
const SORT_LABELS: Record<string, string> = {
[ListToolsSort.newest]: "Newest",
[ListToolsSort.top_rated]: "Top Rated",
[ListToolsSort.most_reviewed]: "Most Reviewed",
[ListToolsSort.name_asc]: "Name (AZ)",
[ListToolsSort.name_desc]: "Name (ZA)",
[ListToolsSort.recently_updated]: "Recently Updated",
};
function SortHeader({
active,
direction,
onClick,
children,
align = "left",
}: {
active: boolean;
direction?: "asc" | "desc";
onClick: () => void;
children: React.ReactNode;
align?: "left" | "right";
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wide hover:text-foreground transition-colors",
active ? "text-foreground" : "text-muted-foreground",
align === "right" && "w-full justify-end",
)}
>
{children}
{active && direction === "asc" ? (
<ArrowUp className="w-3.5 h-3.5" />
) : active && direction === "desc" ? (
<ArrowDown className="w-3.5 h-3.5" />
) : (
<ArrowUpDown className="w-3.5 h-3.5 opacity-50" />
)}
</button>
);
}
export default function ToolsBrowse() {
const [, navigate] = useLocation();
const urlSearch = useSearch();
const initialParams = useMemo(() => new URLSearchParams(urlSearch), [urlSearch]);
const initialSearch = initialParams.get("search") ?? "";
const initialCategory = initialParams.get("category") ?? "all";
const initialSortParam = initialParams.get("sort") ?? "";
const initialSort = SORT_VALUES.has(initialSortParam)
? (initialSortParam as ListToolsSort)
: ListToolsSort.newest;
const { serverView, serverDensity, localView, localDensity, persist } = useBrowsePreferences();
const [search, setSearch] = useState(initialSearch);
const [searchInput, setSearchInput] = useState(initialSearch);
const [category, setCategory] = useState<string>(initialCategory);
const [sort, setSort] = useState<ListToolsSort>(initialSort);
const [overrideView, setOverrideView] = useState<ViewMode | null>(null);
const [overrideDensity, setOverrideDensity] = useState<Density | null>(null);
const urlView = initialParams.get("view");
const urlDensity = initialParams.get("density");
const view: ViewMode =
(isViewMode(urlView) ? urlView : null) ?? overrideView ?? serverView ?? localView ?? "grid";
const density: Density =
(isDensity(urlDensity) ? urlDensity : null) ?? overrideDensity ?? serverDensity ?? localDensity ?? "cozy";
useEffect(() => {
const p = new URLSearchParams();
if (search) p.set("search", search);
if (category && category !== "all") p.set("category", category);
if (sort && sort !== ListToolsSort.newest) p.set("sort", sort);
if (view !== "grid") p.set("view", view);
if (density !== "cozy") p.set("density", density);
const qs = p.toString();
navigate(qs ? `/tools?${qs}` : "/tools", { replace: true });
}, [search, category, sort, view, density, navigate]);
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(t);
}, [searchInput]);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isTyping =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable;
if (e.key === "/" && !isTyping) {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const { data: categories, isLoading: loadingCategories } = useListCategories();
const queryParams = {
...(search ? { search } : {}),
...(category && category !== "all" ? { category } : {}),
...(sort ? { sort } : {}),
};
const { data: tools, isLoading: loadingTools } = useListTools(queryParams);
const allTools = tools ?? [];
function setView(v: ViewMode) {
setOverrideView(v);
persist(v, density);
}
function setDensity(d: Density) {
setOverrideDensity(d);
persist(view, d);
}
const clearFilters = () => {
setSearch("");
setSearchInput("");
setCategory("all");
setSort(ListToolsSort.newest);
};
const hasFilters = search !== "" || category !== "all" || sort !== ListToolsSort.newest;
function toggleNameSort() {
setSort(sort === ListToolsSort.name_asc ? ListToolsSort.name_desc : ListToolsSort.name_asc);
}
// ---- virtualization ----
const tableParentRef = useRef<HTMLDivElement>(null);
const rowsParentRef = useRef<HTMLDivElement>(null);
const tableVirtualizer = useVirtualizer({
count: allTools.length,
getScrollElement: () => tableParentRef.current,
estimateSize: () => (density === "compact" ? 44 : 64),
overscan: 8,
});
const rowsVirtualizer = useVirtualizer({
count: allTools.length,
getScrollElement: () => rowsParentRef.current,
estimateSize: () => (density === "compact" ? 72 : 116),
overscan: 4,
});
const gridPad = density === "compact" ? "gap-3" : "gap-4";
const sortActive = (v: ListToolsSort) => sort === v;
const nameDir: "asc" | "desc" | undefined =
sort === ListToolsSort.name_asc ? "asc" : sort === ListToolsSort.name_desc ? "desc" : undefined;
return (
<Layout>
<div className="space-y-6 pb-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Browse Tools</h1>
<p className="text-muted-foreground">Discover and evaluate the best tools for your stack.</p>
</div>
<Button asChild>
<Link href="/tools/new">Add a Tool</Link>
</Button>
</div>
{/* Filters */}
<div className="bg-card border rounded-xl p-4 flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder='Search tools… (press "/" to focus)'
className="pl-9 w-full"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
data-testid="input-search"
/>
</div>
<div className="flex flex-wrap sm:flex-nowrap gap-4 shrink-0">
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-category">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{!loadingCategories &&
categories?.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sort} onValueChange={(val) => setSort(val as ListToolsSort)}>
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-sort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(ListToolsSort).map((v) => (
<SelectItem key={v} value={v}>
{SORT_LABELS[v]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{hasFilters && (
<div className="flex flex-wrap items-center gap-2 -mt-2">
{search && (
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
{search}
<button
onClick={() => {
setSearch("");
setSearchInput("");
}}
className="rounded-sm hover:bg-muted p-0.5"
aria-label="Remove search"
>
<X className="w-3.5 h-3.5" />
</button>
</Badge>
)}
{category !== "all" && (
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
{category}
<button
onClick={() => setCategory("all")}
className="rounded-sm hover:bg-muted p-0.5"
aria-label="Remove category"
>
<X className="w-3.5 h-3.5" />
</button>
</Badge>
)}
{sort !== ListToolsSort.newest && (
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
{SORT_LABELS[sort]}
<button
onClick={() => setSort(ListToolsSort.newest)}
className="rounded-sm hover:bg-muted p-0.5"
aria-label="Reset sort"
>
<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
</Button>
</div>
)}
{/* Results toolbar */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center text-sm text-muted-foreground">
<SlidersHorizontal className="w-4 h-4 mr-2" />
{loadingTools ? "Loading tools…" : `Showing ${allTools.length} tool${allTools.length === 1 ? "" : "s"}`}
</div>
<div className="flex items-center gap-3">
<DensityToggle value={density} onValueChange={setDensity} />
<ViewToggle value={view} onValueChange={setView} />
</div>
</div>
{/* Results */}
{loadingTools ? (
<Skeletons view={view} density={density} />
) : allTools.length > 0 ? (
view === "grid" ? (
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", gridPad)}>
{allTools.map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
) : view === "rows" ? (
<div ref={rowsParentRef} className="overflow-auto max-h-[72vh]">
<div className="relative" style={{ height: rowsVirtualizer.getTotalSize() }}>
{rowsVirtualizer.getVirtualItems().map((vi) => (
<div
key={allTools[vi.index].id}
ref={rowsVirtualizer.measureElement}
data-index={vi.index}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${vi.start}px)` }}
>
<ToolCardWide tool={allTools[vi.index]} density={density} />
</div>
))}
</div>
</div>
) : (
<div ref={tableParentRef} className="overflow-auto max-h-[72vh] rounded-xl border bg-background">
<div className={cn("grid items-center gap-3 border-b bg-muted sticky top-0 z-10 px-4", TABLE_GRID)}>
<div className="py-2.5">
<SortHeader active={sortActive(ListToolsSort.name_asc) || sortActive(ListToolsSort.name_desc)} direction={nameDir} onClick={toggleNameSort}>
Tool
</SortHeader>
</div>
<div className="py-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Category
</div>
<div className="py-2.5">
<SortHeader active={sortActive(ListToolsSort.top_rated)} onClick={() => setSort(ListToolsSort.top_rated)} align="right">
Rating
</SortHeader>
</div>
<div className="py-2.5">
<SortHeader active={sortActive(ListToolsSort.most_reviewed)} onClick={() => setSort(ListToolsSort.most_reviewed)} align="right">
Reviews
</SortHeader>
</div>
</div>
<div className="relative px-4" style={{ height: tableVirtualizer.getTotalSize() }}>
{tableVirtualizer.getVirtualItems().map((vi) => {
const tool = allTools[vi.index];
return (
<div
key={tool.id}
ref={tableVirtualizer.measureElement}
data-index={vi.index}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${vi.start}px)` }}
>
<ToolRow tool={tool} density={density} />
</div>
);
})}
</div>
</div>
)
) : (
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center">
<div className="bg-muted p-4 rounded-full mb-4">
<Wrench className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-medium mb-2">No tools found</h3>
<p className="text-muted-foreground max-w-md mx-auto mb-6">
We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a
new tool.
</p>
<div className="flex gap-4">
{hasFilters && (
<Button variant="outline" onClick={clearFilters}>
Clear Filters
</Button>
)}
<Button asChild>
<Link href="/tools/new">Add Tool</Link>
</Button>
</div>
</div>
)}
</div>
</Layout>
);
}
function Skeletons({ view, density }: { view: ViewMode; density: Density }) {
const compact = density === "compact";
if (view === "grid") {
return (
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", compact ? "gap-3" : "gap-4")}>
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<Skeleton key={`sk-grid-${i}`} className="h-[200px] w-full rounded-xl" />
))}
</div>
);
}
if (view === "rows") {
return (
<div className={cn("space-y-3")}>
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={`sk-rows-${i}`} className={cn("w-full rounded-xl", compact ? "h-16" : "h-[104px]")} />
))}
</div>
);
}
return (
<div className="rounded-xl border overflow-hidden">
<Skeleton className="h-10 w-full rounded-none" />
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={`sk-table-${i}`} className={cn("w-full rounded-none border-t border-border/40", compact ? "h-11" : "h-16")} />
))}
</div>
);
}