@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.
- package/LICENSE +674 -0
- package/README.md +37 -0
- package/package.json +45 -0
- package/scss/_admin-kit.scss +10 -0
- package/scss/components/_admin-layout.scss +310 -0
- package/scss/components/_empty-state.scss +32 -0
- package/scss/components/_entity-card.scss +86 -0
- package/scss/components/_filter-bar.scss +53 -0
- package/scss/components/_grid.scss +31 -0
- package/scss/components/_manager-card.scss +329 -0
- package/scss/components/_page-blocks.scss +179 -0
- package/scss/components/_pdf-manager.scss +46 -0
- package/scss/components/_preview-manager.scss +5 -0
- package/scss/components/_right-sidebar.scss +152 -0
- package/src/components/ManagerCard.vue +57 -0
- package/src/composables/useRightSidebar.ts +90 -0
- package/src/content/EntityRefSelect.vue +58 -0
- package/src/content/PageBlocks.vue +449 -0
- package/src/content/SchemaFields.vue +308 -0
- package/src/crud/BaseGrid.vue +38 -0
- package/src/crud/EmptyState.vue +13 -0
- package/src/crud/EntityCard.vue +51 -0
- package/src/crud/FilterBar.vue +28 -0
- package/src/crud/useResource.ts +83 -0
- package/src/index.ts +20 -0
- package/src/layout/AdminLayout.vue +181 -0
- package/src/layout/RightSidebar.vue +126 -0
- package/src/pdf/PdfManager.vue +407 -0
- package/src/previews/PreviewManager.vue +398 -0
|
@@ -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>
|