From cf57e7bb5b67491af38587b5ce1baaa04e4195c5 Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Sat, 3 Oct 2026 15:49:04 +0530 Subject: [PATCH] Give the gallery two filters, counts and a shareable URL --- src/__tests__/templateFilters.test.ts | 122 +++++++++++++++++++++++++ src/app/templates/TemplatesClient.tsx | 124 +++++++++++++++++++------- src/app/templates/page.tsx | 9 +- src/lib/templateFilters.ts | 119 ++++++++++++++++++++++++ 4 files changed, 340 insertions(+), 34 deletions(-) create mode 100644 src/__tests__/templateFilters.test.ts create mode 100644 src/lib/templateFilters.ts diff --git a/src/__tests__/templateFilters.test.ts b/src/__tests__/templateFilters.test.ts new file mode 100644 index 0000000..90a03d4 --- /dev/null +++ b/src/__tests__/templateFilters.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { + ALL, + EMPTY_FILTERS, + filterTemplates, + sortTemplates, + categoryCounts, + backgroundCounts, + filtersToQuery, + filtersFromQuery, + hasActiveFilters, + type TemplateFilters, +} from "@/lib/templateFilters"; +import { TEMPLATES, templateCategories } from "@/lib/templates"; + +/** + * The gallery had one filter and a search box. Every listing people actually buy from + * carries two axes and a count beside each value, because a filter that would return + * nothing is worth knowing about before it is clicked. + */ + +const all = TEMPLATES.map((t) => ({ name: t.name, tagline: t.tagline, category: t.category, tags: t.tags, style: t.style })); +const categories = templateCategories(); + +describe("finding a template in a catalogue of 22", () => { + it("filters by what the site is for", () => { + const launch = filterTemplates(all, { ...EMPTY_FILTERS, category: "Launch" }); + expect(launch.length).toBeGreaterThan(0); + expect(launch.every((t) => t.category === "Launch")).toBe(true); + }); + + it("filters by how the background moves", () => { + const waves = filterTemplates(all, { ...EMPTY_FILTERS, background: "wave" }); + expect(waves.length).toBeGreaterThan(0); + expect(waves.every((t) => t.style === "wave")).toBe(true); + }); + + it("narrows on both axes at once", () => { + const f: TemplateFilters = { q: "", category: "SaaS", background: "gradient" }; + expect(filterTemplates(all, f).every((t) => t.category === "SaaS" && t.style === "gradient")).toBe(true); + }); + + it("searches names, taglines, categories, tags and the background's name", () => { + expect(filterTemplates(all, { ...EMPTY_FILTERS, q: "kept" }).map((t) => t.name)).toContain("Kept"); + expect(filterTemplates(all, { ...EMPTY_FILTERS, q: "waitlist" }).length).toBeGreaterThan(0); + expect(filterTemplates(all, { ...EMPTY_FILTERS, q: "particles" }).every((t) => t.style === "particles")).toBe(true); + }); + + it("returns everything when nothing is asked of it", () => { + expect(filterTemplates(all, EMPTY_FILTERS)).toHaveLength(all.length); + expect(hasActiveFilters(EMPTY_FILTERS)).toBe(false); + expect(hasActiveFilters({ ...EMPTY_FILTERS, category: "Launch" })).toBe(true); + }); + + it("sorts by name when asked, and keeps the catalogue's own order otherwise", () => { + expect(sortTemplates(all, "Catalogue order").map((t) => t.name)).toEqual(all.map((t) => t.name)); + const az = sortTemplates(all, "A to Z").map((t) => t.name); + expect(az).toEqual([...az].sort((a, b) => a.localeCompare(b))); + }); +}); + +describe("the count beside each filter answers what happens if you click it", () => { + it("counts every template when nothing else is filtered", () => { + const counts = categoryCounts(all, EMPTY_FILTERS); + expect(counts[ALL]).toBe(all.length); + for (const category of categories) { + expect(counts[category] ?? 0).toBe(all.filter((t) => t.category === category).length); + } + }); + + it("counts one axis with the other applied, not with itself applied", () => { + // Choosing Launch must not reduce the Launch count to Launch-only, or the number + // stops meaning "what you would get". + const f: TemplateFilters = { ...EMPTY_FILTERS, category: "Launch" }; + const counts = categoryCounts(all, f); + expect(counts[ALL]).toBe(all.length); + expect(counts.Launch).toBe(all.filter((t) => t.category === "Launch").length); + }); + + it("shows a background count that reflects the chosen category", () => { + const f: TemplateFilters = { ...EMPTY_FILTERS, category: "SaaS" }; + const counts = backgroundCounts(all, f); + const saas = all.filter((t) => t.category === "SaaS"); + expect(counts[ALL]).toBe(saas.length); + for (const style of ["gradient", "geometric", "particles", "wave"]) { + expect(counts[style] ?? 0).toBe(saas.filter((t) => t.style === style).length); + } + }); + + it("every count matches what the filter actually returns", () => { + for (const category of categories) { + const f: TemplateFilters = { ...EMPTY_FILTERS, category }; + expect(filterTemplates(all, f)).toHaveLength(categoryCounts(all, EMPTY_FILTERS)[category]); + } + }); +}); + +describe("a filtered view is a link someone can send", () => { + it("puts the filters in the query string, and leaves out the defaults", () => { + expect(filtersToQuery(EMPTY_FILTERS, "Catalogue order")).toBe(""); + expect(filtersToQuery({ q: "cafe", category: "Launch", background: "wave" }, "A to Z")) + .toBe("q=cafe&for=Launch&background=wave&sort=az"); + }); + + it("reads them back", () => { + const query = filtersToQuery({ q: "clinic", category: "Launch", background: "wave" }, "A to Z"); + const read = filtersFromQuery(new URLSearchParams(query), { categories }); + expect(read.filters).toEqual({ q: "clinic", category: "Launch", background: "wave" }); + expect(read.sort).toBe("A to Z"); + }); + + it("ignores a category or background that does not exist, rather than showing nothing", () => { + const read = filtersFromQuery(new URLSearchParams("for=Nonsense&background=plaid"), { categories }); + expect(read.filters.category).toBe(ALL); + expect(read.filters.background).toBe(ALL); + }); + + it("caps a search term from the URL", () => { + const read = filtersFromQuery(new URLSearchParams(`q=${"a".repeat(500)}`), { categories }); + expect(read.filters.q.length).toBeLessThanOrEqual(100); + }); +}); diff --git a/src/app/templates/TemplatesClient.tsx b/src/app/templates/TemplatesClient.tsx index cbb190b..7674211 100644 --- a/src/app/templates/TemplatesClient.tsx +++ b/src/app/templates/TemplatesClient.tsx @@ -1,5 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -7,6 +8,21 @@ import { ArrowRight, Search, Eye } from "lucide-react"; import Navbar from "@/components/Navbar"; import type { Style2D } from "@/lib/generate2DFrames"; import SiteFooter from "@/components/SiteFooter"; +import { + ALL, + BACKGROUND_LABELS, + EMPTY_FILTERS, + SORTS, + backgroundCounts, + categoryCounts, + filterTemplates, + filtersFromQuery, + filtersToQuery, + hasActiveFilters, + sortTemplates, + type TemplateFilters, + type TemplateSort, +} from "@/lib/templateFilters"; /** * What a gallery card renders. Deliberately not `Template`: the full record carries every @@ -26,25 +42,27 @@ export interface TemplateCard { } export default function TemplatesClient({ templates, categories: allCategories }: { templates: TemplateCard[]; categories: string[] }) { - const [search, setSearch] = useState(""); - const [activeCategory, setActiveCategory] = useState("All"); - - const categories = useMemo(() => ["All", ...allCategories], [allCategories]); - - const filtered = useMemo(() => { - const q = search.trim().toLowerCase(); - return templates.filter((t) => { - const matchesCategory = activeCategory === "All" || t.category === activeCategory; - if (!matchesCategory) return false; - if (!q) return true; - return ( - t.name.toLowerCase().includes(q) || - t.tagline.toLowerCase().includes(q) || - t.category.toLowerCase().includes(q) || - t.tags.some((tag) => tag.toLowerCase().includes(q)) - ); - }); - }, [search, activeCategory, templates]); + const router = useRouter(); + const pathname = usePathname(); + const params = useSearchParams(); + + // The URL is the starting state, so a filtered gallery can be linked, bookmarked and + // sent to somebody else rather than described to them. + const fromUrl = useMemo(() => filtersFromQuery(params, { categories: allCategories }), [params, allCategories]); + const [filters, setFilters] = useState(fromUrl.filters); + const [sort, setSort] = useState(fromUrl.sort); + + useEffect(() => { + const query = filtersToQuery(filters, sort); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, [filters, sort, pathname, router]); + + const categories = useMemo(() => [ALL, ...allCategories], [allCategories]); + const counts = useMemo(() => categoryCounts(templates, filters), [templates, filters]); + const backgrounds = useMemo(() => backgroundCounts(templates, filters), [templates, filters]); + const filtered = useMemo(() => sortTemplates(filterTemplates(templates, filters), sort), [templates, filters, sort]); + const search = filters.q; + const setSearch = (q: string) => setFilters((f) => ({ ...f, q })); return (
@@ -75,41 +93,81 @@ export default function TemplatesClient({ templates, categories: allCategories } /> -
+
+ What it is for {categories.map((c) => ( ))} -
+ -

- {filtered.length} of {templates.length} templates -

+
+ How the background moves + {[ALL, ...Object.keys(BACKGROUND_LABELS)].map((b) => ( + + ))} +
+ +
+

+ {filtered.length} of {templates.length} templates +

+
+ + +
+ {hasActiveFilters(filters) && ( + + )} +
{filtered.length === 0 ? (
-

Nothing matches “{search}”

+

No template matches that

- Try a category instead, or clear the search. + Clear the filters, or tell us what you were looking for.

diff --git a/src/app/templates/page.tsx b/src/app/templates/page.tsx index 56df5ed..99d50c8 100644 --- a/src/app/templates/page.tsx +++ b/src/app/templates/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { pageMeta } from "@/lib/pageMeta"; import { TEMPLATES, templateCategories, templateScrollHeight, templateSectionCount } from "@/lib/templates"; import TemplatesClient, { type TemplateCard } from "./TemplatesClient"; @@ -35,5 +36,11 @@ export default function TemplatesPage() { scrollHeight: templateScrollHeight(t), })); - return ; + // The gallery reads its filters from the URL, which is a client-side concern; the + // boundary lets the rest of the page stay static. + return ( + + + + ); } diff --git a/src/lib/templateFilters.ts b/src/lib/templateFilters.ts new file mode 100644 index 0000000..9262961 --- /dev/null +++ b/src/lib/templateFilters.ts @@ -0,0 +1,119 @@ +/** + * What the gallery filters on. + * + * Two axes, because one was not enough to find anything in a catalogue this size: what + * the site is for, and how its background moves. Counts come with them, since a filter + * value that would return nothing is worth knowing before it is clicked. + */ + +export const ALL = "All"; + +export const BACKGROUND_LABELS: Record = { + gradient: "Gradient", + geometric: "Geometric", + particles: "Particles", + wave: "Wave", +}; + +export type FilterableTemplate = { + name: string; + tagline: string; + category: string; + tags: string[]; + style: string; +}; + +export type TemplateFilters = { q: string; category: string; background: string }; + +export const EMPTY_FILTERS: TemplateFilters = { q: "", category: ALL, background: ALL }; + +export const SORTS = ["Catalogue order", "A to Z"] as const; +export type TemplateSort = (typeof SORTS)[number]; + +function matchesSearch(t: FilterableTemplate, q: string): boolean { + if (!q) return true; + const needle = q.trim().toLowerCase(); + if (!needle) return true; + return ( + t.name.toLowerCase().includes(needle) || + t.tagline.toLowerCase().includes(needle) || + t.category.toLowerCase().includes(needle) || + (BACKGROUND_LABELS[t.style] ?? t.style).toLowerCase().includes(needle) || + t.tags.some((tag) => tag.toLowerCase().includes(needle)) + ); +} + +export function matchesFilters(t: FilterableTemplate, f: TemplateFilters): boolean { + if (f.category !== ALL && t.category !== f.category) return false; + if (f.background !== ALL && t.style !== f.background) return false; + return matchesSearch(t, f.q); +} + +export function filterTemplates(templates: T[], f: TemplateFilters): T[] { + return templates.filter((t) => matchesFilters(t, f)); +} + +export function sortTemplates(templates: T[], sort: TemplateSort): T[] { + return sort === "A to Z" ? [...templates].sort((a, b) => a.name.localeCompare(b.name)) : templates; +} + +/** + * How many templates each value of one axis would return. + * + * Counted with the other axis applied but not this one, so the numbers answer "what + * happens if I click this" rather than "what is selected now". + */ +function countsFor( + templates: T[], + f: TemplateFilters, + axis: "category" | "background", + valueOf: (t: T) => string +): Record { + const others: TemplateFilters = { ...f, [axis]: ALL }; + const counts: Record = { [ALL]: 0 }; + for (const t of templates) { + if (!matchesFilters(t, others)) continue; + counts[ALL] += 1; + const value = valueOf(t); + counts[value] = (counts[value] ?? 0) + 1; + } + return counts; +} + +export function categoryCounts(templates: T[], f: TemplateFilters) { + return countsFor(templates, f, "category", (t) => t.category); +} + +export function backgroundCounts(templates: T[], f: TemplateFilters) { + return countsFor(templates, f, "background", (t) => t.style); +} + +export function hasActiveFilters(f: TemplateFilters): boolean { + return f.q.trim() !== "" || f.category !== ALL || f.background !== ALL; +} + +/** The filters as a query string, so a filtered view can be linked and shared. */ +export function filtersToQuery(f: TemplateFilters, sort: TemplateSort): string { + const params = new URLSearchParams(); + if (f.q.trim()) params.set("q", f.q.trim()); + if (f.category !== ALL) params.set("for", f.category); + if (f.background !== ALL) params.set("background", f.background); + if (sort !== SORTS[0]) params.set("sort", "az"); + return params.toString(); +} + +export function filtersFromQuery( + params: { get(key: string): string | null }, + known: { categories: string[] } +): { filters: TemplateFilters; sort: TemplateSort } { + const category = params.get("for") ?? ""; + const background = params.get("background") ?? ""; + return { + filters: { + q: (params.get("q") ?? "").slice(0, 100), + category: known.categories.includes(category) ? category : ALL, + background: background in BACKGROUND_LABELS ? background : ALL, + }, + sort: params.get("sort") === "az" ? "A to Z" : SORTS[0], + }; +}