diff --git a/client/assets/css/reset.scss b/client/assets/css/reset.scss index 88de1147..3bfc7cdc 100644 --- a/client/assets/css/reset.scss +++ b/client/assets/css/reset.scss @@ -35,19 +35,17 @@ a{color: inherit; text-decoration: none;} select{-moz-appearance: none;} select:-moz-focusring { - color: inherit; + color: transparent; outline: none; border: none; } -select::-ms-expand { - display: none; -} +select::-ms-expand { display: none; } + button::-moz-focus-inner { border: 0; } - input, textarea{ transition: border 0.2s; outline: none; diff --git a/client/assets/img/check.svg b/client/assets/img/check.svg new file mode 100644 index 00000000..43bbf0e2 --- /dev/null +++ b/client/assets/img/check.svg @@ -0,0 +1,52 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/client/assets/img/file.svg b/client/assets/img/file.svg index 7842cf00..38d84d87 100644 --- a/client/assets/img/file.svg +++ b/client/assets/img/file.svg @@ -40,12 +40,20 @@ id="namedview4654" showgrid="false" inkscape:zoom="49.75" - inkscape:cx="8.4924623" + inkscape:cx="7.4070352" inkscape:cy="8" inkscape:window-x="10" inkscape:window-y="37" inkscape:window-maximized="0" - inkscape:current-layer="svg4652" /> + inkscape:current-layer="svg4652" + showguides="false" + inkscape:guide-bbox="true"> + + + sodipodi:nodetypes="ccc" /> diff --git a/client/assets/img/grid.svg b/client/assets/img/grid.svg new file mode 100644 index 00000000..01de071a --- /dev/null +++ b/client/assets/img/grid.svg @@ -0,0 +1,55 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/client/assets/img/list.svg b/client/assets/img/list.svg new file mode 100644 index 00000000..46e90c38 --- /dev/null +++ b/client/assets/img/list.svg @@ -0,0 +1,55 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/client/assets/img/placeholder.png b/client/assets/img/placeholder.png new file mode 100644 index 00000000..2ae5caba Binary files /dev/null and b/client/assets/img/placeholder.png differ diff --git a/client/assets/img/sort.svg b/client/assets/img/sort.svg new file mode 100644 index 00000000..b83c1031 --- /dev/null +++ b/client/assets/img/sort.svg @@ -0,0 +1,52 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/client/components/breadcrumb.scss b/client/components/breadcrumb.scss index ba868662..0b0a2eb9 100644 --- a/client/components/breadcrumb.scss +++ b/client/components/breadcrumb.scss @@ -62,7 +62,7 @@ font-size: 18px; display: inline-block; - a{border-radius: 2px;} + a{border-radius: 2px; letter-spacing: -0.5px;} &.highlight a{ background: var(--emphasis-primary); border: 2px solid var(--primary); diff --git a/client/components/dropdown.js b/client/components/dropdown.js index 4e5d9c33..a501b6c4 100644 --- a/client/components/dropdown.js +++ b/client/components/dropdown.js @@ -59,9 +59,11 @@ export class Dropdown extends React.Component { const button = this.props.children[0]; const dropdown = React.cloneElement(this.props.children[1], {onSelect: this.onSelect.bind(this)}); - + let className = "component_dropdown "; + className += this.props.className ? this.props.className+" " : ""; + className += this.state.button ? " active" : ""; return ( -
+
{ button } { dropdown }
diff --git a/client/components/icon.js b/client/components/icon.js index 38e16e07..ee8da3f7 100644 --- a/client/components/icon.js +++ b/client/components/icon.js @@ -29,8 +29,13 @@ import img_arrow_down from '../assets/img/arrow-down.svg'; import img_arrow_up_double from '../assets/img/arrow-up-double.svg'; import img_arrow_down_double from '../assets/img/arrow-down-double.svg'; import img_search from '../assets/img/search.svg'; +import img_grid from '../assets/img/grid.svg'; +import img_list from '../assets/img/list.svg'; +import img_sort from '../assets/img/sort.svg'; +import img_check from '../assets/img/check.svg'; export const Icon = (props) => { + if(props.name === null) return null; let img; if(props.name === 'directory'){ img = img_folder; @@ -84,6 +89,14 @@ export const Icon = (props) => { img = img_arrow_down; }else if(props.name === 'search'){ img = img_search; + }else if(props.name === 'grid'){ + img = img_grid; + }else if(props.name === 'list'){ + img = img_list; + }else if(props.name === 'sort'){ + img = img_sort; + }else if(props.name === 'check'){ + img = img_check; }else{ throw('unknown icon'); } diff --git a/client/helpers/index.js b/client/helpers/index.js index bc70d0bd..6d189ca2 100644 --- a/client/helpers/index.js +++ b/client/helpers/index.js @@ -12,3 +12,5 @@ export { prompt } from './prompt'; export { notify } from './notify'; export { gid } from './random'; export { leftPad } from './common'; +export { getMimeType } from './mimetype'; +export { settings_get, settings_put } from './settings'; diff --git a/client/helpers/memory.js b/client/helpers/memory.js index 1ef9b2eb..e70bcd43 100644 --- a/client/helpers/memory.js +++ b/client/helpers/memory.js @@ -9,6 +9,9 @@ function Memory(){ set: function(key, value){ if(!data[key]) data[key] = {}; data[key] = value; + }, + all: function(){ + return data; } } } diff --git a/client/helpers/mimetype.js b/client/helpers/mimetype.js index d4e81b1c..bb6a61dc 100644 --- a/client/helpers/mimetype.js +++ b/client/helpers/mimetype.js @@ -20,11 +20,13 @@ export function opener(file){ return 'image'; }else if(['application/javascript', 'application/xml', 'application/json', 'application/x-perl'].indexOf(mime) !== -1){ return 'editor'; - }else if(['audio/wav', 'audio/mp3', 'audio/flac'].indexOf(mime) !== -1){ + }else if(['audio/wav', 'audio/mp3', 'audio/flac', 'audio/ogg'].indexOf(mime) !== -1){ return 'audio'; - }else if(['video/webm', 'video/mp4', 'application/ogg'].indexOf(mime) !== -1){ + }else if(['video/webm', 'video/mp4'].indexOf(mime) !== -1){ return 'video'; - }else{ + }else if(mime.split('/')[0] === "application") + return 'download'; + else{ return 'editor'; } } @@ -55,7 +57,6 @@ const db = { 'mp4': 'video/mp4', 'mov': 'video/quicktime', 'avi': 'video/x-msvideo', - 'ogg': 'application/ogg', 'ogv': 'application/ogg', 'js': 'application/javascript', 'xml': 'application/xml', @@ -166,7 +167,6 @@ const db = { "mid": "audio/midi", "kar": "audio/midi", "midi": "audio/midi", - "ogg": "audio/ogg", "m4a": "audio/x-m4a", "ra": "audio/x-realaudio", "swf": "application/x-shockwave-flash", diff --git a/client/helpers/settings.js b/client/helpers/settings.js new file mode 100644 index 00000000..e6373fea --- /dev/null +++ b/client/helpers/settings.js @@ -0,0 +1,16 @@ +let settings = JSON.parse(window.localStorage.getItem("settings")) || {}; + +export function settings_get(key){ + return settings[key]; +} + +export function settings_put(key, value){ + settings[key] = value; + save(settings); +} + +function save(d){ + setInterval(() => { + window.localStorage.setItem("settings", JSON.stringify(d)); + }, 500); +} diff --git a/client/pages/filespage.helper.js b/client/pages/filespage.helper.js new file mode 100644 index 00000000..8ed9e986 --- /dev/null +++ b/client/pages/filespage.helper.js @@ -0,0 +1,268 @@ +import { Files } from '../model/'; +import { notify } from '../helpers/'; +import Path from 'path'; + +export const sort = function(files, type){ + if(type === 'name'){ + return sortByName(files); + }else if(type === 'date'){ + return sortByDate(files); + }else{ + return sortByType(files); + } + function sortByType(files){ + return files.sort((fileA, fileB) => { + if(fileA.icon === 'loading' && fileB.icon !== 'loading'){return +1;} + else if(fileA.icon !== 'loading' && fileB.icon === 'loading'){return -1;} + else{ + if(['directory', 'link'].indexOf(fileA.type) === -1 && ['directory', 'link'].indexOf(fileB.type) !== -1){ + return +1; + }else if(['directory', 'link'].indexOf(fileA.type) !== -1 && ['directory', 'link'].indexOf(fileB.type) === -1){ + return -1; + }else{ + if(fileA.name[0] === "." && fileB.name[0] !== ".") return +1; + else if(fileA.name[0] !== "." && fileB.name[0] === ".") return -1; + else{ + if(fileA.name.toLowerCase() === fileB.name.toLowerCase()){ + return fileA.name > fileB.name ? +1 : -1; + } + return fileA.name.toLowerCase() > fileB.name.toLowerCase() ? +1 : -1; + } + } + } + }); + } + function sortByName(files){ + return files.sort((fileA, fileB) => { + if(fileA.icon === 'loading' && fileB.icon !== 'loading'){return +1;} + else if(fileA.icon !== 'loading' && fileB.icon === 'loading'){return -1;} + else{ + if(fileA.name[0] === "." && fileB.name[0] !== ".") return +1; + else if(fileA.name[0] !== "." && fileB.name[0] === ".") return -1; + else{ + if(fileA.name.toLowerCase() === fileB.name.toLowerCase()){ + return fileA.name > fileB.name ? +1 : -1; + } + return fileA.name.toLowerCase() > fileB.name.toLowerCase() ? +1 : -1; + } + } + }); + } + function sortByDate(files){ + return files.sort((fileA, fileB) => { + if(fileA.icon === 'loading' && fileB.icon !== 'loading'){return +1;} + else if(fileA.icon !== 'loading' && fileB.icon === 'loading'){return -1;} + else{ + if(fileB.time === fileA.time){ + return fileA.name > fileB.name ? +1 : -1; + } + return fileB.time - fileA.time; + } + }); + } +}; + +export const onCreate = function(path, type, file){ + if(type === 'file'){ + return Files.touch(path, file) + .then(() => { + notify.send('A file named "'+Path.basename(path)+'" was created', 'success'); + return Promise.resolve(); + }) + .catch((err) => { + notify.send(err, 'error'); + return Promise.reject(err); + }); + }else if(type === 'directory'){ + return Files.mkdir(path) + .then(() => notify.send('A folder named "'+Path.basename(path)+'" was created', 'success')) + .catch((err) => notify.send(err, 'error')); + }else{ + return Promise.reject({message: 'internal error: can\'t create a '+type.toString(), code: 'UNKNOWN_TYPE'}); + } +}; + +export const onRename = function(from, to, type){ + return Files.mv(from, to, type) + .then(() => notify.send('The file "'+Path.basename(from)+'" was renamed', 'success')) + .catch((err) => notify.send(err, 'error')); +}; + +export const onDelete = function(path, type){ + return Files.rm(path, type) + .then(() => notify.send('The file "'+Path.basename(path)+'" was deleted', 'success')) + .catch((err) => notify.send(err, 'error')); +}; + +export const onUpload = function(path, e){ + const MAX_POOL_SIZE = 10; + let PRIOR_STATUS = {}; + extract_upload_directory_the_way_that_works_but_non_official(e.dataTransfer.items || [], []) + .then((files) => { + if(files.length === 0){ + return extract_upload_crappy_hack_but_official_way(e.dataTransfer); + } + return Promise.resolve(files); + }) + .then((files) => { + const processes = files.map((file) => { + file.path = Path.join(path, file.path); + if(file.type === 'file'){ + Files.touch(file.path, file.file, 'prepare_only'); + return { + parent: file._prior || null, + fn: Files.touch.bind(Files, file.path, file.file, 'execute_only') + }; + }else{ + Files.mkdir(file.path, 'prepare_only'); + return { + id: file._id || null, + parent: file._prior || null, + fn: Files.mkdir.bind(Files, file.path, 'execute_only') + }; + } + }); + function runner(id){ + let current_process = null; + if(processes.length === 0) return Promise.resolve(); + + for(let i=0; i { + if(current_process.id) PRIOR_STATUS[current_process.id] = true; + return runner(id); + }) + .catch((err) => { + notify.send(err, 'error'); + return runner(id); + }); + }else{ + return waitABit() + .then(() => runner(id)); + function waitABit(){ + return new Promise((done) => { + window.setTimeout(() => { + requestAnimationFrame(() => { + done(); + }); + }, 100); + }); + } + } + } + + Promise.all(Array.apply(null, Array(MAX_POOL_SIZE)).map((process,index) => { + return runner(); + })).then(() => { + notify.send('Upload completed', 'success'); + }).catch((err) => { + notify.send(err, 'error'); + }); + }); + + + + // adapted from: https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript + function _rand_id(){ + function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + } + return s4() + s4() + s4() + s4(); + } + + function extract_upload_directory_the_way_that_works_but_non_official(items, files = []){ + const traverseDirectory = (item, _files, parent_id) => { + let file = { + path: item.fullPath + }; + if(item.isFile){ + return new Promise((done, err) => { + file.type = "file"; + item.file((_file, _err) => { + if(!_err){ + file.file = _file; + if(parent_id) file._prior = parent_id; + _files.push(file); + } + done(_files); + }); + }); + }else if(item.isDirectory){ + file.type = "directory"; + file.path += "/"; + file._id = _rand_id(); + if(parent_id) file._prior = parent_id; + _files.push(file); + + return new Promise((done, err) => { + item.createReader().readEntries(function(entries){ + Promise.all(entries.map((entry) => { + return traverseDirectory(entry, _files, file._id); + })).then(() => done(_files)); + }); + }); + }else{ + return Promise.resolve(); + } + }; + return Promise.all( + Array.prototype.slice.call(items).map((item) => { + if(typeof item.webkitGetAsEntry === 'function'){ + return traverseDirectory(item.webkitGetAsEntry(), files.slice(0)); + } + }).filter((e) => e) + ).then((res) => Promise.resolve([].concat.apply([], res))); + } + + function extract_upload_crappy_hack_but_official_way(data){ + const _files = data.files; + return Promise.all( + Array.prototype.slice.call(_files).map((_file) => { + return detectType(_file) + .then(transform); + function detectType(_f){ + // the 4096 is an heuristic I've observed and taken from: https://stackoverflow.com/questions/25016442/how-to-distinguish-if-a-file-or-folder-is-being-dragged-prior-to-it-being-droppe + // however the proposed answer is just wrong as it doesn't consider folder with name such as: test.png + // and as Stackoverflow favor consanguinity with their point system, I couldn't rectify the proposed answer. + // The following code is actually working as expected + if(_file.size % 4096 !== 0){ + return Promise.resolve('file'); + } + return new Promise((done, err) => { + let reader = new window.FileReader(); + reader.onload = function() { + done('file'); + }; + reader.onerror = function() { + done('directory'); + }; + reader.readAsText(_f); + }); + } + + function transform(_type){ + let file = { + type: _type, + path: _file.name + }; + if(file.type === 'file'){ + file.file = _file; + }else{ + file.path += "/"; + } + return Promise.resolve(file); + } + }) + ); + } +}; diff --git a/client/pages/filespage.js b/client/pages/filespage.js index 3e6e6147..6fee41fe 100644 --- a/client/pages/filespage.js +++ b/client/pages/filespage.js @@ -1,14 +1,17 @@ import React from 'react'; import { DragDropContext } from 'react-dnd'; import HTML5Backend from 'react-dnd-html5-backend-filedrop'; -import Path from 'path'; import './filespage.scss'; import './error.scss'; import { Files } from '../model/'; +import { sort, onCreate, onRename, onDelete, onUpload } from './filespage.helper'; import { NgIf, Loader, Uploader, EventReceiver } from '../components/'; -import { notify, debounce, goToFiles, goToViewer, event } from '../helpers/'; +import { notify, debounce, goToFiles, goToViewer, event, settings_get, settings_put } from '../helpers/'; import { BreadCrumb, FileSystem, FrequentlyAccess } from './filespage/'; +import InfiniteScroll from 'react-infinite-scroller'; + +const PAGE_NUMBER_INIT = 3; @EventReceiver @DragDropContext(('ontouchstart' in window)? HTML5Backend : HTML5Backend) @@ -17,11 +20,13 @@ export class FilesPage extends React.Component { super(props); this.state = { path: props.match.url.replace('/files', '') || '/', + sort: settings_get('filespage_sort') || 'type', + view: settings_get('filespage_view') || 'grid', files: [], frequents: [], + page_number: PAGE_NUMBER_INIT, loading: true, - error: null, - height: null + error: null }; this.goToFiles = goToFiles.bind(null, this.props.history); @@ -33,10 +38,10 @@ export class FilesPage extends React.Component { this.onRefresh(this.state.path, 'directory'); // subscriptions - this.props.subscribe('file.upload', this.onUpload.bind(this)); - this.props.subscribe('file.create', this.onCreate.bind(this)); - this.props.subscribe('file.rename', this.onRename.bind(this)); - this.props.subscribe('file.delete', this.onDelete.bind(this)); + this.props.subscribe('file.upload', onUpload.bind(this)); + this.props.subscribe('file.create', onCreate.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)); this.hideError(); @@ -79,7 +84,7 @@ export class FilesPage extends React.Component { file.link = file.type === "file" ? "/view"+path : "/files"+path+"/"; return file; }); - this.setState({files: files, loading: false}); + this.setState({files: sort(files, this.state.sort), loading: false, page_number: PAGE_NUMBER_INIT}); }else{ notify.send(res, 'error'); } @@ -100,209 +105,40 @@ export class FilesPage extends React.Component { } } - onCreate(path, type, file){ - if(type === 'file'){ - return Files.touch(path, file) - .then(() => { - notify.send('A file named "'+Path.basename(path)+'" was created', 'success'); - return Promise.resolve(); - }) - .catch((err) => { - notify.send(err, 'error'); - return Promise.reject(err); - }); - }else if(type === 'directory'){ - return Files.mkdir(path) - .then(() => notify.send('A folder named "'+Path.basename(path)+'" was created', 'success')) - .catch((err) => notify.send(err, 'error')); - }else{ - return Promise.reject({message: 'internal error: can\'t create a '+type.toString(), code: 'UNKNOWN_TYPE'}); - } - } - onRename(from, to, type){ - return Files.mv(from, to, type) - .then(() => notify.send('The file "'+Path.basename(from)+'" was renamed', 'success')) - .catch((err) => notify.send(err, 'error')); - } - onDelete(path, type){ - return Files.rm(path, type) - .then(() => notify.send('The file "'+Path.basename(path)+'" was deleted', 'success')) - .catch((err) => notify.send(err, 'error')); - } - - onUpload(path, e){ - const MAX_POOL_SIZE = 10; - let PRIOR_STATUS = {}; - extract_upload_directory_the_way_that_works_but_non_official(e.dataTransfer.items || [], []) - .then((files) => { - if(files.length === 0){ - return extract_upload_crappy_hack_but_official_way(e.dataTransfer); - } - return Promise.resolve(files); - }) - .then((files) => { - const processes = files.map((file) => { - file.path = Path.join(path, file.path); - if(file.type === 'file'){ - Files.touch(file.path, file.file, 'prepare_only'); - return { - parent: file._prior || null, - fn: Files.touch.bind(Files, file.path, file.file, 'execute_only') - }; - }else{ - Files.mkdir(file.path, 'prepare_only'); - return { - id: file._id || null, - parent: file._prior || null, - fn: Files.mkdir.bind(Files, file.path, 'execute_only') - }; - } - }); - function runner(id){ - let current_process = null; - if(processes.length === 0) return Promise.resolve(); - - for(let i=0; i { - if(current_process.id) PRIOR_STATUS[current_process.id] = true; - return runner(id); - }) - .catch((err) => { - notify.send(err, 'error'); - return runner(id); - }); - }else{ - return waitABit() - .then(() => runner(id)); - function waitABit(){ - return new Promise((done) => { - window.setTimeout(() => { - requestAnimationFrame(() => { - done(); - }); - }, 100); - }); - } - } - } - - Promise.all(Array.apply(null, Array(MAX_POOL_SIZE)).map((process,index) => { - return runner(); - })).then(() => { - notify.send('Upload completed', 'success'); - }).catch((err) => { - notify.send(err, 'error'); + onSort(_sort){ + settings_put('filespage_sort', _sort); + this.setState({ + sort: _sort + }, () => { + requestAnimationFrame(() => { + this.setState({ + page_number: PAGE_NUMBER_INIT, + files: sort(this.state.files, _sort) }); }); - - - - // adapted from: https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript - function _rand_id(){ - function s4() { - return Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); - } - return s4() + s4() + s4() + s4(); - } - - function extract_upload_directory_the_way_that_works_but_non_official(items, files = []){ - const traverseDirectory = (item, _files, parent_id) => { - let file = { - path: item.fullPath - }; - if(item.isFile){ - return new Promise((done, err) => { - file.type = "file"; - item.file((_file, _err) => { - if(!_err){ - file.file = _file; - if(parent_id) file._prior = parent_id; - _files.push(file); - } - done(_files); - }); - }); - }else if(item.isDirectory){ - file.type = "directory"; - file.path += "/"; - file._id = _rand_id(); - if(parent_id) file._prior = parent_id; - _files.push(file); - - return new Promise((done, err) => { - item.createReader().readEntries(function(entries){ - Promise.all(entries.map((entry) => { - return traverseDirectory(entry, _files, file._id); - })).then(() => done(_files)); - }); - }); - }else{ - return Promise.resolve(); - } - }; - return Promise.all( - Array.prototype.slice.call(items).map((item) => { - if(typeof item.webkitGetAsEntry === 'function'){ - return traverseDirectory(item.webkitGetAsEntry(), files.slice(0)); - } - }).filter((e) => e) - ).then((res) => Promise.resolve([].concat.apply([], res))); - } - - function extract_upload_crappy_hack_but_official_way(data){ - const _files = data.files; - return Promise.all( - Array.prototype.slice.call(_files).map((_file) => { - return detectType(_file) - .then(transform); - function detectType(_f){ - // the 4096 is an heuristic I've observed and taken from: https://stackoverflow.com/questions/25016442/how-to-distinguish-if-a-file-or-folder-is-being-dragged-prior-to-it-being-droppe - // however the proposed answer is just wrong as it doesn't consider folder with name such as: test.png - // and as Stackoverflow favor consanguinity with their point system, I couldn't rectify the proposed answer. - // The following code is actually working as expected - if(_file.size % 4096 !== 0){ - return Promise.resolve('file'); - } - return new Promise((done, err) => { - let reader = new window.FileReader(); - reader.onload = function() { - done('file'); - }; - reader.onerror = function() { - done('directory'); - }; - reader.readAsText(_f); - }); - } - - function transform(_type){ - let file = { - type: _type, - path: _file.name - }; - if(file.type === 'file'){ - file.file = _file; - }else{ - file.path += "/"; - } - return Promise.resolve(file); - } - }) - ); - } + }); } + onView(){ + const _view = this.state.view === "list" ? "grid" : "list"; + settings_put('filespage_view', _view); + this.setState({ + view: _view + }, () => { + requestAnimationFrame(() => { + this.setState({ + page_number: PAGE_NUMBER_INIT + }); + }); + }); + } + + loadMore(){ + requestAnimationFrame(() => { + let page_number = this.state.page_number + 1; + this.setState({page_number: page_number}); + }); + } render() { return ( @@ -310,13 +146,15 @@ export class FilesPage extends React.Component {
- - - + 70} initialLoad={false} useWindow={false} loadMore={this.loadMore.bind(this)} threshold={40} loader={
}> + + + + + + - - -
+ diff --git a/client/pages/filespage.scss b/client/pages/filespage.scss index 08fa522f..09602a7c 100644 --- a/client/pages/filespage.scss +++ b/client/pages/filespage.scss @@ -15,6 +15,16 @@ display: flex; overflow: hidden; } + + .infinite_scroll_loading{ + text-align: center; + .component_loader{ + margin-top: -50px; + svg{ + height: 40px; + } + } + } } .scroll-y{ diff --git a/client/pages/filespage/filesystem.js b/client/pages/filespage/filesystem.js index f7a922d9..7d0f0a63 100644 --- a/client/pages/filespage/filesystem.js +++ b/client/pages/filespage/filesystem.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import { DropTarget } from 'react-dnd'; + import Path from 'path'; import "./filesystem.scss"; @@ -19,8 +20,7 @@ export class FileSystem extends React.Component { super(props); this.state = { creating: null, - access_right: this._findAccessRight(props.files), - sort: 'type' + access_right: this._findAccessRight(props.files) }; } @@ -34,66 +34,6 @@ export class FileSystem extends React.Component { return {can_create_file: true, can_create_directory: true}; } - sort(files, type){ - if(type === 'name'){ - return sortByName(files); - }else if(type === 'date'){ - return sortByDate(files); - }else{ - return sortByType(files); - } - function sortByType(files){ - return files.sort((fileA, fileB) => { - if(fileA.icon === 'loading' && fileB.icon !== 'loading'){return +1;} - else if(fileA.icon !== 'loading' && fileB.icon === 'loading'){return -1;} - else{ - if(['directory', 'link'].indexOf(fileA.type) === -1 && ['directory', 'link'].indexOf(fileB.type) !== -1){ - return +1; - }else if(['directory', 'link'].indexOf(fileA.type) !== -1 && ['directory', 'link'].indexOf(fileB.type) === -1){ - return -1; - }else{ - if(fileA.name[0] === "." && fileB.name[0] !== ".") return +1; - else if(fileA.name[0] !== "." && fileB.name[0] === ".") return -1; - else{ - if(fileA.name.toLowerCase() === fileB.name.toLowerCase()){ - return fileA.name > fileB.name ? +1 : -1; - } - return fileA.name.toLowerCase() > fileB.name.toLowerCase() ? +1 : -1; - } - } - } - }); - } - function sortByName(files){ - return files.sort((fileA, fileB) => { - if(fileA.icon === 'loading' && fileB.icon !== 'loading'){return +1;} - else if(fileA.icon !== 'loading' && fileB.icon === 'loading'){return -1;} - else{ - if(fileA.name[0] === "." && fileB.name[0] !== ".") return +1; - else if(fileA.name[0] !== "." && fileB.name[0] === ".") return -1; - else{ - if(fileA.name.toLowerCase() === fileB.name.toLowerCase()){ - return fileA.name > fileB.name ? +1 : -1; - } - return fileA.name.toLowerCase() > fileB.name.toLowerCase() ? +1 : -1; - } - } - }); - } - function sortByDate(files){ - return files.sort((fileA, fileB) => { - if(fileA.icon === 'loading' && fileB.icon !== 'loading'){return +1;} - else if(fileA.icon !== 'loading' && fileB.icon === 'loading'){return -1;} - else{ - if(fileB.time === fileA.time){ - return fileA.name > fileB.name ? +1 : -1; - } - return fileB.time - fileA.time; - } - }); - } - } - onComponentPropsUpdate(props){ this.setState({access_right: this._findAccessRight(props.files)}); } @@ -102,16 +42,16 @@ export class FileSystem extends React.Component { return this.props.connectDropFile(
- {this.setState({sort: value})}} accessRight={this.state.access_right}> + this.props.onView(value)} onSortUpdate={(value) => {this.props.onSort(value);}} accessRight={this.state.access_right}> 0}> { - this.sort(this.props.files, this.state.sort).map((file, index) => { + this.props.files.map((file, index) => { if(file.type === 'directory' || file.type === 'file' || file.type === 'link' || file.type === 'bucket'){ - return + return ( ); } }) } diff --git a/client/pages/filespage/thing-existing.js b/client/pages/filespage/thing-existing.js index d016641b..bc259710 100644 --- a/client/pages/filespage/thing-existing.js +++ b/client/pages/filespage/thing-existing.js @@ -1,11 +1,14 @@ import React from 'react'; +import path from 'path'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; import { DragSource, DropTarget } from 'react-dnd'; import './thing.scss'; import { Card, NgIf, Icon, EventEmitter } from '../../components/'; -import { pathBuilder, prompt, leftPad } from '../../helpers/'; +import { pathBuilder, prompt, leftPad, getMimeType, debounce, memory } from '../../helpers/'; +import { Files } from '../../model/'; +import img_placeholder from '../../assets/img/placeholder.png'; const fileSource = { beginDrag(props, monitor, component) { @@ -80,22 +83,67 @@ export class ExistingThing extends React.Component { hover: null, message: null, filename: props.file.name, - is_renaming: false + is_renaming: false, + preview: null }; } + shouldComponentUpdate(nextProps, nextState){ + if(nextState.hover !== this.state.hover || + nextState.message !== this.state.message || + nextState.is_renaming !== this.state.is_renaming || + nextProps.view !== this.props.view || + this.state.preview !== nextState.preview || + this.props.fileIsOver !== nextProps.fileIsOver || + this.props.canDropFile !== nextProps.canDropFile || + this.props.nativeFileIsOver !== nextProps.nativeFileIsOver || + this.props.canDropNativeFile !== nextProps.canDropNativeFile + ){ + return true; + } + return false; + } + + componentDidMount(){ + this.updateThumbnail(this.props); + } + + componentWillReceiveProps(props){ + if(props.view !== this.props.view){ + this.updateThumbnail(props); + } + } + + updateThumbnail(props){ + if(props.view === "grid" && props.icon !== "loading"){ + const _path = path.join(props.path, props.file.name); + const type = getMimeType(_path).split("/")[0]; + if(type === "image"){ + Files.url(_path).then((url) => { + this.setState({preview: url+"&size=250"}); + }); + } + } + } + onRename(newFilename){ - this.props.emit( - 'file.rename', - pathBuilder(this.props.path, this.props.file.name, this.props.file.type), - pathBuilder(this.props.path, newFilename, this.props.file.type), - this.props.file.type - ); + if(typeof newFilename === "string"){ + this.props.emit( + 'file.rename', + pathBuilder(this.props.path, this.props.file.name, this.props.file.type), + pathBuilder(this.props.path, newFilename, this.props.file.type), + this.props.file.type + ); + } this.setState({is_renaming: false}); } - onRenameRequest(){ - this.setState({is_renaming: !this.state.is_renaming}); + onRenameRequest(force){ + let new_state = !this.state.is_renaming; + if(typeof force === "boolean"){ + new_state = force; + } + this.setState({is_renaming: new_state}); } onDeleteRequest(filename){ @@ -150,18 +198,20 @@ export class ExistingThing extends React.Component { if(this.props.file.icon === 'loading'){ className += "loading "; } - + if(this.state.preview){ + className += "preview "; + } className = className.trim(); return connectDragSource(connectDropNativeFile(connectDropFile( -
+
- - + + - +
@@ -185,12 +235,22 @@ class Filename extends React.Component { }; } + + moveCaretAtEnd(e){ + const length = e.target.value.length; + e.target.setSelectionRange(length, length); + } + onRename(e){ e.preventDefault(); e.stopPropagation(); this.props.onRename(this.state.filename); } + onCancel(){ + this.props.onRenameCancel(); + } + preventSelect(e){ e.preventDefault(); } @@ -204,7 +264,7 @@ class Filename extends React.Component {
- this.setState({filename: e.target.value})} autoFocus /> + this.setState({filename: e.target.value})} onBlur={this.onCancel.bind(this)} onFocus={this.moveCaretAtEnd.bind(this)} autoFocus />
@@ -226,7 +286,7 @@ const ActionButton = (props) => { return (
- + @@ -292,3 +352,73 @@ const Message = (props) => { ); }; + + +class Image extends React.Component{ + constructor(props){ + super(props); + } + + render(){ + if(this.props.preview && this.props.view === "grid"){ + return ( + +
+ +
+ ); + } + + const ext = path.extname(this.props.path).replace(/^\./, ""); + return ( + + + + {ext} + + + ); + } +}; + + +class LazyLoadImage extends React.Component { + constructor(props){ + super(props); + this.state = { + appear: false + }; + this.$scroll = document.querySelector(props.scroller); + this.onScroll = debounce(this.onScroll.bind(this), 100); + } + + componentDidMount(){ + if(!this.$scroll){ throw("No scroll detected on LazyLoadImage"); } + this.$scroll.addEventListener("scroll", this.onScroll, {passive: true}); + this.onScroll(); + } + componentWillUnmount(){ + this.$scroll.removeEventListener("scroll", this.onScroll); + } + + onScroll(){ + if(!this.refs.$el) return this.componentWillUnmount(); + const dim_el = this.refs.$el.getBoundingClientRect(); + if(dim_el.top + dim_el.height > 0 && dim_el.top < window.innerHeight){ + this.componentWillUnmount(); + memory.set(this.props.src, true); + this.setState({appear: true}); + } + } + + render(){ + if(this.props.preview || memory.get(this.props.src) === null){ + return ( + + ); + } + return ( + + ); + } +} diff --git a/client/pages/filespage/thing-new.js b/client/pages/filespage/thing-new.js index 32518101..657f9886 100644 --- a/client/pages/filespage/thing-new.js +++ b/client/pages/filespage/thing-new.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Card, NgIf, Icon, EventEmitter } from '../../components/'; +import { Card, NgIf, Icon, EventEmitter, Dropdown, DropdownButton, DropdownList, DropdownItem } from '../../components/'; import { pathBuilder } from '../../helpers/'; import "./thing.scss"; @@ -18,7 +18,7 @@ export class NewThing extends React.Component { this._onEscapeKeyPress = (e) => { if(e.keyCode === 27) this.onDelete(); - } + }; } componentDidMount(){ @@ -48,8 +48,12 @@ export class NewThing extends React.Component { } } - onSortUpdate(e){ - this.props.onSortUpdate(e.target.value); + onViewChange(e){ + this.props.onViewUpdate(); + } + + onSortChange(e){ + this.props.onSortUpdate(e); } render(){ @@ -58,11 +62,17 @@ export class NewThing extends React.Component {
New File New Directory - + + + + + + Sort By Type + Sort By Date + Sort By Name + + +
@@ -94,4 +104,4 @@ NewThing.PropTypes = { onCreate: PropTypes.func.isRequired, onSortUpdate: PropTypes.func.isRequired, sort: PropTypes.string.isRequired -} +}; diff --git a/client/pages/filespage/thing.scss b/client/pages/filespage/thing.scss index 210fdf13..22605028 100644 --- a/client/pages/filespage/thing.scss +++ b/client/pages/filespage/thing.scss @@ -5,17 +5,41 @@ height: 15px; margin-top: 5px; color: var(--light); - margin: 0 0 10px 0; + margin: 5px 0 10px 0; - > span{cursor: pointer; margin-right: 15px;} - select{ + > div, > span{cursor: pointer; margin-right: 15px;} + .view{ float: right; - color: var(--light); - background: none; - border-radius: 3px; - outline: none; - border: 1px solid var(--light); - font-size: 12px; + padding: 2px; + transition: 0.15s ease-out background; + margin-right: 0px; + margin-left: 0px; + .component_icon{ + min-width: 20px; + height: 15px; + width: 15px; + } + &.list-grid, > .dropdown_button{ + border-radius: 2px; + padding: 5px; + margin: 2px 0 2px 0; + &:hover, &:focus{ + background: white; + border-color: var(--bg-color); + box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1); + } + } + &.active > .dropdown_button{ + background: white; + border-color: var(--bg-color); + box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1); + } + .dropdown_container .component_icon{ + box-sizing: border-box; + border: 2px solid rgba(0,0,0,0); + } + &.list-grid{margin-top: -5px!important;} + &.component_dropdown { margin-top: -10px; } } } @@ -107,3 +131,137 @@ } } + + +/* GRID & LIST VIEW */ +.list > span{ + display: flex; + flex-wrap: wrap; + > .component_thing{ + width: 100%; + .box{margin: 0px 0 2px 0;} + } + + // GRID VIEW + > .component_thing.view-grid{ + width: 25%; .box{ height: 160px;} + @media (max-width: 800px){width: 33.33%; .box{height: 200px} } + @media (max-width: 670px){.box{height: 170px}} + @media (max-width: 550px){width: 50%; .box{height: 200px} } + @media (max-width: 480px){.box{height: 170px}} + @media (max-width: 400px){.box{height: 140px; .info_extension{font-size: 0.85em; padding: 3px 10px;}}} + @media (max-width: 340px){.box{height: 130px}} + text-align: center; + .box{ + margin: 2px; + padding: 0; + position: relative; + > span > img{ + padding: 0; + margin: 0; + display: block; + } + > span > img.component_icon{ + padding: 30px; + box-sizing: border-box; + width: auto; + height: 100%; + margin: 0 auto; + z-index: 0; + } + > span > img.thumbnail{ + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 50%; + background: var(--dark); + z-index: 0; + } + .info_extension{ + position: absolute; + top: 50%; + text-align: right; + left: 0; right: 20%; + margin: 0 auto; + span{ + background: var(--color); + color: var(--bg-color); + border-radius: 4px; + padding: 4px 12px; + } + } + .component_filename{ + letter-spacing: -0.5px; + z-index: 2; + position: absolute; + bottom: 2px; + left: 2px; + right: 2px; + border-radius: 2px; + padding: 3px 0px; + .file-details{ + width: calc(100% - 10px); + display: block; + > span{ + width: 100%; + form input{ + letter-spacing: -0.5px; + text-align: center; + width: 100%; + padding: 0; + } + } + } + } + .image_layer{ + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 1; + background: rgba(0,0,0,0); + transition: 0.2s ease-out background; + } + &:hover{ + .component_action{ + transition-delay: 0.1s; + opacity: 1; + transform: translateX(0); + } + img.thumbnail{ + transform: scale(0.6); + } + .image_layer{ + background: rgba(242, 242, 242, 0.7); + } + } + &.preview{ + &:hover{ + .component_filename{ + opacity: 1; + } + } + .component_filename{ + transition: 0.2s ease-out opacity; + 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;} + } + } +} diff --git a/client/pages/viewerpage/imageviewer.js b/client/pages/viewerpage/imageviewer.js index 1d33b819..34e3dd37 100644 --- a/client/pages/viewerpage/imageviewer.js +++ b/client/pages/viewerpage/imageviewer.js @@ -3,11 +3,16 @@ import React from 'react'; import { MenuBar } from './menubar'; export const ImageViewer = (props) => { + const image_url = (size) => { + return props.data+"&meta=true&size="+parseInt((window.innerWidth - 40)*size); + }; return (
- +
); diff --git a/config_server.js b/config_server.js index 2b9ed052..ba1a699b 100644 --- a/config_server.js +++ b/config_server.js @@ -22,5 +22,12 @@ module.exports = { clientID: process.env.DROPBOX_CLIENT_ID, redirectURI: process.env.APPLICATION_URL+"/login" }, + transcoder: { + url: process.env.TRANSCODER_URL || null, + audio_bitrate: 128, // in kbps + audio_quality: 0, + video_codec: 'libx264', + video_bitrate: 1000 // in kbps + }, secret_key: process.env.SECRET_KEY || (Math.random()*Math.pow(10,16)).toString(32) }; diff --git a/package.json b/package.json index 2c1e0045..6574ce9e 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "react-dnd-html5-backend-filedrop": "^1.0.0", "react-dom": "^15.3.2", "react-draggable": "^2.2.6", + "react-infinite-scroller": "^1.1.4", "react-router": "^4.1.1", "react-router-dom": "^4.1.1", "requirejs": "^2.3.5", diff --git a/server/ctrl/files.js b/server/ctrl/files.js index bb4f38a0..ac94b881 100644 --- a/server/ctrl/files.js +++ b/server/ctrl/files.js @@ -3,7 +3,10 @@ var express = require('express'), path = require('path'), crypto = require('../utils/crypto'), Files = require('../model/files'), + config = require('../../config_server'), multiparty = require('multiparty'), + request = require('request'), + FormData = require('form-data'), mime = require('../utils/mimetype.js'); app.use(function(req, res, next){ @@ -35,8 +38,8 @@ app.get('/cat', function(req, res){ res.clearCookie("download"); if(path){ Files.cat(path, req.cookies.auth, res) - .then(function(stream){ - stream = stream.on('error', function (error) { + .then(function(_stream){ + _stream = _stream.on('error', function (error) { let status = 404; if(error && typeof error.status === "number"){ status = error.status; @@ -44,8 +47,33 @@ app.get('/cat', function(req, res){ res.status(status).send({status: status, message: "There's nothing here"}); if(typeof this.end === "function") this.end(); }); - res.set('Content-Type', mime.getMimeType(path)); - stream.pipe(res); + const mType = mime.getMimeType(path); + res.set('Content-Type', mType); + + if((/^image\//.test(mType) || /^video\//.test(mType) ) && config.transcoder.url){ + const form = new FormData(); + form.append('video', _stream, { + filename: 'tmp', + contentType: mType, + }); + + let endpoint = config.transcoder.url; + if(req.query.size){ + endpoint += "?size="+req.query.size; + } + const post_request = request({ + method: "POST", + url: endpoint, + headers: form.getHeaders() + }); + return form.pipe(post_request) + .on('error', (err) => { + console.log(err); + res.status(500).end(); + }) + .pipe(res); + } + return _stream.pipe(res); }) .catch(function(err){ errorHandler(res, err, 'couldn\'t read the file'); }); }else{