@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.
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
@@ -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,12 @@
1
+ <script setup lang="ts">
2
+ </script>
3
+
4
+ <template>
5
+ <div>
6
+ <AppHeader v-if="$route.meta.header !== false" />
7
+ <UMain>
8
+ <slot />
9
+ </UMain>
10
+ <AppFooter v-if="$route.meta.footer !== false" />
11
+ </div>
12
+ </template>
@@ -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
+ })
@@ -0,0 +1,10 @@
1
+ import {
2
+ defineNuxtModule,
3
+ } from '@nuxt/kit'
4
+
5
+ export default defineNuxtModule({
6
+ meta: {
7
+ name: 'config',
8
+ },
9
+ setup() {},
10
+ })