diff --git a/client/components/breadcrumb.js b/client/components/breadcrumb.js index 63010d08..c8249f17 100644 --- a/client/components/breadcrumb.js +++ b/client/components/breadcrumb.js @@ -92,9 +92,11 @@ const Logout = (props) => { const Saving = (props) => { return ( - - * - + + + * + + ); } diff --git a/client/components/breadcrumb.scss b/client/components/breadcrumb.scss index a343869b..1a6560ad 100644 --- a/client/components/breadcrumb.scss +++ b/client/components/breadcrumb.scss @@ -13,7 +13,7 @@ width: 95%; max-width: 800px; padding: 0; - span{display: block; padding: 7px 0;} + > span{display: block; padding: 7px 0;} div, li{ display: inline-block; } @@ -31,6 +31,9 @@ vertical-align: middle; } } + .component_saving{ + padding-left: 1px; + } .component_path-element{ display: inline-block; @@ -128,4 +131,27 @@ body.touch-yes{ transform: translateX(0); transition: all 0.2s ease-out; } + + + + + + .saving_indicator-leave{ + opacity: 1; + } + .saving_indicator-leave.saving_indicator-leave-active{ + opacity: 0; + transition: all 0.2s ease-out; + } + + .saving_indicator-enter, .saving_indicator-appear{ + transform-origin: center; + animation-name: bounce; + animation-duration: 0.5s; + @keyframes bounce { + 0% { transform: scale(0); } + 30% { transform: scale(1.5);} + 100% { transform: scale(1);} + } + } } diff --git a/client/helpers/dom.js b/client/helpers/dom.js index d3dcd27b..4db3ab98 100644 --- a/client/helpers/dom.js +++ b/client/helpers/dom.js @@ -1,8 +1,8 @@ export function screenHeight(){ - const $breadcrumb = document.querySelector(".breadcrumb"); + const $breadcrumb = document.querySelector(".component_breadcrumb"); + const $menubar = document.querySelector(".component_menubar"); let size = document.body.clientHeight; - if($breadcrumb){ - size -= $breadcrumb.clientHeight; - } + if($breadcrumb){ size -= $breadcrumb.clientHeight; } + if($menubar){ size -= $menubar.clientHeight; } return size; } diff --git a/client/model/files.js b/client/model/files.js index b6eb9f8b..113ceeb5 100644 --- a/client/model/files.js +++ b/client/model/files.js @@ -101,15 +101,27 @@ class FileSystem{ cat(path){ const url = '/api/files/cat?path='+prepare(path); return http_get(url, 'raw') - .then((res) => cache.put(cache.FILE_CONTENT, path, {result: res})) + .then((res) => { + if(is_binary(res) === false) cache.put(cache.FILE_CONTENT, path, {result: res}); + return Promise.resolve(res); + }) .catch((res) => { return cache.get(cache.FILE_CONTENT, path) .then((_res) => { - if(!_res || !_res.result) return Promise.reject(_res); + if(!_res || !_res.result) return Promise.reject(res); return Promise.resolve(_res.result); }) .catch(() => Promise.reject(res)); + }) + .then((res) => { + if(is_binary(res) === true) return Promise.reject({code: 'BINARY_FILE'}); + return Promise.resolve(res); }); + + function is_binary(str){ + // Reference: https://en.wikipedia.org/wiki/Specials_(Unicode_block)#Replacement_character + return /\ufffd/.test(str); + } } url(path){ const url = '/api/files/cat?path='+prepare(path); @@ -181,7 +193,10 @@ class FileSystem{ function update_from(){ return cache.get(cache.FILE_PATH, dirname(from), false) .then((res_from) => { - let _file = {name: basename(from), type: /\/$/.test(from) ? 'directory' : 'file'}; + let _file = { + name: basename(from), + type: /\/$/.test(from) ? 'directory' : 'file' + }; res_from.results = res_from.results.map((file) => { if(file.name === basename(from)){ file.name = basename(to); diff --git a/client/pages/viewerpage.js b/client/pages/viewerpage.js index 228b0548..88ab62f1 100644 --- a/client/pages/viewerpage.js +++ b/client/pages/viewerpage.js @@ -23,9 +23,10 @@ export class ViewerPage extends React.Component { super(props); this.state = { path: props.match.url.replace('/view', ''), + url: null, filename: Path.basename(props.match.url.replace('/view', '')) || 'untitled.dat', opener: null, - data: '', + content: null, needSaving: false, isSaving: false, loading: true, @@ -36,35 +37,38 @@ export class ViewerPage extends React.Component { } componentWillMount(){ - this.setState({loading: null}, () => { - window.setTimeout(() => { - if(this.state.loading === null) this.setState({loading: true}); - }, 500); - }); - let app = opener(this.state.path); - if(app === 'editor'){ - Files.cat(this.state.path).then((content) => { - this.setState({data: content, loading: false, opener: app}); - }).catch(err => { - if(err && err.code === 'CANCELLED'){ return; } - if(err.code === 'BINARY_FILE'){ - Files.url(this.state.path).then((url) => { - this.setState({data: url, loading: false, opener: 'download'}); - }).catch(err => { - notify.send(err, 'error'); - }); - }else{ + const metadata = () => { + return new Promise((done, err) => { + let app_opener = opener(this.state.path); + Files.url(this.state.path).then((url) => { + this.setState({ + url: url, + opener: app_opener + }, () => done(app_opener)); + }).catch(err => { notify.send(err, 'error'); - } + err(err); + }); }); - }else{ - Files.url(this.state.path).then((url) => { - this.setState({data: url, loading: false, opener: app}); - }).catch(err => { - if(err && err.code === 'CANCELLED'){ return; } - notify.send(err, 'error'); - }); - } + }; + const data_fetch = (app) => { + if(app === 'editor'){ + Files.cat(this.state.path).then((content) => { + this.setState({content: content || "", loading: false}); + }).catch(err => { + if(err && err.code === 'CANCELLED'){ return; } + if(err.code === 'BINARY_FILE'){ + this.setState({opener: 'download', loading: false}); + }else{ + notify.send(err, 'error'); + } + }); + }else{ + this.setState({loading: false}); + } + }; + return metadata() + .then(data_fetch); } componentWillUnmount() { @@ -79,15 +83,16 @@ export class ViewerPage extends React.Component { save(file){ this.setState({isSaving: true}); - Files.save(this.state.path, file) + return Files.save(this.state.path, file) .then(() => { - this.setState({isSaving: false}); - this.setState({needSaving: false}); + this.setState({isSaving: false, needSaving: false}); + return Promise.resolve(); }) .catch((err) => { if(err && err.code === 'CANCELLED'){ return; } this.setState({isSaving: false}); notify.send(err, 'error'); + return Promise.reject(); }); } @@ -116,23 +121,24 @@ export class ViewerPage extends React.Component { - + - + - + - + - + diff --git a/client/pages/viewerpage/editor.js b/client/pages/viewerpage/editor.js index ccadd29e..b5ad7f3a 100644 --- a/client/pages/viewerpage/editor.js +++ b/client/pages/viewerpage/editor.js @@ -74,13 +74,6 @@ export class Editor extends React.Component { }); CodeMirror.commands.save = () => { - let elt = editor.getWrapperElement(); - elt.style.background = "rgba(0,0,0,0.1)"; - elt.style.transition = ""; - window.setTimeout(function() { - elt.style.transition = "background 0.5s ease-out"; - elt.style.background = ""; - }, 200); this.props.onSave && this.props.onSave(); }; } diff --git a/client/pages/viewerpage/ide.js b/client/pages/viewerpage/ide.js index ce7accd4..ff8c104f 100644 --- a/client/pages/viewerpage/ide.js +++ b/client/pages/viewerpage/ide.js @@ -3,6 +3,7 @@ import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import { NgIf, Fab, Icon } from '../../components/'; import { Editor } from './editor'; +import { MenuBar } from './menubar'; import './ide.scss'; @@ -10,13 +11,14 @@ export class IDE extends React.Component { constructor(props){ super(props); this.state = { - contentToSave: props.content + contentToSave: props.content, + needSaving: false }; } onContentUpdate(text){ this.props.needSaving(true); - this.setState({contentToSave: text}); + this.setState({contentToSave: text, needSaving: true}); } save(){ @@ -28,22 +30,26 @@ export class IDE extends React.Component { // https://stackoverflow.com/questions/33821631/alternative-for-file-constructor-for-safari file = blob; } - this.props.onSave(file); + this.props.onSave(file) + .then(() => this.setState({needSaving: false})); } render(){ return (
- + + - + + - + +
); } diff --git a/client/pages/viewerpage/ide.scss b/client/pages/viewerpage/ide.scss index fe4a1067..724e906e 100644 --- a/client/pages/viewerpage/ide.scss +++ b/client/pages/viewerpage/ide.scss @@ -1,7 +1,17 @@ -.fab-appear{ - opacity: 0; +.fab-appear, .fab-enter{ + opacity: 0.5; + transform: translateX(70px); } -.fab-appear.fab-appear-active{ +.fab-appear.fab-appear-active, .fab-enter.fab-enter-active{ transition: all 0.2s ease-out; + transition-delay: 0.1s; + transform: translateX(0px); opacity: 1; } +.fab-leave{ + opacity: 1; +} +.fab-leave.fab-leave-active{ + transition: opacity 0.2s ease-out; + opacity: 0; +} diff --git a/client/pages/viewerpage/menubar.js b/client/pages/viewerpage/menubar.js index a7aef34b..89b736e5 100644 --- a/client/pages/viewerpage/menubar.js +++ b/client/pages/viewerpage/menubar.js @@ -1,47 +1,71 @@ import React from 'react'; +import PropTypes from 'prop-types'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import { Container, NgIf, Icon } from '../../components/'; +import './menubar.scss'; -export class MenuBar extends React.Component{ + +export const MenuBar = (props) => { + return ( +
+ + + + {props.title} + + +
+ ); +}; + +class DownloadButton extends React.Component { constructor(props){ super(props); - this.state = {loading: false, id: null} + this.state = { + loading: false, + id: null + }; } onDownloadRequest(){ this.setState({ - loading: true, - id: window.setInterval(function(){ - if(document.cookie){ - this.setState({loading: false}) - window.clearInterval(this.state.id); - } - }.bind(this), 80) - }) + loading: true + }); + + // This my friend is a dirty hack aiming to detect when we the download effectively start + // so that we can display a spinner instead of having a user clicking the download button + // 10 times. It works by sniffing a cookie in our session that will get destroy when + // the server actually send a response + document.cookie = "download=yes; path=/; max-age=120;"; + this.state.id = window.setInterval(() => { + if(/download=yes/.test(document.cookie) === false){ + window.clearInterval(this.state.id); + this.setState({loading: false}); + } + }, 100); } componentWillUnmount(){ - window.clearInterval(this.state.id) + window.clearInterval(this.state.id); } - render(){ return ( -
- - - - - - - - - - - - {this.props.title} - +
+ + + + + + + +
); } } +DownloadButton.PropTypes = { + link: PropTypes.string.isRequired, + name: PropTypes.string.isRequired +}; diff --git a/client/pages/viewerpage/menubar.scss b/client/pages/viewerpage/menubar.scss new file mode 100644 index 00000000..0b1b7b59 --- /dev/null +++ b/client/pages/viewerpage/menubar.scss @@ -0,0 +1,24 @@ +.component_menubar{ + background: #313538; + color: #f1f1f1; + border-bottom: 1px solid var(--color); + + .component_container{ + padding: 8px 0; + color: #f1f1f1; + font-size: 0.9em; + } +} + + + +.menubar-appear{ + display: inline-block; + opacity: 0; + transform: translateY(2px); +} +.menubar-appear.menubar-appear-active{ + opacity: 1; + transform: translateY(0px); + transition: all 0.15s ease-out; +} diff --git a/server/ctrl/files.js b/server/ctrl/files.js index e7718ee9..73a87921 100644 --- a/server/ctrl/files.js +++ b/server/ctrl/files.js @@ -36,7 +36,7 @@ app.get('/ls', function(req, res){ // get a file content app.get('/cat', function(req, res){ let path = pathBuilder(req); - res.cookie('download', path, { maxAge: 1000 }); + res.clearCookie("download"); if(path){ Files.cat(path, req.cookies.auth, res) .then(function(stream){ @@ -148,5 +148,5 @@ app.get('/touch', function(req, res){ module.exports = app; function pathBuilder(req){ - return path.join(req.cookies.auth.payload.path, decodeURIComponent(req.query.path)); + return path.join(req.cookies.auth.payload.path || '', decodeURIComponent(req.query.path) || ''); } diff --git a/server/ctrl/session.js b/server/ctrl/session.js index 4b758657..01da8952 100644 --- a/server/ctrl/session.js +++ b/server/ctrl/session.js @@ -26,7 +26,7 @@ app.post('/', function(req, res){ if(Buffer.byteLength(cookie, 'utf-8') > 4096){ res.send({status: 'error', message: 'we can\'t authenticate you', }) }else{ - res.cookie('auth', crypto.encrypt(persist), { maxAge: 365*24*60*60*1000, httpOnly: true }); + res.cookie('auth', crypto.encrypt(persist), { maxAge: 365*24*60*60*1000, httpOnly: true, path: "/api/" }); res.send({status: 'ok'}); } }) @@ -43,7 +43,11 @@ app.post('/', function(req, res){ }); app.delete('/', function(req, res){ - res.clearCookie("auth"); + res.clearCookie("auth", {path: "/api/"}); + + // TODO in May 2019: remove the line below which was inserted to mitigate a cookie migration issue. + res.clearCookie("auth"); // the issue was a change in the cookie path which would have make + // impossible for an existing user to logout res.send({status: 'ok'}) }); diff --git a/server/index.js b/server/index.js index 96c044a6..d38d92d5 100644 --- a/server/index.js +++ b/server/index.js @@ -4,7 +4,7 @@ var app = require('./bootstrap'), sessionRouter = require('./ctrl/session'); -app.get('/ping', function(req, res){ res.send('pong')}) +app.get('/api/ping', function(req, res){ res.send('pong')}) app.use('/api/files', filesRouter) app.use('/api/session', sessionRouter); app.use('/', express.static(__dirname + '/public/')) diff --git a/server/public/cache.js b/server/public/cache.js index c4005916..53224a35 100644 --- a/server/public/cache.js +++ b/server/public/cache.js @@ -92,7 +92,7 @@ function smartCacheStrategy(request){ .catch(function(err){ return fetchAndCache(request); }); - }); + }).catch(() => return request); function fetchAndCache(_request){ @@ -110,7 +110,7 @@ function smartCacheStrategy(request){ cache.put(_request, responseClone); }); return response; - }); + }).catch(() => return _request); } function nil(e){} } @@ -128,7 +128,7 @@ function networkFirstStrategy(request){ network(request.clone && request.clone() || request) .then(done) .catch(error); - }); + }).catch(() => return request); function network(request){ return fetch(request)