From 71a42ccf0c8692f8325aa909f59a0d2e98bc82dc Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Wed, 14 May 2025 17:33:03 +0200 Subject: [PATCH] Progress implementation of GameView --- .../frontend/components/general/CoverRow.tsx | 4 +- .../components/general/covers/GameCover.tsx | 7 +- .../general/covers/GameCoverFallback.tsx | 6 +- .../general/covers/ImageCarousel.tsx | 125 +++++++++++------- .../components/general/input/ComboButton.tsx | 11 +- gameyfin/src/main/frontend/main.css | 10 +- gameyfin/src/main/frontend/util/utils.ts | 2 +- gameyfin/src/main/frontend/views/GameView.tsx | 47 +++++-- 8 files changed, 139 insertions(+), 73 deletions(-) diff --git a/gameyfin/src/main/frontend/components/general/CoverRow.tsx b/gameyfin/src/main/frontend/components/general/CoverRow.tsx index a5c969c..a5c959a 100644 --- a/gameyfin/src/main/frontend/components/general/CoverRow.tsx +++ b/gameyfin/src/main/frontend/components/general/CoverRow.tsx @@ -47,11 +47,11 @@ export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {

{title}

- + {games.slice(0, visibleCount).map((game, index) => (
navigate(`/game/${game.id}`)}> - +
))}
diff --git a/gameyfin/src/main/frontend/components/general/covers/GameCover.tsx b/gameyfin/src/main/frontend/components/general/covers/GameCover.tsx index 9b19e39..cbbb3c1 100644 --- a/gameyfin/src/main/frontend/components/general/covers/GameCover.tsx +++ b/gameyfin/src/main/frontend/components/general/covers/GameCover.tsx @@ -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) ? ( {game.title}} /> - ) : + ) : ); } \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/covers/GameCoverFallback.tsx b/gameyfin/src/main/frontend/components/general/covers/GameCoverFallback.tsx index 4f06da2..7c6bc1d 100644 --- a/gameyfin/src/main/frontend/components/general/covers/GameCoverFallback.tsx +++ b/gameyfin/src/main/frontend/components/general/covers/GameCoverFallback.tsx @@ -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 ( + radius={radius} + className={hover ? "scale-95 hover:scale-100" : ""}>
{title}
diff --git a/gameyfin/src/main/frontend/components/general/covers/ImageCarousel.tsx b/gameyfin/src/main/frontend/components/general/covers/ImageCarousel.tsx index 84247a6..f9d9feb 100644 --- a/gameyfin/src/main/frontend/components/general/covers/ImageCarousel.tsx +++ b/gameyfin/src/main/frontend/components/general/covers/ImageCarousel.tsx @@ -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(); const [selectedImageUrl, setSelectedImageUrl] = useState(); @@ -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 ( -
- - {elements && elements.map((e, index) => ( - - {({isNext}: SlideData) => { - if (e.type === "image") { - return ( - {`Game showImagePopup(e.url)} - /> - ) - } - return ( - - - - ) - }} - - ))} - - +
+ {elements && elements.length > 0 && +
+
+ + + 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) => ( + + {({isActive}: SlideData) => { + if (e.type === "image") { + return ( + {`Game showImagePopup(e.url)} + /> + ) + } + return ( + + } + /> + + ) + }} + + ))} + + + + +
+
+ {/* Wrap the pagination in a div because it gets replaced at runtime be SwiperJS and loses all styling */} +
+
+
+ }
); } diff --git a/gameyfin/src/main/frontend/components/general/input/ComboButton.tsx b/gameyfin/src/main/frontend/components/general/input/ComboButton.tsx index 55e66be..26f3fb1 100644 --- a/gameyfin/src/main/frontend/components/general/input/ComboButton.tsx +++ b/gameyfin/src/main/frontend/components/general/input/ComboButton.tsx @@ -19,11 +19,12 @@ export interface ComboButtonOption { } export interface ComboButtonProps { + description?: string; options: Record; 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(getDisabledKeys(options)); const selectedOptionValue = Array.from(selectedOption)[0]; @@ -56,8 +57,12 @@ export default function ComboButton({options, preferredOptionKey}: ComboButtonPr return ( - diff --git a/gameyfin/src/main/frontend/main.css b/gameyfin/src/main/frontend/main.css index f5d84c1..4ca0f63 100644 --- a/gameyfin/src/main/frontend/main.css +++ b/gameyfin/src/main/frontend/main.css @@ -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 */ diff --git a/gameyfin/src/main/frontend/util/utils.ts b/gameyfin/src/main/frontend/util/utils.ts index 83e4f39..2c60e9a 100644 --- a/gameyfin/src/main/frontend/util/utils.ts +++ b/gameyfin/src/main/frontend/util/utils.ts @@ -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(' '); } diff --git a/gameyfin/src/main/frontend/views/GameView.tsx b/gameyfin/src/main/frontend/views/GameView.tsx index 83b32c9..9113071 100644 --- a/gameyfin/src/main/frontend/views/GameView.tsx +++ b/gameyfin/src/main/frontend/views/GameView.tsx @@ -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() {

{game.release !== undefined ? new Date(game.release).getFullYear() : "unknown"}

- +
-
-

Summary

-

{game.summary}

+
+
+

Summary

+

{game.summary}

+
+
+

Details

+ + + {Object.entries({ + "Developed by": game.developers?.sort().join(" / "), + "Published by": game.publishers?.sort().join(" / "), + "Genres": game.genres?.sort().map(p => {toTitleCase(p)}), + "Themes": game.themes?.sort().map(p => {toTitleCase(p)}), + "Features": game.features?.sort().map(p => {toTitleCase(p)}), + }).map(([key, value]) => ( + + + + + ))} + +
{key}{value}
+
-
+

Media

- {game.imageIds !== undefined && game.imageIds.length > 0 && - `/images/screenshot/${id}`)} - videosUrls={game.videoUrls} - />} + `/images/screenshot/${id}`)} + videosUrls={game.videoUrls} + className="-mx-24" + />