mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 08:15:37 +00:00
Move package "de.grimsi.gameyfin" to "org.gameyfin"
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {Avatar as NextUiAvatar} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const Avatar = ({...props}) => {
|
||||
const auth = useAuth();
|
||||
const username = getUsername();
|
||||
|
||||
function getUsername() {
|
||||
if (props.username === undefined || props.username === null || props.username == "") {
|
||||
return auth.state.user?.username;
|
||||
}
|
||||
|
||||
return props.username;
|
||||
}
|
||||
|
||||
// TODO: Check if avatar can be loaded from SSO
|
||||
return (
|
||||
<NextUiAvatar
|
||||
showFallback
|
||||
src={`/images/avatar?username=${username}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Avatar;
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Alien,
|
||||
CastleTurret,
|
||||
GameController,
|
||||
Ghost,
|
||||
Joystick,
|
||||
Lego,
|
||||
Skull,
|
||||
SoccerBall,
|
||||
Strategy,
|
||||
Sword,
|
||||
TreasureChest,
|
||||
Trophy
|
||||
} from "@phosphor-icons/react";
|
||||
import React from "react";
|
||||
|
||||
export default function IconBackgroundPattern() {
|
||||
return <div className="absolute w-full h-full opacity-50">
|
||||
<GameController size={32} className="absolute fill-primary top-[10%] left-[10%] rotate-[350deg]"/>
|
||||
<SoccerBall size={34} className="absolute fill-primary top-[50%] left-[35%] rotate-[60deg]"/>
|
||||
<Joystick size={40} className="absolute top-[30%] left-[50%] rotate-[90deg]"/>
|
||||
<Strategy size={36} className="absolute fill-primary top-[50%] left-[70%] rotate-[30deg]"/>
|
||||
<Sword size={28} className="absolute top-[70%] left-[10%] rotate-[60deg]"/>
|
||||
<Alien size={34} className="absolute fill-primary top-[10%] left-[85%] rotate-[15deg]"/>
|
||||
<CastleTurret size={30} className="absolute top-[5%] left-[40%] rotate-[320deg]"/>
|
||||
<Ghost size={38} className="absolute fill-primary top-[40%] left-[5%] rotate-[300deg]"/>
|
||||
<Skull size={32} className="absolute top-[80%] left-[30%] rotate-[90deg]"/>
|
||||
<Trophy size={36} className="absolute fill-primary top-[10%] left-[60%] rotate-[45deg]"/>
|
||||
<Lego size={28} className="absolute top-[30%] left-[20%] rotate-[30deg]"/>
|
||||
<TreasureChest size={40} className="absolute top-[70%] left-[50%] rotate-[75deg]"/>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {Chip} from "@heroui/react";
|
||||
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`}>
|
||||
{roleToRoleName(role)}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Link,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Progress,
|
||||
ScrollShadow,
|
||||
Spinner
|
||||
} from "@heroui/react";
|
||||
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} from "@phosphor-icons/react";
|
||||
import {timeBetween, timeUntil} from "Frontend/util/utils";
|
||||
import LibraryScanStatus from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryScanStatus";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function ScanProgressPopover() {
|
||||
const libraries = useSnapshot(libraryState).state;
|
||||
const scans = useSnapshot(scanState).sortedByStartTime as LibraryScanProgress[];
|
||||
const scanInProgress = useSnapshot(scanState).isScanning;
|
||||
|
||||
// Add state to track current time and force re-renders
|
||||
const [currentTime, setCurrentTime] = useState(Date.now());
|
||||
|
||||
// Set up an interval to update the time every second
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 1000);
|
||||
|
||||
// Clean up the interval when component unmounts
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover placement="bottom-end" showArrow={true}>
|
||||
<PopoverTrigger>
|
||||
<Button isIconOnly variant="light">
|
||||
{scanInProgress ?
|
||||
<Spinner size="sm" color="default" variant="spinner"
|
||||
classNames={{
|
||||
spinnerBars: "bg-foreground-500",
|
||||
}}/> :
|
||||
<Target className="fill-foreground-500"/>
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className="flex flex-col gap-2 m-2 w-96">
|
||||
{scans.length === 0 ?
|
||||
<p className="flex h-12 items-center justify-center text-sm text-default-500">
|
||||
No scans in progress or in history.
|
||||
</p> :
|
||||
<ScrollShadow hideScrollBar className="max-h-96">
|
||||
{scans.map((scan, index) =>
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="flex flex-row justify-between items-center text-default-500 mb-1">
|
||||
<p>Scan for library
|
||||
<Link underline="always"
|
||||
color="foreground"
|
||||
size="sm"
|
||||
href={`/administration/libraries/library/${scan.libraryId}`}>
|
||||
{libraries[scan.libraryId].name}
|
||||
</Link>
|
||||
</p>
|
||||
{scan.finishedAt ?
|
||||
<p className="text-default-500">
|
||||
Finished {timeUntil(scan.finishedAt)}
|
||||
</p> :
|
||||
<p className="text-default-500">
|
||||
Started {timeUntil(scan.startedAt)}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
{scan.status === LibraryScanStatus.IN_PROGRESS ?
|
||||
scan.currentStep.current && scan.currentStep.total ?
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-default-500">
|
||||
{`${scan.currentStep.description} (${scan.currentStep.current}/${scan.currentStep.total})`}
|
||||
</p>
|
||||
<Progress
|
||||
value={scan.currentStep.current / scan.currentStep.total * 100}
|
||||
size="sm"/>
|
||||
</div> :
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-default-500">{scan.currentStep.description}</p>
|
||||
<Progress isIndeterminate size="sm"/>
|
||||
</div>
|
||||
:
|
||||
<p>
|
||||
{scan.result?.new} new /
|
||||
{scan.result?.removed} removed /
|
||||
{scan.result?.unmatched} unmatched
|
||||
(in {timeBetween(scan.startedAt, scan.finishedAt!)})
|
||||
</p>
|
||||
}
|
||||
{scans.length > 1 && index < (scans.length - 1) && <Divider className="my-2"/>}
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {Autocomplete, AutocompleteItem} from "@heroui/react";
|
||||
import {CaretRight, MagnifyingGlass} 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";
|
||||
import {useNavigate} from "react-router";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
|
||||
export default function SearchBar() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const state = useSnapshot(gameState);
|
||||
const games = state.recentlyUpdated as GameDto[];
|
||||
|
||||
return <Autocomplete
|
||||
aria-label="Search for games"
|
||||
classNames={{
|
||||
selectorButton: "text-default-500",
|
||||
endContentWrapper: "display-none"
|
||||
}}
|
||||
defaultItems={games}
|
||||
inputProps={{
|
||||
classNames: {
|
||||
input: "text-small w-96",
|
||||
inputWrapper: "h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20"
|
||||
},
|
||||
}}
|
||||
listboxProps={{
|
||||
hideSelectedIcon: true,
|
||||
itemClasses: {
|
||||
base: [
|
||||
"text-default-500",
|
||||
"transition-opacity",
|
||||
"data-[hover=true]:text-foreground",
|
||||
"dark:data-[hover=true]:bg-default-50",
|
||||
"data-[pressed=true]:opacity-70",
|
||||
"data-[hover=true]:bg-default-200",
|
||||
"data-[selectable=true]:focus:bg-default-100",
|
||||
"data-[focus-visible=true]:ring-default-500",
|
||||
],
|
||||
},
|
||||
}}
|
||||
placeholder="Type to search..."
|
||||
startContent={<MagnifyingGlass/>}
|
||||
isVirtualized={true}
|
||||
maxListboxHeight={300}
|
||||
itemHeight={91} // 75px (cover) + 16px (margin top/bottom) = 91px
|
||||
>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.id} textValue={item.title} onPress={() => navigate("/game/" + item.id)}>
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<GameCover game={item} size={75}/>
|
||||
<div className="flex flex-col flex-1 gap-2">
|
||||
<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/>
|
||||
</div>
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</Autocomplete>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {Divider} from "@heroui/react";
|
||||
|
||||
export default function Section({title}: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-xl font-bold mt-8 mb-1">{title}</h2>
|
||||
<Divider className="mb-4"/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
// @ts-ignore
|
||||
export function SmallInfoField({icon: IconComponent, message, ...props}) {
|
||||
return (
|
||||
<div {...props}>
|
||||
<small className="flex flex-row items-center gap-1">
|
||||
<IconComponent weight="fill" size={14}/> {message}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {Button, Card, Chip, Tooltip} from "@heroui/react";
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
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, SlidersHorizontal} 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";
|
||||
|
||||
interface LibraryOverviewCardProps {
|
||||
library: LibraryDto;
|
||||
}
|
||||
|
||||
export function LibraryOverviewCard({library}: LibraryOverviewCardProps) {
|
||||
const MAX_COVER_COUNT = 5;
|
||||
const navigate = useNavigate();
|
||||
const state = useSnapshot(gameState);
|
||||
const randomGames = getRandomGames();
|
||||
|
||||
function getRandomGames() {
|
||||
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
|
||||
if (!games) return [];
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
|
||||
async function triggerScan() {
|
||||
await LibraryEndpoint.triggerScan(ScanType.QUICK, [library]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col justify-between w-[353px]">
|
||||
<div className="flex flex-1 justify-center items-center">
|
||||
<div className="flex flex-1 opacity-10 min-h-[100px]">
|
||||
<IconBackgroundPattern/>
|
||||
{randomGames.length > 0 &&
|
||||
<div className="absolute flex flex-row">
|
||||
{randomGames.map((game) => (
|
||||
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p className="absolute text-2xl font-bold">{library.name}</p>
|
||||
|
||||
<div className="absolute right-0 top-0 flex flex-row">
|
||||
<Tooltip content="Scan library" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={triggerScan}>
|
||||
<MagnifyingGlass/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={() => navigate('library/' + library.id)}>
|
||||
<SlidersHorizontal/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{library.stats &&
|
||||
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
|
||||
<p>Games</p>
|
||||
<p>Downloads</p>
|
||||
<p>Platforms</p>
|
||||
<p className="font-bold">{library.stats.gamesCount}</p>
|
||||
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
|
||||
<Chip size="sm">PC</Chip>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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 PluginState from "Frontend/generated/org/pf4j/PluginState";
|
||||
import React, {ReactNode} from "react";
|
||||
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
|
||||
import PluginLogo from "Frontend/components/general/plugin/PluginLogo";
|
||||
import PluginTrustLevel from "Frontend/generated/org/gameyfin/app/core/plugins/management/PluginTrustLevel";
|
||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import PluginConfigValidationResult
|
||||
from "Frontend/generated/org/gameyfin/pluginapi/core/config/PluginConfigValidationResult";
|
||||
import PluginConfigValidationResultType
|
||||
from "Frontend/generated/org/gameyfin/pluginapi/core/config/PluginConfigValidationResultType";
|
||||
|
||||
export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
const pluginDetailsModal = useDisclosure();
|
||||
|
||||
function borderColor(state: PluginState | undefined, trustLevel: PluginTrustLevel | undefined): "success" | "warning" | "danger" | "default" {
|
||||
if (trustLevel === PluginTrustLevel.UNTRUSTED) return "danger";
|
||||
|
||||
if (isDisabled(state)) return "warning";
|
||||
return stateToColor(state);
|
||||
}
|
||||
|
||||
function stateToColor(state: PluginState | undefined): "success" | "warning" | "danger" | "default" {
|
||||
switch (state) {
|
||||
case PluginState.STARTED:
|
||||
return "success";
|
||||
case PluginState.DISABLED:
|
||||
return "warning";
|
||||
case PluginState.FAILED:
|
||||
case PluginState.STOPPED:
|
||||
return "danger";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function stateToIcon(state: PluginState | undefined): ReactNode {
|
||||
switch (state) {
|
||||
case PluginState.STARTED:
|
||||
return <PlayCircle/>;
|
||||
case PluginState.DISABLED:
|
||||
return <PauseCircle/>;
|
||||
case PluginState.STOPPED:
|
||||
case PluginState.FAILED:
|
||||
return <StopCircle/>;
|
||||
case PluginState.UNLOADED:
|
||||
case PluginState.RESOLVED:
|
||||
return <XCircle/>;
|
||||
default:
|
||||
return <QuestionMark/>;
|
||||
}
|
||||
}
|
||||
|
||||
function configValidationResultToChip(validationResult: PluginConfigValidationResult | undefined): ReactNode {
|
||||
switch (validationResult?.result) {
|
||||
case PluginConfigValidationResultType.VALID:
|
||||
return <Tooltip content="Config valid" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs" color="success">
|
||||
<CheckCircle/>
|
||||
</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/>
|
||||
</Chip>
|
||||
</Tooltip>;
|
||||
default:
|
||||
return <Tooltip content="Config could not be validated" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs">
|
||||
<Question/>
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
}
|
||||
}
|
||||
|
||||
function trustLevelToBadge(trustLevel: PluginTrustLevel | undefined): React.ReactNode {
|
||||
switch (trustLevel) {
|
||||
case PluginTrustLevel.OFFICIAL:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Official plugin">
|
||||
<SealCheck className="fill-success"/>
|
||||
</Tooltip>;
|
||||
case PluginTrustLevel.BUNDLED:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
|
||||
<SealCheck/>
|
||||
</Tooltip>;
|
||||
case PluginTrustLevel.THIRD_PARTY:
|
||||
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
|
||||
<SealWarning/>
|
||||
</Tooltip>;
|
||||
case PluginTrustLevel.UNTRUSTED:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Invalid plugin signature">
|
||||
<SealWarning className="fill-danger"/>
|
||||
</Tooltip>;
|
||||
default:
|
||||
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
|
||||
<SealQuestion/>
|
||||
</Tooltip>;
|
||||
}
|
||||
}
|
||||
|
||||
function isDisabled(state: PluginState | undefined): boolean {
|
||||
return state === PluginState.DISABLED;
|
||||
}
|
||||
|
||||
function togglePluginEnabled() {
|
||||
if (isDisabled(plugin.state)) {
|
||||
PluginEndpoint.enablePlugin(plugin.id);
|
||||
} else {
|
||||
PluginEndpoint.disablePlugin(plugin.id);
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={`flex flex-row justify-between p-2 border-2 border-${borderColor(plugin.state, plugin.trustLevel)}`}>
|
||||
<div className="absolute right-0 top-0 flex flex-row">
|
||||
<Tooltip content={`${isDisabled(plugin.state) ? "Enable" : "Disable"} plugin`} placement="bottom"
|
||||
color="foreground">
|
||||
<Button isIconOnly
|
||||
variant="light"
|
||||
onPress={() => togglePluginEnabled()}
|
||||
isDisabled={plugin.state == PluginState.UNLOADED || plugin.state == PluginState.RESOLVED}
|
||||
>
|
||||
<Power/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Configuration" placement="bottom" color="foreground">
|
||||
<Button isIconOnly variant="light" onPress={pluginDetailsModal.onOpen}>
|
||||
<SlidersHorizontal/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center gap-2">
|
||||
<PluginLogo plugin={plugin}/>
|
||||
<p className="flex flex-row items-center gap-1 font-semibold">
|
||||
{plugin.name}
|
||||
<IconContext.Provider value={{size: 18, weight: "fill"}}>
|
||||
{trustLevelToBadge(plugin.trustLevel)}
|
||||
</IconContext.Provider>
|
||||
</p>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip>
|
||||
<Chip size="sm" radius="sm" className="text-xs" color={stateToColor(plugin.state)}>
|
||||
<Tooltip content={`Plugin ${plugin.state?.toLowerCase()}`} placement="bottom"
|
||||
color="foreground">
|
||||
{stateToIcon(plugin.state)}
|
||||
</Tooltip>
|
||||
</Chip>
|
||||
{configValidationResultToChip(plugin.configValidation)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<PluginDetailsModal plugin={plugin}
|
||||
isOpen={pluginDetailsModal.isOpen}
|
||||
onOpenChange={pluginDetailsModal.onOpenChange}
|
||||
/>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react";
|
||||
import {DotsThreeVertical} from "@phosphor-icons/react";
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {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 UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
|
||||
import RoleChip from "Frontend/components/general/RoleChip";
|
||||
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
|
||||
|
||||
export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
const userDeletionConfirmationModal = useDisclosure();
|
||||
const passwordResetTokenModal = useDisclosure();
|
||||
const roleAssignmentModal = useDisclosure();
|
||||
const [userEnabled, setUserEnabled] = useState(true);
|
||||
const [disabledKeys, setDisabledKeys] = useState<string[]>([]);
|
||||
const [dropdownItems, setDropdownItems] = useState<any[]>([]);
|
||||
const [passwordResetToken, setPasswordResetToken] = useState<TokenDto>();
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
setUserEnabled(user.enabled);
|
||||
let keysToBeDisabled: string[] = [];
|
||||
MessageEndpoint.isEnabled().then((isEnabled) => {
|
||||
if (isEnabled) keysToBeDisabled.push("resetPassword");
|
||||
if (!user.hasAvatar) keysToBeDisabled.push("removeAvatar");
|
||||
setDisabledKeys(keysToBeDisabled);
|
||||
});
|
||||
UserEndpoint.canCurrentUserManage(user.username).then((canManage) => {
|
||||
if (!canManage) keysToBeDisabled.push("assignRole", "disableUser", "delete");
|
||||
setDisabledKeys(keysToBeDisabled);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setDropdownItems(getDropdownItems());
|
||||
}, [userEnabled]);
|
||||
|
||||
async function resetPassword() {
|
||||
let token = await PasswordResetEndpoint.createPasswordResetTokenForUser(user.username);
|
||||
if (token === undefined) return;
|
||||
setPasswordResetToken(token);
|
||||
passwordResetTokenModal.onOpen();
|
||||
}
|
||||
|
||||
function getDropdownItems() {
|
||||
let items = [];
|
||||
|
||||
if (!user.managedBySso) {
|
||||
if (!userEnabled) {
|
||||
items.push(
|
||||
{
|
||||
key: "enableUser",
|
||||
onPress: () => {
|
||||
UserEndpoint.setUserEnabled(user.username, true).then(() => {
|
||||
setUserEnabled(true);
|
||||
})
|
||||
},
|
||||
label: "Enable user"
|
||||
}
|
||||
);
|
||||
} else {
|
||||
items.push(
|
||||
{
|
||||
key: "disableUser",
|
||||
onPress: () => {
|
||||
UserEndpoint.setUserEnabled(user.username, false).then(() => {
|
||||
setUserEnabled(false);
|
||||
})
|
||||
},
|
||||
label: "Disable user"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
key: "removeAvatar",
|
||||
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
|
||||
label: "Remove avatar"
|
||||
},
|
||||
{
|
||||
key: "assignRole",
|
||||
onPress: roleAssignmentModal.onOpen,
|
||||
label: "Assign role"
|
||||
},
|
||||
{
|
||||
key: "resetPassword",
|
||||
onPress: resetPassword,
|
||||
label: "Reset password"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: "delete",
|
||||
onPress: userDeletionConfirmationModal.onOpen,
|
||||
label: "Delete user"
|
||||
}
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"} ${user.managedBySso ? "text-foreground/50" : ""}`}>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<Avatar username={user.username}
|
||||
name={user.username?.charAt(0)}
|
||||
classNames={{
|
||||
base: "gradient-primary size-20",
|
||||
icon: "text-background/80",
|
||||
name: "text-background/80 text-5xl",
|
||||
}}/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-semibold">{user.username}</p>
|
||||
<p className="text-sm">{user.email}</p>
|
||||
{user.roles?.map((role) => (
|
||||
<RoleChip role={role as string}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
|
||||
<DropdownTrigger>
|
||||
<DotsThreeVertical cursor="pointer"/>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
|
||||
{(item) => (
|
||||
<DropdownItem
|
||||
key={item.key}
|
||||
onPress={item.onPress}
|
||||
color={item.key === "delete" ? "danger" : "default"}
|
||||
className={item.key === "delete" ? "text-danger" : ""}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</Card>
|
||||
<ConfirmUserDeletionModal isOpen={userDeletionConfirmationModal.isOpen}
|
||||
onOpenChange={userDeletionConfirmationModal.onOpenChange}
|
||||
user={user}/>
|
||||
<PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen}
|
||||
onOpenChange={passwordResetTokenModal.onOpenChange}
|
||||
token={passwordResetToken as TokenDto}/>
|
||||
<AssignRolesModal isOpen={roleAssignmentModal.isOpen} onOpenChange={roleAssignmentModal.onOpenChange}
|
||||
user={user}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
|
||||
interface CoverGridProps {
|
||||
games: GameDto[];
|
||||
}
|
||||
|
||||
export default function CoverGrid({games}: CoverGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,212px))] gap-4 justify-center">
|
||||
{games.map((game) => (
|
||||
<GameCover key={game.id} game={game} interactive={true}/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {GameCover} from "Frontend/components/general/covers/GameCover";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {ArrowRight} from "@phosphor-icons/react";
|
||||
import {useNavigate} from "react-router";
|
||||
|
||||
interface CoverRowProps {
|
||||
games: GameDto[];
|
||||
title: string;
|
||||
onPressShowMore: () => void;
|
||||
}
|
||||
|
||||
const aspectRatio = 12 / 17; // aspect ratio of the game cover
|
||||
const defaultImageHeight = 300; // default height for the image
|
||||
const defaultImageWidth = aspectRatio * defaultImageHeight; // default width for the image
|
||||
|
||||
export function CoverRow({games, title, onPressShowMore}: CoverRowProps) {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(games.length);
|
||||
|
||||
useEffect(() => {
|
||||
const calculateVisible = () => {
|
||||
if (containerRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const maxFit = Math.floor((containerWidth - defaultImageWidth) / defaultImageWidth) + 1;
|
||||
setVisibleCount(maxFit < games.length ? maxFit : games.length);
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(calculateVisible);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
calculateVisible(); // initial calculation
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [games.length]);
|
||||
|
||||
const showMore = visibleCount < games.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mb-4">
|
||||
<p className="text-2xl font-bold mb-4">{title}</p>
|
||||
<div className="w-full relative">
|
||||
<div ref={containerRef} className="flex flex-row gap-2 rounded-md bg-transparent">
|
||||
{games.slice(0, visibleCount).map((game, index) => (
|
||||
<GameCover key={index} game={game} radius="sm" interactive={true}/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showMore && (
|
||||
<div className="flex flex-row items-center justify-end cursor-pointer"
|
||||
onClick={onPressShowMore}>
|
||||
<div className="absolute h-full w-1/4 right-0 bottom-0
|
||||
bg-gradient-to-r from-transparent to-background
|
||||
transition-all duration-300 ease-in-out hover:opacity-80"/>
|
||||
<div
|
||||
className="absolute h-full right-0 bottom-0 flex flex-row items-center gap-2 pointer-events-none">
|
||||
<p className="text-xl font-semibold">Show more</p>
|
||||
<ArrowRight weight="bold"/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {Image} from "@heroui/react";
|
||||
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
|
||||
|
||||
interface GameCoverProps {
|
||||
game: GameDto;
|
||||
size?: number;
|
||||
radius?: "none" | "sm" | "md" | "lg";
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function GameCover({game, size = 300, radius = "sm", interactive = false}: GameCoverProps) {
|
||||
const coverContent = Number.isInteger(game.coverId) ? (
|
||||
<div className={`${interactive ? "rounded-md scale-95 hover:scale-100 shine transition-all" : ""}`}>
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover aspect-[12/17]"
|
||||
src={`images/cover/${game.coverId}`}
|
||||
radius={radius}
|
||||
height={size}
|
||||
fallbackSrc={<GameCoverFallback title={game.title} size={size} radius={radius}/>}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<GameCoverFallback title={game.title} size={size} radius={radius} hover={interactive}/>
|
||||
);
|
||||
|
||||
return interactive ? (
|
||||
<a href={`/game/${game.id}`}>
|
||||
{coverContent}
|
||||
</a>
|
||||
) : coverContent;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {Card} from "@heroui/react";
|
||||
|
||||
interface GameCoverFallbackProps {
|
||||
title: string;
|
||||
size?: number;
|
||||
radius?: "none" | "sm" | "md" | "lg";
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export function GameCoverFallback({title, size = 300, radius = "sm", hover = false}: GameCoverFallbackProps) {
|
||||
return (
|
||||
<Card style={{aspectRatio: "12 /17", height: size, borderRadius: radius}}
|
||||
radius={radius}
|
||||
className={hover ? "scale-95 hover:scale-100" : ""}>
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
{title}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {Autoplay, Navigation, Pagination} from 'swiper/modules';
|
||||
import {Swiper, SwiperSlide} from "swiper/react";
|
||||
import {Card, Image, Modal, ModalContent, useDisclosure} from "@heroui/react";
|
||||
import ReactPlayer from 'react-player';
|
||||
|
||||
import "swiper/css";
|
||||
import "swiper/css/navigation";
|
||||
import "swiper/css/pagination";
|
||||
import "swiper/css/autoplay";
|
||||
import {useEffect, useState} from "react";
|
||||
import {CaretLeft, CaretRight, IconContext, Play} from "@phosphor-icons/react";
|
||||
|
||||
|
||||
interface ImageCarouselProps {
|
||||
imageUrls?: string[];
|
||||
videosUrls?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SlideData {
|
||||
isActive: boolean;
|
||||
isVisible: boolean;
|
||||
isPrev: boolean;
|
||||
isNext: boolean;
|
||||
}
|
||||
|
||||
export default function ImageCarousel({imageUrls, videosUrls, className}: ImageCarouselProps) {
|
||||
|
||||
interface CarouselElement {
|
||||
type: "image" | "video";
|
||||
url: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SLIDES_PER_VIEW = 3;
|
||||
|
||||
const [elements, setElements] = useState<CarouselElement[]>();
|
||||
const [selectedImageUrl, setSelectedImageUrl] = useState<string>();
|
||||
const imagePopup = useDisclosure();
|
||||
|
||||
useEffect(() => {
|
||||
const images = imageUrls?.map((imageUrl) => ({
|
||||
type: "image" as const,
|
||||
url: imageUrl
|
||||
})) || [];
|
||||
const videos = videosUrls?.map((videoUrl) => ({
|
||||
type: "video" as const,
|
||||
url: videoUrl
|
||||
})) || [];
|
||||
|
||||
setElements([...images, ...videos]);
|
||||
}, [imageUrls, videosUrls])
|
||||
|
||||
function showImagePopup(imageUrl: string) {
|
||||
setSelectedImageUrl(imageUrl);
|
||||
imagePopup.onOpen();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{elements && elements.length > 0 &&
|
||||
<div className="w-full flex flex-col gap-2 items-center">
|
||||
<div className="w-full flex flex-row items-center">
|
||||
<IconContext.Provider value={{size: 50}}>
|
||||
<CaretLeft className="swiper-custom-button-prev cursor-pointer fill-primary"/>
|
||||
<Swiper
|
||||
modules={[Pagination, Navigation, Autoplay]}
|
||||
slidesPerView={DEFAULT_SLIDES_PER_VIEW > elements.length ? elements.length : DEFAULT_SLIDES_PER_VIEW}
|
||||
pagination={{
|
||||
clickable: true,
|
||||
el: ".swiper-custom-pagination"
|
||||
}}
|
||||
navigation={{
|
||||
prevEl: ".swiper-custom-button-prev",
|
||||
nextEl: ".swiper-custom-button-next"
|
||||
}}
|
||||
centeredSlides={true}
|
||||
loop={true}
|
||||
spaceBetween={0}
|
||||
autoplay={{
|
||||
delay: 10000,
|
||||
disableOnInteraction: true
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{elements && elements.map((e, index) => (
|
||||
<SwiperSlide key={index} virtualIndex={index}>
|
||||
{({isActive}: SlideData) => {
|
||||
if (e.type === "image") {
|
||||
return (
|
||||
<Image
|
||||
src={e.url}
|
||||
alt={`Game screenshot slide ${index}`}
|
||||
className={`w-full h-full object-cover aspect-[16/9] cursor-zoom-in ${!isActive ? "scale-90" : ""}`}
|
||||
onClick={() => showImagePopup(e.url)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Card
|
||||
className={`w-full h-full aspect-[16/9] ${!isActive ? "scale-90" : ""}`}>
|
||||
<ReactPlayer
|
||||
url={e.url}
|
||||
width="100%"
|
||||
height="100%"
|
||||
light={true}
|
||||
controls={true}
|
||||
playing={isActive}
|
||||
playIcon={<Play weight="fill"/>}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}}
|
||||
</SwiperSlide>
|
||||
))}
|
||||
<ImagePopup imageUrl={selectedImageUrl} isOpen={imagePopup.isOpen}
|
||||
onOpenChange={imagePopup.onOpenChange}/>
|
||||
</Swiper>
|
||||
<CaretRight className="swiper-custom-button-next cursor-pointer fill-primary"/>
|
||||
</IconContext.Provider>
|
||||
</div>
|
||||
<div>
|
||||
{/* Wrap the pagination in a div because it gets replaced at runtime be SwiperJS and loses all styling */}
|
||||
<div className="swiper-custom-pagination"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImagePopup({imageUrl, isOpen, onOpenChange}: {
|
||||
imageUrl?: string,
|
||||
isOpen: boolean,
|
||||
onOpenChange: (isOpen: boolean) => void
|
||||
}) {
|
||||
return (imageUrl &&
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} hideCloseButton size="full" backdrop="blur">
|
||||
<ModalContent className="bg-transparent">
|
||||
{(onClose) => (
|
||||
<div className="flex flex-grow items-center justify-center cursor-zoom-out"
|
||||
onClick={onClose}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt="Game screenshot"
|
||||
className="max-w-[80vw] max-h-[80vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import React from "react";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import IconBackgroundPattern from "Frontend/components/general/IconBackgroundPattern";
|
||||
import {Card} from "@heroui/react";
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
library: LibraryDto;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LibraryHeader({library, className}: LibraryHeaderProps) {
|
||||
const MAX_COVER_COUNT = 5;
|
||||
const state = useSnapshot(gameState);
|
||||
const randomGames = getRandomGames();
|
||||
|
||||
function getRandomGames() {
|
||||
const games = state.randomlyOrderedGamesByLibraryId[library.id] as GameDto[];
|
||||
if (!games) return [];
|
||||
return games.slice(0, MAX_COVER_COUNT);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`overflow-hidden rounded-lg relative pointer-events-none select-none ${className}`}>
|
||||
<IconBackgroundPattern/>
|
||||
<div className="flex flex-row items-center w-full h-full brightness-50">
|
||||
{randomGames.map((game, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex-none overflow-hidden -ml-[10%]"
|
||||
style={{
|
||||
width: `calc(100% / ${MAX_COVER_COUNT - 2})`,
|
||||
clipPath: 'polygon(15% 0, 100% 0, 85% 100%, 0% 100%)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/images/screenshot/${game.imageIds![0]}`}
|
||||
alt={`Image ${idx}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<h2 className="text-white text-3xl font-bold">{library.name}</h2>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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";
|
||||
|
||||
// @ts-ignore
|
||||
const ArrayInput = ({label, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
const [newElementValue, setNewElementValue] = useState<string>("");
|
||||
|
||||
return (
|
||||
<FieldArray name={field.name}
|
||||
render={arrayHelpers => {
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Enter" || event.key == "Tab" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
|
||||
newElementValue
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value !== "")
|
||||
.forEach((value) => arrayHelpers.push(value));
|
||||
|
||||
setNewElementValue("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 gap-2">
|
||||
<div className="flex flex-row justify-between">
|
||||
<p>{label}</p>
|
||||
<small>{field.value.length} {field.value.length == 1 ? "element" : "elements"}</small>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-2 items-center">
|
||||
{field.value.map((element: any, index: number) => (
|
||||
<Chip key={index} onClose={() => arrayHelpers.remove(index)}>
|
||||
{element}
|
||||
</Chip>
|
||||
))}
|
||||
<Popover placement="bottom" showArrow={true}>
|
||||
<PopoverTrigger>
|
||||
<Button isIconOnly size="sm" variant="light" radius="full"><Plus/></Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Input
|
||||
value={newElementValue}
|
||||
onChange={(e) => setNewElementValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="New element..."
|
||||
variant="bordered"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="min-h-6 text-danger">
|
||||
{meta.touched && meta.error && meta.error.trim().length > 0 && (
|
||||
meta.error
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArrayInput;
|
||||
@@ -0,0 +1,29 @@
|
||||
import {useField} from "formik";
|
||||
import {Checkbox, CheckboxGroup} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const CheckboxInput = ({label, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
|
||||
return (
|
||||
<CheckboxGroup
|
||||
className="flex flex-row flex-1 items-baseline gap-2"
|
||||
isInvalid={!!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
value={field.value ? [field.name] : []}
|
||||
>
|
||||
<Checkbox
|
||||
className="items-baseline"
|
||||
{...field}
|
||||
{...props}
|
||||
// @ts-ignore
|
||||
value={field.name}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</CheckboxGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxInput;
|
||||
@@ -0,0 +1,85 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
SharedSelection
|
||||
} from "@heroui/react";
|
||||
import {CaretDown} from "@phosphor-icons/react";
|
||||
import {UserPreferenceService} from "Frontend/util/user-preference-service";
|
||||
|
||||
export interface ComboButtonOption {
|
||||
label: string;
|
||||
description: string;
|
||||
action: () => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ComboButtonProps {
|
||||
description?: string;
|
||||
options: Record<string, ComboButtonOption>;
|
||||
preferredOptionKey?: string;
|
||||
}
|
||||
|
||||
export default function ComboButton({options, preferredOptionKey, description}: ComboButtonProps) {
|
||||
const [selectedOption, setSelectedOption] = useState(new Set([Object.keys(options)[0]]));
|
||||
const selectedOptionValue = Array.from(selectedOption)[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferredOptionKey) return;
|
||||
|
||||
UserPreferenceService.get(preferredOptionKey).then((key) => {
|
||||
if (key && options[key]) {
|
||||
setSelectedOption(new Set([key]));
|
||||
} else {
|
||||
setSelectedOption(new Set([Object.keys(options)[0]]));
|
||||
}
|
||||
})
|
||||
}, []);
|
||||
|
||||
async function onSelectionChange(keys: SharedSelection) {
|
||||
if (!keys.currentKey) return;
|
||||
|
||||
if (preferredOptionKey) {
|
||||
await UserPreferenceService.set(preferredOptionKey, keys.currentKey);
|
||||
}
|
||||
|
||||
setSelectedOption(new Set([keys.currentKey]));
|
||||
}
|
||||
|
||||
return options[selectedOptionValue] && (
|
||||
<ButtonGroup className="gap-[1px]">
|
||||
<Button color="primary" className="w-52"
|
||||
onPress={options[selectedOptionValue].action}>
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="font-semibold">{options[selectedOptionValue].label}</p>
|
||||
<p className="text-xs font-normal opacity-70 ">{description}</p>
|
||||
</div>
|
||||
</Button>
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly color="primary">
|
||||
<CaretDown/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disallowEmptySelection
|
||||
aria-label="Merge options"
|
||||
selectedKeys={selectedOption}
|
||||
selectionMode="single"
|
||||
onSelectionChange={onSelectionChange}
|
||||
className="w-60"
|
||||
>
|
||||
{Object.entries(options).map(([key, option]) => (
|
||||
<DropdownItem key={key} description={option.description}>
|
||||
{option.label}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useField} from "formik";
|
||||
import {DatePicker, DateValue} from "@heroui/react";
|
||||
import {parseDate} from "@internationalized/date";
|
||||
import {useState} from "react";
|
||||
|
||||
// @ts-ignore
|
||||
export default function DatePickerInput({label, showErrorUntouched = false, ...props}) {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
const [value, setValue] = useState<DateValue | null>(field.value ? parseDate(field.value) : null);
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
className="min-h-20 flex-grow"
|
||||
showMonthAndYearPickers
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
value={value}
|
||||
onChange={(date) => {
|
||||
setValue(date);
|
||||
field.onChange({
|
||||
target: {
|
||||
name: field.name,
|
||||
value: date ? date.toString() : ''
|
||||
}
|
||||
});
|
||||
}}
|
||||
id={label}
|
||||
label={label}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import {Button, Code, useDisclosure} from "@heroui/react";
|
||||
import {ArrowRight, Minus, Plus, XCircle} 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";
|
||||
import {useField} from "formik";
|
||||
|
||||
interface DirectoryMappingInputProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function DirectoryMappingInput({name}: DirectoryMappingInputProps) {
|
||||
const pathPickerModal = useDisclosure();
|
||||
const [field, meta, helpers] = useField<DirectoryMappingDto[]>({name});
|
||||
|
||||
function addDirectoryMapping(directory: DirectoryMappingDto) {
|
||||
helpers.setValue([...(field.value || []), directory]);
|
||||
}
|
||||
|
||||
function removeDirectoryMapping(directory: DirectoryMappingDto) {
|
||||
helpers.setValue((field.value || []).filter((d) => d !== directory));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<p className="font-bold">Directories</p>
|
||||
<Button isIconOnly variant="light" size="sm" color="default"
|
||||
onPress={pathPickerModal.onOpen}>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
{(field.value || []).map((directory) => (
|
||||
<Code
|
||||
className="w-full flex items-center gap-2 overflow-hidden px-2 py-1"
|
||||
key={directory.internalPath}>
|
||||
<input
|
||||
type="text"
|
||||
value={directory.internalPath}
|
||||
readOnly
|
||||
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
|
||||
/>
|
||||
{directory.externalPath && (
|
||||
<>
|
||||
<div className="flex-shrink-0 flex items-center justify-center mx-2">
|
||||
<ArrowRight size={20}/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={directory.externalPath}
|
||||
readOnly
|
||||
className="flex-1 bg-transparent border-none outline-none overflow-x-auto whitespace-nowrap"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="default"
|
||||
onPress={() => removeDirectoryMapping(directory)}
|
||||
className="ml-2"
|
||||
>
|
||||
<Minus/>
|
||||
</Button>
|
||||
</Code>
|
||||
))}
|
||||
<div className="min-h-6 text-danger">
|
||||
{meta.touched && meta.error && (
|
||||
<SmallInfoField icon={XCircle} message={meta.error}/>
|
||||
)}
|
||||
</div>
|
||||
<PathPickerModal returnSelectedPath={addDirectoryMapping}
|
||||
isOpen={pathPickerModal.isOpen}
|
||||
onOpenChange={pathPickerModal.onOpenChange}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import TreeView, {flattenTree, INode, NodeId} from "react-accessible-treeview";
|
||||
import {File, Folder, FolderOpen, 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";
|
||||
import FileType from "Frontend/generated/org/gameyfin/app/core/filesystem/FileType";
|
||||
import {IFlatMetadata} from "react-accessible-treeview/dist/TreeView/utils";
|
||||
import OperatingSystemType from "Frontend/generated/org/gameyfin/app/core/filesystem/OperatingSystemType";
|
||||
|
||||
interface ITreeNode<M extends IFlatMetadata = IFlatMetadata> {
|
||||
id?: NodeId;
|
||||
name: string;
|
||||
isBranch?: boolean;
|
||||
children?: ITreeNode<M>[];
|
||||
metadata?: M;
|
||||
}
|
||||
|
||||
export default function FileTreeView({onPathChange}: { onPathChange: (file: string) => void }) {
|
||||
const rootNode: INode = {
|
||||
id: "root",
|
||||
name: "",
|
||||
children: [],
|
||||
parent: null
|
||||
}
|
||||
|
||||
const [hostOSType, setHostOSType] = useState<OperatingSystemType>();
|
||||
const [fileTree, setFileTree] = useState<ITreeNode>();
|
||||
const [flattenedFileTree, setFlattenedFileTree] = useState<INode[]>([rootNode]);
|
||||
|
||||
useEffect(() => {
|
||||
FilesystemEndpoint.getHostOperatingSystem().then((response) => {
|
||||
setHostOSType(response);
|
||||
})
|
||||
|
||||
FilesystemEndpoint.listSubDirectories("").then(
|
||||
result => {
|
||||
if (result === undefined) return;
|
||||
const nodes = fileDtosToTree(result as FileDto[]);
|
||||
const tree = flattenTree(nodes);
|
||||
setFileTree(nodes);
|
||||
setFlattenedFileTree(tree);
|
||||
}
|
||||
)
|
||||
}, []);
|
||||
|
||||
function getAbsolutePath(node: INode, path: string = ""): string {
|
||||
let pathSeparator = "/";
|
||||
|
||||
if (hostOSType === OperatingSystemType.WINDOWS) {
|
||||
pathSeparator = "\\";
|
||||
if (path.startsWith(pathSeparator)) path = path.substring(1);
|
||||
}
|
||||
|
||||
path = path.replace(`${pathSeparator}${pathSeparator}`, pathSeparator);
|
||||
|
||||
if (node.parent === null) {
|
||||
if (hostOSType === OperatingSystemType.WINDOWS) return path;
|
||||
return `${pathSeparator}${path}`;
|
||||
}
|
||||
|
||||
const parentNode = flattenedFileTree.find(n => n.id === node.parent);
|
||||
if (!parentNode) {
|
||||
throw new Error(`Parent node with id ${node.parent} not found`);
|
||||
}
|
||||
return getAbsolutePath(parentNode, `${node.name}${pathSeparator}${path}`);
|
||||
}
|
||||
|
||||
async function onLoadData({element}: { element: INode }) {
|
||||
const absolutePath = getAbsolutePath(element);
|
||||
|
||||
let subDirectories = await FilesystemEndpoint.listSubDirectories(absolutePath);
|
||||
if (subDirectories === undefined) return;
|
||||
|
||||
const newNodes = fileDtosToNodes(subDirectories as FileDto[]);
|
||||
const updatedTree = updateTreeWithNewNodes(fileTree!!, element.id, newNodes);
|
||||
|
||||
setFileTree(updatedTree);
|
||||
setFlattenedFileTree(flattenTree(updatedTree));
|
||||
onPathChange(absolutePath);
|
||||
}
|
||||
|
||||
function updateTreeWithNewNodes(tree: ITreeNode, nodeId: NodeId, newNodes: ITreeNode[]): ITreeNode {
|
||||
if (tree.id === nodeId) {
|
||||
return {...tree, children: newNodes};
|
||||
}
|
||||
|
||||
if (tree.children) {
|
||||
return {
|
||||
...tree,
|
||||
children: tree.children.map(child => updateTreeWithNewNodes(child, nodeId, newNodes))
|
||||
};
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
function fileDtosToTree(fileDtos: FileDto[], parent: (INode | null) = null): ITreeNode {
|
||||
const nodes = fileDtosToNodes(fileDtos);
|
||||
|
||||
if (parent === null) {
|
||||
return {...rootNode, children: nodes};
|
||||
}
|
||||
|
||||
return {...parent, children: nodes};
|
||||
}
|
||||
|
||||
function fileDtosToNodes(fileDtos: FileDto[]): ITreeNode[] {
|
||||
return fileDtos.map(fileDto => ({
|
||||
id: fileDto.hash,
|
||||
name: fileDto.name || "",
|
||||
isBranch: fileDto.type === FileType.DIRECTORY,
|
||||
children: []
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full gap-4 overflow-hidden">
|
||||
<TreeView
|
||||
data={flattenedFileTree}
|
||||
aria-label="directory tree"
|
||||
onLoadData={onLoadData}
|
||||
nodeRenderer={({
|
||||
element,
|
||||
isBranch,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
getNodeProps,
|
||||
level,
|
||||
}) => (
|
||||
<IconContext.Provider value={{size: 32, weight: "regular"}}>
|
||||
<div {...getNodeProps()}
|
||||
className={`
|
||||
flex flex-row items-center gap-2 w-full
|
||||
rounded-md cursor-pointer
|
||||
${isSelected ? 'bg-primary' : 'hover:bg-primary/20'}`
|
||||
}
|
||||
style={{paddingLeft: 10 * (level - 1)}}>
|
||||
{isBranch ? <FolderIcon isOpen={isExpanded}/> : <FileIcon fileName={element.name}/>}
|
||||
{element.name}
|
||||
</div>
|
||||
</IconContext.Provider>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIcon({isOpen}: { isOpen: boolean }) {
|
||||
return isOpen ? <FolderOpen/> : <Folder/>;
|
||||
}
|
||||
|
||||
function FileIcon({fileName}: { fileName: string }) {
|
||||
return <File/>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {Image, useDisclosure} from "@heroui/react";
|
||||
import {GameCoverFallback} from "Frontend/components/general/covers/GameCoverFallback";
|
||||
import React from "react";
|
||||
import {useField} from "formik";
|
||||
import {GameCoverPickerModal} from "Frontend/components/general/modals/GameCoverPickerModal";
|
||||
import {Pencil} from "@phosphor-icons/react";
|
||||
|
||||
|
||||
// @ts-ignore
|
||||
export default function GameCoverPicker({game, label, showErrorUntouched = false, ...props}) {
|
||||
|
||||
// @ts-ignore
|
||||
const [field] = useField(props);
|
||||
|
||||
const gameCoverPickerModal = useDisclosure();
|
||||
|
||||
return (<>
|
||||
<div className="relative group w-fit h-fit cursor-pointer"
|
||||
onClick={gameCoverPickerModal.onOpenChange}>
|
||||
<Image
|
||||
alt={game.title}
|
||||
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
|
||||
src={field.value ? field.value : `images/cover/${game.coverId}`}
|
||||
{...props}
|
||||
{...field}
|
||||
radius="none"
|
||||
height={216}
|
||||
fallbackSrc={<GameCoverFallback title={game.title}
|
||||
size={216}
|
||||
radius="none"/>}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Pencil size={46}/>
|
||||
</div>
|
||||
</div>
|
||||
<GameCoverPickerModal
|
||||
game={game}
|
||||
isOpen={gameCoverPickerModal.isOpen}
|
||||
onOpenChange={gameCoverPickerModal.onOpenChange}
|
||||
setCoverUrl={(coverUrl) => field.onChange({target: {name: field.name, value: coverUrl}})}
|
||||
/>
|
||||
</>);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useField} from "formik";
|
||||
import {Input as NextUiInput} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const Input = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
|
||||
return (
|
||||
<NextUiInput
|
||||
className="min-h-20 flex-grow"
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
id={label}
|
||||
label={label}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useField} from "formik";
|
||||
import {Select, SelectItem} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
const SelectInput = ({label, values, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
|
||||
const items = values.map((v: string) => ({key: v, label: v}));
|
||||
|
||||
return (
|
||||
<div className="min-h-20 flex-grow">
|
||||
<Select
|
||||
fullWidth={true}
|
||||
{...field}
|
||||
{...props}
|
||||
label={label}
|
||||
items={items}
|
||||
selectedKeys={[field.value]}
|
||||
isInvalid={!!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
disallowEmptySelection
|
||||
>
|
||||
{(item: { key: string, label: string }) => <SelectItem>{item.label}</SelectItem>}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectInput;
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useField} from "formik";
|
||||
import {Textarea} from "@heroui/react";
|
||||
|
||||
// @ts-ignore
|
||||
export default function TextAreaInput({label, showErrorUntouched = false, ...props}) {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className={`flex-grow ${meta.initialError || meta.error ? "" : "mb-6"}`}
|
||||
fullWidth={false}
|
||||
{...props}
|
||||
{...field}
|
||||
id={label}
|
||||
label={label}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
errorMessage={meta.initialError || meta.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import {Check} from "@phosphor-icons/react";
|
||||
import {addToast, Button} from "@heroui/react";
|
||||
import React from "react";
|
||||
import {Form, Formik} from "formik";
|
||||
import {deepDiff} from "Frontend/util/utils";
|
||||
import LibraryUpdateDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryUpdateDto";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
import {useNavigate} from "react-router";
|
||||
import * as Yup from "yup";
|
||||
|
||||
interface LibraryManagementDetailsProps {
|
||||
library: LibraryDto;
|
||||
}
|
||||
|
||||
export default function LibraryManagementDetails({library}: LibraryManagementDetailsProps) {
|
||||
const navigate = useNavigate();
|
||||
const [librarySaved, setLibrarySaved] = React.useState(false);
|
||||
|
||||
async function handleSubmit(values: LibraryDto): Promise<void> {
|
||||
const changed = deepDiff(library, values) as LibraryUpdateDto;
|
||||
|
||||
if (Object.keys(changed).length === 0) return;
|
||||
|
||||
changed.id = library.id;
|
||||
await LibraryEndpoint.updateLibrary(changed);
|
||||
setLibrarySaved(true);
|
||||
setTimeout(() => setLibrarySaved(false), 2000);
|
||||
}
|
||||
|
||||
async function handleDelete(): Promise<void> {
|
||||
try {
|
||||
await LibraryEndpoint.deleteLibrary(library.id);
|
||||
|
||||
addToast({
|
||||
title: "Library deleted",
|
||||
description: `Library ${library.name} deleted!`,
|
||||
color: "success"
|
||||
});
|
||||
|
||||
navigate("/administration/libraries");
|
||||
} catch (e) {
|
||||
addToast({
|
||||
title: "Error deleting library",
|
||||
description: `Library ${library.name} could not be deleted!`,
|
||||
color: "warning"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return <Formik
|
||||
initialValues={library}
|
||||
onSubmit={handleSubmit}
|
||||
enableReinitialize={true}
|
||||
validationSchema={Yup.object({
|
||||
name: Yup.string()
|
||||
.required("Library name is required")
|
||||
.max(255, "Library name must be 255 characters or less"),
|
||||
directories: Yup.array()
|
||||
.of(Yup.object())
|
||||
.min(1, "At least one directory is required")
|
||||
})}
|
||||
>
|
||||
{(formik) => (
|
||||
<Form>
|
||||
<div className="flex flex-row flex-grow justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Edit library details</h1>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={formik.isSubmitting}
|
||||
isDisabled={formik.isSubmitting || librarySaved || !formik.dirty}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : librarySaved ? <Check/> : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input label="Library name" name="name"/>
|
||||
|
||||
<DirectoryMappingInput name="directories"/>
|
||||
|
||||
<Section title="Danger zone"/>
|
||||
<Button color="danger" onPress={handleDelete}>
|
||||
Delete library
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Link,
|
||||
Pagination,
|
||||
Select,
|
||||
SelectItem,
|
||||
SortDescriptor,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
useDisclosure
|
||||
} from "@heroui/react";
|
||||
import {CheckCircle, MagnifyingGlass, Pencil, Trash} from "@phosphor-icons/react";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
import {gameState} from "Frontend/state/GameState";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
|
||||
import {useMemo, useState} from "react";
|
||||
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
|
||||
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||
|
||||
interface LibraryManagementGamesProps {
|
||||
library: LibraryDto;
|
||||
}
|
||||
|
||||
export default function LibraryManagementGames({library}: LibraryManagementGamesProps) {
|
||||
const rowsPerPage = 25;
|
||||
|
||||
const state = useSnapshot(gameState);
|
||||
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : [];
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "title", direction: "ascending"});
|
||||
|
||||
const [selectedGame, setSelectedGame] = useState<GameDto>(games[0]);
|
||||
const editGameModal = useDisclosure();
|
||||
const matchGameModal = useDisclosure();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pages = useMemo(() => {
|
||||
return Math.ceil(getFilteredGames().length / rowsPerPage);
|
||||
}, [games, filter]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return getFilteredGames();
|
||||
}, [games, filter, searchTerm]);
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return filteredItems.slice().sort((a, b) => {
|
||||
let cmp: number;
|
||||
|
||||
switch (sortDescriptor.column) {
|
||||
case "title":
|
||||
cmp = a.title.localeCompare(b.title);
|
||||
break;
|
||||
case "addedToLibrary":
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
case "downloadCount":
|
||||
cmp = a.metadata.downloadCount - b.metadata.downloadCount;
|
||||
break;
|
||||
default:
|
||||
return 0; // No sorting if the column is not recognized
|
||||
}
|
||||
|
||||
if (sortDescriptor.direction === "descending") {
|
||||
cmp *= -1; // Reverse the comparison if sorting in descending order
|
||||
}
|
||||
|
||||
return cmp;
|
||||
});
|
||||
}, [filteredItems, sortDescriptor]);
|
||||
|
||||
const pagedItems = useMemo(() => {
|
||||
const start = (page - 1) * rowsPerPage;
|
||||
const end = start + rowsPerPage;
|
||||
return sortedItems.slice(start, end);
|
||||
}, [page, sortedItems]);
|
||||
|
||||
|
||||
function getFilteredGames() {
|
||||
let filteredGames = games.filter((game) =>
|
||||
game.metadata.path!!.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
game.publishers?.some(publisher => publisher.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
game.developers?.some(developer => developer.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
|
||||
if (filter === "confirmed") {
|
||||
return filteredGames.filter(g => g.metadata.matchConfirmed);
|
||||
}
|
||||
if (filter === "nonConfirmed") {
|
||||
return filteredGames.filter(g => !g.metadata.matchConfirmed);
|
||||
}
|
||||
return filteredGames;
|
||||
}
|
||||
|
||||
async function toggleMatchConfirmed(game: GameDto) {
|
||||
await GameEndpoint.updateGame(
|
||||
{
|
||||
id: game.id,
|
||||
metadata: {matchConfirmed: !game.metadata.matchConfirmed}
|
||||
} as GameUpdateDto
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteGame(game: GameDto) {
|
||||
await GameEndpoint.deleteGame(game.id);
|
||||
}
|
||||
|
||||
return selectedGame && <div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-bold">Manage games in library</h1>
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
<Input
|
||||
className="w-96"
|
||||
isClearable
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onClear={() => setSearchTerm("")}
|
||||
/>
|
||||
<Select
|
||||
selectedKeys={[filter]}
|
||||
disallowEmptySelection
|
||||
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
|
||||
className="w-64"
|
||||
>
|
||||
<SelectItem key="all">Show all</SelectItem>
|
||||
<SelectItem key="confirmed">Show only confirmed</SelectItem>
|
||||
<SelectItem key="nonConfirmed">Show only non confirmed</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
<Table removeWrapper isStriped
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
bottomContent={
|
||||
<div className="flex w-full justify-center sticky">
|
||||
{pagedItems.length > 0 &&
|
||||
<Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="primary"
|
||||
page={page}
|
||||
total={pages}
|
||||
onChange={(page) => setPage(page)}
|
||||
/>}
|
||||
</div>
|
||||
}>
|
||||
<TableHeader>
|
||||
<TableColumn key="title" allowsSorting>Game</TableColumn>
|
||||
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
|
||||
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
|
||||
<TableColumn>Path</TableColumn>
|
||||
{/* width={1} keeps the column as far to the right as possible*/}
|
||||
<TableColumn width={1}>Actions</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="Your filter did not match any games." items={pagedItems}>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<Link href={`/game/${item.id}`}
|
||||
color="foreground"
|
||||
className="text-sm"
|
||||
underline="hover">{item.title} ({item.release !== undefined ? new Date(item.release).getFullYear() : "unknown"})
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.metadata.downloadCount}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.metadata.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
|
||||
{item.metadata.matchConfirmed ?
|
||||
<Tooltip content="Unconfirm match">
|
||||
<CheckCircle weight="fill" className="fill-success"/>
|
||||
</Tooltip> :
|
||||
<Tooltip content="Confirm match">
|
||||
<CheckCircle/>
|
||||
</Tooltip>}
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" onPress={() => {
|
||||
setSelectedGame(item);
|
||||
editGameModal.onOpenChange();
|
||||
}}>
|
||||
<Tooltip content="Edit metadata">
|
||||
<Pencil/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" onPress={() => {
|
||||
setSelectedGame(item);
|
||||
matchGameModal.onOpenChange();
|
||||
}}>
|
||||
<Tooltip content="Match game">
|
||||
<MagnifyingGlass/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
<Button isIconOnly size="sm" color="danger"
|
||||
onPress={() => deleteGame(item)}>
|
||||
<Tooltip content="Remove from library">
|
||||
<Trash/>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EditGameMetadataModal game={selectedGame}
|
||||
isOpen={editGameModal.isOpen}
|
||||
onOpenChange={editGameModal.onOpenChange}/>
|
||||
<MatchGameModal path={selectedGame.metadata.path!!}
|
||||
libraryId={library.id}
|
||||
replaceGameId={selectedGame.id}
|
||||
initialSearchTerm={selectedGame.title}
|
||||
isOpen={matchGameModal.isOpen}
|
||||
onOpenChange={matchGameModal.onOpenChange}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Pagination,
|
||||
SortDescriptor,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
useDisclosure
|
||||
} from "@heroui/react";
|
||||
import {MagnifyingGlass, Trash} 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 MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
|
||||
|
||||
interface LibraryManagementUnmatchedPathsProps {
|
||||
library: LibraryDto;
|
||||
}
|
||||
|
||||
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
|
||||
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 [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "path", direction: "ascending"});
|
||||
|
||||
const pages = useMemo(() => {
|
||||
return Math.ceil(getFilteredPaths().length / rowsPerPage);
|
||||
}, [library.unmatchedPaths, searchTerm]);
|
||||
|
||||
const filteredPaths = useMemo(() => {
|
||||
return library.unmatchedPaths!
|
||||
.filter((path) => path.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.map((path) => ({key: hashCode(path), path}));
|
||||
}, [library, searchTerm]);
|
||||
|
||||
const sortedPaths = useMemo(() => {
|
||||
return filteredPaths.slice().sort((a, b) => {
|
||||
let cmp: number;
|
||||
switch (sortDescriptor.column) {
|
||||
case "path":
|
||||
cmp = a.path.localeCompare(b.path);
|
||||
break;
|
||||
default:
|
||||
cmp = 0;
|
||||
}
|
||||
if (sortDescriptor.direction === "descending") {
|
||||
cmp *= -1;
|
||||
}
|
||||
return cmp;
|
||||
});
|
||||
}, [filteredPaths, sortDescriptor]);
|
||||
|
||||
const pagedPaths = useMemo(() => {
|
||||
const start = (page - 1) * rowsPerPage;
|
||||
const end = start + rowsPerPage;
|
||||
return sortedPaths.slice(start, end);
|
||||
}, [page, sortedPaths]);
|
||||
|
||||
async function deleteUnmatchedPath(unmatchedPath: string) {
|
||||
const libraryUpdateDto: LibraryUpdateDto = {
|
||||
id: library.id,
|
||||
unmatchedPaths: library.unmatchedPaths!.filter((path) => path !== unmatchedPath)
|
||||
}
|
||||
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
|
||||
}
|
||||
|
||||
function getFilteredPaths() {
|
||||
return library.unmatchedPaths!!.filter((path) =>
|
||||
path.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-bold">Manage unmatched paths</h1>
|
||||
<Input
|
||||
className="w-96"
|
||||
isClearable
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onClear={() => setSearchTerm("")}
|
||||
/>
|
||||
<Table removeWrapper isStriped isHeaderSticky
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
bottomContent={
|
||||
<div className="flex w-full justify-center">
|
||||
{pagedPaths.length > 0 &&
|
||||
<Pagination
|
||||
isCompact
|
||||
showControls
|
||||
showShadow
|
||||
color="primary"
|
||||
page={page}
|
||||
total={pages}
|
||||
onChange={(page) => setPage(page)}
|
||||
/>}
|
||||
</div>
|
||||
}>
|
||||
<TableHeader>
|
||||
<TableColumn key="path" allowsSorting>Path</TableColumn>
|
||||
<TableColumn width={1}>Actions</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="This library has no unmatched paths." items={pagedPaths}>
|
||||
{(item) => (
|
||||
<TableRow key={item.key}>
|
||||
<TableCell>
|
||||
{item.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Tooltip content="Match game">
|
||||
<Button isIconOnly size="sm" onPress={() => {
|
||||
setSelectedPath(item.path);
|
||||
matchGameModal.onOpenChange();
|
||||
}}>
|
||||
<MagnifyingGlass/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove entry from list">
|
||||
<Button isIconOnly size="sm" color="danger"
|
||||
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{selectedPath && <MatchGameModal path={selectedPath}
|
||||
libraryId={library.id}
|
||||
initialSearchTerm={fileNameFromPath(selectedPath, false)}
|
||||
isOpen={matchGameModal.isOpen}
|
||||
onOpenChange={matchGameModal.onOpenChange}/>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Select,
|
||||
SelectedItems,
|
||||
Selection,
|
||||
SelectItem
|
||||
} from "@heroui/react";
|
||||
import {UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
|
||||
import RoleChip from "Frontend/components/general/RoleChip";
|
||||
import RoleAssignmentResult from "Frontend/generated/org/gameyfin/app/users/enums/RoleAssignmentResult";
|
||||
|
||||
interface AssignRolesModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
user: UserInfoDto;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default function AssignRolesModal({isOpen, onOpenChange, user}: AssignRolesModalProps) {
|
||||
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
|
||||
const [selectedRole, setSelectedRole] = useState<Selection>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRole(rolesToSelection(user.roles));
|
||||
UserEndpoint.getRolesBelow().then((availableRoles) => {
|
||||
setAvailableRoles(availableRoles.map((role) => ({id: role.toString()})));
|
||||
});
|
||||
}, []);
|
||||
|
||||
function rolesToSelection(roles: Array<string>): Selection {
|
||||
return new Set(roles.map((role) => role.toString()));
|
||||
}
|
||||
|
||||
async function assignRoles() {
|
||||
if (!selectedRole) return;
|
||||
|
||||
let selectedRolesArray = Array.from(selectedRole).map((role) => role.toString());
|
||||
let result = await UserEndpoint.assignRoles(user.username, selectedRolesArray);
|
||||
switch (result) {
|
||||
case RoleAssignmentResult.SUCCESS:
|
||||
window.location.reload();
|
||||
break;
|
||||
case RoleAssignmentResult.NO_ROLES_PROVIDED:
|
||||
setError("Select at least one role");
|
||||
break;
|
||||
case RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH:
|
||||
setError("Power level of user too high");
|
||||
break;
|
||||
case RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH:
|
||||
setError("Power level of assigned role too high");
|
||||
break;
|
||||
default:
|
||||
setError("An error occurred");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
|
||||
hideCloseButton={true} size="lg">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Assign roles to {user.username}</ModalHeader>
|
||||
<ModalBody className="flex flex-col gap-2">
|
||||
<Select
|
||||
items={availableRoles}
|
||||
selectionMode="single"
|
||||
disallowEmptySelection={true}
|
||||
selectedKeys={selectedRole}
|
||||
onSelectionChange={setSelectedRole}
|
||||
placeholder="Select roles"
|
||||
renderValue={(items: SelectedItems<Role>) => {
|
||||
return (
|
||||
<div className="flex flex-grow flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<RoleChip key={item.key} role={item.textValue as string}/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(role) => (
|
||||
<SelectItem key={role.id} textValue={role.id}>
|
||||
<RoleChip key={role.id} role={role.id}/>
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
{error &&
|
||||
<small className="text-danger">{error}</small>
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onPress={assignRoles} isDisabled={!selectedRole}>
|
||||
Assign roles
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Button, Code, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
|
||||
|
||||
interface ConfirmUserDeletionModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
user: UserInfoDto;
|
||||
}
|
||||
|
||||
export default function ConfirmUserDeletionModal({isOpen, onOpenChange, user}: ConfirmUserDeletionModalProps) {
|
||||
const [confirmUsername, setConfirmUsername] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmUsername("");
|
||||
}, []);
|
||||
|
||||
async function deleteUser() {
|
||||
await UserEndpoint.deleteUserByName(user.username);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
|
||||
hideCloseButton={true} size="lg">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Confirm user deletion</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>
|
||||
Confirm deletion of user <Code>{user.username}</Code> by entering the username
|
||||
below
|
||||
</p>
|
||||
<Input onChange={(e) => setConfirmUsername(e.target.value)}/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="danger" onPress={deleteUser}
|
||||
isDisabled={confirmUsername != user.username}>
|
||||
Confirm deletion
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader
|
||||
} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import React from "react";
|
||||
import GameUpdateDto from "Frontend/generated/org/gameyfin/app/games/dto/GameUpdateDto";
|
||||
import {deepDiff} from "Frontend/util/utils";
|
||||
import {GameEndpoint} from "Frontend/generated/endpoints";
|
||||
import TextAreaInput from "Frontend/components/general/input/TextAreaInput";
|
||||
import * as Yup from "yup";
|
||||
import GameCoverPicker from "Frontend/components/general/input/GameCoverPicker";
|
||||
import DatePickerInput from "Frontend/components/general/input/DatePickerInput";
|
||||
import ArrayInput from "Frontend/components/general/input/ArrayInput";
|
||||
|
||||
interface EditGameMetadataModalProps {
|
||||
game: GameDto;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function EditGameMetadataModal({game, isOpen, onOpenChange}: EditGameMetadataModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
|
||||
<ModalContent>
|
||||
{(onClose) => {
|
||||
|
||||
async function updateGame(values: GameUpdateDto) {
|
||||
//@ts-ignore
|
||||
const changed = deepDiff(game, values) as GameUpdateDto;
|
||||
if (Object.keys(changed).length === 0) return;
|
||||
|
||||
changed.id = game.id;
|
||||
await GameEndpoint.updateGame(changed);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik initialValues={game}
|
||||
enableReinitialize={true}
|
||||
onSubmit={updateGame}
|
||||
validationSchema={Yup.object({
|
||||
title: Yup.string().required("Title is required")
|
||||
})}
|
||||
>
|
||||
{(formik: any) => (
|
||||
<Form>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
Update game metadata
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-row gap-8">
|
||||
{/*@ts-ignore*/}
|
||||
<GameCoverPicker key="coverUrl" name="coverUrl" game={game}/>
|
||||
<div className="flex flex-col flex-1">
|
||||
<Input key="metadata.path" name="metadata.path" label="Path"
|
||||
isDisabled/>
|
||||
<Input key="title" name="title" label="Title" isRequired/>
|
||||
<DatePickerInput key="release" name="release" label="Release"/>
|
||||
</div>
|
||||
</div>
|
||||
<TextAreaInput key="summary" name="summary" label="Summary (HTML)"/>
|
||||
<TextAreaInput key="comment" name="comment" label="Comment (Markdown)"/>
|
||||
<Accordion variant="splitted"
|
||||
itemClasses={{
|
||||
base: "-mx-2",
|
||||
content: "max-h-80 overflow-y-auto",
|
||||
}}>
|
||||
<AccordionItem key="additional-metadata"
|
||||
aria-label="Additional Metadata"
|
||||
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"/>
|
||||
<ArrayInput key="keywords" name="keywords" label="Keywords"/>
|
||||
<ArrayInput key="features" name="features" label="Features"/>
|
||||
<ArrayInput key="perspectives" name="perspectives"
|
||||
label="Perspectives"/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={formik.isSubmitting}
|
||||
isDisabled={formik.isSubmitting || !formik.dirty}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : "Save"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)
|
||||
}}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
|
||||
import {Button, Image, Input, Modal, ModalBody, ModalContent, ModalHeader, ScrollShadow} from "@heroui/react";
|
||||
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";
|
||||
|
||||
interface GameCoverPickerModalProps {
|
||||
game: GameDto;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
setCoverUrl: (url: string) => void;
|
||||
}
|
||||
|
||||
export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}: GameCoverPickerModalProps) {
|
||||
const [coverUrl, setCoverUrlState] = useState("");
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(game.title);
|
||||
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && searchTerm.length > 0 && searchResults.length === 0) {
|
||||
search();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, false);
|
||||
let validResults = results.filter(result => result.coverUrl && result.coverUrl.length > 0 && result.coverUrl !== "null");
|
||||
setSearchResults(validResults);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="2xl">
|
||||
<ModalContent>
|
||||
{(onClose) => {
|
||||
return (<>
|
||||
<ModalHeader>
|
||||
Enter a URL or search for a cover
|
||||
</ModalHeader>
|
||||
<ModalBody className="flex flex-col gap-4">
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
<Input isClearable
|
||||
placeholder="Enter a URL"
|
||||
value={coverUrl}
|
||||
onValueChange={setCoverUrlState}
|
||||
onClear={() => setCoverUrlState("")}
|
||||
/>
|
||||
<Button isIconOnly onPress={() => {
|
||||
setCoverUrl(coverUrl);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
<Input placeholder="Search"
|
||||
value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await search();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
</Button>
|
||||
</div>
|
||||
{searchResults.length === 0 && !isSearching &&
|
||||
<p className="text-center">No results found.</p>
|
||||
}
|
||||
{searchResults.length === 0 && isSearching &&
|
||||
<p className="text-center text-foreground/70">Searching...</p>
|
||||
}
|
||||
<ScrollShadow
|
||||
className="grid grid-cols-auto-fill gap-4 h-96 overflow-scroll justify-evenly">
|
||||
{searchResults.map((result) => (
|
||||
<div className="relative group w-fit h-fit cursor-pointer"
|
||||
onClick={() => {
|
||||
setCoverUrl(result.coverUrl!);
|
||||
onClose();
|
||||
}}>
|
||||
<Image
|
||||
key={result.id}
|
||||
alt={result.title}
|
||||
className="z-0 object-cover aspect-[12/17] group-hover:brightness-50"
|
||||
src={result.coverUrl!}
|
||||
radius="none"
|
||||
height={216}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<ArrowRight size={46}/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollShadow>
|
||||
</ModalBody>
|
||||
</>)
|
||||
}}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {addToast, Button, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||
|
||||
interface InviteUserModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function InviteUserModal({isOpen, onOpenChange}: InviteUserModalProps) {
|
||||
const [email, setEmail] = useState<string | null>();
|
||||
const [error, setError] = useState<string | null>();
|
||||
|
||||
useEffect(() => {
|
||||
setEmail(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
async function inviteUser(onClose: () => void) {
|
||||
if (!email) return;
|
||||
|
||||
if (await UserEndpoint.existsByMail(email)) {
|
||||
setError("User with this email already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await RegistrationEndpoint.createInvitation(email);
|
||||
addToast({
|
||||
title: "Invitation sent",
|
||||
description: "The user will receive an email with further instructions shortly.",
|
||||
color: "success"
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError("Failed to create invitation");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Invite a new user</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>Enter the email address of the user you want to invite:</p>
|
||||
<Input errorMessage={error} onChange={(e) => setEmail(e.target.value)} type="email"/>
|
||||
{error && <small className="text-danger">{error}</small>}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="success" onPress={() => inviteUser(onClose)}
|
||||
isDisabled={email === null || email === undefined || email.length < 1}>
|
||||
Send invitation
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, {useState} from "react";
|
||||
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import * as Yup from "yup";
|
||||
import DirectoryMappingInput from "Frontend/components/general/input/DirectoryMappingInput";
|
||||
|
||||
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);
|
||||
|
||||
async function createLibrary(library: LibraryDto) {
|
||||
try {
|
||||
await LibraryEndpoint.createLibrary(library as LibraryDto, scanAfterCreation);
|
||||
|
||||
addToast({
|
||||
title: "New library created",
|
||||
description: `Library ${library.name} created!`,
|
||||
color: "success"
|
||||
});
|
||||
} catch (e) {
|
||||
addToast({
|
||||
title: "Error creating library",
|
||||
description: `Library ${library.name} could not be created!`,
|
||||
color: "warning"
|
||||
});
|
||||
throw "Error creating library: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik initialValues={{name: "", directories: []}}
|
||||
validationSchema={Yup.object({
|
||||
name: Yup.string()
|
||||
.required("Library name is required")
|
||||
.max(255, "Library name must be 255 characters or less"),
|
||||
directories: Yup.array()
|
||||
.of(Yup.object())
|
||||
.min(1, "At least one directory is required")
|
||||
})}
|
||||
isInitialValid={false}
|
||||
onSubmit={async (values: any) => {
|
||||
await createLibrary(values);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{(formik) =>
|
||||
<Form>
|
||||
<ModalHeader className="flex flex-col gap-1">Add a new library</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
name="name"
|
||||
label="Library Name"
|
||||
placeholder="Enter library name"
|
||||
value={formik.values.name}
|
||||
required
|
||||
/>
|
||||
<DirectoryMappingInput name="directories"/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter className="flex flex-row justify-between">
|
||||
<Checkbox isSelected={scanAfterCreation} onValueChange={setScanAfterCreation}>Scan
|
||||
after creation?</Checkbox>
|
||||
<div className="flex flex-row">
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary"
|
||||
isLoading={formik.isSubmitting}
|
||||
isDisabled={formik.isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</Form>
|
||||
}
|
||||
</Formik>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip
|
||||
} from "@heroui/react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {ArrowRight, MagnifyingGlass} 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";
|
||||
|
||||
interface EditGameMetadataModalProps {
|
||||
path: string;
|
||||
libraryId: number;
|
||||
replaceGameId?: number;
|
||||
initialSearchTerm: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function MatchGameModal({
|
||||
path,
|
||||
libraryId,
|
||||
replaceGameId,
|
||||
initialSearchTerm,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: EditGameMetadataModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isMatching, setIsMatching] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(initialSearchTerm);
|
||||
setSearchResults([]);
|
||||
}, [isOpen]);
|
||||
|
||||
async function matchGame(result: GameSearchResultDto) {
|
||||
await GameEndpoint.matchManually(result.originalIds, path, libraryId, replaceGameId);
|
||||
}
|
||||
|
||||
async function search() {
|
||||
setIsSearching(true);
|
||||
const results = await GameEndpoint.getPotentialMatches(searchTerm, true);
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}
|
||||
hideCloseButton
|
||||
isDismissable={!isSearching && !isMatching}
|
||||
isKeyboardDismissDisabled={!isSearching && !isMatching}
|
||||
backdrop="opaque" size="5xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<ModalBody className="my-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<pre>{path}</pre>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 mb-4">
|
||||
<Input value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await search();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
|
||||
<MagnifyingGlass/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Table removeWrapper isStriped isHeaderSticky
|
||||
classNames={{
|
||||
base: "h-80 overflow-scroll",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>Title & Release</TableColumn>
|
||||
<TableColumn>Developer(s)</TableColumn>
|
||||
<TableColumn>Publisher(s)</TableColumn>
|
||||
{/* width={1} keeps the column as far to the right as possible*/}
|
||||
<TableColumn>Sources</TableColumn>
|
||||
<TableColumn width={1}> </TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="Your filter did not match any games." items={searchResults}>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
{item.developers ? item.developers.map(
|
||||
developer => <p>{developer}</p>
|
||||
) : "unknown"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
{item.publishers ? item.publishers.map(
|
||||
publisher => <p>{publisher}</p>
|
||||
) : "unknown"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row gap-2">
|
||||
{Object.values(item.originalIds).map(
|
||||
originalId => <PluginIcon pluginId={originalId.pluginId}/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip content="Pick this result">
|
||||
<Button isIconOnly size="sm"
|
||||
isDisabled={isMatching !== null}
|
||||
isLoading={isMatching === item.id}
|
||||
onPress={async () => {
|
||||
setIsMatching(item.id);
|
||||
await matchGame(item);
|
||||
setIsMatching(null);
|
||||
onClose();
|
||||
}}>
|
||||
<ArrowRight/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ModalBody>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
|
||||
interface PasswordResetModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function PasswordResetModal({
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: PasswordResetModalProps) {
|
||||
const [canResetPassword, setCanResetPassword] = useState(false);
|
||||
const [resetEmail, setResetEmail] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
MessageEndpoint.isEnabled().then(setCanResetPassword);
|
||||
}, []);
|
||||
|
||||
async function resetPassword() {
|
||||
if (!resetEmail) return;
|
||||
|
||||
await PasswordResetEndpoint.requestPasswordReset(resetEmail);
|
||||
addToast({
|
||||
title: "Password reset requested",
|
||||
description: "If the email address is registered, you will receive a message with further instructions.",
|
||||
color: "success"
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
|
||||
<ModalBody>
|
||||
{canResetPassword ?
|
||||
<NextInput
|
||||
onChange={(event: any) => {
|
||||
setResetEmail(event.target.value);
|
||||
}}
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
/> :
|
||||
<div className="flex flex-row items-center gap-4 text-warning">
|
||||
<WarningCircle size={40}/>
|
||||
<p>
|
||||
Password self-service is disabled.<br/>
|
||||
To reset your password please contact your administrator.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary"
|
||||
isDisabled={!canResetPassword}
|
||||
onPress={async () => {
|
||||
await resetPassword();
|
||||
onClose();
|
||||
}}>
|
||||
Send request
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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 {timeUntil} from "Frontend/util/utils";
|
||||
|
||||
interface PasswordResetTokenModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
token: TokenDto;
|
||||
}
|
||||
|
||||
export default function PasswordResetTokenModal({isOpen, onOpenChange, token}: PasswordResetTokenModalProps) {
|
||||
const [timeUntilExpiry, setTimeUntilExpiry] = useState<string>("");
|
||||
|
||||
const timeoutRefresh = setInterval(updateTimeUntilExpiry, 1000);
|
||||
|
||||
useEffect(updateTimeUntilExpiry, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearInterval(timeoutRefresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function passwordResetLink() {
|
||||
return `${document.baseURI}reset-password?token=${token.secret}`;
|
||||
}
|
||||
|
||||
function updateTimeUntilExpiry() {
|
||||
if (!token) return;
|
||||
setTimeUntilExpiry(timeUntil(token.expiresAt as string));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} isDismissable={false}
|
||||
backdrop="opaque" size="4xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
The user can reset their password using the following link
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Snippet symbol="">{passwordResetLink()}</Snippet>
|
||||
{
|
||||
!timeUntilExpiry.endsWith("ago")
|
||||
? <small className="text-warning">
|
||||
This link will expire {timeUntilExpiry}
|
||||
</small>
|
||||
: <small className="text-danger">
|
||||
This link has expired {timeUntilExpiry}
|
||||
</small>
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" onPress={onClose}>
|
||||
OK
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
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";
|
||||
|
||||
interface PathPickerModalProps {
|
||||
returnSelectedPath: (path: DirectoryMappingDto) => void;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function PathPickerModal({returnSelectedPath, isOpen, onOpenChange}: PathPickerModalProps) {
|
||||
const [internalPath, setInternalPath] = useState("");
|
||||
const [externalPath, setExternalPath] = useState("");
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="3xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik initialValues={{internalPath: internalPath, externalPath: externalPath}}
|
||||
onSubmit={(values: DirectoryMappingDto) => {
|
||||
returnSelectedPath(values);
|
||||
setInternalPath("");
|
||||
setExternalPath("");
|
||||
onClose();
|
||||
}}>
|
||||
{(formik) => {
|
||||
useEffect(() => {
|
||||
formik.setFieldValue("internalPath", internalPath);
|
||||
}, [internalPath]);
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<ModalHeader className="flex flex-col gap-1">Select a folder</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Input
|
||||
name="internalPath"
|
||||
label="Selected directory"
|
||||
placeholder=" "
|
||||
value={formik.values.internalPath}
|
||||
isDisabled
|
||||
required
|
||||
/>
|
||||
<ArrowRight className="mb-8"/>
|
||||
<Input
|
||||
name="externalPath"
|
||||
label="External path (optional)"
|
||||
placeholder=" "
|
||||
value={formik.values.externalPath}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-64 overflow-auto">
|
||||
<FileTreeView onPathChange={setInternalPath}/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary"
|
||||
isLoading={formik.isSubmitting}
|
||||
isDisabled={formik.isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : "Select"}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, {useState} from "react";
|
||||
import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Tooltip} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import PluginLogo from "Frontend/components/general/plugin/PluginLogo";
|
||||
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 PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
|
||||
import PluginConfigFormField from "Frontend/components/general/plugin/PluginConfigFormField";
|
||||
|
||||
interface PluginDetailsModalProps {
|
||||
plugin: PluginDto;
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
enum ValidationState {
|
||||
UNCHECKED,
|
||||
VALID,
|
||||
INVALID,
|
||||
IN_PROGRESS
|
||||
}
|
||||
|
||||
export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: PluginDetailsModalProps) {
|
||||
const [configValidated, setConfigValidated] = useState<ValidationState>(ValidationState.UNCHECKED);
|
||||
|
||||
async function saveConfig(values: Record<string, string>) {
|
||||
await PluginEndpoint.updateConfig(plugin.id, values);
|
||||
addToast({
|
||||
title: "Configuration saved",
|
||||
description: `Configuration for plugin ${plugin.name} saved!`,
|
||||
color: "success"
|
||||
});
|
||||
}
|
||||
|
||||
function getEffectiveConfig(): Record<string, any> {
|
||||
const effectiveConfig: Record<string, any> = {};
|
||||
if (!plugin.configMetadata) return effectiveConfig;
|
||||
|
||||
for (const meta of plugin.configMetadata) {
|
||||
const key = meta.key;
|
||||
let value = plugin.config?.[key] ?? meta.default;
|
||||
|
||||
if (value != null) {
|
||||
switch (meta.type.toLowerCase()) {
|
||||
case "float":
|
||||
case "int":
|
||||
effectiveConfig[key] = Number(value);
|
||||
break;
|
||||
case "boolean":
|
||||
effectiveConfig[key] = value === true || value === "true";
|
||||
break;
|
||||
default:
|
||||
effectiveConfig[key] = value.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return effectiveConfig;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
||||
<ModalContent>
|
||||
{(onClose) => {
|
||||
|
||||
async function handleSubmit(values: Record<string, string>): Promise<void> {
|
||||
await saveConfig(values);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik initialValues={getEffectiveConfig()}
|
||||
initialErrors={plugin.configValidation?.errors}
|
||||
enableReinitialize={true}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{(formik: any) => (
|
||||
<Form>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
Plugin configuration for {plugin.name}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col text-sm">
|
||||
<div className="flex flex-row items-center gap-8 mb-4">
|
||||
<PluginLogo plugin={plugin}/>
|
||||
<table className="text-left table-auto">
|
||||
<tbody>
|
||||
{Object.entries({
|
||||
"Author(s)": plugin.author,
|
||||
"Version": plugin.version,
|
||||
"License": plugin.license,
|
||||
"URL": <Link isExternal
|
||||
showAnchorIcon
|
||||
color="foreground"
|
||||
size="sm"
|
||||
href={plugin.url}>
|
||||
{plugin.url}
|
||||
</Link>,
|
||||
}).map(([key, value]) => {
|
||||
if (!value) return;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className="text-default-500 w-0 min-w-20">{key}</td>
|
||||
<td className="flex flex-row gap-1">{value}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-default-500">Description</p>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
components={{
|
||||
a(props) {
|
||||
return <Link isExternal
|
||||
showAnchorIcon
|
||||
color="foreground"
|
||||
underline="always"
|
||||
href={props.href}
|
||||
size="sm">
|
||||
{props.children}
|
||||
</Link>
|
||||
}
|
||||
}}
|
||||
>{plugin.description}</Markdown>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center mt-4 gap-2">
|
||||
<h4 className="text-l font-bold">Configuration</h4>
|
||||
{(plugin.configMetadata && plugin.configMetadata.length > 0) && <>
|
||||
<div className="flex-1"/>
|
||||
{(() => {
|
||||
switch (configValidated) {
|
||||
case ValidationState.VALID:
|
||||
return <p className="text-small text-success">
|
||||
Configuration valid
|
||||
</p>;
|
||||
case ValidationState.INVALID:
|
||||
return <p className="text-small text-danger">
|
||||
Configuration invalid
|
||||
</p>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
<Tooltip content="Re-validate configuration" placement="bottom"
|
||||
color="foreground">
|
||||
<Button isIconOnly variant="light" size="sm"
|
||||
isLoading={configValidated === ValidationState.IN_PROGRESS}
|
||||
onPress={async () => {
|
||||
setConfigValidated(ValidationState.IN_PROGRESS);
|
||||
let result = await PluginEndpoint.validateNewConfig(plugin.id, formik.values)
|
||||
if (result.errors) {
|
||||
formik.setErrors(result.errors);
|
||||
setConfigValidated(ValidationState.INVALID);
|
||||
} else {
|
||||
setConfigValidated(ValidationState.VALID);
|
||||
}
|
||||
setTimeout(() => setConfigValidated(ValidationState.UNCHECKED), 5000);
|
||||
}}>
|
||||
<ArrowClockwise/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>}
|
||||
</div>
|
||||
{(plugin.configMetadata && plugin.configMetadata.length > 0) ?
|
||||
plugin.configMetadata.map((entry: PluginConfigMetadataDto) => (
|
||||
<PluginConfigFormField
|
||||
key={entry.key}
|
||||
pluginConfigMetadata={entry}
|
||||
showErrorUntouched={true}/>
|
||||
)) : "This plugin has no configuration options."
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{(plugin.configMetadata && plugin.configMetadata?.length > 0) ?
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={formik.isSubmitting}
|
||||
isDisabled={formik.isSubmitting || !formik.dirty}
|
||||
type="submit"
|
||||
>
|
||||
{formik.isSubmitting ? "" : "Save"}
|
||||
</Button> : ""}
|
||||
</ModalFooter>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
</Formik>
|
||||
)
|
||||
}}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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 {useListData} from "@react-stately/data";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
|
||||
interface PluginPrioritiesModalProps {
|
||||
plugins: PluginDto[];
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function PluginPrioritiesModal({plugins, isOpen, onOpenChange}: PluginPrioritiesModalProps) {
|
||||
|
||||
const sortedPlugins = useListData({
|
||||
initialItems: plugins, // Already sorted in parent
|
||||
getKey: (plugin) => plugin.id
|
||||
});
|
||||
|
||||
let {dragAndDropHooks} = useDragAndDrop({
|
||||
getItems: (keys) =>
|
||||
[...keys].map((key) => ({'text/plain': sortedPlugins.getItem(key)!.name})),
|
||||
onReorder(e) {
|
||||
if (e.keys.has(e.target.key)) return;
|
||||
|
||||
if (e.target.dropPosition === 'before' || e.target.dropPosition === 'on') {
|
||||
sortedPlugins.moveBefore(e.target.key, e.keys);
|
||||
} else if (e.target.dropPosition === 'after') {
|
||||
sortedPlugins.moveAfter(e.target.key, e.keys);
|
||||
}
|
||||
|
||||
// Recalculate priority based on new position (reversed)
|
||||
sortedPlugins.items.forEach((plugin, index) => {
|
||||
const reversedPriority = sortedPlugins.items.length - index;
|
||||
sortedPlugins.update(plugin.id, {...plugin, priority: reversedPriority});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function generatePrioritiesMap(): Record<string, number> {
|
||||
let map: Record<string, number> = {};
|
||||
const totalPlugins = sortedPlugins.items.length;
|
||||
sortedPlugins.items.forEach((plugin, index) => {
|
||||
map[plugin.id] = totalPlugins - index; // Reverse order
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
async function setPluginPriorities(onClose: () => void) {
|
||||
try {
|
||||
const prioritiesMap = generatePrioritiesMap();
|
||||
await PluginEndpoint.setPluginPriorities(prioritiesMap);
|
||||
|
||||
addToast({
|
||||
title: "Plugin order updated",
|
||||
description: "Plugin order has been updated successfully.",
|
||||
color: "success"
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
addToast({
|
||||
title: "Error",
|
||||
description: "An error occurred while updating plugin order.",
|
||||
color: "warning"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<p>Edit plugin order</p>
|
||||
<p className="text-small font-normal">Plugins higher on the list are preferred</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<ListBox items={sortedPlugins.items}
|
||||
dragAndDropHooks={dragAndDropHooks}
|
||||
className="flex flex-col gap-2">
|
||||
{(plugin: PluginDto) => (
|
||||
<ListBoxItem
|
||||
key={plugin.id}
|
||||
className="flex flex-row p-2 rounded-lg justify-between items-center bg-foreground/5">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Chip size="sm" color="primary">
|
||||
{sortedPlugins.items.findIndex(p => p.id === plugin.id) + 1}
|
||||
</Chip>
|
||||
<p className="font-normal text-small">{plugin.name}</p>
|
||||
</div>
|
||||
<CaretUpDown/>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="success" onPress={() => setPluginPriorities(onClose)}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
|
||||
import UserRegistrationDto from "Frontend/generated/org/gameyfin/app/users/dto/UserRegistrationDto";
|
||||
import {Form, Formik} from "formik";
|
||||
import * as Yup from "yup";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
|
||||
interface SignUpModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
}
|
||||
|
||||
export default function SignUpModal({
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: SignUpModalProps) {
|
||||
|
||||
async function signUp(registration: UserRegistrationDto, onClose: () => void) {
|
||||
try {
|
||||
await RegistrationEndpoint.registerUser({
|
||||
username: registration.username,
|
||||
password: registration.password,
|
||||
email: registration.email
|
||||
});
|
||||
|
||||
onClose();
|
||||
|
||||
addToast({
|
||||
title: "Account created",
|
||||
description: "You will receive an email with further instructions shortly.",
|
||||
color: "success"
|
||||
});
|
||||
} catch (_) {
|
||||
addToast({
|
||||
title: "Registration failed",
|
||||
description: "An error occurred while registering your account.",
|
||||
color: "danger"
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik initialValues={{}}
|
||||
onSubmit={async (values: any, {setFieldError}) => {
|
||||
let usernameAvailable = await RegistrationEndpoint.isUsernameAvailable(values.username);
|
||||
if (!usernameAvailable) {
|
||||
setFieldError('username', 'Username already taken');
|
||||
return;
|
||||
} else {
|
||||
await signUp(values, onClose);
|
||||
}
|
||||
}}
|
||||
validationSchema={Yup.object({
|
||||
username: Yup.string()
|
||||
.required('Required'),
|
||||
password: Yup.string()
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.required('Required'),
|
||||
email: Yup.string()
|
||||
.email()
|
||||
.required('Required'),
|
||||
passwordRepeat: Yup.string()
|
||||
.equals([Yup.ref('password')], 'Passwords do not match')
|
||||
.required('Required')
|
||||
})}>
|
||||
<Form>
|
||||
<ModalHeader className="flex flex-col gap-1">Register a new account</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col">
|
||||
<Input
|
||||
label="Username"
|
||||
name="username"
|
||||
type="text"
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail"
|
||||
name="email"
|
||||
type="email"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
/>
|
||||
<Input
|
||||
label="Password (repeat)"
|
||||
name="passwordRepeat"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" type="submit">
|
||||
Create account
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Form>
|
||||
</Formik>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SelectInput from "Frontend/components/general/input/SelectInput";
|
||||
import CheckboxInput from "Frontend/components/general/input/CheckboxInput";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
import React from "react";
|
||||
import PluginConfigMetadataDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginConfigMetadataDto";
|
||||
|
||||
export default function PluginConfigFormField({pluginConfigMetadata, ...props}: any) {
|
||||
function inputElement(metadata: PluginConfigMetadataDto) {
|
||||
|
||||
if (metadata.allowedValues != null && metadata.allowedValues.length > 0) {
|
||||
return (
|
||||
<SelectInput label={metadata.label}
|
||||
name={metadata.key}
|
||||
values={metadata.allowedValues}
|
||||
isRequired={metadata.required}
|
||||
{...props}/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (metadata.type.toLowerCase()) {
|
||||
case "boolean":
|
||||
return (
|
||||
<CheckboxInput label={metadata.label}
|
||||
name={metadata.key}
|
||||
{...props}/>
|
||||
);
|
||||
case "string":
|
||||
return (
|
||||
<Input label={metadata.label}
|
||||
name={metadata.key}
|
||||
type={metadata.secret ? "password" : "text"}
|
||||
isRequired={metadata.required}
|
||||
{...props}/>
|
||||
);
|
||||
case "float":
|
||||
return (
|
||||
<Input label={metadata.label}
|
||||
name={metadata.key}
|
||||
type="number"
|
||||
isRequired={metadata.required}
|
||||
step="0.1"
|
||||
{...props}/>
|
||||
);
|
||||
case "int":
|
||||
return (
|
||||
<Input label={metadata.label}
|
||||
name={metadata.key}
|
||||
type="number"
|
||||
isRequired={metadata.required}
|
||||
step="1"
|
||||
{...props}/>
|
||||
);
|
||||
default:
|
||||
return <pre>Unsupported type: {metadata.type} for key {metadata.key}</pre>;
|
||||
}
|
||||
}
|
||||
|
||||
return inputElement(pluginConfigMetadata!);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {Image, Tooltip} from "@heroui/react";
|
||||
import {Plug} from "@phosphor-icons/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import {useSnapshot} from "valtio/react";
|
||||
|
||||
interface PluginLogoProps {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export default function PluginIcon({pluginId}: PluginLogoProps) {
|
||||
const state = useSnapshot(pluginState);
|
||||
|
||||
return state.isLoaded && (
|
||||
<Tooltip content={state.state[pluginId].name}>
|
||||
{state.state[pluginId].hasLogo ?
|
||||
<Image src={`/images/plugins/${state.state[pluginId].id}/logo`} width={16} height={16} radius="none"/> :
|
||||
<Plug size={16} weight="fill"/>
|
||||
}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Plug} from "@phosphor-icons/react";
|
||||
import React from "react";
|
||||
import {Image} from "@heroui/react";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface PluginLogoProps {
|
||||
plugin: PluginDto;
|
||||
}
|
||||
|
||||
export default function PluginLogo({plugin}: PluginLogoProps) {
|
||||
return (
|
||||
<>
|
||||
{plugin.hasLogo ?
|
||||
<Image isBlurred src={`/images/plugins/${plugin.id}/logo`} width={64} height={64} radius="none"/> :
|
||||
<Plug size={64} weight="fill"/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {Button, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import {ListNumbers} from "@phosphor-icons/react";
|
||||
import {PluginManagementCard} from "Frontend/components/general/cards/PluginManagementCard";
|
||||
import React from "react";
|
||||
import PluginPrioritiesModal from "Frontend/components/general/modals/PluginPrioritiesModal";
|
||||
import {camelCaseToTitle} from "Frontend/util/utils";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface PluginManagementSectionProps {
|
||||
type: string;
|
||||
plugins: PluginDto[];
|
||||
}
|
||||
|
||||
export function PluginManagementSection({type, plugins}: PluginManagementSectionProps) {
|
||||
const pluginPrioritiesModal = useDisclosure();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row flex-grow justify-between">
|
||||
<h2 className="text-xl font-bold">{camelCaseToTitle(type)}</h2>
|
||||
|
||||
<Tooltip color="foreground" placement="left" content="Change plugin order">
|
||||
<Button isIconOnly variant="flat" onPress={pluginPrioritiesModal.onOpen}>
|
||||
<ListNumbers/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-300px gap-4">
|
||||
{plugins.map((plugin) =>
|
||||
<PluginManagementCard plugin={plugin} key={plugin.id}/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PluginPrioritiesModal
|
||||
key={plugins.map(p => p.id + p.priority).join(',')} // force re-mount if plugin order changes
|
||||
plugins={[...plugins].sort((a, b) => b.priority - a.priority)}
|
||||
isOpen={pluginPrioritiesModal.isOpen}
|
||||
onOpenChange={pluginPrioritiesModal.onOpenChange}
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {Outlet} from "react-router";
|
||||
import {Icon} from "@phosphor-icons/react";
|
||||
import {Listbox, ListboxItem} from "@heroui/react";
|
||||
import {ReactElement, useState} from "react";
|
||||
|
||||
export type MenuItem = {
|
||||
title: string,
|
||||
url: string,
|
||||
icon: ReactElement<Icon>
|
||||
}
|
||||
|
||||
export default function withSideMenu(baseUrl: string, menuItems: MenuItem[]) {
|
||||
return function PageWithSideMenu() {
|
||||
const [selectedItem, setSelectedItem] = useState<string>(initialSelected)
|
||||
|
||||
/**
|
||||
* Remove a "/" at the start if it exists
|
||||
*/
|
||||
function key(k: string): string {
|
||||
return k.replace(/^(\/)/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* If the key starts with "/" assume it's an absolute link, else assume it's relative
|
||||
*/
|
||||
function link(l: string): string {
|
||||
if (l.startsWith("/")) return baseUrl + l;
|
||||
return baseUrl + "/" + l;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the initially selected item by current URL path
|
||||
*/
|
||||
function initialSelected(): string {
|
||||
const p = window.location.pathname;
|
||||
const idx = p.indexOf(baseUrl);
|
||||
if (idx === -1) return "";
|
||||
const afterBase = p.substring(idx + baseUrl.length);
|
||||
// Remove leading slash, then split and take the first segment
|
||||
return afterBase.replace(/^\/+/, "").split("/")[0] || "";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<div className="flex flex-col pr-8">
|
||||
<Listbox className="w-60 fixed" color="primary">
|
||||
{menuItems.map((i) => (
|
||||
<ListboxItem key={key(i.url)} startContent={i.icon} href={link(i.url)}
|
||||
onPress={() => setSelectedItem(i.url)}
|
||||
className={`h-12 ${key(i.url) === selectedItem ? "bg-primary" : ""}`}>
|
||||
<p>{i.title}</p>
|
||||
</ListboxItem>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="ml-60 flex-1 overflow-auto">
|
||||
<Outlet/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user