ci: dev version includes UTC time (dev-YYYYMMDD-HHmm)
Build & Push Docker Image / build (push) Successful in 2m36s

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
This commit is contained in:
opencode
2026-08-02 11:51:25 +02:00
parent 461eca9a0a
commit 0c27982098
14 changed files with 504 additions and 29 deletions
+76 -2
View File
@@ -11,6 +11,7 @@ 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 { FilterPopover } from "@/components/filter-popover";
import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-preferences";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -87,6 +88,16 @@ export default function ToolsBrowse() {
const [sort, setSort] = useState<ListToolsSort>(initialSort);
const [overrideView, setOverrideView] = useState<ViewMode | null>(null);
const [overrideDensity, setOverrideDensity] = useState<Density | null>(null);
const [tags, setTags] = useState<string[]>(() =>
(initialParams.get("tags") ?? "").split(",").map((t) => t.trim()).filter(Boolean),
);
const [features, setFeatures] = useState<string[]>(() =>
(initialParams.get("features") ?? "").split(",").map((f) => f.trim()).filter(Boolean),
);
const [minRating, setMinRating] = useState<number | null>(() => {
const v = Number(initialParams.get("minRating"));
return Number.isFinite(v) ? Math.min(5, Math.max(0, v)) : null;
});
const urlView = initialParams.get("view");
const urlDensity = initialParams.get("density");
@@ -102,9 +113,12 @@ export default function ToolsBrowse() {
if (sort && sort !== ListToolsSort.newest) p.set("sort", sort);
if (view !== "grid") p.set("view", view);
if (density !== "cozy") p.set("density", density);
if (tags.length > 0) p.set("tags", tags.join(","));
if (features.length > 0) p.set("features", features.join(","));
if (minRating != null) p.set("minRating", String(minRating));
const qs = p.toString();
navigate(qs ? `/tools?${qs}` : "/tools", { replace: true });
}, [search, category, sort, view, density, navigate]);
}, [search, category, sort, view, density, tags, features, minRating, navigate]);
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput), 300);
@@ -135,6 +149,9 @@ export default function ToolsBrowse() {
...(search ? { search } : {}),
...(category && category !== "all" ? { category } : {}),
...(sort ? { sort } : {}),
...(tags.length > 0 ? { tags: tags.join(",") } : {}),
...(features.length > 0 ? { features: features.join(",") } : {}),
...(minRating != null ? { minRating } : {}),
};
const { data: tools, isLoading: loadingTools } = useListTools(queryParams);
@@ -155,9 +172,13 @@ export default function ToolsBrowse() {
setSearchInput("");
setCategory("all");
setSort(ListToolsSort.newest);
setTags([]);
setFeatures([]);
setMinRating(null);
};
const hasFilters = search !== "" || category !== "all" || sort !== ListToolsSort.newest;
const filterCount = tags.length + features.length + (minRating != null ? 1 : 0);
const hasFilters = search !== "" || category !== "all" || sort !== ListToolsSort.newest || filterCount > 0;
function toggleNameSort() {
setSort(sort === ListToolsSort.name_asc ? ListToolsSort.name_desc : ListToolsSort.name_asc);
@@ -242,6 +263,23 @@ export default function ToolsBrowse() {
))}
</SelectContent>
</Select>
<FilterPopover
tags={tags}
features={features}
minRating={minRating}
onChange={({ tags: t, features: f, minRating: m }) => {
if (t) setTags(t);
if (f) setFeatures(f);
if (m !== undefined) setMinRating(m);
}}
onClear={() => {
setTags([]);
setFeatures([]);
setMinRating(null);
}}
activeCount={filterCount}
/>
</div>
</div>
@@ -286,6 +324,42 @@ export default function ToolsBrowse() {
</button>
</Badge>
)}
{tags.map((t) => (
<Badge key={`tag-${t}`} variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
{t}
<button
onClick={() => setTags(tags.filter((x) => x !== t))}
className="rounded-sm hover:bg-muted p-0.5"
aria-label={`Remove tag ${t}`}
>
<X className="w-3.5 h-3.5" />
</button>
</Badge>
))}
{features.map((f) => (
<Badge key={`feat-${f}`} variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
{f}
<button
onClick={() => setFeatures(features.filter((x) => x !== f))}
className="rounded-sm hover:bg-muted p-0.5"
aria-label={`Remove feature ${f}`}
>
<X className="w-3.5 h-3.5" />
</button>
</Badge>
))}
{minRating != null && (
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
{minRating.toFixed(1)}+
<button
onClick={() => setMinRating(null)}
className="rounded-sm hover:bg-muted p-0.5"
aria-label="Remove min rating"
>
<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>