mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 08:15:48 +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">
|
<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}>
|
||||||
<Swiper
|
{elements && elements.length > 0 &&
|
||||||
modules={[Virtual, Autoplay]}
|
<div className="w-full flex flex-col gap-2 items-center">
|
||||||
virtual={true}
|
<div className="w-full flex flex-row items-center">
|
||||||
slidesPerView={SLIDES_PER_VIEW}
|
<IconContext.Provider value={{size: 50}}>
|
||||||
spaceBetween={0}
|
<CaretLeft className="swiper-custom-button-prev cursor-pointer fill-primary"/>
|
||||||
autoplay={{
|
<Swiper
|
||||||
delay: 10000,
|
modules={[Pagination, Navigation, Autoplay]}
|
||||||
waitForTransition: false,
|
slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW}
|
||||||
pauseOnMouseEnter: true
|
pagination={{
|
||||||
}}
|
clickable: true,
|
||||||
className="w-full"
|
el: ".swiper-custom-pagination"
|
||||||
>
|
}}
|
||||||
{elements && elements.map((e, index) => (
|
navigation={{
|
||||||
<SwiperSlide key={index} virtualIndex={index}>
|
prevEl: ".swiper-custom-button-prev",
|
||||||
{({isNext}: SlideData) => {
|
nextEl: ".swiper-custom-button-next"
|
||||||
if (e.type === "image") {
|
}}
|
||||||
return (
|
centeredSlides={true}
|
||||||
<Image
|
loop={true}
|
||||||
src={e.url}
|
spaceBetween={0}
|
||||||
alt={`Game screenshot slide ${index}`}
|
autoplay={{
|
||||||
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isNext ? "scale-90" : ""}`}
|
delay: 10000,
|
||||||
onClick={() => showImagePopup(e.url)}
|
disableOnInteraction: true
|
||||||
/>
|
}}
|
||||||
)
|
className="w-full"
|
||||||
}
|
>
|
||||||
return (
|
{elements && elements.map((e, index) => (
|
||||||
<Card
|
<SwiperSlide key={index} virtualIndex={index}>
|
||||||
className={`w-full h-full aspect-[16/9] ${!isNext ? "scale-90" : ""}`}>
|
{({isActive}: SlideData) => {
|
||||||
<ReactPlayer
|
if (e.type === "image") {
|
||||||
url={e.url}
|
return (
|
||||||
width="100%"
|
<Image
|
||||||
height="100%"
|
src={e.url}
|
||||||
/>
|
alt={`Game screenshot slide ${index}`}
|
||||||
</Card>
|
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
|
||||||
)
|
onClick={() => showImagePopup(e.url)}
|
||||||
}}
|
/>
|
||||||
</SwiperSlide>
|
)
|
||||||
))}
|
}
|
||||||
</Swiper>
|
return (
|
||||||
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen} onOpenChange={imagePopup.onOpenChange}/>
|
<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>
|
</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>
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
<p className="text-foreground/60">Summary</p>
|
<div className="flex flex-col flex-1 gap-2">
|
||||||
<p>{game.summary}</p>
|
<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>
|
||||||
<div className="flex flex-col gap-2 overflow-visible">
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user