@finema/finework-layer 0.2.64 → 0.2.66
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/.husky/pre-commit +1 -1
- package/.playground/app/pages/layout-admin/[id]/index.vue +145 -145
- package/.playground/app/pages/layout-admin/test/[id]/index.vue +286 -286
- package/.playground/app/pages/layout-admin.vue +275 -275
- package/.playground/app/pages/submenu/layout-admin.vue +210 -210
- package/.vscode/extensions.json +0 -1
- package/CHANGELOG.md +336 -328
- package/app/app.config.ts +136 -136
- package/app/app.vue +10 -10
- package/app/assets/css/main.css +75 -75
- package/app/components/Button/ActionIcon.vue +29 -29
- package/app/components/Button/Back.vue +22 -22
- package/app/components/InfoItemList.vue +196 -196
- package/app/components/Layout/Admin/Sidebar.vue +329 -329
- package/app/components/Layout/Admin/index.vue +224 -224
- package/app/components/Layout/Apps.vue +45 -45
- package/app/components/Layout/User/index.vue +99 -99
- package/app/components/StatusBox.vue +56 -56
- package/app/composables/useRequestOptions.ts +50 -50
- package/app/constants/routes.ts +86 -86
- package/app/error.vue +218 -218
- package/app/middleware/auth.ts +45 -45
- package/app/middleware/common.ts +12 -12
- package/app/middleware/guest.ts +7 -7
- package/app/middleware/permissions.ts +29 -29
- package/bun.lock +2 -2
- package/index.d.ts +16 -16
- package/nuxt.config.ts +41 -41
- package/package.json +2 -2
|
@@ -1,329 +1,329 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<aside
|
|
3
|
-
:class="[
|
|
4
|
-
`
|
|
5
|
-
flex h-full flex-col overflow-y-auto bg-white
|
|
6
|
-
transition-all duration-300
|
|
7
|
-
`,
|
|
8
|
-
]"
|
|
9
|
-
>
|
|
10
|
-
<div
|
|
11
|
-
:class="[
|
|
12
|
-
'flex h-[72px] items-center max-sm:pr-5',
|
|
13
|
-
{
|
|
14
|
-
'justify-center': isCollapsed,
|
|
15
|
-
'w-[260px] justify-between pl-5': !isCollapsed,
|
|
16
|
-
},
|
|
17
|
-
]"
|
|
18
|
-
>
|
|
19
|
-
<NuxtLink :to="routes.home.to">
|
|
20
|
-
<img
|
|
21
|
-
v-if="!isCollapsed"
|
|
22
|
-
src="/logo-mini.png"
|
|
23
|
-
alt="logo_mini_color"
|
|
24
|
-
class="h-[27px]"
|
|
25
|
-
/>
|
|
26
|
-
</NuxtLink>
|
|
27
|
-
|
|
28
|
-
<Navbar />
|
|
29
|
-
</div>
|
|
30
|
-
<div
|
|
31
|
-
class="bg-primary flex items-center justify-between px-7 py-4 font-bold text-white"
|
|
32
|
-
>
|
|
33
|
-
{{ isCollapsed ? "" : label }}
|
|
34
|
-
|
|
35
|
-
<Icon
|
|
36
|
-
v-if="!isCollapsed"
|
|
37
|
-
name="fluent:arrow-circle-left-24-regular"
|
|
38
|
-
class="size-[24px] cursor-pointer"
|
|
39
|
-
@click="$emit('toggle-collapsed')"
|
|
40
|
-
/>
|
|
41
|
-
<Icon
|
|
42
|
-
v-else
|
|
43
|
-
name="fluent:arrow-circle-right-24-regular"
|
|
44
|
-
class="size-[24px] cursor-pointer"
|
|
45
|
-
@click="$emit('toggle-collapsed')"
|
|
46
|
-
/>
|
|
47
|
-
</div>
|
|
48
|
-
|
|
49
|
-
<div class="flex-1 overflow-y-auto border-r border-gray-200 p-4">
|
|
50
|
-
<div
|
|
51
|
-
v-if="isGroup"
|
|
52
|
-
class="space-y-5"
|
|
53
|
-
>
|
|
54
|
-
<div
|
|
55
|
-
v-for="group in navigationItems"
|
|
56
|
-
:key="group.label"
|
|
57
|
-
>
|
|
58
|
-
<div class="truncate px-2.5 text-xs font-bold text-gray-500">
|
|
59
|
-
{{ group.label }}
|
|
60
|
-
</div>
|
|
61
|
-
<Separator
|
|
62
|
-
v-if="group.label"
|
|
63
|
-
class="mt-3 mb-1"
|
|
64
|
-
/>
|
|
65
|
-
<NavigationMenu
|
|
66
|
-
orientation="vertical"
|
|
67
|
-
:items="group.children"
|
|
68
|
-
:collapsed="isCollapsed"
|
|
69
|
-
:popover="isCollapsed"
|
|
70
|
-
:tooltip="isCollapsed"
|
|
71
|
-
:ui="{
|
|
72
|
-
list: 'space-y-2 ',
|
|
73
|
-
label: [
|
|
74
|
-
'text-sm font-bold text-gray-500 py-[12px] px-[10px] rounded-lg',
|
|
75
|
-
'hover:text-primary',
|
|
76
|
-
],
|
|
77
|
-
link: [
|
|
78
|
-
'cursor-pointer text-sm font-bold text-gray-500 px-[10px] rounded-lg gap-3',
|
|
79
|
-
'hover:text-primary',
|
|
80
|
-
'data-active:before:bg-[#F9FAFB] data-active:before:rounded-lg data-active:text-primary font-semibold',
|
|
81
|
-
],
|
|
82
|
-
linkLabel: '!text-[#344054] font-bold',
|
|
83
|
-
linkLeadingIcon:
|
|
84
|
-
'group-data-[state=open]:text-current text-current size-[24px] group-hover:text-primary',
|
|
85
|
-
childList: 'border-none ms-0 bg-white mt-2 rounded-lg',
|
|
86
|
-
childLink: 'ps-0',
|
|
87
|
-
childItem: 'ps-0',
|
|
88
|
-
}"
|
|
89
|
-
class="w-full justify-center"
|
|
90
|
-
/>
|
|
91
|
-
</div>
|
|
92
|
-
</div>
|
|
93
|
-
<NavigationMenu
|
|
94
|
-
v-else
|
|
95
|
-
orientation="vertical"
|
|
96
|
-
:items="navigationItems"
|
|
97
|
-
:collapsed="isCollapsed"
|
|
98
|
-
:popover="isCollapsed"
|
|
99
|
-
:tooltip="isCollapsed"
|
|
100
|
-
:ui="{
|
|
101
|
-
list: 'space-y-2 ',
|
|
102
|
-
label: [
|
|
103
|
-
'text-sm font-bold text-gray-500 py-[12px] px-[10px] rounded-lg',
|
|
104
|
-
'hover:text-primary ',
|
|
105
|
-
],
|
|
106
|
-
link: [
|
|
107
|
-
'cursor-pointer text-sm font-bold text-gray-500 px-[10px] rounded-lg gap-3',
|
|
108
|
-
'hover:text-primary',
|
|
109
|
-
'data-active:before:bg-[#F9FAFB] data-active:before:rounded-lg data-active:text-primary font-semibold',
|
|
110
|
-
],
|
|
111
|
-
linkLabel: '!text-[#344054] font-bold',
|
|
112
|
-
linkLeadingIcon:
|
|
113
|
-
'group-data-[state=open]:text-current text-current size-[24px] group-hover:text-primary ',
|
|
114
|
-
childList: 'border-none ms-0 bg-white mt-2 rounded-lg',
|
|
115
|
-
childLink: 'ps-0',
|
|
116
|
-
childItem: 'ps-0',
|
|
117
|
-
}"
|
|
118
|
-
class="w-full justify-center"
|
|
119
|
-
/>
|
|
120
|
-
</div>
|
|
121
|
-
<div
|
|
122
|
-
v-if="isMobile"
|
|
123
|
-
class="border-t border-gray-100 p-3"
|
|
124
|
-
>
|
|
125
|
-
<div class="flex items-center justify-between gap-2">
|
|
126
|
-
<div class="flex min-w-0 flex-1 items-center gap-3">
|
|
127
|
-
<Avatar
|
|
128
|
-
class="border-muted size-[32px] flex-shrink-0 border text-lg"
|
|
129
|
-
icon="ri:user-line"
|
|
130
|
-
:src="auth.me.value?.avatar_url || ''"
|
|
131
|
-
/>
|
|
132
|
-
<div class="flex min-w-0 flex-1 flex-col">
|
|
133
|
-
<p class="truncate text-sm font-bold">
|
|
134
|
-
{{ auth.me.value?.display_name || auth.me.value?.full_name }}
|
|
135
|
-
</p>
|
|
136
|
-
<p class="text-muted truncate text-xs">
|
|
137
|
-
{{ auth.me.value?.email || "" }}
|
|
138
|
-
</p>
|
|
139
|
-
</div>
|
|
140
|
-
</div>
|
|
141
|
-
<DropdownMenu
|
|
142
|
-
arrow
|
|
143
|
-
size="xl"
|
|
144
|
-
:items="userMenuItems"
|
|
145
|
-
:ui="{
|
|
146
|
-
content: 'w-48',
|
|
147
|
-
}"
|
|
148
|
-
>
|
|
149
|
-
<Button
|
|
150
|
-
icon="ph:dots-three-outline-vertical-bold"
|
|
151
|
-
variant="ghost"
|
|
152
|
-
color="neutral"
|
|
153
|
-
size="xs"
|
|
154
|
-
/>
|
|
155
|
-
</DropdownMenu>
|
|
156
|
-
</div>
|
|
157
|
-
</div>
|
|
158
|
-
</aside>
|
|
159
|
-
</template>
|
|
160
|
-
|
|
161
|
-
<script lang="ts" setup>
|
|
162
|
-
import type { NavigationMenuItem } from '@nuxt/ui'
|
|
163
|
-
import { computed } from 'vue'
|
|
164
|
-
import { useRoute } from 'vue-router'
|
|
165
|
-
import type { Permission, UserModule } from '#imports'
|
|
166
|
-
import Navbar from '../Apps.vue'
|
|
167
|
-
|
|
168
|
-
defineEmits<{
|
|
169
|
-
'toggle-collapsed': []
|
|
170
|
-
}>()
|
|
171
|
-
|
|
172
|
-
const props = defineProps<{
|
|
173
|
-
label: string
|
|
174
|
-
isCollapsed: boolean
|
|
175
|
-
isMobile?: boolean
|
|
176
|
-
items: NavigationMenuItem[]
|
|
177
|
-
isGroup?: boolean
|
|
178
|
-
}>()
|
|
179
|
-
|
|
180
|
-
const route = useRoute()
|
|
181
|
-
const auth = useAuth()
|
|
182
|
-
const userMenuItems = [
|
|
183
|
-
{
|
|
184
|
-
label: 'View profile',
|
|
185
|
-
icon: 'i-lucide-user',
|
|
186
|
-
to: routes.account.profile.to,
|
|
187
|
-
},
|
|
188
|
-
|
|
189
|
-
{
|
|
190
|
-
label: routes.logout.label,
|
|
191
|
-
icon: 'i-lucide-log-out',
|
|
192
|
-
to: routes.logout.to,
|
|
193
|
-
external: true,
|
|
194
|
-
},
|
|
195
|
-
]
|
|
196
|
-
|
|
197
|
-
const normalizePath = (path: string) => path.replace(/\/$/, '')
|
|
198
|
-
|
|
199
|
-
const mappedItems = (items: NavigationMenuItem[]): NavigationMenuItem[] => {
|
|
200
|
-
return items.map((item) => {
|
|
201
|
-
let isAnyChildActive = false
|
|
202
|
-
const mappedChildren = item.children?.map((child) => {
|
|
203
|
-
const isChildCurrentlyActive
|
|
204
|
-
= normalizePath(route.path) === normalizePath(String(child.to))
|
|
205
|
-
|
|
206
|
-
if (isChildCurrentlyActive) {
|
|
207
|
-
isAnyChildActive = true
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const baseClass
|
|
211
|
-
= 'hover:bg-[#F9FAFB] hover:text-gray-700 hover:font-bold py-2 pl-8 data-active:before:bg-[#F9FAFB] data-active:text-primary data-active:font-bold'
|
|
212
|
-
|
|
213
|
-
const hasIcon = !!child.icon
|
|
214
|
-
const className = hasIcon ? baseClass : `${baseClass} gap-0 data-active:text-gray-500!`
|
|
215
|
-
const icon = hasIcon ? child.icon : 'ph:dot-outline-fill'
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
active: isChildCurrentlyActive,
|
|
219
|
-
class: className,
|
|
220
|
-
icon,
|
|
221
|
-
...child,
|
|
222
|
-
}
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
const allPaths = props.items
|
|
226
|
-
.flatMap((i) => [i.to, ...(i.children?.map((c) => c.to) || [])])
|
|
227
|
-
.filter(Boolean) as string[]
|
|
228
|
-
|
|
229
|
-
const hasExactMatch = allPaths.some(
|
|
230
|
-
(p) => normalizePath(route.path) === normalizePath(p),
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
const basePath = item.to ? normalizePath(String(item.to)) : ''
|
|
234
|
-
const currentPath = normalizePath(route.path)
|
|
235
|
-
const selfIsActive = item.to
|
|
236
|
-
? (
|
|
237
|
-
currentPath === basePath
|
|
238
|
-
|| (
|
|
239
|
-
currentPath.startsWith(`${basePath}/`)
|
|
240
|
-
&& !currentPath.slice(basePath.length + 1).includes('/')
|
|
241
|
-
&& !hasExactMatch
|
|
242
|
-
)
|
|
243
|
-
)
|
|
244
|
-
: false
|
|
245
|
-
|
|
246
|
-
const itemIsActive = selfIsActive || isAnyChildActive
|
|
247
|
-
|
|
248
|
-
const itemDefaultOpen = item.children ? isAnyChildActive : false
|
|
249
|
-
|
|
250
|
-
return {
|
|
251
|
-
...item,
|
|
252
|
-
active: itemIsActive,
|
|
253
|
-
class: itemIsActive
|
|
254
|
-
? 'before:bg-[#F9FAFB] before:rounded-lg text-primary'
|
|
255
|
-
: '',
|
|
256
|
-
defaultOpen: itemDefaultOpen || selfIsActive,
|
|
257
|
-
open: itemDefaultOpen || selfIsActive,
|
|
258
|
-
children: mappedChildren,
|
|
259
|
-
to: mappedChildren ? undefined : item.to,
|
|
260
|
-
}
|
|
261
|
-
})
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// filter items base on auth.hasPermission and nested children , permissions: ['pmo:USER', 'pmo:ADMIN', 'pmo:SUPER']
|
|
265
|
-
const filterItems = (items: NavigationMenuItem[]): NavigationMenuItem[] => {
|
|
266
|
-
return items
|
|
267
|
-
.filter((item) => {
|
|
268
|
-
if (item.permissions && Array.isArray(item.permissions)) {
|
|
269
|
-
// permissions: ['pmo:USER', 'pmo:ADMIN', 'pmo:SUPER'] => module: 'pmo', permissions: ['USER', 'ADMIN', 'SUPER']
|
|
270
|
-
const permissionStrings = item.permissions as string[]
|
|
271
|
-
|
|
272
|
-
// Extract module from first permission (all should have same module)
|
|
273
|
-
const firstPermission = permissionStrings[0]
|
|
274
|
-
|
|
275
|
-
if (!firstPermission) {
|
|
276
|
-
return true
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const [moduleStr] = firstPermission.split(':')
|
|
280
|
-
|
|
281
|
-
// Extract all permission levels
|
|
282
|
-
const permissionLevels = permissionStrings.map(
|
|
283
|
-
(p) => p.split(':')[1],
|
|
284
|
-
) as Permission[]
|
|
285
|
-
|
|
286
|
-
return auth.hasPermission(moduleStr as UserModule, ...permissionLevels)
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Recursively filter children
|
|
290
|
-
if (item.children) {
|
|
291
|
-
const filteredChildren = filterItems(
|
|
292
|
-
item.children as NavigationMenuItem[],
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
// Only include parent if it has visible children or no permission requirement
|
|
296
|
-
return filteredChildren.length > 0
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
return true
|
|
300
|
-
})
|
|
301
|
-
.map((item) => {
|
|
302
|
-
// Apply filtering to children
|
|
303
|
-
if (item.children) {
|
|
304
|
-
return {
|
|
305
|
-
...item,
|
|
306
|
-
children: filterItems(item.children as NavigationMenuItem[]),
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
return item
|
|
311
|
-
})
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const navigationItems = computed<NavigationMenuItem[]>(() => {
|
|
315
|
-
// First filter items based on permissions
|
|
316
|
-
const filteredItems = filterItems(props.items)
|
|
317
|
-
|
|
318
|
-
if (props.isGroup) {
|
|
319
|
-
return filteredItems.map((group) => {
|
|
320
|
-
return {
|
|
321
|
-
...group,
|
|
322
|
-
children: mappedItems(group.children as NavigationMenuItem[]),
|
|
323
|
-
}
|
|
324
|
-
})
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return mappedItems(filteredItems)
|
|
328
|
-
})
|
|
329
|
-
</script>
|
|
1
|
+
<template>
|
|
2
|
+
<aside
|
|
3
|
+
:class="[
|
|
4
|
+
`
|
|
5
|
+
flex h-full flex-col overflow-y-auto bg-white
|
|
6
|
+
transition-all duration-300
|
|
7
|
+
`,
|
|
8
|
+
]"
|
|
9
|
+
>
|
|
10
|
+
<div
|
|
11
|
+
:class="[
|
|
12
|
+
'flex h-[72px] items-center max-sm:pr-5',
|
|
13
|
+
{
|
|
14
|
+
'justify-center': isCollapsed,
|
|
15
|
+
'w-[260px] justify-between pl-5': !isCollapsed,
|
|
16
|
+
},
|
|
17
|
+
]"
|
|
18
|
+
>
|
|
19
|
+
<NuxtLink :to="routes.home.to">
|
|
20
|
+
<img
|
|
21
|
+
v-if="!isCollapsed"
|
|
22
|
+
src="/logo-mini.png"
|
|
23
|
+
alt="logo_mini_color"
|
|
24
|
+
class="h-[27px]"
|
|
25
|
+
/>
|
|
26
|
+
</NuxtLink>
|
|
27
|
+
|
|
28
|
+
<Navbar />
|
|
29
|
+
</div>
|
|
30
|
+
<div
|
|
31
|
+
class="bg-primary flex items-center justify-between px-7 py-4 font-bold text-white"
|
|
32
|
+
>
|
|
33
|
+
{{ isCollapsed ? "" : label }}
|
|
34
|
+
|
|
35
|
+
<Icon
|
|
36
|
+
v-if="!isCollapsed"
|
|
37
|
+
name="fluent:arrow-circle-left-24-regular"
|
|
38
|
+
class="size-[24px] cursor-pointer"
|
|
39
|
+
@click="$emit('toggle-collapsed')"
|
|
40
|
+
/>
|
|
41
|
+
<Icon
|
|
42
|
+
v-else
|
|
43
|
+
name="fluent:arrow-circle-right-24-regular"
|
|
44
|
+
class="size-[24px] cursor-pointer"
|
|
45
|
+
@click="$emit('toggle-collapsed')"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="flex-1 overflow-y-auto border-r border-gray-200 p-4">
|
|
50
|
+
<div
|
|
51
|
+
v-if="isGroup"
|
|
52
|
+
class="space-y-5"
|
|
53
|
+
>
|
|
54
|
+
<div
|
|
55
|
+
v-for="group in navigationItems"
|
|
56
|
+
:key="group.label"
|
|
57
|
+
>
|
|
58
|
+
<div class="truncate px-2.5 text-xs font-bold text-gray-500">
|
|
59
|
+
{{ group.label }}
|
|
60
|
+
</div>
|
|
61
|
+
<Separator
|
|
62
|
+
v-if="group.label"
|
|
63
|
+
class="mt-3 mb-1"
|
|
64
|
+
/>
|
|
65
|
+
<NavigationMenu
|
|
66
|
+
orientation="vertical"
|
|
67
|
+
:items="group.children"
|
|
68
|
+
:collapsed="isCollapsed"
|
|
69
|
+
:popover="isCollapsed"
|
|
70
|
+
:tooltip="isCollapsed"
|
|
71
|
+
:ui="{
|
|
72
|
+
list: 'space-y-2 ',
|
|
73
|
+
label: [
|
|
74
|
+
'text-sm font-bold text-gray-500 py-[12px] px-[10px] rounded-lg',
|
|
75
|
+
'hover:text-primary',
|
|
76
|
+
],
|
|
77
|
+
link: [
|
|
78
|
+
'cursor-pointer text-sm font-bold text-gray-500 px-[10px] rounded-lg gap-3',
|
|
79
|
+
'hover:text-primary',
|
|
80
|
+
'data-active:before:bg-[#F9FAFB] data-active:before:rounded-lg data-active:text-primary font-semibold',
|
|
81
|
+
],
|
|
82
|
+
linkLabel: '!text-[#344054] font-bold',
|
|
83
|
+
linkLeadingIcon:
|
|
84
|
+
'group-data-[state=open]:text-current text-current size-[24px] group-hover:text-primary',
|
|
85
|
+
childList: 'border-none ms-0 bg-white mt-2 rounded-lg',
|
|
86
|
+
childLink: 'ps-0',
|
|
87
|
+
childItem: 'ps-0',
|
|
88
|
+
}"
|
|
89
|
+
class="w-full justify-center"
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<NavigationMenu
|
|
94
|
+
v-else
|
|
95
|
+
orientation="vertical"
|
|
96
|
+
:items="navigationItems"
|
|
97
|
+
:collapsed="isCollapsed"
|
|
98
|
+
:popover="isCollapsed"
|
|
99
|
+
:tooltip="isCollapsed"
|
|
100
|
+
:ui="{
|
|
101
|
+
list: 'space-y-2 ',
|
|
102
|
+
label: [
|
|
103
|
+
'text-sm font-bold text-gray-500 py-[12px] px-[10px] rounded-lg',
|
|
104
|
+
'hover:text-primary ',
|
|
105
|
+
],
|
|
106
|
+
link: [
|
|
107
|
+
'cursor-pointer text-sm font-bold text-gray-500 px-[10px] rounded-lg gap-3',
|
|
108
|
+
'hover:text-primary',
|
|
109
|
+
'data-active:before:bg-[#F9FAFB] data-active:before:rounded-lg data-active:text-primary font-semibold',
|
|
110
|
+
],
|
|
111
|
+
linkLabel: '!text-[#344054] font-bold',
|
|
112
|
+
linkLeadingIcon:
|
|
113
|
+
'group-data-[state=open]:text-current text-current size-[24px] group-hover:text-primary ',
|
|
114
|
+
childList: 'border-none ms-0 bg-white mt-2 rounded-lg',
|
|
115
|
+
childLink: 'ps-0',
|
|
116
|
+
childItem: 'ps-0',
|
|
117
|
+
}"
|
|
118
|
+
class="w-full justify-center"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<div
|
|
122
|
+
v-if="isMobile"
|
|
123
|
+
class="border-t border-gray-100 p-3"
|
|
124
|
+
>
|
|
125
|
+
<div class="flex items-center justify-between gap-2">
|
|
126
|
+
<div class="flex min-w-0 flex-1 items-center gap-3">
|
|
127
|
+
<Avatar
|
|
128
|
+
class="border-muted size-[32px] flex-shrink-0 border text-lg"
|
|
129
|
+
icon="ri:user-line"
|
|
130
|
+
:src="auth.me.value?.avatar_url || ''"
|
|
131
|
+
/>
|
|
132
|
+
<div class="flex min-w-0 flex-1 flex-col">
|
|
133
|
+
<p class="truncate text-sm font-bold">
|
|
134
|
+
{{ auth.me.value?.display_name || auth.me.value?.full_name }}
|
|
135
|
+
</p>
|
|
136
|
+
<p class="text-muted truncate text-xs">
|
|
137
|
+
{{ auth.me.value?.email || "" }}
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
<DropdownMenu
|
|
142
|
+
arrow
|
|
143
|
+
size="xl"
|
|
144
|
+
:items="userMenuItems"
|
|
145
|
+
:ui="{
|
|
146
|
+
content: 'w-48',
|
|
147
|
+
}"
|
|
148
|
+
>
|
|
149
|
+
<Button
|
|
150
|
+
icon="ph:dots-three-outline-vertical-bold"
|
|
151
|
+
variant="ghost"
|
|
152
|
+
color="neutral"
|
|
153
|
+
size="xs"
|
|
154
|
+
/>
|
|
155
|
+
</DropdownMenu>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</aside>
|
|
159
|
+
</template>
|
|
160
|
+
|
|
161
|
+
<script lang="ts" setup>
|
|
162
|
+
import type { NavigationMenuItem } from '@nuxt/ui'
|
|
163
|
+
import { computed } from 'vue'
|
|
164
|
+
import { useRoute } from 'vue-router'
|
|
165
|
+
import type { Permission, UserModule } from '#imports'
|
|
166
|
+
import Navbar from '../Apps.vue'
|
|
167
|
+
|
|
168
|
+
defineEmits<{
|
|
169
|
+
'toggle-collapsed': []
|
|
170
|
+
}>()
|
|
171
|
+
|
|
172
|
+
const props = defineProps<{
|
|
173
|
+
label: string
|
|
174
|
+
isCollapsed: boolean
|
|
175
|
+
isMobile?: boolean
|
|
176
|
+
items: NavigationMenuItem[]
|
|
177
|
+
isGroup?: boolean
|
|
178
|
+
}>()
|
|
179
|
+
|
|
180
|
+
const route = useRoute()
|
|
181
|
+
const auth = useAuth()
|
|
182
|
+
const userMenuItems = [
|
|
183
|
+
{
|
|
184
|
+
label: 'View profile',
|
|
185
|
+
icon: 'i-lucide-user',
|
|
186
|
+
to: routes.account.profile.to,
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
label: routes.logout.label,
|
|
191
|
+
icon: 'i-lucide-log-out',
|
|
192
|
+
to: routes.logout.to,
|
|
193
|
+
external: true,
|
|
194
|
+
},
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
const normalizePath = (path: string) => path.replace(/\/$/, '')
|
|
198
|
+
|
|
199
|
+
const mappedItems = (items: NavigationMenuItem[]): NavigationMenuItem[] => {
|
|
200
|
+
return items.map((item) => {
|
|
201
|
+
let isAnyChildActive = false
|
|
202
|
+
const mappedChildren = item.children?.map((child) => {
|
|
203
|
+
const isChildCurrentlyActive
|
|
204
|
+
= normalizePath(route.path) === normalizePath(String(child.to))
|
|
205
|
+
|
|
206
|
+
if (isChildCurrentlyActive) {
|
|
207
|
+
isAnyChildActive = true
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const baseClass
|
|
211
|
+
= 'hover:bg-[#F9FAFB] hover:text-gray-700 hover:font-bold py-2 pl-8 data-active:before:bg-[#F9FAFB] data-active:text-primary data-active:font-bold'
|
|
212
|
+
|
|
213
|
+
const hasIcon = !!child.icon
|
|
214
|
+
const className = hasIcon ? baseClass : `${baseClass} gap-0 data-active:text-gray-500!`
|
|
215
|
+
const icon = hasIcon ? child.icon : 'ph:dot-outline-fill'
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
active: isChildCurrentlyActive,
|
|
219
|
+
class: className,
|
|
220
|
+
icon,
|
|
221
|
+
...child,
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const allPaths = props.items
|
|
226
|
+
.flatMap((i) => [i.to, ...(i.children?.map((c) => c.to) || [])])
|
|
227
|
+
.filter(Boolean) as string[]
|
|
228
|
+
|
|
229
|
+
const hasExactMatch = allPaths.some(
|
|
230
|
+
(p) => normalizePath(route.path) === normalizePath(p),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
const basePath = item.to ? normalizePath(String(item.to)) : ''
|
|
234
|
+
const currentPath = normalizePath(route.path)
|
|
235
|
+
const selfIsActive = item.to
|
|
236
|
+
? (
|
|
237
|
+
currentPath === basePath
|
|
238
|
+
|| (
|
|
239
|
+
currentPath.startsWith(`${basePath}/`)
|
|
240
|
+
&& !currentPath.slice(basePath.length + 1).includes('/')
|
|
241
|
+
&& !hasExactMatch
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
: false
|
|
245
|
+
|
|
246
|
+
const itemIsActive = selfIsActive || isAnyChildActive
|
|
247
|
+
|
|
248
|
+
const itemDefaultOpen = item.children ? isAnyChildActive : false
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
...item,
|
|
252
|
+
active: itemIsActive,
|
|
253
|
+
class: itemIsActive
|
|
254
|
+
? 'before:bg-[#F9FAFB] before:rounded-lg text-primary'
|
|
255
|
+
: '',
|
|
256
|
+
defaultOpen: itemDefaultOpen || selfIsActive,
|
|
257
|
+
open: itemDefaultOpen || selfIsActive,
|
|
258
|
+
children: mappedChildren,
|
|
259
|
+
to: mappedChildren ? undefined : item.to,
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// filter items base on auth.hasPermission and nested children , permissions: ['pmo:USER', 'pmo:ADMIN', 'pmo:SUPER']
|
|
265
|
+
const filterItems = (items: NavigationMenuItem[]): NavigationMenuItem[] => {
|
|
266
|
+
return items
|
|
267
|
+
.filter((item) => {
|
|
268
|
+
if (item.permissions && Array.isArray(item.permissions)) {
|
|
269
|
+
// permissions: ['pmo:USER', 'pmo:ADMIN', 'pmo:SUPER'] => module: 'pmo', permissions: ['USER', 'ADMIN', 'SUPER']
|
|
270
|
+
const permissionStrings = item.permissions as string[]
|
|
271
|
+
|
|
272
|
+
// Extract module from first permission (all should have same module)
|
|
273
|
+
const firstPermission = permissionStrings[0]
|
|
274
|
+
|
|
275
|
+
if (!firstPermission) {
|
|
276
|
+
return true
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const [moduleStr] = firstPermission.split(':')
|
|
280
|
+
|
|
281
|
+
// Extract all permission levels
|
|
282
|
+
const permissionLevels = permissionStrings.map(
|
|
283
|
+
(p) => p.split(':')[1],
|
|
284
|
+
) as Permission[]
|
|
285
|
+
|
|
286
|
+
return auth.hasPermission(moduleStr as UserModule, ...permissionLevels)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Recursively filter children
|
|
290
|
+
if (item.children) {
|
|
291
|
+
const filteredChildren = filterItems(
|
|
292
|
+
item.children as NavigationMenuItem[],
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
// Only include parent if it has visible children or no permission requirement
|
|
296
|
+
return filteredChildren.length > 0
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return true
|
|
300
|
+
})
|
|
301
|
+
.map((item) => {
|
|
302
|
+
// Apply filtering to children
|
|
303
|
+
if (item.children) {
|
|
304
|
+
return {
|
|
305
|
+
...item,
|
|
306
|
+
children: filterItems(item.children as NavigationMenuItem[]),
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return item
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const navigationItems = computed<NavigationMenuItem[]>(() => {
|
|
315
|
+
// First filter items based on permissions
|
|
316
|
+
const filteredItems = filterItems(props.items)
|
|
317
|
+
|
|
318
|
+
if (props.isGroup) {
|
|
319
|
+
return filteredItems.map((group) => {
|
|
320
|
+
return {
|
|
321
|
+
...group,
|
|
322
|
+
children: mappedItems(group.children as NavigationMenuItem[]),
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return mappedItems(filteredItems)
|
|
328
|
+
})
|
|
329
|
+
</script>
|