@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,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'
|