mgv-backoffice 1.19.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 +142 -2
- package/dist/components/BaseGoogleSignInButton.vue.d.ts +22 -0
- package/dist/components/BaseLoginForm.vue.d.ts +51 -0
- package/dist/components/BaseNotificationPanel.vue.d.ts +19 -0
- package/dist/components/BaseSidebar.vue.d.ts +9 -3
- package/dist/composables/useNotifications.d.ts +29 -0
- package/dist/composables/useSidebarCollapse.d.ts +22 -0
- package/dist/index.d.ts +8 -0
- package/dist/types/auth.d.ts +11 -0
- package/dist/types/notification.d.ts +20 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +1082 -665
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +1 -1
- 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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { useTheme } from '../composables/useTheme'
|
|
4
|
+
import { useThemeClasses } from '../composables/useThemeClasses'
|
|
5
|
+
import BaseGoogleSignInButton from './BaseGoogleSignInButton.vue'
|
|
6
|
+
import type { LoginCredentials } from '../types/auth'
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Card heading. Defaults to "Sign in". */
|
|
10
|
+
title?: string
|
|
11
|
+
/** Optional muted line under the heading. */
|
|
12
|
+
subtitle?: string
|
|
13
|
+
/** Submit button label. Defaults to "Sign in". */
|
|
14
|
+
submitLabel?: string
|
|
15
|
+
/** Disables the form and shows a spinner on the submit button. */
|
|
16
|
+
loading?: boolean
|
|
17
|
+
/** Disables the form and shows a spinner on the Google button. */
|
|
18
|
+
googleLoading?: boolean
|
|
19
|
+
/** Error message rendered above the form (e.g. "Invalid credentials"). */
|
|
20
|
+
error?: string
|
|
21
|
+
/** Render the "Sign in with Google" button + divider. Defaults to true. */
|
|
22
|
+
showGoogle?: boolean
|
|
23
|
+
/** Render the "Remember me" checkbox. Defaults to false. */
|
|
24
|
+
showRemember?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
28
|
+
title: 'Sign in',
|
|
29
|
+
subtitle: '',
|
|
30
|
+
submitLabel: 'Sign in',
|
|
31
|
+
loading: false,
|
|
32
|
+
googleLoading: false,
|
|
33
|
+
error: '',
|
|
34
|
+
showGoogle: true,
|
|
35
|
+
showRemember: false,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
/** Email/password submit. */
|
|
40
|
+
submit: [credentials: LoginCredentials]
|
|
41
|
+
/** User chose "Sign in with Google". */
|
|
42
|
+
'google-sign-in': []
|
|
43
|
+
}>()
|
|
44
|
+
|
|
45
|
+
const { isDark } = useTheme()
|
|
46
|
+
const t = useThemeClasses()
|
|
47
|
+
|
|
48
|
+
const email = ref('')
|
|
49
|
+
const password = ref('')
|
|
50
|
+
const remember = ref(false)
|
|
51
|
+
const showPassword = ref(false)
|
|
52
|
+
|
|
53
|
+
const busy = computed(() => props.loading || props.googleLoading)
|
|
54
|
+
|
|
55
|
+
function onSubmit() {
|
|
56
|
+
if (busy.value) return
|
|
57
|
+
emit('submit', {
|
|
58
|
+
email: email.value,
|
|
59
|
+
password: password.value,
|
|
60
|
+
...(props.showRemember ? { remember: remember.value } : {}),
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<template>
|
|
66
|
+
<!--
|
|
67
|
+
Presentational sign-in card. Owns its own input state and emits
|
|
68
|
+
`submit` (with credentials) / `google-sign-in`. It runs no auth
|
|
69
|
+
itself — the consuming app handles the actual request and feeds back
|
|
70
|
+
`loading` / `error`.
|
|
71
|
+
-->
|
|
72
|
+
<div
|
|
73
|
+
class="w-full max-w-sm mx-auto rounded-xl border shadow-sm p-6 sm:p-8"
|
|
74
|
+
:class="t.card"
|
|
75
|
+
>
|
|
76
|
+
<!-- Brand / logo slot above the heading. -->
|
|
77
|
+
<div v-if="$slots.logo" class="flex justify-center mb-6">
|
|
78
|
+
<slot name="logo" />
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="mb-6 text-center">
|
|
82
|
+
<h1 class="text-xl font-semibold" :class="t.primaryText">{{ title }}</h1>
|
|
83
|
+
<p v-if="subtitle" class="mt-1 text-sm" :class="t.mutedText">{{ subtitle }}</p>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<!-- Error banner -->
|
|
87
|
+
<div
|
|
88
|
+
v-if="error"
|
|
89
|
+
role="alert"
|
|
90
|
+
class="mb-4 rounded-lg border px-3 py-2 text-sm"
|
|
91
|
+
:class="isDark
|
|
92
|
+
? 'bg-red-500/10 border-red-500/30 text-red-300'
|
|
93
|
+
: 'bg-red-50 border-red-200 text-red-700'"
|
|
94
|
+
>
|
|
95
|
+
{{ error }}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<!-- Google sign-in + divider -->
|
|
99
|
+
<template v-if="showGoogle">
|
|
100
|
+
<BaseGoogleSignInButton
|
|
101
|
+
:loading="googleLoading"
|
|
102
|
+
:disabled="loading"
|
|
103
|
+
@click="emit('google-sign-in')"
|
|
104
|
+
/>
|
|
105
|
+
<div class="flex items-center gap-3 my-5">
|
|
106
|
+
<span class="h-px flex-1" :class="isDark ? 'bg-gray-700' : 'bg-gray-200'" />
|
|
107
|
+
<span class="text-xs uppercase tracking-wider" :class="t.dimTextAlt">or</span>
|
|
108
|
+
<span class="h-px flex-1" :class="isDark ? 'bg-gray-700' : 'bg-gray-200'" />
|
|
109
|
+
</div>
|
|
110
|
+
</template>
|
|
111
|
+
|
|
112
|
+
<form class="space-y-4" @submit.prevent="onSubmit">
|
|
113
|
+
<div>
|
|
114
|
+
<label for="login-email" class="block mb-1.5 text-sm font-medium" :class="t.label">
|
|
115
|
+
Email
|
|
116
|
+
</label>
|
|
117
|
+
<input
|
|
118
|
+
id="login-email"
|
|
119
|
+
v-model="email"
|
|
120
|
+
type="email"
|
|
121
|
+
name="email"
|
|
122
|
+
autocomplete="email"
|
|
123
|
+
required
|
|
124
|
+
:disabled="busy"
|
|
125
|
+
placeholder="you@example.com"
|
|
126
|
+
class="w-full h-11 px-3 rounded-lg border text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 disabled:opacity-60"
|
|
127
|
+
:class="t.inputWithPlaceholder"
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div>
|
|
132
|
+
<div class="flex items-center justify-between mb-1.5">
|
|
133
|
+
<label for="login-password" class="block text-sm font-medium" :class="t.label">
|
|
134
|
+
Password
|
|
135
|
+
</label>
|
|
136
|
+
<!-- `forgot` slot: drop a "Forgot password?" link here. -->
|
|
137
|
+
<slot name="forgot" />
|
|
138
|
+
</div>
|
|
139
|
+
<div class="relative">
|
|
140
|
+
<input
|
|
141
|
+
id="login-password"
|
|
142
|
+
v-model="password"
|
|
143
|
+
:type="showPassword ? 'text' : 'password'"
|
|
144
|
+
name="password"
|
|
145
|
+
autocomplete="current-password"
|
|
146
|
+
required
|
|
147
|
+
:disabled="busy"
|
|
148
|
+
placeholder="••••••••"
|
|
149
|
+
class="w-full h-11 pl-3 pr-16 rounded-lg border text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 disabled:opacity-60"
|
|
150
|
+
:class="t.inputWithPlaceholder"
|
|
151
|
+
/>
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
@click="showPassword = !showPassword"
|
|
155
|
+
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
|
156
|
+
:aria-pressed="showPassword"
|
|
157
|
+
class="absolute inset-y-0 right-0 px-3 text-xs font-medium cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 rounded-r-lg"
|
|
158
|
+
:class="t.mutedText"
|
|
159
|
+
>
|
|
160
|
+
{{ showPassword ? 'Hide' : 'Show' }}
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<label
|
|
166
|
+
v-if="showRemember"
|
|
167
|
+
class="flex items-center gap-2 text-sm cursor-pointer select-none"
|
|
168
|
+
:class="t.bodyText"
|
|
169
|
+
>
|
|
170
|
+
<input
|
|
171
|
+
v-model="remember"
|
|
172
|
+
type="checkbox"
|
|
173
|
+
:disabled="busy"
|
|
174
|
+
class="w-4 h-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
|
|
175
|
+
/>
|
|
176
|
+
Remember me
|
|
177
|
+
</label>
|
|
178
|
+
|
|
179
|
+
<button
|
|
180
|
+
type="submit"
|
|
181
|
+
:disabled="busy"
|
|
182
|
+
:aria-busy="loading"
|
|
183
|
+
class="inline-flex items-center justify-center gap-2 w-full h-11 px-4 rounded-lg text-sm font-semibold text-white bg-emerald-600 hover:bg-emerald-700 transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
184
|
+
>
|
|
185
|
+
<svg
|
|
186
|
+
v-if="loading"
|
|
187
|
+
class="w-4 h-4 animate-spin"
|
|
188
|
+
viewBox="0 0 24 24"
|
|
189
|
+
fill="none"
|
|
190
|
+
aria-hidden="true"
|
|
191
|
+
>
|
|
192
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
193
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4z" />
|
|
194
|
+
</svg>
|
|
195
|
+
{{ submitLabel }}
|
|
196
|
+
</button>
|
|
197
|
+
</form>
|
|
198
|
+
|
|
199
|
+
<!-- `footer` slot: e.g. "Don't have an account? Sign up". -->
|
|
200
|
+
<div v-if="$slots.footer" class="mt-6 text-center text-sm" :class="t.mutedText">
|
|
201
|
+
<slot name="footer" />
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</template>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { XMarkIcon, BellIcon } from '@heroicons/vue/24/outline'
|
|
4
|
+
import { useTheme } from '../composables/useTheme'
|
|
5
|
+
import { useThemeClasses } from '../composables/useThemeClasses'
|
|
6
|
+
import { useEscapeKey } from '../composables/useEscapeKey'
|
|
7
|
+
import { useNotifications } from '../composables/useNotifications'
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Panel heading. Defaults to "Notifications". */
|
|
11
|
+
title?: string
|
|
12
|
+
/** Shown when there are no notifications. */
|
|
13
|
+
emptyText?: string
|
|
14
|
+
/** Render the "Mark all read" action when there are unread items. */
|
|
15
|
+
showMarkAllRead?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
19
|
+
title: 'Notifications',
|
|
20
|
+
emptyText: 'You have no notifications.',
|
|
21
|
+
showMarkAllRead: true,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const emit = defineEmits<{
|
|
25
|
+
/** A notification row was clicked (e.g. to navigate to its target). */
|
|
26
|
+
select: [id: import('../types/notification').NotificationItem['id']]
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const { isDark } = useTheme()
|
|
30
|
+
const t = useThemeClasses()
|
|
31
|
+
const {
|
|
32
|
+
notifications,
|
|
33
|
+
unreadCount,
|
|
34
|
+
open,
|
|
35
|
+
closePanel,
|
|
36
|
+
markAllRead,
|
|
37
|
+
markRead,
|
|
38
|
+
} = useNotifications()
|
|
39
|
+
|
|
40
|
+
const dotClass: Record<string, string> = {
|
|
41
|
+
info: 'bg-sky-500',
|
|
42
|
+
success: 'bg-emerald-500',
|
|
43
|
+
warning: 'bg-amber-500',
|
|
44
|
+
error: 'bg-red-500',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const total = computed(() => notifications.value.length)
|
|
48
|
+
|
|
49
|
+
function onSelect(id: import('../types/notification').NotificationItem['id']) {
|
|
50
|
+
markRead(id)
|
|
51
|
+
emit('select', id)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
useEscapeKey(() => {
|
|
55
|
+
if (open.value) closePanel()
|
|
56
|
+
})
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<!--
|
|
61
|
+
Left-anchored notification drawer. Open/close state and the list live
|
|
62
|
+
in `useNotifications()`, so the sidebar bell and this panel stay in
|
|
63
|
+
sync. Teleported to <body> with a backdrop; slides in from the left.
|
|
64
|
+
-->
|
|
65
|
+
<Teleport to="body">
|
|
66
|
+
<!-- Backdrop -->
|
|
67
|
+
<Transition
|
|
68
|
+
enter-active-class="transition-opacity duration-200"
|
|
69
|
+
enter-from-class="opacity-0"
|
|
70
|
+
leave-active-class="transition-opacity duration-200"
|
|
71
|
+
leave-to-class="opacity-0"
|
|
72
|
+
>
|
|
73
|
+
<div
|
|
74
|
+
v-if="open"
|
|
75
|
+
class="fixed inset-0 z-[60] bg-black/50"
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
@click="closePanel"
|
|
78
|
+
/>
|
|
79
|
+
</Transition>
|
|
80
|
+
|
|
81
|
+
<!-- Drawer -->
|
|
82
|
+
<Transition
|
|
83
|
+
enter-active-class="transition-transform duration-300 ease-out"
|
|
84
|
+
enter-from-class="-translate-x-full"
|
|
85
|
+
leave-active-class="transition-transform duration-300 ease-in"
|
|
86
|
+
leave-to-class="-translate-x-full"
|
|
87
|
+
>
|
|
88
|
+
<aside
|
|
89
|
+
v-if="open"
|
|
90
|
+
role="dialog"
|
|
91
|
+
aria-modal="true"
|
|
92
|
+
:aria-label="title"
|
|
93
|
+
class="fixed top-0 left-0 z-[70] h-screen w-full max-w-sm flex flex-col border-r shadow-xl"
|
|
94
|
+
:class="isDark ? 'bg-gray-900 border-gray-800' : 'bg-white border-gray-200'"
|
|
95
|
+
>
|
|
96
|
+
<!-- Header -->
|
|
97
|
+
<div
|
|
98
|
+
class="flex items-center justify-between gap-3 px-5 py-4 border-b shrink-0"
|
|
99
|
+
:class="t.border"
|
|
100
|
+
>
|
|
101
|
+
<h2 class="text-lg font-semibold flex items-center gap-2" :class="t.primaryText">
|
|
102
|
+
{{ title }}
|
|
103
|
+
<span class="text-sm font-normal" :class="t.dimTextAlt">— {{ total }}</span>
|
|
104
|
+
</h2>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
@click="closePanel"
|
|
108
|
+
aria-label="Close notifications"
|
|
109
|
+
class="p-1.5 rounded-lg cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
|
110
|
+
:class="isDark ? 'text-gray-400 hover:bg-gray-800' : 'text-gray-500 hover:bg-gray-100'"
|
|
111
|
+
>
|
|
112
|
+
<XMarkIcon class="w-5 h-5" aria-hidden="true" />
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<!-- Mark all read -->
|
|
117
|
+
<div
|
|
118
|
+
v-if="showMarkAllRead && unreadCount > 0"
|
|
119
|
+
class="px-5 py-2 border-b shrink-0 flex justify-end"
|
|
120
|
+
:class="t.border"
|
|
121
|
+
>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
@click="markAllRead"
|
|
125
|
+
class="text-xs font-medium cursor-pointer transition-colors focus:outline-none focus-visible:underline"
|
|
126
|
+
:class="t.emeraldText"
|
|
127
|
+
>
|
|
128
|
+
Mark all as read
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Body -->
|
|
133
|
+
<div class="flex-1 overflow-y-auto">
|
|
134
|
+
<!-- Empty state -->
|
|
135
|
+
<div
|
|
136
|
+
v-if="total === 0"
|
|
137
|
+
class="flex flex-col items-center justify-center text-center px-6 py-16 gap-3"
|
|
138
|
+
>
|
|
139
|
+
<BellIcon class="w-10 h-10" :class="t.illustration" aria-hidden="true" />
|
|
140
|
+
<p class="text-sm" :class="t.mutedText">{{ emptyText }}</p>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- List -->
|
|
144
|
+
<ul v-else class="divide-y" :class="isDark ? 'divide-gray-800' : 'divide-gray-100'">
|
|
145
|
+
<li v-for="n in notifications" :key="n.id">
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
@click="onSelect(n.id)"
|
|
149
|
+
class="w-full text-left px-5 py-4 flex gap-3 transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500"
|
|
150
|
+
:class="[
|
|
151
|
+
!n.read
|
|
152
|
+
? (isDark ? 'bg-emerald-500/5' : 'bg-emerald-50/60')
|
|
153
|
+
: '',
|
|
154
|
+
isDark ? 'hover:bg-gray-800' : 'hover:bg-gray-50',
|
|
155
|
+
]"
|
|
156
|
+
>
|
|
157
|
+
<span
|
|
158
|
+
class="mt-1.5 w-2 h-2 rounded-full shrink-0"
|
|
159
|
+
:class="n.read ? (isDark ? 'bg-gray-700' : 'bg-gray-300') : (dotClass[n.type ?? 'info'])"
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
/>
|
|
162
|
+
<span class="min-w-0 flex-1">
|
|
163
|
+
<span class="flex items-baseline justify-between gap-2">
|
|
164
|
+
<span
|
|
165
|
+
class="text-sm truncate"
|
|
166
|
+
:class="[n.read ? 'font-medium' : 'font-semibold', t.primaryTextSoft]"
|
|
167
|
+
>
|
|
168
|
+
{{ n.title }}
|
|
169
|
+
</span>
|
|
170
|
+
<span v-if="n.time" class="text-xs shrink-0" :class="t.dimText">{{ n.time }}</span>
|
|
171
|
+
</span>
|
|
172
|
+
<span v-if="n.message" class="mt-0.5 block text-sm" :class="t.mutedText">
|
|
173
|
+
{{ n.message }}
|
|
174
|
+
</span>
|
|
175
|
+
</span>
|
|
176
|
+
</button>
|
|
177
|
+
</li>
|
|
178
|
+
</ul>
|
|
179
|
+
</div>
|
|
180
|
+
</aside>
|
|
181
|
+
</Transition>
|
|
182
|
+
</Teleport>
|
|
183
|
+
</template>
|
|
@@ -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>
|