@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,20 @@
1
+ import type { ParsedRole } from '@bobbykim/manguito-cms-core'
2
+ import { useAuthStore } from '../stores/auth'
3
+ import { useSchemaStore } from '../stores/schema'
4
+
5
+ export function usePermission() {
6
+ const auth = useAuthStore()
7
+ const schema = useSchemaStore()
8
+
9
+ function can(permission: string): boolean {
10
+ return auth.permissions.includes(permission)
11
+ }
12
+
13
+ function rolesBelow(): ParsedRole[] {
14
+ return schema.roles.filter(
15
+ r => r.hierarchy_level > auth.hierarchyLevel && r.name !== 'admin'
16
+ )
17
+ }
18
+
19
+ return { can, rolesBelow }
20
+ }
@@ -0,0 +1,25 @@
1
+ import { nextTick, ref, watch, type Ref } from 'vue'
2
+
3
+ /**
4
+ * Tracks the offsetLeft/offsetWidth of the tab matching `activeId` inside `containerRef`
5
+ * (each tab button must carry `data-tab="<id>"`) so callers can render a sliding underline.
6
+ */
7
+ export function useTabIndicator(containerRef: Ref<HTMLElement | null>, activeId: Ref<string>) {
8
+ const left = ref(0)
9
+ const width = ref(0)
10
+
11
+ function measure() {
12
+ const container = containerRef.value
13
+ if (!container) return
14
+ const el = container.querySelector<HTMLElement>(`[data-tab="${activeId.value}"]`)
15
+ if (!el) return
16
+ left.value = el.offsetLeft
17
+ width.value = el.offsetWidth
18
+ }
19
+
20
+ watch([containerRef, activeId], () => {
21
+ void nextTick(() => measure())
22
+ }, { immediate: true })
23
+
24
+ return { left, width, measure }
25
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ declare const __ADMIN_PREFIX__: string
2
+ declare const __API_PREFIX__: string
package/src/main.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import App from './App.vue'
4
+ import router from './router'
5
+ import '@fontsource/plus-jakarta-sans/400.css'
6
+ import '@fontsource/plus-jakarta-sans/500.css'
7
+ import '@fontsource/plus-jakarta-sans/600.css'
8
+ import '@fontsource/plus-jakarta-sans/700.css'
9
+ import '@fontsource/plus-jakarta-sans/800.css'
10
+ import '@fontsource/jetbrains-mono/400.css'
11
+ import '@fontsource/jetbrains-mono/500.css'
12
+ import './style.css'
13
+
14
+ const app = createApp(App)
15
+
16
+ // Pinia must be installed before the router because navigation guards
17
+ // call useAuthStore(), which requires an active Pinia instance.
18
+ app.use(createPinia())
19
+ app.use(router)
20
+
21
+ app.mount('#app')
@@ -0,0 +1,174 @@
1
+ import { createRouter, createWebHistory } from 'vue-router'
2
+ import type { RouteRecordRaw } from 'vue-router'
3
+ import { useAuthStore } from '../stores/auth'
4
+ import AppShell from '../components/layout/AppShell.vue'
5
+
6
+ // ─── Route meta typing ────────────────────────────────────────────────────────
7
+
8
+ declare module 'vue-router' {
9
+ interface RouteMeta {
10
+ requiresAuth?: boolean
11
+ permission?: string
12
+ }
13
+ }
14
+
15
+ // ─── Routes ───────────────────────────────────────────────────────────────────
16
+
17
+ const routes: RouteRecordRaw[] = [
18
+ // ── Public ────────────────────────────────────────────────────────────────
19
+ {
20
+ path: `${__ADMIN_PREFIX__}/login`,
21
+ name: 'login',
22
+ component: () => import('../views/LoginView.vue'),
23
+ },
24
+
25
+ // ── Authenticated — all rendered inside the AppShell layout ───────────────
26
+ // AppShell contains the sidebar, topbar, and a <router-view> for the child.
27
+ // meta.requiresAuth on the parent is merged into every child's to.meta.
28
+ {
29
+ path: `${__ADMIN_PREFIX__}`,
30
+ component: AppShell,
31
+ meta: { requiresAuth: true },
32
+ children: [
33
+ // Default landing
34
+ { path: '', redirect: `${__ADMIN_PREFIX__}/media` },
35
+
36
+ // Change password
37
+ {
38
+ path: 'change-password',
39
+ name: 'change-password',
40
+ component: () => import('../views/ChangePasswordView.vue'),
41
+ },
42
+
43
+ // ── Content ─────────────────────────────────────────────────────────
44
+ // /new and /settings defined before /:id so static segments win.
45
+ {
46
+ path: 'content/:type',
47
+ name: 'content-list',
48
+ component: () => import('../views/content/ContentListView.vue'),
49
+ },
50
+ {
51
+ path: 'content/:type/new',
52
+ name: 'content-new',
53
+ component: () => import('../views/content/ContentFormView.vue'),
54
+ },
55
+ {
56
+ path: 'content/:type/settings',
57
+ name: 'content-settings',
58
+ component: () => import('../views/content/ContentSettingsView.vue'),
59
+ meta: { permission: 'content:edit' },
60
+ },
61
+ {
62
+ path: 'content/:type/:id',
63
+ name: 'content-edit',
64
+ component: () => import('../views/content/ContentFormView.vue'),
65
+ },
66
+
67
+ // ── Taxonomy ─────────────────────────────────────────────────────────
68
+ {
69
+ path: 'taxonomy/:type',
70
+ name: 'taxonomy-list',
71
+ component: () => import('../views/taxonomy/TaxonomyListView.vue'),
72
+ },
73
+ {
74
+ path: 'taxonomy/:type/new',
75
+ name: 'taxonomy-new',
76
+ component: () => import('../views/taxonomy/TaxonomyFormView.vue'),
77
+ },
78
+ {
79
+ path: 'taxonomy/:type/:id',
80
+ name: 'taxonomy-edit',
81
+ component: () => import('../views/taxonomy/TaxonomyFormView.vue'),
82
+ },
83
+
84
+ // ── Media ─────────────────────────────────────────────────────────────
85
+ {
86
+ path: 'media',
87
+ name: 'media-library',
88
+ component: () => import('../views/media/MediaLibraryView.vue'),
89
+ },
90
+ {
91
+ path: 'media/:id',
92
+ name: 'media-detail',
93
+ component: () => import('../views/media/MediaDetailView.vue'),
94
+ },
95
+
96
+ // ── Users ─────────────────────────────────────────────────────────────
97
+ {
98
+ path: 'users',
99
+ name: 'user-list',
100
+ component: () => import('../views/users/UserListView.vue'),
101
+ meta: { permission: 'users:read' },
102
+ },
103
+ {
104
+ path: 'users/new',
105
+ name: 'user-new',
106
+ component: () => import('../views/users/UserFormView.vue'),
107
+ meta: { permission: 'users:read' },
108
+ },
109
+ {
110
+ path: 'users/:id',
111
+ name: 'user-edit',
112
+ component: () => import('../views/users/UserFormView.vue'),
113
+ meta: { permission: 'users:read' },
114
+ },
115
+
116
+ // ── Roles ─────────────────────────────────────────────────────────────
117
+ {
118
+ path: 'roles',
119
+ name: 'roles',
120
+ component: () => import('../views/RolesView.vue'),
121
+ },
122
+ ],
123
+ },
124
+ ]
125
+
126
+ // ─── Router ───────────────────────────────────────────────────────────────────
127
+
128
+ const router = createRouter({
129
+ history: createWebHistory(),
130
+ routes,
131
+ })
132
+
133
+ // ─── Global auth guard ────────────────────────────────────────────────────────
134
+
135
+ router.beforeEach((to) => {
136
+ const auth = useAuthStore()
137
+
138
+ // Already authenticated — redirect away from login.
139
+ if (to.path === `${__ADMIN_PREFIX__}/login`) {
140
+ if (auth.isAuthenticated) return { path: `${__ADMIN_PREFIX__}/media` }
141
+ return true
142
+ }
143
+
144
+ // Not authenticated — preserve intended destination as redirect query param.
145
+ if (!auth.isAuthenticated) {
146
+ return {
147
+ path: `${__ADMIN_PREFIX__}/login`,
148
+ query: { redirect: to.fullPath },
149
+ }
150
+ }
151
+
152
+ // Must change password — force redirect to change-password.
153
+ if (auth.mustChangePassword && to.path !== `${__ADMIN_PREFIX__}/change-password`) {
154
+ return { path: `${__ADMIN_PREFIX__}/change-password` }
155
+ }
156
+
157
+ // Password already changed — redirect away from change-password.
158
+ if (!auth.mustChangePassword && to.path === `${__ADMIN_PREFIX__}/change-password`) {
159
+ return { path: `${__ADMIN_PREFIX__}/media` }
160
+ }
161
+
162
+ return true
163
+ })
164
+
165
+ // ─── Permission guard ─────────────────────────────────────────────────────────
166
+
167
+ router.beforeEach((to) => {
168
+ const auth = useAuthStore()
169
+ if (to.meta.permission && !auth.hasPermission(to.meta.permission)) {
170
+ return { path: `${__ADMIN_PREFIX__}/media` }
171
+ }
172
+ })
173
+
174
+ export default router
@@ -0,0 +1,82 @@
1
+ import { ref, computed } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+ import { useSchemaStore } from './schema'
4
+
5
+ const STORAGE_KEY = 'manguito_auth'
6
+ type StoredAuth = { id: string; email: string; role: string; mustChangePassword: boolean }
7
+
8
+ function loadStored(): StoredAuth | null {
9
+ try {
10
+ const raw = localStorage.getItem(STORAGE_KEY)
11
+ return raw ? (JSON.parse(raw) as StoredAuth) : null
12
+ } catch {
13
+ return null
14
+ }
15
+ }
16
+
17
+ export const useAuthStore = defineStore('auth', () => {
18
+ const schemaStore = useSchemaStore()
19
+
20
+ const stored = loadStored()
21
+ const id = ref<string | null>(stored?.id ?? null)
22
+ const email = ref<string | null>(stored?.email ?? null)
23
+ const role = ref<string | null>(stored?.role ?? null)
24
+ const mustChangePassword = ref(stored?.mustChangePassword ?? false)
25
+
26
+ const isAuthenticated = computed(() => id.value !== null)
27
+
28
+ // Derived from role name via schema store — not stored directly.
29
+ const permissions = computed<string[]>(() =>
30
+ role.value ? (schemaStore.getRoleByName(role.value)?.permissions ?? []) : []
31
+ )
32
+
33
+ // Derived from role name via schema store — not stored directly.
34
+ const hierarchyLevel = computed<number>(() =>
35
+ role.value
36
+ ? (schemaStore.getRoleByName(role.value)?.hierarchy_level ?? Infinity)
37
+ : Infinity
38
+ )
39
+
40
+ function setUser(user: {
41
+ id: string
42
+ email: string
43
+ role: string
44
+ mustChangePassword?: boolean
45
+ }) {
46
+ id.value = user.id
47
+ email.value = user.email
48
+ role.value = user.role
49
+ mustChangePassword.value = user.mustChangePassword ?? false
50
+ try {
51
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
52
+ id: user.id, email: user.email, role: user.role,
53
+ mustChangePassword: user.mustChangePassword ?? false,
54
+ }))
55
+ } catch { /* storage unavailable (private mode / SSR) — non-fatal */ }
56
+ }
57
+
58
+ function clear() {
59
+ id.value = null
60
+ email.value = null
61
+ role.value = null
62
+ mustChangePassword.value = false
63
+ try { localStorage.removeItem(STORAGE_KEY) } catch { /* storage unavailable — non-fatal */ }
64
+ }
65
+
66
+ function hasPermission(permission: string): boolean {
67
+ return permissions.value.includes(permission)
68
+ }
69
+
70
+ return {
71
+ id,
72
+ email,
73
+ role,
74
+ mustChangePassword,
75
+ isAuthenticated,
76
+ permissions,
77
+ hierarchyLevel,
78
+ setUser,
79
+ clear,
80
+ hasPermission,
81
+ }
82
+ })
@@ -0,0 +1,40 @@
1
+ import { ref } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+
4
+ export const DEFAULT_PAGE_SIZE = 50
5
+
6
+ export const useContentStore = defineStore('content', () => {
7
+ const pages = ref<Map<string, unknown[]>>(new Map())
8
+ const total = ref(0)
9
+ const loading = ref(false)
10
+
11
+ function cacheKey(contentType: string, page: number, perPage: number): string {
12
+ return `${contentType}:${page}:${perPage}`
13
+ }
14
+
15
+ function setPage(contentType: string, page: number, perPage: number, items: unknown[]) {
16
+ pages.value.set(cacheKey(contentType, page, perPage), items)
17
+ }
18
+
19
+ function getPage(contentType: string, page: number, perPage: number): unknown[] | undefined {
20
+ return pages.value.get(cacheKey(contentType, page, perPage))
21
+ }
22
+
23
+ function invalidate(contentType: string) {
24
+ const prefix = `${contentType}:`
25
+ for (const key of [...pages.value.keys()]) {
26
+ if (key.startsWith(prefix)) {
27
+ pages.value.delete(key)
28
+ }
29
+ }
30
+ }
31
+
32
+ return {
33
+ pages,
34
+ total,
35
+ loading,
36
+ setPage,
37
+ getPage,
38
+ invalidate,
39
+ }
40
+ })
@@ -0,0 +1,31 @@
1
+ import { ref } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+ import type { MediaItem } from '@bobbykim/manguito-cms-core'
4
+
5
+ export const useMediaStore = defineStore('media', () => {
6
+ const items = ref<Map<string, MediaItem>>(new Map())
7
+ const loading = ref(false)
8
+
9
+ function setItems(newItems: MediaItem[]) {
10
+ items.value.clear()
11
+ for (const item of newItems) {
12
+ items.value.set(item.id, item)
13
+ }
14
+ }
15
+
16
+ function setItem(item: MediaItem) {
17
+ items.value.set(item.id, item)
18
+ }
19
+
20
+ function removeItem(id: string) {
21
+ items.value.delete(id)
22
+ }
23
+
24
+ return {
25
+ items,
26
+ loading,
27
+ setItems,
28
+ setItem,
29
+ removeItem,
30
+ }
31
+ })
@@ -0,0 +1,130 @@
1
+ import { ref } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+ import type {
4
+ ParsedSchema,
5
+ ParsedContentType,
6
+ ParsedParagraphType,
7
+ ParsedTaxonomyType,
8
+ ParsedEnumType,
9
+ ParsedRole,
10
+ ParsedField,
11
+ UiMeta,
12
+ SystemField,
13
+ } from '@bobbykim/manguito-cms-core'
14
+
15
+ // Shape returned by GET /admin/api/schema — full ParsedField objects so the
16
+ // admin forms can render inputs and run client-side validation without extra
17
+ // round-trips to the server.
18
+ export type ApiSchemaResponse = {
19
+ content_types: Array<{
20
+ name: string
21
+ label: string
22
+ only_one: boolean
23
+ ui: UiMeta
24
+ system_fields: SystemField[]
25
+ fields: ParsedField[]
26
+ }>
27
+ taxonomy_types: Array<{
28
+ name: string
29
+ label: string
30
+ system_fields: SystemField[]
31
+ fields: ParsedField[]
32
+ }>
33
+ paragraph_types: Array<{
34
+ name: string
35
+ label: string
36
+ system_fields: SystemField[]
37
+ fields: ParsedField[]
38
+ }>
39
+ enum_types: Array<{ name: string; label: string; values: string[] }>
40
+ }
41
+
42
+ export const useSchemaStore = defineStore('schema', () => {
43
+ const contentTypes = ref<Record<string, ParsedContentType>>({})
44
+ const taxonomyTypes = ref<Record<string, ParsedTaxonomyType>>({})
45
+ const paragraphTypes = ref<Record<string, ParsedParagraphType>>({})
46
+ const enumTypes = ref<Record<string, ParsedEnumType>>({})
47
+ const roles = ref<ParsedRole[]>([])
48
+
49
+ function setSchema(schema: ParsedSchema) {
50
+ switch (schema.schema_type) {
51
+ case 'content-type':
52
+ contentTypes.value[schema.name] = schema
53
+ break
54
+ case 'taxonomy-type':
55
+ taxonomyTypes.value[schema.name] = schema
56
+ break
57
+ case 'paragraph-type':
58
+ paragraphTypes.value[schema.name] = schema
59
+ break
60
+ case 'enum-type':
61
+ enumTypes.value[schema.name] = schema
62
+ break
63
+ }
64
+ }
65
+
66
+ function setRoles(newRoles: ParsedRole[]) {
67
+ roles.value = newRoles
68
+ }
69
+
70
+ function setFromApiSchema(data: ApiSchemaResponse) {
71
+ for (const ct of data.content_types) {
72
+ contentTypes.value[ct.name] = {
73
+ schema_type: 'content-type',
74
+ name: ct.name,
75
+ label: ct.label,
76
+ only_one: ct.only_one,
77
+ ui: ct.ui,
78
+ system_fields: ct.system_fields,
79
+ fields: ct.fields,
80
+ } as unknown as ParsedContentType
81
+ }
82
+ for (const tt of data.taxonomy_types) {
83
+ taxonomyTypes.value[tt.name] = {
84
+ schema_type: 'taxonomy-type',
85
+ name: tt.name,
86
+ label: tt.label,
87
+ system_fields: tt.system_fields,
88
+ fields: tt.fields,
89
+ } as unknown as ParsedTaxonomyType
90
+ }
91
+ for (const pt of data.paragraph_types) {
92
+ paragraphTypes.value[pt.name] = {
93
+ schema_type: 'paragraph-type',
94
+ name: pt.name,
95
+ label: pt.label,
96
+ system_fields: pt.system_fields,
97
+ fields: pt.fields,
98
+ } as unknown as ParsedParagraphType
99
+ }
100
+ for (const et of data.enum_types) {
101
+ enumTypes.value[et.name] = {
102
+ schema_type: 'enum-type',
103
+ name: et.name,
104
+ label: et.label,
105
+ values: et.values,
106
+ } as unknown as ParsedEnumType
107
+ }
108
+ }
109
+
110
+ function getContentType(name: string): ParsedContentType | undefined {
111
+ return contentTypes.value[name]
112
+ }
113
+
114
+ function getRoleByName(name: string): ParsedRole | undefined {
115
+ return roles.value.find(r => r.name === name)
116
+ }
117
+
118
+ return {
119
+ contentTypes,
120
+ taxonomyTypes,
121
+ paragraphTypes,
122
+ enumTypes,
123
+ roles,
124
+ setSchema,
125
+ setRoles,
126
+ setFromApiSchema,
127
+ getContentType,
128
+ getRoleByName,
129
+ }
130
+ })
@@ -0,0 +1,40 @@
1
+ import { ref } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+
4
+ export const DEFAULT_PAGE_SIZE = 50
5
+
6
+ export const useTaxonomyStore = defineStore('taxonomy', () => {
7
+ const pages = ref<Map<string, unknown[]>>(new Map())
8
+ const total = ref(0)
9
+ const loading = ref(false)
10
+
11
+ function cacheKey(taxonomyType: string, page: number, perPage: number): string {
12
+ return `${taxonomyType}:${page}:${perPage}`
13
+ }
14
+
15
+ function setPage(taxonomyType: string, page: number, perPage: number, items: unknown[]) {
16
+ pages.value.set(cacheKey(taxonomyType, page, perPage), items)
17
+ }
18
+
19
+ function getPage(taxonomyType: string, page: number, perPage: number): unknown[] | undefined {
20
+ return pages.value.get(cacheKey(taxonomyType, page, perPage))
21
+ }
22
+
23
+ function invalidate(taxonomyType: string) {
24
+ const prefix = `${taxonomyType}:`
25
+ for (const key of [...pages.value.keys()]) {
26
+ if (key.startsWith(prefix)) {
27
+ pages.value.delete(key)
28
+ }
29
+ }
30
+ }
31
+
32
+ return {
33
+ pages,
34
+ total,
35
+ loading,
36
+ setPage,
37
+ getPage,
38
+ invalidate,
39
+ }
40
+ })
@@ -0,0 +1,94 @@
1
+ import { ref } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+ import { useMediaQuery } from '@vueuse/core'
4
+
5
+ export type ToastVariant = 'success' | 'error' | 'warning' | 'info'
6
+
7
+ export type Toast = {
8
+ id: string
9
+ variant: ToastVariant
10
+ message: string
11
+ duration: number | null
12
+ }
13
+
14
+ const MAX_VISIBLE = 3
15
+ const DEFAULT_DURATION = 4000
16
+
17
+ export const useUiStore = defineStore('ui', () => {
18
+ const toasts = ref<Toast[]>([])
19
+ // Desktop: rail collapses to icon-only width. Mobile: rail becomes a slide-in drawer instead.
20
+ const sidebarCollapsed = ref(false)
21
+ const isMobile = useMediaQuery('(max-width: 820px)')
22
+ const mobileNavOpen = ref(false)
23
+ const activeModal = ref<string | null>(null)
24
+ const maxFileSize = ref(0)
25
+ // When true (cloud storage), uploads go straight to storage via presigned URL;
26
+ // when false (local storage), they use the direct upload endpoint.
27
+ const presignedUploads = ref(false)
28
+ const cmsName = ref('Manguito CMS')
29
+
30
+ function addToast(variant: ToastVariant, message: string, duration: number | null = DEFAULT_DURATION) {
31
+ // Evict oldest when at capacity before adding the new one.
32
+ if (toasts.value.length >= MAX_VISIBLE) {
33
+ toasts.value.shift()
34
+ }
35
+
36
+ const id = crypto.randomUUID()
37
+ toasts.value.push({ id, variant, message, duration })
38
+
39
+ if (duration !== null) {
40
+ setTimeout(() => removeToast(id), duration)
41
+ }
42
+ }
43
+
44
+ function removeToast(id: string) {
45
+ const index = toasts.value.findIndex(t => t.id === id)
46
+ if (index !== -1) {
47
+ toasts.value.splice(index, 1)
48
+ }
49
+ }
50
+
51
+ function setMaxFileSize(bytes: number) {
52
+ maxFileSize.value = bytes
53
+ }
54
+
55
+ function setPresignedUploads(value: boolean) {
56
+ presignedUploads.value = value
57
+ }
58
+
59
+ function setCmsName(name: string) {
60
+ cmsName.value = name
61
+ }
62
+
63
+ // Single entry point for the topbar toggle button — collapses the rail on desktop,
64
+ // opens the drawer on mobile.
65
+ function toggleSidebar() {
66
+ if (isMobile.value) {
67
+ mobileNavOpen.value = !mobileNavOpen.value
68
+ } else {
69
+ sidebarCollapsed.value = !sidebarCollapsed.value
70
+ }
71
+ }
72
+
73
+ function closeMobileNav() {
74
+ mobileNavOpen.value = false
75
+ }
76
+
77
+ return {
78
+ toasts,
79
+ sidebarCollapsed,
80
+ isMobile,
81
+ mobileNavOpen,
82
+ activeModal,
83
+ maxFileSize,
84
+ presignedUploads,
85
+ cmsName,
86
+ addToast,
87
+ removeToast,
88
+ setMaxFileSize,
89
+ setPresignedUploads,
90
+ setCmsName,
91
+ toggleSidebar,
92
+ closeMobileNav,
93
+ }
94
+ })