@bobbykim/manguito-cms-admin 0.1.1 → 0.3.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.
Files changed (90) hide show
  1. package/dist/assets/{ChangePasswordView-8UW4RWd8.js → ChangePasswordView-B0wg3O_I.js} +1 -1
  2. package/dist/assets/{EnumSelect-BOrBkA76.js → ComputedDisplay-C2FpwSB1.js} +2 -2
  3. package/dist/assets/ContentFormView-D92O0ICh.js +1 -0
  4. package/dist/assets/{ContentListView-BiPHUBHa.js → ContentListView-DM4xggDx.js} +1 -1
  5. package/dist/assets/{ContentSettingsView-DPKF5MRL.js → ContentSettingsView-B9UHNleT.js} +1 -1
  6. package/dist/assets/{LoginView-WELPH4Vh.js → LoginView-DPpdT3Ro.js} +1 -1
  7. package/dist/assets/{MediaDetailView-BT66fV9b.js → MediaDetailView-OUzzhO1B.js} +1 -1
  8. package/dist/assets/{MediaLibraryView-BcdiJ_7a.js → MediaLibraryView-Dx9t8r8T.js} +1 -1
  9. package/dist/assets/TaxonomyFormView-CZlOjxOY.js +1 -0
  10. package/dist/assets/{TaxonomyListView-BB7iTfjb.js → TaxonomyListView-Dkga11a3.js} +1 -1
  11. package/dist/assets/{UserFormView-C2p-FV0A.js → UserFormView-CaOcoKX5.js} +1 -1
  12. package/dist/assets/{UserListView-BnlU2Ovr.js → UserListView-B6_Hdemt.js} +1 -1
  13. package/dist/assets/{index-BkazhAun.js → index-s-2X8XCa.js} +2 -2
  14. package/dist/assets/{useNotification-DU6J5vms.js → useNotification-CEPVkHak.js} +1 -1
  15. package/dist/codegen/index.cjs +7 -2
  16. package/dist/codegen/index.js +7 -2
  17. package/dist/index.html +1 -1
  18. package/index.html +13 -0
  19. package/package.json +11 -7
  20. package/public/favicon.ico +0 -0
  21. package/src/App.vue +147 -0
  22. package/src/adapters/admin.ts +13 -0
  23. package/src/components/.gitkeep +0 -0
  24. package/src/components/fields/BooleanToggle.vue +63 -0
  25. package/src/components/fields/ComputedDisplay.vue +23 -0
  26. package/src/components/fields/DatePicker.vue +61 -0
  27. package/src/components/fields/EnumSelect.vue +69 -0
  28. package/src/components/fields/MediaUpload.vue +445 -0
  29. package/src/components/fields/NumberInput.vue +66 -0
  30. package/src/components/fields/ParagraphEmbed.vue +149 -0
  31. package/src/components/fields/ReferenceSelect.vue +275 -0
  32. package/src/components/fields/RichTextEditor.vue +293 -0
  33. package/src/components/fields/TextInput.vue +93 -0
  34. package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
  35. package/src/components/fields/__tests__/ComputedDisplay.test.ts +34 -0
  36. package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
  37. package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
  38. package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
  39. package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
  40. package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
  41. package/src/components/fields/__tests__/TextInput.test.ts +72 -0
  42. package/src/components/layout/AppShell.vue +33 -0
  43. package/src/components/layout/Footer.vue +11 -0
  44. package/src/components/layout/Sidebar.vue +254 -0
  45. package/src/components/layout/Topbar.vue +46 -0
  46. package/src/components/shared/ConfirmDialog.vue +38 -0
  47. package/src/components/shared/MediaSelectModal.vue +273 -0
  48. package/src/components/shared/Pagination.vue +93 -0
  49. package/src/components/shared/StatusBadge.vue +14 -0
  50. package/src/components/shared/ToastContainer.vue +44 -0
  51. package/src/composables/.gitkeep +0 -0
  52. package/src/composables/__tests__/useApiClient.test.ts +90 -0
  53. package/src/composables/__tests__/useFormValidation.test.ts +83 -0
  54. package/src/composables/__tests__/usePermission.test.ts +116 -0
  55. package/src/composables/useApiClient.ts +68 -0
  56. package/src/composables/useDirtyState.ts +28 -0
  57. package/src/composables/useFormValidation.ts +107 -0
  58. package/src/composables/useNotification.ts +26 -0
  59. package/src/composables/usePermission.ts +20 -0
  60. package/src/composables/useTabIndicator.ts +25 -0
  61. package/src/env.d.ts +2 -0
  62. package/src/main.ts +21 -0
  63. package/src/router/index.ts +174 -0
  64. package/src/stores/auth.ts +82 -0
  65. package/src/stores/content.ts +40 -0
  66. package/src/stores/media.ts +31 -0
  67. package/src/stores/schema.ts +130 -0
  68. package/src/stores/taxonomy.ts +40 -0
  69. package/src/stores/ui.ts +94 -0
  70. package/src/stores/users.ts +44 -0
  71. package/src/style.css +46 -0
  72. package/src/test-utils/server.ts +54 -0
  73. package/src/types/index.ts +0 -0
  74. package/src/views/.gitkeep +0 -0
  75. package/src/views/ChangePasswordView.vue +134 -0
  76. package/src/views/LoginView.vue +187 -0
  77. package/src/views/RolesView.vue +84 -0
  78. package/src/views/content/ContentFormView.vue +553 -0
  79. package/src/views/content/ContentListView.vue +236 -0
  80. package/src/views/content/ContentSettingsView.vue +122 -0
  81. package/src/views/media/MediaDetailView.vue +222 -0
  82. package/src/views/media/MediaLibraryView.vue +492 -0
  83. package/src/views/taxonomy/TaxonomyFormView.vue +286 -0
  84. package/src/views/taxonomy/TaxonomyListView.vue +198 -0
  85. package/src/views/users/UserFormView.vue +307 -0
  86. package/src/views/users/UserListView.vue +127 -0
  87. package/src/vite-env.d.ts +1 -0
  88. package/vite.config.ts +17 -0
  89. package/dist/assets/ContentFormView-vaIInaYs.js +0 -1
  90. package/dist/assets/TaxonomyFormView-BdRO9L-7.js +0 -1
