a new approach for the ui

This commit is contained in:
Danny Coates 2018-10-24 19:07:10 -07:00
parent cc85486414
commit f0cfc19f8c
No known key found for this signature in database
GPG key ID: 4C442633C62E00CB
34 changed files with 2246 additions and 146 deletions

View file

@ -62,7 +62,10 @@ export default class Archive {
return true;
}
remove(index) {
this.files.splice(index, 1);
remove(file) {
const index = this.files.indexOf(file);
if (index > -1) {
this.files.splice(index, 1);
}
}
}

View file

@ -3,16 +3,16 @@ export default function(state, emitter) {
document.body.addEventListener('dragover', event => {
if (state.route === '/') {
event.preventDefault();
const files = document.querySelector('.uploadedFilesWrapper');
files.classList.add('uploadArea--noEvents');
// const files = document.querySelector('.uploadedFilesWrapper');
// files.classList.add('uploadArea--noEvents');
}
});
document.body.addEventListener('drop', event => {
if (state.route === '/' && !state.uploading) {
event.preventDefault();
document
.querySelector('.uploadArea')
.classList.remove('uploadArea--dragging');
// document
// .querySelector('.uploadArea')
// .classList.remove('uploadArea--dragging');
const files = Array.from(event.dataTransfer.files);

View file

@ -66,8 +66,11 @@ export default function(state, emitter) {
metrics.changedDownloadLimit(file);
});
emitter.on('removeUpload', async ({ index }) => {
state.archive.remove(index);
emitter.on('removeUpload', file => {
state.archive.remove(file);
if (state.archive.numFiles === 0) {
state.archive = null;
}
render();
});
@ -86,6 +89,7 @@ export default function(state, emitter) {
} catch (e) {
state.raven.captureException(e);
}
render();
});
emitter.on('cancel', () => {
@ -149,15 +153,26 @@ export default function(state, emitter) {
if (password) {
emitter.emit('password', { password, file: ownedFile });
}
const cancelBtn = document.getElementById('cancel-upload');
if (cancelBtn) {
cancelBtn.hidden = 'hidden';
}
if (document.querySelector('.page')) {
await delay(1000);
}
emitter.emit('pushState', `/share/${ownedFile.id}`);
state.animation = () => {
const x = document.querySelector('.foo');
const y = x.previousElementSibling;
x.animate(
[
{ transform: `translateY(-${y.getBoundingClientRect().height}px)` },
{ transform: 'translateY(0)' }
],
{
duration: 400,
easing: 'ease'
}
);
y.animate([{ opacity: 0 }, { opacity: 1 }], {
delay: 300,
duration: 100,
fill: 'both'
});
};
// emitter.emit('pushState', `/share/${ownedFile.id}`);
} catch (err) {
if (err.message === '0') {
//cancelled. do nothing
@ -176,6 +191,7 @@ export default function(state, emitter) {
state.password = '';
state.uploading = false;
state.transfer = null;
render();
}
});

View file

@ -1,28 +1,81 @@
@import './base.css';
@import './pages/share/share.css';
@import './pages/signin/signin.css';
@import './pages/uploads/uploads.css';
@import './pages/unsupported/unsupported.css';
@import './templates/archiveTile/archiveTile.css';
@import './templates/controlArea/controlArea.css';
@import './templates/downloadButton/downloadButton.css';
@import './templates/downloadPassword/downloadPassword.css';
@import './templates/file/file.css';
@import './templates/fileIcon/fileIcon.css';
@import './templates/fileList/fileList.css';
@import './templates/fileManager/fileManager.css';
@import './templates/footer/footer.css';
@import './templates/fxPromo/fxPromo.css';
@import './templates/header/header.css';
@import './templates/modal/modal.css';
@import './templates/okDialog/okDialog.css';
@import './templates/passwordInput/passwordInput.css';
@import './templates/popup/popup.css';
@import './templates/selectbox/selectbox.css';
@import './templates/setPasswordSection/setPasswordSection.css';
@import './templates/signupDialog/signupDialog.css';
@import './templates/signupPromo/signupPromo.css';
@import './templates/title/title.css';
@import './templates/uploadedFile/uploadedFile.css';
@import './templates/uploadedFileList/uploadedFileList.css';
@import './templates/userAccount/userAccount.css';
@import 'tailwindcss/preflight';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
a {
color: inherit;
text-decoration: none;
}
progress {
@apply bg-grey-light;
@apply rounded-sm;
@apply w-full;
@apply h-1;
}
progress::-moz-progress-bar {
@apply bg-blue;
@apply rounded-sm;
}
progress::-webkit-progress-bar {
@apply bg-grey-light;
@apply rounded-sm;
@apply w-full;
@apply h-1;
}
progress::-webkit-progress-value {
@apply bg-blue;
@apply rounded-sm;
}
.main {
@apply bg-blue-lightest;
min-height: calc(100vh - 6rem);
}
.header-logo {
background-image: url('../assets/send_logo.svg');
background-position: left;
background-repeat: no-repeat;
background-size: 2rem;
padding-left: 2.5rem;
text-decoration: none;
}
.feedback-link {
background-color: #000;
background-image: url('../assets/feedback.svg');
background-position: 0.125rem 0.25rem;
background-repeat: no-repeat;
background-size: 1.125rem;
color: #fff;
display: block;
font-size: 0.75rem;
line-height: 0.75rem;
padding: 0.375rem 0.375rem 0.375rem 1.25rem;
text-indent: 0.125rem;
white-space: nowrap;
}
.bg-shades {
background-color: rgba(0, 0, 0, 0.7);
}
@screen md {
.main {
@apply flex-1;
@apply self-center;
@apply bg-white;
@apply shadow-md;
@apply m-auto;
min-width: 30rem;
max-width: 60rem;
min-height: 30rem;
max-height: 38rem;
}
}

View file

@ -34,6 +34,15 @@ import User from './user';
window.appState = state;
window.appEmit = emitter.emit.bind(emitter);
let unsupportedReason = null;
emitter.on('render', () => {
if (state.animation) {
window.requestAnimationFrame(() => {
state.animation();
state.animation = null;
});
}
});
if (
// Firefox < 50
/firefox/i.test(navigator.userAgent) &&

55
app/routes.js Normal file
View file

@ -0,0 +1,55 @@
const choo = require('choo');
const html = require('choo/html');
const nanotiming = require('nanotiming');
const download = require('./ui/download');
const footer = require('./ui/footer');
const fxPromo = require('./ui/fxPromo');
const modal = require('./ui/modal');
const header = require('./ui/header');
nanotiming.disabled = true;
function banner(state, emit) {
if (state.promo && !state.route.startsWith('/unsupported/')) {
return fxPromo(state, emit);
}
}
function body(main) {
return function(state, emit) {
const b = html`<body class="flex flex-col font-sans bg-white md:h-screen md:bg-grey-lightest">
${state.modal && modal(state, emit)}
${banner(state, emit)}
${header(state, emit)}
${main(state, emit)}
${footer(state)}
</body>`;
if (state.layout) {
// server side only
return state.layout(state, b);
}
return b;
};
}
module.exports = function() {
const app = choo();
app.route('/', body(require('./ui/welcome')));
app.route('/download/:id', body(download));
app.route('/download/:id/:key', body(download));
app.route('/unsupported/:reason', body(require('./ui/unsupported')));
app.route('/legal', body(require('./ui/legal')));
app.route('/error', body(require('./ui/error')));
app.route('/blank', body(require('./ui/blank')));
app.route('/oauth', async function(state, emit) {
try {
await state.user.finishLogin(state.query.code, state.query.state);
emit('replaceState', '/');
} catch (e) {
emit('replaceState', '/error');
setTimeout(() => emit('render'));
}
});
app.route('*', body(require('./ui/notFound')));
return app;
};

View file

@ -1,30 +0,0 @@
/* global downloadMetadata */
const preview = require('../pages/preview');
const password = require('../pages/password');
function createFileInfo(state) {
return {
id: state.params.id,
secretKey: state.params.key,
nonce: downloadMetadata.nonce,
requiresPassword: downloadMetadata.pwd
};
}
module.exports = function(state, emit) {
if (!state.fileInfo) {
state.fileInfo = createFileInfo(state);
}
if (!state.transfer && !state.fileInfo.requiresPassword) {
emit('getMetadata');
}
if (state.transfer) {
return preview(state, emit);
}
if (state.fileInfo.requiresPassword && !state.fileInfo.password) {
return password(state, emit);
}
};

View file

@ -1,65 +0,0 @@
const choo = require('choo');
const html = require('choo/html');
const nanotiming = require('nanotiming');
const download = require('./download');
const footer = require('../templates/footer');
const fxPromo = require('../templates/fxPromo');
const modal = require('../templates/modal');
const header = require('../templates/header');
nanotiming.disabled = true;
module.exports = function() {
const app = choo();
function banner(state, emit) {
if (state.promo && !state.route.startsWith('/unsupported/')) {
return fxPromo(state, emit);
}
}
function modalDialog(state, emit) {
if (state.modal) {
return modal(state, emit);
}
}
function body(page) {
return function(state, emit) {
const b = html`<body>
${modalDialog(state, emit)}
${banner(state, emit)}
${header(state, emit)}
${page(state, emit)}
${footer(state)}
</body>`;
if (state.layout) {
// server side only
return state.layout(state, b);
}
return b;
};
}
app.route('/', body(require('../pages/welcome')));
app.route('/share/:id', body(require('../pages/share')));
app.route('/uploads', body(require('../pages/uploads')));
app.route('/download/:id', body(download));
app.route('/download/:id/:key', body(download));
app.route('/unsupported/:reason', body(require('../pages/unsupported')));
app.route('/legal', body(require('../pages/legal')));
app.route('/error', body(require('../pages/error')));
app.route('/blank', body(require('../pages/blank')));
app.route('/signin', body(require('../pages/signin')));
app.route('/oauth', async function(state, emit) {
try {
await state.user.finishLogin(state.query.code, state.query.state);
emit('replaceState', '/');
} catch (e) {
emit('replaceState', '/error');
setTimeout(() => emit('render'));
}
});
app.route('*', body(require('../pages/notFound')));
return app;
};

63
app/ui/account.js Normal file
View file

@ -0,0 +1,63 @@
const html = require('choo/html');
const itemClass =
'block p-2 text-grey-darkest hover:bg-blue hover:text-white cursor-pointer';
module.exports = function(state, emit) {
if (!state.capabilities.account) {
return null;
}
const user = state.user;
const menuItems = [];
if (user.loggedIn) {
menuItems.push(html`<li class="p-2 text-grey-dark">${user.email}</li>`);
menuItems.push(
html`<li><a class="${itemClass}" onclick=${logout}>${state.translate(
'logOut'
)}</a></li>`
);
} else {
menuItems.push(
html`<li class=""><a class="${itemClass}" onclick=${login}>${state.translate(
'signInMenuOption'
)}</a></li>`
);
}
return html`<div class="relative">
<input
type="image"
alt="${user.email}"
class="w-8 h-8 rounded-full text-white"
src="${user.avatar}"
onclick=${avatarClick}/>
<ul
id="accountMenu"
class="invisible list-reset absolute pin-t pin-r mt-10 pt-2 pb-2 bg-white shadow-md whitespace-no-wrap outline-none z-50"
onblur="${hideMenu}"
tabindex="-1">
${menuItems}
</ul>
</div>`;
function avatarClick(event) {
event.preventDefault();
const menu = document.getElementById('accountMenu');
menu.classList.toggle('invisible');
menu.focus();
}
function hideMenu(event) {
event.stopPropagation();
const menu = document.getElementById('accountMenu');
menu.classList.add('invisible');
}
function login(event) {
event.preventDefault();
emit('login');
}
function logout(event) {
event.preventDefault();
emit('logout');
}
};

41
app/ui/archiveList.js Normal file
View file

@ -0,0 +1,41 @@
const html = require('choo/html');
const assets = require('../../common/assets');
const { list } = require('../utils');
const archiveTile = require('./archiveTile');
function intro(state) {
return html`
<article class="flex flex-col items-center justify-center bg-white border border-grey-light p-2">
<p class="text-center">
<div class="font-semibold">${state.translate('uploadPageHeader')}</div>
<div class="italic">${state.translate('pageHeaderCredits')}</div>
</p>
<img src="${assets.get('illustration_download.svg')}"/>
<p class="m-4 max-w-sm text-sm font-light">${state.translate(
'uploadPageExplainer'
)}</p>
</article>`;
}
module.exports = function(state, emit) {
const archives = state.storage.files.map(archive =>
archiveTile(state, emit, archive)
);
let wip = '';
if (state.uploading) {
wip = archiveTile.uploading(state, emit);
} else if (state.archive) {
wip = archiveTile.wip(state, emit);
} else {
wip = archiveTile.empty(state, emit);
}
archives.reverse();
if (archives.length < 1) {
archives.push(intro(state));
}
return html`
<section class="relative h-full w-full px-6">
<div class="pt-4 pb-2">${wip}</div>
${list(archives, 'list-reset h-full overflow-y-scroll foo', 'py-2')}
</section>`;
};

260
app/ui/archiveTile.js Normal file
View file

@ -0,0 +1,260 @@
const html = require('choo/html');
const raw = require('choo/html/raw');
const assets = require('../../common/assets');
const { bytes, copyToClipboard, list, percent, timeLeft } = require('../utils');
const expiryOptions = require('./expiryOptions');
function expiryInfo(translate, archive) {
const l10n = timeLeft(archive.expiresAt - Date.now());
return raw(
translate('frontPageExpireInfo', {
downloadCount: translate('downloadCount', {
num: archive.dlimit - archive.dtotal
}),
timespan: translate(l10n.id, l10n)
})
);
}
function fileInfo(file, action) {
return html`
<article class="flex flex-row items-start p-3">
<img class="" src="${assets.get('blue_file.svg')}"/>
<p class="ml-3 w-full">
<h1 class="text-base font-semibold">${file.name}</h1>
<div class="text-sm font-light">${bytes(file.size)}</div>
<div class="hidden">${file.type}</div>
</p>
${action}
</article>`;
}
function archiveDetails(translate, archive) {
if (archive.manifest.files.length > 1) {
return html`
<details class="w-full">
<summary>${translate('fileCount', {
num: archive.manifest.files.length
})}</summary>
${list(archive.manifest.files.map(f => fileInfo(f)), 'list-reset')}
</details>`;
}
}
module.exports = function(state, emit, archive) {
return html`
<article
id="${archive.id}"
class="flex flex-col items-start border border-grey-light bg-white p-3">
<p class="w-full">
<img class="float-left mr-3" src="${assets.get('blue_file.svg')}"/>
<input
type="image"
class="float-right self-center text-white"
alt="Delete"
src="${assets.get('close-16.svg')}"
onclick=${del}/>
<h1 class="text-base font-semibold">${archive.name}</h1>
<div class="text-sm font-light">${bytes(archive.size)}</div>
</p>
<div class="text-xs text-grey-dark w-full mt-2 mb-2">
${expiryInfo(state.translate, archive)}
</div>
${archiveDetails(state.translate, archive)}
<hr class="w-full border-t">
<button
class="text-blue self-end"
onclick=${copy}>
<img src="${assets.get('copy-16.svg')}" class="mr-1"/>
${state.translate('copyUrlHover')}
</button>
</article>`;
function copy(event) {
event.stopPropagation();
copyToClipboard(archive.url);
}
function del(event) {
event.stopPropagation();
emit('delete', { file: archive, location: 'success-screen' });
}
};
module.exports.wip = function(state, emit) {
return html`
<article class="relative flex flex-col bg-white border border-grey-light p-2 z-20">
${list(state.archive.files.map(f => fileInfo(f, remove(f))), 'list-reset')}
<div class="border border-dashed border-blue-light mb-2">
<input
id="file-upload"
class="hidden"
type="file"
multiple
onchange=${add} />
<label
for="file-upload"
class="flex flex-row items-center w-full h-full text-blue p-2"
title="${state.translate('addFilesButton')}">
<img src="${assets.get('addfile.svg')}" class="w-6 h-6 mr-2"/>
${state.translate('addFilesButton')}
</label>
</div>
${expiryOptions(state, emit)}
<button
class="border rounded bg-blue text-white mt-2 py-2 px-6"
title="${state.translate('uploadFilesButton')}"
onclick=${upload}>
${state.translate('uploadFilesButton')}
</button>
</article>`;
function upload(event) {
event.preventDefault();
event.target.disabled = true;
if (!state.uploading) {
emit('upload', {
type: 'click',
dlimit: state.downloadCount || 1,
password: state.password
});
}
}
function add(event) {
event.preventDefault();
const newFiles = Array.from(event.target.files);
emit('addFiles', { files: newFiles });
}
function remove(file) {
return html`
<input
type="image"
class="self-center text-white"
alt="Delete"
src="${assets.get('close-16.svg')}"
onclick=${del}/>`;
function del(event) {
event.stopPropagation();
emit('removeUpload', file);
}
}
};
module.exports.uploading = function(state, emit) {
const progress = state.transfer.progressRatio;
const progressPercent = percent(progress);
const archive = state.archive;
return html`
<article
id="${archive.id}"
class="relative z-20 flex flex-col items-start border border-grey-light bg-white p-3">
<p class="w-full">
<img class="float-left mr-3" src="${assets.get('blue_file.svg')}"/>
<h1 class="text-base font-semibold">${archive.name}</h1>
<div class="text-sm font-light">${bytes(archive.size)}</div>
</p>
<div class="text-xs text-grey-dark w-full mt-2 mb-2">
${expiryInfo(state.translate, {
dlimit: state.downloadCount || 1,
dtotal: 0,
expiresAt: Date.now() + 500 + state.timeLimit * 1000
})}
</div>
<div class="text-blue text-sm font-medium">${progressPercent}</div>
<progress class="mb-1" value="${progress}">${progressPercent}</progress>
<button
class="text-blue self-end"
onclick=${cancel}>
${state.translate('uploadingPageCancel')}
</button>
</article>`;
function cancel(event) {
event.stopPropagation();
event.target.disabled = true;
emit('cancel');
}
};
module.exports.empty = function(state, emit) {
return html`
<article class="flex flex-col items-center justify-center border border-dashed border-blue-light p-8">
<div class="p-1">${state.translate('uploadDropDragMessage')}</div>
<input
id="file-upload"
class="hidden"
type="file"
multiple
onchange=${add} />
<label
for="file-upload"
class="border rounded bg-blue text-white py-2 px-6"
title="${state.translate('addFilesButton')}">
${state.translate('addFilesButton')}
</label>
</article>`;
function add(event) {
event.preventDefault();
const newFiles = Array.from(event.target.files);
emit('addFiles', { files: newFiles });
}
};
module.exports.preview = function(state, emit) {
const archive = state.fileInfo;
return html`
<article class="relative flex flex-col bg-white border border-grey-light p-2 z-20">
<p class="w-full mb-4">
<img class="float-left mr-3" src="${assets.get('blue_file.svg')}"/>
<h1 class="text-base font-semibold">${archive.name}</h1>
<div class="text-sm font-light">${bytes(archive.size)}</div>
</p>
${archiveDetails(state.translate, archive)}
<hr class="w-full border-t">
<button
class="border rounded bg-blue text-white mt-2 py-2 px-6"
title="${state.translate('downloadButtonLabel')}"
onclick=${download}>
${state.translate('downloadButtonLabel')}
</button>
</article>`;
function download(event) {
event.preventDefault();
event.target.disabled = true;
emit('download', archive);
}
};
module.exports.downloading = function(state, emit) {
const archive = state.fileInfo;
const progress = state.transfer.progressRatio;
const progressPercent = percent(progress);
return html`
<article class="relative flex flex-col bg-white border border-grey-light p-2 z-20">
<p class="w-full mb-4">
<img class="float-left mr-3" src="${assets.get('blue_file.svg')}"/>
<h1 class="text-base font-semibold">${archive.name}</h1>
<div class="text-sm font-light">${bytes(archive.size)}</div>
</p>
<div class="text-blue text-sm font-medium">${progressPercent}</div>
<progress class="" value="${progress}">${progressPercent}</progress>
<button
class="border rounded bg-grey-dark text-white mt-2 py-2 px-6"
title="${state.translate('downloadCancel')}"
onclick=${cancel}>
${state.translate('downloadCancel')}
</button>
</article>`;
function cancel(event) {
event.preventDefault();
event.target.disabled = true;
emit('download', archive);
}
};

5
app/ui/blank.js Normal file
View file

@ -0,0 +1,5 @@
const html = require('choo/html');
module.exports = function() {
return html`<main class="main"></main>`;
};

103
app/ui/download.js Normal file
View file

@ -0,0 +1,103 @@
/* global downloadMetadata */
const html = require('choo/html');
const archiveTile = require('./archiveTile');
function password(state, emit) {
const fileInfo = state.fileInfo;
const invalid = fileInfo.password === null;
const visible = invalid ? 'visible' : 'invisible';
const invalidBtn = invalid ? '' : '';
const div = html`
<div class="">
<label
class="${visible}"
for="password-input">
${state.translate('passwordTryAgain')}
</label>
<form class="" onsubmit=${checkPassword} data-no-csrf>
<input id="password-input"
class=""
maxlength="64"
autocomplete="off"
placeholder="${state.translate('unlockInputPlaceholder')}"
oninput=${inputChanged}
type="password" />
<input type="submit"
id="password-btn"
class="${invalidBtn}"
value="${state.translate('unlockInputLabel')}"/>
</form>
</div>`;
if (!(div instanceof String)) {
setTimeout(() => document.getElementById('password-input').focus());
}
function inputChanged() {
//TODO
const input = document.querySelector('.passwordForm__error');
input.classList.remove('visible');
const btn = document.getElementById('password-btn');
btn.classList.remove('unlockBtn--error');
}
function checkPassword(event) {
event.preventDefault();
const password = document.getElementById('password-input').value;
if (password.length > 0) {
document.getElementById('password-btn').disabled = true;
state.fileInfo.url = window.location.href;
state.fileInfo.password = password;
emit('getMetadata');
}
return false;
}
return div;
}
function createFileInfo(state) {
return {
id: state.params.id,
secretKey: state.params.key,
nonce: downloadMetadata.nonce,
requiresPassword: downloadMetadata.pwd
};
}
module.exports = function(state, emit) {
let content = '';
if (!state.fileInfo) {
state.fileInfo = createFileInfo(state);
}
if (!state.transfer && !state.fileInfo.requiresPassword) {
emit('getMetadata');
}
if (state.transfer) {
switch (state.transfer.state) {
case 'downloading':
case 'decrypting':
content = archiveTile.downloading(state, emit);
break;
case 'complete':
content = ''; //TODO
break;
default:
content = archiveTile.preview(state, emit);
}
} else if (state.fileInfo.requiresPassword && !state.fileInfo.password) {
content = password(state, emit);
}
return html`
<main class="main">
<section class="relative h-full w-full my-4 px-6">
${content}
</section>
</main>`;
};

18
app/ui/error.js Normal file
View file

@ -0,0 +1,18 @@
const html = require('choo/html');
const assets = require('../../common/assets');
module.exports = function(state) {
return html`
<main class="main">
<div class="flex flex-col items-center text-center bg-white m-6 p-4 border border-grey-light md:border-none md:px-12">
<h1 class="">${state.translate('errorPageHeader')}</h1>
<img class="my-8" src="${assets.get('illustration_error.svg')}"/>
<p class="">
${state.translate('uploadPageExplainer')}
</p>
<a class="text-blue mt-4" href="/">
${state.translate('sendYourFilesLink')}
</a>
</div>
</main>`;
};

73
app/ui/expiryOptions.js Normal file
View file

@ -0,0 +1,73 @@
/* globals DEFAULTS */
const html = require('choo/html');
const raw = require('choo/html/raw');
const { secondsToL10nId } = require('../utils');
const selectbox = require('./selectbox');
const signupDialog = require('./signupDialog');
module.exports = function(state, emit) {
const el = html`
<div class="">
${raw(
state.translate('frontPageExpireInfo', {
downloadCount: '<select id="dlCount"></select>',
timespan: '<select id="timespan"></select>'
})
)}
</div>`;
if (el.__encoded) {
// we're rendering on the server
return el;
}
const counts = DEFAULTS.DOWNLOAD_COUNTS.filter(
i => state.capabilities.account || i <= state.user.maxDownloads
);
const dlCountSelect = el.querySelector('#dlCount');
el.replaceChild(
selectbox(
state.downloadCount || 1,
counts,
num => state.translate('downloadCount', { num }),
value => {
const max = state.user.maxDownloads;
if (value > max) {
state.modal = signupDialog();
value = max;
}
state.downloadCount = value;
emit('render');
}
),
dlCountSelect
);
const expires = DEFAULTS.EXPIRE_TIMES_SECONDS.filter(
i => state.capabilities.account || i <= state.user.maxExpireSeconds
);
const timeSelect = el.querySelector('#timespan');
el.replaceChild(
selectbox(
state.timeLimit || 86400,
expires,
num => {
const l10n = secondsToL10nId(num);
return state.translate(l10n.id, l10n);
},
value => {
const max = state.user.maxExpireSeconds;
if (value > max) {
state.modal = signupDialog();
value = max;
}
state.timeLimit = value;
emit('render');
}
),
timeSelect
);
return el;
};

52
app/ui/footer.js Normal file
View file

@ -0,0 +1,52 @@
const html = require('choo/html');
const version = require('../../package.json').version;
const { browserName } = require('../utils');
module.exports = function(state) {
const browser = browserName();
const feedbackUrl = `https://qsurvey.mozilla.com/s3/txp-firefox-send?ver=${version}&browser=${browser}`;
const footer = html`<footer class="flex-none m-2 font-medium text-xs text-grey-dark">
<ul class="list-reset flex flex-col md:flex-row items-start md:items-center md:justify-end">
<li class="m-2"><a
href="https://www.mozilla.org/about/legal">
${state.translate('footerLinkLegal')}
</a></li>
<li class="m-2"><a
href="https://testpilot.firefox.com/about">
${state.translate('footerLinkAbout')}
</a></li>
<li class="m-2"><a
href="/legal">
${state.translate('footerLinkTerms')}
</a></li>
<li class="m-2"><a
href="https://www.mozilla.org/privacy/websites/#cookies">
${state.translate('footerLinkCookies')}
</a></li>
<li class="m-2"><a
href="https://www.mozilla.org/about/legal/report-infringement/">
${state.translate('reportIPInfringement')}
</a></li>
<li class="m-2"><a
href="https://github.com/mozilla/send">GitHub
</a></li>
<li class="m-2"><a
href="https://twitter.com/FxTestPilot">Twitter
</a></li>
<li class="m-2"><a href="${feedbackUrl}"
rel="noreferrer noopener"
class="feedback-link"
alt="Feedback"
target="_blank">
${state.translate('siteFeedback')}
</a></li>
</ul>
</footer>`;
// HACK
// We only want to render this once because we
// toggle the targets of the links with utils/openLinksInNewTab
footer.isSameNode = function(target) {
return target && target.nodeName && target.nodeName === 'FOOTER';
};
return footer;
};

19
app/ui/fxPromo.js Normal file
View file

@ -0,0 +1,19 @@
const html = require('choo/html');
const assets = require('../../common/assets');
module.exports = function() {
return html`
<div class="flex-none flex flex-row items-center content-center justify-center text-sm bg-grey-light text-grey-darkest h-12 px-4">
<div class="flex items-center mx-auto">
<img
src="${assets.get('firefox_logo-only.svg')}"
class="w-6"
alt="Firefox"/>
<span class="ml-3">Send is brought to you by the all-new Firefox.
<a
class="text-blue"
href="https://www.mozilla.org/firefox/new/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com">Download Firefox now </a>
</span>
</div>
</div>`;
};

21
app/ui/header.js Normal file
View file

@ -0,0 +1,21 @@
const html = require('choo/html');
const account = require('./account');
module.exports = function(state, emit) {
const header = html`
<header class="flex-none flex flex-row items-center bg-white w-full px-4 h-12 shadow-md justify-between">
<a
class="header-logo"
href="/">
<h1 class="text-black font-normal">Firefox <b>Send</b></h1>
</a>
${account(state, emit)}
</header>`;
// HACK
// We only want to render this once because we
// toggle the targets of the links with utils/openLinksInNewTab
// header.isSameNode = function(target) {
// return target && target.nodeName && target.nodeName === 'HEADER';
// };
return header;
};

33
app/ui/legal.js Normal file
View file

@ -0,0 +1,33 @@
const html = require('choo/html');
const raw = require('choo/html/raw');
module.exports = function(state) {
return html`
<main class="main">
<div class="flex flex-col items-center bg-white m-6 p-4 border border-grey-light md:border-none md:px-12">
<h1 class="">${state.translate('legalHeader')}</h1>
${raw(
replaceLinks(state.translate('legalNoticeTestPilot'), [
'https://testpilot.firefox.com/terms',
'https://testpilot.firefox.com/privacy',
'https://testpilot.firefox.com/experiments/send'
])
)}
${raw(
replaceLinks(state.translate('legalNoticeMozilla'), [
'https://www.mozilla.org/privacy/websites/',
'https://www.mozilla.org/about/legal/terms/mozilla/'
])
)}
</div>
</main>`;
};
function replaceLinks(str, urls) {
let i = 0;
const s = str.replace(
/<a>([^<]+)<\/a>/g,
(m, v) => `<a class="text-blue" href="${urls[i++]}">${v}</a>`
);
return `<p class="m-4">${s}</p>`;
}

15
app/ui/modal.js Normal file
View file

@ -0,0 +1,15 @@
const html = require('choo/html');
module.exports = function(state, emit) {
return html`
<div class="fixed pin flex items-center justify-center overflow-hidden z-40 bg-shades" onclick=${close}>
<div class="rounded max-w-md bg-white" onclick=${e => e.stopPropagation()}>
${state.modal(state, emit, close)}
</div>
</div>`;
function close(event) {
state.modal = null;
emit('render');
}
};

18
app/ui/notFound.js Normal file
View file

@ -0,0 +1,18 @@
const html = require('choo/html');
const assets = require('../../common/assets');
module.exports = function(state) {
return html`
<main class="main">
<div class="flex flex-col items-center text-center bg-white m-6 p-4 border border-grey-light md:border-none md:px-12">
<h1 class="text-pink-dark">${state.translate('expiredPageHeader')}</h1>
<img src="${assets.get('illustration_expired.svg')}" id="expired-img">
<p class="">
${state.translate('uploadPageExplainer')}
</p>
<a class="text-blue mt-4" href="/">
${state.translate('sendYourFilesLink')}
</a>
</div>
</main>`;
};

25
app/ui/selectbox.js Normal file
View file

@ -0,0 +1,25 @@
const html = require('choo/html');
module.exports = function(selected, options, translate, changed) {
let x = selected;
return html`
<select class="appearance-none cursor-pointer border rounded-sm bg-blue-lightest p-1" onchange=${choose}>
${options.map(
i =>
html`<option value="${i}" ${
i === selected ? 'selected' : ''
}>${translate(i)}</option>`
)}
</select>`;
function choose(event) {
const target = event.target;
const value = +target.value;
if (x !== value) {
x = value;
changed(value);
}
}
};

56
app/ui/signupDialog.js Normal file
View file

@ -0,0 +1,56 @@
/* global LIMITS */
const html = require('choo/html');
const { bytes } = require('../utils');
module.exports = function() {
return function(state, emit, close) {
return html`
<div class="flex flex-col p-4">
<p class="p-8">
${state.translate('accountBenefitTitle')}
<ul class="my-2">
<li>${state.translate('accountBenefitLargeFiles', {
size: bytes(LIMITS.MAX_FILE_SIZE)
})}</li>
<li>${state.translate('accountBenefitExpiry')}</li>
<li>${state.translate('accountBenefitSync')}</li>
</ul>
</p>
<form
onsubmit=${submitEmail}
data-no-csrf>
<input
id="email-input"
type="text"
class="border rounded w-full px-2 text-lg text-grey-darker leading-loose"
placeholder=${state.translate('emailEntryPlaceholder')}/>
<input
class="hidden"
id="email-submit"
type="submit"/>
</form>
<label class="border rounded bg-blue text-white leading-loose text-center my-2" for="email-submit">
${state.translate('signInMenuOption')}
</label>
<button
class=""
title="${state.translate('deletePopupCancel')}"
onclick=${close}>${state.translate('deletePopupCancel')}
</button>
</div>`;
function submitEmail(event) {
event.preventDefault();
const el = document.getElementById('email-input');
const email = el.value;
if (email) {
// just check if it's the right shape
const a = email.split('@');
if (a.length === 2 && a.every(s => s.length > 0)) {
return emit('login', email);
}
}
el.value = '';
}
};
};

65
app/ui/unsupported.js Normal file
View file

@ -0,0 +1,65 @@
const html = require('choo/html');
const assets = require('../../common/assets');
module.exports = function(state) {
let strings = {};
let why = '';
let url = '';
let buttonAction = '';
if (state.params.reason !== 'outdated') {
strings = unsupportedStrings(state);
why = html`
<a
class="text-blue" href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-is-my-browser-not-supported">
${state.translate('notSupportedLink')}
</a>`;
url =
'https://www.mozilla.org/firefox/new/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com';
buttonAction = html`
<p class="ml-3 font-bold">
Firefox<br><span class="font-light">${strings.button}</span>
</p>`;
} else {
strings = outdatedStrings(state);
url = 'https://support.mozilla.org/kb/update-firefox-latest-version';
buttonAction = html`
<p class="ml-3">
${strings.button}
</p>`;
}
return html`
<main class="main">
<div class="flex flex-col items-center bg-white m-6 p-4 border border-grey-light md:border-none md:px-12">
<h1 class="text-center">${strings.header}</h1>
<p class="my-16">
${strings.description}
</p>
${why}
<a href="${url}" class="border border-green-light rounded bg-green flex items-center justify-center text-2xl text-white my-16 p-2">
<img
src="${assets.get('firefox_logo-only.svg')}"
class="w-16"
alt="Firefox"/>
${buttonAction}
</a>
</div>
</main>`;
};
function outdatedStrings(state) {
return {
header: state.translate('notSupportedHeader'),
description: state.translate('notSupportedOutdatedDetail'),
button: state.translate('updateFirefox')
};
}
function unsupportedStrings(state) {
return {
header: state.translate('notSupportedHeader'),
description: state.translate('notSupportedDetail'),
button: state.translate('downloadFirefoxButtonSub')
};
}

6
app/ui/welcome.js Normal file
View file

@ -0,0 +1,6 @@
const html = require('choo/html');
const archiveList = require('./archiveList');
module.exports = function(state, emit) {
return html`<main class="main">${archiveList(state, emit)}</main>`;
};

View file

@ -1,3 +1,4 @@
const html = require('choo/html');
const b64 = require('base64-js');
function arrayToB64(array) {
@ -182,6 +183,48 @@ async function streamToArrayBuffer(stream, size) {
return result.buffer;
}
function list(items, ulStyle = '', liStyle = '') {
const lis = items.map(i => html`<li class="${liStyle}">${i}</li>`);
return html`<ul class="${ulStyle}">${lis}</ul>`;
}
function secondsToL10nId(seconds) {
if (seconds < 3600) {
return { id: 'timespanMinutes', num: Math.floor(seconds / 60) };
} else if (seconds < 86400) {
return { id: 'timespanHours', num: Math.floor(seconds / 3600) };
} else {
return { id: 'timespanDays', num: Math.floor(seconds / 86400) };
}
}
function timeLeft(milliseconds) {
const minutes = Math.floor(milliseconds / 1000 / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days >= 1) {
return {
id: 'expiresDaysHoursMinutes',
days,
hours: hours % 24,
minutes: minutes % 60
};
}
if (hours >= 1) {
return {
id: 'expiresHoursMinutes',
hours,
minutes: minutes % 60
};
} else if (hours === 0) {
if (minutes === 0) {
return { id: 'expiresMinutes', minutes: '< 1' };
}
return { id: 'expiresMinutes', minutes };
}
return null;
}
module.exports = {
fadeOut,
delay,
@ -196,5 +239,8 @@ module.exports = {
isFile,
openLinksInNewTab,
browserName,
streamToArrayBuffer
streamToArrayBuffer,
list,
secondsToL10nId,
timeLeft
};