@frigear-nu/base 0.0.0-stage → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +89 -2
  2. package/app/app.config.ts +14 -0
  3. package/app/app.d.ts +27 -0
  4. package/app/app.vue +21 -0
  5. package/app/assets/css/main.css +29 -0
  6. package/app/components/App/AppFooter.vue +32 -0
  7. package/app/components/App/AppFooterLeft.vue +71 -0
  8. package/app/components/App/AppFooterRight.vue +60 -0
  9. package/app/components/App/AppHeader.vue +36 -0
  10. package/app/components/App/AppHeaderBody.vue +44 -0
  11. package/app/components/App/AppHeaderCTA.vue +3 -0
  12. package/app/components/App/AppHeaderCenter.vue +32 -0
  13. package/app/components/App/AppHeaderLogo.vue +6 -0
  14. package/app/components/App/AppHeaderRight.vue +7 -0
  15. package/app/components/App/AppLogo.vue +23 -0
  16. package/app/components/IconMenuToggle.vue +83 -0
  17. package/app/components/LanguageSelect.vue +65 -0
  18. package/app/components/content/ContactForm.vue +161 -0
  19. package/app/components/content/HeroBackground.vue +103 -0
  20. package/app/composables/useConfig.ts +12 -0
  21. package/app/composables/useContentLocale.ts +70 -0
  22. package/app/composables/useCurrentPage.ts +17 -0
  23. package/app/composables/useNavigation.ts +13 -0
  24. package/app/composables/usePageSeo.ts +52 -0
  25. package/app/layouts/default.vue +12 -0
  26. package/app/layouts/page.vue +14 -0
  27. package/app/pages/[[lang]]/[...path].vue +28 -0
  28. package/app/templates/LandingPage.vue +13 -0
  29. package/app/utils.ts +1 -0
  30. package/content.config.ts +58 -0
  31. package/modules/config.ts +10 -0
  32. package/modules/routing.ts +112 -0
  33. package/nuxt.config.ts +132 -0
  34. package/package.json +67 -3
  35. package/shared/schema/config.ts +57 -0
  36. package/shared/schema/forms.ts +45 -0
  37. package/shared/schema/page.ts +23 -0
  38. package/shared/utils/content.ts +61 -0
  39. package/utils/pages.ts +12 -0
