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

Merged
konrad merged 27 commits from feature/ready-state into main 2021-11-13 19:49:03 +00:00
10 changed files with 419 additions and 255 deletions

View File

@ -1,25 +1,21 @@
<template>
<div :class="{'is-touch': isTouch}">
<div :class="{'is-hidden': !online}">
<!-- 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>
<top-navigation v-if="authUser"/>
<content-auth v-if="authUser"/>
<content-link-share v-else-if="authLinkShare"/>
<content-no-auth v-else/>
<notification/>
</div>
<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>
<ready>
<div :class="{'is-touch': isTouch}">
<div :class="{'is-hidden': !online}">
<template v-if="authUser">
<top-navigation/>
<content-auth/>
</template>
<content-link-share v-else-if="authLinkShare"/>
<content-no-auth v-else/>
<notification/>
</div>
</div>
<transition name="fade">
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
</transition>
</div>
<transition name="fade">
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
</transition>
</div>
</ready>
</template>
<script>
@ -36,6 +32,7 @@ import ContentLinkShare from './components/home/contentLinkShare'
import ContentNoAuth from './components/home/contentNoAuth'
import {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete'
import Ready from '@/components/misc/ready'
export default defineComponent({
name: 'app',
@ -46,6 +43,7 @@ export default defineComponent({
TopNavigation,
KeyboardShortcuts,
Notification,
Ready,
},
beforeMount() {
this.setupOnlineStatus()
@ -54,13 +52,6 @@ export default defineComponent({
this.setupAccountDeletionVerification()
},
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()
},
created() {
@ -121,29 +112,3 @@ export default defineComponent({
<style lang="scss">
@import '@/styles/global.scss';
</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>
<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>
<router-view/>
</div>
</div>
<no-auth-wrapper>
<router-view/>
</no-auth-wrapper>
</template>
<script>
import {mapState} from 'vuex'
import Logo from '@/components/home/Logo.vue'
import { saveLastVisited } from '@/helpers/saveLastVisited'
import {saveLastVisited} from '@/helpers/saveLastVisited'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
export default {
name: 'contentNoAuth',
components: { Logo },
components: {NoAuthWrapper},
computed: {
routeName() {
return this.$route.name
},
...mapState({
motd: state => state.config.motd,
}),
},
watch: {
routeName: {
@ -62,17 +45,3 @@ export default {
},
}
</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>
</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" >
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
<span
@ -105,7 +105,7 @@
>
<template #item="{element: l}">
<li
class="loader-container"
class="loader-container is-loading-small"
:class="{'is-loading': listUpdating[l.id]}"
>
<router-link
@ -449,14 +449,6 @@ $vikunja-nav-selected-width: 0.4rem;
&:hover :deep(.dropdown-trigger) {
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 {
@ -533,14 +525,6 @@ $vikunja-nav-selected-width: 0.4rem;
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 {
color: $grey-400 !important;
}

View File

@ -26,7 +26,7 @@
<i18n-t keypath="apiConfig.signInOn">
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
</i18n-t>
<br />
<br/>
<a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a>
</div>
@ -46,9 +46,8 @@
</template>
<script>
import { parseURL } from 'ufo'
const API_DEFAULT_PORT = 3456
import {parseURL} from 'ufo'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
export default {
name: 'apiConfig',
@ -71,128 +70,48 @@ export default {
return parseURL(this.apiUrl).host
},
},
props: {
configureOpen: {
dpschen marked this conversation as resolved
Review

I was first confused by the naming of this prop.
Since the component is just use once and if used we set this to true:
shall we just remove it to simplify this?

I was first confused by the naming of this prop. Since the component is just use once and if used we set this to true: shall we just remove it to simplify this?
Review

The <api-config> component is used in two places (ready and login view) and this prop is only used in one of those. Just removing the prop does not seem to be a good option here.

How would you simplify it?

The `<api-config>` component is used in two places (ready and login view) and this prop is only used in one of those. Just removing the prop does not seem to be a good option here. How would you simplify it?
Review

Can you explain why api-config is also in Login.vue? I think I didn't get that =)

Can you explain why api-config is also in Login.vue? I think I didn't get that =)
Review

Mostly to be able to change the url on the login screen, even if one is already defined. It also shows the user what api server they are connecting to.

The login screen is the first entry point, but really this should be on the other noauth screens as well (Register, Password reset etc).

I'm planning a follow-up PR to refactor the whole noauth thing a bit, will include that there.

Mostly to be able to change the url on the login screen, even if one is already defined. It also shows the user what api server they are connecting to. The login screen is the first entry point, but really this should be on the other `noauth` screens as well (Register, Password reset etc). I'm planning a follow-up PR to refactor the whole noauth thing a bit, will include that there.
Review

I'm a bit afraid that I'll never be able to merge the modals branch #816 if we don't plan ahead.

Maybe we can align those changes you still want to do or built them after merge on top of the modals branch.

I'm a bit afraid that I'll never be able to merge the modals branch https://kolaente.dev/vikunja/frontend/pulls/816 if we don't plan ahead. Maybe we can align those changes you still want to do or built them after merge on top of the modals branch.
Review

Sure. It should be fine to do them after the modals branch is done.

Sure. It should be fine to do them after the modals branch is done.
Review

Let's resolve this and merge this branch. Then I can try to merge it into #816 and we can think about how to continue =)

Let's resolve this and merge this branch. Then I can try to merge it into #816 and we can think about how to continue =)
type: Boolean,
required: false,
default: false,
},
},
watch: {
configureOpen: {
handler(value) {
this.configureApi = value
},
immediate: true,
},
},
methods: {
setApiUrl() {
async setApiUrl() {
if (this.apiUrl === '') {
// Don't try to check and set an empty url
this.errorMsg = this.$t('apiConfig.urlRequired')
return
}
let urlToCheck = this.apiUrl
try {
const url = await checkAndSetApiUrl(this.apiUrl)
konrad marked this conversation as resolved
Review

Explain why we return here

Explain why we return here
// Check if the url has an http prefix
if (
!urlToCheck.startsWith('http://') &&
!urlToCheck.startsWith('https://')
) {
urlToCheck = `http://${urlToCheck}`
if (url === '') {
// If the config setter function could not figure out a url
throw new Error('URL cannot be empty.')
}
// 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>
.api-config {
margin-bottom: .75rem;
margin-bottom: .75rem;
}
.api-url-info {
font-size: .9rem;
text-align: right;
font-size: .9rem;
text-align: right;
}
span.url {
border-bottom: 1px dashed $primary;
}
.url {
border-bottom: 1px dashed $primary;
}
</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;
}
konrad marked this conversation as resolved
Review

The min-height should be set from outside.

The min-height should be set from outside.
Review

And the background as well? Because the background will only work with a height of 100vh (to keep the llama at the bottom).

And the background as well? Because the background will only work with a height of 100vh (to keep the llama at the bottom).
Review

Would probably be best to import the llama image as component with the new vite-svg-loader in the parent component.
Then it can be positioned absolute.
Maybe add a new issue and we resolve that after merging. Because there will be conflicts and I think that's hard to solve before.

Would probably be best to import the llama image as component with the new vite-svg-loader in the parent component. Then it can be positioned absolute. Maybe add a new issue and we resolve that after merging. Because there will be conflicts and I think that's hard to solve before.
Review

Opened: #973

Opened: https://kolaente.dev/vikunja/frontend/issues/973
</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",
"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": {
"auth": {
"username": "Username",
@ -778,8 +788,9 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"signInOn": "Sign in to your Vikunja account on {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\".",
"success": "Using 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}\".",
"urlRequired": "A url is required."
},
"loadingError": {
"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 ListService from '../services/list'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
export const store = createStore({
strict: import.meta.env.DEV,
@ -43,6 +44,7 @@ export const store = createStore({
menuActive: true,
keyboardShortcutsActive: false,
quickActionsActive: false,
vikunjaReady: false,
},
mutations: {
[LOADING](state, loading) {
@ -84,6 +86,9 @@ export const store = createStore({
[BACKGROUND](state, background) {
state.background = background
},
vikunjaReady(state, ready) {
state.vikunjaReady = ready
},
},
actions: {
async [CURRENT_LIST]({state, commit}, currentList) {
@ -138,5 +143,10 @@ export const store = createStore({
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
.loader-container.is-loading {
position: relative;
pointer-events: none;
opacity: 0.5;
position: relative;
pointer-events: none;
opacity: 0.5;
&::after {
@include loader;
position: absolute;
top: calc(50% - 2.5rem);
left: calc(50% - 2.5rem);
width: 5rem;
height: 5rem;
border-width: 0.25rem;
}
&::after {
@include loader;
position: absolute;
top: calc(50% - 2.5rem);
left: calc(50% - 2.5rem);
width: 5rem;
height: 5rem;
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
.spinner.is-loading {
pointer-events: none;
pointer-events: none;
&::after {
@include loader;
width: 2rem;
height: 2rem;
margin-left: calc(50% - 1rem);
margin-top: 1rem;
border-width: 0.25rem;
}
&::after {
@include loader;
width: 2rem;
height: 2rem;
margin-left: calc(50% - 1rem);
margin-top: 1rem;
border-width: 0.25rem;
}
}