From 8f98287505599b2174514cc88048fe8a6c3c78d7 Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Tue, 21 Oct 2025 22:50:29 -1000 Subject: [PATCH] Refactor tag filters to dropdowns --- package-lock.json | 222 +++++++++++++++++ package.json | 1 + src/components/Discover/DiscoverBrowser.tsx | 252 +++++++++++++++++--- 3 files changed, 448 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7a119d..2266ebb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", + "@headlessui/react": "^2.2.9", "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.74.0", "embla-carousel-react": "8.6.0", @@ -111,6 +112,79 @@ "tslib": "^2.4.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -736,6 +810,103 @@ "node": ">= 10" } }, + "node_modules/@react-aria/focus": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", + "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.25.6", + "@react-aria/utils": "^3.31.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.6", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", + "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.31.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", + "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.10.8", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", + "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@supabase/auth-js": { "version": "2.74.0", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.74.0.tgz", @@ -1120,6 +1291,33 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1485,6 +1683,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4259,6 +4466,12 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", @@ -4478,6 +4691,15 @@ "node": ">= 10.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/package.json b/package.json index b3e23f5..65700f7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", + "@headlessui/react": "^2.2.9", "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.74.0", "embla-carousel-react": "8.6.0", diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index 3852862..cc81918 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -1,16 +1,44 @@ "use client"; -import React from "react"; +import React, { Fragment } from "react"; import HackCard from "@/components/HackCard"; import { createClient } from "@/utils/supabase/client"; +import { baseRoms } from "@/data/baseRoms"; +import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from "@headlessui/react"; +import { useFloating, offset, flip, shift, size, autoUpdate } from "@floating-ui/react"; +import { IconType } from "react-icons"; +import { + MdCatchingPokemon, + MdNewReleases, + MdAutoFixHigh, + MdSettingsSuggest, +} from "react-icons/md"; +import { BiSolidGame } from "react-icons/bi"; +import { FaClock, FaGaugeHigh, FaMasksTheater } from "react-icons/fa6"; +import { BsSdCardFill } from "react-icons/bs"; +import { IoLogoGameControllerA } from "react-icons/io"; + +const CATEGORY_ICON: Record = { + "Pokédex": MdCatchingPokemon, + "Sprites": BiSolidGame, + "New": MdNewReleases, + "Altered": MdAutoFixHigh, + "Quality of Life": MdSettingsSuggest, + "Gameplay": IoLogoGameControllerA, + "Difficulty": FaGaugeHigh, + "Scale": FaClock, + "Tone": FaMasksTheater, +}; export default function DiscoverBrowser() { const supabase = createClient(); const [query, setQuery] = React.useState(""); - const [tag, setTag] = React.useState(null); + const [selectedTags, setSelectedTags] = React.useState([]); + const [selectedBaseRoms, setSelectedBaseRoms] = React.useState([]); const [sort, setSort] = React.useState("popular"); const [hacks, setHacks] = React.useState([]); - const [tags, setTags] = React.useState([]); + const [tagGroups, setTagGroups] = React.useState>({}); + const [ungroupedTags, setUngroupedTags] = React.useState([]); React.useEffect(() => { const run = async () => { @@ -49,7 +77,7 @@ export default function DiscoverBrowser() { } const { data: tagRows } = await supabase .from("hack_tags") - .select("hack_slug,tags(name)") + .select("hack_slug,tags(name,category)") .in("hack_slug", slugs); const tagsBySlug = new Map(); (tagRows || []).forEach((r: any) => { @@ -58,6 +86,10 @@ export default function DiscoverBrowser() { arr.push(r.tags.name); tagsBySlug.set(r.hack_slug, arr); }); + // Fetch all tags with category to build UI groups + const { data: allTagRows } = await supabase + .from("tags") + .select("name,category"); const { data: profiles } = await supabase .from("profiles") .select("id,username"); @@ -78,9 +110,29 @@ export default function DiscoverBrowser() { })); setHacks(mapped); - const tagSet = new Set(); - mapped.forEach((h) => h.tags.forEach((t) => tagSet.add(t))); - setTags(Array.from(tagSet).sort()); + if (allTagRows) { + const groups: Record = {}; + const ungrouped: string[] = []; + const unique = new Set(); + // Build groups from authoritative tags table, so we include tags not present in current results too + for (const row of allTagRows as any[]) { + const name: string = row.name; + if (unique.has(name)) continue; + unique.add(name); + const category: string | null = row.category ?? null; + if (category) { + if (!groups[category]) groups[category] = []; + groups[category].push(name); + } else { + ungrouped.push(name); + } + } + // Sort for stable UI + Object.keys(groups).forEach((k) => groups[k].sort((a, b) => a.localeCompare(b))); + ungrouped.sort((a, b) => a.localeCompare(b)); + setTagGroups(groups); + setUngroupedTags(ungrouped); + } }; run(); }, [sort]); @@ -95,12 +147,35 @@ export default function DiscoverBrowser() { (h.description || "").toLowerCase().includes(q) ); } - if (tag) out = out.filter((h) => h.tags.includes(tag)); + // AND filter across selected tags: hack must include all selectedTags + if (selectedTags.length > 0) { + out = out.filter((h) => selectedTags.every((t) => h.tags.includes(t))); + } + // OR filter across base roms: hack's baseRomId must be in selectedBaseRoms + if (selectedBaseRoms.length > 0) { + out = out.filter((h) => selectedBaseRoms.includes(h.baseRomId)); + } return out; - }, [hacks, query, tag]); + }, [hacks, query, selectedTags, selectedBaseRoms]); + + function toggleTag(name: string) { + setSelectedTags((prev) => (prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name])); + } + + function clearTags() { + setSelectedTags([]); + } + + function toggleBaseRom(id: string) { + setSelectedBaseRoms((prev) => (prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id])); + } + + function clearBaseRoms() { + setSelectedBaseRoms([]); + } return ( -
+
-
- - {tags.map((t) => ( + {/* Unified filter section: Base ROM dropdown first, category dropdowns next, ungrouped tags last */} +
+ ({ id: b.id, name: b.name }))} + values={selectedBaseRoms} + onChange={setSelectedBaseRoms} + /> + {Object.keys(tagGroups) + .sort((a, b) => a.localeCompare(b)) + .map((cat) => ( + ({ id: t, name: t }))} + values={selectedTags.filter((t) => tagGroups[cat].includes(t))} + onChange={(vals) => { + // Replace selections for this category while keeping others + setSelectedTags((prev) => { + const others = prev.filter((t) => !tagGroups[cat].includes(t)); + return [...others, ...vals]; + }); + }} + /> + ))} + {/* Ungrouped tags as individual pills at the end */} + {ungroupedTags.map((t) => ( ))} + {(selectedTags.length > 0 || selectedBaseRoms.length > 0) && ( + + )}
@@ -155,4 +258,99 @@ export default function DiscoverBrowser() { ); } +interface MultiSelectOption { + id: string; + name: string; +} + +interface MultiSelectDropdownProps { + icon?: IconType; + label: string; + options: MultiSelectOption[]; + values: string[]; + onChange: (next: string[]) => void; +} + +function MultiSelectDropdown({ + icon: Icon, + label, + options, + values, + onChange, +}: MultiSelectDropdownProps) { + const { refs, floatingStyles, update } = useFloating({ + placement: "bottom-start", + strategy: "fixed", + middleware: [ + offset(8), + flip({ padding: 8 }), + shift({ padding: 8 }), + size({ + apply({ availableWidth, elements }) { + Object.assign(elements.floating.style, { + maxWidth: `${Math.min(availableWidth, 420)}px`, + }); + }, + }), + ], + }); + React.useEffect(() => { + const reference = refs.reference.current; + const floating = refs.floating.current; + if (!reference || !floating) return; + return autoUpdate(reference, floating, update); + }, [refs.reference, refs.floating, update]); + const nameById = React.useMemo(() => { + const map = new Map(); + options.forEach((o) => map.set(o.id, o.name)); + return map; + }, [options]); + const selectedNames = values.map((v) => nameById.get(v) || v); + const hasSelection = values.length > 0; + return ( + +
+ + {Icon ? : null} + + {selectedNames.length > 0 ? `${label}: ${selectedNames.join(", ")}` : label} + + + + + {options.map((opt) => ( + + `cursor-pointer select-none rounded px-2 py-1 text-sm ${active ? "bg-black/5 dark:bg-white/10" : ""}` + } + > + {({ selected }) => ( +
+ + {opt.name} +
+ )} +
+ ))} +
+
+
+
+ ); +} +