mirror of https://github.com/requarks/wiki
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.
343 lines
12 KiB
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>
|