feature (download): Add a way to download a file in the IDE + fix - #34

This commit is contained in:
Mickael KERJEAN 2018-04-11 22:43:36 +10:00
parent 76172f0239
commit 3e2714fb33
14 changed files with 208 additions and 98 deletions

View file

@ -92,9 +92,11 @@ const Logout = (props) => {
const Saving = (props) => { const Saving = (props) => {
return ( return (
<NgIf className="component_saving" cond={props.needSaving === true}> <ReactCSSTransitionGroup transitionName="saving_indicator" transitionLeave={true} transitionEnter={true} transitionAppear={true} transitionLeaveTimeout={200} transitionEnterTimeout={500} transitionAppearTimeout={500}>
* <NgIf key={props.needSaving} className="component_saving" cond={props.needSaving === true}>
</NgIf> *
</NgIf>
</ReactCSSTransitionGroup>
); );
} }

View file

@ -13,7 +13,7 @@
width: 95%; width: 95%;
max-width: 800px; max-width: 800px;
padding: 0; padding: 0;
span{display: block; padding: 7px 0;} > span{display: block; padding: 7px 0;}
div, li{ div, li{
display: inline-block; display: inline-block;
} }
@ -31,6 +31,9 @@
vertical-align: middle; vertical-align: middle;
} }
} }
.component_saving{
padding-left: 1px;
}
.component_path-element{ .component_path-element{
display: inline-block; display: inline-block;
@ -128,4 +131,27 @@ body.touch-yes{
transform: translateX(0); transform: translateX(0);
transition: all 0.2s ease-out; 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);}
}
}
} }

View file

@ -1,8 +1,8 @@
export function screenHeight(){ 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; let size = document.body.clientHeight;
if($breadcrumb){ if($breadcrumb){ size -= $breadcrumb.clientHeight; }
size -= $breadcrumb.clientHeight; if($menubar){ size -= $menubar.clientHeight; }
}
return size; return size;
} }

View file

