From 0c27982098b0dd80f21ad76b32397f3cff46abe5 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 2 Aug 2026 11:51:25 +0200 Subject: [PATCH] ci: dev version includes UTC time (dev-YYYYMMDD-HHmm) Phase 1: browse power-up - faceted filters: tags + features (array containment) and min rating on listTools; new ?tags=?features=?minRating= URL params with chips - global Cmd+K command palette (cmdk) with tool search + navigation - mini usefulness/usability bars on grid cards, wide cards and table rows --- .gitea/workflows/build.yaml | 2 +- artifacts/api-server/src/routes/tools.ts | 15 ++- .../src/components/command-palette.tsx | 124 ++++++++++++++++++ .../src/components/filter-popover.tsx | 119 +++++++++++++++++ artifacts/toolrate/src/components/layout.tsx | 2 + .../toolrate/src/components/mini-bars.tsx | 73 +++++++++++ .../src/components/tool-card-wide.tsx | 26 ++-- .../toolrate/src/components/tool-card.tsx | 26 ++-- .../toolrate/src/components/tool-row.tsx | 10 +- artifacts/toolrate/src/pages/tools-browse.tsx | 78 ++++++++++- .../src/generated/api.schemas.ts | 14 ++ lib/api-spec/openapi.yaml | 20 +++ lib/api-zod/src/generated/api.ts | 10 +- .../src/generated/types/listToolsParams.ts | 14 ++ 14 files changed, 504 insertions(+), 29 deletions(-) create mode 100644 artifacts/toolrate/src/components/command-palette.tsx create mode 100644 artifacts/toolrate/src/components/filter-popover.tsx create mode 100644 artifacts/toolrate/src/components/mini-bars.tsx diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 3a7689a..76850f6 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -34,7 +34,7 @@ jobs: VERSION="${{ gitea.ref_name }}" VERSION_TAG="${{ gitea.ref_name }}" else - VERSION="dev-${DATE_STAMP}" + VERSION="dev-$(date -u +"%Y%m%d-%H%M")" VERSION_TAG="nightly-${DATE_STAMP}" fi TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest -t ${IMAGE}:${VERSION_TAG}" diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts index 36a4801..daf4111 100644 --- a/artifacts/api-server/src/routes/tools.ts +++ b/artifacts/api-server/src/routes/tools.ts @@ -52,7 +52,10 @@ router.get("/tools", async (req, res): Promise => { res.status(400).json({ error: parsed.error.message }); return; } - const { category, search, sort } = parsed.data; + const { category, search, sort, tags, features, minRating } = parsed.data; + + const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean); + const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean); let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic(); if (category) { @@ -62,6 +65,12 @@ router.get("/tools", async (req, res): Promise => { const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`); query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`); } + if (tagList.length > 0) { + query = query.where(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`); + } + if (featureList.length > 0) { + query = query.where(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`); + } const tools = await query.orderBy(desc(toolsTable.createdAt)); @@ -95,6 +104,10 @@ router.get("/tools", async (req, res): Promise => { result = result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } + if (minRating != null) { + result = result.filter((t) => (t.avgCombined ?? 0) >= minRating); + } + res.json(result); }); diff --git a/artifacts/toolrate/src/components/command-palette.tsx b/artifacts/toolrate/src/components/command-palette.tsx new file mode 100644 index 0000000..9f37650 --- /dev/null +++ b/artifacts/toolrate/src/components/command-palette.tsx @@ -0,0 +1,124 @@ +import { useState, useEffect, useRef } from "react"; +import { useLocation } from "wouter"; +import { useListTools, getListToolsQueryKey } from "@workspace/api-client-react"; +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandSeparator, +} from "@/components/ui/command"; +import { useAuth } from "@/hooks/use-auth"; +import { + Compass, + PlusCircle, + BarChart3, + Trash2, + ShieldCheck, + Wrench, + Star, +} from "lucide-react"; + +export function CommandPalette() { + const [, navigate] = useLocation(); + const { isAuthenticated, isAdmin, hasFeature } = useAuth(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const searchTimer = useRef | null>(null); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((o) => !o); + } + }; + window.addEventListener("keydown", down); + return () => window.removeEventListener("keydown", down); + }, []); + + useEffect(() => { + if (!open) setSearch(""); + }, [open]); + + const { data: tools } = useListTools( + search ? { search } : undefined, + { + query: { + queryKey: getListToolsQueryKey(search ? { search } : undefined), + enabled: open && search.trim().length > 0, + staleTime: 30_000, + }, + }, + ); + + function go(path: string) { + setOpen(false); + navigate(path); + } + + const showTrash = hasFeature("trash") || isAdmin; + const showWatchlist = (hasFeature("watchlist") || isAdmin) && isAuthenticated; + + return ( + + { + setSearch(v); + if (searchTimer.current) clearTimeout(searchTimer.current); + }} + /> + + + {search ? `No tools found for "${search}".` : "Start typing to search tools."} + + + go("/tools")}> + Browse Tools + + go("/tools/new")}> + Add a Tool + + go("/analytics")}> + Analytics + + {showWatchlist && ( + go("/watchlist")}> + My Watchlist + + )} + {showTrash && ( + go("/trash")}> + Trash + + )} + {isAdmin && ( + go("/admin")}> + Admin + + )} + + {search.trim().length > 0 && ( + <> + + + {(tools ?? []).slice(0, 10).map((tool) => ( + go(`/tools/${tool.id}`)}> + + {tool.name} + + {tool.avgCombined ? `${tool.avgCombined.toFixed(1)}★` : ""} + + + ))} + + + )} + + + ); +} diff --git a/artifacts/toolrate/src/components/filter-popover.tsx b/artifacts/toolrate/src/components/filter-popover.tsx new file mode 100644 index 0000000..31f0992 --- /dev/null +++ b/artifacts/toolrate/src/components/filter-popover.tsx @@ -0,0 +1,119 @@ +import { useListAllTags, useListAllFeatures } from "@workspace/api-client-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { SlidersHorizontal, X } from "lucide-react"; + +export function FilterPopover({ + tags, + features, + minRating, + onChange, + onClear, + activeCount, +}: { + tags: string[]; + features: string[]; + minRating: number | null; + onChange: (patch: { tags?: string[]; features?: string[]; minRating?: number | null }) => void; + onClear: () => void; + activeCount: number; +}) { + const { data: allTags } = useListAllTags(); + const { data: allFeatures } = useListAllFeatures(); + + function toggle(list: string[], value: string): string[] { + return list.includes(value) ? list.filter((v) => v !== value) : [...list, value]; + } + + return ( + + + + + +
+ {allTags && allTags.length > 0 && ( +
+

Tags

+ +
+ {allTags.map((t) => ( + + ))} +
+
+
+ )} + {allFeatures && allFeatures.length > 0 && ( +
+

Features

+ +
+ {allFeatures.map((f) => ( + + ))} +
+
+
+ )} +
+
+

Min. rating

+ {minRating != null && ( + + )} +
+
+ onChange({ minRating: v })} + aria-label="Minimum rating" + /> + + {minRating != null ? `${minRating.toFixed(1)}+` : "Any"} + +
+
+ +
+
+
+ ); +} diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index 254ee10..2b7d55d 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -5,6 +5,7 @@ import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-reac import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { ThemeToggle } from "@/components/theme-toggle"; +import { CommandPalette } from "@/components/command-palette"; export function Layout({ children }: { children: React.ReactNode }) { const [location] = useLocation(); @@ -29,6 +30,7 @@ export function Layout({ children }: { children: React.ReactNode }) { return (
+