Move package "de.grimsi.gameyfin" to "org.gameyfin"

This commit is contained in:
grimsi
2025-06-14 19:23:12 +02:00
parent be0ba28c54
commit d3d46b6b01
328 changed files with 710 additions and 678 deletions
@@ -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>
);
}