@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { FormSubmitEvent } from '@nuxt/ui'
|
|
3
|
+
import {
|
|
4
|
+
contactFormSchema, contactFormSubjectOptions,
|
|
5
|
+
contactFormSubjectSelectItems,
|
|
6
|
+
type ContactSchema,
|
|
7
|
+
} from '../../../shared/schema/forms'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_STATE: Partial<ContactSchema> = {
|
|
10
|
+
name: undefined,
|
|
11
|
+
email: undefined,
|
|
12
|
+
phone: undefined,
|
|
13
|
+
subject: undefined,
|
|
14
|
+
subjectOther: undefined,
|
|
15
|
+
message: undefined,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const form = useTemplateRef('form')
|
|
19
|
+
const isSubmitting = ref(false)
|
|
20
|
+
const state = reactive({ ...DEFAULT_STATE })
|
|
21
|
+
|
|
22
|
+
const toast = useToast()
|
|
23
|
+
|
|
24
|
+
async function onSubmit(event: FormSubmitEvent<ContactSchema>) {
|
|
25
|
+
isSubmitting.value = true
|
|
26
|
+
try {
|
|
27
|
+
await $fetch(['', 'api', '_submit'].join('/'), {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
body: event.data,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
toast.add({
|
|
33
|
+
icon: 'lucide-check-circle',
|
|
34
|
+
title: 'Beskjed sendt!',
|
|
35
|
+
description: 'Vi tar kontak med deg så snart som mulig.',
|
|
36
|
+
color: 'success',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
Object.assign(state, DEFAULT_STATE)
|
|
40
|
+
} catch (err: unknown) {
|
|
41
|
+
toast.add({
|
|
42
|
+
icon: 'lucide-alert-circle',
|
|
43
|
+
title: 'Noe gikk galt',
|
|
44
|
+
description: (err as Error)?.message || 'Ukjent feil.',
|
|
45
|
+
color: 'error',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Since we might encounter unknown things we want to know about...
|
|
49
|
+
throw err
|
|
50
|
+
} finally {
|
|
51
|
+
isSubmitting.value = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// we want to pre-fill the form for the subject if it matches an available one
|
|
56
|
+
const route = useRoute()
|
|
57
|
+
const router = useRouter()
|
|
58
|
+
if (import.meta.client) {
|
|
59
|
+
watch(() => route.query, (nextParams) => {
|
|
60
|
+
if (!nextParams || !nextParams.subject) return
|
|
61
|
+
if (!contactFormSubjectOptions[nextParams.subject as keyof typeof contactFormSubjectOptions]) return
|
|
62
|
+
|
|
63
|
+
nextTick(() => {
|
|
64
|
+
state.subject = nextParams.subject as keyof typeof contactFormSubjectOptions
|
|
65
|
+
router.replace({ query: {} })
|
|
66
|
+
})
|
|
67
|
+
}, { immediate: true })
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<div class="flex flex-row mt-8 items-center justify-center">
|
|
73
|
+
<UPageCard
|
|
74
|
+
title="Send oss en beskjed"
|
|
75
|
+
class="space-y-4 min-w-sm"
|
|
76
|
+
>
|
|
77
|
+
<UForm
|
|
78
|
+
ref="form"
|
|
79
|
+
:schema="contactFormSchema"
|
|
80
|
+
:state="state"
|
|
81
|
+
class="space-y-4 min-w-fit"
|
|
82
|
+
:loading-auto="true"
|
|
83
|
+
@submit="onSubmit"
|
|
84
|
+
>
|
|
85
|
+
<UFormField
|
|
86
|
+
label="Navn"
|
|
87
|
+
name="name"
|
|
88
|
+
required
|
|
89
|
+
>
|
|
90
|
+
<UInput
|
|
91
|
+
v-model="state.name"
|
|
92
|
+
class="w-full"
|
|
93
|
+
/>
|
|
94
|
+
</UFormField>
|
|
95
|
+
|
|
96
|
+
<UFormField
|
|
97
|
+
label="E-post"
|
|
98
|
+
name="email"
|
|
99
|
+
required
|
|
100
|
+
>
|
|
101
|
+
<UInput
|
|
102
|
+
v-model="state.email"
|
|
103
|
+
class="w-full"
|
|
104
|
+
/>
|
|
105
|
+
</UFormField>
|
|
106
|
+
|
|
107
|
+
<UFormField
|
|
108
|
+
label="Telefon"
|
|
109
|
+
name="phone"
|
|
110
|
+
>
|
|
111
|
+
<UInput
|
|
112
|
+
v-model="state.phone"
|
|
113
|
+
class="w-full"
|
|
114
|
+
/>
|
|
115
|
+
</UFormField>
|
|
116
|
+
|
|
117
|
+
<UFormField
|
|
118
|
+
label="Tjeneste/Emne"
|
|
119
|
+
name="subject"
|
|
120
|
+
required
|
|
121
|
+
>
|
|
122
|
+
<USelect
|
|
123
|
+
v-model="state.subject"
|
|
124
|
+
placeholder="Velg tjeneste/emne"
|
|
125
|
+
class="w-full"
|
|
126
|
+
:items="contactFormSubjectSelectItems"
|
|
127
|
+
/>
|
|
128
|
+
</UFormField>
|
|
129
|
+
|
|
130
|
+
<UFormField
|
|
131
|
+
v-if="state.subject === 'other'"
|
|
132
|
+
label="Annet"
|
|
133
|
+
name="subjectOther"
|
|
134
|
+
>
|
|
135
|
+
<UInput
|
|
136
|
+
v-model="state.subjectOther"
|
|
137
|
+
class="w-full"
|
|
138
|
+
/>
|
|
139
|
+
</UFormField>
|
|
140
|
+
|
|
141
|
+
<UFormField
|
|
142
|
+
label="Beskjed"
|
|
143
|
+
name="message"
|
|
144
|
+
required
|
|
145
|
+
>
|
|
146
|
+
<UTextarea
|
|
147
|
+
v-model="state.message"
|
|
148
|
+
class="w-full"
|
|
149
|
+
/>
|
|
150
|
+
</UFormField>
|
|
151
|
+
|
|
152
|
+
<UButton
|
|
153
|
+
type="submit"
|
|
154
|
+
loading-auto
|
|
155
|
+
>
|
|
156
|
+
Send beskjed
|
|
157
|
+
</UButton>
|
|
158
|
+
</UForm>
|
|
159
|
+
</UPageCard>
|
|
160
|
+
</div>
|
|
161
|
+
</template>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { isLoading } = useLoadingIndicator()
|
|
3
|
+
|
|
4
|
+
// Animation phases:
|
|
5
|
+
// 1. Mount -> wait a tick -> fade in (slow, 1s)
|
|
6
|
+
// 2. Once fully appeared -> switch to fast transitions (400ms) for loading pulses
|
|
7
|
+
const hasAppeared = ref(false)
|
|
8
|
+
const isSettled = ref(false)
|
|
9
|
+
|
|
10
|
+
const SETTLE_DELAY = 1000
|
|
11
|
+
|
|
12
|
+
let settleTimeout: ReturnType<typeof setTimeout> | undefined
|
|
13
|
+
|
|
14
|
+
onMounted(async () => {
|
|
15
|
+
await nextTick()
|
|
16
|
+
hasAppeared.value = true
|
|
17
|
+
|
|
18
|
+
settleTimeout = setTimeout(() => {
|
|
19
|
+
isSettled.value = true
|
|
20
|
+
}, SETTLE_DELAY)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
onUnmounted(() => {
|
|
24
|
+
clearTimeout(settleTimeout)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const wrapperClasses = computed(() => [
|
|
28
|
+
isLoading.value ? 'animate-pulse' : hasAppeared.value ? '' : 'opacity-0',
|
|
29
|
+
isSettled.value ? 'duration-[400ms]' : 'duration-1000',
|
|
30
|
+
])
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<ClientOnly>
|
|
35
|
+
<div
|
|
36
|
+
class="absolute w-full -top-px transition-all text-primary shrink-0"
|
|
37
|
+
:class="wrapperClasses"
|
|
38
|
+
>
|
|
39
|
+
<svg
|
|
40
|
+
viewBox="0 0 1440 181"
|
|
41
|
+
fill="none"
|
|
42
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
43
|
+
class="pointer-events-none"
|
|
44
|
+
>
|
|
45
|
+
<mask
|
|
46
|
+
id="path-1-inside-1_414_5526"
|
|
47
|
+
fill="white"
|
|
48
|
+
>
|
|
49
|
+
<path d="M0 0H1440V181H0V0Z" />
|
|
50
|
+
</mask>
|
|
51
|
+
<path
|
|
52
|
+
d="M0 0H1440V181H0V0Z"
|
|
53
|
+
fill="url(#paint0_linear_414_5526)"
|
|
54
|
+
fill-opacity="0.22"
|
|
55
|
+
/>
|
|
56
|
+
<path
|
|
57
|
+
d="M0 2H1440V-2H0V2Z"
|
|
58
|
+
fill="url(#paint1_linear_414_5526)"
|
|
59
|
+
mask="url(#path-1-inside-1_414_5526)"
|
|
60
|
+
/>
|
|
61
|
+
<defs>
|
|
62
|
+
<linearGradient
|
|
63
|
+
id="paint0_linear_414_5526"
|
|
64
|
+
x1="720"
|
|
65
|
+
y1="0"
|
|
66
|
+
x2="720"
|
|
67
|
+
y2="181"
|
|
68
|
+
gradientUnits="userSpaceOnUse"
|
|
69
|
+
>
|
|
70
|
+
<stop stop-color="currentColor" />
|
|
71
|
+
<stop
|
|
72
|
+
offset="1"
|
|
73
|
+
stop-color="currentColor"
|
|
74
|
+
stop-opacity="0"
|
|
75
|
+
/>
|
|
76
|
+
</linearGradient>
|
|
77
|
+
<linearGradient
|
|
78
|
+
id="paint1_linear_414_5526"
|
|
79
|
+
x1="0"
|
|
80
|
+
y1="90.5"
|
|
81
|
+
x2="1440"
|
|
82
|
+
y2="90.5"
|
|
83
|
+
gradientUnits="userSpaceOnUse"
|
|
84
|
+
>
|
|
85
|
+
<stop
|
|
86
|
+
stop-color="currentColor"
|
|
87
|
+
stop-opacity="0"
|
|
88
|
+
/>
|
|
89
|
+
<stop
|
|
90
|
+
offset="0.395"
|
|
91
|
+
stop-color="currentColor"
|
|
92
|
+
/>
|
|
93
|
+
<stop
|
|
94
|
+
offset="1"
|
|
95
|
+
stop-color="currentColor"
|
|
96
|
+
stop-opacity="0"
|
|
97
|
+
/>
|
|
98
|
+
</linearGradient>
|
|
99
|
+
</defs>
|
|
100
|
+
</svg>
|
|
101
|
+
</div>
|
|
102
|
+
</ClientOnly>
|
|
103
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const useConfig = () => {
|
|
2
|
+
const { locale } = useContentLocale()
|
|
3
|
+
const collection = computed(() => localizedCollectionName('config', locale.value))
|
|
4
|
+
|
|
5
|
+
return useAsyncData(
|
|
6
|
+
() => `config:${collection.value}`,
|
|
7
|
+
// Locale-suffixed collections (e.g. `config_no`) share the same schema as
|
|
8
|
+
// `config`, so the cast keeps the return type stable across locales.
|
|
9
|
+
() => queryCollection(collection.value as 'config').first(),
|
|
10
|
+
{ watch: [locale] },
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// `@nuxtjs/i18n` is optional, so we avoid a hard dependency on its types and
|
|
2
|
+
// composables and instead duck-type the `$i18n` plugin it injects when the
|
|
3
|
+
// consuming app installs and configures it.
|
|
4
|
+
type LocaleAwareNuxtApp = ReturnType<typeof useNuxtApp> & {
|
|
5
|
+
$i18n?: { locale: Ref<string>, defaultLocale: string, localeCodes?: Ref<string[]> | string[] }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const useContentLocale = () => {
|
|
9
|
+
const nuxtApp = useNuxtApp() as LocaleAwareNuxtApp
|
|
10
|
+
const defaultLocale = computed(() => nuxtApp.$i18n?.defaultLocale)
|
|
11
|
+
|
|
12
|
+
// `@nuxtjs/i18n` is installed unconditionally by the base layer, but apps
|
|
13
|
+
// that don't configure any `locales` (see `content.config.ts` /
|
|
14
|
+
// `modules/routing.ts`) still get a resolved `$i18n.locale` (e.g. from the
|
|
15
|
+
// `Accept-Language` header or the system locale) even though no such
|
|
16
|
+
// locale was ever configured. Treating that value as significant would
|
|
17
|
+
// point content queries at a locale-suffixed collection (e.g. `pages_en_US`)
|
|
18
|
+
// that doesn't exist, breaking `useCurrentPage`/`useConfig` entirely.
|
|
19
|
+
// Only trust the detected locale when it's one of the app's configured
|
|
20
|
+
// locale codes.
|
|
21
|
+
const availableLocaleCodes = computed(() => {
|
|
22
|
+
const codes = nuxtApp.$i18n?.localeCodes
|
|
23
|
+
return unref(codes) ?? []
|
|
24
|
+
})
|
|
25
|
+
const locale = computed(() => {
|
|
26
|
+
const code = nuxtApp.$i18n?.locale.value
|
|
27
|
+
return code && availableLocaleCodes.value.includes(code) ? code : undefined
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// `@nuxtjs/i18n` prefixes routes with the active locale (e.g. `/en/services`)
|
|
31
|
+
// depending on the configured strategy, but localized content collections
|
|
32
|
+
// are keyed per locale (`pages` -> `content/**`, `pages_no` -> `content/no/**`),
|
|
33
|
+
// and paths within each collection are unprefixed (`/services`). Strip the
|
|
34
|
+
// route's locale prefix so path-based content queries keep matching
|
|
35
|
+
// regardless of the routing strategy (`prefix`, `prefix_except_default`, ...).
|
|
36
|
+
const stripLocalePrefix = (path: string) => {
|
|
37
|
+
const code = locale.value
|
|
38
|
+
if (!code) {
|
|
39
|
+
return path
|
|
40
|
+
}
|
|
41
|
+
const prefix = `/${code}`
|
|
42
|
+
if (path === prefix) {
|
|
43
|
+
return '/'
|
|
44
|
+
}
|
|
45
|
+
if (path.startsWith(`${prefix}/`)) {
|
|
46
|
+
return path.slice(prefix.length)
|
|
47
|
+
}
|
|
48
|
+
return path
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The landing and catch-all pages opt out of `@nuxtjs/i18n`'s automatic
|
|
52
|
+
// route localization (see `modules/routing.ts`) to avoid conflicting,
|
|
53
|
+
// duplicate routes per locale. That means `@nuxtjs/i18n` composables like
|
|
54
|
+
// `switchLocalePath` can't resolve them either, so build the localized
|
|
55
|
+
// path for an arbitrary route ourselves instead.
|
|
56
|
+
const localizePath = (path: string, targetLocale: string) => {
|
|
57
|
+
const unprefixed = stripLocalePrefix(path)
|
|
58
|
+
if (targetLocale === defaultLocale.value) {
|
|
59
|
+
return unprefixed
|
|
60
|
+
}
|
|
61
|
+
return unprefixed === '/' ? `/${targetLocale}` : `/${targetLocale}${unprefixed}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
locale,
|
|
66
|
+
defaultLocale,
|
|
67
|
+
stripLocalePrefix,
|
|
68
|
+
localizePath,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const useCurrentPage = () => {
|
|
2
|
+
const route = useRoute()
|
|
3
|
+
const { locale, stripLocalePrefix } = useContentLocale()
|
|
4
|
+
const collection = computed(() => localizedCollectionName('pages', locale.value))
|
|
5
|
+
const path = computed(() => stripLocalePrefix(route.path))
|
|
6
|
+
|
|
7
|
+
return useAsyncData(
|
|
8
|
+
() => `current-page:${collection.value}:${path.value}`,
|
|
9
|
+
// Locale-suffixed collections (e.g. `pages_no`) share the same schema as
|
|
10
|
+
// `pages`, so the cast keeps the return type stable across locales.
|
|
11
|
+
() => queryCollection(collection.value as 'pages').path(path.value).first(),
|
|
12
|
+
{
|
|
13
|
+
watch: [path, locale, collection],
|
|
14
|
+
dedupe: 'cancel',
|
|
15
|
+
},
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const useNavigation = (tags?: string[]) => {
|
|
2
|
+
return useAsyncData(
|
|
3
|
+
() => `navigation:${tags?.join(',') || 'all'}`,
|
|
4
|
+
async () => {
|
|
5
|
+
const { data: config } = await useConfig() // or however you access the pending promise
|
|
6
|
+
if (!tags) {
|
|
7
|
+
return config.value?.navigation || []
|
|
8
|
+
}
|
|
9
|
+
return (config.value?.navigation || [])
|
|
10
|
+
.filter(item => !tags || tags.some(tag => item?.tags?.includes(tag)))
|
|
11
|
+
},
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { z } from 'zod/v4'
|
|
2
|
+
import type { WebsiteConfigSchema } from '../../shared/schema/config'
|
|
3
|
+
import type { WebsitePageSchema } from '../../shared/schema/page'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Applies title, description and social meta for the current content page.
|
|
7
|
+
*
|
|
8
|
+
* Precedence: page `seo.*` > page `title`/`description` > `config.yaml` `seo.*`
|
|
9
|
+
* > site config. The layer hardcodes no values: with nothing configured the
|
|
10
|
+
* title is the page's own title, without any suffix.
|
|
11
|
+
*/
|
|
12
|
+
export const usePageSeo = () => {
|
|
13
|
+
// Locale-suffixed collections share these schemas, but their generated item
|
|
14
|
+
// types form a union, so narrow to the schema types here.
|
|
15
|
+
const config = useConfig().data as Ref<z.infer<typeof WebsiteConfigSchema> | null | undefined>
|
|
16
|
+
const page = useCurrentPage().data as Ref<z.infer<typeof WebsitePageSchema> | null | undefined>
|
|
17
|
+
const site = useSiteConfig()
|
|
18
|
+
|
|
19
|
+
const title = computed(() =>
|
|
20
|
+
page.value?.seo?.title
|
|
21
|
+
|| page.value?.title
|
|
22
|
+
|| stringOrPropValue(config.value?.name, 'long')
|
|
23
|
+
|| stringOrPropValue(config.value?.name, 'short'),
|
|
24
|
+
)
|
|
25
|
+
const description = computed(() =>
|
|
26
|
+
page.value?.seo?.description || page.value?.description || config.value?.seo?.description,
|
|
27
|
+
)
|
|
28
|
+
const image = computed(() => page.value?.seo?.image || config.value?.seo?.image)
|
|
29
|
+
|
|
30
|
+
const template = computed(() => config.value?.seo?.titleTemplate)
|
|
31
|
+
|
|
32
|
+
useHead({
|
|
33
|
+
titleTemplate: (value) => {
|
|
34
|
+
if (!value) return site.name || null
|
|
35
|
+
if (!template.value || !site.name || value === site.name) return value
|
|
36
|
+
return template.value
|
|
37
|
+
.replace('%s', value)
|
|
38
|
+
.replace('%siteName', site.name)
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
useSeoMeta({
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
ogTitle: title,
|
|
46
|
+
ogDescription: description,
|
|
47
|
+
ogImage: image,
|
|
48
|
+
twitterImage: image,
|
|
49
|
+
twitterCard: computed(() => (image.value ? 'summary_large_image' : undefined)),
|
|
50
|
+
robots: computed(() => (page.value?.seo?.noindex ? 'noindex, nofollow' : undefined)),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
</script>
|
|
3
|
+
|
|
4
|
+
<template>
|
|
5
|
+
<div>
|
|
6
|
+
<AppHeader v-if="$route.meta.header !== false" />
|
|
7
|
+
<UMain>
|
|
8
|
+
<UContainer>
|
|
9
|
+
<slot />
|
|
10
|
+
</UContainer>
|
|
11
|
+
</UMain>
|
|
12
|
+
<AppFooter v-if="$route.meta.footer !== false" />
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { UContainer } from '#components'
|
|
3
|
+
|
|
4
|
+
const { data } = await useCurrentPage()
|
|
5
|
+
|
|
6
|
+
if (!data.value) {
|
|
7
|
+
throw createError({ status: 404, message: 'Page not found' })
|
|
8
|
+
}
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<UPage>
|
|
13
|
+
<UContainer v-if="data && data.pageHeader">
|
|
14
|
+
<LazyUPageHeader
|
|
15
|
+
v-bind="data.pageHeader"
|
|
16
|
+
:ui="{ root: 'text-center', wrapper: 'lg:flex-col lg:items-center lg:justify-center' }"
|
|
17
|
+
/>
|
|
18
|
+
</UContainer>
|
|
19
|
+
<LazyUPageBody>
|
|
20
|
+
<component
|
|
21
|
+
:is="data?.container ? UContainer : 'div'"
|
|
22
|
+
v-if="data"
|
|
23
|
+
>
|
|
24
|
+
<LazyContentRenderer :value="data" />
|
|
25
|
+
</component>
|
|
26
|
+
</LazyUPageBody>
|
|
27
|
+
</UPage>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { data } = await useCurrentPage()
|
|
3
|
+
if (!data.value) {
|
|
4
|
+
throw createError({ status: 404, message: 'Page not found' })
|
|
5
|
+
}
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<LazyContentRenderer
|
|
10
|
+
v-if="data"
|
|
11
|
+
:value="data"
|
|
12
|
+
/>
|
|
13
|
+
</template>
|
package/app/utils.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '../shared/utils/content.ts'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DefinedCollection } from '@nuxt/content'
|
|
3
|
+
import {
|
|
4
|
+
defineCollection,
|
|
5
|
+
defineContentConfig,
|
|
6
|
+
} from '@nuxt/content'
|
|
7
|
+
import { useNuxt } from '@nuxt/kit'
|
|
8
|
+
import { joinURL } from 'ufo'
|
|
9
|
+
import { WebsiteConfigSchema } from './shared/schema/config'
|
|
10
|
+
import { WebsitePageSchema } from './shared/schema/page'
|
|
11
|
+
|
|
12
|
+
const { options } = useNuxt()
|
|
13
|
+
const cwd = joinURL(options.rootDir, 'content')
|
|
14
|
+
|
|
15
|
+
// `@nuxtjs/i18n` is an optional dependency of the consuming app. When it is
|
|
16
|
+
// installed and configured with locales, content can be localized by nesting
|
|
17
|
+
// it under `content/<locale>/**`. Apps that don't opt into i18n keep using
|
|
18
|
+
// the flat `content/**` structure, so this stays fully opt-in.
|
|
19
|
+
const i18nOptions = (options as { i18n?: { locales?: (string | { code: string })[], defaultLocale?: string } }).i18n
|
|
20
|
+
const localeCodes = (i18nOptions?.locales || [])
|
|
21
|
+
.map(locale => (typeof locale === 'string' ? locale : locale.code))
|
|
22
|
+
|
|
23
|
+
const collections: Record<string, DefinedCollection> = {}
|
|
24
|
+
|
|
25
|
+
const defineLocalizedCollections = (locale?: string) => {
|
|
26
|
+
const suffix = locale ? `_${locale.replace(/-/g, '_')}` : ''
|
|
27
|
+
const prefix = locale ? `${locale}/` : ''
|
|
28
|
+
|
|
29
|
+
collections[`config${suffix}`] = defineCollection({
|
|
30
|
+
type: 'data',
|
|
31
|
+
source: {
|
|
32
|
+
cwd,
|
|
33
|
+
include: `${prefix}config.yaml`,
|
|
34
|
+
prefix: '/',
|
|
35
|
+
},
|
|
36
|
+
schema: WebsiteConfigSchema,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
collections[`pages${suffix}`] = defineCollection({
|
|
40
|
+
type: 'page',
|
|
41
|
+
source: {
|
|
42
|
+
cwd,
|
|
43
|
+
include: `${prefix}pages/**/**.md`,
|
|
44
|
+
prefix: '/',
|
|
45
|
+
},
|
|
46
|
+
schema: WebsitePageSchema,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (localeCodes.length > 0) {
|
|
51
|
+
localeCodes.forEach(defineLocalizedCollections)
|
|
52
|
+
} else {
|
|
53
|
+
defineLocalizedCollections()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default defineContentConfig({
|
|
57
|
+
collections,
|
|
58
|
+
})
|