feat: implemented storage management
fix: Enabled fallback secrets feat: Made header stats actually work feat: Made steam deck keyboard auto open for some inputs fix: Made keybaord also work with shortcuts (no tooltips yet)
This commit is contained in:
parent
62f16cbcc1
commit
e4df8fb9fb
55 changed files with 1675 additions and 398 deletions
|
|
@ -9,12 +9,13 @@ import { AutoFocus } from './AutoFocus';
|
|||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
import { Router } from '..';
|
||||
import { PopSource } from '../scripts/spatialNavigation';
|
||||
import { GameListFilterType } from '@/shared/constants';
|
||||
|
||||
export interface CollectionsDetailParams
|
||||
{
|
||||
id?: string;
|
||||
setBackground: (url: string) => void;
|
||||
filters: GameListFilter;
|
||||
filters?: GameListFilterType;
|
||||
headerTitle?: JSX.Element;
|
||||
title?: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
|
|
@ -32,7 +33,7 @@ function HandleGoBack ()
|
|||
|
||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||
{
|
||||
const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`;
|
||||
const focusKey = `game-list-${data.id}-${data.filters ? Object.values(data.filters).map(f => String(f)).join(",") : ''}`;
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey,
|
||||
preferredChildFocusKey: `${focusKey}-list`,
|
||||
|
|
@ -51,7 +52,14 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
<div className="h-fit w-full px-6 pt-4 pb-32">
|
||||
{data.title}
|
||||
<Suspense>
|
||||
<GameList grid setBackground={data.setBackground} filters={data.filters} id={`${focusKey}-list`}></GameList>
|
||||
<GameList
|
||||
grid
|
||||
setBackground={data.setBackground}
|
||||
filters={data.filters}
|
||||
onFocus={(node) => node.scrollIntoView({ block: 'center', behavior: 'smooth' })}
|
||||
id={`${focusKey}-list`}>
|
||||
|
||||
</GameList>
|
||||
<AutoFocus focus={focusSelf} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,20 +2,19 @@ import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedi
|
|||
import classNames from "classnames";
|
||||
import { createContext, JSX, useContext, useEffect } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { X } from "lucide-react";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||
|
||||
const ContextDialogContext = createContext({} as {
|
||||
close: () => void,
|
||||
id: string;
|
||||
});
|
||||
|
||||
export function ContextList (data: { options: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
||||
export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
||||
{
|
||||
const context = useContext(ContextDialogContext);
|
||||
return <ul className={twMerge("list max-h-[70vh] overflow-y-auto", data.className)}>
|
||||
{data.options.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
|
||||
return <ul className={twMerge("list", data.className)}>
|
||||
{data.options?.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
|
||||
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={context.close} id="close" content="Close" />}
|
||||
</ul>;
|
||||
}
|
||||
|
|
@ -29,30 +28,37 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
|||
data.onFocus?.();
|
||||
};
|
||||
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
|
||||
focusKey: `${context.id}-list-option-${data.id}`,
|
||||
onEnterPress: handleAction,
|
||||
onFocus: handleFocus
|
||||
onFocus: handleFocus,
|
||||
trackChildren: typeof data.content !== 'string'
|
||||
});
|
||||
const colors = {
|
||||
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused }),
|
||||
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused }),
|
||||
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused }),
|
||||
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused }),
|
||||
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused }),
|
||||
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused })
|
||||
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused || hasFocusedChild }),
|
||||
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused || hasFocusedChild }),
|
||||
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused || hasFocusedChild }),
|
||||
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused || hasFocusedChild }),
|
||||
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused || hasFocusedChild }),
|
||||
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused || hasFocusedChild })
|
||||
};
|
||||
if (data.shortcuts)
|
||||
{
|
||||
useShortcuts(focusKey, () => data.shortcuts!, [data.shortcuts]);
|
||||
}
|
||||
return <li ref={ref}
|
||||
onClick={handleAction}
|
||||
className={
|
||||
twMerge("flex cursor-pointer")}>
|
||||
<p className={twMerge("flex w-full h-14 items-center px-5 rounded-2xl transition-all gap-2",
|
||||
colors[data.type],
|
||||
classNames({ "font-semibold": focused }),
|
||||
data.className)}>
|
||||
{data.icon}
|
||||
{data.content}
|
||||
</p>
|
||||
<FocusContext value={focusKey}>
|
||||
<div className={twMerge("flex w-full h-14 items-center px-4 rounded-2xl transition-all gap-2",
|
||||
colors[data.type],
|
||||
classNames({ "font-semibold": focused || hasFocusedChild }),
|
||||
data.className)}>
|
||||
{data.icon}
|
||||
{data.content}
|
||||
</div>
|
||||
</FocusContext>
|
||||
</li>;
|
||||
}
|
||||
|
||||
|
|
@ -63,11 +69,23 @@ export interface DialogEntry
|
|||
icon?: string | JSX.Element;
|
||||
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
|
||||
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
|
||||
shortcuts?: Shortcut[];
|
||||
}
|
||||
|
||||
export function ContextDialog (data: { id: string, children: any | any[], open: boolean, close: () => void; })
|
||||
export function ContextDialog (data: {
|
||||
id: string,
|
||||
children: any | any[],
|
||||
open: boolean, close: () => void;
|
||||
className?: string;
|
||||
preferredChildFocusKey?: string;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, focusKey: `${data.id}-context-dialog`, isFocusBoundary: true });
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusable: data.open,
|
||||
focusKey: `${data.id}-context-dialog`,
|
||||
isFocusBoundary: true,
|
||||
preferredChildFocusKey: data.preferredChildFocusKey
|
||||
});
|
||||
useEffect(() =>
|
||||
{
|
||||
if (data.open)
|
||||
|
|
@ -76,14 +94,14 @@ export function ContextDialog (data: { id: string, children: any | any[], open:
|
|||
}
|
||||
}, [data.open]);
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
useShortcuts(focusKey, () => data.open ? [{
|
||||
label: "Close",
|
||||
button: GamePadButtonCode.B,
|
||||
action: () =>
|
||||
{
|
||||
data.close();
|
||||
}
|
||||
}], []);
|
||||
}] : [], [data.open]);
|
||||
|
||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
||||
twMerge("absolute modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||
|
|
@ -96,7 +114,11 @@ export function ContextDialog (data: { id: string, children: any | any[], open:
|
|||
<FocusContext value={focusKey}>
|
||||
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
||||
<div
|
||||
className={twMerge("bg-base-100/80 delay-200 rounded-4xl p-6 min-w-[30vw] cursor-auto", data.open ? "animate-scale-delayed" : "opacity-0")}
|
||||
className={twMerge(
|
||||
"bg-base-100/80 delay-200 rounded-4xl p-6 min-w-[30vw] cursor-auto",
|
||||
data.open ? "animate-scale-delayed" : "opacity-0",
|
||||
data.className)
|
||||
}
|
||||
style={{ backdropFilter: 'blur(24px)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
|
|
|||
286
src/mainview/components/FilePicker.tsx
Normal file
286
src/mainview/components/FilePicker.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ContextList, DialogEntry, OptionElement } from "./ContextDialog";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { createContext, useContext, useRef, useState } from "react";
|
||||
import path from "pathe";
|
||||
import { Check, File, Folder, FolderClosed, FolderInput, FolderOutput, FolderPlus, HardDrive, Plus, Save, Undo, Usb, X } from "lucide-react";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { DirType, Drive } from "@/shared/constants";
|
||||
import classNames from "classnames";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||
import SvgIcon from "./SvgIcon";
|
||||
import { Button } from "./options/Button";
|
||||
import toast from "react-hot-toast";
|
||||
import { drivesQuery, filesQuery } from "../scripts/queries";
|
||||
|
||||
const FilePickerContext = createContext<{
|
||||
allowNewFolderCreation: boolean;
|
||||
isDirectoryPicker: boolean;
|
||||
setCurrentPath: (path: string) => void;
|
||||
currentPath: string | undefined,
|
||||
startingPath: string | undefined;
|
||||
refetchFiles: () => void;
|
||||
drives: Drive[],
|
||||
activeDrive: Drive | undefined;
|
||||
}>({} as any);
|
||||
|
||||
function List (data: {
|
||||
id: string,
|
||||
parentPath: string,
|
||||
dirs: DirType[],
|
||||
select: (path: string) => void;
|
||||
|
||||
})
|
||||
{
|
||||
const { setCurrentPath, startingPath, allowNewFolderCreation, currentPath, isDirectoryPicker } = useContext(FilePickerContext);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: data.id, preferredChildFocusKey: `${data.id}...` });
|
||||
const handleReturn = () => setCurrentPath(data.parentPath);
|
||||
useShortcuts(focusKey, () => [{ label: "Directoy Up", button: GamePadButtonCode.L1, action: handleReturn }], [handleReturn]);
|
||||
return <div ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
<ContextList showCloseButton={false}
|
||||
options={[
|
||||
{
|
||||
action: handleReturn,
|
||||
id: `${data.id}...`,
|
||||
type: 'primary',
|
||||
content: <div className="flex justify-between w-full items-center">...<SvgIcon className="md:size-8 sm:size-6" icon={'steamdeck_button_l1_outline'} /> </div>,
|
||||
icon: <FolderOutput />,
|
||||
shortcuts: [{ label: "Up", action: handleReturn, button: GamePadButtonCode.A }]
|
||||
},
|
||||
...data.dirs.map(f =>
|
||||
{
|
||||
const fullPath = path.join(f.parentPath, f.name);
|
||||
const isDefaultPath = fullPath === startingPath;
|
||||
let icon = <Folder />;
|
||||
if (isDefaultPath)
|
||||
{
|
||||
icon = <FolderInput />;
|
||||
} else if (!f.isDirectory)
|
||||
{
|
||||
icon = <></>;
|
||||
}
|
||||
const shortcuts: Shortcut[] = [];
|
||||
if (f.isDirectory)
|
||||
{
|
||||
shortcuts.push({ label: "Enter", button: GamePadButtonCode.A, action: () => setCurrentPath(fullPath) });
|
||||
if (isDirectoryPicker)
|
||||
shortcuts.push({ label: "Select", button: GamePadButtonCode.X, action: () => data.select(fullPath) });
|
||||
} else
|
||||
{
|
||||
shortcuts.push({ label: "Select", button: GamePadButtonCode.A, action: () => data.select(fullPath) });
|
||||
}
|
||||
const entry: DialogEntry = {
|
||||
content: f.name,
|
||||
id: `${data.id}-${f.name}`,
|
||||
type: 'primary',
|
||||
icon,
|
||||
shortcuts
|
||||
};
|
||||
return entry;
|
||||
}), ...(allowNewFolderCreation && currentPath ? [{
|
||||
content: <NewFolderOption id={`${data.id}-new-folder-content`} dirname={currentPath} />,
|
||||
id: `${data.id}-new-folder`,
|
||||
type: 'primary'
|
||||
} satisfies DialogEntry] : [])]
|
||||
} />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function NewFolderInput (data: { id: string, name: string | undefined, setName: (name: string) => void; className?: string; })
|
||||
{
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onEnterPress: () => inputRef.current?.focus(),
|
||||
onBlur: () => inputRef.current?.blur(),
|
||||
});
|
||||
const handleFocus = () =>
|
||||
{
|
||||
focusSelf();
|
||||
systemApi.api.system.show_keyboard.post();
|
||||
};
|
||||
return <div className={data.className} ref={ref}>
|
||||
<input ref={inputRef}
|
||||
className={twMerge("input rounded-xl focus:ring-base-content w-full", classNames({ "ring-4 ring-accent": focused }))}
|
||||
onFocus={handleFocus}
|
||||
value={data.name}
|
||||
placeholder="New Folder"
|
||||
onChange={e => data.setName(e.target.value)}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function NewFolderOption (data: { id: string, dirname: string; })
|
||||
{
|
||||
const { refetchFiles } = useContext(FilePickerContext);
|
||||
const [name, setName] = useState<string | undefined>();
|
||||
const createMutation = useMutation({
|
||||
mutationKey: ['create', 'folder', data.id], mutationFn: async () =>
|
||||
{
|
||||
if (!name) return;
|
||||
const { error } = await systemApi.api.system.dirs.put({ name, dirname: data.dirname });
|
||||
if (error) throw error.value;
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
toast.success(`Folder ${name} created`);
|
||||
refetchFiles();
|
||||
}
|
||||
});
|
||||
return <div className="flex gap-2 grow -ml-2">
|
||||
<NewFolderInput className="grow" id={`${data.id}-input`} setName={setName} name={name} />
|
||||
<Button id={`${data.id}-create`} onAction={createMutation.mutate} type="button" ><FolderPlus /></Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function OptionButtons (data: {
|
||||
id: string;
|
||||
onCancel: () => void;
|
||||
onSelect: () => void;
|
||||
showConfirm: boolean;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `options-${data.id}`, onEnterPress: data.onSelect });
|
||||
return <div ref={ref} className="flex h-12 w-full justify-end gap-2">
|
||||
<FocusContext value={focusKey}>
|
||||
{data.showConfirm && <Button className="p-6 ring-accent-content" onAction={data.onSelect} id={`${data.id}-select`} focusClassName="ring-7" type="button" ><Check />Select</Button>}
|
||||
<Button className="p-6 ring-warning-content" onAction={data.onCancel} id={`${data.id}-cancel`} type="button" focusClassName="ring-7 btn-warning" ><X />Cancel</Button>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DriveElement (data: { id: string, isActive: boolean, label: string; onSelect: () => void; isRemovable: boolean; })
|
||||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect });
|
||||
return <li ref={ref} onClick={data.onSelect} className={twMerge(
|
||||
"flex bg-base-200 text-base-content rounded-full gap-2 items-center p-2 overflow-hidden max-w-xs",
|
||||
classNames({
|
||||
"bg-primary text-primary-content": data.isActive,
|
||||
"ring-7 ring-base-content": focused
|
||||
})
|
||||
)}>
|
||||
{data.isRemovable ? <Usb /> : <HardDrive />}
|
||||
{data.label}
|
||||
</li>;
|
||||
}
|
||||
|
||||
function Drives (data: {
|
||||
id: string,
|
||||
onSelect: (path: string) => void;
|
||||
})
|
||||
{
|
||||
const { drives, activeDrive } = useContext(FilePickerContext);
|
||||
const { focusKey, ref } = useFocusable({
|
||||
focusKey: data.id,
|
||||
preferredChildFocusKey: activeDrive?.mountPoint ?? undefined,
|
||||
saveLastFocusedChild: false,
|
||||
autoRestoreFocus: false
|
||||
});
|
||||
|
||||
return <ul className="flex flex-col gap-2" ref={ref} >
|
||||
<FocusContext value={focusKey}>
|
||||
{drives?.filter(d => d.mountPoint)
|
||||
.sort((a, b) => b.mountPoint!.length - a.mountPoint!.length)
|
||||
.map(d =>
|
||||
<DriveElement isRemovable={d.isRemovable} onSelect={() => data.onSelect(d.mountPoint!)} id={d.mountPoint!} isActive={activeDrive?.mountPoint === d.mountPoint} label={d.label} />
|
||||
)}
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
}
|
||||
|
||||
function ListWithDrives (data: {
|
||||
id: string,
|
||||
files: DirType[],
|
||||
onSelect: (path: string) => void,
|
||||
parentPath: string;
|
||||
})
|
||||
{
|
||||
const { setCurrentPath, isDirectoryPicker } = useContext(FilePickerContext);
|
||||
const { focusKey, ref } = useFocusable({
|
||||
focusKey: `main-${data.id}`,
|
||||
preferredChildFocusKey: `list-${data.id}`
|
||||
});
|
||||
return <div ref={ref} className="flex grow min-h-0 gap-2">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Drives onSelect={p => setCurrentPath(p)} id={`drives-${data.id}`} />
|
||||
<div className="divider divider-horizontal m-1"></div>
|
||||
</div>
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
<div className="overflow-y-auto w-full">
|
||||
<List
|
||||
id={`list-${data.id}`}
|
||||
dirs={data.files.filter(d =>
|
||||
{
|
||||
if (isDirectoryPicker && !d.isDirectory)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})} parentPath={data.parentPath} select={data.onSelect} />
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function FilePicker (data: {
|
||||
id: string;
|
||||
startingPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
isDirectoryPicker?: boolean;
|
||||
cancel: () => void;
|
||||
allowNewFolderCreation?: boolean;
|
||||
})
|
||||
{
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
|
||||
|
||||
const { data: files, refetch: refetchFiles } = useQuery(filesQuery(currentPath, data.id));
|
||||
const { data: drives } = useQuery(drivesQuery);
|
||||
|
||||
const fullPath = files ? path.join(files.parentPath, files.name) : '';
|
||||
const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0];
|
||||
const activeDriveMount = activeDrive?.mountPoint;
|
||||
const fullPathElements = activeDrive?.label ?
|
||||
[<><HardDrive />{activeDrive?.label}</>, ...fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep)] :
|
||||
fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep);
|
||||
|
||||
return <div className="flex flex-col h-full max-h-full gap-3">
|
||||
<FilePickerContext value={{
|
||||
setCurrentPath,
|
||||
currentPath,
|
||||
isDirectoryPicker: data.isDirectoryPicker ?? false,
|
||||
refetchFiles,
|
||||
startingPath: data.startingPath,
|
||||
allowNewFolderCreation: data.allowNewFolderCreation ?? false,
|
||||
drives: drives ?? [],
|
||||
activeDrive
|
||||
}}>
|
||||
{!!fullPath &&
|
||||
<div className="breadcrumbs flex items-center text-sm min-h-12 max-h-12 h-12 px-4 py-2 overflow-hidden bg-base-300 text-base-content rounded-full">
|
||||
<ul>
|
||||
{fullPathElements.map((p, i) => <li>
|
||||
<a onClick={() =>
|
||||
setCurrentPath(path.join(...fullPath.slice(-i)))
|
||||
}>{p}</a>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div>}
|
||||
|
||||
<ListWithDrives
|
||||
id={data.id}
|
||||
files={files?.dirs ?? []}
|
||||
onSelect={data.onSelect}
|
||||
parentPath={files?.parentPath ?? ''}
|
||||
/>
|
||||
<OptionButtons
|
||||
showConfirm={!!data.isDirectoryPicker}
|
||||
onCancel={data.cancel}
|
||||
onSelect={() => currentPath ? data.onSelect(currentPath) : undefined}
|
||||
id={data.id} />
|
||||
</FilePickerContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -79,11 +79,11 @@ export default function GameCard (data: GameCardParams)
|
|||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||
)}</div>
|
||||
|
||||
<div className="h-0 flex pr-2 justify-end items-center">
|
||||
<div className="h-0 flex pr-2 justify-end items-center gap-2">
|
||||
{data.badges?.map((b, i) =>
|
||||
<div key={i}
|
||||
className={
|
||||
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 mr-4 transition-colors",
|
||||
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 last:mr-4 transition-colors",
|
||||
classNames({ "bg-primary text-primary-content": focused }))}
|
||||
>
|
||||
{b}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,16 @@
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { FrontEndId, RPC_URL } from "../../shared/constants";
|
||||
import { FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
|
||||
export interface GameListFilter
|
||||
{
|
||||
platformId?: number;
|
||||
collectionId?: number;
|
||||
}
|
||||
|
||||
export interface GameListParams
|
||||
{
|
||||
id: string,
|
||||
filters?: GameListFilter,
|
||||
filters?: GameListFilterType,
|
||||
grid?: boolean,
|
||||
setBackground?: (url: string) => void;
|
||||
onGameSelect?: (id: FrontEndId) => void;
|
||||
|
|
@ -29,10 +23,7 @@ export function GameList (data: GameListParams)
|
|||
const games = useSuspenseQuery({
|
||||
queryKey: ['games', data.filters ?? 'all'],
|
||||
queryFn: () => rommApi.api.romm.games.get({
|
||||
query: {
|
||||
platform_id: data.filters?.platformId,
|
||||
collection_id: data.filters?.collectionId
|
||||
}
|
||||
query: data.filters
|
||||
}).then(d => d.data)
|
||||
});
|
||||
const navigator = useNavigate();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import
|
|||
import classNames from "classnames";
|
||||
import
|
||||
{
|
||||
BatteryCharging,
|
||||
BatteryFull,
|
||||
BatteryLow,
|
||||
BatteryMedium,
|
||||
BatteryWarning,
|
||||
Bell,
|
||||
Bluetooth,
|
||||
Clock,
|
||||
|
|
@ -16,14 +20,18 @@ import
|
|||
Sun,
|
||||
User,
|
||||
Wifi,
|
||||
WifiHigh,
|
||||
WifiLow,
|
||||
WifiZero,
|
||||
} from "lucide-react";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
import { JSX } from "react";
|
||||
import { JSX, useEffect, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
|
||||
function HeaderAvatar (data: {
|
||||
id: string;
|
||||
|
|
@ -104,11 +112,128 @@ export interface HeaderAccount
|
|||
action?: () => void;
|
||||
}
|
||||
|
||||
function NotificationStatus ()
|
||||
{
|
||||
const hasUnread = false;
|
||||
return <div className={classNames("p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })}>
|
||||
<Bell className="w-6 h-6" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ClockStatus ()
|
||||
{
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() =>
|
||||
{
|
||||
function update ()
|
||||
{
|
||||
if (ref.current)
|
||||
{
|
||||
ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update immediately
|
||||
update();
|
||||
|
||||
// Wait until next minute boundary
|
||||
const now = new Date();
|
||||
const msUntilNextMinute =
|
||||
(60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
|
||||
const timeout = setTimeout(() =>
|
||||
{
|
||||
update();
|
||||
|
||||
// Then update every minute
|
||||
const interval = setInterval(update, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, msUntilNextMinute);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
return <div className="flex gap-3"><span ref={ref}></span><Clock /></div>;
|
||||
}
|
||||
|
||||
function BluetoothStatus ()
|
||||
{
|
||||
const { data: bluetooth } = useQuery({
|
||||
queryKey: ['wifi'],
|
||||
queryFn: () => systemApi.api.system.info.bluetooth.get().then(d => d.data),
|
||||
refetchInterval: 3000
|
||||
});
|
||||
return bluetooth && bluetooth.find(b => b.connected) && <div>
|
||||
<Bluetooth className="w-6 h-6" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function WiFiStatus ()
|
||||
{
|
||||
const { data: wifi } = useQuery({
|
||||
queryKey: ['wifi'],
|
||||
queryFn: () => systemApi.api.system.info.wifi.get().then(d => d.data),
|
||||
refetchInterval: 3000
|
||||
});
|
||||
|
||||
return <div>
|
||||
{wifi?.map(w =>
|
||||
{
|
||||
const className = "w-6 h-6";
|
||||
let icon = <Wifi className={className} />;
|
||||
if (w.signalLevel >= -60)
|
||||
icon = <Wifi className={className} />;
|
||||
else if (w.signalLevel >= -70)
|
||||
icon = <WifiHigh className={className} />;
|
||||
else if (w.signalLevel >= -80)
|
||||
icon = <WifiLow className={className} />;
|
||||
else if (w.signalLevel >= -90)
|
||||
icon = <WifiZero className={className} />;
|
||||
|
||||
return <div className="tooltip" data-tip={w.signalLevel}>
|
||||
{icon}
|
||||
</div>;
|
||||
})}
|
||||
|
||||
</div>;
|
||||
}
|
||||
|
||||
function BatteryStatus ()
|
||||
{
|
||||
const { data: battery } = useQuery({
|
||||
queryKey: ['battery'],
|
||||
queryFn: () => systemApi.api.system.info.battery.get().then(d => d.data),
|
||||
refetchInterval: 3000
|
||||
});
|
||||
const batteryClassName = "w-6 h-6";
|
||||
let batteryIcon = <BatteryFull className={batteryClassName} />;
|
||||
if (battery?.isCharging || battery?.acConnected)
|
||||
{
|
||||
batteryIcon = <BatteryCharging className={batteryClassName} />;
|
||||
} else if (battery?.percent)
|
||||
{
|
||||
if (battery.percent < 5)
|
||||
{
|
||||
batteryIcon = <BatteryWarning className={batteryClassName} />;
|
||||
}
|
||||
else if (battery.percent < 15)
|
||||
{
|
||||
batteryIcon = <BatteryLow className={batteryClassName} />;
|
||||
} else if (battery.percent < 50)
|
||||
{
|
||||
batteryIcon = <BatteryMedium className={batteryClassName} />;
|
||||
}
|
||||
}
|
||||
return <div className="flex gap-2 items-center">
|
||||
{batteryIcon}
|
||||
<span className="font-semibold">{battery?.percent} %</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[], buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const rommOnline = useQuery({
|
||||
...statsApiStatsGetOptions(),
|
||||
refetchInterval: 30000,
|
||||
|
|
@ -161,18 +286,12 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
|
|||
{data.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text drop-shadow-sm">
|
||||
<div className="flex gap-5">
|
||||
<Clock />
|
||||
<Wifi className="w-6 h-6" />
|
||||
<Bluetooth className="w-6 h-6" />
|
||||
<div className="indicator">
|
||||
<span className="indicator-item status status-error"></span>
|
||||
<Bell className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<BatteryFull className="w-6 h-6" />
|
||||
<span className="font-semibold">100%</span>
|
||||
</div>
|
||||
<div className="flex gap-5 items-center">
|
||||
<ClockStatus />
|
||||
<WiFiStatus />
|
||||
<BluetoothStatus />
|
||||
<NotificationStatus />
|
||||
<BatteryStatus />
|
||||
</div>
|
||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { CardList, GameMetaExtra } from "./CardList";
|
|||
import classNames from "classnames";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { JSX } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
|
||||
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: (node: HTMLElement) => void; })
|
||||
{
|
||||
|
|
@ -29,42 +31,48 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
|
|||
className={data.className}
|
||||
onGameFocus={(id, node) => data.onFocus?.(node)}
|
||||
games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
||||
.map((g) => ({
|
||||
id: g.slug,
|
||||
focusKey: g.slug,
|
||||
title: g.name,
|
||||
subtitle: g.family_name ?? "",
|
||||
previewUrl: "",
|
||||
badges: [(<span className="text-lg font-bold p-2 rounded-full">
|
||||
{g.game_count}
|
||||
</span>)],
|
||||
onFocus: () => data.setBackground(
|
||||
`https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`,
|
||||
),
|
||||
onSelect: () =>
|
||||
{
|
||||
SaveSource('game-list');
|
||||
navigate({ to: `/platform/${g.source ?? g.id.source}/${g.source_id ?? g.id.id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
},
|
||||
preview:
|
||||
({ focused }) => <div
|
||||
className="flex h-60 p-6 bg-base-100 justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
.map((g) =>
|
||||
{
|
||||
const badges: JSX.Element[] = [];
|
||||
badges.push(<span className="flex items-center justify-center size-6 m-1 text-2xl font-semibold font-boldrounded-full">{g.game_count}</span>);
|
||||
if (g.hasLocal)
|
||||
badges.push(<HardDrive className="size-8 m-1" />);
|
||||
const entry: GameMetaExtra = {
|
||||
id: g.slug,
|
||||
focusKey: g.slug,
|
||||
title: g.name,
|
||||
subtitle: g.family_name ?? "",
|
||||
previewUrl: "",
|
||||
badges,
|
||||
onFocus: () => data.setBackground(
|
||||
`https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`,
|
||||
),
|
||||
onSelect: () =>
|
||||
{
|
||||
SaveSource('game-list');
|
||||
navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
},
|
||||
preview:
|
||||
({ focused }) => <div
|
||||
className="flex h-60 p-6 bg-base-100 justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${8 + g.slug.length}/300/300.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: "screen",
|
||||
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
|
||||
src={`${RPC_URL(__HOST__)}${g.path_cover}`}
|
||||
></img>
|
||||
</div>
|
||||
,
|
||||
} satisfies GameMetaExtra))}
|
||||
backgroundBlendMode: "screen",
|
||||
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
|
||||
src={`${RPC_URL(__HOST__)}${g.path_cover}`}
|
||||
></img>
|
||||
</div>
|
||||
,
|
||||
};
|
||||
return entry;
|
||||
})}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -26,11 +26,11 @@ const iconMap: Record<GamePadButtonCode, IconType> = {
|
|||
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
|
||||
{
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 z-1000">
|
||||
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
|
||||
key={s.button}
|
||||
id={`shortcut-${s.button}`}
|
||||
onClick={e => s.action(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
|
||||
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
|
||||
icon={iconMap[s.button]}
|
||||
label={s.label} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,22 +5,39 @@ import
|
|||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
|
||||
export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
|
||||
export function Button (data: {
|
||||
id: string,
|
||||
children?: any,
|
||||
className?: string,
|
||||
disabled?: boolean,
|
||||
type: "reset" | "button" | "submit" | undefined;
|
||||
shortcutLabel?: string;
|
||||
focusClassName?: string;
|
||||
} & InteractParams & FocusParams)
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
const { ref, focused, focusKey } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onEnterPress: data.onAction,
|
||||
onFocus: data.onFocus,
|
||||
focusable: !data.disabled
|
||||
});
|
||||
|
||||
if (data.shortcutLabel)
|
||||
{
|
||||
useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: data.onAction, button: GamePadButtonCode.A }], [data.shortcutLabel]);
|
||||
}
|
||||
|
||||
return <button
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
disabled={data.disabled}
|
||||
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
|
||||
"btn-accent": focused
|
||||
}, data.className))}
|
||||
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg",
|
||||
focused ? data.focusClassName : undefined,
|
||||
classNames({
|
||||
"btn-accent": focused,
|
||||
}, data.className))}
|
||||
type={data.type}
|
||||
>
|
||||
{data.children}
|
||||
|
|
|
|||
32
src/mainview/components/options/DownloadDirectoryOption.tsx
Normal file
32
src/mainview/components/options/DownloadDirectoryOption.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useState } from "react";
|
||||
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { changeDownloadsMutation } from "@/mainview/scripts/queries";
|
||||
|
||||
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
|
||||
{
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const setSettingMutation = useMutation({
|
||||
...changeDownloadsMutation,
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
}
|
||||
});
|
||||
|
||||
return <PathSettingsOptionBase
|
||||
isDirty={dirty}
|
||||
label={data.label}
|
||||
id={data.id}
|
||||
type={data.type}
|
||||
save={setSettingMutation.mutate}
|
||||
allowNewFolderCreation={data.allowNewFolderCreation}
|
||||
isDirectoryPicker={true}
|
||||
localValue={localValue}
|
||||
setLocalValue={(v) =>
|
||||
{
|
||||
setLocalValue(v);
|
||||
setDirty(true);
|
||||
}} />;
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@ export function OptionInput (data: {
|
|||
focusKey: data.name, onEnterPress: () =>
|
||||
{
|
||||
inputRef.current?.focus();
|
||||
systemApi.api.system.show_keyboard.post();
|
||||
}
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -32,6 +31,21 @@ export function OptionInput (data: {
|
|||
inputRef.current?.focus();
|
||||
},
|
||||
});
|
||||
const handleFocus = () =>
|
||||
{
|
||||
option.focus();
|
||||
if (inputRef.current)
|
||||
{
|
||||
var rect = inputRef.current?.getBoundingClientRect();
|
||||
systemApi.api.system.show_keyboard.post({
|
||||
XPosition: rect.x,
|
||||
YPosition: rect.y,
|
||||
Width: rect.width,
|
||||
Height: rect.height
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
|
||||
|
|
@ -47,7 +61,7 @@ export function OptionInput (data: {
|
|||
defaultValue={data.defaultValue}
|
||||
type={data.type}
|
||||
autoComplete={data.autocomplete}
|
||||
onFocus={() => option.focus()}
|
||||
onFocus={handleFocus}
|
||||
placeholder={data.placeholder}
|
||||
onChange={data.onChange}
|
||||
onBlur={data.onBlur}
|
||||
|
|
|
|||
156
src/mainview/components/options/PathSettingsOption.tsx
Normal file
156
src/mainview/components/options/PathSettingsOption.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
|
||||
import { SettingsType } from "../../../shared/constants";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { OptionSpace } from "./OptionSpace";
|
||||
import { OptionInput } from "./OptionInput";
|
||||
import { settingsApi } from "../../scripts/clientApi";
|
||||
import { Button } from "./Button";
|
||||
import { FileSearchCorner, FolderSearch, Pen, Save } from "lucide-react";
|
||||
import { ContextDialog } from "../ContextDialog";
|
||||
import FilePicker from "../FilePicker";
|
||||
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export interface PathSettingsOptionParams
|
||||
{
|
||||
label: string;
|
||||
id: KeysWithValueAssignableTo<SettingsType, string>;
|
||||
type: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
children?: any;
|
||||
onBrowseAction?: (path: string | undefined) => void;
|
||||
requireConfirmation?: boolean;
|
||||
isDirectoryPicker?: boolean;
|
||||
allowNewFolderCreation?: boolean;
|
||||
}
|
||||
|
||||
export function PathSettingsOption (data: PathSettingsOptionParams)
|
||||
{
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const setSettingMutation = useMutation({
|
||||
mutationKey: ["setting", data.id],
|
||||
mutationFn: async (value: any) =>
|
||||
{
|
||||
const response = await settingsApi.api.settings({ id: data.id! }).post({ value });
|
||||
if (response.error) throw response.error;
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
}
|
||||
});
|
||||
|
||||
return <PathSettingsOptionBase
|
||||
isDirty={dirty}
|
||||
label={data.label}
|
||||
id={data.id}
|
||||
type={data.type}
|
||||
save={setSettingMutation.mutate}
|
||||
localValue={localValue}
|
||||
allowNewFolderCreation={data.allowNewFolderCreation}
|
||||
setLocalValue={(v) =>
|
||||
{
|
||||
setLocalValue(v);
|
||||
setDirty(true);
|
||||
}} />;
|
||||
}
|
||||
|
||||
export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
||||
save: (value: string | undefined) => void;
|
||||
localValue: string | undefined;
|
||||
setLocalValue: (value: string | undefined) => void;
|
||||
isDirty: boolean;
|
||||
})
|
||||
{
|
||||
const [isBrowsing, setIsBrowsing] = useState(false);
|
||||
const { data: defaultValue } = useQuery({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["setting", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get();
|
||||
if (error) throw error;
|
||||
if (!data.isDirty)
|
||||
{
|
||||
data.setLocalValue(String(value.value));
|
||||
}
|
||||
return value.value;
|
||||
},
|
||||
});
|
||||
const changed = defaultValue !== data.localValue;
|
||||
|
||||
const handleSelectPath = (path: string) =>
|
||||
{
|
||||
data.setLocalValue(path);
|
||||
handleCloseSeatch();
|
||||
if (data.requireConfirmation !== true)
|
||||
{
|
||||
data.save(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSeatch = () =>
|
||||
{
|
||||
setIsBrowsing(false);
|
||||
setFocus(`${data.id}-browse`);
|
||||
};
|
||||
|
||||
const handleInputBlur = () =>
|
||||
{
|
||||
if (data.requireConfirmation !== true)
|
||||
{
|
||||
data.save(data.localValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OptionSpace id={data.id} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
|
||||
<OptionInput
|
||||
icon={data.icon}
|
||||
name={`${data.id}-input`}
|
||||
type={data.type}
|
||||
placeholder={data.placeholder}
|
||||
onBlur={handleInputBlur}
|
||||
onChange={(e) =>
|
||||
{
|
||||
data.setLocalValue(e.currentTarget.value);
|
||||
}}
|
||||
value={data.localValue}
|
||||
/>
|
||||
<Button id={`${data.id}-browse`} className="ring-accent-content" focusClassName="ring-7" onAction={() =>
|
||||
{
|
||||
setIsBrowsing(true);
|
||||
data.onBrowseAction?.(data.localValue);
|
||||
}} type="button">
|
||||
{data.isDirectoryPicker ? <FolderSearch /> : <FileSearchCorner />}
|
||||
</Button>
|
||||
{data.requireConfirmation === true && <Button
|
||||
disabled={defaultValue === data.localValue}
|
||||
id={`${data.id}-save`}
|
||||
onAction={() => data.save(data.localValue)}
|
||||
type="button">
|
||||
<Save />
|
||||
</Button>}
|
||||
|
||||
<ContextDialog className="h-[80vh] w-[60vw]" id={`file-picker-${data.id}`} open={isBrowsing} close={handleCloseSeatch} >
|
||||
{isBrowsing && <FilePicker
|
||||
isDirectoryPicker={data.isDirectoryPicker}
|
||||
onSelect={handleSelectPath}
|
||||
key={`download-path-${data.id}`}
|
||||
startingPath={data.localValue}
|
||||
id={`download-path-${data.id}`}
|
||||
cancel={handleCloseSeatch}
|
||||
allowNewFolderCreation={data.allowNewFolderCreation}
|
||||
/>
|
||||
}
|
||||
</ContextDialog>
|
||||
{data.children}
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export function SettingsOption (data: {
|
|||
type: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
children?: any;
|
||||
})
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
|
@ -67,6 +68,7 @@ export function SettingsOption (data: {
|
|||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
{data.children}
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue