@btst/plugin-cms 1.0.0-beta.0 → 1.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/api/getters.cjs +37 -25
  2. package/dist/api/getters.mjs +37 -25
  3. package/dist/api/index.cjs +1 -0
  4. package/dist/api/index.d.cts +17 -8
  5. package/dist/api/index.d.mts +17 -8
  6. package/dist/api/index.d.ts +17 -8
  7. package/dist/api/index.mjs +1 -1
  8. package/dist/api/mutations.cjs +46 -19
  9. package/dist/api/mutations.mjs +46 -20
  10. package/dist/api/operations.cjs +586 -177
  11. package/dist/api/operations.mjs +585 -178
  12. package/dist/api/plugin.cjs +56 -7
  13. package/dist/api/plugin.mjs +57 -8
  14. package/dist/api/publication.cjs +8 -0
  15. package/dist/api/publication.mjs +6 -0
  16. package/dist/client/components/forms/content-form.cjs +107 -1
  17. package/dist/client/components/forms/content-form.mjs +108 -2
  18. package/dist/client/components/pages/content-editor-page.internal.cjs +23 -5
  19. package/dist/client/components/pages/content-editor-page.internal.mjs +23 -5
  20. package/dist/client/components/pages/content-list-page.internal.cjs +3 -0
  21. package/dist/client/components/pages/content-list-page.internal.mjs +3 -0
  22. package/dist/client/components/pages/dashboard-page.internal.cjs +10 -30
  23. package/dist/client/components/pages/dashboard-page.internal.mjs +10 -30
  24. package/dist/client/components/revision-history.cjs +161 -0
  25. package/dist/client/components/revision-history.mjs +159 -0
  26. package/dist/client/components/shared/content-type-card.cjs +56 -0
  27. package/dist/client/components/shared/content-type-card.mjs +54 -0
  28. package/dist/client/components/shared/empty-state.cjs +18 -1
  29. package/dist/client/components/shared/empty-state.mjs +19 -2
  30. package/dist/client/hooks/cms-hooks.cjs +18 -2
  31. package/dist/client/hooks/cms-hooks.mjs +17 -3
  32. package/dist/client/hooks/index.cjs +2 -0
  33. package/dist/client/hooks/index.d.cts +306 -2
  34. package/dist/client/hooks/index.d.mts +306 -2
  35. package/dist/client/hooks/index.d.ts +306 -2
  36. package/dist/client/hooks/index.mjs +1 -1
  37. package/dist/client/index.d.cts +55 -2
  38. package/dist/client/index.d.mts +55 -2
  39. package/dist/client/index.d.ts +55 -2
  40. package/dist/db.cjs +25 -0
  41. package/dist/db.mjs +25 -0
  42. package/dist/permissions.cjs +2 -0
  43. package/dist/permissions.d.cts +1 -0
  44. package/dist/permissions.d.mts +1 -0
  45. package/dist/permissions.d.ts +1 -0
  46. package/dist/permissions.mjs +2 -0
  47. package/dist/query-keys.cjs +31 -1
  48. package/dist/query-keys.d.cts +1 -1
  49. package/dist/query-keys.d.mts +1 -1
  50. package/dist/query-keys.d.ts +1 -1
  51. package/dist/query-keys.mjs +31 -1
  52. package/dist/shared/{plugin-cms.Csx7tNDO.d.mts → plugin-cms.DAAUZ41w.d.mts} +314 -81
  53. package/dist/shared/{plugin-cms.eNW9_FO5.d.ts → plugin-cms.DJ_293QU.d.cts} +314 -81
  54. package/dist/shared/{plugin-cms.Bptwe33B.d.cts → plugin-cms.DlUSv7wz.d.ts} +314 -81
  55. package/dist/types.d.cts +38 -2
  56. package/dist/types.d.mts +38 -2
  57. package/dist/types.d.ts +38 -2
  58. package/dist/ui/src/components/auto-form/config.cjs +2 -0
  59. package/dist/ui/src/components/auto-form/config.mjs +2 -0
  60. package/dist/ui/src/components/auto-form/fields/file.cjs +68 -0
  61. package/dist/ui/src/components/auto-form/fields/file.mjs +63 -0
  62. package/dist/ui/src/components/auto-form/fields/object.cjs +9 -2
  63. package/dist/ui/src/components/auto-form/fields/object.mjs +9 -2
  64. package/dist/ui/src/components/auto-form/helpers.cjs +9 -0
  65. package/dist/ui/src/components/auto-form/helpers.mjs +9 -0
  66. package/dist/ui/src/components/auto-form/index.cjs +29 -4
  67. package/dist/ui/src/components/auto-form/index.mjs +31 -6
  68. package/dist/ui/src/components/auto-form/stepped-auto-form.cjs +34 -29
  69. package/dist/ui/src/components/auto-form/stepped-auto-form.mjs +35 -30
  70. package/dist/ui/src/components/sheet.cjs +126 -0
  71. package/dist/ui/src/components/sheet.mjs +105 -0
  72. package/dist/ui/src/lib/form-visibility.cjs +20 -0
  73. package/dist/ui/src/lib/form-visibility.mjs +18 -0
  74. package/dist/ui/src/lib/schema-converter.cjs +101 -0
  75. package/dist/ui/src/lib/schema-converter.mjs +100 -1
  76. package/package.json +5 -4
