create-nextblock 0.14.2 → 0.14.5

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 (68) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
  3. package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
  4. package/templates/nextblock-template/app/actions/postActions.ts +3 -0
  5. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
  6. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
  7. package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
  8. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
  9. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +736 -117
  10. package/templates/nextblock-template/app/api/view/route.ts +114 -0
  11. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
  12. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  13. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  14. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  15. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  16. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
  18. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
  19. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
  20. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
  21. package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
  22. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
  23. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
  24. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
  25. package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
  26. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
  27. package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
  28. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
  29. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
  30. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
  31. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
  32. package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
  33. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
  34. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  35. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  36. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  37. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  38. package/templates/nextblock-template/app/layout.tsx +49 -0
  39. package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
  40. package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
  41. package/templates/nextblock-template/app/page.tsx +6 -3
  42. package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
  43. package/templates/nextblock-template/app/providers.tsx +16 -4
  44. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  45. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  46. package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
  47. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
  48. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  49. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
  50. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
  51. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  52. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  53. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  54. package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
  55. package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
  56. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
  57. package/templates/nextblock-template/lib/search/server.ts +3 -0
  58. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +102 -87
  59. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  60. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  61. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  62. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  63. package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
  64. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
  65. package/templates/nextblock-template/next-env.d.ts +2 -2
  66. package/templates/nextblock-template/package.json +1 -1
  67. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  68. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -43,15 +43,17 @@ export async function createPage(formData: FormData) {
43
43
  title: formData.get("title") as string,
44
44
  slug: formData.get("slug") as string,
45
45
  language_id: parseInt(formData.get("language_id") as string, 10),
46
- status: formData.get("status") as PageStatus,
46
+ // New pages always start private. Going public is an explicit, confirmed act
47
+ // in the editor's top bar, never a side effect of creating something.
48
+ status: "draft" as PageStatus,
47
49
  meta_title: formData.get("meta_title") as string || null,
48
50
  meta_description: formData.get("meta_description") as string || null,
49
51
  custom_canonical: formData.get("custom_canonical") as string || null,
50
52
  feature_image_id: getOptionalFeatureImageId(formData),
51
53
  };
52
54
 
53
- if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id) || !rawFormData.status) {
54
- return encodedRedirect("error", "/cms/pages/new", "Missing required fields: title, slug, language, or status.");
55
+ if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id)) {
56
+ return encodedRedirect("error", "/cms/pages/new", "Missing required fields: title, slug, or language.");
55
57
  }
56
58
 
57
59
  const translation_group_id = formData.get("translation_group_id") as string || uuidv4();
@@ -127,26 +129,27 @@ export async function updatePage(pageId: number, formData: FormData) {
127
129
  return { error: "Original page not found or error fetching it." };
128
130
  }
129
131
 
132
+ // Visibility (status / published_at) is NOT part of this payload. It belongs to
133
+ // the top-bar control, which writes it straight to the `pages` row — so editing
134
+ // page settings can never publish or unpublish anything by accident.
130
135
  const rawFormData = {
131
136
  title: formData.get("title") as string,
132
137
  slug: formData.get("slug") as string,
133
138
  language_id: parseInt(formData.get("language_id") as string, 10),
134
- status: formData.get("status") as PageStatus,
135
139
  meta_title: formData.get("meta_title") as string || null,
136
140
  meta_description: formData.get("meta_description") as string || null,
137
141
  custom_canonical: formData.get("custom_canonical") as string || null,
138
142
  feature_image_id: getOptionalFeatureImageId(formData),
139
143
  };
140
144
 
141
- if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id) || !rawFormData.status) {
142
- return { error: "Missing required fields: title, slug, language, or status." };
145
+ if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id)) {
146
+ return { error: "Missing required fields: title, slug, or language." };
143
147
  }
144
148
 
