@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.
@@ -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,4 @@
1
+ {
2
+ "media": "Medien",
3
+ "section_subtitle": "Dateien und Medieninhalte verwalten"
4
+ }
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "media": "Media"
3
+ }
@@ -0,0 +1,7 @@
1
+ <claude-mem-context>
2
+ # Recent Activity
3
+
4
+ <!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
5
+
6
+ *No recent activity*
7
+ </claude-mem-context>