@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
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') }} · {{ 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>
|