145
149
  const pageUpdateData: Partial<Omit<UpsertPagePayload, 'translation_group_id' | 'author_id'>> = {
146
150
  title: rawFormData.title,
147
151
  slug: rawFormData.slug,
148
152
  language_id: rawFormData.language_id,
149
- status: rawFormData.status,
150
153
  meta_title: rawFormData.meta_title,
151
154
  meta_description: rawFormData.meta_description,
152
155
  custom_canonical: rawFormData.custom_canonical,
@@ -193,9 +196,13 @@ export async function updatePage(pageId: number, formData: FormData) {
193
196
 
194
197
  /**
195
198
  * Publish a page directly (status -> "published") so it becomes visible on the
196
- * live site. Used by the draft-aware "View Live" button when an admin chooses to
197
- * publish a still-draft page. Revalidates the public surfaces, including "/" when
198
- * the page belongs to the homepage translation group.
199
+ * live site. Revalidates the public surfaces, including "/" when the page belongs
200
+ * to the homepage translation group.
201
+ *
202
+ * The CMS top bar now goes through `setContentVisibility` in
203
+ * `app/actions/visibilityActions.ts`, which also handles scheduling and
204
+ * unpublishing. This remains as a simple programmatic publish for scripts and
205
+ * customizations.
199
206
  */
200
207
  export async function publishPage(pageId: number): Promise<{ error?: string } | void> {
201
208
  const supabase = createClient();
@@ -21,7 +21,6 @@ import { useHotkeys } from '../../../../hooks/use-hotkeys';
21
21
  import FeatureImageField from "../../components/FeatureImageField";
22
22
 
23
23
  type Page = Database['public']['Tables']['pages']['Row'];
24
- type PageStatus = Database['public']['Enums']['page_status'];
25
24
  type Language = Database['public']['Tables']['languages']['Row'];
26
25
  // Remove: import { getActiveLanguagesClientSide } from "@nextblock-cms/db";
27
26
 
@@ -76,7 +75,6 @@ export default function PageForm({
76
75
  // If no languages are available, default to an empty string
77
76
  return "";
78
77
  });
79
- const [status, setStatus] = useState<PageStatus>(page?.status || "draft");
80
78
  const [metaTitle, setMetaTitle] = useState(page?.meta_title || "");
81
79
  const [metaDescription, setMetaDescription] = useState(
82
80
  page?.meta_description || ""
@@ -118,7 +116,6 @@ export default function PageForm({
118
116
  setTitle(page.title || "");
119
117
  setSlug(page.slug || "");
120
118
  setLanguageId(page.language_id?.toString() || "");
121
- setStatus(page.status || "draft");
122
119
  setMetaTitle(page.meta_title || "");
123
120
  setMetaDescription(page.meta_description || "");
124
121
  setCustomCanonical(page.custom_canonical || "");
@@ -131,7 +128,6 @@ export default function PageForm({
131
128
  page?.meta_description,
132
129
  page?.meta_title,
133
130
  page?.slug,
134
- page?.status,
135
131
  page?.title,
136
132
  page?.updated_at,
137
133
  ]);
@@ -156,7 +152,6 @@ export default function PageForm({
156
152
  formData.append("title", title);
157
153
  formData.append("slug", slug);
158
154
  formData.append("language_id", languageId);
159
- formData.append("status", status);
160
155
  formData.append("meta_title", metaTitle);
161
156
  formData.append("meta_description", metaDescription);
162
157
  formData.append("custom_canonical", customCanonical);
@@ -214,7 +209,6 @@ export default function PageForm({
214
209
  title !== (page?.title || "") ||
215
210
  slug !== (page?.slug || "") ||
216
211
  languageId !== (page?.language_id?.toString() || "") ||
217
- status !== (page?.status || "draft") ||
218
212
  metaTitle !== (page?.meta_title || "") ||
219
213
  metaDescription !== (page?.meta_description || "") ||
220
214
  customCanonical !== (page?.custom_canonical || "") ||
@@ -227,7 +221,9 @@ export default function PageForm({
227
221
  }, 1000);
228
222
 
229
223
  return () => clearTimeout(timer);
230
- }, [title, slug, languageId, status, metaTitle, metaDescription, featureImageId, page, isEditing]);
224
+ // customCanonical belongs here too: hasChanges checks it, so leaving it out
225
+ // meant editing only the canonical URL never scheduled a save.
226
+ }, [title, slug, languageId, metaTitle, metaDescription, customCanonical, featureImageId, page, isEditing]);
231
227
 
232
228
  // Removed languagesLoading from this condition
233
229
  if (authLoading) {
@@ -304,8 +300,10 @@ export default function PageForm({
304
300
  </p>
305
301
  </div>
306
302
 
307
- {/* Language */}
308
- <div className="md:col-span-2 flex flex-col gap-1">
303
+ {/* Language. Visibility deliberately lives in the top bar, not here: this
304
+ form autosaves into the Live Draft on every keystroke, and publishing
305
+ must never be a side effect of editing settings. */}
306
+ <div className="md:col-span-4 flex flex-col gap-1">
309
307
  <Label htmlFor="language_id" className="text-xs font-medium">Language</Label>
310
308
  {availableLanguages.length > 0 ? (
311
309
  <Select
@@ -330,26 +328,6 @@ export default function PageForm({
330
328
  <p className="text-xs text-muted-foreground py-2 leading-none">No languages available.</p>
331
329
  )}
332
330
  </div>
333
-
334
- {/* Status */}
335
- <div className="md:col-span-2 flex flex-col gap-1">
336
- <Label htmlFor="status" className="text-xs font-medium">Status</Label>
337
- <Select
338
- name="status"
339
- value={status}
340
- onValueChange={(value) => setStatus(value as PageStatus)}
341
- required
342
- >
343
- <SelectTrigger className="h-9">
344
- <SelectValue placeholder="Select status" />
345
- </SelectTrigger>
346
- <SelectContent>
347
- <SelectItem value="draft">Draft</SelectItem>
348
- <SelectItem value="published">Published</SelectItem>
349
- <SelectItem value="archived">Archived</SelectItem>
350
- </SelectContent>
351
- </Select>
352
- </div>
353
331
  </div>
354
332
 
355
333
  {/* Row 2: SEO Settings. Canonical override (optional): blank = self-referencing canonical. */}
@@ -34,6 +34,7 @@ type Page = Database["public"]["Tables"]["pages"]["Row"];
34
34
  import LanguageFilterSelect from "../components/LanguageFilterSelect";
35
35
  import DeletePageButtonClient from "./components/DeletePageButtonClient"; // Import the client component
36
36
  import { ContentTransferControls } from "../import-export/ContentTransferControls";
37
+ import VisibilityBadge from "../components/VisibilityBadge";
37
38
 
38
39
  async function getPagesWithDetails(
39
40
  filterLanguageId?: number
@@ -172,25 +173,11 @@ export default async function CmsPagesListPage(props: CmsPagesListPageProps) {
172
173
  </Link>
173
174
  </TableCell>
174
175
  <TableCell>
175
- <Badge
176
- variant={
177
- page.status === "published"
178
- ? "default"
179
- : page.status === "draft"
180
- ? "secondary"
181
- : "destructive"
182
- }
183
- className={
184
- page.status === "published"
185
- ? "bg-green-100 text-green-700 dark:bg-green-700/30 dark:text-green-300 dark:border-green-700/50"
186
- : page.status === "draft"
187
- ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-700/30 dark:text-yellow-300 dark:border-yellow-700/50"
188
- : "bg-slate-100 text-slate-700 dark:bg-slate-700/30 dark:text-slate-300 dark:border-slate-600"
189
- }
190
- >
191
- {page.status.charAt(0).toUpperCase() +
192
- page.status.slice(1)}
193
- </Badge>
176
+ <VisibilityBadge
177
+ type="page"
178
+ status={page.status}
179
+ publishedAt={page.published_at}
180
+ />
194
181
  </TableCell>
195
182
  <TableCell>
196
183
  <Badge variant="outline" className="dark:border-slate-600">
@@ -3,13 +3,13 @@ import React from "react";
3
3
  import { Separator } from "@nextblock-cms/ui";
4
4
  import { createClient } from "@nextblock-cms/db/server";
5
5
  import PostForm from "../../components/PostForm";
6
- import { updatePost, publishPost } from "../../actions";
6
+ import { updatePost } from "../../actions";
7
7
  import type { Database } from "@nextblock-cms/db";
8
8
  import { notFound, redirect } from "next/navigation";
9
9
  import BlockEditorArea from "../../../blocks/components/BlockEditorArea";
10
10
  import Link from "next/link";
11
- import { Button, ViewLiveButton } from "@nextblock-cms/ui";
12
- import { ArrowLeft, FilePenLine } from "lucide-react";
11
+ import { Button } from "@nextblock-cms/ui";
12
+ import { ArrowLeft, Eye, FilePenLine } from "lucide-react";
13
13
  import ContentLanguageSwitcher from "../../../components/ContentLanguageSwitcher";
14
14
  import { getActiveLanguagesServerSide } from "@nextblock-cms/db/server";
15
15
  import { normalizeContentDraftRow } from "../../../../../lib/visual-editing/draft-content";
@@ -19,6 +19,8 @@ import RevisionHistoryButton from "../../../revisions/RevisionHistoryButton";
19
19
  import { resolveMediaUrl } from "../../../../../lib/media/resolveMediaUrl";
20
20
  import { CortexAiPageContextRegistrar } from "../../../components/CortexAiPageContext";
21
21
  import DraftStatusActions from "../../../components/DraftStatusActions";
22
+ import VisibilityControl from "../../../components/VisibilityControl";
23
+ import { buildViewUrl } from "../../../../../lib/publishing/viewUrl";
22
24
 
23
25
  type PostType = Database['public']['Tables']['posts']['Row'];
24
26
  type BlockType = Database['public']['Tables']['blocks']['Row'];
@@ -30,7 +32,13 @@ interface PostWithBlocks extends PostType {
30
32
  translation_group_id: string;
31
33
  }
32
34
 
33
- async function getPostDataWithBlocks(id: number): Promise<{ post: PostWithBlocks; hasDraft: boolean; liveStatus: string; liveSlug: string } | null> {
35
+ async function getPostDataWithBlocks(id: number): Promise<{
36
+ post: PostWithBlocks;
37
+ hasDraft: boolean;
38
+ liveStatus: string;
39
+ liveSlug: string;
40
+ livePublishedAt: string | null;
41
+ } | null> {
34
42
  const supabase = createClient();
35
43
  const { data: postData, error: postError } = await supabase
36
44
  .from("posts")
@@ -74,13 +82,13 @@ async function getPostDataWithBlocks(id: number): Promise<{ post: PostWithBlocks
74
82
  title: (draft.meta.title as string) ?? postWithBlocks.title,
75
83
  slug: (draft.meta.slug as string) ?? postWithBlocks.slug,
76
84
  language_id: (draft.meta.language_id as number) ?? postWithBlocks.language_id,
77
- status: (draft.meta.status as any) ?? postWithBlocks.status,
85
+ // `status`/`published_at` are intentionally NOT overlaid — see the matching
86
+ // note in cms/pages/[id]/edit/page.tsx. Visibility comes from the row.
78
87
  meta_title: (draft.meta.meta_title as string) ?? postWithBlocks.meta_title,
79
88
  meta_description: (draft.meta.meta_description as string) ?? postWithBlocks.meta_description,
80
89
  label: (draft.meta.label as string) ?? postWithBlocks.label,
81
90
  excerpt: (draft.meta.excerpt as string) ?? postWithBlocks.excerpt,
82
91
  subtitle: (draft.meta.subtitle as string) ?? postWithBlocks.subtitle,
83
- published_at: (draft.meta.published_at as string) ?? postWithBlocks.published_at,
84
92
  feature_image_id: (draft.meta.feature_image_id as any) ?? postWithBlocks.feature_image_id,
85
93
  blocks: draft.blocks as any[],
86
94
  };
@@ -89,10 +97,11 @@ async function getPostDataWithBlocks(id: number): Promise<{ post: PostWithBlocks
89
97
  return {
90
98
  post: postWithBlocks,
91
99
  hasDraft,
92
- // The LIVE row's status/slug (before the draft overlay above) the "View
93
- // Live" button must reflect what is actually published, not the draft.
100
+ // The LIVE row's visibility/slug the top-bar control and the "View Live"
101
+ // link must reflect what is actually published, never the draft.
94
102
  liveStatus: postData.status as string,
95
103
  liveSlug: postData.slug as string,
104
+ livePublishedAt: (postData.published_at as string | null) ?? null,
96
105
  };
97
106
  }
98
107
 
@@ -120,7 +129,9 @@ export default async function EditPostPage(props: { params: Promise<{ id: string
120
129
  return notFound();
121
130
  }
122
131
 
123
- const { post: postWithBlocks, hasDraft, liveStatus, liveSlug } = postDataResult;
132
+ const { post: postWithBlocks, hasDraft, liveStatus, liveSlug, livePublishedAt } = postDataResult;
133
+ const language = allSiteLanguages.find((lang) => lang.id === postWithBlocks.language_id);
134
+ const languageCode = language?.code ?? postWithBlocks.language_code ?? null;
124
135
 
125
136
  let initialFeatureImageUrl: string | null = null;
126
137
  let initialFeatureImageIdProp: string | null = null;
@@ -144,9 +155,10 @@ export default async function EditPostPage(props: { params: Promise<{ id: string
144
155
 
145
156
  const updatePostWithId = updatePost.bind(null, postId);
146
157
  const publicPostUrl = `/article/${postWithBlocks.slug}`;
147
- const draftModeUrl = `/api/draft/start?path=${encodeURIComponent(publicPostUrl)}`;
148
- const isLive = liveStatus === "published";
149
158
  const liveViewUrl = `/article/${liveSlug}`;
159
+ const previewUrl = buildViewUrl({ path: publicPostUrl, languageCode, draft: true });
160
+ const liveUrl = buildViewUrl({ path: liveViewUrl, languageCode });
161
+ const isLive = liveStatus === "published";
150
162
 
151
163
  return (
152
164
  <UploadFolderProvider defaultFolder={`posts/${postWithBlocks.slug}/`}>
@@ -190,19 +202,31 @@ export default async function EditPostPage(props: { params: Promise<{ id: string
190
202
  allSiteLanguages={allSiteLanguages}
191
203
  />
192
204
  )}
193
- <ViewLiveButton
194
- href={liveViewUrl}
195
- isLive={isLive}
196
- label="post"
197
- publishAction={publishPost.bind(null, postId)}
198
- />
199
205
  <Button variant="secondary" asChild>
200
- <a href={draftModeUrl} target="_blank" rel="noopener noreferrer">
206
+ <a href={previewUrl} target="_blank" rel="noopener noreferrer">
201
207
  <FilePenLine className="mr-2 h-4 w-4" />
202
208
  Preview
203
209
  </a>
204
210
  </Button>
211
+ {isLive && (
212
+ <Button variant="outline" asChild>
213
+ <a href={liveUrl} target="_blank" rel="noopener noreferrer">
214
+ <Eye className="mr-2 h-4 w-4" /> View Live
215
+ </a>
216
+ </Button>
217
+ )}
205
218
  <RevisionHistoryButton parentType="post" parentId={postId} />
219
+ <VisibilityControl
220
+ type="post"
221
+ id={postId}
222
+ status={liveStatus}
223
+ publishedAt={livePublishedAt}
224
+ publicPath={liveViewUrl}
225
+ languageName={language ? `${language.name} (${language.code.toUpperCase()})` : undefined}
226
+ translationGroupId={postWithBlocks.translation_group_id}
227
+ languages={allSiteLanguages}
228
+ hasDraft={hasDraft}
229
+ />
206
230
  </div>
207
231
  </div>
208
232
 
@@ -14,8 +14,12 @@ import { encodedRedirect } from "@nextblock-cms/utils/server"; // Ensure this is
14
14
 
15
15
  /**
16
16
  * Publish a post directly (status -> "published") so it becomes visible on the
17
- * live site. Used by the draft-aware "View Live" button when an admin chooses to
18
- * publish a still-draft post.
17
+ * live site.
18
+ *
19
+ * The CMS top bar now goes through `setContentVisibility` in
20
+ * `app/actions/visibilityActions.ts`, which also handles scheduling and
21
+ * unpublishing. This remains as a simple programmatic publish for scripts and
22
+ * customizations.
19
23
  */
20
24
  export async function publishPost(postId: number): Promise<{ error?: string } | void> {
21
25
  const supabase = createClient();
@@ -75,32 +79,26 @@ export async function createPost(formData: FormData) {
75
79
  slug: formData.get("slug") as string,
76
80
  language_id: parseInt(formData.get("language_id") as string, 10),
77
81
  label: formData.get("label") as string || null,
78
- status: formData.get("status") as PageStatus,
82
+ // New posts always start private; going public is an explicit act in the
83
+ // editor's top bar, never a side effect of creating something.
84
+ status: "draft" as PageStatus,
79
85
  excerpt: formData.get("excerpt") as string || null,
80
86
  subtitle: formData.get("subtitle") as string || null,
81
- published_at: formData.get("published_at") as string || null,
82
87
  meta_title: formData.get("meta_title") as string || null,
83
88
  meta_description: formData.get("meta_description") as string || null,
84
89
  custom_canonical: formData.get("custom_canonical") as string || null,
85
90
  feature_image_id: featureImageId_create,
86
91
  };
87
92
 
88
- if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id) || !rawFormData.status) {
89
- return encodedRedirect("error", "/cms/posts/new", "Missing required fields: title, slug, language, or status.");
90
- }
91
-
92
- let publishedAtISO: string | null = null;
93
- if (rawFormData.published_at) {
94
- const parsedDate = new Date(rawFormData.published_at);
95
- if (!isNaN(parsedDate.getTime())) publishedAtISO = parsedDate.toISOString();
96
- else publishedAtISO = rawFormData.published_at;
93
+ if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id)) {
94
+ return encodedRedirect("error", "/cms/posts/new", "Missing required fields: title, slug, or language.");
97
95
  }
98
96
 
99
97
  const newTranslationGroupId = uuidv4();
100
98
 
101
99
  const postData: UpsertPostPayload = {
102
100
  ...rawFormData,
103
- published_at: publishedAtISO,
101
+ published_at: null,
104
102
  author_id: user.id,
105
103
  translation_group_id: newTranslationGroupId,
106
104
  feature_image_id: rawFormData.feature_image_id,
@@ -196,35 +194,28 @@ export async function updatePost(postId: number, formData: FormData) {
196
194
  featureImageId_update = featureImageIdStr_update;
197
195
  }
198
196
 
197
+ // Visibility (status / published_at) is NOT part of this payload — see the note
198
+ // in cms/pages/actions.ts updatePage. The top-bar control owns it.
199
199
  const rawFormData = {
200
200
  title: formData.get("title") as string,
201
201
  slug: formData.get("slug") as string,
202
202
  language_id: existingPost.language_id, // Use existing post's language_id
203
203
  label: formData.get("label") as string || null,
204
- status: formData.get("status") as PageStatus,
205
204
  excerpt: formData.get("excerpt") as string || null,
206
205
  subtitle: formData.get("subtitle") as string || null,
207
- published_at: formData.get("published_at") as string || null,
208
206
  meta_title: formData.get("meta_title") as string || null,
209
207
  meta_description: formData.get("meta_description") as string || null,
210
208
  custom_canonical: formData.get("custom_canonical") as string || null,
211
209
  feature_image_id: featureImageId_update,
212
210
  };
213
211
 
214
- if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id) || !rawFormData.status) {
215
- return { error: "Missing required fields: title, slug, language, or status." };
212
+ if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id)) {
213
+ return { error: "Missing required fields: title, slug, or language." };
216
214
  }
217
215
  if (rawFormData.language_id !== existingPost.language_id) {
218
216
  return { error: "Changing the language of an existing post version is not allowed. Create a new translation instead." };
219
217
  }
220
218
 
221
- let publishedAtISO: string | null = null;
222
- if (rawFormData.published_at) {
223
- const parsedDate = new Date(rawFormData.published_at);
224
- if (!isNaN(parsedDate.getTime())) publishedAtISO = parsedDate.toISOString();
225
- else publishedAtISO = rawFormData.published_at;
226
- }
227
-
228
219
  const postUpdateData: Partial<Omit<UpsertPostPayload, 'translation_group_id' | 'author_id'>> = {
229
220
  title: rawFormData.title,
230
221
  slug: rawFormData.slug,
@@ -232,8 +223,6 @@ export async function updatePost(postId: number, formData: FormData) {
232
223
  label: rawFormData.label,
233
224
  excerpt: rawFormData.excerpt,
234
225
  subtitle: rawFormData.subtitle,
235
- status: rawFormData.status,
236
- published_at: publishedAtISO,
237
226
  meta_title: rawFormData.meta_title,
238
227
  meta_description: rawFormData.meta_description,
239
228
  custom_canonical: rawFormData.custom_canonical,
@@ -20,7 +20,6 @@ import { useAuth } from '../../../../context/AuthContext';
20
20
  import FeatureImageField from "../../components/FeatureImageField";
21
21
 
22
22
  type Post = Database['public']['Tables']['posts']['Row'];
23
- type PageStatus = Database['public']['Enums']['page_status'];
24
23
  type Language = Database['public']['Tables']['languages']['Row'];
25
24
  import { useHotkeys } from '../../../../hooks/use-hotkeys';
26
25
 
@@ -35,28 +34,6 @@ interface PostFormProps {
35
34
  initialFeatureImageId?: string | null;
36
35
  }
37
36
 
38
- function formatDateTimeLocal(value: string | null | undefined) {
39
- if (!value) {
40
- return "";
41
- }
42
-
43
- try {
44
- const date = new Date(value);
45
- if (Number.isNaN(date.getTime())) {
46
- return "";
47
- }
48
-
49
- const year = date.getFullYear();
50
- const month = (date.getMonth() + 1).toString().padStart(2, '0');
51
- const day = date.getDate().toString().padStart(2, '0');
52
- const hours = date.getHours().toString().padStart(2, '0');
53
- const minutes = date.getMinutes().toString().padStart(2, '0');
54
- return `${year}-${month}-${day}T${hours}:${minutes}`;
55
- } catch {
56
- return "";
57
- }
58
- }
59
-
60
37
  export default function PostForm({
61
38
  post,
62
39
  formAction,
@@ -77,12 +54,8 @@ export default function PostForm({
77
54
  const [languageId, setLanguageId] = useState<string>(
78
55
  post?.language_id?.toString() || ""
79
56
  );
80
- const [status, setStatus] = useState<PageStatus>(post?.status || "draft");
81
57
  const [excerpt, setExcerpt] = useState(post?.excerpt || "");
82
58
  const [subtitle, setSubtitle] = useState(post?.subtitle || "");
83
- const [publishedAt, setPublishedAt] = useState<string>(() =>
84
- formatDateTimeLocal(post?.published_at)
85
- );
86
59
  const [metaTitle, setMetaTitle] = useState(post?.meta_title || "");
87
60
  const [metaDescription, setMetaDescription] = useState(
88
61
  post?.meta_description || ""
@@ -124,10 +97,8 @@ export default function PostForm({
124
97
  setSlug(post.slug || "");
125
98
  setLabel(post.label || "");
126
99
  setLanguageId(post.language_id?.toString() || "");
127
- setStatus(post.status || "draft");
128
100
  setExcerpt(post.excerpt || "");
129
101
  setSubtitle(post.subtitle || "");
130
- setPublishedAt(formatDateTimeLocal(post.published_at));
131
102
  setMetaTitle(post.meta_title || "");
132
103
  setMetaDescription(post.meta_description || "");
133
104
  setCustomCanonical(post.custom_canonical || "");
@@ -141,9 +112,7 @@ export default function PostForm({
141
112
  post?.language_id,
142
113
  post?.meta_description,
143
114
  post?.meta_title,
144
- post?.published_at,
145
115
  post?.slug,
146
- post?.status,
147
116
  post?.subtitle,
148
117
  post?.title,
149
118
  post?.updated_at,
@@ -181,10 +150,8 @@ export default function PostForm({
181
150
  formData.append("slug", slug);
182
151
  formData.append("language_id", languageId);
183
152
  formData.append("label", label);
184
- formData.append("status", status);
185
153
  formData.append("excerpt", excerpt);
186
154
  formData.append("subtitle", subtitle);
187
- formData.append("published_at", publishedAt);
188
155
  formData.append("meta_title", metaTitle);
189
156
  formData.append("meta_description", metaDescription);
190
157
  formData.append("custom_canonical", customCanonical);
@@ -235,17 +202,13 @@ export default function PostForm({
235
202
  return;
236
203
  }
237
204
 
238
- const dbPublishedAt = formatDateTimeLocal(post?.published_at);
239
-
240
205
  const hasChanges =
241
206
  title !== (post?.title || "") ||
242
207
  slug !== (post?.slug || "") ||
243
208
  label !== (post?.label || "") ||
244
209
  languageId !== (post?.language_id?.toString() || "") ||
245
- status !== (post?.status || "draft") ||
246
210
  excerpt !== (post?.excerpt || "") ||
247
211
  subtitle !== (post?.subtitle || "") ||
248
- publishedAt !== dbPublishedAt ||
249
212
  metaTitle !== (post?.meta_title || "") ||
250
213
  metaDescription !== (post?.meta_description || "") ||
251
214
  customCanonical !== (post?.custom_canonical || "") ||
@@ -263,10 +226,8 @@ export default function PostForm({
263
226
  slug,
264
227
  label,
265
228
  languageId,
266
- status,
267
229
  excerpt,
268
230
  subtitle,
269
- publishedAt,
270
231
  metaTitle,
271
232
  metaDescription,
272
233
  customCanonical,
@@ -345,35 +306,17 @@ export default function PostForm({
345
306
  )}
346
307
  </div>
347
308
 
348
- {/* Status */}
349
- <div className="md:col-span-2 flex flex-col gap-1">
350
- <Label htmlFor="status" className="text-xs font-medium">Status</Label>
351
- <Select name="status" value={status} onValueChange={(value) => setStatus(value as PageStatus)} required>
352
- <SelectTrigger className="h-9"><SelectValue placeholder="Select status" /></SelectTrigger>
353
- <SelectContent>
354
- <SelectItem value="draft">Draft</SelectItem>
355
- <SelectItem value="published">Published</SelectItem>
356
- <SelectItem value="archived">Archived</SelectItem>
357
- </SelectContent>
358
- </Select>
359
- </div>
360
309
  </div>
361
310
 
362
- {/* Row 2: Label + Published At */}
311
+ {/* Row 2: Label. Visibility and the go-live date live in the top bar — this
312
+ form autosaves into the Live Draft, and publishing must never be a side
313
+ effect of editing post settings. */}
363
314
  <div className="grid grid-cols-1 md:grid-cols-12 gap-4">
364
- {/* Label */}
365
315
  <div className="md:col-span-6 flex flex-col gap-1">
366
316
  <Label htmlFor="label" className="text-xs font-medium">Label</Label>
367
317
  <Input id="label" name="label" value={label} onChange={(e) => setLabel(e.target.value)} className="h-9" placeholder="e.g. Architecture" />
368
318
  <p className="text-[10px] text-muted-foreground leading-tight">Short pill text shown on the article hero and post cards.</p>
369
319
  </div>
370
-
371
- {/* Published At */}
372
- <div className="md:col-span-6 flex flex-col gap-1">
373
- <Label htmlFor="published_at" className="text-xs font-medium">Published At (Optional)</Label>
374
- <Input id="published_at" name="published_at" type="datetime-local" value={publishedAt} onChange={(e) => setPublishedAt(e.target.value)} className="h-9" />
375
- <p className="text-[10px] text-muted-foreground leading-tight">Leave blank to publish immediately when status is &apos;Published&apos;.</p>
376
- </div>
377
320
  </div>
378
321
 
379
322
  {/* Row 3: Excerpt + Subtitle */}
@@ -30,6 +30,7 @@ type Post = Database['public']['Tables']['posts']['Row'] & { feature_image_url?:
30
30
  import LanguageFilterSelect from "../components/LanguageFilterSelect";
31
31
  import DeletePostButtonClient from "./components/DeletePostButtonClient"; // Import the new client component
32
32
  import { ContentTransferControls } from "../import-export/ContentTransferControls";
33
+ import VisibilityBadge from "../components/VisibilityBadge";
33
34
 
34
35
  async function getPostsWithDetails(filterLanguageId?: number): Promise<{ post: Post; languageCode: string }[]> {
35
36
  const supabase = createClient();
@@ -154,19 +155,11 @@ export default async function CmsPostsListPage(props: CmsPostsListPageProps) {
154
155
  </Link>
155
156
  </TableCell>
156
157
  <TableCell>
157
- <Badge
158
- variant={
159
- post.status === "published" ? "default" :
160
- post.status === "draft" ? "secondary" : "destructive"
161
- }
162
- className={
163
- post.status === "published" ? "bg-green-100 text-green-700 dark:bg-green-700/30 dark:text-green-300 dark:border-green-700/50" :
164
- post.status === "draft" ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-700/30 dark:text-yellow-300 dark:border-yellow-700/50" :
165
- "bg-slate-100 text-slate-700 dark:bg-slate-700/30 dark:text-slate-300 dark:border-slate-600"
166
- }
167
- >
168
- {post.status.charAt(0).toUpperCase() + post.status.slice(1)}
169
- </Badge>
158
+ <VisibilityBadge
159
+ type="post"
160
+ status={post.status}
161
+ publishedAt={post.published_at}
162
+ />
170
163
  </TableCell>
171
164
  <TableCell><Badge variant="outline" className="dark:border-slate-600">{languageCode}</Badge></TableCell>
172
165
  <TableCell className="text-muted-foreground text-xs hidden md:table-cell">/article/{post.slug}</TableCell>