mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Finish SearchView implementation
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080">
|
||||
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080">
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
Generated
+7
@@ -37,6 +37,7 @@
|
||||
"date-fns": "2.29.3",
|
||||
"formik": "^2.4.6",
|
||||
"framer-motion": "^12.5.0",
|
||||
"fzf": "^0.5.2",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"lit": "3.3.0",
|
||||
"moment": "^2.30.1",
|
||||
@@ -12998,6 +12999,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fzf": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
|
||||
"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"date-fns": "2.29.3",
|
||||
"formik": "^2.4.6",
|
||||
"framer-motion": "^12.5.0",
|
||||
"fzf": "^0.5.2",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"lit": "3.3.0",
|
||||
"moment": "^2.30.1",
|
||||
@@ -203,4 +204,4 @@
|
||||
},
|
||||
"hash": "dc682332ca36d64f455f6e13888e1ffcca97e888cbad8d356973e830f7463a10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||
|
||||
export function GameOverviewCard({game}: { game: GameDto }) {
|
||||
return (
|
||||
<GameCover game={game} radius="sm"></GameCover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
|
||||
interface CoverGridProps {
|
||||
games: GameDto[];
|
||||
}
|
||||
|
||||
export default function CoverGrid({games}: CoverGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
|
||||
{games.map((game) => (
|
||||
<GameCover key={game.id} game={game} interactive={true}/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-7
@@ -1,7 +1,6 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||
import {Card} from "@heroui/react";
|
||||
import {ArrowRight} from "@phosphor-icons/react";
|
||||
import {useNavigate} from "react-router";
|
||||
|
||||
@@ -14,7 +13,6 @@ interface CoverRowProps {
|
||||
const aspectRatio = 12 / 17; // aspect ratio of the game cover
|
||||
const defaultImageHeight = 300; // default height for the image
|
||||
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
|
||||
const radius = "sm";
|
||||
|
||||
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
||||
|
||||
@@ -47,13 +45,11 @@ export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
||||
<div className="flex flex-col mb-4">
|
||||
<p className="text-2xl font-bold mb-4">{title}</p>
|
||||
<div className="w-full relative">
|
||||
<Card ref={containerRef} className="flex flex-row gap-2 bg-transparent" radius={radius}>
|
||||
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
|
||||
{games.slice(0, visibleCount).map((game, index) => (
|
||||
<a key={index} href={`/game/${game.id}`}>
|
||||
<GameCover game={game} radius={radius} hover={true}/>
|
||||
</a>
|
||||
<GameCover key={index} game={game} radius="sm" interactive={true}/>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{showMore && (
|
||||
<div className="flex flex-row items-center justify-end cursor-pointer"
|
||||
@@ -6,22 +6,28 @@ interface GameCoverProps {
|
||||
game: GameDto;
|
||||
size?: number;
|
||||
radius?: "none" | "sm" | "md" | "lg";
|
||||
hover?: boolean;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function GameCover({game, size = 300, radius = "sm", hover = false}: GameCoverProps) {
|
||||
return (
|
||||
Number.isInteger(game.coverId) ? (
|
||||
<div className={`${hover ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 size-full object-cover aspect-[12/17]"
|
||||
src={`images/cover/${game.coverId}`}
|
||||
radius={radius}
|
||||
height={size}
|
||||
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
||||
/>
|
||||
</div>
|
||||
) : <GameCoverFallback title={game.title} size={size} radius={radius} hover={hover}/>
|
||||
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
|
||||
const coverContent = Number.isInteger(game.coverId) ? (
|
||||
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover aspect-[12/17]"
|
||||
src={`images/cover/${game.coverId}`}
|
||||
radius={radius}
|
||||
height={size}
|
||||
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<GameCoverFallback title={game.title} size={size} radius={radius} hover={interactive}/>
|
||||
);
|
||||
|
||||
return interactive ? (
|
||||
<a href={`/game/${game.id}`}>
|
||||
{coverContent}
|
||||
</a>
|
||||
) : coverContent;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
||||
})) || [];
|
||||
|
||||
setElements([...images, ...videos]);
|
||||
}, [])
|
||||
}, [imageUrls, videosUrls])
|
||||
|
||||
function showImagePopup(imageUrl: string) {
|
||||
setSelectedImageUrl(imageUrl);
|
||||
|
||||
@@ -11,6 +11,7 @@ type GameState = {
|
||||
state: Record<number, GameDto>;
|
||||
games: GameDto[];
|
||||
gamesByLibraryId: Record<number, GameDto[]>;
|
||||
sortedAlphabetically: GameDto[];
|
||||
sortedByMostRecentlyAdded: GameDto[];
|
||||
sortedByMostRecentlyUpdated: GameDto[];
|
||||
randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>;
|
||||
@@ -37,6 +38,10 @@ export const gameState = proxy<GameState>({
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
get sortedAlphabetically() {
|
||||
return this.games
|
||||
.sort((a: GameDto, b: GameDto) => a.title.localeCompare(b.title, undefined, {sensitivity: 'base'}));
|
||||
},
|
||||
get sortedByMostRecentlyAdded() {
|
||||
return this.games
|
||||
.sort((a: GameDto, b: GameDto) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
@@ -57,25 +62,53 @@ export const gameState = proxy<GameState>({
|
||||
return result;
|
||||
},
|
||||
get knownPublishers() {
|
||||
return new Set<string>(this.games.flatMap((game: GameDto) => game.publishers ? game.publishers : []));
|
||||
return new Set<string>(
|
||||
this.games
|
||||
.flatMap((game: GameDto) => game.publishers ? game.publishers : [])
|
||||
.sort()
|
||||
);
|
||||
},
|
||||
get knownDevelopers() {
|
||||
return new Set<string>(this.games.flatMap((game: GameDto) => game.developers ? game.developers : []));
|
||||
return new Set<string>(
|
||||
this.games
|
||||
.flatMap((game: GameDto) => game.developers ? game.developers : [])
|
||||
.sort()
|
||||
);
|
||||
},
|
||||
get knownGenres() {
|
||||
return new Set<string>(this.games.flatMap((game: GameDto) => game.genres ? game.genres : []));
|
||||
return new Set<string>(
|
||||
this.games
|
||||
.flatMap((game: GameDto) => game.genres ? game.genres : [])
|
||||
.sort()
|
||||
);
|
||||
},
|
||||
get knownThemes() {
|
||||
return new Set<string>(this.games.flatMap((game: GameDto) => game.themes ? game.themes : []));
|
||||
return new Set<string>(
|
||||
this.games
|
||||
.flatMap((game: GameDto) => game.themes ? game.themes : [])
|
||||
.sort()
|
||||
);
|
||||
},
|
||||
get knownKeywords() {
|
||||
return new Set<string>(this.games.flatMap((game: GameDto) => game.keywords ? game.keywords : []));
|
||||
return new Set<string>(
|
||||
this.games
|
||||
.flatMap((game: GameDto) => game.keywords ? game.keywords : [])
|
||||
.sort()
|
||||
);
|
||||
},
|
||||
get knownFeatures() {
|
||||
return new Set<string>(this.games.flatMap((game: GameDto) => game.features ? game.features : []));
|
||||
return new Set<string>(
|
||||
this.games
|
||||
.flatMap((game: GameDto) => game.features ? game.features : [])
|
||||
.sort()
|
||||
);
|
||||
},
|
||||
get knownPerspectives() {
|
||||
return new Set<string>(this.games.flatMap((game: GameDto) => game.perspectives ? game.perspectives : []));
|
||||
return new Set<string>(
|
||||
this.games
|
||||
.flatMap((game: GameDto) => game.perspectives ? game.perspectives : [])
|
||||
.sort()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||
import {CoverRow} from "Frontend/components/general/CoverRow";
|
||||
import {CoverRow} from "Frontend/components/general/covers/CoverRow";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
|
||||
@@ -6,11 +6,13 @@ import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
|
||||
import * as PackageJson from "../../../../package.json";
|
||||
import {Outlet, useLocation, useNavigate} from "react-router";
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {Heart, ListMagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass} from "@phosphor-icons/react";
|
||||
import Confetti, {ConfettiProps} from "react-confetti-boom";
|
||||
import {useTheme} from "next-themes";
|
||||
import {UserPreferenceService} from "Frontend/util/user-preference-service";
|
||||
import SearchBar from "Frontend/components/general/SearchBar";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
|
||||
export default function MainLayout() {
|
||||
const navigate = useNavigate();
|
||||
@@ -19,7 +21,9 @@ export default function MainLayout() {
|
||||
const routeMetadata = useRouteMetadata();
|
||||
const {setTheme} = useTheme();
|
||||
const isSearchPage = location.pathname.startsWith("/search");
|
||||
const isHomePage = location.pathname === "/";
|
||||
const [isExploding, setIsExploding] = useState(false);
|
||||
const games = useSnapshot(gameState).games;
|
||||
|
||||
useEffect(() => {
|
||||
let newTitle = `Gameyfin - ${routeMetadata?.title}`;
|
||||
@@ -55,17 +59,33 @@ export default function MainLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
function getRandomGameId() {
|
||||
return games[Math.floor(Math.random() * games.length)].id;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
{isExploding ? <Confetti {...confettiProps}/> : <></>}
|
||||
|
||||
<Navbar maxWidth="full" className="2xl:px-[12.5%]">
|
||||
<NavbarBrand>
|
||||
<div className="cursor-pointer" onClick={() => navigate('/')}>
|
||||
<GameyfinLogo className="h-10 fill-foreground"/>
|
||||
</div>
|
||||
{isHomePage ? <GameyfinLogo className="h-10 fill-foreground"/> :
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button isIconOnly onPress={() => history.back()} variant="light">
|
||||
<ArrowLeft size={26} weight="bold"/>
|
||||
</Button>
|
||||
<Button isIconOnly onPress={() => navigate("/")} variant="light">
|
||||
<House size={26} weight="fill"/>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</NavbarBrand>
|
||||
{!isSearchPage && <NavbarContent justify="center" className="flex-1 max-w-96">
|
||||
<Tooltip content="I'm feeling lucky" placement="bottom">
|
||||
<Button isIconOnly variant="light" onPress={() => navigate("/game/" + getRandomGameId())}>
|
||||
<DiceSix/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<SearchBar/>
|
||||
<Tooltip content="Advanced search" placement="bottom">
|
||||
<Button isIconOnly variant="light" onPress={() => navigate("/search")}>
|
||||
|
||||
@@ -1,27 +1,168 @@
|
||||
import {Input} from "@heroui/react";
|
||||
import {Input, Select, SelectItem} from "@heroui/react";
|
||||
import {MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import {useSearchParams} from "react-router";
|
||||
import {ChangeEvent} from "react";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {Fzf} from "fzf";
|
||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
|
||||
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
|
||||
import {toTitleCase} from "Frontend/util/utils";
|
||||
|
||||
export default function SearchView() {
|
||||
const gamesState = useSnapshot(gameState);
|
||||
const librariesState = useSnapshot(libraryState);
|
||||
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
|
||||
const knownDevelopers = useSnapshot(gameState).knownDevelopers as Set<string>;
|
||||
const knownGenres = useSnapshot(gameState).knownGenres;
|
||||
const knownThemes = useSnapshot(gameState).knownThemes;
|
||||
const knownFeatures = useSnapshot(gameState).knownFeatures;
|
||||
const knownPerspectives = useSnapshot(gameState).knownPerspectives;
|
||||
const libraries = useSnapshot(libraryState).libraries as LibraryDto[];
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const term = searchParams.get("term") ?? "";
|
||||
|
||||
const updateSearchParam = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (value) {
|
||||
setSearchParams({term: value});
|
||||
} else {
|
||||
setSearchParams({});
|
||||
// State to track selected filter values
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [selectedLibraries, setSelectedLibraries] = useState<Set<string>>(new Set());
|
||||
const [selectedDevelopers, setSelectedDevelopers] = useState<Set<string>>(new Set());
|
||||
const [selectedGenres, setSelectedGenres] = useState<Set<string>>(new Set());
|
||||
const [selectedThemes, setSelectedThemes] = useState<Set<string>>(new Set());
|
||||
const [selectedFeatures, setSelectedFeatures] = useState<Set<string>>(new Set());
|
||||
const [selectedPerspectives, setSelectedPerspectives] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load initial filter values from URL parameters on component mount
|
||||
useEffect(() => {
|
||||
// Get all parameters from the URL
|
||||
const term = searchParams.get("term") || "";
|
||||
const libs = searchParams.getAll("lib");
|
||||
const devs = searchParams.getAll("dev");
|
||||
const genres = searchParams.getAll("genre");
|
||||
const themes = searchParams.getAll("theme");
|
||||
const features = searchParams.getAll("feature");
|
||||
const perspectives = searchParams.getAll("perspective");
|
||||
|
||||
setSearchTerm(term);
|
||||
setSelectedLibraries(new Set(libs));
|
||||
setSelectedDevelopers(new Set(devs));
|
||||
setSelectedGenres(new Set(genres));
|
||||
setSelectedThemes(new Set(themes));
|
||||
setSelectedFeatures(new Set(features));
|
||||
setSelectedPerspectives(new Set(perspectives));
|
||||
}, []);
|
||||
|
||||
// Update search parameters whenever the filters change
|
||||
useEffect(() => {
|
||||
const newParams = new URLSearchParams();
|
||||
|
||||
// Preserve search term if exists
|
||||
if (searchTerm && searchTerm.trim() !== "") {
|
||||
newParams.set("term", searchTerm);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="flex flex-col items-center">
|
||||
// Only add parameters for non-empty filters
|
||||
if (selectedLibraries.size > 0) {
|
||||
selectedLibraries.forEach(lib => {
|
||||
newParams.append("lib", lib.toString());
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedDevelopers.size > 0) {
|
||||
selectedDevelopers.forEach(dev => {
|
||||
newParams.append("dev", dev);
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedGenres.size > 0) {
|
||||
selectedGenres.forEach(genre => {
|
||||
newParams.append("genre", genre);
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedThemes.size > 0) {
|
||||
selectedThemes.forEach(theme => {
|
||||
newParams.append("theme", theme);
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedFeatures.size > 0) {
|
||||
selectedFeatures.forEach(feature => {
|
||||
newParams.append("feature", feature);
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedPerspectives.size > 0) {
|
||||
selectedPerspectives.forEach(perspective => {
|
||||
newParams.append("perspective", perspective);
|
||||
});
|
||||
}
|
||||
|
||||
setSearchParams(newParams);
|
||||
}, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres,
|
||||
selectedThemes, selectedFeatures, selectedPerspectives]);
|
||||
|
||||
const filteredGames = useMemo(() => filterGames(), [
|
||||
games, searchTerm,
|
||||
selectedLibraries, selectedDevelopers,
|
||||
selectedGenres, selectedThemes,
|
||||
selectedFeatures, selectedPerspectives
|
||||
]);
|
||||
|
||||
function filterGames(): GameDto[] {
|
||||
let filtered = games;
|
||||
|
||||
// Apply text search filter if term exists
|
||||
if (searchTerm !== "") {
|
||||
const fzf = new Fzf(filtered, {
|
||||
selector: (game: GameDto) => game.title
|
||||
});
|
||||
filtered = fzf.find(searchTerm).map(result => result.item);
|
||||
}
|
||||
|
||||
// Apply library filter
|
||||
if (selectedLibraries.size > 0) {
|
||||
filtered = filtered.filter(game => selectedLibraries.has(game.libraryId.toString()));
|
||||
}
|
||||
|
||||
// Apply developer filter
|
||||
if (selectedDevelopers.size > 0) {
|
||||
filtered = filtered.filter(game =>
|
||||
game.developers?.some(developer => selectedDevelopers.has(developer))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply genre filter
|
||||
if (selectedGenres.size > 0) {
|
||||
filtered = filtered.filter(game =>
|
||||
game.genres?.some(genre => selectedGenres.has(genre))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply theme filter
|
||||
if (selectedThemes.size > 0) {
|
||||
filtered = filtered.filter(game =>
|
||||
game.themes?.some(theme => selectedThemes.has(theme))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply feature filter
|
||||
if (selectedFeatures.size > 0) {
|
||||
filtered = filtered.filter(game =>
|
||||
game.features?.some(feature => selectedFeatures.has(feature))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply perspective filter
|
||||
if (selectedPerspectives.size > 0) {
|
||||
filtered = filtered.filter(game =>
|
||||
game.perspectives?.some(perspective => selectedPerspectives.has(perspective))
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-4 items-center w-full">
|
||||
<Input
|
||||
classNames={{
|
||||
base: "w-1/3",
|
||||
@@ -32,8 +173,104 @@ export default function SearchView() {
|
||||
placeholder="Type to search..."
|
||||
startContent={<MagnifyingGlass/>}
|
||||
type="search"
|
||||
value={term}
|
||||
onChange={updateSearchParam}
|
||||
value={searchTerm}
|
||||
isClearable
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
onClear={() => setSearchTerm("")}
|
||||
/>
|
||||
<div className="flex flex-row flex-wrap gap-2 justify-center">
|
||||
<Select
|
||||
size="sm"
|
||||
className="max-w-xs"
|
||||
selectionMode="multiple"
|
||||
label="Libraries"
|
||||
placeholder="Filter by library"
|
||||
selectedKeys={selectedLibraries}
|
||||
//@ts-ignore
|
||||
onSelectionChange={setSelectedLibraries}
|
||||
>
|
||||
{libraries.map((library) => (
|
||||
<SelectItem key={library.id}>{library.name}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="sm"
|
||||
className="max-w-xs"
|
||||
selectionMode="multiple"
|
||||
label="Developers"
|
||||
placeholder="Filter by developer"
|
||||
selectedKeys={selectedDevelopers}
|
||||
//@ts-ignore
|
||||
onSelectionChange={setSelectedDevelopers}
|
||||
>
|
||||
{Array.from(knownDevelopers).map((developer) => (
|
||||
<SelectItem key={developer}>{developer}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="sm"
|
||||
className="max-w-xs"
|
||||
selectionMode="multiple"
|
||||
label="Genres"
|
||||
placeholder="Filter by genre"
|
||||
selectedKeys={selectedGenres}
|
||||
//@ts-ignore
|
||||
onSelectionChange={setSelectedGenres}
|
||||
>
|
||||
{Array.from(knownGenres).map((genre) => (
|
||||
<SelectItem key={genre}>{toTitleCase(genre)}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="sm"
|
||||
className="max-w-xs"
|
||||
selectionMode="multiple"
|
||||
label="Themes"
|
||||
placeholder="Filter by theme"
|
||||
selectedKeys={selectedThemes}
|
||||
//@ts-ignore
|
||||
onSelectionChange={setSelectedThemes}
|
||||
>
|
||||
{Array.from(knownThemes).map((theme) => (
|
||||
<SelectItem key={theme}>{toTitleCase(theme)}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="sm"
|
||||
className="max-w-xs"
|
||||
selectionMode="multiple"
|
||||
label="Features"
|
||||
placeholder="Filter by feature"
|
||||
selectedKeys={selectedFeatures}
|
||||
//@ts-ignore
|
||||
onSelectionChange={setSelectedFeatures}
|
||||
>
|
||||
{Array.from(knownFeatures).map((feature) => (
|
||||
<SelectItem key={feature}>{toTitleCase(feature)}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="sm"
|
||||
className="max-w-xs"
|
||||
selectionMode="multiple"
|
||||
label="Perspectives"
|
||||
placeholder="Filter by perspective"
|
||||
selectedKeys={selectedPerspectives}
|
||||
//@ts-ignore
|
||||
onSelectionChange={setSelectedPerspectives}
|
||||
>
|
||||
{Array.from(knownPerspectives).map((perspective) => (
|
||||
<SelectItem key={perspective}>{toTitleCase(perspective)}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mt-4 w-full px-4 select-none">
|
||||
<CoverGrid games={filteredGames}/>
|
||||
{filteredGames.length === 0 && (
|
||||
<div className="text-center mt-8 text-default-500">
|
||||
No games found matching your filters
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
Reference in New Issue
Block a user