@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,327 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useIntersectionObserver } from '@vueuse/core'
|
|
3
|
+
import type {
|
|
4
|
+
FilterDef,
|
|
5
|
+
BulkActionDef,
|
|
6
|
+
PaginatedResponse,
|
|
7
|
+
GridParams
|
|
8
|
+
} from '@motor-cms/ui-core/app/types/grid'
|
|
9
|
+
import type { FileResource } from '../../types/media'
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<{
|
|
12
|
+
id: string
|
|
13
|
+
fetch: (params: GridParams) => Promise<PaginatedResponse<FileResource>>
|
|
14
|
+
filters?: FilterDef[]
|
|
15
|
+
bulkActions?: BulkActionDef[]
|
|
16
|
+
searchable?: boolean
|
|
17
|
+
perPage?: number
|
|
18
|
+
}>(), {
|
|
19
|
+
searchable: true,
|
|
20
|
+
perPage: 25
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{
|
|
24
|
+
'show-usage': [fileId: number]
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const { t } = useI18n()
|
|
28
|
+
|
|
29
|
+
// Grid state — used only for search + filters (URL-synced)
|
|
30
|
+
const gridState = useGridState({
|
|
31
|
+
gridId: props.id,
|
|
32
|
+
defaultPerPage: props.perPage
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
if (props.filters) {
|
|
36
|
+
gridState.initFilters(props.filters.map(f => f.key))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Accumulated items + internal pagination
|
|
40
|
+
const items = ref<FileResource[]>([])
|
|
41
|
+
const currentPage = ref(0)
|
|
42
|
+
const lastPage = ref(1)
|
|
43
|
+
const totalItems = ref(0)
|
|
44
|
+
const initialLoading = ref(true)
|
|
45
|
+
const loadingMore = ref(false)
|
|
46
|
+
const fetchError = ref<Error | null>(null)
|
|
47
|
+
|
|
48
|
+
// Selection
|
|
49
|
+
const selectedIds = ref<Set<number>>(new Set())
|
|
50
|
+
const hasBulkActions = computed(() => (props.bulkActions?.length ?? 0) > 0)
|
|
51
|
+
|
|
52
|
+
function toggleSelection(id: number, selected: boolean) {
|
|
53
|
+
const next = new Set(selectedIds.value)
|
|
54
|
+
if (selected) next.add(id)
|
|
55
|
+
else next.delete(id)
|
|
56
|
+
selectedIds.value = next
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clearSelection() {
|
|
60
|
+
selectedIds.value = new Set()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const selectedIdsArray = computed(() => Array.from(selectedIds.value))
|
|
64
|
+
|
|
65
|
+
const hasActiveFilters = computed(() => {
|
|
66
|
+
if (gridState.state.search) return true
|
|
67
|
+
return Object.values(gridState.state.filters).some(
|
|
68
|
+
v => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0)
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const hasMore = computed(() => currentPage.value < lastPage.value)
|
|
73
|
+
|
|
74
|
+
// Build fetch params combining filters with internal page
|
|
75
|
+
function buildParams(page: number): GridParams {
|
|
76
|
+
const params: GridParams = {
|
|
77
|
+
page,
|
|
78
|
+
per_page: props.perPage
|
|
79
|
+
}
|
|
80
|
+
if (gridState.state.search) params.search = gridState.state.search
|
|
81
|
+
Object.entries(gridState.state.filters).forEach(([key, value]) => {
|
|
82
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
83
|
+
params[key] = value
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
return params
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function loadPage(page: number, append: boolean): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
const response = await props.fetch(buildParams(page))
|
|
92
|
+
if (append) {
|
|
93
|
+
items.value = [...items.value, ...response.data]
|
|
94
|
+
} else {
|
|
95
|
+
items.value = response.data
|
|
96
|
+
}
|
|
97
|
+
currentPage.value = response.meta.current_page
|
|
98
|
+
lastPage.value = response.meta.last_page
|
|
99
|
+
totalItems.value = response.meta.total
|
|
100
|
+
fetchError.value = null
|
|
101
|
+
} catch (e) {
|
|
102
|
+
fetchError.value = e as Error
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function loadMore(): Promise<void> {
|
|
107
|
+
if (loadingMore.value || !hasMore.value) return
|
|
108
|
+
loadingMore.value = true
|
|
109
|
+
try {
|
|
110
|
+
await loadPage(currentPage.value + 1, true)
|
|
111
|
+
} finally {
|
|
112
|
+
loadingMore.value = false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function resetAndFetch(): Promise<void> {
|
|
117
|
+
initialLoading.value = true
|
|
118
|
+
currentPage.value = 0
|
|
119
|
+
selectedIds.value = new Set()
|
|
120
|
+
try {
|
|
121
|
+
await loadPage(1, false)
|
|
122
|
+
} finally {
|
|
123
|
+
initialLoading.value = false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
defineExpose({ refresh: resetAndFetch })
|
|
128
|
+
|
|
129
|
+
// Register contextual shortcuts for the overlay
|
|
130
|
+
const { register: registerShortcut, unregister: unregisterShortcut } = useShortcutRegistry()
|
|
131
|
+
registerShortcut({
|
|
132
|
+
id: 'media',
|
|
133
|
+
label: t('motor-core.shortcuts.media'),
|
|
134
|
+
icon: 'i-lucide-image',
|
|
135
|
+
shortcuts: [
|
|
136
|
+
{ keys: ['meta', t('motor-core.shortcuts.click')], label: t('motor-core.shortcuts.select_multiple'), icon: 'i-lucide-mouse-pointer-click' }
|
|
137
|
+
]
|
|
138
|
+
})
|
|
139
|
+
onUnmounted(() => unregisterShortcut('media'))
|
|
140
|
+
|
|
141
|
+
// SSR-safe initial load (page 1)
|
|
142
|
+
const { error: ssrError } = await useAsyncData(
|
|
143
|
+
`gallery-${props.id}-init`,
|
|
144
|
+
() => loadPage(1, false)
|
|
145
|
+
)
|
|
146
|
+
if (ssrError.value) fetchError.value = ssrError.value
|
|
147
|
+
initialLoading.value = false
|
|
148
|
+
|
|
149
|
+
// Watch filter/search changes — reset accumulated data
|
|
150
|
+
const filterKey = computed(() => JSON.stringify({
|
|
151
|
+
search: gridState.state.search,
|
|
152
|
+
filters: gridState.state.filters
|
|
153
|
+
}))
|
|
154
|
+
|
|
155
|
+
watch(filterKey, () => {
|
|
156
|
+
resetAndFetch()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Intersection observer for infinite scroll
|
|
160
|
+
const sentinelRef = ref<HTMLElement | null>(null)
|
|
161
|
+
|
|
162
|
+
useIntersectionObserver(
|
|
163
|
+
sentinelRef,
|
|
164
|
+
([entry]) => {
|
|
165
|
+
if (entry?.isIntersecting && hasMore.value && !loadingMore.value && !initialLoading.value) {
|
|
166
|
+
loadMore()
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
{ rootMargin: '200px' }
|
|
170
|
+
)
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
<template>
|
|
174
|
+
<div class="flex flex-col gap-4 flex-1 min-h-0">
|
|
175
|
+
<!-- Toolbar (search + filters only, no pagination) -->
|
|
176
|
+
<GridToolbar
|
|
177
|
+
:searchable="searchable"
|
|
178
|
+
:filters="filters"
|
|
179
|
+
:search-value="gridState.state.search"
|
|
180
|
+
:filter-values="gridState.state.filters"
|
|
181
|
+
:has-active-filters="hasActiveFilters"
|
|
182
|
+
@update:search-value="gridState.setSearch($event)"
|
|
183
|
+
@update:filter-value="(key: string, value: unknown) => gridState.setFilter(key, value)"
|
|
184
|
+
@reset-filters="gridState.resetFilters()"
|
|
185
|
+
>
|
|
186
|
+
<template #toolbar-extra>
|
|
187
|
+
<span
|
|
188
|
+
v-if="totalItems > 0"
|
|
189
|
+
class="text-sm text-muted whitespace-nowrap"
|
|
190
|
+
>
|
|
191
|
+
{{ items.length }} / {{ totalItems }}
|
|
192
|
+
</span>
|
|
193
|
+
<slot name="toolbar-extra" />
|
|
194
|
+
</template>
|
|
195
|
+
</GridToolbar>
|
|
196
|
+
|
|
197
|
+
<!-- Bulk Actions -->
|
|
198
|
+
<GridBulkActions
|
|
199
|
+
v-if="bulkActions && bulkActions.length > 0"
|
|
200
|
+
:actions="bulkActions"
|
|
201
|
+
:selected-ids="selectedIdsArray"
|
|
202
|
+
@action-complete="resetAndFetch"
|
|
203
|
+
@clear-selection="clearSelection"
|
|
204
|
+
/>
|
|
205
|
+
|
|
206
|
+
<!-- Scrollable content area -->
|
|
207
|
+
<div class="flex-1 min-h-0 overflow-y-auto p-2">
|
|
208
|
+
<!-- Error state -->
|
|
209
|
+
<div
|
|
210
|
+
v-if="fetchError"
|
|
211
|
+
class="flex flex-col items-center justify-center py-12 gap-3"
|
|
212
|
+
>
|
|
213
|
+
<UIcon
|
|
214
|
+
name="i-lucide-alert-triangle"
|
|
215
|
+
class="text-red-500 size-8"
|
|
216
|
+
/>
|
|
217
|
+
<p class="text-sm text-muted">
|
|
218
|
+
{{ fetchError.message || t('motor-core.grid.fetch_error') }}
|
|
219
|
+
</p>
|
|
220
|
+
<UButton
|
|
221
|
+
:label="t('motor-core.grid.retry')"
|
|
222
|
+
icon="i-lucide-refresh-cw"
|
|
223
|
+
variant="outline"
|
|
224
|
+
size="sm"
|
|
225
|
+
@click="resetAndFetch()"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<!-- Initial loading skeleton -->
|
|
230
|
+
<div
|
|
231
|
+
v-else-if="initialLoading"
|
|
232
|
+
class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4"
|
|
233
|
+
>
|
|
234
|
+
<div
|
|
235
|
+
v-for="i in 12"
|
|
236
|
+
:key="i"
|
|
237
|
+
class="break-inside-avoid mb-4"
|
|
238
|
+
>
|
|
239
|
+
<USkeleton
|
|
240
|
+
class="w-full rounded-xl"
|
|
241
|
+
:style="{ height: `${140 + (i % 4) * 40}px` }"
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<!-- Gallery masonry grid -->
|
|
247
|
+
<template v-else-if="items.length > 0">
|
|
248
|
+
<div class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4">
|
|
249
|
+
<MediaGalleryCard
|
|
250
|
+
v-for="item in items"
|
|
251
|
+
:key="item.id"
|
|
252
|
+
:item="item"
|
|
253
|
+
:selectable="hasBulkActions"
|
|
254
|
+
:selected="selectedIds.has(item.id)"
|
|
255
|
+
@select="toggleSelection"
|
|
256
|
+
@show-usage="emit('show-usage', $event)"
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<!-- Infinite scroll sentinel -->
|
|
261
|
+
<div
|
|
262
|
+
ref="sentinelRef"
|
|
263
|
+
class="flex items-center justify-center py-6"
|
|
264
|
+
>
|
|
265
|
+
<div
|
|
266
|
+
v-if="loadingMore"
|
|
267
|
+
class="flex items-center gap-2 text-sm text-muted"
|
|
268
|
+
>
|
|
269
|
+
<UIcon
|
|
270
|
+
name="i-lucide-loader-2"
|
|
271
|
+
class="size-4 animate-spin"
|
|
272
|
+
/>
|
|
273
|
+
{{ t('motor-core.global.loading') }}
|
|
274
|
+
</div>
|
|
275
|
+
<span
|
|
276
|
+
v-else-if="!hasMore"
|
|
277
|
+
class="text-xs text-dimmed"
|
|
278
|
+
>
|
|
279
|
+
{{ t('motor-media.files.all_loaded') }}
|
|
280
|
+
</span>
|
|
281
|
+
</div>
|
|
282
|
+
</template>
|
|
283
|
+
|
|
284
|
+
<!-- Empty states -->
|
|
285
|
+
<div v-else>
|
|
286
|
+
<div
|
|
287
|
+
v-if="hasActiveFilters"
|
|
288
|
+
class="flex flex-col items-center justify-center py-16 gap-3"
|
|
289
|
+
>
|
|
290
|
+
<UIcon
|
|
291
|
+
name="i-lucide-search-x"
|
|
292
|
+
class="size-10 text-muted"
|
|
293
|
+
/>
|
|
294
|
+
<p class="text-sm font-medium text-muted">
|
|
295
|
+
{{ t('motor-core.grid.no_filter_results') }}
|
|
296
|
+
</p>
|
|
297
|
+
<p class="text-xs text-dimmed">
|
|
298
|
+
{{ t('motor-core.grid.no_filter_results_hint') }}
|
|
299
|
+
</p>
|
|
300
|
+
<UButton
|
|
301
|
+
:label="t('motor-core.grid.clear_filters')"
|
|
302
|
+
icon="i-lucide-x"
|
|
303
|
+
variant="outline"
|
|
304
|
+
size="sm"
|
|
305
|
+
@click="gridState.resetFilters()"
|
|
306
|
+
/>
|
|
307
|
+
</div>
|
|
308
|
+
<div
|
|
309
|
+
v-else
|
|
310
|
+
class="flex flex-col items-center justify-center py-16 gap-3"
|
|
311
|
+
>
|
|
312
|
+
<UIcon
|
|
313
|
+
name="i-lucide-image"
|
|
314
|
+
class="size-10 text-muted"
|
|
315
|
+
/>
|
|
316
|
+
<p class="text-sm font-medium text-muted">
|
|
317
|
+
{{ t('motor-core.grid.no_records') }}
|
|
318
|
+
</p>
|
|
319
|
+
<p class="text-xs text-dimmed">
|
|
320
|
+
{{ t('motor-core.grid.no_records_create') }}
|
|
321
|
+
</p>
|
|
322
|
+
<slot name="empty-action" />
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
</template>
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FileResource } from '../../types/media'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
item: FileResource
|
|
6
|
+
selected?: boolean
|
|
7
|
+
selectable?: boolean
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
'select': [id: number, selected: boolean]
|
|
12
|
+
'show-usage': [fileId: number]
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
function handleCardClick(e: MouseEvent) {
|
|
16
|
+
if (!props.selectable) return
|
|
17
|
+
if (e.metaKey || e.ctrlKey) {
|
|
18
|
+
e.preventDefault()
|
|
19
|
+
e.stopPropagation()
|
|
20
|
+
emit('select', props.item.id, !props.selected)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const createdAt = computed(() => {
|
|
25
|
+
const raw = props.item.file?.created_at
|
|
26
|
+
if (!raw) return ''
|
|
27
|
+
return new Date(raw).toLocaleDateString(undefined, {
|
|
28
|
+
day: '2-digit',
|
|
29
|
+
month: '2-digit',
|
|
30
|
+
year: 'numeric'
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const toast = useToast()
|
|
35
|
+
const { t } = useI18n()
|
|
36
|
+
const lightboxOpen = ref(false)
|
|
37
|
+
|
|
38
|
+
const media = computed(() => props.item.file)
|
|
39
|
+
|
|
40
|
+
const thumbnailUrl = computed(() => {
|
|
41
|
+
// Prefer API url (actual storage/CDN URL) when available
|
|
42
|
+
if (media.value?.url) return media.value.url
|
|
43
|
+
if (!props.item.id) return undefined
|
|
44
|
+
return `${backendBaseUrl}/download/${props.item.id}`
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const isImage = computed(() => media.value?.mime_type?.startsWith('image/'))
|
|
48
|
+
|
|
49
|
+
const fileExtension = computed(() => {
|
|
50
|
+
if (!media.value?.file_name) return ''
|
|
51
|
+
return media.value.file_name.split('.').pop()?.toUpperCase() ?? ''
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const mimeIcon = computed(() => {
|
|
55
|
+
const mime = media.value?.mime_type ?? ''
|
|
56
|
+
if (mime.startsWith('video/')) return 'i-lucide-film'
|
|
57
|
+
if (mime.startsWith('audio/')) return 'i-lucide-music'
|
|
58
|
+
if (mime.includes('pdf')) return 'i-lucide-file-text'
|
|
59
|
+
if (mime.includes('zip') || mime.includes('archive') || mime.includes('compressed')) return 'i-lucide-archive'
|
|
60
|
+
if (mime.includes('spreadsheet') || mime.includes('excel') || mime.includes('csv')) return 'i-lucide-table'
|
|
61
|
+
if (mime.includes('document') || mime.includes('word') || mime.includes('text')) return 'i-lucide-file-text'
|
|
62
|
+
return 'i-lucide-file'
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const runtimeConfig = useRuntimeConfig()
|
|
66
|
+
const backendBaseUrl = runtimeConfig.public.backendBaseUrl as string
|
|
67
|
+
|
|
68
|
+
const downloadUrl = computed(() => {
|
|
69
|
+
if (!props.item.id) return undefined
|
|
70
|
+
return `${backendBaseUrl}/download/${props.item.id}`
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
async function copyUrl() {
|
|
74
|
+
const url = downloadUrl.value
|
|
75
|
+
if (!url) return
|
|
76
|
+
try {
|
|
77
|
+
await navigator.clipboard.writeText(url)
|
|
78
|
+
} catch {
|
|
79
|
+
const textarea = document.createElement('textarea')
|
|
80
|
+
textarea.value = url
|
|
81
|
+
textarea.style.position = 'fixed'
|
|
82
|
+
textarea.style.opacity = '0'
|
|
83
|
+
document.body.appendChild(textarea)
|
|
84
|
+
textarea.select()
|
|
85
|
+
document.execCommand('copy')
|
|
86
|
+
document.body.removeChild(textarea)
|
|
87
|
+
}
|
|
88
|
+
toast.add({
|
|
89
|
+
title: t('motor-media.files.url_copied'),
|
|
90
|
+
icon: 'i-lucide-check',
|
|
91
|
+
color: 'success'
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function forceDownload() {
|
|
96
|
+
const url = downloadUrl.value
|
|
97
|
+
if (!url) return
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch(url, { credentials: 'include' })
|
|
100
|
+
const blob = await response.blob()
|
|
101
|
+
const blobUrl = URL.createObjectURL(blob)
|
|
102
|
+
const a = document.createElement('a')
|
|
103
|
+
a.href = blobUrl
|
|
104
|
+
a.download = media.value?.file_name ?? 'download'
|
|
105
|
+
document.body.appendChild(a)
|
|
106
|
+
a.click()
|
|
107
|
+
document.body.removeChild(a)
|
|
108
|
+
URL.revokeObjectURL(blobUrl)
|
|
109
|
+
} catch {
|
|
110
|
+
const a = document.createElement('a')
|
|
111
|
+
a.href = url
|
|
112
|
+
a.download = media.value?.file_name ?? 'download'
|
|
113
|
+
a.target = '_blank'
|
|
114
|
+
document.body.appendChild(a)
|
|
115
|
+
a.click()
|
|
116
|
+
document.body.removeChild(a)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onCheckboxChange(checked: boolean | 'indeterminate') {
|
|
121
|
+
if (typeof checked === 'boolean') {
|
|
122
|
+
emit('select', props.item.id, checked)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<div class="group break-inside-avoid mb-4">
|
|
129
|
+
<div
|
|
130
|
+
class="relative rounded-xl overflow-hidden ring-1 ring-[var(--ui-border)] bg-[var(--ui-bg-elevated)] shadow-sm hover:shadow-xl hover:ring-[var(--ui-border-accented)] transition-all duration-300"
|
|
131
|
+
:class="{ 'ring-2 ring-[var(--ui-primary)]': selected }"
|
|
132
|
+
@click="handleCardClick"
|
|
133
|
+
>
|
|
134
|
+
<!-- Selection checkbox -->
|
|
135
|
+
<div
|
|
136
|
+
v-if="selectable"
|
|
137
|
+
class="absolute top-2.5 left-2.5 z-10 transition-opacity duration-200"
|
|
138
|
+
:class="selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
|
|
139
|
+
data-no-row-click
|
|
140
|
+
>
|
|
141
|
+
<UCheckbox
|
|
142
|
+
:model-value="selected"
|
|
143
|
+
@update:model-value="onCheckboxChange"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<!-- File type badge -->
|
|
148
|
+
<div
|
|
149
|
+
v-if="fileExtension"
|
|
150
|
+
class="absolute top-2.5 right-2.5 z-10 px-2 py-0.5 rounded-md text-[10px] font-bold tracking-wider uppercase bg-black/50 text-white backdrop-blur-sm"
|
|
151
|
+
>
|
|
152
|
+
{{ fileExtension }}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<!-- Image thumbnail -->
|
|
156
|
+
<NuxtLink
|
|
157
|
+
v-if="isImage && thumbnailUrl"
|
|
158
|
+
:to="`/motor-media/files/${item.id}/edit`"
|
|
159
|
+
class="block"
|
|
160
|
+
>
|
|
161
|
+
<img
|
|
162
|
+
:src="thumbnailUrl"
|
|
163
|
+
:alt="item.description || media?.file_name || ''"
|
|
164
|
+
class="w-full block transition-transform duration-300 group-hover:scale-105"
|
|
165
|
+
loading="lazy"
|
|
166
|
+
>
|
|
167
|
+
</NuxtLink>
|
|
168
|
+
|
|
169
|
+
<!-- Non-image placeholder -->
|
|
170
|
+
<NuxtLink
|
|
171
|
+
v-else
|
|
172
|
+
:to="`/motor-media/files/${item.id}/edit`"
|
|
173
|
+
class="flex aspect-[4/3] flex-col items-center justify-center gap-2 bg-[var(--ui-bg-muted)]"
|
|
174
|
+
>
|
|
175
|
+
<UIcon
|
|
176
|
+
:name="mimeIcon"
|
|
177
|
+
class="size-12 text-[var(--ui-text-dimmed)]"
|
|
178
|
+
/>
|
|
179
|
+
<span class="text-xs font-medium text-[var(--ui-text-muted)]">
|
|
180
|
+
{{ media?.file_name }}
|
|
181
|
+
</span>
|
|
182
|
+
</NuxtLink>
|
|
183
|
+
|
|
184
|
+
<!-- Hover overlay with actions -->
|
|
185
|
+
<div class="absolute inset-0 flex items-center justify-center gap-2 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
|
|
186
|
+
<div class="flex items-center gap-2 pointer-events-auto">
|
|
187
|
+
<button
|
|
188
|
+
v-if="isImage"
|
|
189
|
+
class="flex items-center justify-center size-10 rounded-full bg-white/20 text-white backdrop-blur-sm hover:bg-white/40 transition-colors"
|
|
190
|
+
:title="t('motor-media.files.preview')"
|
|
191
|
+
@click.prevent.stop="lightboxOpen = true"
|
|
192
|
+
>
|
|
193
|
+
<UIcon
|
|
194
|
+
name="i-lucide-expand"
|
|
195
|
+
class="size-5"
|
|
196
|
+
/>
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
class="flex items-center justify-center size-10 rounded-full bg-white/20 text-white backdrop-blur-sm hover:bg-white/40 transition-colors"
|
|
200
|
+
:title="t('motor-core.global.download')"
|
|
201
|
+
@click.prevent.stop="forceDownload"
|
|
202
|
+
>
|
|
203
|
+
<UIcon
|
|
204
|
+
name="i-lucide-download"
|
|
205
|
+
class="size-5"
|
|
206
|
+
/>
|
|
207
|
+
</button>
|
|
208
|
+
<button
|
|
209
|
+
class="flex items-center justify-center size-10 rounded-full bg-white/20 text-white backdrop-blur-sm hover:bg-white/40 transition-colors"
|
|
210
|
+
:title="t('motor-media.files.copy_url')"
|
|
211
|
+
@click.prevent.stop="copyUrl"
|
|
212
|
+
>
|
|
213
|
+
<UIcon
|
|
214
|
+
name="i-lucide-link"
|
|
215
|
+
class="size-5"
|
|
216
|
+
/>
|
|
217
|
+
</button>
|
|
218
|
+
<button
|
|
219
|
+
class="flex items-center justify-center size-10 rounded-full bg-white/20 text-white backdrop-blur-sm hover:bg-white/40 transition-colors"
|
|
220
|
+
:title="t('motor-media.files.usage_title')"
|
|
221
|
+
@click.prevent.stop="emit('show-usage', item.id)"
|
|
222
|
+
>
|
|
223
|
+
<UIcon
|
|
224
|
+
name="i-lucide-network"
|
|
225
|
+
class="size-5"
|
|
226
|
+
/>
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<!-- Bottom info bar -->
|
|
232
|
+
<div class="px-3 py-2.5 border-t border-[var(--ui-border)]">
|
|
233
|
+
<NuxtLink
|
|
234
|
+
:to="`/motor-media/files/${item.id}/edit`"
|
|
235
|
+
class="block truncate text-sm font-medium text-[var(--ui-text)] hover:text-[var(--ui-primary)] transition-colors"
|
|
236
|
+
:title="item.description || media?.file_name"
|
|
237
|
+
>
|
|
238
|
+
{{ item.description || media?.file_name || '-' }}
|
|
239
|
+
</NuxtLink>
|
|
240
|
+
<span
|
|
241
|
+
v-if="createdAt"
|
|
242
|
+
class="text-xs text-[var(--ui-text-muted)]"
|
|
243
|
+
>
|
|
244
|
+
{{ createdAt }}
|
|
245
|
+
</span>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<MediaLightbox
|
|
250
|
+
v-if="isImage"
|
|
251
|
+
v-model:open="lightboxOpen"
|
|
252
|
+
:src="downloadUrl ?? ''"
|
|
253
|
+
:alt="item.description || media?.file_name"
|
|
254
|
+
:file-name="media?.file_name"
|
|
255
|
+
:download-url="downloadUrl"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
</template>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": "Dateien",
|
|
3
|
+
"file": "Datei",
|
|
4
|
+
"title": "Dateien",
|
|
5
|
+
"subtitle": "Dateien verwalten",
|
|
6
|
+
"add": "Datei hinzufuegen",
|
|
7
|
+
"create_title": "Dateien hochladen",
|
|
8
|
+
"edit_title": "Datei bearbeiten",
|
|
9
|
+
"created_success": "Dateien wurden erfolgreich hochgeladen",
|
|
10
|
+
"updated_success": "Datei wurde erfolgreich aktualisiert",
|
|
11
|
+
"description": "Beschreibung",
|
|
12
|
+
"author": "Autor",
|
|
13
|
+
"source": "Quelle",
|
|
14
|
+
"alt_text": "Alt-Text",
|
|
15
|
+
"tags": "Tags",
|
|
16
|
+
"file_name": "Dateiname",
|
|
17
|
+
"mime_type": "MIME-Typ",
|
|
18
|
+
"categories": "Kategorien",
|
|
19
|
+
"client_id": "Mandant",
|
|
20
|
+
"is_global": "Global",
|
|
21
|
+
"is_excluded_from_search_index": "Vom Suchindex ausschliessen",
|
|
22
|
+
"group_basic": "Allgemein",
|
|
23
|
+
"group_categories": "Kategorien",
|
|
24
|
+
"group_settings": "Einstellungen",
|
|
25
|
+
"drop_files": "Dateien hierher ziehen oder klicken zum Auswaehlen",
|
|
26
|
+
"browse": "Durchsuchen",
|
|
27
|
+
"pending_files": "Ausgewaehlte Dateien",
|
|
28
|
+
"no_files_selected": "Keine Dateien ausgewaehlt",
|
|
29
|
+
"current_file": "Aktuelle Datei",
|
|
30
|
+
"replace_file": "Datei ersetzen",
|
|
31
|
+
"url_copied": "URL in die Zwischenablage kopiert",
|
|
32
|
+
"copy_url": "URL kopieren",
|
|
33
|
+
"preview": "Vorschau",
|
|
34
|
+
"upload_progress": "Lade Datei {current} von {total} hoch...",
|
|
35
|
+
"categories_required": "Bitte mindestens eine Kategorie auswaehlen",
|
|
36
|
+
"files_required": "Bitte mindestens eine Datei auswaehlen",
|
|
37
|
+
"view_gallery": "Galerie-Ansicht",
|
|
38
|
+
"view_table": "Tabellen-Ansicht",
|
|
39
|
+
"all_loaded": "Alle Dateien geladen",
|
|
40
|
+
"change": "Ändern",
|
|
41
|
+
"upload": "Hochladen",
|
|
42
|
+
"remove": "Entfernen",
|
|
43
|
+
"avatar_alt": "Profilbild",
|
|
44
|
+
"usage_title": "Datei-Verwendung"
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": "Files",
|
|
3
|
+
"file": "File",
|
|
4
|
+
"title": "Files",
|
|
5
|
+
"subtitle": "Manage files",
|
|
6
|
+
"add": "Upload Files",
|
|
7
|
+
"create_title": "Upload Files",
|
|
8
|
+
"edit_title": "Edit File",
|
|
9
|
+
"created_success": "Files were uploaded successfully",
|
|
10
|
+
"updated_success": "File was updated successfully",
|
|
11
|
+
"description": "Description",
|
|
12
|
+
"author": "Author",
|
|
13
|
+
"source": "Source",
|
|
14
|
+
"alt_text": "Alt Text",
|
|
15
|
+
"tags": "Tags",
|
|
16
|
+
"file_name": "Filename",
|
|
17
|
+
"mime_type": "MIME Type",
|
|
18
|
+
"categories": "Categories",
|
|
19
|
+
"client_id": "Client",
|
|
20
|
+
"is_global": "Global",
|
|
21
|
+
"is_excluded_from_search_index": "Exclude from search index",
|
|
22
|
+
"group_basic": "General",
|
|
23
|
+
"group_categories": "Categories",
|
|
24
|
+
"group_settings": "Settings",
|
|
25
|
+
"drop_files": "Drop files here or click to browse",
|
|
26
|
+
"browse": "Browse",
|
|
27
|
+
"pending_files": "Selected files",
|
|
28
|
+
"no_files_selected": "No files selected",
|
|
29
|
+
"current_file": "Current file",
|
|
30
|
+
"replace_file": "Replace file",
|
|
31
|
+
"url_copied": "URL copied to clipboard",
|
|
32
|
+
"copy_url": "Copy URL",
|
|
33
|
+
"preview": "Preview",
|
|
34
|
+
"upload_progress": "Uploading file {current} of {total}...",
|
|
35
|
+
"categories_required": "Please select at least one category",
|
|
36
|
+
"files_required": "Please select at least one file",
|
|
37
|
+
"view_gallery": "Gallery view",
|
|
38
|
+
"view_table": "Table view",
|
|
39
|
+
"all_loaded": "All files loaded",
|
|
40
|
+
"change": "Change",
|
|
41
|
+
"upload": "Upload",
|
|
42
|
+
"remove": "Remove",
|
|
43
|
+
"avatar_alt": "Profile avatar",
|
|
44
|
+
"usage_title": "File Usage"
|
|
45
|
+
}
|