create-nextblock 0.12.16 → 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 (45) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/actions/interactions.ts +27 -4
  3. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
  5. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
  6. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
  7. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
  8. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
  9. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
  10. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
  11. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
  12. package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +40 -2
  13. package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
  14. package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
  15. package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
  16. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
  17. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
  18. package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
  19. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
  20. package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
  21. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
  22. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
  23. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
  24. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
  25. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +20 -1
  26. package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
  27. package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
  28. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
  29. package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
  30. package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
  31. package/templates/nextblock-template/app/lib/homepage.ts +36 -0
  32. package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
  33. package/templates/nextblock-template/app/page.tsx +55 -12
  34. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
  35. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
  36. package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
  37. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
  38. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
  39. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
  40. package/templates/nextblock-template/lib/search/server.ts +11 -1
  41. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
  42. package/templates/nextblock-template/next-env.d.ts +1 -1
  43. package/templates/nextblock-template/package.json +1 -1
  44. package/templates/nextblock-template/proxy.ts +5 -0
  45. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -4,8 +4,17 @@ import React, { useState, useTransition } from "react";
4
4
  import { Button } from "@nextblock-cms/ui";
5
5
  import { Avatar, AvatarFallback, AvatarImage } from "@nextblock-cms/ui";
6
6
  import { Badge } from "@nextblock-cms/ui";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ } from "@nextblock-cms/ui";
7
15
  import { updateInteractionStatus, saveNotificationEmails } from "../../actions/interactions";
8
16
  import { cn } from "@nextblock-cms/utils";
