From a4621875e4521f53af719b3feff8bf690f6155f6 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 25 Nov 2025 12:32:02 +0000 Subject: [PATCH] fuzzy search logic --- package-lock.json | 169 +++++++++++++++++++++++++ package.json | 2 + src/app/components/SearchBar.js | 65 +++++++++- src/app/components/SearchResultCard.js | 27 ++-- src/app/models/page.js | 119 +++++++++++------ src/app/page.js | 104 +++++++-------- src/components/ui/dropdown-menu.jsx | 158 +++++++++++++++++++++++ src/components/ui/popover.jsx | 29 +++++ 8 files changed, 560 insertions(+), 113 deletions(-) create mode 100644 src/components/ui/dropdown-menu.jsx create mode 100644 src/components/ui/popover.jsx diff --git a/package-lock.json b/package-lock.json index e202bbc..5b3889b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "ISC", "dependencies": { "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -1697,6 +1699,34 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -1796,6 +1826,115 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -1912,6 +2051,36 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", diff --git a/package.json b/package.json index d85822a..4cbdb60 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/src/app/components/SearchBar.js b/src/app/components/SearchBar.js index fa11b55..d50db52 100644 --- a/src/app/components/SearchBar.js +++ b/src/app/components/SearchBar.js @@ -1,32 +1,85 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; export default function SearchBar({ onSearch }) { const [query, setQuery] = useState(""); + const debounceTimer = useRef(null); + const inputRef = useRef(null); + /** Fire search immediately */ + function fireSearch(value) { + onSearch(value.trim()); + } + + /** Debounced search while typing */ + useEffect(() => { + const value = query.trim(); + + // If empty, clear results + if (value === "") { + fireSearch(""); + return; + } + + clearTimeout(debounceTimer.current); + debounceTimer.current = setTimeout(() => { + fireSearch(value); + }, 300); + + return () => clearTimeout(debounceTimer.current); + }, [query]); + + /** Enter key triggers immediate search */ function handleKeyDown(e) { if (e.key === "Enter") { - e.preventDefault(); // Prevents form submission / reloads - onSearch(query.trim()); // Fire search + e.preventDefault(); + clearTimeout(debounceTimer.current); + fireSearch(query); + inputRef.current?.blur(); // optional UX } } + /** Click search button */ function handleSearchClick() { - onSearch(query.trim()); + clearTimeout(debounceTimer.current); + fireSearch(query); + } + + /** Clear input */ + function clearInput() { + setQuery(""); + fireSearch(""); + inputRef.current?.focus(); } return ( -
+
setQuery(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Search for a model or part…" + placeholder="Search models or parts…" className="flex-1 border rounded-xl px-4 py-2 shadow-sm focus:ring-2 focus:ring-blue-500" /> + + {/* Clear button (only visible when typing) */} + {query && ( + + )} +
+ {/* Loading skeleton */} + {loading && ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ )} + {/* Model Grid */} -
- {filtered.map(model => ( - - - - ))} -
+ {!loading && ( +
+ {filtered.map((model) => ( + + + + ))} +
+ )}
); } diff --git a/src/app/page.js b/src/app/page.js index 9686bb0..a183282 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -1,59 +1,22 @@ -// "use client"; - -// import { useState } from "react"; -// import SearchBar from "./components/SearchBar"; -// import { searchModels, searchParts } from "../lib/search/search"; - -// export default function HomePage() { -// const [results, setResults] = useState([]); - -// function handleSearch(query) { -// const models = searchModels(query); -// const parts = searchParts(query); -// setResults([...models, ...parts]); -// } - -// return ( -//
-//

RC Compatibility Explorer

- -// - -//
-// {results.map((item) => { -// const isModel = !!item.categories; -// const link = isModel ? `/model/${item.id}` : `/part/${item.id}`; - -// return ( -// -// {item.name} -//
{isModel ? "Model" : "Part"}
-//
-// ); -// })} -//
-//
-// ); -// } - "use client"; import { useState } from "react"; +import { createClient } from "@supabase/supabase-js"; import SearchBar from "./components/SearchBar"; import SearchResultCard from "./components/SearchResultCard"; import SearchSkeletonCard from "./components/SearchSkeletonCard"; -import { searchModels, searchParts } from "@/lib/search/search"; + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY +); export default function HomePage() { - const [results, setResults] = useState(null); // null = loading phase + const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); async function handleSearch(query) { - if (!query) { + if (!query || query.trim() === "") { setResults([]); return; } @@ -61,24 +24,43 @@ export default function HomePage() { setLoading(true); setResults(null); - // Simulate real network delay for proper skeleton visuals - setTimeout(() => { - const models = searchModels(query); - const parts = searchParts(query); - setResults([...models, ...parts]); - setLoading(false); - }, 300); + const q = query.trim(); + + // Fuzzy RPC search + const { data: modelData } = await supabase.rpc("search_models", { q }); + const { data: partData } = await supabase.rpc("search_parts", { q }); + + const models = (modelData ?? []).map((m) => ({ + ...m, + type: "model", + slug: m.metadata?.slug ?? null, + })); + + const parts = (partData ?? []).map((p) => ({ + ...p, + type: "part", + })); + + // Sort by score + const combined = [...models, ...parts].sort( + (a, b) => (b.score ?? 0) - (a.score ?? 0) + ); + + setResults({ + list: combined, + bestScore: combined.length > 0 ? combined[0].score : null, + }); + + setLoading(false); } return (
- {/* Title */}

RC Compatibility Explorer

- {/* Search Field */} - {/* Skeleton Loader */} + {/* Skeleton */} {loading && (
{Array.from({ length: 6 }).map((_, i) => ( @@ -88,19 +70,23 @@ export default function HomePage() { )} {/* Results */} - {!loading && results?.length > 0 && ( + {!loading && results?.list?.length > 0 && ( <>

Search Results

- {results.map((item) => ( - + {results.list.map((item) => ( + ))}
)} {/* No Results */} - {!loading && results?.length === 0 && ( + {!loading && results?.list?.length === 0 && (

No matching models or parts found.

)}
diff --git a/src/components/ui/dropdown-menu.jsx b/src/components/ui/dropdown-menu.jsx new file mode 100644 index 0000000..c4b5f9d --- /dev/null +++ b/src/components/ui/dropdown-menu.jsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}) => { + return ( + + ); +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/components/ui/popover.jsx b/src/components/ui/popover.jsx new file mode 100644 index 0000000..37d1072 --- /dev/null +++ b/src/components/ui/popover.jsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverAnchor = PopoverPrimitive.Anchor + +const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }