@frigear-nu/base 0.0.0-stage → 0.0.2
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.
- package/README.md +89 -2
- package/app/app.config.ts +14 -0
- package/app/app.d.ts +27 -0
- package/app/app.vue +21 -0
- package/app/assets/css/main.css +29 -0
- package/app/components/App/AppFooter.vue +32 -0
- package/app/components/App/AppFooterLeft.vue +71 -0
- package/app/components/App/AppFooterRight.vue +60 -0
- package/app/components/App/AppHeader.vue +36 -0
- package/app/components/App/AppHeaderBody.vue +44 -0
- package/app/components/App/AppHeaderCTA.vue +3 -0
- package/app/components/App/AppHeaderCenter.vue +32 -0
- package/app/components/App/AppHeaderLogo.vue +6 -0
- package/app/components/App/AppHeaderRight.vue +7 -0
- package/app/components/App/AppLogo.vue +23 -0
- package/app/components/IconMenuToggle.vue +83 -0
- package/app/components/LanguageSelect.vue +65 -0
- package/app/components/content/ContactForm.vue +161 -0
- package/app/components/content/HeroBackground.vue +103 -0
- package/app/composables/useConfig.ts +12 -0
- package/app/composables/useContentLocale.ts +70 -0
- package/app/composables/useCurrentPage.ts +17 -0
- package/app/composables/useNavigation.ts +13 -0
- package/app/composables/usePageSeo.ts +52 -0
- package/app/layouts/default.vue +12 -0
- package/app/layouts/page.vue +14 -0
- package/app/pages/[[lang]]/[...path].vue +28 -0
- package/app/templates/LandingPage.vue +13 -0
- package/app/utils.ts +1 -0
- package/content.config.ts +58 -0
- package/modules/config.ts +10 -0
- package/modules/routing.ts +112 -0
- package/nuxt.config.ts +132 -0
- package/package.json +67 -3
- package/shared/schema/config.ts +57 -0
- package/shared/schema/forms.ts +45 -0
- package/shared/schema/page.ts +23 -0
- package/shared/utils/content.ts +61 -0
- package/utils/pages.ts +12 -0
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @frigear-nu/base
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
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,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,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>
|