mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Progress implementation of GameView
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user