@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
package/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
7
+ <title>Manguito CMS</title>
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.ts"></script>
12
+ </body>
13
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobbykim/manguito-cms-admin",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "author": "Bobby Kim <bobby.sihun.kim@gmail.com>",
6
6
  "homepage": "https://github.com/bobbykim89/manguito-cms",
@@ -21,33 +21,37 @@
21
21
  "main": "./dist/index.js",
22
22
  "types": "./dist/index.d.ts",
23
23
  "files": [
24
- "dist"
24
+ "dist",
25
+ "src",
26
+ "public",
27
+ "index.html",
28
+ "vite.config.ts"
25
29
  ],
26
30
  "dependencies": {
27
31
  "@fontsource/jetbrains-mono": "^5.2.8",
28
32
  "@fontsource/plus-jakarta-sans": "^5.2.8",
33
+ "@tailwindcss/vite": "^4.3.2",
29
34
  "@tiptap/extension-link": "^2.27.2",
30
35
  "@tiptap/starter-kit": "^2.27.2",
31
36
  "@tiptap/vue-3": "^2.27.2",
37
+ "@vitejs/plugin-vue": "^6.0.7",
32
38
  "@vueuse/core": "^12.8.2",
33
39
  "pinia": "^3.0.4",
40
+ "tailwindcss": "^4.3.2",
41
+ "vite": "^8.1.3",
34
42
  "vue": "^3.5.39",
35
43
  "vue-router": "^4.6.4",
36
44
  "@bobbykim/manguito-cms-core": "0.1.1"
37
45
  },
38
46
  "devDependencies": {
39
- "@tailwindcss/vite": "^4.3.2",
40
47
  "@testing-library/vue": "^8.1.0",
41
48
  "@types/jsdom": "^28.0.3",
42
- "@vitejs/plugin-vue": "^6.0.7",
43
49
  "@vue/test-utils": "^2.4.11",
44
50
  "dotenv-cli": "latest",
45
51
  "jsdom": "^29.1.1",
46
52
  "msw": "^2.15.0",
47
- "tailwindcss": "^4.3.2",
48
53
  "tsup": "^8.5.1",
49
54
  "typescript": "^6.0.3",
50
- "vite": "^8.1.3",
51
55
  "vitest": "^4.1.10",
52
56
  "vue-tsc": "^3.3.7"
53
57
  },
