qdadm 0.13.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/CHANGELOG.md +270 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +48 -0
- package/src/assets/logo.svg +6 -0
- package/src/components/BoolCell.vue +28 -0
- package/src/components/dialogs/BulkStatusDialog.vue +43 -0
- package/src/components/dialogs/MultiStepDialog.vue +321 -0
- package/src/components/dialogs/SimpleDialog.vue +108 -0
- package/src/components/dialogs/UnsavedChangesDialog.vue +87 -0
- package/src/components/display/CardsGrid.vue +155 -0
- package/src/components/display/CopyableId.vue +92 -0
- package/src/components/display/EmptyState.vue +114 -0
- package/src/components/display/IntensityBar.vue +171 -0
- package/src/components/display/RichCardsGrid.vue +220 -0
- package/src/components/editors/JsonEditorFoldable.vue +467 -0
- package/src/components/editors/JsonStructuredField.vue +218 -0
- package/src/components/editors/JsonViewer.vue +91 -0
- package/src/components/editors/KeyValueEditor.vue +314 -0
- package/src/components/editors/LanguageEditor.vue +245 -0
- package/src/components/editors/ScopeEditor.vue +341 -0
- package/src/components/editors/VanillaJsonEditor.vue +185 -0
- package/src/components/forms/FormActions.vue +104 -0
- package/src/components/forms/FormField.vue +64 -0
- package/src/components/forms/FormTab.vue +217 -0
- package/src/components/forms/FormTabs.vue +108 -0
- package/src/components/index.js +44 -0
- package/src/components/layout/AppLayout.vue +430 -0
- package/src/components/layout/Breadcrumb.vue +106 -0
- package/src/components/layout/PageHeader.vue +75 -0
- package/src/components/layout/PageLayout.vue +93 -0
- package/src/components/lists/ActionButtons.vue +41 -0
- package/src/components/lists/ActionColumn.vue +37 -0
- package/src/components/lists/FilterBar.vue +53 -0
- package/src/components/lists/ListPage.vue +319 -0
- package/src/composables/index.js +19 -0
- package/src/composables/useApp.js +43 -0
- package/src/composables/useAuth.js +49 -0
- package/src/composables/useBareForm.js +143 -0
- package/src/composables/useBreadcrumb.js +221 -0
- package/src/composables/useDirtyState.js +103 -0
- package/src/composables/useEntityTitle.js +121 -0
- package/src/composables/useForm.js +254 -0
- package/src/composables/useGuardStore.js +37 -0
- package/src/composables/useJsonSyntax.js +101 -0
- package/src/composables/useListPageBuilder.js +1176 -0
- package/src/composables/useNavigation.js +89 -0
- package/src/composables/usePageBuilder.js +334 -0
- package/src/composables/useStatus.js +146 -0
- package/src/composables/useSubEditor.js +165 -0
- package/src/composables/useTabSync.js +110 -0
- package/src/composables/useUnsavedChangesGuard.js +122 -0
- package/src/entity/EntityManager.js +540 -0
- package/src/entity/index.js +11 -0
- package/src/entity/storage/ApiStorage.js +146 -0
- package/src/entity/storage/LocalStorage.js +220 -0
- package/src/entity/storage/MemoryStorage.js +201 -0
- package/src/entity/storage/index.js +10 -0
- package/src/index.js +29 -0
- package/src/kernel/Kernel.js +234 -0
- package/src/kernel/index.js +7 -0
- package/src/module/index.js +16 -0
- package/src/module/moduleRegistry.js +222 -0
- package/src/orchestrator/Orchestrator.js +141 -0
- package/src/orchestrator/index.js +8 -0
- package/src/orchestrator/useOrchestrator.js +61 -0
- package/src/plugin.js +142 -0
- package/src/styles/_alerts.css +48 -0
- package/src/styles/_code.css +33 -0
- package/src/styles/_dialogs.css +17 -0
- package/src/styles/_markdown.css +82 -0
- package/src/styles/_show-pages.css +84 -0
- package/src/styles/index.css +16 -0
- package/src/styles/main.css +845 -0
- package/src/styles/theme/components.css +286 -0
- package/src/styles/theme/index.css +10 -0
- package/src/styles/theme/tokens.css +125 -0
- package/src/styles/theme/utilities.css +172 -0
- package/src/utils/debugInjector.js +261 -0
- package/src/utils/formatters.js +165 -0
- package/src/utils/index.js +35 -0
- package/src/utils/transformers.js +105 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* AppLayout - Generic admin layout with sidebar navigation
|
|
4
|
+
*
|
|
5
|
+
* Navigation is auto-built from moduleRegistry.
|
|
6
|
+
* Branding comes from createQdadm({ app: {...} }) config.
|
|
7
|
+
* Auth is optional via authAdapter.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* <AppLayout>
|
|
11
|
+
* <RouterView />
|
|
12
|
+
* </AppLayout>
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ref, watch, onMounted, computed, inject, useSlots } from 'vue'
|
|
16
|
+
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
|
17
|
+
import { useNavigation } from '../../composables/useNavigation'
|
|
18
|
+
import { useApp } from '../../composables/useApp'
|
|
19
|
+
import { useAuth } from '../../composables/useAuth'
|
|
20
|
+
import { useGuardDialog } from '../../composables/useGuardStore'
|
|
21
|
+
import Button from 'primevue/button'
|
|
22
|
+
import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
|
|
23
|
+
import qdadmLogo from '../../assets/logo.svg'
|
|
24
|
+
import { version as qdadmVersion } from '../../../package.json'
|
|
25
|
+
|
|
26
|
+
const features = inject('qdadmFeatures', { poweredBy: true })
|
|
27
|
+
|
|
28
|
+
// Guard dialog from shared store (registered by useBareForm/useForm when a form is active)
|
|
29
|
+
const guardDialog = useGuardDialog()
|
|
30
|
+
|
|
31
|
+
const router = useRouter()
|
|
32
|
+
const route = useRoute()
|
|
33
|
+
const app = useApp()
|
|
34
|
+
const { navSections, isNavActive, sectionHasActiveItem, handleNavClick } = useNavigation()
|
|
35
|
+
const { isAuthenticated, user, logout, authEnabled } = useAuth()
|
|
36
|
+
|
|
37
|
+
// LocalStorage key for collapsed sections state (namespaced by app)
|
|
38
|
+
const STORAGE_KEY = computed(() => `${app.shortName.toLowerCase()}_nav_collapsed`)
|
|
39
|
+
|
|
40
|
+
// Collapsed sections state (section title -> boolean)
|
|
41
|
+
const collapsedSections = ref({})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load collapsed state from localStorage
|
|
45
|
+
*/
|
|
46
|
+
function loadCollapsedState() {
|
|
47
|
+
try {
|
|
48
|
+
const stored = localStorage.getItem(STORAGE_KEY.value)
|
|
49
|
+
if (stored) {
|
|
50
|
+
collapsedSections.value = JSON.parse(stored)
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.warn('Failed to load nav state:', e)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Save collapsed state to localStorage
|
|
59
|
+
*/
|
|
60
|
+
function saveCollapsedState() {
|
|
61
|
+
try {
|
|
62
|
+
localStorage.setItem(STORAGE_KEY.value, JSON.stringify(collapsedSections.value))
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.warn('Failed to save nav state:', e)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Toggle section collapsed state
|
|
70
|
+
*/
|
|
71
|
+
function toggleSection(sectionTitle) {
|
|
72
|
+
collapsedSections.value[sectionTitle] = !collapsedSections.value[sectionTitle]
|
|
73
|
+
saveCollapsedState()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if section should be shown expanded
|
|
78
|
+
* - Never collapsed if it contains active item
|
|
79
|
+
* - Otherwise respect user preference
|
|
80
|
+
*/
|
|
81
|
+
function isSectionExpanded(section) {
|
|
82
|
+
if (sectionHasActiveItem(section)) {
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
return !collapsedSections.value[section.title]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Load state on mount
|
|
89
|
+
onMounted(() => {
|
|
90
|
+
loadCollapsedState()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// Auto-expand section when navigating to an item in it
|
|
94
|
+
watch(() => route.path, () => {
|
|
95
|
+
for (const section of navSections.value) {
|
|
96
|
+
if (sectionHasActiveItem(section) && collapsedSections.value[section.title]) {
|
|
97
|
+
// Auto-expand but don't save (user can collapse again if they want)
|
|
98
|
+
collapsedSections.value[section.title] = false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const userInitials = computed(() => {
|
|
104
|
+
const username = user.value?.username
|
|
105
|
+
if (!username) return '?'
|
|
106
|
+
return username.substring(0, 2).toUpperCase()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const userDisplayName = computed(() => {
|
|
110
|
+
return user.value?.username || user.value?.name || 'User'
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const userSubtitle = computed(() => {
|
|
114
|
+
return user.value?.email || user.value?.role || ''
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
function handleLogout() {
|
|
118
|
+
logout()
|
|
119
|
+
router.push({ name: 'login' })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if slot content is provided
|
|
123
|
+
const slots = useSlots()
|
|
124
|
+
const hasSlotContent = computed(() => !!slots.default)
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<div class="app-layout">
|
|
129
|
+
<!-- Sidebar -->
|
|
130
|
+
<aside class="sidebar">
|
|
131
|
+
<div class="sidebar-header">
|
|
132
|
+
<img v-if="app.logo" :src="app.logo" :alt="app.name" class="sidebar-logo" />
|
|
133
|
+
<h1 v-else>{{ app.name }}</h1>
|
|
134
|
+
<span v-if="app.version" class="version">v{{ app.version }}</span>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<nav class="sidebar-nav">
|
|
138
|
+
<div v-for="section in navSections" :key="section.title" class="nav-section">
|
|
139
|
+
<div
|
|
140
|
+
class="nav-section-title"
|
|
141
|
+
:class="{ 'nav-section-active': sectionHasActiveItem(section) }"
|
|
142
|
+
@click="toggleSection(section.title)"
|
|
143
|
+
>
|
|
144
|
+
<span>{{ section.title }}</span>
|
|
145
|
+
<i
|
|
146
|
+
class="nav-section-chevron pi"
|
|
147
|
+
:class="isSectionExpanded(section) ? 'pi-chevron-down' : 'pi-chevron-right'"
|
|
148
|
+
></i>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="nav-section-items" :class="{ 'nav-section-collapsed': !isSectionExpanded(section) }">
|
|
151
|
+
<RouterLink
|
|
152
|
+
v-for="item in section.items"
|
|
153
|
+
:key="item.route"
|
|
154
|
+
:to="{ name: item.route }"
|
|
155
|
+
class="nav-item"
|
|
156
|
+
:class="{ 'nav-item-active': isNavActive(item) }"
|
|
157
|
+
@click="handleNavClick($event, item)"
|
|
158
|
+
>
|
|
159
|
+
<i :class="item.icon"></i>
|
|
160
|
+
<span>{{ item.label }}</span>
|
|
161
|
+
</RouterLink>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</nav>
|
|
165
|
+
|
|
166
|
+
<div v-if="authEnabled" class="sidebar-footer">
|
|
167
|
+
<div class="user-info">
|
|
168
|
+
<div class="user-avatar">{{ userInitials }}</div>
|
|
169
|
+
<div class="user-details">
|
|
170
|
+
<div class="user-name">{{ userDisplayName }}</div>
|
|
171
|
+
<div class="user-role">{{ userSubtitle }}</div>
|
|
172
|
+
</div>
|
|
173
|
+
<Button
|
|
174
|
+
icon="pi pi-sign-out"
|
|
175
|
+
severity="secondary"
|
|
176
|
+
text
|
|
177
|
+
rounded
|
|
178
|
+
@click="handleLogout"
|
|
179
|
+
v-tooltip.top="'Logout'"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div v-if="features.poweredBy" class="powered-by">
|
|
185
|
+
<img :src="qdadmLogo" alt="qdadm" class="powered-by-logo" />
|
|
186
|
+
<span class="powered-by-text">
|
|
187
|
+
powered by <strong>qdadm</strong> v{{ qdadmVersion }}
|
|
188
|
+
</span>
|
|
189
|
+
</div>
|
|
190
|
+
</aside>
|
|
191
|
+
|
|
192
|
+
<!-- Main content -->
|
|
193
|
+
<main class="main-content">
|
|
194
|
+
<div class="page-content">
|
|
195
|
+
<!-- Use slot if provided, otherwise RouterView for nested routes -->
|
|
196
|
+
<slot v-if="hasSlotContent" />
|
|
197
|
+
<RouterView v-else />
|
|
198
|
+
</div>
|
|
199
|
+
</main>
|
|
200
|
+
|
|
201
|
+
<!-- Unsaved Changes Dialog (auto-rendered when a form registers guardDialog) -->
|
|
202
|
+
<UnsavedChangesDialog
|
|
203
|
+
v-if="guardDialog"
|
|
204
|
+
:visible="guardDialog.visible.value"
|
|
205
|
+
:saving="guardDialog.saving.value"
|
|
206
|
+
:message="guardDialog.message"
|
|
207
|
+
:hasOnSave="guardDialog.hasOnSave"
|
|
208
|
+
@saveAndLeave="guardDialog.onSaveAndLeave"
|
|
209
|
+
@leave="guardDialog.onLeave"
|
|
210
|
+
@stay="guardDialog.onStay"
|
|
211
|
+
/>
|
|
212
|
+
<!-- Note: guardDialog is a shallowRef, Vue auto-unwraps it in templates -->
|
|
213
|
+
</div>
|
|
214
|
+
</template>
|
|
215
|
+
|
|
216
|
+
<style scoped>
|
|
217
|
+
.app-layout {
|
|
218
|
+
display: flex;
|
|
219
|
+
min-height: 100vh;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.sidebar {
|
|
223
|
+
width: var(--fad-sidebar-width, 15rem);
|
|
224
|
+
background: var(--p-surface-800, #1e293b);
|
|
225
|
+
color: var(--p-surface-0, white);
|
|
226
|
+
display: flex;
|
|
227
|
+
flex-direction: column;
|
|
228
|
+
position: fixed;
|
|
229
|
+
top: 0;
|
|
230
|
+
left: 0;
|
|
231
|
+
bottom: 0;
|
|
232
|
+
z-index: 100;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.sidebar-header {
|
|
236
|
+
padding: 1.5rem;
|
|
237
|
+
border-bottom: 1px solid var(--p-surface-700, #334155);
|
|
238
|
+
display: flex;
|
|
239
|
+
align-items: center;
|
|
240
|
+
gap: 0.5rem;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.sidebar-header h1 {
|
|
244
|
+
font-size: 1.25rem;
|
|
245
|
+
font-weight: 600;
|
|
246
|
+
margin: 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.sidebar-logo {
|
|
250
|
+
max-height: 32px;
|
|
251
|
+
max-width: 160px;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.version {
|
|
255
|
+
font-size: 0.75rem;
|
|
256
|
+
color: var(--p-surface-400, #94a3b8);
|
|
257
|
+
background: var(--p-surface-700, #334155);
|
|
258
|
+
padding: 0.125rem 0.375rem;
|
|
259
|
+
border-radius: 0.25rem;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.sidebar-nav {
|
|
263
|
+
flex: 1;
|
|
264
|
+
overflow-y: auto;
|
|
265
|
+
padding: 1rem 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.nav-section {
|
|
269
|
+
margin-bottom: 0.5rem;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.nav-section-title {
|
|
273
|
+
padding: 0.5rem 1.5rem;
|
|
274
|
+
font-size: 0.75rem;
|
|
275
|
+
font-weight: 600;
|
|
276
|
+
text-transform: uppercase;
|
|
277
|
+
letter-spacing: 0.05em;
|
|
278
|
+
color: var(--p-surface-400, #94a3b8);
|
|
279
|
+
display: flex;
|
|
280
|
+
justify-content: space-between;
|
|
281
|
+
align-items: center;
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
user-select: none;
|
|
284
|
+
transition: color 0.15s;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.nav-section-title:hover {
|
|
288
|
+
color: var(--p-surface-200, #e2e8f0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.nav-section-active {
|
|
292
|
+
color: var(--p-primary-400, #60a5fa);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.nav-section-chevron {
|
|
296
|
+
font-size: 0.625rem;
|
|
297
|
+
transition: transform 0.2s;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.nav-section-items {
|
|
301
|
+
max-height: 500px;
|
|
302
|
+
overflow: hidden;
|
|
303
|
+
transition: max-height 0.2s ease-out, opacity 0.15s;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.nav-section-collapsed {
|
|
307
|
+
max-height: 0;
|
|
308
|
+
opacity: 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.nav-item {
|
|
312
|
+
display: flex;
|
|
313
|
+
align-items: center;
|
|
314
|
+
gap: 0.75rem;
|
|
315
|
+
padding: 0.625rem 1.5rem;
|
|
316
|
+
color: var(--p-surface-300, #cbd5e1);
|
|
317
|
+
text-decoration: none;
|
|
318
|
+
transition: all 0.15s;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.nav-item:hover {
|
|
322
|
+
background: var(--p-surface-700, #334155);
|
|
323
|
+
color: var(--p-surface-0, white);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.nav-item-active {
|
|
327
|
+
background: var(--p-primary-600, #2563eb);
|
|
328
|
+
color: var(--p-surface-0, white);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.nav-item i {
|
|
332
|
+
font-size: 1rem;
|
|
333
|
+
width: 1.25rem;
|
|
334
|
+
text-align: center;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.sidebar-footer {
|
|
338
|
+
padding: 1rem 1.5rem;
|
|
339
|
+
border-top: 1px solid var(--p-surface-700, #334155);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.user-info {
|
|
343
|
+
display: flex;
|
|
344
|
+
align-items: center;
|
|
345
|
+
gap: 0.75rem;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.user-avatar {
|
|
349
|
+
width: 36px;
|
|
350
|
+
height: 36px;
|
|
351
|
+
border-radius: 50%;
|
|
352
|
+
background: var(--p-primary-600, #2563eb);
|
|
353
|
+
display: flex;
|
|
354
|
+
align-items: center;
|
|
355
|
+
justify-content: center;
|
|
356
|
+
font-size: 0.875rem;
|
|
357
|
+
font-weight: 600;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.user-details {
|
|
361
|
+
flex: 1;
|
|
362
|
+
min-width: 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.user-name {
|
|
366
|
+
font-weight: 500;
|
|
367
|
+
font-size: 0.875rem;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.user-role {
|
|
371
|
+
font-size: 0.75rem;
|
|
372
|
+
color: var(--p-surface-400, #94a3b8);
|
|
373
|
+
overflow: hidden;
|
|
374
|
+
text-overflow: ellipsis;
|
|
375
|
+
white-space: nowrap;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.main-content {
|
|
379
|
+
flex: 1;
|
|
380
|
+
margin-left: var(--fad-sidebar-width, 15rem);
|
|
381
|
+
background: var(--p-surface-50, #f8fafc);
|
|
382
|
+
min-height: 100vh;
|
|
383
|
+
display: flex;
|
|
384
|
+
flex-direction: column;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.page-content {
|
|
388
|
+
flex: 1;
|
|
389
|
+
padding: 1.5rem;
|
|
390
|
+
overflow-y: auto;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* Dark mode support */
|
|
394
|
+
.dark-mode .sidebar {
|
|
395
|
+
background: var(--p-surface-900);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.dark-mode .main-content {
|
|
399
|
+
background: var(--p-surface-900);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.powered-by {
|
|
403
|
+
padding: 0.75rem 1rem;
|
|
404
|
+
border-top: 1px solid var(--p-surface-700, #334155);
|
|
405
|
+
display: flex;
|
|
406
|
+
align-items: center;
|
|
407
|
+
gap: 0.5rem;
|
|
408
|
+
opacity: 0.6;
|
|
409
|
+
transition: opacity 0.15s;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.powered-by:hover {
|
|
413
|
+
opacity: 1;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.powered-by-logo {
|
|
417
|
+
width: 1.25rem;
|
|
418
|
+
height: 1.25rem;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.powered-by-text {
|
|
422
|
+
font-size: 0.625rem;
|
|
423
|
+
color: var(--p-surface-400, #94a3b8);
|
|
424
|
+
letter-spacing: 0.02em;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.powered-by-text strong {
|
|
428
|
+
color: var(--p-surface-300, #cbd5e1);
|
|
429
|
+
}
|
|
430
|
+
</style>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* Breadcrumb - Navigation breadcrumb component
|
|
4
|
+
*
|
|
5
|
+
* Props:
|
|
6
|
+
* - items: Array of { label, to?, icon? }
|
|
7
|
+
*
|
|
8
|
+
* Last item is always rendered as current page (no link)
|
|
9
|
+
*/
|
|
10
|
+
import { RouterLink } from 'vue-router'
|
|
11
|
+
|
|
12
|
+
defineProps({
|
|
13
|
+
items: {
|
|
14
|
+
type: Array,
|
|
15
|
+
default: () => []
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<nav class="breadcrumb" aria-label="Breadcrumb">
|
|
22
|
+
<ol class="breadcrumb-list">
|
|
23
|
+
<li
|
|
24
|
+
v-for="(item, index) in items"
|
|
25
|
+
:key="index"
|
|
26
|
+
class="breadcrumb-item"
|
|
27
|
+
:class="{ 'is-current': index === items.length - 1 }"
|
|
28
|
+
>
|
|
29
|
+
<template v-if="index > 0">
|
|
30
|
+
<i class="pi pi-chevron-right breadcrumb-separator" ></i>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<RouterLink
|
|
34
|
+
v-if="item.to && index < items.length - 1"
|
|
35
|
+
:to="item.to"
|
|
36
|
+
class="breadcrumb-link"
|
|
37
|
+
>
|
|
38
|
+
<i v-if="item.icon" :class="item.icon" ></i>
|
|
39
|
+
<span>{{ item.label }}</span>
|
|
40
|
+
</RouterLink>
|
|
41
|
+
|
|
42
|
+
<span v-else class="breadcrumb-text">
|
|
43
|
+
<i v-if="item.icon" :class="item.icon" ></i>
|
|
44
|
+
<span>{{ item.label }}</span>
|
|
45
|
+
</span>
|
|
46
|
+
</li>
|
|
47
|
+
</ol>
|
|
48
|
+
</nav>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<style scoped>
|
|
52
|
+
.breadcrumb {
|
|
53
|
+
margin-bottom: 0.5rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.breadcrumb-list {
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: 0.5rem;
|
|
60
|
+
list-style: none;
|
|
61
|
+
margin: 0;
|
|
62
|
+
padding: 0;
|
|
63
|
+
font-size: 0.875rem;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.breadcrumb-item {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: 0.5rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.breadcrumb-separator {
|
|
73
|
+
font-size: 0.75rem;
|
|
74
|
+
color: var(--p-text-muted-color, #888);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.breadcrumb-link {
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: 0.375rem;
|
|
81
|
+
color: var(--p-primary-color, #3b82f6);
|
|
82
|
+
text-decoration: none;
|
|
83
|
+
transition: color 0.2s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.breadcrumb-link:hover {
|
|
87
|
+
color: var(--p-primary-hover-color, #2563eb);
|
|
88
|
+
text-decoration: underline;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.breadcrumb-link i {
|
|
92
|
+
font-size: 0.875rem;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.breadcrumb-text {
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
gap: 0.375rem;
|
|
99
|
+
color: var(--p-text-muted-color, #888);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.breadcrumb-item.is-current .breadcrumb-text {
|
|
103
|
+
color: var(--p-text-color, #333);
|
|
104
|
+
font-weight: 500;
|
|
105
|
+
}
|
|
106
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* PageHeader - Reusable page header with breadcrumb, title and actions
|
|
4
|
+
*
|
|
5
|
+
* Props:
|
|
6
|
+
* - title: Page title (required)
|
|
7
|
+
* - breadcrumb: Array of { label, to?, icon? } - optional breadcrumb items
|
|
8
|
+
*
|
|
9
|
+
* Slots:
|
|
10
|
+
* - subtitle: Optional content next to title
|
|
11
|
+
* - actions: Action buttons on the right
|
|
12
|
+
*/
|
|
13
|
+
import Breadcrumb from './Breadcrumb.vue'
|
|
14
|
+
|
|
15
|
+
defineProps({
|
|
16
|
+
title: {
|
|
17
|
+
type: String,
|
|
18
|
+
required: true
|
|
19
|
+
},
|
|
20
|
+
subtitle: {
|
|
21
|
+
type: String,
|
|
22
|
+
default: null
|
|
23
|
+
},
|
|
24
|
+
breadcrumb: {
|
|
25
|
+
type: Array,
|
|
26
|
+
default: null
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<div class="page-header">
|
|
33
|
+
<div class="page-header-content">
|
|
34
|
+
<Breadcrumb v-if="breadcrumb?.length" :items="breadcrumb" />
|
|
35
|
+
<div class="page-header-row">
|
|
36
|
+
<div class="page-header-left">
|
|
37
|
+
<div>
|
|
38
|
+
<h1 class="page-title">{{ title }}</h1>
|
|
39
|
+
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
|
40
|
+
</div>
|
|
41
|
+
<slot name="subtitle" ></slot>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="header-actions">
|
|
44
|
+
<slot name="actions" ></slot>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<style scoped>
|
|
52
|
+
.page-header-content {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-direction: column;
|
|
55
|
+
gap: 0.25rem;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.page-header-row {
|
|
59
|
+
display: flex;
|
|
60
|
+
justify-content: space-between;
|
|
61
|
+
align-items: center;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.page-header-left {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 1rem;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.page-subtitle {
|
|
71
|
+
margin: 0.25rem 0 0 0;
|
|
72
|
+
font-size: 0.875rem;
|
|
73
|
+
color: var(--p-text-secondary);
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* PageLayout - Base layout for dashboard pages
|
|
4
|
+
*
|
|
5
|
+
* Provides:
|
|
6
|
+
* - Auto-generated breadcrumb
|
|
7
|
+
* - PageHeader with title and actions
|
|
8
|
+
* - CardsGrid zone (optional)
|
|
9
|
+
* - Main content slot
|
|
10
|
+
*
|
|
11
|
+
* Use this for custom pages that don't follow the standard list pattern.
|
|
12
|
+
* For CRUD list pages, use ListPage instead.
|
|
13
|
+
*
|
|
14
|
+
* Note: UnsavedChangesDialog is rendered automatically by AppLayout
|
|
15
|
+
* via provide/inject from useBareForm/useForm.
|
|
16
|
+
*/
|
|
17
|
+
import { toRef } from 'vue'
|
|
18
|
+
import PageHeader from './PageHeader.vue'
|
|
19
|
+
import CardsGrid from '../display/CardsGrid.vue'
|
|
20
|
+
import Button from 'primevue/button'
|
|
21
|
+
import { useBreadcrumb } from '../../composables/useBreadcrumb'
|
|
22
|
+
|
|
23
|
+
const props = defineProps({
|
|
24
|
+
// Header
|
|
25
|
+
title: { type: String, required: true },
|
|
26
|
+
subtitle: { type: String, default: null },
|
|
27
|
+
headerActions: { type: Array, default: () => [] },
|
|
28
|
+
breadcrumb: { type: Array, default: null }, // Override auto breadcrumb
|
|
29
|
+
|
|
30
|
+
// Entity data for dynamic breadcrumb labels
|
|
31
|
+
entity: { type: Object, default: null },
|
|
32
|
+
manager: { type: Object, default: null }, // EntityManager - provides labelField automatically
|
|
33
|
+
|
|
34
|
+
// Cards
|
|
35
|
+
cards: { type: Array, default: () => [] },
|
|
36
|
+
cardsColumns: { type: [Number, String], default: 'auto' }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Auto-generate breadcrumb from route, using entity data for labels
|
|
40
|
+
// getEntityLabel from manager handles both string field and callback
|
|
41
|
+
const { breadcrumbItems } = useBreadcrumb({
|
|
42
|
+
entity: toRef(() => props.entity), // Make reactive
|
|
43
|
+
getEntityLabel: props.manager
|
|
44
|
+
? (e) => props.manager.getEntityLabel(e)
|
|
45
|
+
: (e) => e?.name || null // Fallback if no manager
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
function resolveLabel(label) {
|
|
49
|
+
return typeof label === 'function' ? label() : label
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div>
|
|
55
|
+
<PageHeader :title="title" :breadcrumb="props.breadcrumb || breadcrumbItems">
|
|
56
|
+
<template #subtitle>
|
|
57
|
+
<slot name="subtitle">
|
|
58
|
+
<span v-if="subtitle" class="page-subtitle">{{ subtitle }}</span>
|
|
59
|
+
</slot>
|
|
60
|
+
</template>
|
|
61
|
+
<template #actions>
|
|
62
|
+
<slot name="header-actions" ></slot>
|
|
63
|
+
<Button
|
|
64
|
+
v-for="action in headerActions"
|
|
65
|
+
:key="action.name"
|
|
66
|
+
:label="resolveLabel(action.label)"
|
|
67
|
+
:icon="action.icon"
|
|
68
|
+
:severity="action.severity"
|
|
69
|
+
:loading="action.isLoading"
|
|
70
|
+
@click="action.onClick"
|
|
71
|
+
/>
|
|
72
|
+
</template>
|
|
73
|
+
</PageHeader>
|
|
74
|
+
|
|
75
|
+
<!-- Cards Zone -->
|
|
76
|
+
<CardsGrid v-if="cards.length > 0" :cards="cards" :columns="cardsColumns">
|
|
77
|
+
<template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]="slotProps">
|
|
78
|
+
<slot :name="slotName" v-bind="slotProps" ></slot>
|
|
79
|
+
</template>
|
|
80
|
+
</CardsGrid>
|
|
81
|
+
|
|
82
|
+
<!-- Main Content -->
|
|
83
|
+
<slot ></slot>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<style scoped>
|
|
88
|
+
.page-subtitle {
|
|
89
|
+
color: var(--p-surface-500);
|
|
90
|
+
font-size: 0.9rem;
|
|
91
|
+
margin-left: 1rem;
|
|
92
|
+
}
|
|
93
|
+
</style>
|