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>
</DropdownTrigger>
<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>
</DropdownItem>
{profileMenuItems.filter(item => item.showIf !== false).map(({label, icon, onClick, color}) => {
@@ -72,6 +72,7 @@ export default function ProfileMenu() {
/* @ts-ignore */
color={color ? color : ""}
className={`text-${color} hover:bg-primary/20`}
textValue={label}
>
{label}
</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";
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 React, {useEffect, useState} from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {GameCover} from "Frontend/components/general/GameCover";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import {
Alien,
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
* @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 games = await LibraryEndpoint.getGamesInLibrary(library.id);
return games
.sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5)
.slice(0, count);
.slice(0, count ?? games.length);
}
@@ -1,9 +1,9 @@
import {useEffect, useState} from "react";
import {GameEndpoint, LibraryEndpoint} from "Frontend/generated/endpoints";
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 {randomGamesFromLibrary} from "Frontend/util/utils";
import {CoverRow} from "Frontend/components/general/CoverRow";
export default function HomeView() {
const [recentlyAddedGames, setRecentlyAddedGames] = useState<GameDto[]>([]);
@@ -15,7 +15,7 @@ export default function HomeView() {
setLibraries(libraries);
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) => {
@@ -35,12 +35,14 @@ export default function HomeView() {
return (
<div className="w-full">
<p className="text-center text-2xl font-extrabold">Welcome to Gameyfin!</p>
<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) => (
<HorizontalGameList key={library.id} title={library.name}
games={libraryIdToGames.get(library.id) || []}/>
<CoverRow key={library.id} title={library.name}
games={libraryIdToGames.get(library.id) || []}
onPressShowMore={() => alert(`show more of library '${library.name}'`)}
/>
))}
</div>
</div>
+23 -24
View File
@@ -57,35 +57,34 @@ export default function MainLayout() {
}
return (
<div className="flex flex-col min-h-svh">
<div className="flex flex-col min-h-screen">
{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">
<Outlet/>
</div>
<Navbar maxWidth="full" className="2xl:px-[12.5%]">
<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="flex flex-col flex-grow 2xl:px-[12.5%] overflow-x-hidden mt-4">
<Outlet/>
</div>
<Divider className="mt-8"/>
<div className="flex flex-col w-full 2xl:w-3/4 m-auto">
<footer className="flex flex-row items-center justify-between py-4 px-12">
<div className="flex flex-col w-full 2xl:px-[12.5%]">
<footer className="flex flex-row items-center justify-between py-4">
<p>Gameyfin {PackageJson.version}</p>
<p className="flex flex-row gap-1 items-baseline">
Made with