@bobbykim/manguito-cms-admin 0.1.0 → 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/LICENSE.md +21 -0
- package/index.html +13 -0
- package/package.json +19 -7
- 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,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>
|