@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.
Files changed (69) hide show
  1. package/index.html +13 -0
  2. package/package.json +10 -6
  3. package/public/favicon.ico +0 -0
  4. package/src/App.vue +147 -0
  5. package/src/adapters/admin.ts +13 -0
  6. package/src/components/.gitkeep +0 -0
  7. package/src/components/fields/BooleanToggle.vue +63 -0
  8. package/src/components/fields/DatePicker.vue +61 -0
  9. package/src/components/fields/EnumSelect.vue +69 -0
  10. package/src/components/fields/MediaUpload.vue +445 -0
  11. package/src/components/fields/NumberInput.vue +66 -0
  12. package/src/components/fields/ParagraphEmbed.vue +149 -0
  13. package/src/components/fields/ReferenceSelect.vue +275 -0
  14. package/src/components/fields/RichTextEditor.vue +293 -0
  15. package/src/components/fields/TextInput.vue +93 -0
  16. package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
  17. package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
  18. package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
  19. package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
  20. package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
  21. package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
  22. package/src/components/fields/__tests__/TextInput.test.ts +72 -0
  23. package/src/components/layout/AppShell.vue +33 -0
  24. package/src/components/layout/Footer.vue +11 -0
  25. package/src/components/layout/Sidebar.vue +254 -0
  26. package/src/components/layout/Topbar.vue +46 -0
  27. package/src/components/shared/ConfirmDialog.vue +38 -0
  28. package/src/components/shared/MediaSelectModal.vue +273 -0
  29. package/src/components/shared/Pagination.vue +93 -0
  30. package/src/components/shared/StatusBadge.vue +14 -0
  31. package/src/components/shared/ToastContainer.vue +44 -0
  32. package/src/composables/.gitkeep +0 -0
  33. package/src/composables/__tests__/useApiClient.test.ts +90 -0
  34. package/src/composables/__tests__/useFormValidation.test.ts +83 -0
  35. package/src/composables/__tests__/usePermission.test.ts +116 -0
  36. package/src/composables/useApiClient.ts +68 -0
  37. package/src/composables/useDirtyState.ts +28 -0
  38. package/src/composables/useFormValidation.ts +107 -0
  39. package/src/composables/useNotification.ts +26 -0
  40. package/src/composables/usePermission.ts +20 -0
  41. package/src/composables/useTabIndicator.ts +25 -0
  42. package/src/env.d.ts +2 -0
  43. package/src/main.ts +21 -0
  44. package/src/router/index.ts +174 -0
  45. package/src/stores/auth.ts +82 -0
  46. package/src/stores/content.ts +40 -0
  47. package/src/stores/media.ts +31 -0
  48. package/src/stores/schema.ts +130 -0
  49. package/src/stores/taxonomy.ts +40 -0
  50. package/src/stores/ui.ts +94 -0
  51. package/src/stores/users.ts +44 -0
  52. package/src/style.css +46 -0
  53. package/src/test-utils/server.ts +54 -0
  54. package/src/types/index.ts +0 -0
  55. package/src/views/.gitkeep +0 -0
  56. package/src/views/ChangePasswordView.vue +134 -0
  57. package/src/views/LoginView.vue +187 -0
  58. package/src/views/RolesView.vue +84 -0
  59. package/src/views/content/ContentFormView.vue +548 -0
  60. package/src/views/content/ContentListView.vue +236 -0
  61. package/src/views/content/ContentSettingsView.vue +122 -0
  62. package/src/views/media/MediaDetailView.vue +222 -0
  63. package/src/views/media/MediaLibraryView.vue +492 -0
  64. package/src/views/taxonomy/TaxonomyFormView.vue +281 -0
  65. package/src/views/taxonomy/TaxonomyListView.vue +198 -0
  66. package/src/views/users/UserFormView.vue +307 -0
  67. package/src/views/users/UserListView.vue +127 -0
  68. package/src/vite-env.d.ts +1 -0
  69. package/vite.config.ts +17 -0
