Selaa lähdekoodia

feat: admin language switcher

NGPixel 2 vuotta sitten
vanhempi
sitoutus
1522b26997

+ 1 - 1
server/graph/resolvers/localization.mjs

@@ -5,7 +5,7 @@ export default {
   Query: {
     async locales(obj, args, context, info) {
       let remoteLocales = await WIKI.cache.get('locales')
-      let localLocales = await WIKI.db.locales.query().select('code', 'isRTL', 'name', 'nativeName', 'createdAt', 'updatedAt', 'completeness')
+      let localLocales = await WIKI.db.locales.query().select('code', 'isRTL', 'language', 'name', 'nativeName', 'createdAt', 'updatedAt', 'completeness')
       remoteLocales = remoteLocales || localLocales
       return _.map(remoteLocales, rl => {
         let isInstalled = _.some(localLocales, ['code', rl.code])

+ 1 - 0
server/graph/schemas/localization.graphql

@@ -31,6 +31,7 @@ type LocalizationLocale {
   installDate: Date
   isInstalled: Boolean
   isRTL: Boolean
+  language: String
   name: String
   nativeName: String
   region: String

+ 45 - 43
ux/src/App.vue

@@ -5,15 +5,16 @@ router-view
 <script setup>
 import { nextTick, onMounted, reactive, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
-import { useFlagsStore } from 'src/stores/flags'
-import { useSiteStore } from 'src/stores/site'
-import { useUserStore } from 'src/stores/user'
 import { setCssVar, useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
-import gql from 'graphql-tag'
 
 import '@mdi/font/css/materialdesignicons.css'
 
+import { useCommonStore } from './stores/common'
+import { useFlagsStore } from 'src/stores/flags'
+import { useSiteStore } from 'src/stores/site'
+import { useUserStore } from 'src/stores/user'
+
 /* global siteConfig */
 
 // QUASAR
@@ -22,6 +23,7 @@ const $q = useQuasar()
 
 // STORES
 
+const commonStore = useCommonStore()
 const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
 const userStore = useUserStore()
@@ -54,6 +56,25 @@ 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) {
+      $q.notify({
+        type: 'negative',
+        message: `Failed to load ${locale} locale strings.`,
+        caption: err.message
+      })
+    }
+  }
+  i18n.locale.value = locale
+}
+
 // THEME
 
 async function applyTheme () {
@@ -89,35 +110,6 @@ async function applyTheme () {
   }
 }
 
-// LOCALE
-
-async function fetchLocaleStrings (locale) {
-  try {
-    const resp = await APOLLO_CLIENT.query({
-      query: gql`
-        query fetchLocaleStrings (
-          $locale: String!
-        ) {
-          localeStrings (
-            locale: $locale
-          )
-        }
-      `,
-      fetchPolicy: 'cache-first',
-      variables: {
-        locale
-      }
-    })
-    return resp?.data?.localeStrings
-  } catch (err) {
-    console.warn(err)
-    $q.notify({
-      type: 'negative',
-      message: 'Failed to load locale strings.'
-    })
-  }
-}
-
 // INIT SITE STORE
 
 if (typeof siteConfig !== 'undefined') {
@@ -128,30 +120,40 @@ if (typeof siteConfig !== 'undefined') {
   applyTheme()
 }
 
+// ROUTE GUARDS
+
 router.beforeEach(async (to, from) => {
-  siteStore.routerLoading = true
-  // System Flags
+  commonStore.routerLoading = true
+
+  // -> System Flags
   if (!flagsStore.loaded) {
     flagsStore.load()
   }
-  // Site Info
+
+  // -> Site Info
   if (!siteStore.id) {
     console.info('No pre-cached site config. Loading site info...')
     await siteStore.loadSite(window.location.hostname)
     console.info(`Using Site ID ${siteStore.id}`)
   }
-  // Locales
-  if (!i18n.availableLocales.includes('en')) {
-    i18n.setLocaleMessage('en', await fetchLocaleStrings('en'))
+
+  // -> Locale
+  if (!commonStore.desiredLocale || !siteStore.locales.active.includes(commonStore.desiredLocale)) {
+    commonStore.setLocale(siteStore.locales.primary)
+  } else {
+    applyLocale(commonStore.desiredLocale)
   }
-  // User Auth
+
+  // -> User Auth
   await userStore.refreshAuth()
-  // User Profile
+
+  // -> User Profile
   if (userStore.authenticated && !userStore.profileLoaded) {
     console.info(`Refreshing user ${userStore.id} profile...`)
     await userStore.refreshProfile()
   }
-  // Page Permissions
+
+  // -> Page Permissions
   await userStore.fetchPagePermissions(to.path)
 })
 
@@ -177,7 +179,7 @@ router.afterEach(() => {
     applyTheme()
     document.querySelector('.init-loading').remove()
   }
-  siteStore.routerLoading = false
+  commonStore.routerLoading = false
 })
 
 </script>

+ 6 - 2
ux/src/boot/apollo.js

@@ -3,10 +3,14 @@ import { ApolloClient, InMemoryCache } from '@apollo/client/core'
 import { setContext } from '@apollo/client/link/context'
 import { createUploadLink } from 'apollo-upload-client'
 
-export default boot(({ app, store }) => {
+import { useUserStore } from 'src/stores/user'
+
+export default boot(({ app }) => {
+  const userStore = useUserStore()
+
   // Authentication Link
   const authLink = setContext(async (req, { headers }) => {
-    const token = store.state.value.user.token
+    const token = userStore.token
     return {
       headers: {
         ...headers,

+ 5 - 1
ux/src/boot/i18n.js

@@ -1,10 +1,14 @@
 import { boot } from 'quasar/wrappers'
 import { createI18n } from 'vue-i18n'
 
+import { useCommonStore } from 'src/stores/common'
+
 export default boot(({ app }) => {
+  const commonStore = useCommonStore()
+
   const i18n = createI18n({
     legacy: false,
-    locale: 'en',
+    locale: commonStore.locale || 'en',
     fallbackLocale: 'en',
     fallbackWarn: false,
     messages: {}

+ 3 - 1
ux/src/components/HeaderNav.vue

@@ -68,7 +68,7 @@ q-header.bg-header.text-white.site-header(
       q-space
       transition(name='syncing')
         q-spinner-tail(
-          v-show='siteStore.routerLoading'
+          v-show='commonStore.routerLoading'
           color='accent'
           size='24px'
         )
@@ -130,6 +130,7 @@ import { useI18n } from 'vue-i18n'
 import { useQuasar } from 'quasar'
 import { reactive } from 'vue'
 
+import { useCommonStore } from 'src/stores/common'
 import { useSiteStore } from 'src/stores/site'
 import { useUserStore } from 'src/stores/user'
 
@@ -139,6 +140,7 @@ const $q = useQuasar()
 
 // STORES
 
+const commonStore = useCommonStore()
 const siteStore = useSiteStore()
 const userStore = useUserStore()
 

+ 27 - 14
ux/src/layouts/AdminLayout.vue

@@ -18,11 +18,25 @@ q-layout.admin(view='hHh Lpr lff')
         q-space
         transition(name='syncing')
           q-spinner-tail(
-            v-show='siteStore.routerLoading'
+            v-show='commonStore.routerLoading'
             color='accent'
             size='24px'
           )
-        q-btn.q-ml-md(flat, dense, icon='las la-times-circle', label='Exit' color='pink', to='/')
+        q-btn.q-ml-md(flat, dense, icon='las la-times-circle', :label='t(`common.actions.exit`)' color='pink', to='/')
+        q-btn.q-ml-md(flat, dense, icon='las la-language', :label='commonStore.locale' color='grey-4')
+          q-menu.translucent-menu(auto-close, anchor='bottom right', self='top right')
+            q-list(separator)
+              q-item(
+                v-for='lang of adminStore.locales'
+                clickable
+                @click='commonStore.setLocale(lang.code)'
+                )
+                q-item-section(side)
+                  q-avatar(rounded, :color='lang.code === commonStore.locale ? `secondary` : `primary`', text-color='white', size='sm')
+                    .text-caption.text-uppercase: strong {{ lang.language }}
+                q-item-section
+                  q-item-label {{ lang.name }}
+                  q-item-label(caption) {{ lang.nativeName }}
         account-menu
   q-drawer.admin-sidebar(v-model='leftDrawerOpen', show-if-above, bordered)
     q-scroll-area.admin-nav(
@@ -108,6 +122,9 @@ q-layout.admin(view='hHh Lpr lff')
             q-item-section(avatar)
               q-icon(name='img:/_assets/icons/fluent-ssd.svg')
             q-item-section {{ t('admin.storage.title') }}
+            q-item-section(side)
+              //- TODO: Reflect site storage status
+              status-light(:color='true ? `positive` : `warning`', :pulse='false')
           q-item(:to='`/_admin/` + adminStore.currentSiteId + `/theme`', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`) || userStore.can(`manage:theme`)')
             q-item-section(avatar)
               q-icon(name='img:/_assets/icons/fluent-paint-roller.svg')
@@ -159,7 +176,7 @@ q-layout.admin(view='hHh Lpr lff')
               q-icon(name='img:/_assets/icons/fluent-message-settings.svg')
             q-item-section {{ t('admin.mail.title') }}
             q-item-section(side)
-              status-light(:color='adminStore.info.isMailConfigured ? `positive` : `warning`')
+              status-light(:color='adminStore.info.isMailConfigured ? `positive` : `warning`', :pulse='!adminStore.info.isMailConfigured')
           q-item(to='/_admin/rendering', v-ripple, active-class='bg-primary text-white')
             q-item-section(avatar)
               q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
@@ -169,7 +186,7 @@ q-layout.admin(view='hHh Lpr lff')
               q-icon(name='img:/_assets/icons/fluent-bot.svg')
             q-item-section {{ t('admin.scheduler.title') }}
             q-item-section(side)
-              status-light(:color='adminStore.info.isSchedulerHealthy ? `positive` : `warning`')
+              status-light(:color='adminStore.info.isSchedulerHealthy ? `positive` : `warning`', :pulse='!adminStore.info.isSchedulerHealthy')
           q-item(to='/_admin/security', v-ripple, active-class='bg-primary text-white')
             q-item-section(avatar)
               q-icon(name='img:/_assets/icons/fluent-protect.svg')
@@ -223,6 +240,7 @@ import { useRouter, useRoute } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 
 import { useAdminStore } from 'src/stores/admin'
+import { useCommonStore } from 'src/stores/common'
 import { useFlagsStore } from 'src/stores/flags'
 import { useSiteStore } from 'src/stores/site'
 import { useUserStore } from 'src/stores/user'
@@ -244,6 +262,7 @@ const $q = useQuasar()
 // STORES
 
 const adminStore = useAdminStore()
+const commonStore = useCommonStore()
 const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
 const userStore = useUserStore()
@@ -266,13 +285,6 @@ useMeta({
 // DATA
 
 const leftDrawerOpen = ref(true)
-const overlayIsShown = ref(false)
-const search = ref('')
-const user = reactive({
-  name: 'John Doe',
-  email: 'test@example.com',
-  picture: null
-})
 const thumbStyle = {
   right: '1px',
   borderRadius: '5px',
@@ -292,6 +304,9 @@ const siteSectionShown = computed(() => {
 const usersSectionShown = computed(() => {
   return userStore.can('manage:groups') || userStore.can('manage:users')
 })
+const overlayIsShown = computed(() => {
+  return Boolean(adminStore.overlay)
+})
 
 // WATCHERS
 
@@ -308,9 +323,6 @@ watch(() => adminStore.sites, (newValue) => {
     })
   }
 })
-watch(() => adminStore.overlay, (newValue) => {
-  overlayIsShown.value = !!newValue
-})
 watch(() => adminStore.currentSiteId, (newValue) => {
   if (newValue && route.params.siteid !== newValue) {
     router.push({ params: { siteid: newValue } })
@@ -325,6 +337,7 @@ onMounted(async () => {
     return
   }
 
+  adminStore.fetchLocales()
   await adminStore.fetchSites()
   if (route.params.siteid) {
     adminStore.$patch({

+ 28 - 13
ux/src/stores/admin.js

@@ -35,24 +35,20 @@ export const useAdminStore = defineStore('admin', {
     }
   },
   actions: {
-    async fetchSites () {
+    async fetchLocales () {
       const resp = await APOLLO_CLIENT.query({
         query: gql`
-          query getSites {
-            sites {
-              id
-              hostname
-              isEnabled
-              title
+          query getAdminLocales {
+            locales {
+              code
+              language
+              name
+              nativeName
             }
           }
-        `,
-        fetchPolicy: 'network-only'
+        `
       })
-      this.sites = cloneDeep(resp?.data?.sites ?? [])
-      if (!this.currentSiteId) {
-        this.currentSiteId = this.sites[0].id
-      }
+      this.locales = cloneDeep(resp?.data?.locales ?? [])
     },
     async fetchInfo () {
       const resp = await APOLLO_CLIENT.query({
@@ -78,6 +74,25 @@ export const useAdminStore = defineStore('admin', {
       this.info.isApiEnabled = clone(resp?.data?.apiState ?? false)
       this.info.isMailConfigured = clone(resp?.data?.systemInfo?.isMailConfigured ?? false)
       this.info.isSchedulerHealthy = clone(resp?.data?.systemInfo?.isSchedulerHealthy ?? false)
+    },
+    async fetchSites () {
+      const resp = await APOLLO_CLIENT.query({
+        query: gql`
+          query getSites {
+            sites {
+              id
+              hostname
+              isEnabled
+              title
+            }
+          }
+        `,
+        fetchPolicy: 'network-only'
+      })
+      this.sites = cloneDeep(resp?.data?.sites ?? [])
+      if (!this.currentSiteId) {
+        this.currentSiteId = this.sites[0].id
+      }
     }
   }
 })

+ 43 - 0
ux/src/stores/common.js

@@ -0,0 +1,43 @@
+import { defineStore } from 'pinia'
+import gql from 'graphql-tag'
+
+export const useCommonStore = defineStore('common', {
+  state: () => ({
+    routerLoading: false,
+    locale: localStorage.getItem('locale') || 'en',
+    desiredLocale: localStorage.getItem('locale')
+  }),
+  getters: {},
+  actions: {
+    async fetchLocaleStrings (locale) {
+      try {
+        const resp = await APOLLO_CLIENT.query({
+          query: gql`
+            query fetchLocaleStrings (
+              $locale: String!
+            ) {
+              localeStrings (
+                locale: $locale
+              )
+            }
+          `,
+          fetchPolicy: 'cache-first',
+          variables: {
+            locale
+          }
+        })
+        return resp?.data?.localeStrings
+      } catch (err) {
+        console.warn(err)
+        throw err
+      }
+    },
+    setLocale (locale) {
+      this.$patch({
+        locale,
+        desiredLocale: locale
+      })
+      localStorage.setItem('locale', locale)
+    }
+  }
+})

+ 27 - 14
ux/src/stores/site.js

@@ -6,9 +6,7 @@ import { useUserStore } from './user'
 
 export const useSiteStore = defineStore('site', {
   state: () => ({
-    routerLoading: false,
     id: null,
-    useLocales: false,
     hostname: '',
     company: '',
     contentLicense: '',
@@ -39,6 +37,10 @@ export const useSiteStore = defineStore('site', {
       markdown: false,
       wysiwyg: false
     },
+    locales: {
+      primary: 'en',
+      active: ['en']
+    },
     theme: {
       dark: false,
       injectCSS: '',
@@ -84,6 +86,9 @@ export const useSiteStore = defineStore('site', {
           opacity: isDark ? 0.25 : 1
         }
       }
+    },
+    useLocales: (state) => {
+      return state.locales?.active?.length > 1
     }
   },
   actions: {
@@ -104,20 +109,9 @@ export const useSiteStore = defineStore('site', {
                 hostname: $hostname
                 exact: false
                 ) {
-                id
-                hostname
-                title
-                description
-                logoText
                 company
                 contentLicense
-                footerExtra
-                features {
-                  profile
-                  ratingsMode
-                  reasonForChange
-                  search
-                }
+                description
                 editors {
                   asciidoc {
                     isActive
@@ -129,6 +123,20 @@ export const useSiteStore = defineStore('site', {
                     isActive
                   }
                 }
+                features {
+                  profile
+                  ratingsMode
+                  reasonForChange
+                  search
+                }
+                footerExtra
+                hostname
+                id
+                locales {
+                  primary
+                  active
+                }
+                logoText
                 theme {
                   dark
                   colorPrimary
@@ -144,6 +152,7 @@ export const useSiteStore = defineStore('site', {
                   baseFont
                   contentFont
                 }
+                title
               }
             }
           `,
@@ -171,6 +180,10 @@ export const useSiteStore = defineStore('site', {
               markdown: clone(siteInfo.editors.markdown.isActive),
               wysiwyg: clone(siteInfo.editors.wysiwyg.isActive)
             },
+            locales: {
+              primary: clone(siteInfo.locales.primary),
+              active: clone(siteInfo.locales.active)
+            },
             theme: {
               ...this.theme,
               ...clone(siteInfo.theme)