Resolve Vue Material use; replace toast with snackbar
This commit is contained in:
parent
e70f88e188
commit
b5146f825a
|
@ -20,7 +20,6 @@
|
|||
"vue-material": "^1.0.0-beta-11",
|
||||
"vue-progressbar": "^0.7.3",
|
||||
"vue-router": "^3.0.0",
|
||||
"vue-toast": "^3.1.0",
|
||||
"vuex": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -1,28 +1,38 @@
|
|||
<template lang="pug">
|
||||
md-app(md-waterfall md-mode='fixed-last' role='application')
|
||||
p navigation here
|
||||
navigation
|
||||
p navigation there
|
||||
md-app-drawer(:md-active.sync='menuVisible')
|
||||
| test
|
||||
md-app-content
|
||||
router-view
|
||||
vue-progress-bar
|
||||
toast(ref='toast')
|
||||
p.mpj-muted-text
|
||||
| myPrayerJournal v{{ version }}
|
||||
br
|
||||
em: small.
|
||||
#[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] •
|
||||
#[router-link(:to="{ name: 'TermsOfService' }") Terms of Service] •
|
||||
#[a(href='https://github.com/bit-badger/myprayerjournal' target='_blank') Developed] and hosted by
|
||||
#[a(href='https://bitbadger.solutions' target='_blank') Bit Badger Solutions]
|
||||
#app
|
||||
md-app(md-waterfall md-mode='fixed-last' role='application')
|
||||
md-app-toolbar.md-large.md-dense.md-primary
|
||||
.md-toolbar-row
|
||||
.md-toolbar-section-start
|
||||
span.md-title
|
||||
span(style='font-weight:100;') my
|
||||
span(style='font-weight:400;') Prayer
|
||||
span(style='font-weight:700;') Journal
|
||||
navigation
|
||||
md-app-content
|
||||
router-view
|
||||
vue-progress-bar
|
||||
md-snackbar(:md-active.sync='snackbar.visible'
|
||||
md-position='center'
|
||||
:md-duration='snackbar.interval'
|
||||
ref='snackbar')
|
||||
| {{ snackbar.message }}
|
||||
p.mpj-muted-text.mpj-text-right
|
||||
| myPrayerJournal v{{ version }}
|
||||
br
|
||||
em: small.
|
||||
#[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] •
|
||||
#[router-link(:to="{ name: 'TermsOfService' }") Terms of Service] •
|
||||
#[a(href='https://github.com/bit-badger/myprayerjournal' target='_blank') Developed] and hosted by
|
||||
#[a(href='https://bitbadger.solutions' target='_blank') Bit Badger Solutions]
|
||||
</template>
|
||||
|
||||
<script>
|
||||
'use strict'
|
||||
|
||||
import Navigation from './components/common/Navigation.vue'
|
||||
import Vue from 'vue'
|
||||
|
||||
import Navigation from '@/components/common/Navigation'
|
||||
|
||||
import { version } from '../package.json'
|
||||
|
||||
|
@ -32,14 +42,22 @@ export default {
|
|||
Navigation
|
||||
},
|
||||
data () {
|
||||
return {}
|
||||
return {
|
||||
messageEvents: new Vue(),
|
||||
snackbar: {
|
||||
visible: false,
|
||||
message: '',
|
||||
interval: 4000
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$refs.toast.setOptions({ position: 'bottom right' })
|
||||
this.messageEvents.$on('info', this.showInfo)
|
||||
this.messageEvents.$on('error', this.showError)
|
||||
},
|
||||
computed: {
|
||||
toast () {
|
||||
return this.$refs.toast
|
||||
messages () {
|
||||
return this.messageEvents
|
||||
},
|
||||
version () {
|
||||
return version.endsWith('.0')
|
||||
|
@ -48,6 +66,25 @@ export default {
|
|||
: version.substr(0, version.length - 2)
|
||||
: version
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showSnackbar (message) {
|
||||
this.snackbar.message = message
|
||||
this.snackbar.visible = true
|
||||
},
|
||||
showInfo (message) {
|
||||
this.snackbar.interval = 4000
|
||||
this.showSnackbar(message)
|
||||
},
|
||||
showError (message) {
|
||||
this.snackbar.interval = Infinity
|
||||
this.showSnackbar(message)
|
||||
}
|
||||
},
|
||||
provide () {
|
||||
return {
|
||||
messages: this.messageEvents
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -58,10 +95,6 @@ html, body {
|
|||
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
body {
|
||||
padding-top: 50px;
|
||||
margin: 0;
|
||||
}
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-weight: 500;
|
||||
margin-top: 0;
|
||||
|
|
|
@ -11,15 +11,11 @@ article.mpj-main-content-wide(role='main')
|
|||
.mpj-journal(v-if='journal.length > 0')
|
||||
request-card(v-for='request in journal'
|
||||
:key='request.requestId'
|
||||
:request='request'
|
||||
:events='eventBus'
|
||||
:toast='toast')
|
||||
:request='request')
|
||||
p.text-center(v-else): em.
|
||||
No requests found; click the “Add a New Request” button to add one
|
||||
notes-edit(:events='eventBus'
|
||||
:toast='toast')
|
||||
snooze-request(:events='eventBus'
|
||||
:toast='toast')
|
||||
notes-edit
|
||||
snooze-request
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -36,6 +32,7 @@ import actions from '@/store/action-types'
|
|||
|
||||
export default {
|
||||
name: 'journal',
|
||||
inject: ['messages'],
|
||||
components: {
|
||||
NotesEdit,
|
||||
RequestCard,
|
||||
|
@ -50,14 +47,19 @@ export default {
|
|||
title () {
|
||||
return `${this.user.given_name}’s Prayer Journal`
|
||||
},
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
snackbar () {
|
||||
return this.$parent.$refs.snackbar
|
||||
},
|
||||
...mapState(['user', 'journal', 'isLoadingJournal'])
|
||||
},
|
||||
async created () {
|
||||
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
|
||||
this.toast.showToast(`Loaded ${this.journal.length} prayer requests`, { theme: 'success' })
|
||||
this.messages.$emit('info', `Loaded ${this.journal.length} prayer requests`)
|
||||
},
|
||||
provide () {
|
||||
return {
|
||||
journalEvents: this.eventBus
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,42 +1,32 @@
|
|||
<template lang="pug">
|
||||
md-toolbar.md-large.md-dense.md-primary
|
||||
.md-toolbar-row
|
||||
.md-toolbar-section-start
|
||||
| howdy
|
||||
span.md-title
|
||||
span(style='font-weight:100;') my
|
||||
span(style='font-weight:600;') Prayer
|
||||
span(style='font-weight:700;') Journal
|
||||
.md-toolbar-section-end
|
||||
| ""
|
||||
.md-toolbar-row
|
||||
md-tabs.md-primary
|
||||
md-tab#mpj-home(md-label='Home'
|
||||
:to="{ name: 'Home' }")
|
||||
md-tab#mpj-journal(v-if='isAuthenticated'
|
||||
md-label='Journal'
|
||||
:to="{ name: 'Journal' }")
|
||||
md-tab#mpj-active(v-if='isAuthenticated'
|
||||
md-label='Active'
|
||||
:to="{ name: 'ActiveRequests' }")
|
||||
md-tab#mpj-snoozed(v-if='hasSnoozed'
|
||||
md-label='Snoozed'
|
||||
:to="{ name: 'SnoozedRequests' }")
|
||||
md-tab#mpj-answered(v-if='isAuthenticated'
|
||||
md-label='Answered'
|
||||
:to="{ name: 'AnsweredRequests' }")
|
||||
md-tab#mpj-log-off(v-if='isAuthenticated'
|
||||
md-label='Log Off'
|
||||
href='#'
|
||||
@click.stop='logOff()')
|
||||
md-tab#mpj-log-on(v-if='!isAuthenticated'
|
||||
md-label='Log On'
|
||||
href='#'
|
||||
@click.stop='logOn()')
|
||||
md-tab#mpj-docs(md-label='Docs'
|
||||
href='https://docs.prayerjournal.me'
|
||||
target='_blank'
|
||||
@click.stop='')
|
||||
.md-toolbar-row
|
||||
md-tabs.md-primary
|
||||
md-tab#mpj-home(md-label='Home'
|
||||
:to="{ name: 'Home' }")
|
||||
md-tab#mpj-journal(v-if='isAuthenticated'
|
||||
md-label='Journal'
|
||||
:to="{ name: 'Journal' }")
|
||||
md-tab#mpj-active(v-if='isAuthenticated'
|
||||
md-label='Active'
|
||||
:to="{ name: 'ActiveRequests' }")
|
||||
md-tab#mpj-snoozed(v-if='hasSnoozed'
|
||||
md-label='Snoozed'
|
||||
:to="{ name: 'SnoozedRequests' }")
|
||||
md-tab#mpj-answered(v-if='isAuthenticated'
|
||||
md-label='Answered'
|
||||
:to="{ name: 'AnsweredRequests' }")
|
||||
md-tab#mpj-log-off(v-if='isAuthenticated'
|
||||
md-label='Log Off'
|
||||
href='#'
|
||||
@click.stop='logOff()')
|
||||
md-tab#mpj-log-on(v-if='!isAuthenticated'
|
||||
md-label='Log On'
|
||||
href='#'
|
||||
@click.stop='logOn()')
|
||||
md-tab#mpj-docs(md-label='Docs'
|
||||
href='https://docs.prayerjournal.me'
|
||||
target='_blank'
|
||||
@click.stop='')
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
|
|
@ -6,8 +6,7 @@ article.mpj-main-content(role='main')
|
|||
No active requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
|
||||
request-list-item(v-for='req in requests'
|
||||
:key='req.requestId'
|
||||
:request='req'
|
||||
:toast='toast')
|
||||
:request='req')
|
||||
p(v-else) Loading journal...
|
||||
</template>
|
||||
|
||||
|
@ -32,9 +31,6 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
},
|
||||
...mapState(['journal', 'isLoadingJournal'])
|
||||
},
|
||||
created () {
|
||||
|
|
|
@ -6,8 +6,7 @@ article.mpj-main-content(role='main')
|
|||
No answered requests found; once you have marked one as “Answered”, it will appear here
|
||||
request-list-item(v-for='req in requests'
|
||||
:key='req.requestId'
|
||||
:request='req'
|
||||
:toast='toast')
|
||||
:request='req')
|
||||
p(v-else) Loading answered requests...
|
||||
</template>
|
||||
|
||||
|
@ -20,6 +19,7 @@ import RequestListItem from '@/components/request/RequestListItem'
|
|||
|
||||
export default {
|
||||
name: 'answered-requests',
|
||||
inject: ['messages'],
|
||||
components: {
|
||||
RequestListItem
|
||||
},
|
||||
|
@ -29,11 +29,6 @@ export default {
|
|||
loaded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
this.$Progress.start()
|
||||
try {
|
||||
|
@ -42,7 +37,7 @@ export default {
|
|||
this.$Progress.finish()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
this.toast.showToast('Error loading requests; check console for details', { theme: 'danger' })
|
||||
this.messages.$emit('error', 'Error loading requests; check console for details')
|
||||
this.$Progress.fail()
|
||||
} finally {
|
||||
this.loaded = true
|
||||
|
|
|
@ -77,6 +77,7 @@ import actions from '@/store/action-types'
|
|||
|
||||
export default {
|
||||
name: 'edit-request',
|
||||
inject: ['messages'],
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
|
@ -112,9 +113,6 @@ export default {
|
|||
showRecurrence () {
|
||||
return this.form.recur.typ !== 'Immediate'
|
||||
},
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
},
|
||||
...mapState(['journal'])
|
||||
},
|
||||
async mounted () {
|
||||
|
@ -169,7 +167,7 @@ export default {
|
|||
recurType: this.form.recur.typ === 'Immediate' ? 'Immediate' : this.form.recur.other,
|
||||
recurCount: this.form.recur.typ === 'Immediate' ? 0 : Number.parseInt(this.form.recur.count)
|
||||
})
|
||||
this.toast.showToast('New prayer request added', { theme: 'success' })
|
||||
this.messages.$emit('info', 'New prayer request added')
|
||||
} else {
|
||||
await this.$store.dispatch(actions.UPDATE_REQUEST, {
|
||||
progress: this.$Progress,
|
||||
|
@ -180,9 +178,9 @@ export default {
|
|||
recurCount: this.form.recur.typ === 'Immediate' ? 0 : Number.parseInt(this.form.recur.count)
|
||||
})
|
||||
if (this.form.status === 'Answered') {
|
||||
this.toast.showToast('Request updated and removed from active journal', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Request updated and removed from active journal')
|
||||
} else {
|
||||
this.toast.showToast('Request updated', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Request updated')
|
||||
}
|
||||
}
|
||||
this.goBack()
|
||||
|
|
|
@ -37,10 +37,10 @@ import api from '@/api'
|
|||
|
||||
export default {
|
||||
name: 'notes-edit',
|
||||
props: {
|
||||
toast: { required: true },
|
||||
events: { required: true }
|
||||
},
|
||||
inject: [
|
||||
'messages',
|
||||
'journalEvents'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
notesVisible: false,
|
||||
|
@ -61,7 +61,7 @@ export default {
|
|||
}
|
||||
},
|
||||
created () {
|
||||
this.events.$on('notes', this.openDialog)
|
||||
this.journalEvents.$on('notes', this.openDialog)
|
||||
},
|
||||
methods: {
|
||||
closeDialog () {
|
||||
|
@ -93,7 +93,7 @@ export default {
|
|||
try {
|
||||
await api.addNote(this.form.requestId, this.form.notes)
|
||||
this.$Progress.finish()
|
||||
this.toast.showToast('Added notes', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Added notes')
|
||||
this.closeDialog()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
|
|
@ -21,10 +21,12 @@ import actions from '@/store/action-types'
|
|||
|
||||
export default {
|
||||
name: 'request-card',
|
||||
inject: [
|
||||
'messages',
|
||||
'journalEvents'
|
||||
],
|
||||
props: {
|
||||
request: { required: true },
|
||||
toast: { required: true },
|
||||
events: { required: true }
|
||||
request: { required: true }
|
||||
},
|
||||
computed: {
|
||||
shouldDisplay () {
|
||||
|
@ -40,16 +42,16 @@ export default {
|
|||
status: 'Prayed',
|
||||
updateText: ''
|
||||
})
|
||||
this.toast.showToast('Request marked as prayed', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Request marked as prayed')
|
||||
},
|
||||
showEdit () {
|
||||
this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } })
|
||||
},
|
||||
showNotes () {
|
||||
this.events.$emit('notes', this.request)
|
||||
this.journalEvents.$emit('notes', this.request)
|
||||
},
|
||||
snooze () {
|
||||
this.events.$emit('snooze', this.request.requestId)
|
||||
this.journalEvents.$emit('snooze', this.request.requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,9 +35,9 @@ import actions from '@/store/action-types'
|
|||
|
||||
export default {
|
||||
name: 'request-list-item',
|
||||
inject: ['messages'],
|
||||
props: {
|
||||
request: { required: true },
|
||||
toast: { required: true }
|
||||
request: { required: true }
|
||||
},
|
||||
data () {
|
||||
return {}
|
||||
|
@ -63,7 +63,7 @@ export default {
|
|||
requestId: this.request.requestId,
|
||||
until: 0
|
||||
})
|
||||
this.toast.showToast('Request un-snoozed', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Request un-snoozed')
|
||||
this.$parent.$emit('requestUnsnoozed')
|
||||
},
|
||||
editRequest () {
|
||||
|
@ -75,7 +75,7 @@ export default {
|
|||
requestId: this.request.requestId,
|
||||
showAfter: Date.now()
|
||||
})
|
||||
this.toast.showToast('Recurrence skipped; request now shows in journal', { theme: 'success' })
|
||||
this.messages.$emit('info', 'Recurrence skipped; request now shows in journal')
|
||||
this.$parent.$emit('requestNowShown')
|
||||
},
|
||||
viewFull () {
|
||||
|
|
|
@ -26,8 +26,8 @@ import actions from '@/store/action-types'
|
|||
|
||||
export default {
|
||||
name: 'snooze-request',
|
||||
inject: ['messages'],
|
||||
props: {
|
||||
toast: { required: true },
|
||||
events: { required: true }
|
||||
},
|
||||
data () {
|
||||
|
@ -63,7 +63,7 @@ export default {
|
|||
requestId: this.form.requestId,
|
||||
until: Date.parse(this.form.snoozedUntil)
|
||||
})
|
||||
this.toast.showToast(`Request snoozed until ${this.form.snoozedUntil}`, { theme: 'success' })
|
||||
this.messages.$emit('info', `Request snoozed until ${this.form.snoozedUntil}`)
|
||||
this.closeDialog()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,7 @@ article.mpj-main-content(role='main')
|
|||
No snoozed requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
|
||||
request-list-item(v-for='req in requests'
|
||||
:key='req.requestId'
|
||||
:request='req'
|
||||
:toast='toast')
|
||||
:request='req')
|
||||
p(v-else) Loading journal...
|
||||
</template>
|
||||
|
||||
|
@ -32,9 +31,6 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
toast () {
|
||||
return this.$parent.$refs.toast
|
||||
},
|
||||
...mapState(['journal', 'isLoadingJournal'])
|
||||
},
|
||||
created () {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
/* eslint-disable */
|
||||
|
||||
// Vue packages and components
|
||||
import Vue from 'vue'
|
||||
import VueMaterial from 'vue-material'
|
||||
import Vue from 'vue'
|
||||
import VueMaterial from 'vue-material'
|
||||
import VueProgressBar from 'vue-progressbar'
|
||||
import VueToast from 'vue-toast'
|
||||
|
||||
// myPrayerJournal components
|
||||
import App from './App'
|
||||
|
@ -11,10 +12,11 @@ import store from './store'
|
|||
import DateFromNow from './components/common/DateFromNow'
|
||||
import PageTitle from './components/common/PageTitle'
|
||||
|
||||
/* eslint-enable */
|
||||
|
||||
// Styles
|
||||
import 'vue-material/dist/vue-material.min.css'
|
||||
import 'vue-material/dist/theme/default.css'
|
||||
import 'vue-toast/dist/vue-toast.min.css'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
|
@ -31,8 +33,7 @@ Vue.use(VueProgressBar, {
|
|||
|
||||
Vue.use(VueMaterial)
|
||||
Vue.component('date-from-now', DateFromNow)
|
||||
Vue.component('page-title', PageTitle)
|
||||
Vue.component('toast', VueToast)
|
||||
Vue.component('page-title', PageTitle)
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
'use strict'
|
||||
|
||||
import Vue from 'vue'
|
||||
/* eslint-disable */
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
|
||||
import ActiveRequests from '@/components/request/ActiveRequests'
|
||||
import ActiveRequests from '@/components/request/ActiveRequests'
|
||||
import AnsweredRequests from '@/components/request/AnsweredRequests'
|
||||
import EditRequest from '@/components/request/EditRequest'
|
||||
import FullRequest from '@/components/request/FullRequest'
|
||||
import Home from '@/components/Home'
|
||||
import Journal from '@/components/Journal'
|
||||
import LogOn from '@/components/user/LogOn'
|
||||
import PrivacyPolicy from '@/components/legal/PrivacyPolicy'
|
||||
import SnoozedRequests from '@/components/request/SnoozedRequests'
|
||||
import TermsOfService from '@/components/legal/TermsOfService'
|
||||
import EditRequest from '@/components/request/EditRequest'
|
||||
import FullRequest from '@/components/request/FullRequest'
|
||||
import Home from '@/components/Home'
|
||||
import Journal from '@/components/Journal'
|
||||
import LogOn from '@/components/user/LogOn'
|
||||
import PrivacyPolicy from '@/components/legal/PrivacyPolicy'
|
||||
import SnoozedRequests from '@/components/request/SnoozedRequests'
|
||||
import TermsOfService from '@/components/legal/TermsOfService'
|
||||
/* eslint-enable */
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
'use strict'
|
||||
|
||||
import Vue from 'vue'
|
||||
/* eslint-disable */
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
import api from '@/api'
|
||||
import api from '@/api'
|
||||
import AuthService from '@/auth/AuthService'
|
||||
|
||||
import mutations from './mutation-types'
|
||||
import actions from './action-types'
|
||||
import actions from './action-types'
|
||||
/* eslint-enable */
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
|
|
|
@ -8338,11 +8338,6 @@ vue-template-es2015-compiler@^1.9.0:
|
|||
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
|
||||
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
|
||||
|
||||
vue-toast@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/vue-toast/-/vue-toast-3.1.0.tgz#19eb4c8150faf5c31c12f8b897a955d1ac0b5e9e"
|
||||
integrity sha1-GetMgVD69cMcEvi4l6lV0awLXp4=
|
||||
|
||||
vue@^2.5.15:
|
||||
version "2.6.10"
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637"
|
||||
|
|
Loading…
Reference in New Issue
Block a user