mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
Refactor tag filters to dropdowns
This commit is contained in:
parent
bfa29d239f
commit
8f98287505
222
package-lock.json
generated
222
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, IconType> = {
|
||||
"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<string | null>(null);
|
||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||
const [selectedBaseRoms, setSelectedBaseRoms] = React.useState<string[]>([]);
|
||||
const [sort, setSort] = React.useState("popular");
|
||||
const [hacks, setHacks] = React.useState<any[]>([]);
|
||||
const [tags, setTags] = React.useState<string[]>([]);
|
||||
const [tagGroups, setTagGroups] = React.useState<Record<string, string[]>>({});
|
||||
const [ungroupedTags, setUngroupedTags] = React.useState<string[]>([]);
|
||||
|
||||
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<string, string[]>();
|
||||
(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<string>();
|
||||
mapped.forEach((h) => h.tags.forEach((t) => tagSet.add(t)));
|
||||
setTags(Array.from(tagSet).sort());
|
||||
if (allTagRows) {
|
||||
const groups: Record<string, string[]> = {};
|
||||
const ungrouped: string[] = [];
|
||||
const unique = new Set<string>();
|
||||
// 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 (
|
||||
<div>
|
||||
<div className="max-w-[1200px] mx-auto">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
|
|
@ -120,30 +195,58 @@ export default function DiscoverBrowser() {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setTag(null)}
|
||||
className={`rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors shadow-sm ${
|
||||
tag === null
|
||||
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35 shadow-[inset_0_1px_0_rgba(0,0,0,0.04)]"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 shadow-[inset_0_1px_0_rgba(0,0,0,0.03)]"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{tags.map((t) => (
|
||||
{/* Unified filter section: Base ROM dropdown first, category dropdowns next, ungrouped tags last */}
|
||||
<div className="mt-6 flex flex-wrap items-center gap-2">
|
||||
<MultiSelectDropdown
|
||||
icon={BsSdCardFill}
|
||||
label="Base ROM"
|
||||
options={baseRoms.map((b) => ({ id: b.id, name: b.name }))}
|
||||
values={selectedBaseRoms}
|
||||
onChange={setSelectedBaseRoms}
|
||||
/>
|
||||
{Object.keys(tagGroups)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((cat) => (
|
||||
<MultiSelectDropdown
|
||||
key={cat}
|
||||
icon={CATEGORY_ICON[cat]}
|
||||
label={cat}
|
||||
options={tagGroups[cat].map((t) => ({ 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) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTag(t)}
|
||||
className={`rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors shadow-sm ${
|
||||
tag === t
|
||||
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35 shadow-[inset_0_1px_0_rgba(0,0,0,0.04)]"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 shadow-[inset_0_1px_0_rgba(0,0,0,0.03)]"
|
||||
onClick={() => toggleTag(t)}
|
||||
className={`rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors ${
|
||||
selectedTags.includes(t)
|
||||
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
{(selectedTags.length > 0 || selectedBaseRoms.length > 0) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearTags();
|
||||
clearBaseRoms();
|
||||
}}
|
||||
className="ml-2 rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
|
@ -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<string, string>();
|
||||
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 (
|
||||
<Listbox value={values} onChange={onChange} multiple>
|
||||
<div className="relative">
|
||||
<ListboxButton
|
||||
ref={refs.setReference}
|
||||
className={`flex max-w-[22rem] cursor-pointer select-none items-center gap-2 truncate rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors ${
|
||||
hasSelection
|
||||
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
} data-open:ring-2 data-open:ring-[var(--ring)]`}
|
||||
>
|
||||
{Icon ? <Icon className="h-4 w-4" /> : null}
|
||||
<span className="truncate">
|
||||
{selectedNames.length > 0 ? `${label}: ${selectedNames.join(", ")}` : label}
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions ref={refs.setFloating} style={floatingStyles} className="z-50 max-h-64 min-w-[14rem] overflow-auto rounded-md border border-[var(--border)] bg-white/70 dark:bg-zinc-800/70 backdrop-blur-xl p-1 shadow-lg focus:outline-none">
|
||||
{options.map((opt) => (
|
||||
<ListboxOption
|
||||
key={opt.id}
|
||||
value={opt.id}
|
||||
className={({ active }) =>
|
||||
`cursor-pointer select-none rounded px-2 py-1 text-sm ${active ? "bg-black/5 dark:bg-white/10" : ""}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" readOnly checked={selected} className="h-4 w-4 accent-[var(--accent)]" />
|
||||
<span className="text-foreground/90">{opt.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user