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.
@@ -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 { Bars3Icon, XMarkIcon } from '@heroicons/vue/24/outline'
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-transform duration-300 lg:pointer-events-auto"
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 class="px-5 pt-5 pb-4 flex items-center justify-center relative">
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 class="px-2 mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
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="isActive(item.name)
144
- ? (isDark
145
- ? 'bg-emerald-500/15 text-emerald-300'
146
- : 'bg-emerald-50 text-emerald-700')
147
- : (isDark
148
- ? 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
149
- : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100')"
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: Theme toggle, status slot, footer slot -->
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="isDark
171
- ? 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
172
- : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'"
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
- {{ isDark ? 'Switch to light' : 'Switch to dark' }}
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
- <slot name="status" />
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
- <slot name="footer">
194
- <div
195
- v-if="appName || version"
196
- class="flex items-center justify-center gap-2 text-xs"
197
- :class="isDark ? 'text-gray-500' : 'text-gray-500'"
198
- >
199
- <span>{{ appName }}<template v-if="appName && version"> </template><template v-if="version">v{{ version }}</template></span>
200
- </div>
201
- </slot>
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>