Finish SearchView implementation

This commit is contained in:
grimsi
2025-05-24 18:27:32 +02:00
parent 2acbc0d654
commit 067253b30d
12 changed files with 368 additions and 60 deletions
+1 -1
View File
@@ -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>
+7
View File
@@ -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",
+2 -1
View File
@@ -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>
);
}
@@ -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);
+40 -7
View File
@@ -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")}>
+252 -15
View File
@@ -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>
}