17
+ import EmailRecipientsInput, { type EmailRecipientsInputHandle } from "./EmailRecipientsInput";
9
18
  import {
10
19
  MessageSquare,
11
20
  Check,
@@ -31,44 +40,100 @@ export default function InteractionsModerationClient({
31
40
  }: InteractionsModerationClientProps) {
32
41
  const [interactions, setInteractions] = useState<any[]>(initialInteractions);
33
42
 
34
- // Notification settings states
35
- const [emailsInput, setEmailsInput] = useState("");
43
+ // Notification settings states. `emails` is the working (editable) list; `savedEmails`
44
+ // mirrors what is persisted, so cancelling can revert unsaved edits.
45
+ const [emails, setEmails] = useState<string[]>([]);
46
+ const [savedEmails, setSavedEmails] = useState<string[]>([]);
36
47
  const [isSettingsOpen, setIsSettingsOpen] = useState(false);
37
48
  const [savingEmails, setSavingEmails] = useState(false);
38
49
  const [settingsError, setSettingsError] = useState<string | null>(null);
39
50
  const [settingsSuccess, setSettingsSuccess] = useState<string | null>(null);
51
+ const recipientsRef = React.useRef<EmailRecipientsInputHandle>(null);
52
+ const autoCloseTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
53
+
54
+ const clearAutoClose = () => {
55
+ if (autoCloseTimer.current) {
56
+ clearTimeout(autoCloseTimer.current);
57
+ autoCloseTimer.current = null;
58
+ }
59
+ };
60
+
61
+ // Cancel any pending success auto-close timer on unmount.
62
+ React.useEffect(() => clearAutoClose, []);
63
+
64
+ const parseEmails = (raw: string | undefined | null): string[] =>
65
+ (raw || "")
66
+ .split(",")
67
+ .map((e) => e.trim())
68
+ .filter(Boolean);
40
69
 
41
70
  React.useEffect(() => {
42
71
  if (isAdmin) {
43
72
  import("../../actions/interactions").then(({ getNotificationEmails }) => {
44
73
  getNotificationEmails().then((res) => {
45
74
  if (res.success && res.emails) {
46
- setEmailsInput(res.emails);
75
+ const list = parseEmails(res.emails);
76
+ setEmails(list);
77
+ setSavedEmails(list);
47
78
  }
48
79
  });
49
80
  });
50
81
  }
51
82
  }, [isAdmin]);
52
83
 
84
+ const openSettings = () => {
85
+ // Start from the last-saved list with a clean slate — discards any edits abandoned
86
+ // in a previous open, and clears stale success/error banners.
87
+ clearAutoClose();
88
+ setEmails(savedEmails);
89
+ setSettingsError(null);
90
+ setSettingsSuccess(null);
91
+ setIsSettingsOpen(true);
92
+ };
93
+
94
+ const closeSettings = () => {
95
+ if (savingEmails) return; // don't dismiss mid-save
96
+ clearAutoClose();
97
+ setEmails(savedEmails); // revert unsaved edits
98
+ setSettingsError(null);
99
+ setSettingsSuccess(null);
100
+ setIsSettingsOpen(false);
101
+ };
102
+
53
103
  const handleSaveEmails = async () => {
104
+ // Commit any address still typed in the input. `null` means it's invalid — the
105
+ // input already shows the inline error, so don't proceed to save.
106
+ const finalList = recipientsRef.current?.flush();
107
+ if (finalList === null) return;
108
+ const list = finalList ?? emails;
109
+
110
+ clearAutoClose(); // supersede any prior success timer
54
111
  setSavingEmails(true);
55
112
  setSettingsError(null);
56
113
  setSettingsSuccess(null);
57
114
 
58
- const res = await saveNotificationEmails(emailsInput);
59
- setSavingEmails(false);
60
-
61
- if (res.error) {
62
- setSettingsError(res.error);
63
- } else {
64
- setSettingsSuccess("Notification settings saved successfully.");
65
- if (res.emails) {
66
- setEmailsInput(res.emails);
115
+ try {
116
+ const res = await saveNotificationEmails(list.join(", "));
117
+ if (res.error) {
118
+ setSettingsError(res.error);
119
+ } else {
120
+ const saved = parseEmails(res.emails);
121
+ setEmails(saved);
122
+ setSavedEmails(saved);
123
+ setSettingsSuccess("Notification settings saved successfully.");
124
+ // Auto-close after a beat. Tracked so a reopen/second save cancels it.
125
+ autoCloseTimer.current = setTimeout(() => {
126
+ autoCloseTimer.current = null;
127
+ setSettingsSuccess(null);
128
+ setIsSettingsOpen(false);
129
+ }, 1500);
67
130
  }
68
- setTimeout(() => {
69
- setSettingsSuccess(null);
70
- setIsSettingsOpen(false);
71
- }, 1500);
131
+ } catch {
132
+ // The server action rejected (offline, 500, auth outage). Surface it — the
133
+ // `finally` re-enables the close paths so the dialog can never get stuck.
134
+ setSettingsError("Couldn't save settings. Check your connection and try again.");
135
+ } finally {
136
+ setSavingEmails(false);
72
137
  }
73
138
  };
74
139
  const [filterType, setFilterType] = useState<"all" | "review" | "comment">("all");
@@ -141,7 +206,7 @@ export default function InteractionsModerationClient({
141
206
  {isAdmin && (
142
207
  <div className="flex items-center gap-2 self-start md:self-auto">
143
208
  <Button
144
- onClick={() => setIsSettingsOpen(true)}
209
+ onClick={openSettings}
145
210
  className="flex items-center gap-2 text-xs"
146
211
  variant="outline"
147
212
  >
@@ -347,62 +412,64 @@ export default function InteractionsModerationClient({
347
412
  )}
348
413
  </div>
349
414
 
350
- {/* Settings Modal */}
351
- {isSettingsOpen && (
352
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/45 backdrop-blur-sm animate-in fade-in duration-200">
353
- <div className="bg-background border border-border rounded-2xl max-w-md w-full p-6 shadow-xl space-y-4 animate-in zoom-in-95 duration-200">
354
- <div className="flex items-center justify-between">
355
- <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
356
- <Mail className="h-5 w-5 text-primary" />
357
- Notification Settings
358
- </h3>
359
- <button
360
- onClick={() => setIsSettingsOpen(false)}
361
- className="text-muted-foreground hover:text-foreground transition-colors"
362
- >
363
- <X className="h-5 w-5" />
364
- </button>
365
- </div>
366
- <p className="text-sm text-muted-foreground text-left">
415
+ {/* Settings Modal — Radix Dialog handles focus trap/restore, Escape, and overlay
416
+ click. onOpenChange fires for every close path; closeSettings() blocks dismissal
417
+ mid-save and reverts unsaved edits. */}
418
+ <Dialog
419
+ open={isSettingsOpen}
420
+ onOpenChange={(open) => {
421
+ if (!open) closeSettings();
422
+ else setIsSettingsOpen(true);
423
+ }}
424
+ >
425
+ <DialogContent
426
+ className="max-w-md rounded-2xl space-y-4"
427
+ onInteractOutside={(e) => {
428
+ if (savingEmails) e.preventDefault();
429
+ }}
430
+ onEscapeKeyDown={(e) => {
431
+ if (savingEmails) e.preventDefault();
432
+ }}
433
+ >
434
+ <DialogHeader>
435
+ <DialogTitle className="flex items-center gap-2">
436
+ <Mail className="h-5 w-5 text-primary" />
437
+ Notification Settings
438
+ </DialogTitle>
439
+ <DialogDescription>
367
440
  Configure which email addresses receive notification alerts when new pending reviews or comments are submitted.
368
- </p>
369
- <div className="space-y-1.5 text-left">
370
- <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">
371
- Email Recipients
372
- </label>
373
- <textarea
374
- value={emailsInput}
375
- onChange={(e) => setEmailsInput(e.target.value)}
376
- placeholder="admin@example.com, moderator@example.com"
377
- className="w-full min-h-[80px] bg-background border border-border rounded-xl px-3 py-2 text-sm text-foreground focus:ring-1 focus:ring-primary focus:outline-none"
378
- />
379
- <span className="text-[10px] text-muted-foreground">
380
- Enter a comma-separated list of email addresses.
381
- </span>
382
- </div>
383
-
384
- {settingsError && <div className="text-xs font-medium text-destructive text-left">{settingsError}</div>}
385
- {settingsSuccess && <div className="text-xs font-medium text-emerald-600 text-left">{settingsSuccess}</div>}
386
-
387
- <div className="flex justify-end gap-3 pt-2">
388
- <Button
389
- variant="ghost"
390
- onClick={() => setIsSettingsOpen(false)}
391
- disabled={savingEmails}
392
- >
393
- Cancel
394
- </Button>
395
- <Button
396
- onClick={handleSaveEmails}
397
- disabled={savingEmails}
398
- className="min-w-[100px]"
399
- >
400
- {savingEmails ? "Saving..." : "Save Settings"}
401
- </Button>
402
- </div>
441
+ </DialogDescription>
442
+ </DialogHeader>
443
+
444
+ <div className="space-y-1.5 text-left">
445
+ <label
446
+ htmlFor="notification-emails-input"
447
+ className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block"
448
+ >
449
+ Email Recipients
450
+ </label>
451
+ <EmailRecipientsInput
452
+ ref={recipientsRef}
453
+ inputId="notification-emails-input"
454
+ value={emails}
455
+ onChange={setEmails}
456
+ disabled={savingEmails}
457
+ />
403
458
  </div>
404
- </div>
405
- )}
459
+
460
+ {settingsError && <div role="alert" className="text-xs font-medium text-destructive text-left">{settingsError}</div>}
461
+ {settingsSuccess && <div className="text-xs font-medium text-emerald-600 text-left">{settingsSuccess}</div>}
462
+
463
+ <DialogFooter className="gap-3 sm:gap-2">
464
+ <Button variant="ghost" onClick={closeSettings} disabled={savingEmails}>
465
+ Cancel
466
+ </Button>
467
+ <Button onClick={handleSaveEmails} disabled={savingEmails} className="min-w-[100px]">
468
+ {savingEmails ? "Saving..." : "Save Settings"}
469
+ </Button>
470
+ </DialogFooter>
471
+ </DialogContent>
472
+ </Dialog>
406
473
  </div>
407
474
  );
408
475
  }
@@ -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}