Progress implementation of GameView

This commit is contained in:
grimsi
2025-05-14 17:33:03 +02:00
parent b906d8a77b
commit 71a42ccf0c
8 changed files with 139 additions and 73 deletions
@@ -47,11 +47,11 @@ export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
<div className="flex flex-col mb-4"> <div className="flex flex-col mb-4">
<p className="text-2xl font-bold mb-4">{title}</p> <p className="text-2xl font-bold mb-4">{title}</p>
<div className="w-full relative"> <div className="w-full relative">
<Card ref={containerRef} className="flex flex-row gap-4 bg-transparent" radius={radius}> <Card ref={containerRef} className="flex flex-row gap-2 bg-transparent" radius={radius}>
{games.slice(0, visibleCount).map((game, index) => ( {games.slice(0, visibleCount).map((game, index) => (
<div className="flex-shrink-0 cursor-pointer" key={index} <div className="flex-shrink-0 cursor-pointer" key={index}
onClick={() => navigate(`/game/${game.id}`)}> onClick={() => navigate(`/game/${game.id}`)}>
<GameCover game={game} radius={radius}/> <GameCover game={game} radius={radius} hover={true}/>
</div> </div>
))} ))}
</Card> </Card>
@@ -6,19 +6,20 @@ interface GameCoverProps {
game: GameDto; game: GameDto;
size?: number; size?: number;
radius?: "none" | "sm" | "md" | "lg"; radius?: "none" | "sm" | "md" | "lg";
hover?: boolean;
} }
export function GameCover({game, size = 300, radius = "sm"}: GameCoverProps) { export function GameCover({game, size = 300, radius = "sm", hover = false}: GameCoverProps) {
return ( return (
Number.isInteger(game.coverId) ? ( Number.isInteger(game.coverId) ? (
<Image <Image
alt={game.title} alt={game.title}
className="z-0 w-full h-full object-cover aspect-[12/17]" className={`z-0 w-full h-full object-cover aspect-[12/17] ${hover ? "scale-95 hover:scale-100" : ""}`}
src={`images/cover/${game.coverId}`} src={`images/cover/${game.coverId}`}
radius={radius} radius={radius}
height={size} height={size}
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>} fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
/> />
) : <GameCoverFallback title={game.title} size={size} radius={radius}/> ) : <GameCoverFallback title={game.title} size={size} radius={radius} hover={hover}/>
); );
} }
@@ -4,12 +4,14 @@ interface GameCoverFallbackProps {
title: string; title: string;
size?: number; size?: number;
radius?: "none" | "sm" | "md" | "lg"; radius?: "none" | "sm" | "md" | "lg";
hover?: boolean;
} }
export function GameCoverFallback({title, size = 300, radius = "sm"}: GameCoverFallbackProps) { export function GameCoverFallback({title, size = 300, radius = "sm", hover = false}: GameCoverFallbackProps) {
return ( return (
<Card style={{aspectRatio: "12 /17", height: size, borderRadius: radius}} <Card style={{aspectRatio: "12 /17", height: size, borderRadius: radius}}
radius={radius}> radius={radius}
className={hover ? "scale-95 hover:scale-100" : ""}>
<div className="flex flex-col items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-full">
{title} {title}
</div> </div>
@@ -1,4 +1,4 @@
import {Autoplay, Virtual} from 'swiper/modules'; import {Autoplay, Navigation, Pagination} from 'swiper/modules';
import {Swiper, SwiperSlide} from "swiper/react"; import {Swiper, SwiperSlide} from "swiper/react";
import {Card, Image, Modal, ModalContent, useDisclosure} from "@heroui/react"; import {Card, Image, Modal, ModalContent, useDisclosure} from "@heroui/react";
import ReactPlayer from 'react-player'; import ReactPlayer from 'react-player';
@@ -8,11 +8,13 @@ import "swiper/css/navigation";
import "swiper/css/pagination"; import "swiper/css/pagination";
import "swiper/css/autoplay"; import "swiper/css/autoplay";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {CaretLeft, CaretRight, IconContext, Play} from "@phosphor-icons/react";
interface ImageCarouselProps { interface ImageCarouselProps {
imageUrls?: string[]; imageUrls?: string[];
videosUrls?: string[]; videosUrls?: string[];
className?: string;
} }
interface SlideData { interface SlideData {
@@ -22,14 +24,14 @@ interface SlideData {
isNext: boolean; isNext: boolean;
} }
export default function ImageCarousel({imageUrls, videosUrls}: ImageCarouselProps) { export default function ImageCarousel({imageUrls, videosUrls, className}: ImageCarouselProps) {
interface CarouselElement { interface CarouselElement {
type: "image" | "video"; type: "image" | "video";
url: string; url: string;
} }
const SLIDES_PER_VIEW = 3; const DEFAULT_SLIDES_PER_VIEW = 3;
const [elements, setElements] = useState<CarouselElement[]>(); const [elements, setElements] = useState<CarouselElement[]>();
const [selectedImageUrl, setSelectedImageUrl] = useState<string>(); const [selectedImageUrl, setSelectedImageUrl] = useState<string>();
@@ -45,13 +47,7 @@ export default function ImageCarousel({imageUrls, videosUrls}: ImageCarouselProp
url: videoUrl url: videoUrl
})) || []; })) || [];
if ((images.length + videos.length) > SLIDES_PER_VIEW) { setElements([...images, ...videos]);
let elements = [...videos, ...images];
// Add the last element to the start of the array and the first element to the end of the array to create a loop
setElements([elements[elements.length - 1], ...elements, elements[0]]);
} else {
setElements([...videos, ...images]);
}
}, []) }, [])
function showImagePopup(imageUrl: string) { function showImagePopup(imageUrl: string) {
@@ -60,47 +56,74 @@ export default function ImageCarousel({imageUrls, videosUrls}: ImageCarouselProp
} }
return ( return (
<div className="flex flex-col gap-2 bg-transparent"> <div className={className}>
{elements && elements.length > 0 &&
<div className="w-full flex flex-col gap-2 items-center">
<div className="w-full flex flex-row items-center">
<IconContext.Provider value={{size: 50}}>
<CaretLeft className="swiper-custom-button-prev cursor-pointer fill-primary"/>
<Swiper <Swiper
modules={[Virtual, Autoplay]} modules={[Pagination, Navigation, Autoplay]}
virtual={true} slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW}
slidesPerView={SLIDES_PER_VIEW} pagination={{
clickable: true,
el: ".swiper-custom-pagination"
}}
navigation={{
prevEl: ".swiper-custom-button-prev",
nextEl: ".swiper-custom-button-next"
}}
centeredSlides={true}
loop={true}
spaceBetween={0} spaceBetween={0}
autoplay={{ autoplay={{
delay: 10000, delay: 10000,
waitForTransition: false, disableOnInteraction: true
pauseOnMouseEnter: true
}} }}
className="w-full" className="w-full"
> >
{elements && elements.map((e, index) => ( {elements && elements.map((e, index) => (
<SwiperSlide key={index} virtualIndex={index}> <SwiperSlide key={index} virtualIndex={index}>
{({isNext}: SlideData) => { {({isActive}: SlideData) => {
if (e.type === "image") { if (e.type === "image") {
return ( return (
<Image <Image
src={e.url} src={e.url}
alt={`Game screenshot slide ${index}`} alt={`Game screenshot slide ${index}`}
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isNext ? "scale-90" : ""}`} className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
onClick={() => showImagePopup(e.url)} onClick={() => showImagePopup(e.url)}
/> />
) )
} }
return ( return (
<Card <Card
className={`w-full h-full aspect-[16/9] ${!isNext ? "scale-90" : ""}`}> className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}>
<ReactPlayer <ReactPlayer
url={e.url} url={e.url}
width="100%" width="100%"
height="100%" height="100%"
light={true}
controls={true}
playing={isActive}
playIcon={<Play weight="fill"/>}
/> />
</Card> </Card>
) )
}} }}
</SwiperSlide> </SwiperSlide>
))} ))}
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
onOpenChange={imagePopup.onOpenChange}/>
</Swiper> </Swiper>
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen} onOpenChange={imagePopup.onOpenChange}/> <CaretRight className="swiper-custom-button-next cursor-pointer fill-primary"/>
</IconContext.Provider>
</div>
<div>
{/* Wrap the pagination in a div because it gets replaced at runtime be SwiperJS and loses all styling */}
<div className="swiper-custom-pagination"/>
</div>
</div>
}
</div> </div>
); );
} }
@@ -19,11 +19,12 @@ export interface ComboButtonOption {
} }
export interface ComboButtonProps { export interface ComboButtonProps {
description?: string;
options: Record<string, ComboButtonOption>; options: Record<string, ComboButtonOption>;
preferredOptionKey?: string; preferredOptionKey?: string;
} }
export default function ComboButton({options, preferredOptionKey}: ComboButtonProps) { export default function ComboButton({options, preferredOptionKey, description}: ComboButtonProps) {
const [selectedOption, setSelectedOption] = useState(new Set([Object.keys(options)[0]])); const [selectedOption, setSelectedOption] = useState(new Set([Object.keys(options)[0]]));
const [disabledOptions] = useState<string[]>(getDisabledKeys(options)); const [disabledOptions] = useState<string[]>(getDisabledKeys(options));
const selectedOptionValue = Array.from(selectedOption)[0]; const selectedOptionValue = Array.from(selectedOption)[0];
@@ -56,8 +57,12 @@ export default function ComboButton({options, preferredOptionKey}: ComboButtonPr
return ( return (
<ButtonGroup className="gap-[1px]"> <ButtonGroup className="gap-[1px]">
<Button color="primary" className="font-semibold w-52" <Button color="primary" className="w-52"
onPress={options[selectedOptionValue].action}>{options[selectedOptionValue].label} onPress={options[selectedOptionValue].action}>
<div className="flex flex-col items-center">
<p className="font-semibold">{options[selectedOptionValue].label}</p>
<p className="text-xs font-normal opacity-70 ">{description}</p>
</div>
</Button> </Button>
<Dropdown placement="bottom-end"> <Dropdown placement="bottom-end">
<DropdownTrigger> <DropdownTrigger>
+9 -1
View File
@@ -14,9 +14,17 @@
/* Custom CSS */ /* Custom CSS */
/* Overwrite default Hilla styles (e.g. loading indicator) */
:root { :root {
/* Overwrite default Hilla styles (e.g. loading indicator) */
--lumo-primary-color: theme(colors.primary); --lumo-primary-color: theme(colors.primary);
/* Overwrite SwiperJS styles */
--swiper-navigation-color: theme(colors.primary);
--swiper-pagination-color: theme(colors.primary);
.swiper-pagination-bullet {
background-color: theme(colors.primary);
}
} }
/* List box drag & drop */ /* List box drag & drop */
+1 -1
View File
@@ -25,7 +25,7 @@ export function roleToRoleName(role: string) {
} }
export function toTitleCase(str: string) { export function toTitleCase(str: string) {
return str.toLowerCase().split(' ').map((word: string) => { return str.replaceAll("_", " ").toLowerCase().split(' ').map((word: string) => {
return (word.charAt(0).toUpperCase() + word.slice(1)); return (word.charAt(0).toUpperCase() + word.slice(1));
}).join(' '); }).join(' ');
} }
+34 -7
View File
@@ -5,6 +5,8 @@ import {useParams} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover"; 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 {Chip} from "@heroui/react";
import {toTitleCase} from "Frontend/util/utils";
export default function GameView() { export default function GameView() {
const {gameId} = useParams(); const {gameId} = useParams();
@@ -58,20 +60,45 @@ export default function GameView() {
<p className="text-foreground/60">{game.release !== undefined ? new Date(game.release).getFullYear() : "unknown"}</p> <p className="text-foreground/60">{game.release !== undefined ? new Date(game.release).getFullYear() : "unknown"}</p>
</div> </div>
</div> </div>
<ComboButton options={downloadOptions} preferredOptionKey="preferred-download-method"/> <ComboButton description="64 GiB"
options={downloadOptions}
preferredOptionKey="preferred-download-method"
/>
</div> </div>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-row gap-12">
<div className="flex flex-col flex-1 gap-2">
<p className="text-foreground/60">Summary</p> <p className="text-foreground/60">Summary</p>
<p>{game.summary}</p> <p className="text-justify">{game.summary}</p>
</div> </div>
<div className="flex flex-col gap-2 overflow-visible"> <div className="flex flex-col flex-1 gap-2">
<p className="text-foreground/60">Details</p>
<table className="text-left w-full table-auto">
<tbody>
{Object.entries({
"Developed by": game.developers?.sort().join(" / "),
"Published by": game.publishers?.sort().join(" / "),
"Genres": game.genres?.sort().map(p => <Chip radius="sm">{toTitleCase(p)}</Chip>),
"Themes": game.themes?.sort().map(p => <Chip radius="sm">{toTitleCase(p)}</Chip>),
"Features": game.features?.sort().map(p => <Chip
radius="sm">{toTitleCase(p)}</Chip>),
}).map(([key, value]) => (
<tr key={key}>
<td className="text-foreground/60 w-0 min-w-32">{key}</td>
<td className="flex flex-row gap-1">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="flex flex-col gap-4">
<p className="text-foreground/60">Media</p> <p className="text-foreground/60">Media</p>
{game.imageIds !== undefined && game.imageIds.length > 0 &&
<ImageCarousel <ImageCarousel
imageUrls={game.imageIds.map(id => `/images/screenshot/${id}`)} imageUrls={game.imageIds?.map(id => `/images/screenshot/${id}`)}
videosUrls={game.videoUrls} videosUrls={game.videoUrls}
/>} className="-mx-24"
/>
</div> </div>
</div> </div>
</div> </div>