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

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