@plutocms/pluto 0.10.0 → 0.12.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/CHANGELOG.md +27 -0
- package/FEATURES.md +17 -0
- package/README.md +30 -0
- package/app/assets/css/admin-theme.css +207 -0
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/ColorModeButton.vue +4 -2
- package/app/components/Pluto/Content/PlutoContentForm.vue +325 -50
- package/app/components/Pluto/Content/PlutoContentList.vue +192 -108
- package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +114 -0
- package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +103 -0
- package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +70 -0
- package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +6 -1
- package/app/components/Pluto/View/PlutoViewAdminPanel.vue +119 -0
- package/app/components/Pluto/View/PlutoViewAside.vue +79 -0
- package/app/components/Pluto/View/PlutoViewToolbar.vue +64 -0
- package/app/composables/pluto-admin-theme.ts +14 -0
- package/app/composables/pluto-command-palette.ts +59 -0
- package/app/composables/pluto-content-actions.ts +17 -0
- package/app/composables/pluto-content-activity.ts +37 -0
- package/app/composables/pluto-content-stats.ts +78 -0
- package/app/composables/pluto-extension.ts +1 -0
- package/app/composables/pluto-registry.ts +1 -0
- package/app/composables/pluto-view.ts +9 -0
- package/app/layouts/admin.vue +59 -62
- package/app/layouts/auth.vue +4 -2
- package/app/pages/admin/home.vue +34 -7
- package/app/plugins/pluto-core-registrations.ts +34 -0
- package/modules/pluto-css/css-entries.ts +182 -0
- package/modules/pluto-css/index.ts +86 -0
- package/nuxt.config.ts +3 -1
- package/package.json +23 -22
- package/scripts/generate-admin-theme.cjs +96 -0
- package/server/api/_pluto/content/[type]/index.get.ts +4 -1
- package/server/api/_pluto/content-activity/[type].get.ts +15 -0
- package/server/utils/pluto-content-handlers.ts +65 -1
- package/shared/types/content.ts +37 -0
- package/shared/types/registry.ts +30 -0
- package/shared/utils/content-map.ts +15 -0
- package/app/assets/css/tailwind.css +0 -11
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { ButtonProps } from '@nuxt/ui'
|
|
3
|
+
|
|
2
4
|
const props = defineProps<{
|
|
3
5
|
/** A content type's `name` (not a registry entry id) — see `usePlutoContentTypes`. */
|
|
4
6
|
type: string
|
|
@@ -13,7 +15,9 @@ if (!contentType.value) {
|
|
|
13
15
|
// The generated page (`app/pages/admin/content/[type]/{new,[id]}.vue`)
|
|
14
16
|
// already resolves the type and 404s before this component ever mounts,
|
|
15
17
|
// so this should not normally happen.
|
|
16
|
-
console.error(
|
|
18
|
+
console.error(
|
|
19
|
+
`PlutoContentForm: content type "${props.type}" was not found in the registry.`
|
|
20
|
+
)
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
const { can } = usePlutoPermissions()
|
|
@@ -21,11 +25,26 @@ const { can } = usePlutoPermissions()
|
|
|
21
25
|
// "No capability declared" means the operation is open to anyone who can
|
|
22
26
|
// reach this page — never call can('') as a stand-in for that.
|
|
23
27
|
const canWrite = computed(
|
|
24
|
-
() =>
|
|
28
|
+
() =>
|
|
29
|
+
!contentType.value?.capabilities?.write ||
|
|
30
|
+
can(contentType.value.capabilities.write)
|
|
31
|
+
)
|
|
32
|
+
const canPublish = computed(() => {
|
|
33
|
+
const cap =
|
|
34
|
+
contentType.value?.capabilities?.publish ??
|
|
35
|
+
contentType.value?.capabilities?.write
|
|
36
|
+
return !cap || can(cap)
|
|
37
|
+
})
|
|
38
|
+
const canDelete = computed(
|
|
39
|
+
() =>
|
|
40
|
+
!contentType.value?.capabilities?.delete ||
|
|
41
|
+
can(contentType.value.capabilities.delete)
|
|
25
42
|
)
|
|
26
43
|
|
|
27
44
|
const editing = computed(() => props.id !== undefined)
|
|
28
|
-
const basePath = computed(
|
|
45
|
+
const basePath = computed(
|
|
46
|
+
() => contentType.value?.basePath ?? `/admin/content/${props.type}`
|
|
47
|
+
)
|
|
29
48
|
|
|
30
49
|
// editPath exists so an already-deployed layer's edit URL never has to
|
|
31
50
|
// change to adopt this component — see the doc comment on
|
|
@@ -35,12 +54,14 @@ function editPath(id: string | number) {
|
|
|
35
54
|
return contentType.value?.editPath?.(id) ?? `${basePath.value}/${id}`
|
|
36
55
|
}
|
|
37
56
|
|
|
38
|
-
const { item, save } = usePlutoContentItem(props.type, () => props.id)
|
|
57
|
+
const { item, save, remove } = usePlutoContentItem(props.type, () => props.id)
|
|
39
58
|
|
|
40
59
|
function sortedFields(region: 'main' | 'side') {
|
|
41
60
|
return computed(() =>
|
|
42
61
|
(contentType.value?.fields ?? [])
|
|
43
|
-
.filter(
|
|
62
|
+
.filter(
|
|
63
|
+
(field) => field.inForm !== false && (field.region ?? 'main') === region
|
|
64
|
+
)
|
|
44
65
|
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
45
66
|
)
|
|
46
67
|
}
|
|
@@ -63,7 +84,9 @@ const sideFields = sortedFields('side')
|
|
|
63
84
|
// the moment a 'slug' field's own `update:modelValue` fires.
|
|
64
85
|
const slugTouched = ref(false)
|
|
65
86
|
const slugField = computed(() =>
|
|
66
|
-
contentType.value?.fields.find(
|
|
87
|
+
contentType.value?.fields.find(
|
|
88
|
+
(field): field is PlutoSlugField => field.type === 'slug'
|
|
89
|
+
)
|
|
67
90
|
)
|
|
68
91
|
|
|
69
92
|
function slugify(value: string): string {
|
|
@@ -96,13 +119,40 @@ function updateField(field: PlutoField, value: unknown) {
|
|
|
96
119
|
item.value[field.name] = value
|
|
97
120
|
}
|
|
98
121
|
|
|
122
|
+
// --- Status / publish workflow ----------------------------------------------
|
|
123
|
+
|
|
124
|
+
const statusConfig = computed(() => contentType.value?.status || null)
|
|
125
|
+
const publishedValue = computed(
|
|
126
|
+
() => statusConfig.value?.publishedValue ?? 'published'
|
|
127
|
+
)
|
|
128
|
+
const draftValue = computed(
|
|
129
|
+
() =>
|
|
130
|
+
statusConfig.value?.default ??
|
|
131
|
+
statusConfig.value?.values.find(
|
|
132
|
+
(value) => value.value !== publishedValue.value
|
|
133
|
+
)?.value
|
|
134
|
+
)
|
|
135
|
+
const currentStatus = computed(
|
|
136
|
+
() => (item.value.status as string | undefined) ?? statusConfig.value?.default
|
|
137
|
+
)
|
|
138
|
+
const isPublished = computed(
|
|
139
|
+
() =>
|
|
140
|
+
statusConfig.value !== null && currentStatus.value === publishedValue.value
|
|
141
|
+
)
|
|
142
|
+
const statusLabel = computed(
|
|
143
|
+
() =>
|
|
144
|
+
statusConfig.value?.values.find(
|
|
145
|
+
(value) => value.value === currentStatus.value
|
|
146
|
+
)?.label ?? currentStatus.value
|
|
147
|
+
)
|
|
148
|
+
|
|
99
149
|
// --- Save -------------------------------------------------------------------
|
|
100
150
|
|
|
101
151
|
const toast = useToast()
|
|
102
152
|
const saving = ref(false)
|
|
103
153
|
const validationErrors = ref<ContentValidationError[]>([])
|
|
104
154
|
|
|
105
|
-
function buildPayload(): Record<string, unknown> {
|
|
155
|
+
function buildPayload(statusOverride?: string): Record<string, unknown> {
|
|
106
156
|
const type = contentType.value
|
|
107
157
|
if (!type) {
|
|
108
158
|
return {}
|
|
@@ -115,17 +165,23 @@ function buildPayload(): Record<string, unknown> {
|
|
|
115
165
|
}
|
|
116
166
|
}
|
|
117
167
|
|
|
168
|
+
if (statusOverride !== undefined) {
|
|
169
|
+
payload.status = statusOverride
|
|
170
|
+
}
|
|
171
|
+
|
|
118
172
|
return payload
|
|
119
173
|
}
|
|
120
174
|
|
|
121
|
-
async function onSave() {
|
|
175
|
+
async function onSave(statusOverride?: string) {
|
|
122
176
|
const type = contentType.value
|
|
123
177
|
if (!type) {
|
|
124
178
|
return
|
|
125
179
|
}
|
|
126
180
|
|
|
127
|
-
const payload = buildPayload()
|
|
128
|
-
const errors = validateContentPayload(type, payload, {
|
|
181
|
+
const payload = buildPayload(statusOverride)
|
|
182
|
+
const errors = validateContentPayload(type, payload, {
|
|
183
|
+
partial: editing.value,
|
|
184
|
+
})
|
|
129
185
|
|
|
130
186
|
if (errors.length > 0) {
|
|
131
187
|
validationErrors.value = errors
|
|
@@ -143,8 +199,15 @@ async function onSave() {
|
|
|
143
199
|
return
|
|
144
200
|
}
|
|
145
201
|
|
|
202
|
+
const title =
|
|
203
|
+
statusOverride === publishedValue.value
|
|
204
|
+
? 'Published'
|
|
205
|
+
: statusOverride === draftValue.value
|
|
206
|
+
? 'Unpublished'
|
|
207
|
+
: 'Saved'
|
|
208
|
+
|
|
146
209
|
toast.add({
|
|
147
|
-
title
|
|
210
|
+
title,
|
|
148
211
|
description: `${type.label} updated successfully.`,
|
|
149
212
|
color: 'success',
|
|
150
213
|
})
|
|
@@ -161,52 +224,232 @@ async function onSave() {
|
|
|
161
224
|
saving.value = false
|
|
162
225
|
}
|
|
163
226
|
}
|
|
227
|
+
|
|
228
|
+
// --- Delete -------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
const isDeleteModalOpen = ref(false)
|
|
231
|
+
const deleting = ref(false)
|
|
232
|
+
|
|
233
|
+
function openDeleteModal() {
|
|
234
|
+
isDeleteModalOpen.value = true
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function closeDeleteModal() {
|
|
238
|
+
isDeleteModalOpen.value = false
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function confirmDelete() {
|
|
242
|
+
deleting.value = true
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
await remove()
|
|
246
|
+
closeDeleteModal()
|
|
247
|
+
await navigateTo(basePath.value)
|
|
248
|
+
} catch {
|
|
249
|
+
toast.add({
|
|
250
|
+
title: 'Error',
|
|
251
|
+
description: 'An error occurred while removing.',
|
|
252
|
+
color: 'error',
|
|
253
|
+
})
|
|
254
|
+
} finally {
|
|
255
|
+
deleting.value = false
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- Layout / aside -----------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
const layout = computed(() => contentType.value?.form?.layout ?? 'default')
|
|
262
|
+
const showAside = computed(() => {
|
|
263
|
+
const configured = contentType.value?.form?.aside
|
|
264
|
+
if (configured === true || configured === false) {
|
|
265
|
+
return configured
|
|
266
|
+
}
|
|
267
|
+
return sideFields.value.length > 0
|
|
268
|
+
})
|
|
269
|
+
const asideCollapsedByDefault = computed(
|
|
270
|
+
() => contentType.value?.form?.asideCollapsed ?? layout.value === 'focus'
|
|
271
|
+
)
|
|
272
|
+
// Remember the open/closed state per content type across visits.
|
|
273
|
+
const asideOpen = useCookie<boolean>(`pluto-content-aside-${props.type}`, {
|
|
274
|
+
default: () => !asideCollapsedByDefault.value,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
function toggleAside() {
|
|
278
|
+
asideOpen.value = !asideOpen.value
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Registry content actions --------------------------------------------------
|
|
282
|
+
|
|
283
|
+
const toolbarActions = usePlutoContentActions(props.type, 'toolbar')
|
|
284
|
+
|
|
285
|
+
// Only rendered from the template inside the `v-if="contentType"` branch, so
|
|
286
|
+
// `contentType.value` is always set by the time this is read.
|
|
287
|
+
function buildActionContext(): PlutoContentActionContext {
|
|
288
|
+
return {
|
|
289
|
+
type: contentType.value as PlutoContentType,
|
|
290
|
+
item: item.value,
|
|
291
|
+
editing: editing.value,
|
|
292
|
+
saving: saving.value,
|
|
293
|
+
save: () => onSave(),
|
|
294
|
+
remove: () => confirmDelete(),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// No explicit `DropdownMenuItem` annotation. A consumer that links this
|
|
299
|
+
// layer from source can resolve a second copy of `@nuxt/ui`. A named type
|
|
300
|
+
// from one copy then fails to match the component from the other copy. An
|
|
301
|
+
// inferred, structural type matches both.
|
|
302
|
+
const unpublishDeleteItems = computed(() => [
|
|
303
|
+
[
|
|
304
|
+
...(statusConfig.value && isPublished.value && canPublish.value
|
|
305
|
+
? [
|
|
306
|
+
{
|
|
307
|
+
label: 'Unpublish',
|
|
308
|
+
icon: 'lucide:eye-off',
|
|
309
|
+
onSelect: () => onSave(draftValue.value),
|
|
310
|
+
},
|
|
311
|
+
]
|
|
312
|
+
: []),
|
|
313
|
+
...(editing.value && canDelete.value
|
|
314
|
+
? [
|
|
315
|
+
{
|
|
316
|
+
label: 'Delete',
|
|
317
|
+
icon: 'lucide:trash',
|
|
318
|
+
color: 'error' as const,
|
|
319
|
+
onSelect: () => openDeleteModal(),
|
|
320
|
+
},
|
|
321
|
+
]
|
|
322
|
+
: []),
|
|
323
|
+
],
|
|
324
|
+
])
|
|
164
325
|
</script>
|
|
165
326
|
|
|
166
327
|
<template>
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
328
|
+
<PlutoViewAdminPanel
|
|
329
|
+
v-if="contentType"
|
|
330
|
+
:aside-open="showAside && asideOpen"
|
|
331
|
+
:width="layout === 'focus' ? 'full' : 'container'"
|
|
332
|
+
>
|
|
333
|
+
<template #toolbar>
|
|
334
|
+
<PlutoViewToolbar
|
|
335
|
+
:back-to="basePath"
|
|
336
|
+
:title="
|
|
337
|
+
layout === 'focus'
|
|
338
|
+
? editing
|
|
339
|
+
? `Edit ${contentType.label}`
|
|
340
|
+
: `New ${contentType.label}`
|
|
341
|
+
: undefined
|
|
342
|
+
"
|
|
173
343
|
>
|
|
174
|
-
<template #
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
344
|
+
<template #right>
|
|
345
|
+
<UBadge
|
|
346
|
+
v-if="statusConfig"
|
|
347
|
+
:color="isPublished ? 'success' : 'neutral'"
|
|
348
|
+
variant="subtle"
|
|
349
|
+
>
|
|
350
|
+
{{ statusLabel }}
|
|
351
|
+
</UBadge>
|
|
352
|
+
|
|
353
|
+
<template v-for="action in toolbarActions" :key="action.id">
|
|
354
|
+
<!-- prettier-ignore -->
|
|
355
|
+
<UButton
|
|
356
|
+
v-if="action.show?.(buildActionContext()) !== false"
|
|
357
|
+
:icon="action.icon"
|
|
358
|
+
:color="(action.color as ButtonProps['color'])"
|
|
359
|
+
:variant="(action.variant as ButtonProps['variant'])"
|
|
360
|
+
@click="action.onSelect(buildActionContext())"
|
|
361
|
+
>
|
|
362
|
+
{{ action.label }}
|
|
363
|
+
</UButton>
|
|
364
|
+
</template>
|
|
188
365
|
|
|
189
|
-
<
|
|
190
|
-
v-
|
|
191
|
-
:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
366
|
+
<UButton
|
|
367
|
+
v-if="canWrite && (!statusConfig || !isPublished)"
|
|
368
|
+
:loading="saving"
|
|
369
|
+
icon="lucide:save"
|
|
370
|
+
variant="subtle"
|
|
371
|
+
@click="onSave()"
|
|
372
|
+
>
|
|
373
|
+
Save{{ statusConfig ? ' draft' : '' }}
|
|
374
|
+
</UButton>
|
|
198
375
|
|
|
199
|
-
<div class="flex w-full shrink-0 flex-col gap-6 lg:max-w-xs">
|
|
200
376
|
<UButton
|
|
201
|
-
v-if="canWrite"
|
|
377
|
+
v-if="canWrite && statusConfig && isPublished"
|
|
202
378
|
:loading="saving"
|
|
203
379
|
icon="lucide:save"
|
|
204
|
-
|
|
205
|
-
|
|
380
|
+
@click="onSave()"
|
|
381
|
+
>
|
|
382
|
+
Update
|
|
383
|
+
</UButton>
|
|
384
|
+
|
|
385
|
+
<UButton
|
|
386
|
+
v-if="canPublish && statusConfig && !isPublished"
|
|
387
|
+
:loading="saving"
|
|
388
|
+
icon="lucide:upload"
|
|
389
|
+
@click="onSave(publishedValue)"
|
|
206
390
|
>
|
|
207
|
-
|
|
391
|
+
Publish
|
|
208
392
|
</UButton>
|
|
209
393
|
|
|
394
|
+
<UDropdownMenu
|
|
395
|
+
v-if="
|
|
396
|
+
(editing && canDelete) ||
|
|
397
|
+
(statusConfig && isPublished && canPublish)
|
|
398
|
+
"
|
|
399
|
+
:items="unpublishDeleteItems"
|
|
400
|
+
>
|
|
401
|
+
<UButton
|
|
402
|
+
icon="lucide:ellipsis-vertical"
|
|
403
|
+
color="neutral"
|
|
404
|
+
variant="ghost"
|
|
405
|
+
square
|
|
406
|
+
/>
|
|
407
|
+
</UDropdownMenu>
|
|
408
|
+
|
|
409
|
+
<UButton
|
|
410
|
+
v-if="showAside"
|
|
411
|
+
:icon="
|
|
412
|
+
asideOpen ? 'lucide:panel-right-close' : 'lucide:panel-right-open'
|
|
413
|
+
"
|
|
414
|
+
color="neutral"
|
|
415
|
+
variant="ghost"
|
|
416
|
+
square
|
|
417
|
+
@click="toggleAside"
|
|
418
|
+
/>
|
|
419
|
+
</template>
|
|
420
|
+
</PlutoViewToolbar>
|
|
421
|
+
</template>
|
|
422
|
+
|
|
423
|
+
<UAlert
|
|
424
|
+
v-if="validationErrors.length > 0"
|
|
425
|
+
color="error"
|
|
426
|
+
title="Please fix the following before saving"
|
|
427
|
+
>
|
|
428
|
+
<template #description>
|
|
429
|
+
<ul class="list-disc pl-4">
|
|
430
|
+
<li v-for="fieldError in validationErrors" :key="fieldError.field">
|
|
431
|
+
{{ fieldError.message }}
|
|
432
|
+
</li>
|
|
433
|
+
</ul>
|
|
434
|
+
</template>
|
|
435
|
+
</UAlert>
|
|
436
|
+
|
|
437
|
+
<h1 v-if="layout !== 'focus'" class="text-3xl font-bold lg:text-4xl">
|
|
438
|
+
{{ editing ? `Edit ${contentType.label}` : `New ${contentType.label}` }}
|
|
439
|
+
</h1>
|
|
440
|
+
|
|
441
|
+
<PlutoContentField
|
|
442
|
+
v-for="field in mainFields"
|
|
443
|
+
:key="field.name"
|
|
444
|
+
:field="field"
|
|
445
|
+
:model-value="item[field.name]"
|
|
446
|
+
:disabled="!canWrite || saving"
|
|
447
|
+
@update:model-value="updateField(field, $event)"
|
|
448
|
+
/>
|
|
449
|
+
|
|
450
|
+
<template v-if="showAside" #aside>
|
|
451
|
+
<PlutoViewAside v-model:open="asideOpen" title="Settings">
|
|
452
|
+
<div class="flex flex-col gap-6">
|
|
210
453
|
<PlutoContentField
|
|
211
454
|
v-for="field in sideFields"
|
|
212
455
|
:key="field.name"
|
|
@@ -216,15 +459,47 @@ async function onSave() {
|
|
|
216
459
|
@update:model-value="updateField(field, $event)"
|
|
217
460
|
/>
|
|
218
461
|
</div>
|
|
219
|
-
</
|
|
220
|
-
</
|
|
221
|
-
</
|
|
462
|
+
</PlutoViewAside>
|
|
463
|
+
</template>
|
|
464
|
+
</PlutoViewAdminPanel>
|
|
222
465
|
|
|
223
|
-
<
|
|
466
|
+
<PlutoViewAdminPanel v-else>
|
|
224
467
|
<UAlert
|
|
225
468
|
:description="`No content type named "${type}" is registered.`"
|
|
226
469
|
color="error"
|
|
227
470
|
title="Content type not found"
|
|
228
471
|
/>
|
|
229
|
-
</
|
|
472
|
+
</PlutoViewAdminPanel>
|
|
473
|
+
|
|
474
|
+
<Modal v-model="isDeleteModalOpen" :custom-size="480">
|
|
475
|
+
<ModalHeader @close="closeDeleteModal">
|
|
476
|
+
Remove {{ contentType?.label }}
|
|
477
|
+
</ModalHeader>
|
|
478
|
+
|
|
479
|
+
<ModalContent>
|
|
480
|
+
<p>Do you really want to remove this item?</p>
|
|
481
|
+
</ModalContent>
|
|
482
|
+
|
|
483
|
+
<ModalFooter>
|
|
484
|
+
<div class="flex items-center gap-4">
|
|
485
|
+
<UButton
|
|
486
|
+
icon="lucide:x"
|
|
487
|
+
variant="ghost"
|
|
488
|
+
color="neutral"
|
|
489
|
+
@click="closeDeleteModal"
|
|
490
|
+
>
|
|
491
|
+
Cancel
|
|
492
|
+
</UButton>
|
|
493
|
+
|
|
494
|
+
<UButton
|
|
495
|
+
:loading="deleting"
|
|
496
|
+
icon="lucide:trash"
|
|
497
|
+
color="error"
|
|
498
|
+
@click="confirmDelete"
|
|
499
|
+
>
|
|
500
|
+
Remove
|
|
501
|
+
</UButton>
|
|
502
|
+
</div>
|
|
503
|
+
</ModalFooter>
|
|
504
|
+
</Modal>
|
|
230
505
|
</template>
|