qdadm 0.15.1 → 0.16.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -3,8 +3,8 @@
3
3
  * BoolCell - Standardized boolean display for list columns
4
4
  *
5
5
  * Tristate display:
6
- * - true: green check
7
- * - false: red cross
6
+ * - true / "true" / 1: green check
7
+ * - false / "false" / 0: red cross
8
8
  * - null/undefined: empty
9
9
  *
10
10
  * Usage:
@@ -14,15 +14,20 @@
14
14
  * </template>
15
15
  * </Column>
16
16
  */
17
- defineProps({
17
+ import { computed } from 'vue'
18
+
19
+ const props = defineProps({
18
20
  value: {
19
- type: Boolean,
21
+ type: [Boolean, String, Number],
20
22
  default: null
21
23
  }
22
24
  })
25
+
26
+ const isTrue = computed(() => props.value === true || props.value === 'true' || props.value === 1)
27
+ const isFalse = computed(() => props.value === false || props.value === 'false' || props.value === 0)
23
28
  </script>
24
29
 
25
30
  <template>
26
- <i v-if="value === true" class="pi pi-check" style="color: var(--p-green-500)" />
27
- <i v-else-if="value === false" class="pi pi-times" style="color: var(--p-red-500)" />
31
+ <i v-if="isTrue" class="pi pi-check" style="color: var(--p-green-500)" />
32
+ <i v-else-if="isFalse" class="pi pi-times" style="color: var(--p-red-500)" />
28
33
  </template>
@@ -18,7 +18,7 @@ import { useNavigation } from '../../composables/useNavigation'
18
18
  import { useApp } from '../../composables/useApp'
19
19
  import { useAuth } from '../../composables/useAuth'
20
20
  import { useGuardDialog } from '../../composables/useGuardStore'
21
- import { useBreadcrumb } from '../../composables/useBreadcrumb'
21
+ import { useNavContext } from '../../composables/useNavContext'
22
22
  import Button from 'primevue/button'
23
23
  import Breadcrumb from 'primevue/breadcrumb'
24
24
  import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
@@ -125,18 +125,18 @@ function handleLogout() {
125
125
  const slots = useSlots()
126
126
  const hasSlotContent = computed(() => !!slots.default)
127
127
 
128
- // Breadcrumb (auto-generated from route)
129
- const { breadcrumbItems: defaultBreadcrumb } = useBreadcrumb()
128
+ // Navigation context (breadcrumb + navlinks from route config)
129
+ const { breadcrumb: defaultBreadcrumb, navlinks: defaultNavlinks } = useNavContext()
130
130
 
131
- // Allow child pages to override breadcrumb via provide/inject
131
+ // Allow child pages to override breadcrumb/navlinks via provide/inject
132
132
  const breadcrumbOverride = ref(null)
133
133
  const navlinksOverride = ref(null)
134
134
  provide('qdadmBreadcrumbOverride', breadcrumbOverride)
135
135
  provide('qdadmNavlinksOverride', navlinksOverride)
136
136
 
137
- // Use override if provided, otherwise default
137
+ // Use override if provided, otherwise default from useNavContext
138
138
  const breadcrumbItems = computed(() => breadcrumbOverride.value || defaultBreadcrumb.value)
139
- const navlinks = computed(() => navlinksOverride.value || [])
139
+ const navlinks = computed(() => navlinksOverride.value || defaultNavlinks.value)
140
140
 
141
141
  // Show breadcrumb if enabled, has items, and not on home page
142
142
  const showBreadcrumb = computed(() => {
@@ -152,8 +152,10 @@ const showBreadcrumb = computed(() => {
152
152
  <!-- Sidebar -->
153
153
  <aside class="sidebar">
154
154
  <div class="sidebar-header">
155
- <img v-if="app.logo" :src="app.logo" :alt="app.name" class="sidebar-logo" />
156
- <h1 v-else>{{ app.name }}</h1>
155
+ <div class="sidebar-header-top">
156
+ <img v-if="app.logo" :src="app.logo" :alt="app.name" class="sidebar-logo" />
157
+ <h1 v-else>{{ app.name }}</h1>
158
+ </div>
157
159
  <span v-if="app.version" class="version">v{{ app.version }}</span>
158
160
  </div>
159
161
 
@@ -288,6 +290,13 @@ const showBreadcrumb = computed(() => {
288
290
  .sidebar-header {
289
291
  padding: 1.5rem;
290
292
  border-bottom: 1px solid var(--p-surface-700, #334155);
293
+ display: flex;
294
+ flex-direction: column;
295
+ align-items: flex-start;
296
+ gap: 0.5rem;
297
+ }
298
+
299
+ .sidebar-header-top {
291
300
  display: flex;
292
301
  align-items: center;
293
302
  gap: 0.5rem;
@@ -305,7 +314,7 @@ const showBreadcrumb = computed(() => {
305
314
  }
306
315
 
307
316
  .version {
308
- font-size: 0.75rem;
317
+ font-size: 0.625rem;
309
318
  color: var(--p-surface-400, #94a3b8);
310
319
  background: var(--p-surface-700, #334155);
311
320
  padding: 0.125rem 0.375rem;
@@ -42,17 +42,13 @@ const effectiveTitleParts = computed(() => {
42
42
  return props.titleParts || injectedTitleParts?.value || null
43
43
  })
44
44
 
45
+ // Simple title from usePageTitle('My Title')
46
+ const simpleTitle = computed(() => effectiveTitleParts.value?.simple)
47
+
45
48
  // Compute title display
46
49
  const hasDecoratedTitle = computed(() => {
47
50
  return effectiveTitleParts.value?.entityLabel
48
51
  })
49
-
50
- const titleBase = computed(() => {
51
- if (effectiveTitleParts.value) {
52
- return `${effectiveTitleParts.value.action} ${effectiveTitleParts.value.entityName}`
53
- }
54
- return props.title
55
- })
56
52
  </script>
57
53
 
58
54
  <template>
@@ -62,8 +58,9 @@ const titleBase = computed(() => {
62
58
  <div class="page-header-left">
63
59
  <div>
64
60
  <h1 class="page-title">
65
- <template v-if="hasDecoratedTitle"><span class="entity-label">{{ effectiveTitleParts.entityLabel }}</span></template>
66
- <span v-if="effectiveTitleParts" class="action-badge">{{ effectiveTitleParts.action }} {{ effectiveTitleParts.entityName }}</span>
61
+ <template v-if="simpleTitle">{{ simpleTitle }}</template>
62
+ <template v-else-if="hasDecoratedTitle"><span class="entity-label">{{ effectiveTitleParts.entityLabel }}</span></template>
63
+ <span v-if="effectiveTitleParts && !simpleTitle" class="action-badge">{{ effectiveTitleParts.action }} {{ effectiveTitleParts.entityName }}</span>
67
64
  <template v-if="!effectiveTitleParts">{{ title }}</template>
68
65
  </h1>
69
66
  <p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
@@ -25,6 +25,7 @@ import { useOrchestrator } from '../../orchestrator/useOrchestrator.js'
25
25
  // Inject override refs from AppLayout
26
26
  const breadcrumbOverride = inject('qdadmBreadcrumbOverride', null)
27
27
  const navlinksOverride = inject('qdadmNavlinksOverride', null)
28
+ const homeRouteName = inject('qdadmHomeRoute', null)
28
29
 
29
30
  const props = defineProps({
30
31
  entity: { type: Object, default: null },
@@ -60,10 +61,24 @@ watch(() => [parentConfig.value, route.params], async () => {
60
61
  }
61
62
  }, { immediate: true })
62
63
 
64
+ // Home breadcrumb item
65
+ const homeItem = computed(() => {
66
+ if (!homeRouteName) return null
67
+ const routes = router.getRoutes()
68
+ if (!routes.some(r => r.name === homeRouteName)) return null
69
+ const label = homeRouteName === 'dashboard' ? 'Dashboard' : 'Home'
70
+ return { label, to: { name: homeRouteName }, icon: 'pi pi-home' }
71
+ })
72
+
63
73
  // Build breadcrumb items
64
74
  const breadcrumbItems = computed(() => {
65
75
  const items = []
66
76
 
77
+ // Always start with Home if configured
78
+ if (homeItem.value) {
79
+ items.push(homeItem.value)
80
+ }
81
+
67
82
  if (!parentConfig.value) {
68
83
  // No parent - use simple breadcrumb from entity
69
84
  const entityName = route.meta?.entity
@@ -10,10 +10,12 @@ export { useForm } from './useForm'
10
10
  export * from './useJsonSyntax'
11
11
  export { useListPageBuilder, PAGE_SIZE_OPTIONS } from './useListPageBuilder'
12
12
  export { usePageBuilder } from './usePageBuilder'
13
+ export { usePageTitle } from './usePageTitle'
13
14
  export { useSubEditor } from './useSubEditor'
14
15
  export { useTabSync } from './useTabSync'
15
16
  export { useApp } from './useApp'
16
17
  export { useAuth } from './useAuth'
18
+ export { useNavContext } from './useNavContext'
17
19
  export { useNavigation } from './useNavigation'
18
20
  export { useStatus } from './useStatus'
19
21
  export { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
@@ -1,4 +1,4 @@
1
- import { computed } from 'vue'
1
+ import { computed, inject } from 'vue'
2
2
  import { useRoute, useRouter } from 'vue-router'
3
3
 
4
4
  /**
@@ -35,9 +35,11 @@ import { useRoute, useRouter } from 'vue-router'
35
35
  export function useBreadcrumb(options = {}) {
36
36
  const route = useRoute()
37
37
  const router = useRouter()
38
+ const homeRouteName = inject('qdadmHomeRoute', null)
38
39
 
39
40
  // Label mapping for common route names
40
41
  const labelMap = {
42
+ home: 'Home',
41
43
  dashboard: 'Dashboard',
42
44
  users: 'Users',
43
45
  roles: 'Roles',
@@ -102,13 +104,15 @@ export function useBreadcrumb(options = {}) {
102
104
  }
103
105
 
104
106
  /**
105
- * Get home breadcrumb item (dashboard if exists, otherwise null)
107
+ * Get home breadcrumb item from configured homeRoute
106
108
  */
107
109
  function getHomeItem() {
108
- if (routeExists('dashboard')) {
109
- return { label: 'Dashboard', to: { name: 'dashboard' }, icon: 'pi pi-home' }
110
+ if (!homeRouteName || !routeExists(homeRouteName)) {
111
+ return null
110
112
  }
111
- return null
113
+ // Use label from labelMap or capitalize route name
114
+ const label = labelMap[homeRouteName] || capitalize(homeRouteName)
115
+ return { label, to: { name: homeRouteName }, icon: 'pi pi-home' }
112
116
  }
113
117
 
114
118
  /**
@@ -0,0 +1,372 @@
1
+ /**
2
+ * useNavContext - Route-aware navigation context for breadcrumb and navlinks
3
+ *
4
+ * Builds navigation from route path pattern analysis (not heuristics).
5
+ *
6
+ * The route path pattern defines the navigation structure:
7
+ * - Static segments (e.g., 'books') → entity list
8
+ * - Param segments (e.g., ':id', ':bookId') → entity item
9
+ * - Action segments (e.g., 'edit', 'create') → ignored
10
+ *
11
+ * Route meta configuration:
12
+ * - meta.entity: Entity managed by this route (required)
13
+ * - meta.parent: Parent entity config for nested routes
14
+ * - parent.entity: Parent entity name
15
+ * - parent.param: Route param for parent ID
16
+ *
17
+ * Examples:
18
+ * Path: /books meta: { entity: 'books' }
19
+ * → Home > Books
20
+ *
21
+ * Path: /books/:id/edit meta: { entity: 'books' }
22
+ * → Home > Books > "Le Petit Prince"
23
+ *
24
+ * Path: /books/:bookId/loans meta: { entity: 'loans', parent: { entity: 'books', param: 'bookId' } }
25
+ * → Home > Books > "Le Petit Prince" > Loans
26
+ *
27
+ * Path: /books/:bookId/loans/:id/edit meta: { entity: 'loans', parent: { entity: 'books', param: 'bookId' } }
28
+ * → Home > Books > "Le Petit Prince" > Loans > "Loan #abc123"
29
+ */
30
+ import { ref, computed, watch, inject, unref } from 'vue'
31
+ import { useRoute, useRouter } from 'vue-router'
32
+ import { getSiblingRoutes } from '../module/moduleRegistry.js'
33
+
34
+ // Action segments that don't appear in breadcrumb
35
+ const ACTION_SEGMENTS = ['edit', 'create', 'new', 'show', 'view', 'delete']
36
+
37
+ export function useNavContext(options = {}) {
38
+ const route = useRoute()
39
+ const router = useRouter()
40
+
41
+ // Injected dependencies
42
+ const orchestrator = inject('qdadmOrchestrator', null)
43
+ const homeRouteName = inject('qdadmHomeRoute', null)
44
+
45
+ // Entity data cache
46
+ const entityDataCache = ref(new Map())
47
+
48
+ function getManager(entityName) {
49
+ return orchestrator?.get(entityName)
50
+ }
51
+
52
+ function routeExists(name) {
53
+ return router.getRoutes().some(r => r.name === name)
54
+ }
55
+
56
+ // ============================================================================
57
+ // PATH PATTERN ANALYSIS
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Parse route path pattern into typed segments
62
+ *
63
+ * Input: '/books/:bookId/loans/:id/edit'
64
+ * Output: [
65
+ * { type: 'static', value: 'books' },
66
+ * { type: 'param', value: 'bookId' },
67
+ * { type: 'static', value: 'loans' },
68
+ * { type: 'param', value: 'id' },
69
+ * { type: 'action', value: 'edit' }
70
+ * ]
71
+ */
72
+ function parsePathPattern(pathPattern) {
73
+ const segments = []
74
+ const parts = pathPattern.split('/').filter(Boolean)
75
+
76
+ for (const part of parts) {
77
+ if (part.startsWith(':')) {
78
+ // Param segment: :id, :bookId
79
+ segments.push({ type: 'param', value: part.slice(1) })
80
+ } else if (ACTION_SEGMENTS.includes(part.toLowerCase())) {
81
+ // Action segment: edit, create, show
82
+ segments.push({ type: 'action', value: part })
83
+ } else {
84
+ // Static segment: books, loans
85
+ segments.push({ type: 'static', value: part })
86
+ }
87
+ }
88
+
89
+ return segments
90
+ }
91
+
92
+ /**
93
+ * Build navigation chain from parsed path segments + route meta
94
+ *
95
+ * Uses route meta to know which entity each static segment represents.
96
+ * The meta.parent chain declares the entity hierarchy.
97
+ */
98
+ function buildNavChain(pathSegments, routeMeta, routeParams) {
99
+ const chain = []
100
+ const meta = routeMeta || {}
101
+
102
+ // Collect all entities in the hierarchy (parent chain + current)
103
+ const entityHierarchy = []
104
+
105
+ // Build parent chain (oldest ancestor first)
106
+ function collectParents(parentConfig) {
107
+ if (!parentConfig) return
108
+ const parentManager = getManager(parentConfig.entity)
109
+ if (!parentManager) return
110
+
111
+ // Check if this parent has its own parent
112
+ const parentRoute = router.getRoutes().find(r =>
113
+ r.name === `${parentManager.routePrefix}-edit`
114
+ )
115
+ if (parentRoute?.meta?.parent) {
116
+ collectParents(parentRoute.meta.parent)
117
+ }
118
+
119
+ entityHierarchy.push({
120
+ entity: parentConfig.entity,
121
+ manager: parentManager,
122
+ idParam: parentConfig.param
123
+ })
124
+ }
125
+
126
+ collectParents(meta.parent)
127
+
128
+ // Add current entity
129
+ if (meta.entity) {
130
+ const currentManager = getManager(meta.entity)
131
+ if (currentManager) {
132
+ entityHierarchy.push({
133
+ entity: meta.entity,
134
+ manager: currentManager,
135
+ idParam: 'id' // Standard param for current entity
136
+ })
137
+ }
138
+ }
139
+
140
+ // Now build chain from hierarchy
141
+ for (const { entity, manager, idParam } of entityHierarchy) {
142
+ const entityId = routeParams[idParam]
143
+
144
+ // Add list segment
145
+ chain.push({
146
+ type: 'list',
147
+ entity,
148
+ manager,
149
+ label: manager.labelPlural || manager.name,
150
+ routeName: manager.routePrefix
151
+ })
152
+
153
+ // Add item segment if we have an ID for this entity
154
+ if (entityId) {
155
+ chain.push({
156
+ type: 'item',
157
+ entity,
158
+ manager,
159
+ id: entityId,
160
+ routeName: `${manager.routePrefix}-edit`
161
+ })
162
+ }
163
+ }
164
+
165
+ // Handle child-list case: when on a child route without :id
166
+ // The last segment is a child-list, not a regular list
167
+ if (meta.parent && !routeParams.id && chain.length > 0) {
168
+ const lastSegment = chain[chain.length - 1]
169
+ if (lastSegment.type === 'list') {
170
+ lastSegment.type = 'child-list'
171
+ lastSegment.navLabel = meta.navLabel
172
+ }
173
+ }
174
+
175
+ return chain
176
+ }
177
+
178
+ // ============================================================================
179
+ // COMPUTED NAVIGATION CHAIN
180
+ // ============================================================================
181
+
182
+ /**
183
+ * Parsed path segments from current route
184
+ */
185
+ const pathSegments = computed(() => {
186
+ // Get the matched route's path pattern
187
+ const matched = route.matched
188
+ if (!matched.length) return []
189
+
190
+ // Use the last matched route's full path
191
+ const lastMatch = matched[matched.length - 1]
192
+ return parsePathPattern(lastMatch.path)
193
+ })
194
+
195
+ /**
196
+ * Navigation chain built from path analysis
197
+ */
198
+ const navChain = computed(() => {
199
+ return buildNavChain(pathSegments.value, route.meta, route.params)
200
+ })
201
+
202
+ // ============================================================================
203
+ // ENTITY DATA FETCHING
204
+ // ============================================================================
205
+
206
+ const chainData = ref(new Map()) // Map: chainIndex -> entityData
207
+
208
+ /**
209
+ * Fetch entity data for all 'item' segments in the chain
210
+ */
211
+ watch([navChain, () => options.entityData], async ([chain]) => {
212
+ chainData.value.clear()
213
+ const externalData = unref(options.entityData)
214
+
215
+ for (let i = 0; i < chain.length; i++) {
216
+ const segment = chain[i]
217
+ if (segment.type !== 'item') continue
218
+
219
+ // For the last item, use external data if provided (from useForm)
220
+ const isLastItem = !chain.slice(i + 1).some(s => s.type === 'item')
221
+ if (isLastItem && externalData) {
222
+ chainData.value.set(i, externalData)
223
+ continue
224
+ }
225
+
226
+ // Fetch from manager
227
+ try {
228
+ const data = await segment.manager.get(segment.id)
229
+ chainData.value.set(i, data)
230
+ } catch (e) {
231
+ console.warn(`[useNavContext] Failed to fetch ${segment.entity}:${segment.id}`, e)
232
+ }
233
+ }
234
+ }, { immediate: true, deep: true })
235
+
236
+ // ============================================================================
237
+ // BREADCRUMB
238
+ // ============================================================================
239
+
240
+ const homeItem = computed(() => {
241
+ if (!homeRouteName || !routeExists(homeRouteName)) return null
242
+ return {
243
+ label: homeRouteName === 'dashboard' ? 'Dashboard' : 'Home',
244
+ to: { name: homeRouteName },
245
+ icon: 'pi pi-home'
246
+ }
247
+ })
248
+
249
+ const breadcrumb = computed(() => {
250
+ const items = []
251
+ const chain = navChain.value
252
+
253
+ // Home
254
+ if (homeItem.value) {
255
+ items.push(homeItem.value)
256
+ }
257
+
258
+ // Build from chain
259
+ for (let i = 0; i < chain.length; i++) {
260
+ const segment = chain[i]
261
+ const isLast = i === chain.length - 1
262
+
263
+ if (segment.type === 'list') {
264
+ items.push({
265
+ label: segment.label,
266
+ to: { name: segment.routeName }
267
+ })
268
+ } else if (segment.type === 'item') {
269
+ const data = chainData.value.get(i)
270
+ const label = data ? segment.manager.getEntityLabel(data) : '...'
271
+
272
+ items.push({
273
+ label,
274
+ to: isLast ? null : { name: segment.routeName, params: { id: segment.id } }
275
+ })
276
+ } else if (segment.type === 'child-list') {
277
+ items.push({
278
+ label: segment.navLabel || segment.label
279
+ })
280
+ }
281
+ }
282
+
283
+ return items
284
+ })
285
+
286
+ // ============================================================================
287
+ // NAVLINKS (for child routes)
288
+ // ============================================================================
289
+
290
+ const parentConfig = computed(() => route.meta?.parent)
291
+
292
+ const parentId = computed(() => {
293
+ if (!parentConfig.value) return null
294
+ return route.params[parentConfig.value.param]
295
+ })
296
+
297
+ const navlinks = computed(() => {
298
+ if (!parentConfig.value) return []
299
+
300
+ const { entity: parentEntity, param, itemRoute } = parentConfig.value
301
+ const parentManager = getManager(parentEntity)
302
+ if (!parentManager) return []
303
+
304
+ const parentRouteName = itemRoute || `${parentManager.routePrefix}-edit`
305
+ const isOnParent = route.name === parentRouteName
306
+
307
+ // Details link
308
+ const links = [{
309
+ label: 'Details',
310
+ to: { name: parentRouteName, params: { id: parentId.value } },
311
+ active: isOnParent
312
+ }]
313
+
314
+ // Sibling routes
315
+ const siblings = getSiblingRoutes(parentEntity, param)
316
+ for (const sibling of siblings) {
317
+ const sibManager = sibling.meta?.entity ? getManager(sibling.meta.entity) : null
318
+ links.push({
319
+ label: sibling.meta?.navLabel || sibManager?.labelPlural || sibling.name,
320
+ to: { name: sibling.name, params: route.params },
321
+ active: route.name === sibling.name
322
+ })
323
+ }
324
+
325
+ return links
326
+ })
327
+
328
+ // ============================================================================
329
+ // CONVENIENCE ACCESSORS
330
+ // ============================================================================
331
+
332
+ const entityData = computed(() => {
333
+ const chain = navChain.value
334
+ for (let i = chain.length - 1; i >= 0; i--) {
335
+ if (chain[i].type === 'item') {
336
+ return chainData.value.get(i) || null
337
+ }
338
+ }
339
+ return null
340
+ })
341
+
342
+ const parentData = computed(() => {
343
+ const chain = navChain.value
344
+ let foundCurrent = false
345
+ for (let i = chain.length - 1; i >= 0; i--) {
346
+ if (chain[i].type === 'item') {
347
+ if (foundCurrent) return chainData.value.get(i) || null
348
+ foundCurrent = true
349
+ }
350
+ }
351
+ return null
352
+ })
353
+
354
+ return {
355
+ // Analysis
356
+ pathSegments,
357
+ navChain,
358
+
359
+ // Data
360
+ entityData,
361
+ parentData,
362
+ chainData,
363
+
364
+ // Navigation
365
+ breadcrumb,
366
+ navlinks,
367
+
368
+ // Helpers
369
+ parentConfig,
370
+ parentId
371
+ }
372
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * usePageTitle - Provide custom page title for PageHeader
3
+ *
4
+ * Use this composable in custom pages to set the title displayed in PageHeader.
5
+ * For standard CRUD pages, useForm handles this automatically.
6
+ *
7
+ * Usage:
8
+ * ```js
9
+ * // Simple title
10
+ * const { setTitle } = usePageTitle('My Custom Page')
11
+ *
12
+ * // Decorated title (entityLabel shown prominently, action+entityName as badge)
13
+ * usePageTitle({
14
+ * action: 'View',
15
+ * entityName: 'Stats',
16
+ * entityLabel: 'Dashboard'
17
+ * })
18
+ *
19
+ * // Reactive updates
20
+ * const { setTitle } = usePageTitle('Initial')
21
+ * setTitle('Updated Title')
22
+ * ```
23
+ */
24
+ import { ref, provide, watchEffect, isRef, unref } from 'vue'
25
+
26
+ export function usePageTitle(initialTitle = null) {
27
+ const titleParts = ref(null)
28
+
29
+ // Convert string to titleParts format (simple title)
30
+ function normalize(title) {
31
+ if (!title) return null
32
+ if (typeof title === 'string') {
33
+ return { simple: title }
34
+ }
35
+ return title
36
+ }
37
+
38
+ // Set title (string or { action, entityName, entityLabel })
39
+ function setTitle(newTitle) {
40
+ titleParts.value = normalize(unref(newTitle))
41
+ }
42
+
43
+ // Initialize with provided value
44
+ if (initialTitle !== null) {
45
+ if (isRef(initialTitle)) {
46
+ // Reactive: watch for changes
47
+ watchEffect(() => {
48
+ setTitle(initialTitle.value)
49
+ })
50
+ } else {
51
+ setTitle(initialTitle)
52
+ }
53
+ }
54
+
55
+ // Provide to PageHeader via same key as useForm
56
+ provide('qdadmPageTitleParts', titleParts)
57
+
58
+ return { titleParts, setTitle }
59
+ }
package/src/index.js CHANGED
@@ -27,3 +27,6 @@ export * from './module/index.js'
27
27
 
28
28
  // Utils
29
29
  export * from './utils/index.js'
30
+
31
+ // Assets
32
+ export { default as qdadmLogo } from './assets/logo.svg'
@@ -210,6 +210,10 @@ export class Kernel {
210
210
  // Router
211
211
  app.use(this.router)
212
212
 
213
+ // Extract home route name for breadcrumb
214
+ const { homeRoute } = this.options
215
+ const homeRouteName = typeof homeRoute === 'object' ? homeRoute.name : homeRoute
216
+
213
217
  // qdadm plugin
214
218
  app.use(createQdadm({
215
219
  orchestrator: this.orchestrator,
@@ -218,6 +222,7 @@ export class Kernel {
218
222
  router: this.router,
219
223
  toast: app.config.globalProperties.$toast,
220
224
  app: this.options.app,
225
+ homeRoute: homeRouteName,
221
226
  features: {
222
227
  auth: !!authAdapter,
223
228
  poweredBy: true,
package/src/plugin.js CHANGED
@@ -22,6 +22,7 @@ import qdadmLogo from './assets/logo.svg'
22
22
  * @param {object} options.features - Optional: Feature toggles (auth, poweredBy)
23
23
  * @param {object} options.builtinModules - Optional: Builtin modules configuration
24
24
  * @param {object} options.endpoints - Optional: API endpoints configuration
25
+ * @param {string} options.homeRoute - Optional: Home route name for breadcrumb
25
26
  * @returns {object} Vue plugin
26
27
  */
27
28
  export function createQdadm(options) {
@@ -112,6 +113,10 @@ export function createQdadm(options) {
112
113
  app.provide('qdadmSectionOrder', options.modules.sectionOrder)
113
114
  }
114
115
 
116
+ if (options.homeRoute) {
117
+ app.provide('qdadmHomeRoute', options.homeRoute)
118
+ }
119
+
115
120
  // Add route guard for entity permissions
116
121
  options.router.beforeEach((to, from, next) => {
117
122
  const entity = to.meta?.entity