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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/actions/interactions.ts +27 -4
- 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/custom-blocks/components/BlockComposer.tsx +40 -2
- package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
- 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/cms/users/[id]/edit/page.tsx +20 -1
- package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
- package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
- package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
- package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
- 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
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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={
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
</
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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 {
|
|
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}
|