kmcom-nuxt-layers 1.5.1 → 1.6.1

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.
@@ -3,7 +3,7 @@ import type { BlogQueryOptions } from '../types/content'
3
3
  export function useBlogPosts(options: BlogQueryOptions = {}) {
4
4
  const { excludeDrafts = true, tags, limit } = options
5
5
 
6
- return useAsyncData('blog-posts', async () => {
6
+ return useContentData('blog-posts', async () => {
7
7
  let posts = await queryCollection('blog').order('date', 'DESC').all()
8
8
 
9
9
  if (excludeDrafts) {
@@ -1,5 +1,5 @@
1
1
  export function useCollectionItem(collection: string, slug: string) {
2
- return useAsyncData(`${collection}-${slug}`, () =>
2
+ return useContentData(`${collection}-${slug}`, () =>
3
3
  queryCollection(collection).path(`/${collection}/${slug}`).first()
4
4
  )
5
5
  }
@@ -3,7 +3,7 @@ export function useCollectionSurround(
3
3
  slug: string,
4
4
  fields: string[] = ['description']
5
5
  ) {
6
- return useAsyncData(`${collection}-${slug}-surround`, () =>
6
+ return useContentData(`${collection}-${slug}-surround`, () =>
7
7
  queryCollectionItemSurroundings(collection, `/${collection}/${slug}`, { fields })
8
8
  )
9
9
  }
@@ -0,0 +1,3 @@
1
+ export const useContentData = createUseAsyncData({
2
+ dedupe: 'cancel',
3
+ })
@@ -1,3 +1,3 @@
1
1
  export function useContentPage(path: string) {
2
- return useAsyncData(`content-page-${path}`, () => queryCollection('content').path(path).first())
2
+ return useContentData(`content-page-${path}`, () => queryCollection('content').path(path).first())
3
3
  }
@@ -3,7 +3,7 @@ import type { GalleryQueryOptions } from '../types/content'
3
3
  export function useGalleryItems(options: GalleryQueryOptions = {}) {
4
4
  const { tags, limit } = options
5
5
 
6
- return useAsyncData('gallery-items', async () => {
6
+ return useContentData('gallery-items', async () => {
7
7
  let items = await queryCollection('gallery').order('date', 'DESC').all()
8
8
 
9
9
  if (tags?.length) {
@@ -3,7 +3,7 @@ import type { PortfolioQueryOptions } from '../types/content'
3
3
  export function usePortfolioItems(options: PortfolioQueryOptions = {}) {
4
4
  const { featured, tags, limit } = options
5
5
 
6
- return useAsyncData('portfolio-items', async () => {
6
+ return useContentData('portfolio-items', async () => {
7
7
  let items = await queryCollection('portfolio').order('year', 'DESC').all()
8
8
 
9
9
  if (featured !== undefined) {
@@ -0,0 +1,3 @@
1
+ export const useFormsFetch = createUseFetch({
2
+ baseURL: '/api/forms',
3
+ })
@@ -84,9 +84,6 @@ provide('pageTitle', title)
84
84
 
85
85
  <!-- Main content slot -->
86
86
  <slot />
87
-
88
- <!-- Grid debugging helper -->
89
- <LayoutGridDebug />
90
87
  </template>
91
88
 
92
89
  <!-- UPage Layout Mode - needs wrapper with MastMain -->
@@ -96,8 +93,5 @@ provide('pageTitle', title)
96
93
  <slot />
97
94
  </UPage>
98
95
  </MastMain>
99
-
100
- <!-- Grid debugging helper -->
101
- <LayoutGridDebug />
102
96
  </div>
103
97
  </template>
@@ -1,4 +1,8 @@
1
1
  export default defineAppConfig({
2
+ mastNav: {
3
+ links: [] as Array<{ id: string; label: string; to: string | { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } }>,
4
+ scrollBehaviour: 'router' as 'smooth-scroll' | 'router',
5
+ },
2
6
  uiLayer: {
3
7
  gradients: {
4
8
  brand: { shape: 'linear', direction: 'to-br', from: { color: 'primary', shade: 500 }, to: { color: 'secondary', shade: 600 } },
@@ -35,6 +39,10 @@ export default defineAppConfig({
35
39
 
36
40
  declare module '@nuxt/schema' {
37
41
  interface AppConfigInput {
42
+ mastNav?: {
43
+ links?: Array<{ id: string; label: string; to: string | { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } }>
44
+ scrollBehaviour?: 'smooth-scroll' | 'router'
45
+ }
38
46
  uiLayer?: {
39
47
  name?: string
40
48
  gradients?: Record<string, import('./types/gradient').GradientConfig>
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * BaseModal — overlay-compatible modal shell.
4
+ *
5
+ * Use this as a starting point for new modals:
6
+ * 1. Copy or extend this component
7
+ * 2. Add your props and content in the slots
8
+ * 3. Register with createModal() in a composable
9
+ *
10
+ * Example:
11
+ * // components/ConfirmModal.vue
12
+ * <BaseModal title="Are you sure?" @confirm="..." />
13
+ *
14
+ * // composables/confirmModal.ts
15
+ * export const useConfirmModal = createModal(ConfirmModal)
16
+ */
17
+ const { open = false, title, description, size = 'md' } = defineProps<{
18
+ open?: boolean
19
+ title?: string
20
+ description?: string
21
+ size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
22
+ }>()
23
+
24
+ const emit = defineEmits<{
25
+ 'update:open': [value: boolean]
26
+ 'close': []
27
+ }>()
28
+
29
+ const sizeClass: Record<string, string> = {
30
+ sm: 'max-w-sm',
31
+ md: 'max-w-md',
32
+ lg: 'max-w-lg',
33
+ xl: 'max-w-xl',
34
+ full: 'max-w-full',
35
+ }
36
+
37
+ function dismiss() {
38
+ emit('update:open', false)
39
+ emit('close')
40
+ }
41
+ </script>
42
+
43
+ <template>
44
+ <Transition name="base-modal">
45
+ <div
46
+ v-if="open"
47
+ class="fixed inset-0 z-50 flex items-center justify-center p-4"
48
+ @click.self="dismiss"
49
+ >
50
+ <!-- Backdrop -->
51
+ <div class="bg-(--ui-bg-muted) absolute inset-0 opacity-60" @click="dismiss" />
52
+
53
+ <!-- Panel -->
54
+ <div
55
+ class="bg-(--ui-bg) ring-(--ui-border) relative w-full rounded-2xl shadow-xl ring-1"
56
+ :class="sizeClass[size]"
57
+ >
58
+ <!-- Header -->
59
+ <div v-if="title || $slots.header" class="border-(--ui-border) flex items-start justify-between border-b px-6 py-4">
60
+ <slot name="header">
61
+ <div>
62
+ <p class="text-(--ui-text) text-base font-semibold">
63
+ {{ title }}
64
+ </p>
65
+ <p v-if="description" class="text-(--ui-text-muted) mt-0.5 text-sm">
66
+ {{ description }}
67
+ </p>
68
+ </div>
69
+ </slot>
70
+ <button
71
+ class="text-(--ui-text-muted) hover:text-(--ui-text) ml-4 cursor-pointer border-0 bg-transparent p-1 transition-colors"
72
+ aria-label="Close"
73
+ @click="dismiss"
74
+ >
75
+ <UIcon name="lucide:x" class="size-5" />
76
+ </button>
77
+ </div>
78
+
79
+ <!-- Body -->
80
+ <div class="px-6 py-5">
81
+ <slot />
82
+ </div>
83
+
84
+ <!-- Footer -->
85
+ <div v-if="$slots.footer" class="border-(--ui-border) flex justify-end gap-3 border-t px-6 py-4">
86
+ <slot name="footer" :dismiss="dismiss" />
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </Transition>
91
+ </template>
92
+
93
+ <style scoped>
94
+ .base-modal-enter-active,
95
+ .base-modal-leave-active {
96
+ transition: opacity 200ms ease;
97
+ }
98
+ .base-modal-enter-from,
99
+ .base-modal-leave-to {
100
+ opacity: 0;
101
+ }
102
+ .base-modal-enter-active .bg-\(--ui-bg\),
103
+ .base-modal-leave-active .bg-\(--ui-bg\) {
104
+ transition: opacity 200ms ease, transform 200ms ease;
105
+ }
106
+ .base-modal-enter-from .bg-\(--ui-bg\),
107
+ .base-modal-leave-to .bg-\(--ui-bg\) {
108
+ opacity: 0;
109
+ transform: scale(0.96) translateY(4px);
110
+ }
111
+ </style>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ const { open } = useMastNav()
3
+ const z = useGridConfig().useZIndex('dropdown')
4
+ </script>
5
+
6
+ <template>
7
+ <button
8
+ aria-label="Open navigation menu"
9
+ class="fixed right-0 top-0 flex cursor-pointer items-center justify-center border-0 bg-transparent p-4"
10
+ :style="{ zIndex: z }"
11
+ @click="open"
12
+ >
13
+ <slot name="icon">
14
+ <UIcon name="lucide:menu" class="size-7" />
15
+ </slot>
16
+ </button>
17
+ </template>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ const { open } = defineProps<{ open?: boolean }>()
3
+ const emit = defineEmits<{
4
+ 'update:open': [value: boolean]
5
+ 'close': []
6
+ }>()
7
+
8
+ const { links, scrollBehaviour } = useAppConfig().mastNav
9
+ const activeSection = useState<string>('activeSection', () => '')
10
+ const route = useRoute()
11
+ const { close: closeNav } = useMastNav()
12
+
13
+ function dismiss() {
14
+ emit('update:open', false)
15
+ emit('close')
16
+ closeNav()
17
+ }
18
+
19
+ function handleNav(link: { id: string; to: string | { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } }) {
20
+ if (scrollBehaviour === 'smooth-scroll' && route.name === 'index') {
21
+ try { useSmoothScroll().scrollTo(`#${link.id}`) }
22
+ catch {}
23
+ }
24
+ else {
25
+ navigateTo(link.to)
26
+ }
27
+ dismiss()
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <Transition name="nav-modal">
33
+ <div
34
+ v-if="open"
35
+ class="bg-(--ui-bg) text-(--ui-text) fixed inset-0 flex flex-col items-center justify-center"
36
+ :style="{ zIndex: useGridConfig().useZIndex('modal') }"
37
+ >
38
+ <button
39
+ class="absolute right-[5vw] top-[3vh] cursor-pointer border-0 bg-transparent p-2"
40
+ aria-label="Close navigation"
41
+ @click="dismiss"
42
+ >
43
+ <UIcon name="lucide:x" class="size-7" />
44
+ </button>
45
+
46
+ <LinksGroup tag="nav" class="flex flex-col items-center gap-2">
47
+ <button
48
+ v-for="link in links"
49
+ :key="link.id"
50
+ class="cursor-pointer border-0 bg-transparent px-4 py-3 text-3xl uppercase tracking-widest transition-all"
51
+ :class="activeSection === link.id ? 'font-normal' : 'font-light'"
52
+ @click="handleNav(link)"
53
+ >
54
+ {{ link.label }}
55
+ </button>
56
+ </LinksGroup>
57
+ </div>
58
+ </Transition>
59
+ </template>
60
+
61
+ <style scoped>
62
+ .nav-modal-enter-active,
63
+ .nav-modal-leave-active {
64
+ transition: opacity 300ms ease;
65
+ }
66
+ .nav-modal-enter-from,
67
+ .nav-modal-leave-to {
68
+ opacity: 0;
69
+ }
70
+ </style>
@@ -0,0 +1,25 @@
1
+ import { createSharedComposable } from '@vueuse/core'
2
+ import MastNavModal from '../components/Mast/NavModal.vue'
3
+
4
+ function _useMastNav() {
5
+ if (import.meta.server) return { open: () => {}, close: () => {} }
6
+
7
+ const overlay = useOverlay()
8
+ const modal = overlay.create(MastNavModal)
9
+
10
+ function open() {
11
+ try { useSmoothScroll().lockScrolling() }
12
+ catch {}
13
+ modal.open()
14
+ }
15
+
16
+ function close() {
17
+ modal.close()
18
+ try { useSmoothScroll().unlockScrolling() }
19
+ catch {}
20
+ }
21
+
22
+ return { open, close }
23
+ }
24
+
25
+ export const useMastNav = createSharedComposable(_useMastNav)
@@ -0,0 +1,22 @@
1
+ type ToastOptions = {
2
+ description?: string
3
+ duration?: number
4
+ }
5
+
6
+ export function useAppToast() {
7
+ const toast = useToast()
8
+
9
+ return {
10
+ success: (title: string, opts?: ToastOptions) =>
11
+ toast.add({ title, icon: 'lucide:check-circle', color: 'success', ...opts }),
12
+
13
+ error: (title: string, opts?: ToastOptions) =>
14
+ toast.add({ title, icon: 'lucide:x-circle', color: 'error', ...opts }),
15
+
16
+ info: (title: string, opts?: ToastOptions) =>
17
+ toast.add({ title, icon: 'lucide:info', color: 'info', ...opts }),
18
+
19
+ warning: (title: string, opts?: ToastOptions) =>
20
+ toast.add({ title, icon: 'lucide:triangle-alert', color: 'warning', ...opts }),
21
+ }
22
+ }
@@ -8,14 +8,11 @@ const hasMastHeader = typeof mastHeader !== 'string'
8
8
  const hasMastFooter = typeof mastFooter !== 'string'
9
9
  </script>
10
10
 
11
- <!-- eslint-disable vue/no-multiple-template-root -->
12
11
  <template>
13
- <MastScroller>
14
- <component :is="mastHeader" v-if="hasMastHeader" />
15
- <MastMain>
16
- <slot />
17
- </MastMain>
18
- <component :is="mastFooter" v-if="hasMastFooter" />
19
- <LayoutGridDebug />
20
- </MastScroller>
12
+ <component :is="mastHeader" v-if="hasMastHeader" />
13
+ <MastMain>
14
+ <slot />
15
+ </MastMain>
16
+ <component :is="mastFooter" v-if="hasMastFooter" />
17
+ <UOverlayProvider />
21
18
  </template>
@@ -0,0 +1,34 @@
1
+ import { createSharedComposable } from '@vueuse/core'
2
+ import type { Component } from 'vue'
3
+
4
+ /**
5
+ * Factory that turns any overlay-compatible component into a shared modal composable.
6
+ *
7
+ * Usage:
8
+ * // composables/myModal.ts
9
+ * import MyModal from '../components/MyModal.vue'
10
+ * export const useMyModal = createModal(MyModal)
11
+ *
12
+ * // anywhere in the app
13
+ * const { open, close, patch } = useMyModal()
14
+ * open({ title: 'Hello' })
15
+ *
16
+ * The returned composable is wrapped with createSharedComposable so overlay.create()
17
+ * is only called once regardless of how many components call it.
18
+ *
19
+ * The modal component must accept `open: boolean` and emit `update:open` + `close`.
20
+ * Extend BaseModal.vue as a starting point.
21
+ */
22
+ export function createModal<P extends Record<string, unknown>>(component: Component) {
23
+ return createSharedComposable(() => {
24
+ if (import.meta.server) return { open: () => {}, close: () => {}, patch: () => {} }
25
+
26
+ const overlay = useOverlay()
27
+ const modal = overlay.create<P>(component)
28
+ return {
29
+ open: (props?: Partial<P>) => modal.open(props),
30
+ close: () => modal.close(),
31
+ patch: (props: Partial<P>) => modal.patch(props),
32
+ }
33
+ })
34
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kmcom-nuxt-layers",
3
3
  "private": false,
4
- "version": "1.5.1",
4
+ "version": "1.6.1",
5
5
  "description": "Composable Nuxt 4 layers for building scalable Vue applications",
6
6
  "files": [
7
7
  "layers/*/nuxt.config.ts",
@@ -35,7 +35,7 @@
35
35
  "better-sqlite3": "^12.6.2",
36
36
  "gsap": "^3.14.2",
37
37
  "locomotive-scroll": "^5.0.1",
38
- "nuxt": "^4.3.1",
38
+ "nuxt": "^4.4.2",
39
39
  "nuxt-studio": "^1.4.0",
40
40
  "pinia": "^3.0.4",
41
41
  "tailwindcss": "^4.2.1",