(vue) migrate <Header> to composition API with typescript to prevent accessing Nuxt context before it is initialized

This commit is contained in:
Valentyne Stigloher 2025-01-18 01:22:29 +01:00
parent 065aa2ae42
commit 5b77e76a37

View File

@ -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";