add error handling, toasts, and loading spinner

This commit is contained in:
Nolan Lawson 2018-01-14 11:22:57 -08:00
parent 9d9e6716d5
commit ee1251467a
18 changed files with 298 additions and 13 deletions

41
package-lock.json generated
View file

@ -1321,6 +1321,11 @@
"stream-shift": "1.0.0" "stream-shift": "1.0.0"
} }
}, },
"eases-jsnext": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/eases-jsnext/-/eases-jsnext-1.0.10.tgz",
"integrity": "sha512-1bO1+FIuqtOZpcyoIJuTnw8PU9X+RHHA248mZ1m+CPiiKFGCiNLWecITlhO4DXe7whZmBoJyfKwUoMW0KK5mNw=="
},
"ecc-jsbn": { "ecc-jsbn": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz",
@ -6779,6 +6784,11 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-1.51.0.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-1.51.0.tgz",
"integrity": "sha512-lqa9eAZ4ZQLMWsoyynAogUtib7HhHnrJJaS93uRgZU5cfXquBVR+FkKVK41LdlwffmOfOjbUin6pT8e/LZUwjA==" "integrity": "sha512-lqa9eAZ4ZQLMWsoyynAogUtib7HhHnrJJaS93uRgZU5cfXquBVR+FkKVK41LdlwffmOfOjbUin6pT8e/LZUwjA=="
}, },
"svelte-extras": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/svelte-extras/-/svelte-extras-1.6.0.tgz",
"integrity": "sha512-0yzXHJdnaX3+KiLrDu9Hl6V7+idfKrUkYqhpbdnxCEJos2FSxtpos6cjAt+A2vVrdcNjFqtXYs6xS+rFWeg1yA=="
},
"svelte-loader": { "svelte-loader": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/svelte-loader/-/svelte-loader-2.3.3.tgz", "resolved": "https://registry.npmjs.org/svelte-loader/-/svelte-loader-2.3.3.tgz",
@ -6788,6 +6798,37 @@
"tmp": "0.0.31" "tmp": "0.0.31"
} }
}, },
"svelte-transitions": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/svelte-transitions/-/svelte-transitions-1.1.1.tgz",
"integrity": "sha1-AaLpVPOnTXH8dtOn3Sn90VVRCBA=",
"requires": {
"svelte-transitions-fade": "1.0.0",
"svelte-transitions-fly": "1.0.2",
"svelte-transitions-slide": "1.0.0"
}
},
"svelte-transitions-fade": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svelte-transitions-fade/-/svelte-transitions-fade-1.0.0.tgz",
"integrity": "sha1-2+FSDfH1tTcL1hr+/Gfy0v/2MwM="
},
"svelte-transitions-fly": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/svelte-transitions-fly/-/svelte-transitions-fly-1.0.2.tgz",
"integrity": "sha1-CP02aUG0uSmpL5Y1uQJDpYbCuNc=",
"requires": {
"eases-jsnext": "1.0.10"
}
},
"svelte-transitions-slide": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svelte-transitions-slide/-/svelte-transitions-slide-1.0.0.tgz",
"integrity": "sha1-FQ3Zy455+p4vJQ4ZjH1plgvyGZQ=",
"requires": {
"eases-jsnext": "1.0.10"
}
},
"svgo": { "svgo": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-0.7.2.tgz", "resolved": "https://registry.npmjs.org/svgo/-/svgo-0.7.2.tgz",

View file

@ -34,7 +34,9 @@
"serve-static": "^1.13.1", "serve-static": "^1.13.1",
"style-loader": "^0.19.1", "style-loader": "^0.19.1",
"svelte": "^1.50.0", "svelte": "^1.50.0",
"svelte-extras": "^1.6.0",
"svelte-loader": "^2.3.3", "svelte-loader": "^2.3.3",
"svelte-transitions": "^1.1.1",
"uglifyjs-webpack-plugin": "^1.1.5", "uglifyjs-webpack-plugin": "^1.1.5",
"url-search-params": "^0.10.0", "url-search-params": "^0.10.0",
"webpack": "^3.10.0", "webpack": "^3.10.0",

View file

@ -0,0 +1,61 @@
<div class="loading-container">
{{#if show}}
<div transition:fade class="loading-mask">
<svg>
<use xlink:href="#fa-spinner" />
</svg>
</div>
{{/if}}
</div>
<style>
.loading-container {
left: 0;
right: 0;
top: 0;
bottom: 0;
position: fixed;
pointer-events: none;
z-index: 100;
}
.loading-mask {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--mask-bg);
opacity: 0.6;
pointer-events: auto;
}
svg {
width: 64px;
height: 64px;
fill: var(--mask-svg-fill);
animation: spin 2s infinite linear;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(90deg);
}
50% {
transform: rotate(180deg);
}
75% {
transform: rotate(270deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<script>
import { fade } from 'svelte-transitions'
export default {
transitions: { fade }
}
</script>

View file

@ -0,0 +1,96 @@
<div class="toast-modal {{shown ? 'shown' : ''}}">
<div class="toast-container">
{{text}}
</div>
</div>
<style>
.toast-modal {
position: fixed;
bottom: 40px;
left: 0;
right: 0;
opacity: 0;
transition: opacity 333ms linear;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
.toast-container {
max-width: 600px;
max-height: 20vh;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
border: 2px solid var(--toast-border);
background: var(--toast-bg);
border-radius: 5px;
margin: 0 40px;
padding: 20px;
font-size: 1.3em;
color: var(--toast-text);
}
.toast-modal.shown {
opacity: 1;
}
@media (max-width: 767px) {
.toast-container {
max-width: 80vw;
}
}
</style>
<script>
import { splice, push } from 'svelte-extras'
const TIME_TO_SHOW_TOAST = 5000
const DELAY_BETWEEN_TOASTS = 1000
export default {
oncreate () {
this._queue = Promise.resolve()
this.observe('messages', (messages) => {
console.log('messages', messages)
if (messages.length) {
this.onNewToast(messages[0])
this.splice('messages', 0, 1)
}
})
},
ondestroy () {
},
data: () => ({
text: '',
shown: false,
messages: []
}),
methods: {
push,
splice,
say(text) {
this.push('messages', text)
},
onNewToast(text) {
this._queue = this._queue.then(() => {
this.set({
'text': text,
shown: true
})
return new Promise(resolve => {
setTimeout(resolve, TIME_TO_SHOW_TOAST)
})
}).then(() => {
this.set({
shown: false
})
return new Promise(resolve => {
setTimeout(resolve, DELAY_BETWEEN_TOASTS)
})
})
}
}
}
</script>

18
routes/_utils/toast.js Normal file
View file

@ -0,0 +1,18 @@
import Toast from '../_components/Toast.html'
let toast
if (process.browser) {
toast = new Toast({
target: document.querySelector('#toast')
})
if (process.env.NODE_ENV !== 'production') {
window.toast = toast // for debugging
}
} else {
toast = {
say: () => {}
}
}
export { toast }

View file

@ -4,8 +4,15 @@
<style> <style>
ul { ul {
list-style: none; list-style: none;
width: 80%; width: 100%;
border: 1px solid var(--settings-list-item-border); border: 1px solid var(--settings-list-item-border);
margin: 20px auto; margin: 20px auto;
} }
@media (min-width: 768px) {
ul {
max-width: 80%;
}
}
</style> </style>

View file

@ -13,16 +13,15 @@
<style> <style>
ul { ul {
margin: 0; margin: 5px 10px;
padding: 0; padding: 0;
list-style: none; list-style: none;
display: flex;
align-items: center;
} }
li { li {
margin: 10px 0; margin: 5px 0;
font-size: 1em; font-size: 1em;
display: inline-block;
} }
li::after { li::after {

View file

@ -68,7 +68,7 @@
justify-content: right; justify-content: right;
} }
.instance-actions button { .instance-actions button {
margin: 0 20px; margin: 0 5px;
flex-basis: 100%; flex-basis: 100%;
} }
</style> </style>

View file

@ -6,6 +6,8 @@
<SettingsLayout page='settings/instances/add' label="Add an Instance"> <SettingsLayout page='settings/instances/add' label="Add an Instance">
<h1>Add an Instance</h1> <h1>Add an Instance</h1>
<LoadingMask show="{{loading}}"/>
{{#if $isUserLoggedIn}} {{#if $isUserLoggedIn}}
<p>Connect to an instance to log in.</p> <p>Connect to an instance to log in.</p>
{{else}} {{else}}
@ -15,7 +17,7 @@
<form class="add-new-instance" on:submit='onSubmit(event)'> <form class="add-new-instance" on:submit='onSubmit(event)'>
<label for="instanceInput">Instance name:</label> <label for="instanceInput">Instance name:</label>
<input type="text" id="instanceInput" bind:value='$instanceNameInSearch' placeholder=''> <input type="text" id="instanceInput" bind:value='$instanceNameInSearch' placeholder=''>
<button class="primary" type="submit" id="submitButton">Add instance</button> <button class="primary" type="submit" id="submitButton" disabled="{{!$instanceNameInSearch}}">Add instance</button>
</form> </form>
{{#if !$isUserLoggedIn}} {{#if !$isUserLoggedIn}}
@ -53,6 +55,9 @@
import { store } from '../../_utils/store' import { store } from '../../_utils/store'
import { goto } from 'sapper/runtime.js' import { goto } from 'sapper/runtime.js'
import { switchToTheme } from '../../_utils/themeEngine' import { switchToTheme } from '../../_utils/themeEngine'
import { toast } from '../../_utils/toast'
import LoadingMask from '../../_components/LoadingMask'
import { fade } from 'svelte-transitions'
const REDIRECT_URI = (typeof location !== 'undefined' ? const REDIRECT_URI = (typeof location !== 'undefined' ?
location.origin : 'https://pinafore.social') + '/settings/instances/add' location.origin : 'https://pinafore.social') + '/settings/instances/add'
@ -70,17 +75,37 @@
}, },
components: { components: {
Layout, Layout,
SettingsLayout SettingsLayout,
LoadingMask
}, },
store: () => store, store: () => store,
transitions: {
fade
},
methods: { methods: {
onSubmit: async function(event) { onSubmit: async function(event) {
event.preventDefault() event.preventDefault()
this.set({loading: true})
try {
await this.redirectToOauth()
} catch (err) {
if (process.env.NODE_ENV !== 'production') {
console.error(err)
}
toast.say(`Error: ${err.message || err.name}. Is this a valid Mastodon instance?`)
} finally {
this.set({loading: false})
}
},
redirectToOauth: async function() {
let instanceName = this.store.get('instanceNameInSearch') let instanceName = this.store.get('instanceNameInSearch')
let loggedInInstances = this.store.get('loggedInInstances')
instanceName = instanceName.replace(/^https?:\/\//, '').replace('/$', '') instanceName = instanceName.replace(/^https?:\/\//, '').replace('/$', '')
// TODO: show toast error if you're already logged into this instance if (Object.keys(loggedInInstances).includes(instanceName)) {
toast.say(`You've already logged in to ${instanceName}`)
return
}
let instanceData = await registerApplication(instanceName, REDIRECT_URI) let instanceData = await registerApplication(instanceName, REDIRECT_URI)
// TODO: handle error
this.store.set({ this.store.set({
currentRegisteredInstanceName: instanceName, currentRegisteredInstanceName: instanceName,
currentRegisteredInstance: instanceData currentRegisteredInstance: instanceData
@ -94,6 +119,16 @@
document.location.href = oauthUrl document.location.href = oauthUrl
}, },
onReceivedOauthCode: async function(code) { onReceivedOauthCode: async function(code) {
try {
this.set({loading: true})
await this.registerNewInstance(code)
} catch (err) {
toast.say(`Error: ${err.message || err.name}. Failed to connect to instance.`)
} finally {
this.set({loading: false})
}
},
registerNewInstance: async function (code) {
let currentRegisteredInstanceName = this.store.get('currentRegisteredInstanceName') let currentRegisteredInstanceName = this.store.get('currentRegisteredInstanceName')
let currentRegisteredInstance = this.store.get('currentRegisteredInstance') let currentRegisteredInstance = this.store.get('currentRegisteredInstance')
let instanceData = await getAccessTokenFromAuthCode( let instanceData = await getAccessTokenFromAuthCode(
@ -103,7 +138,6 @@
code, code,
REDIRECT_URI REDIRECT_URI
) )
// TODO: handle error
let loggedInInstances = this.store.get('loggedInInstances') let loggedInInstances = this.store.get('loggedInInstances')
let loggedInInstancesInOrder = this.store.get('loggedInInstancesInOrder') let loggedInInstancesInOrder = this.store.get('loggedInInstancesInOrder')
let instanceThemes = this.store.get('instanceThemes') let instanceThemes = this.store.get('instanceThemes')
@ -124,7 +158,7 @@
this.store.save() this.store.save()
switchToTheme('default') switchToTheme('default')
goto('/') goto('/')
}, }
} }
} }
</script> </script>

View file

@ -24,7 +24,6 @@ main {
background: var(--main-bg); background: var(--main-bg);
border: 1px solid var(--main-border); border: 1px solid var(--main-border);
border-radius: 1px; border-radius: 1px;
min-height: 50vh;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {

View file

@ -47,4 +47,11 @@
--settings-list-item-border: $border-color; --settings-list-item-border: $border-color;
--settings-list-item-bg-active: darken($main-bg-color, 10%); --settings-list-item-bg-active: darken($main-bg-color, 10%);
--settings-list-item-bg-hover: darken($main-bg-color, 2%); --settings-list-item-bg-hover: darken($main-bg-color, 2%);
--toast-bg: $toast-bg;
--toast-border: $toast-border;
--toast-text: $secondary-text-color;
--mask-bg: $toast-bg;
--mask-svg-fill: $secondary-text-color;
} }

View file

@ -5,6 +5,8 @@ $main-text-color: #333;
$border-color: #dadada; $border-color: #dadada;
$main-bg-color: white; $main-bg-color: white;
$secondary-text-color: white; $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
@import "_base.scss"; @import "_base.scss";

View file

@ -5,6 +5,8 @@ $main-text-color: #333;
$border-color: #dadada; $border-color: #dadada;
$main-bg-color: white; $main-bg-color: white;
$secondary-text-color: white; $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
@import "_base.scss"; @import "_base.scss";

View file

@ -5,6 +5,8 @@ $main-text-color: #333;
$border-color: #dadada; $border-color: #dadada;
$main-bg-color: white; $main-bg-color: white;
$secondary-text-color: white; $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
@import "_base.scss"; @import "_base.scss";

View file

@ -5,6 +5,8 @@ $main-text-color: #333;
$border-color: #dadada; $border-color: #dadada;
$main-bg-color: white; $main-bg-color: white;
$secondary-text-color: white; $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
@import "_base.scss"; @import "_base.scss";

View file

@ -5,6 +5,8 @@ $main-text-color: #333;
$border-color: #dadada; $border-color: #dadada;
$main-bg-color: white; $main-bg-color: white;
$secondary-text-color: white; $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
@import "_base.scss"; @import "_base.scss";

View file

@ -5,6 +5,8 @@ $main-text-color: #333;
$border-color: #dadada; $border-color: #dadada;
$main-bg-color: white; $main-bg-color: white;
$secondary-text-color: white; $secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
@import "_base.scss"; @import "_base.scss";

View file

@ -94,11 +94,20 @@
<path d="M576 736v192q0 40-28 68t-68 28H288q-40 0-68-28t-28-68V736q0-40 28-68t68-28h192q40 0 68 28t28 68zm512 0v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68V736q0-40 28-68t68-28h192q40 0 68 28t28 68zm512 0v192q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68V736q0-40 28-68t68-28h192q40 0 68 28t28 68z"/> <path d="M576 736v192q0 40-28 68t-68 28H288q-40 0-68-28t-28-68V736q0-40 28-68t68-28h192q40 0 68 28t28 68zm512 0v192q0 40-28 68t-68 28H800q-40 0-68-28t-28-68V736q0-40 28-68t68-28h192q40 0 68 28t28 68zm512 0v192q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68V736q0-40 28-68t68-28h192q40 0 68 28t28 68z"/>
</symbol> </symbol>
<symbol id="fa-spinner" viewBox="0 0 1792 1792">
<title>Spinner</title>
<path d="M526 1394q0 53-37.5 90.5T398 1522q-52 0-90-38t-38-90q0-53 37.5-90.5T398 1266t90.5 37.5T526 1394zm498 206q0 53-37.5 90.5T896 1728t-90.5-37.5T768 1600t37.5-90.5T896 1472t90.5 37.5 37.5 90.5zM320 896q0 53-37.5 90.5T192 1024t-90.5-37.5T64 896t37.5-90.5T192 768t90.5 37.5T320 896zm1202 498q0 52-38 90t-90 38q-53 0-90.5-37.5T1266 1394t37.5-90.5 90.5-37.5 90.5 37.5 37.5 90.5zM558 398q0 66-47 113t-113 47-113-47-47-113 47-113 113-47 113 47 47 113zm1170 498q0 53-37.5 90.5T1600 1024t-90.5-37.5T1472 896t37.5-90.5T1600 768t90.5 37.5T1728 896zm-640-704q0 80-56 136t-136 56-136-56-56-136 56-136T896 0t136 56 56 136zm530 206q0 93-66 158.5T1394 622q-93 0-158.5-65.5T1170 398q0-92 65.5-158t158.5-66q92 0 158 66t66 158z"/>
</symbol>
</svg> </svg>
<!-- The application will be rendered inside this element, <!-- The application will be rendered inside this element,
because `templates/main.js` references it --> because `templates/main.js` references it -->
<div id='sapper'>%sapper.html%</div> <div id='sapper'>%sapper.html%</div>
<!-- Toast.html gets rendered here -->
<div id="toast"></div>
<!-- Sapper creates a <script> tag containing `templates/main.js` <!-- Sapper creates a <script> tag containing `templates/main.js`
and anything else it needs to hydrate the app and and anything else it needs to hydrate the app and
initialise the router --> initialise the router -->