feat ContextMenu: Rename and Delete

This commit is contained in:
Mahdi Dibaiee
2015-09-03 15:02:46 +04:30
parent ee6f5d6ffb
commit 79ae4c1a47
94 changed files with 4211 additions and 2216 deletions

View File

@ -1,6 +1,7 @@
import { CHANGE_DIRECTORY } from 'actions/types';
export default function changedir(dir) {
if (dir === 'sdcard') dir = '';
return {
type: CHANGE_DIRECTORY,
dir

32
src/js/actions/dialog.js Normal file
View File

@ -0,0 +1,32 @@
import { DIALOG } from 'actions/types';
export function show(id) {
return {
type: DIALOG,
active: true,
id
}
}
export function hide(id) {
return {
type: DIALOG,
active: false,
id
}
}
export function toggle(id) {
return {
type: DIALOG,
active: 'toggle',
id
}
}
export function hideAll() {
return {
type: DIALOG,
active: false
}
}

35
src/js/actions/file.js Normal file
View File

@ -0,0 +1,35 @@
import { CREATE_FILE, SHARE_FILE, RENAME_FILE, ACTIVE_FILE, DELETE_FILE } from 'actions/types';
export function create(path, name) {
return {
type: CREATE_FILE,
path, name
}
}
export function share() {
return {
type: SHARE_FILE
}
}
export function rename(file, name) {
return {
type: RENAME_FILE,
file, name
}
}
export function active(file) {
return {
type: ACTIVE_FILE,
file
}
}
export function deleteFile(file) {
return {
type: DELETE_FILE,
file
}
}

View File

@ -0,0 +1,29 @@
import { LIST_FILES, FILES_VIEW, REFRESH } from 'actions/types';
import store from 'store';
export function refresh() {
return {
type: REFRESH
}
}
export function toggle(state) {
return {
type: FILES_VIEW,
view: 'toggle'
}
}
export function details(state) {
return {
type: FILES_VIEW,
view: 'details'
}
}
export function list(state) {
return {
type: FILES_VIEW,
view: 'list'
}
}

32
src/js/actions/menu.js Normal file
View File

@ -0,0 +1,32 @@
import { MENU } from 'actions/types';
export function show(id, x, y) {
return {
type: MENU,
active: true,
id, x, y
}
}
export function hide(id) {
return {
type: MENU,
active: false,
id
}
}
export function toggle(id, x, y) {
return {
type: MENU,
active: 'toggle',
id, x, y
}
}
export function hideAll() {
return {
type: MENU,
active: false
}
}

View File

@ -0,0 +1,22 @@
import { NAVIGATION, TOGGLE } from 'actions/types';
export function show() {
return {
type: NAVIGATION,
active: true
}
}
export function hide() {
return {
type: NAVIGATION,
active: false
}
}
export function toggle() {
return {
type: NAVIGATION,
active: TOGGLE
}
}

View File

@ -1,9 +1,26 @@
const TYPES = {
CHANGE_DIRECTORY: Symbol(),
LIST_FILES: Symbol(),
SORT: Symbol(),
SEARCH: Symbol(),
REFRESH: Symbol()
CHANGE_DIRECTORY: Symbol('CHANGE_DIRECTORY'),
LIST_FILES: Symbol('LIST_FILES'),
FILES_VIEW: Symbol('FILES_VIEW'),
NAVIGATION: Symbol('NAVIGATION'),
TOGGLE: Symbol('TOGGLE'),
REFRESH: Symbol('REFRESH'),
SORT: Symbol('SORT'),
NEW_FILE: Symbol('NEW_FILE'),
CREATE_FILE: Symbol('CREATE_FILE'),
SHARE_FILE: Symbol('SHARE_FILE'),
RENAME_FILE: Symbol('RENAME_FILE'),
ACTIVE_FILE: Symbol('ACTIVE_FILE'),
DELETE_FILE: Symbol('DELETE_FILE'),
MENU: Symbol('MENU'),
DIALOG: Symbol('DEBUG'),
SEARCH: Symbol('SEARCH')
};
export default TYPES;

View File

@ -1,13 +1,86 @@
export async function directory(dir = '/') {
let storage = navigator.getDeviceStorage('sdcard');
let root = await storage.getRoot();
import { type } from 'utils';
if (dir === '/' || !dir) return root;
let SD_CACHE;
export function sdcard() {
if (SD_CACHE) return SD_CACHE;
return await root.get(dir);
SD_CACHE = navigator.getDeviceStorage('sdcard');
return SD_CACHE;
}
let ROOT_CACHE;
export async function root() {
if (ROOT_CACHE) return ROOT_CACHE;
ROOT_CACHE = await sdcard().getRoot();
return ROOT_CACHE;
}
export async function getFile(dir = '/') {
let parent = await root();
if (dir === '/' || !dir) return root();
return await parent.get(dir);
}
export async function children(dir) {
let parent = await directory(dir);
let parent = await getFile(dir);
return await parent.getFilesAndDirectories();
}
export async function readFile(path) {
let file = await getFile(path);
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.onabort = reject;
reader.readAsArrayBuffer(file);
});
}
export async function createFile(...args) {
let parent = await root();
return await parent.createFile(...args);
}
export async function createDirectory(...args) {
let parent = await root();
return await parent.createDirectory(...args);
}
export async function rename(file, newName) {
console.log(file);
let path = (file.path || '').slice(1); // remove starting slash
let oldPath = (path + file.name);
let newPath = path + newName;
let target = await getFile(oldPath);
if (type(target) === 'Directory') {
await createDirectory(newPath);
let childs = await target.getFilesAndDirectories();
for (let child of childs) {
await rename(child, newPath + '/' + child.name);
}
target.delete();
return;
} else {
let content = await readFile(fullpath);
let blob = new Blob([content], {type: target.type});
sdcard().delete(fullpath);
sdcard().addNamed(blob, path + newName);
}
}

View File

@ -0,0 +1,53 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import changedir from 'actions/changedir';
import { bind } from 'store';
@connect(props)
export default class Breadcrumb extends Component {
render() {
let directories = this.props.cwd.split('/');
directories.unshift('sdcard');
let els = directories.map((dir, index, arr) => {
let path = arr.slice(1, index + 1).join('/');
let slash = index > 0 ? '/' : '';
return (
<span key={index} onClick={bind(changedir(path))}>
<i>{slash}</i>{dir}
</span>
);
});
let lastDirectories = this.props.lwd.split('/');
if (lastDirectories.length > directories.length - 1) {
lastDirectories.splice(0, directories.length - 1);
let history = lastDirectories.map((dir, index, arr) => {
let current = directories.slice(1).concat(arr.slice(0, index + 1));
let path = current.join('/');
return (
<span key={directories.length + index} className='history' onClick={bind(changedir(path))}>
<i>/</i>{dir}
</span>
)
});
els = els.concat(history);
}
return (
<div className='breadcrumb'>
{els}
</div>
);
}
}
function props(state) {
return {
lwd: state.get('lwd'), // last working directory
cwd: state.get('cwd')
}
}

View File

@ -0,0 +1,28 @@
import React, { Component } from 'react';
export default class Dialog extends Component {
render() {
let conditionalInput = this.props.input ? <input ref='input' /> : '';
let buttons = this.props.buttons.map((button, i) => {
return <button className={button.className + ' btn'} key={i}
onClick={button.action.bind(this)}>
{button.text}
</button>;
});
let className = this.props.active ? 'dialog active' : 'dialog';
return (
<div className={className}>
<p className='regular-medium'>{this.props.title}</p>
<p className='light-medium'>{this.props.description}</p>
{conditionalInput}
<div className='foot'>
{buttons}
</div>
</div>
)
}
}

View File

@ -0,0 +1,39 @@
import React, { Component } from 'react';
import changedir from 'actions/changedir';
import { show } from 'actions/menu';
import { active } from 'actions/file';
import { MENU_WIDTH } from './menu';
import store from 'store';
const MENU_TOP_SPACE = 20;
export default class Directory extends Component {
render() {
return (
<div className='directory' ref='container'
onClick={this.peek.bind(this)}
onContextMenu={this.contextMenu.bind(this)}>
<i></i>
<p>{this.props.name}</p>
</div>
);
}
peek() {
let file = store.getState().get('files')[this.props.index];
store.dispatch(changedir(file.path.slice(1) + file.name));
}
contextMenu(e) {
e.preventDefault();
let rect = React.findDOMNode(this.refs.container).getBoundingClientRect();
let {x, y, width, height} = rect;
let left = x + width / 2 - MENU_WIDTH / 2,
top = y + height / 2 + MENU_TOP_SPACE;
store.dispatch(show('directoryMenu', left, top));
store.dispatch(active(this.props.index));
}
}

View File

@ -1,6 +1,7 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import File from './file';
import Directory from './directory';
@connect(props)
export default class FileList extends Component {
@ -9,14 +10,18 @@ export default class FileList extends Component {
}
render() {
let { cwd, files } = this.props;
let { files } = this.props;
let els = files.map((file, index) => {
return <File key={index} index={index} name={file.name} />;
if (fileType(file) === 'File') {
return <File key={index} index={index} name={file.name} />;
} else {
return <Directory key={index} index={index} name={file.name} />
}
});
return (
<div><strong>cwd: {cwd}</strong>
<div className='file-list'>
{els}
</div>
);
@ -25,7 +30,6 @@ export default class FileList extends Component {
function props(state) {
return {
cwd: state.get('cwd'),
files: state.get('files')
}
}
@ -36,3 +40,7 @@ async function getFiles(dir) {
return await root.getFilesAndDirectories();
}
function fileType(file) {
return Object.prototype.toString.call(file).slice(8, -1);
}

View File

@ -1,20 +1,35 @@
import React, { Component } from 'react';
import { show } from 'actions/menu';
import { active } from 'actions/file';
import { MENU_WIDTH } from './menu';
import store from 'store';
import changedir from 'actions/changedir';
const MENU_TOP_SPACE = 20;
export default class File extends Component {
constructor() {
super();
}
render() {
return (
<div onClick={this.peekInside.bind(this)}>
<p>{this.props.index}. {this.props.name}</p>
<div className='file' ref='container'
onContextMenu={this.contextMenu.bind(this)}>
<i></i>
<p>{this.props.name}</p>
</div>
);
}
peekInside() {
let file = store.getState().get('files')[this.props.index];
contextMenu(e) {
e.preventDefault();
console.log(file);
store.dispatch(changedir(file.path.slice(1) + file.name));
let rect = React.findDOMNode(this.refs.container).getBoundingClientRect();
let {x, y, width, height} = rect;
let left = x + width / 2 - MENU_WIDTH / 2,
top = y + height / 2 + MENU_TOP_SPACE;
store.dispatch(show('fileMenu', left, top));
store.dispatch(active(this.props.index));
}
}

View File

@ -0,0 +1,18 @@
import React, { Component } from 'react';
import { toggle } from 'actions/navigation';
import store from 'store';
export default class Header extends Component {
render() {
return (
<header>
<button className='drawer' onClick={this.toggleNavigation.bind(this)}></button>
<h1 className='regular-medium'>Hawk</h1>
</header>
);
}
toggleNavigation() {
store.dispatch(toggle());
}
}

21
src/js/components/menu.js Normal file
View File

@ -0,0 +1,21 @@
import React, { Component } from 'react';
export const MENU_WIDTH = 245;
export default class Menu extends Component {
render() {
let { items, active, style } = this.props;
items = items || [];
let els = items.map((item, index) => {
return <li key={index} onClick={item.action.bind(this)}>{item.name}</li>
});
let className = 'menu ' + (active ? 'active' : '');
return (
<div className={className} style={style}>
<ul>{els}</ul>
</div>
);
}
}

View File

@ -0,0 +1,43 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { hide } from 'actions/navigation';
@connect(props)
export default class Navigation extends Component {
render() {
return (
<nav className={this.props.active ? 'active' : ''}>
<i onClick={this.hide.bind(this)} />
<p>Filter</p>
<ul>
<li>Picture</li>
<li>Video</li>
<li>Audio</li>
</ul>
<p>Tools</p>
<ul>
<li>FTP Browser</li>
</ul>
<p>Preferences</p>
<ul>
<li>Show Hidden Files <input type='checkbox' /></li>
<li>Show Directories First <input type='checkbox' /></li>
<li>Advanced Preferences</li>
</ul>
</nav>
);
}
hide() {
this.props.dispatch(hide());
}
}
function props(store) {
return {
active: store.get('navigation')
}
}

View File

@ -1,18 +1,46 @@
import React, { Component } from 'react'
import FileList from 'components/file-list';
import Navigation from 'components/navigation';
import Header from 'components/header';
import Breadcrumb from 'components/breadcrumb';
import Toolbar from 'components/toolbar';
import Menu from 'components/menu';
import Dialog from 'components/dialog';
import { connect } from 'react-redux';
import { hideAll } from 'actions/menu';
import changedir from 'actions/changedir';
import store from 'store';
window.store = store;
window.changedir = changedir;
let FileMenu = connect(state => state.get('fileMenu'))(Menu);
let DirectoryMenu = connect(state => state.get('directoryMenu'))(Menu);
let RenameDialog = connect(state => state.get('renameDialog'))(Dialog);
export default class Root extends Component {
render() {
return (
<div>
Hawk!
<div onTouchStart={this.touchStart.bind(this)}>
<Header />
<Breadcrumb />
<Navigation />
<FileList />
<Toolbar />
<FileMenu />
<DirectoryMenu />
<RenameDialog />
</div>
);
}
touchStart(e) {
if (!e.target.closest('.menu')) {
store.dispatch(hideAll());
}
}
}

View File

@ -0,0 +1,26 @@
import React, { Component } from 'react';
import { create, share } from 'actions/file';
import { toggle as toggleView, refresh } from 'actions/files-view';
import { bind } from 'store';
export default class Toolbar extends Component {
render() {
return (
<div className='toolbar'>
<button className='icon-plus' onClick={this.newFile} />
<button className='icon-view' onClick={bind(toggleView())} />
<button className='icon-refresh' onClick={bind(refresh())} />
<button className='icon-share' onClick={bind(share())} />
<button className='icon-more' onClick={this.showMore} />
</div>
);
}
showMore() {
}
newFile() {
}
}

48
src/js/dialogs.js Normal file
View File

@ -0,0 +1,48 @@
import React from 'react';
import { hide, hideAll } from 'actions/dialog';
import { rename, deleteFile } from 'actions/file';
import store, { bind } from 'store';
export default {
renameDialog: {
title: 'Rename',
description: 'Enter your desired new name',
input: true,
buttons: [
{
text: 'Cancel',
action: bind(hideAll())
},
{
text: 'Rename',
action() {
let input = React.findDOMNode(this.refs.input);
let activeFile = store.getState().get('activeFile');
this.props.dispatch(rename(activeFile, input.value))
this.props.dispatch(hideAll());
},
className: 'success'
}
]
},
deleteDialog: {
title: 'Delete',
description: 'Are you sure you want to remove @activeFile.name?',
buttons: [
{
text: 'No',
action: bind(hideAll())
},
{
text: 'Yes',
action() {
let activeFile = store.getState().get('activeFile');
this.props.dispatch(deleteFile(activeFile));
this.props.dispatch(hideAll());
},
className: 'success'
}
]
}
}

27
src/js/menus.js Normal file
View File

@ -0,0 +1,27 @@
import { hideAll } from 'actions/menu';
import { show } from 'actions/dialog';
import store from 'store';
const entryMenu = {
items: [
{
name: 'Rename',
action() {
store.dispatch(hideAll());
store.dispatch(show('renameDialog'));
}
},
{
name: 'Delete',
action() {
store.dispatch(hideAll());
store.dispatch(show('deleteDialog'))
}
}
]
};
export default {
fileMenu: Object.assign({}, entryMenu),
directoryMenu: Object.assign({}, entryMenu)
}

View File

@ -0,0 +1,9 @@
import { ACTIVE_FILE } from 'actions/types';
export default function(state = -1, action) {
if (action.type === ACTIVE_FILE) {
return action.file;
}
return state;
}

View File

@ -1,10 +1,23 @@
import Immutable from 'immutable';
import cwd from './cwd';
import lwd from './lwd';
import files from './files';
import navigation from './navigation';
import activeFile from './active-file';
import menu from './menu';
import dialog from './dialog';
export default function(state = new Immutable.Map(), action) {
console.log('action', action);
return new Immutable.Map({
lwd: lwd(state, action), // last working directory
cwd: cwd(state.get('cwd'), action),
files: files(state.get('files'), action)
files: files(state.get('files'), action),
activeFile: activeFile(state.get('activeFile'), action),
navigation: navigation(state.get('navigation'), action),
fileMenu: menu(state, action, 'fileMenu'),
directoryMenu: menu(state, action, 'directoryMenu'),
renameDialog: dialog(state, action, 'renameDialog'),
deleteDialog: dialog(state, action, 'deleteDialog')
});
}

View File

@ -1,16 +1,22 @@
import { CHANGE_DIRECTORY } from 'actions/types';
import { CHANGE_DIRECTORY, REFRESH } from 'actions/types';
import listFiles from 'actions/list-files';
import { children } from 'api/files';
import store from 'store';
export default function(state = '/', action) {
switch (action.type) {
case CHANGE_DIRECTORY:
children(action.dir).then(files => {
store.dispatch(listFiles(files));
});
return action.dir;
default:
return state;
export default function(state = '', action) {
if (action.type === CHANGE_DIRECTORY) {
children(action.dir).then(files => {
store.dispatch(listFiles(files));
});
return action.dir;
}
if (action.type === REFRESH) {
children(state).then(files => {
store.dispatch(listFiles(files));
});
return state;
}
return state;
}

22
src/js/reducers/dialog.js Normal file
View File

@ -0,0 +1,22 @@
import { DIALOG } from 'actions/types';
import Immutable from 'immutable';
export default function(state = new Immutable.Map({}), action, id) {
if (action.type === DIALOG) {
// action applied to all dialogs
if (!action.id) {
return Object.assign({}, state.get(id), {active: action.active});
}
if (action.id !== id) return state.get(id);
let target = state.get(action.id);
let active = action.active === 'toggle' ? !target.get('active') : action.active;
let style = Object.assign({}, state.style, {left: action.x, top: action.y});
return Object.assign({}, target, { style, active });
} else {
return state.get(id);
}
}

View File

@ -1,10 +1,29 @@
import { LIST_FILES } from 'actions/types';
import { LIST_FILES, RENAME_FILE, DELETE_FILE } from 'actions/types';
import { refresh } from 'actions/files-view';
import { rename, sdcard } from 'api/files';
export default function(state = [], action) {
switch (action.type) {
case LIST_FILES:
return action.files;
default:
return state;
if (action.type === LIST_FILES) {
return action.files;
}
if (action.type === RENAME_FILE) {
let file = state[action.file];
rename(file, action.name).then(refresh);
return state;
}
if (action.type === DELETE_FILE) {
let file = state[action.file];
sdcard().delete((file.path || '') + '/' + file.name);
let copy = state.slice(0);
copy.splice(action.file, 1);
return copy;
}
return state;
}

8
src/js/reducers/lwd.js Normal file
View File

@ -0,0 +1,8 @@
import { CHANGE_DIRECTORY } from 'actions/types';
export default function(state = '', action) {
if (action.type === CHANGE_DIRECTORY) {
return state.get('cwd');
}
return state.get('lwd');
}

22
src/js/reducers/menu.js Normal file
View File

@ -0,0 +1,22 @@
import { MENU } from 'actions/types';
import Immutable from 'immutable';
export default function(state = new Immutable.Map({}), action, id) {
if (action.type === MENU) {
// action applied to all menus
if (!action.id) {
return Object.assign({}, state.get(id), {active: action.active});
}
if (action.id !== id) return state.get(id);
let target = state.get(action.id);
let active = action.active === 'toggle' ? !target.get('active') : action.active;
let style = Object.assign({}, state.style, {left: action.x, top: action.y});
return Object.assign({}, target, { style, active });
} else {
return state.get(id);
}
}

View File

@ -0,0 +1,9 @@
import { NAVIGATION, TOGGLE } from 'actions/types';
export default function(state = false, action) {
if (action.type === NAVIGATION) {
return action.active === TOGGLE ? !state : action.active;
}
return state;
}

View File

@ -2,13 +2,19 @@ import { createStore } from 'redux';
import reducers from 'reducers/all';
import changedir from 'actions/changedir';
import Immutable from 'immutable';
import menus from './menus';
import dialogs from './dialogs';
const DEFAULT = new Immutable.Map({
dir: '/',
const DEFAULT = new Immutable.Map(Object.assign({
dir: '',
files: []
});
}, dialogs, menus));
let store = createStore(reducers, DEFAULT);
store.dispatch(changedir(DEFAULT.dir));
store.dispatch(changedir(DEFAULT.get('dir')));
export function bind(action) {
return () => store.dispatch(action);
}
export default store;

3
src/js/utils.js Normal file
View File

@ -0,0 +1,3 @@
export function type(obj) {
return Object.prototype.toString.call(obj).slice(8, -1);
}