@@ -0,0 +1,492 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, watch } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import type { MediaItem } from '@bobbykim/manguito-cms-core'
5
+ import { useApiClient } from '../../composables/useApiClient'
6
+ import { usePermission } from '../../composables/usePermission'
7
+ import { useNotification } from '../../composables/useNotification'
8
+ import { useUiStore } from '../../stores/ui'
9
+ import { useMediaStore } from '../../stores/media'
10
+ import { useTabIndicator } from '../../composables/useTabIndicator'
11
+ import Pagination from '../../components/shared/Pagination.vue'
12
+ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
13
+
14
+ const router = useRouter()
15
+ const api = useApiClient()
16
+ const { can } = usePermission()
17
+ const notify = useNotification()
18
+ const uiStore = useUiStore()
19
+ const mediaStore = useMediaStore()
20
+
21
+ // ── Tab state ─────────────────────────────────────────────────────────────────
22
+
23
+ type MediaTab = 'all' | 'image' | 'video' | 'file' | 'orphaned'
24
+
25
+ const activeTab = ref<MediaTab>('all')
26
+ const isOrphanedTab = computed(() => activeTab.value === 'orphaned')
27
+
28
+ const TABS: { value: MediaTab; label: string }[] = [
29
+ { value: 'all', label: 'All' },
30
+ { value: 'image', label: 'Images' },
31
+ { value: 'video', label: 'Videos' },
32
+ { value: 'file', label: 'PDFs' },
33
+ { value: 'orphaned', label: 'Orphaned' },
34
+ ]
35
+
36
+ const tabBarRef = ref<HTMLElement | null>(null)
37
+ const { left: tabIndLeft, width: tabIndWidth } = useTabIndicator(tabBarRef, activeTab)
38
+
39
+ // ── Grid data ─────────────────────────────────────────────────────────────────
40
+
41
+ const items = ref<MediaItem[]>([])
42
+ const total = ref(0)
43
+ const page = ref(1)
44
+ const PER_PAGE = 20
45
+ const loading = ref(false)
46
+
47
+ function buildPath(tab: MediaTab, p: number): string {
48
+ const params = new URLSearchParams({ page: String(p), per_page: String(PER_PAGE) })
49
+ if (tab === 'orphaned') {
50
+ params.set('orphaned', 'true')
51
+ } else if (tab !== 'all') {
52
+ params.set('type', tab)
53
+ }
54
+ return `/media?${params.toString()}`
55
+ }
56
+
57
+ async function loadPage(tab: MediaTab, p: number) {
58
+ loading.value = true
59
+ const res = await api.get<MediaItem[]>(buildPath(tab, p))
60
+ loading.value = false
61
+ if (res.ok) {
62
+ items.value = res.data
63
+ const raw = res as unknown as { meta?: { total: number } }
64
+ total.value = raw.meta?.total ?? res.data.length
65
+ mediaStore.setItems(res.data)
66
+ }
67
+ }
68
+
69
+ onMounted(() => { void loadPage(activeTab.value, page.value) })
70
+
71
+ watch(activeTab, (tab) => {
72
+ page.value = 1
73
+ selectedIds.value.clear()
74
+ void loadPage(tab, 1)
75
+ })
76
+
77
+ function onPageChange(p: number) {
78
+ page.value = p
79
+ void loadPage(activeTab.value, p)
80
+ }
81
+
82
+ // ── Navigation ────────────────────────────────────────────────────────────────
83
+
84
+ function goToDetail(id: string) {
85
+ void router.push({ name: 'media-detail', params: { id } })
86
+ }
87
+
88
+ // ── Bulk delete (orphaned tab only) ───────────────────────────────────────────
89
+
90
+ const selectedIds = ref(new Set<string>())
91
+ const showBulkConfirm = ref(false)
92
+
93
+ function toggleSelect(id: string) {
94
+ if (selectedIds.value.has(id)) {
95
+ selectedIds.value.delete(id)
96
+ } else {
97
+ selectedIds.value.add(id)
98
+ }
99
+ }
100
+
101
+ function toggleSelectAll() {
102
+ if (selectedIds.value.size === items.value.length) {
103
+ selectedIds.value.clear()
104
+ } else {
105
+ selectedIds.value = new Set(items.value.map(i => i.id))
106
+ }
107
+ }
108
+
109
+ async function doBulkDelete() {
110
+ showBulkConfirm.value = false
111
+ const ids = [...selectedIds.value]
112
+ for (const id of ids) {
113
+ await api.del(`/media/${id}`)
114
+ mediaStore.removeItem(id)
115
+ }
116
+ items.value = items.value.filter(i => !ids.includes(i.id))
117
+ total.value = Math.max(0, total.value - ids.length)
118
+ selectedIds.value.clear()
119
+ notify.success(`Deleted ${ids.length} item${ids.length !== 1 ? 's' : ''}.`)
120
+ }
121
+
122
+ // ── Upload flow ───────────────────────────────────────────────────────────────
123
+
124
+ type UploadPhase = 'idle' | 'alt-collect' | 'uploading'
125
+
126
+ const uploadPhase = ref<UploadPhase>('idle')
127
+ const pendingFile = ref<File | null>(null)
128
+ const pendingMediaType = ref<'image' | 'video' | 'file'>('file')
129
+ const altText = ref('')
130
+ const uploadProgress = ref(0)
131
+ const uploadError = ref('')
132
+ const fileInputRef = ref<HTMLInputElement | null>(null)
133
+
134
+ function detectMediaType(file: File): 'image' | 'video' | 'file' {
135
+ if (file.type.startsWith('image/')) return 'image'
136
+ if (file.type.startsWith('video/')) return 'video'
137
+ return 'file'
138
+ }
139
+
140
+ function formatSize(bytes: number): string {
141
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`
142
+ if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} KB`
143
+ return `${bytes} B`
144
+ }
145
+
146
+ function onFileSelected(e: Event) {
147
+ const file = (e.target as HTMLInputElement).files?.[0]
148
+ if (!file) return
149
+ ;(e.target as HTMLInputElement).value = ''
150
+
151
+ pendingFile.value = file
152
+ pendingMediaType.value = detectMediaType(file)
153
+ altText.value = ''
154
+ uploadError.value = ''
155
+
156
+ if (pendingMediaType.value !== 'image') {
157
+ // Video / file: collect required alt before upload.
158
+ uploadPhase.value = 'alt-collect'
159
+ } else {
160
+ void startUpload(file)
161
+ }
162
+ }
163
+
164
+ function uploadDirect(file: File, alt: string): Promise<MediaItem> {
165
+ return new Promise((resolve, reject) => {
166
+ const form = new FormData()
167
+ form.append('file', file)
168
+ if (alt) form.append('alt', alt)
169
+
170
+ const xhr = new XMLHttpRequest()
171
+ xhr.withCredentials = true
172
+ xhr.open('POST', `${__ADMIN_PREFIX__}/api/media/${pendingMediaType.value}`)
173
+
174
+ xhr.upload.onprogress = (ev) => {
175
+ if (ev.lengthComputable) {
176
+ uploadProgress.value = Math.round((ev.loaded / ev.total) * 100)
177
+ }
178
+ }
179
+
180
+ xhr.onload = () => {
181
+ try {
182
+ const json = JSON.parse(xhr.responseText) as { ok: boolean; data?: MediaItem; error?: { message: string } }
183
+ json.ok && json.data ? resolve(json.data) : reject(new Error(json.error?.message ?? 'Upload failed'))
184
+ } catch {
185
+ reject(new Error('Invalid server response'))
186
+ }
187
+ }
188
+ xhr.onerror = () => reject(new Error('Network error'))
189
+ xhr.send(form)
190
+ })
191
+ }
192
+
193
+ async function uploadPresigned(file: File, alt: string): Promise<MediaItem> {
194
+ const params = new URLSearchParams({ type: pendingMediaType.value, filename: file.name, mime_type: file.type })
195
+ const presignedRes = await fetch(`${__ADMIN_PREFIX__}/api/media/presigned-url?${params.toString()}`, { credentials: 'include' })
196
+ const presignedJson = await presignedRes.json() as { ok: boolean; data?: { upload_url: string; media_id: string; method?: 'PUT' | 'POST'; fields?: Record<string, string> }; error?: { message: string } }
197
+ if (!presignedJson.ok || !presignedJson.data) throw new Error(presignedJson.error?.message ?? 'Failed to get upload URL')
198
+
199
+ const { upload_url, media_id, method, fields } = presignedJson.data
200
+
201
+ await new Promise<void>((resolve, reject) => {
202
+ const xhr = new XMLHttpRequest()
203
+ xhr.upload.onprogress = (ev) => {
204
+ if (ev.lengthComputable) uploadProgress.value = Math.round((ev.loaded / ev.total) * 100)
205
+ }
206
+ xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Storage upload failed (${xhr.status})`)))
207
+ xhr.onerror = () => reject(new Error('Network error'))
208
+ if (method === 'POST' && fields) {
209
+ // Cloudinary: multipart POST with signed fields + the file.
210
+ const form = new FormData()
211
+ for (const [k, v] of Object.entries(fields)) form.append(k, v)
212
+ form.append('file', file)
213
+ xhr.open('POST', upload_url)
214
+ xhr.send(form)
215
+ } else {
216
+ // S3: raw PUT to the presigned URL.
217
+ xhr.open('PUT', upload_url)
218
+ xhr.send(file)
219
+ }
220
+ })
221
+
222
+ const confirmRes = await fetch(`${__ADMIN_PREFIX__}/api/media/confirm/${media_id}`, {
223
+ method: 'POST', credentials: 'include',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({ alt }),
226
+ })
227
+ const confirmJson = await confirmRes.json() as { ok: boolean; data?: MediaItem; error?: { message: string } }
228
+ if (!confirmJson.ok || !confirmJson.data) throw new Error(confirmJson.error?.message ?? 'Upload confirmation failed')
229
+ return confirmJson.data
230
+ }
231
+
232
+ async function startUpload(file: File) {
233
+ uploadPhase.value = 'uploading'
234
+ uploadProgress.value = 0
235
+ uploadError.value = ''
236
+
237
+ try {
238
+ const alt = altText.value.trim()
239
+ // Cloud storage uploads straight to the bucket via presigned URL; local
240
+ // storage routes through the server's direct endpoint.
241
+ const newItem = uiStore.presignedUploads
242
+ ? await uploadPresigned(file, alt)
243
+ : await uploadDirect(file, alt)
244
+
245
+ items.value = [newItem, ...items.value]
246
+ total.value += 1
247
+ mediaStore.setItem(newItem)
248
+ notify.success('Upload complete.')
249
+ } catch (e) {
250
+ uploadError.value = e instanceof Error ? e.message : 'Upload failed'
251
+ } finally {
252
+ uploadPhase.value = 'idle'
253
+ pendingFile.value = null
254
+ }
255
+ }
256
+
257
+ function startUploadFromForm() {
258
+ if (!pendingFile.value) return
259
+ void startUpload(pendingFile.value)
260
+ }
261
+
262
+ function cancelUpload() {
263
+ pendingFile.value = null
264
+ altText.value = ''
265
+ uploadError.value = ''
266
+ uploadPhase.value = 'idle'
267
+ }
268
+ </script>
269
+
270
+ <template>
271
+ <div>
272
+ <!-- Page header -->
273
+ <div class="mb-[18px] flex flex-wrap items-center justify-between gap-4">
274
+ <h1 class="text-[26px] font-bold tracking-tight text-ink">Media library</h1>
275
+
276
+ <!-- Upload button -->
277
+ <div v-if="can('media:create')">
278
+ <input
279
+ ref="fileInputRef"
280
+ type="file"
281
+ class="hidden"
282
+ accept="image/*,video/*,application/pdf,.pdf"
283
+ @change="onFileSelected"
284
+ />
285
+ <button
286
+ type="button"
287
+ class="inline-flex items-center gap-1.75 rounded-[11px] bg-indigo-600 px-4 py-2.5 text-[13.5px] font-semibold text-white shadow-[0_3px_10px_rgba(91,87,232,0.3)] transition-all hover:bg-indigo-700 hover:shadow-[0_6px_18px_rgba(91,87,232,0.4)]"
288
+ @click="fileInputRef?.click()"
289
+ >
290
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
291
+ Upload
292
+ </button>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Upload progress panel -->
297
+ <div
298
+ v-if="uploadPhase !== 'idle'"
299
+ class="mb-5 rounded-md border border-gray-200 bg-white p-4 shadow-sm"
300
+ >
301
+ <!-- Alt text collection for video / file -->
302
+ <div v-if="uploadPhase === 'alt-collect'" class="space-y-3">
303
+ <p class="text-sm font-medium text-gray-700">{{ pendingFile?.name }}</p>
304
+ <div>
305
+ <label for="upload-alt" class="block text-xs text-gray-600">
306
+ Alt text <span class="text-red-500">*</span>
307
+ </label>
308
+ <input
309
+ id="upload-alt"
310
+ v-model="altText"
311
+ type="text"
312
+ placeholder="Describe this file"
313
+ class="mt-0.5 block w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200"
314
+ />
315
+ </div>
316
+ <div class="flex gap-2">
317
+ <button
318
+ type="button"
319
+ class="rounded-md px-3 py-1.5 text-sm text-gray-600 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
320
+ @click="cancelUpload"
321
+ >
322
+ Cancel
323
+ </button>
324
+ <button
325
+ type="button"
326
+ :disabled="!altText.trim()"
327
+ class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
328
+ @click="startUploadFromForm"
329
+ >
330
+ Upload
331
+ </button>
332
+ </div>
333
+ </div>
334
+
335
+ <!-- Upload in progress -->
336
+ <div v-else-if="uploadPhase === 'uploading'" class="space-y-2">
337
+ <p class="text-sm text-gray-500">Uploading{{ pendingFile ? ` ${pendingFile.name}` : '…' }}</p>
338
+ <div class="flex items-center gap-2">
339
+ <div class="h-2 flex-1 overflow-hidden rounded-full bg-gray-100">
340
+ <div
341
+ class="h-2 rounded-full bg-indigo-600 transition-all duration-150"
342
+ :style="{ width: `${uploadProgress}%` }"
343
+ />
344
+ </div>
345
+ <span class="w-8 shrink-0 text-right text-xs text-gray-500">{{ uploadProgress }}%</span>
346
+ </div>
347
+ </div>
348
+
349
+ <p v-if="uploadError" class="mt-2 text-sm text-red-600" role="alert">{{ uploadError }}</p>
350
+ </div>
351
+
352
+ <!-- Tab bar -->
353
+ <div ref="tabBarRef" class="relative mb-[22px] flex gap-0 overflow-x-auto border-b border-card-border">
354
+ <button
355
+ v-for="tab in TABS"
356
+ :key="tab.value"
357
+ :data-tab="tab.value"
358
+ type="button"
359
+ :class="[
360
+ 'mr-[22px] whitespace-nowrap bg-none px-0.5 py-[11px] text-sm transition-colors',
361
+ activeTab === tab.value ? 'font-semibold text-indigo-600' : 'font-medium text-faint hover:text-[#8A8A9E]',
362
+ ]"
363
+ @click="activeTab = tab.value"
364
+ >
365
+ {{ tab.label }}
366
+ </button>
367
+ <div
368
+ class="absolute bottom-[-1px] h-[2px] rounded-full bg-indigo-600 transition-[left,width] duration-300"
369
+ :style="{ left: `${tabIndLeft}px`, width: `${tabIndWidth}px` }"
370
+ />
371
+ </div>
372
+
373
+ <!-- Bulk delete toolbar (orphaned tab) -->
374
+ <div
375
+ v-if="isOrphanedTab && items.length > 0 && can('media:delete')"
376
+ class="mb-3 flex items-center gap-3"
377
+ >
378
+ <label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
379
+ <input
380
+ type="checkbox"
381
+ :checked="selectedIds.size === items.length && items.length > 0"
382
+ :indeterminate="selectedIds.size > 0 && selectedIds.size < items.length"
383
+ class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
384
+ @change="toggleSelectAll"
385
+ />
386
+ Select all
387
+ </label>
388
+ <button
389
+ v-if="selectedIds.size > 0"
390
+ type="button"
391
+ class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
392
+ @click="showBulkConfirm = true"
393
+ >
394
+ Delete {{ selectedIds.size }} selected
395
+ </button>
396
+ </div>
397
+
398
+ <!-- Loading skeleton -->
399
+ <div v-if="loading" class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
400
+ <div
401
+ v-for="n in 12"
402
+ :key="n"
403
+ class="aspect-square animate-pulse rounded-[14px] bg-gray-100"
404
+ />
405
+ </div>
406
+
407
+ <!-- Empty state -->
408
+ <div
409
+ v-else-if="items.length === 0"
410
+ class="rounded-2xl border border-dashed border-gray-300 p-12 text-center"
411
+ >
412
+ <p class="text-sm text-muted">
413
+ {{ isOrphanedTab ? 'No orphaned files.' : 'No media files yet.' }}
414
+ </p>
415
+ </div>
416
+
417
+ <!-- Media grid -->
418
+ <div
419
+ v-else
420
+ class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6"
421
+ >
422
+ <div
423
+ v-for="item in items"
424
+ :key="item.id"
425
+ class="group relative aspect-square"
426
+ >
427
+ <!-- Selection checkbox (orphaned tab only) -->
428
+ <label
429
+ v-if="isOrphanedTab && can('media:delete')"
430
+ class="absolute left-1.5 top-1.5 z-10 cursor-pointer"
431
+ @click.stop
432
+ >
433
+ <input
434
+ type="checkbox"
435
+ :checked="selectedIds.has(item.id)"
436
+ class="rounded border-gray-300 bg-white text-indigo-600 shadow focus:ring-indigo-500"
437
+ @change="toggleSelect(item.id)"
438
+ />
439
+ </label>
440
+
441
+ <!-- Clickable item card -->
442
+ <button
443
+ type="button"
444
+ class="h-full w-full overflow-hidden rounded-[14px] border border-card-border shadow-[0_1px_2px_rgba(24,24,48,0.04),0_8px_22px_rgba(24,24,48,0.05)] transition-all hover:-translate-y-[3px] hover:shadow-[0_12px_30px_rgba(24,24,48,0.12)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
445
+ :aria-label="`View ${item.alt ?? item.url.split('/').pop()}`"
446
+ @click="goToDetail(item.id)"
447
+ >
448
+ <!-- Image thumbnail -->
449
+ <img
450
+ v-if="item.type === 'image'"
451
+ :src="item.url"
452
+ :alt="item.alt ?? ''"
453
+ class="h-full w-full object-cover"
454
+ loading="lazy"
455
+ />
456
+
457
+ <!-- Video / file icon -->
458
+ <div
459
+ v-else
460
+ class="flex h-full w-full flex-col items-center justify-center gap-1 bg-gray-50 p-1 text-gray-400"
461
+ >
462
+ <span class="text-3xl" aria-hidden="true">
463
+ {{ item.type === 'video' ? '▶' : '📄' }}
464
+ </span>
465
+ <span class="max-w-full truncate text-center text-xs">
466
+ {{ item.url.split('/').pop() }}
467
+ </span>
468
+ <span class="text-xs text-gray-300">{{ formatSize(item.file_size) }}</span>
469
+ </div>
470
+ </button>
471
+ </div>
472
+ </div>
473
+
474
+ <!-- Pagination -->
475
+ <div v-if="!loading && total > PER_PAGE" class="mt-5 flex justify-center">
476
+ <Pagination
477
+ :total="total"
478
+ :page="page"
479
+ :per-page="PER_PAGE"
480
+ @update:page="onPageChange"
481
+ />
482
+ </div>
483
+
484
+ <!-- Bulk delete confirmation -->
485
+ <ConfirmDialog
486
+ v-if="showBulkConfirm"
487
+ :message="`Permanently delete ${selectedIds.size} file${selectedIds.size !== 1 ? 's' : ''}? This cannot be undone.`"
488
+ :on-confirm="doBulkDelete"
489
+ :on-cancel="() => (showBulkConfirm = false)"
490
+ />
491
+ </div>
492
+ </template>