Show rating in GameView

Implement filter by rating
This commit is contained in:
grimsi
2025-09-04 17:20:13 +02:00
parent 151f25cfba
commit ab0f28e94f
7 changed files with 105 additions and 24 deletions
@@ -91,7 +91,7 @@ export default function ProfileManagement() {
{formik.values.newPassword.length > 0 &&
<SmallInfoField icon={Info}
message="You will be logged out of all current sessions"
className="text-foreground/70"
className="text-default-500"
/>
}
<Button
@@ -81,7 +81,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
<p className="text-center">No results found.</p>
}
{searchResults.length === 0 && isSearching &&
<p className="text-center text-foreground/70">Searching...</p>
<p className="text-center text-default-500">Searching...</p>
}
<ScrollShadow
className="grid grid-cols-auto-fill gap-4 h-96 overflow-y-scroll justify-evenly">
@@ -81,7 +81,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
<p className="text-center">No results found.</p>
}
{searchResults.length === 0 && isSearching &&
<p className="text-center text-foreground/70">Searching...</p>
<p className="text-center text-default-500">Searching...</p>
}
<ScrollShadow
className="flex flex-col items-center gap-4 h-96 overflow-y-scroll">
+34 -1
View File
@@ -210,7 +210,8 @@ export function fileNameFromPath(path: string, includeExtension: boolean = true)
return dotIndex < 0 ? fileName : fileName.substring(0, dotIndex);
}
/** Calculate the completeness of a GameDto
/**
* Calculate the completeness of a GameDto
* @param game
* @returns completeness percentage (0-100)
*/
@@ -227,4 +228,36 @@ export function metadataCompleteness(game: GameDto) {
}).length;
return Math.round((filledFields / totalFields) * 100);
}
/**
* Scale a number from one range to another
* @param value The number to scale
* @param originalRange The original range [min, max]
* @param targetRange The target range [min, max]
* @returns The scaled number
*/
function convertRange(value: number, originalRange: number[], targetRange: number[]): number {
return (value - originalRange[0]) * (targetRange[1] - targetRange[0]) / (originalRange[1] - originalRange[0]) + targetRange[0];
}
/**
* Convert a GameDto's ratings to a star rating out of 5.
* If both criticRating and userRating are present, their average is taken.
* If neither is present, "N/A" is returned.
* @param game The GameDto object containing the ratings.
* @returns A string representing the star rating out of 5, or "N/A" if no ratings are available.
*/
export function gameRatingInStars(game: GameDto) {
if (!game.criticRating && !game.userRating) return "N/A";
const originalRange = [0, 100];
const starRange = [1, 5];
const ratings = [];
if (game.criticRating) ratings.push(game.criticRating);
if (game.userRating) ratings.push(game.userRating);
const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length;
return convertRange(avgRating, originalRange, starRange).toFixed(1);
}
@@ -164,7 +164,7 @@ export default function GameRequestView() {
{!areGameRequestsEnabled &&
<SmallInfoField icon={Info}
message="Request submission is disabled"
className="text-foreground/70"/>
className="text-default-500"/>
}
<Button className="w-fit"
color="primary"
@@ -233,7 +233,7 @@ export default function GameRequestView() {
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</TableCell>
<TableCell>
<p className="text-foreground/70">
<p className="text-default-500">
{item.requester ?
item.requester.username :
"Guest"
+14 -5
View File
@@ -5,11 +5,11 @@ import {GameCover} from "Frontend/components/general/covers/GameCover";
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
import {humanFileSize, isAdmin, toTitleCase} from "Frontend/util/utils";
import {gameRatingInStars, humanFileSize, isAdmin, toTitleCase} from "Frontend/util/utils";
import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
import {gameState} from "Frontend/state/GameState";
import {useSnapshot} from "valtio/react";
import {CheckCircle, Info, MagnifyingGlass, Pencil, Trash, TriangleDashed} from "@phosphor-icons/react";
import {CheckCircle, Info, MagnifyingGlass, Pencil, Star, Trash, TriangleDashed} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
@@ -102,14 +102,23 @@ export default function GameView() {
<GameCover game={game} size={320} radius="none"/>
</div>
<div className="flex flex-col gap-1">
<p className="font-semibold text-3xl">{game.title}</p>
<div className="flex flex-row gap-4 items-end">
<p className="font-semibold text-3xl">
{game.title}
</p>
<div className="flex flex-row gap-1 mb-0.5 text-default-500">
<Star weight="fill"/>
{gameRatingInStars(game)}
</div>
</div>
<div className="flex flex-row items-center gap-2">
<p className="text-default-500">
{game.release !== undefined ? new Date(game.release).getFullYear() :
<p className="text-default-500">no data</p>}
</p>
<Tooltip content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
placement="right">
<Tooltip
content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
placement="right">
<Info/>
</Tooltip>
</div>
+52 -13
View File
@@ -1,5 +1,5 @@
import {Button, Input, Select, SelectItem, Tooltip} from "@heroui/react";
import {FunnelSimple, FunnelSimpleX, MagnifyingGlass, SortAscending} from "@phosphor-icons/react";
import {Button, Input, Select, SelectedItems, SelectItem, Tooltip} from "@heroui/react";
import {FunnelSimple, FunnelSimpleX, MagnifyingGlass, SortAscending, Star} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState";
import {libraryState} from "Frontend/state/LibraryState";
@@ -9,7 +9,7 @@ import {Fzf} from "fzf";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import {toTitleCase} from "Frontend/util/utils";
import {gameRatingInStars, toTitleCase} from "Frontend/util/utils";
export default function SearchView() {
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
@@ -36,6 +36,7 @@ export default function SearchView() {
const [selectedFeatures, setSelectedFeatures] = useState<Set<string>>(new Set());
const [selectedPerspectives, setSelectedPerspectives] = useState<Set<string>>(new Set());
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(new Set());
const [minRating, setMinRating] = useState<number>(1); // Minimum rating filter
// Load initial filter values from URL parameters on component mount
useEffect(() => {
@@ -49,6 +50,7 @@ export default function SearchView() {
const perspectives = searchParams.getAll("perspective");
const keywords = searchParams.getAll("keyword");
const sort = searchParams.get("sort") || "title_asc";
const minRatingParam = parseInt(searchParams.get("minRating") || "1", 10);
setSearchTerm(term);
setSelectedLibraries(new Set(libs));
@@ -59,6 +61,7 @@ export default function SearchView() {
setSelectedPerspectives(new Set(perspectives));
setSelectedKeywords(new Set(keywords));
setSortBy(sort);
setMinRating(isNaN(minRatingParam) ? 1 : minRatingParam);
setInitialLoadComplete(true);
}, []);
@@ -80,43 +83,40 @@ export default function SearchView() {
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);
});
}
if (selectedKeywords.size > 0) {
selectedKeywords.forEach(keyword => {
newParams.append("keyword", keyword);
});
}
// Add minRating param if not default
if (minRating > 1) {
newParams.set("minRating", minRating.toString());
}
// Add sort param
if (sortBy && sortBy !== "title_asc") {
newParams.set("sort", sortBy);
@@ -124,7 +124,7 @@ export default function SearchView() {
setSearchParams(newParams, {replace: true});
}, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres,
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords, sortBy]);
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords, sortBy, minRating]);
// Sorting function (refactored to use sortKey and sortDirection)
function sortGames(games: GameDto[]): GameDto[] {
@@ -165,7 +165,7 @@ export default function SearchView() {
games, searchTerm,
selectedLibraries, selectedDevelopers,
selectedGenres, selectedThemes,
selectedFeatures, selectedPerspectives, selectedKeywords, sortBy
selectedFeatures, selectedPerspectives, selectedKeywords, sortBy, minRating
]);
function filterGames(): GameDto[] {
@@ -226,9 +226,30 @@ export default function SearchView() {
);
}
// Apply minimum rating filter
if (minRating > 1) {
filtered = filtered.filter(game => {
const ratingStr = gameRatingInStars(game);
if (ratingStr === "N/A") return false;
const ratingNum = parseFloat(ratingStr);
return ratingNum >= minRating;
});
}
return filtered;
}
function stars(filled: number, total: number = 5) {
const stars = [];
for (let i = 0; i < total; i++) {
stars.push(
<Star key={i} weight={i < filled ? "fill" : "regular"} className="inline-block"/>
);
}
return <div className="flex flex-row">
{stars}
</div>;
}
return <div className="flex flex-col gap-4 items-center w-full">
<div className="flex w-full justify-between px-12 gap-4 flex-col lg:flex-row">
<Input
@@ -286,6 +307,7 @@ export default function SearchView() {
setSelectedFeatures(new Set());
setSelectedPerspectives(new Set());
setSelectedKeywords(new Set());
setMinRating(1);
}}
aria-label="Clear All Filters"
>
@@ -295,7 +317,7 @@ export default function SearchView() {
</div>
</div>
{showFilters && <div
className="w-full justify-center"
className="w-full justify-center px-12"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
@@ -316,6 +338,23 @@ export default function SearchView() {
<SelectItem key={library.id}>{library.name}</SelectItem>
))}
</Select>
<Select
size="sm"
selectionMode="single"
label="Minimum Rating"
placeholder="Minimum rating"
selectedKeys={[minRating.toString()]}
onSelectionChange={keys => setMinRating(parseInt(Array.from(keys)[0] as string, 10))}
renderValue={(items: SelectedItems<any>) => {
return items.map((item) => stars(parseInt(item.key as string)));
}}
>
<SelectItem key="1">{stars(1)}</SelectItem>
<SelectItem key="2">{stars(2)}</SelectItem>
<SelectItem key="3">{stars(3)}</SelectItem>
<SelectItem key="4">{stars(4)}</SelectItem>
<SelectItem key="5">{stars(5)}</SelectItem>
</Select>
<Select
size="sm"
selectionMode="multiple"