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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
- package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
- package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
- package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
- package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
- package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
- package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
- package/templates/nextblock-template/app/lib/homepage.ts +36 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
- package/templates/nextblock-template/app/page.tsx +55 -12
- package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
- package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
- package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
- package/templates/nextblock-template/lib/search/server.ts +11 -1
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
- 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
|
-
|
|
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(
|
|
929
|
+
.some(
|
|
930
|
+
(nextActivity) =>
|
|
931
|
+
nextActivity.name === activity.name &&
|
|
932
|
+
nextActivity.status === "success" &&
|
|
933
|
+
!toolOutputIsNotice(nextActivity.output)
|
|
934
|
+
);
|
|
914
935
|
}),
|
|
915
|
-
[cancelledConfirmationKeys,
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 {
|
|
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(
|
|
22
|
+
function revalidatePublicPageSlug(
|
|
23
|
+
slug: string | null | undefined,
|
|
24
|
+
isHomepage = false
|
|
25
|
+
) {
|
|
22
26
|
if (!slug) return;
|
|
23
27
|
|
|
24
28
|
revalidatePath(`/${slug}`);
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|