mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 08:15:48 +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 &&
|
{formik.values.newPassword.length > 0 &&
|
||||||
<SmallInfoField icon={Info}
|
<SmallInfoField icon={Info}
|
||||||
message="You will be logged out of all current sessions"
|
message="You will be logged out of all current sessions"
|
||||||
className="text-foreground/70"
|
className="text-default-500"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
|||||||
<p className="text-center">No results found.</p>
|
<p className="text-center">No results found.</p>
|
||||||
}
|
}
|
||||||
{searchResults.length === 0 && isSearching &&
|
{searchResults.length === 0 && isSearching &&
|
||||||
<p className="text-center text-foreground/70">Searching...</p>
|
<p className="text-center text-default-500">Searching...</p>
|
||||||
}
|
}
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
className="grid grid-cols-auto-fill gap-4 h-96 overflow-y-scroll justify-evenly">
|
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>
|
<p className="text-center">No results found.</p>
|
||||||
}
|
}
|
||||||
{searchResults.length === 0 && isSearching &&
|
{searchResults.length === 0 && isSearching &&
|
||||||
<p className="text-center text-foreground/70">Searching...</p>
|
<p className="text-center text-default-500">Searching...</p>
|
||||||
}
|
}
|
||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
className="flex flex-col items-center gap-4 h-96 overflow-y-scroll">
|
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);
|
return dotIndex < 0 ? fileName : fileName.substring(0, dotIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calculate the completeness of a GameDto
|
/**
|
||||||
|
* Calculate the completeness of a GameDto
|
||||||
* @param game
|
* @param game
|
||||||
* @returns completeness percentage (0-100)
|
* @returns completeness percentage (0-100)
|
||||||
*/
|
*/
|
||||||
@@ -228,3 +229,35 @@ export function metadataCompleteness(game: GameDto) {
|
|||||||
|
|
||||||
return Math.round((filledFields / totalFields) * 100);
|
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 &&
|
{!areGameRequestsEnabled &&
|
||||||
<SmallInfoField icon={Info}
|
<SmallInfoField icon={Info}
|
||||||
message="Request submission is disabled"
|
message="Request submission is disabled"
|
||||||
className="text-foreground/70"/>
|
className="text-default-500"/>
|
||||||
}
|
}
|
||||||
<Button className="w-fit"
|
<Button className="w-fit"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -233,7 +233,7 @@ export default function GameRequestView() {
|
|||||||
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<p className="text-foreground/70">
|
<p className="text-default-500">
|
||||||
{item.requester ?
|
{item.requester ?
|
||||||
item.requester.username :
|
item.requester.username :
|
||||||
"Guest"
|
"Guest"
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import {GameCover} from "Frontend/components/general/covers/GameCover";
|
|||||||
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
|
||||||
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
|
||||||
import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
|
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 {DownloadEndpoint} from "Frontend/endpoints/endpoints";
|
||||||
import {gameState} from "Frontend/state/GameState";
|
import {gameState} from "Frontend/state/GameState";
|
||||||
import {useSnapshot} from "valtio/react";
|
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 {useAuth} from "Frontend/util/auth";
|
||||||
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||||
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
|
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
|
||||||
@@ -102,14 +102,23 @@ export default function GameView() {
|
|||||||
<GameCover game={game} size={320} radius="none"/>
|
<GameCover game={game} size={320} radius="none"/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<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">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<p className="text-default-500">
|
<p className="text-default-500">
|
||||||
{game.release !== undefined ? new Date(game.release).getFullYear() :
|
{game.release !== undefined ? new Date(game.release).getFullYear() :
|
||||||
<p className="text-default-500">no data</p>}
|
<p className="text-default-500">no data</p>}
|
||||||
</p>
|
</p>
|
||||||
<Tooltip content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
|
<Tooltip
|
||||||
placement="right">
|
content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
|
||||||
|
placement="right">
|
||||||
<Info/>
|
<Info/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Button, Input, Select, SelectItem, Tooltip} from "@heroui/react";
|
import {Button, Input, Select, SelectedItems, SelectItem, Tooltip} from "@heroui/react";
|
||||||
import {FunnelSimple, FunnelSimpleX, MagnifyingGlass, SortAscending} from "@phosphor-icons/react";
|
import {FunnelSimple, FunnelSimpleX, MagnifyingGlass, SortAscending, Star} 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";
|
||||||
@@ -9,7 +9,7 @@ import {Fzf} from "fzf";
|
|||||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||||
import CoverGrid from "Frontend/components/general/covers/CoverGrid";
|
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() {
|
export default function SearchView() {
|
||||||
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
|
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
|
||||||
@@ -36,6 +36,7 @@ export default function SearchView() {
|
|||||||
const [selectedFeatures, setSelectedFeatures] = useState<Set<string>>(new Set());
|
const [selectedFeatures, setSelectedFeatures] = useState<Set<string>>(new Set());
|
||||||
const [selectedPerspectives, setSelectedPerspectives] = useState<Set<string>>(new Set());
|
const [selectedPerspectives, setSelectedPerspectives] = useState<Set<string>>(new Set());
|
||||||
const [selectedKeywords, setSelectedKeywords] = 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
|
// Load initial filter values from URL parameters on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,6 +50,8 @@ export default function SearchView() {
|
|||||||
const perspectives = searchParams.getAll("perspective");
|
const perspectives = searchParams.getAll("perspective");
|
||||||
const keywords = searchParams.getAll("keyword");
|
const keywords = searchParams.getAll("keyword");
|
||||||
const sort = searchParams.get("sort") || "title_asc";
|
const sort = searchParams.get("sort") || "title_asc";
|
||||||
|
const minRatingParam = parseInt(searchParams.get("minRating") || "1", 10);
|
||||||
|
const filtersParam = searchParams.get("filters");
|
||||||
|
|
||||||
setSearchTerm(term);
|
setSearchTerm(term);
|
||||||
setSelectedLibraries(new Set(libs));
|
setSelectedLibraries(new Set(libs));
|
||||||
@@ -59,6 +62,8 @@ export default function SearchView() {
|
|||||||
setSelectedPerspectives(new Set(perspectives));
|
setSelectedPerspectives(new Set(perspectives));
|
||||||
setSelectedKeywords(new Set(keywords));
|
setSelectedKeywords(new Set(keywords));
|
||||||
setSortBy(sort);
|
setSortBy(sort);
|
||||||
|
setMinRating(isNaN(minRatingParam) ? 1 : minRatingParam);
|
||||||
|
setShowFilters(filtersParam === "1");
|
||||||
|
|
||||||
setInitialLoadComplete(true);
|
setInitialLoadComplete(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -80,51 +85,52 @@ export default function SearchView() {
|
|||||||
newParams.append("lib", lib.toString());
|
newParams.append("lib", lib.toString());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedDevelopers.size > 0) {
|
if (selectedDevelopers.size > 0) {
|
||||||
selectedDevelopers.forEach(dev => {
|
selectedDevelopers.forEach(dev => {
|
||||||
newParams.append("dev", dev);
|
newParams.append("dev", dev);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedGenres.size > 0) {
|
if (selectedGenres.size > 0) {
|
||||||
selectedGenres.forEach(genre => {
|
selectedGenres.forEach(genre => {
|
||||||
newParams.append("genre", genre);
|
newParams.append("genre", genre);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedThemes.size > 0) {
|
if (selectedThemes.size > 0) {
|
||||||
selectedThemes.forEach(theme => {
|
selectedThemes.forEach(theme => {
|
||||||
newParams.append("theme", theme);
|
newParams.append("theme", theme);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedFeatures.size > 0) {
|
if (selectedFeatures.size > 0) {
|
||||||
selectedFeatures.forEach(feature => {
|
selectedFeatures.forEach(feature => {
|
||||||
newParams.append("feature", feature);
|
newParams.append("feature", feature);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedPerspectives.size > 0) {
|
if (selectedPerspectives.size > 0) {
|
||||||
selectedPerspectives.forEach(perspective => {
|
selectedPerspectives.forEach(perspective => {
|
||||||
newParams.append("perspective", perspective);
|
newParams.append("perspective", perspective);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedKeywords.size > 0) {
|
if (selectedKeywords.size > 0) {
|
||||||
selectedKeywords.forEach(keyword => {
|
selectedKeywords.forEach(keyword => {
|
||||||
newParams.append("keyword", keyword);
|
newParams.append("keyword", keyword);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Add minRating param if not default
|
||||||
|
if (minRating > 1) {
|
||||||
|
newParams.set("minRating", minRating.toString());
|
||||||
|
}
|
||||||
// Add sort param
|
// Add sort param
|
||||||
if (sortBy && sortBy !== "title_asc") {
|
if (sortBy && sortBy !== "title_asc") {
|
||||||
newParams.set("sort", sortBy);
|
newParams.set("sort", sortBy);
|
||||||
}
|
}
|
||||||
|
// Add showFilters param
|
||||||
|
if (showFilters) {
|
||||||
|
newParams.set("filters", "1");
|
||||||
|
}
|
||||||
|
|
||||||
setSearchParams(newParams, {replace: true});
|
setSearchParams(newParams, {replace: true});
|
||||||
}, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres,
|
}, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres,
|
||||||
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords, sortBy]);
|
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords, sortBy, minRating, showFilters]);
|
||||||
|
|
||||||
// Sorting function (refactored to use sortKey and sortDirection)
|
// Sorting function (refactored to use sortKey and sortDirection)
|
||||||
function sortGames(games: GameDto[]): GameDto[] {
|
function sortGames(games: GameDto[]): GameDto[] {
|
||||||
@@ -165,7 +171,7 @@ export default function SearchView() {
|
|||||||
games, searchTerm,
|
games, searchTerm,
|
||||||
selectedLibraries, selectedDevelopers,
|
selectedLibraries, selectedDevelopers,
|
||||||
selectedGenres, selectedThemes,
|
selectedGenres, selectedThemes,
|
||||||
selectedFeatures, selectedPerspectives, selectedKeywords, sortBy
|
selectedFeatures, selectedPerspectives, selectedKeywords, sortBy, minRating
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function filterGames(): GameDto[] {
|
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;
|
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">
|
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">
|
<div className="flex w-full justify-between px-12 gap-4 flex-col lg:flex-row">
|
||||||
<Input
|
<Input
|
||||||
@@ -286,6 +316,7 @@ export default function SearchView() {
|
|||||||
setSelectedFeatures(new Set());
|
setSelectedFeatures(new Set());
|
||||||
setSelectedPerspectives(new Set());
|
setSelectedPerspectives(new Set());
|
||||||
setSelectedKeywords(new Set());
|
setSelectedKeywords(new Set());
|
||||||
|
setMinRating(1);
|
||||||
}}
|
}}
|
||||||
aria-label="Clear All Filters"
|
aria-label="Clear All Filters"
|
||||||
>
|
>
|
||||||
@@ -295,7 +326,7 @@ export default function SearchView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showFilters && <div
|
{showFilters && <div
|
||||||
className="w-full justify-center"
|
className="w-full justify-center px-12"
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
|
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
|
||||||
@@ -316,6 +347,24 @@ export default function SearchView() {
|
|||||||
<SelectItem key={library.id}>{library.name}</SelectItem>
|
<SelectItem key={library.id}>{library.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</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
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
|
|||||||
Reference in New Issue
Block a user