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,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user