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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
- package/templates/nextblock-template/app/actions/postActions.ts +3 -0
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +736 -117
- package/templates/nextblock-template/app/api/view/route.ts +114 -0
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
- package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
- package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
- package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
- package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
- package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
- package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
- package/templates/nextblock-template/app/layout.tsx +49 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
- package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
- package/templates/nextblock-template/app/page.tsx +6 -3
- package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
- package/templates/nextblock-template/app/providers.tsx +16 -4
- package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
- package/templates/nextblock-template/components/theme-icon.tsx +78 -0
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
- package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
- package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
- package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
- package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
- package/templates/nextblock-template/lib/search/server.ts +3 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +102 -87
- package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
- 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
|
-
|
|
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)
|
|
54
|
-
return encodedRedirect("error", "/cms/pages/new", "Missing required fields: title, slug,
|
|
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)
|
|
142
|
-
return { error: "Missing required fields: title, slug,
|
|
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.
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
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<{
|
|
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
|
-
|
|
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
|
|
93
|
-
//
|
|
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={
|
|
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.
|
|
18
|
-
*
|
|
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
|
-
|
|
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)
|
|
89
|
-
return encodedRedirect("error", "/cms/posts/new", "Missing required fields: title, slug,
|
|
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:
|
|
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)
|
|
215
|
-
return { error: "Missing required fields: title, slug,
|
|
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
|
|
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 'Published'.</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
|
-
<
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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>
|