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