create-nextblock 0.13.1 → 0.13.2

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 (34) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
  3. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
  4. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
  5. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
  6. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
  7. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
  8. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
  9. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
  10. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
  11. package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
  12. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
  13. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
  14. package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
  15. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
  16. package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
  17. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
  18. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
  19. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
  20. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
  21. package/templates/nextblock-template/app/lib/homepage.ts +36 -0
  22. package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
  23. package/templates/nextblock-template/app/page.tsx +55 -12
  24. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
  25. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
  26. package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
  27. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
  28. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
  29. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
  30. package/templates/nextblock-template/lib/search/server.ts +11 -1
  31. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
  32. package/templates/nextblock-template/next-env.d.ts +1 -1
  33. package/templates/nextblock-template/package.json +1 -1
  34. package/templates/nextblock-template/proxy.ts +5 -0
@@ -84,6 +84,9 @@ type CortexAgentStreamEvent =
84
84
  message: string;
85
85
  type: "error";
86
86
  }
87
+ | {
88
+ type: "status";
89
+ }
87
90
  | {
88
91
  type: "finish";
89
92
  };
@@ -92,7 +95,10 @@ const LEGACY_STORAGE_KEY = "nextblock-cortex-global-agent-chat";
92
95
  const THREADS_STORAGE_KEY = "nextblock-cortex-global-agent-chat-threads";
93
96
  const MAX_STORED_MESSAGES = 40;
94
97
  const MAX_STORED_THREADS = 20;
95
- const REQUEST_TIMEOUT_MS = 90000;
98
+ // Idle timeout: the request is aborted only after this many ms with NO stream
99
+ // activity, so a long multi-section build that keeps streaming tool/text events
100
+ // is not killed at a fixed wall-clock deadline.
101
+ const IDLE_TIMEOUT_MS = 90000;
96
102
  const CORTEX_AI_SETTINGS_CHANGED_EVENT = "nextblock:cortex-ai-settings-changed";
