mgv-backoffice 1.18.0 → 1.20.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/README.md +152 -9
- package/dist/{src/components → components}/BaseActionButton.vue.d.ts +9 -13
- package/dist/{src/components → components}/BaseAlert.vue.d.ts +3 -2
- package/dist/components/BaseAppLayout.vue.d.ts +28 -0
- package/dist/{src/components → components}/BaseBadge.vue.d.ts +7 -11
- package/dist/{src/components → components}/BaseBreadcrumb.vue.d.ts +3 -2
- package/dist/{src/components → components}/BaseButton.vue.d.ts +9 -12
- package/dist/{src/components → components}/BaseCollapsibleSection.vue.d.ts +7 -11
- package/dist/{src/components → components}/BaseConfirmModal.vue.d.ts +9 -12
- package/dist/{src/components → components}/BaseCopyButton.vue.d.ts +2 -1
- package/dist/{src/components → components}/BaseEntityPickerModal.vue.d.ts +8 -16
- package/dist/components/BaseGoogleSignInButton.vue.d.ts +22 -0
- package/dist/{src/components → components}/BaseLine.vue.d.ts +2 -1
- package/dist/components/BaseLoginForm.vue.d.ts +51 -0
- package/dist/{src/components → components}/BaseLogo.vue.d.ts +2 -1
- package/dist/{src/components → components}/BaseModal.vue.d.ts +7 -11
- package/dist/{src/components → components}/BaseModalShell.vue.d.ts +19 -17
- package/dist/{src/components → components}/BaseNotFoundPage.vue.d.ts +3 -2
- package/dist/components/BaseNotificationPanel.vue.d.ts +19 -0
- package/dist/components/BasePageHeader.vue.d.ts +28 -0
- package/dist/{src/components → components}/BaseRow.vue.d.ts +8 -12
- package/dist/components/BaseSidebar.vue.d.ts +47 -0
- package/dist/{src/components → components}/BaseSpinner.vue.d.ts +2 -1
- package/dist/{src/components → components}/BaseTextInputModal.vue.d.ts +8 -16
- package/dist/{src/components → components}/BaseToast.vue.d.ts +2 -1
- package/dist/{src/components → components}/BaseToolbarButton.vue.d.ts +9 -13
- package/dist/components/ColoredSquares.vue.d.ts +23 -0
- package/dist/{src/components → components}/EarningsCard.vue.d.ts +3 -2
- package/dist/{src/components → components}/EuroAmount.vue.d.ts +2 -1
- package/dist/{src/components → components}/Pagination.vue.d.ts +3 -2
- package/dist/{src/components → components}/TrendArrow.vue.d.ts +2 -1
- package/dist/composables/useNotifications.d.ts +29 -0
- package/dist/composables/useSidebarCollapse.d.ts +22 -0
- package/dist/index.d.ts +62 -1
- package/dist/types/auth.d.ts +11 -0
- package/dist/types/notification.d.ts +20 -0
- package/dist/ui-lib.css +2 -2
- package/dist/ui-lib.js +1088 -671
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +14 -13
- package/src/components/BaseAppLayout.vue +4 -1
- package/src/components/BaseGoogleSignInButton.vue +83 -0
- package/src/components/BaseLoginForm.test.ts +52 -0
- package/src/components/BaseLoginForm.vue +204 -0
- package/src/components/BaseNotificationPanel.vue +183 -0
- package/src/components/BaseSidebar.vue +140 -31
- package/src/composables/useNotifications.test.ts +64 -0
- package/src/composables/useNotifications.ts +81 -0
- package/src/composables/useSidebarCollapse.ts +64 -0
- package/src/index.ts +8 -0
- package/src/types/auth.ts +11 -0
- package/src/types/notification.ts +20 -0
- package/dist/src/components/BaseAppLayout.vue.d.ts +0 -31
- package/dist/src/components/BasePageHeader.vue.d.ts +0 -31
- package/dist/src/components/BaseSidebar.vue.d.ts +0 -46
- package/dist/src/components/ColoredSquares.vue.d.ts +0 -27
- package/dist/src/index.d.ts +0 -54
- /package/dist/{src/composables → composables}/useDebounce.d.ts +0 -0
- /package/dist/{src/composables → composables}/useEscapeKey.d.ts +0 -0
- /package/dist/{src/composables → composables}/useMobileSidebar.d.ts +0 -0
- /package/dist/{src/composables → composables}/useTheme.d.ts +0 -0
- /package/dist/{src/composables → composables}/useThemeClasses.d.ts +0 -0
- /package/dist/{src/composables → composables}/useToast.d.ts +0 -0
- /package/dist/{src/enums → enums}/AlertEnum.d.ts +0 -0
- /package/dist/{src/enums → enums}/BaseBadgeEnum.d.ts +0 -0
- /package/dist/{src/enums → enums}/BaseButtonEnum.d.ts +0 -0
- /package/dist/{src/enums → enums}/BaseButtonSizeEnum.d.ts +0 -0
- /package/dist/{src/enums → enums}/BaseLogoEnum.d.ts +0 -0
- /package/dist/{src/enums → enums}/BaseModalEnum.d.ts +0 -0
- /package/dist/{src/enums → enums}/BaseToastEnum.d.ts +0 -0
- /package/dist/{src/enums → enums}/ColorsEnums.d.ts +0 -0
- /package/dist/{src/enums → enums}/LineEnum.d.ts +0 -0
- /package/dist/{src/enums → enums}/PositioningEnum.d.ts +0 -0
- /package/dist/{src/types → types}/entityPicker.d.ts +0 -0
- /package/dist/{src/types → types}/sidebar.d.ts +0 -0
- /package/dist/{src/utils → utils}/format.d.ts +0 -0
- /package/dist/{src/utils → utils}/httpColors.d.ts +0 -0
- /package/dist/{src/utils → utils}/pnl.d.ts +0 -0
- /package/dist/{src/utils → utils}/sanitizeHtml.d.ts +0 -0
- /package/dist/{src/utils → utils}/util.d.ts +0 -0
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, nextTick, watch } from 'vue'
|
|
2
|
+
import { ref, computed, nextTick, watch } from 'vue'
|
|
3
3
|
import { useRouter, useRoute } from 'vue-router'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
Bars3Icon,
|
|
6
|
+
XMarkIcon,
|
|
7
|
+
ChevronDoubleLeftIcon,
|
|
8
|
+
ChevronDoubleRightIcon,
|
|
9
|
+
MoonIcon,
|
|
10
|
+
SunIcon,
|
|
11
|
+
BellIcon,
|
|
12
|
+
} from '@heroicons/vue/24/outline'
|
|
5
13
|
import { useTheme } from '../composables/useTheme'
|
|
6
14
|
import { useMobileSidebar } from '../composables/useMobileSidebar'
|
|
15
|
+
import { useSidebarCollapse } from '../composables/useSidebarCollapse'
|
|
16
|
+
import { useNotifications } from '../composables/useNotifications'
|
|
7
17
|
import type { NavSection } from '../types/sidebar'
|
|
8
18
|
|
|
9
19
|
interface Props {
|
|
@@ -17,6 +27,10 @@ interface Props {
|
|
|
17
27
|
version?: string
|
|
18
28
|
/** Whether to render the dark/light toggle in the footer. */
|
|
19
29
|
showThemeToggle?: boolean
|
|
30
|
+
/** Whether to render the desktop collapse toggle (icon-only rail). */
|
|
31
|
+
collapsible?: boolean
|
|
32
|
+
/** Whether to render the notifications bell button. */
|
|
33
|
+
showNotifications?: boolean
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -24,12 +38,20 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
24
38
|
appName: '',
|
|
25
39
|
version: '',
|
|
26
40
|
showThemeToggle: true,
|
|
41
|
+
collapsible: true,
|
|
42
|
+
showNotifications: false,
|
|
27
43
|
})
|
|
28
44
|
|
|
29
45
|
const router = useRouter()
|
|
30
46
|
const route = useRoute()
|
|
31
47
|
const { isDark, toggleTheme } = useTheme()
|
|
32
48
|
const { mobileOpen, close: closeMobile, toggle: toggleMobile } = useMobileSidebar()
|
|
49
|
+
const { collapsed, toggle: toggleCollapsed } = useSidebarCollapse()
|
|
50
|
+
const { unreadCount, togglePanel: toggleNotifications } = useNotifications()
|
|
51
|
+
|
|
52
|
+
// Whether the icon-only rail styling applies. Only relevant on desktop
|
|
53
|
+
// (`lg:`) — on mobile the sidebar is always the full off-canvas panel.
|
|
54
|
+
const isRail = computed(() => props.collapsible && collapsed.value)
|
|
33
55
|
|
|
34
56
|
// Refs needed for the mobile focus management.
|
|
35
57
|
const closeMobileButton = ref<HTMLButtonElement | null>(null)
|
|
@@ -103,17 +125,21 @@ function navigate(name: string) {
|
|
|
103
125
|
<aside
|
|
104
126
|
role="navigation"
|
|
105
127
|
aria-label="Main navigation"
|
|
106
|
-
class="fixed top-0 left-0 h-screen w-60 flex flex-col border-r transition-
|
|
128
|
+
class="fixed top-0 left-0 h-screen w-60 flex flex-col border-r transition-all duration-300 lg:pointer-events-auto"
|
|
107
129
|
:class="[
|
|
108
130
|
isDark ? 'bg-gray-900 border-gray-800' : 'bg-white border-gray-200',
|
|
109
131
|
mobileOpen ? 'translate-x-0 z-50' : '-translate-x-full max-lg:pointer-events-none lg:translate-x-0 z-50',
|
|
132
|
+
collapsible && collapsed ? 'lg:w-16' : 'lg:w-60',
|
|
110
133
|
]"
|
|
111
134
|
>
|
|
112
135
|
<div class="flex flex-col h-full">
|
|
113
136
|
<!-- Logo -->
|
|
114
|
-
<div
|
|
137
|
+
<div
|
|
138
|
+
class="px-5 pt-5 pb-4 flex items-center justify-center relative"
|
|
139
|
+
:class="{ 'lg:px-2': isRail }"
|
|
140
|
+
>
|
|
115
141
|
<button type="button" @click="goHome" class="cursor-pointer" aria-label="Go to home">
|
|
116
|
-
<slot name="logo" :size="52" />
|
|
142
|
+
<slot name="logo" :size="isRail ? 36 : 52" />
|
|
117
143
|
</button>
|
|
118
144
|
<button
|
|
119
145
|
ref="closeMobileButton"
|
|
@@ -130,7 +156,10 @@ function navigate(name: string) {
|
|
|
130
156
|
<!-- Nav -->
|
|
131
157
|
<nav class="flex-1 overflow-y-auto px-3 space-y-6">
|
|
132
158
|
<div v-for="section in sections" :key="section.title">
|
|
133
|
-
<p
|
|
159
|
+
<p
|
|
160
|
+
class="px-2 mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500"
|
|
161
|
+
:class="{ 'lg:hidden': isRail }"
|
|
162
|
+
>
|
|
134
163
|
{{ section.title }}
|
|
135
164
|
</p>
|
|
136
165
|
<ul class="space-y-0.5">
|
|
@@ -138,27 +167,64 @@ function navigate(name: string) {
|
|
|
138
167
|
<button
|
|
139
168
|
type="button"
|
|
140
169
|
:aria-current="isActive(item.name) ? 'page' : undefined"
|
|
170
|
+
:title="isRail ? item.label : undefined"
|
|
141
171
|
@click="navigate(item.name)"
|
|
142
172
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors w-full text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
143
|
-
:class="
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
173
|
+
:class="[
|
|
174
|
+
isActive(item.name)
|
|
175
|
+
? (isDark
|
|
176
|
+
? 'bg-emerald-500/15 text-emerald-300'
|
|
177
|
+
: 'bg-emerald-50 text-emerald-700')
|
|
178
|
+
: (isDark
|
|
179
|
+
? 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
|
|
180
|
+
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'),
|
|
181
|
+
{ 'lg:justify-center': isRail },
|
|
182
|
+
]"
|
|
150
183
|
>
|
|
151
184
|
<component :is="item.icon" class="w-[18px] h-[18px] shrink-0" />
|
|
152
|
-
{{ item.label }}
|
|
185
|
+
<span :class="{ 'lg:hidden': isRail }">{{ item.label }}</span>
|
|
153
186
|
</button>
|
|
154
187
|
</li>
|
|
155
188
|
</ul>
|
|
156
189
|
</div>
|
|
157
190
|
</nav>
|
|
158
191
|
|
|
159
|
-
<!-- Bottom:
|
|
192
|
+
<!-- Bottom: Notifications, theme toggle, status slot, footer slot -->
|
|
160
193
|
<div class="px-4 py-4 border-t transition-colors space-y-3"
|
|
161
194
|
:class="isDark ? 'border-gray-800' : 'border-gray-200'">
|
|
195
|
+
<!--
|
|
196
|
+
Notifications bell: toggles the shared `BaseNotificationPanel`
|
|
197
|
+
(via useNotifications). The unread badge reads the same shared
|
|
198
|
+
count. Drop a `<BaseNotificationPanel />` in your layout to
|
|
199
|
+
render the panel itself.
|
|
200
|
+
-->
|
|
201
|
+
<button
|
|
202
|
+
v-if="showNotifications"
|
|
203
|
+
type="button"
|
|
204
|
+
@click="toggleNotifications"
|
|
205
|
+
title="Notifications"
|
|
206
|
+
:aria-label="unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'"
|
|
207
|
+
class="relative flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
208
|
+
:class="[
|
|
209
|
+
isDark
|
|
210
|
+
? 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
|
|
211
|
+
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
|
|
212
|
+
{ 'lg:justify-center': isRail },
|
|
213
|
+
]"
|
|
214
|
+
>
|
|
215
|
+
<span class="relative shrink-0">
|
|
216
|
+
<BellIcon class="w-[18px] h-[18px]" aria-hidden="true" />
|
|
217
|
+
<!-- Unread badge -->
|
|
218
|
+
<span
|
|
219
|
+
v-if="unreadCount > 0"
|
|
220
|
+
class="absolute -top-1.5 -right-1.5 min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-semibold leading-4 text-center"
|
|
221
|
+
>
|
|
222
|
+
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
|
223
|
+
</span>
|
|
224
|
+
</span>
|
|
225
|
+
<span :class="{ 'lg:hidden': isRail }">Notifications</span>
|
|
226
|
+
</button>
|
|
227
|
+
|
|
162
228
|
<button
|
|
163
229
|
v-if="showThemeToggle"
|
|
164
230
|
type="button"
|
|
@@ -167,38 +233,81 @@ function navigate(name: string) {
|
|
|
167
233
|
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
|
168
234
|
:aria-pressed="isDark"
|
|
169
235
|
class="flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
170
|
-
:class="
|
|
171
|
-
|
|
172
|
-
|
|
236
|
+
:class="[
|
|
237
|
+
isDark
|
|
238
|
+
? 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
|
|
239
|
+
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
|
|
240
|
+
{ 'lg:justify-center': isRail },
|
|
241
|
+
]"
|
|
173
242
|
>
|
|
174
|
-
|
|
243
|
+
<!-- Icon stand-in shown only on the collapsed desktop rail. -->
|
|
244
|
+
<component
|
|
245
|
+
:is="isDark ? SunIcon : MoonIcon"
|
|
246
|
+
class="hidden w-[18px] h-[18px] shrink-0"
|
|
247
|
+
:class="{ 'lg:block': isRail }"
|
|
248
|
+
aria-hidden="true"
|
|
249
|
+
/>
|
|
250
|
+
<span :class="{ 'lg:hidden': isRail }">{{ isDark ? 'Switch to light' : 'Switch to dark' }}</span>
|
|
175
251
|
<span class="ml-auto relative w-9 h-5 rounded-full transition-colors"
|
|
176
|
-
:class="isDark ? 'bg-emerald-600' : 'bg-gray-300'">
|
|
252
|
+
:class="[isDark ? 'bg-emerald-600' : 'bg-gray-300', { 'lg:hidden': isRail }]">
|
|
177
253
|
<span class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-300"
|
|
178
254
|
:class="isDark ? 'translate-x-4' : 'translate-x-0'" />
|
|
179
255
|
</span>
|
|
180
256
|
</button>
|
|
181
257
|
|
|
258
|
+
<!--
|
|
259
|
+
Desktop-only collapse toggle: shrinks the sidebar to an
|
|
260
|
+
icon-only rail. Hidden on mobile, where the off-canvas menu is
|
|
261
|
+
controlled by the open/close buttons instead.
|
|
262
|
+
-->
|
|
263
|
+
<button
|
|
264
|
+
v-if="collapsible"
|
|
265
|
+
type="button"
|
|
266
|
+
@click="toggleCollapsed"
|
|
267
|
+
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
|
268
|
+
:aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
|
269
|
+
:aria-pressed="collapsed"
|
|
270
|
+
class="hidden lg:flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
271
|
+
:class="[
|
|
272
|
+
isDark
|
|
273
|
+
? 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
|
|
274
|
+
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
|
|
275
|
+
{ 'lg:justify-center': isRail },
|
|
276
|
+
]"
|
|
277
|
+
>
|
|
278
|
+
<component
|
|
279
|
+
:is="collapsed ? ChevronDoubleRightIcon : ChevronDoubleLeftIcon"
|
|
280
|
+
class="w-[18px] h-[18px] shrink-0"
|
|
281
|
+
aria-hidden="true"
|
|
282
|
+
/>
|
|
283
|
+
<span :class="{ 'lg:hidden': isRail }">Collapse</span>
|
|
284
|
+
</button>
|
|
285
|
+
|
|
182
286
|
<!--
|
|
183
287
|
`status` slot: consumers drop in their own indicator (e.g.
|
|
184
288
|
health check, sync status). The wrapper handles spacing only.
|
|
289
|
+
Hidden on the collapsed desktop rail where there's no room.
|
|
185
290
|
-->
|
|
186
|
-
<
|
|
291
|
+
<div :class="{ 'lg:hidden': isRail }">
|
|
292
|
+
<slot name="status" />
|
|
293
|
+
</div>
|
|
187
294
|
|
|
188
295
|
<!--
|
|
189
296
|
`footer` slot: links / version / etc. If not provided and
|
|
190
297
|
either `appName` or `version` is set, we render a default
|
|
191
|
-
"appName vVersion" line.
|
|
298
|
+
"appName vVersion" line. Hidden on the collapsed desktop rail.
|
|
192
299
|
-->
|
|
193
|
-
<
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
300
|
+
<div :class="{ 'lg:hidden': isRail }">
|
|
301
|
+
<slot name="footer">
|
|
302
|
+
<div
|
|
303
|
+
v-if="appName || version"
|
|
304
|
+
class="flex items-center justify-center gap-2 text-xs"
|
|
305
|
+
:class="isDark ? 'text-gray-500' : 'text-gray-500'"
|
|
306
|
+
>
|
|
307
|
+
<span>{{ appName }}<template v-if="appName && version"> </template><template v-if="version">v{{ version }}</template></span>
|
|
308
|
+
</div>
|
|
309
|
+
</slot>
|
|
310
|
+
</div>
|
|
202
311
|
</div>
|
|
203
312
|
</div>
|
|
204
313
|
</aside>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { useNotifications } from './useNotifications'
|
|
3
|
+
|
|
4
|
+
describe('useNotifications', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
// Singleton state persists across tests — reset it each time.
|
|
7
|
+
const { clear, closePanel } = useNotifications()
|
|
8
|
+
clear()
|
|
9
|
+
closePanel()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('counts only unread notifications', () => {
|
|
13
|
+
const { setNotifications, unreadCount } = useNotifications()
|
|
14
|
+
setNotifications([
|
|
15
|
+
{ id: 1, title: 'a' },
|
|
16
|
+
{ id: 2, title: 'b', read: true },
|
|
17
|
+
{ id: 3, title: 'c' },
|
|
18
|
+
])
|
|
19
|
+
expect(unreadCount.value).toBe(2)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('markAllRead clears the unread count', () => {
|
|
23
|
+
const { setNotifications, markAllRead, unreadCount } = useNotifications()
|
|
24
|
+
setNotifications([
|
|
25
|
+
{ id: 1, title: 'a' },
|
|
26
|
+
{ id: 2, title: 'b' },
|
|
27
|
+
])
|
|
28
|
+
expect(unreadCount.value).toBe(2)
|
|
29
|
+
markAllRead()
|
|
30
|
+
expect(unreadCount.value).toBe(0)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('markRead marks a single notification read', () => {
|
|
34
|
+
const { setNotifications, markRead, unreadCount } = useNotifications()
|
|
35
|
+
setNotifications([
|
|
36
|
+
{ id: 1, title: 'a' },
|
|
37
|
+
{ id: 2, title: 'b' },
|
|
38
|
+
])
|
|
39
|
+
markRead(1)
|
|
40
|
+
expect(unreadCount.value).toBe(1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('add prepends newest-first and remove deletes by id', () => {
|
|
44
|
+
const { add, remove, notifications } = useNotifications()
|
|
45
|
+
add({ id: 1, title: 'first' })
|
|
46
|
+
add({ id: 2, title: 'second' })
|
|
47
|
+
expect(notifications.value.map((n) => n.id)).toEqual([2, 1])
|
|
48
|
+
remove(1)
|
|
49
|
+
expect(notifications.value.map((n) => n.id)).toEqual([2])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('toggles and sets the panel open state', () => {
|
|
53
|
+
const { open, openPanel, closePanel, togglePanel } = useNotifications()
|
|
54
|
+
expect(open.value).toBe(false)
|
|
55
|
+
openPanel()
|
|
56
|
+
expect(open.value).toBe(true)
|
|
57
|
+
togglePanel()
|
|
58
|
+
expect(open.value).toBe(false)
|
|
59
|
+
togglePanel()
|
|
60
|
+
expect(open.value).toBe(true)
|
|
61
|
+
closePanel()
|
|
62
|
+
expect(open.value).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ref, computed } from 'vue'
|
|
2
|
+
import type { NotificationItem } from '../types/notification'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared notification state for the sidebar bell + `BaseNotificationPanel`.
|
|
6
|
+
*
|
|
7
|
+
* Module-scoped singleton (mirrors useMobileSidebar / useToast) so the
|
|
8
|
+
* sidebar's unread badge, the bell's open/close toggle, and the panel all
|
|
9
|
+
* read and mutate the same source. The consuming app owns the data: call
|
|
10
|
+
* `setNotifications()` (or `add()`) to feed it in.
|
|
11
|
+
*
|
|
12
|
+
* There's only ever one notification surface per app, so a singleton is
|
|
13
|
+
* the right shape here.
|
|
14
|
+
*/
|
|
15
|
+
const notifications = ref<NotificationItem[]>([])
|
|
16
|
+
const open = ref(false)
|
|
17
|
+
|
|
18
|
+
const unreadCount = computed(
|
|
19
|
+
() => notifications.value.filter((n) => !n.read).length,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export function useNotifications() {
|
|
23
|
+
function openPanel() {
|
|
24
|
+
open.value = true
|
|
25
|
+
}
|
|
26
|
+
function closePanel() {
|
|
27
|
+
open.value = false
|
|
28
|
+
}
|
|
29
|
+
function togglePanel() {
|
|
30
|
+
open.value = !open.value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Replace the whole list (typical after a fetch). */
|
|
34
|
+
function setNotifications(items: NotificationItem[]) {
|
|
35
|
+
notifications.value = items
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Prepend a single notification (newest first). */
|
|
39
|
+
function add(item: NotificationItem) {
|
|
40
|
+
notifications.value = [item, ...notifications.value]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Remove one by id. */
|
|
44
|
+
function remove(id: NotificationItem['id']) {
|
|
45
|
+
notifications.value = notifications.value.filter((n) => n.id !== id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Mark a single notification read. */
|
|
49
|
+
function markRead(id: NotificationItem['id']) {
|
|
50
|
+
notifications.value = notifications.value.map((n) =>
|
|
51
|
+
n.id === id ? { ...n, read: true } : n,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Mark every notification read (clears the badge). */
|
|
56
|
+
function markAllRead() {
|
|
57
|
+
notifications.value = notifications.value.map((n) =>
|
|
58
|
+
n.read ? n : { ...n, read: true },
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Remove every notification. */
|
|
63
|
+
function clear() {
|
|
64
|
+
notifications.value = []
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
notifications,
|
|
69
|
+
unreadCount,
|
|
70
|
+
open,
|
|
71
|
+
openPanel,
|
|
72
|
+
closePanel,
|
|
73
|
+
togglePanel,
|
|
74
|
+
setNotifications,
|
|
75
|
+
add,
|
|
76
|
+
remove,
|
|
77
|
+
markRead,
|
|
78
|
+
markAllRead,
|
|
79
|
+
clear,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ref, watch, effectScope } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared collapsed/expanded state for the desktop sidebar.
|
|
5
|
+
*
|
|
6
|
+
* Module-scoped singleton (mirrors useMobileSidebar) so `BaseSidebar` and
|
|
7
|
+
* the page layout (`BaseAppLayout`, which offsets `<main>` by the sidebar
|
|
8
|
+
* width) stay in sync. When collapsed, the sidebar shrinks to an icon-only
|
|
9
|
+
* rail on desktop.
|
|
10
|
+
*
|
|
11
|
+
* The preference is persisted to localStorage. The first consumer can pass
|
|
12
|
+
* an options object to override the key; subsequent callers reuse the same
|
|
13
|
+
* singleton and the option is only consulted on the first call.
|
|
14
|
+
*/
|
|
15
|
+
export interface UseSidebarCollapseOptions {
|
|
16
|
+
/** localStorage key used to persist the preference. */
|
|
17
|
+
storageKey?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const collapsed = ref(false)
|
|
21
|
+
|
|
22
|
+
let initialized = false
|
|
23
|
+
let configuredStorageKey = 'mgv-sidebar-collapsed'
|
|
24
|
+
|
|
25
|
+
function initialize(storageKey: string) {
|
|
26
|
+
if (initialized) {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
initialized = true
|
|
30
|
+
configuredStorageKey = storageKey
|
|
31
|
+
|
|
32
|
+
if (typeof window !== 'undefined') {
|
|
33
|
+
const saved = window.localStorage?.getItem(configuredStorageKey)
|
|
34
|
+
if (saved) {
|
|
35
|
+
collapsed.value = saved === 'true'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Detached scope so the persistence watcher lives for the app's
|
|
39
|
+
// lifetime and is NOT torn down when the first consuming component
|
|
40
|
+
// unmounts.
|
|
41
|
+
const scope = effectScope(true)
|
|
42
|
+
scope.run(() => {
|
|
43
|
+
watch(collapsed, (value) => {
|
|
44
|
+
window.localStorage?.setItem(configuredStorageKey, String(value))
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useSidebarCollapse(options?: UseSidebarCollapseOptions) {
|
|
51
|
+
initialize(options?.storageKey ?? configuredStorageKey)
|
|
52
|
+
|
|
53
|
+
function collapse() {
|
|
54
|
+
collapsed.value = true
|
|
55
|
+
}
|
|
56
|
+
function expand() {
|
|
57
|
+
collapsed.value = false
|
|
58
|
+
}
|
|
59
|
+
function toggle() {
|
|
60
|
+
collapsed.value = !collapsed.value
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { collapsed, collapse, expand, toggle }
|
|
64
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,9 @@ export { default as BasePageHeader } from './components/BasePageHeader.vue'
|
|
|
30
30
|
export { default as BaseToolbarButton } from './components/BaseToolbarButton.vue'
|
|
31
31
|
export { default as BaseActionButton } from './components/BaseActionButton.vue'
|
|
32
32
|
export { default as BaseCopyButton } from './components/BaseCopyButton.vue'
|
|
33
|
+
export { default as BaseGoogleSignInButton } from './components/BaseGoogleSignInButton.vue'
|
|
34
|
+
export { default as BaseLoginForm } from './components/BaseLoginForm.vue'
|
|
35
|
+
export { default as BaseNotificationPanel } from './components/BaseNotificationPanel.vue'
|
|
33
36
|
|
|
34
37
|
// Composables
|
|
35
38
|
export { useTheme, initTheme } from './composables/useTheme'
|
|
@@ -40,6 +43,9 @@ export { useEscapeKey } from './composables/useEscapeKey'
|
|
|
40
43
|
export { useDebouncedRef } from './composables/useDebounce'
|
|
41
44
|
export { useToast } from './composables/useToast'
|
|
42
45
|
export { useMobileSidebar } from './composables/useMobileSidebar'
|
|
46
|
+
export { useSidebarCollapse } from './composables/useSidebarCollapse'
|
|
47
|
+
export type { UseSidebarCollapseOptions } from './composables/useSidebarCollapse'
|
|
48
|
+
export { useNotifications } from './composables/useNotifications'
|
|
43
49
|
|
|
44
50
|
// Enums
|
|
45
51
|
export { AlertEnum } from './enums/AlertEnum'
|
|
@@ -57,6 +63,8 @@ export { PositioningEnum } from './enums/PositioningEnum'
|
|
|
57
63
|
export type { BreadCrumb } from './components/BaseBreadcrumb.vue'
|
|
58
64
|
export type { NavItem, NavSection } from './types/sidebar'
|
|
59
65
|
export type { EntityPickerItem } from './types/entityPicker'
|
|
66
|
+
export type { LoginCredentials } from './types/auth'
|
|
67
|
+
export type { NotificationItem } from './types/notification'
|
|
60
68
|
|
|
61
69
|
// Utils
|
|
62
70
|
export { getBaseColor, getBaseColorOf } from './utils/util'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials emitted by `BaseLoginForm` on submit.
|
|
3
|
+
*
|
|
4
|
+
* `remember` is only present when the form is rendered with the
|
|
5
|
+
* "remember me" checkbox enabled.
|
|
6
|
+
*/
|
|
7
|
+
export interface LoginCredentials {
|
|
8
|
+
email: string
|
|
9
|
+
password: string
|
|
10
|
+
remember?: boolean
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single entry rendered in `BaseNotificationPanel` and counted by the
|
|
3
|
+
* sidebar's unread badge.
|
|
4
|
+
*
|
|
5
|
+
* • `id` — stable key, used for list rendering and mark/remove ops.
|
|
6
|
+
* • `title` — bold headline line.
|
|
7
|
+
* • `message` — optional secondary line.
|
|
8
|
+
* • `time` — optional pre-formatted timestamp (e.g. "2h ago"). The
|
|
9
|
+
* library does not format dates for you.
|
|
10
|
+
* • `read` — unread entries are highlighted and count toward the badge.
|
|
11
|
+
* • `type` — drives the small status dot colour.
|
|
12
|
+
*/
|
|
13
|
+
export interface NotificationItem {
|
|
14
|
+
id: string | number
|
|
15
|
+
title: string
|
|
16
|
+
message?: string
|
|
17
|
+
time?: string
|
|
18
|
+
read?: boolean
|
|
19
|
+
type?: 'info' | 'success' | 'warning' | 'error'
|
|
20
|
+
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
interface Props {
|
|
2
|
-
/**
|
|
3
|
-
* Whether the sidebar should currently be rendered. Defaults to true.
|
|
4
|
-
* Set to false for routes that should occupy the full viewport (e.g.
|
|
5
|
-
* landing / marketing pages).
|
|
6
|
-
*/
|
|
7
|
-
showSidebar?: boolean;
|
|
8
|
-
/** Skip-link label. Defaults to "Skip to main content". */
|
|
9
|
-
skipLinkLabel?: string;
|
|
10
|
-
}
|
|
11
|
-
declare function __VLS_template(): {
|
|
12
|
-
attrs: Partial<{}>;
|
|
13
|
-
slots: {
|
|
14
|
-
sidebar?(_: {}): any;
|
|
15
|
-
default?(_: {}): any;
|
|
16
|
-
};
|
|
17
|
-
refs: {};
|
|
18
|
-
rootEl: HTMLDivElement;
|
|
19
|
-
};
|
|
20
|
-
type __VLS_TemplateResult = ReturnType<typeof __VLS_template>;
|
|
21
|
-
declare const __VLS_component: import('vue').DefineComponent<Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<Props> & Readonly<{}>, {
|
|
22
|
-
showSidebar: boolean;
|
|
23
|
-
skipLinkLabel: string;
|
|
24
|
-
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
|
|
25
|
-
declare const _default: __VLS_WithTemplateSlots<typeof __VLS_component, __VLS_TemplateResult["slots"]>;
|
|
26
|
-
export default _default;
|
|
27
|
-
type __VLS_WithTemplateSlots<T, S> = T & {
|
|
28
|
-
new (): {
|
|
29
|
-
$slots: S;
|
|
30
|
-
};
|
|
31
|
-
};
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
type IconColor = 'emerald' | 'sky' | 'red' | 'amber';
|
|
2
|
-
type __VLS_Props = {
|
|
3
|
-
title: string;
|
|
4
|
-
subtitle?: string;
|
|
5
|
-
iconColor?: IconColor;
|
|
6
|
-
maxWidthClass?: string;
|
|
7
|
-
};
|
|
8
|
-
declare function __VLS_template(): {
|
|
9
|
-
attrs: Partial<{}>;
|
|
10
|
-
slots: {
|
|
11
|
-
icon?(_: {
|
|
12
|
-
iconClass: string;
|
|
13
|
-
}): any;
|
|
14
|
-
actions?(_: {}): any;
|
|
15
|
-
};
|
|
16
|
-
refs: {};
|
|
17
|
-
rootEl: any;
|
|
18
|
-
};
|
|
19
|
-
type __VLS_TemplateResult = ReturnType<typeof __VLS_template>;
|
|
20
|
-
declare const __VLS_component: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
|
|
21
|
-
subtitle: string;
|
|
22
|
-
maxWidthClass: string;
|
|
23
|
-
iconColor: IconColor;
|
|
24
|
-
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
25
|
-
declare const _default: __VLS_WithTemplateSlots<typeof __VLS_component, __VLS_TemplateResult["slots"]>;
|
|
26
|
-
export default _default;
|
|
27
|
-
type __VLS_WithTemplateSlots<T, S> = T & {
|
|
28
|
-
new (): {
|
|
29
|
-
$slots: S;
|
|
30
|
-
};
|
|
31
|
-
};
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { NavSection } from '../types/sidebar';
|
|
2
|
-
interface Props {
|
|
3
|
-
/** Nav sections (groups of items). */
|
|
4
|
-
sections: NavSection[];
|
|
5
|
-
/** Route name to navigate to when the logo / home button is clicked. */
|
|
6
|
-
homeRouteName?: string;
|
|
7
|
-
/** Optional app name shown in the footer next to the version. */
|
|
8
|
-
appName?: string;
|
|
9
|
-
/** Optional version string shown in the footer. */
|
|
10
|
-
version?: string;
|
|
11
|
-
/** Whether to render the dark/light toggle in the footer. */
|
|
12
|
-
showThemeToggle?: boolean;
|
|
13
|
-
}
|
|
14
|
-
declare function __VLS_template(): {
|
|
15
|
-
attrs: Partial<{}>;
|
|
16
|
-
slots: {
|
|
17
|
-
logo?(_: {
|
|
18
|
-
size: number;
|
|
19
|
-
}): any;
|
|
20
|
-
logo?(_: {
|
|
21
|
-
size: number;
|
|
22
|
-
}): any;
|
|
23
|
-
status?(_: {}): any;
|
|
24
|
-
footer?(_: {}): any;
|
|
25
|
-
};
|
|
26
|
-
refs: {
|
|
27
|
-
closeMobileButton: HTMLButtonElement;
|
|
28
|
-
};
|
|
29
|
-
rootEl: HTMLDivElement;
|
|
30
|
-
};
|
|
31
|
-
type __VLS_TemplateResult = ReturnType<typeof __VLS_template>;
|
|
32
|
-
declare const __VLS_component: import('vue').DefineComponent<Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<Props> & Readonly<{}>, {
|
|
33
|
-
homeRouteName: string;
|
|
34
|
-
appName: string;
|
|
35
|
-
version: string;
|
|
36
|
-
showThemeToggle: boolean;
|
|
37
|
-
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {
|
|
38
|
-
closeMobileButton: HTMLButtonElement;
|
|
39
|
-
}, HTMLDivElement>;
|
|
40
|
-
declare const _default: __VLS_WithTemplateSlots<typeof __VLS_component, __VLS_TemplateResult["slots"]>;
|
|
41
|
-
export default _default;
|
|
42
|
-
type __VLS_WithTemplateSlots<T, S> = T & {
|
|
43
|
-
new (): {
|
|
44
|
-
$slots: S;
|
|
45
|
-
};
|
|
46
|
-
};
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
declare function __VLS_template(): {
|
|
2
|
-
attrs: Partial<{}>;
|
|
3
|
-
slots: {
|
|
4
|
-
default?(_: {}): any;
|
|
5
|
-
};
|
|
6
|
-
refs: {};
|
|
7
|
-
rootEl: HTMLDivElement;
|
|
8
|
-
};
|
|
9
|
-
type __VLS_TemplateResult = ReturnType<typeof __VLS_template>;
|
|
10
|
-
declare const __VLS_component: import('vue').DefineComponent<import('vue').ExtractPropTypes<{
|
|
11
|
-
color: {
|
|
12
|
-
type: StringConstructor;
|
|
13
|
-
required: false;
|
|
14
|
-
};
|
|
15
|
-
}>, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<import('vue').ExtractPropTypes<{
|
|
16
|
-
color: {
|
|
17
|
-
type: StringConstructor;
|
|
18
|
-
required: false;
|
|
19
|
-
};
|
|
20
|
-
}>> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, HTMLDivElement>;
|
|
21
|
-
declare const _default: __VLS_WithTemplateSlots<typeof __VLS_component, __VLS_TemplateResult["slots"]>;
|
|
22
|
-
export default _default;
|
|
23
|
-
type __VLS_WithTemplateSlots<T, S> = T & {
|
|
24
|
-
new (): {
|
|
25
|
-
$slots: S;
|
|
26
|
-
};
|
|
27
|
-
};
|