@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,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PanelRight } from '@lucide/vue'
|
|
3
|
+
|
|
4
|
+
// Tarjeta de los gestores del admin (previews y PDF): NO colapsa. Muestra el
|
|
5
|
+
// título (+ chip opcional), el resumen (slot meta) y las acciones "de todas"
|
|
6
|
+
// en el pie (slot actions). TODA la tarjeta selecciona (excepto los botones,
|
|
7
|
+
// enlaces e inputs interiores): su detalle —selector de elementos, etc.— se
|
|
8
|
+
// abre en el panel derecho del layout.
|
|
9
|
+
// Se coloca dentro de .manager-grid (1-2 columnas según el contenedor).
|
|
10
|
+
|
|
11
|
+
defineProps<{
|
|
12
|
+
title: string
|
|
13
|
+
/** Etiqueta pequeña junto al título (p. ej. el layout del export). */
|
|
14
|
+
chip?: string
|
|
15
|
+
/** Tarjeta activa: su contenido manda en el panel derecho. */
|
|
16
|
+
active?: boolean
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{ select: [] }>()
|
|
20
|
+
|
|
21
|
+
/** Selecciona salvo que el clic naciera en un control interior. */
|
|
22
|
+
function onClick(event: MouseEvent) {
|
|
23
|
+
const target = event.target as HTMLElement | null
|
|
24
|
+
if (target?.closest('button, a, input, select, textarea, label')) return
|
|
25
|
+
emit('select')
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<section
|
|
31
|
+
class="manager-card"
|
|
32
|
+
:class="{ 'is-active': active }"
|
|
33
|
+
role="button"
|
|
34
|
+
tabindex="0"
|
|
35
|
+
@click="onClick"
|
|
36
|
+
@keydown.enter.self="emit('select')"
|
|
37
|
+
@keydown.space.self.prevent="emit('select')"
|
|
38
|
+
>
|
|
39
|
+
<div class="manager-card__head">
|
|
40
|
+
<span class="manager-card__title">{{ title }}</span>
|
|
41
|
+
<span v-if="chip" class="chip manager-card__chip">{{ chip }}</span>
|
|
42
|
+
<PanelRight :size="16" class="manager-card__hint" />
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div v-if="$slots.meta" class="manager-card__meta">
|
|
46
|
+
<slot name="meta" />
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div v-if="$slots.default" class="manager-card__body">
|
|
50
|
+
<slot />
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<footer v-if="$slots.actions" class="manager-card__actions">
|
|
54
|
+
<slot name="actions" />
|
|
55
|
+
</footer>
|
|
56
|
+
</section>
|
|
57
|
+
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { onBeforeUnmount, ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Panel lateral derecho contextual, por vista (patrón kontuan).
|
|
5
|
+
*
|
|
6
|
+
* Espejo del modelo de la navegación izquierda:
|
|
7
|
+
* - escritorio: visible cuando la vista actual registra contenido;
|
|
8
|
+
* se puede plegar con `collapsed`.
|
|
9
|
+
* - móvil: oculto; entra como drawer cuando `mobileOpen` es true.
|
|
10
|
+
*
|
|
11
|
+
* La propiedad se rastrea con un token opaco para que el `unregister` de una
|
|
12
|
+
* vista saliente nunca borre el panel cuando otra vista ya ha registrado el
|
|
13
|
+
* suyo (en un cambio de ruta, el setup nuevo corre antes que el onUnmounted
|
|
14
|
+
* viejo).
|
|
15
|
+
*/
|
|
16
|
+
const hasContent = ref(false)
|
|
17
|
+
const collapsed = ref(false)
|
|
18
|
+
const mobileOpen = ref(false)
|
|
19
|
+
const title = ref<string>('')
|
|
20
|
+
const handleFlashId = ref(0)
|
|
21
|
+
|
|
22
|
+
let ownerToken: symbol | null = null
|
|
23
|
+
|
|
24
|
+
export type RightSidebarToken = symbol
|
|
25
|
+
|
|
26
|
+
export function useRightSidebar() {
|
|
27
|
+
function toggleCollapsed() {
|
|
28
|
+
collapsed.value = !collapsed.value
|
|
29
|
+
}
|
|
30
|
+
function openMobile() {
|
|
31
|
+
mobileOpen.value = true
|
|
32
|
+
}
|
|
33
|
+
function closeMobile() {
|
|
34
|
+
mobileOpen.value = false
|
|
35
|
+
}
|
|
36
|
+
function toggleMobile() {
|
|
37
|
+
mobileOpen.value = !mobileOpen.value
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Destello del asa cuando llega contenido nuevo con el panel oculto. */
|
|
41
|
+
function flashHandle() {
|
|
42
|
+
handleFlashId.value++
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Muestra el panel esté donde esté (despliega en escritorio, abre en móvil). */
|
|
46
|
+
function reveal() {
|
|
47
|
+
collapsed.value = false
|
|
48
|
+
mobileOpen.value = true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function register(panelTitle = '', token?: RightSidebarToken): RightSidebarToken {
|
|
52
|
+
const owned = token ?? Symbol('right-sidebar-owner')
|
|
53
|
+
ownerToken = owned
|
|
54
|
+
hasContent.value = true
|
|
55
|
+
title.value = panelTitle
|
|
56
|
+
return owned
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Limpia el panel solo si quien llama sigue siendo el dueño. */
|
|
60
|
+
function unregister(token?: RightSidebarToken) {
|
|
61
|
+
if (token !== undefined && token !== ownerToken) return
|
|
62
|
+
ownerToken = null
|
|
63
|
+
hasContent.value = false
|
|
64
|
+
title.value = ''
|
|
65
|
+
mobileOpen.value = false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function useRegister(panelTitle = ''): RightSidebarToken {
|
|
69
|
+
const token = register(panelTitle)
|
|
70
|
+
onBeforeUnmount(() => unregister(token))
|
|
71
|
+
return token
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
hasContent,
|
|
76
|
+
collapsed,
|
|
77
|
+
mobileOpen,
|
|
78
|
+
title,
|
|
79
|
+
handleFlashId,
|
|
80
|
+
toggleCollapsed,
|
|
81
|
+
openMobile,
|
|
82
|
+
closeMobile,
|
|
83
|
+
toggleMobile,
|
|
84
|
+
flashHandle,
|
|
85
|
+
reveal,
|
|
86
|
+
register,
|
|
87
|
+
unregister,
|
|
88
|
+
useRegister,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue'
|
|
3
|
+
import type { AxiosInstance } from 'axios'
|
|
4
|
+
import { SearchSelect, type SearchSelectOption } from '@edc-motor/ui'
|
|
5
|
+
|
|
6
|
+
// Selector del campo `entity` del DSL (doc 03): busca entre las opciones
|
|
7
|
+
// del endpoint del juego (shape {data: [{id, name: {locale: texto}}]}) y
|
|
8
|
+
// guarda el id en settings. El filtrado es en cliente (los options ya
|
|
9
|
+
// vienen completos y ligeros).
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
modelValue: number | null
|
|
12
|
+
label: string
|
|
13
|
+
optionsUrl: string
|
|
14
|
+
api: AxiosInstance
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{ 'update:modelValue': [id: number | null] }>()
|
|
18
|
+
|
|
19
|
+
interface OptionRow {
|
|
20
|
+
id: number
|
|
21
|
+
name: Record<string, string> | string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rows = ref<OptionRow[]>([])
|
|
25
|
+
const query = ref('')
|
|
26
|
+
|
|
27
|
+
function labelOf(row: OptionRow): string {
|
|
28
|
+
if (typeof row.name === 'string') return row.name
|
|
29
|
+
return Object.values(row.name)[0] ?? String(row.id)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const options = computed<SearchSelectOption[]>(() =>
|
|
33
|
+
rows.value
|
|
34
|
+
.map((row) => ({ id: row.id, label: labelOf(row) }))
|
|
35
|
+
.filter((o) => o.label.toLowerCase().includes(query.value.toLowerCase())),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
onMounted(async () => {
|
|
39
|
+
try {
|
|
40
|
+
const { data } = await props.api.get(props.optionsUrl)
|
|
41
|
+
rows.value = data.data
|
|
42
|
+
} catch {
|
|
43
|
+
rows.value = []
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div class="form-field">
|
|
50
|
+
<span class="form-field__label">{{ label }}</span>
|
|
51
|
+
<SearchSelect
|
|
52
|
+
:model-value="modelValue"
|
|
53
|
+
:options="options"
|
|
54
|
+
@update:model-value="(id) => emit('update:modelValue', Number(id))"
|
|
55
|
+
@search="(q) => (query = q)"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref } from 'vue'
|
|
3
|
+
import type { AxiosInstance } from 'axios'
|
|
4
|
+
import { GripVertical, Plus, SquarePen, Trash2 } from '@lucide/vue'
|
|
5
|
+
import { VueDraggable } from 'vue-draggable-plus'
|
|
6
|
+
import {
|
|
7
|
+
BaseButton,
|
|
8
|
+
BaseCheckbox,
|
|
9
|
+
EditModal,
|
|
10
|
+
useConfirm,
|
|
11
|
+
useToast,
|
|
12
|
+
type RichIcon,
|
|
13
|
+
type RichTextLabels,
|
|
14
|
+
} from '@edc-motor/ui'
|
|
15
|
+
import SchemaFields, { type FieldSchema } from './SchemaFields.vue'
|
|
16
|
+
import { useRightSidebar } from '../composables/useRightSidebar'
|
|
17
|
+
|
|
18
|
+
// Gestor de bloques de una página (doc 03): paleta de tipos (motor + juego),
|
|
19
|
+
// lista reordenable con drag (DC-17) y modal de edición GENERADO desde el
|
|
20
|
+
// esquema de campos del tipo. Añadir un tipo de bloque no toca este código.
|
|
21
|
+
// Agnóstico de i18n (DC-29): textos por prop; gancho translate para nombres.
|
|
22
|
+
|
|
23
|
+
export interface PageBlocksLabels {
|
|
24
|
+
add: string
|
|
25
|
+
edit: string
|
|
26
|
+
delete: string
|
|
27
|
+
save: string
|
|
28
|
+
cancel: string
|
|
29
|
+
empty: string
|
|
30
|
+
printable: string
|
|
31
|
+
indexable: string
|
|
32
|
+
common: string
|
|
33
|
+
confirmDelete: string
|
|
34
|
+
error: string
|
|
35
|
+
panelTitle: string
|
|
36
|
+
panelEmpty: string
|
|
37
|
+
panelContent: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const defaultLabels: PageBlocksLabels = {
|
|
41
|
+
add: 'Añadir bloque',
|
|
42
|
+
edit: 'Editar bloque',
|
|
43
|
+
delete: 'Borrar',
|
|
44
|
+
save: 'Guardar',
|
|
45
|
+
cancel: 'Cancelar',
|
|
46
|
+
empty: 'La página aún no tiene bloques.',
|
|
47
|
+
printable: 'Entra en el PDF de la página',
|
|
48
|
+
indexable: 'Aparece en el índice',
|
|
49
|
+
common: 'Ajustes comunes',
|
|
50
|
+
confirmDelete: '¿Borrar este bloque?',
|
|
51
|
+
error: 'No se ha podido completar la acción.',
|
|
52
|
+
panelTitle: 'Bloque',
|
|
53
|
+
panelEmpty: 'Selecciona un bloque para ver sus acciones.',
|
|
54
|
+
panelContent: 'Contenido',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const props = withDefaults(
|
|
58
|
+
defineProps<{
|
|
59
|
+
api: AxiosInstance
|
|
60
|
+
pageId: number
|
|
61
|
+
locales: { code: string; name: string }[]
|
|
62
|
+
icons?: RichIcon[]
|
|
63
|
+
richLabels?: Partial<RichTextLabels>
|
|
64
|
+
labels?: Partial<PageBlocksLabels>
|
|
65
|
+
/** Localiza nombres de tipos y etiquetas de campos: (clave, fallback) => texto. */
|
|
66
|
+
translate?: (key: string, fallback: string) => string
|
|
67
|
+
}>(),
|
|
68
|
+
{ icons: () => [], richLabels: () => ({}), labels: () => ({}), translate: undefined },
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const L = reactive({ ...defaultLabels, ...props.labels }) as PageBlocksLabels
|
|
72
|
+
|
|
73
|
+
const toast = useToast()
|
|
74
|
+
const { confirm } = useConfirm()
|
|
75
|
+
|
|
76
|
+
// El detalle/acciones del bloque seleccionado viven en el panel derecho.
|
|
77
|
+
const sidebar = useRightSidebar()
|
|
78
|
+
sidebar.useRegister(L.panelTitle)
|
|
79
|
+
|
|
80
|
+
interface BlockTypeSchema {
|
|
81
|
+
key: string
|
|
82
|
+
name: string
|
|
83
|
+
icon: string
|
|
84
|
+
category: string
|
|
85
|
+
fields: FieldSchema[]
|
|
86
|
+
common: FieldSchema[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface BlockRow {
|
|
90
|
+
id: number
|
|
91
|
+
type: string
|
|
92
|
+
order: number
|
|
93
|
+
settings: Record<string, unknown>
|
|
94
|
+
is_printable: boolean
|
|
95
|
+
is_indexable: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const types = ref<BlockTypeSchema[]>([])
|
|
99
|
+
const blocks = ref<BlockRow[]>([])
|
|
100
|
+
const selectedId = ref<number | null>(null)
|
|
101
|
+
const selected = computed(() => blocks.value.find((b) => b.id === selectedId.value) ?? null)
|
|
102
|
+
|
|
103
|
+
/** Toda la fila selecciona, salvo sus controles interiores y el grip. */
|
|
104
|
+
function selectBlock(block: BlockRow, event: MouseEvent) {
|
|
105
|
+
const target = event.target as HTMLElement | null
|
|
106
|
+
if (target?.closest('button, a, input, label, .page-blocks__grip')) return
|
|
107
|
+
selectedId.value = block.id
|
|
108
|
+
sidebar.reveal()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Acción rápida del panel: alterna un flag sin abrir el modal. */
|
|
112
|
+
async function toggleFlag(flag: 'is_printable' | 'is_indexable', value: boolean) {
|
|
113
|
+
if (!selected.value) return
|
|
114
|
+
try {
|
|
115
|
+
await props.api.put(`/admin/blocks/${selected.value.id}`, { [flag]: value })
|
|
116
|
+
selected.value[flag] = value
|
|
117
|
+
} catch {
|
|
118
|
+
toast.danger(L.error)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const busy = ref(false)
|
|
122
|
+
const paletteOpen = ref(false)
|
|
123
|
+
|
|
124
|
+
// Modal de edición (crear/editar comparten formulario).
|
|
125
|
+
const modalOpen = ref(false)
|
|
126
|
+
const editing = ref<BlockRow | null>(null)
|
|
127
|
+
const modalType = ref<BlockTypeSchema | null>(null)
|
|
128
|
+
const form = ref<Record<string, unknown>>({})
|
|
129
|
+
const formPrintable = ref(true)
|
|
130
|
+
const formIndexable = ref(true)
|
|
131
|
+
|
|
132
|
+
function typeName(key: string): string {
|
|
133
|
+
const type = types.value.find((t) => t.key === key)
|
|
134
|
+
const fallback = type?.name ?? key
|
|
135
|
+
return props.translate?.(`blockTypes.${key}`, fallback) ?? fallback
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Resumen del bloque en la lista: el primer texto traducible con valor. */
|
|
139
|
+
function summary(block: BlockRow): string {
|
|
140
|
+
const type = types.value.find((t) => t.key === block.type)
|
|
141
|
+
for (const field of type?.fields ?? []) {
|
|
142
|
+
if (!['text', 'textarea', 'richtext'].includes(field.type)) continue
|
|
143
|
+
const value = block.settings?.[field.key]
|
|
144
|
+
if (field.translatable && value && typeof value === 'object') {
|
|
145
|
+
const text = Object.values(value as Record<string, string>).find(Boolean)
|
|
146
|
+
if (text) return text.replace(/<[^>]*>/g, '').slice(0, 80)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return ''
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** URL de un campo imagen (traducible o no): la primera con valor. */
|
|
153
|
+
function imageUrl(field: FieldSchema, block: BlockRow): string {
|
|
154
|
+
const raw = block.settings?.[field.key]
|
|
155
|
+
if (raw && typeof raw === 'object') {
|
|
156
|
+
return Object.values(raw as Record<string, string>).find(Boolean) ?? ''
|
|
157
|
+
}
|
|
158
|
+
return raw ? String(raw) : ''
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Etiqueta de un campo del esquema (misma convención que SchemaFields). */
|
|
162
|
+
function fieldLabel(field: FieldSchema): string {
|
|
163
|
+
return props.translate?.(`blockFields.${field.key}`, field.label) ?? field.label
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Valor legible de un campo para el panel: texto plano, truncado por CSS. */
|
|
167
|
+
function fieldValue(field: FieldSchema, block: BlockRow): string {
|
|
168
|
+
const raw = block.settings?.[field.key]
|
|
169
|
+
if (raw === null || raw === undefined || raw === '') return ''
|
|
170
|
+
if (field.translatable && typeof raw === 'object') {
|
|
171
|
+
const text = Object.values(raw as Record<string, string>).find(Boolean) ?? ''
|
|
172
|
+
return text
|
|
173
|
+
.replace(/<[^>]*>/g, ' ')
|
|
174
|
+
.replace(/\s+/g, ' ')
|
|
175
|
+
.trim()
|
|
176
|
+
}
|
|
177
|
+
if (field.type === 'boolean') return raw ? '✓' : '✗'
|
|
178
|
+
if (field.type === 'select' && field.options) {
|
|
179
|
+
const text = field.options[String(raw)] ?? String(raw)
|
|
180
|
+
return props.translate?.(`blockOptions.${field.key}.${String(raw)}`, text) ?? text
|
|
181
|
+
}
|
|
182
|
+
// Anidados del DSL: un resumen, no el volcado del objeto.
|
|
183
|
+
if (field.type === 'repeater' && Array.isArray(raw)) {
|
|
184
|
+
return `× ${raw.length}`
|
|
185
|
+
}
|
|
186
|
+
if (field.type === 'group' && typeof raw === 'object') {
|
|
187
|
+
return `{ ${Object.keys(raw as object).join(', ')} }`
|
|
188
|
+
}
|
|
189
|
+
if (field.type === 'entity') return `#${String(raw)}`
|
|
190
|
+
return String(raw)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Campos del bloque seleccionado con valor (la imagen se pinta aparte). */
|
|
194
|
+
const selectedFields = computed(() => {
|
|
195
|
+
if (!selected.value) return []
|
|
196
|
+
const type = types.value.find((t) => t.key === selected.value?.type)
|
|
197
|
+
return (type?.fields ?? [])
|
|
198
|
+
.map((field) => ({
|
|
199
|
+
field,
|
|
200
|
+
value:
|
|
201
|
+
field.type === 'image'
|
|
202
|
+
? imageUrl(field, selected.value!)
|
|
203
|
+
: fieldValue(field, selected.value!),
|
|
204
|
+
}))
|
|
205
|
+
.filter((entry) => entry.value)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
async function load() {
|
|
209
|
+
try {
|
|
210
|
+
const [palette, list] = await Promise.all([
|
|
211
|
+
props.api.get('/admin/block-types'),
|
|
212
|
+
props.api.get(`/admin/pages/${props.pageId}/blocks`),
|
|
213
|
+
])
|
|
214
|
+
types.value = palette.data.data
|
|
215
|
+
blocks.value = list.data.data
|
|
216
|
+
} catch {
|
|
217
|
+
toast.danger(L.error)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function openCreate(type: BlockTypeSchema) {
|
|
222
|
+
paletteOpen.value = false
|
|
223
|
+
editing.value = null
|
|
224
|
+
modalType.value = type
|
|
225
|
+
const defaults: Record<string, unknown> = {}
|
|
226
|
+
for (const field of [...type.fields, ...type.common]) {
|
|
227
|
+
if (field.default !== null && field.default !== undefined) defaults[field.key] = field.default
|
|
228
|
+
}
|
|
229
|
+
form.value = defaults
|
|
230
|
+
formPrintable.value = true
|
|
231
|
+
formIndexable.value = true
|
|
232
|
+
modalOpen.value = true
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function openEdit(block: BlockRow) {
|
|
236
|
+
editing.value = block
|
|
237
|
+
modalType.value = types.value.find((t) => t.key === block.type) ?? null
|
|
238
|
+
form.value = { ...(block.settings ?? {}) }
|
|
239
|
+
formPrintable.value = block.is_printable
|
|
240
|
+
formIndexable.value = block.is_indexable
|
|
241
|
+
modalOpen.value = true
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function save() {
|
|
245
|
+
if (!modalType.value) return
|
|
246
|
+
busy.value = true
|
|
247
|
+
try {
|
|
248
|
+
const payload = {
|
|
249
|
+
type: modalType.value.key,
|
|
250
|
+
settings: form.value,
|
|
251
|
+
is_printable: formPrintable.value,
|
|
252
|
+
is_indexable: formIndexable.value,
|
|
253
|
+
}
|
|
254
|
+
if (editing.value) {
|
|
255
|
+
await props.api.put(`/admin/blocks/${editing.value.id}`, payload)
|
|
256
|
+
} else {
|
|
257
|
+
await props.api.post(`/admin/pages/${props.pageId}/blocks`, payload)
|
|
258
|
+
}
|
|
259
|
+
modalOpen.value = false
|
|
260
|
+
await load()
|
|
261
|
+
} catch {
|
|
262
|
+
toast.danger(L.error)
|
|
263
|
+
} finally {
|
|
264
|
+
busy.value = false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function remove(block: BlockRow) {
|
|
269
|
+
const ok = await confirm({
|
|
270
|
+
message: L.confirmDelete,
|
|
271
|
+
confirmLabel: L.delete,
|
|
272
|
+
cancelLabel: L.cancel,
|
|
273
|
+
variant: 'danger',
|
|
274
|
+
})
|
|
275
|
+
if (!ok) return
|
|
276
|
+
try {
|
|
277
|
+
await props.api.delete(`/admin/blocks/${block.id}`)
|
|
278
|
+
if (selectedId.value === block.id) selectedId.value = null
|
|
279
|
+
await load()
|
|
280
|
+
} catch {
|
|
281
|
+
toast.danger(L.error)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** El drag reordena en cliente; se persiste la lista de ids. */
|
|
286
|
+
async function persistOrder() {
|
|
287
|
+
try {
|
|
288
|
+
await props.api.post(`/admin/pages/${props.pageId}/blocks/reorder`, {
|
|
289
|
+
ids: blocks.value.map((b) => b.id),
|
|
290
|
+
})
|
|
291
|
+
} catch {
|
|
292
|
+
toast.danger(L.error)
|
|
293
|
+
await load()
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const presentation = computed(() => types.value.filter((t) => t.category === 'presentation'))
|
|
298
|
+
const dataTypes = computed(() => types.value.filter((t) => t.category !== 'presentation'))
|
|
299
|
+
|
|
300
|
+
onMounted(load)
|
|
301
|
+
defineExpose({ reload: load })
|
|
302
|
+
</script>
|
|
303
|
+
|
|
304
|
+
<template>
|
|
305
|
+
<div class="page-blocks">
|
|
306
|
+
<div class="page-blocks__bar">
|
|
307
|
+
<div class="page-blocks__palette">
|
|
308
|
+
<BaseButton :disabled="busy" @click="paletteOpen = !paletteOpen">
|
|
309
|
+
<template #icon><Plus :size="16" /></template>
|
|
310
|
+
{{ L.add }}
|
|
311
|
+
</BaseButton>
|
|
312
|
+
<div v-if="paletteOpen" class="page-blocks__menu">
|
|
313
|
+
<button
|
|
314
|
+
v-for="type in [...presentation, ...dataTypes]"
|
|
315
|
+
:key="type.key"
|
|
316
|
+
type="button"
|
|
317
|
+
class="page-blocks__menu-item"
|
|
318
|
+
:class="{ 'is-data': type.category !== 'presentation' }"
|
|
319
|
+
@click="openCreate(type)"
|
|
320
|
+
>
|
|
321
|
+
{{ typeName(type.key) }}
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<p v-if="!blocks.length" class="page-blocks__empty">{{ L.empty }}</p>
|
|
328
|
+
|
|
329
|
+
<VueDraggable
|
|
330
|
+
v-model="blocks"
|
|
331
|
+
class="page-blocks__list"
|
|
332
|
+
handle=".page-blocks__grip"
|
|
333
|
+
:animation="150"
|
|
334
|
+
@end="persistOrder"
|
|
335
|
+
>
|
|
336
|
+
<article
|
|
337
|
+
v-for="block in blocks"
|
|
338
|
+
:key="block.id"
|
|
339
|
+
class="page-blocks__item"
|
|
340
|
+
:class="{ 'is-active': selectedId === block.id }"
|
|
341
|
+
@click="(e) => selectBlock(block, e)"
|
|
342
|
+
>
|
|
343
|
+
<span class="page-blocks__grip"><GripVertical :size="16" /></span>
|
|
344
|
+
<span class="page-blocks__type">{{ typeName(block.type) }}</span>
|
|
345
|
+
<span class="page-blocks__summary">{{ summary(block) }}</span>
|
|
346
|
+
<span class="page-blocks__flags">
|
|
347
|
+
<span v-if="block.is_printable" class="chip is-ok">PDF</span>
|
|
348
|
+
<span v-if="block.is_indexable" class="chip">IDX</span>
|
|
349
|
+
</span>
|
|
350
|
+
</article>
|
|
351
|
+
</VueDraggable>
|
|
352
|
+
|
|
353
|
+
<!-- Modal generado desde el esquema del tipo -->
|
|
354
|
+
<EditModal
|
|
355
|
+
v-model="modalOpen"
|
|
356
|
+
:title="modalType ? typeName(modalType.key) : ''"
|
|
357
|
+
:submit-label="L.save"
|
|
358
|
+
:cancel-label="L.cancel"
|
|
359
|
+
:loading="busy"
|
|
360
|
+
@submit="save"
|
|
361
|
+
>
|
|
362
|
+
<template v-if="modalType">
|
|
363
|
+
<SchemaFields
|
|
364
|
+
v-model="form"
|
|
365
|
+
:fields="modalType.fields"
|
|
366
|
+
:locales="locales"
|
|
367
|
+
:api="api"
|
|
368
|
+
:icons="icons"
|
|
369
|
+
:rich-labels="richLabels"
|
|
370
|
+
:translate="translate"
|
|
371
|
+
/>
|
|
372
|
+
|
|
373
|
+
<details class="page-blocks__common">
|
|
374
|
+
<summary>{{ L.common }}</summary>
|
|
375
|
+
<SchemaFields
|
|
376
|
+
v-model="form"
|
|
377
|
+
:fields="modalType.common"
|
|
378
|
+
:locales="locales"
|
|
379
|
+
:api="api"
|
|
380
|
+
:translate="translate"
|
|
381
|
+
/>
|
|
382
|
+
<BaseCheckbox v-model="formPrintable" :label="L.printable" />
|
|
383
|
+
<BaseCheckbox v-model="formIndexable" :label="L.indexable" />
|
|
384
|
+
</details>
|
|
385
|
+
</template>
|
|
386
|
+
</EditModal>
|
|
387
|
+
|
|
388
|
+
<!-- Acciones del bloque seleccionado, en el panel derecho (patrón kontuan) -->
|
|
389
|
+
<Teleport defer to="#right-sidebar-target">
|
|
390
|
+
<div class="manager-panel">
|
|
391
|
+
<p v-if="!selected" class="manager-panel__empty">{{ L.panelEmpty }}</p>
|
|
392
|
+
<template v-else>
|
|
393
|
+
<p class="manager-panel__kicker">{{ typeName(selected.type) }}</p>
|
|
394
|
+
|
|
395
|
+
<!-- Acciones PRIMERO; después, secciones separadas (patrón panel) -->
|
|
396
|
+
<div class="manager-detail__actions">
|
|
397
|
+
<BaseButton :disabled="busy" @click="openEdit(selected)">
|
|
398
|
+
<template #icon><SquarePen :size="14" /></template>
|
|
399
|
+
{{ L.edit }}
|
|
400
|
+
</BaseButton>
|
|
401
|
+
<BaseButton variant="danger" :disabled="busy" @click="remove(selected)">
|
|
402
|
+
<template #icon><Trash2 :size="14" /></template>
|
|
403
|
+
{{ L.delete }}
|
|
404
|
+
</BaseButton>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<hr class="manager-panel__divider" />
|
|
408
|
+
|
|
409
|
+
<h3 class="manager-detail__title">{{ summary(selected) || typeName(selected.type) }}</h3>
|
|
410
|
+
|
|
411
|
+
<!-- Acciones rápidas sin modal -->
|
|
412
|
+
<BaseCheckbox
|
|
413
|
+
:model-value="selected.is_printable"
|
|
414
|
+
:label="L.printable"
|
|
415
|
+
@update:model-value="(v) => toggleFlag('is_printable', v)"
|
|
416
|
+
/>
|
|
417
|
+
<BaseCheckbox
|
|
418
|
+
:model-value="selected.is_indexable"
|
|
419
|
+
:label="L.indexable"
|
|
420
|
+
@update:model-value="(v) => toggleFlag('is_indexable', v)"
|
|
421
|
+
/>
|
|
422
|
+
|
|
423
|
+
<!-- Contenido: cada campo del bloque con su valor (truncado) -->
|
|
424
|
+
<hr v-if="selectedFields.length" class="manager-panel__divider" />
|
|
425
|
+
<div v-if="selectedFields.length" class="manager-detail">
|
|
426
|
+
<p class="manager-panel__kicker">{{ L.panelContent }}</p>
|
|
427
|
+
<dl class="manager-detail__fields">
|
|
428
|
+
<div
|
|
429
|
+
v-for="entry in selectedFields"
|
|
430
|
+
:key="entry.field.key"
|
|
431
|
+
class="manager-detail__field"
|
|
432
|
+
>
|
|
433
|
+
<dt>{{ fieldLabel(entry.field) }}</dt>
|
|
434
|
+
<dd v-if="entry.field.type === 'image'">
|
|
435
|
+
<img
|
|
436
|
+
:src="entry.value"
|
|
437
|
+
:alt="fieldLabel(entry.field)"
|
|
438
|
+
class="manager-detail__thumb"
|
|
439
|
+
/>
|
|
440
|
+
</dd>
|
|
441
|
+
<dd v-else>{{ entry.value }}</dd>
|
|
442
|
+
</div>
|
|
443
|
+
</dl>
|
|
444
|
+
</div>
|
|
445
|
+
</template>
|
|
446
|
+
</div>
|
|
447
|
+
</Teleport>
|
|
448
|
+
</div>
|
|
449
|
+
</template>
|