97
103
  const MUTATING_TOOL_NAMES = new Set([
98
104
  "create_cms_page",
@@ -105,6 +111,9 @@ const MUTATING_TOOL_NAMES = new Set([
105
111
  "execute_database_mutation",
106
112
  "execute_cms_action_plan",
107
113
  "insert_content_block",
114
+ "rewrite_page_draft",
115
+ "set_content_images",
116
+ "translate_page",
108
117
  "update_cms_item_field",
109
118
  "update_current_cms_fields",
110
119
  "update_content_block",
@@ -123,6 +132,22 @@ const TOOL_COPY: Record<string, { done: string; running: string }> = {
123
132
  done: "Documentation searched",
124
133
  running: "Searching documentation...",
125
134
  },
135
+ fetch_url_content: {
136
+ done: "Website content read",
137
+ running: "Reading the website...",
138
+ },
139
+ rewrite_page_draft: {
140
+ done: "Draft rewrite staged",
141
+ running: "Staging a live draft...",
142
+ },
143
+ translate_page: {
144
+ done: "Translation created",
145
+ running: "Creating the translation...",
146
+ },
147
+ set_content_images: {
148
+ done: "Images updated",
149
+ running: "Setting images...",
150
+ },
126
151
  create_cms_page: {
127
152
  done: "Page created",
128
153
  running: "Preparing page...",
@@ -875,23 +900,9 @@ export function CortexGlobalAgentChat() {
875
900
  const canSubmit = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
876
901
  const fallbackPageContext = useMemo(() => buildFallbackPageContext(pathname), [pathname]);
877
902
  const pageContext = cortexAiPageContext?.pageContext ?? fallbackPageContext;
878
- const hasSuccessfulMutationActivity = useMemo(
879
- () =>
880
- toolActivities.some(
881
- (activity) =>
882
- activity.status === "success" &&
883
- isMutatingToolName(activity.name) &&
884
- toolOutputExecutedMutation(activity.output)
885
- ),
886
- [toolActivities]
887
- );
888
903
  const visibleToolActivities = useMemo(
889
904
  () =>
890
905
  toolActivities.filter((activity, index) => {
891
- if (hasSuccessfulMutationActivity && activity.status === "error") {
892
- return false;
893
- }
894
-
895
906
  if (activity.status !== "error") {
896
907
  const confirmationKey = getConfirmationKey(activity);
897
908
 
@@ -908,11 +919,21 @@ export function CortexGlobalAgentChat() {
908
919
  return true;
909
920
  }
910
921
 
922
+ // Keep genuine failures visible. Only hide an error when a later retry
923
+ // of the SAME tool succeeded (a transient error that self-recovered);
924
+ // do not hide it just because some other, unrelated tool later
925
+ // succeeded, or a multi-section build would look fully successful even
926
+ // when individual sections failed.
911
927
  return !toolActivities
912
928
  .slice(index + 1)
913
- .some((nextActivity) => nextActivity.status === "success" && !toolOutputIsNotice(nextActivity.output));
929
+ .some(
930
+ (nextActivity) =>
931
+ nextActivity.name === activity.name &&
932
+ nextActivity.status === "success" &&
933
+ !toolOutputIsNotice(nextActivity.output)
934
+ );
914
935
  }),
915
- [cancelledConfirmationKeys, hasSuccessfulMutationActivity, toolActivities]
936
+ [cancelledConfirmationKeys, toolActivities]
916
937
  );
917
938
 
918
939
  const updateThreadMessages = (
@@ -1133,10 +1154,17 @@ export function CortexGlobalAgentChat() {
1133
1154
  }));
1134
1155
  const abortController = new AbortController();
1135
1156
  let timedOut = false;
1136
- const timeoutId = window.setTimeout(() => {
1137
- timedOut = true;
1138
- abortController.abort();
1139
- }, REQUEST_TIMEOUT_MS);
1157
+ let idleTimeoutId: number | undefined;
1158
+ const armIdleTimeout = () => {
1159
+ if (idleTimeoutId !== undefined) {
1160
+ window.clearTimeout(idleTimeoutId);
1161
+ }
1162
+ idleTimeoutId = window.setTimeout(() => {
1163
+ timedOut = true;
1164
+ abortController.abort();
1165
+ }, IDLE_TIMEOUT_MS);
1166
+ };
1167
+ armIdleTimeout();
1140
1168
 
1141
1169
  if (!threadId) {
1142
1170
  const thread = createChatThread([userMessage, assistantMessage]);
@@ -1206,6 +1234,9 @@ export function CortexGlobalAgentChat() {
1206
1234
  break;
1207
1235
  }
1208
1236
 
1237
+ // Reset the idle timer on every chunk so an actively-streaming build
1238
+ // (many tool + text events) is never aborted mid-flight.
1239
+ armIdleTimeout();
1209
1240
  buffer += decoder.decode(value, { stream: true });
1210
1241
  const frames = buffer.split("\n\n");
1211
1242
  buffer = frames.pop() || "";
@@ -1282,7 +1313,9 @@ export function CortexGlobalAgentChat() {
1282
1313
  )
1283
1314
  );
1284
1315
  } finally {
1285
- window.clearTimeout(timeoutId);
1316
+ if (idleTimeoutId !== undefined) {
1317
+ window.clearTimeout(idleTimeoutId);
1318
+ }
1286
1319
  if (shouldRefreshAfterMutation && typeof window !== "undefined") {
1287
1320
  // Let client-rendered lists (e.g. the custom blocks library) refetch even
1288
1321
  // though router.refresh() does not re-run their mount-time data fetch.
@@ -1465,6 +1498,13 @@ export function CortexGlobalAgentChat() {
1465
1498
  ))}
1466
1499
  </div>
1467
1500
  )}
1501
+
1502
+ {isStreaming && (
1503
+ <div className="flex items-center gap-2 px-1 text-xs text-slate-500 dark:text-slate-400">
1504
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
1505
+ <span>Cortex is working… building a full page can take a little while.</span>
1506
+ </div>
1507
+ )}
1468
1508
  </div>
1469
1509
 
1470
1510
  {streamError && (
@@ -0,0 +1,289 @@
1
+ // app/cms/media/import-external-image.ts
2
+ "use server";
3
+
4
+ import "server-only";
5
+
6
+ import sharp from "sharp";
7
+ import { PutObjectCommand } from "@aws-sdk/client-s3";
8
+
9
+ import { createClient } from "@nextblock-cms/db/server";
10
+ import { recordMediaUpload } from "@nextblock-cms/db";
11
+ import { getS3Client } from "@nextblock-cms/utils/server";
12
+
13
+ import { getStorageBackend, getStorageBucket } from "../../../lib/storage/provider";
14
+ import { supabaseUploadObject } from "../../../lib/storage/supabase-storage";
15
+ import { resolveMediaUrl } from "../../../lib/media/resolveMediaUrl";
16
+
17
+ const MAX_IMPORT_BYTES = 15 * 1024 * 1024; // 15MB
18
+ const IMPORT_TIMEOUT_MS = 15000;
19
+
20
+ type ImportExternalImageResult =
21
+ | {
22
+ success: true;
23
+ media: {
24
+ id: string;
25
+ object_key: string;
26
+ width: number;
27
+ height: number;
28
+ url: string;
29
+ blur_data_url: string | null;
30
+ alt_text: string;
31
+ };
32
+ }
33
+ | { error: string };
34
+
35
+ /**
36
+ * Reject local/loopback/private/link-local hosts and cloud metadata endpoints so an
37
+ * admin-supplied URL cannot be used to probe internal infrastructure (SSRF).
38
+ */
39
+ function isBlockedImportHost(hostname: string): boolean {
40
+ const host = hostname.trim().toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
41
+
42
+ if (
43
+ !host ||
44
+ host === "localhost" ||
45
+ host.endsWith(".localhost") ||
46
+ host.endsWith(".local") ||
47
+ host.endsWith(".internal") ||
48
+ host === "metadata.google.internal"
49
+ ) {
50
+ return true;
51
+ }
52
+
53
+ if (host === "0.0.0.0" || host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) {
54
+ return true;
55
+ }
56
+
57
+ const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
58
+
59
+ if (ipv4) {
60
+ const a = Number(ipv4[1]);
61
+ const b = Number(ipv4[2]);
62
+
63
+ if (a === 10 || a === 127 || a === 0 || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)) {
64
+ return true;
65
+ }
66
+ }
67
+
68
+ return false;
69
+ }
70
+
71
+ function extensionForImage(contentType: string, sharpFormat?: string): string {
72
+ const format = (sharpFormat || "").toLowerCase();
73
+ const byFormat: Record<string, string> = { jpeg: "jpg", jpg: "jpg", png: "png", webp: "webp", avif: "avif", gif: "gif", svg: "svg" };
74
+ if (byFormat[format]) return byFormat[format];
75
+
76
+ const ct = contentType.toLowerCase();
77
+ if (ct.includes("jpeg") || ct.includes("jpg")) return "jpg";
78
+ if (ct.includes("png")) return "png";
79
+ if (ct.includes("webp")) return "webp";
80
+ if (ct.includes("avif")) return "avif";
81
+ if (ct.includes("gif")) return "gif";
82
+ if (ct.includes("svg")) return "svg";
83
+ return "jpg";
84
+ }
85
+
86
+ function slugifyFileBase(value: string): string {
87
+ const base = (value || "image")
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9]+/g, "-")
90
+ .replace(/^-+|-+$/g, "")
91
+ .slice(0, 40);
92
+ return base || "image";
93
+ }
94
+
95
+ /**
96
+ * Download an external image (e.g. an AI-inserted stock photo) and persist it into the
97
+ * NextBlock media library (R2 or Supabase Storage) so it becomes a permanent, optimized
98
+ * asset the page no longer hotlinks. ADMIN/WRITER only.
99
+ */
100
+ export async function importExternalImageToMedia(input: {
101
+ url: string;
102
+ altText?: string;
103
+ fileName?: string;
104
+ }): Promise<ImportExternalImageResult> {
105
+ const supabase = createClient();
106
+ const {
107
+ data: { user },
108
+ } = await supabase.auth.getUser();
109
+
110
+ if (!user) {
111
+ return { error: "You must be signed in to import an image." };
112
+ }
113
+
114
+ const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
115
+
116
+ if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
117
+ return { error: "You do not have permission to import media." };
118
+ }
119
+
120
+ let target: URL;
121
+
122
+ try {
123
+ target = new URL(input.url);
124
+ } catch {
125
+ return { error: "That is not a valid URL." };
126
+ }
127
+
128
+ if (target.protocol !== "https:" && target.protocol !== "http:") {
129
+ return { error: "Only http/https image URLs can be imported." };
130
+ }
131
+
132
+ if (isBlockedImportHost(target.hostname)) {
133
+ return { error: "Refusing to fetch a local, private, or internal address." };
134
+ }
135
+
136
+ // Unsplash API Guidelines require photos to stay hotlinked to their original
137
+ // image URL — re-hosting them into our own storage is not permitted. Pexels'
138
+ // license does allow downloading/hosting, so only Unsplash is blocked here.
139
+ const importHost = target.hostname.toLowerCase();
140
+ if (importHost === "unsplash.com" || importHost.endsWith(".unsplash.com")) {
141
+ return {
142
+ error:
143
+ "Unsplash requires photos to stay hotlinked, so they can't be saved into your media library. Keep it as an external image (it still displays fine), or use a Pexels photo or your own upload if you need a stored asset.",
144
+ };
145
+ }
146
+
147
+ const controller = new AbortController();
148
+ const timeoutId = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_MS);
149
+ let buffer: Buffer;
150
+ let contentType: string;
151
+
152
+ try {
153
+ const response = await fetch(target.toString(), {
154
+ headers: { accept: "image/*" },
155
+ redirect: "follow",
156
+ signal: controller.signal,
157
+ });
158
+
159
+ if (!response.ok) {
160
+ return { error: `The image URL responded with HTTP ${response.status}.` };
161
+ }
162
+
163
+ if (isBlockedImportHost(new URL(response.url || target.toString()).hostname)) {
164
+ return { error: "The image URL redirected to a blocked internal address." };
165
+ }
166
+
167
+ contentType = response.headers.get("content-type") || "";
168
+
169
+ if (!/^image\//i.test(contentType)) {
170
+ return { error: `The URL is not an image (content-type: ${contentType || "unknown"}).` };
171
+ }
172
+
173
+ const arrayBuffer = await response.arrayBuffer();
174
+
175
+ if (arrayBuffer.byteLength > MAX_IMPORT_BYTES) {
176
+ return { error: "The image is too large to import (max 15MB)." };
177
+ }
178
+
179
+ buffer = Buffer.from(arrayBuffer);
180
+ } catch (error) {
181
+ const aborted = error instanceof Error && /abort/i.test(error.message);
182
+ return {
183
+ error: aborted
184
+ ? "Fetching the image timed out."
185
+ : `Failed to fetch the image: ${error instanceof Error ? error.message : String(error)}`,
186
+ };
187
+ } finally {
188
+ clearTimeout(timeoutId);
189
+ }
190
+
191
+ let width = 0;
192
+ let height = 0;
193
+ let blurDataUrl: string | null = null;
194
+ let sharpFormat: string | undefined;
195
+ const isSvg = /svg/i.test(contentType);
196
+
197
+ if (!isSvg) {
198
+ try {
199
+ const metadata = await sharp(buffer).metadata();
200
+ width = metadata.width || 0;
201
+ height = metadata.height || 0;
202
+ sharpFormat = metadata.format;
203
+
204
+ const blurBuffer = await sharp(buffer).resize(16, 16, { fit: "inside" }).webp({ quality: 40 }).toBuffer();
205
+ blurDataUrl = `data:image/webp;base64,${blurBuffer.toString("base64")}`;
206
+ } catch {
207
+ // Non-fatal: proceed without dimensions/blur (e.g. an unusual format sharp can't parse).
208
+ }
209
+ }
210
+
211
+ const extension = extensionForImage(contentType, sharpFormat);
212
+ const baseName = slugifyFileBase(
213
+ input.fileName || input.altText || target.hostname.replace(/^www\./, "").split(".")[0] || "image"
214
+ );
215
+ const random = Math.random().toString(36).slice(2, 8);
216
+ const objectKey = `uploads/${baseName}-${Date.now()}-${random}.${extension}`;
217
+ const resolvedContentType = contentType || `image/${extension}`;
218
+
219
+ try {
220
+ if (getStorageBackend() === "supabase") {
221
+ await supabaseUploadObject(objectKey, buffer, resolvedContentType);
222
+ } else {
223
+ const s3Client = await getS3Client();
224
+ const bucket = getStorageBucket();
225
+
226
+ if (!s3Client || !bucket) {
227
+ return { error: "File storage is not configured on this server." };
228
+ }
229
+
230
+ await s3Client.send(
231
+ new PutObjectCommand({
232
+ Body: buffer,
233
+ Bucket: bucket,
234
+ ContentType: resolvedContentType,
235
+ Key: objectKey,
236
+ Metadata: { "uploader-user-id": user.id },
237
+ })
238
+ );
239
+ }
240
+ } catch (error) {
241
+ return {
242
+ error: `Failed to upload the image to storage: ${error instanceof Error ? error.message : String(error)}`,
243
+ };
244
+ }
245
+
246
+ const publicUrl = resolveMediaUrl(objectKey) ?? "";
247
+ const fileName = `${baseName}.${extension}`;
248
+ const altText = (input.altText || baseName.replace(/-/g, " ")).trim();
249
+ const originalVariant = {
250
+ fileType: resolvedContentType,
251
+ height,
252
+ objectKey,
253
+ sizeBytes: buffer.byteLength,
254
+ url: publicUrl,
255
+ variantLabel: "original",
256
+ width,
257
+ };
258
+
259
+ const record = await recordMediaUpload(
260
+ {
261
+ blurDataUrl: blurDataUrl || undefined,
262
+ description: altText || undefined,
263
+ fileName,
264
+ originalImageDetails: originalVariant,
265
+ r2OriginalKey: objectKey,
266
+ r2Variants: [originalVariant],
267
+ },
268
+ true
269
+ );
270
+
271
+ if (!record || "error" in record) {
272
+ return { error: record && "error" in record ? record.error : "Failed to record the imported image." };
273
+ }
274
+
275
+ const media = record.data;
276
+
277
+ return {
278
+ media: {
279
+ alt_text: (media.description || altText || "").trim(),
280
+ blur_data_url: media.blur_data_url ?? blurDataUrl,
281
+ height: media.height || height,
282
+ id: media.id,
283
+ object_key: media.object_key,
284
+ url: publicUrl,
285
+ width: media.width || width,
286
+ },
287
+ success: true,
288
+ };
289
+ }
@@ -4,7 +4,9 @@ import Link from "next/link";
4
4
  import React from "react";
