diff --git a/site/package-lock.json b/site/package-lock.json index d9d5279..2c31c77 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -34,7 +34,8 @@ "react": "^18", "react-dom": "^18", "react-markdown": "^9.0.1", - "recharts": "^2.12.7" + "recharts": "^2.12.7", + "use-debounce": "^10.0.2" }, "devDependencies": { "@cloudflare/next-on-pages": "^1.12.1", @@ -3422,9 +3423,9 @@ } }, "node_modules/@vercel/python": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@vercel/python/-/python-4.3.0.tgz", - "integrity": "sha512-tj6ffEh+ligmQoo/ONOg7DNX0VGKJt9FyswyOIIp6lZufs5oGzHAfan4+5QzF/2INxvXobN0aMYgcbFHJ81ZKg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@vercel/python/-/python-4.3.1.tgz", + "integrity": "sha512-pWRApBwUsAQJS8oZ7eKMiaBGbYJO71qw2CZqDFvkTj34FNBZtNIUcWSmqGfJJY5m2pU/9wt8z1CnKIyT9dstog==", "dev": true }, "node_modules/@vercel/redwood": { @@ -3450,9 +3451,9 @@ } }, "node_modules/@vercel/remix-builder": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@vercel/remix-builder/-/remix-builder-2.2.1.tgz", - "integrity": "sha512-3mM8XNWEo5HmPv/FT2pseGk6MIHcRcLgNHwVQxWe+CSgEXt4QcNQYtwF6v9pb4HDTt09Y1rRkSED5HXvMO38/A==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@vercel/remix-builder/-/remix-builder-2.2.2.tgz", + "integrity": "sha512-PvtiRgampHVou6hlKbdHvS/rzsS/4LIntdRrrcvJotWcG8PcpxsydeGvl7RWGNlcUnspX33OsHkjiArskjGKRg==", "dev": true, "dependencies": { "@vercel/error-utils": "2.0.2", @@ -11355,6 +11356,17 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/use-debounce": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.2.tgz", + "integrity": "sha512-MwBiJK2dk+2qhMDVDCPRPeLuIekKfH2t1UYMnrW9pwcJJGFDbTLliSMBz2UKGmE1PJs8l3XoMqbIU1MemMAJ8g==", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", @@ -11436,9 +11448,9 @@ "dev": true }, "node_modules/vercel": { - "version": "35.1.0", - "resolved": "https://registry.npmjs.org/vercel/-/vercel-35.1.0.tgz", - "integrity": "sha512-q9vHaeMGQaOPPOEtzABuvN7qO2+hwKnDIzV7GprPECXpcvP3J2Ge8yJls1erESa0uvjcKU55T8fI4RS3rWzXgA==", + "version": "35.2.2", + "resolved": "https://registry.npmjs.org/vercel/-/vercel-35.2.2.tgz", + "integrity": "sha512-zG1TURH7bviGs70XUWGboc/IuZsAVijRmMiIFk3yCmE6XACvvLSNWBQVJ41Kx/J/ms0Zh88aOvnE+LuogHNc9Q==", "dev": true, "dependencies": { "@vercel/build-utils": "8.3.5", @@ -11447,9 +11459,9 @@ "@vercel/hydrogen": "1.0.4", "@vercel/next": "4.3.6", "@vercel/node": "3.2.7", - "@vercel/python": "4.3.0", + "@vercel/python": "4.3.1", "@vercel/redwood": "2.1.3", - "@vercel/remix-builder": "2.2.1", + "@vercel/remix-builder": "2.2.2", "@vercel/ruby": "2.1.0", "@vercel/static-build": "2.5.17", "chokidar": "3.3.1" diff --git a/site/package.json b/site/package.json index 82606c3..87e0940 100644 --- a/site/package.json +++ b/site/package.json @@ -39,7 +39,8 @@ "react": "^18", "react-dom": "^18", "react-markdown": "^9.0.1", - "recharts": "^2.12.7" + "recharts": "^2.12.7", + "use-debounce": "^10.0.2" }, "devDependencies": { "@cloudflare/next-on-pages": "^1.12.1", diff --git a/site/src/app/components/SearchSpotlight.tsx b/site/src/app/components/SearchSpotlight.tsx new file mode 100644 index 0000000..462d801 --- /dev/null +++ b/site/src/app/components/SearchSpotlight.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { Spotlight, SpotlightActionGroupData } from "@mantine/spotlight"; +import { useEffect, useState } from "react"; +import { useDebounce } from "use-debounce"; +import { useRouter } from "next/navigation"; +import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; + +type ClientFriendlyData = { + href: string; + title: string; +}; + +async function getResults(query: string): Promise<{ + debate: ClientFriendlyData[]; + bill: ClientFriendlyData[]; +}> { + const url = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/search`; + const headers = new Headers({ + Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`, + }); + const request = new Request(url, { + headers: headers, + method: "POST", + body: JSON.stringify({ query }), + }); + const response = await fetch(request); + return response.json(); +} + +function convertDataToActions( + router: AppRouterInstance, + data: ClientFriendlyData[], +) { + return data.map((item) => ({ + id: item.href, + label: item.title, + onClick: () => router.push(item.href), + })); +} + +function convertResultsToActionGroups( + router: AppRouterInstance, + results: { + debate: ClientFriendlyData[]; + bill: ClientFriendlyData[]; + }, +): SpotlightActionGroupData[] { + return [ + { + group: "Debates", + actions: convertDataToActions(router, results.debate), + }, + { + group: "Bills", + actions: convertDataToActions(router, results.bill), + }, + ]; +} + +// Empty action groups make nothingFound not work properly, and empty headers ain't that good anyway +function clearEmptyActionGroups(actionGroups: SpotlightActionGroupData[]) { + return actionGroups.filter((group) => group.actions.length > 0); +} + +export default function SearchSpotlight() { + const router = useRouter(); + + const [query, setQuery] = useState(""); + const [debouncedQuery] = useDebounce(query, 500); + const [results, setResults] = useState<{ + searchHappened: boolean; + actionGroups: SpotlightActionGroupData[]; + }>({ + searchHappened: false, + actionGroups: [], + }); + + useEffect(() => { + let isCanceled = false; + (async () => { + if (debouncedQuery != "") { + const results = await getResults(debouncedQuery); + if (isCanceled) { + return; + } + setResults({ + searchHappened: true, + actionGroups: clearEmptyActionGroups( + convertResultsToActionGroups(router, results), + ), + }); + } else { + setResults({ searchHappened: false, actionGroups: [] }); + } + })(); + // If the debouncedQuery changes before the request completes, cancel the request's setActions call + return () => { + isCanceled = true; + }; + }, [debouncedQuery]); + + return ( + { + setQuery(query); + }} + actions={results.actionGroups} + shortcut={null} + filter={(_, actions) => + // Don't filter; search is already done + actions + } + scrollable + highlightQuery + nothingFound={ + results.searchHappened ? "No results found" : "Start typing to search" + } + radius="lg" + /> + ); +} diff --git a/site/src/app/components/StandardShell.tsx b/site/src/app/components/StandardShell.tsx index deb6066..7be6b1b 100644 --- a/site/src/app/components/StandardShell.tsx +++ b/site/src/app/components/StandardShell.tsx @@ -14,6 +14,8 @@ import { useDisclosure, useHeadroom } from "@mantine/hooks"; import classes from "./StandardShell.module.css"; import PageFooter from "@/app/components/PageFooter"; import Link from "next/link"; +import { spotlight } from "@mantine/spotlight"; +import SearchSpotlight from "@/app/components/SearchSpotlight"; type PageLink = { name: string; @@ -21,17 +23,30 @@ type PageLink = { }; function generateButtonsFromLinks(links: PageLink[], closeNavbar: () => void) { - return links.map(({ name, href }) => ( - - {name} - - )); + return ( + <> + { + closeNavbar(); + spotlight.open(); + }} + > + Search + + {links.map(({ name, href }) => ( + + {name} + + ))} + + ); } export default function StandardShell({ @@ -78,6 +93,9 @@ export default function StandardShell({ + + {/* I think this can be anywhere since it's a modal */} + ); } diff --git a/site/src/app/layout.tsx b/site/src/app/layout.tsx index 3fd5fca..3b94061 100644 --- a/site/src/app/layout.tsx +++ b/site/src/app/layout.tsx @@ -5,6 +5,7 @@ import "./globals.css"; // Import styles of packages that you've installed. // All packages except `@mantine/hooks` require styles imports import "@mantine/core/styles.css"; +import "@mantine/spotlight/styles.css"; import { ColorSchemeScript, MantineProvider, Text } from "@mantine/core"; import StandardShell from "@/app/components/StandardShell";