@xleddyl/nuxt-cms 0.2.2 → 0.2.3
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/README.md +3 -1
- package/dist/module.d.mts +4 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +319 -32
- package/dist/runtime/app/blocks/CmsBlocks.d.vue.ts +45 -0
- package/dist/runtime/app/blocks/CmsBlocks.vue +36 -0
- package/dist/runtime/app/blocks/CmsBlocks.vue.d.ts +45 -0
- package/dist/runtime/app/blocks/resolve.d.ts +21 -0
- package/dist/runtime/app/blocks/resolve.js +37 -0
- package/dist/runtime/app/components/cms/BlockOutline.d.vue.ts +17 -0
- package/dist/runtime/app/components/cms/BlockOutline.vue +99 -0
- package/dist/runtime/app/components/cms/BlockOutline.vue.d.ts +17 -0
- package/dist/runtime/app/components/cms/BlockPalette.d.vue.ts +12 -0
- package/dist/runtime/app/components/cms/BlockPalette.vue +29 -0
- package/dist/runtime/app/components/cms/BlockPalette.vue.d.ts +12 -0
- package/dist/runtime/app/components/cms/BlocksField.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/BlocksField.vue +83 -3
- package/dist/runtime/app/components/cms/BlocksField.vue.d.ts +2 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +9 -4
- package/dist/runtime/app/components/cms/FieldInput.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/FieldInput.vue +18 -1
- package/dist/runtime/app/components/cms/FieldInput.vue.d.ts +2 -0
- package/dist/runtime/app/components/cms/SettingsEntry.vue +3 -2
- package/dist/runtime/app/components/cms/SettingsModal.vue +2 -1
- package/dist/runtime/app/components/cms/StatusBadge.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/StatusBadge.vue +9 -3
- package/dist/runtime/app/components/cms/StatusBadge.vue.d.ts +2 -0
- package/dist/runtime/app/components/cms/VisualEditor.d.vue.ts +37 -0
- package/dist/runtime/app/components/cms/VisualEditor.vue +533 -0
- package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +37 -0
- package/dist/runtime/app/composables/cms-entry-disabled.d.ts +7 -1
- package/dist/runtime/app/composables/cms-entry-disabled.js +15 -0
- package/dist/runtime/app/composables/cms-entry.d.ts +7 -1
- package/dist/runtime/app/composables/cms-entry.js +51 -1
- package/dist/runtime/app/composables/cms-runtime.d.ts +1 -0
- package/dist/runtime/app/composables/cms-settings.js +8 -2
- package/dist/runtime/app/layouts/cms-admin.vue +5 -0
- package/dist/runtime/app/layouts/cms-editor.d.vue.ts +13 -0
- package/dist/runtime/app/layouts/cms-editor.vue +14 -0
- package/dist/runtime/app/layouts/cms-editor.vue.d.ts +13 -0
- package/dist/runtime/app/pages/admin-collection.vue +33 -4
- package/dist/runtime/app/pages/admin-content.d.vue.ts +3 -0
- package/dist/runtime/app/pages/admin-content.vue +284 -0
- package/dist/runtime/app/pages/admin-content.vue.d.ts +3 -0
- package/dist/runtime/app/pages/admin-preview.d.vue.ts +3 -0
- package/dist/runtime/app/pages/admin-preview.vue +132 -0
- package/dist/runtime/app/pages/admin-preview.vue.d.ts +3 -0
- package/dist/runtime/app/preview/PreviewOverlay.d.vue.ts +15 -0
- package/dist/runtime/app/preview/PreviewOverlay.vue +362 -0
- package/dist/runtime/app/preview/PreviewOverlay.vue.d.ts +15 -0
- package/dist/runtime/app/utils/ui.js +1 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/collection.get.js +3 -2
- package/dist/runtime/server/api/collection.post.js +4 -1
- package/dist/runtime/server/api/item.delete.js +2 -1
- package/dist/runtime/server/api/item.put.js +2 -1
- package/dist/runtime/server/utils/graphql.js +23 -8
- package/dist/runtime/server/utils/registry.d.ts +1 -0
- package/dist/runtime/server/utils/registry.js +6 -0
- package/dist/runtime/server/utils/visibility.d.ts +4 -0
- package/dist/runtime/server/utils/visibility.js +12 -0
- package/dist/runtime/shared/graphql-sdl.js +4 -1
- package/dist/runtime/shared/index.d.ts +59 -5
- package/dist/runtime/shared/index.js +133 -0
- package/dist/runtime/shared/preview.d.ts +85 -0
- package/dist/runtime/shared/preview.js +219 -0
- package/dist/runtime/shared/validation.d.ts +2 -0
- package/dist/runtime/shared/validation.js +15 -2
- package/package.json +1 -1
|
@@ -14,6 +14,21 @@ export function useCmsCollection(name, options = {}) {
|
|
|
14
14
|
async () => fallback ? fallback() : []
|
|
15
15
|
);
|
|
16
16
|
}
|
|
17
|
+
export function useCmsContents(name, options = {}) {
|
|
18
|
+
const { locale, filters, sort, limit, offset, key, default: fallback } = options;
|
|
19
|
+
const variables = { locale, filters, sort, limit, offset };
|
|
20
|
+
return useAsyncData(
|
|
21
|
+
key ?? `cms-contents:${String(name)}:${JSON.stringify(variables)}`,
|
|
22
|
+
async () => fallback ? fallback() : []
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
export function useCmsContent(name, slug, options = {}) {
|
|
26
|
+
const { locale, key, default: fallback } = options;
|
|
27
|
+
return useAsyncData(
|
|
28
|
+
key ?? `cms-content:${String(name)}:${slug}:${locale ?? ""}`,
|
|
29
|
+
async () => fallback ? fallback() : null
|
|
30
|
+
);
|
|
31
|
+
}
|
|
17
32
|
export function useCmsPage(path, options = {}) {
|
|
18
33
|
const { locale, key, default: fallback } = options;
|
|
19
34
|
return useAsyncData(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AsyncData } from 'nuxt/app';
|
|
2
|
-
import type { CmsCollectionName, CmsCollectionTypes, CmsPagePath, CmsPageTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
|
|
2
|
+
import type { CmsCollectionName, CmsCollectionTypes, CmsContentName, CmsContentTypes, CmsPagePath, CmsPageTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
|
|
3
3
|
import type { CmsAsyncDataOptions } from './cms-query.js';
|
|
4
4
|
export interface CmsSortInput<Entry> {
|
|
5
5
|
field: Extract<keyof Entry, string>;
|
|
@@ -18,6 +18,12 @@ export interface CmsCollectionOptions<ResT, DefaultT, Entry> extends CmsAsyncDat
|
|
|
18
18
|
limit?: number;
|
|
19
19
|
offset?: number;
|
|
20
20
|
}
|
|
21
|
+
export interface CmsContentOptions<ResT, DefaultT> extends CmsAsyncDataOptions<ResT, DefaultT> {
|
|
22
|
+
locale?: string;
|
|
23
|
+
}
|
|
24
|
+
export type CmsContentsOptions<ResT, DefaultT, Entry> = CmsCollectionOptions<ResT, DefaultT, Entry>;
|
|
21
25
|
export declare function useCmsSingle<K extends CmsSingleName, DefaultT = null>(name: K, options?: CmsSingleOptions<CmsSingleTypes[K] | null, DefaultT>): AsyncData<CmsSingleTypes[K] | DefaultT | null, Error | undefined>;
|
|
22
26
|
export declare function useCmsCollection<K extends CmsCollectionName, DefaultT = CmsCollectionTypes[K][]>(name: K, options?: CmsCollectionOptions<CmsCollectionTypes[K][], DefaultT, CmsCollectionTypes[K]>): AsyncData<CmsCollectionTypes[K][] | DefaultT, Error | undefined>;
|
|
27
|
+
export declare function useCmsContents<K extends CmsContentName, DefaultT = CmsContentTypes[K][]>(name: K, options?: CmsContentsOptions<CmsContentTypes[K][], DefaultT, CmsContentTypes[K]>): AsyncData<CmsContentTypes[K][] | DefaultT, Error | undefined>;
|
|
28
|
+
export declare function useCmsContent<K extends CmsContentName, DefaultT = null>(name: K, slug: string, options?: CmsContentOptions<CmsContentTypes[K] | null, DefaultT>): AsyncData<CmsContentTypes[K] | DefaultT | null, Error | undefined>;
|
|
23
29
|
export declare function useCmsPage<P extends CmsPagePath, DefaultT = null>(path: P, options?: CmsPageOptions<CmsPageTypes[P] | null, DefaultT>): AsyncData<CmsPageTypes[P] | DefaultT | null, Error | undefined>;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
cmsCollectionQueries,
|
|
3
|
+
cmsContentItemQueries,
|
|
4
|
+
cmsContentQueries,
|
|
5
|
+
cmsPageQueries,
|
|
6
|
+
cmsSingleQueries
|
|
7
|
+
} from "#cms-queries";
|
|
2
8
|
import { useAsyncData } from "#imports";
|
|
3
9
|
import { $cmsQuery } from "./cms-query.js";
|
|
4
10
|
function unknownEntry(name) {
|
|
@@ -48,6 +54,50 @@ export function useCmsCollection(name, options = {}) {
|
|
|
48
54
|
}
|
|
49
55
|
);
|
|
50
56
|
}
|
|
57
|
+
export function useCmsContents(name, options = {}) {
|
|
58
|
+
const {
|
|
59
|
+
locale,
|
|
60
|
+
filters,
|
|
61
|
+
sort,
|
|
62
|
+
limit,
|
|
63
|
+
offset,
|
|
64
|
+
key,
|
|
65
|
+
default: fallback,
|
|
66
|
+
...asyncDataOptions
|
|
67
|
+
} = options;
|
|
68
|
+
const entryName = String(name);
|
|
69
|
+
const query = cmsContentQueries[entryName];
|
|
70
|
+
const variables = { locale, filters, sort, limit, offset };
|
|
71
|
+
return useAsyncData(
|
|
72
|
+
key ?? `cms-contents:${entryName}:${JSON.stringify(variables)}`,
|
|
73
|
+
async () => {
|
|
74
|
+
if (!query) unknownEntry(entryName);
|
|
75
|
+
const result = await $cmsQuery(query, variables);
|
|
76
|
+
return result?.[entryName] ?? [];
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
default: fallback ?? (() => []),
|
|
80
|
+
...asyncDataOptions
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
export function useCmsContent(name, slug, options = {}) {
|
|
85
|
+
const { locale, key, default: fallback, ...asyncDataOptions } = options;
|
|
86
|
+
const entryName = String(name);
|
|
87
|
+
const query = cmsContentItemQueries[entryName];
|
|
88
|
+
return useAsyncData(
|
|
89
|
+
key ?? `cms-content:${entryName}:${slug}:${locale ?? ""}`,
|
|
90
|
+
async () => {
|
|
91
|
+
if (!query) unknownEntry(entryName);
|
|
92
|
+
const result = await $cmsQuery(query, { slug, locale });
|
|
93
|
+
return result?.item ?? null;
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
default: fallback ?? (() => null),
|
|
97
|
+
...asyncDataOptions
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
}
|
|
51
101
|
export function useCmsPage(path, options = {}) {
|
|
52
102
|
const { locale, key, default: fallback, ...asyncDataOptions } = options;
|
|
53
103
|
const pagePath = String(path);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isCollectionKind,
|
|
3
|
+
layoutFromConfig,
|
|
4
|
+
pageAllFields,
|
|
5
|
+
resolveFormLayout,
|
|
6
|
+
resolveListColumns
|
|
7
|
+
} from "#nuxt-cms";
|
|
2
8
|
import { useFetch, useState } from "#imports";
|
|
3
9
|
import cmsConfig from "#cms-config";
|
|
4
10
|
import { cmsApi } from "../utils/api.js";
|
|
@@ -45,7 +51,7 @@ export function entryFormLayout(name, settings, fields = entryFields(name)) {
|
|
|
45
51
|
}
|
|
46
52
|
export function listExtraColumns(name) {
|
|
47
53
|
const entry = entryOf(name);
|
|
48
|
-
return entry
|
|
54
|
+
return entry && isCollectionKind(entry) && entry.drafts ? ["status"] : [];
|
|
49
55
|
}
|
|
50
56
|
export function entryListColumns(name, settings) {
|
|
51
57
|
const entry = entryOf(name);
|
|
@@ -109,6 +109,11 @@ const groups = computed(
|
|
|
109
109
|
icon: "document-text",
|
|
110
110
|
links: links.filter((l) => l.kind === "single")
|
|
111
111
|
},
|
|
112
|
+
{
|
|
113
|
+
title: "Content",
|
|
114
|
+
icon: "newspaper",
|
|
115
|
+
links: links.filter((l) => l.kind === "content")
|
|
116
|
+
},
|
|
112
117
|
{
|
|
113
118
|
title: "Pages",
|
|
114
119
|
icon: "window",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
declare var __VLS_1: {};
|
|
2
|
+
type __VLS_Slots = {} & {
|
|
3
|
+
default?: (props: typeof __VLS_1) => any;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
6
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
7
|
+
declare const _default: typeof __VLS_export;
|
|
8
|
+
export default _default;
|
|
9
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
10
|
+
new (): {
|
|
11
|
+
$slots: S;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="cms-scope cms-canvas" :data-theme="theme">
|
|
3
|
+
<slot />
|
|
4
|
+
<CmsToaster />
|
|
5
|
+
<CmsConfirmModal />
|
|
6
|
+
</div>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<script setup>
|
|
10
|
+
import { useCmsTheme } from "../composables/cms-theme";
|
|
11
|
+
import { loadCmsSettings } from "../composables/cms-settings";
|
|
12
|
+
const theme = useCmsTheme();
|
|
13
|
+
await loadCmsSettings();
|
|
14
|
+
</script>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
declare var __VLS_1: {};
|
|
2
|
+
type __VLS_Slots = {} & {
|
|
3
|
+
default?: (props: typeof __VLS_1) => any;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
6
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
7
|
+
declare const _default: typeof __VLS_export;
|
|
8
|
+
export default _default;
|
|
9
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
10
|
+
new (): {
|
|
11
|
+
$slots: S;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="cms-page" :class="{ 'is-fill': config
|
|
2
|
+
<div class="cms-page" :class="{ 'is-fill': isCollectionKind(config) && !error }">
|
|
3
3
|
<CmsPageHeader
|
|
4
4
|
:title="config.label"
|
|
5
5
|
:icon="kindMeta.icon"
|
|
@@ -156,7 +156,11 @@
|
|
|
156
156
|
<CmsMediaThumb :value="mediaThumbValue(key, row.original[key])" />
|
|
157
157
|
</template>
|
|
158
158
|
<template v-if="drafts" #status-cell="{ row }">
|
|
159
|
-
<CmsStatusBadge
|
|
159
|
+
<CmsStatusBadge
|
|
160
|
+
:published="row.original.status === 'published'"
|
|
161
|
+
:scheduled="mounted && isContent && isScheduledContent(row.original)"
|
|
162
|
+
:at="formatDateTime(row.original.publishedAt)"
|
|
163
|
+
/>
|
|
160
164
|
</template>
|
|
161
165
|
</CmsTable>
|
|
162
166
|
|
|
@@ -182,6 +186,7 @@
|
|
|
182
186
|
<CmsPagination v-model:page="page" :total="total" :items-per-page="PAGE_SIZE" />
|
|
183
187
|
|
|
184
188
|
<CmsEntryDrawer
|
|
189
|
+
v-if="!isContent"
|
|
185
190
|
v-model:open="drawerOpen"
|
|
186
191
|
:collection="name"
|
|
187
192
|
:config="config"
|
|
@@ -195,6 +200,8 @@
|
|
|
195
200
|
|
|
196
201
|
<script setup>
|
|
197
202
|
import {
|
|
203
|
+
isCollectionKind,
|
|
204
|
+
isScheduledContent,
|
|
198
205
|
isTranslatableField,
|
|
199
206
|
isTranslatableMediaField,
|
|
200
207
|
pageParentPath,
|
|
@@ -204,6 +211,7 @@ import {
|
|
|
204
211
|
computed,
|
|
205
212
|
createError,
|
|
206
213
|
definePageMeta,
|
|
214
|
+
navigateTo,
|
|
207
215
|
onMounted,
|
|
208
216
|
ref,
|
|
209
217
|
useFetch,
|
|
@@ -234,12 +242,14 @@ if (!config) {
|
|
|
234
242
|
const KIND_META = {
|
|
235
243
|
collection: { icon: "square-3-stack-3d", description: "Collection of entries." },
|
|
236
244
|
single: { icon: "document-text", description: "Single entry, edited in place." },
|
|
237
|
-
page: { icon: "window", description: "Content for the routes of your app." }
|
|
245
|
+
page: { icon: "window", description: "Content for the routes of your app." },
|
|
246
|
+
content: { icon: "newspaper", description: "Items built from blocks, with scheduling." }
|
|
238
247
|
};
|
|
239
248
|
const kindMeta = { ...KIND_META[config.kind], icon: config.icon ?? KIND_META[config.kind].icon };
|
|
240
249
|
const fieldKeys = Object.keys(config.fields);
|
|
241
250
|
const mediaKeys = fieldKeys.filter((key) => config.fields[key].type === "media");
|
|
242
|
-
const
|
|
251
|
+
const isContent = config.kind === "content";
|
|
252
|
+
const drafts = isCollectionKind(config) && !!config.drafts;
|
|
243
253
|
const formKeys = drafts ? [...fieldKeys, "status"] : fieldKeys;
|
|
244
254
|
const endpoint = `/api/cms/admin/${name}`;
|
|
245
255
|
const PAGE_SIZE = 25;
|
|
@@ -357,6 +367,15 @@ function mediaThumbValue(key, value) {
|
|
|
357
367
|
const locale = contentI18n.defaultLocale;
|
|
358
368
|
return pickTranslatedMedia(value, locale, locale);
|
|
359
369
|
}
|
|
370
|
+
function formatDateTime(value) {
|
|
371
|
+
if (typeof value !== "string" || !value) return "";
|
|
372
|
+
const date = new Date(value);
|
|
373
|
+
if (Number.isNaN(date.getTime()) || !mounted.value) return value.slice(0, 16).replace("T", " ");
|
|
374
|
+
return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], {
|
|
375
|
+
hour: "2-digit",
|
|
376
|
+
minute: "2-digit"
|
|
377
|
+
})}`;
|
|
378
|
+
}
|
|
360
379
|
function relationLabel(field, key, id) {
|
|
361
380
|
const title = relations.value[key]?.[String(id)];
|
|
362
381
|
if (title == null || title === "") return `#${id}`;
|
|
@@ -378,6 +397,8 @@ function displayValue(field, key, value) {
|
|
|
378
397
|
return truncate(stripHtml(String(value)));
|
|
379
398
|
case "json":
|
|
380
399
|
return truncate(JSON.stringify(value));
|
|
400
|
+
case "datetime":
|
|
401
|
+
return formatDateTime(value);
|
|
381
402
|
case "blocks":
|
|
382
403
|
return `${value.length} \u25A4`;
|
|
383
404
|
case "relation": {
|
|
@@ -407,10 +428,18 @@ const columns = computed(
|
|
|
407
428
|
const drawerOpen = ref(false);
|
|
408
429
|
const drawerEntryId = ref(null);
|
|
409
430
|
function onSelect(_event, row) {
|
|
431
|
+
if (isContent) {
|
|
432
|
+
void navigateTo(`/cms/${name}/${row.original.id}`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
410
435
|
drawerEntryId.value = String(row.original.id);
|
|
411
436
|
drawerOpen.value = true;
|
|
412
437
|
}
|
|
413
438
|
function openCreate() {
|
|
439
|
+
if (isContent) {
|
|
440
|
+
void navigateTo(`/cms/${name}/new`);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
414
443
|
drawerEntryId.value = null;
|
|
415
444
|
drawerOpen.value = true;
|
|
416
445
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<CmsVisualEditor
|
|
3
|
+
ref="editor"
|
|
4
|
+
v-model:state="formState"
|
|
5
|
+
:entry-name="name"
|
|
6
|
+
:field-key="CONTENT_BODY_FIELD"
|
|
7
|
+
:fields="config.fields"
|
|
8
|
+
:title="headerTitle"
|
|
9
|
+
content
|
|
10
|
+
>
|
|
11
|
+
<template #start>
|
|
12
|
+
<CmsButton
|
|
13
|
+
icon="arrow-left"
|
|
14
|
+
size="sm"
|
|
15
|
+
variant="ghost"
|
|
16
|
+
color="neutral"
|
|
17
|
+
:aria-label="`Back to ${config.label}`"
|
|
18
|
+
:title="`Back to ${config.label}`"
|
|
19
|
+
@click="goBack"
|
|
20
|
+
/>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<template #status>
|
|
24
|
+
<CmsStatusBadge :published="published" :scheduled="scheduled" :at="scheduledAt" />
|
|
25
|
+
<span v-if="dirty" class="cms-editor-dirty">Unsaved changes</span>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<template #actions>
|
|
29
|
+
<CmsPopover v-model:open="scheduleOpen" class="cms-editor-schedule">
|
|
30
|
+
<CmsButton
|
|
31
|
+
icon="clock"
|
|
32
|
+
size="sm"
|
|
33
|
+
variant="ghost"
|
|
34
|
+
color="neutral"
|
|
35
|
+
:label="scheduled ? 'Reschedule' : 'Schedule'"
|
|
36
|
+
:disabled="saving"
|
|
37
|
+
aria-haspopup="dialog"
|
|
38
|
+
:aria-expanded="scheduleOpen"
|
|
39
|
+
/>
|
|
40
|
+
<template #content>
|
|
41
|
+
<div class="cms-editor-schedule-panel" role="dialog" aria-label="Schedule">
|
|
42
|
+
<CmsFormField
|
|
43
|
+
label="Publish at"
|
|
44
|
+
description="The item stays hidden from the site until this moment."
|
|
45
|
+
>
|
|
46
|
+
<CmsFieldInput v-model="scheduleDraft" :field="publishedAtField" />
|
|
47
|
+
</CmsFormField>
|
|
48
|
+
<div class="cms-actions is-end">
|
|
49
|
+
<CmsButton
|
|
50
|
+
label="Cancel"
|
|
51
|
+
size="sm"
|
|
52
|
+
variant="ghost"
|
|
53
|
+
color="neutral"
|
|
54
|
+
@click="scheduleOpen = false"
|
|
55
|
+
/>
|
|
56
|
+
<CmsButton
|
|
57
|
+
label="Schedule"
|
|
58
|
+
size="sm"
|
|
59
|
+
:disabled="!scheduleDraft"
|
|
60
|
+
:loading="saving"
|
|
61
|
+
@click="schedule"
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
</CmsPopover>
|
|
67
|
+
<CmsButton
|
|
68
|
+
label="Save"
|
|
69
|
+
size="sm"
|
|
70
|
+
variant="soft"
|
|
71
|
+
color="neutral"
|
|
72
|
+
:loading="saving"
|
|
73
|
+
@click="save()"
|
|
74
|
+
/>
|
|
75
|
+
<CmsButton
|
|
76
|
+
:label="published ? 'Unpublish' : 'Publish'"
|
|
77
|
+
size="sm"
|
|
78
|
+
:variant="published ? 'soft' : 'solid'"
|
|
79
|
+
:color="published ? 'neutral' : 'primary'"
|
|
80
|
+
:loading="saving"
|
|
81
|
+
@click="save(published ? 'draft' : 'published')"
|
|
82
|
+
/>
|
|
83
|
+
<CmsDropdownMenu
|
|
84
|
+
v-if="!isNew"
|
|
85
|
+
:items="[{ label: 'Delete', onSelect: remove }]"
|
|
86
|
+
:content="{ align: 'end' }"
|
|
87
|
+
>
|
|
88
|
+
<CmsButton
|
|
89
|
+
icon="ellipsis-horizontal"
|
|
90
|
+
size="sm"
|
|
91
|
+
variant="ghost"
|
|
92
|
+
color="neutral"
|
|
93
|
+
aria-label="More actions"
|
|
94
|
+
title="More actions"
|
|
95
|
+
/>
|
|
96
|
+
</CmsDropdownMenu>
|
|
97
|
+
</template>
|
|
98
|
+
</CmsVisualEditor>
|
|
99
|
+
</template>
|
|
100
|
+
|
|
101
|
+
<script setup>
|
|
102
|
+
import {
|
|
103
|
+
CONTENT_BODY_FIELD,
|
|
104
|
+
CONTENT_PUBLISHED_AT_FIELD,
|
|
105
|
+
CONTENT_TITLE_FIELD,
|
|
106
|
+
isScheduledContent
|
|
107
|
+
} from "#nuxt-cms";
|
|
108
|
+
import {
|
|
109
|
+
computed,
|
|
110
|
+
createError,
|
|
111
|
+
definePageMeta,
|
|
112
|
+
navigateTo,
|
|
113
|
+
onBeforeRouteLeave,
|
|
114
|
+
onBeforeUnmount,
|
|
115
|
+
onMounted,
|
|
116
|
+
ref,
|
|
117
|
+
useFetch,
|
|
118
|
+
useHead,
|
|
119
|
+
useRoute,
|
|
120
|
+
watch
|
|
121
|
+
} from "#imports";
|
|
122
|
+
import cmsConfig from "#cms-config";
|
|
123
|
+
import { useCmsConfirm } from "../composables/cms-confirm";
|
|
124
|
+
import { useCmsRuntime } from "../composables/cms-runtime";
|
|
125
|
+
import { useCmsToast } from "../composables/cms-toast";
|
|
126
|
+
import { cmsApi } from "../utils/api";
|
|
127
|
+
import { errorMessage } from "../utils/ui";
|
|
128
|
+
definePageMeta({
|
|
129
|
+
layout: "cms-editor",
|
|
130
|
+
middleware: "cms-auth",
|
|
131
|
+
key: (route2) => route2.fullPath
|
|
132
|
+
});
|
|
133
|
+
const route = useRoute();
|
|
134
|
+
const toast = useCmsToast();
|
|
135
|
+
const confirmAction = useCmsConfirm();
|
|
136
|
+
const { i18n } = useCmsRuntime();
|
|
137
|
+
const name = String(route.meta.cmsEntry ?? route.path.split("/")[2] ?? "");
|
|
138
|
+
const config = cmsConfig[name];
|
|
139
|
+
if (!config || config.kind !== "content") {
|
|
140
|
+
throw createError({ statusCode: 404, statusMessage: "Unknown content entry", fatal: true });
|
|
141
|
+
}
|
|
142
|
+
const id = route.params.id;
|
|
143
|
+
const isNew = id === void 0;
|
|
144
|
+
const endpoint = `/api/cms/admin/${name}`;
|
|
145
|
+
const formKeys = [...Object.keys(config.fields), "status"];
|
|
146
|
+
const editor = ref(null);
|
|
147
|
+
function pickFields(row) {
|
|
148
|
+
return Object.fromEntries(formKeys.map((key) => [key, row[key] ?? null]));
|
|
149
|
+
}
|
|
150
|
+
const formState = ref(
|
|
151
|
+
Object.fromEntries(formKeys.map((key) => [key, key === "status" ? "draft" : null]))
|
|
152
|
+
);
|
|
153
|
+
if (!isNew) {
|
|
154
|
+
const { data, error } = await useFetch(`${endpoint}/${id}`);
|
|
155
|
+
if (error.value || !data.value) {
|
|
156
|
+
const statusCode = error.value?.statusCode ?? 404;
|
|
157
|
+
throw createError({
|
|
158
|
+
statusCode,
|
|
159
|
+
statusMessage: statusCode === 404 ? "Entry not found" : "Failed to load entry",
|
|
160
|
+
fatal: true
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
formState.value = pickFields(data.value);
|
|
164
|
+
}
|
|
165
|
+
const snapshot = ref(JSON.stringify(formState.value));
|
|
166
|
+
const dirty = computed(() => JSON.stringify(formState.value) !== snapshot.value);
|
|
167
|
+
const titleText = computed(() => {
|
|
168
|
+
const value = formState.value[CONTENT_TITLE_FIELD];
|
|
169
|
+
if (typeof value === "string") return value;
|
|
170
|
+
if (value && typeof value === "object") {
|
|
171
|
+
const values = value;
|
|
172
|
+
return values[i18n.defaultLocale] || Object.values(values).find(Boolean) || "";
|
|
173
|
+
}
|
|
174
|
+
return "";
|
|
175
|
+
});
|
|
176
|
+
const headerTitle = computed(() => titleText.value || (isNew ? "New item" : "Untitled"));
|
|
177
|
+
useHead({ title: computed(() => `${headerTitle.value} \xB7 ${config.label}`) });
|
|
178
|
+
const published = computed(() => formState.value.status === "published");
|
|
179
|
+
const mounted = ref(false);
|
|
180
|
+
const scheduled = computed(() => mounted.value && isScheduledContent(formState.value));
|
|
181
|
+
const scheduledAt = computed(() => {
|
|
182
|
+
const value = formState.value[CONTENT_PUBLISHED_AT_FIELD];
|
|
183
|
+
if (!mounted.value || typeof value !== "string" || !value) return "";
|
|
184
|
+
return new Date(value).toLocaleString();
|
|
185
|
+
});
|
|
186
|
+
const publishedAtField = computed(() => ({
|
|
187
|
+
...config.fields[CONTENT_PUBLISHED_AT_FIELD],
|
|
188
|
+
required: true
|
|
189
|
+
}));
|
|
190
|
+
const saving = ref(false);
|
|
191
|
+
const scheduleOpen = ref(false);
|
|
192
|
+
const scheduleDraft = ref(null);
|
|
193
|
+
watch(scheduleOpen, (open) => {
|
|
194
|
+
if (!open) return;
|
|
195
|
+
const current = formState.value[CONTENT_PUBLISHED_AT_FIELD];
|
|
196
|
+
const future = typeof current === "string" && new Date(current).getTime() > Date.now();
|
|
197
|
+
scheduleDraft.value = future ? current : new Date(Math.ceil(Date.now() / 36e5) * 36e5 + 36e5).toISOString();
|
|
198
|
+
});
|
|
199
|
+
function onBeforeUnload(event) {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
}
|
|
202
|
+
watch(dirty, (value) => {
|
|
203
|
+
if (value) window.addEventListener("beforeunload", onBeforeUnload);
|
|
204
|
+
else window.removeEventListener("beforeunload", onBeforeUnload);
|
|
205
|
+
});
|
|
206
|
+
onMounted(() => {
|
|
207
|
+
mounted.value = true;
|
|
208
|
+
});
|
|
209
|
+
onBeforeUnmount(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|
210
|
+
onBeforeRouteLeave(async () => {
|
|
211
|
+
if (dirty.value && !await confirmAction("You have unsaved changes. Leave anyway?"))
|
|
212
|
+
return false;
|
|
213
|
+
});
|
|
214
|
+
function goBack() {
|
|
215
|
+
navigateTo(`/cms/${name}`);
|
|
216
|
+
}
|
|
217
|
+
const SUCCESS = {
|
|
218
|
+
save: "Saved",
|
|
219
|
+
published: "Published",
|
|
220
|
+
draft: "Moved to drafts",
|
|
221
|
+
scheduled: "Scheduled"
|
|
222
|
+
};
|
|
223
|
+
async function save(status, changes = {}, notice) {
|
|
224
|
+
const before = formState.value;
|
|
225
|
+
formState.value = { ...before, ...changes, ...status ? { status } : {} };
|
|
226
|
+
if (!editor.value?.validate()) {
|
|
227
|
+
formState.value = {
|
|
228
|
+
...formState.value,
|
|
229
|
+
status: before.status,
|
|
230
|
+
...pickRevert(before, changes)
|
|
231
|
+
};
|
|
232
|
+
toast.add({ title: "Check the highlighted fields", color: "error" });
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
saving.value = true;
|
|
236
|
+
try {
|
|
237
|
+
const row = await (isNew ? cmsApi(endpoint, { method: "POST", body: formState.value }) : cmsApi(`${endpoint}/${id}`, {
|
|
238
|
+
method: "PUT",
|
|
239
|
+
body: formState.value
|
|
240
|
+
}));
|
|
241
|
+
formState.value = pickFields(row);
|
|
242
|
+
snapshot.value = JSON.stringify(formState.value);
|
|
243
|
+
toast.add({ title: SUCCESS[notice ?? status ?? "save"] ?? "Saved", color: "success" });
|
|
244
|
+
if (isNew && row.id) await navigateTo(`/cms/${name}/${row.id}`, { replace: true });
|
|
245
|
+
return true;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
formState.value = {
|
|
248
|
+
...formState.value,
|
|
249
|
+
status: before.status,
|
|
250
|
+
...pickRevert(before, changes)
|
|
251
|
+
};
|
|
252
|
+
toast.add({ title: "Save failed", description: errorMessage(error), color: "error" });
|
|
253
|
+
return false;
|
|
254
|
+
} finally {
|
|
255
|
+
saving.value = false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function pickRevert(before, changes) {
|
|
259
|
+
return Object.fromEntries(Object.keys(changes).map((key) => [key, before[key] ?? null]));
|
|
260
|
+
}
|
|
261
|
+
async function schedule() {
|
|
262
|
+
if (!scheduleDraft.value) return;
|
|
263
|
+
const saved = await save(
|
|
264
|
+
"published",
|
|
265
|
+
{ [CONTENT_PUBLISHED_AT_FIELD]: scheduleDraft.value },
|
|
266
|
+
new Date(scheduleDraft.value).getTime() > Date.now() ? "scheduled" : "published"
|
|
267
|
+
);
|
|
268
|
+
if (saved) scheduleOpen.value = false;
|
|
269
|
+
}
|
|
270
|
+
async function remove() {
|
|
271
|
+
if (!await confirmAction("Delete this item?")) return;
|
|
272
|
+
saving.value = true;
|
|
273
|
+
try {
|
|
274
|
+
await cmsApi(`${endpoint}/${id}`, { method: "DELETE" });
|
|
275
|
+
snapshot.value = JSON.stringify(formState.value);
|
|
276
|
+
toast.add({ title: "Deleted", color: "success" });
|
|
277
|
+
goBack();
|
|
278
|
+
} catch (error) {
|
|
279
|
+
toast.add({ title: "Delete failed", description: errorMessage(error), color: "error" });
|
|
280
|
+
} finally {
|
|
281
|
+
saving.value = false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
</script>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|