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">
<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}>
<Card ref={containerRef} className="flex flex-row gap-2 bg-transparent" radius={radius}>
{games.slice(0, visibleCount).map((game, index) => (
<div className="flex-shrink-0 cursor-pointer" key={index}
onClick={() => navigate(`/game/${game.id}`)}>
<GameCover game={game} radius={radius}/>
<GameCover game={game} radius={radius} hover={true}/>
</div>
))}
</Card>
@@ -6,19 +6,20 @@ interface GameCoverProps {
game: GameDto;
size?: number;
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 (
Number.isInteger(game.coverId) ? (
<Image
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}`}
radius={radius}
height={size}
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;
size?: number;
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 (
<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">
{title}
</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 {Card, Image, Modal, ModalContent, useDisclosure} from "@heroui/react";
import ReactPlayer from 'react-player';
@@ -8,11 +8,13 @@ import "swiper/css/navigation";
import "swiper/css/pagination";
import "swiper/css/autoplay";
import {useEffect, useState} from "react";
import {CaretLeft, CaretRight, IconContext, Play} from "@phosphor-icons/react";
interface ImageCarouselProps {
imageUrls?: string[];
videosUrls?: string[];
className?: string;
}
interface SlideData {
@@ -22,14 +24,14 @@ interface SlideData {
isNext: boolean;
}
export default function ImageCarousel({imageUrls, videosUrls}: ImageCarouselProps) {
export default function ImageCarousel({imageUrls, videosUrls, className}: ImageCarouselProps) {
interface CarouselElement {
type: "image" | "video";
url: string;
}
const SLIDES_PER_VIEW = 3;
const DEFAULT_SLIDES_PER_VIEW = 3;
const [elements, setElements] = useState<CarouselElement[]>();
const [selectedImageUrl, setSelectedImageUrl] = useState<string>();
@@ -45,13 +47,7 @@ export default function ImageCarousel({imageUrls, videosUrls}: ImageCarouselProp
url: videoUrl
})) || [];
if ((images.length + videos.length) > SLIDES_PER_VIEW) {
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]);
}
setElements([...images, ...videos]);
}, [])
function showImagePopup(imageUrl: string) {
@@ -60,47 +56,74 @@ export default function ImageCarousel({imageUrls, videosUrls}: ImageCarouselProp
}
return (
<div className="flex flex-col gap-2 bg-transparent">
<Swiper
modules={[Virtual, Autoplay]}
virtual={true}
slidesPerView={SLIDES_PER_VIEW}
spaceBetween={0}
autoplay={{
delay: 10000,
waitForTransition: false,
pauseOnMouseEnter: true
}}
className="w-full"
>
{elements && elements.map((e, index) => (
<SwiperSlide key={index} virtualIndex={index}>
{({isNext}: SlideData) => {
if (e.type === "image") {
return (
<Image
src={e.url}
alt={`Game screenshot slide ${index}`}
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isNext ? "scale-90" : ""}`}
onClick={() => showImagePopup(e.url)}
/>
)
}
return (
<Card
className={`w-full h-full aspect-[16/9] ${!isNext ? "scale-90" : ""}`}>
<ReactPlayer
url={e.url}
width="100%"
height="100%"
/>
</Card>
)
}}
</SwiperSlide>
))}
</Swiper>
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen} onOpenChange={imagePopup.onOpenChange}/>
<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
modules={[Pagination, Navigation, Autoplay]}
slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_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}
autoplay={{
delay: 10000,
disableOnInteraction: true
}}
className="w-full"
>
{elements && elements.map((e, index) => (
<SwiperSlide key={index} virtualIndex={index}>
{({isActive}: SlideData) => {
if (e.type === "image") {
return (
<Image
src={e.url}
alt={`Game screenshot slide ${index}`}
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
onClick={() => showImagePopup(e.url)}
/>
)
}
return (
<Card
className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}>
<ReactPlayer
url={e.url}
width="100%"
height="100%"
light={true}
controls={true}
playing={isActive}
playIcon={<Play weight="fill"/>}
/>
</Card>
)
}}
</SwiperSlide>
))}
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
onOpenChange={imagePopup.onOpenChange}/>
</Swiper>
<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>
);
}
@@ -19,11 +19,12 @@ export interface ComboButtonOption {
}
export interface ComboButtonProps {
description?: string;
options: Record<string, ComboButtonOption>;
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 [disabledOptions] = useState<string[]>(getDisabledKeys(options));
const selectedOptionValue = Array.from(selectedOption)[0];
@@ -56,8 +57,12 @@ export default function ComboButton({options, preferredOptionKey}: ComboButtonPr
return (
<ButtonGroup className="gap-[1px]">
<Button color="primary" className="font-semibold w-52"
onPress={options[selectedOptionValue].action}>{options[selectedOptionValue].label}
<Button color="primary" className="w-52"
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>
<Dropdown placement="bottom-end">
<DropdownTrigger>
+9 -1
View File
@@ -14,9 +14,17 @@
/* Custom CSS */
/* Overwrite default Hilla styles (e.g. loading indicator) */
:root {
/* Overwrite default Hilla styles (e.g. loading indicator) */
--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 */
+1 -1
View File
@@ -25,7 +25,7 @@ export function roleToRoleName(role: 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));
}).join(' ');
}
+37 -10
View File
@@ -5,6 +5,8 @@ import {useParams} from "react-router";
import {GameCover} from "Frontend/components/general/covers/GameCover";
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
import {Chip} from "@heroui/react";
import {toTitleCase} from "Frontend/util/utils";
export default function GameView() {
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>
</div>
</div>
<ComboButton options={downloadOptions} preferredOptionKey="preferred-download-method"/>
<ComboButton description="64 GiB"
options={downloadOptions}
preferredOptionKey="preferred-download-method"
/>
</div>
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<p className="text-foreground/60">Summary</p>
<p>{game.summary}</p>
<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-justify">{game.summary}</p>
</div>
<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-2 overflow-visible">
<div className="flex flex-col gap-4">
<p className="text-foreground/60">Media</p>
{game.imageIds !== undefined && game.imageIds.length > 0 &&
<ImageCarousel
imageUrls={game.imageIds.map(id => `/images/screenshot/${id}`)}
videosUrls={game.videoUrls}
/>}
<ImageCarousel
imageUrls={game.imageIds?.map(id => `/images/screenshot/${id}`)}
videosUrls={game.videoUrls}
className="-mx-24"
/>
</div>
</div>
</div>