create-nextblock 0.15.10 → 0.16.1

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 (52) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
  3. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +193 -1
  5. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +7 -1
  6. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  7. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -229
  8. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  9. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -245
  10. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  11. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  12. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -406
  13. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  14. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -383
  15. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  16. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  17. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  18. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  19. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  20. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  21. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  22. package/templates/nextblock-template/app/layout.tsx +1 -1
  23. package/templates/nextblock-template/app/robots.ts +123 -0
  24. package/templates/nextblock-template/app/sitemap.ts +1 -1
  25. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  26. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  27. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  28. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  29. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  30. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  31. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  32. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  33. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  34. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  35. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  36. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  37. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  38. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  39. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  40. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  41. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  42. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  43. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  44. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  45. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  46. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  47. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  48. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
  49. package/templates/nextblock-template/next-env.d.ts +2 -2
  50. package/templates/nextblock-template/package.json +1 -1
  51. package/templates/nextblock-template/proxy.ts +240 -20
  52. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -1,229 +1,406 @@
1
- // app/cms/blocks/editors/ImageBlockEditor.tsx
2
- "use client";
3
-
4
- import React, { useState, useTransition } from 'react';
5
- import Image from 'next/image';
6
- import { Label } from "@nextblock-cms/ui";
7
- import { Input } from "@nextblock-cms/ui";
8
- import { Button } from "@nextblock-cms/ui";
9
- import type { Database } from "@nextblock-cms/db";
10
-
11
- type Media = Database['public']['Tables']['media']['Row'];
12
- export type ImageBlockContent = {
13
- media_id: string | null;
14
- object_key: string | null;
15
- external_url?: string | null;
16
- alt_text: string | null;
17
- caption: string | null;
18
- width: number | null;
19
- height: number | null;
20
- blur_data_url: string | null;
21
- };
22
- import { ImageIcon, X as XIcon, Link as LinkIcon, DownloadCloud } from 'lucide-react';
23
- import MediaPickerDialog from "../../media/components/MediaPickerDialog";
24
- import { BlockEditorProps } from '../components/BlockEditorModal';
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
- };
34
-
35
- export default function ImageBlockEditor({ content, onChange }: BlockEditorProps<Partial<ImageBlockContent>>) {
36
- const [selectedMediaObjectKey, setSelectedMediaObjectKey] = useState<string | null | undefined>(content.object_key);
37
- const [externalUrlInput, setExternalUrlInput] = useState("");
38
- const [importError, setImportError] = useState<string | null>(null);
39
- const [isImporting, startImport] = useTransition();
40
-
41
- const handleSelectMediaFromLibrary = (mediaItem: Media) => {
42
- const newAlt = mediaItem.description && mediaItem.description.trim().length > 0
43
- ? mediaItem.description
44
- : deriveAltFromFilename(mediaItem.file_name || 'Image');
45
-
46
- setSelectedMediaObjectKey(mediaItem.object_key);
47
- setImportError(null);
48
- onChange({
49
- media_id: mediaItem.id,
50
- object_key: mediaItem.object_key,
51
- external_url: null,
52
- alt_text: newAlt,
53
- caption: content.caption || "",
54
- width: mediaItem.width,
55
- height: mediaItem.height,
56
- blur_data_url: mediaItem.blur_data_url,
57
- });
58
- };
59
-
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);
68
- onChange({
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,
77
- });
78
- setExternalUrlInput("");
79
- };
80
-
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
- });
104
- });
105
- };
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
-
115
- const handleRemoveImage = () => {
116
- setSelectedMediaObjectKey(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 });
119
- };
120
-
121
- const displayObjectKey = content.object_key || selectedMediaObjectKey;
122
- const displayImageUrl = resolveMediaUrl(displayObjectKey);
123
- const hasImage = Boolean(displayObjectKey || content.external_url);
124
-
125
- return (
126
- <div className="space-y-3 p-3 border-t mt-2">
127
- <Label>Image</Label>
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 }}>
154
- <Image
155
- src={displayImageUrl}
156
- alt={content.alt_text || "Selected image"}
157
- width={content.width}
158
- height={content.height}
159
- className="rounded-md object-contain"
160
- // width/height auto so the CSS max-height scales both axes and
161
- // keeps the intrinsic aspect ratio (next/image warns otherwise)
162
- style={{ width: 'auto', height: 'auto', maxWidth: '100%', maxHeight: '200px' }}
163
- placeholder={content.blur_data_url ? "blur" : "empty"}
164
- blurDataURL={content.blur_data_url || undefined}
165
- />
166
- <Button
167
- type="button" variant="destructive" size="icon"
168
- className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6"
169
- onClick={handleRemoveImage} title="Remove Image"
170
- > <XIcon className="h-3 w-3" /> </Button>
171
- </div>
172
- ) : displayImageUrl ? (
173
- <div className="relative group inline-block">
174
- <Image
175
- src={displayImageUrl}
176
- alt={content.alt_text || "Selected image"}
177
- width={300}
178
- height={200}
179
- className="rounded-md object-contain max-h-40 block"
180
- style={{ width: 'auto', height: 'auto', maxWidth: '100%' }}
181
- />
182
- <Button
183
- type="button" variant="destructive" size="icon"
184
- className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6"
185
- onClick={handleRemoveImage} title="Remove Image"
186
- > <XIcon className="h-3 w-3" /> </Button>
187
- <p className="text-xs text-orange-500 mt-1">Preview: Dimensions missing, using fallback.</p>
188
- </div>
189
- ) : content.media_id ? (
190
- <p className="text-sm text-red-500">Image details (object_key or dimensions) missing for Media ID: {content.media_id}. Try re-selecting.</p>
191
- ) : (
192
- <ImageIcon className="h-16 w-16 text-muted-foreground" />
193
- )}
194
-
195
- <MediaPickerDialog triggerLabel={hasImage ? "Change Image" : "Select from Library"} onSelect={handleSelectMediaFromLibrary} accept={(m)=>!!m.file_type?.startsWith("image/")} title="Select or Upload Image" />
196
-
197
- {!content.external_url && (
198
- <div className="w-full pt-2 mt-1 border-t">
199
- <Label className="text-xs text-muted-foreground flex items-center gap-1">
200
- <LinkIcon className="h-3 w-3" /> or paste an image URL
201
- </Label>
202
- <div className="flex gap-2 mt-1">
203
- <Input
204
- value={externalUrlInput}
205
- onChange={(e) => setExternalUrlInput(e.target.value)}
206
- placeholder="https://images.example.com/photo.jpg"
207
- className="text-sm"
208
- />
209
- <Button type="button" variant="outline" size="sm" onClick={handleUseExternalUrl} disabled={!externalUrlInput.trim()}>
210
- Use URL
211
- </Button>
212
- </div>
213
- </div>
214
- )}
215
-
216
- {importError && <p className="text-xs text-red-500 mt-1 w-full text-center">{importError}</p>}
217
- </div>
218
-
219
- <div>
220
- <Label htmlFor={`image-alt-${content.media_id || 'new'}`}>Alt Text</Label>
221
- <Input id={`image-alt-${content.media_id || 'new'}`} value={content.alt_text || ""} onChange={handleAltTextChange} className="mt-1" disabled={!hasImage} />
222
- </div>
223
- <div>
224
- <Label htmlFor={`image-caption-${content.media_id || 'new'}`}>Caption</Label>
225
- <Input id={`image-caption-${content.media_id || 'new'}`} value={content.caption || ""} onChange={handleCaptionChange} className="mt-1" disabled={!hasImage} />
226
- </div>
227
- </div>
228
- );
229
- }
1
+ // app/cms/blocks/editors/ImageBlockEditor.tsx
2
+ "use client";
3
+
4
+ import React, { useRef, useState, useTransition } from 'react';
5
+ import Image from 'next/image';
6
+ import { Label } from "@nextblock-cms/ui";
7
+ import { Input } from "@nextblock-cms/ui";
8
+ import { Button } from "@nextblock-cms/ui";
9
+ import type { Database } from "@nextblock-cms/db";
10
+
11
+ type Media = Database['public']['Tables']['media']['Row'];
12
+ export type ImageBlockContent = {
13
+ media_id: string | null;
14
+ object_key: string | null;
15
+ external_url?: string | null;
16
+ alt_text: string | null;
17
+ caption: string | null;
18
+ width: number | null;
19
+ height: number | null;
20
+ blur_data_url: string | null;
21
+ };
22
+ import { ImageIcon, X as XIcon, Link as LinkIcon, DownloadCloud, Loader2, Sparkles } from 'lucide-react';
23
+ import { toast } from 'sonner';
24
+ import MediaPickerDialog from "../../media/components/MediaPickerDialog";
25
+ import { BlockEditorProps } from '../components/BlockEditorModal';
26
+ import { resolveMediaUrl } from '../../../../lib/media/resolveMediaUrl';
27
+ import { importExternalImageToMedia } from "../../media/import-external-image";
28
+ import { useCortexAiActive } from '../../components/CortexAiActiveContext';
29
+ import { useCortexAiPageContext } from '../../components/CortexAiPageContext';
30
+ import { buildCortexAiRequestHeaders } from '../../../../lib/cortex-ai/sandbox-headers';
31
+ import {
32
+ buildAltTextContext,
33
+ toAbsoluteImageUrl,
34
+ UNRESOLVABLE_IMAGE_URL_MESSAGE,
35
+ } from '../../../../lib/cortex-ai/alt-text-request';
36
+ import {
37
+ altTextImageIdentity,
38
+ resolveAltTextWriteBack,
39
+ STALE_ALT_TEXT_MESSAGE,
40
+ } from '../../../../lib/seo/alt-text-write-back';
41
+
42
+ const deriveAltFromFilename = (name: string) => {
43
+ const lastDot = name.lastIndexOf('.');
44
+ const base = lastDot > 0 ? name.substring(0, lastDot) : name;
45
+ const spaced = base.replace(/[-+_\\]+/g, ' ').replace(/\s+/g, ' ').trim();
46
+ return spaced.replace(/\b\w+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
47
+ };
48
+
49
+ export default function ImageBlockEditor({ content, onChange }: BlockEditorProps<Partial<ImageBlockContent>>) {
50
+ const [selectedMediaObjectKey, setSelectedMediaObjectKey] = useState<string | null | undefined>(content.object_key);
51
+ const [externalUrlInput, setExternalUrlInput] = useState("");
52
+ const [importError, setImportError] = useState<string | null>(null);
53
+ const [isImporting, startImport] = useTransition();
54
+
55
+ // Cortex AI is a premium package. Gating the affordance on activation (rather than
56
+ // letting the button 403) is the difference between a feature the operator has not
57
+ // bought and a feature that looks broken.
58
+ const isCortexAiActive = useCortexAiActive();
59
+ const cortexAiPageContext = useCortexAiPageContext();
60
+ const [isGeneratingAltText, setIsGeneratingAltText] = useState(false);
61
+ // Kept separate from `importError` so an alt-text failure never clears — or is cleared
62
+ // by — an unrelated "save to library" failure sitting a few lines above it.
63
+ const [altTextError, setAltTextError] = useState<string | null>(null);
64
+
65
+ /**
66
+ * Live mirrors of the two values that describe which image this block holds.
67
+ *
68
+ * The alt-text generation below is the only async write-back in this editor, and it is
69
+ * the only place that must read these AFTER an await. Refs updated during render (the
70
+ * same pattern `RobotsCard` uses for its Ctrl+S handler) give that continuation the
71
+ * current values instead of the ones its closure captured when the button was clicked,
72
+ * which is what stops a slow vision response from overwriting a newer edit.
73
+ */
74
+ const contentRef = useRef(content);
75
+ contentRef.current = content;
76
+ const selectedMediaObjectKeyRef = useRef(selectedMediaObjectKey);
77
+ selectedMediaObjectKeyRef.current = selectedMediaObjectKey;
78
+
79
+ const handleSelectMediaFromLibrary = (mediaItem: Media) => {
80
+ const newAlt = mediaItem.description && mediaItem.description.trim().length > 0
81
+ ? mediaItem.description
82
+ : deriveAltFromFilename(mediaItem.file_name || 'Image');
83
+
84
+ setSelectedMediaObjectKey(mediaItem.object_key);
85
+ setImportError(null);
86
+ onChange({
87
+ media_id: mediaItem.id,
88
+ object_key: mediaItem.object_key,
89
+ external_url: null,
90
+ alt_text: newAlt,
91
+ caption: content.caption || "",
92
+ width: mediaItem.width,
93
+ height: mediaItem.height,
94
+ blur_data_url: mediaItem.blur_data_url,
95
+ });
96
+ };
97
+
98
+ const handleUseExternalUrl = () => {
99
+ const url = externalUrlInput.trim();
100
+ if (!/^https?:\/\//i.test(url)) {
101
+ setImportError("Enter a valid http(s) image URL.");
102
+ return;
103
+ }
104
+ setImportError(null);
105
+ setSelectedMediaObjectKey(null);
106
+ onChange({
107
+ media_id: null,
108
+ object_key: null,
109
+ external_url: url,
110
+ alt_text: content.alt_text || "",
111
+ caption: content.caption || "",
112
+ width: null,
113
+ height: null,
114
+ blur_data_url: null,
115
+ });
116
+ setExternalUrlInput("");
117
+ };
118
+
119
+ const handleImportToLibrary = () => {
120
+ if (!content.external_url) return;
121
+ setImportError(null);
122
+ startImport(async () => {
123
+ const result = await importExternalImageToMedia({
124
+ url: content.external_url as string,
125
+ altText: content.alt_text || undefined,
126
+ });
127
+ if ("error" in result) {
128
+ setImportError(result.error);
129
+ return;
130
+ }
131
+ setSelectedMediaObjectKey(result.media.object_key);
132
+ onChange({
133
+ media_id: result.media.id,
134
+ object_key: result.media.object_key,
135
+ external_url: null,
136
+ alt_text: content.alt_text || result.media.alt_text || "",
137
+ caption: content.caption || "",
138
+ width: result.media.width || null,
139
+ height: result.media.height || null,
140
+ blur_data_url: result.media.blur_data_url,
141
+ });
142
+ });
143
+ };
144
+
145
+ const handleAltTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
146
+ onChange({ ...content, object_key: selectedMediaObjectKey, alt_text: event.target.value });
147
+ };
148
+
149
+ const handleCaptionChange = (event: React.ChangeEvent<HTMLInputElement>) => {
150
+ onChange({ ...content, object_key: selectedMediaObjectKey, caption: event.target.value });
151
+ };
152
+
153
+ const handleRemoveImage = () => {
154
+ setSelectedMediaObjectKey(null);
155
+ setImportError(null);
156
+ onChange({ media_id: null, object_key: null, external_url: null, alt_text: "", caption: "", width: null, height: null, blur_data_url: null });
157
+ };
158
+
159
+ const displayObjectKey = content.object_key || selectedMediaObjectKey;
160
+ const displayImageUrl = resolveMediaUrl(displayObjectKey);
161
+ const hasImage = Boolean(displayObjectKey || content.external_url);
162
+
163
+ /**
164
+ * Ask Cortex AI to describe the image currently in this block.
165
+ *
166
+ * Three things about this deserve stating. First, the URL: `resolveMediaUrl()` hands back
167
+ * a RELATIVE `/${objectKey}` whenever `NEXT_PUBLIC_R2_BASE_URL` is unset — which is the
168
+ * default on the native Supabase-storage backend — and the alt-text route cannot use
169
+ * that, because the AI SDK downloads the image server-side and a relative path has no
170
+ * base there. `toAbsoluteImageUrl()` resolves it against the origin the CMS is being
171
+ * served from; if the result still is not http(s) (a blob: preview of an unsaved file,
172
+ * say) we say so inline instead of firing a request guaranteed to fail.
173
+ *
174
+ * Second, the write-back: the generated string goes through the exact same
175
+ * `onChange({ ...content, object_key, alt_text })` call the text input uses. Feature 2
176
+ * requires the value to land in the block's JSONB attributes, and the surest way to
177
+ * guarantee that is to make the AI path indistinguishable from typing — same shape,
178
+ * same key set, same downstream save. A bespoke write here would be a second code path
179
+ * to keep in sync forever, for no gain.
180
+ *
181
+ * Third, staleness. This handler used to close over `content` and finish with
182
+ * `onChange({ ...capturedContent, ... })`, on the assumption that the block it started
183
+ * on would still be the block it finished on. It is not: the vision call takes seconds
184
+ * and the editor stays live throughout, so an author who removes the image or picks a
185
+ * different one mid-flight would have had the deleted image restored, or a new image
186
+ * labelled with the previous image's description — alt text that is confidently wrong
187
+ * and undetectable by the screen-reader user it is written for. The image's identity is
188
+ * therefore captured instead of its content, and the response is discarded unless the
189
+ * block still holds that same image. `resolveAltTextWriteBack` owns that rule so it can
190
+ * be unit-tested; this component cannot be, as the workspace has no DOM test env.
191
+ */
192
+ const handleGenerateAltText = async () => {
193
+ if (!hasImage || isGeneratingAltText) {
194
+ return;
195
+ }
196
+
197
+ const sourceUrl = content.external_url || displayImageUrl;
198
+ const imageUrl = toAbsoluteImageUrl(sourceUrl);
199
+
200
+ if (!imageUrl) {
201
+ setAltTextError(UNRESOLVABLE_IMAGE_URL_MESSAGE);
202
+ return;
203
+ }
204
+
205
+ // Taken before the await, from the same two values the request itself is built from,
206
+ // so "is this still the image we asked about?" is answerable when the reply lands.
207
+ const capturedIdentity = altTextImageIdentity(content, selectedMediaObjectKey);
208
+
209
+ setAltTextError(null);
210
+ setIsGeneratingAltText(true);
211
+
212
+ try {
213
+ // Context materially improves alt text — "Nicolas at the 2026 harvest" beats "a man
214
+ // in a field" — so we pass whatever is already in hand for free: this block's own
215
+ // caption, and the title of the page or post being edited, which the CMS layout
216
+ // already registers in the Cortex AI page context. Nothing is fetched for this.
217
+ const context = buildAltTextContext(
218
+ content.caption ? `Image caption: ${content.caption}` : null,
219
+ cortexAiPageContext?.pageContext?.title
220
+ ? `Appears on the ${cortexAiPageContext.pageContext.contentType} “${cortexAiPageContext.pageContext.title}”.`
221
+ : null
222
+ );
223
+
224
+ const response = await fetch('/api/ai/seo/alt-text', {
225
+ // The route's body schema is a `z.strictObject` of optionals, so an absent field
226
+ // must be omitted rather than sent as null — a null would fail validation outright.
227
+ body: JSON.stringify({
228
+ ...(context ? { context } : {}),
229
+ imageUrl,
230
+ }),
231
+ headers: buildCortexAiRequestHeaders(),
232
+ method: 'POST',
233
+ });
234
+
235
+ const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
236
+
237
+ if (!response.ok || !payload) {
238
+ const message =
239
+ payload && typeof payload['error'] === 'string'
240
+ ? (payload['error'] as string)
241
+ : 'Cortex AI could not describe this image.';
242
+ throw new Error(message);
243
+ }
244
+
245
+ const generated = typeof payload['altText'] === 'string' ? payload['altText'].trim() : '';
246
+ if (!generated) {
247
+ throw new Error('Cortex AI returned an empty description.');
248
+ }
249
+
250
+ const currentContent = contentRef.current;
251
+ const currentObjectKey = selectedMediaObjectKeyRef.current;
252
+ const writeBack = resolveAltTextWriteBack({
253
+ capturedIdentity,
254
+ currentContent,
255
+ currentIdentity: altTextImageIdentity(currentContent, currentObjectKey),
256
+ generatedAltText: generated,
257
+ objectKey: currentObjectKey,
258
+ });
259
+
260
+ if (!writeBack) {
261
+ // Not thrown: nothing failed. The model answered, the answer is simply about an
262
+ // image this block no longer shows, so it is reported inline and dropped. A toast
263
+ // is skipped for the same reason — this is not an error the operator caused.
264
+ setAltTextError(STALE_ALT_TEXT_MESSAGE);
265
+ return;
266
+ }
267
+
268
+ onChange(writeBack);
269
+ } catch (error) {
270
+ const message =
271
+ error instanceof Error ? error.message : 'Cortex AI could not describe this image.';
272
+ setAltTextError(message);
273
+ toast.error(message);
274
+ } finally {
275
+ setIsGeneratingAltText(false);
276
+ }
277
+ };
278
+
279
+ return (
280
+ <div className="space-y-3 p-3 border-t mt-2">
281
+ <Label>Image</Label>
282
+ <div className="mt-1 p-3 border rounded-md bg-muted/30 min-h-[120px] flex flex-col items-center justify-center gap-2">
283
+ {content.external_url ? (
284
+ <div className="relative group inline-block w-full text-center">
285
+ {/* eslint-disable-next-line @next/next/no-img-element */}
286
+ <img
287
+ src={content.external_url}
288
+ alt={content.alt_text || "External image"}
289
+ className="rounded-md object-contain mx-auto max-h-[200px] max-w-full"
290
+ />
291
+ <Button
292
+ type="button" variant="destructive" size="icon"
293
+ className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6"
294
+ onClick={handleRemoveImage} title="Remove Image"
295
+ > <XIcon className="h-3 w-3" /> </Button>
296
+ <div className="mt-2 flex items-center justify-center">
297
+ <Button type="button" size="sm" variant="secondary" onClick={handleImportToLibrary} disabled={isImporting}>
298
+ <DownloadCloud className="mr-1.5 h-3.5 w-3.5" />
299
+ {isImporting ? "Saving to library..." : "Save to media library"}
300
+ </Button>
301
+ </div>
302
+ <p className="text-xs text-muted-foreground mt-1">
303
+ External image placeholder. Save it to your library to make it a permanent, optimized asset.
304
+ </p>
305
+ </div>
306
+ ) : displayImageUrl && typeof content.width === 'number' && typeof content.height === 'number' && content.width > 0 && content.height > 0 ? (
307
+ <div className="relative group inline-block" style={{ maxWidth: content.width, maxHeight: 200 }}>
308
+ <Image
309
+ src={displayImageUrl}
310
+ alt={content.alt_text || "Selected image"}
311
+ width={content.width}
312
+ height={content.height}
313
+ className="rounded-md object-contain"
314
+ // width/height auto so the CSS max-height scales both axes and
315
+ // keeps the intrinsic aspect ratio (next/image warns otherwise)
316
+ style={{ width: 'auto', height: 'auto', maxWidth: '100%', maxHeight: '200px' }}
317
+ placeholder={content.blur_data_url ? "blur" : "empty"}
318
+ blurDataURL={content.blur_data_url || undefined}
319
+ />
320
+ <Button
321
+ type="button" variant="destructive" size="icon"
322
+ className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6"
323
+ onClick={handleRemoveImage} title="Remove Image"
324
+ > <XIcon className="h-3 w-3" /> </Button>
325
+ </div>
326
+ ) : displayImageUrl ? (
327
+ <div className="relative group inline-block">
328
+ <Image
329
+ src={displayImageUrl}
330
+ alt={content.alt_text || "Selected image"}
331
+ width={300}
332
+ height={200}
333
+ className="rounded-md object-contain max-h-40 block"
334
+ style={{ width: 'auto', height: 'auto', maxWidth: '100%' }}
335
+ />
336
+ <Button
337
+ type="button" variant="destructive" size="icon"
338
+ className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6"
339
+ onClick={handleRemoveImage} title="Remove Image"
340
+ > <XIcon className="h-3 w-3" /> </Button>
341
+ <p className="text-xs text-orange-500 mt-1">Preview: Dimensions missing, using fallback.</p>
342
+ </div>
343
+ ) : content.media_id ? (
344
+ <p className="text-sm text-red-500">Image details (object_key or dimensions) missing for Media ID: {content.media_id}. Try re-selecting.</p>
345
+ ) : (
346
+ <ImageIcon className="h-16 w-16 text-muted-foreground" />
347
+ )}
348
+
349
+ <MediaPickerDialog triggerLabel={hasImage ? "Change Image" : "Select from Library"} onSelect={handleSelectMediaFromLibrary} accept={(m)=>!!m.file_type?.startsWith("image/")} title="Select or Upload Image" />
350
+
351
+ {!content.external_url && (
352
+ <div className="w-full pt-2 mt-1 border-t">
353
+ <Label className="text-xs text-muted-foreground flex items-center gap-1">
354
+ <LinkIcon className="h-3 w-3" /> or paste an image URL
355
+ </Label>
356
+ <div className="flex gap-2 mt-1">
357
+ <Input
358
+ value={externalUrlInput}
359
+ onChange={(e) => setExternalUrlInput(e.target.value)}
360
+ placeholder="https://images.example.com/photo.jpg"
361
+ className="text-sm"
362
+ />
363
+ <Button type="button" variant="outline" size="sm" onClick={handleUseExternalUrl} disabled={!externalUrlInput.trim()}>
364
+ Use URL
365
+ </Button>
366
+ </div>
367
+ </div>
368
+ )}
369
+
370
+ {importError && <p className="text-xs text-red-500 mt-1 w-full text-center">{importError}</p>}
371
+ </div>
372
+
373
+ <div>
374
+ <div className="flex items-center justify-between gap-2">
375
+ <Label htmlFor={`image-alt-${content.media_id || 'new'}`}>Alt Text</Label>
376
+ {/* Rendered only when the premium package is active, so a non-premium install
377
+ never shows a button that cannot work. */}
378
+ {isCortexAiActive && (
379
+ <Button
380
+ type="button"
381
+ variant="outline"
382
+ size="sm"
383
+ className="h-7 gap-1.5 px-2 text-[11px]"
384
+ onClick={handleGenerateAltText}
385
+ disabled={!hasImage || isGeneratingAltText}
386
+ title={hasImage ? "Describe this image with Cortex AI" : "Select an image first"}
387
+ >
388
+ {isGeneratingAltText ? (
389
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
390
+ ) : (
391
+ <Sparkles aria-hidden="true" className="h-3.5 w-3.5 text-amber-500" />
392
+ )}
393
+ {isGeneratingAltText ? "Generating…" : "Generate with AI"}
394
+ </Button>
395
+ )}
396
+ </div>
397
+ <Input id={`image-alt-${content.media_id || 'new'}`} value={content.alt_text || ""} onChange={handleAltTextChange} className="mt-1" disabled={!hasImage} />
398
+ {altTextError && <p className="text-xs text-red-500 mt-1">{altTextError}</p>}
399
+ </div>
400
+ <div>
401
+ <Label htmlFor={`image-caption-${content.media_id || 'new'}`}>Caption</Label>
402
+ <Input id={`image-caption-${content.media_id || 'new'}`} value={content.caption || ""} onChange={handleCaptionChange} className="mt-1" disabled={!hasImage} />
403
+ </div>
404
+ </div>
405
+ );
406
+ }