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