Almost finished HomeView

This commit is contained in:
grimsi
2025-05-09 23:56:43 +02:00
parent 47e69d0d6a
commit e49f61a1db
11 changed files with 151 additions and 84 deletions
@@ -60,7 +60,7 @@ export default function ProfileMenu() {
</div> </div>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu disabledKeys={["username"]}> <DropdownMenu disabledKeys={["username"]}>
<DropdownItem key="username"> <DropdownItem key="username" textValue={auth.state.user?.username}>
<p className="font-bold">Signed in as {auth.state.user?.username}</p> <p className="font-bold">Signed in as {auth.state.user?.username}</p>
</DropdownItem> </DropdownItem>
{profileMenuItems.filter(item => item.showIf !== false).map(({label, icon, onClick, color}) => { {profileMenuItems.filter(item => item.showIf !== false).map(({label, icon, onClick, color}) => {
@@ -72,6 +72,7 @@ export default function ProfileMenu() {
/* @ts-ignore */ /* @ts-ignore */
color={color ? color : ""} color={color ? color : ""}
className={`text-${color} hover:bg-primary/20`} className={`text-${color} hover:bg-primary/20`}
textValue={label}
> >
{label} {label}
</DropdownItem> </DropdownItem>
@@ -0,0 +1,72 @@
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";
interface CoverRowProps {
games: GameDto[];
title: string;
onPressShowMore: () => void;
}
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) {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(games.length);
useEffect(() => {
const calculateVisible = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
setVisibleCount(maxFit < games.length ? maxFit : games.length);
}
};
const resizeObserver = new ResizeObserver(calculateVisible);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
calculateVisible(); // initial calculation
return () => resizeObserver.disconnect();
}, [games.length]);
const showMore = visibleCount < games.length;
return (
<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-4 bg-transparent" radius={radius}>
{games.slice(0, visibleCount).map((game, index) => (
<div className="flex-shrink-0" key={index}>
<GameCover game={game} radius={radius}/>
</div>
))}
</Card>
{showMore && (
<div className="flex flex-row items-center justify-end cursor-pointer"
onClick={onPressShowMore}>
<div className="absolute h-full w-1/4 right-0 bottom-0
bg-gradient-to-r from-transparent to-background
transition-all duration-300 ease-in-out hover:opacity-80"/>
<div
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
<p className="text-xl font-semibold">Show more</p>
<ArrowRight weight="bold"/>
</div>
</div>
)}
</div>
</div>
);
}
@@ -1,20 +0,0 @@
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {Image} from "@heroui/react";
interface GameCoverProps {
game: GameDto;
size?: number;
radius?: "none" | "sm" | "md" | "lg" | "full";
}
export function GameCover({game, size = 300, radius = "sm"}: GameCoverProps) {
return (
<Image
alt={game.title}
className="z-0 w-full h-full object-cover aspect-[12/17]"
src={`images/cover/${game.coverId}`}
radius={radius}
height={size}
/>
);
}
@@ -1,29 +0,0 @@
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import Section from "Frontend/components/general/Section";
import {GameCover} from "Frontend/components/general/GameCover";
import {Card} from "@heroui/react";
interface HorizontalGameListProps {
title: string;
games: GameDto[];
}
export function HorizontalGameList({title, games}: HorizontalGameListProps) {
return (
<div className="flex flex-col gap-2">
<Section title={title}/>
<div className="flex flex-row gap-4 overflow-x-auto">
{games.length > 0 ?
games.map((game) => (
<GameCover game={game}/>
))
: <Card className="h-[300px] aspect-[12/17]">
<div className="flex flex-col items-center justify-center h-full">
<p className="text-gray-500">No content</p>
</div>
</Card>
}
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import {GameCover} from "Frontend/components/general/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";
export function GameOverviewCard({game}: { game: GameDto }) { export function GameOverviewCard({game}: { game: GameDto }) {
@@ -3,7 +3,7 @@ import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/Libr
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/GameCover"; import {GameCover} from "Frontend/components/general/covers/GameCover";
import { import {
Alien, Alien,
CastleTurret, CastleTurret,
@@ -0,0 +1,24 @@
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {Image} from "@heroui/react";
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
interface GameCoverProps {
game: GameDto;
size?: number;
radius?: "none" | "sm" | "md" | "lg";
}
export function GameCover({game, size = 300, radius = "sm"}: GameCoverProps) {
return (
Number.isInteger(game.coverId) ? (
<Image
alt={game.title}
className="z-0 w-full h-full object-cover aspect-[12/17]"
src={`images/cover/${game.coverId}`}
radius={radius}
height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
/>
) : <GameCoverFallback title={game.title} size={size} radius={radius}/>
);
}
@@ -0,0 +1,18 @@
import {Card} from "@heroui/react";
interface GameCoverFallbackProps {
title: string;
size?: number;
radius?: "none" | "sm" | "md" | "lg";
}
export function GameCoverFallback({title, size = 300, radius = "sm"}: GameCoverFallbackProps) {
return (
<Card style={{aspectRatio: "12 /17", height: size, borderRadius: radius}}
radius={radius}>
<div className="flex flex-col items-center justify-center h-full">
{title}
</div>
</Card>
);
}
+2 -2
View File
@@ -87,11 +87,11 @@ export function timeUntil(instantString: string, timeZone: string = moment.tz.gu
* @param count * @param count
* @returns {GameDto[]} * @returns {GameDto[]}
*/ */
export async function randomGamesFromLibrary(library: LibraryDto, count: number): Promise<GameDto[]> { export async function randomGamesFromLibrary(library: LibraryDto, count?: number): Promise<GameDto[]> {
const rand = new Rand(library.id.toString()); const rand = new Rand(library.id.toString());
const games = await LibraryEndpoint.getGamesInLibrary(library.id); const games = await LibraryEndpoint.getGamesInLibrary(library.id);
return games return games
.sort((a: GameDto, b: GameDto) => a.id - b.id) .sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5) .sort(() => rand.next() - 0.5)
.slice(0, count); .slice(0, count ?? games.length);
} }
@@ -1,9 +1,9 @@
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {GameEndpoint, LibraryEndpoint} from "Frontend/generated/endpoints"; import {GameEndpoint, LibraryEndpoint} from "Frontend/generated/endpoints";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {HorizontalGameList} from "Frontend/components/general/HorizontalGameList";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {randomGamesFromLibrary} from "Frontend/util/utils"; import {randomGamesFromLibrary} from "Frontend/util/utils";
import {CoverRow} from "Frontend/components/general/CoverRow";
export default function HomeView() { export default function HomeView() {
const [recentlyAddedGames, setRecentlyAddedGames] = useState<GameDto[]>([]); const [recentlyAddedGames, setRecentlyAddedGames] = useState<GameDto[]>([]);
@@ -15,7 +15,7 @@ export default function HomeView() {
setLibraries(libraries); setLibraries(libraries);
const gamePromises = libraries.map((library) => const gamePromises = libraries.map((library) =>
randomGamesFromLibrary(library, 10).then((games) => [library.id, games] as [number, GameDto[]]) randomGamesFromLibrary(library).then((games) => [library.id, games] as [number, GameDto[]])
); );
Promise.all(gamePromises).then((results) => { Promise.all(gamePromises).then((results) => {
@@ -35,12 +35,14 @@ export default function HomeView() {
return ( return (
<div className="w-full"> <div className="w-full">
<p className="text-center text-2xl font-extrabold">Welcome to Gameyfin!</p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<HorizontalGameList title="Recently added" games={recentlyAddedGames}/> <CoverRow title="Recently added" games={recentlyAddedGames}
onPressShowMore={() => alert("show more of 'Recently added'")}/>
{libraries.map((library) => ( {libraries.map((library) => (
<HorizontalGameList key={library.id} title={library.name} <CoverRow key={library.id} title={library.name}
games={libraryIdToGames.get(library.id) || []}/> games={libraryIdToGames.get(library.id) || []}
onPressShowMore={() => alert(`show more of library '${library.name}'`)}
/>
))} ))}
</div> </div>
</div> </div>
+23 -24
View File
@@ -57,35 +57,34 @@ export default function MainLayout() {
} }
return ( return (
<div className="flex flex-col min-h-svh"> <div className="flex flex-col min-h-screen">
{isExploding ? <Confetti {...confettiProps}/> : <></>} {isExploding ? <Confetti {...confettiProps}/> : <></>}
<div className="flex flex-col flex-grow w-full 2xl:w-3/4 m-auto">
<Navbar maxWidth="full">
<NavbarBrand as="button" onClick={() => navigate('/')}>
<GameyfinLogo className="h-10 fill-foreground"/>
</NavbarBrand>
<NavbarContent justify="end">
{auth.state.user?.emailConfirmed === false ?
<NavbarItem>
<small className="text-warning">Please confirm your email</small>
</NavbarItem>
:
""
}
<NavbarItem>
<ProfileMenu/>
</NavbarItem>
</NavbarContent>
</Navbar>
<div className="w-full overflow-hidden ml-2 pr-8 mt-4"> <Navbar maxWidth="full" className="2xl:px-[12.5%]">
<Outlet/> <NavbarBrand as="button" onClick={() => navigate('/')}>
</div> <GameyfinLogo className="h-10 fill-foreground"/>
</NavbarBrand>
<NavbarContent justify="end">
{auth.state.user?.emailConfirmed === false ?
<NavbarItem>
<small className="text-warning">Please confirm your email</small>
</NavbarItem>
:
""
}
<NavbarItem>
<ProfileMenu/>
</NavbarItem>
</NavbarContent>
</Navbar>
<div className="flex flex-col flex-grow 2xl:px-[12.5%] overflow-x-hidden mt-4">
<Outlet/>
</div> </div>
<Divider className="mt-8"/> <Divider className="mt-8"/>
<div className="flex flex-col w-full 2xl:w-3/4 m-auto"> <div className="flex flex-col w-full 2xl:px-[12.5%]">
<footer className="flex flex-row items-center justify-between py-4 px-12"> <footer className="flex flex-row items-center justify-between py-4">
<p>Gameyfin {PackageJson.version}</p> <p>Gameyfin {PackageJson.version}</p>
<p className="flex flex-row gap-1 items-baseline"> <p className="flex flex-row gap-1 items-baseline">
Made with Made with