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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import BaseLoginForm from './BaseLoginForm.vue'
|
|
4
|
+
|
|
5
|
+
describe('BaseLoginForm', () => {
|
|
6
|
+
it('emits submit with the entered credentials', async () => {
|
|
7
|
+
const wrapper = mount(BaseLoginForm)
|
|
8
|
+
await wrapper.find('input[type="email"]').setValue('user@example.com')
|
|
9
|
+
await wrapper.find('input[name="password"]').setValue('hunter2')
|
|
10
|
+
await wrapper.find('form').trigger('submit.prevent')
|
|
11
|
+
|
|
12
|
+
expect(wrapper.emitted('submit')).toEqual([
|
|
13
|
+
[{ email: 'user@example.com', password: 'hunter2' }],
|
|
14
|
+
])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('includes remember when the checkbox is enabled', async () => {
|
|
18
|
+
const wrapper = mount(BaseLoginForm, { props: { showRemember: true } })
|
|
19
|
+
await wrapper.find('input[type="email"]').setValue('a@b.com')
|
|
20
|
+
await wrapper.find('input[name="password"]').setValue('pw')
|
|
21
|
+
await wrapper.find('input[type="checkbox"]').setValue(true)
|
|
22
|
+
await wrapper.find('form').trigger('submit.prevent')
|
|
23
|
+
|
|
24
|
+
expect(wrapper.emitted('submit')![0]).toEqual([
|
|
25
|
+
{ email: 'a@b.com', password: 'pw', remember: true },
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('emits google-sign-in when the Google button is clicked', async () => {
|
|
30
|
+
const wrapper = mount(BaseLoginForm)
|
|
31
|
+
// The Google button renders a <button> with the brand label.
|
|
32
|
+
const googleBtn = wrapper
|
|
33
|
+
.findAll('button')
|
|
34
|
+
.find((b) => b.text().includes('Sign in with Google'))
|
|
35
|
+
expect(googleBtn, 'expected a Google sign-in button').toBeTruthy()
|
|
36
|
+
await googleBtn!.trigger('click')
|
|
37
|
+
expect(wrapper.emitted('google-sign-in')).toHaveLength(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not submit while loading', async () => {
|
|
41
|
+
const wrapper = mount(BaseLoginForm, { props: { loading: true } })
|
|
42
|
+
await wrapper.find('form').trigger('submit.prevent')
|
|
43
|
+
expect(wrapper.emitted('submit')).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders the error banner', () => {
|
|
47
|
+
const wrapper = mount(BaseLoginForm, { props: { error: 'Invalid credentials' } })
|
|
48
|
+
const alert = wrapper.find('[role="alert"]')
|
|
49
|
+
expect(alert.exists()).toBe(true)
|
|
50
|
+
expect(alert.text()).toContain('Invalid credentials')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -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>
|