feature (batch): create selections of files/folder to batch delete and move actions

This commit is contained in:
=
2019-04-24 15:47:40 +10:00
parent 191ddbd11f
commit 65a61588a8
11 changed files with 210 additions and 74 deletions

View File

@ -56,7 +56,7 @@ export class BreadCrumb extends React.Component {
{
this.state.path.map((path, index) => {
return (
<Path key={"breadcrumb_"+index} path={path} isLast={this.state.path.length === index + 1} needSaving={this.props.needSaving} />
<Path key={"breadcrumb_"+index} currentSelection={this.props.currentSelection} path={path} isLast={this.state.path.length === index + 1} needSaving={this.props.needSaving} />
);
})
}

View File

@ -102,6 +102,18 @@ export const onDelete = function(path, type){
.catch((err) => notify.send(err, 'error'));
};
export const onMultiDelete = function(arrOfPath){
return Promise.all(arrOfPath.map((p) => Files.rm(p)))
.then(() => notify.send('All done!', 'success'))
.catch((err) => notify.send(err, 'error'));
}
export const onMultiRename = function(arrOfPath){
return Promise.all(arrOfPath.map((p) => Files.mv(p[0], p[1])))
.then(() => notify.send('All done!', 'success'))
.catch((err) => notify.send(err, 'error'));
}
/*
* The upload method has a few strategies:
* 1. user is coming from drag and drop + browser provides support to read entire folders

View File

@ -1,11 +1,12 @@
import React from 'react';
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend-filedrop';
import { SelectableGroup } from 'react-selectable';
import './filespage.scss';
import './error.scss';
import { Files } from '../model/';
import { sort, onCreate, onRename, onDelete, onUpload, onSearch, createLink } from './filespage.helper';
import { sort, onCreate, onRename, onMultiRename, onDelete, onMultiDelete, onUpload, onSearch, createLink } from './filespage.helper';
import { NgIf, Loader, EventReceiver, LoggedInOnly, ErrorPage } from '../components/';
import { notify, debounce, goToFiles, goToViewer, event, settings_get, settings_put } from '../helpers/';
import { BreadCrumb, FileSystem, FrequentlyAccess, Submenu } from './filespage/';
@ -33,13 +34,14 @@ export class FilesPage extends React.Component {
this.props.history.push(props.match.url + "/");
}
this.state = {
path: props.match.url.replace('/files', '').replace(/%23/g, "#") || '/',
sort: settings_get('filespage_sort') || 'type',
path: props.match.url.replace("/files", "").replace(/%23/g, "#") || "/",
sort: settings_get("filespage_sort") || "type",
sort_reverse: true,
show_hidden: settings_get('filespage_show_hidden') || CONFIG["display_hidden"],
view: settings_get('filespage_view') || 'grid',
show_hidden: settings_get("filespage_show_hidden") || CONFIG["display_hidden"],
view: settings_get("filespage_view") || "grid",
is_search: false,
files: [],
search_loading: false,
selected: [],
metadata: null,
frequents: null,
page_number: PAGE_NUMBER_INIT,
@ -51,31 +53,36 @@ export class FilesPage extends React.Component {
}
componentDidMount(){
this.onRefresh(this.state.path, 'directory');
this.onRefresh(this.state.path, "directory");
// subscriptions
this.props.subscribe('file.create', function(){
this.props.subscribe("file.create", function(){
return onCreate.apply(this, arguments).then(() => {
if(this.state.metadata && this.state.metadata.refresh_on_create === true){
this.onRefresh(this.state.path, 'directory')
this.onRefresh(this.state.path, "directory");
}
return Promise.resolve()
return Promise.resolve();
});
}.bind(this));
this.props.subscribe('file.upload', onUpload.bind(this));
this.props.subscribe('file.rename', onRename.bind(this));
this.props.subscribe('file.delete', onDelete.bind(this));
this.props.subscribe('file.refresh', this.onRefresh.bind(this));
window.addEventListener('keydown', this.toggleHiddenFilesVisibilityonCtrlK);
this.props.subscribe("file.upload", onUpload.bind(this));
this.props.subscribe("file.rename", onRename.bind(this));
this.props.subscribe("file.rename.multiple", onMultiRename.bind(this));
this.props.subscribe("file.delete", onDelete.bind(this));
this.props.subscribe("file.delete.multiple", onMultiDelete.bind(this));
this.props.subscribe("file.refresh", this.onRefresh.bind(this));
this.props.subscribe("file.select", this.toggleSelect.bind(this));
window.addEventListener("keydown", this.toggleHiddenFilesVisibilityonCtrlK);
}
componentWillUnmount() {
this.props.unsubscribe('file.upload');
this.props.unsubscribe('file.create');
this.props.unsubscribe('file.rename');
this.props.unsubscribe('file.delete');
this.props.unsubscribe('file.refresh');
window.removeEventListener('keydown', this.toggleHiddenFilesVisibilityonCtrlK);
this.props.unsubscribe("file.upload");
this.props.unsubscribe("file.create");
this.props.unsubscribe("file.rename");
this.props.unsubscribe("file.delete");
this.props.unsubscribe("file.delete.multiple");
this.props.unsubscribe("file.refresh");
this.props.unsubscribe("file.select");
window.removeEventListener("keydown", this.toggleHiddenFilesVisibilityonCtrlK);
this._cleanupListeners();
LAST_PAGE_PARAMS.path = this.state.path;
@ -100,11 +107,11 @@ export class FilesPage extends React.Component {
if(e.keyCode === 72 && e.ctrlKey === true){
e.preventDefault();
this.setState({show_hidden: !this.state.show_hidden}, () => {
settings_put('filespage_show_hidden', this.state.show_hidden);
settings_put("filespage_show_hidden", this.state.show_hidden);
if(!!this.state.show_hidden){
notify.send("Display hidden files", 'info');
notify.send("Display hidden files", "info");
}else{
notify.send("Hide hidden files", 'info');
notify.send("Hide hidden files", "info");
}
});
this.onRefresh();
@ -114,7 +121,7 @@ export class FilesPage extends React.Component {
onRefresh(path = this.state.path){
this._cleanupListeners();
const observer = Files.ls(path).subscribe((res) => {
if(res.status === 'ok'){
if(res.status === "ok"){
let files = new Array(res.results.length);
for(let i=0,l=res.results.length; i<l; i++){
let path = this.state.path+res.results[i].name;
@ -123,12 +130,14 @@ export class FilesPage extends React.Component {
continue;
}
files[i] = res.results[i];
files[i].link = createLink(res.results[i].type, res.results[i].path)
files[i].link = createLink(res.results[i].type, res.results[i].path);
}
this.setState({
metadata: res.metadata,
files: sort(files, this.state.sort),
selected: [],
loading: false,
is_search: false,
page_number: function(){
if(this.state.path === LAST_PAGE_PARAMS.path){
return LAST_PAGE_PARAMS.page_number;
@ -141,7 +150,7 @@ export class FilesPage extends React.Component {
}
});
}else{
notify.send(res, 'error');
notify.send(res, "error");
}
}, (error) => {
this.props.error(error);
@ -162,7 +171,7 @@ export class FilesPage extends React.Component {
}
onSort(_sort){
settings_put('filespage_sort', _sort);
settings_put("filespage_sort", _sort);
const same_sort = _sort === this.state.sort;
this.setState({
sort: _sort
@ -181,7 +190,7 @@ export class FilesPage extends React.Component {
onView(){
const _view = this.state.view === "list" ? "grid" : "list";
settings_put('filespage_view', _view);
settings_put("filespage_view", _view);
this.setState({
view: _view
}, () => {
@ -210,6 +219,7 @@ export class FilesPage extends React.Component {
f.link = createLink(f.type, f.path);
return f;
}) || [],
is_search: true,
metadata: {
can_rename: false,
can_delete: false,
@ -226,6 +236,19 @@ export class FilesPage extends React.Component {
});
}
handleMultiSelect(selectedKeys, e){
this.setState({selected: selectedKeys});
}
toggleSelect(path){
const idx = this.state.selected.indexOf(path);
if(idx == -1){
this.setState({ selected: this.state.selected.concat([path]) });
} else {
this.state.selected.splice(idx, 1);
this.setState({ selected: this.state.selected });
}
}
render() {
let $moreLoading = ( <div className="infinite_scroll_loading" key={-1}><Loader/></div> );
if(this.state.files.length <= this.state.page_number * LOAD_PER_SCROLL){
@ -233,19 +256,20 @@ export class FilesPage extends React.Component {
}
return (
<div className="component_page_filespage">
<BreadCrumb className="breadcrumb" path={this.state.path} />
<BreadCrumb className="breadcrumb" path={this.state.path} currentSelection={this.state.selected} />
<SelectableGroup onSelection={this.handleMultiSelect.bind(this)} tolerance={2} onNonItemClick={this.handleMultiSelect.bind(this, [])} preventDefault={true} enabled={this.state.is_search === false} className="selectablegroup">
<div className="page_container">
<div ref="$scroll" className="scroll-y">
<InfiniteScroll pageStart={0} loader={$moreLoading} hasMore={this.state.files.length > 70}
initialLoad={false} useWindow={false} loadMore={this.loadMore.bind(this)} threshold={100}>
<NgIf className="container" cond={!this.state.loading}>
<NgIf cond={this.state.path === '/'}>
<NgIf cond={this.state.path === "/"}>
<FrequentlyAccess files={this.state.frequents} />
</NgIf>
<Submenu path={this.state.path} sort={this.state.sort} view={this.state.view} onSearch={this.onSearch.bind(this)} onViewUpdate={(value) => this.onView(value)} onSortUpdate={(value) => {this.onSort(value);}} accessRight={this.state.metadata || {}}></Submenu>
<Submenu path={this.state.path} sort={this.state.sort} view={this.state.view} onSearch={this.onSearch.bind(this)} onViewUpdate={(value) => this.onView(value)} onSortUpdate={(value) => {this.onSort(value);}} accessRight={this.state.metadata || {}} selected={this.state.selected}></Submenu>
<NgIf cond={true}>
<FileSystem path={this.state.path} sort={this.state.sort} view={this.state.view}
files={this.state.files.slice(0, this.state.page_number * LOAD_PER_SCROLL)}
<FileSystem path={this.state.path} sort={this.state.sort} view={this.state.view} selected={this.state.selected}
files={this.state.files.slice(0, this.state.page_number * LOAD_PER_SCROLL)} isSearch={this.state.is_search}
metadata={this.state.metadata || {}} onSort={this.onSort.bind(this)} onView={this.onView.bind(this)} />
</NgIf>
</NgIf>
@ -259,6 +283,7 @@ export class FilesPage extends React.Component {
<div className="upload-footer">
<div className="bar"></div>
</div>
</SelectableGroup>
</div>
);
}

View File

@ -10,11 +10,17 @@
box-sizing: border-box;
}
.page_container{
.page_container, .selectablegroup{
flex: 1;
display: flex;
overflow: hidden;
}
.selectablegroup{
overflow: hidden!important;
> div[style] span {
border: 1px dashed var(--light)!important;
}
}
.infinite_scroll_loading{
text-align: center;

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { DropTarget } from 'react-dnd';
import { EventEmitter, BreadCrumb, PathElement } from '../../components/';
import { pathBuilder } from '../../helpers/';
import { pathBuilder, filetype, basename } from '../../helpers/';
export class BreadCrumbTargettable extends BreadCrumb {
constructor(props){
@ -21,9 +21,21 @@ const fileTarget = {
},
drop(props, monitor, component){
let src = monitor.getItem();
let from = pathBuilder(src.path, src.name, src.type);
let to = pathBuilder(props.path.full, src.name, src.type);
return {action: 'rename', args: [from, to, src.type], ctx: 'breadcrumb'}
if(props.currentSelection.length === 0){
const from = pathBuilder(src.path, src.name, src.type);
const to = pathBuilder(props.path.full, src.name, src.type);
return {action: 'rename', args: [from, to, src.type], ctx: 'breadcrumb'};
} else {
return {action: 'rename.multiple', args: props.currentSelection.map((selectionPath) => {
const from = selectionPath;
const to = pathBuilder(
props.path.full,
"./"+basename(selectionPath),
filetype(selectionPath)
);
return [from, to];
})};
}
}
}
const nativeFileTarget = {
@ -34,7 +46,7 @@ const nativeFileTarget = {
let files = monitor.getItem();
props.emit('file.upload', props.path.full, files);
}
}
};
@EventEmitter
@DropTarget('__NATIVE_FILE__', nativeFileTarget, (connect, monitor) => ({

View File

@ -29,7 +29,7 @@ export class FileSystem extends React.PureComponent {
{
this.props.files.map((file, index) => {
if(file.type === 'directory' || file.type === 'file' || file.type === 'link' || file.type === 'bucket'){
return ( <ExistingThing view={this.props.view} key={file.name+file.path+(file.icon || '')} file={file} path={this.props.path} metadata={this.props.metadata || {}} /> );
return ( <ExistingThing view={this.props.view} key={file.name+file.path+(file.icon || '')} file={file} path={this.props.path} metadata={this.props.metadata || {}} selectableKey={file.path} selected={this.props.selected.indexOf(file.path) !== -1} currentSelection={this.props.selected} /> );
}
})
}
@ -37,7 +37,7 @@ export class FileSystem extends React.PureComponent {
</NgIf>
<NgIf className="error" cond={this.props.files.length === 0}>
<p className="empty_image">
<Icon name="file"/>
<Icon name={this.props.isSearch ? "search" : "file"}/>
</p>
<p>There is nothing here</p>
</NgIf>

View File

@ -1,8 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import { Card, NgIf, Icon, EventEmitter, Dropdown, DropdownButton, DropdownList, DropdownItem, Container } from '../../components/';
import { pathBuilder, debounce } from '../../helpers/';
import { pathBuilder, debounce, prompt } from '../../helpers/';
import "./submenu.scss";
@EventEmitter
@ -49,6 +50,19 @@ export class Submenu extends React.Component {
onNew(type){
this.props.emit("new::"+type);
}
onDelete(arrayOfPaths){
prompt.now(
"Confirm by typing \"remove\"",
(answer) => {
if(answer !== "remove"){
return Promise.resolve();
}
this.props.emit("file.delete.multiple", arrayOfPaths);
return Promise.resolve();
},
() => { /* click on cancel */ }
);
}
onViewChange(){
requestAnimationFrame(() => this.props.onViewUpdate());
@ -104,12 +118,18 @@ export class Submenu extends React.Component {
<div className="component_submenu">
<Container>
<div className={"menubar no-select "+(this.state.search_input_visible ? "search_focus" : "")}>
<NgIf cond={this.props.accessRight.can_create_file !== false} onClick={this.onNew.bind(this, 'file')} type="inline">
<NgIf cond={this.props.accessRight.can_create_file !== false && this.props.selected.length === 0} onClick={this.onNew.bind(this, 'file')} type="inline">
New File
</NgIf>
<NgIf cond={this.props.accessRight.can_create_directory !== false} onClick={this.onNew.bind(this, 'directory')} type="inline">
<NgIf cond={this.props.accessRight.can_create_directory !== false && this.props.selected.length === 0} onClick={this.onNew.bind(this, 'directory')} type="inline">
New Directory
</NgIf>
<NgIf cond={this.props.selected.length > 0} type="inline" onMouseDown={this.onDelete.bind(this, this.props.selected)}>
<ReactCSSTransitionGroup transitionName="submenuwithSelection" transitionLeave={false} transitionEnter={false} transitionAppear={true} transitionAppearTimeout={10000}>
<span>Remove</span>
</ReactCSSTransitionGroup>
</NgIf>
<Dropdown className="view sort" onChange={this.onSortChange.bind(this)}>
<DropdownButton>
<Icon name="sort"/>

View File

@ -86,4 +86,15 @@
}
}
}
.submenuwithSelection-appear{
display: inline-block;
opacity: 0;
transform: translateY(3px);
transition: opacity 0.2s ease, transform 0.4s ease;
}
.submenuwithSelection-appear.submenuwithSelection-appear-active{
opacity: 1;
transform: translateY(0px);
}
}

