@edc-motor/admin-kit 0.2.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,181 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onUnmounted, watch } from 'vue'
3
+ import { useRoute, type RouteLocationRaw } from 'vue-router'
4
+ import { ChevronLeft, ChevronRight, Menu, X } from '@lucide/vue'
5
+ import {
6
+ MotorBadge,
7
+ ThemeSelector,
8
+ LocaleSelector,
9
+ AppBreadcrumbs,
10
+ type Crumb,
11
+ } from '@edc-motor/ui'
12
+ import RightSidebar from './RightSidebar.vue'
13
+ import { useRightSidebar } from '../composables/useRightSidebar'
14
+
15
+ // Layout del panel — portado del AppLayout de kontuan (DC-28): sidebar
16
+ // colapsable en escritorio, drawer a pantalla completa en móvil, preferencias
17
+ // (tema + idioma) en la cabecera del sidebar y migas de pan en el contenido.
18
+ interface Locale {
19
+ code: string
20
+ name: string
21
+ }
22
+
23
+ const props = withDefaults(
24
+ defineProps<{
25
+ title?: string
26
+ brand?: string
27
+ locales?: Locale[]
28
+ /** Locale de contenido (v-model:locale). */
29
+ locale?: string
30
+ /** Ruta a la que lleva el logo (la app puede no llamarla 'dashboard'). */
31
+ homeRoute?: RouteLocationRaw
32
+ /** Miga "home"; pasa null para ocultarla. */
33
+ homeCrumb?: Crumb | null
34
+ /** Migas ya traducidas (i18n desde la app). */
35
+ breadcrumbs?: Crumb[] | null
36
+ }>(),
37
+ {
38
+ title: '',
39
+ brand: 'EdC Admin',
40
+ locales: () => [],
41
+ locale: '',
42
+ homeRoute: () => ({ name: 'dashboard' }),
43
+ homeCrumb: () => ({ label: 'Inicio', to: { name: 'dashboard' } }),
44
+ breadcrumbs: null,
45
+ },
46
+ )
47
+
48
+ const emit = defineEmits<{ 'update:locale': [code: string] }>()
49
+
50
+ // Umbral móvil (igual que kontuan: MOBILE_BREAKPOINT = bp-md = 768).
51
+ const MOBILE_BREAKPOINT = 768
52
+
53
+ const route = useRoute()
54
+ const sidebarCollapsed = ref(localStorage.getItem('edc_admin_collapsed') === '1')
55
+ const sidebarMobileOpen = ref(false)
56
+ const isMobile = ref(false)
57
+
58
+ function checkMobile() {
59
+ isMobile.value = window.innerWidth < MOBILE_BREAKPOINT
60
+ if (!isMobile.value) sidebarMobileOpen.value = false
61
+ }
62
+
63
+ onMounted(() => {
64
+ checkMobile()
65
+ window.addEventListener('resize', checkMobile)
66
+ })
67
+ onUnmounted(() => window.removeEventListener('resize', checkMobile))
68
+
69
+ watch(
70
+ () => route.fullPath,
71
+ () => {
72
+ if (isMobile.value) sidebarMobileOpen.value = false
73
+ },
74
+ )
75
+
76
+ // Al abrir el drawer IZQUIERDO se CIERRA el derecho (no solo se oculta):
77
+ // así, al cerrar el izquierdo, el derecho sigue cerrado (queda su asa).
78
+ const rightSidebar = useRightSidebar()
79
+ watch(sidebarMobileOpen, (open) => {
80
+ if (open) rightSidebar.closeMobile()
81
+ })
82
+
83
+ function toggleSidebar() {
84
+ sidebarCollapsed.value = !sidebarCollapsed.value
85
+ localStorage.setItem('edc_admin_collapsed', sidebarCollapsed.value ? '1' : '0')
86
+ }
87
+ </script>
88
+
89
+ <template>
90
+ <div
91
+ class="app-layout"
92
+ :class="{
93
+ 'sidebar-collapsed': sidebarCollapsed,
94
+ 'is-mobile': isMobile,
95
+ 'left-drawer-open': isMobile && sidebarMobileOpen,
96
+ }"
97
+ >
98
+ <div
99
+ v-if="isMobile && sidebarMobileOpen"
100
+ class="sidebar-overlay"
101
+ @click="sidebarMobileOpen = false"
102
+ />
103
+
104
+ <aside class="sidebar" :class="{ 'sidebar--mobile-open': sidebarMobileOpen }">
105
+ <div
106
+ class="sidebar-header"
107
+ :class="{ 'sidebar-header--collapsed': sidebarCollapsed && !isMobile }"
108
+ >
109
+ <RouterLink
110
+ v-if="!sidebarCollapsed || isMobile"
111
+ :to="props.homeRoute"
112
+ class="sidebar-logo-link"
113
+ >
114
+ <MotorBadge :label="brand" />
115
+ </RouterLink>
116
+ <button class="sidebar-toggle" type="button" @click="toggleSidebar">
117
+ <ChevronRight v-if="sidebarCollapsed" :size="16" />
118
+ <ChevronLeft v-else :size="16" />
119
+ </button>
120
+ </div>
121
+
122
+ <nav class="sidebar-nav">
123
+ <div v-if="!sidebarCollapsed || isMobile" class="sidebar-preferences">
124
+ <ThemeSelector />
125
+ <LocaleSelector
126
+ v-if="locales.length"
127
+ :model-value="locale"
128
+ :locales="locales"
129
+ @update:model-value="(c: string) => emit('update:locale', c)"
130
+ />
131
+ </div>
132
+ <hr v-if="!sidebarCollapsed || isMobile" class="sidebar-divider" />
133
+
134
+ <div class="sidebar-items" @click="isMobile && (sidebarMobileOpen = false)">
135
+ <slot name="nav" />
136
+ </div>
137
+ </nav>
138
+
139
+ <div
140
+ class="sidebar-user"
141
+ :class="{ 'sidebar-user--collapsed': sidebarCollapsed && !isMobile }"
142
+ >
143
+ <slot name="user" :collapsed="sidebarCollapsed && !isMobile" />
144
+ </div>
145
+ </aside>
146
+
147
+ <div class="main-wrapper">
148
+ <header class="navbar">
149
+ <div class="navbar-left">
150
+ <button
151
+ v-if="isMobile"
152
+ class="hamburger"
153
+ type="button"
154
+ @click="sidebarMobileOpen = !sidebarMobileOpen"
155
+ >
156
+ <X v-if="sidebarMobileOpen" :size="20" />
157
+ <Menu v-else :size="20" />
158
+ </button>
159
+ <!-- En estrecho el sidebar (y su marca) están ocultos: la marca
160
+ pasa a la barra superior -->
161
+ <RouterLink v-if="isMobile" :to="props.homeRoute" class="navbar-brand">
162
+ <MotorBadge :label="brand" />
163
+ </RouterLink>
164
+ <span class="navbar-title">{{ title }}</span>
165
+ </div>
166
+ <div class="navbar-right">
167
+ <slot name="actions" />
168
+ </div>
169
+ </header>
170
+
171
+ <div class="main-body">
172
+ <main class="main-content">
173
+ <AppBreadcrumbs :home="homeCrumb" :crumbs="breadcrumbs" />
174
+ <slot />
175
+ </main>
176
+ <!-- Panel derecho contextual: cada vista lo activa con useRightSidebar() -->
177
+ <RightSidebar />
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </template>
@@ -0,0 +1,126 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
3
+ import { ChevronLeft, ChevronRight } from '@lucide/vue'
4
+ import { useRightSidebar } from '../composables/useRightSidebar'
5
+
6
+ // Panel lateral derecho contextual (portado de kontuan, DC-28): cada vista lo
7
+ // activa con useRightSidebar().useRegister(titulo) y teletransporta su
8
+ // contenido a #right-sidebar-target. En pantallas anchas es una columna
9
+ // plegable junto al contenido; por debajo de OVERLAY_BREAKPOINT pasa a drawer
10
+ // superpuesto (con asa flotante) para que el contenido principal NUNCA quede
11
+ // más estrecho que las barras. Lo monta AdminLayout una vez.
12
+ // Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
13
+
14
+ const props = withDefaults(
15
+ defineProps<{
16
+ showLabel?: string
17
+ hideLabel?: string
18
+ /** Título por defecto si la vista registra sin título. */
19
+ fallbackTitle?: string
20
+ }>(),
21
+ { showLabel: 'Mostrar panel', hideLabel: 'Ocultar panel', fallbackTitle: 'Detalle' },
22
+ )
23
+
24
+ const {
25
+ hasContent,
26
+ collapsed,
27
+ mobileOpen,
28
+ title,
29
+ handleFlashId,
30
+ toggleCollapsed,
31
+ toggleMobile,
32
+ closeMobile,
33
+ } = useRightSidebar()
34
+
35
+ // Por debajo de este ancho el panel se superpone (drawer) en vez de ocupar
36
+ // columna: sidebar izquierda (~240) + panel (320) + contenido útil mínimo.
37
+ const OVERLAY_BREAKPOINT = 1100
38
+
39
+ const overlay = ref(false)
40
+ const flashing = ref(false)
41
+ let flashTimer: ReturnType<typeof setTimeout> | null = null
42
+
43
+ function checkOverlay() {
44
+ overlay.value = window.innerWidth < OVERLAY_BREAKPOINT
45
+ if (!overlay.value) mobileOpen.value = false
46
+ }
47
+
48
+ onMounted(() => {
49
+ checkOverlay()
50
+ window.addEventListener('resize', checkOverlay)
51
+ })
52
+
53
+ onUnmounted(() => {
54
+ window.removeEventListener('resize', checkOverlay)
55
+ if (flashTimer) clearTimeout(flashTimer)
56
+ })
57
+
58
+ const isVisible = computed(() => (overlay.value ? mobileOpen.value : !collapsed.value))
59
+ const panelTitle = computed(() => title.value || props.fallbackTitle)
60
+
61
+ // Con el panel oculto, un destello en el asa avisa de contenido nuevo.
62
+ watch(handleFlashId, () => {
63
+ if (isVisible.value) return
64
+ flashing.value = false
65
+ if (flashTimer) clearTimeout(flashTimer)
66
+ requestAnimationFrame(() => {
67
+ flashing.value = true
68
+ flashTimer = setTimeout(() => {
69
+ flashing.value = false
70
+ }, 1100)
71
+ })
72
+ })
73
+
74
+ function handleToggle(e: MouseEvent) {
75
+ // Evita dejar el foco dentro de un subárbol inert/aria-hidden.
76
+ ;(e.currentTarget as HTMLElement | null)?.blur()
77
+ if (overlay.value) toggleMobile()
78
+ else toggleCollapsed()
79
+ }
80
+ </script>
81
+
82
+ <template>
83
+ <div
84
+ v-if="overlay && hasContent && mobileOpen"
85
+ class="right-sidebar-overlay"
86
+ @click="closeMobile"
87
+ />
88
+
89
+ <button
90
+ v-if="hasContent && !isVisible"
91
+ type="button"
92
+ class="right-sidebar-handle"
93
+ :class="{ 'right-sidebar-handle--flash': flashing }"
94
+ :aria-label="showLabel"
95
+ @click="handleToggle"
96
+ >
97
+ <ChevronLeft :size="16" />
98
+ </button>
99
+
100
+ <aside
101
+ v-show="hasContent"
102
+ class="right-sidebar"
103
+ :class="{
104
+ 'right-sidebar--collapsed': collapsed && !overlay,
105
+ 'right-sidebar--drawer': overlay,
106
+ 'right-sidebar--drawer-open': overlay && mobileOpen,
107
+ }"
108
+ :inert="!isVisible"
109
+ >
110
+ <div class="right-sidebar__header">
111
+ <button
112
+ type="button"
113
+ class="right-sidebar__toggle"
114
+ :aria-label="hideLabel"
115
+ @click="handleToggle"
116
+ >
117
+ <ChevronRight :size="16" />
118
+ </button>
119
+ <span class="right-sidebar__title">{{ panelTitle }}</span>
120
+ </div>
121
+
122
+ <div class="right-sidebar__body">
123
+ <div id="right-sidebar-target" />
124
+ </div>
125
+ </aside>
126
+ </template>