mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 16:20:03 +00:00
Release v2.2.0 (#741)
* Migrate to TailwindCSS v4 (#740) * Remove "material-tailwind" dependencies due to incompatibility of Stepper component with Tailwind v4 * Clean up Tailwind configs before upgrade * Run HeroUI upgrade * Run TailwindCSS upgrade * Replace PostCSS with Vite * Migrate custom styles to v4 * Remove tailwind.config.ts * Add heroui.ts Add tailwind vite plugin * Fix small UI color inconsistency * Fix theming system Rename purple theme to pink * Re-implement stepper in HeroUI * Fix RoleChip colors * Migrate icon names (#743) * Add migration script for phosphor-icons * Migrate icon usages * Update version to 2.2.0-preview * Revert accidental rename of menu title * Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#750) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Improve library scanning (#749) * Update script to generate example libraries using SteamSpy API * Refactor library scanning process * Display Flyway startup log by default * Fix race condition in CompanyService * Fix race condition in ImageService Remove obsolete table * Fix SMTP config requiring an email as username (#755) * Disable length limit for config values (#757) * Deprecate DockerHub image (#759) * Remove deprecation warning from web UI * Reworked the CICD pipelines * Optimize container image (#761) * Fix Gradle warning * Rework Docker image to improve layer caching * Bump stefanzweifel/git-auto-commit-action from 6 to 7 (#765) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6 to 7. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v6...v7) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Multi platform support (#764) * Remove migrate-phosphor-icons.js since migration has been successful * Refactor GameMetadata into separate files * Add Platform enum * Implement platform support in Plugin API * Implement platform support in Steam Plugin * Implement platform support in IGDB Plugin * Add database migration for platform support * Implement platform support in GameService * Implement platform support on most endpoints and features, some are still missing Implemented platform support in all bundled plugins (although not finished polishing yet) * Implement platforms in UI * Make GameRequest platform aware * Return headerImages from IGDB * Implement proper PlatformMapper for IGDB plugin * Fix various smaller issues and inconsistencies * Replace placeholder in LibraryOverviewCard (#767) * Bump actions/download-artifact from 5 to 6 (#769) * Bump actions/upload-artifact from 4 to 5 (#770) * Multi platform support (#773) * Fix bug in Plugin API related to state loading/saving * Hide Flyway query logs by default * Extend migration script for multi platform tables * Plugins now store their data and state in ./plugindata * Add "plugindata" directory to entrypoint scripts * Improve download handling (#756) * Process download in background thread to avoid session timeout affecting it * Increase default session timeout to 24h * Use virtual thread pool for download task in background * Make KSP extensions.idx generation more robust * Implement download bandwidth limiter Implement SliderInput Refactor NumberInput * Implement download bandwidth throttling Implement real-time download monitoring * Improve UI for DownloadManagement Track more stats in SessionStats * Update Hilla Use React 19 * Implement real-time graph to track bandwidth usage Implement downloaded data sum over last day Small bug fixes Small refactorings * Update docker-compose.example.yml * Improve DownloadSessionCard (#784) * Fix unit on y-axis of download graph * Show game size and library in tooltip Make game chips interactive in DownloadSessionCard (leads to game page when clicked) Optimize graph settings * Migrate torrent plugin to libtorrent (#775) * Disable TorrentDownloadPlugin in Alpine based Docker image * Improve test coverage (#785) * Fix potential divide by zero bug * Add mockk dependency * Add tests for org.gameyfin.app.core.download * Add tests for Filesytem package Fix DownloadServiceTest * Fix FilesystemServiceTest * Add tests for "job" package * Upgrade Gradle wrapper Enable Gradle config cache * Added more tests * Added tests for the "security" package * Add tests for "game" package * Fix AsyncFileTailer not shutting down properly on Windows * Fix GameServiceTest * Added tests for "libraries" package * Added tests for "media" package * Fix warning in ImageService * Add tests fpr "messages" package Make sure transport is closed even in case an exception is thrown * Add tests for "platforms" package * Add tests for "requests" package * Moved "token" package to "core" package (from "shared") * Add tests for "token" package * Fix issue in RoleEnum.safeValueOf() throwing Exception * Fix potential issue in UserEndpoint.getUserInfo() when auth is null * Added tests for "user" package * Migrate package for "token" in FE * Publish test report in CI * Fix workflow permissions * Remove test because of timing issue in CI * Replaced "unmatched paths" with "ignored paths" (#791) * Use new "AutoComplete" component (#793) * Use ArrayInputAutocomplete in EditGameMetadataModal * Add test for getEnumPropertyValues --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import {Chip, Tooltip} from "@heroui/react";
|
||||
|
||||
interface ChipListProps {
|
||||
items: string[];
|
||||
maxVisible?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
radius?: "none" | "sm" | "md" | "lg" | "full";
|
||||
defaultContent?: string;
|
||||
}
|
||||
|
||||
export default function ChipList({items, maxVisible = 1, size = "sm", radius = "sm", defaultContent}: ChipListProps) {
|
||||
if (items.length === 0) {
|
||||
return defaultContent ? <Chip radius={radius} size={size}>{defaultContent}</Chip> : null;
|
||||
}
|
||||
|
||||
const visibleItems = items.slice(0, maxVisible);
|
||||
const remainingItems = items.slice(maxVisible);
|
||||
const hasMore = remainingItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
{visibleItems.map(item => (
|
||||
<Chip key={item} radius={radius} size={size}>
|
||||
{item}
|
||||
</Chip>
|
||||
))}
|
||||
{hasMore && (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex flex-col gap-1">
|
||||
{remainingItems.map(item => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
placement="right">
|
||||
<Chip radius={radius} size={size}>
|
||||
{maxVisible > 0 && "+"}{remainingItems.length}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,4 @@
|
||||
import {
|
||||
Alien,
|
||||
Baseball,
|
||||
Basketball,
|
||||
CastleTurret,
|
||||
DiceFive,
|
||||
GameController,
|
||||
Ghost,
|
||||
IconContext,
|
||||
Joystick,
|
||||
Lego,
|
||||
Medal,
|
||||
PuzzlePiece,
|
||||
Rocket,
|
||||
Skull,
|
||||
SoccerBall,
|
||||
Star,
|
||||
Strategy,
|
||||
Sword,
|
||||
Target,
|
||||
ThumbsUp,
|
||||
TreasureChest,
|
||||
Trophy,
|
||||
User,
|
||||
Volleyball
|
||||
} from "@phosphor-icons/react";
|
||||
import { AlienIcon, BaseballIcon, BasketballIcon, CastleTurretIcon, DiceFiveIcon, GameControllerIcon, GhostIcon, IconContext, JoystickIcon, LegoIcon, MedalIcon, PuzzlePieceIcon, RocketIcon, SkullIcon, SoccerBallIcon, StarIcon, StrategyIcon, SwordIcon, TargetIcon, ThumbsUpIcon, TreasureChestIcon, TrophyIcon, UserIcon, VolleyballIcon } from "@phosphor-icons/react";
|
||||
import React, {useEffect} from "react";
|
||||
|
||||
export default function IconBackgroundPattern() {
|
||||
@@ -54,29 +29,29 @@ export default function IconBackgroundPattern() {
|
||||
|
||||
return <div ref={containerRef} className="absolute w-full h-full opacity-50">
|
||||
<IconContext.Provider value={{size: iconSize}}>
|
||||
<GameController className="absolute fill-primary top-[8%] left-[8%] rotate-[350deg]"/>
|
||||
<SoccerBall className="absolute fill-primary top-[48%] left-[96%] rotate-[60deg]"/>
|
||||
<Joystick className="absolute top-[28%] left-[52%] rotate-[90deg]"/>
|
||||
<Strategy className="absolute fill-primary top-[52%] left-[68%] rotate-[30deg]"/>
|
||||
<Sword className="absolute top-[72%] left-[12%] rotate-[60deg]"/>
|
||||
<Alien className="absolute fill-primary top-[12%] left-[88%] rotate-[15deg]"/>
|
||||
<CastleTurret className="absolute top-[6%] left-[38%] rotate-[320deg]"/>
|
||||
<Ghost className="absolute fill-primary top-[38%] left-[6%] rotate-[300deg]"/>
|
||||
<Skull className="absolute top-[82%] left-[28%] rotate-[90deg]"/>
|
||||
<Trophy className="absolute fill-primary top-[12%] left-[62%] rotate-[45deg]"/>
|
||||
<Lego className="absolute top-[32%] left-[18%] rotate-[30deg]"/>
|
||||
<TreasureChest className="absolute top-[68%] left-[48%] rotate-[75deg]"/>
|
||||
<Basketball className="absolute fill-primary top-[22%] left-[37%] rotate-[10deg]"/>
|
||||
<Baseball className="absolute top-[92%] left-[82%] rotate-[340deg]"/>
|
||||
<DiceFive className="absolute top-[62%] left-[22%] rotate-[120deg]"/>
|
||||
<Medal className="absolute fill-primary top-[18%] left-[28%] rotate-[300deg]"/>
|
||||
<PuzzlePiece className="absolute top-[42%] left-[78%] rotate-[45deg]"/>
|
||||
<Rocket className="absolute fill-primary top-[88%] left-[52%] rotate-[15deg]"/>
|
||||
<Star className="absolute top-[28%] left-[72%] rotate-[60deg]"/>
|
||||
<Target className="absolute fill-primary top-[68%] left-[62%] rotate-[330deg]"/>
|
||||
<ThumbsUp className="absolute top-[82%] left-[12%] rotate-[80deg]"/>
|
||||
<User className="absolute fill-primary top-[38%] left-[62%] rotate-[20deg]"/>
|
||||
<Volleyball className="absolute top-[78%] left-[92%] rotate-[100deg]"/>
|
||||
<GameControllerIcon className="absolute fill-primary top-[8%] left-[8%] rotate-350"/>
|
||||
<SoccerBallIcon className="absolute fill-primary top-[48%] left-[96%] rotate-60"/>
|
||||
<JoystickIcon className="absolute top-[28%] left-[52%] rotate-90"/>
|
||||
<StrategyIcon className="absolute fill-primary top-[52%] left-[68%] rotate-30"/>
|
||||
<SwordIcon className="absolute top-[72%] left-[12%] rotate-60"/>
|
||||
<AlienIcon className="absolute fill-primary top-[12%] left-[88%] rotate-15"/>
|
||||
<CastleTurretIcon className="absolute top-[6%] left-[38%] rotate-320"/>
|
||||
<GhostIcon className="absolute fill-primary top-[38%] left-[6%] rotate-300"/>
|
||||
<SkullIcon className="absolute top-[82%] left-[28%] rotate-90"/>
|
||||
<TrophyIcon className="absolute fill-primary top-[12%] left-[62%] rotate-45"/>
|
||||
<LegoIcon className="absolute top-[32%] left-[18%] rotate-30"/>
|
||||
<TreasureChestIcon className="absolute top-[68%] left-[48%] rotate-75"/>
|
||||
<BasketballIcon className="absolute fill-primary top-[22%] left-[37%] rotate-10"/>
|
||||
<BaseballIcon className="absolute top-[92%] left-[82%] rotate-340"/>
|
||||
<DiceFiveIcon className="absolute top-[62%] left-[22%] rotate-120"/>
|
||||
<MedalIcon className="absolute fill-primary top-[18%] left-[28%] rotate-300"/>
|
||||
<PuzzlePieceIcon className="absolute top-[42%] left-[78%] rotate-45"/>
|
||||
<RocketIcon className="absolute fill-primary top-[88%] left-[52%] rotate-15"/>
|
||||
<StarIcon className="absolute top-[28%] left-[72%] rotate-60"/>
|
||||
<TargetIcon className="absolute fill-primary top-[68%] left-[62%] rotate-330"/>
|
||||
<ThumbsUpIcon className="absolute top-[82%] left-[12%] rotate-80"/>
|
||||
<UserIcon className="absolute fill-primary top-[38%] left-[62%] rotate-20"/>
|
||||
<VolleyballIcon className="absolute top-[78%] left-[92%] rotate-100"/>
|
||||
</IconContext.Provider>
|
||||
</div>
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {roleToColor, roleToRoleName} from "Frontend/util/utils";
|
||||
|
||||
export default function RoleChip({role}: { role: string }) {
|
||||
return (
|
||||
<Chip key={role} size="sm" radius="sm" className={`text-xs bg-${roleToColor(role)}-500`}>
|
||||
<Chip key={role} size="sm" radius="sm" className={`text-xs ${roleToColor(role)}`}>
|
||||
{roleToRoleName(role)}
|
||||
</Chip>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {useSnapshot} from "valtio/react";
|
||||
import {scanState} from "Frontend/state/ScanState";
|
||||
import LibraryScanProgress from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanProgress";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import {Target, Warning} from "@phosphor-icons/react";
|
||||
import { TargetIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import {timeBetween, timeUntil, toTitleCase} from "Frontend/util/utils";
|
||||
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
|
||||
import {useEffect, useState} from "react";
|
||||
@@ -45,7 +45,7 @@ export default function ScanProgressPopover() {
|
||||
classNames={{
|
||||
spinnerBars: "bg-foreground-500",
|
||||
}}/> :
|
||||
<Target className="fill-foreground-500"/>
|
||||
<TargetIcon className="fill-foreground-500"/>
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -103,7 +103,7 @@ export default function ScanProgressPopover() {
|
||||
</p>
|
||||
}
|
||||
{scan.status === LibraryScanStatus.FAILED &&
|
||||
<p className="text-danger flex flex-row gap-1"><Warning weight="fill"/>
|
||||
<p className="text-danger flex flex-row gap-1"><WarningIcon weight="fill"/>
|
||||
Scan failed (check logs for details)
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Autocomplete, AutocompleteItem} from "@heroui/react";
|
||||
import {CaretRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import { CaretRightIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
@@ -41,7 +41,7 @@ export default function SearchBar() {
|
||||
},
|
||||
}}
|
||||
placeholder="Type to search..."
|
||||
startContent={<MagnifyingGlass/>}
|
||||
startContent={<MagnifyingGlassIcon/>}
|
||||
isVirtualized={true}
|
||||
maxListboxHeight={300}
|
||||
itemHeight={91} // 75px (cover) + 16px (margin top/bottom) = 91px
|
||||
@@ -54,7 +54,7 @@ export default function SearchBar() {
|
||||
<p><b>{item.title}</b> ({item.release && new Date(item.release).getFullYear()})</p>
|
||||
<p className="text-default-500">{item.developers && [...item.developers].sort().join(" / ")}</p>
|
||||
</div>
|
||||
<CaretRight/>
|
||||
<CaretRightIcon/>
|
||||
</div>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {downloadSessionState} from "Frontend/state/DownloadSessionState";
|
||||
import {Card, Chip, Tooltip} from "@heroui/react";
|
||||
import {InfoIcon} from "@phosphor-icons/react";
|
||||
import {convertBpsToMbps, hslToHex, humanFileSize, timeUntil} from "Frontend/util/utils";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import RealtimeChart, {RealtimeChartData, RealtimeChartOptions} from "react-realtime-chart";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useNavigate} from "react-router";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
|
||||
export function DownloadSessionCard({sessionId}: { sessionId: string }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const session = useSnapshot(downloadSessionState).byId[sessionId];
|
||||
const games = useSnapshot(gameState).state;
|
||||
const libraries = useSnapshot(libraryState).state;
|
||||
|
||||
const [currentTime, setCurrentTime] = useState<Date>(new Date());
|
||||
const [chartData, setChartData] = useState<RealtimeChartData[][]>([]);
|
||||
const [foregroundColor, setForegroundColor] = useState<string>("#00F");
|
||||
|
||||
// Get theme colors from CSS variables
|
||||
useEffect(() => {
|
||||
const chartColor = window.getComputedStyle(document.body).getPropertyValue('--heroui-foreground');
|
||||
if (chartColor) {
|
||||
setForegroundColor(hslToHex(chartColor.trim()));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
const dataPoints: RealtimeChartData[] = session.bandwidthHistory.map((bps, idx) => {
|
||||
let date = new Date();
|
||||
date.setSeconds(currentTime.getSeconds() - session.bandwidthHistory.length + idx + 1);
|
||||
return {
|
||||
date: date,
|
||||
value: convertBpsToMbps(bps)
|
||||
};
|
||||
});
|
||||
setChartData([dataPoints]);
|
||||
}
|
||||
}, [currentTime]);
|
||||
|
||||
const chartOptions: RealtimeChartOptions = {
|
||||
fps: 60,
|
||||
timeSlots: 30,
|
||||
colors: [foregroundColor],
|
||||
margin: {left: 60},
|
||||
lines: [
|
||||
{
|
||||
area: true,
|
||||
areaColor: foregroundColor,
|
||||
areaOpacity: 0.03,
|
||||
lineWidth: 2,
|
||||
curve: "basis",
|
||||
},
|
||||
],
|
||||
yGrid: {
|
||||
min: 0,
|
||||
color: foregroundColor,
|
||||
opacity: 0.25,
|
||||
size: 1,
|
||||
tickNumber: 7,
|
||||
tickFormat: (v) => `${v}Mb/s`
|
||||
},
|
||||
xGrid: {
|
||||
color: foregroundColor,
|
||||
opacity: 0.25,
|
||||
size: 1,
|
||||
tickNumber: 5
|
||||
},
|
||||
};
|
||||
|
||||
return (session &&
|
||||
<Card
|
||||
className={`flex flex-col gap-2 m-0.5 p-4 border-2
|
||||
${(session.currentBytesPerSecond > 0) ? "border-primary bg-primary/10" : "border-default"}`}>
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="flex flex-row items-center flex-1">
|
||||
<b>User:</b>
|
||||
{session.username ?? "Anonymous User"}
|
||||
<Tooltip
|
||||
content={<pre>Session ID: {session.sessionId}</pre>}
|
||||
placement="right"
|
||||
>
|
||||
<InfoIcon size={18}/>
|
||||
</Tooltip>
|
||||
</p>
|
||||
<div className="flex-1 flex justify-center">Remote IP:
|
||||
{<Chip size="sm" radius="sm">
|
||||
<pre>{session.remoteIp}</pre>
|
||||
</Chip>}
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 flex justify-end">{session.activeGameIds.length > 0 ? "Session active since" : "Session inactive since"}
|
||||
{<Chip size="sm" radius="sm">
|
||||
{timeUntil(session.startTime, undefined, true)}
|
||||
</Chip>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Only render chart when downloads are active or have been active within the last minute */}
|
||||
{(session.activeGameIds.length > 0 || (currentTime.getTime() - new Date(session.startTime).getTime() < 60000)) &&
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex flex-row gap-2">
|
||||
Active downloads:
|
||||
{session.activeGameIds.length === 0 && <p>No active downloads</p>}
|
||||
{session.activeGameIds.map(gameId =>
|
||||
games[gameId] &&
|
||||
<Tooltip key={gameId}
|
||||
size="sm"
|
||||
content={`Size: ${humanFileSize(games[gameId].metadata.fileSize)} / Library: ${libraries[games[gameId].libraryId]?.name || "Unknown"}`}
|
||||
placement="bottom">
|
||||
<Chip size="sm" radius="sm"
|
||||
onClick={() => navigate(`/game/${gameId}`)}
|
||||
className="cursor-pointer"
|
||||
>{games[gameId].title}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full h-48">
|
||||
<RealtimeChart options={chartOptions} data={chartData}/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import {Button, Card, Chip, Tooltip} from "@heroui/react";
|
||||
import {Button, Card, Tooltip} from "@heroui/react";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import React from "react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import {MagnifyingGlass, MagnifyingGlassPlus, SlidersHorizontal} from "@phosphor-icons/react";
|
||||
import {MagnifyingGlassIcon, MagnifyingGlassPlusIcon, SlidersHorizontalIcon} from "@phosphor-icons/react";
|
||||
import ScanType from "Frontend/generated/org/gameyfin/app/libraries/enums/ScanType";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
import ChipList from "Frontend/components/general/ChipList";
|
||||
|
||||
interface LibraryOverviewCardProps {
|
||||
library: LibraryAdminDto;
|
||||
@@ -50,17 +51,17 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
<div className="absolute right-0 top-0 flex flex-row">
|
||||
<Tooltip content="Scan library (quick)" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.QUICK)}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Scan library (full)" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => triggerScan(ScanType.FULL)}>
|
||||
<MagnifyingGlassPlus/>
|
||||
<MagnifyingGlassPlusIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
|
||||
<SlidersHorizontal/>
|
||||
<SlidersHorizontalIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -73,7 +74,7 @@ export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
<p>Platforms</p>
|
||||
<p className="font-bold">{library.stats.gamesCount}</p>
|
||||
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
|
||||
<Chip size="sm">PC</Chip>
|
||||
<ChipList items={library.platforms} maxVisible={0} defaultContent="All"/>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {
|
||||
CheckCircle,
|
||||
IconContext,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
Power,
|
||||
Question,
|
||||
QuestionMark,
|
||||
SealCheck,
|
||||
SealQuestion,
|
||||
SealWarning,
|
||||
SlidersHorizontal,
|
||||
StopCircle,
|
||||
WarningCircle,
|
||||
XCircle
|
||||
} from "@phosphor-icons/react";
|
||||
import { CheckCircleIcon, IconContext, PauseCircleIcon, PlayCircleIcon, PowerIcon, QuestionIcon, QuestionMarkIcon, SealCheckIcon, SealQuestionIcon, SealWarningIcon, SlidersHorizontalIcon, StopCircleIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import PluginState from "Frontend/generated/org/pf4j/PluginState";
|
||||
import React, {ReactNode} from "react";
|
||||
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
|
||||
@@ -54,17 +39,17 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
function stateToIcon(state: PluginState | undefined): ReactNode {
|
||||
switch (state) {
|
||||
case PluginState.STARTED:
|
||||
return <PlayCircle/>;
|
||||
return <PlayCircleIcon/>;
|
||||
case PluginState.DISABLED:
|
||||
return <PauseCircle/>;
|
||||
return <PauseCircleIcon/>;
|
||||
case PluginState.STOPPED:
|
||||
case PluginState.FAILED:
|
||||
return <StopCircle/>;
|
||||
return <StopCircleIcon/>;
|
||||
case PluginState.UNLOADED:
|
||||
case PluginState.RESOLVED:
|
||||
return <XCircle/>;
|
||||
return <XCircleIcon/>;
|
||||
default:
|
||||
return <QuestionMark/>;
|
||||
return <QuestionMarkIcon/>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,19 +58,19 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
case PluginConfigValidationResultType.VALID:
|
||||
return <Tooltip content="Config valid" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs" color="success">
|
||||
<CheckCircle/>
|
||||
<CheckCircleIcon/>
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
case PluginConfigValidationResultType.INVALID:
|
||||
return <Tooltip content="Config invalid" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs" color="danger">
|
||||
<WarningCircle/>
|
||||
<WarningCircleIcon/>
|
||||
</Chip>
|
||||
</Tooltip>;
|
||||
default:
|
||||
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs">
|
||||
<Question/>
|
||||
<QuestionIcon/>
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
}
|
||||
@@ -95,23 +80,23 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
switch (trustLevel) {
|
||||
case PluginTrustLevel.OFFICIAL:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Official plugin">
|
||||
<SealCheck className="fill-success"/>
|
||||
<SealCheckIcon className="fill-success"/>
|
||||
</Tooltip>;
|
||||
case PluginTrustLevel.BUNDLED:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
|
||||
<SealCheck/>
|
||||
<SealCheckIcon/>
|
||||
</Tooltip>;
|
||||
case PluginTrustLevel.THIRD_PARTY:
|
||||
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
|
||||
<SealWarning/>
|
||||
<SealWarningIcon/>
|
||||
</Tooltip>;
|
||||
case PluginTrustLevel.UNTRUSTED:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
|
||||
<SealWarning className="fill-danger"/>
|
||||
<SealWarningIcon className="fill-danger"/>
|
||||
</Tooltip>;
|
||||
default:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
|
||||
<SealQuestion/>
|
||||
<SealQuestionIcon/>
|
||||
</Tooltip>;
|
||||
}
|
||||
}
|
||||
@@ -141,12 +126,12 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
onPress={() => togglePluginEnabled()}
|
||||
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
|
||||
>
|
||||
<Power/>
|
||||
<PowerIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}>
|
||||
<SlidersHorizontal/>
|
||||
<SlidersHorizontalIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {Button, Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react";
|
||||
import {DotsThreeVertical} from "@phosphor-icons/react";
|
||||
import {DotsThreeVerticalIcon} from "@phosphor-icons/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
|
||||
import Avatar from "Frontend/components/general/Avatar";
|
||||
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
|
||||
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
|
||||
import RoleChip from "Frontend/components/general/RoleChip";
|
||||
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
|
||||
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||
@@ -112,7 +112,7 @@ export function UserManagementCard({user}: { user: ExtendedUserInfoDto }) {
|
||||
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant="light">
|
||||
<DotsThreeVertical/>
|
||||
<DotsThreeVerticalIcon/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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";
|
||||
import {ArrowRightIcon} from "@phosphor-icons/react";
|
||||
|
||||
interface CoverRowProps {
|
||||
games: GameDto[];
|
||||
@@ -16,7 +15,6 @@ const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for
|
||||
|
||||
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(games.length);
|
||||
|
||||
@@ -55,12 +53,12 @@ export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
||||
<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
|
||||
bg-linear-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"/>
|
||||
<ArrowRightIcon weight="bold"/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function GameCover({game, size = 300, radius = "sm", interactive = false}
|
||||
<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]"
|
||||
className="z-0 object-cover aspect-12/17"
|
||||
src={`images/cover/${game.coverId}`}
|
||||
radius={radius}
|
||||
height={size}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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";
|
||||
import { CaretLeftIcon, CaretRightIcon, IconContext, PlayIcon } from "@phosphor-icons/react";
|
||||
|
||||
|
||||
interface ImageCarouselProps {
|
||||
@@ -61,7 +61,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
||||
<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"/>
|
||||
<CaretLeftIcon 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}
|
||||
@@ -90,14 +90,14 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
||||
<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" : ""}`}
|
||||
className={`w-full h-full object-cover aspect-video cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
|
||||
onClick={() => showImagePopup(e.url)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Card
|
||||
className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}>
|
||||
className={`w-full h-full aspect-video ${!isActive ? "scale-90" : ""}`}>
|
||||
<ReactPlayer
|
||||
url={e.url}
|
||||
width="100%"
|
||||
@@ -105,7 +105,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
||||
light={true}
|
||||
controls={true}
|
||||
playing={isActive}
|
||||
playIcon={<Play weight="fill"/>}
|
||||
playIcon={<PlayIcon weight="fill"/>}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
@@ -115,7 +115,7 @@ export default function ImageCarousel({imageUrls, videosUrls, className}: ImageC
|
||||
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
|
||||
onOpenChange={imagePopup.onOpenChange}/>
|
||||
</Swiper>
|
||||
<CaretRight className="swiper-custom-button-next cursor-pointer fill-primary"/>
|
||||
<CaretRightIcon className="swiper-custom-button-next cursor-pointer fill-primary"/>
|
||||
</IconContext.Provider>
|
||||
</div>
|
||||
<div>
|
||||
@@ -137,7 +137,7 @@ function ImagePopup({imageUrl, isOpen, onOpenChange}: {
|
||||
<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"
|
||||
<div className="flex grow items-center justify-center cursor-zoom-out"
|
||||
onClick={onClose}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {FieldArray, useField} from "formik";
|
||||
import {Button, Chip, Input, Popover, PopoverContent, PopoverTrigger} from "@heroui/react";
|
||||
import {KeyboardEvent, useState} from "react";
|
||||
import {Plus} from "@phosphor-icons/react";
|
||||
import { PlusIcon } from "@phosphor-icons/react";
|
||||
|
||||
// @ts-ignore
|
||||
const ArrayInput = ({label, ...props}) => {
|
||||
@@ -41,7 +41,7 @@ const ArrayInput = ({label, ...props}) => {
|
||||
))}
|
||||
<Popover placement="bottom" showArrow={true}>
|
||||
<PopoverTrigger>
|
||||
<Button isIconOnly size="sm" variant="light" radius="full"><Plus/></Button>
|
||||
<Button isIconOnly size="sm" variant="light" radius="full"><PlusIcon/></Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, {Key, useEffect, useState} from "react";
|
||||
import {Autocomplete, AutocompleteItem, Chip} from "@heroui/react";
|
||||
import {FieldArray, useField} from "formik";
|
||||
|
||||
type ArrayInputAutocompleteProps = {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
options: string[];
|
||||
name: string;
|
||||
defaultSelected?: string[];
|
||||
};
|
||||
|
||||
export default function ArrayInputAutocomplete({
|
||||
options,
|
||||
label,
|
||||
placeholder = "Search...",
|
||||
defaultSelected = [],
|
||||
...props
|
||||
}: ArrayInputAutocompleteProps) {
|
||||
const [field, meta, helpers] = useField(props);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Initialize field value if undefined or empty
|
||||
useEffect(() => {
|
||||
if (!field.value) {
|
||||
helpers.setValue(defaultSelected.length > 0 ? defaultSelected : []);
|
||||
} else if (field.value.length === 0 && defaultSelected.length > 0) {
|
||||
helpers.setValue(defaultSelected);
|
||||
}
|
||||
}, [defaultSelected, field.value, helpers]);
|
||||
|
||||
return (
|
||||
<FieldArray name={field.name}
|
||||
render={arrayHelpers => {
|
||||
const selectedValues = field.value || [];
|
||||
const filteredOptions = options.filter(
|
||||
(option) =>
|
||||
option.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!selectedValues.find((selected: string) => selected === option),
|
||||
);
|
||||
|
||||
const handleSelect = (item: string) => {
|
||||
if (!selectedValues.find((selected: string) => selected === item)) {
|
||||
arrayHelpers.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
arrayHelpers.remove(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 gap-2">
|
||||
{label && (
|
||||
<div className="flex flex-row justify-between">
|
||||
<p>{label}</p>
|
||||
<small>{selectedValues.length} {selectedValues.length === 1 ? "element" : "elements"} selected</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Autocomplete
|
||||
{...props}
|
||||
aria-labelledby="search"
|
||||
shouldCloseOnBlur={false}
|
||||
placeholder={placeholder}
|
||||
inputValue={search}
|
||||
onInputChange={(value) => setSearch(value)}
|
||||
onSelectionChange={(value: Key | null) => {
|
||||
const item = options.find((option) => option === value);
|
||||
if (item) handleSelect(item);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option) => (
|
||||
<AutocompleteItem key={option} data-selected="true">
|
||||
{option}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</Autocomplete>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedValues.map((item: string, index: number) => (
|
||||
<Chip key={index} variant="flat"
|
||||
onClose={() => handleRemove(index)}>
|
||||
{item}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-6 text-danger">
|
||||
{meta.touched && meta.error && meta.error.trim().length > 0 && (
|
||||
meta.error
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DropdownTrigger,
|
||||
SharedSelection
|
||||
} from "@heroui/react";
|
||||
import {CaretDown} from "@phosphor-icons/react";
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
|
||||
|
||||
export interface ComboButtonOption {
|
||||
@@ -52,7 +52,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
|
||||
}
|
||||
|
||||
return options[selectedOptionValue] && (
|
||||
<ButtonGroup className="gap-[1px]">
|
||||
<ButtonGroup className="gap-px">
|
||||
<Button color="primary" className="w-52"
|
||||
onPress={options[selectedOptionValue].action}>
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -63,7 +63,7 @@ export default function ComboButton({options, preferredOptionKey, description}:
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly color="primary">
|
||||
<CaretDown/>
|
||||
<CaretDownIcon/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function DatePickerInput({label, showErrorUntouched = false, ...p
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
className="min-h-20 flex-grow"
|
||||
className="min-h-20 grow"
|
||||
showMonthAndYearPickers
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import {Button, Code, useDisclosure} from "@heroui/react";
|
||||
import {ArrowRight, Minus, Plus, XCircle} from "@phosphor-icons/react";
|
||||
import { ArrowRightIcon, MinusIcon, PlusIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
|
||||
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
|
||||
@@ -28,7 +28,7 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
|
||||
<p className="font-bold">Directories</p>
|
||||
<Button isIconOnly variant="light" size="sm" color="default"
|
||||
onPress={pathPickerModal.onOpen}>
|
||||
<Plus/>
|
||||
<PlusIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
{(field.value || []).map((directory) => (
|
||||
@@ -43,8 +43,8 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
|
||||
/>
|
||||
{directory.externalPath && (
|
||||
<>
|
||||
<div className="flex-shrink-0 flex items-center justify-center mx-2">
|
||||
<ArrowRight size={20}/>
|
||||
<div className="shrink-0 flex items-center justify-center mx-2">
|
||||
<ArrowRightIcon size={20}/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
@@ -62,13 +62,13 @@ export default function DirectoryMappingInput({name}: DirectoryMappingInputProps
|
||||
onPress={() => removeDirectoryMapping(directory)}
|
||||
className="ml-2"
|
||||
>
|
||||
<Minus/>
|
||||
<MinusIcon/>
|
||||
</Button>
|
||||
</Code>
|
||||
))}
|
||||
<div className="min-h-6 text-danger">
|
||||
{meta.touched && meta.error && (
|
||||
<SmallInfoField icon={XCircle} message={meta.error}/>
|
||||
<SmallInfoField icon={XCircleIcon} message={meta.error}/>
|
||||
)}
|
||||
</div>
|
||||
<PathPickerModal returnSelectedPath={addDirectoryMapping}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import TreeView, {flattenTree, INode, NodeId} from "react-accessible-treeview";
|
||||
import {File, Folder, FolderOpen, IconContext} from "@phosphor-icons/react";
|
||||
import {
|
||||
FileIcon as PhFileIcon,
|
||||
FolderIcon as PhFolderIcon,
|
||||
FolderOpenIcon as PhFolderOpenIcon,
|
||||
IconContext
|
||||
} from "@phosphor-icons/react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {FilesystemEndpoint} from "Frontend/generated/endpoints";
|
||||
import FileDto from "Frontend/generated/org/gameyfin/app/core/filesystem/FileDto";
|
||||
@@ -146,9 +151,9 @@ export default function FileTreeView({onPathChange}: { onPathChange: (file: stri
|
||||
}
|
||||
|
||||
function FolderIcon({isOpen}: { isOpen: boolean }) {
|
||||
return isOpen ? <FolderOpen/> : <Folder/>;
|
||||
return isOpen ? <PhFolderOpenIcon/> : <PhFolderIcon/>;
|
||||
}
|
||||
|
||||
function FileIcon({fileName}: { fileName: string }) {
|
||||
return <File/>;
|
||||
return <PhFileIcon/>;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {Image, useDisclosure} from "@heroui/react";
|
||||
import React from "react";
|
||||
import {useField} from "formik";
|
||||
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
||||
import {ImageBroken, Pencil} from "@phosphor-icons/react";
|
||||
import { ImageBrokenIcon, PencilIcon } from "@phosphor-icons/react";
|
||||
|
||||
|
||||
// @ts-ignore
|
||||
@@ -14,13 +14,13 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
|
||||
const gameCoverPickerModal = useDisclosure();
|
||||
|
||||
return (<>
|
||||
<div className="relative group aspect-[12/17] cursor-pointer bg-background/50"
|
||||
<div className="relative group aspect-12/17 cursor-pointer bg-background/50"
|
||||
onClick={gameCoverPickerModal.onOpenChange}>
|
||||
{field.value || game.coverId ?
|
||||
<div className="size-full overflow-hidden">
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover group-hover:brightness-[25%]"
|
||||
className="z-0 object-cover group-hover:brightness-25"
|
||||
src={field.value ? field.value : `images/cover/${game.coverId}`}
|
||||
{...props}
|
||||
{...field}
|
||||
@@ -30,13 +30,13 @@ export default function GameCoverPicker({game, showErrorUntouched = false, ...pr
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
|
||||
>
|
||||
<ImageBroken size={46}/>
|
||||
<ImageBrokenIcon size={46}/>
|
||||
<p>No cover image available</p>
|
||||
</div>}
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Pencil size={46}/>
|
||||
<PencilIcon size={46}/>
|
||||
<p>Edit cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Image, useDisclosure} from "@heroui/react";
|
||||
import React from "react";
|
||||
import {useField} from "formik";
|
||||
import {ImageBroken, Pencil} from "@phosphor-icons/react";
|
||||
import {ImageBrokenIcon, PencilIcon} from "@phosphor-icons/react";
|
||||
import {GameHeaderPickerModal} from "Frontend/components/general/modals/GameHeaderPickerModal";
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
|
||||
<div className="size-full overflow-hidden">
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover group-hover:brightness-[25%]"
|
||||
className="z-0 object-cover group-hover:brightness-25"
|
||||
src={field.value ? field.value : `images/cover/${game.headerId}`}
|
||||
{...props}
|
||||
{...field}
|
||||
@@ -30,13 +30,13 @@ export default function GameHeaderPicker({game, showErrorUntouched = false, ...p
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col text-center items-center justify-center group-hover:opacity-0"
|
||||
>
|
||||
<ImageBroken size={46}/>
|
||||
<ImageBrokenIcon size={46}/>
|
||||
<p>No header image available</p>
|
||||
</div>}
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col gap-2 text-center items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Pencil size={46}/>
|
||||
<PencilIcon size={46}/>
|
||||
<p>Edit header image</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useField} from "formik";
|
||||
import {Input as NextUiInput} from "@heroui/react";
|
||||
import {Input as HeroUiInput} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const Input = ({label, showErrorUntouched = false, ...props}) => {
|
||||
@@ -7,8 +7,8 @@ const Input = ({label, showErrorUntouched = false, ...props}) => {
|
||||
const [field, meta] = useField(props);
|
||||
|
||||
return (
|
||||
<NextUiInput
|
||||
className="min-h-20 flex-grow"
|
||||
<HeroUiInput
|
||||
className="min-h-20 grow"
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useField} from "formik";
|
||||
import {NumberInput as HeroUiNumberInput} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const NumberInput = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta, helpers] = useField(props);
|
||||
|
||||
return (
|
||||
<HeroUiNumberInput
|
||||
className="min-h-20 grow"
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
value={field.value}
|
||||
onValueChange={(value) => helpers.setValue(value)}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
id={label}
|
||||
label={label}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberInput;
|
||||
@@ -9,7 +9,7 @@ const SelectInput = ({label, values, ...props}) => {
|
||||
const items = values.map((v: string) => ({key: v, label: v}));
|
||||
|
||||
return (
|
||||
<div className="min-h-20 flex-grow">
|
||||
<div className="min-h-20 grow">
|
||||
<Select
|
||||
fullWidth={true}
|
||||
{...field}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useField} from "formik";
|
||||
import {Slider as HeroUiSlider} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const SliderInput = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta, helpers] = useField(props);
|
||||
|
||||
return (
|
||||
<HeroUiSlider
|
||||
className="min-h-20 grow"
|
||||
{...props}
|
||||
value={field.value}
|
||||
onChange={(value) => helpers.setValue(value)}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
id={label}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SliderInput;
|
||||
@@ -8,7 +8,7 @@ export default function TextAreaInput({label, showErrorUntouched = false, ...pro
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
||||
className={`grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import {Check} from "@phosphor-icons/react";
|
||||
import {CheckIcon} from "@phosphor-icons/react";
|
||||
import {addToast, Button} from "@heroui/react";
|
||||
import React from "react";
|
||||
import {Form, Formik} from "formik";
|
||||
@@ -11,6 +11,9 @@ import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMa
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import {useNavigate} from "react-router";
|
||||
import * as Yup from "yup";
|
||||
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
|
||||
interface LibraryManagementDetailsProps {
|
||||
library: LibraryDto;
|
||||
@@ -19,6 +22,7 @@ interface LibraryManagementDetailsProps {
|
||||
export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) {
|
||||
const navigate = useNavigate();
|
||||
const [librarySaved, setLibrarySaved] = React.useState(false);
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
|
||||
async function handleSubmit(values: LibraryDto): Promise<void> {
|
||||
const changed = deepDiff(library, values) as LibraryUpdateDto;
|
||||
@@ -66,7 +70,7 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
|
||||
>
|
||||
{(formik) => (
|
||||
<Form>
|
||||
<div className="flex flex-row flex-grow justify-between mb-4">
|
||||
<div className="flex flex-row grow justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Edit library details</h1>
|
||||
<Button
|
||||
color="primary"
|
||||
@@ -74,12 +78,14 @@ export default function LibraryManagementDetails({library}: LibraryManagementDet
|
||||
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : librarySaved ? <Check/> : "Save"}
|
||||
{formik.isSubmitting ? "" : librarySaved ? <CheckIcon/> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input label="Library name" name="name"/>
|
||||
|
||||
<ArrayInputAutocomplete options={Array.from(availablePlatforms)} name="platforms" label="Platforms"/>
|
||||
|
||||
<DirectoryMappingInput name="directories"/>
|
||||
|
||||
<Section title="Danger zone"/>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Tooltip,
|
||||
useDisclosure
|
||||
} from "@heroui/react";
|
||||
import {CheckCircle, MagnifyingGlass, Pencil, Trash} from "@phosphor-icons/react";
|
||||
import {CheckCircleIcon, MagnifyingGlassIcon, PencilIcon, TrashIcon} from "@phosphor-icons/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
@@ -28,6 +28,7 @@ import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||
import {GameAdminDto} from "Frontend/dtos/GameDtos";
|
||||
import MetadataCompletenessIndicator from "Frontend/components/general/MetadataCompletenessIndicator";
|
||||
import {metadataCompleteness} from "Frontend/util/utils";
|
||||
import ChipList from "Frontend/components/general/ChipList";
|
||||
|
||||
interface LibraryManagementGamesProps {
|
||||
library: LibraryDto;
|
||||
@@ -162,6 +163,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
}>
|
||||
<TableHeader>
|
||||
<TableColumn key="title" allowsSorting>Game</TableColumn>
|
||||
<TableColumn key="platforms">Platforms</TableColumn>
|
||||
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
|
||||
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
|
||||
<TableColumn>Path</TableColumn>
|
||||
@@ -179,6 +181,9 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
underline="hover">{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ChipList items={item.platforms} maxVisible={1} defaultContent="Unspecified"/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
@@ -196,10 +201,10 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
|
||||
{item.metadata.matchConfirmed ?
|
||||
<Tooltip content="Unconfirm match">
|
||||
<CheckCircle weight="fill" className="fill-success"/>
|
||||
<CheckCircleIcon weight="fill" className="fill-success"/>
|
||||
</Tooltip> :
|
||||
<Tooltip content="Confirm match">
|
||||
<CheckCircle/>
|
||||
<CheckCircleIcon/>
|
||||
</Tooltip>}
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" onPress={() => {
|
||||
@@ -207,7 +212,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
editGameModal.onOpenChange();
|
||||
}}>
|
||||
<Tooltip content="Edit metadata">
|
||||
<Pencil/>
|
||||
<PencilIcon/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" onPress={() => {
|
||||
@@ -215,13 +220,13 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
|
||||
matchGameModal.onOpenChange();
|
||||
}}>
|
||||
<Tooltip content="Match game">
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" color="danger"
|
||||
onPress={() => deleteGame(item)}>
|
||||
<Tooltip content="Remove from library">
|
||||
<Trash/>
|
||||
<TrashIcon/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+57
-21
@@ -12,35 +12,45 @@ import {
|
||||
Tooltip,
|
||||
useDisclosure
|
||||
} from "@heroui/react";
|
||||
import {MagnifyingGlass, Trash} from "@phosphor-icons/react";
|
||||
import {MagnifyingGlassIcon, TrashIcon} from "@phosphor-icons/react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import {useMemo, useState} from "react";
|
||||
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
|
||||
import {fileNameFromPath, hashCode} from "Frontend/util/utils";
|
||||
import {fileNameFromPath} from "Frontend/util/utils";
|
||||
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
import IgnoredPathDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathDto";
|
||||
import IgnoredPathSourceTypeDto from "Frontend/generated/org/gameyfin/app/libraries/dto/IgnoredPathSourceTypeDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import {userState} from "Frontend/state/UserState";
|
||||
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface LibraryManagementUnmatchedPathsProps {
|
||||
interface LibraryManagementIgnoredPathsProps {
|
||||
library: LibraryAdminDto;
|
||||
}
|
||||
|
||||
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
|
||||
export default function LibraryManagementIgnoredPaths({library}: LibraryManagementIgnoredPathsProps) {
|
||||
const plugins = useSnapshot(pluginState).state;
|
||||
const users = useSnapshot(userState).state;
|
||||
|
||||
const matchGameModal = useDisclosure();
|
||||
const [page, setPage] = useState(1);
|
||||
const rowsPerPage = 25;
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedPath, setSelectedPath] = useState(library.unmatchedPaths ? library.unmatchedPaths[0] : null);
|
||||
const [selectedPath, setSelectedPath] = useState(library.ignoredPaths ? library.ignoredPaths[0] : null);
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
|
||||
|
||||
const pages = useMemo(() => {
|
||||
return Math.ceil(getFilteredPaths().length / rowsPerPage);
|
||||
}, [library.unmatchedPaths, searchTerm]);
|
||||
}, [library.ignoredPaths, searchTerm]);
|
||||
|
||||
const filteredPaths = useMemo(() => {
|
||||
return library.unmatchedPaths!
|
||||
.filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.map((path) => ({key: hashCode(path), path}));
|
||||
return library.ignoredPaths!
|
||||
.filter((path) => path.path.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.map((path) => ({key: path.id, path}));
|
||||
}, [library, searchTerm]);
|
||||
|
||||
const sortedPaths = useMemo(() => {
|
||||
@@ -48,7 +58,7 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
let cmp: number;
|
||||
switch (sortDescriptor.column) {
|
||||
case "path":
|
||||
cmp = a.path.localeCompare(b.path);
|
||||
cmp = a.path.path.localeCompare(b.path.path);
|
||||
break;
|
||||
default:
|
||||
cmp = 0;
|
||||
@@ -66,22 +76,44 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
return sortedPaths.slice(start, end);
|
||||
}, [page, sortedPaths]);
|
||||
|
||||
async function deleteUnmatchedPath(unmatchedPath: string) {
|
||||
async function deleteIgnoredPath(ignoredPath: IgnoredPathDto) {
|
||||
const libraryUpdateDto: LibraryUpdateDto = {
|
||||
id: library.id,
|
||||
unmatchedPaths: library.unmatchedPaths!.filter((path) => path !== unmatchedPath)
|
||||
ignoredPaths: library.ignoredPaths!.filter((path) => path.id !== ignoredPath.id)
|
||||
}
|
||||
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
|
||||
}
|
||||
|
||||
function getFilteredPaths() {
|
||||
return library.unmatchedPaths!!.filter((path) =>
|
||||
path.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
return library.ignoredPaths!!.filter((path) =>
|
||||
path.path.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
function renderSource(ignoredPath: IgnoredPathDto) {
|
||||
if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.USER) {
|
||||
const userId = Number(ignoredPath.source);
|
||||
const user = users[userId];
|
||||
return user ? `Manually added by user (${user.username})` : "Unknown user";
|
||||
} else if (ignoredPath.sourceType === IgnoredPathSourceTypeDto.PLUGIN) {
|
||||
const pluginIds: string[] = JSON.parse(ignoredPath.source)
|
||||
return pluginIds ?
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<p>Automatically added by plugins (</p>
|
||||
{pluginIds.map(id => {
|
||||
const p = plugins[id];
|
||||
return p ? <PluginIcon key={id} plugin={p as PluginDto}/>
|
||||
: "Unknown plugin";
|
||||
})}
|
||||
<p>)</p>
|
||||
</div>
|
||||
: "Unknown plugins"
|
||||
}
|
||||
return ignoredPath.source;
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-bold">Manage unmatched paths</h1>
|
||||
<h1 className="text-2xl font-bold">Manage ignored paths</h1>
|
||||
<Input
|
||||
className="w-96"
|
||||
isClearable
|
||||
@@ -109,13 +141,17 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
}>
|
||||
<TableHeader>
|
||||
<TableColumn key="path" allowsSorting>Path</TableColumn>
|
||||
<TableColumn key="source">Source</TableColumn>
|
||||
<TableColumn width={1}>Actions</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="This library has no unmatched paths." items={pagedPaths}>
|
||||
<TableBody emptyContent="This library has no ignored paths." items={pagedPaths}>
|
||||
{(item) => (
|
||||
<TableRow key={item.key}>
|
||||
<TableCell>
|
||||
{item.path}
|
||||
{item.path.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{renderSource(item.path)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row gap-2">
|
||||
@@ -124,12 +160,12 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
setSelectedPath(item.path);
|
||||
matchGameModal.onOpenChange();
|
||||
}}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove entry from list">
|
||||
<Button isIconOnly size="sm" color="danger"
|
||||
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
|
||||
onPress={() => deleteIgnoredPath(item.path)}><TrashIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -138,9 +174,9 @@ export default function LibraryManagementUnmatchedPaths({library}: LibraryManage
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{selectedPath && <MatchGameModal path={selectedPath}
|
||||
{selectedPath && <MatchGameModal path={selectedPath.path}
|
||||
libraryId={library.id}
|
||||
initialSearchTerm={fileNameFromPath(selectedPath, false)}
|
||||
initialSearchTerm={fileNameFromPath(selectedPath.path, false)}
|
||||
isOpen={matchGameModal.isOpen}
|
||||
onOpenChange={matchGameModal.onOpenChange}/>
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export default function AssignRolesModal({isOpen, onOpenChange, user}: AssignRol
|
||||
placeholder="Select roles"
|
||||
renderValue={(items: SelectedItems<Role>) => {
|
||||
return (
|
||||
<div className="flex flex-grow flex-wrap gap-2">
|
||||
<div className="flex grow flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<RoleChip key={item.key} role={item.textValue as string}/>
|
||||
))}
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import React from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
|
||||
import GameEnumPropertyValuesDto from "Frontend/generated/org/gameyfin/app/games/dto/GameEnumPropertyValuesDto";
|
||||
import {deepDiff} from "Frontend/util/utils";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
|
||||
@@ -21,6 +22,9 @@ import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
|
||||
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
|
||||
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
||||
import GameHeaderPicker from "Frontend/components/general/input/GameHeaderPicker";
|
||||
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
|
||||
interface EditGameMetadataModalProps {
|
||||
game: GameDto;
|
||||
@@ -29,7 +33,14 @@ interface EditGameMetadataModalProps {
|
||||
}
|
||||
|
||||
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
|
||||
return (
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
const [propertyEnumValues, setPropertyEnumValues] = useState<GameEnumPropertyValuesDto>();
|
||||
|
||||
useEffect(() => {
|
||||
GameEndpoint.getEnumPropertyValues().then(setPropertyEnumValues);
|
||||
}, []);
|
||||
|
||||
return propertyEnumValues && (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
|
||||
<ModalContent>
|
||||
{(onClose) => {
|
||||
@@ -69,6 +80,8 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
||||
<DatePickerInput key="release" name="release" label="Release"
|
||||
className="w-fit"/>
|
||||
</div>
|
||||
<ArrayInputAutocomplete options={Array.from(availablePlatforms)}
|
||||
name="platforms" label="Platforms"/>
|
||||
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
|
||||
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
|
||||
<Accordion variant="splitted"
|
||||
@@ -81,14 +94,21 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
|
||||
title="Additional Metadata">
|
||||
<ArrayInput key="developers" name="developers" label="Developers"/>
|
||||
<ArrayInput key="publishers" name="publishers" label="Publishers"/>
|
||||
<ArrayInput key="genres" name="genres" label="Genres"/>
|
||||
<ArrayInput key="themes" name="themes" label="Themes"/>
|
||||
<ArrayInputAutocomplete options={propertyEnumValues.genres}
|
||||
defaultSelected={game.genres}
|
||||
key="genres" name="genres" label="Genres"/>
|
||||
<ArrayInputAutocomplete options={propertyEnumValues.themes}
|
||||
defaultSelected={game.themes}
|
||||
key="themes" name="themes" label="Themes"/>
|
||||
<ArrayInputAutocomplete options={propertyEnumValues.features}
|
||||
defaultSelected={game.features}
|
||||
key="features" name="features"
|
||||
label="Features"/>
|
||||
<ArrayInputAutocomplete options={propertyEnumValues.perspectives}
|
||||
defaultSelected={game.perspectives}
|
||||
key="perspectives" name="perspectives"
|
||||
label="Perspectives"/>
|
||||
<ArrayInput key="keywords" name="keywords" label="Keywords"/>
|
||||
<ArrayInput key="features" name="features" label="Features"/>
|
||||
<ArrayInput key="perspectives" name="perspectives"
|
||||
label="Perspectives"/>
|
||||
<ArrayInput key="keywords" name="keywords"
|
||||
label="Keywords"/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</ModalBody>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
|
||||
import React, {useEffect, useState} from "react";
|
||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
@@ -33,7 +33,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, game.platforms);
|
||||
let validResults = results.filter(result => result.coverUrls && result.coverUrls.length > 0);
|
||||
setSearchResults(validResults);
|
||||
setIsSearching(false);
|
||||
@@ -59,7 +59,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
setCoverUrl(coverUrl);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
@@ -74,7 +74,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
{searchResults.length === 0 && !isSearching &&
|
||||
@@ -103,7 +103,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
>
|
||||
<Image
|
||||
alt={cover.title}
|
||||
className="z-0 object-cover aspect-[12/17] group-hover:brightness-[25%]"
|
||||
className="z-0 object-cover aspect-12/17 group-hover:brightness-25"
|
||||
src={cover.url}
|
||||
radius="none"
|
||||
height={216}
|
||||
@@ -113,7 +113,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
|
||||
<PluginIcon plugin={state[cover.source] as PluginDto} size={32}
|
||||
blurred={false} showTooltip={false}/>
|
||||
<p className="text-s text-center">{cover.title}</p>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, Scrol
|
||||
import React, {useEffect, useState} from "react";
|
||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import PluginIcon from "Frontend/components/general/plugin/PluginIcon";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
@@ -33,7 +33,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, game.platforms);
|
||||
let validResults = results.filter(result => result.headerUrls && result.headerUrls.length > 0);
|
||||
setSearchResults(validResults);
|
||||
setIsSearching(false);
|
||||
@@ -59,7 +59,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
setHeaderUrl(headerUrl);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
@@ -74,7 +74,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
{searchResults.length === 0 && !isSearching &&
|
||||
@@ -103,7 +103,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
>
|
||||
<Image
|
||||
alt={header.title}
|
||||
className="z-0 object-cover group-hover:brightness-[25%]"
|
||||
className="z-0 object-cover group-hover:brightness-25"
|
||||
src={header.url}
|
||||
radius="none"
|
||||
/>
|
||||
@@ -112,7 +112,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
|
||||
<PluginIcon plugin={state[header.source] as PluginDto} size={32}
|
||||
blurred={false} showTooltip={false}/>
|
||||
<p className="text-s text-center">{header.title}</p>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
|
||||
import {MessageEndpoint, RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
|
||||
import {Form, Formik, FormikErrors} from "formik";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import * as Yup from "yup";
|
||||
|
||||
@@ -7,21 +7,22 @@ import Input from "Frontend/components/general/input/Input";
|
||||
import * as Yup from "yup";
|
||||
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
import ArrayInputAutocomplete from "Frontend/components/general/input/ArrayInputAutocomplete";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
|
||||
interface LibraryCreationModalProps {
|
||||
libraries: LibraryDto[];
|
||||
setLibraries: (libraries: LibraryDto[]) => void;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function LibraryCreationModal({
|
||||
libraries,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: LibraryCreationModalProps) {
|
||||
|
||||
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
|
||||
async function createLibrary(library: LibraryDto) {
|
||||
await LibraryEndpoint.createLibrary(library as LibraryAdminDto, scanAfterCreation);
|
||||
@@ -33,12 +34,12 @@ export default function LibraryCreationModal({
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
return (availablePlatforms &&
|
||||
<>
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik initialValues={{name: "", directories: []}}
|
||||
<Formik initialValues={{name: "", directories: [], platforms: []}}
|
||||
validationSchema={Yup.object({
|
||||
name: Yup.string()
|
||||
.required("Library name is required")
|
||||
@@ -65,6 +66,11 @@ export default function LibraryCreationModal({
|
||||
value={formik.values.name}
|
||||
required
|
||||
/>
|
||||
<ArrayInputAutocomplete options={Array.from(availablePlatforms)}
|
||||
name="platforms"
|
||||
label="Platforms"
|
||||
placeholder="Platform(s) of the games in this library (leave empty for all platforms)"
|
||||
/>
|
||||
<DirectoryMappingInput name="directories"/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -13,13 +13,15 @@ import {
|
||||
Tooltip
|
||||
} from "@heroui/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||
import PluginIcon from "../plugin/PluginIcon";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import {libraryState} from "Frontend/state/LibraryState";
|
||||
import LibraryAdminDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryAdminDto";
|
||||
|
||||
interface MatchGameModalProps {
|
||||
path: string;
|
||||
@@ -44,6 +46,7 @@ export default function MatchGameModal({
|
||||
const [isMatching, setIsMatching] = useState<string | null>(null);
|
||||
|
||||
const state = useSnapshot(pluginState).state;
|
||||
const librariesState = useSnapshot(libraryState).state;
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(initialSearchTerm);
|
||||
@@ -56,7 +59,7 @@ export default function MatchGameModal({
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, (librariesState[libraryId] as LibraryAdminDto).platforms);
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
}
|
||||
@@ -84,7 +87,7 @@ export default function MatchGameModal({
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +144,7 @@ export default function MatchGameModal({
|
||||
setIsMatching(null);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {Input as NextInput} from "@heroui/input";
|
||||
import {WarningCircle} from "@phosphor-icons/react";
|
||||
import { WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
|
||||
interface PasswordResetModalProps {
|
||||
@@ -47,7 +47,7 @@ export default function PasswordResetModal({
|
||||
placeholder="Email"
|
||||
/> :
|
||||
<div className="flex flex-row items-center gap-4 text-warning">
|
||||
<WarningCircle size={40}/>
|
||||
<WarningCircleIcon size={40}/>
|
||||
<p>
|
||||
Password self-service is disabled.<br/>
|
||||
To reset your password please contact your administrator.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@heroui/react";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
|
||||
import TokenDto from "Frontend/generated/org/gameyfin/app/core/token/TokenDto";
|
||||
import {timeUntil} from "Frontend/util/utils";
|
||||
|
||||
interface PasswordResetTokenModalProps {
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, {useEffect, useState} from "react";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import FileTreeView from "Frontend/components/general/input/FileTreeView";
|
||||
import DirectoryMappingDto from "Frontend/generated/org/gameyfin/app/libraries/dto/DirectoryMappingDto";
|
||||
import {ArrowRight} from "@phosphor-icons/react";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface PathPickerModalProps {
|
||||
returnSelectedPath: (path: DirectoryMappingDto) => void;
|
||||
@@ -45,7 +45,7 @@ export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChang
|
||||
isDisabled
|
||||
required
|
||||
/>
|
||||
<ArrowRight className="mb-8"/>
|
||||
<ArrowRightIcon className="mb-8"/>
|
||||
<Input
|
||||
name="externalPath"
|
||||
label="External path (optional)"
|
||||
|
||||
@@ -6,7 +6,7 @@ import Markdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import {ArrowClockwise} from "@phosphor-icons/react";
|
||||
import { ArrowClockwiseIcon } from "@phosphor-icons/react";
|
||||
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
|
||||
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField";
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
|
||||
}
|
||||
setTimeout(() => setConfigValidated(ValidationState.UNCHECKED), 5000);
|
||||
}}>
|
||||
<ArrowClockwise/>
|
||||
<ArrowClockwiseIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import {addToast, Button, Chip, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {ListBox, ListBoxItem, useDragAndDrop} from "react-aria-components";
|
||||
import {CaretUpDown} from "@phosphor-icons/react";
|
||||
import { CaretUpDownIcon } from "@phosphor-icons/react";
|
||||
import {useListData} from "@react-stately/data";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
@@ -91,7 +91,7 @@ export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: P
|
||||
</Chip>
|
||||
<p className="font-normal text-small">{plugin.name}</p>
|
||||
</div>
|
||||
<CaretUpDown/>
|
||||
<CaretUpDownIcon/>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
@@ -101,7 +101,7 @@ export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: P
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="success" onPress={() => setPluginPriorities(onClose)}>
|
||||
<Button color="primary" onPress={() => setPluginPriorities(onClose)}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
addToast,
|
||||
Autocomplete,
|
||||
AutocompleteItem,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
Tooltip
|
||||
} from "@heroui/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
|
||||
import {ArrowRightIcon, MagnifyingGlassIcon} from "@phosphor-icons/react";
|
||||
import {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
|
||||
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
|
||||
import PluginIcon from "../plugin/PluginIcon";
|
||||
@@ -22,22 +24,29 @@ import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto";
|
||||
import Platform from "Frontend/generated/org/gameyfin/pluginapi/gamemetadata/Platform";
|
||||
import {platformState} from "Frontend/state/PlatformState";
|
||||
|
||||
interface RequestGameModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
// TODO: Maybe make this configurable in the admin settings?
|
||||
const DEFAULT_PLATFORM_FOR_NEW_REQUESTS = "PC (Microsoft Windows)";
|
||||
|
||||
export default function RequestGameModal({
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: RequestGameModalProps) {
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string>(DEFAULT_PLATFORM_FOR_NEW_REQUESTS);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isRequesting, setIsRequesting] = useState<string | null>(null);
|
||||
|
||||
const plugins = useSnapshot(pluginState).state;
|
||||
const availablePlatforms = useSnapshot(platformState).available;
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm("");
|
||||
@@ -47,7 +56,9 @@ export default function RequestGameModal({
|
||||
async function requestGame(game: GameSearchResultDto) {
|
||||
const request: GameRequestCreationDto = {
|
||||
title: game.title,
|
||||
release: game.release
|
||||
release: game.release,
|
||||
// Since we can only request for one platform at a time, just pick the first one
|
||||
platform: game.platforms ? game.platforms[0] : DEFAULT_PLATFORM_FOR_NEW_REQUESTS as Platform
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -66,7 +77,7 @@ export default function RequestGameModal({
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, [selectedPlatform] as Platform[]);
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
}
|
||||
@@ -83,6 +94,18 @@ export default function RequestGameModal({
|
||||
<div className="flex flex-col items-center">
|
||||
<h2 className="text-xl font-semibold">Request a game</h2>
|
||||
</div>
|
||||
<Autocomplete
|
||||
label="Platform"
|
||||
size="sm"
|
||||
allowsCustomValue={false}
|
||||
selectedKey={selectedPlatform}
|
||||
//@ts-ignore
|
||||
onSelectionChange={(newSelection) => newSelection && setSelectedPlatform(newSelection)}
|
||||
>
|
||||
{Array.from(availablePlatforms).map((platform) => (
|
||||
<AutocompleteItem key={platform}>{platform}</AutocompleteItem>
|
||||
))}
|
||||
</Autocomplete>
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
<Input value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
@@ -93,8 +116,11 @@ export default function RequestGameModal({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
<Button isIconOnly
|
||||
color="primary"
|
||||
onPress={search}
|
||||
isLoading={isSearching}>
|
||||
<MagnifyingGlassIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +177,7 @@ export default function RequestGameModal({
|
||||
setIsRequesting(null);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
<ArrowRightIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Image, Tooltip} from "@heroui/react";
|
||||
import {Plug} from "@phosphor-icons/react";
|
||||
import { PlugIcon } from "@phosphor-icons/react";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface PluginIconProps {
|
||||
@@ -18,7 +18,7 @@ export default function PluginIcon({
|
||||
const icon = plugin.hasLogo
|
||||
?
|
||||
<Image isBlurred={blurred} src={`/images/plugins/${plugin.id}/logo`} width={size} height={size} radius="none"/>
|
||||
: <Plug size={size} weight="fill"/>;
|
||||
: <PlugIcon size={size} weight="fill"/>;
|
||||
|
||||
return showTooltip
|
||||
? <Tooltip content={plugin.name}>{icon}</Tooltip>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Button, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {ListNumbers} from "@phosphor-icons/react";
|
||||
import { ListNumbersIcon } from "@phosphor-icons/react";
|
||||
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
|
||||
import React from "react";
|
||||
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
|
||||
@@ -16,7 +16,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row flex-grow justify-between">
|
||||
<div className="flex flex-row grow justify-between">
|
||||
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
|
||||
|
||||
<Tooltip color="foreground" placement="left" content="Change plugin order">
|
||||
@@ -24,7 +24,7 @@ export function PluginManagementSection({type, plugins = []}: PluginManagementSe
|
||||
variant="flat"
|
||||
onPress={pluginPrioritiesModal.onOpen}
|
||||
isDisabled={plugins.length === 0}>
|
||||
<ListNumbers/>
|
||||
<ListNumbersIcon/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user