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:
Simon
2025-11-17 08:45:39 +01:00
committed by GitHub
parent dd3b18e5e3
commit 717a423449
357 changed files with 39213 additions and 7918 deletions
@@ -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>&nbsp;
{session.username ?? "Anonymous User"}&nbsp;
<Tooltip
content={<pre>Session ID: {session.sessionId}</pre>}
placement="right"
>
<InfoIcon size={18}/>
</Tooltip>
</p>
<div className="flex-1 flex justify-center">Remote IP:&nbsp;
{<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"}&nbsp;
{<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>
@@ -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>