You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/frontend/src/App.vue

343 lines
12 KiB

<template>
<router-view />
<!-- Mounted once for the whole app; driven by composables/{notify,loading,dialog}.js -->
<w-notifications />
<w-loading-overlay />
<w-dialog-host />
<component :is="DevQuickMenu" v-if="DevQuickMenu" />
</template>
<script setup>
import { defineAsyncComponent, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { setCssVar } from '@/helpers/cssVars'
import { splitLocalePath, stripPageExtension } from '@/helpers/pagePaths'
import { useDark } from '@/composables/dark'
import { notify } from '@/composables/notify'
import WDialogHost from '@/components/shared/WDialogHost.vue'
import WLoadingOverlay from '@/components/shared/WLoadingOverlay.vue'
import WNotifications from '@/components/shared/WNotifications.vue'
import { useCommonStore } from './stores/common'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
/* global siteConfig */
// DEV TOOLS
/*
The dev quick menu, and nothing of it in a release.
`import.meta.env.DEV` is substituted at build time, so a production build sees `false ? … : null`,
drops the branch, and with it the only reference to the dynamic import -- the component is never
emitted as a chunk, not merely never rendered. Keep the import inside this expression for that
reason: a top-level `import` of it would be bundled however it was guarded afterwards.
*/
const DevQuickMenu = import.meta.env.DEV
? defineAsyncComponent(() => import('@/components/DevQuickMenu.vue'))
: null
// DARK MODE
const dark = useDark()
// STORES
const commonStore = useCommonStore()
const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const i18n = useI18n({ useScope: 'global' })
// ROUTER
const router = useRouter()
// STATE
const state = reactive({
isInitialized: false
})
// WATCHERS
watch(
() => userStore.appearance,
(newValue) => {
if (newValue === 'site') {
dark.set(siteStore.theme.dark)
} else {
dark.set(newValue === 'dark')
}
}
)
watch(
() => userStore.cvd,
() => {
applyTheme()
}
)
watch(() => commonStore.locale, applyLocale)
// LOCALE
async function applyLocale(locale) {
if (!i18n.availableLocales.includes(locale)) {
try {
i18n.setLocaleMessage(locale, await commonStore.fetchLocaleStrings(locale))
} catch (err) {
notify({
type: 'negative',
message: `Failed to load ${locale} locale strings.`,
caption: err.message
})
}
}
i18n.locale.value = locale
/*
The document says what language it is in too, not just the strings in it. `index.html` ships
`lang="en"` because that is all a static shell can say, and it stayed that way however the
interface was switched -- so a French page announced itself as English to a screen reader, and to
anything else reading the document for its language.
*/
document.documentElement.lang = locale
}
// THEME
async function applyTheme() {
// -> Dark Mode
if (userStore.appearance === 'site') {
dark.set(siteStore.theme.dark)
} else {
dark.set(userStore.appearance === 'dark')
}
// -> CSS Vars
setCssVar('primary', userStore.getAccessibleColor('primary', siteStore.theme.colorPrimary))
setCssVar('secondary', userStore.getAccessibleColor('secondary', siteStore.theme.colorSecondary))
setCssVar('accent', userStore.getAccessibleColor('accent', siteStore.theme.colorAccent))
setCssVar('header', userStore.getAccessibleColor('header', siteStore.theme.colorHeader))
setCssVar('sidebar', userStore.getAccessibleColor('sidebar', siteStore.theme.colorSidebar))
setCssVar('positive', userStore.getAccessibleColor('positive', '#02C39A'))
setCssVar('negative', userStore.getAccessibleColor('negative', '#f03a47'))
// -> Highlight.js Theme
await applyCodeBlocksTheme()
}
/**
* Every highlight.js theme the admin area offers, as loaders that fetch one on demand.
*
* `?inline` hands back the stylesheet as a STRING rather than injecting it: these have to be scoped to
* the page content before they are applied (see below), which cannot be done to a stylesheet the
* bundler has already added to the document. `**` covers the `base16/` family, since that is how the
* admin's list names half of its options.
*
* Only the theme in use is ever fetched; the rest sit in the build as assets nobody asks for.
*/
const HLJS_THEMES = import.meta.glob('../node_modules/highlight.js/styles/**/*.min.css', {
query: '?inline',
import: 'default'
})
/**
* Paint code blocks in the theme chosen under Admin → Theme.
*
* The stylesheet is wrapped in `.page-contents { ... }` and applied through CSS nesting, for two
* reasons: a highlight.js theme is written as bare `.hljs*` rules that would otherwise reach every
* code sample in the interface, and nesting lifts its selectors to the same weight as the fallback
* palette in `_page-contents.scss` -- so this one wins on being applied later, which is exactly the
* relationship wanted. With no theme chosen, nothing is injected and that fallback is what shows.
*/
async function applyCodeBlocksTheme() {
document.querySelector('#hljs-theme')?.remove()
// -> A colour-vision-deficient palette cannot be honoured per theme, so it takes a neutral one
const desiredHljsTheme = userStore.cvd !== 'none' ? 'github' : siteStore.theme.codeBlocksTheme
if (!desiredHljsTheme) {
return
}
const load = HLJS_THEMES[`../node_modules/highlight.js/styles/${desiredHljsTheme}.min.css`]
if (!load) {
// -> A name the admin area offers that highlight.js does not ship; the fallback palette stands in
console.warn(`Unknown code blocks theme: ${desiredHljsTheme}`)
return
}
const styleEl = document.createElement('style')
styleEl.id = 'hljs-theme'
styleEl.textContent = `.page-contents {\n${await load()}\n}`
document.head.appendChild(styleEl)
}
// INIT SITE STORE
if (typeof siteConfig !== 'undefined') {
siteStore.$patch({
id: siteConfig.id,
title: siteConfig.title
})
applyTheme()
}
/**
* Everything the app has to know before it can draw: which site it is on, which system flags are set,
* and who is asking.
*
* The three have endpoints of their own, and are still asked separately where they change on their
* own — the admin area saves flags, a login changes who is asking. This is the load, where all three
* are wanted at once and none of them is known yet.
*
* A failure leaves the stores at their defaults and says so in the console, which is what the three
* calls did before: there is no interface yet to put an error in front of.
*/
async function loadBootstrap() {
try {
const data = await API_CLIENT.get('bootstrap', {
searchParams: { hostname: window.location.hostname },
cache: 'no-store'
}).json()
// -> Before the site: `applySiteInfo` resolves the site's active locale codes against this
siteStore.installedLocales = data.locales ?? []
siteStore.applySiteInfo(data.site)
flagsStore.apply(data.flags)
userStore.applyProfile(data.user)
} catch (err) {
console.warn(`Could not load the site configuration: ${err.message}`)
}
}
// ROUTE GUARDS
router.beforeEach(async (to, from) => {
commonStore.routerLoading = true
/*
-> Site info, system flags and the session
One request for the three of them: none touches the database, so what they cost is the round trip,
and a full load paid it three times over before it could draw anything. Asked once — a guest is an
answer like any other, so this does not run again on the way to the next page.
*/
if (!siteStore.id || !flagsStore.loaded || !userStore.profileLoaded) {
await loadBootstrap()
}
/*
-> Page extensions
A path ending in one of the extensions the site's content is written in addresses the page
underneath it, so `/foo/bar.md` is `/foo/bar`. The server redirects a request that reaches it, but
a link inside a page is followed by the router alone -- which is what this is for. Below the
bootstrap above, since that is where the site's extensions come from. A `/_` route is the app
itself rather than a page, and is left alone as it is by the server.
*/
const isPagePath = !to.path.startsWith('/_')
const withoutExtension = isPagePath ? stripPageExtension(to.path, siteStore.pageExtensions) : null
if (withoutExtension) {
return { path: withoutExtension, query: to.query, hash: to.hash, replace: true }
}
/*
-> Locale prefix
A site that brackets its URLs by locale sends a path arriving without one to its primary locale, so
that every page has a single address. The server does this for a request that reaches it; this is
the same rule for a link inside a page, which the router follows on its own. The prefix is the
locale's short code -- `/fr` for `fr-FR` -- the same segment its content is filed under.
*/
if (
isPagePath &&
siteStore.locales.forcePrefix &&
!splitLocalePath(to.path, siteStore.localePrefixes)
) {
const primary = siteStore.localeAlias(siteStore.locales.primary)
return {
path: `/${primary}${to.path === '/' ? '' : to.path}`,
query: to.query,
hash: to.hash,
replace: true
}
}
/*
-> Locale
On a page, the interface speaks whatever the page is written in -- the prefix in the URL when
there is one, and the site's PRIMARY locale when there is not, because that is what an unprefixed
path resolves to. Falling back to the stored choice instead is what left `/` showing the English
home page with a French interface and French in the picker, after a detour through `/fr/...`.
It replaces the stored choice rather than shadowing it, which is what carries the switch on to a
screen with no locale in its path: the admin area and the profile are not pages, and keep it. The
site's primary is also the fallback for a first visit, and for a stored locale the site no longer
offers.
*/
const pageLocale = isPagePath
? (splitLocalePath(to.path, siteStore.localePrefixes)?.locale ?? siteStore.locales.primary)
: null
if (pageLocale) {
if (pageLocale !== commonStore.desiredLocale) {
commonStore.setLocale(pageLocale)
}
} else if (
!commonStore.desiredLocale ||
!siteStore.locales.active.some((l) => l.code === commonStore.desiredLocale)
) {
commonStore.setLocale(siteStore.locales.primary)
}
applyLocale(commonStore.desiredLocale)
/*
-> Page Permissions
Not fetched here any more: what this reader may do at a path comes back with the page itself, so
a page view is one request rather than two. What is left is the routes that are not a page —
dropping the last page's permissions on the way out of the page view, which takes no request at
all. A path with no page behind it has nothing to carry them, and asks in `pages/Index.vue`.
*/
if (to.path.startsWith('/_')) {
userStore.$patch({ pagePermissions: [] })
}
})
// GLOBAL EVENTS HANDLERS
EVENT_BUS.on('logout', ({ redirect } = {}) => {
const target = redirect || '/'
// -> A group or the site can send logged out users to another site entirely, which the router cannot
// navigate to — and leaving the wiki means there is no point notifying anyone either
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) {
window.location.assign(target)
return
}
router.push(target)
notify({
type: 'positive',
icon: 'mdi:logout',
message: i18n.t('auth.logoutSuccess')
})
})
EVENT_BUS.on('applyTheme', () => {
applyTheme()
})
// LOADER
router.afterEach(() => {
if (!state.isInitialized) {
state.isInitialized = true
applyTheme()
document.querySelector('.init-loading').remove()
}
commonStore.routerLoading = false
})
</script>