mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 16:20:03 +00:00
Move package "de.grimsi.gameyfin" to "org.gameyfin"
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
|
||||
interface CoverGridProps {
|
||||
games: GameDto[];
|
||||
}
|
||||
|
||||
export default function CoverGrid({games}: CoverGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
|
||||
{games.map((game) => (
|
||||
<GameCover key={game.id} game={game} interactive={true}/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {ArrowRight} from "@phosphor-icons/react";
|
||||
import {useNavigate} from "react-router";
|
||||
|
||||
interface CoverRowProps {
|
||||
games: GameDto[];
|
||||
title: string;
|
||||
onPressShowMore: () => void;
|
||||
}
|
||||
|
||||
const aspectRatio = 12 / 17; // aspect ratio of the game cover
|
||||
const defaultImageHeight = 300; // default height for the image
|
||||
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
|
||||
|
||||
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(games.length);
|
||||
|
||||
useEffect(() => {
|
||||
const calculateVisible = () => {
|
||||
if (containerRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
|
||||
setVisibleCount(maxFit < games.length ? maxFit : games.length);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(calculateVisible);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
calculateVisible(); // initial calculation
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [games.length]);
|
||||
|
||||
const showMore = visibleCount < games.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mb-4">
|
||||
<p className="text-2xl font-bold mb-4">{title}</p>
|
||||
<div className="w-full relative">
|
||||
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
|
||||
{games.slice(0, visibleCount).map((game, index) => (
|
||||
<GameCover key={index} game={game} radius="sm" interactive={true}/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showMore && (
|
||||
<div className="flex flex-row items-center justify-end cursor-pointer"
|
||||
onClick={onPressShowMore}>
|
||||
<div className="absolute h-full w-1/4 right-0 bottom-0
|
||||
bg-gradient-to-r from-transparent to-background
|
||||
transition-all duration-300 ease-in-out hover:opacity-80"/>
|
||||
<div
|
||||
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
|
||||
<p className="text-xl font-semibold">Show more</p>
|
||||
<ArrowRight weight="bold"/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {Image} from "@heroui/react";
|
||||
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
|
||||
|
||||
interface GameCoverProps {
|
||||
game: GameDto;
|
||||
size?: number;
|
||||
radius?: "none" | "sm" | "md" | "lg";
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
|
||||
const coverContent = Number.isInteger(game.coverId) ? (
|
||||
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover aspect-[12/17]"
|
||||
src={`images/cover/${game.coverId}`}
|
||||
radius={radius}
|
||||
height={size}
|
||||
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<GameCoverFallback title={game.title} size={size} radius={radius} hover={interactive}/>
|
||||
);
|
||||
|
||||
return interactive ? (
|
||||
<a href={`/game/${game.id}`}>
|
||||
{coverContent}
|
||||
</a>
|
||||
) : coverContent;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {Card} from "@heroui/react";
|
||||
|
||||
interface GameCoverFallbackProps {
|
||||
title: string;
|
||||
size?: number;
|
||||
radius?: "none" | "sm" | "md" | "lg";
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export function GameCoverFallback({title, size = 300, radius = "sm", hover = false}: GameCoverFallbackProps) {
|
||||
return (
|
||||
<Card style={{aspectRatio: "12 /17", height: size, borderRadius: radius}}
|
||||
radius={radius}
|
||||
className={hover ? "scale-95 hover:scale-100" : ""}>
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
{title}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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';
|
||||
|
||||
import "swiper/css";
|
||||
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 {
|
||||
isActive: boolean;
|
||||
isVisible: boolean;
|
||||
isPrev: boolean;
|
||||
isNext: boolean;
|
||||
}
|
||||
|
||||
export default function ImageCarousel({imageUrls, videosUrls, className}: ImageCarouselProps) {
|
||||
|
||||
interface CarouselElement {
|
||||
type: "image" | "video";
|
||||
url: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SLIDES_PER_VIEW = 3;
|
||||
|
||||
const [elements, setElements] = useState<CarouselElement[]>();
|
||||
const [selectedImageUrl, setSelectedImageUrl] = useState<string>();
|
||||
const imagePopup = useDisclosure();
|
||||
|
||||
useEffect(() => {
|
||||
const images = imageUrls?.map((imageUrl) => ({
|
||||
type: "image" as const,
|
||||
url: imageUrl
|
||||
})) || [];
|
||||
const videos = videosUrls?.map((videoUrl) => ({
|
||||
type: "video" as const,
|
||||
url: videoUrl
|
||||
})) || [];
|
||||
|
||||
setElements([...images, ...videos]);
|
||||
}, [imageUrls, videosUrls])
|
||||
|
||||
function showImagePopup(imageUrl: string) {
|
||||
setSelectedImageUrl(imageUrl);
|
||||
imagePopup.onOpen();
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function ImagePopup({imageUrl, isOpen, onOpenChange}: {
|
||||
imageUrl?: string,
|
||||
isOpen: boolean,
|
||||
onOpenChange: (isOpen: boolean) => void
|
||||
}) {
|
||||
return (imageUrl &&
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur">
|
||||
<ModalContent className="bg-transparent">
|
||||
{(onClose) => (
|
||||
<div className="flex flex-grow items-center justify-center cursor-zoom-out"
|
||||
onClick={onClose}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt="Game screenshot"
|
||||
className="max-w-[80vw] max-h-[80vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import React from "react";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||
import {Card} from "@heroui/react";
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
library: LibraryDto;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LibraryHeader({library, className}: LibraryHeaderProps) {
|
||||
const MAX_COVER_COUNT = 5;
|
||||
const state = useSnapshot(gameState);
|
||||
const randomGames = getRandomGames();
|
||||
|
||||
function getRandomGames() {
|
||||
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
|
||||
if (!games) return [];
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
|
||||
<IconBackgroundPattern/>
|
||||
<div className="flex flex-row items-center w-full h-full brightness-50">
|
||||
{randomGames.map((game, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex-none overflow-hidden -ml-[10%]"
|
||||
style={{
|
||||
width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
|
||||
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/images/screenshot/${game.imageIds![0]}`}
|
||||
alt={`Image ${idx}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<h2 className="text-white text-3xl font-bold">{library.name}</h2>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user