@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 ADDED
@@ -0,0 +1,122 @@
1
+ # motor-ui-media
2
+
3
+ Media library layer for the Energis CMS. Provides file upload, gallery browsing, metadata editing, and file usage tracking.
4
+
5
+ ## Directory Structure
6
+
7
+ ```
8
+ motor-ui-media/
9
+ ├── app/
10
+ │ ├── components/
11
+ │ │ ├── form/inputs/
12
+ │ │ │ └── MultiFileUpload.vue # Drag-drop multi-file input with per-file metadata
13
+ │ │ └── media/
14
+ │ │ ├── Gallery.vue # Infinite-scroll masonry gallery with search/filters
15
+ │ │ └── GalleryCard.vue # Media card (thumbnail, lightbox, download, copy URL, usage)
16
+ │ ├── lang/
17
+ │ │ ├── de/motor-media/ # German translations (2 modules: global, files)
18
+ │ │ └── en/motor-media/ # English translations (2 modules: global, files)
19
+ │ ├── pages/
20
+ │ │ └── motor-media/
21
+ │ │ ├── index.vue # Landing redirect
22
+ │ │ └── files/
23
+ │ │ ├── index.vue # Files grid/gallery (dual view mode)
24
+ │ │ ├── create.vue # Multi-file upload with categories
25
+ │ │ └── [id]/edit.vue # File metadata edit + file replacement
26
+ │ └── types/
27
+ │ ├── media.ts # MediaFile, FileResource interfaces
28
+ │ └── generated/
29
+ │ ├── form-meta.ts # Auto-generated form field metadata
30
+ │ └── grid-meta.ts # Auto-generated grid column metadata
31
+ ├── tests/
32
+ │ └── e2e/
33
+ │ └── media-files-grid.spec.ts # Gallery view loading test
34
+ ├── nuxt.config.ts
35
+ └── vitest.config.ts
36
+ ```
37
+
38
+ ## Key Features
39
+
40
+ | Feature | Description |
41
+ |---------|-------------|
42
+ | Dual view modes | Gallery (masonry) and table views, toggled via a `useCookie`-persisted preference |
43
+ | Multi-file upload | Drag-drop zone, per-file description/alt text, sequential upload with progress |
44
+ | Gallery masonry | Responsive CSS columns (2-5) with infinite scroll via IntersectionObserver |
45
+ | Gallery card | Image thumbnail with lazy loading, lightbox preview, download, copy URL, usage modal |
46
+ | File metadata | Description, alt text, category tree assignment, file replacement on edit |
47
+ | Bulk delete | Multi-select with Cmd/Ctrl+click, bulk delete with confirmation |
48
+ | File usage tracking | EntityUsageModal shows where each file is referenced |
49
+ | Search and filtering | Client filter, category filter, text search (URL-synced via `useGridState`) |
50
+
51
+ ## Components
52
+
53
+ ### Gallery.vue
54
+
55
+ Infinite-scroll masonry grid for media files. Accepts a `fetch` function (from `useGridFetch`), filters, and bulk actions. Uses `useIntersectionObserver` from VueUse to trigger `loadMore()` when the sentinel element becomes visible. Exposes a `refresh()` method.
56
+
57
+ ### GalleryCard.vue
58
+
59
+ Renders a single media file as a card. Features:
60
+ - Image thumbnail or MIME-type icon for non-images
61
+ - Hover overlay with lightbox, download, copy URL, and usage actions
62
+ - Selection checkbox (Cmd/Ctrl+click when bulk actions are available)
63
+ - File extension badge and creation date
64
+
65
+ ### MultiFileUpload.vue
66
+
67
+ Drag-and-drop file input. Manages pending files with per-entry description and alt text fields. Propagates form-level defaults to non-dirty entries. Emits `PendingFile[]`.
68
+
69
+ ## Types
70
+
71
+ | Type | File | Description |
72
+ |------|------|-------------|
73
+ | `MediaFile` | `types/media.ts` | File metadata (url, file_name, mime_type, size, conversions) |
74
+ | `FileResource` | `types/media.ts` | API resource (id, description, file, categories) |
75
+
76
+ ## Pages
77
+
78
+ | Route | Permission | Description |
79
+ |-------|------------|-------------|
80
+ | `/motor-media` | -- | Landing/redirect |
81
+ | `/motor-media/files` | `files.read` | Grid or gallery view with filters and bulk actions |
82
+ | `/motor-media/files/create` | `files.write` | Multi-file upload with category selection |
83
+ | `/motor-media/files/[id]/edit` | `files.write` | Edit metadata, replace file, view usage |
84
+
85
+ ## Dependencies
86
+
87
+ This layer consumes **motor-ui-core** for:
88
+ - Composables: `useEntityForm`, `useGridFetch`, `useGridState`, `columnsFromMeta`, `createdAtColumn`, `useClientFilter`, `useCategoryFilter`, `useNotify`, `useShortcutRegistry`, `fileToDataUrl`
89
+ - Components: `GridBase`, `GridPage`, `GridToolbar`, `GridBulkActions`, `FormBase`, `FormPage`, `EntityUsageModal`, `MediaLightbox`, `FormInputsCategoryTreeInput`
90
+ - Types: `FormInputProps`, `GridParams`, `PaginatedResponse`, `FilterDef`, `BulkActionDef`, `RowActionDef`
91
+ - Form configs: `fileCreateFormConfig`, `fileEditFormConfig`, `fileSelectOptionConfigs` from `motor-ui-core/app/types/config/file`
92
+
93
+ No custom composables or stores -- all state is managed via motor-ui-core composables and component-local refs.
94
+
95
+ ## i18n
96
+
97
+ 2 translation modules per locale under `motor-media`:
98
+
99
+ | Module | Keys |
100
+ |--------|------|
101
+ | `global` | Navigation and section labels |
102
+ | `files` | File-specific labels (title, subtitle, add, upload progress, field labels, etc.) |
103
+
104
+ Reference: `t('motor-media.files.<key>')`
105
+
106
+ ## Generated Metadata
107
+
108
+ `form-meta.ts` and `grid-meta.ts` are auto-generated from the backend OpenAPI spec. Regenerate with:
109
+
110
+ ```bash
111
+ pnpm sync:api
112
+ ```
113
+
114
+ Do not edit these files manually.
115
+
116
+ ## Tests
117
+
118
+ 1 Playwright E2E spec verifying that the gallery view loads correctly on `/motor-media/files`.
119
+
120
+ ```bash
121
+ pnpm test:e2e
122
+ ```
@@ -0,0 +1,252 @@
1
+ <script setup lang="ts">
2
+ import type { FormInputProps } from '@motor-cms/ui-core/app/types/form'
3
+
4
+ export interface PendingFile {
5
+ file: File
6
+ description: string
7
+ alt_text: string
8
+ }
9
+
10
+ interface Props extends FormInputProps {
11
+ accept?: string
12
+ defaults?: { description: string, alt_text: string }
13
+ }
14
+
15
+ const props = withDefaults(defineProps<Props>(), {
16
+ accept: 'image/*',
17
+ defaults: () => ({ description: '', alt_text: '' })
18
+ })
19
+
20
+ const emit = defineEmits<{
21
+ 'update:modelValue': [files: PendingFile[]]
22
+ }>()
23
+
24
+ const { t } = useI18n()
25
+
26
+ const pendingFiles = ref<PendingFile[]>([])
27
+ const isDragging = ref(false)
28
+ const fileInputRef = ref<HTMLInputElement | null>(null)
29
+
30
+ // Dirty tracking: keyed by index-stable ID per entry
31
+ let nextId = 0
32
+ const entryIds = ref<number[]>([])
33
+ const dirtyFields = new Map<number, Set<string>>()
34
+
35
+ const previews = computed(() =>
36
+ pendingFiles.value.map((entry, index) => ({
37
+ entry,
38
+ id: entryIds.value[index]!,
39
+ url: URL.createObjectURL(entry.file),
40
+ sizeHuman: formatFileSize(entry.file.size)
41
+ }))
42
+ )
43
+
44
+ function formatFileSize(bytes: number): string {
45
+ if (bytes < 1024) return `${bytes} B`
46
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
47
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
48
+ }
49
+
50
+ function addFiles(newFiles: FileList | File[]) {
51
+ const fileArray = Array.from(newFiles)
52
+ const desc = props.defaults?.description ?? ''
53
+ const alt = props.defaults?.alt_text ?? ''
54
+ for (const file of fileArray) {
55
+ const id = nextId++
56
+ entryIds.value.push(id)
57
+ pendingFiles.value.push({
58
+ file,
59
+ description: desc,
60
+ alt_text: alt
61
+ })
62
+ }
63
+ pendingFiles.value = [...pendingFiles.value]
64
+ entryIds.value = [...entryIds.value]
65
+ emit('update:modelValue', pendingFiles.value)
66
+ }
67
+
68
+ function removeFile(index: number) {
69
+ URL.revokeObjectURL(previews.value[index]!.url)
70
+ const removedId = entryIds.value[index]!
71
+ dirtyFields.delete(removedId)
72
+ pendingFiles.value.splice(index, 1)
73
+ entryIds.value.splice(index, 1)
74
+ pendingFiles.value = [...pendingFiles.value]
75
+ entryIds.value = [...entryIds.value]
76
+ emit('update:modelValue', pendingFiles.value)
77
+ }
78
+
79
+ function updateField(index: number, field: 'description' | 'alt_text', value: string) {
80
+ const id = entryIds.value[index]!
81
+ if (!dirtyFields.has(id)) {
82
+ dirtyFields.set(id, new Set())
83
+ }
84
+ dirtyFields.get(id)!.add(field)
85
+ pendingFiles.value[index]![field] = value
86
+ pendingFiles.value = [...pendingFiles.value]
87
+ emit('update:modelValue', pendingFiles.value)
88
+ }
89
+
90
+ // Propagate defaults to non-dirty entries
91
+ watch(
92
+ [() => props.defaults?.description, () => props.defaults?.alt_text],
93
+ ([newDesc, newAlt]) => {
94
+ if (pendingFiles.value.length === 0) return
95
+ let changed = false
96
+ for (let i = 0; i < pendingFiles.value.length; i++) {
97
+ const id = entryIds.value[i]!
98
+ const dirty = dirtyFields.get(id)
99
+ if (!dirty?.has('description') && pendingFiles.value[i]!.description !== (newDesc ?? '')) {
100
+ pendingFiles.value[i]!.description = newDesc ?? ''
101
+ changed = true
102
+ }
103
+ if (!dirty?.has('alt_text') && pendingFiles.value[i]!.alt_text !== (newAlt ?? '')) {
104
+ pendingFiles.value[i]!.alt_text = newAlt ?? ''
105
+ changed = true
106
+ }
107
+ }
108
+ if (changed) {
109
+ pendingFiles.value = [...pendingFiles.value]
110
+ emit('update:modelValue', pendingFiles.value)
111
+ }
112
+ }
113
+ )
114
+
115
+ function onDrop(event: DragEvent) {
116
+ isDragging.value = false
117
+ if (event.dataTransfer?.files) {
118
+ addFiles(event.dataTransfer.files)
119
+ }
120
+ }
121
+
122
+ function onDragOver(event: DragEvent) {
123
+ event.preventDefault()
124
+ isDragging.value = true
125
+ }
126
+
127
+ function onDragLeave() {
128
+ isDragging.value = false
129
+ }
130
+
131
+ function openFilePicker() {
132
+ fileInputRef.value?.click()
133
+ }
134
+
135
+ function onFileInputChange(event: Event) {
136
+ const input = event.target as HTMLInputElement
137
+ if (input.files) {
138
+ addFiles(input.files)
139
+ input.value = ''
140
+ }
141
+ }
142
+
143
+ onBeforeUnmount(() => {
144
+ previews.value.forEach(p => URL.revokeObjectURL(p.url))
145
+ })
146
+ </script>
147
+
148
+ <template>
149
+ <div class="space-y-4">
150
+ <!-- Drop zone -->
151
+ <div
152
+ class="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer"
153
+ :class="isDragging ? 'border-primary bg-primary/5' : 'border-muted hover:border-primary/50'"
154
+ @drop.prevent="onDrop"
155
+ @dragover="onDragOver"
156
+ @dragleave="onDragLeave"
157
+ @click="openFilePicker"
158
+ >
159
+ <UIcon
160
+ name="i-lucide-upload-cloud"
161
+ class="size-10 text-muted"
162
+ />
163
+ <p class="text-sm text-muted">
164
+ {{ t('motor-media.files.drop_files') }}
165
+ </p>
166
+ <UButton
167
+ variant="outline"
168
+ size="sm"
169
+ icon="i-lucide-folder-open"
170
+ @click.stop="openFilePicker"
171
+ >
172
+ {{ t('motor-media.files.browse') }}
173
+ </UButton>
174
+ <input
175
+ ref="fileInputRef"
176
+ type="file"
177
+ :accept="accept"
178
+ multiple
179
+ class="hidden"
180
+ @change="onFileInputChange"
181
+ >
182
+ </div>
183
+
184
+ <!-- Pending files list -->
185
+ <div
186
+ v-if="pendingFiles.length > 0"
187
+ class="space-y-2"
188
+ >
189
+ <p class="text-sm font-medium">
190
+ {{ t('motor-media.files.pending_files') }} ({{ pendingFiles.length }})
191
+ </p>
192
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
193
+ <div
194
+ v-for="(preview, index) in previews"
195
+ :key="preview.id"
196
+ class="rounded-lg border p-3 space-y-3"
197
+ >
198
+ <div class="flex items-center gap-3">
199
+ <img
200
+ v-if="preview.entry.file.type.startsWith('image/')"
201
+ :src="preview.url"
202
+ :alt="preview.entry.file.name"
203
+ class="size-12 rounded object-cover shrink-0"
204
+ >
205
+ <UIcon
206
+ v-else
207
+ name="i-lucide-file"
208
+ class="size-12 text-muted shrink-0"
209
+ />
210
+ <div class="min-w-0 flex-1">
211
+ <p class="truncate text-sm font-medium">
212
+ {{ preview.entry.file.name }}
213
+ </p>
214
+ <p class="text-xs text-muted">
215
+ {{ preview.entry.file.type || t('motor-core.global.unknown') }} &middot; {{ preview.sizeHuman }}
216
+ </p>
217
+ </div>
218
+ <UButton
219
+ icon="i-lucide-x"
220
+ variant="ghost"
221
+ size="xs"
222
+ color="error"
223
+ @click="removeFile(index)"
224
+ />
225
+ </div>
226
+ <UFormField :label="t('motor-media.files.description')">
227
+ <UInput
228
+ :model-value="preview.entry.description"
229
+ size="sm"
230
+ class="w-full"
231
+ @update:model-value="updateField(index, 'description', $event as string)"
232
+ />
233
+ </UFormField>
234
+ <UFormField :label="t('motor-media.files.alt_text')">
235
+ <UInput
236
+ :model-value="preview.entry.alt_text"
237
+ size="sm"
238
+ class="w-full"
239
+ @update:model-value="updateField(index, 'alt_text', $event as string)"
240
+ />
241
+ </UFormField>
242
+ </div>
243
+ </div>
244
+ </div>
245
+ <p
246
+ v-else
247
+ class="text-sm text-muted"
248
+ >
249
+ {{ t('motor-media.files.no_files_selected') }}
250
+ </p>
251
+ </div>
252
+ </template>
@@ -0,0 +1,175 @@
1
+ <script setup lang="ts">
2
+ import type { RendererProps, MediaValue } from '@motor-cms/ui-core/app/types/grid'
3
+
4
+ const props = defineProps<RendererProps<MediaValue | null>>()
5
+
6
+ const toast = useToast()
7
+ const { t } = useI18n()
8
+ const lightboxOpen = ref(false)
9
+
10
+ const runtimeConfig = useRuntimeConfig()
11
+ const backendBaseUrl = runtimeConfig.public.backendBaseUrl as string
12
+
13
+ const downloadUrl = computed(() => {
14
+ const fileId = props.row?.id
15
+ if (!fileId) return undefined
16
+ return `${backendBaseUrl}/download/${fileId}`
17
+ })
18
+
19
+ const thumbnailUrl = computed(() => {
20
+ // Prefer API url (actual storage/CDN URL) when available
21
+ if (props.value?.url) return props.value.url
22
+ if (!props.row?.id) return undefined
23
+ return `${backendBaseUrl}/download/${props.row.id}`
24
+ })
25
+
26
+ const isImage = computed(() => {
27
+ return props.value?.mime_type?.startsWith('image/')
28
+ })
29
+
30
+ const fileExtension = computed(() => {
31
+ if (!props.value?.file_name) return ''
32
+ return props.value.file_name.split('.').pop()?.toUpperCase() ?? ''
33
+ })
34
+
35
+ const mimeIcon = computed(() => {
36
+ const mime = props.value?.mime_type ?? ''
37
+ if (mime.startsWith('video/')) return 'i-lucide-film'
38
+ if (mime.startsWith('audio/')) return 'i-lucide-music'
39
+ if (mime.includes('pdf')) return 'i-lucide-file-text'
40
+ if (mime.includes('zip') || mime.includes('archive') || mime.includes('compressed')) return 'i-lucide-archive'
41
+ if (mime.includes('spreadsheet') || mime.includes('excel') || mime.includes('csv')) return 'i-lucide-table'
42
+ if (mime.includes('document') || mime.includes('word') || mime.includes('text')) return 'i-lucide-file-text'
43
+ return 'i-lucide-file'
44
+ })
45
+
46
+ async function copyUrl() {
47
+ const url = downloadUrl.value
48
+ if (!url) return
49
+ try {
50
+ await navigator.clipboard.writeText(url)
51
+ } catch {
52
+ const textarea = document.createElement('textarea')
53
+ textarea.value = url
54
+ textarea.style.position = 'fixed'
55
+ textarea.style.opacity = '0'
56
+ document.body.appendChild(textarea)
57
+ textarea.select()
58
+ document.execCommand('copy')
59
+ document.body.removeChild(textarea)
60
+ }
61
+ toast.add({
62
+ title: t('motor-media.files.url_copied'),
63
+ icon: 'i-lucide-check',
64
+ color: 'success'
65
+ })
66
+ }
67
+
68
+ async function forceDownload() {
69
+ const url = downloadUrl.value
70
+ if (!url) return
71
+ try {
72
+ const response = await fetch(url, { credentials: 'include' })
73
+ const blob = await response.blob()
74
+ const blobUrl = URL.createObjectURL(blob)
75
+ const a = document.createElement('a')
76
+ a.href = blobUrl
77
+ a.download = props.value?.file_name ?? 'download'
78
+ document.body.appendChild(a)
79
+ a.click()
80
+ document.body.removeChild(a)
81
+ URL.revokeObjectURL(blobUrl)
82
+ } catch {
83
+ const a = document.createElement('a')
84
+ a.href = url
85
+ a.download = props.value?.file_name ?? 'download'
86
+ a.target = '_blank'
87
+ document.body.appendChild(a)
88
+ a.click()
89
+ document.body.removeChild(a)
90
+ }
91
+ }
92
+ </script>
93
+
94
+ <template>
95
+ <div
96
+ v-if="value"
97
+ class="py-2"
98
+ >
99
+ <!-- Thumbnail container: fixed size, hover to enlarge -->
100
+ <div class="group/thumb relative size-28 rounded-lg overflow-hidden ring-1 ring-[var(--ui-border)] bg-[var(--ui-bg-elevated)] shadow-sm hover:shadow-lg hover:ring-[var(--ui-border-accented)] transition-all duration-300 cursor-pointer">
101
+ <!-- Image thumbnail -->
102
+ <img
103
+ v-if="isImage && thumbnailUrl"
104
+ :src="thumbnailUrl"
105
+ :alt="value.file_name ?? ''"
106
+ class="size-full object-cover transition-transform duration-300 group-hover/thumb:scale-110"
107
+ @click.stop="lightboxOpen = true"
108
+ >
109
+ <!-- Non-image file placeholder -->
110
+ <div
111
+ v-else
112
+ class="size-full flex flex-col items-center justify-center gap-1 bg-[var(--ui-bg-muted)]"
113
+ >
114
+ <UIcon
115
+ :name="mimeIcon"
116
+ class="size-7 text-[var(--ui-text-dimmed)]"
117
+ />
118
+ <span
119
+ v-if="fileExtension"
120
+ class="text-[9px] font-bold tracking-wider text-[var(--ui-text-dimmed)] uppercase"
121
+ >
122
+ {{ fileExtension }}
123
+ </span>
124
+ </div>
125
+
126
+ <!-- Hover overlay with actions -->
127
+ <div class="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 group-hover/thumb:opacity-100 transition-opacity duration-200">
128
+ <button
129
+ v-if="isImage"
130
+ class="flex items-center justify-center size-9 rounded-full bg-white/20 text-white backdrop-blur-sm hover:bg-white/40 transition-colors"
131
+ :title="t('motor-media.files.preview')"
132
+ @click.stop="lightboxOpen = true"
133
+ >
134
+ <UIcon
135
+ name="i-lucide-expand"
136
+ class="size-4"
137
+ />
138
+ </button>
139
+ <button
140
+ class="flex items-center justify-center size-9 rounded-full bg-white/20 text-white backdrop-blur-sm hover:bg-white/40 transition-colors"
141
+ :title="t('motor-core.global.download')"
142
+ @click.stop="forceDownload"
143
+ >
144
+ <UIcon
145
+ name="i-lucide-download"
146
+ class="size-4"
147
+ />
148
+ </button>
149
+ <button
150
+ class="flex items-center justify-center size-9 rounded-full bg-white/20 text-white backdrop-blur-sm hover:bg-white/40 transition-colors"
151
+ :title="t('motor-media.files.copy_url')"
152
+ @click.stop="copyUrl"
153
+ >
154
+ <UIcon
155
+ name="i-lucide-link"
156
+ class="size-4"
157
+ />
158
+ </button>
159
+ </div>
160
+ </div>
161
+
162
+ <MediaLightbox
163
+ v-if="isImage"
164
+ v-model:open="lightboxOpen"
165
+ :src="downloadUrl ?? ''"
166
+ :alt="value.file_name"
167
+ :file-name="value.file_name"
168
+ :download-url="downloadUrl"
169
+ />
170
+ </div>
171
+ <span
172
+ v-else
173
+ class="text-[var(--ui-text-muted)]"
174
+ >-</span>
175
+ </template>