fuzzy search logic
This commit is contained in:
Generated
+169
@@ -10,7 +10,9 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@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-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@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": {
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "2.2.6",
|
"version": "2.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@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-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
|||||||
@@ -1,32 +1,85 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
export default function SearchBar({ onSearch }) {
|
export default function SearchBar({ onSearch }) {
|
||||||
const [query, setQuery] = useState("");
|
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) {
|
function handleKeyDown(e) {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault(); // Prevents form submission / reloads
|
e.preventDefault();
|
||||||
onSearch(query.trim()); // Fire search
|
clearTimeout(debounceTimer.current);
|
||||||
|
fireSearch(query);
|
||||||
|
inputRef.current?.blur(); // optional UX
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Click search button */
|
||||||
function handleSearchClick() {
|
function handleSearchClick() {
|
||||||
onSearch(query.trim());
|
clearTimeout(debounceTimer.current);
|
||||||
|
fireSearch(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear input */
|
||||||
|
function clearInput() {
|
||||||
|
setQuery("");
|
||||||
|
fireSearch("");
|
||||||
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 w-full max-w-xl">
|
<div className="flex gap-3 w-full max-w-xl relative">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
suppressHydrationWarning
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
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"
|
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 && (
|
||||||
|
<button
|
||||||
|
suppressHydrationWarning
|
||||||
|
type="button"
|
||||||
|
onClick={clearInput}
|
||||||
|
className="absolute right-24 top-1/2 -translate-y-1/2 text-gray-500 hover:text-black"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
suppressHydrationWarning
|
||||||
onClick={handleSearchClick}
|
onClick={handleSearchClick}
|
||||||
className="px-5 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition"
|
className="px-5 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function SearchResultCard({ item }) {
|
export default function SearchResultCard({ item, bestScore }) {
|
||||||
const isModel = !!item.categories; // Models have .categories
|
const isModel = item.type === "model";
|
||||||
const href = isModel ? `/model/${item.id}` : `/part/${item.id}`;
|
const href = isModel
|
||||||
|
? `/model/${item.slug ?? item.id}`
|
||||||
|
: `/part/${item.sku ?? item.id}`;
|
||||||
|
|
||||||
|
// Determine if this is the “best match”
|
||||||
|
const isBestMatch =
|
||||||
|
bestScore != null && item.score != null && item.score >= bestScore - 0.02; // 2% threshold
|
||||||
|
|
||||||
const placeholderImg = `https://placehold.co/400x250?text=${encodeURIComponent(
|
const placeholderImg = `https://placehold.co/400x250?text=${encodeURIComponent(
|
||||||
item.name
|
item.name
|
||||||
@@ -13,17 +19,21 @@ export default function SearchResultCard({ item }) {
|
|||||||
href={href}
|
href={href}
|
||||||
className="block border rounded-xl p-5 shadow-sm hover:shadow-md hover:-translate-y-1 transition bg-white"
|
className="block border rounded-xl p-5 shadow-sm hover:shadow-md hover:-translate-y-1 transition bg-white"
|
||||||
>
|
>
|
||||||
{/* Image */}
|
{/* Best Match Badge */}
|
||||||
|
{isBestMatch && (
|
||||||
|
<div className="inline-block mb-2 px-3 py-1 text-xs font-semibold bg-green-100 text-green-700 rounded-full">
|
||||||
|
Best Match
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={placeholderImg}
|
src={placeholderImg}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="rounded mb-4 w-full object-cover"
|
className="rounded mb-4 w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<h3 className="text-lg font-bold mb-1">{item.name}</h3>
|
<h3 className="text-lg font-bold mb-1">{item.name}</h3>
|
||||||
|
|
||||||
{/* Type Tag */}
|
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-3 py-1 text-xs rounded-full capitalize ${
|
className={`inline-block px-3 py-1 text-xs rounded-full capitalize ${
|
||||||
isModel
|
isModel
|
||||||
@@ -34,9 +44,8 @@ export default function SearchResultCard({ item }) {
|
|||||||
{isModel ? "Model" : "Part"}
|
{isModel ? "Model" : "Part"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Extra meta */}
|
<p className="mt-3 text-sm text-gray-500 font-mono">
|
||||||
<p className="mt-3 text-sm text-gray-500">
|
{isModel ? item.slug ?? item.id : item.sku ?? item.id}
|
||||||
ID: <span className="font-mono">{item.id}</span>
|
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
+80
-39
@@ -1,55 +1,84 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import ModelCard from "../components/ModelCard";
|
import ModelCard from "../components/ModelCard";
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
);
|
||||||
|
|
||||||
export default function ModelsPage() {
|
export default function ModelsPage() {
|
||||||
const [models, setModels] = useState([]);
|
const [models, setModels] = useState([]);
|
||||||
const [filtered, setFiltered] = useState([]);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
const [brand, setBrand] = useState("all");
|
const [brand, setBrand] = useState("all");
|
||||||
const [scale, setScale] = useState("all");
|
const [scale, setScale] = useState("all");
|
||||||
|
|
||||||
|
const filtered = models.filter((m) => {
|
||||||
|
const matchesSearch =
|
||||||
|
m.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
m.manufacturer.toLowerCase().includes(search.toLowerCase());
|
||||||
|
|
||||||
|
const matchesBrand = brand === "all" || m.manufacturer === brand;
|
||||||
|
const matchesScale = scale === "all" || m.scale === scale;
|
||||||
|
|
||||||
|
return matchesSearch && matchesBrand && matchesScale;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const res = await fetch("/api/models", { cache: "no-store" });
|
setLoading(true);
|
||||||
const data = await res.json();
|
|
||||||
setModels(data);
|
const { data, error } = await supabase
|
||||||
setFiltered(data);
|
.from("models")
|
||||||
|
.select("*")
|
||||||
|
.order("manufacturer", { ascending: true })
|
||||||
|
.order("name", { ascending: true });
|
||||||
|
|
||||||
|
if (error) console.error(error);
|
||||||
|
|
||||||
|
setModels(data || []);
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function filter() {
|
const brands = [...new Set(models.map((m) => m.manufacturer))];
|
||||||
let result = [...models];
|
const scales = [...new Set(models.map((m) => m.scale))];
|
||||||
|
|
||||||
if (brand !== "all") {
|
|
||||||
result = result.filter(m => m.brand === brand);
|
|
||||||
}
|
|
||||||
if (scale !== "all") {
|
|
||||||
result = result.filter(m => m.scale === scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiltered(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
const brands = [...new Set(models.map(m => m.brand))];
|
|
||||||
const scales = [...new Set(models.map(m => m.scale))];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-4xl font-bold">All Models</h1>
|
<h1 className="text-4xl font-bold">All Models</h1>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-4 items-end">
|
<div className="flex gap-4 items-end flex-wrap">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 font-semibold">Search</label>
|
||||||
|
<input
|
||||||
|
className="border p-2 rounded w-64"
|
||||||
|
placeholder="Search models..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block mb-1 font-semibold">Brand</label>
|
<label className="block mb-1 font-semibold">Brand</label>
|
||||||
<select
|
<select
|
||||||
className="border p-2 rounded"
|
className="border p-2 rounded"
|
||||||
value={brand}
|
value={brand}
|
||||||
onChange={e => setBrand(e.target.value)}
|
onChange={(e) => setBrand(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
{brands.map((b) => (
|
||||||
|
<option key={b} value={b}>
|
||||||
|
{b}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -58,29 +87,41 @@ export default function ModelsPage() {
|
|||||||
<select
|
<select
|
||||||
className="border p-2 rounded"
|
className="border p-2 rounded"
|
||||||
value={scale}
|
value={scale}
|
||||||
onChange={e => setScale(e.target.value)}
|
onChange={(e) => setScale(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
{scales.map(s => <option key={s} value={s}>{s}</option>)}
|
{scales.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={filter}
|
|
||||||
className="px-4 py-2 bg-primary text-white rounded"
|
|
||||||
>
|
|
||||||
Apply Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loading skeleton */}
|
||||||
|
{loading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-40 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Model Grid */}
|
{/* Model Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{!loading && (
|
||||||
{filtered.map(model => (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<a key={model.id} href={`/model/${model.id}`} className="block">
|
{filtered.map((model) => (
|
||||||
<ModelCard model={model} />
|
<a
|
||||||
</a>
|
key={model.slug}
|
||||||
))}
|
href={`/models/${model.slug}`}
|
||||||
</div>
|
className="block"
|
||||||
|
>
|
||||||
|
<ModelCard model={model} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-59
@@ -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 (
|
|
||||||
// <div className="space-y-6">
|
|
||||||
// <h1 className="text-4xl font-bold">RC Compatibility Explorer</h1>
|
|
||||||
|
|
||||||
// <SearchBar onSearch={handleSearch} />
|
|
||||||
|
|
||||||
// <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
// {results.map((item) => {
|
|
||||||
// const isModel = !!item.categories;
|
|
||||||
// const link = isModel ? `/model/${item.id}` : `/part/${item.id}`;
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <a
|
|
||||||
// key={item.id}
|
|
||||||
// href={link}
|
|
||||||
// className="block border p-4 rounded hover:bg-gray-50"
|
|
||||||
// >
|
|
||||||
// <strong>{item.name}</strong>
|
|
||||||
// <div className="opacity-60">{isModel ? "Model" : "Part"}</div>
|
|
||||||
// </a>
|
|
||||||
// );
|
|
||||||
// })}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import SearchBar from "./components/SearchBar";
|
import SearchBar from "./components/SearchBar";
|
||||||
import SearchResultCard from "./components/SearchResultCard";
|
import SearchResultCard from "./components/SearchResultCard";
|
||||||
import SearchSkeletonCard from "./components/SearchSkeletonCard";
|
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() {
|
export default function HomePage() {
|
||||||
const [results, setResults] = useState(null); // null = loading phase
|
const [results, setResults] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function handleSearch(query) {
|
async function handleSearch(query) {
|
||||||
if (!query) {
|
if (!query || query.trim() === "") {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -61,24 +24,43 @@ export default function HomePage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setResults(null);
|
setResults(null);
|
||||||
|
|
||||||
// Simulate real network delay for proper skeleton visuals
|
const q = query.trim();
|
||||||
setTimeout(() => {
|
|
||||||
const models = searchModels(query);
|
// Fuzzy RPC search
|
||||||
const parts = searchParts(query);
|
const { data: modelData } = await supabase.rpc("search_models", { q });
|
||||||
setResults([...models, ...parts]);
|
const { data: partData } = await supabase.rpc("search_parts", { q });
|
||||||
setLoading(false);
|
|
||||||
}, 300);
|
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 (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{/* Title */}
|
|
||||||
<h1 className="text-4xl font-bold">RC Compatibility Explorer</h1>
|
<h1 className="text-4xl font-bold">RC Compatibility Explorer</h1>
|
||||||
|
|
||||||
{/* Search Field */}
|
|
||||||
<SearchBar onSearch={handleSearch} />
|
<SearchBar onSearch={handleSearch} />
|
||||||
|
|
||||||
{/* Skeleton Loader */}
|
{/* Skeleton */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
@@ -88,19 +70,23 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{!loading && results?.length > 0 && (
|
{!loading && results?.list?.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-2xl font-semibold">Search Results</h2>
|
<h2 className="text-2xl font-semibold">Search Results</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{results.map((item) => (
|
{results.list.map((item) => (
|
||||||
<SearchResultCard key={item.id} item={item} />
|
<SearchResultCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
bestScore={results.bestScore}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No Results */}
|
{/* No Results */}
|
||||||
{!loading && results?.length === 0 && (
|
{!loading && results?.list?.length === 0 && (
|
||||||
<p>No matching models or parts found.</p>
|
<p>No matching models or parts found.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>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) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props} />
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -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) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
Reference in New Issue
Block a user