@plutocms/pluto 0.6.0 → 0.8.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/CHANGELOG.md +14 -0
- package/FEATURES.md +4 -1
- package/app/components/Pluto/Content/PlutoContentField.vue +34 -0
- package/app/components/Pluto/Content/PlutoContentForm.vue +222 -0
- package/app/components/Pluto/Content/PlutoContentList.vue +197 -0
- package/app/components/Pluto/Content/fields/PlutoFieldBoolean.vue +23 -0
- package/app/components/Pluto/Content/fields/PlutoFieldDate.vue +41 -0
- package/app/components/Pluto/Content/fields/PlutoFieldNumber.vue +30 -0
- package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +79 -0
- package/app/components/Pluto/Content/fields/PlutoFieldSelect.vue +28 -0
- package/app/components/Pluto/Content/fields/PlutoFieldSlug.vue +33 -0
- package/app/components/Pluto/Content/fields/PlutoFieldText.vue +40 -0
- package/app/composables/pluto-admin-nav.ts +49 -9
- package/app/composables/pluto-admin-pages.ts +28 -3
- package/app/composables/pluto-content-item.ts +98 -0
- package/app/composables/pluto-content-list.ts +60 -0
- package/app/pages/admin/content/[type]/[id].vue +19 -0
- package/app/pages/admin/content/[type]/index.vue +19 -0
- package/app/pages/admin/content/[type]/new.vue +19 -0
- package/app/plugins/pluto-core-registrations.ts +32 -3
- package/package.json +2 -1
- package/server/api/_pluto/content/[type]/[id].delete.ts +15 -0
- package/server/api/_pluto/content/[type]/[id].get.ts +13 -0
- package/server/api/_pluto/content/[type]/[id].patch.ts +14 -0
- package/server/api/_pluto/content/[type]/index.get.ts +16 -0
- package/server/api/_pluto/content/[type]/index.post.ts +13 -0
- package/server/utils/pluto-content-handlers.ts +168 -0
- package/server/utils/pluto-content.ts +44 -0
- package/shared/types/content.ts +57 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A plain text input plus a preview line. This widget does not auto-derive
|
|
3
|
+
// the slug from another field — only `PlutoContentForm` knows about the
|
|
4
|
+
// *other* field (`field.from`) a slug derives from, so that logic lives
|
|
5
|
+
// there, not here. See the content-model skill for the slug bug this
|
|
6
|
+
// separation is meant to avoid repeating.
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
field: PlutoSlugField
|
|
9
|
+
modelValue: unknown
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
'update:modelValue': [value: string]
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const value = computed(() => (props.modelValue as string | undefined) ?? '')
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<UFormField :label="field.label" :description="field.description" :required="field.required">
|
|
22
|
+
<UInput
|
|
23
|
+
:model-value="value"
|
|
24
|
+
:disabled="disabled"
|
|
25
|
+
class="w-full"
|
|
26
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
27
|
+
/>
|
|
28
|
+
|
|
29
|
+
<p v-if="field.preview" class="text-muted mt-1 text-sm">
|
|
30
|
+
{{ field.preview }}{{ value }}
|
|
31
|
+
</p>
|
|
32
|
+
</UFormField>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Renders both 'text' and 'textarea' fields. `richtext` has no core widget
|
|
3
|
+
// — a layer (for example @plutocms/supabase-blog) registers its own.
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
field: PlutoTextField
|
|
6
|
+
modelValue: unknown
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
defineEmits<{
|
|
11
|
+
'update:modelValue': [value: string]
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const isTextarea = computed(() => props.field.type === 'textarea')
|
|
15
|
+
const value = computed(() => (props.modelValue as string | undefined) ?? undefined)
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<UFormField :label="field.label" :description="field.description" :required="field.required">
|
|
20
|
+
<UTextarea
|
|
21
|
+
v-if="isTextarea"
|
|
22
|
+
:model-value="value"
|
|
23
|
+
:placeholder="field.placeholder"
|
|
24
|
+
:maxlength="field.maxLength"
|
|
25
|
+
:disabled="disabled"
|
|
26
|
+
class="w-full"
|
|
27
|
+
@update:model-value="$emit('update:modelValue', $event)"
|
|
28
|
+
/>
|
|
29
|
+
|
|
30
|
+
<UInput
|
|
31
|
+
v-else
|
|
32
|
+
:model-value="value"
|
|
33
|
+
:placeholder="field.placeholder"
|
|
34
|
+
:maxlength="field.maxLength"
|
|
35
|
+
:disabled="disabled"
|
|
36
|
+
class="w-full"
|
|
37
|
+
@update:model-value="$emit('update:modelValue', $event)"
|
|
38
|
+
/>
|
|
39
|
+
</UFormField>
|
|
40
|
+
</template>
|
|
@@ -6,21 +6,41 @@ interface LegacyHrefLink {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* The admin sidebar nav. Merges
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* The admin sidebar nav. Merges three sources:
|
|
10
|
+
*
|
|
11
|
+
* 1. The registry's own `nav` bucket — a layer's own `definePlutoExtension`
|
|
12
|
+
* call.
|
|
13
|
+
* 2. The old `useSidebarAdminActions` composable (`@plutocms/utils`), so an
|
|
14
|
+
* unmigrated layer's sidebar entries keep showing up. A bridged entry is
|
|
15
|
+
* skipped when a registered entry already uses the same label, so a
|
|
16
|
+
* layer that migrates does not get a duplicate item.
|
|
17
|
+
* 3. One synthesized entry per auto-routed content type (`type.autoRoutes
|
|
18
|
+
* !== false`) — a layer that declares a content type gets a nav entry
|
|
19
|
+
* for free, without calling `definePlutoExtension` itself. Gated on the
|
|
20
|
+
* same "no capability declared = always visible" rule the generic
|
|
21
|
+
* content routes already use server-side (see the content-model skill):
|
|
22
|
+
* when `type.capabilities.read` is unset, the entry is always visible;
|
|
23
|
+
* when it is set, `can(...)` decides.
|
|
24
|
+
*
|
|
25
|
+
* `entry.enabled` is read and enforced uniformly across all three sources
|
|
26
|
+
* here. Source 1 already filters on it a second time, inside
|
|
27
|
+
* `createOwnedRegistry` itself (see
|
|
28
|
+
* `app/composables/internal/registry-factory.ts`) — applying the same
|
|
29
|
+
* filter again here is harmless for that source, and is what makes sources
|
|
30
|
+
* 2 and 3 (plain objects that never pass through that registry) respect
|
|
31
|
+
* `enabled` too, instead of only source 1.
|
|
14
32
|
*/
|
|
15
33
|
export function usePlutoAdminNav() {
|
|
16
34
|
const registry = usePlutoRegistry()
|
|
17
35
|
const legacy = useSidebarAdminActions()
|
|
36
|
+
const { items: contentTypes } = usePlutoContentTypes()
|
|
37
|
+
const { can } = usePlutoPermissions()
|
|
18
38
|
|
|
19
39
|
const items = computed<PlutoNavItem[]>(() => {
|
|
20
40
|
const registered = registry.nav.list.value
|
|
21
41
|
const seen = new Set(registered.map((item) => item.label))
|
|
22
42
|
|
|
23
|
-
const bridged = legacy.actions.value
|
|
43
|
+
const bridged: PlutoNavItem[] = legacy.actions.value
|
|
24
44
|
.filter((action) => !seen.has(String(action.label)))
|
|
25
45
|
.map((action, index) => ({
|
|
26
46
|
id: `legacy:sidebar-${index}`,
|
|
@@ -35,9 +55,29 @@ export function usePlutoAdminNav() {
|
|
|
35
55
|
})),
|
|
36
56
|
}))
|
|
37
57
|
|
|
38
|
-
|
|
39
|
-
(
|
|
40
|
-
|
|
58
|
+
const contentNav: PlutoNavItem[] = contentTypes.value
|
|
59
|
+
.filter((entry) => entry.type.autoRoutes !== false)
|
|
60
|
+
.map((entry) => {
|
|
61
|
+
const type = entry.type
|
|
62
|
+
const basePath = type.basePath ?? `/admin/content/${type.name}`
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
id: `content:${type.name}`,
|
|
66
|
+
order: type.navOrder ?? 100,
|
|
67
|
+
label: type.labelPlural,
|
|
68
|
+
icon: type.icon,
|
|
69
|
+
to: basePath,
|
|
70
|
+
enabled: () => !type.capabilities?.read || can(type.capabilities.read),
|
|
71
|
+
children: [
|
|
72
|
+
{ label: `All ${type.labelPlural}`, to: basePath },
|
|
73
|
+
{ label: `Create new ${type.label}`, to: `${basePath}/new` },
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return [...registered, ...bridged, ...contentNav]
|
|
79
|
+
.filter((item) => item.enabled?.() ?? true)
|
|
80
|
+
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
41
81
|
})
|
|
42
82
|
|
|
43
83
|
return { items }
|
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Metadata for admin pages, registered on top of routes Nuxt already
|
|
3
|
-
* creates from files
|
|
4
|
-
*
|
|
3
|
+
* creates from files, plus two synthesized entries per auto-routed content
|
|
4
|
+
* type (`type.autoRoutes !== false`): a list page and a "new" page — the
|
|
5
|
+
* page-metadata equivalent of the nav entry `usePlutoAdminNav` synthesizes
|
|
6
|
+
* for the same content type. No layer registers page metadata today, so
|
|
7
|
+
* the registry bucket itself has no legacy bridge.
|
|
5
8
|
*/
|
|
6
9
|
export function usePlutoAdminPages() {
|
|
7
10
|
const registry = usePlutoRegistry()
|
|
8
11
|
const route = useRoute()
|
|
12
|
+
const { items: contentTypes } = usePlutoContentTypes()
|
|
13
|
+
|
|
14
|
+
const items = computed<PlutoAdminPage[]>(() => {
|
|
15
|
+
const contentPages: PlutoAdminPage[] = contentTypes.value
|
|
16
|
+
.filter((entry) => entry.type.autoRoutes !== false)
|
|
17
|
+
.flatMap((entry) => {
|
|
18
|
+
const type = entry.type
|
|
19
|
+
const basePath = type.basePath ?? `/admin/content/${type.name}`
|
|
20
|
+
const navId = `content:${type.name}`
|
|
21
|
+
|
|
22
|
+
return [
|
|
23
|
+
{ id: `${navId}:list`, path: basePath, title: type.labelPlural },
|
|
24
|
+
{
|
|
25
|
+
id: `${navId}:new`,
|
|
26
|
+
path: `${basePath}/new`,
|
|
27
|
+
title: `Create new ${type.label}`,
|
|
28
|
+
parent: navId,
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return [...registry.pages.list.value, ...contentPages]
|
|
34
|
+
})
|
|
9
35
|
|
|
10
|
-
const items = computed(() => registry.pages.list.value)
|
|
11
36
|
const current = computed(() => items.value.find((page) => page.path === route.path))
|
|
12
37
|
|
|
13
38
|
return { items, current }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads (or creates) one item of one content type, for the generic form page
|
|
3
|
+
* (`PlutoContentForm`).
|
|
4
|
+
*
|
|
5
|
+
* This is one half of the split that replaces the old `usePost`/`useProduct`
|
|
6
|
+
* pattern (see `usePlutoContentList` for the other half). That old pattern
|
|
7
|
+
* fetched a list AND a single item from one composable every time, and threw
|
|
8
|
+
* away whichever one the caller did not need. `usePlutoContentItem` only
|
|
9
|
+
* ever fetches a single item, and only when `id` is defined — a "new entry"
|
|
10
|
+
* form never fires a GET at all.
|
|
11
|
+
*
|
|
12
|
+
* It is also not a "fake thenable": it returns plain refs and explicit
|
|
13
|
+
* methods, so a caller `await`s `save()`/`remove()`/`refresh()` directly,
|
|
14
|
+
* instead of `await`ing the composable's own return value through a
|
|
15
|
+
* hand-rolled `.then()`.
|
|
16
|
+
*
|
|
17
|
+
* `id` is read once, with `toValue`, at composable-creation time. It is not
|
|
18
|
+
* watched for later changes — each of the three generated pages
|
|
19
|
+
* (`app/pages/admin/content/[type]/{index,new,[id]}.vue`) mounts fresh on
|
|
20
|
+
* every route change, so there is no case in this wave where `id` changes
|
|
21
|
+
* under a still-mounted `PlutoContentForm`.
|
|
22
|
+
*/
|
|
23
|
+
export function usePlutoContentItem(
|
|
24
|
+
typeName: string,
|
|
25
|
+
id: MaybeRefOrGetter<string | number | undefined>
|
|
26
|
+
) {
|
|
27
|
+
const resolvedId = toValue(id)
|
|
28
|
+
const isNew = resolvedId === undefined
|
|
29
|
+
|
|
30
|
+
const item = ref<Record<string, unknown>>({})
|
|
31
|
+
const pending = ref(false)
|
|
32
|
+
const error = ref<unknown>(null)
|
|
33
|
+
|
|
34
|
+
async function refresh(): Promise<void> {
|
|
35
|
+
// A new, unsaved entry has nothing to fetch — `item` stays the empty
|
|
36
|
+
// object it started as.
|
|
37
|
+
if (isNew) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pending.value = true
|
|
42
|
+
error.value = null
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
item.value = await $fetch<PlutoContentItem>(`/api/_pluto/content/${typeName}/${resolvedId}`)
|
|
46
|
+
} catch (caught) {
|
|
47
|
+
error.value = caught
|
|
48
|
+
throw caught
|
|
49
|
+
} finally {
|
|
50
|
+
pending.value = false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fire the initial fetch right away, same reasoning as
|
|
55
|
+
// usePlutoContentList: /admin/** is client-only, so there is no SSR
|
|
56
|
+
// payload concern. The failure path already lands in `error` for a
|
|
57
|
+
// reactive consumer to read; swallow the rejection here so it never
|
|
58
|
+
// surfaces as an unhandled rejection. A caller invoking `refresh()`
|
|
59
|
+
// again later still gets a real rejection to `await`.
|
|
60
|
+
refresh().catch(() => {})
|
|
61
|
+
|
|
62
|
+
async function save(values: Record<string, unknown>): Promise<PlutoContentItem> {
|
|
63
|
+
error.value = null
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const saved = isNew
|
|
67
|
+
? await $fetch<PlutoContentItem>(`/api/_pluto/content/${typeName}`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
body: values,
|
|
70
|
+
})
|
|
71
|
+
: await $fetch<PlutoContentItem>(`/api/_pluto/content/${typeName}/${resolvedId}`, {
|
|
72
|
+
method: 'PATCH',
|
|
73
|
+
body: values,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
item.value = saved
|
|
77
|
+
return saved
|
|
78
|
+
} catch (caught) {
|
|
79
|
+
error.value = caught
|
|
80
|
+
throw caught
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function remove(): Promise<void> {
|
|
85
|
+
if (isNew) {
|
|
86
|
+
throw new Error('Cannot remove an entry that has not been saved yet.')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await $fetch(`/api/_pluto/content/${typeName}/${resolvedId}`, { method: 'DELETE' })
|
|
91
|
+
} catch (caught) {
|
|
92
|
+
error.value = caught
|
|
93
|
+
throw caught
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { item, pending, error, refresh, save, remove }
|
|
98
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches one content type's list of items, for the generic list page
|
|
3
|
+
* (`PlutoContentList`).
|
|
4
|
+
*
|
|
5
|
+
* This is one half of the split that replaces the old `usePost`/`useProduct`
|
|
6
|
+
* pattern (see `usePlutoContentItem` for the other half). That old pattern
|
|
7
|
+
* fetched a list AND a single item from one composable every time, and threw
|
|
8
|
+
* away whichever one the caller did not need — a list page paid for an item
|
|
9
|
+
* fetch it never used, and an edit page paid for a list fetch it never used.
|
|
10
|
+
* `usePlutoContentList` only ever fetches a list, because a list page only
|
|
11
|
+
* ever needs a list.
|
|
12
|
+
*
|
|
13
|
+
* It is also not a "fake thenable": it returns plain refs and explicit
|
|
14
|
+
* methods, so a caller `await`s `refresh()` or `remove()` directly, instead
|
|
15
|
+
* of `await`ing the composable's own return value through a hand-rolled
|
|
16
|
+
* `.then()`.
|
|
17
|
+
*/
|
|
18
|
+
export function usePlutoContentList(typeName: string) {
|
|
19
|
+
const items = ref<PlutoContentItem[]>([])
|
|
20
|
+
const total = ref<number | undefined>(undefined)
|
|
21
|
+
const pending = ref(false)
|
|
22
|
+
const error = ref<unknown>(null)
|
|
23
|
+
|
|
24
|
+
async function refresh(): Promise<void> {
|
|
25
|
+
pending.value = true
|
|
26
|
+
error.value = null
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = await $fetch<PlutoContentListResult>(`/api/_pluto/content/${typeName}`)
|
|
30
|
+
items.value = result.data
|
|
31
|
+
total.value = result.total
|
|
32
|
+
} catch (caught) {
|
|
33
|
+
error.value = caught
|
|
34
|
+
throw caught
|
|
35
|
+
} finally {
|
|
36
|
+
pending.value = false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Fire the initial fetch right away. /admin/** is client-only
|
|
41
|
+
// (ssr: false in nuxt.config.ts), so there is no SSR payload concern —
|
|
42
|
+
// this only ever runs in the browser. The failure path already lands in
|
|
43
|
+
// `error` for a reactive consumer to read; swallow the rejection here so
|
|
44
|
+
// it never surfaces as an unhandled rejection. A caller invoking
|
|
45
|
+
// `refresh()` again later still gets a real rejection to `await`.
|
|
46
|
+
refresh().catch(() => {})
|
|
47
|
+
|
|
48
|
+
async function remove(id: string | number): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
await $fetch(`/api/_pluto/content/${typeName}/${id}`, { method: 'DELETE' })
|
|
51
|
+
} catch (caught) {
|
|
52
|
+
error.value = caught
|
|
53
|
+
throw caught
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await refresh()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { items, total, pending, error, refresh, remove }
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const route = useRoute()
|
|
3
|
+
const typeName = route.params.type as string
|
|
4
|
+
|
|
5
|
+
const { byName } = usePlutoContentTypes()
|
|
6
|
+
const type = byName(typeName)
|
|
7
|
+
|
|
8
|
+
if (!type) {
|
|
9
|
+
throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
useHead({
|
|
13
|
+
title: `Edit ${type.label}`,
|
|
14
|
+
})
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<PlutoContentForm :id="route.params.id as string" :type="type.name" />
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const route = useRoute()
|
|
3
|
+
const typeName = route.params.type as string
|
|
4
|
+
|
|
5
|
+
const { byName } = usePlutoContentTypes()
|
|
6
|
+
const type = byName(typeName)
|
|
7
|
+
|
|
8
|
+
if (!type) {
|
|
9
|
+
throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
useHead({
|
|
13
|
+
title: type.labelPlural,
|
|
14
|
+
})
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<PlutoContentList :type="type.name" />
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const route = useRoute()
|
|
3
|
+
const typeName = route.params.type as string
|
|
4
|
+
|
|
5
|
+
const { byName } = usePlutoContentTypes()
|
|
6
|
+
const type = byName(typeName)
|
|
7
|
+
|
|
8
|
+
if (!type) {
|
|
9
|
+
throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
useHead({
|
|
13
|
+
title: `New ${type.label}`,
|
|
14
|
+
})
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<PlutoContentForm :type="type.name" />
|
|
19
|
+
</template>
|
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import PlutoFieldBoolean from '../components/Pluto/Content/fields/PlutoFieldBoolean.vue'
|
|
2
|
+
import PlutoFieldDate from '../components/Pluto/Content/fields/PlutoFieldDate.vue'
|
|
3
|
+
import PlutoFieldNumber from '../components/Pluto/Content/fields/PlutoFieldNumber.vue'
|
|
4
|
+
import PlutoFieldReference from '../components/Pluto/Content/fields/PlutoFieldReference.vue'
|
|
5
|
+
import PlutoFieldSelect from '../components/Pluto/Content/fields/PlutoFieldSelect.vue'
|
|
6
|
+
import PlutoFieldSlug from '../components/Pluto/Content/fields/PlutoFieldSlug.vue'
|
|
7
|
+
import PlutoFieldText from '../components/Pluto/Content/fields/PlutoFieldText.vue'
|
|
8
|
+
|
|
9
|
+
// Registers core's own Home and Settings anchors, plus the eight built-in
|
|
10
|
+
// content field widgets. `order: 0` and `order: 1000` are reserved for the
|
|
11
|
+
// Home/Settings nav entries, so every other layer's nav item sorts between
|
|
12
|
+
// them by default (default order is 100).
|
|
13
|
+
//
|
|
14
|
+
// Everything core itself contributes to the registry lives in this one
|
|
15
|
+
// `definePlutoExtension` call — `definePlutoExtension` replaces a layer's
|
|
16
|
+
// whole contribution on every call, not just the buckets that call passes,
|
|
17
|
+
// so a second `id: 'pluto'` call elsewhere would silently wipe this one out.
|
|
18
|
+
//
|
|
19
|
+
// `richtext` and `media` have no core widget here on purpose — a layer
|
|
20
|
+
// registers those (blog registers `richtext`, storage registers `media`),
|
|
21
|
+
// because core has no real editor or file picker to offer for either. See
|
|
22
|
+
// the content-model skill.
|
|
4
23
|
export default defineNuxtPlugin(() => {
|
|
5
24
|
definePlutoExtension({
|
|
6
25
|
id: 'pluto',
|
|
@@ -12,5 +31,15 @@ export default defineNuxtPlugin(() => {
|
|
|
12
31
|
{ id: 'home', path: '/admin/home', title: 'Dashboard', icon: 'lucide:house' },
|
|
13
32
|
{ id: 'settings', path: '/admin/settings', title: 'Settings', icon: 'lucide:settings' },
|
|
14
33
|
],
|
|
34
|
+
contentFieldWidgets: [
|
|
35
|
+
{ id: 'text', fieldType: 'text', component: PlutoFieldText },
|
|
36
|
+
{ id: 'textarea', fieldType: 'textarea', component: PlutoFieldText },
|
|
37
|
+
{ id: 'slug', fieldType: 'slug', component: PlutoFieldSlug },
|
|
38
|
+
{ id: 'number', fieldType: 'number', component: PlutoFieldNumber },
|
|
39
|
+
{ id: 'boolean', fieldType: 'boolean', component: PlutoFieldBoolean },
|
|
40
|
+
{ id: 'date', fieldType: 'date', component: PlutoFieldDate },
|
|
41
|
+
{ id: 'select', fieldType: 'select', component: PlutoFieldSelect },
|
|
42
|
+
{ id: 'reference', fieldType: 'reference', component: PlutoFieldReference },
|
|
43
|
+
],
|
|
15
44
|
})
|
|
16
45
|
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/pluto",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "The layerable CMS",
|
|
6
6
|
"author": "Victor Ribeiro",
|
|
7
7
|
"license": "MIT",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@iconify-json/lucide": "^1.2.116",
|
|
46
46
|
"@iconify-json/simple-icons": "^1.2.89",
|
|
47
|
+
"@internationalized/date": "^3.12.2",
|
|
47
48
|
"@nuxt/eslint": "^1.16.0",
|
|
48
49
|
"@nuxt/ui": "^4.9.0",
|
|
49
50
|
"@plutocms/utils": "^0.2.1",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deletes one item of one content type. Generic, core-internal route — see
|
|
3
|
+
* the content-model skill for the `/_pluto/` prefix and the
|
|
4
|
+
* delete-capability enforcement this route follows.
|
|
5
|
+
*/
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const typeName = getRouterParam(event, 'type')
|
|
8
|
+
const type = requireContentType(typeName ?? '')
|
|
9
|
+
|
|
10
|
+
const id = getRouterParam(event, 'id') ?? ''
|
|
11
|
+
|
|
12
|
+
await deleteContentItem({ event, type }, id)
|
|
13
|
+
|
|
14
|
+
return { success: true }
|
|
15
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets one item of one content type, by id or slug. Generic, core-internal
|
|
3
|
+
* route — see the content-model skill for the `/_pluto/` prefix and the
|
|
4
|
+
* capability rule this route follows (no `read` capability check, ever).
|
|
5
|
+
*/
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const typeName = getRouterParam(event, 'type')
|
|
8
|
+
const type = requireContentType(typeName ?? '')
|
|
9
|
+
|
|
10
|
+
const idOrSlug = getRouterParam(event, 'id') ?? ''
|
|
11
|
+
|
|
12
|
+
return getContentItem({ event, type }, idOrSlug)
|
|
13
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Updates one item of one content type. Generic, core-internal route — see
|
|
3
|
+
* the content-model skill for the `/_pluto/` prefix, partial-payload
|
|
4
|
+
* validation, and the write-capability enforcement this route follows.
|
|
5
|
+
*/
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const typeName = getRouterParam(event, 'type')
|
|
8
|
+
const type = requireContentType(typeName ?? '')
|
|
9
|
+
|
|
10
|
+
const id = getRouterParam(event, 'id') ?? ''
|
|
11
|
+
const body = await readBody(event)
|
|
12
|
+
|
|
13
|
+
return updateContentItem({ event, type }, id, body)
|
|
14
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lists items of one content type. Generic, core-internal route — see the
|
|
3
|
+
* content-model skill for the `/_pluto/` prefix and the capability rule
|
|
4
|
+
* this route follows (no `read` capability check, ever).
|
|
5
|
+
*/
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const typeName = getRouterParam(event, 'type')
|
|
8
|
+
const type = requireContentType(typeName ?? '')
|
|
9
|
+
|
|
10
|
+
const query = getQuery(event)
|
|
11
|
+
const limit = query.limit === undefined ? undefined : Number.parseInt(query.limit as string, 10)
|
|
12
|
+
const offset = query.offset === undefined ? undefined : Number.parseInt(query.offset as string, 10)
|
|
13
|
+
const search = query.search === undefined ? undefined : String(query.search)
|
|
14
|
+
|
|
15
|
+
return listContentItems({ event, type }, { limit, offset, search })
|
|
16
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates one item of one content type. Generic, core-internal route — see
|
|
3
|
+
* the content-model skill for the `/_pluto/` prefix, payload validation,
|
|
4
|
+
* and the write-capability enforcement this route follows.
|
|
5
|
+
*/
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const typeName = getRouterParam(event, 'type')
|
|
8
|
+
const type = requireContentType(typeName ?? '')
|
|
9
|
+
|
|
10
|
+
const body = await readBody(event)
|
|
11
|
+
|
|
12
|
+
return createContentItem({ event, type }, body)
|
|
13
|
+
})
|