mirror of
https://github.com/zoriya/flood.git
synced 2025-12-05 23:06:20 +00:00
FilesystemBrowser: redesign to allow searching in current directory
Plus, this greatly reduces the number of unnecessary requests sent to Flood server.
This commit is contained in:
@@ -1,8 +1,42 @@
|
||||
import {css} from '@emotion/react';
|
||||
import {darken, lighten, rgba, saturate} from 'polished';
|
||||
import {FC, memo, ReactNodeArray, useEffect, useState} from 'react';
|
||||
import sort from 'fast-sort';
|
||||
import {Trans} from '@lingui/react';
|
||||
|
||||
import {Arrow, File, FolderClosedSolid} from '@client/ui/icons';
|
||||
import {Arrow, File, FolderClosedOutlined, FolderClosedSolid, FolderOpenSolid} from '@client/ui/icons';
|
||||
import FloodActions from '@client/actions/FloodActions';
|
||||
import termMatch from '@client/util/termMatch';
|
||||
|
||||
const foregroundColor = '#5E728C';
|
||||
|
||||
const headerStyle = css({
|
||||
borderBottom: `1px solid ${lighten(0.43, foregroundColor)}`,
|
||||
marginBottom: '3px',
|
||||
paddingBottom: '3px',
|
||||
opacity: 0.75,
|
||||
'&:last-child': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const listItemStyle = css({
|
||||
opacity: 0.5,
|
||||
padding: '3px 9px',
|
||||
transition: 'color 0.25s',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
const listItemSelectableStyle = css({
|
||||
opacity: 1,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.25s, color 0.25s',
|
||||
userSelect: 'none',
|
||||
'&:hover': {
|
||||
color: saturate(0.1, darken(0.15, foregroundColor)),
|
||||
background: rgba(foregroundColor, 0.1),
|
||||
},
|
||||
});
|
||||
|
||||
const MESSAGES = {
|
||||
EACCES: 'filesystem.error.eacces',
|
||||
@@ -12,157 +46,191 @@ const MESSAGES = {
|
||||
interface FilesystemBrowserProps {
|
||||
selectable?: 'files' | 'directories';
|
||||
directory: string;
|
||||
onItemSelection?: (newDestination: string, isDirectory?: boolean) => void;
|
||||
onItemSelection?: (newDestination: string, shouldKeepOpen?: boolean) => void;
|
||||
}
|
||||
|
||||
const FilesystemBrowser: FC<FilesystemBrowserProps> = memo(
|
||||
({directory, selectable, onItemSelection}: FilesystemBrowserProps) => {
|
||||
const [errorResponse, setErrorResponse] = useState<{data?: NodeJS.ErrnoException} | null>(null);
|
||||
const [separator, setSeparator] = useState<string>('/');
|
||||
const [directories, setDirectories] = useState<string[]>([]);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [separator, setSeparator] = useState<string>(directory.includes('/') ? '/' : '\\');
|
||||
const [directories, setDirectories] = useState<string[] | null>(null);
|
||||
const [files, setFiles] = useState<string[] | null>(null);
|
||||
|
||||
const lastSegmentIndex = directory.lastIndexOf(separator) + 1;
|
||||
const currentDirectory = lastSegmentIndex > 0 ? directory.substr(0, lastSegmentIndex) : directory;
|
||||
const lastSegment = directory.substr(lastSegmentIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (!directory) {
|
||||
if (!currentDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
FloodActions.fetchDirectoryList(directory)
|
||||
setDirectories(null);
|
||||
setFiles(null);
|
||||
|
||||
FloodActions.fetchDirectoryList(currentDirectory)
|
||||
.then(({files: fetchedFiles, directories: fetchedDirectories, separator: fetchedSeparator}) => {
|
||||
setFiles(fetchedFiles);
|
||||
setDirectories(fetchedDirectories);
|
||||
setFiles(fetchedFiles);
|
||||
setSeparator(fetchedSeparator);
|
||||
setErrorResponse(null);
|
||||
})
|
||||
.catch(({response}) => {
|
||||
setErrorResponse(response);
|
||||
});
|
||||
}, [directory]);
|
||||
}, [currentDirectory]);
|
||||
|
||||
let errorMessage = null;
|
||||
let errorMessage: string | null = null;
|
||||
let listItems = null;
|
||||
let parentDirectoryElement = null;
|
||||
let shouldShowDirectoryList = true;
|
||||
|
||||
if ((directories == null && selectable === 'directories') || (files == null && selectable === 'files')) {
|
||||
shouldShowDirectoryList = false;
|
||||
errorMessage = (
|
||||
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
|
||||
<em>
|
||||
<Trans id="filesystem.fetching" />
|
||||
</em>
|
||||
</div>
|
||||
);
|
||||
errorMessage = 'filesystem.fetching';
|
||||
}
|
||||
|
||||
if (errorResponse && errorResponse.data && errorResponse.data.code) {
|
||||
shouldShowDirectoryList = false;
|
||||
errorMessage = (
|
||||
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
|
||||
<em>
|
||||
<Trans id={MESSAGES[errorResponse.data.code as keyof typeof MESSAGES] || 'filesystem.error.unknown'} />
|
||||
</em>
|
||||
</div>
|
||||
);
|
||||
errorMessage = MESSAGES[errorResponse.data.code as keyof typeof MESSAGES] || 'filesystem.error.unknown';
|
||||
}
|
||||
|
||||
if (directory) {
|
||||
parentDirectoryElement = (
|
||||
if (!directory) {
|
||||
errorMessage = 'filesystem.error.no.input';
|
||||
} else {
|
||||
const parentDirectory = `${currentDirectory.split(separator).slice(0, -2).join(separator)}${separator}`;
|
||||
const parentDirectoryElement = (
|
||||
<li
|
||||
className="filesystem__directory-list__item filesystem__directory-list__item--parent"
|
||||
onClick={() => {
|
||||
let parentDirectory = directory;
|
||||
|
||||
if (directory.endsWith(separator)) {
|
||||
parentDirectory = directory.substring(0, directory.length - 1);
|
||||
}
|
||||
|
||||
const directoryArr = directory.split(separator);
|
||||
directoryArr.pop();
|
||||
parentDirectory = directoryArr.join(separator);
|
||||
|
||||
onItemSelection?.(parentDirectory);
|
||||
}}>
|
||||
<Arrow />
|
||||
<Trans id="filesystem.parent.directory" />
|
||||
css={[
|
||||
listItemStyle,
|
||||
listItemSelectableStyle,
|
||||
{
|
||||
'@media (max-width: 720px)': headerStyle,
|
||||
},
|
||||
]}
|
||||
key={parentDirectory}
|
||||
onClick={selectable !== 'files' ? () => onItemSelection?.(parentDirectory, true) : undefined}>
|
||||
<Arrow css={{transform: 'scale(0.75) rotate(180deg)'}} />
|
||||
..
|
||||
</li>
|
||||
);
|
||||
} else {
|
||||
shouldShowDirectoryList = false;
|
||||
errorMessage = (
|
||||
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
|
||||
<em>
|
||||
<Trans id="filesystem.error.no.input" />
|
||||
</em>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowDirectoryList) {
|
||||
const directoryMatched = lastSegment ? termMatch(directories, (subDirectory) => subDirectory, lastSegment) : [];
|
||||
const directoryList: ReactNodeArray =
|
||||
directories?.map((subDirectory) => (
|
||||
<li
|
||||
className={`${'filesystem__directory-list__item filesystem__directory-list__item--directory'.concat(
|
||||
selectable !== 'files' ? ' filesystem__directory-list__item--selectable' : '',
|
||||
)}`}
|
||||
key={subDirectory}
|
||||
onClick={
|
||||
selectable !== 'files'
|
||||
? () => {
|
||||
onItemSelection?.(
|
||||
directory?.endsWith(separator)
|
||||
? `${directory}${subDirectory}`
|
||||
: `${directory}${separator}${subDirectory}`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}>
|
||||
<FolderClosedSolid />
|
||||
{subDirectory}
|
||||
</li>
|
||||
)) ?? [];
|
||||
(directories?.length &&
|
||||
sort(directories.slice())
|
||||
.desc((subDirectory) => directoryMatched.includes(subDirectory))
|
||||
.map((subDirectory) => {
|
||||
const destination = `${currentDirectory}${subDirectory}${separator}`;
|
||||
return (
|
||||
<li
|
||||
css={[
|
||||
listItemStyle,
|
||||
selectable !== 'files' ? listItemSelectableStyle : undefined,
|
||||
directoryMatched.includes(subDirectory) ? {fontWeight: 'bold'} : undefined,
|
||||
]}
|
||||
key={destination}
|
||||
onClick={selectable !== 'files' ? () => onItemSelection?.(destination, true) : undefined}>
|
||||
<FolderClosedSolid />
|
||||
{subDirectory}
|
||||
</li>
|
||||
);
|
||||
})) ||
|
||||
[];
|
||||
|
||||
const filesList: ReactNodeArray =
|
||||
files?.map((file) => (
|
||||
<li
|
||||
className={`${'filesystem__directory-list__item filesystem__directory-list__item--file'.concat(
|
||||
selectable !== 'directories' ? ' filesystem__directory-list__item--selectable' : '',
|
||||
)}`}
|
||||
key={file}
|
||||
onClick={
|
||||
selectable !== 'directories'
|
||||
? () => {
|
||||
onItemSelection?.(
|
||||
directory?.endsWith(separator) ? `${directory}${file}` : `${directory}${separator}${file}`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}>
|
||||
<File />
|
||||
{file}
|
||||
</li>
|
||||
)) ?? [];
|
||||
const fileMatched = lastSegment ? termMatch(files, (file) => file, lastSegment) : [];
|
||||
const fileList: ReactNodeArray =
|
||||
(files?.length &&
|
||||
sort(files.slice())
|
||||
.desc((file) => fileMatched.includes(file))
|
||||
.map((file) => {
|
||||
const destination = `${currentDirectory}${file}`;
|
||||
return (
|
||||
<li
|
||||
css={[
|
||||
listItemStyle,
|
||||
selectable !== 'directories' ? listItemSelectableStyle : undefined,
|
||||
fileMatched.includes(file) ? {fontWeight: 'bold'} : undefined,
|
||||
]}
|
||||
key={destination}
|
||||
onClick={selectable !== 'directories' ? () => onItemSelection?.(destination, false) : undefined}>
|
||||
<File />
|
||||
{file}
|
||||
</li>
|
||||
);
|
||||
})) ||
|
||||
[];
|
||||
|
||||
listItems = [...directoryList, ...filesList];
|
||||
}
|
||||
if (directoryList.length === 0 && fileList.length === 0 && !errorMessage) {
|
||||
errorMessage = 'filesystem.empty.directory';
|
||||
}
|
||||
|
||||
if ((!listItems || listItems.length === 0) && !errorMessage) {
|
||||
errorMessage = (
|
||||
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
|
||||
<em>
|
||||
<Trans id="filesystem.empty.directory" />
|
||||
</em>
|
||||
</div>
|
||||
);
|
||||
const inputDirectoryElement =
|
||||
!directoryMatched.includes(lastSegment) && selectable === 'directories' && lastSegment && !errorMessage
|
||||
? (() => {
|
||||
const inputDestination = `${currentDirectory}${lastSegment}${separator}`;
|
||||
return [
|
||||
<li
|
||||
css={[
|
||||
listItemStyle,
|
||||
listItemSelectableStyle,
|
||||
{fontWeight: 'bold', '@media (max-width: 720px)': {display: 'none'}},
|
||||
]}
|
||||
key={inputDestination}
|
||||
onClick={() => onItemSelection?.(inputDestination, false)}>
|
||||
<FolderClosedOutlined />
|
||||
<span css={{whiteSpace: 'pre-wrap'}}>{lastSegment}</span>
|
||||
<em css={{fontWeight: 'lighter'}}>
|
||||
{' - '}
|
||||
<Trans id="filesystem.error.enoent" />
|
||||
</em>
|
||||
</li>,
|
||||
];
|
||||
})()
|
||||
: [];
|
||||
|
||||
listItems = [parentDirectoryElement, ...inputDirectoryElement, ...directoryList, ...fileList];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filesystem__directory-list context-menu__items__padding-surrogate">
|
||||
{parentDirectoryElement}
|
||||
{errorMessage}
|
||||
<div
|
||||
css={{
|
||||
color: foregroundColor,
|
||||
listStyle: 'none',
|
||||
padding: '3px 0px',
|
||||
'.icon': {
|
||||
fill: 'currentColor',
|
||||
height: '14px',
|
||||
width: '14px',
|
||||
marginRight: `${25 * (1 / 5)}px`,
|
||||
marginTop: '-3px',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
}}>
|
||||
{currentDirectory && (
|
||||
<li
|
||||
css={[
|
||||
listItemStyle,
|
||||
headerStyle,
|
||||
{
|
||||
whiteSpace: 'pre-wrap',
|
||||
'.icon': {
|
||||
transform: 'scale(0.9)',
|
||||
marginTop: '-2px !important',
|
||||
},
|
||||
'@media (max-width: 720px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
]}>
|
||||
<FolderOpenSolid />
|
||||
{currentDirectory}
|
||||
</li>
|
||||
)}
|
||||
{listItems}
|
||||
{errorMessage && (
|
||||
<div css={[listItemStyle, {opacity: 1}]}>
|
||||
<em>
|
||||
<Trans id={errorMessage} />
|
||||
</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -139,13 +139,13 @@ const FilesystemBrowserTextbox = forwardRef<HTMLInputElement, FilesystemBrowserT
|
||||
<FilesystemBrowser
|
||||
directory={destination}
|
||||
selectable={selectable}
|
||||
onItemSelection={(newDestination: string, isDirectory = true) => {
|
||||
onItemSelection={(newDestination: string, shouldKeepOpen = true) => {
|
||||
if (textboxRef.current != null) {
|
||||
textboxRef.current.value = newDestination;
|
||||
}
|
||||
|
||||
setIsDirectoryListOpen(shouldKeepOpen);
|
||||
setDestination(newDestination);
|
||||
setIsDirectoryListOpen(isDirectory);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
const termMatch = <T>(elements: Array<T>, sub: (element: T) => string, searchString: string): Array<T> => {
|
||||
if (searchString !== '') {
|
||||
const termMatch = <T>(
|
||||
elements: Array<T> | undefined | null,
|
||||
sub: (element: T) => string,
|
||||
searchString: string,
|
||||
): Array<T> => {
|
||||
if (searchString !== '' && elements?.length) {
|
||||
const queries: Array<RegExp> = [];
|
||||
const searchTerms = searchString.replace(/,/g, ' ').split(' ');
|
||||
|
||||
for (let i = 0, len = searchTerms.length; i < len; i += 1) {
|
||||
queries.push(new RegExp(searchTerms[i], 'gi'));
|
||||
try {
|
||||
queries.push(new RegExp(searchTerms[i], 'gi'));
|
||||
} catch {
|
||||
// do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
return elements.filter((element) => {
|
||||
for (let i = 0, len = queries.length; i < len; i += 1) {
|
||||
if (!sub(element).match(queries[i])) {
|
||||
return false;
|
||||
}
|
||||
if (sub(element) === searchString) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
if (queries.every((query) => sub(element).match(query))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return elements;
|
||||
return elements ?? [];
|
||||
};
|
||||
|
||||
export default termMatch;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
$filesystem--directory-list--foreground: #5e728c;
|
||||
$filesystem--directory-list--foreground--hover: saturate(darken($filesystem--directory-list--foreground, 15%), 10%);
|
||||
$filesystem--directory-list--background--hover: rgba($filesystem--directory-list--foreground, 0.1);
|
||||
$filesystem--directory-list--parent--border-color: lighten($filesystem--directory-list--foreground, 43%);
|
||||
|
||||
.filesystem {
|
||||
&__directory-list {
|
||||
color: $filesystem--directory-list--foreground;
|
||||
list-style: none;
|
||||
|
||||
&__item {
|
||||
opacity: 0.5;
|
||||
padding: $spacing--xx-small $spacing--small;
|
||||
transition: color 0.25s;
|
||||
white-space: nowrap;
|
||||
|
||||
&--parent,
|
||||
&--selectable {
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
transition: background $speed--x-fast, color $speed--x-fast;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: $filesystem--directory-list--foreground--hover;
|
||||
background: $filesystem--directory-list--background--hover;
|
||||
}
|
||||
}
|
||||
|
||||
&--message {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--parent {
|
||||
border-bottom: 1px solid $filesystem--directory-list--parent--border-color;
|
||||
margin-bottom: $spacing--small;
|
||||
padding-bottom: $spacing--small;
|
||||
opacity: 0.75;
|
||||
|
||||
.icon {
|
||||
transform: scale(0.75) rotate(180deg);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
fill: currentColor;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
margin-right: $spacing-unit * 1/5;
|
||||
margin-top: -3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@
|
||||
@import 'components/dropdown';
|
||||
@import 'components/dropzone';
|
||||
@import 'components/duration';
|
||||
@import 'components/filesystem';
|
||||
@import 'components/floating-action';
|
||||
@import 'components/icons';
|
||||
@import 'components/interactive-list';
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -154,6 +154,7 @@
|
||||
"parse-torrent": "^9.1.3",
|
||||
"passport": "^0.4.1",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"polished": "^4.1.1",
|
||||
"postcss": "^8.2.7",
|
||||
"postcss-loader": "^5.0.0",
|
||||
"prettier": "^2.2.1",
|
||||
@@ -14978,6 +14979,18 @@
|
||||
"integrity": "sha512-6XYcNkXWGiJ2CVXogTP7uJ6ZXQCldYLZc16wgRp8tqRaBTTyIfF+TUT3EQJPXTLAT7OTPpTAoaFdoXKfaTRU1w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/polished": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/polished/-/polished-4.1.1.tgz",
|
||||
"integrity": "sha512-4MZTrfPMPRLD7ac8b+2JZxei58zw6N1hFkdBDERif5Tlj19y3vPoPusrLG+mJIlPTGnUlKw3+yWz0BazvMx1vg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/portfinder": {
|
||||
"version": "1.0.28",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz",
|
||||
@@ -36329,6 +36342,15 @@
|
||||
"integrity": "sha512-6XYcNkXWGiJ2CVXogTP7uJ6ZXQCldYLZc16wgRp8tqRaBTTyIfF+TUT3EQJPXTLAT7OTPpTAoaFdoXKfaTRU1w==",
|
||||
"dev": true
|
||||
},
|
||||
"polished": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/polished/-/polished-4.1.1.tgz",
|
||||
"integrity": "sha512-4MZTrfPMPRLD7ac8b+2JZxei58zw6N1hFkdBDERif5Tlj19y3vPoPusrLG+mJIlPTGnUlKw3+yWz0BazvMx1vg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
}
|
||||
},
|
||||
"portfinder": {
|
||||
"version": "1.0.28",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz",
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"parse-torrent": "^9.1.3",
|
||||
"passport": "^0.4.1",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"polished": "^4.1.1",
|
||||
"postcss": "^8.2.7",
|
||||
"postcss-loader": "^5.0.0",
|
||||
"prettier": "^2.2.1",
|
||||
|
||||
Reference in New Issue
Block a user