mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
DIsplay rating & implement rating filter
DIsplay rating & implement rating filter
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,8 @@ 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);
|
||||
const filtersParam = searchParams.get("filters");
|
||||
|
||||
setSearchTerm(term);
|
||||
setSelectedLibraries(new Set(libs));
|
||||
@@ -59,6 +62,8 @@ export default function SearchView() {
|
||||
setSelectedPerspectives(new Set(perspectives));
|
||||
setSelectedKeywords(new Set(keywords));
|
||||
setSortBy(sort);
|
||||
setMinRating(isNaN(minRatingParam) ? 1 : minRatingParam);
|
||||
setShowFilters(filtersParam === "1");
|
||||
|
||||
setInitialLoadComplete(true);
|
||||
}, []);
|
||||
@@ -80,51 +85,52 @@ 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);
|
||||
}
|
||||
// Add showFilters param
|
||||
if (showFilters) {
|
||||
newParams.set("filters", "1");
|
||||
}
|
||||
|
||||
setSearchParams(newParams, {replace: true});
|
||||
}, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres,
|
||||
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords, sortBy]);
|
||||
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords, sortBy, minRating, showFilters]);
|
||||
|
||||
// Sorting function (refactored to use sortKey and sortDirection)
|
||||
function sortGames(games: GameDto[]): GameDto[] {
|
||||
@@ -165,7 +171,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 +232,33 @@ 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);
|
||||
if (minRating === 5) {
|
||||
return ratingNum > 4.5;
|
||||
}
|
||||
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 +316,7 @@ export default function SearchView() {
|
||||
setSelectedFeatures(new Set());
|
||||
setSelectedPerspectives(new Set());
|
||||
setSelectedKeywords(new Set());
|
||||
setMinRating(1);
|
||||
}}
|
||||
aria-label="Clear All Filters"
|
||||
>
|
||||
@@ -295,7 +326,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 +347,24 @@ export default function SearchView() {
|
||||
<SelectItem key={library.id}>{library.name}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="sm"
|
||||
selectionMode="single"
|
||||
label="Minimum Rating"
|
||||
placeholder="Minimum rating"
|
||||
disallowEmptySelection
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user