@edc-motor/admin-kit 0.4.23 → 0.4.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edc-motor/admin-kit",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
4
4
  "description": "EdC Motor — kit del panel de administración: layout, gestores CRUD, imágenes, PDF, páginas, configuración, usuarios y copias (paquete fuente: lo compila el consumidor con Vite)",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@edc-motor/ui": "^0.4.23",
36
+ "@edc-motor/ui": "^0.4.24",
37
37
  "axios": "^1.7.0",
38
38
  "vue-draggable-plus": "^0.6.1"
39
39
  },
@@ -8,3 +8,4 @@
8
8
  @use "components/preview-manager";
9
9
  @use "components/pdf-manager";
10
10
  @use "components/page-blocks";
11
+ @use "components/menu-manager";
@@ -0,0 +1,133 @@
1
+ @use "tokens" as *;
2
+
3
+ // Configurador del menú público (doc 10 ampliado): filas tipo PageBlocks
4
+ // (icono + etiqueta + badges + acciones), sin drag — el orden se mueve con
5
+ // flechas, dentro de cada nivel (raíz o hijos de un grupo).
6
+ .menu-manager {
7
+ display: grid;
8
+ gap: $space-3;
9
+
10
+ &__bar {
11
+ display: flex;
12
+ justify-content: flex-end;
13
+ }
14
+
15
+ &__empty { color: $text-3; margin: 0; }
16
+
17
+ &__list {
18
+ display: grid;
19
+ gap: $space-2;
20
+ }
21
+
22
+ &__item {
23
+ display: flex;
24
+ align-items: center;
25
+ flex-wrap: wrap;
26
+ gap: $space-2;
27
+ border: 1px solid $border;
28
+ border-radius: $radius-md;
29
+ background: $surface;
30
+ padding: $space-2 $space-3;
31
+ min-width: 0;
32
+ transition: border-color 0.15s ease;
33
+
34
+ &:hover { border-color: $border-strong; }
35
+ &.is-hidden { opacity: 0.65; }
36
+ }
37
+
38
+ // Hijos de un grupo: sangrados justo debajo de él (mismo lenguaje que los
39
+ // bloques hijos de PageBlocks).
40
+ &__item.is-child { margin-left: $space-7; }
41
+
42
+ &__icon {
43
+ display: inline-flex;
44
+ color: $text-3;
45
+ flex: 0 0 auto;
46
+ }
47
+
48
+ &__label {
49
+ font-weight: $k-fw-semibold;
50
+ min-width: 0;
51
+ overflow: hidden;
52
+ text-overflow: ellipsis;
53
+ white-space: nowrap;
54
+ margin-right: auto;
55
+ }
56
+
57
+ &__badges { display: flex; gap: $space-1; }
58
+
59
+ &__moves {
60
+ display: flex;
61
+ gap: 2px;
62
+ }
63
+
64
+ &__move {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ background: none;
69
+ border: 1px solid $border;
70
+ border-radius: $radius-sm;
71
+ color: $text-2;
72
+ cursor: pointer;
73
+ padding: $space-1;
74
+ transition: background-color 0.15s ease;
75
+
76
+ &:hover:not(:disabled) { background: $surface-2; }
77
+ &:disabled { opacity: 0.4; cursor: default; }
78
+ }
79
+
80
+ &__visibility {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ justify-content: center;
84
+ background: transparent;
85
+ border: 1px solid color-mix(in srgb, $success 45%, transparent);
86
+ border-radius: $radius-sm;
87
+ color: $success;
88
+ cursor: pointer;
89
+ padding: $space-1;
90
+ transition: background-color 0.15s ease, border-color 0.15s ease;
91
+
92
+ &.is-on {
93
+ background: $success;
94
+ border-color: $success;
95
+ @include contrast-text($success);
96
+ }
97
+ &:disabled { opacity: 0.6; cursor: default; }
98
+ }
99
+
100
+ &__group-select {
101
+ min-width: 160px;
102
+
103
+ .form-field__select { min-height: 34px; }
104
+ }
105
+
106
+ &__group-actions {
107
+ display: flex;
108
+ gap: $space-1;
109
+ }
110
+
111
+ &__edit,
112
+ &__delete {
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ background: none;
117
+ border: none;
118
+ border-radius: $radius-sm;
119
+ padding: $space-1;
120
+ cursor: pointer;
121
+ transition: background-color 0.15s ease;
122
+ }
123
+
124
+ &__edit {
125
+ color: $accent-500;
126
+ &:hover { background: color-mix(in srgb, $accent-500 12%, transparent); }
127
+ }
128
+
129
+ &__delete {
130
+ color: $danger;
131
+ &:hover { background: color-mix(in srgb, $danger 12%, transparent); }
132
+ }
133
+ }
@@ -0,0 +1,391 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, reactive, ref } from 'vue'
3
+ import type { AxiosInstance } from 'axios'
4
+ import {
5
+ ArrowDown,
6
+ ArrowUp,
7
+ Compass,
8
+ Eye,
9
+ EyeOff,
10
+ File,
11
+ Folder,
12
+ Plus,
13
+ SquarePen,
14
+ Trash2,
15
+ } from '@lucide/vue'
16
+ import {
17
+ BaseButton,
18
+ BaseSelect,
19
+ EditModal,
20
+ TranslatableInput,
21
+ useConfirm,
22
+ useToast,
23
+ } from '@edc-motor/ui'
24
+
25
+ // Configurador del menú de la web pública (doc 10 ampliado): mezcla páginas
26
+ // del CRM y "rutas" propias del juego bajo un árbol de UN nivel (los grupos
27
+ // son carpetas del admin; una página o ruta puede colgar de un grupo, un
28
+ // grupo no puede colgar de otro). Filas tipo PageBlocks (no cards):
29
+ // subir/bajar dentro de su nivel, interruptor de visibilidad, select de
30
+ // grupo y, en grupos, editar label + borrar. Agnóstico de i18n (DC-29):
31
+ // todos los textos por prop; `routeLabels` los pone el juego.
32
+
33
+ export interface MenuManagerLabels {
34
+ newGroup: string
35
+ newGroupTitle: string
36
+ editGroupTitle: string
37
+ groupLabel: string
38
+ save: string
39
+ cancel: string
40
+ delete: string
41
+ confirmDelete: string
42
+ empty: string
43
+ root: string
44
+ hidden: string
45
+ draft: string
46
+ moveUp: string
47
+ moveDown: string
48
+ visible: string
49
+ group: string
50
+ error: string
51
+ }
52
+
53
+ const defaultLabels: MenuManagerLabels = {
54
+ newGroup: 'Nuevo grupo',
55
+ newGroupTitle: 'Nuevo grupo',
56
+ editGroupTitle: 'Editar grupo',
57
+ groupLabel: 'Nombre del grupo',
58
+ save: 'Guardar',
59
+ cancel: 'Cancelar',
60
+ delete: 'Borrar',
61
+ confirmDelete: '¿Borrar este grupo? Sus elementos pasarán a la raíz.',
62
+ empty: 'El menú aún no tiene elementos.',
63
+ root: '— Raíz —',
64
+ hidden: 'Oculto',
65
+ draft: 'Borrador',
66
+ moveUp: 'Subir',
67
+ moveDown: 'Bajar',
68
+ visible: 'Visible',
69
+ group: 'Grupo',
70
+ error: 'No se ha podido completar la acción.',
71
+ }
72
+
73
+ const props = withDefaults(
74
+ defineProps<{
75
+ api: AxiosInstance
76
+ locales: { code: string; name: string }[]
77
+ /** Etiqueta visible de cada route_key que el juego ofrece al menú. */
78
+ routeLabels?: Record<string, string>
79
+ labels?: Partial<MenuManagerLabels>
80
+ }>(),
81
+ { routeLabels: () => ({}), labels: () => ({}) },
82
+ )
83
+
84
+ const L = reactive({ ...defaultLabels, ...props.labels }) as MenuManagerLabels
85
+
86
+ const toast = useToast()
87
+ const { confirm } = useConfirm()
88
+
89
+ interface MenuPageInfo {
90
+ id: number
91
+ title: Record<string, string>
92
+ is_published: boolean
93
+ }
94
+
95
+ interface MenuNode {
96
+ id: number
97
+ type: 'page' | 'route' | 'group'
98
+ is_visible: boolean
99
+ order: number
100
+ route_key: string | null
101
+ label: Record<string, string> | null
102
+ page: MenuPageInfo | null
103
+ children: MenuNode[]
104
+ }
105
+
106
+ const tree = ref<MenuNode[]>([])
107
+ const busy = ref(false)
108
+
109
+ async function load() {
110
+ try {
111
+ const { data } = await props.api.get('/admin/menu')
112
+ tree.value = data.data
113
+ } catch {
114
+ toast.danger(L.error)
115
+ }
116
+ }
117
+
118
+ // Filas aplanadas para pintar (profundidad máxima 1: hijos de un grupo,
119
+ // indentados justo debajo de él — un grupo no puede colgar de otro).
120
+ interface Row {
121
+ node: MenuNode
122
+ depth: 0 | 1
123
+ parentId: number | null
124
+ }
125
+ const rows = computed<Row[]>(() => {
126
+ const out: Row[] = []
127
+ for (const node of tree.value) {
128
+ out.push({ node, depth: 0, parentId: null })
129
+ if (node.type === 'group') {
130
+ for (const child of node.children) out.push({ node: child, depth: 1, parentId: node.id })
131
+ }
132
+ }
133
+ return out
134
+ })
135
+
136
+ const groups = computed(() => tree.value.filter((n) => n.type === 'group'))
137
+
138
+ function siblingIds(parentId: number | null): number[] {
139
+ if (parentId === null) return tree.value.map((n) => n.id)
140
+ return groups.value.find((g) => g.id === parentId)?.children.map((c) => c.id) ?? []
141
+ }
142
+
143
+ function siblingIndex(row: Row): number {
144
+ return siblingIds(row.parentId).indexOf(row.node.id)
145
+ }
146
+
147
+ function canMoveUp(row: Row): boolean {
148
+ return siblingIndex(row) > 0
149
+ }
150
+ function canMoveDown(row: Row): boolean {
151
+ const ids = siblingIds(row.parentId)
152
+ const idx = siblingIndex(row)
153
+ return idx >= 0 && idx < ids.length - 1
154
+ }
155
+
156
+ /** Primer texto disponible (agnóstico de locale, patrón EntityRefSelect). */
157
+ function firstText(map: Record<string, string> | null | undefined): string {
158
+ if (!map) return ''
159
+ return Object.values(map).find(Boolean) ?? ''
160
+ }
161
+
162
+ function labelOf(node: MenuNode): string {
163
+ if (node.type === 'page') return node.page ? firstText(node.page.title) : ''
164
+ if (node.type === 'route') return props.routeLabels[node.route_key ?? ''] ?? node.route_key ?? ''
165
+ return firstText(node.label)
166
+ }
167
+
168
+ async function move(row: Row, direction: 'up' | 'down') {
169
+ const ids = siblingIds(row.parentId)
170
+ const idx = ids.indexOf(row.node.id)
171
+ const swapWith = direction === 'up' ? idx - 1 : idx + 1
172
+ if (idx < 0 || swapWith < 0 || swapWith >= ids.length) return
173
+ const next = [...ids]
174
+ ;[next[idx], next[swapWith]] = [next[swapWith], next[idx]]
175
+ busy.value = true
176
+ try {
177
+ await props.api.post('/admin/menu/reorder', { ids: next })
178
+ await load()
179
+ } catch {
180
+ toast.danger(L.error)
181
+ } finally {
182
+ busy.value = false
183
+ }
184
+ }
185
+
186
+ async function toggleVisible(row: Row) {
187
+ busy.value = true
188
+ try {
189
+ await props.api.patch(`/admin/menu/${row.node.id}`, { is_visible: !row.node.is_visible })
190
+ await load()
191
+ } catch {
192
+ toast.danger(L.error)
193
+ } finally {
194
+ busy.value = false
195
+ }
196
+ }
197
+
198
+ async function setGroup(row: Row, value: string) {
199
+ busy.value = true
200
+ try {
201
+ await props.api.patch(`/admin/menu/${row.node.id}`, {
202
+ parent_id: value ? Number(value) : null,
203
+ })
204
+ await load()
205
+ } catch {
206
+ toast.danger(L.error)
207
+ } finally {
208
+ busy.value = false
209
+ }
210
+ }
211
+
212
+ // Modal de grupo (crear / editar label), compartido.
213
+ const modalOpen = ref(false)
214
+ const editingGroup = ref<MenuNode | null>(null)
215
+ const groupLabelForm = ref<Record<string, string>>({})
216
+ const saving = ref(false)
217
+
218
+ function openCreateGroup() {
219
+ editingGroup.value = null
220
+ groupLabelForm.value = {}
221
+ modalOpen.value = true
222
+ }
223
+
224
+ function openEditGroup(node: MenuNode) {
225
+ editingGroup.value = node
226
+ groupLabelForm.value = { ...(node.label ?? {}) }
227
+ modalOpen.value = true
228
+ }
229
+
230
+ async function saveGroup() {
231
+ saving.value = true
232
+ try {
233
+ if (editingGroup.value) {
234
+ await props.api.patch(`/admin/menu/${editingGroup.value.id}`, { label: groupLabelForm.value })
235
+ } else {
236
+ await props.api.post('/admin/menu/groups', { label: groupLabelForm.value })
237
+ }
238
+ modalOpen.value = false
239
+ await load()
240
+ } catch {
241
+ toast.danger(L.error)
242
+ } finally {
243
+ saving.value = false
244
+ }
245
+ }
246
+
247
+ async function removeGroup(node: MenuNode) {
248
+ const ok = await confirm({
249
+ message: L.confirmDelete,
250
+ confirmLabel: L.delete,
251
+ cancelLabel: L.cancel,
252
+ variant: 'danger',
253
+ })
254
+ if (!ok) return
255
+ busy.value = true
256
+ try {
257
+ await props.api.delete(`/admin/menu/${node.id}`)
258
+ await load()
259
+ } catch {
260
+ toast.danger(L.error)
261
+ } finally {
262
+ busy.value = false
263
+ }
264
+ }
265
+
266
+ onMounted(load)
267
+ </script>
268
+
269
+ <template>
270
+ <div class="menu-manager">
271
+ <div class="menu-manager__bar">
272
+ <BaseButton :disabled="busy" @click="openCreateGroup">
273
+ <template #icon><Plus :size="16" /></template>
274
+ {{ L.newGroup }}
275
+ </BaseButton>
276
+ </div>
277
+
278
+ <p v-if="!rows.length" class="menu-manager__empty">{{ L.empty }}</p>
279
+
280
+ <div class="menu-manager__list">
281
+ <article
282
+ v-for="row in rows"
283
+ :key="row.node.id"
284
+ class="menu-manager__item"
285
+ :class="{ 'is-child': row.depth === 1, 'is-hidden': !row.node.is_visible }"
286
+ >
287
+ <span class="menu-manager__icon">
288
+ <File v-if="row.node.type === 'page'" :size="16" />
289
+ <Compass v-else-if="row.node.type === 'route'" :size="16" />
290
+ <Folder v-else :size="16" />
291
+ </span>
292
+
293
+ <span class="menu-manager__label">{{ labelOf(row.node) }}</span>
294
+
295
+ <span class="menu-manager__badges">
296
+ <span v-if="!row.node.is_visible" class="chip is-missing">{{ L.hidden }}</span>
297
+ <span
298
+ v-if="row.node.type === 'page' && row.node.page && !row.node.page.is_published"
299
+ class="chip is-missing"
300
+ >
301
+ {{ L.draft }}
302
+ </span>
303
+ </span>
304
+
305
+ <span class="menu-manager__moves">
306
+ <button
307
+ type="button"
308
+ class="menu-manager__move"
309
+ :title="L.moveUp"
310
+ :aria-label="L.moveUp"
311
+ :disabled="busy || !canMoveUp(row)"
312
+ @click="move(row, 'up')"
313
+ >
314
+ <ArrowUp :size="14" />
315
+ </button>
316
+ <button
317
+ type="button"
318
+ class="menu-manager__move"
319
+ :title="L.moveDown"
320
+ :aria-label="L.moveDown"
321
+ :disabled="busy || !canMoveDown(row)"
322
+ @click="move(row, 'down')"
323
+ >
324
+ <ArrowDown :size="14" />
325
+ </button>
326
+ </span>
327
+
328
+ <button
329
+ type="button"
330
+ class="menu-manager__visibility"
331
+ :class="row.node.is_visible ? 'is-on' : 'is-off'"
332
+ :aria-pressed="row.node.is_visible"
333
+ :title="L.visible"
334
+ :disabled="busy"
335
+ @click="toggleVisible(row)"
336
+ >
337
+ <Eye v-if="row.node.is_visible" :size="15" />
338
+ <EyeOff v-else :size="15" />
339
+ </button>
340
+
341
+ <BaseSelect
342
+ v-if="row.node.type !== 'group'"
343
+ class="menu-manager__group-select"
344
+ :model-value="row.parentId === null ? '' : String(row.parentId)"
345
+ :options="[
346
+ { value: '', label: L.root },
347
+ ...groups.map((g) => ({ value: String(g.id), label: labelOf(g) })),
348
+ ]"
349
+ @update:model-value="(v: string) => setGroup(row, v)"
350
+ />
351
+
352
+ <span v-if="row.node.type === 'group'" class="menu-manager__group-actions">
353
+ <button
354
+ type="button"
355
+ class="menu-manager__edit"
356
+ :title="L.editGroupTitle"
357
+ :aria-label="L.editGroupTitle"
358
+ @click="openEditGroup(row.node)"
359
+ >
360
+ <SquarePen :size="15" />
361
+ </button>
362
+ <button
363
+ type="button"
364
+ class="menu-manager__delete"
365
+ :title="L.delete"
366
+ :aria-label="L.delete"
367
+ @click="removeGroup(row.node)"
368
+ >
369
+ <Trash2 :size="15" />
370
+ </button>
371
+ </span>
372
+ </article>
373
+ </div>
374
+
375
+ <EditModal
376
+ v-model="modalOpen"
377
+ :title="editingGroup ? L.editGroupTitle : L.newGroupTitle"
378
+ :submit-label="L.save"
379
+ :cancel-label="L.cancel"
380
+ :loading="saving"
381
+ @submit="saveGroup"
382
+ >
383
+ <TranslatableInput
384
+ v-model="groupLabelForm"
385
+ :locales="locales"
386
+ :label="L.groupLabel"
387
+ required
388
+ />
389
+ </EditModal>
390
+ </div>
391
+ </template>
package/src/index.ts CHANGED
@@ -24,6 +24,9 @@ export { useResource, type ResourceMeta } from './crud/useResource'
24
24
  // CRM de páginas y bloques (doc 03): editor dirigido por esquema.
25
25
  export { default as SchemaFields, type FieldSchema } from './content/SchemaFields.vue'
26
26
  export { default as PageBlocks, type PageBlocksLabels } from './content/PageBlocks.vue'
27
+ // Configurador del menú público (doc 10 ampliado): páginas del CRM + rutas
28
+ // del juego, reordenables y agrupables. Agnóstico de i18n (DC-29).
29
+ export { default as MenuManager, type MenuManagerLabels } from './content/MenuManager.vue'
27
30
  // Subidas de imagen diferidas al guardar (uploads de contenido sin huérfanos):
28
31
  // las usan PageBlocks y las vistas del cascarón (Ajustes, form de página).
29
32
  export {