create-nextblock 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
  3. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
  4. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
  5. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
  6. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
  7. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
  8. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
  9. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
  10. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
  11. package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
  12. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
  13. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
  14. package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
  15. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
  16. package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
  17. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
  18. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
  19. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
  20. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
  21. package/templates/nextblock-template/app/lib/homepage.ts +36 -0
  22. package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
  23. package/templates/nextblock-template/app/page.tsx +55 -12
  24. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
  25. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
  26. package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
  27. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
  28. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
  29. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
  30. package/templates/nextblock-template/lib/search/server.ts +11 -1
  31. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
  32. package/templates/nextblock-template/next-env.d.ts +1 -1
  33. package/templates/nextblock-template/package.json +1 -1
  34. package/templates/nextblock-template/proxy.ts +5 -0
@@ -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
- {selectedImageUrl ? (
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.position }}
369
+ style={{ objectPosition: selectedImage?.position }}
306
370
  />
307
371
  ) : null}
308
- {selectedImage.overlay && (
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[]>(() => initialBlocks.sort((a, b) => a.order - b.order));
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 imageUrl = resolveMediaUrl(block.content.object_key) || block.content.src;
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
- <div className="flex gap-3">
186
- <div className="flex-shrink-0 h-10 w-10 bg-muted/20 rounded overflow-hidden flex items-center justify-center border border-white/10">
187
- {imageUrl ? (
188
- /* eslint-disable-next-line @next/next/no-img-element */
189
- <img src={imageUrl} alt={block.content.alt_text || 'Block image'} className="h-full w-full object-cover" />
190
- ) : (
191
- <ImageIcon className={cn("h-5 w-5", isDarkBackground ? "text-white/50" : "text-muted-foreground")} />
192
- )}
193
- </div>
194
- <div className="flex flex-col justify-center">
195
- <span className="text-xs font-medium truncate max-w-[150px]">{block.content.alt_text || 'No description'}</span>
196
- <span className={cn("text-[10px] truncate max-w-[150px]", isDarkBackground ? "text-white/50" : "text-muted-foreground")}>{imageUrl ? 'Image set' : 'No image selected'}</span>
197
- </div>
198
- </div>
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 imageUrl = resolveMediaUrl(content.object_key) || content.src;
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
- <div className="flex gap-4 py-2">
185
- <div className="flex-shrink-0 h-16 w-16 bg-muted rounded overflow-hidden flex items-center justify-center border">
186
- {imageUrl ? (
187
- /* eslint-disable-next-line @next/next/no-img-element */
188
- <img src={imageUrl} alt={content.alt_text || 'Block image'} className="h-full w-full object-cover" />
189
- ) : (
190
- <ImageIcon className="h-6 w-6 text-muted-foreground" />
191
- )}
192
- </div>
193
- <div className="flex flex-col justify-center">
194
- <span className="text-sm font-medium truncate max-w-[200px]">{content.alt_text || 'No description'}</span>
195
- <span className="text-xs text-muted-foreground truncate max-w-[200px]">{imageUrl ? 'Image set' : 'No image selected'}</span>
196
- </div>
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'; // Removed useTransition as it's not used here
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"; // Import the upload form
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 [isLoadingMediaDetails] = useState(false); // For fetching details if only ID is present
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
- alt_text: newAlt, // overwrite alt when image changes
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 handleAltTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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
- ...content,
61
- media_id: content.media_id || null,
62
- object_key: selectedMediaObjectKey,
63
- alt_text: event.target.value,
64
- width: content.width,
65
- height: content.height,
66
- blur_data_url: content.blur_data_url
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 handleCaptionChange = (event: React.ChangeEvent<HTMLInputElement>) => {
71
- onChange({
72
- ...content,
73
- media_id: content.media_id || null,
74
- object_key: selectedMediaObjectKey,
75
- caption: event.target.value,
76
- width: content.width,
77
- height: content.height,
78
- blur_data_url: content.blur_data_url
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
- onChange({ media_id: null, object_key: null, alt_text: "", caption: "", width: null, height: null, blur_data_url: null });
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
- {isLoadingMediaDetails && <p>Loading image details...</p>}
95
- {!isLoadingMediaDetails && displayImageUrl && typeof content.width === 'number' && typeof content.height === 'number' && content.width > 0 && content.height > 0 ? (
96
- <div className="relative group inline-block" style={{ maxWidth: content.width, maxHeight: 200 }}> {/* Max height for editor preview consistency */}
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" // Removed max-h-40, relying on width/height and parent max-height
103
- style={{ maxHeight: '200px' }} // Ensure image does not exceed this height in preview
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
- ) : !isLoadingMediaDetails && displayImageUrl ? ( // Fallback if width/height are missing but key exists
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
- ) : !isLoadingMediaDetails && content.media_id ? (
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={displayObjectKey ? "Change Image" : "Select from Library"} onSelect={handleSelectMediaFromLibrary} accept={(m)=>!!m.file_type?.startsWith("image/")} title="Select or Upload Image" />
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={!displayObjectKey} />
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={!displayObjectKey} />
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
- const imageUrl = resolveMediaUrl(background.image.object_key);
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
  }