@@ -0,0 +1,236 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, watch } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { useDebounceFn } from '@vueuse/core'
5
+ import { useApiClient } from '../../composables/useApiClient'
6
+ import { usePermission } from '../../composables/usePermission'
7
+ import { useSchemaStore } from '../../stores/schema'
8
+ import { useContentStore, DEFAULT_PAGE_SIZE } from '../../stores/content'
9
+ import Pagination from '../../components/shared/Pagination.vue'
10
+ import StatusBadge from '../../components/shared/StatusBadge.vue'
11
+
12
+ const route = useRoute()
13
+ const router = useRouter()
14
+ const api = useApiClient()
15
+ const { can } = usePermission()
16
+ const schemaStore = useSchemaStore()
17
+ const contentStore = useContentStore()
18
+
19
+ const type = computed(() => route.params.type as string)
20
+ const contentType = computed(() => schemaStore.contentTypes[type.value])
21
+ const isSingleton = computed(() => contentType.value?.only_one === true)
22
+
23
+ // First text/plain field — used as the title column.
24
+ const titleField = computed(
25
+ () => contentType.value?.fields.find(f => f.field_type === 'text/plain')?.name ?? 'id'
26
+ )
27
+
28
+ const items = ref<Record<string, unknown>[]>([])
29
+ const total = ref(0)
30
+ const page = ref(1)
31
+ const loading = ref(false)
32
+ const search = ref('')
33
+
34
+ function formatDate(val: unknown): string {
35
+ if (typeof val !== 'string' || !val) return '—'
36
+ return new Date(val).toLocaleDateString(undefined, {
37
+ year: 'numeric',
38
+ month: 'short',
39
+ day: 'numeric',
40
+ })
41
+ }
42
+
43
+ async function loadPage(p: number) {
44
+ loading.value = true
45
+
46
+ const term = search.value.trim()
47
+ // Search has no place in the page cache's key — bypass it and hit the API directly.
48
+ if (term === '') {
49
+ const cached = contentStore.getPage(type.value, p, DEFAULT_PAGE_SIZE)
50
+ if (cached) {
51
+ items.value = cached as Record<string, unknown>[]
52
+ total.value = contentStore.total
53
+ loading.value = false
54
+ return
55
+ }
56
+ }
57
+
58
+ const searchParam = term !== '' ? `&search=${encodeURIComponent(term)}` : ''
59
+ const res = await api.get<Record<string, unknown>[]>(
60
+ `/content/${type.value}?page=${p}&per_page=${DEFAULT_PAGE_SIZE}${searchParam}`
61
+ )
62
+ loading.value = false
63
+
64
+ if (res.ok) {
65
+ items.value = res.data
66
+ const raw = res as unknown as { meta?: { total: number } }
67
+ total.value = raw.meta?.total ?? res.data.length
68
+ if (term === '') {
69
+ contentStore.total = total.value
70
+ contentStore.setPage(type.value, p, DEFAULT_PAGE_SIZE, res.data)
71
+ }
72
+ }
73
+ }
74
+
75
+ const onSearchInput = useDebounceFn(() => {
76
+ page.value = 1
77
+ void loadPage(1)
78
+ }, 300)
79
+
80
+ async function handleSingletonRedirect() {
81
+ const res = await api.get<Record<string, unknown>[]>(`/content/${type.value}`)
82
+ const first = res.ok ? res.data[0] : undefined
83
+ if (first) {
84
+ void router.replace({
85
+ name: 'content-edit',
86
+ params: { type: type.value, id: String(first.id) },
87
+ })
88
+ } else {
89
+ void router.replace({ name: 'content-new', params: { type: type.value } })
90
+ }
91
+ }
92
+
93
+ onMounted(async () => {
94
+ if (!contentType.value) return
95
+ if (isSingleton.value) {
96
+ await handleSingletonRedirect()
97
+ return
98
+ }
99
+ await loadPage(page.value)
100
+ })
101
+
102
+ // Re-fetch when type changes (sidebar nav between content types).
103
+ watch(type, async () => {
104
+ page.value = 1
105
+ items.value = []
106
+ if (!contentType.value) return
107
+ if (isSingleton.value) {
108
+ await handleSingletonRedirect()
109
+ return
110
+ }
111
+ await loadPage(1)
112
+ })
113
+
114
+ function onPageChange(p: number) {
115
+ page.value = p
116
+ void loadPage(p)
117
+ }
118
+
119
+ function goToEdit(item: Record<string, unknown>) {
120
+ void router.push({
121
+ name: 'content-edit',
122
+ params: { type: type.value, id: String(item.id) },
123
+ })
124
+ }
125
+
126
+ function goToCreate() {
127
+ void router.push({ name: 'content-new', params: { type: type.value } })
128
+ }
129
+ </script>
130
+
131
+ <template>
132
+ <!-- Loading / redirect for singleton — blank while redirect fires -->
133
+ <div v-if="isSingleton || !contentType" />
134
+
135
+ <div v-else>
136
+ <!-- Page header -->
137
+ <div class="mb-[22px] flex flex-wrap items-center justify-between gap-4">
138
+ <h1 class="text-[26px] font-bold tracking-tight text-ink">{{ contentType.label }}</h1>
139
+ <button
140
+ v-if="can('content:create')"
141
+ type="button"
142
+ class="inline-flex items-center gap-[7px] 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)]"
143
+ @click="goToCreate"
144
+ >
145
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14" /><path d="M5 12h14" /></svg>
146
+ New {{ contentType.label }}
147
+ </button>
148
+ </div>
149
+
150
+ <!-- Search -->
151
+ <div class="mb-4 flex h-[38px] items-center gap-2 rounded-[10px] border border-card-border bg-[#FBFBFD] px-[11px] text-[13px] transition-colors focus-within:border-indigo-400">
152
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-faint">
153
+ <path d="M21 21l-4.3-4.3" /><path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z" />
154
+ </svg>
155
+ <input
156
+ v-model="search"
157
+ type="text"
158
+ :placeholder="`Search ${contentType.label.toLowerCase()}…`"
159
+ class="w-full bg-transparent text-ink outline-none placeholder:text-faint"
160
+ @input="onSearchInput"
161
+ />
162
+ </div>
163
+
164
+ <!-- Loading skeleton -->
165
+ <div v-if="loading" class="space-y-2">
166
+ <div
167
+ v-for="n in 8"
168
+ :key="n"
169
+ class="h-12 animate-pulse rounded-[10px] bg-gray-100"
170
+ />
171
+ </div>
172
+
173
+ <!-- Empty state -->
174
+ <div
175
+ v-else-if="items.length === 0"
176
+ class="rounded-[16px] border border-dashed border-gray-300 p-12 text-center"
177
+ >
178
+ <p class="text-sm text-muted">
179
+ {{ search.trim() !== '' ? `No results for “${search.trim()}”.` : `No ${contentType.label} items yet.` }}
180
+ </p>
181
+ <button
182
+ v-if="can('content:create') && search.trim() === ''"
183
+ type="button"
184
+ class="mt-3 text-sm font-medium text-indigo-600 hover:text-indigo-800"
185
+ @click="goToCreate"
186
+ >
187
+ Create the first one →
188
+ </button>
189
+ </div>
190
+
191
+ <!-- Table -->
192
+ <div v-else class="overflow-hidden rounded-[16px] border border-card-border bg-white shadow-[0_1px_2px_rgba(24,24,48,0.04),0_10px_28px_rgba(24,24,48,0.04)]">
193
+ <table class="w-full text-sm">
194
+ <thead class="text-[11.5px] font-bold uppercase tracking-[.06em] text-faint">
195
+ <tr class="border-b border-divider">
196
+ <th class="px-[22px] py-3.5 text-left">Title</th>
197
+ <th class="px-[22px] py-3.5 text-left">Slug</th>
198
+ <th class="px-[22px] py-3.5 text-left">Status</th>
199
+ <th class="px-[22px] py-3.5 text-left">Updated</th>
200
+ </tr>
201
+ </thead>
202
+ <tbody class="divide-y divide-[#F4F3F9]">
203
+ <tr
204
+ v-for="item in items"
205
+ :key="String(item.id)"
206
+ class="cursor-pointer transition-colors hover:bg-[#FAFAFE]"
207
+ @click="goToEdit(item)"
208
+ >
209
+ <td class="px-[22px] py-4 text-[14.5px] font-semibold text-ink">
210
+ {{ item[titleField] ?? '—' }}
211
+ </td>
212
+ <td class="px-[22px] py-4 font-mono text-[12.5px] text-muted">
213
+ {{ item.slug ?? '—' }}
214
+ </td>
215
+ <td class="px-[22px] py-4">
216
+ <StatusBadge :published="item.published === true" />
217
+ </td>
218
+ <td class="px-[22px] py-4 text-[13px] text-faint">
219
+ {{ formatDate(item.updated_at) }}
220
+ </td>
221
+ </tr>
222
+ </tbody>
223
+ </table>
224
+ </div>
225
+
226
+ <!-- Pagination -->
227
+ <div v-if="!loading && total > DEFAULT_PAGE_SIZE" class="mt-4 flex justify-center">
228
+ <Pagination
229
+ :total="total"
230
+ :page="page"
231
+ :per-page="DEFAULT_PAGE_SIZE"
232
+ @update:page="onPageChange"
233
+ />
234
+ </div>
235
+ </div>
236
+ </template>
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { useRoute } from 'vue-router'
4
+ import { useApiClient } from '../../composables/useApiClient'
5
+ import { useNotification } from '../../composables/useNotification'
6
+ import { useSchemaStore } from '../../stores/schema'
7
+
8
+ const route = useRoute()
9
+ const api = useApiClient()
10
+ const notify = useNotification()
11
+ const schemaStore = useSchemaStore()
12
+
13
+ const type = computed(() => route.params.type as string)
14
+ const contentType = computed(() => schemaStore.contentTypes[type.value])
15
+
16
+ // Available base paths fetched from the API at mount.
17
+ const availableRoutes = ref<string[]>([])
18
+ const selectedPath = ref('')
19
+ const loadingRoutes = ref(true)
20
+ const saving = ref(false)
21
+ const formError = ref('')
22
+
23
+ onMounted(async () => {
24
+ // Seed with current path from schema.
25
+ selectedPath.value = contentType.value?.default_base_path ?? ''
26
+
27
+ const res = await api.get<string[]>('/routes')
28
+ loadingRoutes.value = false
29
+
30
+ if (res.ok) {
31
+ availableRoutes.value = res.data
32
+ // Ensure the current path appears even if not in the list.
33
+ if (selectedPath.value && !availableRoutes.value.includes(selectedPath.value)) {
34
+ availableRoutes.value = [selectedPath.value, ...availableRoutes.value]
35
+ }
36
+ } else {
37
+ // If the routes endpoint isn't available, fall back to a free-text input.
38
+ availableRoutes.value = []
39
+ }
40
+ })
41
+
42
+ async function onSave() {
43
+ if (!selectedPath.value.trim()) return
44
+ saving.value = true
45
+ formError.value = ''
46
+
47
+ const res = await api.patch(`/content/${type.value}/settings`, {
48
+ base_path: selectedPath.value.trim(),
49
+ })
50
+
51
+ saving.value = false
52
+
53
+ if (!res.ok) {
54
+ formError.value = (res as { ok: false; error: { message: string } }).error.message
55
+ return
56
+ }
57
+
58
+ notify.success('Settings saved.')
59
+ }
60
+ </script>
61
+
62
+ <template>
63
+ <div class="max-w-lg">
64
+ <div class="mb-6">
65
+ <h1 class="text-[26px] font-bold tracking-tight text-ink">
66
+ {{ contentType?.label ?? type }} — Settings
67
+ </h1>
68
+ <p class="mt-1 text-sm text-gray-500">
69
+ Change the base path for this content type. This does not modify schema files.
70
+ </p>
71
+ </div>
72
+
73
+ <div v-if="loadingRoutes" class="space-y-2">
74
+ <div class="h-8 w-48 animate-pulse rounded bg-gray-100" />
75
+ <div class="h-10 animate-pulse rounded bg-gray-100" />
76
+ </div>
77
+
78
+ <form v-else class="space-y-4" @submit.prevent="onSave">
79
+ <div>
80
+ <label for="base-path" class="block text-[13px] font-semibold text-[#3D3D52]">
81
+ Base path
82
+ </label>
83
+
84
+ <!-- Dropdown when routes are available -->
85
+ <select
86
+ v-if="availableRoutes.length > 0"
87
+ id="base-path"
88
+ v-model="selectedPath"
89
+ :disabled="saving"
90
+ class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 disabled:cursor-not-allowed disabled:opacity-60"
91
+ >
92
+ <option v-for="p in availableRoutes" :key="p" :value="p">
93
+ {{ p }}
94
+ </option>
95
+ </select>
96
+
97
+ <!-- Free-text fallback when routes endpoint not available -->
98
+ <input
99
+ v-else
100
+ id="base-path"
101
+ v-model="selectedPath"
102
+ type="text"
103
+ :disabled="saving"
104
+ placeholder="/my-route"
105
+ class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 disabled:cursor-not-allowed disabled:opacity-60"
106
+ />
107
+ </div>
108
+
109
+ <p v-if="formError" class="text-sm text-red-600" role="alert">
110
+ {{ formError }}
111
+ </p>
112
+
113
+ <button
114
+ type="submit"
115
+ :disabled="saving || !selectedPath.trim()"
116
+ class="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)] disabled:cursor-not-allowed disabled:opacity-50"
117
+ >
118
+ {{ saving ? 'Saving…' : 'Save' }}
119
+ </button>
120
+ </form>
121
+ </div>
122
+ </template>
@@ -0,0 +1,222 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { useRoute, 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 { useMediaStore } from '../../stores/media'
9
+ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
10
+
11
+ const route = useRoute()
12
+ const router = useRouter()
13
+ const api = useApiClient()
14
+ const { can } = usePermission()
15
+ const notify = useNotification()
16
+ const mediaStore = useMediaStore()
17
+
18
+ const id = computed(() => route.params.id as string)
19
+
20
+ const item = ref<MediaItem | null>(null)
21
+ const loading = ref(true)
22
+ const altText = ref('')
23
+ const savingAlt = ref(false)
24
+ const deleting = ref(false)
25
+ const showDeleteConfirm = ref(false)
26
+
27
+ function formatSize(bytes: number): string {
28
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`
29
+ if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} KB`
30
+ return `${bytes} B`
31
+ }
32
+
33
+ function formatDate(dateStr: string | Date | unknown): string {
34
+ if (!dateStr) return '—'
35
+ return new Date(String(dateStr)).toLocaleString(undefined, {
36
+ year: 'numeric', month: 'short', day: 'numeric',
37
+ hour: '2-digit', minute: '2-digit',
38
+ })
39
+ }
40
+
41
+ onMounted(async () => {
42
+ const cached = mediaStore.items.get(id.value)
43
+ if (cached) {
44
+ item.value = cached
45
+ altText.value = cached.alt ?? ''
46
+ loading.value = false
47
+ return
48
+ }
49
+
50
+ const res = await api.get<MediaItem>(`/media/${id.value}`)
51
+ loading.value = false
52
+ if (res.ok) {
53
+ item.value = res.data
54
+ altText.value = res.data.alt ?? ''
55
+ mediaStore.setItem(res.data)
56
+ }
57
+ })
58
+
59
+ async function saveAlt() {
60
+ if (!item.value) return
61
+ savingAlt.value = true
62
+ const res = await api.patch<MediaItem>(`/media/${item.value.id}`, { alt: altText.value.trim() })
63
+ savingAlt.value = false
64
+ if (res.ok) {
65
+ item.value = res.data
66
+ mediaStore.setItem(res.data)
67
+ notify.success('Alt text saved.')
68
+ } else {
69
+ notify.error(res.error.message)
70
+ }
71
+ }
72
+
73
+ async function doDelete() {
74
+ if (!item.value) return
75
+ showDeleteConfirm.value = false
76
+ deleting.value = true
77
+ const res = await api.del(`/media/${item.value.id}`)
78
+ deleting.value = false
79
+ if (!res.ok) {
80
+ notify.error((res as { ok: false; error: { message: string } }).error.message)
81
+ return
82
+ }
83
+ mediaStore.removeItem(item.value.id)
84
+ void router.push({ name: 'media-library' })
85
+ }
86
+
87
+ const canDelete = computed(() => item.value !== null && item.value.reference_count === 0)
88
+ const deleteTooltip = computed(() =>
89
+ canDelete.value
90
+ ? undefined
91
+ : `This file is referenced by ${item.value?.reference_count ?? 0} item${(item.value?.reference_count ?? 0) !== 1 ? 's' : ''} and cannot be deleted.`
92
+ )
93
+ </script>
94
+
95
+ <template>
96
+ <!-- Loading -->
97
+ <div v-if="loading" class="flex items-center justify-center py-20">
98
+ <span
99
+ class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-indigo-600 border-t-transparent"
100
+ aria-hidden="true"
101
+ />
102
+ </div>
103
+
104
+ <div v-else-if="!item" class="py-12 text-center text-sm text-gray-500">
105
+ Media item not found.
106
+ </div>
107
+
108
+ <div v-else class="mx-auto max-w-2xl space-y-6">
109
+ <!-- Back link -->
110
+ <button
111
+ type="button"
112
+ class="text-sm text-indigo-600 hover:text-indigo-800"
113
+ @click="router.back()"
114
+ >
115
+ ← Back to library
116
+ </button>
117
+
118
+ <!-- Preview -->
119
+ <div class="overflow-hidden rounded-2xl border border-card-border shadow-[0_1px_2px_rgba(24,24,48,0.04),0_8px_22px_rgba(24,24,48,0.04)] bg-white">
120
+ <img
121
+ v-if="item.type === 'image'"
122
+ :src="item.url"
123
+ :alt="item.alt ?? ''"
124
+ class="max-h-96 w-full object-contain"
125
+ />
126
+ <div
127
+ v-else
128
+ class="flex h-48 items-center justify-center text-6xl text-gray-300"
129
+ aria-hidden="true"
130
+ >
131
+ {{ item.type === 'video' ? '▶' : '📄' }}
132
+ </div>
133
+ </div>
134
+
135
+ <!-- Metadata -->
136
+ <div class="rounded-2xl border border-card-border shadow-[0_1px_2px_rgba(24,24,48,0.04),0_8px_22px_rgba(24,24,48,0.04)] bg-white p-5">
137
+ <h2 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">Metadata</h2>
138
+ <dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
139
+ <div>
140
+ <dt class="text-gray-500">Type</dt>
141
+ <dd class="mt-0.5 font-medium text-gray-800">{{ item.mime_type }}</dd>
142
+ </div>
143
+ <div>
144
+ <dt class="text-gray-500">Size</dt>
145
+ <dd class="mt-0.5 font-medium text-gray-800">{{ formatSize(item.file_size) }}</dd>
146
+ </div>
147
+ <div v-if="item.width && item.height">
148
+ <dt class="text-gray-500">Dimensions</dt>
149
+ <dd class="mt-0.5 font-medium text-gray-800">{{ item.width }} × {{ item.height }}px</dd>
150
+ </div>
151
+ <div v-if="item.duration">
152
+ <dt class="text-gray-500">Duration</dt>
153
+ <dd class="mt-0.5 font-medium text-gray-800">{{ item.duration }}s</dd>
154
+ </div>
155
+ <div>
156
+ <dt class="text-gray-500">Uploaded</dt>
157
+ <dd class="mt-0.5 font-medium text-gray-800">{{ formatDate(item.created_at) }}</dd>
158
+ </div>
159
+ <div>
160
+ <dt class="text-gray-500">References</dt>
161
+ <dd class="mt-0.5 font-medium text-gray-800">
162
+ {{ item.reference_count }} content item{{ item.reference_count !== 1 ? 's' : '' }}
163
+ </dd>
164
+ </div>
165
+ </dl>
166
+ </div>
167
+
168
+ <!-- Alt text -->
169
+ <div class="rounded-2xl border border-card-border shadow-[0_1px_2px_rgba(24,24,48,0.04),0_8px_22px_rgba(24,24,48,0.04)] bg-white p-5">
170
+ <h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Alt text</h2>
171
+ <textarea
172
+ v-model="altText"
173
+ rows="3"
174
+ :disabled="savingAlt"
175
+ placeholder="Describe this file for screen readers and SEO"
176
+ class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 disabled:cursor-not-allowed disabled:opacity-60"
177
+ />
178
+ <div class="mt-3 flex justify-end">
179
+ <button
180
+ type="button"
181
+ :disabled="savingAlt"
182
+ class="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)] disabled:cursor-not-allowed disabled:opacity-50"
183
+ @click="saveAlt"
184
+ >
185
+ {{ savingAlt ? 'Saving…' : 'Save alt text' }}
186
+ </button>
187
+ </div>
188
+ </div>
189
+
190
+ <!-- Danger zone -->
191
+ <div v-if="can('media:delete')" class="rounded-lg border border-red-200 bg-white p-5">
192
+ <h2 class="mb-1 text-sm font-semibold uppercase tracking-wide text-red-500">Danger zone</h2>
193
+ <p class="mb-3 text-sm text-gray-500">
194
+ Permanently deletes this file from storage. This cannot be undone.
195
+ </p>
196
+
197
+ <!-- Wrapper needed for title on a disabled button in some browsers -->
198
+ <span :title="deleteTooltip">
199
+ <button
200
+ type="button"
201
+ :disabled="!canDelete || deleting"
202
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
203
+ @click="showDeleteConfirm = true"
204
+ >
205
+ {{ deleting ? 'Deleting…' : 'Delete file' }}
206
+ </button>
207
+ </span>
208
+
209
+ <p v-if="!canDelete" class="mt-2 text-xs text-gray-400">
210
+ {{ deleteTooltip }}
211
+ </p>
212
+ </div>
213
+
214
+ <!-- Delete confirmation -->
215
+ <ConfirmDialog
216
+ v-if="showDeleteConfirm"
217
+ message="Permanently delete this file? This action cannot be undone."
218
+ :on-confirm="doDelete"
219
+ :on-cancel="() => (showDeleteConfirm = false)"
220
+ />
221
+ </div>
222
+ </template>