@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,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
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
|
+
})
|
package/src/stores/ui.ts
ADDED
|
@@ -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
|
+
})
|