View File

@ -3,10 +3,11 @@ import path from 'path';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { DragSource, DropTarget } from 'react-dnd';
import { createSelectable } from 'react-selectable';
import './thing.scss';
import { Card, NgIf, Icon, EventEmitter, Button } from '../../components/';
import { pathBuilder, prompt, alert, leftPad, getMimeType, debounce, memory } from '../../helpers/';
import { pathBuilder, basename, filetype, prompt, alert, leftPad, getMimeType, debounce, memory } from '../../helpers/';
import { Files } from '../../model/';
import { ShareComponent } from './share';
import img_placeholder from '../../assets/img/placeholder.png';
@ -30,6 +31,8 @@ const fileSource = {
let result = monitor.getDropResult();
if(result.action === 'rename'){
props.emit.apply(component, ['file.rename'].concat(result.args));
}else if(result.action === "rename.multiple"){
props.emit.call(component, "file.rename.multiple", result.args);
}else{
throw 'unknown action';
}
@ -40,19 +43,31 @@ const fileSource = {
const fileTarget = {
canDrop(props, monitor){
let file = monitor.getItem();
if(props.file.type === 'directory' && file.name !== props.file.name && props.file.icon !== 'loading'){
if(props.file.type !== "directory") return false;
else if(file.name === props.file.name) return false;
else if(props.file.icon === "loading") return false;
else if(props.selected === true) return false;
return true;
}else{
return false;
}
},
drop(props, monitor, component){
let src = monitor.getItem();
let dest = props.file;
let from = pathBuilder(props.path, src.name, src.type);
let to = pathBuilder(props.path, './'+dest.name+'/'+src.name, src.type);
if(props.currentSelection.length === 0){
const from = pathBuilder(props.path, src.name, src.type);
const to = pathBuilder(props.path, './'+dest.name+'/'+src.name, src.type);
return {action: 'rename', args: [from, to, src.type], ctx: 'existingfile'};
} else {
return {action: 'rename.multiple', args: props.currentSelection.map((selectionPath) => {
const from = selectionPath;
const to = pathBuilder(
props.path,
"./"+dest.name+"/"+basename(selectionPath),
filetype(selectionPath)
);
return [ from, to ];
})};
}
}
};
@ -62,9 +77,10 @@ const nativeFileTarget = {
let path = pathBuilder(props.path, props.file.name, 'directory');
props.emit('file.upload', path, monitor.getItem());
}
}
};
@createSelectable
@EventEmitter
@DropTarget('__NATIVE_FILE__', nativeFileTarget, (connect, monitor) => ({
connectDropNativeFile: connect.dropTarget(),
@ -99,7 +115,8 @@ export class ExistingThing extends React.Component {
this.props.fileIsOver !== nextProps.fileIsOver ||
this.props.canDropFile !== nextProps.canDropFile ||
this.props.nativeFileIsOver !== nextProps.nativeFileIsOver ||
this.props.canDropNativeFile !== nextProps.canDropNativeFile
this.props.canDropNativeFile !== nextProps.canDropNativeFile ||
this.props.selected !== nextProps.selected
){
return true;
}
@ -188,6 +205,16 @@ export class ExistingThing extends React.Component {
);
}
onThingClick(e){
if(e.ctrlKey === true){
e.preventDefault();
this.props.emit(
"file.select",
pathBuilder(this.props.path, this.props.file.name, this.props.file.type)
);
}
}
_confirm_delete_text(){
return this.props.file.name.length > 16? this.props.file.name.substring(0, 10).toLowerCase() : this.props.file.name;
}
@ -213,8 +240,8 @@ export class ExistingThing extends React.Component {
className = className.trim();
return connectDragSource(connectDropNativeFile(connectDropFile(
<div className={"component_thing view-"+this.props.view}>
<ToggleableLink to={this.props.file.link + window.location.search} disabled={this.props.file.icon === "loading"}>
<div className={"component_thing view-"+this.props.view+(this.props.selected === true ? " selected" : " not-selected")}>
<ToggleableLink onClick={this.onThingClick.bind(this)} to={this.props.file.link + window.location.search} disabled={this.props.file.icon === "loading"}>
<Card ref="$card"className={this.state.hover} className={className}>
<Image preview={this.state.preview}
icon={this.props.file.icon || this.props.file.type}
@ -231,6 +258,7 @@ export class ExistingThing extends React.Component {
<DateTime show={this.state.icon !== 'loading'} timestamp={this.props.file.time} />
<ActionButton onClickRename={this.onRenameRequest.bind(this)} onClickDelete={this.onDeleteRequest.bind(this)} onClickShare={this.onShareRequest.bind(this)} is_renaming={this.state.is_renaming}
can_rename={this.props.metadata.can_rename !== false} can_delete={this.props.metadata.can_delete !== false} can_share={this.props.metadata.can_share !== false && window.CONFIG.enable_share === true} />
<div className="selectionOverlay"></div>
</Card>
</ToggleableLink>
</div>

View File

@ -82,6 +82,19 @@
}
}
.selectionOverlay{ display: none; }
&.selected .selectionOverlay{
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: var(--primary);
z-index: 2;
opacity: 0.3;
}
}
@ -104,6 +117,7 @@
@media (max-width: 400px){.box{height: 140px; .info_extension{font-size: 0.8em!important; padding: 3px 10px;}}}
@media (max-width: 340px){.box{height: 130px}}
text-align: center;
.box{
margin: 2px;
padding: 0;
@ -190,6 +204,23 @@
background: rgba(0,0,0,0);
transition: 0.2s ease-out background;
}
.component_filesize, .component_datetime{ display: none; }
.component_action{
opacity: 0;
transform: translateX(5px);
transition: 0.15s ease-out all;
z-index: 2;
display: block;
position: absolute;
top: 5px;
right: 5px;
border-radius: 5px;
margin-right: 0px;
padding: 0px;
}
img.thumbnail{transition: 0.2s ease-out transform;}
}
&.not-selected .box{
&:hover{
.component_action{
transition-delay: 0.1s;
@ -214,21 +245,11 @@
opacity: 0;
}
}
.component_filesize, .component_datetime{ display: none; }
.component_action{
opacity: 0;
transform: translateX(5px);
transition: 0.15s ease-out all;
z-index: 2;
display: block;
position: absolute;
top: 5px;
right: 5px;
border-radius: 5px;
margin-right: 0px;
padding: 0px;
}
img.thumbnail{transition: 0.2s ease-out transform;}
&.selected .box{
img.thumbnail{
transform: scale(0.6);
}
}
}
}

View File

@ -12,7 +12,8 @@
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^2.4.3"
"bcryptjs": "^2.4.3",
"react-selectable": "^2.0.1"
},
"devDependencies": {
"babel-core": "^6.13.2",