mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +00:00
Almost finished HomeView
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user