@ -101,15 +101,27 @@ class FileSystem{
cat(path){ cat(path){
const url = '/api/files/cat?path='+prepare(path); const url = '/api/files/cat?path='+prepare(path);
return http_get(url, 'raw') 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) => { .catch((res) => {
return cache.get(cache.FILE_CONTENT, path) return cache.get(cache.FILE_CONTENT, path)
.then((_res) => { .then((_res) => {
if(!_res || !_res.result) return Promise.reject(_res); if(!_res || !_res.result) return Promise.reject(res);
return Promise.resolve(_res.result); return Promise.resolve(_res.result);
}) })
.catch(() => Promise.reject(res)); .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){ url(path){
const url = '/api/files/cat?path='+prepare(path); const url = '/api/files/cat?path='+prepare(path);
@ -181,7 +193,10 @@ class FileSystem{
function update_from(){ function update_from(){
return cache.get(cache.FILE_PATH, dirname(from), false) return cache.get(cache.FILE_PATH, dirname(from), false)
.then((res_from) => { .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) => { res_from.results = res_from.results.map((file) => {
if(file.name === basename(from)){ if(file.name === basename(from)){
file.name = basename(to); file.name = basename(to);

View file

@ -23,9 +23,10 @@ export class ViewerPage extends React.Component {
super(props); super(props);
this.state = { this.state = {
path: props.match.url.replace('/view', ''), path: props.match.url.replace('/view', ''),
url: null,
filename: Path.basename(props.match.url.replace('/view', '')) || 'untitled.dat', filename: Path.basename(props.match.url.replace('/view', '')) || 'untitled.dat',
opener: null, opener: null,
data: '', content: null,
needSaving: false, needSaving: false,
isSaving: false, isSaving: false,
loading: true, loading: true,
@ -36,35 +37,38 @@ export class ViewerPage extends React.Component {
} }
componentWillMount(){ componentWillMount(){
this.setState({loading: null}, () => { const metadata = () => {
window.setTimeout(() => { return new Promise((done, err) => {
if(this.state.loading === null) this.setState({loading: true}); let app_opener = opener(this.state.path);
}, 500); Files.url(this.state.path).then((url) => {
}); this.setState({
let app = opener(this.state.path); url: url,
if(app === 'editor'){ opener: app_opener
Files.cat(this.state.path).then((content) => { }, () => done(app_opener));
this.setState({data: content, loading: false, opener: app}); }).catch(err => {
}).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{
notify.send(err, 'error'); notify.send(err, 'error');
} err(err);
});
}); });
}else{ };
Files.url(this.state.path).then((url) => { const data_fetch = (app) => {
this.setState({data: url, loading: false, opener: app}); if(app === 'editor'){
}).catch(err => { Files.cat(this.state.path).then((content) => {
if(err && err.code === 'CANCELLED'){ return; } this.setState({content: content || "", loading: false});
notify.send(err, 'error'); }).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() { componentWillUnmount() {
@ -79,15 +83,16 @@ export class ViewerPage extends React.Component {
save(file){ save(file){
this.setState({isSaving: true}); this.setState({isSaving: true});
Files.save(this.state.path, file) return Files.save(this.state.path, file)
.then(() => { .then(() => {
this.setState({isSaving: false}); this.setState({isSaving: false, needSaving: false});
this.setState({needSaving: false}); return Promise.resolve();
}) })
.catch((err) => { .catch((err) => {
if(err && err.code === 'CANCELLED'){ return; } if(err && err.code === 'CANCELLED'){ return; }
this.setState({isSaving: false}); this.setState({isSaving: false});
notify.send(err, 'error'); notify.send(err, 'error');
return Promise.reject();
}); });
} }
@ -116,23 +121,24 @@ export class ViewerPage extends React.Component {
<IDE needSaving={this.needSaving.bind(this)} <IDE needSaving={this.needSaving.bind(this)}
isSaving={this.state.isSaving} isSaving={this.state.isSaving}
onSave={this.save.bind(this)} onSave={this.save.bind(this)}
content={this.state.data || ''} content={this.state.content}
url={this.state.url}
filename={this.state.filename}/> filename={this.state.filename}/>
</NgIf> </NgIf>
<NgIf cond={this.state.opener === 'image'} style={style}> <NgIf cond={this.state.opener === 'image'} style={style}>
<ImageViewer data={this.state.data} filename={this.state.filename} /> <ImageViewer data={this.state.url} filename={this.state.filename} />
</NgIf> </NgIf>
<NgIf cond={this.state.opener === 'pdf'} style={style}> <NgIf cond={this.state.opener === 'pdf'} style={style}>
<PDFViewer data={this.state.data} filename={this.state.filename} /> <PDFViewer data={this.state.url} filename={this.state.filename} />
</NgIf> </NgIf>
<NgIf cond={this.state.opener === 'video'} style={style}> <NgIf cond={this.state.opener === 'video'} style={style}>
<VideoPlayer data={this.state.data} filename={this.state.filename} /> <VideoPlayer data={this.state.url} filename={this.state.filename} />
</NgIf> </NgIf>
<NgIf cond={this.state.opener === 'audio'} style={style}> <NgIf cond={this.state.opener === 'audio'} style={style}>
<AudioPlayer data={this.state.data} filename={this.state.filename} /> <AudioPlayer data={this.state.url} filename={this.state.filename} />
</NgIf> </NgIf>
<NgIf cond={this.state.opener === 'download'} style={style}> <NgIf cond={this.state.opener === 'download'} style={style}>
<FileDownloader data={this.state.data} filename={this.state.filename} /> <FileDownloader data={this.state.url} filename={this.state.filename} />
</NgIf> </NgIf>
</NgIf> </NgIf>
<NgIf cond={this.state.loading === true}> <NgIf cond={this.state.loading === true}>

View file

@ -74,13 +74,6 @@ export class Editor extends React.Component {
}); });
CodeMirror.commands.save = () => { 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(); this.props.onSave && this.props.onSave();
}; };
} }

View file

@ -3,6 +3,7 @@ import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import { NgIf, Fab, Icon } from '../../components/'; import { NgIf, Fab, Icon } from '../../components/';
import { Editor } from './editor'; import { Editor } from './editor';
import { MenuBar } from './menubar';
import './ide.scss'; import './ide.scss';
@ -10,13 +11,14 @@ export class IDE extends React.Component {
constructor(props){ constructor(props){
super(props); super(props);
this.state = { this.state = {
contentToSave: props.content contentToSave: props.content,
needSaving: false
}; };
} }
onContentUpdate(text){ onContentUpdate(text){
this.props.needSaving(true); this.props.needSaving(true);
this.setState({contentToSave: text}); this.setState({contentToSave: text, needSaving: true});
} }
save(){ save(){
@ -28,22 +30,26 @@ export class IDE extends React.Component {
// https://stackoverflow.com/questions/33821631/alternative-for-file-constructor-for-safari // https://stackoverflow.com/questions/33821631/alternative-for-file-constructor-for-safari
file = blob; file = blob;
} }
this.props.onSave(file); this.props.onSave(file)
.then(() => this.setState({needSaving: false}));
} }
render(){ render(){
return ( return (
<div style={{height: '100%'}}> <div style={{height: '100%'}}>
<Editor onSave={this.save.bind(this)} filename={this.props.filename} content={this.props.content} onChange={this.onContentUpdate.bind(this)} /> <MenuBar title={this.props.filename} download={this.props.url} />
<Editor onSave={this.save.bind(this)} filename={this.props.filename} content={this.props.content} onChange={this.onContentUpdate.bind(this)} />
<ReactCSSTransitionGroup transitionName="fab" transitionLeave={false} transitionEnter={false} transitionAppear={true} transitionAppearTimeout={25000}> <ReactCSSTransitionGroup transitionName="fab" transitionLeave={true} transitionEnter={true} transitionAppear={true} transitionAppearTimeout="300" transitonEnterTimeout="300" transitionLeaveTimeout="200">
<NgIf key={this.state.needSaving} cond={this.state.needSaving}>
<NgIf cond={!this.props.isSaving}> <NgIf cond={!this.props.isSaving}>
<Fab onClick={this.save.bind(this)}><Icon name="save" style={{height: '100%', width: '100%'}}/></Fab> <Fab onClick={this.save.bind(this)}><Icon name="save" style={{height: '100%', width: '100%'}}/></Fab>
</NgIf> </NgIf>
<NgIf cond={this.props.isSaving}> <NgIf cond={this.props.isSaving}>
<Fab><Icon name="loading_white" style={{height: '100%', width: '100%'}}/></Fab> <Fab><Icon name="loading_white" style={{height: '100%', width: '100%'}}/></Fab>
</NgIf> </NgIf>
</ReactCSSTransitionGroup> </NgIf>
</ReactCSSTransitionGroup>
</div> </div>
); );
} }

View file

@ -1,7 +1,17 @@
.fab-appear{ .fab-appear, .fab-enter{
opacity: 0; 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: all 0.2s ease-out;
transition-delay: 0.1s;
transform: translateX(0px);
opacity: 1; opacity: 1;
} }
.fab-leave{
opacity: 1;
}
.fab-leave.fab-leave-active{
transition: opacity 0.2s ease-out;
opacity: 0;
}

View file

@ -1,47 +1,71 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import { Container, NgIf, Icon } from '../../components/'; import { Container, NgIf, Icon } from '../../components/';
import './menubar.scss';
export class MenuBar extends React.Component{
export const MenuBar = (props) => {
return (
<div className="component_menubar">
<Container>
<ReactCSSTransitionGroup transitionName="menubar" transitionLeave={false} transitionEnter={false} transitionAppear={true} transitionAppearTimeout={150}>
<DownloadButton link={props.download} name={props.title} />
<span style={{letterSpacing: '0.3px'}}>{props.title}</span>
</ReactCSSTransitionGroup>
</Container>
</div>
);
};
class DownloadButton extends React.Component {
constructor(props){ constructor(props){
super(props); super(props);
this.state = {loading: false, id: null} this.state = {
loading: false,
id: null
};
} }
onDownloadRequest(){ onDownloadRequest(){
this.setState({ this.setState({
loading: true, loading: true
id: window.setInterval(function(){ });
if(document.cookie){
this.setState({loading: false}) // This my friend is a dirty hack aiming to detect when we the download effectively start
window.clearInterval(this.state.id); // 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
}.bind(this), 80) // 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(){ componentWillUnmount(){
window.clearInterval(this.state.id) window.clearInterval(this.state.id);
} }
render(){ render(){
return ( return (
<div style={{background: '#313538', color: '#f1f1f1', boxShadow: 'rgba(0, 0, 0, 0.14) 2px 2px 2px 0px'}}> <div style={{float: 'right', height: '1em'}}>
<Container style={{padding: '9px 0', textAlign: 'center', color: '#f1f1f1', fontSize: '0.9em'}}> <NgIf cond={!this.state.loading} style={{display: 'inline'}}>
<NgIf cond={this.props.hasOwnProperty('download')} style={{float: 'right', height: '1em'}}> <a href={this.props.link} download={this.props.name} onClick={this.onDownloadRequest.bind(this)}>
<NgIf cond={!this.state.loading} style={{display: 'inline'}}> <Icon name="download" style={{width: '15px', height: '15px'}} />
<a href={this.props.download} download={this.props.title} onClick={this.onDownloadRequest.bind(this)}> </a>
<Icon name="download" style={{width: '15px', height: '15px'}} /> </NgIf>
</a> <NgIf cond={this.state.loading} style={{display: 'inline'}}>
</NgIf> <Icon name="loading" style={{width: '15px', height: '15px'}} />
<NgIf cond={this.state.loading} style={{display: 'inline'}}> </NgIf>
<Icon name="loading" style={{width: '15px', height: '15px'}} />
</NgIf>
</NgIf>
<span style={{letterSpacing: '0.3px'}}>{this.props.title}</span>
</Container>
</div> </div>
); );
} }
} }
DownloadButton.PropTypes = {
link: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
};

View file

@ -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;
}

View file

@ -36,7 +36,7 @@ app.get('/ls', function(req, res){
// get a file content // get a file content
app.get('/cat', function(req, res){ app.get('/cat', function(req, res){
let path = pathBuilder(req); let path = pathBuilder(req);
res.cookie('download', path, { maxAge: 1000 }); res.clearCookie("download");
if(path){ if(path){
Files.cat(path, req.cookies.auth, res) Files.cat(path, req.cookies.auth, res)
.then(function(stream){ .then(function(stream){
@ -148,5 +148,5 @@ app.get('/touch', function(req, res){
module.exports = app; module.exports = app;
function pathBuilder(req){ 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) || '');
} }

View file

@ -26,7 +26,7 @@ app.post('/', function(req, res){
if(Buffer.byteLength(cookie, 'utf-8') > 4096){ if(Buffer.byteLength(cookie, 'utf-8') > 4096){
res.send({status: 'error', message: 'we can\'t authenticate you', }) res.send({status: 'error', message: 'we can\'t authenticate you', })
}else{ }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'}); res.send({status: 'ok'});
} }
}) })
@ -43,7 +43,11 @@ app.post('/', function(req, res){
}); });
app.delete('/', 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'}) res.send({status: 'ok'})
}); });

View file

@ -4,7 +4,7 @@ var app = require('./bootstrap'),
sessionRouter = require('./ctrl/session'); 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/files', filesRouter)
app.use('/api/session', sessionRouter); app.use('/api/session', sessionRouter);
app.use('/', express.static(__dirname + '/public/')) app.use('/', express.static(__dirname + '/public/'))

View file

@ -92,7 +92,7 @@ function smartCacheStrategy(request){
.catch(function(err){ .catch(function(err){
return fetchAndCache(request); return fetchAndCache(request);
}); });
}); }).catch(() => return request);
function fetchAndCache(_request){ function fetchAndCache(_request){
@ -110,7 +110,7 @@ function smartCacheStrategy(request){
cache.put(_request, responseClone); cache.put(_request, responseClone);
}); });
return response; return response;
}); }).catch(() => return _request);
} }
function nil(e){} function nil(e){}
} }
@ -128,7 +128,7 @@ function networkFirstStrategy(request){
network(request.clone && request.clone() || request) network(request.clone && request.clone() || request)
.then(done) .then(done)
.catch(error); .catch(error);
}); }).catch(() => return request);
function network(request){ function network(request){
return fetch(request) return fetch(request)