@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,281 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, watch } from 'vue'
3
+ import type { Component } from 'vue'
4
+ import { useRoute, useRouter } from 'vue-router'
5
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
6
+ import { useApiClient } from '../../composables/useApiClient'
7
+ import { usePermission } from '../../composables/usePermission'
8
+ import { useFormValidation } from '../../composables/useFormValidation'
9
+ import { useNotification } from '../../composables/useNotification'
10
+ import { useSchemaStore } from '../../stores/schema'
11
+ import { useTaxonomyStore } from '../../stores/taxonomy'
12
+ import TextInput from '../../components/fields/TextInput.vue'
13
+ import RichTextEditor from '../../components/fields/RichTextEditor.vue'
14
+ import NumberInput from '../../components/fields/NumberInput.vue'
15
+ import BooleanToggle from '../../components/fields/BooleanToggle.vue'
16
+ import DatePicker from '../../components/fields/DatePicker.vue'
17
+ import EnumSelect from '../../components/fields/EnumSelect.vue'
18
+ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
19
+
20
+ const route = useRoute()
21
+ const router = useRouter()
22
+ const api = useApiClient()
23
+ const { can } = usePermission()
24
+ const { errors, validate, mergeServerErrors, clearErrors, setError } = useFormValidation()
25
+ const notify = useNotification()
26
+ const schemaStore = useSchemaStore()
27
+ const taxonomyStore = useTaxonomyStore()
28
+
29
+ // ── Route ─────────────────────────────────────────────────────────────────────
30
+
31
+ const type = computed(() => route.params.type as string)
32
+ const routeId = computed(() => route.params.id as string | undefined)
33
+ const isEdit = computed(() => !!routeId.value)
34
+
35
+ // ── Schema ────────────────────────────────────────────────────────────────────
36
+
37
+ const taxonomyType = computed(() => schemaStore.taxonomyTypes[type.value])
38
+ const allFields = computed(() => taxonomyType.value?.fields ?? [])
39
+
40
+ // ── Form state ────────────────────────────────────────────────────────────────
41
+
42
+ const item = ref<Record<string, unknown> | null>(null)
43
+ const form = ref<Record<string, unknown>>({})
44
+ const loading = ref(true)
45
+ const saving = ref(false)
46
+ const formError = ref('')
47
+ const showDeleteConfirm = ref(false)
48
+
49
+ // ── Field component mapping (flat — no paragraphs, media, or references in taxonomy) ──
50
+
51
+ const FIELD_COMP = {
52
+ 'text/plain': TextInput,
53
+ 'text/rich': RichTextEditor,
54
+ integer: NumberInput,
55
+ float: NumberInput,
56
+ boolean: BooleanToggle,
57
+ date: DatePicker,
58
+ enum: EnumSelect,
59
+ } as const
60
+
61
+ function componentFor(field: ParsedField): Component {
62
+ const key = field.field_type as keyof typeof FIELD_COMP
63
+ return FIELD_COMP[key] ?? TextInput
64
+ }
65
+
66
+ // ── Form helpers ──────────────────────────────────────────────────────────────
67
+
68
+ function defaultForField(field: ParsedField): unknown {
69
+ switch (field.field_type) {
70
+ case 'text/plain':
71
+ case 'text/rich':
72
+ return ''
73
+ case 'integer':
74
+ case 'float':
75
+ return null
76
+ case 'boolean':
77
+ return false
78
+ case 'date':
79
+ return null
80
+ case 'enum':
81
+ return ''
82
+ default:
83
+ return null
84
+ }
85
+ }
86
+
87
+ function initForm(source?: Record<string, unknown>) {
88
+ const f: Record<string, unknown> = {}
89
+ for (const field of allFields.value) {
90
+ f[field.name] = source?.[field.name] ?? defaultForField(field)
91
+ }
92
+ form.value = f
93
+ }
94
+
95
+ function updateField(name: string, value: unknown) {
96
+ form.value[name] = value
97
+ }
98
+
99
+ // ── Data loading ──────────────────────────────────────────────────────────────
100
+
101
+ async function loadItem(id: string) {
102
+ const res = await api.get<Record<string, unknown>>(`/taxonomy/${type.value}/${id}`)
103
+ if (res.ok) {
104
+ item.value = res.data
105
+ initForm(res.data)
106
+ } else {
107
+ formError.value = res.error.message
108
+ }
109
+ }
110
+
111
+ onMounted(async () => {
112
+ if (!taxonomyType.value) {
113
+ loading.value = false
114
+ return
115
+ }
116
+ if (isEdit.value && routeId.value) {
117
+ await loadItem(routeId.value)
118
+ } else {
119
+ initForm()
120
+ }
121
+ loading.value = false
122
+ })
123
+
124
+ watch(routeId, async (newId, oldId) => {
125
+ if (newId === oldId) return
126
+ loading.value = true
127
+ clearErrors()
128
+ if (newId) {
129
+ await loadItem(newId)
130
+ } else {
131
+ item.value = null
132
+ initForm()
133
+ }
134
+ loading.value = false
135
+ })
136
+
137
+ // ── Save / delete ─────────────────────────────────────────────────────────────
138
+
139
+ async function onSave() {
140
+ const valid = validate(allFields.value, form.value)
141
+ if (!valid) return
142
+
143
+ saving.value = true
144
+ formError.value = ''
145
+
146
+ const res = isEdit.value
147
+ ? await api.patch<Record<string, unknown>>(
148
+ `/taxonomy/${type.value}/${routeId.value!}`,
149
+ form.value
150
+ )
151
+ : await api.post<Record<string, unknown>>(`/taxonomy/${type.value}`, form.value)
152
+
153
+ saving.value = false
154
+
155
+ if (!res.ok) {
156
+ const raw = res.error as {
157
+ code: string
158
+ message: string
159
+ details?: Array<{ field: string; message: string }>
160
+ }
161
+ if (raw.code === 'PUBLISH_VALIDATION_ERROR' && raw.details) {
162
+ mergeServerErrors(raw.details)
163
+ } else if (raw.code === 'SLUG_CONFLICT') {
164
+ setError('slug', 'This slug is already taken.')
165
+ } else {
166
+ formError.value = raw.message
167
+ }
168
+ return
169
+ }
170
+
171
+ item.value = res.data
172
+ taxonomyStore.invalidate(type.value)
173
+ notify.success('Saved.')
174
+
175
+ if (!isEdit.value) {
176
+ void router.replace({
177
+ name: 'taxonomy-edit',
178
+ params: { type: type.value, id: String(res.data.id) },
179
+ })
180
+ }
181
+ }
182
+
183
+ async function doDelete() {
184
+ showDeleteConfirm.value = false
185
+ saving.value = true
186
+ const res = await api.del(`/taxonomy/${type.value}/${routeId.value!}`)
187
+ saving.value = false
188
+
189
+ if (!res.ok) {
190
+ formError.value = (res as { ok: false; error: { message: string } }).error.message
191
+ return
192
+ }
193
+
194
+ taxonomyStore.invalidate(type.value)
195
+ void router.push({ name: 'taxonomy-list', params: { type: type.value } })
196
+ }
197
+
198
+ // ── Page title ────────────────────────────────────────────────────────────────
199
+
200
+ const pageTitle = computed(() => {
201
+ if (!isEdit.value) return `New ${taxonomyType.value?.label ?? ''}`
202
+ const tf = allFields.value.find(f => f.field_type === 'text/plain')
203
+ return tf ? String(item.value?.[tf.name] ?? '') : (taxonomyType.value?.label ?? '')
204
+ })
205
+ </script>
206
+
207
+ <template>
208
+ <!-- Loading -->
209
+ <div v-if="loading" class="flex items-center justify-center py-20">
210
+ <span
211
+ class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-indigo-600 border-t-transparent"
212
+ aria-hidden="true"
213
+ />
214
+ </div>
215
+
216
+ <div v-else-if="!taxonomyType" class="py-12 text-center text-sm text-gray-500">
217
+ Taxonomy type not found.
218
+ </div>
219
+
220
+ <div v-else>
221
+ <!-- Page header -->
222
+ <div class="mb-6 flex items-start justify-between gap-4">
223
+ <div>
224
+ <p class="text-xs font-medium uppercase tracking-wide text-gray-400">
225
+ {{ taxonomyType.label }}
226
+ </p>
227
+ <h1 class="text-[26px] font-bold tracking-tight text-ink">{{ pageTitle }}</h1>
228
+ </div>
229
+
230
+ <!-- Action bar -->
231
+ <div class="flex shrink-0 items-center gap-2">
232
+ <button
233
+ v-if="can('taxonomy:delete') && isEdit"
234
+ type="button"
235
+ :disabled="saving"
236
+ class="rounded-md px-3 py-2 text-sm font-medium text-red-600 ring-1 ring-inset ring-red-300 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
237
+ @click="showDeleteConfirm = true"
238
+ >
239
+ Delete
240
+ </button>
241
+
242
+ <button
243
+ v-if="isEdit ? can('taxonomy:edit') : can('taxonomy:create')"
244
+ type="button"
245
+ :disabled="saving"
246
+ 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"
247
+ @click="onSave"
248
+ >
249
+ {{ saving ? 'Saving…' : 'Save' }}
250
+ </button>
251
+ </div>
252
+ </div>
253
+
254
+ <!-- Flat field layout — no tabs, no slug, no publish toggle -->
255
+ <div class="space-y-5">
256
+ <component
257
+ :is="componentFor(field)"
258
+ v-for="field in allFields"
259
+ :key="field.name"
260
+ :field="field"
261
+ :model-value="form[field.name]"
262
+ :error="errors[field.name]"
263
+ :disabled="saving"
264
+ @update:model-value="updateField(field.name, $event)"
265
+ />
266
+ </div>
267
+
268
+ <!-- Form-level error -->
269
+ <p v-if="formError" class="mt-4 text-sm text-red-600" role="alert">
270
+ {{ formError }}
271
+ </p>
272
+
273
+ <!-- Delete confirmation -->
274
+ <ConfirmDialog
275
+ v-if="showDeleteConfirm"
276
+ message="Are you sure you want to delete this term? This action cannot be undone."
277
+ :on-confirm="doDelete"
278
+ :on-cancel="() => (showDeleteConfirm = false)"
279
+ />
280
+ </div>
281
+ </template>
@@ -0,0 +1,198 @@
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 { useTaxonomyStore, DEFAULT_PAGE_SIZE } from '../../stores/taxonomy'
9
+ import Pagination from '../../components/shared/Pagination.vue'
10
+
11
+ const route = useRoute()
12
+ const router = useRouter()
13
+ const api = useApiClient()
14
+ const { can } = usePermission()
15
+ const schemaStore = useSchemaStore()
16
+ const taxonomyStore = useTaxonomyStore()
17
+
18
+ const type = computed(() => route.params.type as string)
19
+ const taxonomyType = computed(() => schemaStore.taxonomyTypes[type.value])
20
+
21
+ // First text/plain field — used as the title column.
22
+ const titleField = computed(
23
+ () => taxonomyType.value?.fields.find(f => f.field_type === 'text/plain')?.name ?? 'id'
24
+ )
25
+
26
+ const items = ref<Record<string, unknown>[]>([])
27
+ const total = ref(0)
28
+ const page = ref(1)
29
+ const loading = ref(false)
30
+ const search = ref('')
31
+
32
+ function formatDate(val: unknown): string {
33
+ if (typeof val !== 'string' || !val) return '—'
34
+ return new Date(val).toLocaleDateString(undefined, {
35
+ year: 'numeric',
36
+ month: 'short',
37
+ day: 'numeric',
38
+ })
39
+ }
40
+
41
+ async function loadPage(p: number) {
42
+ loading.value = true
43
+
44
+ const term = search.value.trim()
45
+ if (term === '') {
46
+ const cached = taxonomyStore.getPage(type.value, p, DEFAULT_PAGE_SIZE)
47
+ if (cached) {
48
+ items.value = cached as Record<string, unknown>[]
49
+ total.value = taxonomyStore.total
50
+ loading.value = false
51
+ return
52
+ }
53
+ }
54
+
55
+ const searchParam = term !== '' ? `&search=${encodeURIComponent(term)}` : ''
56
+ const res = await api.get<Record<string, unknown>[]>(
57
+ `/taxonomy/${type.value}?page=${p}&per_page=${DEFAULT_PAGE_SIZE}${searchParam}`
58
+ )
59
+ loading.value = false
60
+
61
+ if (res.ok) {
62
+ items.value = res.data
63
+ const raw = res as unknown as { meta?: { total: number } }
64
+ total.value = raw.meta?.total ?? res.data.length
65
+ if (term === '') {
66
+ taxonomyStore.total = total.value
67
+ taxonomyStore.setPage(type.value, p, DEFAULT_PAGE_SIZE, res.data)
68
+ }
69
+ }
70
+ }
71
+
72
+ const onSearchInput = useDebounceFn(() => {
73
+ page.value = 1
74
+ void loadPage(1)
75
+ }, 300)
76
+
77
+ onMounted(() => { void loadPage(page.value) })
78
+
79
+ watch(type, () => {
80
+ page.value = 1
81
+ items.value = []
82
+ void loadPage(1)
83
+ })
84
+
85
+ function onPageChange(p: number) {
86
+ page.value = p
87
+ void loadPage(p)
88
+ }
89
+
90
+ function goToEdit(item: Record<string, unknown>) {
91
+ void router.push({
92
+ name: 'taxonomy-edit',
93
+ params: { type: type.value, id: String(item.id) },
94
+ })
95
+ }
96
+
97
+ function goToCreate() {
98
+ void router.push({ name: 'taxonomy-new', params: { type: type.value } })
99
+ }
100
+ </script>
101
+
102
+ <template>
103
+ <div>
104
+ <!-- Page header -->
105
+ <div class="mb-5.5 flex flex-wrap items-center justify-between gap-4">
106
+ <h1 class="text-[26px] font-bold tracking-tight text-ink">
107
+ {{ taxonomyType?.label ?? type }}
108
+ </h1>
109
+ <button
110
+ v-if="can('taxonomy:create')"
111
+ type="button"
112
+ class="inline-flex items-center gap-1.75 rounded-[11px] bg-indigo-600 px-4 py-2.5 text-[13.5px] font-semibold text-white shadow-[0_3px_10px_rgba(91,87,232,0.3)] transition-all hover:bg-indigo-700 hover:shadow-[0_6px_18px_rgba(91,87,232,0.4)]"
113
+ @click="goToCreate"
114
+ >
115
+ <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>
116
+ New {{ taxonomyType?.label ?? 'Term' }}
117
+ </button>
118
+ </div>
119
+
120
+ <!-- Search -->
121
+ <div class="mb-4 flex h-9.5 items-center gap-2 rounded-[10px] border border-card-border bg-[#FBFBFD] px-2.75 text-[13px] transition-colors focus-within:border-indigo-400">
122
+ <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">
123
+ <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" />
124
+ </svg>
125
+ <input
126
+ v-model="search"
127
+ type="text"
128
+ placeholder="Search terms…"
129
+ class="w-full bg-transparent text-ink outline-none placeholder:text-faint"
130
+ @input="onSearchInput"
131
+ />
132
+ </div>
133
+
134
+ <!-- Loading skeleton -->
135
+ <div v-if="loading" class="space-y-2">
136
+ <div
137
+ v-for="n in 6"
138
+ :key="n"
139
+ class="h-12 animate-pulse rounded-[10px] bg-gray-100"
140
+ />
141
+ </div>
142
+
143
+ <!-- Empty state -->
144
+ <div
145
+ v-else-if="items.length === 0"
146
+ class="rounded-2xl border border-dashed border-gray-300 p-12 text-center"
147
+ >
148
+ <p class="text-sm text-muted">
149
+ {{ search.trim() !== '' ? `No results for “${search.trim()}”.` : 'No terms yet.' }}
150
+ </p>
151
+ <button
152
+ v-if="can('taxonomy:create') && search.trim() === ''"
153
+ type="button"
154
+ class="mt-3 text-sm font-medium text-indigo-600 hover:text-indigo-800"
155
+ @click="goToCreate"
156
+ >
157
+ Create the first one →
158
+ </button>
159
+ </div>
160
+
161
+ <!-- Table -->
162
+ <div v-else class="overflow-hidden rounded-2xl 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)]">
163
+ <table class="w-full text-sm">
164
+ <thead class="text-[11.5px] font-bold uppercase tracking-[.06em] text-faint">
165
+ <tr class="border-b border-divider">
166
+ <th class="px-5.5 py-3.5 text-left">Name</th>
167
+ <th class="px-5.5 py-3.5 text-left">Updated</th>
168
+ </tr>
169
+ </thead>
170
+ <tbody class="divide-y divide-[#F4F3F9]">
171
+ <tr
172
+ v-for="item in items"
173
+ :key="String(item.id)"
174
+ class="cursor-pointer transition-colors hover:bg-[#FAFAFE]"
175
+ @click="goToEdit(item)"
176
+ >
177
+ <td class="px-5.5 py-4 text-[14.5px] font-semibold text-ink">
178
+ {{ item[titleField] ?? '—' }}
179
+ </td>
180
+ <td class="px-5.5 py-4 text-[13px] text-faint">
181
+ {{ formatDate(item.updated_at) }}
182
+ </td>
183
+ </tr>
184
+ </tbody>
185
+ </table>
186
+ </div>
187
+
188
+ <!-- Pagination -->
189
+ <div v-if="!loading && total > DEFAULT_PAGE_SIZE" class="mt-4 flex justify-center">
190
+ <Pagination
191
+ :total="total"
192
+ :page="page"
193
+ :per-page="DEFAULT_PAGE_SIZE"
194
+ @update:page="onPageChange"
195
+ />
196
+ </div>
197
+ </div>
198
+ </template>