@motor-cms/ui-media 1.0.1-alpha.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/README.md +122 -0
- package/app/components/form/inputs/MultiFileUpload.vue +252 -0
- package/app/components/grid/renderers/ImageRenderer.vue +175 -0
- package/app/components/media/Gallery.vue +327 -0
- package/app/components/media/GalleryCard.vue +258 -0
- package/app/lang/de/motor-media/files.json +45 -0
- package/app/lang/de/motor-media/global.json +4 -0
- package/app/lang/en/motor-media/files.json +45 -0
- package/app/lang/en/motor-media/global.json +3 -0
- package/app/pages/motor-media/CLAUDE.md +7 -0
- package/app/pages/motor-media/files/CLAUDE.md +7 -0
- package/app/pages/motor-media/files/[id]/CLAUDE.md +24 -0
- package/app/pages/motor-media/files/[id]/edit.vue +236 -0
- package/app/pages/motor-media/files/create.vue +201 -0
- package/app/pages/motor-media/files/index.vue +164 -0
- package/app/pages/motor-media/index.vue +11 -0
- package/app/plugins/grid-renderers.ts +10 -0
- package/app/types/generated/form-meta.ts +38 -0
- package/app/types/generated/grid-meta.ts +41 -0
- package/app/types/media.ts +16 -0
- package/app/types/renderer-props.ts +1 -0
- package/nuxt.config.ts +1 -0
- package/package.json +20 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 18, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #27256 | 4:59 PM | 🔄 | Major codebase cleanup and component consolidation | ~433 |
|
|
11
|
+
| #26878 | 12:18 PM | 🟣 | Motor-Media Files Module Implementation Complete | ~429 |
|
|
12
|
+
|
|
13
|
+
### Feb 27, 2026
|
|
14
|
+
|
|
15
|
+
| ID | Time | T | Title | Read |
|
|
16
|
+
|----|------|---|-------|------|
|
|
17
|
+
| #30755 | 1:37 PM | 🔵 | Comprehensive Media Management System in Motor Admin Frontend | ~594 |
|
|
18
|
+
|
|
19
|
+
### Mar 7, 2026
|
|
20
|
+
|
|
21
|
+
| ID | Time | T | Title | Read |
|
|
22
|
+
|----|------|---|-------|------|
|
|
23
|
+
| #34014 | 10:53 AM | 🔴 | Fixed permission naming in motor-media pages to align with backend API | ~289 |
|
|
24
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { fileFormMeta } from '../../../../types/generated/form-meta'
|
|
3
|
+
import { fileEditFormConfig, fileSelectOptionConfigs } from '@motor-cms/ui-core/app/types/config/file'
|
|
4
|
+
|
|
5
|
+
definePageMeta({ layout: 'default', permission: 'files.write' })
|
|
6
|
+
|
|
7
|
+
const route = useRoute()
|
|
8
|
+
const { t } = useI18n()
|
|
9
|
+
const client = useSanctumClient()
|
|
10
|
+
const { success, error: notifyError } = useNotify()
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
|
|
13
|
+
const { fields, schema, groups, state, loading, fetching, fetchError, formRef, selectOptions, selectOptionsLoading, deleteRecord, deleting } = await useEntityForm({
|
|
14
|
+
apiEndpoint: '/api/v2/files',
|
|
15
|
+
routePrefix: '/motor-media/files',
|
|
16
|
+
translationPrefix: 'motor-media.files',
|
|
17
|
+
formMeta: fileFormMeta,
|
|
18
|
+
formConfig: fileEditFormConfig,
|
|
19
|
+
mode: 'edit',
|
|
20
|
+
id: route.params.id as string,
|
|
21
|
+
selectOptionConfigs: fileSelectOptionConfigs
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const fileId = route.params.id as string
|
|
25
|
+
const replacementFile = ref<File | null>(null)
|
|
26
|
+
|
|
27
|
+
const usageModalOpen = ref(false)
|
|
28
|
+
const usageEndpoint = computed(() => `/api/v2/files/${fileId}/usage`)
|
|
29
|
+
|
|
30
|
+
// Reuse the record already fetched by useEntityForm (no duplicate request)
|
|
31
|
+
const { data: fileRecord } = useNuxtData<{ data: Record<string, unknown> }>(
|
|
32
|
+
`entity-form-/api/v2/files-${fileId}`
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const currentFile = computed(() => {
|
|
36
|
+
const file = fileRecord.value?.data?.file as { url?: string, file_name?: string, mime_type?: string } | null
|
|
37
|
+
return file ?? null
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const selectedCategories = ref<number[]>([])
|
|
41
|
+
|
|
42
|
+
watch(fileRecord, (res) => {
|
|
43
|
+
if (res?.data) {
|
|
44
|
+
const cats = res.data.categories as Array<{ id: number }> | null
|
|
45
|
+
if (cats) {
|
|
46
|
+
selectedCategories.value = cats.map(c => c.id)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}, { immediate: true })
|
|
50
|
+
|
|
51
|
+
function onFileChange(event: Event) {
|
|
52
|
+
const input = event.target as HTMLInputElement
|
|
53
|
+
if (input.files?.[0]) {
|
|
54
|
+
replacementFile.value = input.files[0]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function submitFile(eventData: Record<string, unknown>): Promise<void> {
|
|
59
|
+
const body: Record<string, unknown> = { ...eventData, categories: selectedCategories.value }
|
|
60
|
+
if (replacementFile.value) {
|
|
61
|
+
body.file = await fileToDataUrl(replacementFile.value)
|
|
62
|
+
}
|
|
63
|
+
await client(`/api/v2/files/${fileId}`, { method: 'PATCH', body })
|
|
64
|
+
success(t('motor-media.files.edit_title'), t('motor-media.files.updated_success'))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleSubmitError(err: unknown) {
|
|
68
|
+
const fetchErr = err as { response?: { status?: number, _data?: { message?: string, errors?: Record<string, string[]> } } }
|
|
69
|
+
if (fetchErr.response?.status === 422 && fetchErr.response._data?.errors) {
|
|
70
|
+
const serverErrors = fetchErr.response._data.errors
|
|
71
|
+
const formErrors = Object.entries(serverErrors).map(([path, messages]) => ({
|
|
72
|
+
path,
|
|
73
|
+
message: messages[0] ?? ''
|
|
74
|
+
}))
|
|
75
|
+
formRef.value?.setErrors(formErrors)
|
|
76
|
+
|
|
77
|
+
const count = formErrors.length
|
|
78
|
+
const summary = count === 1
|
|
79
|
+
? formErrors[0]!.message
|
|
80
|
+
: t('motor-core.global.validation_errors', { count })
|
|
81
|
+
notifyError(t('motor-core.global.validation_failed'), summary)
|
|
82
|
+
|
|
83
|
+
nextTick(() => {
|
|
84
|
+
const errorEl = document.querySelector('[class*="error"]')
|
|
85
|
+
errorEl?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
86
|
+
})
|
|
87
|
+
} else {
|
|
88
|
+
const message = err instanceof Error ? err.message : 'Failed to update file'
|
|
89
|
+
notifyError(t('motor-media.files.edit_title'), message)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function onSubmit(event: { data: Record<string, unknown> }) {
|
|
94
|
+
loading.value = true
|
|
95
|
+
try {
|
|
96
|
+
await submitFile(event.data)
|
|
97
|
+
router.push('/motor-media/files')
|
|
98
|
+
} catch (err: unknown) {
|
|
99
|
+
handleSubmitError(err)
|
|
100
|
+
} finally {
|
|
101
|
+
loading.value = false
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function onSaveAndNew(event: { data: Record<string, unknown> }) {
|
|
106
|
+
loading.value = true
|
|
107
|
+
try {
|
|
108
|
+
await submitFile(event.data)
|
|
109
|
+
router.push('/motor-media/files/create')
|
|
110
|
+
} catch (err: unknown) {
|
|
111
|
+
handleSubmitError(err)
|
|
112
|
+
} finally {
|
|
113
|
+
loading.value = false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function onSaveAndContinue(event: { data: Record<string, unknown> }) {
|
|
118
|
+
loading.value = true
|
|
119
|
+
try {
|
|
120
|
+
await submitFile(event.data)
|
|
121
|
+
nextTick(() => formRef.value?.captureSnapshot())
|
|
122
|
+
} catch (err: unknown) {
|
|
123
|
+
handleSubmitError(err)
|
|
124
|
+
} finally {
|
|
125
|
+
loading.value = false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<template>
|
|
131
|
+
<div>
|
|
132
|
+
<FormPage
|
|
133
|
+
:title="t('motor-media.files.edit_title')"
|
|
134
|
+
back-route="/motor-media/files"
|
|
135
|
+
:loading="fetching"
|
|
136
|
+
:error="fetchError"
|
|
137
|
+
>
|
|
138
|
+
<FormBase
|
|
139
|
+
ref="formRef"
|
|
140
|
+
v-model:state="state"
|
|
141
|
+
:fields="fields"
|
|
142
|
+
:schema="schema"
|
|
143
|
+
:groups="groups"
|
|
144
|
+
:select-options="selectOptions"
|
|
145
|
+
:select-options-loading="selectOptionsLoading"
|
|
146
|
+
:loading="loading"
|
|
147
|
+
:delete-record="deleteRecord"
|
|
148
|
+
:deleting="deleting"
|
|
149
|
+
cancel-route="/motor-media/files"
|
|
150
|
+
show-save-and-continue
|
|
151
|
+
show-save-and-new
|
|
152
|
+
@submit="onSubmit"
|
|
153
|
+
@save-and-continue="onSaveAndContinue"
|
|
154
|
+
@save-and-new="onSaveAndNew"
|
|
155
|
+
>
|
|
156
|
+
<template #after-fields>
|
|
157
|
+
<UPageCard :title="t('motor-media.files.group_categories')">
|
|
158
|
+
<FormInputsCategoryTreeInput
|
|
159
|
+
v-model="selectedCategories"
|
|
160
|
+
scope="media"
|
|
161
|
+
/>
|
|
162
|
+
</UPageCard>
|
|
163
|
+
|
|
164
|
+
<UPageCard :title="t('motor-media.files.usage_title')">
|
|
165
|
+
<UButton
|
|
166
|
+
:label="t('motor-media.files.usage_title')"
|
|
167
|
+
icon="i-lucide-network"
|
|
168
|
+
variant="outline"
|
|
169
|
+
@click="usageModalOpen = true"
|
|
170
|
+
/>
|
|
171
|
+
</UPageCard>
|
|
172
|
+
|
|
173
|
+
<UPageCard :title="t('motor-media.files.current_file')">
|
|
174
|
+
<div class="space-y-4">
|
|
175
|
+
<!-- Current file preview -->
|
|
176
|
+
<div
|
|
177
|
+
v-if="currentFile?.url"
|
|
178
|
+
class="flex items-center gap-4"
|
|
179
|
+
>
|
|
180
|
+
<img
|
|
181
|
+
v-if="currentFile.mime_type?.startsWith('image/')"
|
|
182
|
+
:src="currentFile.url"
|
|
183
|
+
:alt="currentFile.file_name ?? ''"
|
|
184
|
+
class="max-h-40 rounded object-contain"
|
|
185
|
+
>
|
|
186
|
+
<div
|
|
187
|
+
v-else
|
|
188
|
+
class="flex items-center gap-2"
|
|
189
|
+
>
|
|
190
|
+
<UIcon
|
|
191
|
+
name="i-lucide-file"
|
|
192
|
+
class="size-8 text-muted"
|
|
193
|
+
/>
|
|
194
|
+
<span class="text-sm">{{ currentFile.file_name }}</span>
|
|
195
|
+
</div>
|
|
196
|
+
<UButton
|
|
197
|
+
icon="i-lucide-download"
|
|
198
|
+
variant="outline"
|
|
199
|
+
size="sm"
|
|
200
|
+
:to="currentFile.url"
|
|
201
|
+
target="_blank"
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<!-- Replacement file input -->
|
|
206
|
+
<div class="space-y-2">
|
|
207
|
+
<UFormField
|
|
208
|
+
name="replacement_file"
|
|
209
|
+
:label="t('motor-media.files.replace_file')"
|
|
210
|
+
>
|
|
211
|
+
<UInput
|
|
212
|
+
type="file"
|
|
213
|
+
class="w-full"
|
|
214
|
+
@change="onFileChange"
|
|
215
|
+
/>
|
|
216
|
+
</UFormField>
|
|
217
|
+
<p
|
|
218
|
+
v-if="replacementFile"
|
|
219
|
+
class="text-sm text-muted"
|
|
220
|
+
>
|
|
221
|
+
{{ replacementFile.name }} ({{ (replacementFile.size / 1024).toFixed(1) }} KB)
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</UPageCard>
|
|
226
|
+
</template>
|
|
227
|
+
</FormBase>
|
|
228
|
+
</FormPage>
|
|
229
|
+
|
|
230
|
+
<EntityUsageModal
|
|
231
|
+
v-model:open="usageModalOpen"
|
|
232
|
+
:endpoint="usageEndpoint"
|
|
233
|
+
:title="t('motor-media.files.usage_title')"
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
</template>
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { PendingFile } from '../../../components/form/inputs/MultiFileUpload.vue'
|
|
3
|
+
import { fileFormMeta } from '../../../types/generated/form-meta'
|
|
4
|
+
import { fileCreateFormConfig, fileSelectOptionConfigs } from '@motor-cms/ui-core/app/types/config/file'
|
|
5
|
+
|
|
6
|
+
definePageMeta({ layout: 'default', permission: 'files.write' })
|
|
7
|
+
|
|
8
|
+
const { t } = useI18n()
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
const client = useSanctumClient()
|
|
11
|
+
const { success, error: notifyError, warning } = useNotify()
|
|
12
|
+
|
|
13
|
+
const formRef = ref<{ captureSnapshot: () => void, isDirty: boolean, setErrors: (errors: Array<{ path: string, message: string }>) => void } | null>(null)
|
|
14
|
+
|
|
15
|
+
const { fields, schema, groups, state, selectOptions, selectOptionsLoading } = await useEntityForm({
|
|
16
|
+
apiEndpoint: '/api/v2/files',
|
|
17
|
+
routePrefix: '/motor-media/files',
|
|
18
|
+
translationPrefix: 'motor-media.files',
|
|
19
|
+
formMeta: fileFormMeta,
|
|
20
|
+
formConfig: fileCreateFormConfig,
|
|
21
|
+
mode: 'create',
|
|
22
|
+
selectOptionConfigs: fileSelectOptionConfigs
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const selectedCategories = ref<number[]>([])
|
|
26
|
+
|
|
27
|
+
const pendingFiles = ref<PendingFile[]>([])
|
|
28
|
+
const uploading = ref(false)
|
|
29
|
+
const uploadProgress = ref('')
|
|
30
|
+
|
|
31
|
+
const defaults = computed(() => ({
|
|
32
|
+
description: (state.description as string) || '',
|
|
33
|
+
alt_text: (state.alt_text as string) || ''
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
const submitted = ref(false)
|
|
37
|
+
const categoriesError = computed(() =>
|
|
38
|
+
submitted.value && selectedCategories.value.length === 0
|
|
39
|
+
? t('motor-media.files.categories_required')
|
|
40
|
+
: undefined
|
|
41
|
+
)
|
|
42
|
+
const filesError = computed(() =>
|
|
43
|
+
submitted.value && pendingFiles.value.length === 0
|
|
44
|
+
? t('motor-media.files.files_required')
|
|
45
|
+
: undefined
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// Clear errors once requirements are met
|
|
49
|
+
watch(selectedCategories, () => {
|
|
50
|
+
if (selectedCategories.value.length > 0 && pendingFiles.value.length > 0) submitted.value = false
|
|
51
|
+
})
|
|
52
|
+
watch(pendingFiles, () => {
|
|
53
|
+
if (pendingFiles.value.length > 0 && selectedCategories.value.length > 0) submitted.value = false
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
function scrollToFirstError() {
|
|
57
|
+
nextTick(() => {
|
|
58
|
+
const errorEl = document.querySelector('.text-error')
|
|
59
|
+
if (errorEl) {
|
|
60
|
+
errorEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handleServerError(err: unknown) {
|
|
66
|
+
const fetchErr = err as { response?: { status?: number, _data?: { message?: string, errors?: Record<string, string[]> } } }
|
|
67
|
+
if (fetchErr.response?.status === 422 && fetchErr.response._data?.errors) {
|
|
68
|
+
const serverErrors = fetchErr.response._data.errors
|
|
69
|
+
const formErrors = Object.entries(serverErrors).map(([path, messages]) => ({
|
|
70
|
+
path,
|
|
71
|
+
message: messages[0] ?? ''
|
|
72
|
+
}))
|
|
73
|
+
formRef.value?.setErrors(formErrors)
|
|
74
|
+
|
|
75
|
+
const count = formErrors.length
|
|
76
|
+
const summary = count === 1
|
|
77
|
+
? formErrors[0]!.message
|
|
78
|
+
: t('motor-core.global.validation_errors', { count })
|
|
79
|
+
notifyError(t('motor-core.global.validation_failed'), summary)
|
|
80
|
+
scrollToFirstError()
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function onSubmit(event: { data: Record<string, unknown> }) {
|
|
87
|
+
submitted.value = true
|
|
88
|
+
if (selectedCategories.value.length === 0 || pendingFiles.value.length === 0) {
|
|
89
|
+
const missingFields: string[] = []
|
|
90
|
+
if (selectedCategories.value.length === 0) missingFields.push(t('motor-media.files.group_categories'))
|
|
91
|
+
if (pendingFiles.value.length === 0) missingFields.push(t('motor-media.files.pending_files'))
|
|
92
|
+
warning(t('motor-core.global.validation_failed'), missingFields.join(', '))
|
|
93
|
+
scrollToFirstError()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
uploading.value = true
|
|
98
|
+
const total = pendingFiles.value.length
|
|
99
|
+
const errors: string[] = []
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
for (let i = 0; i < total; i++) {
|
|
103
|
+
const pending = pendingFiles.value[i]!
|
|
104
|
+
uploadProgress.value = t('motor-media.files.upload_progress', { current: i + 1, total })
|
|
105
|
+
|
|
106
|
+
const fileData = await fileToDataUrl(pending.file)
|
|
107
|
+
const body = {
|
|
108
|
+
...event.data,
|
|
109
|
+
description: pending.description,
|
|
110
|
+
alt_text: pending.alt_text,
|
|
111
|
+
categories: selectedCategories.value,
|
|
112
|
+
files: [fileData]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await client('/api/v2/files', {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
body
|
|
119
|
+
})
|
|
120
|
+
} catch (err: unknown) {
|
|
121
|
+
if (!handleServerError(err)) {
|
|
122
|
+
const message = err instanceof Error ? err.message : `Failed to upload ${pending.file.name}`
|
|
123
|
+
errors.push(`${pending.file.name}: ${message}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (errors.length === 0) {
|
|
129
|
+
success(t('motor-media.files.create_title'), t('motor-media.files.created_success'))
|
|
130
|
+
router.push('/motor-media/files')
|
|
131
|
+
} else if (errors.length < total) {
|
|
132
|
+
const uploaded = total - errors.length
|
|
133
|
+
success(t('motor-media.files.create_title'), `${uploaded}/${total} files uploaded`)
|
|
134
|
+
notifyError(t('motor-media.files.create_title'), errors.join('\n'))
|
|
135
|
+
router.push('/motor-media/files')
|
|
136
|
+
} else {
|
|
137
|
+
notifyError(t('motor-media.files.create_title'), errors.join('\n'))
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
uploading.value = false
|
|
141
|
+
uploadProgress.value = ''
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<template>
|
|
147
|
+
<FormPage
|
|
148
|
+
:title="t('motor-media.files.create_title')"
|
|
149
|
+
back-route="/motor-media/files"
|
|
150
|
+
>
|
|
151
|
+
<FormBase
|
|
152
|
+
ref="formRef"
|
|
153
|
+
v-model:state="state"
|
|
154
|
+
:fields="fields"
|
|
155
|
+
:schema="schema"
|
|
156
|
+
:groups="groups"
|
|
157
|
+
:select-options="selectOptions"
|
|
158
|
+
:select-options-loading="selectOptionsLoading"
|
|
159
|
+
:loading="uploading"
|
|
160
|
+
cancel-route="/motor-media/files"
|
|
161
|
+
@submit="onSubmit"
|
|
162
|
+
>
|
|
163
|
+
<template #after-fields>
|
|
164
|
+
<UPageCard :title="t('motor-media.files.group_categories')">
|
|
165
|
+
<FormInputsCategoryTreeInput
|
|
166
|
+
v-model="selectedCategories"
|
|
167
|
+
scope="media"
|
|
168
|
+
/>
|
|
169
|
+
<p
|
|
170
|
+
v-if="categoriesError"
|
|
171
|
+
class="mt-2 text-sm text-error"
|
|
172
|
+
>
|
|
173
|
+
{{ categoriesError }}
|
|
174
|
+
</p>
|
|
175
|
+
</UPageCard>
|
|
176
|
+
|
|
177
|
+
<UPageCard :title="t('motor-media.files.pending_files')">
|
|
178
|
+
<p
|
|
179
|
+
v-if="uploadProgress"
|
|
180
|
+
class="mb-3 text-sm font-medium text-primary"
|
|
181
|
+
>
|
|
182
|
+
{{ uploadProgress }}
|
|
183
|
+
</p>
|
|
184
|
+
<FormInputsMultiFileUpload
|
|
185
|
+
:field="{ key: 'files', label: '', input: 'multi-file-upload', required: true }"
|
|
186
|
+
:model-value="(pendingFiles as any)"
|
|
187
|
+
:defaults="defaults"
|
|
188
|
+
accept="*/*"
|
|
189
|
+
@update:model-value="pendingFiles = $event"
|
|
190
|
+
/>
|
|
191
|
+
<p
|
|
192
|
+
v-if="filesError"
|
|
193
|
+
class="mt-2 text-sm text-error"
|
|
194
|
+
>
|
|
195
|
+
{{ filesError }}
|
|
196
|
+
</p>
|
|
197
|
+
</UPageCard>
|
|
198
|
+
</template>
|
|
199
|
+
</FormBase>
|
|
200
|
+
</FormPage>
|
|
201
|
+
</template>
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { components } from '@motor-cms/ui-core/app/types/generated/api'
|
|
3
|
+
import type { BulkActionDef, RowActionDef } from '@motor-cms/ui-core/app/types/grid'
|
|
4
|
+
import { fileMeta } from '../../../types/generated/grid-meta'
|
|
5
|
+
import { fileGridConfig } from '@motor-cms/ui-core/app/types/config/file'
|
|
6
|
+
|
|
7
|
+
type File = components['schemas']['FileResource']
|
|
8
|
+
|
|
9
|
+
definePageMeta({ permission: 'files.read' })
|
|
10
|
+
|
|
11
|
+
const client = useSanctumClient()
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
|
|
14
|
+
const usageModalOpen = ref(false)
|
|
15
|
+
const usageEndpoint = ref('')
|
|
16
|
+
|
|
17
|
+
function openUsageModal(fileId: number) {
|
|
18
|
+
usageEndpoint.value = `/api/v2/files/${fileId}/usage`
|
|
19
|
+
usageModalOpen.value = true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// View mode toggle (persisted in cookie for SSR safety)
|
|
23
|
+
const viewMode = useCookie<'gallery' | 'table'>('media-view-mode', { default: () => 'gallery' })
|
|
24
|
+
|
|
25
|
+
// Table columns (only needed for table view)
|
|
26
|
+
const columns = columnsFromMeta<File>(fileMeta, t, fileGridConfig)
|
|
27
|
+
|
|
28
|
+
columns.unshift({
|
|
29
|
+
key: 'file',
|
|
30
|
+
label: t('motor-media.files.file'),
|
|
31
|
+
renderer: 'image',
|
|
32
|
+
width: 'w-36'
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const descIdx = columns.findIndex(c => c.key === 'description')
|
|
36
|
+
columns.splice(descIdx + 1, 0, {
|
|
37
|
+
key: 'file.file_name',
|
|
38
|
+
label: t('motor-media.files.file_name')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
columns.push({
|
|
42
|
+
key: 'file.mime_type',
|
|
43
|
+
label: t('motor-media.files.mime_type')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
columns.push(createdAtColumn(t, { key: 'file.created_at', sortKey: 'created_at' }))
|
|
47
|
+
|
|
48
|
+
const rowActions: RowActionDef<File>[] = [
|
|
49
|
+
{
|
|
50
|
+
key: 'usage',
|
|
51
|
+
label: t('motor-media.files.usage_title'),
|
|
52
|
+
icon: 'i-lucide-link',
|
|
53
|
+
silent: true,
|
|
54
|
+
handler: (row: File) => {
|
|
55
|
+
openUsageModal(row.id as number)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const filters = [useClientFilter(), useCategoryFilter('media')]
|
|
61
|
+
|
|
62
|
+
const bulkActions: BulkActionDef[] = [
|
|
63
|
+
{
|
|
64
|
+
key: 'delete',
|
|
65
|
+
label: t('motor-core.grid.delete_selected'),
|
|
66
|
+
icon: 'i-lucide-trash-2',
|
|
67
|
+
color: 'error',
|
|
68
|
+
permission: 'files.delete',
|
|
69
|
+
confirm: count => t('motor-core.grid.confirm_delete', { count }),
|
|
70
|
+
handler: async (ids) => {
|
|
71
|
+
await Promise.all(
|
|
72
|
+
ids.map(id => client(`/api/v2/files/${id}`, { method: 'DELETE' }))
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
const fetchFiles = useGridFetch<File>('/api/v2/files')
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<template>
|
|
82
|
+
<div>
|
|
83
|
+
<GridPage
|
|
84
|
+
:title="t('motor-media.files.title')"
|
|
85
|
+
:subtitle="t('motor-media.files.subtitle')"
|
|
86
|
+
add-route="/motor-media/files/create"
|
|
87
|
+
:add-label="t('motor-media.files.add')"
|
|
88
|
+
write-permission="files.write"
|
|
89
|
+
>
|
|
90
|
+
<template #actions>
|
|
91
|
+
<div class="inline-flex rounded-lg ring-1 ring-[var(--ui-border)] overflow-hidden">
|
|
92
|
+
<button
|
|
93
|
+
class="flex items-center justify-center size-9 transition-colors"
|
|
94
|
+
:class="viewMode === 'gallery'
|
|
95
|
+
? 'bg-[var(--ui-primary)] text-white'
|
|
96
|
+
: 'bg-[var(--ui-bg)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text)] hover:bg-[var(--ui-bg-elevated)]'"
|
|
97
|
+
:title="t('motor-media.files.view_gallery')"
|
|
98
|
+
@click="viewMode = 'gallery'"
|
|
99
|
+
>
|
|
100
|
+
<UIcon
|
|
101
|
+
name="i-lucide-layout-grid"
|
|
102
|
+
class="size-4"
|
|
103
|
+
/>
|
|
104
|
+
</button>
|
|
105
|
+
<button
|
|
106
|
+
class="flex items-center justify-center size-9 transition-colors border-l border-[var(--ui-border)]"
|
|
107
|
+
:class="viewMode === 'table'
|
|
108
|
+
? 'bg-[var(--ui-primary)] text-white'
|
|
109
|
+
: 'bg-[var(--ui-bg)] text-[var(--ui-text-muted)] hover:text-[var(--ui-text)] hover:bg-[var(--ui-bg-elevated)]'"
|
|
110
|
+
:title="t('motor-media.files.view_table')"
|
|
111
|
+
@click="viewMode = 'table'"
|
|
112
|
+
>
|
|
113
|
+
<UIcon
|
|
114
|
+
name="i-lucide-table-2"
|
|
115
|
+
class="size-4"
|
|
116
|
+
/>
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
|
|
121
|
+
<!-- Gallery view -->
|
|
122
|
+
<MediaGallery
|
|
123
|
+
v-if="viewMode === 'gallery'"
|
|
124
|
+
id="files-gallery"
|
|
125
|
+
:fetch="fetchFiles"
|
|
126
|
+
:filters="filters"
|
|
127
|
+
:bulk-actions="bulkActions"
|
|
128
|
+
:per-page="25"
|
|
129
|
+
@show-usage="openUsageModal"
|
|
130
|
+
>
|
|
131
|
+
<template #empty-action>
|
|
132
|
+
<UButton
|
|
133
|
+
to="/motor-media/files/create"
|
|
134
|
+
:label="t('motor-media.files.add')"
|
|
135
|
+
icon="i-lucide-plus"
|
|
136
|
+
size="sm"
|
|
137
|
+
/>
|
|
138
|
+
</template>
|
|
139
|
+
</MediaGallery>
|
|
140
|
+
|
|
141
|
+
<!-- Table view -->
|
|
142
|
+
<GridBase
|
|
143
|
+
v-else
|
|
144
|
+
id="files-grid"
|
|
145
|
+
:fetch="fetchFiles"
|
|
146
|
+
:columns="columns"
|
|
147
|
+
:filters="filters"
|
|
148
|
+
:bulk-actions="bulkActions"
|
|
149
|
+
base-path="/motor-media/files"
|
|
150
|
+
name-key="description"
|
|
151
|
+
:row-actions="rowActions"
|
|
152
|
+
write-permission="files.write"
|
|
153
|
+
delete-permission="files.delete"
|
|
154
|
+
:row-click-to="(row: any) => `/motor-media/files/${row.id}/edit`"
|
|
155
|
+
/>
|
|
156
|
+
</GridPage>
|
|
157
|
+
|
|
158
|
+
<EntityUsageModal
|
|
159
|
+
v-model:open="usageModalOpen"
|
|
160
|
+
:endpoint="usageEndpoint"
|
|
161
|
+
:title="t('motor-media.files.usage_title')"
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({ permission: 'files.read' })
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<SectionLanding
|
|
7
|
+
section-prefix="motor-media"
|
|
8
|
+
title-key="motor-media.global.media"
|
|
9
|
+
subtitle-key="motor-media.global.section_subtitle"
|
|
10
|
+
/>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { registerGridRenderers } from '#imports'
|
|
2
|
+
import ImageRenderer from '../components/grid/renderers/ImageRenderer.vue'
|
|
3
|
+
|
|
4
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
5
|
+
nuxtApp.hook('app:created', () => {
|
|
6
|
+
registerGridRenderers({
|
|
7
|
+
'image': ImageRenderer,
|
|
8
|
+
})
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form field metadata — auto-generated from OpenAPI spec.
|
|
3
|
+
* Do not edit manually. Run `npm run sync:api` to regenerate.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const fileFormMeta = {
|
|
7
|
+
post: {
|
|
8
|
+
schemaName: 'FilePostRequest',
|
|
9
|
+
fields: {
|
|
10
|
+
client_id: { input: 'select' },
|
|
11
|
+
description: { input: 'textarea', required: true },
|
|
12
|
+
author: { input: 'text', required: true },
|
|
13
|
+
source: { input: 'text', required: true },
|
|
14
|
+
alt_text: { input: 'text', required: true },
|
|
15
|
+
is_global: { input: 'toggle' },
|
|
16
|
+
is_excluded_from_search_index: { input: 'toggle' },
|
|
17
|
+
file: { input: 'text' },
|
|
18
|
+
tags: { input: 'text' },
|
|
19
|
+
categories: { input: 'multi-select', required: true },
|
|
20
|
+
files: { input: 'text', required: true }
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
patch: {
|
|
24
|
+
schemaName: 'FilePatchRequest',
|
|
25
|
+
fields: {
|
|
26
|
+
client_id: { input: 'select' },
|
|
27
|
+
description: { input: 'textarea', required: true },
|
|
28
|
+
author: { input: 'text', required: true },
|
|
29
|
+
source: { input: 'text', required: true },
|
|
30
|
+
alt_text: { input: 'text', required: true },
|
|
31
|
+
is_global: { input: 'toggle' },
|
|
32
|
+
is_excluded_from_search_index: { input: 'toggle' },
|
|
33
|
+
tags: { input: 'text' },
|
|
34
|
+
categories: { input: 'multi-select', required: true },
|
|
35
|
+
file: { input: 'text' }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} as const
|