@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.
- package/dist/assets/{ChangePasswordView-8UW4RWd8.js → ChangePasswordView-B0wg3O_I.js} +1 -1
- package/dist/assets/{EnumSelect-BOrBkA76.js → ComputedDisplay-C2FpwSB1.js} +2 -2
- package/dist/assets/ContentFormView-D92O0ICh.js +1 -0
- package/dist/assets/{ContentListView-BiPHUBHa.js → ContentListView-DM4xggDx.js} +1 -1
- package/dist/assets/{ContentSettingsView-DPKF5MRL.js → ContentSettingsView-B9UHNleT.js} +1 -1
- package/dist/assets/{LoginView-WELPH4Vh.js → LoginView-DPpdT3Ro.js} +1 -1
- package/dist/assets/{MediaDetailView-BT66fV9b.js → MediaDetailView-OUzzhO1B.js} +1 -1
- package/dist/assets/{MediaLibraryView-BcdiJ_7a.js → MediaLibraryView-Dx9t8r8T.js} +1 -1
- package/dist/assets/TaxonomyFormView-CZlOjxOY.js +1 -0
- package/dist/assets/{TaxonomyListView-BB7iTfjb.js → TaxonomyListView-Dkga11a3.js} +1 -1
- package/dist/assets/{UserFormView-C2p-FV0A.js → UserFormView-CaOcoKX5.js} +1 -1
- package/dist/assets/{UserListView-BnlU2Ovr.js → UserListView-B6_Hdemt.js} +1 -1
- package/dist/assets/{index-BkazhAun.js → index-s-2X8XCa.js} +2 -2
- package/dist/assets/{useNotification-DU6J5vms.js → useNotification-CEPVkHak.js} +1 -1
- package/dist/codegen/index.cjs +7 -2
- package/dist/codegen/index.js +7 -2
- package/dist/index.html +1 -1
- package/index.html +13 -0
- package/package.json +11 -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/ComputedDisplay.vue +23 -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__/ComputedDisplay.test.ts +34 -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 +553 -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 +286 -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
- package/dist/assets/ContentFormView-vaIInaYs.js +0 -1
- package/dist/assets/TaxonomyFormView-BdRO9L-7.js +0 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
import { useSchemaStore } from '../../stores/schema'
|
|
5
|
+
import { useAuthStore } from '../../stores/auth'
|
|
6
|
+
import { useUiStore } from '../../stores/ui'
|
|
7
|
+
import { usePermission } from '../../composables/usePermission'
|
|
8
|
+
import { useApiClient } from '../../composables/useApiClient'
|
|
9
|
+
|
|
10
|
+
const schema = useSchemaStore()
|
|
11
|
+
const authStore = useAuthStore()
|
|
12
|
+
const uiStore = useUiStore()
|
|
13
|
+
const { can } = usePermission()
|
|
14
|
+
const router = useRouter()
|
|
15
|
+
const api = useApiClient()
|
|
16
|
+
|
|
17
|
+
const contentTypeList = computed(() => Object.values(schema.contentTypes))
|
|
18
|
+
const taxonomyTypeList = computed(() => Object.values(schema.taxonomyTypes))
|
|
19
|
+
|
|
20
|
+
const roleLabel = computed(() => {
|
|
21
|
+
if (!authStore.role) return null
|
|
22
|
+
return schema.getRoleByName(authStore.role)?.label ?? authStore.role
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const contentOpen = ref(true)
|
|
26
|
+
const taxonomyOpen = ref(true)
|
|
27
|
+
|
|
28
|
+
// On mobile the rail is always rendered at full width inside the drawer.
|
|
29
|
+
const collapsed = computed(() => !uiStore.isMobile && uiStore.sidebarCollapsed)
|
|
30
|
+
|
|
31
|
+
const adminPrefix = __ADMIN_PREFIX__
|
|
32
|
+
|
|
33
|
+
async function logout() {
|
|
34
|
+
await api.post('/auth/logout', {})
|
|
35
|
+
authStore.clear()
|
|
36
|
+
router.push(`${adminPrefix}/login`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function userInitial(): string {
|
|
40
|
+
return authStore.email ? authStore.email.charAt(0).toUpperCase() : '?'
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<aside
|
|
46
|
+
:class="[
|
|
47
|
+
'flex h-full flex-col border-r border-card-border bg-white transition-[width] duration-300',
|
|
48
|
+
uiStore.isMobile
|
|
49
|
+
? 'fixed inset-y-0 left-0 z-50 w-[258px] shadow-2xl transition-transform duration-300'
|
|
50
|
+
: 'relative shrink-0 overflow-hidden',
|
|
51
|
+
uiStore.isMobile && !uiStore.mobileNavOpen ? '-translate-x-full' : 'translate-x-0',
|
|
52
|
+
!uiStore.isMobile && (collapsed ? 'w-[74px]' : 'w-[258px]'),
|
|
53
|
+
]"
|
|
54
|
+
aria-label="Main navigation"
|
|
55
|
+
>
|
|
56
|
+
<!-- Brand -->
|
|
57
|
+
<div class="flex items-center gap-[11px] px-[17px] pb-[14px] pt-[18px]">
|
|
58
|
+
<div
|
|
59
|
+
class="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[11px] bg-gradient-to-br from-[#6D5EF0] to-[#5B57E8] shadow-[0_4px_12px_rgba(91,87,232,0.36)]"
|
|
60
|
+
>
|
|
61
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.1" stroke-linecap="round">
|
|
62
|
+
<path d="M4 7h16" /><path d="M4 12h10" /><path d="M4 17h13" />
|
|
63
|
+
</svg>
|
|
64
|
+
</div>
|
|
65
|
+
<div v-if="!collapsed" class="overflow-hidden">
|
|
66
|
+
<div class="whitespace-nowrap text-[15px] font-bold tracking-tight text-ink">
|
|
67
|
+
{{ uiStore.cmsName }}
|
|
68
|
+
</div>
|
|
69
|
+
<div class="whitespace-nowrap text-[11px] font-medium text-faint">Admin panel</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Scrollable nav -->
|
|
74
|
+
<div class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 pb-2.5 pt-1">
|
|
75
|
+
<!-- Content group -->
|
|
76
|
+
<div>
|
|
77
|
+
<button
|
|
78
|
+
v-if="!collapsed"
|
|
79
|
+
type="button"
|
|
80
|
+
class="mb-1 mt-4 flex w-full items-center justify-between px-2 text-[11px] font-bold uppercase tracking-[.09em] text-faint hover:text-muted"
|
|
81
|
+
:aria-expanded="contentOpen"
|
|
82
|
+
@click="contentOpen = !contentOpen"
|
|
83
|
+
>
|
|
84
|
+
<span>Content</span>
|
|
85
|
+
<svg
|
|
86
|
+
class="h-3 w-3 text-[#C2C2D0] transition-transform"
|
|
87
|
+
:class="contentOpen ? '' : '-rotate-90'"
|
|
88
|
+
fill="none"
|
|
89
|
+
viewBox="0 0 24 24"
|
|
90
|
+
stroke="currentColor"
|
|
91
|
+
stroke-width="2.5"
|
|
92
|
+
>
|
|
93
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
94
|
+
</svg>
|
|
95
|
+
</button>
|
|
96
|
+
<div
|
|
97
|
+
class="grid transition-[grid-template-rows] duration-300 ease-in-out"
|
|
98
|
+
:style="{ gridTemplateRows: (collapsed || contentOpen) ? '1fr' : '0fr' }"
|
|
99
|
+
>
|
|
100
|
+
<div class="min-h-0 overflow-hidden">
|
|
101
|
+
<RouterLink
|
|
102
|
+
v-for="type in contentTypeList"
|
|
103
|
+
:key="type.name"
|
|
104
|
+
:to="`${adminPrefix}/content/${type.name}`"
|
|
105
|
+
:class="[
|
|
106
|
+
'my-0.5 flex items-center rounded-[10px] text-sm font-medium text-[#4A4A60] transition-colors hover:bg-[#F3F2F8]',
|
|
107
|
+
collapsed ? 'justify-center px-0 py-[11px]' : 'gap-3 px-3 py-2.5',
|
|
108
|
+
]"
|
|
109
|
+
active-class="!bg-indigo-50 !text-indigo-600 !font-semibold"
|
|
110
|
+
>
|
|
111
|
+
<span class="flex shrink-0">
|
|
112
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
113
|
+
<path d="M14 3v4a2 2 0 0 0 2 2h4" /><path d="M5 3h9l5 5v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" /><path d="M9 13h6" /><path d="M9 17h5" />
|
|
114
|
+
</svg>
|
|
115
|
+
</span>
|
|
116
|
+
<span v-if="!collapsed" class="overflow-hidden whitespace-nowrap">{{ type.label }}</span>
|
|
117
|
+
</RouterLink>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<!-- Taxonomy group -->
|
|
123
|
+
<div v-if="taxonomyTypeList.length > 0">
|
|
124
|
+
<button
|
|
125
|
+
v-if="!collapsed"
|
|
126
|
+
type="button"
|
|
127
|
+
class="mb-1 mt-4 flex w-full items-center justify-between px-2 text-[11px] font-bold uppercase tracking-[.09em] text-faint hover:text-muted"
|
|
128
|
+
:aria-expanded="taxonomyOpen"
|
|
129
|
+
@click="taxonomyOpen = !taxonomyOpen"
|
|
130
|
+
>
|
|
131
|
+
<span>Taxonomy</span>
|
|
132
|
+
<svg
|
|
133
|
+
class="h-3 w-3 text-[#C2C2D0] transition-transform"
|
|
134
|
+
:class="taxonomyOpen ? '' : '-rotate-90'"
|
|
135
|
+
fill="none"
|
|
136
|
+
viewBox="0 0 24 24"
|
|
137
|
+
stroke="currentColor"
|
|
138
|
+
stroke-width="2.5"
|
|
139
|
+
>
|
|
140
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
141
|
+
</svg>
|
|
142
|
+
</button>
|
|
143
|
+
<div
|
|
144
|
+
class="grid transition-[grid-template-rows] duration-300 ease-in-out"
|
|
145
|
+
:style="{ gridTemplateRows: (collapsed || taxonomyOpen) ? '1fr' : '0fr' }"
|
|
146
|
+
>
|
|
147
|
+
<div class="min-h-0 overflow-hidden">
|
|
148
|
+
<RouterLink
|
|
149
|
+
v-for="type in taxonomyTypeList"
|
|
150
|
+
:key="type.name"
|
|
151
|
+
:to="`${adminPrefix}/taxonomy/${type.name}`"
|
|
152
|
+
:class="[
|
|
153
|
+
'my-0.5 flex items-center rounded-[10px] text-sm font-medium text-[#4A4A60] transition-colors hover:bg-[#F3F2F8]',
|
|
154
|
+
collapsed ? 'justify-center px-0 py-[11px]' : 'gap-3 px-3 py-2.5',
|
|
155
|
+
]"
|
|
156
|
+
active-class="!bg-indigo-50 !text-indigo-600 !font-semibold"
|
|
157
|
+
>
|
|
158
|
+
<span class="flex shrink-0">
|
|
159
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
160
|
+
<path d="M20.6 13.4l-7.2 7.2a2 2 0 0 1-2.8 0L2 12V2h10l8.6 8.6a2 2 0 0 1 0 2.8z" /><path d="M7 7h.01" />
|
|
161
|
+
</svg>
|
|
162
|
+
</span>
|
|
163
|
+
<span v-if="!collapsed" class="overflow-hidden whitespace-nowrap">{{ type.label }}</span>
|
|
164
|
+
</RouterLink>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="my-3.5 mx-2.5 h-px bg-divider" />
|
|
170
|
+
|
|
171
|
+
<!-- Solo items -->
|
|
172
|
+
<RouterLink
|
|
173
|
+
v-if="can('media:read')"
|
|
174
|
+
:to="`${adminPrefix}/media`"
|
|
175
|
+
:class="[
|
|
176
|
+
'my-0.5 flex items-center rounded-[10px] text-sm font-medium text-[#4A4A60] transition-colors hover:bg-[#F3F2F8]',
|
|
177
|
+
collapsed ? 'justify-center px-0 py-[11px]' : 'gap-3 px-3 py-2.5',
|
|
178
|
+
]"
|
|
179
|
+
active-class="!bg-indigo-50 !text-indigo-600 !font-semibold"
|
|
180
|
+
>
|
|
181
|
+
<span class="flex shrink-0">
|
|
182
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
183
|
+
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" /><path d="M8.5 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" /><path d="M21 15l-5-5L5 21" />
|
|
184
|
+
</svg>
|
|
185
|
+
</span>
|
|
186
|
+
<span v-if="!collapsed" class="overflow-hidden whitespace-nowrap">Media</span>
|
|
187
|
+
</RouterLink>
|
|
188
|
+
|
|
189
|
+
<RouterLink
|
|
190
|
+
v-if="can('users:read')"
|
|
191
|
+
:to="`${adminPrefix}/users`"
|
|
192
|
+
:class="[
|
|
193
|
+
'my-0.5 flex items-center rounded-[10px] text-sm font-medium text-[#4A4A60] transition-colors hover:bg-[#F3F2F8]',
|
|
194
|
+
collapsed ? 'justify-center px-0 py-[11px]' : 'gap-3 px-3 py-2.5',
|
|
195
|
+
]"
|
|
196
|
+
active-class="!bg-indigo-50 !text-indigo-600 !font-semibold"
|
|
197
|
+
>
|
|
198
|
+
<span class="flex shrink-0">
|
|
199
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
200
|
+
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><path d="M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" /><path d="M22 21v-2a4 4 0 0 0-3-3.9" /><path d="M16 3.1a4 4 0 0 1 0 7.8" />
|
|
201
|
+
</svg>
|
|
202
|
+
</span>
|
|
203
|
+
<span v-if="!collapsed" class="overflow-hidden whitespace-nowrap">Users</span>
|
|
204
|
+
</RouterLink>
|
|
205
|
+
|
|
206
|
+
<RouterLink
|
|
207
|
+
v-if="can('roles:read')"
|
|
208
|
+
:to="`${adminPrefix}/roles`"
|
|
209
|
+
:class="[
|
|
210
|
+
'my-0.5 flex items-center rounded-[10px] text-sm font-medium text-[#4A4A60] transition-colors hover:bg-[#F3F2F8]',
|
|
211
|
+
collapsed ? 'justify-center px-0 py-[11px]' : 'gap-3 px-3 py-2.5',
|
|
212
|
+
]"
|
|
213
|
+
active-class="!bg-indigo-50 !text-indigo-600 !font-semibold"
|
|
214
|
+
>
|
|
215
|
+
<span class="flex shrink-0">
|
|
216
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
217
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
218
|
+
</svg>
|
|
219
|
+
</span>
|
|
220
|
+
<span v-if="!collapsed" class="overflow-hidden whitespace-nowrap">Roles</span>
|
|
221
|
+
</RouterLink>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<!-- Footer: user + logout -->
|
|
225
|
+
<div class="border-t border-divider p-3">
|
|
226
|
+
<div class="flex items-center gap-2.5 p-1.5">
|
|
227
|
+
<div
|
|
228
|
+
class="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[10px] bg-gradient-to-br from-[#6D5EF0] to-[#A06BF0] text-[14px] font-bold text-white"
|
|
229
|
+
>
|
|
230
|
+
{{ userInitial() }}
|
|
231
|
+
</div>
|
|
232
|
+
<div v-if="!collapsed" class="overflow-hidden">
|
|
233
|
+
<div class="whitespace-nowrap text-[13px] font-semibold text-[#2C2C44]">{{ authStore.email }}</div>
|
|
234
|
+
<div v-if="roleLabel" class="whitespace-nowrap text-[11.5px] text-[#9A9AB0]">{{ roleLabel }}</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
:class="[
|
|
240
|
+
'mt-1 flex w-full items-center rounded-[10px] py-[9px] text-left text-[13.5px] font-medium text-[#74748C] transition-colors hover:bg-[#FDF2F4] hover:text-[#E1495B]',
|
|
241
|
+
collapsed ? 'justify-center px-0' : 'gap-[11px] px-2',
|
|
242
|
+
]"
|
|
243
|
+
@click="logout"
|
|
244
|
+
>
|
|
245
|
+
<span class="flex shrink-0">
|
|
246
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
247
|
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="M16 17l5-5-5-5" /><path d="M21 12H9" />
|
|
248
|
+
</svg>
|
|
249
|
+
</span>
|
|
250
|
+
<span v-if="!collapsed" class="whitespace-nowrap">Log out</span>
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
</aside>
|
|
254
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useUiStore } from '../../stores/ui'
|
|
3
|
+
|
|
4
|
+
const uiStore = useUiStore()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<header
|
|
9
|
+
class="flex h-[60px] shrink-0 items-center gap-[13px] border-b border-card-border bg-white/85 px-4 backdrop-blur-md"
|
|
10
|
+
>
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
class="flex h-[38px] w-[38px] items-center justify-center rounded-[10px] text-[#4A4A60] transition-colors hover:bg-[#F1F0F7]"
|
|
14
|
+
aria-label="Toggle sidebar"
|
|
15
|
+
@click="uiStore.toggleSidebar()"
|
|
16
|
+
>
|
|
17
|
+
<!-- Hamburger on mobile (opens drawer), panel-collapse icon on desktop -->
|
|
18
|
+
<svg
|
|
19
|
+
v-if="uiStore.isMobile"
|
|
20
|
+
width="21"
|
|
21
|
+
height="21"
|
|
22
|
+
viewBox="0 0 24 24"
|
|
23
|
+
fill="none"
|
|
24
|
+
stroke="currentColor"
|
|
25
|
+
stroke-width="1.9"
|
|
26
|
+
stroke-linecap="round"
|
|
27
|
+
>
|
|
28
|
+
<path d="M3 6h18" /><path d="M3 12h18" /><path d="M3 18h18" />
|
|
29
|
+
</svg>
|
|
30
|
+
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
31
|
+
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" /><path d="M9 3v18" />
|
|
32
|
+
</svg>
|
|
33
|
+
</button>
|
|
34
|
+
|
|
35
|
+
<div class="flex min-w-0 items-center gap-[9px]">
|
|
36
|
+
<span class="shrink-0 rounded-[7px] bg-indigo-50 px-[9px] py-[3px] text-[10.5px] font-bold tracking-[.05em] text-indigo-600">
|
|
37
|
+
ADMIN
|
|
38
|
+
</span>
|
|
39
|
+
<span class="overflow-hidden truncate whitespace-nowrap text-[14.5px] font-semibold text-[#2C2C44]">
|
|
40
|
+
{{ uiStore.cmsName }}
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="flex-1" />
|
|
45
|
+
</header>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
message: string
|
|
4
|
+
onConfirm: () => void
|
|
5
|
+
onCancel: () => void
|
|
6
|
+
}>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<Teleport to="body">
|
|
11
|
+
<div
|
|
12
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
13
|
+
role="dialog"
|
|
14
|
+
aria-modal="true"
|
|
15
|
+
>
|
|
16
|
+
<div class="mx-4 w-full max-w-sm rounded-lg bg-white p-6 shadow-xl">
|
|
17
|
+
<p class="text-sm text-gray-700">{{ message }}</p>
|
|
18
|
+
|
|
19
|
+
<div class="mt-6 flex justify-end gap-3">
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
class="rounded-md px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
|
23
|
+
@click="onCancel"
|
|
24
|
+
>
|
|
25
|
+
Cancel
|
|
26
|
+
</button>
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
|
30
|
+
@click="onConfirm"
|
|
31
|
+
>
|
|
32
|
+
Confirm
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</Teleport>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watch, onMounted } from 'vue'
|
|
3
|
+
import type { MediaItem } from '@bobbykim/manguito-cms-core'
|
|
4
|
+
import { useApiClient } from '../../composables/useApiClient'
|
|
5
|
+
|
|
6
|
+
type MediaType = 'image' | 'video' | 'file'
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
// When set, the filter tab is locked to this type.
|
|
10
|
+
acceptedType?: MediaType
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
select: [item: MediaItem]
|
|
15
|
+
close: []
|
|
16
|
+
}>()
|
|
17
|
+
|
|
18
|
+
const api = useApiClient()
|
|
19
|
+
|
|
20
|
+
// ── Filter tabs ───────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
type Tab = { label: string; value: MediaType | 'all' }
|
|
23
|
+
|
|
24
|
+
const ALL_TABS: Tab[] = [
|
|
25
|
+
{ label: 'Images', value: 'image' },
|
|
26
|
+
{ label: 'Videos', value: 'video' },
|
|
27
|
+
{ label: 'PDFs', value: 'file' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const activeTab = ref<MediaType | 'all'>(props.acceptedType ?? 'all')
|
|
31
|
+
const tabsVisible = computed(() => !props.acceptedType)
|
|
32
|
+
|
|
33
|
+
// When acceptedType changes (unlikely at runtime, but defensive), sync.
|
|
34
|
+
watch(
|
|
35
|
+
() => props.acceptedType,
|
|
36
|
+
(t) => { if (t) activeTab.value = t }
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// ── Data ──────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const items = ref<MediaItem[]>([])
|
|
42
|
+
const loading = ref(false)
|
|
43
|
+
const page = ref(1)
|
|
44
|
+
const totalPages = ref(1)
|
|
45
|
+
const selectedItem = ref<MediaItem | null>(null)
|
|
46
|
+
|
|
47
|
+
function buildPath(tab: MediaType | 'all', p: number): string {
|
|
48
|
+
const params = new URLSearchParams({ page: String(p), per_page: '20' })
|
|
49
|
+
if (tab !== 'all') params.set('type', tab)
|
|
50
|
+
return `/media?${params.toString()}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fetchMedia() {
|
|
54
|
+
loading.value = true
|
|
55
|
+
const res = await api.get<MediaItem[]>(buildPath(activeTab.value, page.value))
|
|
56
|
+
loading.value = false
|
|
57
|
+
|
|
58
|
+
if (res.ok) {
|
|
59
|
+
items.value = res.data
|
|
60
|
+
|
|
61
|
+
// The server also returns meta — access it via type assertion since
|
|
62
|
+
// ApiResult<T> doesn't model the extra fields.
|
|
63
|
+
const raw = res as unknown as { meta?: { total_pages: number } }
|
|
64
|
+
totalPages.value = raw.meta?.total_pages ?? 1
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
watch(activeTab, () => {
|
|
69
|
+
page.value = 1
|
|
70
|
+
selectedItem.value = null
|
|
71
|
+
void fetchMedia()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
watch(page, () => {
|
|
75
|
+
void fetchMedia()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
onMounted(() => { void fetchMedia() })
|
|
79
|
+
|
|
80
|
+
// ── Actions ───────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function confirm() {
|
|
83
|
+
if (!selectedItem.value) return
|
|
84
|
+
emit('select', selectedItem.value)
|
|
85
|
+
}
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<template>
|
|
89
|
+
<Teleport to="body">
|
|
90
|
+
<div
|
|
91
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
92
|
+
role="dialog"
|
|
93
|
+
aria-modal="true"
|
|
94
|
+
aria-label="Select media"
|
|
95
|
+
@click.self="$emit('close')"
|
|
96
|
+
>
|
|
97
|
+
<div class="flex h-full max-h-144 w-full max-w-3xl flex-col overflow-hidden rounded-lg bg-white shadow-xl">
|
|
98
|
+
|
|
99
|
+
<!-- Header -->
|
|
100
|
+
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
|
|
101
|
+
<h2 class="text-base font-semibold text-gray-900">Select Media</h2>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
class="text-xl leading-none text-gray-400 hover:text-gray-600"
|
|
105
|
+
aria-label="Close"
|
|
106
|
+
@click="$emit('close')"
|
|
107
|
+
>
|
|
108
|
+
×
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<!-- Filter tabs — hidden when type is locked -->
|
|
113
|
+
<div
|
|
114
|
+
v-if="tabsVisible"
|
|
115
|
+
class="flex gap-1 border-b border-gray-200 px-5 pt-3"
|
|
116
|
+
>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
:class="[
|
|
120
|
+
'rounded-t-md px-3 py-1.5 text-sm font-medium transition-colors',
|
|
121
|
+
activeTab === 'all'
|
|
122
|
+
? 'border-b-2 border-indigo-600 text-indigo-600'
|
|
123
|
+
: 'text-gray-500 hover:text-gray-800',
|
|
124
|
+
]"
|
|
125
|
+
@click="activeTab = 'all'"
|
|
126
|
+
>
|
|
127
|
+
All
|
|
128
|
+
</button>
|
|
129
|
+
<button
|
|
130
|
+
v-for="tab in ALL_TABS"
|
|
131
|
+
:key="tab.value"
|
|
132
|
+
type="button"
|
|
133
|
+
:class="[
|
|
134
|
+
'rounded-t-md px-3 py-1.5 text-sm font-medium transition-colors',
|
|
135
|
+
activeTab === tab.value
|
|
136
|
+
? 'border-b-2 border-indigo-600 text-indigo-600'
|
|
137
|
+
: 'text-gray-500 hover:text-gray-800',
|
|
138
|
+
]"
|
|
139
|
+
@click="activeTab = tab.value"
|
|
140
|
+
>
|
|
141
|
+
{{ tab.label }}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<!-- Locked tab label -->
|
|
146
|
+
<div
|
|
147
|
+
v-else
|
|
148
|
+
class="border-b border-gray-200 px-5 py-2 text-sm text-gray-500"
|
|
149
|
+
>
|
|
150
|
+
Showing:
|
|
151
|
+
<span class="font-medium text-gray-700">
|
|
152
|
+
{{ ALL_TABS.find(t => t.value === acceptedType)?.label ?? acceptedType }}
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<!-- Grid -->
|
|
157
|
+
<div class="flex-1 overflow-y-auto p-5">
|
|
158
|
+
<!-- Loading -->
|
|
159
|
+
<div v-if="loading" class="flex items-center justify-center py-16">
|
|
160
|
+
<span
|
|
161
|
+
class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-indigo-600 border-t-transparent"
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<!-- Empty state -->
|
|
167
|
+
<div
|
|
168
|
+
v-else-if="items.length === 0"
|
|
169
|
+
class="flex flex-col items-center justify-center py-16 text-gray-400"
|
|
170
|
+
>
|
|
171
|
+
<span class="text-4xl" aria-hidden="true">🖼</span>
|
|
172
|
+
<p class="mt-2 text-sm">No media found</p>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<!-- Media grid -->
|
|
176
|
+
<div
|
|
177
|
+
v-else
|
|
178
|
+
class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5"
|
|
179
|
+
>
|
|
180
|
+
<button
|
|
181
|
+
v-for="item in items"
|
|
182
|
+
:key="item.id"
|
|
183
|
+
type="button"
|
|
184
|
+
:class="[
|
|
185
|
+
'group relative aspect-square overflow-hidden rounded-md border-2 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1',
|
|
186
|
+
selectedItem?.id === item.id
|
|
187
|
+
? 'border-indigo-500'
|
|
188
|
+
: 'border-transparent hover:border-gray-300',
|
|
189
|
+
]"
|
|
190
|
+
:aria-pressed="selectedItem?.id === item.id"
|
|
191
|
+
:aria-label="`Select ${item.alt ?? item.url.split('/').pop()}`"
|
|
192
|
+
@click="selectedItem = item"
|
|
193
|
+
>
|
|
194
|
+
<!-- Image thumbnail -->
|
|
195
|
+
<img
|
|
196
|
+
v-if="item.type === 'image'"
|
|
197
|
+
:src="item.url"
|
|
198
|
+
:alt="item.alt ?? ''"
|
|
199
|
+
class="h-full w-full object-cover"
|
|
200
|
+
loading="lazy"
|
|
201
|
+
/>
|
|
202
|
+
|
|
203
|
+
<!-- Video / file icon -->
|
|
204
|
+
<div
|
|
205
|
+
v-else
|
|
206
|
+
class="flex h-full w-full flex-col items-center justify-center gap-1 bg-gray-50 text-gray-400"
|
|
207
|
+
aria-hidden="true"
|
|
208
|
+
>
|
|
209
|
+
<span class="text-3xl">{{ item.type === 'video' ? '▶' : '📄' }}</span>
|
|
210
|
+
<span class="max-w-full truncate px-1 text-xs">
|
|
211
|
+
{{ item.url.split('/').pop() }}
|
|
212
|
+
</span>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<!-- Selection overlay tick -->
|
|
216
|
+
<div
|
|
217
|
+
v-if="selectedItem?.id === item.id"
|
|
218
|
+
class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-xs text-white"
|
|
219
|
+
aria-hidden="true"
|
|
220
|
+
>
|
|
221
|
+
✓
|
|
222
|
+
</div>
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<!-- Pagination -->
|
|
228
|
+
<div
|
|
229
|
+
v-if="totalPages > 1"
|
|
230
|
+
class="flex items-center justify-center gap-3 border-t border-gray-100 px-5 py-2"
|
|
231
|
+
>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
:disabled="page === 1"
|
|
235
|
+
class="rounded px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
236
|
+
@click="page--"
|
|
237
|
+
>
|
|
238
|
+
← Prev
|
|
239
|
+
</button>
|
|
240
|
+
<span class="text-xs text-gray-500">{{ page }} / {{ totalPages }}</span>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
:disabled="page === totalPages"
|
|
244
|
+
class="rounded px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
245
|
+
@click="page++"
|
|
246
|
+
>
|
|
247
|
+
Next →
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<!-- Footer -->
|
|
252
|
+
<div class="flex justify-end gap-3 border-t border-gray-200 px-5 py-4">
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
class="rounded-md px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
|
256
|
+
@click="$emit('close')"
|
|
257
|
+
>
|
|
258
|
+
Cancel
|
|
259
|
+
</button>
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
:disabled="!selectedItem"
|
|
263
|
+
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
264
|
+
@click="confirm"
|
|
265
|
+
>
|
|
266
|
+
Select
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</Teleport>
|
|
273
|
+
</template>
|