mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-17 00:30:04 +00:00
Finish SearchView implementation
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<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" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
Generated
+7
@@ -37,6 +37,7 @@
|
|||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"framer-motion": "^12.5.0",
|
"framer-motion": "^12.5.0",
|
||||||
|
"fzf": "^0.5.2",
|
||||||
"http-status-codes": "^2.3.0",
|
"http-status-codes": "^2.3.0",
|
||||||
"lit": "3.3.0",
|
"lit": "3.3.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
@@ -12998,6 +12999,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"framer-motion": "^12.5.0",
|
"framer-motion": "^12.5.0",
|
||||||
|
"fzf": "^0.5.2",
|
||||||
"http-status-codes": "^2.3.0",
|
"http-status-codes": "^2.3.0",
|
||||||
"lit": "3.3.0",
|
"lit": "3.3.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
@@ -203,4 +204,4 @@
|
|||||||
},
|
},
|
||||||
"hash": "dc682332ca36d64f455f6e13888e1ffcca97e888cbad8d356973e830f7463a10"
|
"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 React, {useEffect, useRef, useState} from "react";
|
||||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||||
import {Card} from "@heroui/react";
|
|
||||||
import {ArrowRight} from "@phosphor-icons/react";
|
import {ArrowRight} from "@phosphor-icons/react";
|
||||||
import {useNavigate} from "react-router";
|
import {useNavigate} from "react-router";
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ interface CoverRowProps {
|
|||||||
const aspectRatio = 12 / 17; // aspect ratio of the game cover
|
const aspectRatio = 12 / 17; // aspect ratio of the game cover
|
||||||
const defaultImageHeight = 300; // default height for the image
|
const defaultImageHeight = 300; // default height for the image
|
||||||
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
|
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
|
||||||
const radius = "sm";
|
|
||||||
|
|
||||||
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
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">
|
<div className="flex flex-col mb-4">
|
||||||
<p className="text-2xl font-bold mb-4">{title}</p>
|
<p className="text-2xl font-bold mb-4">{title}</p>
|
||||||
<div className="w-full relative">
|
<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) => (
|
{games.slice(0, visibleCount).map((game, index) => (
|
||||||
<a key={index} href={`/game/${game.id}`}>
|
<GameCover key={index} game={game} radius="sm" interactive={true}/>
|
||||||
<GameCover game={game} radius={radius} hover={true}/>
|
|
||||||
</a>
|
|
||||||
))}
|
))}
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
{showMore && (
|
{showMore && (
|
||||||
<div className="flex flex-row items-center justify-end cursor-pointer"
|
<div className="flex flex-row items-center justify-end cursor-pointer"
|
||||||
@@ -6,22 +6,28 @@ interface GameCoverProps {
|
|||||||
game: GameDto;
|
game: GameDto;
|
||||||
size?: number;
|
size?: number;
|
||||||
radius?: "none" | "sm" | "md" | "lg";
|
radius?: "none" | "sm" | "md" | "lg";
|
||||||
hover?: boolean;
|
interactive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameCover({game, size = 300, radius = "sm", hover = false}: GameCoverProps) {
|
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
|
||||||
return (
|
const coverContent = Number.isInteger(game.coverId) ? (
|
||||||
Number.isInteger(game.coverId) ? (
|
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
||||||
<div className={`${hover ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
<Image
|
||||||
<Image
|
alt={game.title}
|
||||||
alt={game.title}
|
className="z-0 object-cover aspect-[12/17]"
|
||||||
className="z-0 size-full object-cover aspect-[12/17]"
|
src={`images/cover/${game.coverId}`}
|
||||||
src={`images/cover/${game.coverId}`}
|
radius={radius}
|
||||||
radius={radius}
|
height={size}
|
||||||
height={size}
|
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
||||||
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : <GameCoverFallback title={game.title} size={size} radius={radius} hover={hover}/>
|
<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]);
|
setElements([...images, ...videos]);
|
||||||
}, [])
|
}, [imageUrls, videosUrls])
|
||||||
|
|
||||||
function showImagePopup(imageUrl: string) {
|
function showImagePopup(imageUrl: string) {
|
||||||
setSelectedImageUrl(imageUrl);
|
setSelectedImageUrl(imageUrl);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type GameState = {
|
|||||||
state: Record<number, GameDto>;
|
state: Record<number, GameDto>;
|
||||||
games: GameDto[];
|
games: GameDto[];
|
||||||
gamesByLibraryId: Record<number, GameDto[]>;
|
gamesByLibraryId: Record<number, GameDto[]>;
|
||||||
|
sortedAlphabetically: GameDto[];
|
||||||
sortedByMostRecentlyAdded: GameDto[];
|
sortedByMostRecentlyAdded: GameDto[];
|
||||||
sortedByMostRecentlyUpdated: GameDto[];
|
sortedByMostRecentlyUpdated: GameDto[];
|
||||||
randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>;
|
randomlyOrderedGamesByLibraryId: Record<number, GameDto[]>;
|
||||||
@@ -37,6 +38,10 @@ export const gameState = proxy<GameState>({
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
},
|
},
|
||||||
|
get sortedAlphabetically() {
|
||||||
|
return this.games
|
||||||
|
.sort((a: GameDto, b: GameDto) => a.title.localeCompare(b.title, undefined, {sensitivity: 'base'}));
|
||||||
|
},
|
||||||
get sortedByMostRecentlyAdded() {
|
get sortedByMostRecentlyAdded() {
|
||||||
return this.games
|
return this.games
|
||||||
.sort((a: GameDto, b: GameDto) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
.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;
|
return result;
|
||||||
},
|
},
|
||||||
get knownPublishers() {
|
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() {
|
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() {
|
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() {
|
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() {
|
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() {
|
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() {
|
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 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 {useSnapshot} from "valtio/react";
|
||||||
import {libraryState} from "Frontend/state/LibraryState";
|
import {libraryState} from "Frontend/state/LibraryState";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
|
|||||||
import * as PackageJson from "../../../../package.json";
|
import * as PackageJson from "../../../../package.json";
|
||||||
import {Outlet, useLocation, useNavigate} from "react-router";
|
import {Outlet, useLocation, useNavigate} from "react-router";
|
||||||
import {useAuth} from "Frontend/util/auth";
|
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 Confetti, {ConfettiProps} from "react-confetti-boom";
|
||||||
import {useTheme} from "next-themes";
|
import {useTheme} from "next-themes";
|
||||||
import {UserPreferenceService} from "Frontend/util/user-preference-service";
|
import {UserPreferenceService} from "Frontend/util/user-preference-service";
|
||||||
import SearchBar from "Frontend/components/general/SearchBar";
|
import SearchBar from "Frontend/components/general/SearchBar";
|
||||||
|
import {useSnapshot} from "valtio/react";
|
||||||
|
import {gameState} from "Frontend/state/GameState";
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -19,7 +21,9 @@ export default function MainLayout() {
|
|||||||
const routeMetadata = useRouteMetadata();
|
const routeMetadata = useRouteMetadata();
|
||||||
const {setTheme} = useTheme();
|
const {setTheme} = useTheme();
|
||||||
const isSearchPage = location.pathname.startsWith("/search");
|
const isSearchPage = location.pathname.startsWith("/search");
|
||||||
|
const isHomePage = location.pathname === "/";
|
||||||
const [isExploding, setIsExploding] = useState(false);
|
const [isExploding, setIsExploding] = useState(false);
|
||||||
|
const games = useSnapshot(gameState).games;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let newTitle = `Gameyfin - ${routeMetadata?.title}`;
|
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 (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
{isExploding ? <Confetti {...confettiProps}/> : <></>}
|
{isExploding ? <Confetti {...confettiProps}/> : <></>}
|
||||||
|
|
||||||
<Navbar maxWidth="full" className="2xl:px-[12.5%]">
|
<Navbar maxWidth="full" className="2xl:px-[12.5%]">
|
||||||
<NavbarBrand>
|
<NavbarBrand>
|
||||||
<div className="cursor-pointer" onClick={() => navigate('/')}>
|
{isHomePage ? <GameyfinLogo className="h-10 fill-foreground"/> :
|
||||||
<GameyfinLogo className="h-10 fill-foreground"/>
|
<div className="flex flex-row gap-2">
|
||||||
</div>
|
<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>
|
</NavbarBrand>
|
||||||
{!isSearchPage && <NavbarContent justify="center" className="flex-1 max-w-96">
|
{!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/>
|
<SearchBar/>
|
||||||
<Tooltip content="Advanced search" placement="bottom">
|
<Tooltip content="Advanced search" placement="bottom">
|
||||||
<Button isIconOnly variant="light" onPress={() => navigate("/search")}>
|
<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 {MagnifyingGlass} from "@phosphor-icons/react";
|
||||||
import {useSnapshot} from "valtio/react";
|
import {useSnapshot} from "valtio/react";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import {libraryState} from "Frontend/state/LibraryState";
|
import {libraryState} from "Frontend/state/LibraryState";
|
||||||
import {useSearchParams} from "react-router";
|
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() {
|
export default function SearchView() {
|
||||||
const gamesState = useSnapshot(gameState);
|
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
|
||||||
const librariesState = useSnapshot(libraryState);
|
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 [searchParams, setSearchParams] = useSearchParams();
|
||||||
const term = searchParams.get("term") ?? "";
|
|
||||||
|
|
||||||
const updateSearchParam = (e: ChangeEvent<HTMLInputElement>) => {
|
// State to track selected filter values
|
||||||
const value = e.target.value;
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
if (value) {
|
const [selectedLibraries, setSelectedLibraries] = useState<Set<string>>(new Set());
|
||||||
setSearchParams({term: value});
|
const [selectedDevelopers, setSelectedDevelopers] = useState<Set<string>>(new Set());
|
||||||
} else {
|
const [selectedGenres, setSelectedGenres] = useState<Set<string>>(new Set());
|
||||||
setSearchParams({});
|
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
|
<Input
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "w-1/3",
|
base: "w-1/3",
|
||||||
@@ -32,8 +173,104 @@ export default function SearchView() {
|
|||||||
placeholder="Type to search..."
|
placeholder="Type to search..."
|
||||||
startContent={<MagnifyingGlass/>}
|
startContent={<MagnifyingGlass/>}
|
||||||
type="search"
|
type="search"
|
||||||
value={term}
|
value={searchTerm}
|
||||||
onChange={updateSearchParam}
|
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>
|
</div>
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user