Binary file
package/src/App.vue ADDED
@@ -0,0 +1,147 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, watch } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import type { ParsedRole } from '@bobbykim/manguito-cms-core'
5
+ import { useAuthStore } from './stores/auth'
6
+ import { useSchemaStore, type ApiSchemaResponse } from './stores/schema'
7
+ import { useUiStore } from './stores/ui'
8
+
9
+ // ─── Config response shape ────────────────────────────────────────────────────
10
+ // Matches the spec in phase-08-api-client.md.
11
+ // The current API config endpoint returns cms_name, version, and roles.
12
+ // user and media are included here for when the API is extended.
13
+
14
+ type ConfigResponse = {
15
+ cms_name: string
16
+ version: string
17
+ roles: Array<{ name: string; label: string; hierarchy_level: number }>
18
+ all_roles?: Array<{
19
+ name: string
20
+ label: string
21
+ hierarchy_level: number
22
+ is_system: boolean
23
+ permissions: string[]
24
+ }>
25
+ user?: {
26
+ id: string
27
+ email: string
28
+ role: string
29
+ must_change_password: boolean
30
+ }
31
+ media?: {
32
+ max_file_size: number
33
+ presigned_uploads?: boolean
34
+ }
35
+ }
36
+
37
+ // ─── Component state ──────────────────────────────────────────────────────────
38
+
39
+ const loading = ref(true)
40
+ const router = useRouter()
41
+ const authStore = useAuthStore()
42
+ const schemaStore = useSchemaStore()
43
+ const uiStore = useUiStore()
44
+
45
+ // ─── Bootstrap ────────────────────────────────────────────────────────────────
46
+
47
+ async function fetchConfigAndSchema(redirectOn401 = true): Promise<void> {
48
+ const response = await fetch(`${__ADMIN_PREFIX__}/api/config`, {
49
+ credentials: 'include',
50
+ })
51
+
52
+ if (response.status === 401) {
53
+ if (redirectOn401) {
54
+ authStore.clear()
55
+ void router.push(`${__ADMIN_PREFIX__}/login`)
56
+ }
57
+ return
58
+ }
59
+
60
+ if (response.ok) {
61
+ const result = await response.json() as { ok: true; data: ConfigResponse }
62
+ const data = result.data
63
+
64
+ const roleSource = data.all_roles ?? []
65
+ schemaStore.setRoles(
66
+ roleSource.map(r => ({
67
+ name: r.name,
68
+ label: r.label,
69
+ hierarchy_level: r.hierarchy_level,
70
+ is_system: r.is_system,
71
+ permissions: r.permissions as ParsedRole['permissions'],
72
+ }))
73
+ )
74
+
75
+ if (data.user) {
76
+ authStore.setUser({
77
+ id: data.user.id,
78
+ email: data.user.email,
79
+ role: data.user.role,
80
+ mustChangePassword: data.user.must_change_password,
81
+ })
82
+ }
83
+
84
+ uiStore.setCmsName(data.cms_name)
85
+
86
+ if (data.media?.max_file_size) {
87
+ uiStore.setMaxFileSize(data.media.max_file_size)
88
+ }
89
+ uiStore.setPresignedUploads(data.media?.presigned_uploads ?? false)
90
+
91
+ const schemaRes = await fetch(`${__ADMIN_PREFIX__}/api/schema`, { credentials: 'include' })
92
+ if (schemaRes.ok) {
93
+ const schemaResult = await schemaRes.json() as { ok: true; data: ApiSchemaResponse }
94
+ schemaStore.setFromApiSchema(schemaResult.data)
95
+ }
96
+
97
+ if (authStore.mustChangePassword) {
98
+ void router.push(`${__ADMIN_PREFIX__}/change-password`)
99
+ }
100
+ }
101
+ }
102
+
103
+ onMounted(async () => {
104
+ if (!authStore.isAuthenticated) {
105
+ loading.value = false
106
+ return
107
+ }
108
+ try {
109
+ await fetchConfigAndSchema()
110
+ } finally {
111
+ loading.value = false
112
+ }
113
+ })
114
+
115
+ watch(() => uiStore.cmsName, (name) => { document.title = name }, { immediate: true })
116
+
117
+ // Re-run bootstrap when the user logs in (isAuthenticated flips false → true).
118
+ // Show the loading spinner during the fetch so AppShell never renders with an
119
+ // empty schema store (same guarantee as a page refresh gives).
120
+ watch(() => authStore.isAuthenticated, async (isAuth, wasAuth) => {
121
+ if (isAuth && !wasAuth) {
122
+ loading.value = true
123
+ try {
124
+ await fetchConfigAndSchema(false)
125
+ } catch {
126
+ // ignore
127
+ } finally {
128
+ loading.value = false
129
+ }
130
+ }
131
+ })
132
+ </script>
133
+
134
+ <template>
135
+ <div
136
+ v-if="loading"
137
+ class="fixed inset-0 flex items-center justify-center bg-white"
138
+ aria-label="Loading"
139
+ >
140
+ <div
141
+ class="h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-gray-600"
142
+ role="status"
143
+ />
144
+ </div>
145
+
146
+ <router-view v-else />
147
+ </template>
@@ -0,0 +1,13 @@
1
+ import type { AdminAdapter } from '@bobbykim/manguito-cms-core'
2
+
3
+ export type AdminAdapterOptions = {
4
+ prefix?: string
5
+ }
6
+
7
+ export function createAdminAdapter(
8
+ options: AdminAdapterOptions = {}
9
+ ): AdminAdapter {
10
+ const prefix = options.prefix ?? '/admin'
11
+
12
+ return { prefix }
13
+ }
File without changes
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+
5
+ const props = defineProps<{
6
+ field: ParsedField
7
+ modelValue: unknown
8
+ error?: string
9
+ disabled?: boolean
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: boolean]
14
+ }>()
15
+
16
+ const boolValue = computed(() => props.modelValue === true)
17
+
18
+ function toggle() {
19
+ if (!props.disabled) {
20
+ emit('update:modelValue', !boolValue.value)
21
+ }
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <div>
27
+ <span class="block text-[13px] font-semibold text-[#3D3D52]">
28
+ {{ field.label }}
29
+ <span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
30
+ </span>
31
+
32
+ <div class="mt-2 flex items-center gap-3">
33
+ <button
34
+ :id="field.name"
35
+ type="button"
36
+ role="switch"
37
+ :aria-checked="boolValue"
38
+ :aria-label="field.label"
39
+ :disabled="disabled"
40
+ :class="[
41
+ 'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2',
42
+ boolValue ? 'bg-indigo-600' : 'bg-gray-200',
43
+ disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
44
+ ]"
45
+ @click="toggle"
46
+ >
47
+ <span
48
+ :class="[
49
+ 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
50
+ boolValue ? 'translate-x-6' : 'translate-x-1',
51
+ ]"
52
+ />
53
+ </button>
54
+ <span class="text-sm text-gray-600 select-none">
55
+ {{ boolValue ? 'Yes' : 'No' }}
56
+ </span>
57
+ </div>
58
+
59
+ <p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
60
+ {{ error }}
61
+ </p>
62
+ </div>
63
+ </template>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+
5
+ const props = defineProps<{
6
+ field: ParsedField
7
+ modelValue: unknown
8
+ error?: string
9
+ disabled?: boolean
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: string]
14
+ }>()
15
+
16
+ // datetime-local requires "YYYY-MM-DDTHH:MM". ISO strings are "YYYY-MM-DDTHH:MM:SS.mssZ".
17
+ // Slice the first 16 characters to get the compatible format, treating stored value as UTC.
18
+ const displayValue = computed(() => {
19
+ if (typeof props.modelValue !== 'string' || !props.modelValue) return ''
20
+ return props.modelValue.slice(0, 16)
21
+ })
22
+
23
+ function onInput(event: Event) {
24
+ const value = (event.target as HTMLInputElement).value
25
+ // Append seconds and Z to form a valid ISO 8601 UTC datetime string.
26
+ emit('update:modelValue', value ? `${value}:00.000Z` : '')
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <div>
32
+ <label
33
+ :for="field.name"
34
+ class="block text-[13px] font-semibold text-[#3D3D52]"
35
+ >
36
+ {{ field.label }}
37
+ <span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
38
+ </label>
39
+
40
+ <div class="mt-1">
41
+ <input
42
+ :id="field.name"
43
+ type="datetime-local"
44
+ :value="displayValue"
45
+ :disabled="disabled"
46
+ :class="[
47
+ 'block w-full rounded-[11px] border px-3.5 py-3 text-sm focus:outline-none focus:ring-[3px]',
48
+ error
49
+ ? 'border-red-300 focus:border-red-400 focus:ring-red-200'
50
+ : 'border-[#E4E3EE] bg-[#FBFBFD] focus:border-indigo-400 focus:bg-white focus:ring-indigo-50',
51
+ disabled && 'cursor-not-allowed bg-gray-50 opacity-60',
52
+ ]"
53
+ @input="onInput"
54
+ />
55
+ </div>
56
+
57
+ <p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
58
+ {{ error }}
59
+ </p>
60
+ </div>
61
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { ParsedField } from '@bobbykim/manguito-cms-core'
4
+
5
+ const props = defineProps<{
6
+ field: ParsedField
7
+ modelValue: unknown
8
+ error?: string
9
+ disabled?: boolean
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: string]
14
+ }>()
15
+
16
+ const options = computed(() => props.field.validation.allowed_values ?? [])
17
+ const hasOptions = computed(() => options.value.length > 0)
18
+
19
+ const selectValue = computed(() =>
20
+ typeof props.modelValue === 'string' ? props.modelValue : ''
21
+ )
22
+
23
+ function onChange(event: Event) {
24
+ emit('update:modelValue', (event.target as HTMLSelectElement).value)
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <div>
30
+ <label
31
+ :for="field.name"
32
+ class="block text-[13px] font-semibold text-[#3D3D52]"
33
+ >
34
+ {{ field.label }}
35
+ <span v-if="field.required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
36
+ </label>
37
+
38
+ <div class="mt-1">
39
+ <select
40
+ :id="field.name"
41
+ :value="selectValue"
42
+ :disabled="disabled || !hasOptions"
43
+ :class="[
44
+ 'block w-full rounded-[11px] border px-3.5 py-3 text-sm focus:outline-none focus:ring-[3px]',
45
+ error
46
+ ? 'border-red-300 focus:border-red-400 focus:ring-red-200'
47
+ : 'border-[#E4E3EE] bg-[#FBFBFD] focus:border-indigo-400 focus:bg-white focus:ring-indigo-50',
48
+ (disabled || !hasOptions) && 'cursor-not-allowed bg-gray-50 opacity-60',
49
+ ]"
50
+ @change="onChange"
51
+ >
52
+ <option value="" disabled>
53
+ {{ hasOptions ? `Select ${field.label}` : 'No options available' }}
54
+ </option>
55
+ <option
56
+ v-for="opt in options"
57
+ :key="opt"
58
+ :value="opt"
59
+ >
60
+ {{ opt }}
61
+ </option>
62
+ </select>
63
+ </div>
64
+
65
+ <p v-if="error" class="mt-1 text-sm text-red-600" role="alert">
66
+ {{ error }}
67
+ </p>
68
+ </div>
69
+ </template>