mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-22 03:57:47 -04:00
(vue) migrate <Header> to composition API with typescript to prevent accessing Nuxt context before it is initialized
This commit is contained in:
parent
065aa2ae42
commit
5b77e76a37
@ -1,3 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { DateTime } from 'luxon';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
|
||||
import useConfig from '../composables/useConfig.ts';
|
||||
import forbidden from '../src/forbidden.ts';
|
||||
import type { User } from '../src/user.ts';
|
||||
import { useMainStore } from '../store/index.ts';
|
||||
|
||||
import type SearchDialogue from '~/components/search/SearchDialogue.vue';
|
||||
import { newDate } from '~/src/helpers.ts';
|
||||
|
||||
type HeaderTo = { link: string; name?: never } | { name: string; link?: never };
|
||||
|
||||
type HeaderLink = HeaderTo & {
|
||||
header?: boolean;
|
||||
avatar?: User | null;
|
||||
icon: string;
|
||||
text: string;
|
||||
textLong?: string;
|
||||
extra?: (string | null)[];
|
||||
desktop?: boolean;
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
const NounsNav = useLocaleComponent('nouns', 'NounsNav');
|
||||
|
||||
const { $translator: translator, $isGranted: isGranted } = useNuxtApp();
|
||||
const config = useConfig();
|
||||
|
||||
const hoverItem = ref<HeaderLink | null>(null);
|
||||
const searchDialogue = useTemplateRef<typeof SearchDialogue>('searchDialogue');
|
||||
const hamburgerActive = ref(false);
|
||||
const hamburgerShown = ref(false);
|
||||
const censusDismissed = ref(false);
|
||||
|
||||
const today = newDate();
|
||||
|
||||
const { user } = storeToRefs(useMainStore());
|
||||
const links = computed((): HeaderLink[] => {
|
||||
// remember to modify ~/server/api/search.get.ts, page kind too
|
||||
const links: HeaderLink[] = [];
|
||||
|
||||
links.push({
|
||||
header: true,
|
||||
name: 'index',
|
||||
icon: 'home',
|
||||
text: translator.translate('home.header'),
|
||||
textLong: translator.translate('home.link'),
|
||||
});
|
||||
|
||||
if (config.pronouns.enabled) {
|
||||
const extra = ['all'];
|
||||
const prefixes = [...config.pronouns.sentence ? config.pronouns.sentence.prefixes : [], ''];
|
||||
for (const prefix of prefixes) {
|
||||
extra.push(`${prefix}/${config.pronouns.any}`);
|
||||
extra.push(`${prefix}/${config.pronouns.any}:`);
|
||||
if (config.pronouns.null && config.pronouns.null.routes) {
|
||||
for (const route of config.pronouns.null.routes) {
|
||||
extra.push(`${prefix}/${route}`);
|
||||
}
|
||||
}
|
||||
if (config.pronouns.mirror) {
|
||||
extra.push(`${prefix}/${config.pronouns.mirror.route}`);
|
||||
}
|
||||
if (config.pronouns.ask) {
|
||||
for (const route of config.pronouns.ask.routes) {
|
||||
extra.push(`pronouns-${route}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
links.push({
|
||||
name: 'pronouns',
|
||||
icon: 'tags',
|
||||
text: translator.translate('pronouns.header'),
|
||||
textLong: translator.translate('pronouns.headerLong').replace(/(<([^>]+)>)/ig, ''),
|
||||
extra,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.nouns.enabled) {
|
||||
const extras = [];
|
||||
for (const subroute of config.nouns.subroutes || []) {
|
||||
extras.push(`/${subroute}`);
|
||||
}
|
||||
links.push({
|
||||
name: 'nouns',
|
||||
icon: 'book',
|
||||
text: translator.translate('nouns.header'),
|
||||
textLong: translator.translate('nouns.headerLong'),
|
||||
extra: extras,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.sources.enabled) {
|
||||
links.push({
|
||||
name: 'sources',
|
||||
icon: 'books',
|
||||
text: translator.translate('sources.header'),
|
||||
textLong: translator.translate('sources.headerLong'),
|
||||
});
|
||||
}
|
||||
|
||||
if (config.faq.enabled && !config.links.split) {
|
||||
links.push({
|
||||
name: 'faq',
|
||||
icon: 'map-marker-question',
|
||||
text: translator.translate('faq.header'),
|
||||
textLong: translator.translate('faq.headerLong'),
|
||||
});
|
||||
}
|
||||
|
||||
if (config.links.enabled) {
|
||||
links.push({
|
||||
name: 'links',
|
||||
icon: 'bookmark',
|
||||
text: translator.translate('links.header'),
|
||||
textLong: translator.translate('links.headerLong'),
|
||||
extra: [
|
||||
config.links.academicRoute ? `/${config.links.academicRoute}` : '',
|
||||
config.links.translinguisticsRoute ? `/${config.links.translinguisticsRoute}` : '',
|
||||
config.links.splitBlog ? '' : 'blog',
|
||||
config.links.splitBlog ? '' : 'blogEntry',
|
||||
config.links.splitBlog ? '' : 'blogEntryShortcut',
|
||||
config.links.mediaRoute ? `/${config.links.mediaRoute}` : '',
|
||||
config.links.split ? 'faq' : '',
|
||||
config.english && config.english.enabled ? 'english' : '',
|
||||
config.links.zine && config.links.zine.enabled ? `/${config.links.zine.route}` : '',
|
||||
],
|
||||
});
|
||||
|
||||
if (config.links.blog && config.links.splitBlog) {
|
||||
links.push({
|
||||
name: 'blog',
|
||||
icon: 'pen-nib',
|
||||
text: translator.translate('links.blog'),
|
||||
extra: ['blogEntry', 'blogEntryShortcut'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ((config.terminology.enabled && config.terminology.published) ||
|
||||
config.calendar?.enabled ||
|
||||
config.census?.enabled ||
|
||||
config.inclusive?.enabled ||
|
||||
config.people?.enabled ||
|
||||
(config.contact && config.contact.team?.enabled)
|
||||
) {
|
||||
const extra = [
|
||||
config.terminology.enabled && config.terminology.published ? 'terminology' : '',
|
||||
config.calendar?.enabled ? 'calendar' : '',
|
||||
config.calendar?.enabled ? 'calendarDay' : '',
|
||||
config.census.enabled ? 'census' : '',
|
||||
config.inclusive.enabled ? 'inclusive' : '',
|
||||
config.names.enabled && config.names.published ? 'names' : '',
|
||||
config.people.enabled ? 'people' : '',
|
||||
config.contact.enabled ? 'team' : '',
|
||||
config.workshops?.enabled ? 'workshops' : '',
|
||||
];
|
||||
|
||||
if (config.community) {
|
||||
links.push({
|
||||
link: `/${encodeURIComponent(config.community.route)}`,
|
||||
icon: 'users',
|
||||
text: translator.translate('community.header'),
|
||||
textLong: translator.translate('community.headerLong'),
|
||||
extra,
|
||||
});
|
||||
} else if (config.calendar && config.calendar.enabled) {
|
||||
links.push({
|
||||
name: 'calendar',
|
||||
icon: 'calendar-star',
|
||||
text: translator.translate('calendar.header'),
|
||||
textLong: translator.translate('calendar.headerLong'),
|
||||
extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.contact.enabled) {
|
||||
links.push({
|
||||
name: 'contact',
|
||||
icon: 'comment-alt-smile',
|
||||
text: translator.translate('contact.header'),
|
||||
});
|
||||
}
|
||||
|
||||
links.push({
|
||||
link: 'https://shop.pronouns.page',
|
||||
icon: 'shopping-bag',
|
||||
text: translator.translate('contact.groups.shop'),
|
||||
desktop: false,
|
||||
});
|
||||
|
||||
if (config.user.enabled) {
|
||||
links.push({
|
||||
name: 'user',
|
||||
avatar: user.value,
|
||||
icon: 'user',
|
||||
text: user.value ? `@${user.value.username}` : translator.translate('user.header'),
|
||||
textLong: user.value ? `@${user.value.username}` : translator.translate('user.headerLong'),
|
||||
extra: ['/editor', user.value ? `/@${user.value.username}` : null],
|
||||
});
|
||||
if (isGranted('panel')) {
|
||||
links.push({
|
||||
name: 'admin',
|
||||
icon: 'user-cog',
|
||||
text: translator.translate('admin.header'),
|
||||
textLong: translator.translate('admin.header'),
|
||||
extra: [
|
||||
'/admin/users',
|
||||
'/admin/profiles',
|
||||
'/admin/timesheets',
|
||||
'/admin/moderation',
|
||||
'/admin/abuse-reports',
|
||||
'/admin/pending-bans',
|
||||
'/admin/translations/missing',
|
||||
'/admin/translations/awaiting',
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const showCensus = computed((): boolean => {
|
||||
if (!import.meta.client) {
|
||||
return false;
|
||||
}
|
||||
const finished = !!parseInt(window.localStorage.getItem(`census-${config.census.edition}-finished`) || '0');
|
||||
const dismissed = !!parseInt(window.localStorage.getItem(`census-${config.census.edition}-dismissed`) || '0');
|
||||
const alreadyIn = route.name === 'census';
|
||||
const isHomepage = route.name === 'index';
|
||||
if (!config.census.enabled || !isHomepage && (finished || dismissed) || censusDismissed.value || alreadyIn) {
|
||||
return false;
|
||||
}
|
||||
const start = DateTime.fromISO(config.census.start).toLocal();
|
||||
const end = DateTime.fromISO(config.census.end).toLocal();
|
||||
const now = DateTime.utc().setZone(config.format?.timezone ?? 'utc');
|
||||
return now >= start && now <= end;
|
||||
});
|
||||
|
||||
const buildRoute = (link: HeaderLink): RouteLocationRaw => {
|
||||
if (link.name !== undefined) {
|
||||
return { name: link.name };
|
||||
}
|
||||
return link.link;
|
||||
};
|
||||
const isActiveRoute = (link: HeaderLink) => {
|
||||
return route.name === link.name ||
|
||||
link.extra && route.name && link.extra.includes((route.name as string).split(':')[0]) ||
|
||||
(link.extra || []).includes(decodeURIComponent(route.path)) ||
|
||||
(link.extra || []).filter((x) => x && (
|
||||
decodeURIComponent(route.path).startsWith(`${x}/`) ||
|
||||
decodeURIComponent(route.path).startsWith(`${x}:`))).length;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', documentClicked);
|
||||
updateShown();
|
||||
window.addEventListener('scroll', updateShown);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', documentClicked);
|
||||
document.removeEventListener('scroll', updateShown);
|
||||
});
|
||||
|
||||
const documentClicked = (): void => {
|
||||
if (hamburgerActive.value) {
|
||||
hamburgerActive.value = false;
|
||||
}
|
||||
};
|
||||
const updateShown = (): void => {
|
||||
const st = document.body.scrollTop || document.querySelector('html')!.scrollTop;
|
||||
hamburgerShown.value = st > 300;
|
||||
};
|
||||
const dismissCensus = (): void => {
|
||||
window.localStorage.setItem(`census-${config.census.edition}-dismissed`, '1');
|
||||
censusDismissed.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="config.header" class="mb-lg-4">
|
||||
<header @mouseleave="hoverItem = null">
|
||||
@ -249,302 +533,6 @@
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { DateTime } from 'luxon';
|
||||
import { mapState } from 'pinia';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
|
||||
import useConfig from '../composables/useConfig.ts';
|
||||
import forbidden from '../src/forbidden.ts';
|
||||
import { newDate } from '../src/helpers.ts';
|
||||
import type { User } from '../src/user.ts';
|
||||
import { useMainStore } from '../store/index.ts';
|
||||
|
||||
import type SearchDialogue from '~/components/search/SearchDialogue.vue';
|
||||
|
||||
type HeaderTo = { link: string; name?: never } | { name: string; link?: never };
|
||||
|
||||
type HeaderLink = HeaderTo & {
|
||||
header?: boolean;
|
||||
avatar?: User | null;
|
||||
icon: string;
|
||||
text: string;
|
||||
textLong?: string;
|
||||
extra?: (string | null)[];
|
||||
desktop?: boolean;
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
components: { NounsNav: useLocaleComponent('nouns', 'NounsNav') },
|
||||
setup() {
|
||||
const hoverItem = ref<HeaderLink | null>(null);
|
||||
return {
|
||||
config: useConfig(),
|
||||
hoverItem,
|
||||
searchDialogue: useTemplateRef<typeof SearchDialogue>('searchDialogue'),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
today: newDate(),
|
||||
hamburgerActive: false,
|
||||
hamburgerShown: false,
|
||||
censusDismissed: false,
|
||||
forbidden,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useMainStore, [
|
||||
'user',
|
||||
'darkMode',
|
||||
]),
|
||||
links(): HeaderLink[] {
|
||||
// remember to modify ~/server/api/search.get.ts, page kind too
|
||||
|
||||
const links: HeaderLink[] = [];
|
||||
|
||||
links.push({
|
||||
header: true,
|
||||
name: 'index',
|
||||
icon: 'home',
|
||||
text: this.$t('home.header'),
|
||||
textLong: this.$t('home.link'),
|
||||
});
|
||||
|
||||
if (this.config.pronouns.enabled) {
|
||||
const extra = ['all'];
|
||||
const prefixes = [...this.config.pronouns.sentence ? this.config.pronouns.sentence.prefixes : [], ''];
|
||||
for (const prefix of prefixes) {
|
||||
extra.push(`${prefix}/${this.config.pronouns.any}`);
|
||||
extra.push(`${prefix}/${this.config.pronouns.any}:`);
|
||||
if (this.config.pronouns.null && this.config.pronouns.null.routes) {
|
||||
for (const route of this.config.pronouns.null.routes) {
|
||||
extra.push(`${prefix}/${route}`);
|
||||
}
|
||||
}
|
||||
if (this.config.pronouns.mirror) {
|
||||
extra.push(`${prefix}/${this.config.pronouns.mirror.route}`);
|
||||
}
|
||||
if (this.config.pronouns.ask) {
|
||||
for (const route of this.config.pronouns.ask.routes) {
|
||||
extra.push(`pronouns-${route}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
links.push({
|
||||
name: 'pronouns',
|
||||
icon: 'tags',
|
||||
text: this.$t('pronouns.header'),
|
||||
textLong: this.$t('pronouns.headerLong').replace(/(<([^>]+)>)/ig, ''),
|
||||
extra,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.nouns.enabled) {
|
||||
const extras = [];
|
||||
for (const subroute of this.config.nouns.subroutes || []) {
|
||||
extras.push(`/${subroute}`);
|
||||
}
|
||||
links.push({
|
||||
name: 'nouns',
|
||||
icon: 'book',
|
||||
text: this.$t('nouns.header'),
|
||||
textLong: this.$t('nouns.headerLong'),
|
||||
extra: extras,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.sources.enabled) {
|
||||
links.push({
|
||||
name: 'sources',
|
||||
icon: 'books',
|
||||
text: this.$t('sources.header'),
|
||||
textLong: this.$t('sources.headerLong'),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.faq.enabled && !this.config.links.split) {
|
||||
links.push({
|
||||
name: 'faq',
|
||||
icon: 'map-marker-question',
|
||||
text: this.$t('faq.header'),
|
||||
textLong: this.$t('faq.headerLong'),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.links.enabled) {
|
||||
links.push({
|
||||
name: 'links',
|
||||
icon: 'bookmark',
|
||||
text: this.$t('links.header'),
|
||||
textLong: this.$t('links.headerLong'),
|
||||
extra: [
|
||||
this.config.links.academicRoute ? `/${this.config.links.academicRoute}` : '',
|
||||
this.config.links.translinguisticsRoute ? `/${this.config.links.translinguisticsRoute}` : '',
|
||||
this.config.links.splitBlog ? '' : 'blog',
|
||||
this.config.links.splitBlog ? '' : 'blogEntry',
|
||||
this.config.links.splitBlog ? '' : 'blogEntryShortcut',
|
||||
this.config.links.mediaRoute ? `/${this.config.links.mediaRoute}` : '',
|
||||
this.config.links.split ? 'faq' : '',
|
||||
this.config.english && this.config.english.enabled ? 'english' : '',
|
||||
this.config.links.zine && this.config.links.zine.enabled ? `/${this.config.links.zine.route}` : '',
|
||||
],
|
||||
});
|
||||
|
||||
if (this.config.links.blog && this.config.links.splitBlog) {
|
||||
links.push({
|
||||
name: 'blog',
|
||||
icon: 'pen-nib',
|
||||
text: this.$t('links.blog'),
|
||||
extra: ['blogEntry', 'blogEntryShortcut'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.terminology.enabled && this.config.terminology.published ||
|
||||
this.config.calendar && this.config.calendar.enabled ||
|
||||
this.config.census && this.config.census.enabled ||
|
||||
this.config.inclusive && this.config.inclusive.enabled ||
|
||||
this.config.people && this.config.people.enabled ||
|
||||
this.config.contact && this.config.contact.team && this.config.contact.team.enabled
|
||||
) {
|
||||
const extra = [
|
||||
this.config.terminology.enabled && this.config.terminology.published ? 'terminology' : '',
|
||||
this.config.calendar?.enabled ? 'calendar' : '',
|
||||
this.config.calendar?.enabled ? 'calendarDay' : '',
|
||||
this.config.census.enabled ? 'census' : '',
|
||||
this.config.inclusive.enabled ? 'inclusive' : '',
|
||||
this.config.names.enabled && this.config.names.published ? 'names' : '',
|
||||
this.config.people.enabled ? 'people' : '',
|
||||
this.config.contact.enabled ? 'team' : '',
|
||||
this.config.workshops?.enabled ? 'workshops' : '',
|
||||
];
|
||||
|
||||
if (this.config.community) {
|
||||
links.push({
|
||||
link: `/${encodeURIComponent(this.config.community.route)}`,
|
||||
icon: 'users',
|
||||
text: this.$t('community.header'),
|
||||
textLong: this.$t('community.headerLong'),
|
||||
extra,
|
||||
});
|
||||
} else if (this.config.calendar && this.config.calendar.enabled) {
|
||||
links.push({
|
||||
name: 'calendar',
|
||||
icon: 'calendar-star',
|
||||
text: this.$t('calendar.header'),
|
||||
textLong: this.$t('calendar.headerLong'),
|
||||
extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.contact.enabled) {
|
||||
links.push({
|
||||
name: 'contact',
|
||||
icon: 'comment-alt-smile',
|
||||
text: this.$t('contact.header'),
|
||||
});
|
||||
}
|
||||
|
||||
links.push({
|
||||
link: 'https://shop.pronouns.page',
|
||||
icon: 'shopping-bag',
|
||||
text: this.$t('contact.groups.shop'),
|
||||
desktop: false,
|
||||
});
|
||||
|
||||
if (this.config.user.enabled) {
|
||||
const user = this.$user();
|
||||
links.push({
|
||||
name: 'user',
|
||||
avatar: this.$user(),
|
||||
icon: 'user',
|
||||
text: this.user ? `@${this.user.username}` : this.$t('user.header'),
|
||||
textLong: this.user ? `@${this.user.username}` : this.$t('user.headerLong'),
|
||||
extra: ['/editor', user ? `/@${user.username}` : null],
|
||||
});
|
||||
if (this.$isGranted('panel')) {
|
||||
links.push({
|
||||
name: 'admin',
|
||||
icon: 'user-cog',
|
||||
text: this.$t('admin.header'),
|
||||
textLong: this.$t('admin.header'),
|
||||
extra: [
|
||||
'/admin/users',
|
||||
'/admin/profiles',
|
||||
'/admin/timesheets',
|
||||
'/admin/moderation',
|
||||
'/admin/abuse-reports',
|
||||
'/admin/pending-bans',
|
||||
'/admin/translations/missing',
|
||||
'/admin/translations/awaiting',
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
},
|
||||
showCensus(): boolean {
|
||||
if (!import.meta.client) {
|
||||
return false;
|
||||
}
|
||||
const finished = !!parseInt(window.localStorage.getItem(`census-${this.config.census.edition}-finished`) || '0');
|
||||
const dismissed = !!parseInt(window.localStorage.getItem(`census-${this.config.census.edition}-dismissed`) || '0');
|
||||
const alreadyIn = this.$route.name === 'census';
|
||||
const isHomepage = this.$route.name === 'index';
|
||||
if (!this.config.census.enabled || !isHomepage && (finished || dismissed) || this.censusDismissed || alreadyIn) {
|
||||
return false;
|
||||
}
|
||||
const start = DateTime.fromISO(this.config.census.start).toLocal();
|
||||
const end = DateTime.fromISO(this.config.census.end).toLocal();
|
||||
const now = DateTime.utc().setZone(this.config.format?.timezone ?? 'utc');
|
||||
return now >= start && now <= end;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('click', this.documentClicked);
|
||||
this.updateShown();
|
||||
window.addEventListener('scroll', this.updateShown);
|
||||
},
|
||||
unmounted() {
|
||||
document.removeEventListener('click', this.documentClicked);
|
||||
document.removeEventListener('scroll', this.updateShown);
|
||||
},
|
||||
methods: {
|
||||
buildRoute(link: HeaderLink): RouteLocationRaw {
|
||||
if (link.name !== undefined) {
|
||||
return { name: link.name };
|
||||
}
|
||||
return link.link;
|
||||
},
|
||||
isActiveRoute(link: HeaderLink) {
|
||||
return this.$route.name === link.name ||
|
||||
link.extra && this.$route.name && link.extra.includes((this.$route.name as string).split(':')[0]) ||
|
||||
(link.extra || []).includes(decodeURIComponent(this.$route.path)) ||
|
||||
(link.extra || []).filter((x) => x && (
|
||||
decodeURIComponent(this.$route.path).startsWith(`${x}/`) ||
|
||||
decodeURIComponent(this.$route.path).startsWith(`${x}:`))).length;
|
||||
},
|
||||
documentClicked(): void {
|
||||
if (this.hamburgerActive) {
|
||||
this.hamburgerActive = false;
|
||||
}
|
||||
},
|
||||
updateShown(): void {
|
||||
const st = document.body.scrollTop || document.querySelector('html')!.scrollTop;
|
||||
this.hamburgerShown = st > 300;
|
||||
},
|
||||
dismissCensus(): void {
|
||||
window.localStorage.setItem(`census-${this.config.census.edition}-dismissed`, '1');
|
||||
this.censusDismissed = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/variables";
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user