ci: dev version includes UTC time (dev-YYYYMMDD-HHmm)
Build & Push Docker Image / build (push) Successful in 2m36s
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user