mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Migrated both files onto the token-driven ./ui component library: Table,
Modal, Button/IconButton, Checkbox, Header, Panel, Progress, Placeholder,
Skeleton, ActionInput/TextInput, IconStack, Menu/ButtonGroup for the slicer
split-button, and native <img>/<form> in place of Semantic's Image/Form.
Notable adaptations:
- Semantic's per-row `disabled` styling (no visual equivalent in the new
Table) is reproduced with inline opacity/pointer-events instead.
- DirectorySearch's autoFocus now passes straight through as a prop instead
of a nested `input={{autoFocus}}` object, matching SearchBox's API.
- The responsive icon+label footer buttons keep a single Button and toggle
children instead of swapping component type.
- FileBrowser.test.js: two upload-button assertions keyed off a literal
Semantic `.green` class; broadened to also match on the icon, mirroring
the sibling assertion already using that fallback.
Preserved as-is (reported, not fixed): getVideoPreviewModal's
`<Header as='h5'>{path['path']}</Header>` indexes a string with `['path']`,
which was already dead/undefined before this migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
952 lines
34 KiB
JavaScript
952 lines
34 KiB
JavaScript
import {
|
|
ActionInput,
|
|
Button,
|
|
Checkbox,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
IconStack,
|
|
Modal,
|
|
Panel,
|
|
Placeholder,
|
|
Progress,
|
|
Skeleton,
|
|
Stack,
|
|
Table,
|
|
TextInput,
|
|
} from "./ui";
|
|
import {
|
|
APIButton,
|
|
DirectorySearch,
|
|
ErrorMessage,
|
|
FileIcon,
|
|
humanFileSize,
|
|
mimetypeIconName,
|
|
Toggle,
|
|
useIsIgnoredDirectory
|
|
} from "./Common";
|
|
import React, {useContext, useEffect, useState} from "react";
|
|
import {IconArrowsMove, IconChevronsUp, IconCursorText, IconTags} from "@tabler/icons-react";
|
|
import {useDropzone} from "react-dropzone";
|
|
import {useHotkeys} from "react-hotkeys-hook";
|
|
import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api";
|
|
import _ from 'lodash';
|
|
import {SortableTable} from "./SortableTable";
|
|
import {useBrowseFiles, useElementWidth, useMediaDirectory, useUploadFile, useWROLMode} from "../hooks/customHooks";
|
|
import {FileRowTagIcon, FilesRefreshButton} from "./Files";
|
|
import {FilePreviewContext} from "./FilePreview";
|
|
import {DragSelectionContext, DragSelectionProvider, SettingsContext, ThemeContext} from "../contexts/contexts";
|
|
import {BulkTagModal} from "./BulkTagModal";
|
|
import {InlineErrorBoundary} from "./ErrorBoundary";
|
|
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
|
|
|
|
function depthIndentation(path) {
|
|
// Repeated spaces for every folder a path is in.
|
|
const depth = (path.match(/\//g) || []).length;
|
|
return '\xa0\xa0\xa0\xa0'.repeat(depth);
|
|
}
|
|
|
|
export function pathDirectory(path) {
|
|
// Return the full path of the path's directory.
|
|
// pathDirectory('/some/directory/file.txt') -> '/some/directory'
|
|
return path.substring(0, path.lastIndexOf('/'));
|
|
}
|
|
|
|
export function pathName(path) {
|
|
// Return the file's name.
|
|
// pathName('/some/directory/file.txt') -> 'file.txt'
|
|
// pathName('/some/directory') -> 'directory'
|
|
if (path.endsWith('/')) {
|
|
// Path is a directory, return its name.
|
|
path = path.substring(0, path.length - 1);
|
|
}
|
|
return path.substring(path.lastIndexOf('/') + 1);
|
|
}
|
|
|
|
export function splitPathParentAndName(path) {
|
|
if (path.endsWith('/')) {
|
|
// Path is a directory, return its name.
|
|
path = path.substring(0, path.length - 1);
|
|
}
|
|
const slashIndex = path.lastIndexOf('/');
|
|
return [path.substring(0, slashIndex), path.substring(slashIndex + 1, path.length)];
|
|
}
|
|
|
|
export function filterBrowseFiles(browseFiles, filterStr) {
|
|
// Reduce the file tree to the rows whose name matches `filterStr`, keeping the ancestor
|
|
// folders of every match. A folder whose own name matches keeps its entire subtree.
|
|
if (!filterStr) {
|
|
return browseFiles;
|
|
}
|
|
const needle = filterStr.toLowerCase();
|
|
const filterList = (files) => {
|
|
// `files` may be a dict keyed by name (from the API) or an array.
|
|
const fileList = Array.isArray(files) ? files : Object.values(files);
|
|
const matches = [];
|
|
for (const file of fileList) {
|
|
if (pathName(file.path).toLowerCase().includes(needle)) {
|
|
matches.push(file);
|
|
} else if (file.children) {
|
|
const children = filterList(file.children);
|
|
if (children.length > 0) {
|
|
matches.push({...file, children});
|
|
}
|
|
}
|
|
}
|
|
return matches;
|
|
};
|
|
return filterList(browseFiles);
|
|
}
|
|
|
|
function Folder({folder, onFolderClick, sortData, onFileClick, disabled, loadingFolders}) {
|
|
const {handleDragStart, handleDragMove, getDragState, shouldAllowClick, selectedPaths, onSelect} = useContext(DragSelectionContext);
|
|
// Creates a single table row for a folder, or a row for itself and indented rows for its children.
|
|
let {path, children, is_empty} = folder;
|
|
const ignored = useIsIgnoredDirectory(path);
|
|
const pathWithNoTrailingSlash = path.substring(0, path.length - 1);
|
|
const name = path.substring(pathWithNoTrailingSlash.lastIndexOf('/') + 1);
|
|
const isInSelectedPaths = selectedPaths.indexOf(folder['path']) >= 0;
|
|
const {isDragSelecting, isDragDeselecting} = getDragState(path, isInSelectedPaths);
|
|
// Preview selection: show checked if selecting, unchecked if deselecting
|
|
const showAsSelected = (isInSelectedPaths && !isDragDeselecting) || isDragSelecting;
|
|
// Visual highlight class
|
|
const dragClass = isDragSelecting ? 'drag-selecting' : (isDragDeselecting ? 'drag-deselecting' : '');
|
|
|
|
const isLoading = loadingFolders && loadingFolders.has(path);
|
|
const depth = (path.match(/\//g) || []).length;
|
|
|
|
const f = <Table.Row
|
|
key={path}
|
|
className={dragClass}
|
|
style={(isLoading || disabled) ? {opacity: 0.5, pointerEvents: 'none'} : undefined}
|
|
onMouseDown={(e) => handleDragStart(path, e)}
|
|
onMouseEnter={() => handleDragMove(path)}
|
|
>
|
|
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
|
|
<Checkbox checked={showAsSelected} onChange={() => onSelect(folder['path'])}/>
|
|
</Table.Cell>
|
|
<Table.Cell
|
|
onClick={() => shouldAllowClick() && onFolderClick(path)}
|
|
className='file-path'
|
|
colSpan={2}
|
|
style={is_empty ? {opacity: 0.5} : undefined}
|
|
>
|
|
{depthIndentation(pathWithNoTrailingSlash)}
|
|
{is_empty ? <Icon name='folder outline'/> : <Icon name='folder'/>}
|
|
{ignored && <Icon name='eye slash' color='red'/>}
|
|
{name}
|
|
</Table.Cell>
|
|
</Table.Row>;
|
|
if (children) {
|
|
// Folder has children, recursively display them.
|
|
children = sortData(children);
|
|
let childPaths = [];
|
|
_.forEach(children, (p, k) => {
|
|
childPaths = [...childPaths,
|
|
<InlineErrorBoundary key={k}>
|
|
<Path path={p} onFolderClick={onFolderClick} sortData={sortData} onFileClick={onFileClick}
|
|
disabled={disabled} loadingFolders={loadingFolders}/>
|
|
</InlineErrorBoundary>];
|
|
});
|
|
return <React.Fragment>
|
|
{f}
|
|
{childPaths}
|
|
</React.Fragment>
|
|
} else if (isLoading) {
|
|
// Folder is loading - show skeleton rows
|
|
return <React.Fragment>
|
|
{f}
|
|
<SkeletonFileRow depth={depth}/>
|
|
</React.Fragment>;
|
|
} else {
|
|
// Folder has no children.
|
|
return f;
|
|
}
|
|
}
|
|
|
|
function File({file, onFileClick, disabled}) {
|
|
const {handleDragStart, handleDragMove, getDragState, shouldAllowClick, selectedPaths, onSelect} = useContext(DragSelectionContext);
|
|
const {path, size} = file;
|
|
const isInSelectedPaths = selectedPaths.indexOf(path) >= 0;
|
|
const {isDragSelecting, isDragDeselecting} = getDragState(path, isInSelectedPaths);
|
|
// Preview selection: show checked if selecting, unchecked if deselecting
|
|
const showAsSelected = (isInSelectedPaths && !isDragDeselecting) || isDragSelecting;
|
|
// Visual highlight class
|
|
const dragClass = isDragSelecting ? 'drag-selecting' : (isDragDeselecting ? 'drag-deselecting' : '');
|
|
return <Table.Row
|
|
key={path}
|
|
className={dragClass}
|
|
style={disabled ? {opacity: 0.5, pointerEvents: 'none'} : undefined}
|
|
onMouseDown={(e) => handleDragStart(path, e)}
|
|
onMouseEnter={() => handleDragMove(path)}
|
|
>
|
|
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
|
|
<Checkbox checked={showAsSelected} onChange={() => onSelect(path)}/>
|
|
</Table.Cell>
|
|
<Table.Cell onClick={() => shouldAllowClick() && onFileClick(file)} className='file-path'>
|
|
{depthIndentation(path)}
|
|
<FileRowTagIcon file={file}/>
|
|
{/*null size to make icon the correct size*/}
|
|
<FileIcon file={file} size={null}/>
|
|
{pathName(path)}
|
|
</Table.Cell>
|
|
<Table.Cell numeric style={{width: '1%', whiteSpace: 'nowrap'}}>
|
|
{humanFileSize(size)}
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
}
|
|
|
|
function Path({path, onFolderClick, onFileClick, sortData, disabled, loadingFolders}) {
|
|
if (path['path'].endsWith('/')) {
|
|
return <Folder
|
|
folder={path}
|
|
onFolderClick={onFolderClick}
|
|
sortData={sortData}
|
|
onFileClick={onFileClick}
|
|
disabled={disabled}
|
|
loadingFolders={loadingFolders}
|
|
/>
|
|
} else {
|
|
return <File
|
|
file={path}
|
|
onFileClick={onFileClick}
|
|
disabled={disabled}
|
|
/>
|
|
}
|
|
}
|
|
|
|
function SkeletonFileRow({depth}) {
|
|
const indentation = '\xa0\xa0\xa0\xa0'.repeat(depth);
|
|
return (
|
|
<Table.Row>
|
|
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
|
|
<Checkbox disabled/>
|
|
</Table.Cell>
|
|
<Table.Cell className='file-path' colSpan={2}>
|
|
{indentation}
|
|
<Skeleton height={12} width={200} radius={0} animate style={{display: 'inline-block', marginLeft: '0.5em'}}/>
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
);
|
|
}
|
|
|
|
// Below this footer width (px) the action buttons are icon-only; at or above it they expand to show labels.
|
|
// The eight labeled buttons need ~1020px to fit on a single row; this leaves a small buffer.
|
|
const FOOTER_LABELS_MIN_WIDTH = 1050;
|
|
|
|
export function FileBrowser() {
|
|
const [selectedPaths, setSelectedPaths] = React.useState([]);
|
|
const [renameOpen, setRenameOpen] = React.useState(false);
|
|
const [moveOpen, setMoveOpen] = React.useState(false);
|
|
const [makeDirectoryOpen, setMakeDirectoryOpen] = React.useState(false);
|
|
const [ignoreDirectoryOpen, setIgnoreDirectoryOpen] = React.useState(false);
|
|
const [bulkTagOpen, setBulkTagOpen] = React.useState(false);
|
|
const [uploadOpen, setUploadOpen] = React.useState(false);
|
|
const [pending, setPending] = React.useState(false);
|
|
// Tracks which folders are currently loading their children
|
|
const [loadingFolders, setLoadingFolders] = React.useState(new Set());
|
|
// Only true if only one path is selected, and it is a directory.
|
|
const [singleDirectorySelected, setSingleDirectorySelected] = React.useState(false);
|
|
// Only true if single directory is selected, and it is in settings['ignored_directories']
|
|
const [isDirectoryIgnored, setIsDirectoryIgnored] = React.useState(false);
|
|
// Tracks folder that was opened while selected - its children will be auto-selected when they load
|
|
const pendingAutoSelectRef = React.useRef(null);
|
|
// Tagged file groups that would be deleted - shown in confirmation modal
|
|
const [taggedFileGroups, setTaggedFileGroups] = React.useState(null);
|
|
// Reduces the visible rows to those matching, and their parent folders.
|
|
const [filterStr, setFilterStr] = React.useState('');
|
|
const filterInputRef = React.useRef();
|
|
|
|
// 'f' focuses the filter input, like the search inputs on other pages.
|
|
useHotkeys('f', (e) => {
|
|
e.preventDefault();
|
|
if (filterInputRef.current) {
|
|
filterInputRef.current.focus();
|
|
}
|
|
}, {enableOnFormTags: false});
|
|
|
|
const {settings, fetchSettings} = React.useContext(SettingsContext);
|
|
const {inverted} = React.useContext(ThemeContext);
|
|
|
|
const selectedPathsCount = selectedPaths ? selectedPaths.length : 0;
|
|
|
|
const {browseFiles, openFolders, setOpenFolders, fetchFiles} = useBrowseFiles();
|
|
const wrolModeEnabled = useWROLMode();
|
|
|
|
const headers = [
|
|
{key: 'select', text: ''},
|
|
{key: 'path', text: 'Path', sortBy: i => i['path'].toLowerCase()},
|
|
{key: 'size', text: 'Size', sortBy: 'size'},
|
|
];
|
|
|
|
const {setPreviewFile, setCallbacks} = React.useContext(FilePreviewContext);
|
|
|
|
useEffect(() => {
|
|
// Fetch files after tagging.
|
|
setCallbacks([() => fetchFiles(openFolders)]);
|
|
|
|
return () => setCallbacks(null);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectedPaths.length === 1 && selectedPaths[0].endsWith('/')) {
|
|
setSingleDirectorySelected(true);
|
|
if (!_.isEmpty(settings)) {
|
|
// Used to toggle between ignore/un-ignore (eye/eye lash) icon.
|
|
const directory = selectedPaths[0].slice(0, -1);
|
|
setIsDirectoryIgnored(settings['ignored_directories'].indexOf(directory) >= 0);
|
|
}
|
|
} else {
|
|
setSingleDirectorySelected(false);
|
|
}
|
|
}, [selectedPaths, settings]);
|
|
|
|
// Auto-select immediate children when a selected folder is opened
|
|
useEffect(() => {
|
|
const folderToAutoSelect = pendingAutoSelectRef.current;
|
|
if (folderToAutoSelect && browseFiles && typeof browseFiles === 'object') {
|
|
// browseFiles is a dict: {'map/': {path: 'map/', children: {...}}, ...}
|
|
// children is also a dict: {'file.txt': {path: 'map/file.txt', ...}, ...}
|
|
const findFolderChildren = (files, targetFolder) => {
|
|
if (!files || typeof files !== 'object') return null;
|
|
// Convert dict to array of values for iteration
|
|
const fileList = Array.isArray(files) ? files : Object.values(files);
|
|
for (const file of fileList) {
|
|
if (file.path === targetFolder && file.children) {
|
|
// Return children as array of values
|
|
return Array.isArray(file.children) ? file.children : Object.values(file.children);
|
|
}
|
|
if (file.children) {
|
|
const found = findFolderChildren(file.children, targetFolder);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const children = findFolderChildren(browseFiles, folderToAutoSelect);
|
|
if (children && children.length > 0) {
|
|
const childPaths = children.map(child => child.path);
|
|
const newSelectedPaths = [...new Set([...selectedPaths, ...childPaths])];
|
|
setSelectedPaths(newSelectedPaths);
|
|
}
|
|
pendingAutoSelectRef.current = null;
|
|
}
|
|
}, [browseFiles]);
|
|
|
|
// Clear loading states when file data updates
|
|
useEffect(() => {
|
|
if (loadingFolders.size > 0) {
|
|
setLoadingFolders(new Set());
|
|
}
|
|
}, [browseFiles]);
|
|
|
|
const onIgnore = async () => {
|
|
setIgnoreDirectoryOpen(false);
|
|
await fetchSettings();
|
|
}
|
|
|
|
const handleDeleteComplete = async () => {
|
|
// Remove deleted directories from openFolders to prevent fetching non-existent directories
|
|
const deletedDirs = selectedPaths.filter(p => p.endsWith('/'));
|
|
if (deletedDirs.length > 0 && openFolders && openFolders.length > 0) {
|
|
const newOpenFolders = openFolders.filter(folder =>
|
|
!deletedDirs.some(deleted => folder === deleted || folder.startsWith(deleted))
|
|
);
|
|
setOpenFolders(newOpenFolders.length > 0 ? newOpenFolders : null);
|
|
}
|
|
await reset();
|
|
};
|
|
|
|
const onDelete = async () => {
|
|
const result = await deleteFile(selectedPaths);
|
|
if (result && result.tagged) {
|
|
// Show modal with tagged file groups for user to confirm.
|
|
setTaggedFileGroups(result.file_groups);
|
|
return;
|
|
}
|
|
await handleDeleteComplete();
|
|
};
|
|
|
|
const onForceDelete = async () => {
|
|
setTaggedFileGroups(null);
|
|
const result = await deleteFile(selectedPaths, true);
|
|
if (result && result.tagged) return; // unexpected tagged response
|
|
if (result && !result.ok) return; // error already toasted
|
|
await handleDeleteComplete();
|
|
};
|
|
|
|
const handleMakeDirectory = async () => {
|
|
setMakeDirectoryOpen(false);
|
|
await reset();
|
|
}
|
|
|
|
const reset = async () => {
|
|
setSelectedPaths([]);
|
|
setPending(true);
|
|
try {
|
|
await fetchFiles();
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
const [footerRef, footerWidth] = useElementWidth();
|
|
// Expand icon-only buttons to include labels once the footer is wide enough to fit them.
|
|
const showLabels = footerWidth >= FOOTER_LABELS_MIN_WIDTH;
|
|
// Show a label alongside the icon once there's room; otherwise the icon carries the meaning alone.
|
|
const label = (text) => showLabels ? text : null;
|
|
|
|
const footerClassName = `sticky-footer ${inverted}`;
|
|
const footer = <div className={footerClassName} ref={footerRef}>
|
|
<FilesRefreshButton paths={selectedPaths} showLabel={showLabels}/>
|
|
<APIButton
|
|
icon='trash'
|
|
role='danger'
|
|
confirmContent='Are you sure you want to delete these files?'
|
|
confirmButton='Delete'
|
|
onClick={onDelete}
|
|
disabled={selectedPathsCount === 0}
|
|
obeyWROLMode={true}
|
|
>{label('Delete')}</APIButton>
|
|
<Button icon={IconCursorText}
|
|
color='yellow'
|
|
onClick={() => setRenameOpen(true)}
|
|
disabled={wrolModeEnabled || selectedPathsCount !== 1}
|
|
>{label('Rename')}</Button>
|
|
{selectedPathsCount === 1 ?
|
|
<RenameModal
|
|
open={renameOpen}
|
|
onClose={() => setRenameOpen(false)}
|
|
onPending={setPending}
|
|
path={selectedPaths[0]}
|
|
onSubmit={reset}
|
|
/> : null}
|
|
<Button icon={IconArrowsMove}
|
|
color='teal'
|
|
disabled={wrolModeEnabled || selectedPathsCount === 0}
|
|
onClick={() => setMoveOpen(true)}
|
|
>{label('Move')}</Button>
|
|
{selectedPathsCount > 0 ?
|
|
<MoveModal open={moveOpen}
|
|
onClose={() => setMoveOpen(false)}
|
|
paths={selectedPaths}
|
|
onSubmit={reset}
|
|
/> : null}
|
|
<Button
|
|
color='blue'
|
|
onClick={() => setMakeDirectoryOpen(true)}
|
|
disabled={wrolModeEnabled}
|
|
style={{paddingLeft: '1em', paddingRight: showLabels ? '1em' : '0.8em'}}
|
|
>
|
|
<IconStack corner={<Icon name='add'/>} label='New folder'>
|
|
<Icon name='folder'/>
|
|
</IconStack>
|
|
{showLabels ? <> New Folder</> : null}
|
|
</Button>
|
|
<MakeDirectoryModal
|
|
open={makeDirectoryOpen}
|
|
onClose={() => setMakeDirectoryOpen(false)}
|
|
parent={selectedPaths.length ? selectedPaths[0] : null}
|
|
onSubmit={handleMakeDirectory}
|
|
/>
|
|
<Button
|
|
icon='upload'
|
|
color='green'
|
|
disabled={wrolModeEnabled || (selectedPathsCount > 0 && !singleDirectorySelected)}
|
|
onClick={() => setUploadOpen(true)}
|
|
>{label('Upload')}</Button>
|
|
<FileBrowserUploadModal
|
|
open={uploadOpen}
|
|
onClose={() => setUploadOpen(false)}
|
|
destination={singleDirectorySelected && selectedPaths[0] ? selectedPaths[0].slice(0, -1) : ''}
|
|
onComplete={reset}
|
|
/>
|
|
<Button
|
|
color='grey'
|
|
icon={isDirectoryIgnored ? 'eye' : 'eye slash'}
|
|
disabled={!singleDirectorySelected || wrolModeEnabled}
|
|
onClick={() => setIgnoreDirectoryOpen(true)}
|
|
>{label(isDirectoryIgnored ? 'Unignore' : 'Ignore')}</Button>
|
|
<IgnoreDirectoryModal
|
|
open={ignoreDirectoryOpen}
|
|
onClose={onIgnore}
|
|
onSubmit={onIgnore}
|
|
directory={selectedPaths.length === 1 ? selectedPaths[0] : null}
|
|
ignored={isDirectoryIgnored}
|
|
/>
|
|
<Button
|
|
color='violet'
|
|
icon={IconTags}
|
|
disabled={selectedPathsCount === 0}
|
|
onClick={() => setBulkTagOpen(true)}
|
|
>{label('Tag')}</Button>
|
|
<BulkTagModal
|
|
open={bulkTagOpen}
|
|
onClose={() => setBulkTagOpen(false)}
|
|
paths={selectedPaths}
|
|
onComplete={reset}
|
|
/>
|
|
<TaggedDeleteConfirmModal
|
|
open={taggedFileGroups !== null}
|
|
taggedFileGroups={taggedFileGroups}
|
|
onCancel={() => setTaggedFileGroups(null)}
|
|
onConfirm={onForceDelete}
|
|
/>
|
|
</div>;
|
|
|
|
const onCollapseAll = () => {
|
|
if (!openFolders) return;
|
|
// Deselect any paths that will no longer be visible (descendants of the open folders).
|
|
const newSelectedPaths = selectedPaths.filter(p =>
|
|
!openFolders.some(folder => p.startsWith(folder) && p !== folder));
|
|
if (newSelectedPaths.length !== selectedPaths.length) {
|
|
setSelectedPaths(newSelectedPaths);
|
|
}
|
|
setOpenFolders(null);
|
|
}
|
|
|
|
const onFolderClick = async (folder) => {
|
|
let newFolders;
|
|
if (openFolders.indexOf(folder) >= 0) {
|
|
// Remove the folder that was clicked on, as well as its sub-folders.
|
|
newFolders = openFolders.filter(i => !i.startsWith(folder));
|
|
// Unselect any children of the closed folder (they're no longer visible)
|
|
const newSelectedPaths = selectedPaths.filter(p => !p.startsWith(folder) || p === folder);
|
|
if (newSelectedPaths.length !== selectedPaths.length) {
|
|
setSelectedPaths(newSelectedPaths);
|
|
}
|
|
} else {
|
|
// Add the new folder to the opened folders.
|
|
newFolders = [...openFolders, folder];
|
|
// Mark folder as loading so skeleton rows show
|
|
setLoadingFolders(prev => new Set([...prev, folder]));
|
|
// If folder is selected, mark it for auto-selection of its children
|
|
if (selectedPaths.includes(folder)) {
|
|
pendingAutoSelectRef.current = folder;
|
|
}
|
|
}
|
|
console.debug(`newFolders=${newFolders}`);
|
|
setOpenFolders(newFolders);
|
|
}
|
|
|
|
const filteredFiles = React.useMemo(
|
|
() => browseFiles ? filterBrowseFiles(browseFiles, filterStr) : browseFiles,
|
|
[browseFiles, filterStr],
|
|
);
|
|
|
|
if (browseFiles === null) {
|
|
return <Placeholder lines={2}/>;
|
|
} else if (browseFiles === undefined) {
|
|
return <ErrorMessage>Could not fetch files</ErrorMessage>
|
|
}
|
|
|
|
return <DragSelectionProvider
|
|
selectedPaths={selectedPaths}
|
|
setSelectedPaths={setSelectedPaths}
|
|
browseFiles={filteredFiles}
|
|
openFolders={openFolders}
|
|
>
|
|
<div className='file-browser-filter-container'>
|
|
<ActionInput
|
|
leftSection={<Icon name='filter'/>}
|
|
placeholder='Filter files...'
|
|
value={filterStr}
|
|
onChange={(e) => setFilterStr(e.target.value)}
|
|
aria-label='Filter files'
|
|
className='file-browser-filter'
|
|
ref={filterInputRef}
|
|
action={<IconButton
|
|
icon='close'
|
|
type='button'
|
|
onClick={() => setFilterStr('')}
|
|
disabled={!filterStr}
|
|
className='search-clear'
|
|
label='Clear filter'
|
|
/>}
|
|
/>
|
|
<IconButton
|
|
icon={IconChevronsUp}
|
|
type='button'
|
|
onClick={onCollapseAll}
|
|
disabled={!openFolders || openFolders.length === 0}
|
|
label='Close all folders'
|
|
style={{marginLeft: '0.5em'}}
|
|
/>
|
|
</div>
|
|
<FileBrowserContent
|
|
browseFiles={filteredFiles}
|
|
headers={headers}
|
|
onFolderClick={onFolderClick}
|
|
setPreviewFile={setPreviewFile}
|
|
pending={pending}
|
|
loadingFolders={loadingFolders}
|
|
/>
|
|
{footer}
|
|
</DragSelectionProvider>
|
|
}
|
|
|
|
function FileBrowserContent({browseFiles, headers, onFolderClick, setPreviewFile, pending, loadingFolders}) {
|
|
const {isDragging} = useContext(DragSelectionContext);
|
|
const containerClassName = isDragging ? 'file-browser-dragging' : '';
|
|
|
|
return (
|
|
<div style={{marginBottom: '4em'}} className={containerClassName}>
|
|
<SortableTable
|
|
data={browseFiles}
|
|
tableHeaders={headers}
|
|
defaultSortColumn='path'
|
|
rowKey='path'
|
|
rowFunc={(i, sortData) => (
|
|
<Path
|
|
key={i['key']}
|
|
path={i}
|
|
onFolderClick={onFolderClick}
|
|
onFileClick={(file) => setPreviewFile(file)}
|
|
sortData={sortData}
|
|
disabled={pending}
|
|
loadingFolders={loadingFolders}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RenameModal({open, onClose, path, onSubmit, onPending}) {
|
|
const [parent, name] = splitPathParentAndName(path);
|
|
const [value, setValue] = React.useState(name);
|
|
const disabled = value === name;
|
|
|
|
const handleInputChange = (e) => {
|
|
setValue(e.target.value);
|
|
}
|
|
|
|
const handleSubmit = async (e) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
try {
|
|
console.log(`Renaming: ${path} to ${value}`);
|
|
onPending(true);
|
|
await renamePath(path, value);
|
|
} finally {
|
|
onClose();
|
|
onPending(false);
|
|
if (onSubmit) {
|
|
await onSubmit();
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
setValue(pathName(path));
|
|
}, [path]);
|
|
|
|
return <Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
>
|
|
<Modal.Header>Rename: {name}</Modal.Header>
|
|
<Modal.Content>
|
|
<form onSubmit={handleSubmit}>
|
|
<Header as='h4'>New Name</Header>
|
|
<pre>{parent ? parent + '/' : null}{value}</pre>
|
|
|
|
<TextInput
|
|
autoFocus
|
|
label='Name'
|
|
value={value}
|
|
onChange={handleInputChange}
|
|
/>
|
|
</form>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button
|
|
onClick={() => setValue(name)}
|
|
style={{marginRight: 'auto'}}
|
|
>
|
|
Reset
|
|
</Button>
|
|
<Button
|
|
color='violet'
|
|
disabled={disabled}
|
|
onClick={handleSubmit}
|
|
>
|
|
Rename
|
|
</Button>
|
|
</Modal.Actions>
|
|
</Modal>
|
|
}
|
|
|
|
export function MoveModal({open, paths, onClose, onSubmit}) {
|
|
const [destination, setDestination] = React.useState('');
|
|
const [pending, setPending] = React.useState(false);
|
|
|
|
const handleDirectory = (value) => {
|
|
setDestination(value);
|
|
}
|
|
|
|
const handleMove = async () => {
|
|
try {
|
|
setPending(true);
|
|
await movePaths(destination, paths);
|
|
onClose();
|
|
} catch (e) {
|
|
console.error(e);
|
|
setPending(false);
|
|
}
|
|
if (onSubmit) {
|
|
await onSubmit();
|
|
}
|
|
}
|
|
|
|
const fileCell = i => <Table.Cell>
|
|
<pre style={{marginTop: 0, marginBottom: 0}}>{i}</pre>
|
|
</Table.Cell>;
|
|
|
|
const destinationRows = paths.map(i => <Table.Row key={i}>
|
|
{fileCell(i)}
|
|
{destination ? fileCell(destination + '/' + pathName(i)) : fileCell(pathName(i))}
|
|
</Table.Row>);
|
|
|
|
return <Modal
|
|
onClose={onClose}
|
|
open={open}
|
|
>
|
|
<Modal.Header>Move Files/Directories</Modal.Header>
|
|
<Modal.Content>
|
|
<p>Search for a directory to move your files into:</p>
|
|
<DirectorySearch onSelect={handleDirectory} disabled={pending} autoFocus/>
|
|
|
|
<Table>
|
|
<Table.Header>
|
|
<Table.Row>
|
|
<Table.HeaderCell>Source</Table.HeaderCell>
|
|
<Table.HeaderCell>Destination</Table.HeaderCell>
|
|
</Table.Row>
|
|
</Table.Header>
|
|
<Table.Body>
|
|
{destinationRows}
|
|
</Table.Body>
|
|
</Table>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button
|
|
onClick={() => setDestination(null)}
|
|
style={{marginRight: 'auto'}}
|
|
disabled={pending}
|
|
>Reset</Button>
|
|
<Button
|
|
color='violet'
|
|
onClick={handleMove}
|
|
disabled={pending}
|
|
>Move</Button>
|
|
</Modal.Actions>
|
|
</Modal>
|
|
}
|
|
|
|
export function IgnoreDirectoryModal({open, onClose, onSubmit, directory, ignored}) {
|
|
const mediaDirectory = useMediaDirectory();
|
|
const [pending, setPending] = React.useState(false);
|
|
|
|
const handleIgnore = async () => {
|
|
try {
|
|
setPending(true);
|
|
if (ignored) {
|
|
await unignoreDirectory(`${mediaDirectory}/${directory}`);
|
|
} else {
|
|
await ignoreDirectory(`${mediaDirectory}/${directory}`);
|
|
}
|
|
if (onSubmit) {
|
|
onSubmit();
|
|
}
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
const header = ignored ? 'Remove Ignore Directory' : 'Ignore Directory';
|
|
const content = ignored ?
|
|
'Remove ignore of the following directory? You must refresh your files after this.'
|
|
: 'Ignore the following directory? The files within will not be indexed and will not show in search results.';
|
|
let submitButton = <Button role='primary'
|
|
disabled={pending}
|
|
onClick={handleIgnore}
|
|
>{ignored ? 'Un-ignore' : 'Ignore'}</Button>;
|
|
|
|
return <Modal open={open} onClose={onClose}>
|
|
<Modal.Header>{header}</Modal.Header>
|
|
<Modal.Content>
|
|
{content}
|
|
<pre>{mediaDirectory}/{directory}</pre>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button role='cancel' style={{marginRight: 'auto'}} onClick={onClose}>Close</Button>
|
|
{submitButton}
|
|
</Modal.Actions>
|
|
</Modal>
|
|
}
|
|
|
|
export function MakeDirectoryModal({open, onClose, parent, onSubmit}) {
|
|
const [value, setValue] = useState('');
|
|
const mediaDirectory = useMediaDirectory();
|
|
|
|
const handleInputChange = (e) => {
|
|
setValue(e.target.value);
|
|
}
|
|
|
|
const handleSubmit = async (e) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
try {
|
|
const path = parent ? `${parent}/${value}` : value;
|
|
await makeDirectory(path);
|
|
setValue('');
|
|
if (onSubmit) {
|
|
await onSubmit();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
return <Modal open={open} onClose={onClose}>
|
|
<Modal.Header>Make Directory</Modal.Header>
|
|
<Modal.Content>
|
|
<form onSubmit={handleSubmit}>
|
|
<pre>{mediaDirectory}/{parent}{value}</pre>
|
|
<TextInput
|
|
autoFocus
|
|
label='Name'
|
|
value={value}
|
|
onChange={handleInputChange}
|
|
/>
|
|
</form>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button color='violet' onClick={handleSubmit}>Create</Button>
|
|
</Modal.Actions>
|
|
</Modal>
|
|
}
|
|
|
|
export function FileBrowserUploadModal({open, onClose, destination, onComplete}) {
|
|
const mediaDirectory = useMediaDirectory();
|
|
|
|
const {
|
|
setFiles,
|
|
progresses,
|
|
setDestination,
|
|
doClear,
|
|
tagsSelector,
|
|
overwrite,
|
|
setOverwrite,
|
|
overallProgress,
|
|
inProgress,
|
|
} = useUploadFile();
|
|
|
|
const onDrop = React.useCallback(async (acceptedFiles) => {
|
|
if (acceptedFiles && acceptedFiles.length > 0) {
|
|
setFiles(acceptedFiles);
|
|
}
|
|
}, [setFiles]);
|
|
|
|
const {getRootProps, getInputProps} = useDropzone({onDrop, disabled: inProgress});
|
|
|
|
// Set destination when modal opens or destination changes
|
|
useEffect(() => {
|
|
if (open) {
|
|
setDestination(destination);
|
|
}
|
|
}, [open, destination, setDestination]);
|
|
|
|
// Clear upload state when destination is cleared
|
|
useEffect(() => {
|
|
if (!destination && open) {
|
|
doClear();
|
|
}
|
|
}, [destination, open, doClear]);
|
|
|
|
const handleClose = () => {
|
|
if (!inProgress) {
|
|
doClear();
|
|
onClose();
|
|
if (onComplete) {
|
|
onComplete();
|
|
}
|
|
}
|
|
};
|
|
|
|
let progressBars;
|
|
if (!_.isEmpty(progresses)) {
|
|
progressBars = Object.entries(progresses).map(([name, value]) => {
|
|
const {percent, status, type} = value;
|
|
let color = 'grey';
|
|
let statusString;
|
|
if (status === 'complete') {
|
|
color = 'green';
|
|
statusString = 'Complete:';
|
|
} else if (status === 'pending') {
|
|
statusString = 'Pending:';
|
|
} else if (status === 'failed') {
|
|
color = 'red';
|
|
statusString = 'Failed:';
|
|
} else if (status === 'conflicting') {
|
|
color = 'orange';
|
|
statusString = 'Already Exists:';
|
|
}
|
|
|
|
return <div key={name} style={{display: 'flex', alignItems: 'center', gap: '0.75em'}}>
|
|
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='large'/>
|
|
<div style={{flex: 1}}>
|
|
<Progress percent={percent} color={color} label={`${statusString} ${name}`}/>
|
|
</div>
|
|
</div>
|
|
})
|
|
}
|
|
|
|
const displayPath = destination ? `${mediaDirectory}/${destination}` : mediaDirectory;
|
|
|
|
return <Modal open={open} onClose={handleClose} size='large'>
|
|
<Modal.Header>Upload to: {displayPath}</Modal.Header>
|
|
<Modal.Content>
|
|
<form onSubmit={e => e.preventDefault()}>
|
|
{tagsSelector}
|
|
<div style={{marginTop: '1em'}}>
|
|
<Toggle
|
|
checked={overwrite}
|
|
label='Overwrite existing files'
|
|
onChange={() => setOverwrite(!overwrite)}
|
|
disabled={inProgress}
|
|
/>
|
|
</div>
|
|
</form>
|
|
|
|
<Panel style={{marginTop: '1em', padding: '1em'}}>
|
|
<div {...getRootProps()} style={{cursor: 'pointer', textAlign: 'center'}}>
|
|
<input {...getInputProps()}/>
|
|
<Header as='h4' icon='file text'>Click here, or drop files here to upload</Header>
|
|
</div>
|
|
</Panel>
|
|
|
|
{progresses && Object.keys(progresses).length > 1 &&
|
|
<div style={{marginTop: '1em'}}>
|
|
<Progress percent={overallProgress} color='blue' label='Overall Progress'/>
|
|
</div>}
|
|
|
|
<Stack gap={8} style={{marginTop: '1em'}}>
|
|
{progressBars}
|
|
</Stack>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button
|
|
role='cancel'
|
|
onClick={handleClose}
|
|
disabled={inProgress}
|
|
>
|
|
Close
|
|
</Button>
|
|
</Modal.Actions>
|
|
</Modal>
|
|
}
|