@bobbykim/manguito-cms-admin 0.1.1 → 0.2.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/index.html +13 -0
- package/package.json +10 -6
- package/public/favicon.ico +0 -0
- package/src/App.vue +147 -0
- package/src/adapters/admin.ts +13 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/fields/BooleanToggle.vue +63 -0
- package/src/components/fields/DatePicker.vue +61 -0
- package/src/components/fields/EnumSelect.vue +69 -0
- package/src/components/fields/MediaUpload.vue +445 -0
- package/src/components/fields/NumberInput.vue +66 -0
- package/src/components/fields/ParagraphEmbed.vue +149 -0
- package/src/components/fields/ReferenceSelect.vue +275 -0
- package/src/components/fields/RichTextEditor.vue +293 -0
- package/src/components/fields/TextInput.vue +93 -0
- package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
- package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
- package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
- package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
- package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
- package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
- package/src/components/fields/__tests__/TextInput.test.ts +72 -0
- package/src/components/layout/AppShell.vue +33 -0
- package/src/components/layout/Footer.vue +11 -0
- package/src/components/layout/Sidebar.vue +254 -0
- package/src/components/layout/Topbar.vue +46 -0
- package/src/components/shared/ConfirmDialog.vue +38 -0
- package/src/components/shared/MediaSelectModal.vue +273 -0
- package/src/components/shared/Pagination.vue +93 -0
- package/src/components/shared/StatusBadge.vue +14 -0
- package/src/components/shared/ToastContainer.vue +44 -0
- package/src/composables/.gitkeep +0 -0
- package/src/composables/__tests__/useApiClient.test.ts +90 -0
- package/src/composables/__tests__/useFormValidation.test.ts +83 -0
- package/src/composables/__tests__/usePermission.test.ts +116 -0
- package/src/composables/useApiClient.ts +68 -0
- package/src/composables/useDirtyState.ts +28 -0
- package/src/composables/useFormValidation.ts +107 -0
- package/src/composables/useNotification.ts +26 -0
- package/src/composables/usePermission.ts +20 -0
- package/src/composables/useTabIndicator.ts +25 -0
- package/src/env.d.ts +2 -0
- package/src/main.ts +21 -0
- package/src/router/index.ts +174 -0
- package/src/stores/auth.ts +82 -0
- package/src/stores/content.ts +40 -0
- package/src/stores/media.ts +31 -0
- package/src/stores/schema.ts +130 -0
- package/src/stores/taxonomy.ts +40 -0
- package/src/stores/ui.ts +94 -0
- package/src/stores/users.ts +44 -0
- package/src/style.css +46 -0
- package/src/test-utils/server.ts +54 -0
- package/src/types/index.ts +0 -0
- package/src/views/.gitkeep +0 -0
- package/src/views/ChangePasswordView.vue +134 -0
- package/src/views/LoginView.vue +187 -0
- package/src/views/RolesView.vue +84 -0
- package/src/views/content/ContentFormView.vue +548 -0
- package/src/views/content/ContentListView.vue +236 -0
- package/src/views/content/ContentSettingsView.vue +122 -0
- package/src/views/media/MediaDetailView.vue +222 -0
- package/src/views/media/MediaLibraryView.vue +492 -0
- package/src/views/taxonomy/TaxonomyFormView.vue +281 -0
- package/src/views/taxonomy/TaxonomyListView.vue +198 -0
- package/src/views/users/UserFormView.vue +307 -0
- package/src/views/users/UserListView.vue +127 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.config.ts +17 -0
|
@@ -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>
|