create-nextblock 0.16.1 → 0.16.3

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