create-nextblock 0.16.1 → 0.16.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 (258) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -3092
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  42. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  43. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  44. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  45. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  46. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  47. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  48. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  49. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  50. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  51. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  52. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  53. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  54. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  55. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  56. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  57. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  58. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  59. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  60. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  61. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  62. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  63. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  64. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  65. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  66. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  67. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  68. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  69. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  70. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  71. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -618
  72. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  73. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  74. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  75. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  76. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  77. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  78. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  79. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  80. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  81. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  82. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  83. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  84. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  85. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  86. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  87. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  88. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  89. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  90. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  91. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  92. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  93. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  94. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  95. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  96. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  97. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  98. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  99. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  100. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  101. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  102. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  103. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  104. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  105. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  106. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  107. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  108. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  109. package/templates/nextblock-template/app/layout.tsx +671 -671
  110. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  111. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  112. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  113. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  114. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  115. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  116. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  117. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  118. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  119. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  120. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  121. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  122. package/templates/nextblock-template/app/providers.tsx +96 -96
  123. package/templates/nextblock-template/app/robots.ts +123 -123
  124. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  125. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  126. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  127. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  128. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  129. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  130. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  131. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  132. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  133. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  134. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  135. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  136. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  137. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  138. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  139. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  140. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  141. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  142. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  143. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  144. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  145. package/templates/nextblock-template/components/Header.tsx +38 -38
  146. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  147. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  148. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  149. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  150. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  151. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  152. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  153. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  154. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  155. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  156. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  157. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  158. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  159. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  160. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  161. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  162. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  163. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  164. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  165. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  166. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  167. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  168. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  169. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  170. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  171. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  172. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  173. package/templates/nextblock-template/components/form-message.tsx +32 -32
  174. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  175. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -244
  176. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  177. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  178. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  179. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  180. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  181. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  182. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  183. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  184. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  185. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  186. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  187. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  188. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  189. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  190. package/templates/nextblock-template/docs/README.md +42 -42
  191. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  192. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  193. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  194. package/templates/nextblock-template/index.d.ts +7 -7
  195. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  196. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  197. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  198. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  199. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  200. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  201. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  202. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  203. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  204. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  205. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  206. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  207. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  208. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  209. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  210. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  211. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  212. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  213. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  214. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  217. package/templates/nextblock-template/lib/search/types.ts +27 -27
  218. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  219. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  220. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  221. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  222. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  223. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -140
  224. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -350
  225. package/templates/nextblock-template/lib/seo/page-document.ts +412 -412
  226. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  227. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  228. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  229. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  230. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  231. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  232. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -177
  233. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  234. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  235. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  236. package/templates/nextblock-template/lib/site-url.ts +102 -102
  237. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  238. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  239. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  240. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  241. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  242. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  243. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  244. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  245. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  246. package/templates/nextblock-template/next-env.d.ts +1 -2
  247. package/templates/nextblock-template/package.json +1 -1
  248. package/templates/nextblock-template/postcss.config.js +6 -6
  249. package/templates/nextblock-template/scripts/backup.js +115 -115
  250. package/templates/nextblock-template/scripts/restore.js +385 -385
  251. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  252. package/templates/nextblock-template/tailwind.config.js +25 -25
  253. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  254. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  255. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  256. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  257. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  258. package/tsconfig.base.json +3 -3
@@ -1,406 +1,406 @@
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
- }
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
+ }