feat: defer everything until the api config is loaded (#926)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/926
Reviewed-by: dpschen <dpschen@noreply.kolaente.de>
Co-authored-by: konrad <k@knt.li>
Co-committed-by: konrad <k@knt.li>
This commit is contained in:
konrad 2021-11-13 19:49:02 +00:00
parent 31f0c384ac
commit 0a2d5ef820
10 changed files with 419 additions and 255 deletions

View file

@ -1,25 +1,21 @@
<template> <template>
<div :class="{'is-touch': isTouch}"> <ready>
<div :class="{'is-hidden': !online}"> <div :class="{'is-touch': isTouch}">
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image --> <div :class="{'is-hidden': !online}">
<div class="offline" style="height: 0;width: 0;"></div> <template v-if="authUser">
<top-navigation v-if="authUser"/> <top-navigation/>
<content-auth v-if="authUser"/> <content-auth/>
<content-link-share v-else-if="authLinkShare"/> </template>
<content-no-auth v-else/> <content-link-share v-else-if="authLinkShare"/>
<notification/> <content-no-auth v-else/>
</div> <notification/>
<div class="app offline" v-if="!online">
<div class="offline-message">
<h1>You are offline.</h1>
<p>Please check your network connection and try again.</p>
</div> </div>
</div>
<transition name="fade"> <transition name="fade">
<keyboard-shortcuts v-if="keyboardShortcutsActive"/> <keyboard-shortcuts v-if="keyboardShortcutsActive"/>
</transition> </transition>
</div> </div>
</ready>
</template> </template>
<script> <script>
@ -36,6 +32,7 @@ import ContentLinkShare from './components/home/contentLinkShare'
import ContentNoAuth from './components/home/contentNoAuth' import ContentNoAuth from './components/home/contentNoAuth'
import {setLanguage} from './i18n' import {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete' import AccountDeleteService from '@/services/accountDelete'
import Ready from '@/components/misc/ready'
export default defineComponent({ export default defineComponent({
name: 'app', name: 'app',
@ -46,6 +43,7 @@ export default defineComponent({
TopNavigation, TopNavigation,
KeyboardShortcuts, KeyboardShortcuts,
Notification, Notification,
Ready,
}, },
beforeMount() { beforeMount() {
this.setupOnlineStatus() this.setupOnlineStatus()
@ -54,13 +52,6 @@ export default defineComponent({
this.setupAccountDeletionVerification() this.setupAccountDeletionVerification()
}, },
beforeCreate() { beforeCreate() {
// FIXME: async action in beforeCreate, might be not finished when component mounts
this.$store.dispatch('config/update')
.then(() => {
this.$store.dispatch('auth/checkAuth')
})
this.$store.dispatch('auth/checkAuth')
setLanguage() setLanguage()
}, },
created() { created() {
@ -121,29 +112,3 @@ export default defineComponent({
<style lang="scss"> <style lang="scss">
@import '@/styles/global.scss'; @import '@/styles/global.scss';
</style> </style>
<style lang="scss" scoped>
.offline {
background: url('@/assets/llama-nightscape.jpg') no-repeat center;
background-size: cover;
height: 100vh;
.offline-message {
text-align: center;
position: absolute;
width: 100vw;
bottom: 5vh;
color: $white;
padding: 0 1rem;
h1 {
font-weight: bold;
font-size: 1.5rem;
text-align: center;
color: $white;
font-weight: 700 !important;
font-size: 1.5rem;
}
}
}
</style>

View file

@ -1,37 +1,20 @@
<template> <template>
<div class="no-auth-wrapper"> <no-auth-wrapper>
<div class="noauth-container"> <router-view/>
<Logo width="400" height="117" /> </no-auth-wrapper>
<div class="message is-info" v-if="motd !== ''">
<div class="message-header">
<p>{{ $t('misc.info') }}</p>
</div>
<div class="message-body">
{{ motd }}
</div>
</div>
<router-view/>
</div>
</div>
</template> </template>
<script> <script>
import {mapState} from 'vuex' import {saveLastVisited} from '@/helpers/saveLastVisited'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
import Logo from '@/components/home/Logo.vue'
import { saveLastVisited } from '@/helpers/saveLastVisited'
export default { export default {
name: 'contentNoAuth', name: 'contentNoAuth',
components: { Logo }, components: {NoAuthWrapper},
computed: { computed: {
routeName() { routeName() {
return this.$route.name return this.$route.name
}, },
...mapState({
motd: state => state.config.motd,
}),
}, },
watch: { watch: {
routeName: { routeName: {
@ -62,17 +45,3 @@ export default {
}, },
} }
</script> </script>
<style lang="scss" scoped>
.no-auth-wrapper {
background: url('@/assets/llama.svg?url') no-repeat bottom left fixed $light-background;
min-height: 100vh;
}
.noauth-container {
max-width: 450px;
width: 100%;
margin: 0 auto;
padding: 1rem;
}
</style>

View file

@ -48,7 +48,7 @@
</ul> </ul>
</div> </div>
<aside class="menu namespaces-lists loader-container" :class="{'is-loading': loading}"> <aside class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id" > <template v-for="(n, nk) in namespaces" :key="n.id" >
<div class="namespace-title" :class="{'has-menu': n.id > 0}"> <div class="namespace-title" :class="{'has-menu': n.id > 0}">
<span <span
@ -105,7 +105,7 @@
> >
<template #item="{element: l}"> <template #item="{element: l}">
<li <li
class="loader-container" class="loader-container is-loading-small"
:class="{'is-loading': listUpdating[l.id]}" :class="{'is-loading': listUpdating[l.id]}"
> >
<router-link <router-link
@ -449,14 +449,6 @@ $vikunja-nav-selected-width: 0.4rem;
&:hover :deep(.dropdown-trigger) { &:hover :deep(.dropdown-trigger) {
opacity: 1; opacity: 1;
} }
&.loader-container.is-loading:after {
width: 1.5rem;
height: 1.5rem;
top: calc(50% - .75rem);
left: calc(50% - .75rem);
border-width: 2px;
}
} }
.flip-list-move { .flip-list-move {
@ -533,14 +525,6 @@ $vikunja-nav-selected-width: 0.4rem;
padding-top: math.div($navbar-padding, 2); padding-top: math.div($navbar-padding, 2);
} }
&.loader-container.is-loading:after {
width: 1.5rem;
height: 1.5rem;
top: calc(50% - .75rem);
left: calc(50% - .75rem);
border-width: 2px;
}
.icon { .icon {
color: $grey-400 !important; color: $grey-400 !important;
} }

View file

@ -26,7 +26,7 @@
<i18n-t keypath="apiConfig.signInOn"> <i18n-t keypath="apiConfig.signInOn">
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span> <span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
</i18n-t> </i18n-t>
<br /> <br/>
<a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a> <a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a>
</div> </div>
@ -46,9 +46,8 @@
</template> </template>
<script> <script>
import { parseURL } from 'ufo' import {parseURL} from 'ufo'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
const API_DEFAULT_PORT = 3456
export default { export default {
name: 'apiConfig', name: 'apiConfig',
@ -71,128 +70,48 @@ export default {
return parseURL(this.apiUrl).host return parseURL(this.apiUrl).host
}, },
}, },
props: {
configureOpen: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
configureOpen: {
handler(value) {
this.configureApi = value
},
immediate: true,
},
},
methods: { methods: {
setApiUrl() { async setApiUrl() {
if (this.apiUrl === '') { if (this.apiUrl === '') {
// Don't try to check and set an empty url
this.errorMsg = this.$t('apiConfig.urlRequired')
return return
} }
let urlToCheck = this.apiUrl try {
const url = await checkAndSetApiUrl(this.apiUrl)
// Check if the url has an http prefix if (url === '') {
if ( // If the config setter function could not figure out a url
!urlToCheck.startsWith('http://') && throw new Error('URL cannot be empty.')
!urlToCheck.startsWith('https://') }
) {
urlToCheck = `http://${urlToCheck}` // Set it + save it to local storage to save us the hoops
this.errorMsg = ''
this.successMsg = this.$t('apiConfig.success', {domain: this.apiDomain})
this.configureApi = false
this.apiUrl = url
this.$emit('foundApi', this.apiUrl)
} catch (e) {
// Still not found, url is still invalid
this.successMsg = ''
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
} }
urlToCheck = new URL(urlToCheck)
const origUrlToCheck = urlToCheck
const oldUrl = window.API_URL
window.API_URL = urlToCheck.toString()
// Check if the api is reachable at the provided url
this.$store
.dispatch('config/update')
.catch((e) => {
// Check if it is reachable at /api/v1 and http
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
!urlToCheck.pathname.endsWith('/api/v1/')
) {
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
window.API_URL = urlToCheck.toString()
return this.$store.dispatch('config/update')
}
throw e
})
.catch((e) => {
// Check if it has a port and if not check if it is reachable at https
if (urlToCheck.protocol === 'http:') {
urlToCheck.protocol = 'https:'
window.API_URL = urlToCheck.toString()
return this.$store.dispatch('config/update')
}
throw e
})
.catch((e) => {
// Check if it is reachable at /api/v1 and https
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
!urlToCheck.pathname.endsWith('/api/v1/')
) {
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
window.API_URL = urlToCheck.toString()
return this.$store.dispatch('config/update')
}
throw e
})
.catch((e) => {
// Check if it is reachable at port API_DEFAULT_PORT and https
if (urlToCheck.port !== API_DEFAULT_PORT) {
urlToCheck.protocol = 'https:'
urlToCheck.port = API_DEFAULT_PORT
window.API_URL = urlToCheck.toString()
return this.$store.dispatch('config/update')
}
throw e
})
.catch((e) => {
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and https
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
!urlToCheck.pathname.endsWith('/api/v1/')
) {
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
window.API_URL = urlToCheck.toString()
return this.$store.dispatch('config/update')
}
throw e
})
.catch((e) => {
// Check if it is reachable at port API_DEFAULT_PORT and http
if (urlToCheck.port !== API_DEFAULT_PORT) {
urlToCheck.protocol = 'http:'
urlToCheck.port = API_DEFAULT_PORT
window.API_URL = urlToCheck.toString()
return this.$store.dispatch('config/update')
}
throw e
})
.catch((e) => {
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and http
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
!urlToCheck.pathname.endsWith('/api/v1/')
) {
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
window.API_URL = urlToCheck.toString()
return this.$store.dispatch('config/update')
}
throw e
})
.catch(() => {
// Still not found, url is still invalid
this.successMsg = ''
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
window.API_URL = oldUrl
})
.then((r) => {
if (typeof r !== 'undefined') {
// Set it + save it to local storage to save us the hoops
this.errorMsg = ''
this.successMsg = this.$t('apiConfig.success', {domain: this.apiDomain})
localStorage.setItem('API_URL', window.API_URL)
this.configureApi = false
this.apiUrl = window.API_URL
this.$emit('foundApi', this.apiUrl)
}
})
}, },
}, },
} }
@ -200,15 +119,15 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.api-config { .api-config {
margin-bottom: .75rem; margin-bottom: .75rem;
} }
.api-url-info { .api-url-info {
font-size: .9rem; font-size: .9rem;
text-align: right; text-align: right;
}
span.url { .url {
border-bottom: 1px dashed $primary; border-bottom: 1px dashed $primary;
}
} }
</style> </style>

View file

@ -0,0 +1,39 @@
<template>
<div class="no-auth-wrapper">
<div class="noauth-container">
<Logo width="400" height="117" />
<div class="message is-info" v-if="motd !== ''">
<div class="message-header">
<p>{{ $t('misc.info') }}</p>
</div>
<div class="message-body">
{{ motd }}
</div>
</div>
<slot/>
</div>
</div>
</template>
<script setup>
import Logo from '@/components/home/Logo.vue'
import {useStore} from 'vuex'
import {computed} from 'vue'
const store = useStore()
const motd = computed(() => store.state.config.motd)
</script>
<style lang="scss" scoped>
.no-auth-wrapper {
background: url('@/assets/llama.svg') no-repeat bottom left fixed $light-background;
min-height: 100vh;
}
.noauth-container {
max-width: 450px;
width: 100%;
margin: 0 auto;
padding: 1rem;
}
</style>

View file

@ -0,0 +1,141 @@
<template>
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
<div class="offline" style="height: 0;width: 0;"></div>
<div class="app offline" v-if="!online">
<div class="offline-message">
<h1>{{ $t('offline.title') }}</h1>
<p>{{ $t('offline.text') }}</p>
</div>
</div>
<template v-else-if="ready">
<slot/>
</template>
<section v-else-if="error !== ''">
<no-auth-wrapper>
<card>
<p v-if="error === errorNoApiUrl">
{{ $t('ready.noApiUrlConfigured') }}
</p>
<div class="notification is-danger" v-else>
<p>
{{ $t('ready.errorOccured') }}<br/>
{{ error }}
</p>
<p>
{{ $t('ready.checkApiUrl') }}
</p>
</div>
<api-config :configure-open="true" @found-api="load"/>
</card>
</no-auth-wrapper>
</section>
<transition name="fade">
<section class="vikunja-loading" v-if="showLoading">
<img alt="Vikunja" :src="logoUrl" width="100" height="100"/>
<p>
<span class="loader-container is-loading-small is-loading"></span>
{{ $t('ready.loading') }}
</p>
</section>
</transition>
</template>
<script>
import logoUrl from '@/assets/logo.svg'
import ApiConfig from '@/components/misc/api-config'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
import {mapState} from 'vuex'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
export default {
name: 'ready',
components: {
NoAuthWrapper,
ApiConfig,
},
data() {
return {
logoUrl,
error: '',
errorNoApiUrl: ERROR_NO_API_URL,
}
},
created() {
this.load()
},
computed: {
ready() {
return this.$store.state.vikunjaReady
},
showLoading() {
return !this.ready && this.error === ''
},
...mapState([
'online',
]),
},
methods: {
load() {
this.$store.dispatch('loadApp')
.catch(e => {
this.error = e
})
},
},
}
</script>
<style lang="scss" scoped>
.vikunja-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: $grey-100;
z-index: 99;
img {
margin-bottom: 1rem;
}
}
.loader-container {
margin-right: 1rem;
&.is-loading::after {
border-left-color: $grey-400;
border-bottom-color: $grey-400;
}
}
.offline {
background: url('@/assets/llama-nightscape.jpg') no-repeat center;
background-size: cover;
height: 100vh;
}
.offline-message {
text-align: center;
position: absolute;
width: 100vw;
bottom: 5vh;
color: $white;
padding: 0 1rem;
h1 {
font-weight: bold;
font-size: 1.5rem;
text-align: center;
color: $white;
font-weight: 700 !important;
font-size: 1.5rem;
}
}
</style>

View file

@ -0,0 +1,118 @@
import {store} from '@/store'
const API_DEFAULT_PORT = '3456'
export const ERROR_NO_API_URL = 'noApiUrlProvided'
const updateConfig = () => store.dispatch('config/update')
export const checkAndSetApiUrl = (url: string): Promise<string> => {
// Check if the url has an http prefix
if (
!url.startsWith('http://') &&
!url.startsWith('https://')
) {
url = `http://${url}`
}
const urlToCheck: URL = new URL(url)
const origUrlToCheck = urlToCheck
const oldUrl = window.API_URL
window.API_URL = urlToCheck.toString()
// Check if the api is reachable at the provided url
return updateConfig()
.catch(e => {
// Check if it is reachable at /api/v1 and http
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
!urlToCheck.pathname.endsWith('/api/v1/')
) {
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
// Check if it has a port and if not check if it is reachable at https
if (urlToCheck.protocol === 'http:') {
urlToCheck.protocol = 'https:'
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
// Check if it is reachable at /api/v1 and https
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
!urlToCheck.pathname.endsWith('/api/v1/')
) {
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
// Check if it is reachable at port API_DEFAULT_PORT and https
if (urlToCheck.port !== API_DEFAULT_PORT) {
urlToCheck.protocol = 'https:'
urlToCheck.port = API_DEFAULT_PORT
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and https
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
!urlToCheck.pathname.endsWith('/api/v1/')
) {
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
// Check if it is reachable at port API_DEFAULT_PORT and http
if (urlToCheck.port !== API_DEFAULT_PORT) {
urlToCheck.protocol = 'http:'
urlToCheck.port = API_DEFAULT_PORT
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and http
urlToCheck.pathname = origUrlToCheck.pathname
if (
!urlToCheck.pathname.endsWith('/api/v1') &&
!urlToCheck.pathname.endsWith('/api/v1/')
) {
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
window.API_URL = urlToCheck.toString()
return updateConfig()
}
throw e
})
.catch(e => {
window.API_URL = oldUrl
throw e
})
.then(r => {
if (typeof r !== 'undefined') {
localStorage.setItem('API_URL', window.API_URL)
return window.API_URL
}
throw new Error(ERROR_NO_API_URL)
})
}

View file

@ -16,6 +16,16 @@
"title": "Not found", "title": "Not found",
"text": "The page you requested does not exist." "text": "The page you requested does not exist."
}, },
"ready": {
"loading": "Vikunja is loading…",
"errorOccured": "An error occured:",
"checkApiUrl": "Please check if the api url is correct.",
"noApiUrlConfigured": "No API url was configured. Please set one below:"
},
"offline": {
"title": "You are offline.",
"text": "Please check your network connection and try again."
},
"user": { "user": {
"auth": { "auth": {
"username": "Username", "username": "Username",
@ -779,8 +789,9 @@
"urlPlaceholder": "eg. https://localhost:3456", "urlPlaceholder": "eg. https://localhost:3456",
"change": "change", "change": "change",
"signInOn": "Sign in to your Vikunja account on {0}", "signInOn": "Sign in to your Vikunja account on {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\".", "error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"success": "Using Vikunja installation at \"{domain}\"." "success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
}, },
"loadingError": { "loadingError": {
"failed": "Loading failed, please {0}. If the error persists, please {1}.", "failed": "Loading failed, please {0}. If the error persists, please {1}.",

View file

@ -19,6 +19,7 @@ import attachments from './modules/attachments'
import labels from './modules/labels' import labels from './modules/labels'
import ListService from '../services/list' import ListService from '../services/list'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
export const store = createStore({ export const store = createStore({
strict: import.meta.env.DEV, strict: import.meta.env.DEV,
@ -43,6 +44,7 @@ export const store = createStore({
menuActive: true, menuActive: true,
keyboardShortcutsActive: false, keyboardShortcutsActive: false,
quickActionsActive: false, quickActionsActive: false,
vikunjaReady: false,
}, },
mutations: { mutations: {
[LOADING](state, loading) { [LOADING](state, loading) {
@ -84,6 +86,9 @@ export const store = createStore({
[BACKGROUND](state, background) { [BACKGROUND](state, background) {
state.background = background state.background = background
}, },
vikunjaReady(state, ready) {
state.vikunjaReady = ready
},
}, },
actions: { actions: {
async [CURRENT_LIST]({state, commit}, currentList) { async [CURRENT_LIST]({state, commit}, currentList) {
@ -138,5 +143,10 @@ export const store = createStore({
commit(CURRENT_LIST, currentList) commit(CURRENT_LIST, currentList)
}, },
async loadApp({commit, dispatch}) {
await checkAndSetApiUrl(window.API_URL)
await dispatch('auth/checkAuth')
commit('vikunjaReady', true)
},
}, },
}) })

View file

@ -1,30 +1,38 @@
// FIXME: move to loading.vue // FIXME: move to loading.vue
.loader-container.is-loading { .loader-container.is-loading {
position: relative; position: relative;
pointer-events: none; pointer-events: none;
opacity: 0.5; opacity: 0.5;
&::after { &::after {
@include loader; @include loader;
position: absolute; position: absolute;
top: calc(50% - 2.5rem); top: calc(50% - 2.5rem);
left: calc(50% - 2.5rem); left: calc(50% - 2.5rem);
width: 5rem; width: 5rem;
height: 5rem; height: 5rem;
border-width: 0.25rem; border-width: 0.25rem;
} }
&.is-loading-small::after {
width: 1.5rem;
height: 1.5rem;
top: calc(50% - .75rem);
left: calc(50% - .75rem);
border-width: 2px;
}
} }
// FIXME: move to ShowTasks.vue // FIXME: move to ShowTasks.vue
.spinner.is-loading { .spinner.is-loading {
pointer-events: none; pointer-events: none;
&::after { &::after {
@include loader; @include loader;
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
margin-left: calc(50% - 1rem); margin-left: calc(50% - 1rem);
margin-top: 1rem; margin-top: 1rem;
border-width: 0.25rem; border-width: 0.25rem;
} }
} }