@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,308 @@
1
+ <script setup lang="ts">
2
+ import type { AxiosInstance } from 'axios'
3
+ import { ArrowDown, ArrowUp, Plus, X } from '@lucide/vue'
4
+ import {
5
+ BaseButton,
6
+ BaseCheckbox,
7
+ BaseInput,
8
+ BaseSelect,
9
+ BaseTextarea,
10
+ IconButton,
11
+ ImageUpload,
12
+ NumericInput,
13
+ PaletteColorPicker,
14
+ RichTextInput,
15
+ TranslatableImage,
16
+ TranslatableInput,
17
+ type RichIcon,
18
+ type RichTextLabels,
19
+ type SelectOption,
20
+ } from '@edc-motor/ui'
21
+ import EntityRefSelect from './EntityRefSelect.vue'
22
+
23
+ // Renderer del DSL de campos (DC-08): pinta el formulario de un bloque a
24
+ // partir del esquema serializado del BlockType (GET /admin/block-types).
25
+ // Añadir un tipo de bloque NO toca este componente. Los tipos anidados
26
+ // (group y repeater) se pintan con RECURSIÓN sobre sí mismo; entity con un
27
+ // buscador alimentado por el endpoint de opciones del juego.
28
+ // Agnóstico de i18n (DC-29): etiquetas del esquema (castellano) con gancho
29
+ // `translate` para que el juego las localice por convención.
30
+
31
+ export interface FieldSchema {
32
+ key: string
33
+ type: string
34
+ label: string
35
+ translatable: boolean
36
+ required: boolean
37
+ default: unknown
38
+ options: Record<string, string> | null
39
+ min: number | null
40
+ max: number | null
41
+ /** Subcampos de group/repeater. */
42
+ fields?: FieldSchema[] | null
43
+ /** Para entity: endpoint de opciones del admin. */
44
+ options_url?: string | null
45
+ }
46
+
47
+ const props = withDefaults(
48
+ defineProps<{
49
+ fields: FieldSchema[]
50
+ modelValue: Record<string, unknown>
51
+ locales: { code: string; name: string }[]
52
+ /** Cliente de la API del admin (sube las imágenes de contenido). */
53
+ api: AxiosInstance
54
+ /** Iconos del juego para el WYSIWYG. */
55
+ icons?: RichIcon[]
56
+ richLabels?: Partial<RichTextLabels>
57
+ /** Localiza etiquetas: (clave, fallback) => texto. */
58
+ translate?: (key: string, fallback: string) => string
59
+ }>(),
60
+ { icons: () => [], richLabels: () => ({}), translate: undefined },
61
+ )
62
+
63
+ const emit = defineEmits<{ 'update:modelValue': [value: Record<string, unknown>] }>()
64
+
65
+ function label(field: FieldSchema): string {
66
+ return props.translate?.(`blockFields.${field.key}`, field.label) ?? field.label
67
+ }
68
+
69
+ function set(key: string, value: unknown) {
70
+ emit('update:modelValue', { ...props.modelValue, [key]: value })
71
+ }
72
+
73
+ function translations(field: FieldSchema): Record<string, string> {
74
+ const value = props.modelValue[field.key]
75
+ // Guarda ante datos antiguos guardados como cadena única (no traducible).
76
+ if (!value || typeof value !== 'object') return {}
77
+ return value as Record<string, string>
78
+ }
79
+
80
+ function selectOptions(field: FieldSchema): SelectOption[] {
81
+ return Object.entries(field.options ?? {}).map(([value, text]) => ({
82
+ value,
83
+ label: props.translate?.(`blockOptions.${field.key}.${value}`, text) ?? text,
84
+ }))
85
+ }
86
+
87
+ /** Sube la imagen al momento; en settings queda la URL pública. */
88
+ async function upload(file: File): Promise<string> {
89
+ const form = new FormData()
90
+ form.append('image', file)
91
+ const { data } = await props.api.post('/admin/content/uploads', form)
92
+ return data.url
93
+ }
94
+
95
+ async function uploadImage(field: FieldSchema, file: File | null) {
96
+ set(field.key, file ? await upload(file) : null)
97
+ }
98
+
99
+ // --- Anidados (group / repeater) ---
100
+
101
+ function groupValue(field: FieldSchema): Record<string, unknown> {
102
+ const value = props.modelValue[field.key]
103
+ return value && typeof value === 'object' && !Array.isArray(value)
104
+ ? (value as Record<string, unknown>)
105
+ : {}
106
+ }
107
+
108
+ function rows(field: FieldSchema): Record<string, unknown>[] {
109
+ const value = props.modelValue[field.key]
110
+ return Array.isArray(value) ? (value as Record<string, unknown>[]) : []
111
+ }
112
+
113
+ function setRow(field: FieldSchema, index: number, row: Record<string, unknown>) {
114
+ const list = [...rows(field)]
115
+ list[index] = row
116
+ set(field.key, list)
117
+ }
118
+
119
+ function addRow(field: FieldSchema) {
120
+ set(field.key, [...rows(field), {}])
121
+ }
122
+
123
+ function removeRow(field: FieldSchema, index: number) {
124
+ set(
125
+ field.key,
126
+ rows(field).filter((_, i) => i !== index),
127
+ )
128
+ }
129
+
130
+ function moveRow(field: FieldSchema, index: number, delta: number) {
131
+ const list = [...rows(field)]
132
+ const target = index + delta
133
+ if (target < 0 || target >= list.length) return
134
+ ;[list[index], list[target]] = [list[target], list[index]]
135
+ set(field.key, list)
136
+ }
137
+
138
+ function addLabel(): string {
139
+ return props.translate?.('blockEditor.addRow', 'Añadir') ?? 'Añadir'
140
+ }
141
+ </script>
142
+
143
+ <template>
144
+ <div class="schema-fields">
145
+ <template v-for="field in fields" :key="field.key">
146
+ <!-- Traducibles: text / textarea / richtext van al TranslatableInput -->
147
+ <TranslatableInput
148
+ v-if="field.translatable && ['text', 'textarea', 'richtext'].includes(field.type)"
149
+ :model-value="translations(field)"
150
+ :locales="locales"
151
+ :label="label(field)"
152
+ :required="field.required"
153
+ :type="
154
+ field.type === 'richtext' ? 'wysiwyg' : field.type === 'textarea' ? 'textarea' : 'text'
155
+ "
156
+ :icons="icons"
157
+ :rich-labels="richLabels"
158
+ @update:model-value="(v) => set(field.key, v)"
159
+ />
160
+
161
+ <BaseInput
162
+ v-else-if="field.type === 'text'"
163
+ :model-value="(modelValue[field.key] as string) ?? ''"
164
+ :label="label(field)"
165
+ :required="field.required"
166
+ @update:model-value="(v) => set(field.key, v)"
167
+ />
168
+
169
+ <BaseTextarea
170
+ v-else-if="field.type === 'textarea'"
171
+ :model-value="(modelValue[field.key] as string) ?? ''"
172
+ :label="label(field)"
173
+ @update:model-value="(v: string) => set(field.key, v)"
174
+ />
175
+
176
+ <RichTextInput
177
+ v-else-if="field.type === 'richtext'"
178
+ :model-value="(modelValue[field.key] as string) ?? ''"
179
+ :icons="icons"
180
+ :labels="richLabels"
181
+ @update:model-value="(v: string) => set(field.key, v)"
182
+ />
183
+
184
+ <NumericInput
185
+ v-else-if="field.type === 'number'"
186
+ :model-value="(modelValue[field.key] as number) ?? (field.default as number) ?? 0"
187
+ :label="label(field)"
188
+ :min="field.min ?? 0"
189
+ :max="field.max ?? undefined"
190
+ @update:model-value="(v) => set(field.key, v)"
191
+ />
192
+
193
+ <BaseCheckbox
194
+ v-else-if="field.type === 'boolean'"
195
+ :model-value="Boolean(modelValue[field.key] ?? field.default)"
196
+ :label="label(field)"
197
+ @update:model-value="(v) => set(field.key, v)"
198
+ />
199
+
200
+ <BaseSelect
201
+ v-else-if="field.type === 'select'"
202
+ :model-value="(modelValue[field.key] as string) ?? (field.default as string) ?? ''"
203
+ :label="label(field)"
204
+ :options="selectOptions(field)"
205
+ @update:model-value="(v) => set(field.key, v)"
206
+ />
207
+
208
+ <div v-else-if="field.type === 'color'" class="schema-fields__color">
209
+ <span class="form-field__label">{{ label(field) }}</span>
210
+ <PaletteColorPicker
211
+ :model-value="(modelValue[field.key] as string) ?? null"
212
+ @update:model-value="(v) => set(field.key, v)"
213
+ />
214
+ </div>
215
+
216
+ <!-- Grupo: objeto {subclave: valor} con los subcampos anidados -->
217
+ <fieldset v-else-if="field.type === 'group'" class="schema-fields__group">
218
+ <legend class="form-field__label">{{ label(field) }}</legend>
219
+ <SchemaFields
220
+ :fields="field.fields ?? []"
221
+ :model-value="groupValue(field)"
222
+ :locales="locales"
223
+ :api="api"
224
+ :icons="icons"
225
+ :rich-labels="richLabels"
226
+ :translate="translate"
227
+ @update:model-value="(v) => set(field.key, v)"
228
+ />
229
+ </fieldset>
230
+
231
+ <!-- Repeater: filas con los mismos subcampos (añadir/quitar/reordenar) -->
232
+ <div v-else-if="field.type === 'repeater'" class="schema-fields__repeater">
233
+ <span class="form-field__label">{{ label(field) }}</span>
234
+ <fieldset v-for="(row, index) in rows(field)" :key="index" class="schema-fields__row">
235
+ <div class="schema-fields__row-bar">
236
+ <span class="schema-fields__row-index">{{ index + 1 }}</span>
237
+ <IconButton
238
+ v-if="index > 0"
239
+ variant="neutral"
240
+ :title="'↑'"
241
+ @click="moveRow(field, index, -1)"
242
+ ><ArrowUp :size="14"
243
+ /></IconButton>
244
+ <IconButton
245
+ v-if="index < rows(field).length - 1"
246
+ variant="neutral"
247
+ :title="'↓'"
248
+ @click="moveRow(field, index, 1)"
249
+ ><ArrowDown :size="14"
250
+ /></IconButton>
251
+ <IconButton variant="danger" :title="'×'" @click="removeRow(field, index)"
252
+ ><X :size="14"
253
+ /></IconButton>
254
+ </div>
255
+ <SchemaFields
256
+ :fields="field.fields ?? []"
257
+ :model-value="row"
258
+ :locales="locales"
259
+ :api="api"
260
+ :icons="icons"
261
+ :rich-labels="richLabels"
262
+ :translate="translate"
263
+ @update:model-value="(v) => setRow(field, index, v)"
264
+ />
265
+ </fieldset>
266
+ <BaseButton
267
+ v-if="field.max === null || rows(field).length < field.max"
268
+ variant="text"
269
+ @click="addRow(field)"
270
+ >
271
+ <template #icon><Plus :size="14" /></template>
272
+ {{ addLabel() }}
273
+ </BaseButton>
274
+ </div>
275
+
276
+ <!-- Referencia a una entidad del juego: buscador sobre su endpoint -->
277
+ <EntityRefSelect
278
+ v-else-if="field.type === 'entity' && field.options_url"
279
+ :model-value="(modelValue[field.key] as number) ?? null"
280
+ :label="label(field)"
281
+ :options-url="field.options_url"
282
+ :api="api"
283
+ @update:model-value="(v) => set(field.key, v)"
284
+ />
285
+
286
+ <!-- Imagen traducible: una URL por locale (fallback al default al renderizar) -->
287
+ <TranslatableImage
288
+ v-else-if="field.type === 'image' && field.translatable"
289
+ :model-value="translations(field)"
290
+ :locales="locales"
291
+ :label="label(field)"
292
+ :required="field.required"
293
+ :upload="upload"
294
+ @update:model-value="(v) => set(field.key, v)"
295
+ />
296
+
297
+ <div v-else-if="field.type === 'image'" class="schema-fields__image">
298
+ <span class="form-field__label">{{ label(field) }}</span>
299
+ <ImageUpload
300
+ :model-value="null"
301
+ :current-url="(modelValue[field.key] as string) ?? null"
302
+ @update:model-value="(f: File | null) => uploadImage(field, f)"
303
+ @remove="set(field.key, null)"
304
+ />
305
+ </div>
306
+ </template>
307
+ </div>
308
+ </template>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ // Grid responsive de tarjetas (portado de kontuan). Responde al ancho del
5
+ // contenedor `content` (no al viewport), así coincide con el espacio real.
6
+ type Breakpoint = 'base' | 'sm' | 'md' | 'lg'
7
+ type ResponsiveCols = Partial<Record<Breakpoint, number>>
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ cols?: number | ResponsiveCols
12
+ gap?: 'sm' | 'md' | 'lg'
13
+ preset?: 'cards' | 'cards-wide' | 'cards-narrow' | 'cards-full' | 'halves' | 'thirds'
14
+ }>(),
15
+ { cols: () => ({ base: 1, sm: 2, lg: 3 }), gap: 'md' },
16
+ )
17
+
18
+ const presetCols: Record<string, ResponsiveCols> = {
19
+ cards: { base: 1, sm: 2, lg: 3 },
20
+ 'cards-wide': { base: 1, sm: 2, lg: 4 },
21
+ 'cards-narrow': { base: 1, sm: 2 },
22
+ 'cards-full': { base: 1, sm: 2, md: 3, lg: 4 },
23
+ halves: { base: 1, md: 2 },
24
+ thirds: { base: 1, sm: 2, md: 3 },
25
+ }
26
+
27
+ const gridClasses = computed(() => {
28
+ const classes = ['grid', `grid--gap-${props.gap}`]
29
+ const cols = props.preset ? presetCols[props.preset] : props.cols
30
+ if (typeof cols === 'number') classes.push(`grid--base-${cols}`)
31
+ else for (const [bp, n] of Object.entries(cols ?? {})) classes.push(`grid--${bp}-${n}`)
32
+ return classes
33
+ })
34
+ </script>
35
+
36
+ <template>
37
+ <div :class="gridClasses"><slot /></div>
38
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup lang="ts">
2
+ // Estado vacío de un listado (portado de kontuan).
3
+ defineProps<{ title: string; description?: string }>()
4
+ </script>
5
+
6
+ <template>
7
+ <div class="empty-state">
8
+ <div v-if="$slots.icon" class="empty-state__icon"><slot name="icon" /></div>
9
+ <h3 class="empty-state__title">{{ title }}</h3>
10
+ <p v-if="description" class="empty-state__description">{{ description }}</p>
11
+ <div v-if="$slots.action" class="empty-state__action"><slot name="action" /></div>
12
+ </div>
13
+ </template>
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ // Tarjeta de entidad para los listados (index). Mezcla kontuan + CDL:
3
+ // - kontuan: contenedor con borde/sombra, hover al accent, slots.
4
+ // - CDL: cabecera con título + acciones y divisoria, zona de contenido con
5
+ // "badges" (chips de estado) y "meta" (datos secundarios).
6
+ // Opcionalmente una franja "media" arriba (p. ej. el emblema de la casa).
7
+ defineProps<{
8
+ title: string
9
+ clickable?: boolean
10
+ muted?: boolean
11
+ /** Marca la tarjeta como seleccionada (panel derecho). */
12
+ active?: boolean
13
+ }>()
14
+
15
+ defineEmits<{ view: [] }>()
16
+
17
+ defineSlots<{
18
+ media?: () => unknown
19
+ actions?: () => unknown
20
+ badges?: () => unknown
21
+ meta?: () => unknown
22
+ default?: () => unknown
23
+ }>()
24
+ </script>
25
+
26
+ <template>
27
+ <div
28
+ class="entity-card"
29
+ :class="{
30
+ 'entity-card--clickable': clickable,
31
+ 'entity-card--muted': muted,
32
+ 'is-active': active,
33
+ }"
34
+ @click="clickable ? $emit('view') : undefined"
35
+ >
36
+ <div v-if="$slots.media" class="entity-card__media"><slot name="media" /></div>
37
+
38
+ <div class="entity-card__header">
39
+ <h3 class="entity-card__title">{{ title }}</h3>
40
+ <div v-if="$slots.actions" class="entity-card__actions" @click.stop>
41
+ <slot name="actions" />
42
+ </div>
43
+ </div>
44
+
45
+ <div v-if="$slots.badges || $slots.meta || $slots.default" class="entity-card__content">
46
+ <div v-if="$slots.badges" class="entity-card__badges"><slot name="badges" /></div>
47
+ <div v-if="$slots.meta" class="entity-card__meta"><slot name="meta" /></div>
48
+ <slot />
49
+ </div>
50
+ </div>
51
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { Search } from '@lucide/vue'
3
+
4
+ // Barra de filtros (estilo kontuan): caja de búsqueda con icono de lupa.
5
+ // Controlada por v-model; se compone por encima de las tabs. Slot por defecto
6
+ // para filtros extra (selects, fechas, …) a la derecha de la búsqueda.
7
+ defineProps<{
8
+ modelValue: string
9
+ placeholder?: string
10
+ }>()
11
+ defineEmits<{ 'update:modelValue': [value: string] }>()
12
+ </script>
13
+
14
+ <template>
15
+ <div class="filter-bar">
16
+ <div class="filter-bar__search">
17
+ <input
18
+ type="search"
19
+ :value="modelValue"
20
+ :placeholder="placeholder"
21
+ class="filter-bar__search-input"
22
+ @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
23
+ />
24
+ <Search :size="16" class="filter-bar__search-icon" />
25
+ </div>
26
+ <div v-if="$slots.default" class="filter-bar__extra"><slot /></div>
27
+ </div>
28
+ </template>
@@ -0,0 +1,83 @@
1
+ import { ref } from 'vue'
2
+ import type { AxiosInstance } from 'axios'
3
+
4
+ /** Meta de paginación que devuelve la API ({ data, meta }). */
5
+ export interface ResourceMeta {
6
+ current_page?: number
7
+ last_page?: number
8
+ per_page?: number
9
+ total?: number
10
+ }
11
+
12
+ /**
13
+ * Composable de CRUD genérico sobre la API REST del motor. Cada juego lo usa
14
+ * para sus entidades sin reescribir el ir-y-venir con axios.
15
+ */
16
+ export function useResource<T = unknown>(api: AxiosInstance, basePath: string) {
17
+ const items = ref<T[]>([])
18
+ const meta = ref<ResourceMeta | null>(null)
19
+ const loading = ref(false)
20
+
21
+ async function list(params: Record<string, unknown> = {}) {
22
+ loading.value = true
23
+ try {
24
+ const { data } = await api.get(basePath, { params })
25
+ items.value = data.data
26
+ meta.value = data.meta ?? null
27
+ } finally {
28
+ loading.value = false
29
+ }
30
+ }
31
+
32
+ async function find(id: number | string): Promise<T> {
33
+ const { data } = await api.get(`${basePath}/${id}`)
34
+ return data.data
35
+ }
36
+
37
+ async function create(payload: Record<string, unknown>): Promise<T> {
38
+ const { data } = await api.post(basePath, payload)
39
+ return data.data
40
+ }
41
+
42
+ async function update(id: number | string, payload: Record<string, unknown>): Promise<T> {
43
+ const { data } = await api.put(`${basePath}/${id}`, payload)
44
+ return data.data
45
+ }
46
+
47
+ async function remove(id: number | string): Promise<void> {
48
+ await api.delete(`${basePath}/${id}`)
49
+ }
50
+
51
+ /** Alta con multipart (FormData) — para subir ficheros. */
52
+ async function createForm(form: FormData): Promise<T> {
53
+ const { data } = await api.post(basePath, form)
54
+ return data.data
55
+ }
56
+
57
+ /** Edición con multipart. PHP no parsea multipart en PUT: se usa POST + _method. */
58
+ async function updateForm(id: number | string, form: FormData): Promise<T> {
59
+ form.append('_method', 'PUT')
60
+ const { data } = await api.post(`${basePath}/${id}`, form)
61
+ return data.data
62
+ }
63
+
64
+ /** Acciones POST extra: /{id}/{verb} (toggle-published, restore, …). */
65
+ async function action(id: number | string, verb: string): Promise<T> {
66
+ const { data } = await api.post(`${basePath}/${id}/${verb}`)
67
+ return data.data
68
+ }
69
+
70
+ return {
71
+ items,
72
+ meta,
73
+ loading,
74
+ list,
75
+ find,
76
+ create,
77
+ update,
78
+ createForm,
79
+ updateForm,
80
+ remove,
81
+ action,
82
+ }
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ // @edc-motor/admin-kit — layout del panel + scaffolding CRUD (sobre @edc-motor/ui).
2
+
3
+ export { default as AdminLayout } from './layout/AdminLayout.vue'
4
+ // Panel derecho contextual (patrón kontuan): AdminLayout lo monta; cada vista
5
+ // lo activa con useRightSidebar() y Teleport a #right-sidebar-target.
6
+ export { default as RightSidebar } from './layout/RightSidebar.vue'
7
+ export { useRightSidebar, type RightSidebarToken } from './composables/useRightSidebar'
8
+ // Tarjeta colapsable de los gestores (previews/PDF), reutilizable.
9
+ export { default as ManagerCard } from './components/ManagerCard.vue'
10
+ // Index de entidades: grid de tarjetas (sin tablas) + filtros + estado vacío.
11
+ export { default as BaseGrid } from './crud/BaseGrid.vue'
12
+ export { default as EntityCard } from './crud/EntityCard.vue'
13
+ export { default as FilterBar } from './crud/FilterBar.vue'
14
+ export { default as EmptyState } from './crud/EmptyState.vue'
15
+ export { default as PreviewManager, type PreviewManagerLabels } from './previews/PreviewManager.vue'
16
+ export { default as PdfManager, type PdfManagerLabels } from './pdf/PdfManager.vue'
17
+ export { useResource, type ResourceMeta } from './crud/useResource'
18
+ // CRM de páginas y bloques (doc 03): editor dirigido por esquema.
19
+ export { default as SchemaFields, type FieldSchema } from './content/SchemaFields.vue'
20
+ export { default as PageBlocks, type PageBlocksLabels } from './content/PageBlocks.vue'