5
5
  import { Separator } from "@nextblock-cms/ui";
6
6
  import { Button } from "@nextblock-cms/ui";
7
- import { ArrowLeft, Eye, FilePenLine } from "lucide-react";
7
+ import { ViewLiveButton } from "@nextblock-cms/ui";
8
+ import { ArrowLeft, FilePenLine } from "lucide-react";
9
+ import { publishPage } from "../../actions";
8
10
  import PageForm from "../../components/PageForm";
9
11
  import BlockEditorArea from "../../../blocks/components/BlockEditorArea";
10
12
  import ContentLanguageSwitcher from "../../../components/ContentLanguageSwitcher";
@@ -31,6 +33,8 @@ interface EditPageClientProps {
31
33
  allSiteLanguages: Language[];
32
34
  updatePageAction: (formData: FormData) => Promise<{ error?: string } | void>;
33
35
  publicPageUrl: string;
36
+ isLive: boolean;
37
+ liveViewUrl: string;
34
38
  isDraftModeEnabled: boolean;
35
39
  initialFeatureImageUrl?: string | null;
36
40
  initialFeatureImageId?: string | null;
@@ -43,6 +47,8 @@ export default function EditPageClient({
43
47
  allSiteLanguages,
44
48
  updatePageAction,
45
49
  publicPageUrl,
50
+ isLive,
51
+ liveViewUrl,
46
52
  isDraftModeEnabled,
47
53
  initialFeatureImageUrl,
48
54
  initialFeatureImageId,
@@ -105,15 +111,12 @@ export default function EditPageClient({
105
111
  allSiteLanguages={allSiteLanguages}
106
112
  />
107
113
  )}
108
- <Button variant="outline" asChild>
109
- <Link
110
- href={publicPageUrl}
111
- target="_blank"
112
- rel="noopener noreferrer"
113
- >
114
- <Eye className="mr-2 h-4 w-4" /> View Live
115
- </Link>
116
- </Button>
114
+ <ViewLiveButton
115
+ href={liveViewUrl}
116
+ isLive={isLive}
117
+ label="page"
118
+ publishAction={() => publishPage(pageId)}
119
+ />
117
120
  <Button variant="secondary" asChild>
118
121
  <a
119
122
  href={draftModeUrl}
@@ -20,7 +20,7 @@ interface PageWithBlocks extends Page {
20
20
  translation_group_id: string;
21
21
  }
22
22
 
23
- async function getPageDataWithBlocks(id: number): Promise<{ page: PageWithBlocks; hasDraft: boolean } | null> {
23
+ async function getPageDataWithBlocks(id: number): Promise<{ page: PageWithBlocks; hasDraft: boolean; liveStatus: string; liveSlug: string } | null> {
24
24
  const supabase = createClient();
25
25
  const { data: pageData, error: pageError } = await supabase
26
26
  .from("pages")
@@ -71,7 +71,14 @@ async function getPageDataWithBlocks(id: number): Promise<{ page: PageWithBlocks
71
71
  };
72
72
  }
73
73
 
74
- return { page: pageWithBlocks, hasDraft };
74
+ return {
75
+ page: pageWithBlocks,
76
+ hasDraft,
77
+ // The LIVE row's status/slug (before the draft overlay above) — the "View
78
+ // Live" button must reflect what is actually published, not the draft.
79
+ liveStatus: pageData.status as string,
80
+ liveSlug: pageData.slug as string,
81
+ };
75
82
  }
76
83
 
77
84
 
@@ -90,13 +97,15 @@ export default async function EditPage(props: { params: Promise<{ id: string }>
90
97
 
91
98
  const pageDataResult = await getPageDataWithBlocks(pageId);
92
99
  if (!pageDataResult) return notFound();
93
- const { page: pageWithBlocks, hasDraft } = pageDataResult;
100
+ const { page: pageWithBlocks, hasDraft, liveStatus, liveSlug } = pageDataResult;
94
101
 
95
102
  const allSiteLanguages = await getActiveLanguagesServerSide();
96
103
 
97
104
  const draft = await draftMode();
98
105
  const updatePageWithId = updatePage.bind(null, pageId);
99
106
  const publicPageUrl = `/${pageWithBlocks.slug}`;
107
+ const isLive = liveStatus === "published";
108
+ const liveViewUrl = liveSlug === "home" ? "/" : `/${liveSlug}`;
100
109
  let initialFeatureImageUrl: string | null = null;
101
110
  let initialFeatureImageId: string | null = null;
102
111
 
@@ -123,6 +132,8 @@ export default async function EditPage(props: { params: Promise<{ id: string }>
123
132
  allSiteLanguages={allSiteLanguages}
124
133
  updatePageAction={updatePageWithId}
125
134
  publicPageUrl={publicPageUrl}
135
+ isLive={isLive}
136
+ liveViewUrl={liveViewUrl}
126
137
  isDraftModeEnabled={draft.isEnabled}
127
138
  initialFeatureImageUrl={initialFeatureImageUrl}
128
139
  initialFeatureImageId={initialFeatureImageId}
@@ -7,6 +7,7 @@ import { redirect } from "next/navigation";
7
7
  import type { Database } from "@nextblock-cms/db";
8
8
  import { v4 as uuidv4 } from 'uuid';
9
9
  import { getOrCreateContentDraft } from "../../../lib/visual-editing/draft-content";
10
+ import { getHomepageTranslationGroupId } from "../../lib/homepage";
10
11
 
11
12
  type PageStatus = Database['public']['Enums']['page_status'];
12
13
  import { encodedRedirect } from "@nextblock-cms/utils/server";
@@ -18,11 +19,16 @@ function getOptionalFeatureImageId(formData: FormData) {
18
19
  return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
19
20
  }
20
21
 
21
- function revalidatePublicPageSlug(slug: string | null | undefined) {
22
+ function revalidatePublicPageSlug(
23
+ slug: string | null | undefined,
24
+ isHomepage = false
25
+ ) {
22
26
  if (!slug) return;
23
27
 
24
28
  revalidatePath(`/${slug}`);
25
- if (slug === "home" || slug === "accueil") {
29
+ // Any language variation of the homepage is also served at "/", regardless of
30
+ // its slug — bust that cache too (the literal-slug check is a cheap fallback).
31
+ if (isHomepage || slug === "home" || slug === "accueil") {
26
32
  revalidatePath("/");
27
33
  }
28
34
  }
@@ -91,7 +97,11 @@ export async function createPage(formData: FormData) {
91
97
  }
92
98
 
93
99
  revalidatePath("/cms/pages");
94
- revalidatePublicPageSlug(newPage?.slug);
100
+ const createHomepageGroupId = await getHomepageTranslationGroupId(supabase);
101
+ revalidatePublicPageSlug(
102
+ newPage?.slug,
103
+ !!createHomepageGroupId && newPage?.translation_group_id === createHomepageGroupId
104
+ );
95
105
 
96
106
  if (newPage?.id) {
97
107
  redirect(`/cms/pages/${newPage.id}/edit?success=${encodeURIComponent("Page created successfully.")}`);
@@ -168,9 +178,12 @@ export async function updatePage(pageId: number, formData: FormData) {
168
178
  }
169
179
 
170
180
  revalidatePath("/cms/pages");
171
- revalidatePublicPageSlug(existingPage.slug);
181
+ const updateHomepageGroupId = await getHomepageTranslationGroupId(supabase);
182
+ const updateIsHomepage =
183
+ !!updateHomepageGroupId && existingPage.translation_group_id === updateHomepageGroupId;
184
+ revalidatePublicPageSlug(existingPage.slug, updateIsHomepage);
172
185
  if (rawFormData.slug && rawFormData.slug !== existingPage.slug) {
173
- revalidatePublicPageSlug(rawFormData.slug);
186
+ revalidatePublicPageSlug(rawFormData.slug, updateIsHomepage);
174
187
  }
175
188
 
176
189
  revalidatePath(pageEditPath);
@@ -178,6 +191,39 @@ export async function updatePage(pageId: number, formData: FormData) {
178
191
  }
179
192
 
180
193
 
194
+ /**
195
+ * 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
+ */
200
+ export async function publishPage(pageId: number): Promise<{ error?: string } | void> {
201
+ const supabase = createClient();
202
+ const { data: { user } } = await supabase.auth.getUser();
203
+ if (!user) return { error: "User not authenticated." };
204
+
205
+ const { data: page, error } = await supabase
206
+ .from("pages")
207
+ .update({ status: "published", updated_at: new Date().toISOString() })
208
+ .eq("id", pageId)
209
+ .select("slug, translation_group_id")
210
+ .single();
211
+
212
+ if (error || !page) {
213
+ return { error: error?.message || "Could not publish the page." };
214
+ }
215
+
216
+ revalidatePath("/cms/pages");
217
+ revalidatePath(`/cms/pages/${pageId}/edit`);
218
+ const homepageGroupId = await getHomepageTranslationGroupId(supabase);
219
+ revalidatePublicPageSlug(
220
+ page.slug,
221
+ !!homepageGroupId && page.translation_group_id === homepageGroupId
222
+ );
223
+
224
+ return {};
225
+ }
226
+
181
227
  export async function deletePage(pageId: number) {
182
228
  const supabase = createClient();
183
229
 
@@ -195,6 +241,13 @@ export async function deletePage(pageId: number) {
195
241
 
196
242
  const { translation_group_id } = page;
197
243
 
244
+ // Resolve whether this is the homepage BEFORE deleting the group rows — once
245
+ // the default-language "home" page is gone, the lookup would return null and a
246
+ // homepage with a non-literal slug wouldn't get "/" revalidated.
247
+ const deleteHomepageGroupId = await getHomepageTranslationGroupId(supabase);
248
+ const deleteIsHomepage =
249
+ !!deleteHomepageGroupId && translation_group_id === deleteHomepageGroupId;
250
+
198
251
  // 2. Find All Related Pages
199
252
  const { data: relatedPages, error: relatedPagesError } = await supabase
200
253
  .from("pages")
@@ -239,7 +292,7 @@ export async function deletePage(pageId: number) {
239
292
  revalidatePath("/cms/navigation");
240
293
  if (relatedPages) {
241
294
  relatedPages.forEach(p => {
242
- revalidatePublicPageSlug(p.slug);
295
+ revalidatePublicPageSlug(p.slug, deleteIsHomepage);
243
296
  });
244
297
  }
245
298