@bobbykim/manguito-cms-admin 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/assets/{ChangePasswordView-8UW4RWd8.js → ChangePasswordView-B0wg3O_I.js} +1 -1
  2. package/dist/assets/{EnumSelect-BOrBkA76.js → ComputedDisplay-C2FpwSB1.js} +2 -2
  3. package/dist/assets/ContentFormView-D92O0ICh.js +1 -0
  4. package/dist/assets/{ContentListView-BiPHUBHa.js → ContentListView-DM4xggDx.js} +1 -1
  5. package/dist/assets/{ContentSettingsView-DPKF5MRL.js → ContentSettingsView-B9UHNleT.js} +1 -1
  6. package/dist/assets/{LoginView-WELPH4Vh.js → LoginView-DPpdT3Ro.js} +1 -1
  7. package/dist/assets/{MediaDetailView-BT66fV9b.js → MediaDetailView-OUzzhO1B.js} +1 -1
  8. package/dist/assets/{MediaLibraryView-BcdiJ_7a.js → MediaLibraryView-Dx9t8r8T.js} +1 -1
  9. package/dist/assets/TaxonomyFormView-CZlOjxOY.js +1 -0
  10. package/dist/assets/{TaxonomyListView-BB7iTfjb.js → TaxonomyListView-Dkga11a3.js} +1 -1
  11. package/dist/assets/{UserFormView-C2p-FV0A.js → UserFormView-CaOcoKX5.js} +1 -1
  12. package/dist/assets/{UserListView-BnlU2Ovr.js → UserListView-B6_Hdemt.js} +1 -1
  13. package/dist/assets/{index-BkazhAun.js → index-s-2X8XCa.js} +2 -2
  14. package/dist/assets/{useNotification-DU6J5vms.js → useNotification-CEPVkHak.js} +1 -1
  15. package/dist/codegen/index.cjs +7 -2
  16. package/dist/codegen/index.js +7 -2
  17. package/dist/index.html +1 -1
  18. package/index.html +13 -0
  19. package/package.json +11 -7
  20. package/public/favicon.ico +0 -0
  21. package/src/App.vue +147 -0
  22. package/src/adapters/admin.ts +13 -0
  23. package/src/components/.gitkeep +0 -0
  24. package/src/components/fields/BooleanToggle.vue +63 -0
  25. package/src/components/fields/ComputedDisplay.vue +23 -0
  26. package/src/components/fields/DatePicker.vue +61 -0
  27. package/src/components/fields/EnumSelect.vue +69 -0
  28. package/src/components/fields/MediaUpload.vue +445 -0
  29. package/src/components/fields/NumberInput.vue +66 -0
  30. package/src/components/fields/ParagraphEmbed.vue +149 -0
  31. package/src/components/fields/ReferenceSelect.vue +275 -0
  32. package/src/components/fields/RichTextEditor.vue +293 -0
  33. package/src/components/fields/TextInput.vue +93 -0
  34. package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
  35. package/src/components/fields/__tests__/ComputedDisplay.test.ts +34 -0
  36. package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
  37. package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
  38. package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
  39. package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
  40. package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
  41. package/src/components/fields/__tests__/TextInput.test.ts +72 -0
  42. package/src/components/layout/AppShell.vue +33 -0
  43. package/src/components/layout/Footer.vue +11 -0
  44. package/src/components/layout/Sidebar.vue +254 -0
  45. package/src/components/layout/Topbar.vue +46 -0
  46. package/src/components/shared/ConfirmDialog.vue +38 -0
  47. package/src/components/shared/MediaSelectModal.vue +273 -0
  48. package/src/components/shared/Pagination.vue +93 -0
  49. package/src/components/shared/StatusBadge.vue +14 -0
  50. package/src/components/shared/ToastContainer.vue +44 -0
  51. package/src/composables/.gitkeep +0 -0
  52. package/src/composables/__tests__/useApiClient.test.ts +90 -0
  53. package/src/composables/__tests__/useFormValidation.test.ts +83 -0
  54. package/src/composables/__tests__/usePermission.test.ts +116 -0
  55. package/src/composables/useApiClient.ts +68 -0
  56. package/src/composables/useDirtyState.ts +28 -0
  57. package/src/composables/useFormValidation.ts +107 -0
  58. package/src/composables/useNotification.ts +26 -0
  59. package/src/composables/usePermission.ts +20 -0
  60. package/src/composables/useTabIndicator.ts +25 -0
  61. package/src/env.d.ts +2 -0
  62. package/src/main.ts +21 -0
  63. package/src/router/index.ts +174 -0
  64. package/src/stores/auth.ts +82 -0
  65. package/src/stores/content.ts +40 -0
  66. package/src/stores/media.ts +31 -0
  67. package/src/stores/schema.ts +130 -0
  68. package/src/stores/taxonomy.ts +40 -0
  69. package/src/stores/ui.ts +94 -0
  70. package/src/stores/users.ts +44 -0
  71. package/src/style.css +46 -0
  72. package/src/test-utils/server.ts +54 -0
  73. package/src/types/index.ts +0 -0
  74. package/src/views/.gitkeep +0 -0
  75. package/src/views/ChangePasswordView.vue +134 -0
  76. package/src/views/LoginView.vue +187 -0
  77. package/src/views/RolesView.vue +84 -0
  78. package/src/views/content/ContentFormView.vue +553 -0
  79. package/src/views/content/ContentListView.vue +236 -0
  80. package/src/views/content/ContentSettingsView.vue +122 -0
  81. package/src/views/media/MediaDetailView.vue +222 -0
  82. package/src/views/media/MediaLibraryView.vue +492 -0
  83. package/src/views/taxonomy/TaxonomyFormView.vue +286 -0
  84. package/src/views/taxonomy/TaxonomyListView.vue +198 -0
  85. package/src/views/users/UserFormView.vue +307 -0
  86. package/src/views/users/UserListView.vue +127 -0
  87. package/src/vite-env.d.ts +1 -0
  88. package/vite.config.ts +17 -0
  89. package/dist/assets/ContentFormView-vaIInaYs.js +0 -1
  90. package/dist/assets/TaxonomyFormView-BdRO9L-7.js +0 -1
@@ -0,0 +1,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
+ &times;
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>