mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-06-17 08:47:49 +00:00
[Feature] File picker improvements (#454)
* First iteration for internationalization of the loader * First iteration for internationalization of the loader * Cleanup node mess * Cleanup node mess pt2 * Additional touches * Latest decky changed merged into i18n and updated translation. * Styling fixes * Initial backend hosting implementation * Added correct url path of the loopback server. * Added correct url path of the loopback server. * Some better namespaced text. * Added whitelist for locales path. * Refactor languages and fix hooks logic bugs. * Small typo in language translation structure. * Working backend, automatically swtich languages with steam and language fixes. * Fix to languages * Key fixes * Additional language fixes. * Additional json changes * Final text revision and added a vscode tasks to automatically extract text from code. * Typo in the middleware * Remove unused imports * Cleanup whitespaces. * Import changes * Revert "Import changes" This reverts commit8e8231950f. * Update index.d.ts * Clean up unused imports * Delete pnpm-lock.yaml * Update rollup.config.js * Update PluginInstallModal.tsx * Update index.tsx * Update plugin-loader.tsx * Update plugin-loader.tsx * Revert "Delete pnpm-lock.yaml" This reverts commit3a39f36f21. * Additional strings reworks. * Fixes for issues coming from github merge. * Fixes for master * Styling fixes * Styling pt2 * Missed a few strings in master, * Styling fixes * Additional master merge fixes. * Final cleanup and adaptation to master. * Final empty language cleanup and few string added * Small changes to italian translation * Disabled translation on a few components inside plugin-loader for missing react hooks. * Fixed passing tag to translation. * Disable debug output for reducing console spam. * Return correct content type * Small italian language change * Added support for country code * Fixed missing translation for uninstall popup. * Fix class name shenanigans for toast notification * Update dependencies * Fixed github workflow to include the new locales folder * Update dependencies to latest version (unless it's React) and fixed the new small errors that cropped up * Missed a file name change * Updated dev dependencies to latest version * Missed a few dev dependencies * Revert "Update dependencies to latest version (unless it's React) and fixed the new small errors that cropped up" Messed up merge with a different main branch * Messed up deletion of rollup config. * Fix broken pnpm lock file * Missed a localized string during the merge * Fixed a parameter mistake in the uninstall text parameter * Fix pnpm random issues * Small italian language tweaks * Fix wrong parameter passed to the uninstall function call * Another fix on a wrong function parameter * Additional translation text on the store and branch selection channels * Changed the default type passed to map to being able to index the two arrays. * Reverted and reworked the last changes * Distinguish events in UI for installing vs reinstalling plugins * Additional fixes for reinstall prompt * Revert the use of intevalPlural since the parser doesn't seem to support that. * Missed a routing path in the backend * Small bugfixes * Small fixes * Correctly adding the parameter to the request headers. * Refactoring of the UI popup modal * Fix pnpm shenanigans * Final fixes for the install UI localization * Clean up unnedeed backend code * Small rework on text selection. * Cleaned up parser configuration * Removed extracttext dependency to pnpmsetup * Merged translation and cleaned up parser * Fixed JSON structure after manual merge. * Added translation to the file picker * First iteration for merging the new filepicker. * Revert changes to PluginInstallModal * Reworked the text modal for the final time * Missed the proper linted text * Missed the backend change * Final branch cleanup * First iteration for porting the new file picker * Hotfix for i18n where the detector was overriding localStorage * Please, pnpm, cooperate * Small fix regarding the backend getting hammered when switching to not supported languages plus a small english typo * Initial working upstream iteration for file picker * Typo on translation variable * File picker final improvements * Stylistic fixes and fix on wrong bool passed to fp * Fixup merge from main * Other merge errors fixed * Minor cleanups * Fixed missing padding under text label extension * Implement pagination backend side * First draft for filtering backend side * Implemented matching on file names. * Fix for unable to order per size on folders. * Hard checking a return value * Added a missing import. * Implemented show more as a frontend button * Whoops, python typo * Fixed python backend * Rendering bug fix and small qol improvement * Added missing parameter to openFilePicker call * Fixed path on windows and unknown error on wrong path * Small backend fixes * Extension fix * Simplified extension logic * Less string conversions. * Optimize backend code and removed additional components. * Take correctly into account the max value The button will now respect the actual maximum desired number of entries. * Bugfix for ordering logic and ignore cases during sorting * Regex call was missing an argument * Fixed issues with filtering extensions * Rollback testing changes * Minor cleanup and attempt at fixing the not updating multimodal. * Cleanup variable types. * Mantains the same api format from the original source code. * Removing hardcoded paths in the code * Additional fixes for resolving the user path * Cleanup useless modifications * Final fixes for avoid path hardcoding * Update lockfile and i18next version
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
DialogButton,
|
||||
DialogCheckbox,
|
||||
DialogCheckboxProps,
|
||||
Marquee,
|
||||
Menu,
|
||||
MenuItem,
|
||||
findModuleChild,
|
||||
showContextMenu,
|
||||
} from 'decky-frontend-lib';
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaChevronDown } from 'react-icons/fa';
|
||||
|
||||
const dropDownControlButtonClass = findModuleChild((m) => {
|
||||
if (typeof m !== 'object') return undefined;
|
||||
for (const prop in m) {
|
||||
if (m[prop]?.toString()?.includes('gamepaddropdown_DropDownControlButton')) {
|
||||
return m[prop];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const DropdownMultiselectItem: FC<
|
||||
{
|
||||
value: any;
|
||||
onSelect: (checked: boolean, value: any) => void;
|
||||
checked: boolean;
|
||||
} & DialogCheckboxProps
|
||||
> = ({ value, onSelect, checked: defaultChecked, ...rest }) => {
|
||||
const [checked, setChecked] = useState(defaultChecked);
|
||||
|
||||
useEffect(() => {
|
||||
onSelect?.(checked, value);
|
||||
}, [checked, onSelect, value]);
|
||||
|
||||
return (
|
||||
<MenuItem bInteractableItem onClick={() => setChecked((x) => !x)}>
|
||||
<DialogCheckbox
|
||||
style={{ marginBottom: 0, padding: 0 }}
|
||||
className="decky_DropdownMultiselectItem_DialogCheckbox"
|
||||
bottomSeparator="none"
|
||||
{...rest}
|
||||
onClick={() => setChecked((x) => !x)}
|
||||
onChange={(checked) => setChecked(checked)}
|
||||
controlled
|
||||
checked={checked}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const DropdownMultiselect: FC<{
|
||||
items: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
selected: string[];
|
||||
onSelect: (selected: any[]) => void;
|
||||
label: string;
|
||||
}> = ({ label, items, selected, onSelect }) => {
|
||||
const [itemsSelected, setItemsSelected] = useState<any>(selected);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleItemSelect = useCallback((checked, value) => {
|
||||
setItemsSelected((x: any) =>
|
||||
checked ? [...x.filter((y: any) => y !== value), value] : x.filter((y: any) => y !== value),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onSelect(itemsSelected);
|
||||
}, [itemsSelected, onSelect]);
|
||||
|
||||
return (
|
||||
<DialogButton
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
className={dropDownControlButtonClass}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
showContextMenu(
|
||||
<Menu label={label} cancelText={t('DropdownMultiselect.button.back') as string}>
|
||||
<style>
|
||||
{`
|
||||
/* Inherit color from ".basiccontextmenu" */
|
||||
.decky_DropdownMultiselectItem_DialogCheckbox > .DialogToggle_Label {
|
||||
color: inherit;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div style={{ marginTop: '10px' }}>{/*FIXME: Hack for missing padding under label menu*/}</div>
|
||||
{items.map((x) => (
|
||||
<DropdownMultiselectItem
|
||||
key={x.value}
|
||||
label={x.label}
|
||||
value={x.value}
|
||||
checked={itemsSelected.includes(x.value)}
|
||||
onSelect={handleItemSelect}
|
||||
/>
|
||||
))}
|
||||
</Menu>,
|
||||
evt.currentTarget ?? window,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Marquee>
|
||||
{selected.length > 0
|
||||
? selected.map((x: any) => items[items.findIndex((v) => v.value === x)].label).join(', ')
|
||||
: '…'}
|
||||
</Marquee>
|
||||
<div style={{ flexGrow: 1, minWidth: '1ch' }} />
|
||||
<FaChevronDown style={{ height: '1em', flex: '0 0 1em' }} />
|
||||
</DialogButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMultiselect;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconContext } from 'react-icons';
|
||||
import { FaExclamationTriangle, FaQuestionCircle } from 'react-icons/fa';
|
||||
|
||||
export enum FileErrorTypes {
|
||||
FileNotFound,
|
||||
Unknown,
|
||||
None,
|
||||
}
|
||||
|
||||
interface FilePickerErrorProps {
|
||||
error: FileErrorTypes;
|
||||
rawError?: string;
|
||||
}
|
||||
|
||||
const FilePickerError: FC<FilePickerErrorProps> = ({ error, rawError = null }) => {
|
||||
const [icon, setIcon] = useState<JSX.Element>(<FaQuestionCircle />);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
switch (error) {
|
||||
case FileErrorTypes.FileNotFound:
|
||||
setText(t('FilePickerError.errors.file_not_found'));
|
||||
setIcon(<FaExclamationTriangle />);
|
||||
break;
|
||||
case FileErrorTypes.Unknown:
|
||||
setText(t('FilePickerError.errors.unknown', { raw_error: rawError }));
|
||||
setIcon(<FaQuestionCircle />);
|
||||
break;
|
||||
case FileErrorTypes.None:
|
||||
setText(null);
|
||||
setIcon(<div></div>);
|
||||
break;
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ paddingTop: '50px', textAlign: 'center', height: '100%' }}>
|
||||
<IconContext.Provider value={{ className: 'fileError', size: '128px' }}>
|
||||
<div style={{ alignSelf: 'center', alignContent: 'center' }}>{icon}</div>
|
||||
</IconContext.Provider>
|
||||
<p style={{ height: '32px', paddingTop: '25px', alignSelf: 'flex-start', textAlign: 'center' }}>{text}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePickerError;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FC } from 'react';
|
||||
import { Translation } from 'react-i18next';
|
||||
|
||||
export enum SortOptions {
|
||||
name_desc = 'name_desc',
|
||||
name_asc = 'name_asc',
|
||||
modified_desc = 'modified_desc',
|
||||
modified_asc = 'modified_asc',
|
||||
created_desc = 'created_desc',
|
||||
created_asc = 'created_asc',
|
||||
size_desc = 'size_desc',
|
||||
size_asc = 'size_asc',
|
||||
}
|
||||
|
||||
interface TSortOptionsProps {
|
||||
trans_part: SortOptions;
|
||||
}
|
||||
|
||||
const TSortOptions: FC<TSortOptionsProps> = ({ trans_part }) => {
|
||||
return (
|
||||
<Translation>
|
||||
{(t, {}) => {
|
||||
switch (trans_part) {
|
||||
case SortOptions.name_desc:
|
||||
return t('FilePickerIndex.filter.name_desc');
|
||||
case SortOptions.name_asc:
|
||||
return t('FilePickerIndex.filter.name_asce');
|
||||
case SortOptions.modified_desc:
|
||||
return t('FilePickerIndex.filter.modified_desc');
|
||||
case SortOptions.modified_asc:
|
||||
return t('FilePickerIndex.filter.modified_asce');
|
||||
case SortOptions.created_desc:
|
||||
return t('FilePickerIndex.filter.created_desc');
|
||||
case SortOptions.created_asc:
|
||||
return t('FilePickerIndex.filter.created_asce');
|
||||
case SortOptions.size_desc:
|
||||
return t('FilePickerIndex.filter.size_desc');
|
||||
case SortOptions.size_asc:
|
||||
return t('FilePickerIndex.filter.size_asce');
|
||||
}
|
||||
}}
|
||||
</Translation>
|
||||
);
|
||||
};
|
||||
|
||||
export default TSortOptions;
|
||||
@@ -38,7 +38,7 @@ const imageStyle = {
|
||||
color: '#d18f00',
|
||||
};
|
||||
|
||||
const imageExtList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff'];
|
||||
const imageExtList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff', 'apng', 'tga'];
|
||||
|
||||
styleDef.push([imageStyle, imageExtList]);
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { DialogButton, Focusable, SteamSpinner, TextField } from 'decky-frontend-lib';
|
||||
import { useEffect } from 'react';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import {
|
||||
ControlsList,
|
||||
DialogBody,
|
||||
DialogButton,
|
||||
DialogControlsSection,
|
||||
DialogFooter,
|
||||
Dropdown,
|
||||
Focusable,
|
||||
Marquee,
|
||||
SteamSpinner,
|
||||
TextField,
|
||||
ToggleField,
|
||||
} from 'decky-frontend-lib';
|
||||
import { filesize } from 'filesize';
|
||||
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FileIcon, defaultStyles } from 'react-file-icon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaArrowUp, FaFolder } from 'react-icons/fa';
|
||||
|
||||
import Logger from '../../../logger';
|
||||
import DropdownMultiselect from '../DropdownMultiselect';
|
||||
import FilePickerError, { FileErrorTypes } from './FilePickerError';
|
||||
import TSortOption, { SortOptions } from './i18n/TSortOptions';
|
||||
import { styleDefObj } from './iconCustomizations';
|
||||
|
||||
const logger = new Logger('FilePicker');
|
||||
@@ -13,27 +28,89 @@ const logger = new Logger('FilePicker');
|
||||
export interface FilePickerProps {
|
||||
startPath: string;
|
||||
includeFiles?: boolean;
|
||||
regex?: RegExp;
|
||||
includeFolders?: boolean;
|
||||
filter?: RegExp | ((file: File) => boolean);
|
||||
validFileExtensions?: string[];
|
||||
allowAllFiles?: boolean;
|
||||
defaultHidden?: boolean;
|
||||
max?: number;
|
||||
onSubmit: (val: { path: string; realpath: string }) => void;
|
||||
closeModal?: () => void;
|
||||
}
|
||||
|
||||
interface File {
|
||||
export interface File {
|
||||
isdir: boolean;
|
||||
ishidden: boolean;
|
||||
name: string;
|
||||
realpath: string;
|
||||
size: number;
|
||||
modified: number;
|
||||
created: number;
|
||||
}
|
||||
|
||||
interface FileListing {
|
||||
realpath: string;
|
||||
files: File[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const sortOptions = [
|
||||
{
|
||||
data: SortOptions.name_desc,
|
||||
label: <TSortOption trans_part={SortOptions.name_desc} />,
|
||||
},
|
||||
{
|
||||
data: SortOptions.name_asc,
|
||||
label: <TSortOption trans_part={SortOptions.name_asc} />,
|
||||
},
|
||||
{
|
||||
data: SortOptions.modified_desc,
|
||||
label: <TSortOption trans_part={SortOptions.modified_desc} />,
|
||||
},
|
||||
{
|
||||
data: SortOptions.modified_asc,
|
||||
label: <TSortOption trans_part={SortOptions.modified_asc} />,
|
||||
},
|
||||
{
|
||||
data: SortOptions.created_desc,
|
||||
label: <TSortOption trans_part={SortOptions.created_desc} />,
|
||||
},
|
||||
{
|
||||
data: SortOptions.created_asc,
|
||||
label: <TSortOption trans_part={SortOptions.created_asc} />,
|
||||
},
|
||||
{
|
||||
data: SortOptions.size_desc,
|
||||
label: <TSortOption trans_part={SortOptions.size_desc} />,
|
||||
},
|
||||
{
|
||||
data: SortOptions.size_asc,
|
||||
label: <TSortOption trans_part={SortOptions.size_asc} />,
|
||||
},
|
||||
];
|
||||
|
||||
function getList(
|
||||
path: string,
|
||||
includeFiles: boolean = true,
|
||||
includeFiles: boolean,
|
||||
includeFolders: boolean = true,
|
||||
includeExt: string[] | null = null,
|
||||
includeHidden: boolean = false,
|
||||
orderBy: SortOptions = SortOptions.name_desc,
|
||||
filterFor: RegExp | ((file: File) => boolean) | null = null,
|
||||
pageNumber: number = 1,
|
||||
max: number = 1000,
|
||||
): Promise<{ result: FileListing | string; success: boolean }> {
|
||||
return window.DeckyPluginLoader.callServerMethod('filepicker_ls', { path, include_files: includeFiles });
|
||||
return window.DeckyPluginLoader.callServerMethod('filepicker_ls', {
|
||||
path,
|
||||
include_files: includeFiles,
|
||||
include_folders: includeFolders,
|
||||
include_ext: includeExt ? includeExt : [],
|
||||
include_hidden: includeHidden,
|
||||
order_by: orderBy,
|
||||
filter_for: filterFor,
|
||||
page: pageNumber,
|
||||
max: max,
|
||||
});
|
||||
}
|
||||
|
||||
const iconStyles = {
|
||||
@@ -44,126 +121,240 @@ const iconStyles = {
|
||||
const FilePicker: FunctionComponent<FilePickerProps> = ({
|
||||
startPath,
|
||||
includeFiles = true,
|
||||
regex,
|
||||
filter = undefined,
|
||||
includeFolders = true,
|
||||
validFileExtensions = undefined,
|
||||
allowAllFiles = true,
|
||||
defaultHidden = false, // false by default makes sense for most users
|
||||
max = 1000,
|
||||
onSubmit,
|
||||
closeModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (startPath.endsWith('/')) startPath = startPath.substring(0, startPath.length - 1); // remove trailing path
|
||||
|
||||
if (startPath !== '/' && startPath.endsWith('/')) startPath = startPath.substring(0, startPath.length - 1); // remove trailing path
|
||||
const [path, setPath] = useState<string>(startPath);
|
||||
const [listing, setListing] = useState<FileListing>({ files: [], realpath: path });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [listing, setListing] = useState<FileListing>({ files: [], realpath: path, total: 0 });
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [error, setError] = useState<FileErrorTypes>(FileErrorTypes.None);
|
||||
const [rawError, setRawError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [showHidden, setShowHidden] = useState<boolean>(defaultHidden);
|
||||
const [sort, setSort] = useState<SortOptions>(SortOptions.name_desc);
|
||||
const [selectedExts, setSelectedExts] = useState<string[] | undefined>(validFileExtensions);
|
||||
|
||||
const validExtsOptions = useMemo(() => {
|
||||
let validExt: { label: string; value: string }[] = [];
|
||||
if (validFileExtensions) {
|
||||
if (allowAllFiles) {
|
||||
validExt.push({ label: t('FilePickerIndex.files.all_files'), value: 'all_files' });
|
||||
}
|
||||
validExt.push(...validFileExtensions.map((x) => ({ label: x, value: x })));
|
||||
}
|
||||
return validExt;
|
||||
}, [validFileExtensions, allowAllFiles]);
|
||||
|
||||
function isSelectionValid(validExts: string[], selection: string[]) {
|
||||
if (validExts.some((el) => selection.includes(el))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const handleExtsSelect = useCallback((val: any) => {
|
||||
// unselect other options if "All Files" is checked
|
||||
if (allowAllFiles && val.includes('all_files')) {
|
||||
setSelectedExts(['all_files']);
|
||||
} else if (validFileExtensions && isSelectionValid(validFileExtensions, val)) {
|
||||
// If at least one extension is still selected, then assign this selection to the selected values
|
||||
setSelectedExts(val);
|
||||
} else {
|
||||
// Else do nothing
|
||||
setSelectedExts(selectedExts);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (error) setError(null);
|
||||
setLoading(true);
|
||||
const listing = await getList(path, includeFiles);
|
||||
const listing = await getList(
|
||||
path,
|
||||
includeFiles,
|
||||
includeFolders,
|
||||
selectedExts,
|
||||
showHidden,
|
||||
sort,
|
||||
filter,
|
||||
page,
|
||||
max,
|
||||
);
|
||||
if (!listing.success) {
|
||||
setListing({ files: [], realpath: path });
|
||||
setListing({ files: [], realpath: path, total: 0 });
|
||||
setLoading(false);
|
||||
setError(listing.result as string);
|
||||
logger.error(listing.result);
|
||||
const theError = listing.result as string;
|
||||
switch (theError) {
|
||||
case theError.match(/\[Errno\s2.*/i)?.input:
|
||||
case theError.match(/\[WinError\s3.*/i)?.input:
|
||||
setError(FileErrorTypes.FileNotFound);
|
||||
break;
|
||||
default:
|
||||
setRawError(theError);
|
||||
setError(FileErrorTypes.Unknown);
|
||||
break;
|
||||
}
|
||||
logger.debug(theError);
|
||||
return;
|
||||
} else {
|
||||
setRawError(null);
|
||||
setError(FileErrorTypes.None);
|
||||
setFiles((listing.result as FileListing).files);
|
||||
}
|
||||
setLoading(false);
|
||||
setListing(listing.result as FileListing);
|
||||
logger.log('reloaded', path, listing);
|
||||
})();
|
||||
}, [path]);
|
||||
}, [error, path, includeFiles, includeFolders, showHidden, sort, selectedExts, page]);
|
||||
|
||||
return (
|
||||
<div className="deckyFilePicker">
|
||||
<Focusable style={{ display: 'flex', flexDirection: 'row', paddingBottom: '10px' }}>
|
||||
<DialogButton
|
||||
style={{
|
||||
minWidth: 'unset',
|
||||
width: '40px',
|
||||
flexGrow: '0',
|
||||
borderRadius: 'unset',
|
||||
margin: '0',
|
||||
padding: '10px',
|
||||
}}
|
||||
onClick={() => {
|
||||
const newPathArr = path.split('/');
|
||||
newPathArr.pop();
|
||||
let newPath = newPathArr.join('/');
|
||||
if (newPath == '') newPath = '/';
|
||||
setPath(newPath);
|
||||
}}
|
||||
>
|
||||
<FaArrowUp />
|
||||
</DialogButton>
|
||||
<div style={{ flexGrow: '1', width: '100%' }}>
|
||||
<TextField
|
||||
value={path}
|
||||
onChange={(e) => {
|
||||
e.target.value && setPath(e.target.value);
|
||||
}}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</Focusable>
|
||||
<Focusable style={{ display: 'flex', flexDirection: 'column', height: '60vh', overflow: 'scroll' }}>
|
||||
{loading && <SteamSpinner style={{ height: '100%' }} />}
|
||||
{!loading &&
|
||||
listing.files
|
||||
.filter((file) => (includeFiles || file.isdir) && (!regex || regex.test(file.name)))
|
||||
.map((file) => {
|
||||
let extension = file.realpath.split('.').pop() as string;
|
||||
return (
|
||||
<DialogButton
|
||||
style={{ borderRadius: 'unset', margin: '0', padding: '10px' }}
|
||||
onClick={() => {
|
||||
const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`;
|
||||
if (file.isdir) setPath(fullPath);
|
||||
else {
|
||||
onSubmit({ path: fullPath, realpath: file.realpath });
|
||||
closeModal?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-start' }}>
|
||||
{file.isdir ? (
|
||||
<FaFolder style={iconStyles} />
|
||||
) : (
|
||||
<div style={iconStyles}>
|
||||
{file.realpath.includes('.') ? (
|
||||
<FileIcon {...defaultStyles[extension]} {...styleDefObj[extension]} extension={''} />
|
||||
) : (
|
||||
<FileIcon />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
<>
|
||||
<DialogBody className="deckyFilePicker">
|
||||
<DialogControlsSection>
|
||||
<Focusable flow-children="right" style={{ display: 'flex', marginBottom: '1em' }}>
|
||||
<DialogButton
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 'unset',
|
||||
width: '40px',
|
||||
borderRadius: 'unset',
|
||||
margin: '0',
|
||||
padding: '10px',
|
||||
}}
|
||||
onClick={() => {
|
||||
const newPathArr = path.split('/');
|
||||
const lastPath = newPathArr.pop();
|
||||
//If I have a single / with spaces, pop the array twice
|
||||
if (lastPath?.match(/^\/\s*$/) != null) newPathArr.pop();
|
||||
let newPath = newPathArr.join('/');
|
||||
if (newPath == '') newPath = '/';
|
||||
setPath(newPath);
|
||||
}}
|
||||
>
|
||||
<FaArrowUp />
|
||||
</DialogButton>
|
||||
<div style={{ width: '100%' }}>
|
||||
<TextField
|
||||
value={path}
|
||||
onChange={(e) => {
|
||||
e.target.value && setPath(e.target.value);
|
||||
}}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</Focusable>
|
||||
<ControlsList alignItems="center" spacing="standard">
|
||||
<ToggleField
|
||||
highlightOnFocus={false}
|
||||
label={t('FilePickerIndex.files.show_hidden')}
|
||||
bottomSeparator="none"
|
||||
checked={showHidden}
|
||||
onChange={() => setShowHidden((x) => !x)}
|
||||
/>
|
||||
<Dropdown rgOptions={sortOptions} selectedOption={sort} onChange={(x) => setSort(x.data)} />
|
||||
{validFileExtensions && (
|
||||
<DropdownMultiselect
|
||||
label={t('FilePickerIndex.files.file_type')}
|
||||
items={validExtsOptions}
|
||||
selected={selectedExts ? selectedExts : []}
|
||||
onSelect={handleExtsSelect}
|
||||
/>
|
||||
)}
|
||||
</ControlsList>
|
||||
</DialogControlsSection>
|
||||
<DialogControlsSection style={{ marginTop: '1em' }}>
|
||||
<Focusable
|
||||
style={{ display: 'flex', gap: '.25em', flexDirection: 'column', height: '60vh', overflow: 'scroll' }}
|
||||
>
|
||||
{loading && error === FileErrorTypes.None && <SteamSpinner style={{ height: '100%' }} />}
|
||||
{!loading &&
|
||||
error === FileErrorTypes.None &&
|
||||
files.map((file) => {
|
||||
const extension = file.realpath.split('.').pop() as string;
|
||||
return (
|
||||
<DialogButton
|
||||
key={`${file.realpath}${file.name}`}
|
||||
style={{ borderRadius: 'unset', margin: '0', padding: '10px' }}
|
||||
onClick={() => {
|
||||
const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`;
|
||||
if (file.isdir) setPath(fullPath);
|
||||
else {
|
||||
onSubmit({ path: fullPath, realpath: file.realpath });
|
||||
closeModal?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-start' }}>
|
||||
{file.isdir ? (
|
||||
<FaFolder style={iconStyles} />
|
||||
) : (
|
||||
<div style={iconStyles}>
|
||||
{file.realpath.includes('.') ? (
|
||||
<FileIcon {...defaultStyles[extension]} {...styleDefObj[extension]} extension={''} />
|
||||
) : (
|
||||
<FileIcon />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Marquee>{file.name}</Marquee>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
opacity: 0.5,
|
||||
fontSize: '.6em',
|
||||
textAlign: 'left',
|
||||
lineHeight: 1,
|
||||
marginTop: '.5em',
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
</DialogButton>
|
||||
);
|
||||
})}
|
||||
{error}
|
||||
</Focusable>
|
||||
{file.isdir ? t('FilePickerIndex.folder.label') : filesize(file.size, { standard: 'iec' })}
|
||||
<span style={{ marginLeft: 'auto' }}>{new Date(file.modified * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
</DialogButton>
|
||||
);
|
||||
})}
|
||||
{error !== FileErrorTypes.None && <FilePickerError error={error} rawError={rawError ? rawError : ''} />}
|
||||
</Focusable>
|
||||
</DialogControlsSection>
|
||||
</DialogBody>
|
||||
{!loading && !error && !includeFiles && (
|
||||
<DialogButton
|
||||
className="Primary"
|
||||
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
|
||||
onClick={() => {
|
||||
onSubmit({ path, realpath: listing.realpath });
|
||||
closeModal?.();
|
||||
}}
|
||||
>
|
||||
{t('FilePickerIndex.folder.select')}
|
||||
</DialogButton>
|
||||
<DialogFooter>
|
||||
<DialogButton
|
||||
className="Primary"
|
||||
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
|
||||
onClick={() => {
|
||||
onSubmit({ path, realpath: listing.realpath });
|
||||
closeModal?.();
|
||||
}}
|
||||
>
|
||||
{t('FilePickerIndex.folder.select')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</div>
|
||||
{page * max < listing.total && (
|
||||
<DialogFooter>
|
||||
<DialogButton
|
||||
className="Primary"
|
||||
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
|
||||
onClick={() => {
|
||||
setPage(page + 1);
|
||||
}}
|
||||
>
|
||||
{t('FilePickerIndex.folder.show_more')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user