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
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// app/cms/blocks/components/BackgroundSelector.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import React, { useState, useEffect } from "react";
|
|
4
|
+
import React, { useState, useEffect, useTransition } from "react";
|
|
5
5
|
import Image from "next/image";
|
|
6
6
|
import { Label, Select, SelectTrigger, SelectContent, SelectItem, SelectValue, Button, Input, Checkbox } from "@nextblock-cms/ui";
|
|
7
7
|
import { CustomSelectWithInput, ColorPicker } from "@nextblock-cms/ui";
|
|
8
8
|
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
|
9
|
-
import { Trash } from "lucide-react";
|
|
9
|
+
import { Trash, DownloadCloud } from "lucide-react";
|
|
10
10
|
import type { Database } from "@nextblock-cms/db";
|
|
11
11
|
import { SectionBlockContent } from '../../../../lib/blocks/blockRegistry';
|
|
12
12
|
import MediaPickerDialog from "../../media/components/MediaPickerDialog";
|
|
13
13
|
import { resolveMediaUrl } from "../../../../lib/media/resolveMediaUrl";
|
|
14
|
+
import { importExternalImageToMedia } from "../../media/import-external-image";
|
|
14
15
|
|
|
15
16
|
type Media = Database["public"]["Tables"]["media"]["Row"];
|
|
16
17
|
|
|
@@ -26,6 +27,13 @@ export default function BackgroundSelector({ background, onChange }: BackgroundS
|
|
|
26
27
|
const [minHeight, setMinHeight] = useState(background?.min_height || "");
|
|
27
28
|
const [imagePosition, setImagePosition] = useState<string>(selectedImage?.position || "center");
|
|
28
29
|
const [overlayDirection, setOverlayDirection] = useState(selectedImage?.overlay?.gradient?.direction || "to bottom");
|
|
30
|
+
const [externalBgUrlInput, setExternalBgUrlInput] = useState("");
|
|
31
|
+
const [bgImportError, setBgImportError] = useState<string | null>(null);
|
|
32
|
+
const [isBgImporting, startBgImport] = useTransition();
|
|
33
|
+
const externalBgUrl =
|
|
34
|
+
selectedImage && typeof selectedImage.external_url === "string" && /^https?:\/\//i.test(selectedImage.external_url)
|
|
35
|
+
? selectedImage.external_url
|
|
36
|
+
: null;
|
|
29
37
|
|
|
30
38
|
useEffect(() => {
|
|
31
39
|
setMinHeight(background?.min_height || "");
|
|
@@ -93,6 +101,7 @@ export default function BackgroundSelector({ background, onChange }: BackgroundS
|
|
|
93
101
|
image: {
|
|
94
102
|
media_id: "",
|
|
95
103
|
object_key: "",
|
|
104
|
+
external_url: undefined,
|
|
96
105
|
size: "cover",
|
|
97
106
|
position: "center",
|
|
98
107
|
overlay: undefined,
|
|
@@ -100,6 +109,53 @@ export default function BackgroundSelector({ background, onChange }: BackgroundS
|
|
|
100
109
|
});
|
|
101
110
|
};
|
|
102
111
|
|
|
112
|
+
const handleUseExternalBgUrl = () => {
|
|
113
|
+
const url = externalBgUrlInput.trim();
|
|
114
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
115
|
+
setBgImportError("Enter a valid http(s) image URL.");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
setBgImportError(null);
|
|
119
|
+
onChange({
|
|
120
|
+
type: "image",
|
|
121
|
+
image: {
|
|
122
|
+
...selectedImage,
|
|
123
|
+
external_url: url,
|
|
124
|
+
media_id: undefined,
|
|
125
|
+
object_key: undefined,
|
|
126
|
+
size: selectedImage?.size || "cover",
|
|
127
|
+
position: selectedImage?.position || "center",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
setExternalBgUrlInput("");
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleImportBgToLibrary = () => {
|
|
134
|
+
if (!externalBgUrl) return;
|
|
135
|
+
setBgImportError(null);
|
|
136
|
+
startBgImport(async () => {
|
|
137
|
+
const result = await importExternalImageToMedia({ url: externalBgUrl, altText: selectedImage?.alt_text });
|
|
138
|
+
if ("error" in result) {
|
|
139
|
+
setBgImportError(result.error);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
onChange({
|
|
143
|
+
type: "image",
|
|
144
|
+
image: {
|
|
145
|
+
...selectedImage,
|
|
146
|
+
external_url: undefined,
|
|
147
|
+
media_id: result.media.id,
|
|
148
|
+
object_key: result.media.object_key,
|
|
149
|
+
width: result.media.width || undefined,
|
|
150
|
+
height: result.media.height || undefined,
|
|
151
|
+
blur_data_url: result.media.blur_data_url || undefined,
|
|
152
|
+
size: selectedImage?.size || "cover",
|
|
153
|
+
position: selectedImage?.position || "center",
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
|
|
103
159
|
const handleImagePropertyChange = (prop: "size" | "position", value: string) => {
|
|
104
160
|
if (background?.type === "image" && background.image) {
|
|
105
161
|
onChange({ ...background, image: { ...background.image, [prop]: value } });
|
|
@@ -292,24 +348,46 @@ export default function BackgroundSelector({ background, onChange }: BackgroundS
|
|
|
292
348
|
{backgroundType === "image" && (
|
|
293
349
|
<div className="space-y-1.5">
|
|
294
350
|
<Label className="text-xs uppercase font-bold text-muted-foreground tracking-wider leading-none">Image</Label>
|
|
295
|
-
{selectedImage?.object_key ? (
|
|
351
|
+
{(selectedImage?.object_key || externalBgUrl) ? (
|
|
296
352
|
<div className="flex items-center gap-2 h-9">
|
|
297
353
|
<div className="relative w-9 h-9 rounded border bg-muted overflow-hidden flex-shrink-0 shadow-sm">
|
|
298
|
-
{
|
|
354
|
+
{externalBgUrl ? (
|
|
355
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
356
|
+
<img
|
|
357
|
+
src={externalBgUrl}
|
|
358
|
+
alt="Thumbnail"
|
|
359
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
360
|
+
style={{ objectPosition: selectedImage?.position }}
|
|
361
|
+
/>
|
|
362
|
+
) : selectedImageUrl ? (
|
|
299
363
|
<Image
|
|
300
364
|
src={selectedImageUrl}
|
|
301
365
|
alt="Thumbnail"
|
|
302
366
|
fill
|
|
303
367
|
sizes="36px"
|
|
304
368
|
className="object-cover"
|
|
305
|
-
style={{ objectPosition: selectedImage
|
|
369
|
+
style={{ objectPosition: selectedImage?.position }}
|
|
306
370
|
/>
|
|
307
371
|
) : null}
|
|
308
|
-
{selectedImage
|
|
372
|
+
{selectedImage?.overlay && (
|
|
309
373
|
<div className="absolute inset-0" style={{ background: generateGradientCss(selectedImage.overlay.gradient) }} />
|
|
310
374
|
)}
|
|
311
375
|
</div>
|
|
312
376
|
<div className="flex items-center gap-1.5">
|
|
377
|
+
{externalBgUrl && (
|
|
378
|
+
<Button
|
|
379
|
+
type="button"
|
|
380
|
+
variant="secondary"
|
|
381
|
+
size="sm"
|
|
382
|
+
className="h-9 px-2.5 text-xs"
|
|
383
|
+
onClick={handleImportBgToLibrary}
|
|
384
|
+
disabled={isBgImporting}
|
|
385
|
+
title="Save this image to your media library"
|
|
386
|
+
>
|
|
387
|
+
<DownloadCloud className="mr-1 h-3.5 w-3.5" />
|
|
388
|
+
{isBgImporting ? "Saving..." : "Save"}
|
|
389
|
+
</Button>
|
|
390
|
+
)}
|
|
313
391
|
<MediaPickerDialog
|
|
314
392
|
triggerLabel="Change"
|
|
315
393
|
triggerVariant="outline"
|
|
@@ -334,7 +412,7 @@ export default function BackgroundSelector({ background, onChange }: BackgroundS
|
|
|
334
412
|
</div>
|
|
335
413
|
</div>
|
|
336
414
|
) : (
|
|
337
|
-
<div className="h-9 flex items-center">
|
|
415
|
+
<div className="h-9 flex items-center gap-2">
|
|
338
416
|
<MediaPickerDialog
|
|
339
417
|
triggerLabel="Select Image"
|
|
340
418
|
triggerVariant="outline"
|
|
@@ -346,13 +424,30 @@ export default function BackgroundSelector({ background, onChange }: BackgroundS
|
|
|
346
424
|
Select Image
|
|
347
425
|
</Button>
|
|
348
426
|
</MediaPickerDialog>
|
|
427
|
+
<Input
|
|
428
|
+
value={externalBgUrlInput}
|
|
429
|
+
onChange={(e) => setExternalBgUrlInput(e.target.value)}
|
|
430
|
+
placeholder="or paste image URL"
|
|
431
|
+
className="h-9 text-xs w-[170px]"
|
|
432
|
+
/>
|
|
433
|
+
<Button
|
|
434
|
+
type="button"
|
|
435
|
+
variant="outline"
|
|
436
|
+
size="sm"
|
|
437
|
+
className="h-9 px-2.5 text-xs"
|
|
438
|
+
onClick={handleUseExternalBgUrl}
|
|
439
|
+
disabled={!externalBgUrlInput.trim()}
|
|
440
|
+
>
|
|
441
|
+
Use URL
|
|
442
|
+
</Button>
|
|
349
443
|
</div>
|
|
350
444
|
)}
|
|
445
|
+
{bgImportError && <p className="text-xs text-red-500">{bgImportError}</p>}
|
|
351
446
|
</div>
|
|
352
447
|
)}
|
|
353
448
|
|
|
354
449
|
{/* Overlay Checkbox */}
|
|
355
|
-
{backgroundType === "image" && selectedImage?.object_key && (
|
|
450
|
+
{backgroundType === "image" && (selectedImage?.object_key || externalBgUrl) && (
|
|
356
451
|
<div className="space-y-1.5">
|
|
357
452
|
<Label className="text-xs uppercase font-bold text-muted-foreground tracking-wider leading-none">Overlay</Label>
|
|
358
453
|
<div className="flex items-center justify-center h-9 border border-input rounded-md px-3 bg-background">
|
|
@@ -68,6 +68,33 @@ interface EditingNestedBlockInfo {
|
|
|
68
68
|
blockData: NestedBlockData;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// AI-generated Live Draft blocks arrive without a DB id, so key={block.id} collides
|
|
72
|
+
// (all undefined) and dnd/inline-edit/delete — which all key off block.id — break for
|
|
73
|
+
// them. Give any block missing (or duplicating) an id a deterministic, unique negative
|
|
74
|
+
// temp id. Deterministic (no Math.random) so SSR and client render identical keys.
|
|
75
|
+
function withStableBlockIds(blocks: Block[]): Block[] {
|
|
76
|
+
const used = new Set<number>();
|
|
77
|
+
let nextTempId = -1;
|
|
78
|
+
const takeTempId = () => {
|
|
79
|
+
while (used.has(nextTempId)) {
|
|
80
|
+
nextTempId -= 1;
|
|
81
|
+
}
|
|
82
|
+
used.add(nextTempId);
|
|
83
|
+
const id = nextTempId;
|
|
84
|
+
nextTempId -= 1;
|
|
85
|
+
return id;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return blocks.map((block) => {
|
|
89
|
+
const id = block.id;
|
|
90
|
+
if (id != null && !used.has(id)) {
|
|
91
|
+
used.add(id);
|
|
92
|
+
return block;
|
|
93
|
+
}
|
|
94
|
+
return { ...block, id: takeTempId() };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
export default function BlockEditorArea({ parentId, parentType, initialBlocks, languageId }: BlockEditorAreaProps) {
|
|
72
99
|
// Prevent SSR/hydration mismatches from dnd-kit by rendering on client only
|
|
73
100
|
// Important: keep hooks order stable across renders; defer early return until after hooks
|
|
@@ -76,7 +103,9 @@ export default function BlockEditorArea({ parentId, parentType, initialBlocks, l
|
|
|
76
103
|
setIsMounted(true);
|
|
77
104
|
}, []);
|
|
78
105
|
|
|
79
|
-
const [blocks, setBlocks] = useState<Block[]>(() =>
|
|
106
|
+
const [blocks, setBlocks] = useState<Block[]>(() =>
|
|
107
|
+
withStableBlockIds(initialBlocks).sort((a, b) => a.order - b.order)
|
|
108
|
+
);
|
|
80
109
|
const lastSavedBlocks = useRef(blocks);
|
|
81
110
|
const router = useRouter();
|
|
82
111
|
const [isPending, startTransition] = useTransition();
|
|
@@ -89,7 +118,7 @@ export default function BlockEditorArea({ parentId, parentType, initialBlocks, l
|
|
|
89
118
|
const [tempNestedBlockContent, setTempNestedBlockContent] = useState<Json | null>(null);
|
|
90
119
|
|
|
91
120
|
useEffect(() => {
|
|
92
|
-
const sortedBlocks = initialBlocks.sort((a, b) => a.order - b.order);
|
|
121
|
+
const sortedBlocks = withStableBlockIds(initialBlocks).sort((a, b) => a.order - b.order);
|
|
93
122
|
setBlocks(sortedBlocks);
|
|
94
123
|
lastSavedBlocks.current = sortedBlocks;
|
|
95
124
|
}, [initialBlocks]);
|
|
@@ -180,22 +180,44 @@ function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, bloc
|
|
|
180
180
|
);
|
|
181
181
|
}
|
|
182
182
|
case 'image': {
|
|
183
|
-
const
|
|
183
|
+
const externalUrl =
|
|
184
|
+
typeof block.content.external_url === 'string' &&
|
|
185
|
+
/^https?:\/\//i.test(block.content.external_url.trim())
|
|
186
|
+
? block.content.external_url.trim()
|
|
187
|
+
: null;
|
|
188
|
+
const imageUrl = externalUrl || resolveMediaUrl(block.content.object_key) || block.content.src;
|
|
189
|
+
if (!imageUrl) {
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
className={cn(
|
|
193
|
+
"flex h-20 w-full items-center justify-center gap-2 rounded border border-dashed",
|
|
194
|
+
isDarkBackground ? "border-white/20 text-white/50" : "border-muted-foreground/30 text-muted-foreground"
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
<ImageIcon className="h-5 w-5" />
|
|
198
|
+
<span className="text-[10px]">No image selected</span>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
184
202
|
return (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
<figure className="w-full">
|
|
204
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
205
|
+
<img
|
|
206
|
+
src={imageUrl}
|
|
207
|
+
alt={block.content.alt_text || 'Block image'}
|
|
208
|
+
className="h-auto w-full rounded border border-white/10"
|
|
209
|
+
/>
|
|
210
|
+
{(block.content.caption || block.content.alt_text) && (
|
|
211
|
+
<figcaption
|
|
212
|
+
className={cn(
|
|
213
|
+
"mt-1 truncate text-[10px]",
|
|
214
|
+
isDarkBackground ? "text-white/60" : "text-muted-foreground"
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
{block.content.caption || block.content.alt_text}
|
|
218
|
+
</figcaption>
|
|
219
|
+
)}
|
|
220
|
+
</figure>
|
|
199
221
|
);
|
|
200
222
|
}
|
|
201
223
|
case 'button': {
|
|
@@ -179,22 +179,33 @@ export default function EditableBlock({
|
|
|
179
179
|
}
|
|
180
180
|
case 'image': {
|
|
181
181
|
const content = (block.content || {}) as any;
|
|
182
|
-
const
|
|
182
|
+
const externalUrl =
|
|
183
|
+
typeof content.external_url === 'string' && /^https?:\/\//i.test(content.external_url.trim())
|
|
184
|
+
? content.external_url.trim()
|
|
185
|
+
: null;
|
|
186
|
+
const imageUrl = externalUrl || resolveMediaUrl(content.object_key) || content.src;
|
|
187
|
+
if (!imageUrl) {
|
|
188
|
+
return (
|
|
189
|
+
<div className="flex h-28 w-full items-center justify-center gap-2 rounded border border-dashed py-2 text-muted-foreground">
|
|
190
|
+
<ImageIcon className="h-6 w-6" />
|
|
191
|
+
<span className="text-sm">No image selected</span>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
183
195
|
return (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
</div>
|
|
196
|
+
<figure className="w-full py-1">
|
|
197
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
198
|
+
<img
|
|
199
|
+
src={imageUrl}
|
|
200
|
+
alt={content.alt_text || 'Block image'}
|
|
201
|
+
className="h-auto w-full rounded border"
|
|
202
|
+
/>
|
|
203
|
+
{(content.caption || content.alt_text) && (
|
|
204
|
+
<figcaption className="mt-1 truncate text-xs text-muted-foreground">
|
|
205
|
+
{content.caption || content.alt_text}
|
|
206
|
+
</figcaption>
|
|
207
|
+
)}
|
|
208
|
+
</figure>
|
|
198
209
|
);
|
|
199
210
|
}
|
|
200
211
|
case 'button': {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// app/cms/blocks/editors/ImageBlockEditor.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import React, { useState } from 'react';
|
|
4
|
+
import React, { useState, useTransition } from 'react';
|
|
5
5
|
import Image from 'next/image';
|
|
6
6
|
import { Label } from "@nextblock-cms/ui";
|
|
7
7
|
import { Input } from "@nextblock-cms/ui";
|
|
@@ -12,42 +12,44 @@ type Media = Database['public']['Tables']['media']['Row'];
|
|
|
12
12
|
export type ImageBlockContent = {
|
|
13
13
|
media_id: string | null;
|
|
14
14
|
object_key: string | null;
|
|
15
|
+
external_url?: string | null;
|
|
15
16
|
alt_text: string | null;
|
|
16
17
|
caption: string | null;
|
|
17
18
|
width: number | null;
|
|
18
19
|
height: number | null;
|
|
19
20
|
blur_data_url: string | null;
|
|
20
21
|
};
|
|
21
|
-
import { ImageIcon, X as XIcon } from 'lucide-react';
|
|
22
|
-
import MediaPickerDialog from "../../media/components/MediaPickerDialog";
|
|
22
|
+
import { ImageIcon, X as XIcon, Link as LinkIcon, DownloadCloud } from 'lucide-react';
|
|
23
|
+
import MediaPickerDialog from "../../media/components/MediaPickerDialog";
|
|
23
24
|
import { BlockEditorProps } from '../components/BlockEditorModal';
|
|
24
25
|
import { resolveMediaUrl } from '../../../../lib/media/resolveMediaUrl';
|
|
26
|
+
import { importExternalImageToMedia } from "../../media/import-external-image";
|
|
27
|
+
|
|
28
|
+
const deriveAltFromFilename = (name: string) => {
|
|
29
|
+
const lastDot = name.lastIndexOf('.');
|
|
30
|
+
const base = lastDot > 0 ? name.substring(0, lastDot) : name;
|
|
31
|
+
const spaced = base.replace(/[-+_\\]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
32
|
+
return spaced.replace(/\b\w+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
|
|
33
|
+
};
|
|
25
34
|
|
|
26
35
|
export default function ImageBlockEditor({ content, onChange }: BlockEditorProps<Partial<ImageBlockContent>>) {
|
|
27
36
|
const [selectedMediaObjectKey, setSelectedMediaObjectKey] = useState<string | null | undefined>(content.object_key);
|
|
28
|
-
const [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// Effect to fetch media details (like object_key) if only media_id is present in content
|
|
32
|
-
|
|
37
|
+
const [externalUrlInput, setExternalUrlInput] = useState("");
|
|
38
|
+
const [importError, setImportError] = useState<string | null>(null);
|
|
39
|
+
const [isImporting, startImport] = useTransition();
|
|
33
40
|
|
|
34
41
|
const handleSelectMediaFromLibrary = (mediaItem: Media) => {
|
|
35
|
-
// Always reset alt to the new media's description (or derived from filename)
|
|
36
|
-
const deriveAltFromFilename = (name: string) => {
|
|
37
|
-
const lastDot = name.lastIndexOf('.');
|
|
38
|
-
const base = lastDot > 0 ? name.substring(0, lastDot) : name;
|
|
39
|
-
const spaced = base.replace(/[-+_\\]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
40
|
-
return spaced.replace(/\b\w+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
|
|
41
|
-
};
|
|
42
42
|
const newAlt = mediaItem.description && mediaItem.description.trim().length > 0
|
|
43
43
|
? mediaItem.description
|
|
44
44
|
: deriveAltFromFilename(mediaItem.file_name || 'Image');
|
|
45
45
|
|
|
46
46
|
setSelectedMediaObjectKey(mediaItem.object_key);
|
|
47
|
+
setImportError(null);
|
|
47
48
|
onChange({
|
|
48
49
|
media_id: mediaItem.id,
|
|
49
50
|
object_key: mediaItem.object_key,
|
|
50
|
-
|
|
51
|
+
external_url: null,
|
|
52
|
+
alt_text: newAlt,
|
|
51
53
|
caption: content.caption || "",
|
|
52
54
|
width: mediaItem.width,
|
|
53
55
|
height: mediaItem.height,
|
|
@@ -55,52 +57,107 @@ export default function ImageBlockEditor({ content, onChange }: BlockEditorProps
|
|
|
55
57
|
});
|
|
56
58
|
};
|
|
57
59
|
|
|
58
|
-
const
|
|
60
|
+
const handleUseExternalUrl = () => {
|
|
61
|
+
const url = externalUrlInput.trim();
|
|
62
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
63
|
+
setImportError("Enter a valid http(s) image URL.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setImportError(null);
|
|
67
|
+
setSelectedMediaObjectKey(null);
|
|
59
68
|
onChange({
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
alt_text:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
media_id: null,
|
|
70
|
+
object_key: null,
|
|
71
|
+
external_url: url,
|
|
72
|
+
alt_text: content.alt_text || "",
|
|
73
|
+
caption: content.caption || "",
|
|
74
|
+
width: null,
|
|
75
|
+
height: null,
|
|
76
|
+
blur_data_url: null,
|
|
67
77
|
});
|
|
78
|
+
setExternalUrlInput("");
|
|
68
79
|
};
|
|
69
80
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
const handleImportToLibrary = () => {
|
|
82
|
+
if (!content.external_url) return;
|
|
83
|
+
setImportError(null);
|
|
84
|
+
startImport(async () => {
|
|
85
|
+
const result = await importExternalImageToMedia({
|
|
86
|
+
url: content.external_url as string,
|
|
87
|
+
altText: content.alt_text || undefined,
|
|
88
|
+
});
|
|
89
|
+
if ("error" in result) {
|
|
90
|
+
setImportError(result.error);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setSelectedMediaObjectKey(result.media.object_key);
|
|
94
|
+
onChange({
|
|
95
|
+
media_id: result.media.id,
|
|
96
|
+
object_key: result.media.object_key,
|
|
97
|
+
external_url: null,
|
|
98
|
+
alt_text: content.alt_text || result.media.alt_text || "",
|
|
99
|
+
caption: content.caption || "",
|
|
100
|
+
width: result.media.width || null,
|
|
101
|
+
height: result.media.height || null,
|
|
102
|
+
blur_data_url: result.media.blur_data_url,
|
|
103
|
+
});
|
|
79
104
|
});
|
|
80
105
|
};
|
|
81
106
|
|
|
107
|
+
const handleAltTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
108
|
+
onChange({ ...content, object_key: selectedMediaObjectKey, alt_text: event.target.value });
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleCaptionChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
112
|
+
onChange({ ...content, object_key: selectedMediaObjectKey, caption: event.target.value });
|
|
113
|
+
};
|
|
114
|
+
|
|
82
115
|
const handleRemoveImage = () => {
|
|
83
116
|
setSelectedMediaObjectKey(null);
|
|
84
|
-
|
|
117
|
+
setImportError(null);
|
|
118
|
+
onChange({ media_id: null, object_key: null, external_url: null, alt_text: "", caption: "", width: null, height: null, blur_data_url: null });
|
|
85
119
|
};
|
|
86
120
|
|
|
87
121
|
const displayObjectKey = content.object_key || selectedMediaObjectKey;
|
|
88
122
|
const displayImageUrl = resolveMediaUrl(displayObjectKey);
|
|
123
|
+
const hasImage = Boolean(displayObjectKey || content.external_url);
|
|
89
124
|
|
|
90
125
|
return (
|
|
91
126
|
<div className="space-y-3 p-3 border-t mt-2">
|
|
92
127
|
<Label>Image</Label>
|
|
93
|
-
<div className="mt-1 p-3 border rounded-md bg-muted/30 min-h-[120px] flex flex-col items-center justify-center">
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
|
|
128
|
+
<div className="mt-1 p-3 border rounded-md bg-muted/30 min-h-[120px] flex flex-col items-center justify-center gap-2">
|
|
129
|
+
{content.external_url ? (
|
|
130
|
+
<div className="relative group inline-block w-full text-center">
|
|
131
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
132
|
+
<img
|
|
133
|
+
src={content.external_url}
|
|
134
|
+
alt={content.alt_text || "External image"}
|
|
135
|
+
className="rounded-md object-contain mx-auto max-h-[200px] max-w-full"
|
|
136
|
+
/>
|
|
137
|
+
<Button
|
|
138
|
+
type="button" variant="destructive" size="icon"
|
|
139
|
+
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6"
|
|
140
|
+
onClick={handleRemoveImage} title="Remove Image"
|
|
141
|
+
> <XIcon className="h-3 w-3" /> </Button>
|
|
142
|
+
<div className="mt-2 flex items-center justify-center">
|
|
143
|
+
<Button type="button" size="sm" variant="secondary" onClick={handleImportToLibrary} disabled={isImporting}>
|
|
144
|
+
<DownloadCloud className="mr-1.5 h-3.5 w-3.5" />
|
|
145
|
+
{isImporting ? "Saving to library..." : "Save to media library"}
|
|
146
|
+
</Button>
|
|
147
|
+
</div>
|
|
148
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
149
|
+
External image placeholder. Save it to your library to make it a permanent, optimized asset.
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
) : displayImageUrl && typeof content.width === 'number' && typeof content.height === 'number' && content.width > 0 && content.height > 0 ? (
|
|
153
|
+
<div className="relative group inline-block" style={{ maxWidth: content.width, maxHeight: 200 }}>
|
|
97
154
|
<Image
|
|
98
155
|
src={displayImageUrl}
|
|
99
156
|
alt={content.alt_text || "Selected image"}
|
|
100
157
|
width={content.width}
|
|
101
158
|
height={content.height}
|
|
102
|
-
className="rounded-md object-contain"
|
|
103
|
-
style={{ maxHeight: '200px' }}
|
|
159
|
+
className="rounded-md object-contain"
|
|
160
|
+
style={{ maxHeight: '200px' }}
|
|
104
161
|
placeholder={content.blur_data_url ? "blur" : "empty"}
|
|
105
162
|
blurDataURL={content.blur_data_url || undefined}
|
|
106
163
|
/>
|
|
@@ -110,7 +167,7 @@ export default function ImageBlockEditor({ content, onChange }: BlockEditorProps
|
|
|
110
167
|
onClick={handleRemoveImage} title="Remove Image"
|
|
111
168
|
> <XIcon className="h-3 w-3" /> </Button>
|
|
112
169
|
</div>
|
|
113
|
-
) :
|
|
170
|
+
) : displayImageUrl ? (
|
|
114
171
|
<div className="relative group inline-block">
|
|
115
172
|
<Image
|
|
116
173
|
src={displayImageUrl}
|
|
@@ -127,24 +184,44 @@ export default function ImageBlockEditor({ content, onChange }: BlockEditorProps
|
|
|
127
184
|
> <XIcon className="h-3 w-3" /> </Button>
|
|
128
185
|
<p className="text-xs text-orange-500 mt-1">Preview: Dimensions missing, using fallback.</p>
|
|
129
186
|
</div>
|
|
130
|
-
) :
|
|
187
|
+
) : content.media_id ? (
|
|
131
188
|
<p className="text-sm text-red-500">Image details (object_key or dimensions) missing for Media ID: {content.media_id}. Try re-selecting.</p>
|
|
132
189
|
) : (
|
|
133
190
|
<ImageIcon className="h-16 w-16 text-muted-foreground" />
|
|
134
191
|
)}
|
|
135
192
|
|
|
136
|
-
<MediaPickerDialog triggerLabel={
|
|
193
|
+
<MediaPickerDialog triggerLabel={hasImage ? "Change Image" : "Select from Library"} onSelect={handleSelectMediaFromLibrary} accept={(m)=>!!m.file_type?.startsWith("image/")} title="Select or Upload Image" />
|
|
194
|
+
|
|
195
|
+
{!content.external_url && (
|
|
196
|
+
<div className="w-full pt-2 mt-1 border-t">
|
|
197
|
+
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
|
198
|
+
<LinkIcon className="h-3 w-3" /> or paste an image URL
|
|
199
|
+
</Label>
|
|
200
|
+
<div className="flex gap-2 mt-1">
|
|
201
|
+
<Input
|
|
202
|
+
value={externalUrlInput}
|
|
203
|
+
onChange={(e) => setExternalUrlInput(e.target.value)}
|
|
204
|
+
placeholder="https://images.example.com/photo.jpg"
|
|
205
|
+
className="text-sm"
|
|
206
|
+
/>
|
|
207
|
+
<Button type="button" variant="outline" size="sm" onClick={handleUseExternalUrl} disabled={!externalUrlInput.trim()}>
|
|
208
|
+
Use URL
|
|
209
|
+
</Button>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{importError && <p className="text-xs text-red-500 mt-1 w-full text-center">{importError}</p>}
|
|
137
215
|
</div>
|
|
138
216
|
|
|
139
217
|
<div>
|
|
140
218
|
<Label htmlFor={`image-alt-${content.media_id || 'new'}`}>Alt Text</Label>
|
|
141
|
-
<Input id={`image-alt-${content.media_id || 'new'}`} value={content.alt_text || ""} onChange={handleAltTextChange} className="mt-1" disabled={!
|
|
219
|
+
<Input id={`image-alt-${content.media_id || 'new'}`} value={content.alt_text || ""} onChange={handleAltTextChange} className="mt-1" disabled={!hasImage} />
|
|
142
220
|
</div>
|
|
143
221
|
<div>
|
|
144
222
|
<Label htmlFor={`image-caption-${content.media_id || 'new'}`}>Caption</Label>
|
|
145
|
-
<Input id={`image-caption-${content.media_id || 'new'}`} value={content.caption || ""} onChange={handleCaptionChange} className="mt-1" disabled={!
|
|
223
|
+
<Input id={`image-caption-${content.media_id || 'new'}`} value={content.caption || ""} onChange={handleCaptionChange} className="mt-1" disabled={!hasImage} />
|
|
146
224
|
</div>
|
|
147
225
|
</div>
|
|
148
226
|
);
|
|
149
227
|
}
|
|
150
|
-
|
|
@@ -83,7 +83,14 @@ function generateBackgroundStyles(background: SectionBlockContent['background'])
|
|
|
83
83
|
|
|
84
84
|
case 'image':
|
|
85
85
|
if (background.image) {
|
|
86
|
-
|
|
86
|
+
// Prefer an external stock-photo URL (external_url) before the stored R2
|
|
87
|
+
// object key, so AI-inserted hero backgrounds paint in the editor too.
|
|
88
|
+
const externalBgUrl =
|
|
89
|
+
typeof background.image.external_url === 'string' &&
|
|
90
|
+
/^https?:\/\//i.test(background.image.external_url.trim())
|
|
91
|
+
? background.image.external_url.trim()
|
|
92
|
+
: null;
|
|
93
|
+
const imageUrl = externalBgUrl || resolveMediaUrl(background.image.object_key);
|
|
87
94
|
if (!imageUrl) {
|
|
88
95
|
break;
|
|
89
96
|
}
|