@@ -74,6 +74,17 @@ async function syncContentTypes(adapter, config) {
74
74
  }
75
75
  }
76
76
  const cmsBackendPlugin = (config) => {
77
+ for (const type of config.contentTypes) {
78
+ const policy = type.publication;
79
+ if (!policy) continue;
80
+ const field = type.schema.shape[policy.stateField];
81
+ if (!field || policy.scheduledValue === policy.publishedValue || !z.z.safeParse(field, policy.scheduledValue).success || !z.z.safeParse(field, policy.publishedValue).success) {
82
+ throw new TypeError(
83
+ `Invalid publication policy for content type "${type.slug}"`
84
+ );
85
+ }
86
+ }
87
+ const publicationFor = (typeSlug, view) => view === "content" ? config.contentTypes.find((type) => type.slug === typeSlug)?.publication : void 0;
77
88
  let syncPromise = null;
78
89
  const ensureSynced = (adapter) => {
79
90
  if (!syncPromise) {
@@ -123,10 +134,16 @@ const cmsBackendPlugin = (config) => {
123
134
  fields: params?.fields
124
135
  });
125
136
  const [contentTypes, contentItems] = await Promise.all([
126
- getContentTypesWithCounts(adapter),
127
- getters.getAllContentItems(adapter, typeSlug, options)
137
+ publicationFor(typeSlug, options.view) ? Promise.resolve(null) : getContentTypesWithCounts(adapter),
138
+ getters.getAllContentItems(
139
+ adapter,
140
+ typeSlug,
141
+ options,
142
+ publicationFor(typeSlug, options.view)
143
+ )
128
144
  ]);
129
- queryClient.setQueryData(queryKeyDefs.CMS_QUERY_KEYS.typesList(), contentTypes);
145
+ if (contentTypes)
146
+ queryClient.setQueryData(queryKeyDefs.CMS_QUERY_KEYS.typesList(), contentTypes);
130
147
  queryClient.setQueryData(
131
148
  queryKeyDefs.CMS_QUERY_KEYS.contentList({ typeSlug, ...options }),
132
149
  {
@@ -150,11 +167,18 @@ const cmsBackendPlugin = (config) => {
150
167
  view: params?.view,
151
168
  fields: params?.fields
152
169
  });
153
- const [contentTypes, item] = await Promise.all([
154
- getContentTypesWithCounts(adapter),
155
- id ? getters.getContentItemById(adapter, id, options) : Promise.resolve(null)
170
+ const [contentTypes, fullItem] = await Promise.all([
171
+ publicationFor(typeSlug, options.view) ? Promise.resolve(null) : getContentTypesWithCounts(adapter),
172
+ id ? getters.getContentItemById(adapter, id) : Promise.resolve(null)
156
173
  ]);
157
- queryClient.setQueryData(queryKeyDefs.CMS_QUERY_KEYS.typesList(), contentTypes);
174
+ const item = fullItem?.contentType?.slug === typeSlug ? await getters.getContentItemById(
175
+ adapter,
176
+ id,
177
+ options,
178
+ publicationFor(typeSlug, options.view)
179
+ ) : null;
180
+ if (contentTypes)
181
+ queryClient.setQueryData(queryKeyDefs.CMS_QUERY_KEYS.typesList(), contentTypes);
158
182
  if (id) {
159
183
  queryClient.setQueryData(
160
184
  queryKeyDefs.CMS_QUERY_KEYS.contentDetail(typeSlug, id, options),
@@ -170,6 +194,7 @@ const cmsBackendPlugin = (config) => {
170
194
  dbPlugin: db.cmsSchema,
171
195
  operations: (adapter) => operations.createCMSOperations(adapter, {
172
196
  ensureSynced: () => ensureSynced(adapter),
197
+ contentTypes: config.contentTypes,
173
198
  getContentTypesWithCounts: () => getContentTypesWithCounts(adapter),
174
199
  ...config.maxPageSize !== void 0 ? { maxPageSize: config.maxPageSize } : {},
175
200
  ...config.hooks ? { hooks: config.hooks } : {}
@@ -261,6 +286,28 @@ const cmsBackendPlugin = (config) => {
261
286
  },
262
287
  operations$1.deleteContentItem.route((ctx) => ctx.params)
263
288
  );
289
+ const listContentRevisions = api.createEndpoint(
290
+ "/content/:typeSlug/:id/revisions",
291
+ {
292
+ method: "GET",
293
+ params: operations.CMSContentItemParamsSchema,
294
+ query: operations.CMSRevisionsQuerySchema,
295
+ requireRequest: true
296
+ },
297
+ operations$1.listContentRevisions.route((ctx) => ({
298
+ ...ctx.params,
299
+ ...ctx.query
300
+ }))
301
+ );
302
+ const restoreContentRevision = api.createEndpoint(
303
+ "/content/:typeSlug/:id/revisions/:revisionId/restore",
304
+ {
305
+ method: "POST",
306
+ params: operations.CMSRevisionParamsSchema,
307
+ requireRequest: true
308
+ },
309
+ operations$1.restoreContentRevision.route((ctx) => ctx.params)
310
+ );
264
311
  const getContentItemPopulated = api.createEndpoint(
265
312
  "/content/:typeSlug/:id/populated",
266
313
  {
@@ -320,6 +367,8 @@ const cmsBackendPlugin = (config) => {
320
367
  getContentItem,
321
368
  createContentItem,
322
369
  updateContentItem,
370
+ listContentRevisions,
371
+ restoreContentRevision,
323
372
  deleteContentItem,
324
373
  getContentItemPopulated,
325
374
  listContentByRelation,
@@ -4,7 +4,7 @@ import { zodToFormSchema } from '../ui/src/lib/schema-converter.mjs';
4
4
  import { cmsSchema } from '../db.mjs';
5
5
  import { createListContentQuerySchema, contentReadQuerySchema, DEFAULT_MAX_PAGE_SIZE, contentReadOptionsSchema } from '../schemas.mjs';
6
6
  import { getContentItemById, getAllContentItems, getAllContentTypes } from './getters.mjs';
7
- import { CMSContentTypeParamsSchema, CMSContentItemParamsSchema, CMSCreateContentItemBodySchema, CMSUpdateContentItemBodySchema, createCMSOperations } from './operations.mjs';
7
+ import { CMSContentTypeParamsSchema, CMSContentItemParamsSchema, CMSCreateContentItemBodySchema, CMSUpdateContentItemBodySchema, CMSRevisionsQuerySchema, CMSRevisionParamsSchema, createCMSOperations } from './operations.mjs';
8
8
  import { CMS_QUERY_KEYS } from './query-key-defs.mjs';
9
9
 
10
10
  function schemasEqual(stored, configured) {
@@ -72,6 +72,17 @@ async function syncContentTypes(adapter, config) {
72
72
  }
73
73
  }
74
74
  const cmsBackendPlugin = (config) => {
75
+ for (const type of config.contentTypes) {
76
+ const policy = type.publication;
77
+ if (!policy) continue;
78
+ const field = type.schema.shape[policy.stateField];
79
+ if (!field || policy.scheduledValue === policy.publishedValue || !z.safeParse(field, policy.scheduledValue).success || !z.safeParse(field, policy.publishedValue).success) {
80
+ throw new TypeError(
81
+ `Invalid publication policy for content type "${type.slug}"`
82
+ );
83
+ }
84
+ }
85
+ const publicationFor = (typeSlug, view) => view === "content" ? config.contentTypes.find((type) => type.slug === typeSlug)?.publication : void 0;
75
86
  let syncPromise = null;
76
87
  const ensureSynced = (adapter) => {
77
88
  if (!syncPromise) {
@@ -121,10 +132,16 @@ const cmsBackendPlugin = (config) => {
121
132
  fields: params?.fields
122
133
  });
123
134
  const [contentTypes, contentItems] = await Promise.all([
124
- getContentTypesWithCounts(adapter),
125
- getAllContentItems(adapter, typeSlug, options)
135
+ publicationFor(typeSlug, options.view) ? Promise.resolve(null) : getContentTypesWithCounts(adapter),
136
+ getAllContentItems(
137
+ adapter,
138
+ typeSlug,
139
+ options,
140
+ publicationFor(typeSlug, options.view)
141
+ )
126
142
  ]);
127
- queryClient.setQueryData(CMS_QUERY_KEYS.typesList(), contentTypes);
143
+ if (contentTypes)
144
+ queryClient.setQueryData(CMS_QUERY_KEYS.typesList(), contentTypes);
128
145
  queryClient.setQueryData(
129
146
  CMS_QUERY_KEYS.contentList({ typeSlug, ...options }),
130
147
  {
@@ -148,11 +165,18 @@ const cmsBackendPlugin = (config) => {
148
165
  view: params?.view,
149
166
  fields: params?.fields
150
167
  });
151
- const [contentTypes, item] = await Promise.all([
152
- getContentTypesWithCounts(adapter),
153
- id ? getContentItemById(adapter, id, options) : Promise.resolve(null)
168
+ const [contentTypes, fullItem] = await Promise.all([
169
+ publicationFor(typeSlug, options.view) ? Promise.resolve(null) : getContentTypesWithCounts(adapter),
170
+ id ? getContentItemById(adapter, id) : Promise.resolve(null)
154
171
  ]);
155
- queryClient.setQueryData(CMS_QUERY_KEYS.typesList(), contentTypes);
172
+ const item = fullItem?.contentType?.slug === typeSlug ? await getContentItemById(
173
+ adapter,
174
+ id,
175
+ options,
176
+ publicationFor(typeSlug, options.view)
177
+ ) : null;
178
+ if (contentTypes)
179
+ queryClient.setQueryData(CMS_QUERY_KEYS.typesList(), contentTypes);
156
180
  if (id) {
157
181
  queryClient.setQueryData(
158
182
  CMS_QUERY_KEYS.contentDetail(typeSlug, id, options),
@@ -168,6 +192,7 @@ const cmsBackendPlugin = (config) => {
168
192
  dbPlugin: cmsSchema,
169
193
  operations: (adapter) => createCMSOperations(adapter, {
170
194
  ensureSynced: () => ensureSynced(adapter),
195
+ contentTypes: config.contentTypes,
171
196
  getContentTypesWithCounts: () => getContentTypesWithCounts(adapter),
172
197
  ...config.maxPageSize !== void 0 ? { maxPageSize: config.maxPageSize } : {},
173
198
  ...config.hooks ? { hooks: config.hooks } : {}
@@ -259,6 +284,28 @@ const cmsBackendPlugin = (config) => {
259
284
  },
260
285
  operations.deleteContentItem.route((ctx) => ctx.params)
261
286
  );
287
+ const listContentRevisions = createEndpoint(
288
+ "/content/:typeSlug/:id/revisions",
289
+ {
290
+ method: "GET",
291
+ params: CMSContentItemParamsSchema,
292
+ query: CMSRevisionsQuerySchema,
293
+ requireRequest: true
294
+ },
295
+ operations.listContentRevisions.route((ctx) => ({
296
+ ...ctx.params,
297
+ ...ctx.query
298
+ }))
299
+ );
300
+ const restoreContentRevision = createEndpoint(
301
+ "/content/:typeSlug/:id/revisions/:revisionId/restore",
302
+ {
303
+ method: "POST",
304
+ params: CMSRevisionParamsSchema,
305
+ requireRequest: true
306
+ },
307
+ operations.restoreContentRevision.route((ctx) => ctx.params)
308
+ );
262
309
  const getContentItemPopulated = createEndpoint(
263
310
  "/content/:typeSlug/:id/populated",
264
311
  {
@@ -318,6 +365,8 @@ const cmsBackendPlugin = (config) => {
318
365
  getContentItem,
319
366
  createContentItem,
320
367
  updateContentItem,
368
+ listContentRevisions,
369
+ restoreContentRevision,
321
370
  deleteContentItem,
322
371
  getContentItemPopulated,
323
372
  listContentByRelation,
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ function isPublishedContent(item, policy) {
4
+ if (item.publishAt) return false;
5
+ return JSON.parse(item.data)[policy.stateField] === policy.publishedValue;
6
+ }
7
+
8
+ exports.isPublishedContent = isPublishedContent;
@@ -0,0 +1,6 @@
1
+ function isPublishedContent(item, policy) {
2
+ if (item.publishAt) return false;
3
+ return JSON.parse(item.data)[policy.stateField] === policy.publishedValue;
4
+ }
5
+
6
+ export { isPublishedContent };
@@ -11,6 +11,7 @@ const input = require('../../../ui/src/components/input.cjs');
11
11
  const label = require('../../../ui/src/components/label.cjs');
12
12
  const badge = require('../../../ui/src/components/badge.cjs');
13
13
  const context = require('@btst/stack/context');
14
+ const pageContext = require('@btst/stack/page-context');
14
15
  const constants = require('../../constants.cjs');
15
16
  const utils = require('../../../utils.cjs');
16
17
  const fileUpload = require('./file-upload.cjs');
@@ -123,6 +124,7 @@ function ContentForm({
123
124
  contentType,
124
125
  initialData = {},
125
126
  initialSlug = "",
127
+ initialPublishAt,
126
128
  isEditing = false,
127
129
  onSubmit,
128
130
  onCancel,
@@ -139,6 +141,13 @@ function ContentForm({
139
141
  fieldComponents
140
142
  } = context.usePluginOverrides(constants.CMS_PLUGIN_ID);
141
143
  const [slug, setSlug] = React.useState(initialSlug);
144
+ const publishAtEditedRef = React.useRef(false);
145
+ const aiPublishAtRef = React.useRef(void 0);
146
+ const [publishAt, setPublishAt] = React.useState(() => {
147
+ if (!initialPublishAt) return "";
148
+ const date = new Date(initialPublishAt);
149
+ return new Date(date.getTime() - date.getTimezoneOffset() * 6e4).toISOString().slice(0, 16);
150
+ });
142
151
  const [slugManuallyEdited, setSlugManuallyEdited] = React.useState(isEditing);
143
152
  const [isSubmittingLocal, setIsSubmittingLocal] = React.useState(false);
144
153
  const [formData, setFormData] = React.useState(initialData);
@@ -179,6 +188,76 @@ function ContentForm({
179
188
  return z.z.object({});
180
189
  }
181
190
  }, [jsonSchema]);
191
+ pageContext.useRegisterPageAIContext({
192
+ routeName: "cms-content-editor",
193
+ pageDescription: JSON.stringify({
194
+ page: "CMS entry draft. Fill only fields in this active content type; the user reviews and saves.",
195
+ contentType: contentType.slug,
196
+ schema: jsonSchema,
197
+ publishAt
198
+ }).slice(0, 16e3),
199
+ clientTools: {
200
+ fillCmsEntryForm: async (input) => {
201
+ if (isSubmitting)
202
+ return {
203
+ success: false,
204
+ message: "Wait for the entry save to finish."
205
+ };
206
+ const parsed = z.z.object({
207
+ data: z.z.record(z.z.string(), z.z.unknown()).optional(),
208
+ publishAt: z.z.iso.datetime({ offset: true }).nullable().optional()
209
+ }).strict().safeParse(input);
210
+ if (!parsed.success)
211
+ return { success: false, message: "Invalid entry draft." };
212
+ const patch = parsed.data.data ?? {};
213
+ const properties = jsonSchema.properties;
214
+ if (Object.keys(patch).some(
215
+ (key) => !properties || !Object.hasOwn(properties, key) || ["__proto__", "constructor", "prototype"].includes(key)
216
+ ))
217
+ return {
218
+ success: false,
219
+ message: "Draft contains fields outside the active content type."
220
+ };
221
+ let validation;
222
+ try {
223
+ validation = schemaConverter.formSchemaToZod(jsonSchema).safeParse({
224
+ ...formData,
225
+ ...patch
226
+ });
227
+ } catch {
228
+ return {
229
+ success: false,
230
+ message: "The active content type could not be validated."
231
+ };
232
+ }
233
+ if (!validation.success && validation.error.issues.some(
234
+ (issue) => issue.path.length === 0 || Object.hasOwn(patch, String(issue.path[0]))
235
+ ))
236
+ return {
237
+ success: false,
238
+ message: "Draft values do not match the active content type."
239
+ };
240
+ for (const [key, value] of Object.entries(patch))
241
+ formInstance?.setValue(key, value, {
242
+ shouldDirty: true,
243
+ shouldValidate: true
244
+ });
245
+ setFormData((current) => ({ ...current, ...patch }));
246
+ if (parsed.data.publishAt !== void 0) {
247
+ aiPublishAtRef.current = parsed.data.publishAt;
248
+ publishAtEditedRef.current = true;
249
+ const date = parsed.data.publishAt ? new Date(parsed.data.publishAt) : null;
250
+ setPublishAt(
251
+ date ? new Date(date.getTime() - date.getTimezoneOffset() * 6e4).toISOString().slice(0, 16) : ""
252
+ );
253
+ }
254
+ return {
255
+ success: true,
256
+ message: "Entry draft updated. Review it before saving."
257
+ };
258
+ }
259
+ }
260
+ });
182
261
  const fieldConfig = React.useMemo(
183
262
  () => buildFieldConfigFromJsonSchema(
184
263
  jsonSchema,
@@ -216,7 +295,11 @@ function ContentForm({
216
295
  }
217
296
  setIsSubmittingLocal(true);
218
297
  try {
219
- await onSubmit({ slug, data });
298
+ await onSubmit({
299
+ slug,
300
+ data,
301
+ publishAt: aiPublishAtRef.current !== void 0 ? aiPublishAtRef.current : publishAtEditedRef.current ? publishAt ? new Date(publishAt).toISOString() : null : initialPublishAt ?? null
302
+ });
220
303
  } catch (error) {
221
304
  const message = error instanceof Error ? error.message : localization?.CMS_TOAST_ERROR ?? t("cms.toasts.error", "An error occurred. Please try again.");
222
305
  setSubmitError(message);
@@ -258,6 +341,29 @@ function ContentForm({
258
341
  "URL-friendly identifier for this item"
259
342
  ) })
260
343
  ] }),
344
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
345
+ /* @__PURE__ */ jsxRuntime.jsx(label.Label, { htmlFor: "publishAt", children: t("cms.editor.publishAt", "Publish at") }),
346
+ /* @__PURE__ */ jsxRuntime.jsx(
347
+ input.Input,
348
+ {
349
+ id: "publishAt",
350
+ type: "datetime-local",
351
+ value: publishAt,
352
+ onChange: (event) => {
353
+ aiPublishAtRef.current = void 0;
354
+ publishAtEditedRef.current = true;
355
+ setPublishAt(event.target.value);
356
+ },
357
+ disabled: isSubmitting,
358
+ "aria-describedby": "publishAt-description"
359
+ }
360
+ ),
361
+ /* @__PURE__ */ jsxRuntime.jsx("p", { id: "publishAt-description", className: "text-sm text-muted-foreground", children: t(
362
+ "cms.editor.publishAtDescription",
363
+ "Optional. Uses your local time zone. Leave empty to clear the schedule. Scheduling does not change this item's publication state."
364
+ ) }),
365
+ serverFieldErrors.publishAt && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "alert", className: "text-sm text-destructive", children: serverFieldErrors.publishAt })
366
+ ] }),
261
367
  bannerMessage && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "rounded-md border border-destructive/50 bg-destructive/10 p-3", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-destructive", children: bannerMessage }) }),
262
368
  /* @__PURE__ */ jsxRuntime.jsx(
263
369
  steppedAutoForm.SteppedAutoForm,
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsxs, jsx } from 'react/jsx-runtime';
3
- import { useState, useMemo, useRef, useEffect } from 'react';
3
+ import { useState, useRef, useMemo, useEffect } from 'react';
4
4
  import { z } from 'zod';
5
5
  import { SteppedAutoForm } from '../../../ui/src/components/auto-form/stepped-auto-form.mjs';
6
6
  import { buildFieldConfigFromJsonSchema as buildFieldConfigFromJsonSchema$1 } from '../../../ui/src/components/auto-form/helpers.mjs';
@@ -9,6 +9,7 @@ import { Input } from '../../../ui/src/components/input.mjs';
9
9
  import { Label } from '../../../ui/src/components/label.mjs';
10
10
  import { Badge } from '../../../ui/src/components/badge.mjs';
11
11
  import { useTranslate, usePluginOverrides } from '@btst/stack/context';
12
+ import { useRegisterPageAIContext } from '@btst/stack/page-context';
12
13
  import { CMS_PLUGIN_ID } from '../../constants.mjs';
13
14
  import { slugify } from '../../../utils.mjs';
14
15
  import { CMSFileUpload } from './file-upload.mjs';
@@ -121,6 +122,7 @@ function ContentForm({
121
122
  contentType,
122
123
  initialData = {},
123
124
  initialSlug = "",
125
+ initialPublishAt,
124
126
  isEditing = false,
125
127
  onSubmit,
126
128
  onCancel,
@@ -137,6 +139,13 @@ function ContentForm({
137
139
  fieldComponents
138
140
  } = usePluginOverrides(CMS_PLUGIN_ID);
139
141
  const [slug, setSlug] = useState(initialSlug);
142
+ const publishAtEditedRef = useRef(false);
143
+ const aiPublishAtRef = useRef(void 0);
144
+ const [publishAt, setPublishAt] = useState(() => {
145
+ if (!initialPublishAt) return "";
146
+ const date = new Date(initialPublishAt);
147
+ return new Date(date.getTime() - date.getTimezoneOffset() * 6e4).toISOString().slice(0, 16);
148
+ });
140
149
  const [slugManuallyEdited, setSlugManuallyEdited] = useState(isEditing);
141
150
  const [isSubmittingLocal, setIsSubmittingLocal] = useState(false);
142
151
  const [formData, setFormData] = useState(initialData);
@@ -177,6 +186,76 @@ function ContentForm({
177
186
  return z.object({});
178
187
  }
179
188
  }, [jsonSchema]);
189
+ useRegisterPageAIContext({
190
+ routeName: "cms-content-editor",
191
+ pageDescription: JSON.stringify({
192
+ page: "CMS entry draft. Fill only fields in this active content type; the user reviews and saves.",
193
+ contentType: contentType.slug,
194
+ schema: jsonSchema,
195
+ publishAt
196
+ }).slice(0, 16e3),
197
+ clientTools: {
198
+ fillCmsEntryForm: async (input) => {
199
+ if (isSubmitting)
200
+ return {
201
+ success: false,
202
+ message: "Wait for the entry save to finish."
203
+ };
204
+ const parsed = z.object({
205
+ data: z.record(z.string(), z.unknown()).optional(),
206
+ publishAt: z.iso.datetime({ offset: true }).nullable().optional()
207
+ }).strict().safeParse(input);
208
+ if (!parsed.success)
209
+ return { success: false, message: "Invalid entry draft." };
210
+ const patch = parsed.data.data ?? {};
211
+ const properties = jsonSchema.properties;
212
+ if (Object.keys(patch).some(
213
+ (key) => !properties || !Object.hasOwn(properties, key) || ["__proto__", "constructor", "prototype"].includes(key)
214
+ ))
215
+ return {
216
+ success: false,
217
+ message: "Draft contains fields outside the active content type."
218
+ };
219
+ let validation;
220
+ try {
221
+ validation = formSchemaToZod(jsonSchema).safeParse({
222
+ ...formData,
223
+ ...patch
224
+ });
225
+ } catch {
226
+ return {
227
+ success: false,
228
+ message: "The active content type could not be validated."
229
+ };
230
+ }
231
+ if (!validation.success && validation.error.issues.some(
232
+ (issue) => issue.path.length === 0 || Object.hasOwn(patch, String(issue.path[0]))
233
+ ))
234
+ return {
235
+ success: false,
236
+ message: "Draft values do not match the active content type."
237
+ };
238
+ for (const [key, value] of Object.entries(patch))
239
+ formInstance?.setValue(key, value, {
240
+ shouldDirty: true,
241
+ shouldValidate: true
242
+ });
243
+ setFormData((current) => ({ ...current, ...patch }));
244
+ if (parsed.data.publishAt !== void 0) {
245
+ aiPublishAtRef.current = parsed.data.publishAt;
246
+ publishAtEditedRef.current = true;
247
+ const date = parsed.data.publishAt ? new Date(parsed.data.publishAt) : null;
248
+ setPublishAt(
249
+ date ? new Date(date.getTime() - date.getTimezoneOffset() * 6e4).toISOString().slice(0, 16) : ""
250
+ );
251
+ }
252
+ return {
253
+ success: true,
254
+ message: "Entry draft updated. Review it before saving."
255
+ };
256
+ }
257
+ }
258
+ });
180
259
  const fieldConfig = useMemo(
181
260
  () => buildFieldConfigFromJsonSchema(
182
261
  jsonSchema,
@@ -214,7 +293,11 @@ function ContentForm({
214
293
  }
215
294
  setIsSubmittingLocal(true);
216
295
  try {
217
- await onSubmit({ slug, data });
296
+ await onSubmit({
297
+ slug,
298
+ data,
299
+ publishAt: aiPublishAtRef.current !== void 0 ? aiPublishAtRef.current : publishAtEditedRef.current ? publishAt ? new Date(publishAt).toISOString() : null : initialPublishAt ?? null
300
+ });
218
301
  } catch (error) {
219
302
  const message = error instanceof Error ? error.message : localization?.CMS_TOAST_ERROR ?? t("cms.toasts.error", "An error occurred. Please try again.");
220
303
  setSubmitError(message);
@@ -256,6 +339,29 @@ function ContentForm({
256
339
  "URL-friendly identifier for this item"
257
340
  ) })
258
341
  ] }),
342
+ /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
343
+ /* @__PURE__ */ jsx(Label, { htmlFor: "publishAt", children: t("cms.editor.publishAt", "Publish at") }),
344
+ /* @__PURE__ */ jsx(
345
+ Input,
346
+ {
347
+ id: "publishAt",
348
+ type: "datetime-local",
349
+ value: publishAt,
350
+ onChange: (event) => {
351
+ aiPublishAtRef.current = void 0;
352
+ publishAtEditedRef.current = true;
353
+ setPublishAt(event.target.value);
354
+ },
355
+ disabled: isSubmitting,
356
+ "aria-describedby": "publishAt-description"
357
+ }
358
+ ),
359
+ /* @__PURE__ */ jsx("p", { id: "publishAt-description", className: "text-sm text-muted-foreground", children: t(
360
+ "cms.editor.publishAtDescription",
361
+ "Optional. Uses your local time zone. Leave empty to clear the schedule. Scheduling does not change this item's publication state."
362
+ ) }),
363
+ serverFieldErrors.publishAt && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-sm text-destructive", children: serverFieldErrors.publishAt })
364
+ ] }),
259
365
  bannerMessage && /* @__PURE__ */ jsx("div", { className: "rounded-md border border-destructive/50 bg-destructive/10 p-3", children: /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", children: bannerMessage }) }),
260
366
  /* @__PURE__ */ jsx(
261
367
  SteppedAutoForm,
@@ -10,6 +10,7 @@ const overrides = require('../../overrides.cjs');
10
10
  const constants = require('../../constants.cjs');
11
11
  const contentForm = require('../forms/content-form.cjs');
12
12
  const inverseRelationsPanel = require('../inverse-relations-panel.cjs');
13
+ const revisionHistory = require('../revision-history.cjs');
13
14
  const emptyState = require('../shared/empty-state.cjs');
14
15
  const pageWrapper = require('../shared/page-wrapper.cjs');
15
16
  const editorSkeleton = require('../loading/editor-skeleton.cjs');
@@ -87,6 +88,7 @@ function ContentEditorPage({ typeSlug, id }) {
87
88
  );
88
89
  const contentType = contentTypes.find((ct) => ct.slug === typeSlug);
89
90
  const isEditing = !!id;
91
+ const [restoreCount, setRestoreCount] = React.useState(0);
90
92
  const { item, isLoading: isLoadingItem } = cmsHooks.useContentItem(typeSlug, id ?? "");
91
93
  const resourceForm = cmsHooks.useContentItemForm({
92
94
  action: isEditing ? "edit" : "create",
@@ -95,12 +97,17 @@ function ContentEditorPage({ typeSlug, id }) {
95
97
  toCreateVars: (values) => ({
96
98
  typeSlug,
97
99
  slug: values.slug,
98
- data: values.data
100
+ data: values.data,
101
+ publishAt: values.publishAt
99
102
  }),
100
103
  toUpdateVars: (values) => ({
101
104
  typeSlug,
102
105
  id: id ?? "",
103
- data: { slug: values.slug, data: values.data }
106
+ data: {
107
+ slug: values.slug,
108
+ data: values.data,
109
+ publishAt: values.publishAt
110
+ }
104
111
  }),
105
112
  onSuccess: () => {
106
113
  void navigate?.(context.joinBasePath(basePath, `/cms/${typeSlug}`));
@@ -110,6 +117,7 @@ function ContentEditorPage({ typeSlug, id }) {
110
117
  return /* @__PURE__ */ jsxRuntime.jsx(pageWrapper.PageWrapper, { testId: "cms-editor-page", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-full max-w-2xl", children: /* @__PURE__ */ jsxRuntime.jsx(
111
118
  emptyState.EmptyState,
112
119
  {
120
+ page: id ? "editContent" : "newContent",
113
121
  title: localization?.CMS_ERROR_NOT_FOUND ?? t("cms.common.notFound", "Not found"),
114
122
  description: localization?.CMS_ERROR_TYPE_NOT_FOUND_DESCRIPTION ?? t("cms.common.typeNotFoundDescription", "Content type not found")
115
123
  }
@@ -122,6 +130,7 @@ function ContentEditorPage({ typeSlug, id }) {
122
130
  return /* @__PURE__ */ jsxRuntime.jsx(pageWrapper.PageWrapper, { testId: "cms-editor-page", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-full max-w-2xl", children: /* @__PURE__ */ jsxRuntime.jsx(
123
131
  emptyState.EmptyState,
124
132
  {
133
+ page: id ? "editContent" : "newContent",
125
134
  title: localization?.CMS_ERROR_NOT_FOUND ?? t("cms.common.notFound", "Not found"),
126
135
  description: localization?.CMS_ERROR_ITEM_NOT_FOUND_DESCRIPTION ?? t("cms.common.itemNotFoundDescription", "Content item not found")
127
136
  }
@@ -133,7 +142,7 @@ function ContentEditorPage({ typeSlug, id }) {
133
142
  const hasFieldErrors = Object.keys(resourceForm.fieldErrors).length > 0;
134
143
  const title = isEditing ? (localization?.CMS_EDITOR_TITLE_EDIT ?? t("cms.editor.titleEdit", "Edit {typeName}")).replace("{typeName}", contentType.name) : (localization?.CMS_EDITOR_TITLE_NEW ?? t("cms.editor.titleNew", "New {typeName}")).replace("{typeName}", contentType.name);
135
144
  return /* @__PURE__ */ jsxRuntime.jsx(pageWrapper.PageWrapper, { testId: "cms-editor-page", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "w-full max-w-2xl space-y-6", children: [
136
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-4", children: [
145
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [
137
146
  /* @__PURE__ */ jsxRuntime.jsx(
138
147
  button.Button,
139
148
  {
@@ -143,7 +152,15 @@ function ContentEditorPage({ typeSlug, id }) {
143
152
  children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ArrowLeft, { className: "h-4 w-4" })
144
153
  }
145
154
  ),
146
- /* @__PURE__ */ jsxRuntime.jsx("h1", { className: "text-2xl font-bold tracking-tight", children: title })
155
+ /* @__PURE__ */ jsxRuntime.jsx("h1", { className: "text-2xl font-bold tracking-tight", children: title }),
156
+ isEditing && id && /* @__PURE__ */ jsxRuntime.jsx(
157
+ revisionHistory.RevisionHistory,
158
+ {
159
+ typeSlug,
160
+ itemId: id,
161
+ onRestored: () => setRestoreCount((count) => count + 1)
162
+ }
163
+ )
147
164
  ] }),
148
165
  /* @__PURE__ */ jsxRuntime.jsx(
149
166
  contentForm.ContentForm,
@@ -154,6 +171,7 @@ function ContentEditorPage({ typeSlug, id }) {
154
171
  JSON.parse(contentType.jsonSchema)
155
172
  ) : void 0,
156
173
  initialSlug: item?.slug,
174
+ initialPublishAt: item?.publishAt,
157
175
  isEditing,
158
176
  onSubmit: handleSubmit,
159
177
  onCancel: () => void navigate?.(context.joinBasePath(basePath, `/cms/${typeSlug}`)),
@@ -161,7 +179,7 @@ function ContentEditorPage({ typeSlug, id }) {
161
179
  errorMessage: hasFieldErrors ? void 0 : resourceForm.error?.message,
162
180
  isSubmitting: resourceForm.isSubmitting
163
181
  },
164
- isEditing ? `edit-${id}` : "create"
182
+ isEditing ? `edit-${id}-${restoreCount}` : "create"
165
183
  ),
166
184
  isEditing && id && /* @__PURE__ */ jsxRuntime.jsx(inverseRelationsPanel.InverseRelationsPanel, { contentTypeSlug: typeSlug, itemId: id })
167
185
  ] }) });