package/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # @frigear-nu/base
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared Nuxt layer for the frigear.nu websites.
4
+
5
+ This package is the common foundation used by the Nuxt apps in this monorepo. It centralizes shared configuration, Nuxt modules, app defaults, and styling primitives so the websites stay consistent without duplicating setup in each app.
6
+
7
+ ## What it provides
8
+
9
+ - Shared Nuxt config and module wiring
10
+ - Tailwind and UI defaults
11
+ - Common app-level conventions and aliases
12
+ - Reusable runtime setup for Frigear.nu sites
13
+
14
+ ## Consumers
15
+
16
+ - [apps/website](../../apps/website)
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ pnpm add @frigear-nu/base nuxt
22
+ ```
23
+
24
+ ```ts
25
+ // nuxt.config.ts
26
+ export default defineNuxtConfig({
27
+ extends: ['@frigear-nu/base'],
28
+ })
29
+ ```
30
+
31
+ `nuxt` is a peer dependency. The layer ships as source (`app`, `modules`, `shared`, `utils`, `content.config.ts`,
32
+ `nuxt.config.ts`) with no build step. Its `@frigear-nu/menu` dependency is installed with it.
33
+
34
+ ## Setup
35
+
36
+ From the repository root:
37
+
38
+ ```bash
39
+ corepack enable
40
+ pnpm install
41
+ ```
42
+
43
+ ## Local development
44
+
45
+ This package is usually consumed through the app workspaces rather than run directly. If you need to validate the layer in isolation:
46
+
47
+ ```bash
48
+ pnpm --filter '@frigear-nu/base' typecheck
49
+ pnpm --filter '@frigear-nu/base' lint
50
+ ```
51
+
52
+ ## Common workspace commands
53
+
54
+ ```bash
55
+ # Validate the shared layer
56
+ pnpm --filter '@frigear-nu/base' typecheck
57
+ pnpm --filter '@frigear-nu/base' lint
58
+
59
+ # Run an app that consumes the layer
60
+ pnpm --filter '@frigear-nu/website' dev
61
+ ```
62
+
63
+ ## Optional i18n for content
64
+
65
+ Content collections (`config`, `pages`) support opt-in localization, fully driven by the consuming app:
66
+
67
+ - By default (no i18n configured), content stays flat: `content/config.yaml`, `content/pages/**`.
68
+ - To localize, register `@nuxtjs/i18n` in the consuming app and configure its `locales` (e.g. `['en', 'no']`), then nest content under a locale folder: `content/en/config.yaml`, `content/en/pages/**`, `content/no/config.yaml`, `content/no/pages/**`.
69
+ - When locales are configured, `content.config.ts` defines locale-suffixed collections (`config_en`, `pages_en`, ...) instead of the flat ones. `useConfig`/`useCurrentPage` pick the right collection automatically based on the current `$i18n` locale.
70
+ - No locale folder/config means no behavior change — this feature is entirely opt-in.
71
+
72
+ The `config` and `pages` collections are validated against `WebsiteConfigSchema` and `WebsitePageSchema` (see
73
+ `shared/schema/config.ts` and `shared/schema/page.ts`). For the full schema reference and more detail on the
74
+ localized/non-localized setup, see the [Base Layer docs](../../apps/docs/content/3.layers/1.base.md).
75
+
76
+ Page front matter also accepts `container: true`, which wraps the rendered body in a `UContainer` (use it for plain prose pages such as legal; pages built from `u-page-*` sections manage their own containers). It also accepts `seo: { title, description, image, noindex }`, and `config.yaml` accepts `seo: { titleTemplate, description, image }`; the layer ships no SEO defaults, see the docs.
77
+
78
+ ## Notes
79
+
80
+ - This package is a shared workspace dependency and is linked via `workspace:*` from apps.
81
+ - Keep changes here compatible with both consuming apps.
82
+ - If you change module config or public runtime behavior, validate both app consumers in the same change.
83
+
84
+ ## Publishing
85
+
86
+ Released like the other packages (see the [publishing guide](../../apps/docs/content/1.getting-started/4.publishing.md)):
87
+ the **Release** workflow with `base-layer`, or `pnpm exec release-it` from this folder. Check what ships with
88
+ `pnpm pack --dry-run`. Only the paths in `files` are published; add new top-level folders there. Dependencies the layer
89
+ needs at runtime belong in `dependencies`, not `devDependencies`. There is deliberately no `postinstall` script: it would
90
+ run in every consumer's install.
@@ -0,0 +1,14 @@
1
+ export default defineAppConfig({
2
+ ui: {
3
+ colors: {
4
+ primary: 'green',
5
+ neutral: 'slate',
6
+ },
7
+ },
8
+ header: {
9
+ icon: {
10
+ desktop: false,
11
+ mobile: true,
12
+ },
13
+ },
14
+ })
package/app/app.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ declare module 'nuxt/schema' {
2
+ interface AppConfigInput {
3
+ header?: {
4
+ icon?: {
5
+ desktop?: boolean
6
+ mobile?: boolean
7
+ }
8
+ }
9
+ socials?: Record<string, string>
10
+ github?: {
11
+ url?: string
12
+ }
13
+ }
14
+
15
+ interface AppConfig {
16
+ header?: {
17
+ icon?: {
18
+ desktop?: boolean
19
+ mobile?: boolean
20
+ }
21
+ }
22
+ socials?: Record<string, string>
23
+ github?: {
24
+ url?: string
25
+ }
26
+ }
27
+ }
package/app/app.vue ADDED
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ useHead({
3
+ meta: [
4
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
5
+ ],
6
+ link: [
7
+ { rel: 'icon', href: '/favicon.ico' },
8
+ ],
9
+ })
10
+
11
+ usePageSeo()
12
+ </script>
13
+
14
+ <template>
15
+ <UApp>
16
+ <NuxtLayout>
17
+ <NuxtPage />
18
+ </NuxtLayout>
19
+ <CookieConsentBanner />
20
+ </UApp>
21
+ </template>
@@ -0,0 +1,29 @@
1
+ @import "tailwindcss";
2
+ @import "@nuxt/ui";
3
+
4
+ @theme static {
5
+ --font-sans: 'Public Sans', sans-serif;
6
+
7
+ --color-green-50: #EFFDF5;
8
+ --color-green-100: #D9FBE8;
9
+ --color-green-200: #B3F5D1;
10
+ --color-green-300: #75EDAE;
11
+ --color-green-400: #00DC82;
12
+ --color-green-500: #00C16A;
13
+ --color-green-600: #00A155;
14
+ --color-green-700: #007F45;
15
+ --color-green-800: #016538;
16
+ --color-green-900: #0A5331;
17
+ --color-green-950: #052E16;
18
+ --color-brand-50: #e6f1fb;
19
+ --color-brand-100: #c3ddf1;
20
+ --color-brand-200: #96c4e6;
21
+ --color-brand-300: #a5c7e8;
22
+ --color-brand-400: #87b3d8;
23
+ --color-brand-500: #4299e1;
24
+ --color-brand-600: #3188ca;
25
+ --color-brand-700: #2b78b5;
26
+ --color-brand-800: #25689f;
27
+ --color-brand-900: #1e527d;
28
+ --color-brand-950: #123354;
29
+ }
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ const { data: config } = await useConfig()
3
+ </script>
4
+
5
+ <template>
6
+ <UFooter>
7
+ <template
8
+ v-if="config?.footer && config?.footer?.copyright"
9
+ #left
10
+ >
11
+ <AppFooterLeft
12
+ :parts="Array.isArray(config?.footer?.copyright) ? config?.footer?.copyright : [config?.footer?.copyright]"
13
+ />
14
+ </template>
15
+ <template
16
+ v-if="config?.footer && config?.footer.sections && config?.footer.sections.length"
17
+ #top
18
+ >
19
+ <UContainer>
20
+ <UFooterColumns
21
+ :columns="config?.footer.sections"
22
+ />
23
+ </UContainer>
24
+ </template>
25
+ <template
26
+ v-if="config?.footer && config?.footer.right"
27
+ #right
28
+ >
29
+ <AppFooterRight :links="config?.footer.right" />
30
+ </template>
31
+ </UFooter>
32
+ </template>
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ type ReplaceKey = string | number | symbol | RegExp
3
+ type ReplaceFn = (matches: string[]) => string
4
+ type ReplaceValue = string | number | ReplaceFn
5
+ type ReplaceItem = { match: ReplaceKey, replace: ReplaceValue }
6
+
7
+ const props = withDefaults(defineProps<{
8
+ parts?: string[]
9
+ replacements?: ReplaceItem[]
10
+ }>(), {
11
+ parts: () => ['Copyright ©', '{fullYear}'],
12
+ })
13
+
14
+ const currentUrl = useRequestURL()
15
+
16
+ // todo: move this logic to a shared space
17
+ const allReplacements = computed<ReplaceItem[]>(() => [
18
+ {
19
+ match: '{fullYear}',
20
+ replace: () => String(new Date().getFullYear()),
21
+ },
22
+ {
23
+ match: '{url}',
24
+ replace: () => currentUrl.toString(),
25
+ },
26
+ ...(props.replacements ?? []),
27
+ ])
28
+
29
+ const rawItems = computed(() => {
30
+ if (!props.parts || props.parts.length === 0) return ['']
31
+ return props.parts
32
+ })
33
+
34
+ const normalizeMatch = (match: ReplaceKey): RegExp => {
35
+ if (match instanceof RegExp) return match
36
+ return new RegExp(String(match), 'g')
37
+ }
38
+
39
+ const applyReplace = (
40
+ text: string,
41
+ matcher: RegExp,
42
+ replacer: ReplaceValue,
43
+ ) => {
44
+ if (typeof replacer === 'function') {
45
+ return text.replace(matcher, (...args) => {
46
+ const matches = args.slice(0, -2)
47
+ return replacer(matches)
48
+ })
49
+ }
50
+
51
+ return text.replace(matcher, String(replacer))
52
+ }
53
+
54
+ const replaceParts = (text: string) => {
55
+ return allReplacements.value.reduce((acc, item) => {
56
+ const matcher = normalizeMatch(item.match)
57
+ return applyReplace(acc, matcher, item.replace)
58
+ }, text)
59
+ }
60
+
61
+ const text = computed(() => {
62
+ const joined = [...toValue(rawItems)].join(' ')
63
+ return replaceParts(joined)
64
+ })
65
+ </script>
66
+
67
+ <template>
68
+ <div class="text-sm text-muted">
69
+ <LazyMDC :value="text" />
70
+ </div>
71
+ </template>
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ import type { ButtonProps } from '@nuxt/ui'
3
+
4
+ type FooterLink = {
5
+ 'label'?: string
6
+ 'to'?: string
7
+ 'icon'?: string
8
+ 'variant'?: ButtonProps['variant']
9
+ 'size'?: ButtonProps['size']
10
+ 'target'?: string
11
+ 'aria-label'?: string
12
+ }
13
+
14
+ const props = defineProps<{
15
+ links?: FooterLink[]
16
+ }>()
17
+
18
+ const appConfig = useAppConfig()
19
+ const defaultLinks = computed<FooterLink[]>(() => {
20
+ const links = Object.entries(appConfig.socials || {}).map(([key, url]) => ({
21
+ 'icon': `i-simple-icons-${key}`,
22
+ 'to': url,
23
+ 'target': '_blank',
24
+ 'aria-label': `${key} social link`,
25
+ }))
26
+
27
+ if (appConfig.github?.url) {
28
+ links.push({
29
+ 'icon': 'i-simple-icons-github',
30
+ 'to': appConfig.github.url,
31
+ 'target': '_blank',
32
+ 'aria-label': 'GitHub repository',
33
+ })
34
+ }
35
+
36
+ return links
37
+ })
38
+ const footerLinks = computed<FooterLink[]>(() => props.links !== undefined ? props.links : defaultLinks.value)
39
+ </script>
40
+
41
+ <template>
42
+ <template v-if="footerLinks.length">
43
+ <UButton
44
+ v-for="(link, index) of footerLinks"
45
+ :key="index"
46
+ size="sm"
47
+ v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
48
+ />
49
+ </template>
50
+ <CookieConsentManage
51
+ class="inline-flex size-8 items-center justify-center rounded-md text-muted hover:bg-elevated"
52
+ aria-label="Manage cookies"
53
+ >
54
+ <UIcon
55
+ name="i-lucide-cookie"
56
+ class="size-5"
57
+ />
58
+ </CookieConsentManage>
59
+ <UColorModeButton />
60
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ const { data: config } = await useConfig()
3
+ const { data: navigation } = useMenu('main')
4
+ </script>
5
+
6
+ <template>
7
+ <UHeader
8
+ :ui="{ center: navigation && navigation.length > 0 ? '' : 'flex-1' }"
9
+ to="/"
10
+ :title="stringOrPropValue(config?.name, 'long') || stringOrPropValue(config?.name, 'short')"
11
+ >
12
+ <template #title>
13
+ <AppHeaderLogo class="shrink-0" />
14
+ </template>
15
+
16
+ <AppHeaderCenter />
17
+ <template #right>
18
+ <AppHeaderCTA />
19
+ <LanguageSelect v-if="typeof $i18n !== 'undefined' && $i18n.locales && $i18n.locales.value && $i18n.locales.value.length > 1" />
20
+ <UColorModeButton />
21
+ <AppHeaderRight />
22
+ </template>
23
+
24
+ <template #toggle="{ open, toggle }">
25
+ <IconMenuToggle
26
+ :open="open"
27
+ class="lg:hidden"
28
+ @click="toggle"
29
+ />
30
+ </template>
31
+
32
+ <template #body>
33
+ <LazyAppHeaderBody />
34
+ </template>
35
+ </UHeader>
36
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import type { ContentNavigationItem } from '@nuxt/content'
3
+
4
+ const { data: config } = await useConfig()
5
+
6
+ const items = computed(() => {
7
+ const iconOnMobile = useAppConfig().header?.icon?.mobile
8
+
9
+ if (typeof iconOnMobile === 'boolean' && !iconOnMobile) {
10
+ return config.value?.navigation?.map(item => ({
11
+ ...item,
12
+ icon: undefined,
13
+ }))
14
+ } else {
15
+ return config.value?.navigation
16
+ }
17
+ })
18
+
19
+ const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
20
+ </script>
21
+
22
+ <template>
23
+ <div class="mt-4">
24
+ <UPageGrid v-if="config?.navigation">
25
+ <UPageCard
26
+ v-for="item in items "
27
+ :key="item.label"
28
+ :title="item.label"
29
+ :icon="item.icon"
30
+ :description="item.description"
31
+ :to="item.to"
32
+ class="hober:shadow-lg"
33
+ variant="subtle"
34
+ :ui="{ title: 'text-xl font-semibold' }"
35
+ />
36
+ </UPageGrid>
37
+ <UContentNavigation
38
+ v-else
39
+ highlight
40
+ variant="link"
41
+ :navigation="navigation"
42
+ />
43
+ </div>
44
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <div />
3
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ const { data: navigation } = useMenu('main')
3
+ const items = computed(() => {
4
+ const iconOnDesktop = useAppConfig().header?.icon?.desktop
5
+
6
+ if (typeof iconOnDesktop === 'boolean' && !iconOnDesktop) {
7
+ return navigation?.value?.map(item => ({
8
+ ...item,
9
+ icon: undefined,
10
+ }))
11
+ } else {
12
+ return navigation?.value
13
+ }
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <UNavigationMenu
19
+ v-if="navigation"
20
+ :items="items"
21
+ variant="link"
22
+ />
23
+ <UContentSearchButton
24
+ v-else
25
+ :collapsed="false"
26
+ class="w-full"
27
+ variant="soft"
28
+ :ui="{
29
+ leadingIcon: 'size-4 mr-1',
30
+ }"
31
+ />
32
+ </template>
@@ -0,0 +1,6 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <AppLogo />
6
+ </template>
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ // This file can be overridden by creating a file in the same path in the consuming application.
3
+ </script>
4
+
5
+ <template>
6
+ <span />
7
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import { name } from '~~/package.json'
3
+
4
+ const { data: config } = await useConfig()
5
+ const logoLight = computed(() => stringOrPropValue(config.value?.logo, 'light') || stringOrPropValue(config.value?.logo, 'dark') || '')
6
+ const logoDark = computed(() => stringOrPropValue(config.value?.logo, 'dark') || stringOrPropValue(config.value?.logo, 'light') || '')
7
+ const logoAlt = computed(() => stringOrPropValue(config.value?.logo, 'alt') || stringOrPropValue(config.value?.name, 'long') || name)
8
+ </script>
9
+
10
+ <template>
11
+ <UColorModeImage
12
+ v-if="config?.logo"
13
+ :light="logoLight"
14
+ :dark="logoDark"
15
+ :alt="logoAlt"
16
+ :class="stringOrPropValue(config?.logo, 'class') || []"
17
+ fetchpriority="high"
18
+ rel="preload"
19
+ />
20
+ <span v-else>
21
+ {{ stringOrPropValue(config?.name, 'long') || stringOrPropValue(config?.name, 'short') || name }}
22
+ </span>
23
+ </template>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import { motion } from 'motion-v'
3
+ import type { VariantType } from 'motion-v'
4
+
5
+ const props = defineProps<{
6
+ open: boolean
7
+ }>()
8
+
9
+ const variants: { [k: string]: VariantType | ((custom: unknown) => VariantType) } = {
10
+ normal: {
11
+ rotate: 0,
12
+ y: 0,
13
+ opacity: 1,
14
+ },
15
+ close: (custom: unknown) => {
16
+ const c = custom as number
17
+ return {
18
+ rotate: c === 1 ? 45 : c === 3 ? -45 : 0,
19
+ y: c === 1 ? 6 : c === 3 ? -6 : 0,
20
+ opacity: c === 2 ? 0 : 1,
21
+ transition: {
22
+ type: 'spring',
23
+ stiffness: 260,
24
+ damping: 20,
25
+ },
26
+ }
27
+ },
28
+ }
29
+
30
+ const state = computed(() => props.open ? 'close' : 'normal')
31
+ </script>
32
+
33
+ <template>
34
+ <UButton
35
+ size="sm"
36
+ variant="ghost"
37
+ color="neutral"
38
+ class="-me-1.5"
39
+ square
40
+ >
41
+ <svg
42
+ xmlns="http://www.w3.org/2000/svg"
43
+ class="size-5"
44
+ viewBox="0 0 24 24"
45
+ fill="none"
46
+ stroke="currentColor"
47
+ stroke-width="2"
48
+ stroke-linecap="round"
49
+ stroke-linejoin="round"
50
+ >
51
+ <motion.line
52
+ x1="4"
53
+ y1="6"
54
+ x2="20"
55
+ y2="6"
56
+ :variants="variants"
57
+ :animate="state"
58
+ :custom="1"
59
+ class="outline-none"
60
+ />
61
+ <motion.line
62
+ x1="4"
63
+ y1="12"
64
+ x2="20"
65
+ y2="12"
66
+ :variants="variants"
67
+ :animate="state"
68
+ :custom="2"
69
+ class="outline-none"
70
+ />
71
+ <motion.line
72
+ x1="4"
73
+ y1="18"
74
+ x2="20"
75
+ y2="18"
76
+ :variants="variants"
77
+ :animate="state"
78
+ :custom="3"
79
+ class="outline-none"
80
+ />
81
+ </svg>
82
+ </UButton>
83
+ </template>
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuItem } from '@nuxt/ui'
3
+
4
+ const { locale, locales } = useI18n()
5
+ const route = useRoute()
6
+ const { localizePath } = useContentLocale()
7
+
8
+ const languageToCountry: Record<string, string> = {
9
+ ar: 'sa', bn: 'bd', ca: 'es', ckb: 'iq', cs: 'cz', da: 'dk',
10
+ el: 'gr', en: 'gb', et: 'ee', he: 'il', hi: 'in', hy: 'am',
11
+ ja: 'jp', kk: 'kz', km: 'kh', ko: 'kr', ky: 'kg', lb: 'lu',
12
+ ms: 'my', nb: 'no', sl: 'si', sv: 'se', uk: 'ua', ur: 'pk', vi: 'vn',
13
+ }
14
+
15
+ function getEmojiFlag(loc: string): string {
16
+ const baseLanguage = loc.split('-')[0]?.toLowerCase() || loc
17
+ const countryCode = languageToCountry[baseLanguage] || loc.replace(/^.*-/, '').slice(0, 2)
18
+
19
+ return countryCode.toUpperCase()
20
+ .split('')
21
+ .map(char => String.fromCodePoint(0x1F1A5 + char.charCodeAt(0)))
22
+ .join('')
23
+ }
24
+
25
+ const items = computed<DropdownMenuItem[]>(() =>
26
+ (locales.value || []).map(l => ({
27
+ label: l.name,
28
+ onClick: () => {
29
+ return navigateTo(localizePath(route.path, l.code))
30
+ },
31
+ active: l.code === locale.value,
32
+ // stash the code so the trailing slot can look up the flag
33
+ value: l.code,
34
+ })),
35
+ )
36
+
37
+ const currentLabel = computed(() =>
38
+ (locales.value || []).find(l => l.code === locale.value)?.name ?? locale.value,
39
+ )
40
+ </script>
41
+
42
+ <template>
43
+ <UDropdownMenu
44
+ :items="items"
45
+ :content="{ align: 'end' }"
46
+ :ui="{ content: 'w-44' }"
47
+ >
48
+ <UButton
49
+ color="neutral"
50
+ variant="ghost"
51
+ class="size-8"
52
+ :aria-label="`Change language, current: ${currentLabel}`"
53
+ >
54
+ <template #trailing>
55
+ <span class="text-lg">{{ getEmojiFlag(locale) }}</span>
56
+ </template>
57
+ </UButton>
58
+
59
+ <template #item-trailing="{ item }">
60
+ <span class="size-5 text-center text-base">
61
+ {{ getEmojiFlag((item as any).value) }}
62
+ </span>
63
+ </template>
64
+ </UDropdownMenu>
65
+ </template>