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) => {
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>
</ReactCSSTransitionGroup>
);
}

View file

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

View file

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

View file

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

View file

@ -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);
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);
});
let app = opener(this.state.path);
});
};
const data_fetch = (app) => {
if(app === 'editor'){
Files.cat(this.state.path).then((content) => {
this.setState({data: content, loading: false, opener: app});
this.setState({content: content || "", loading: false});
}).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');
});
this.setState({opener: 'download', loading: false});
}else{
notify.send(err, 'error');
}
});
}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');
});
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 {
<IDE needSaving={this.needSaving.bind(this)}
isSaving={this.state.isSaving}
onSave={this.save.bind(this)}
content={this.state.data || ''}
content={this.state.content}
url={this.state.url}
filename={this.state.filename}/>
</NgIf>
<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 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 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 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 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 cond={this.state.loading === true}>

View file

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

View file

@ -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,21 +30,25 @@ 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 (
<div style={{height: '100%'}}>
<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}>
<Fab onClick={this.save.bind(this)}><Icon name="save" style={{height: '100%', width: '100%'}}/></Fab>
</NgIf>
<NgIf cond={this.props.isSaving}>
<Fab><Icon name="loading_white" style={{height: '100%', width: '100%'}}/></Fab>
</NgIf>
</NgIf>
</ReactCSSTransitionGroup>
</div>
);

View file

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

View file

@ -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 (
<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){
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})
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});
}
}.bind(this), 80)
})
}, 100);
}
componentWillUnmount(){
window.clearInterval(this.state.id)
window.clearInterval(this.state.id);
}
render(){
return (
<div style={{background: '#313538', color: '#f1f1f1', boxShadow: 'rgba(0, 0, 0, 0.14) 2px 2px 2px 0px'}}>
<Container style={{padding: '9px 0', textAlign: 'center', color: '#f1f1f1', fontSize: '0.9em'}}>
<NgIf cond={this.props.hasOwnProperty('download')} style={{float: 'right', height: '1em'}}>
<div style={{float: 'right', height: '1em'}}>
<NgIf cond={!this.state.loading} style={{display: 'inline'}}>
<a href={this.props.download} download={this.props.title} onClick={this.onDownloadRequest.bind(this)}>
<a href={this.props.link} download={this.props.name} onClick={this.onDownloadRequest.bind(this)}>
<Icon name="download" style={{width: '15px', height: '15px'}} />
</a>
</NgIf>
<NgIf cond={this.state.loading} style={{display: 'inline'}}>
<Icon name="loading" style={{width: '15px', height: '15px'}} />
</NgIf>
</NgIf>
<span style={{letterSpacing: '0.3px'}}>{this.props.title}</span>
</Container>
</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
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) || '');
}

View file

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

View file

@ -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/'))

View file

@ -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)