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,649 +1,649 @@
1
- // app/cms/pages/components/PageForm.tsx
2
- "use client";
3
-
4
- import { useEffect, useMemo, useRef, useState, useTransition } from "react";
5
- import { useRouter, useSearchParams } from "next/navigation";
6
- import { Button } from "@nextblock-cms/ui";
7
- import { Spinner, Alert, AlertDescription } from "@nextblock-cms/ui";
8
- import { Input } from "@nextblock-cms/ui";
9
- import { Label } from "@nextblock-cms/ui";
10
- import {
11
- Select,
12
- SelectContent,
13
- SelectItem,
14
- SelectTrigger,
15
- SelectValue,
16
- } from "@nextblock-cms/ui";
17
- import { Textarea } from "@nextblock-cms/ui";
18
- import type { Database } from "@nextblock-cms/db";
19
- import { useAuth } from '../../../../context/AuthContext';
20
- import { useHotkeys } from '../../../../hooks/use-hotkeys';
21
- import FeatureImageField from "../../components/FeatureImageField";
22
- import { useCortexAiActive } from "../../components/CortexAiActiveContext";
23
- import GenerateMetaButton, {
24
- type GeneratedSeoMetadata,
25
- } from "../../../../components/seo/GenerateMetaButton";
26
- import SocialPreviewDialog from "../../../../components/seo/SocialPreviewDialog";
27
- import { buildSeoContentForGeneration } from "../../../../lib/seo/block-content";
28
- import { usePageSeo } from "../../../../lib/seo/page-audit-context";
29
- import { resolveSiteUrl } from "../../../../lib/site-url";
30
-
31
- type Page = Database['public']['Tables']['pages']['Row'];
32
- type Language = Database['public']['Tables']['languages']['Row'];
33
- // Remove: import { getActiveLanguagesClientSide } from "@nextblock-cms/db";
34
-
35
- /**
36
- * Google truncates a title around 60 characters and a description around 160. These are
37
- * soft advice, not validation — the same numbers `SiteSeoSettingsForm` shows, and the same
38
- * amber-past-the-limit treatment, so the counter means one thing everywhere in the CMS.
39
- */
40
- const META_TITLE_RECOMMENDED_MAX = 60;
41
- const META_DESCRIPTION_RECOMMENDED_MAX = 160;
42
-
43
- interface PageFormProps {
44
- page?: (Page & { feature_image_id?: string | null }) | null;
45
- formAction: (formData: FormData) => Promise<{ error?: string } | void>;
46
- actionButtonText?: string;
47
- isEditing?: boolean;
48
- availableLanguagesProp: Language[]; // New prop
49
- translationGroupId?: string;
50
- target_lang_id?: string;
51
- initialFeatureImageUrl?: string | null;
52
- initialFeatureImageId?: string | null;
53
- /**
54
- * The page's block rows, read-only, purely so Cortex AI can be asked to summarize the
55
- * page it is writing metadata for.
56
- *
57
- * Deliberately NOT a form field and NOT persisted by this form: nothing here is added to
58
- * the FormData, to `hasChanges`, or to the autosave dependency list. It flows one way,
59
- * parent → form → AI request. Blocks have their own editor and their own save path
60
- * (`BlockEditorArea`); this form must never become a second writer of them.
61
- */
62
- contentBlocks?: unknown;
63
- }
64
-
65
- export default function PageForm({
66
- page,
67
- formAction,
68
- actionButtonText = "Save Page",
69
- isEditing = false,
70
- availableLanguagesProp, // Use the new prop
71
- translationGroupId,
72
- target_lang_id,
73
- initialFeatureImageUrl,
74
- initialFeatureImageId,
75
- contentBlocks,
76
- }: PageFormProps) {
77
- const router = useRouter();
78
- const searchParams = useSearchParams();
79
- const [isPending, startTransition] = useTransition();
80
- const { user, isLoading: authLoading } = useAuth();
81
-
82
- const [title, setTitle] = useState(page?.title || "");
83
- const [slug, setSlug] = useState(page?.slug || "");
84
- const [languageId, setLanguageId] = useState<string>(() => {
85
- // If editing, use the page's language
86
- if (page?.language_id) {
87
- return page.language_id.toString();
88
- }
89
- // If creating a translation, use the target language
90
- if (target_lang_id) {
91
- return target_lang_id;
92
- }
93
- // Otherwise, find the default language from the available languages
94
- if (availableLanguagesProp && availableLanguagesProp.length > 0) {
95
- const defaultLang = availableLanguagesProp.find((l) => l.is_default);
96
- if (defaultLang) {
97
- return defaultLang.id.toString();
98
- }
99
- // As a fallback, use the first available language
100
- return availableLanguagesProp[0].id.toString();
101
- }
102
- // If no languages are available, default to an empty string
103
- return "";
104
- });
105
- const [metaTitle, setMetaTitle] = useState(page?.meta_title || "");
106
- const [metaDescription, setMetaDescription] = useState(
107
- page?.meta_description || ""
108
- );
109
- const [customCanonical, setCustomCanonical] = useState(page?.custom_canonical || "");
110
- const [featureImageId, setFeatureImageId] = useState<string | null>(
111
- initialFeatureImageId || page?.feature_image_id || null
112
- );
113
- /**
114
- * The resolved URL of the feature image, for the share preview only.
115
- *
116
- * This one is exempt from everything the autosave path looks at, and the exemption is
117
- * deliberate rather than an oversight: it is absent from the FormData, from `hasChanges`,
118
- * and from the autosave effect's dependency array. `feature_image_id` is the persisted
119
- * field — `FeatureImageField` renders it as the hidden input and the diff above already
120
- * watches it — and the URL is nothing but that id resolved against the `media` row, which
121
- * the server redoes on the next render. Persisting it would store a duplicate that can go
122
- * stale, and adding it to the autosave deps would only schedule a second save for the
123
- * change `featureImageId` already covers.
124
- *
125
- * It exists because the preview was previously bound to `initialFeatureImageUrl`, which
126
- * is fixed for the life of the render — so picking an image left the share card showing
127
- * the old one (or a placeholder) until a full reload, which defeats a live preview.
128
- */
129
- const [featureImageUrl, setFeatureImageUrl] = useState<string | null>(
130
- initialFeatureImageUrl || null
131
- );
132
-
133
- const isCortexAiActive = useCortexAiActive();
134
- /**
135
- * The social-specific copy Cortex AI returns alongside the meta fields.
136
- *
137
- * This is held in component state and never written anywhere. There are no
138
- * `og_title` / `og_description` columns on `pages`, and adding one is not a one-line
139
- * change — a new persisted field needs the useState, the sync-from-prop effect AND its
140
- * dependency array, `hasChanges`, the autosave deps, the FormData fallback list, the JSX
141
- * `name`, the server action's rawFormData/updateData/payload type, and the draft overlay
142
- * in the edit RSC, and missing any one of those makes the field silently never save.
143
- * So the OG copy exists to inform the meta fields, which DO persist, and the operator can
144
- * fold it in with one click. The live share card is built from the meta title and
145
- * description below plus the feature image.
146
- */
147
- const [socialSuggestion, setSocialSuggestion] = useState<{
148
- ogDescription: string;
149
- ogTitle: string;
150
- } | null>(null);
151
-
152
- /**
153
- * Prose for the metadata call. Memoized because this form re-renders on every keystroke
154
- * (each one re-arms the autosave timer) and flattening a page's whole block tree on each
155
- * of those would be real, pointless work — the blocks themselves cannot change while
156
- * this form is mounted, since they are edited by `BlockEditorArea` below.
157
- *
158
- * On a brand-new page with no blocks the title is a thin but genuine brief, which beats
159
- * a permanently disabled button on day one.
160
- */
161
- const seoContent = useMemo(
162
- () => buildSeoContentForGeneration(contentBlocks, title),
163
- [contentBlocks, title]
164
- );
165
-
166
- // Use the passed-in languages
167
- const [availableLanguages] = useState<Language[]>(availableLanguagesProp);
168
- // languagesLoading is no longer needed if languages are passed as props
169
- // const [languagesLoading, setLanguagesLoading] = useState(true); // Remove or set to false initially
170
-
171
- const [formMessage, setFormMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
172
- const formRef = useRef<HTMLFormElement>(null);
173
- const [isSaving, setIsSaving] = useState(false);
174
- const [saveError, setSaveError] = useState<string | null>(null);
175
- const [lastSaved, setLastSaved] = useState<Date | null>(null);
176
- const isFirstRender = useRef(true);
177
-
178
- useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
179
-
180
- useEffect(() => {
181
- const successMessage = searchParams.get('success');
182
- const errorMessage = searchParams.get('error');
183
- if (successMessage) {
184
- setFormMessage({ type: 'success', text: successMessage });
185
- } else if (errorMessage) {
186
- setFormMessage({ type: 'error', text: errorMessage });
187
- }
188
- }, [searchParams]);
189
-
190
- useEffect(() => {
191
- if (!page) {
192
- return;
193
- }
194
-
195
- setTitle(page.title || "");
196
- setSlug(page.slug || "");
197
- setLanguageId(page.language_id?.toString() || "");
198
- setMetaTitle(page.meta_title || "");
199
- setMetaDescription(page.meta_description || "");
200
- setCustomCanonical(page.custom_canonical || "");
201
- setFeatureImageId(initialFeatureImageId || page.feature_image_id || null);
202
- // Re-seeded in lockstep with the id above. Both come from the same server-resolved
203
- // media row, so letting the id snap back to the props while the URL kept a newer value
204
- // would put the preview and the field it previews out of step.
205
- setFeatureImageUrl(initialFeatureImageUrl || null);
206
- }, [
207
- initialFeatureImageId,
208
- initialFeatureImageUrl,
209
- page?.id,
210
- page?.custom_canonical,
211
- page?.language_id,
212
- page?.meta_description,
213
- page?.meta_title,
214
- page?.slug,
215
- page?.title,
216
- page?.updated_at,
217
- ]);
218
-
219
- const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
220
- const newTitle = e.target.value;
221
- setTitle(newTitle);
222
- if (!isEditing || !slug) {
223
- setSlug(newTitle.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]+/g, ""));
224
- }
225
- };
226
-
227
- const saveDraft = async (customFormData?: FormData) => {
228
- if (!title.trim() || !slug.trim()) {
229
- return;
230
- }
231
- setIsSaving(true);
232
- setSaveError(null);
233
-
234
- const formData = customFormData || (formRef.current ? new FormData(formRef.current) : new FormData());
235
- if (!customFormData && !formRef.current) {
236
- formData.append("title", title);
237
- formData.append("slug", slug);
238
- formData.append("language_id", languageId);
239
- formData.append("meta_title", metaTitle);
240
- formData.append("meta_description", metaDescription);
241
- formData.append("custom_canonical", customCanonical);
242
- formData.append("feature_image_id", featureImageId || "");
243
- if (translationGroupId) {
244
- formData.append("translation_group_id", translationGroupId);
245
- }
246
- }
247
-
248
- try {
249
- const result = await formAction(formData);
250
- if (result && 'error' in result && result.error) {
251
- setSaveError(result.error);
252
- setFormMessage({ type: 'error', text: result.error });
253
- } else {
254
- setLastSaved(new Date());
255
- setFormMessage(null);
256
- router.refresh();
257
- }
258
- } catch (err: any) {
259
- const msg = err.message || "Failed to save draft";
260
- setSaveError(msg);
261
- setFormMessage({ type: 'error', text: msg });
262
- } finally {
263
- setIsSaving(false);
264
- }
265
- };
266
-
267
- const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
268
- event.preventDefault();
269
- if (!isEditing) {
270
- setFormMessage(null);
271
- const formData = new FormData(event.currentTarget);
272
-
273
- startTransition(async () => {
274
- const result = await formAction(formData);
275
- if (result?.error) {
276
- setFormMessage({ type: 'error', text: result.error });
277
- }
278
- });
279
- } else {
280
- await saveDraft();
281
- }
282
- };
283
-
284
- useEffect(() => {
285
- if (!isEditing) return;
286
-
287
- if (isFirstRender.current) {
288
- isFirstRender.current = false;
289
- return;
290
- }
291
-
292
- const hasChanges =
293
- title !== (page?.title || "") ||
294
- slug !== (page?.slug || "") ||
295
- languageId !== (page?.language_id?.toString() || "") ||
296
- metaTitle !== (page?.meta_title || "") ||
297
- metaDescription !== (page?.meta_description || "") ||
298
- customCanonical !== (page?.custom_canonical || "") ||
299
- featureImageId !== (page?.feature_image_id || null);
300
-
301
- if (!hasChanges) return;
302
-
303
- const timer = setTimeout(() => {
304
- saveDraft();
305
- }, 1000);
306
-
307
- return () => clearTimeout(timer);
308
- // customCanonical belongs here too: hasChanges checks it, so leaving it out
309
- // meant editing only the canonical URL never scheduled a save.
310
- }, [title, slug, languageId, metaTitle, metaDescription, customCanonical, featureImageId, page, isEditing]);
311
-
312
- /**
313
- * Hand the live meta fields to the page-level SEO audit.
314
- *
315
- * The panel that grades a page cannot read this form's state directly — it renders as a
316
- * sibling, not a child — so the title and description have to be published outward. This
317
- * is a one-way broadcast of values this form ALREADY owns and already persists; it adds
318
- * no field, changes nothing about what `hasChanges` above compares, and puts nothing in
319
- * the FormData. The autosave diff is deliberately untouched, because publishing a value
320
- * is not the same event as changing one: were `setMeta` wired into that diff, merely
321
- * mounting the audit panel would look like an edit and schedule a draft write.
322
- *
323
- * `usePageSeo()` returns null when no provider is above this form, which is a supported
324
- * arrangement rather than a bug — some surfaces embed these fields without a page-level
325
- * panel — so the call is optional all the way through.
326
- *
327
- * The dependency is `setPageSeoMeta`, not the whole context value: the setter is a stable
328
- * `useCallback`, whereas the context object is rebuilt whenever the blocks array changes,
329
- * which would re-run this effect on every keystroke elsewhere in the editor. The setter
330
- * also bails when the values are unchanged, so there is no path from this effect back into
331
- * a render of this form and therefore no loop.
332
- */
333
- const setPageSeoMeta = usePageSeo()?.setMeta;
334
- useEffect(() => {
335
- setPageSeoMeta?.({ metaDescription, metaTitle });
336
- }, [metaDescription, metaTitle, setPageSeoMeta]);
337
-
338
- // Removed languagesLoading from this condition
339
- if (authLoading) {
340
- return <div>Loading form...</div>;
341
- }
342
-
343
- if (!user) {
344
- return <div>Please log in to manage pages.</div>;
345
- }
346
-
347
- const selectedLanguageCode =
348
- availableLanguages.find((lang) => lang.id.toString() === languageId)?.code ?? null;
349
- // What the share card will actually show. Meta title wins when set, because that is what
350
- // `buildSocialMetadata` uses; otherwise the page title, exactly as the public site does.
351
- const previewTitle = metaTitle.trim() || title.trim();
352
- const previewUrl = `${resolveSiteUrl()}/${(slug || "").replace(/^\/+/, "")}`;
353
-
354
- /**
355
- * Fold a generation into the form.
356
- *
357
- * Setting state is the whole persistence story here: the autosave effect above diffs
358
- * this state against the `page` prop and calls `formAction` a second later, writing into
359
- * `content_drafts`. A direct row UPDATE from this handler would bypass the draft and
360
- * mutate the live page — precisely the thing draft mode exists to prevent.
361
- */
362
- /**
363
- * Track the picked image for the preview as well as for the save.
364
- *
365
- * The id half feeds `hasChanges` and therefore the autosave; the URL half is purely
366
- * presentational (see the state declaration above) and stops here.
367
- */
368
- const handleFeatureImageChange = (imageId: string | null, imageUrl: string | null) => {
369
- setFeatureImageId(imageId);
370
- setFeatureImageUrl(imageUrl);
371
- };
372
-
373
- const handleGeneratedMetadata = (result: GeneratedSeoMetadata) => {
374
- if (result.metaTitle) {
375
- setMetaTitle(result.metaTitle);
376
- }
377
- if (result.metaDescription) {
378
- setMetaDescription(result.metaDescription);
379
- }
380
- setSocialSuggestion(
381
- result.ogTitle || result.ogDescription
382
- ? { ogDescription: result.ogDescription, ogTitle: result.ogTitle }
383
- : null
384
- );
385
- };
386
-
387
- return (
388
- <form ref={formRef} onSubmit={handleSubmit} className="space-y-4 w-full mx-auto px-6">
389
- {isEditing && (
390
- <div className="flex items-center justify-between text-xs text-muted-foreground pb-2 border-b border-border/40 mb-2">
391
- <span className="font-semibold text-[11px] uppercase tracking-wider text-muted-foreground/80">Page Settings</span>
392
- <div className="flex items-center gap-1.5 min-h-[16px]">
393
- {isSaving ? (
394
- <>
395
- <div className="relative flex h-2 w-2">
396
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
397
- <span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
398
- </div>
399
- <span className="text-amber-600 dark:text-amber-400 font-medium">Autosaving settings...</span>
400
- </>
401
- ) : saveError ? (
402
- <span className="text-red-500 font-medium">Error saving settings: {saveError}</span>
403
- ) : lastSaved ? (
404
- <span className="text-emerald-600 dark:text-emerald-400 font-medium">
405
- Settings autosaved at {lastSaved.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
406
- </span>
407
- ) : (
408
- <span className="text-muted-foreground/60">Settings autosave in draft mode</span>
409
- )}
410
- </div>
411
- </div>
412
- )}
413
- {formMessage && (
414
- <Alert variant={formMessage.type === 'success' ? 'success' : 'destructive'} className="mb-4">
415
- <AlertDescription>{formMessage.text}</AlertDescription>
416
- </Alert>
417
- )}
418
- {translationGroupId && (
419
- <input type="hidden" name="translation_group_id" value={translationGroupId} />
420
- )}
421
-
422
- {/* Row 1: Basic Page Information */}
423
- <div className="grid grid-cols-1 md:grid-cols-12 gap-4">
424
- {/* Title */}
425
- <div className="md:col-span-4 flex flex-col gap-1">
426
- <Label htmlFor="title" className="text-xs font-medium">Title</Label>
427
- <Input
428
- id="title"
429
- name="title"
430
- value={title}
431
- onChange={handleTitleChange}
432
- required
433
- className="h-9"
434
- />
435
- </div>
436
-
437
- {/* Slug */}
438
- <div className="md:col-span-4 flex flex-col gap-1">
439
- <Label htmlFor="slug" className="text-xs font-medium">Slug</Label>
440
- <Input
441
- id="slug"
442
- name="slug"
443
- value={slug}
444
- onChange={(e) => setSlug(e.target.value)}
445
- required
446
- className="h-9"
447
- />
448
- <p className="text-[10px] text-muted-foreground mt-0.5 leading-tight truncate" title="URL-friendly identifier. Auto-generated from title if left empty on creation.">
449
- URL-friendly identifier. Auto-generated from title if left empty.
450
- </p>
451
- </div>
452
-
453
- {/* Language. Visibility deliberately lives in the top bar, not here: this
454
- form autosaves into the Live Draft on every keystroke, and publishing
455
- must never be a side effect of editing settings. */}
456
- <div className="md:col-span-4 flex flex-col gap-1">
457
- <Label htmlFor="language_id" className="text-xs font-medium">Language</Label>
458
- {availableLanguages.length > 0 ? (
459
- <Select
460
- name="language_id"
461
- defaultValue={target_lang_id}
462
- value={languageId}
463
- onValueChange={setLanguageId}
464
- required
465
- >
466
- <SelectTrigger className="h-9">
467
- <SelectValue placeholder="Select language" />
468
- </SelectTrigger>
469
- <SelectContent>
470
- {availableLanguages.map((lang) => (
471
- <SelectItem key={lang.id} value={lang.id.toString()}>
472
- {lang.name} ({lang.code})
473
- </SelectItem>
474
- ))}
475
- </SelectContent>
476
- </Select>
477
- ) : (
478
- <p className="text-xs text-muted-foreground py-2 leading-none">No languages available.</p>
479
- )}
480
- </div>
481
- </div>
482
-
483
- {/* Row 2: SEO Settings. Canonical override (optional): blank = self-referencing canonical.
484
-
485
- This header is no longer gated on `isCortexAiActive`. It now carries the share-card
486
- trigger, which has nothing to do with the AI package and must be reachable on every
487
- install; only the generate button remains premium-gated, inside the row. */}
488
- <div className="flex flex-wrap items-center justify-between gap-2 pt-1">
489
- <span className="text-[11px] uppercase tracking-wider text-muted-foreground/80 font-semibold">
490
- Search &amp; social
491
- </span>
492
- <div className="flex flex-wrap items-center gap-2">
493
- {isCortexAiActive && (
494
- <GenerateMetaButton
495
- content={seoContent}
496
- locale={selectedLanguageCode}
497
- onGenerated={handleGeneratedMetadata}
498
- title={title}
499
- />
500
- )}
501
- {/* The share-card rehearsal lives in a modal rather than inline: it is a glance,
502
- not a field, and inline it added roughly four hundred pixels to a form that
503
- already scrolls. Sitting here it is adjacent to the two inputs it previews.
504
- Every prop is live form state, so the card repaints while the dialog is open,
505
- and none of it is written anywhere — see `SocialPreviewDialog`. */}
506
- <SocialPreviewDialog
507
- description={metaDescription}
508
- imageUrl={featureImageUrl}
509
- title={previewTitle}
510
- url={previewUrl}
511
- />
512
- </div>
513
- </div>
514
- <div className="grid grid-cols-1 md:grid-cols-12 gap-4">
515
- {/* Meta Title */}
516
- <div className="md:col-span-4 flex flex-col gap-1">
517
- <div className="flex items-center justify-between gap-2">
518
- <Label htmlFor="meta_title" className="text-xs font-medium">Meta Title (SEO)</Label>
519
- <span
520
- className={`text-[10px] ${
521
- metaTitle.length > META_TITLE_RECOMMENDED_MAX
522
- ? 'text-amber-600'
523
- : 'text-muted-foreground'
524
- }`}
525
- >
526
- {metaTitle.length}/{META_TITLE_RECOMMENDED_MAX}
527
- </span>
528
- </div>
529
- <Input
530
- id="meta_title"
531
- name="meta_title"
532
- value={metaTitle}
533
- onChange={(e) => setMetaTitle(e.target.value)}
534
- className="h-9"
535
- />
536
- </div>
537
-
538
- {/* Meta Description */}
539
- <div className="md:col-span-4 flex flex-col gap-1">
540
- <div className="flex items-center justify-between gap-2">
541
- <Label htmlFor="meta_description" className="text-xs font-medium">Meta Description (SEO)</Label>
542
- <span
543
- className={`text-[10px] ${
544
- metaDescription.length > META_DESCRIPTION_RECOMMENDED_MAX
545
- ? 'text-amber-600'
546
- : 'text-muted-foreground'
547
- }`}
548
- >
549
- {metaDescription.length}/{META_DESCRIPTION_RECOMMENDED_MAX}
550
- </span>
551
- </div>
552
- <Textarea
553
- id="meta_description"
554
- name="meta_description"
555
- value={metaDescription}
556
- onChange={(e) => setMetaDescription(e.target.value)}
557
- className="min-h-[36px] h-9 py-1.5 resize-y text-sm leading-normal"
558
- rows={1}
559
- placeholder="Meta description for search engines..."
560
- />
561
- </div>
562
-
563
- {/* Canonical URL */}
564
- <div className="md:col-span-4 flex flex-col gap-1">
565
- <Label htmlFor="custom_canonical" className="text-xs font-medium">Canonical URL (SEO, optional)</Label>
566
- <Input
567
- id="custom_canonical"
568
- name="custom_canonical"
569
- value={customCanonical}
570
- onChange={(e) => setCustomCanonical(e.target.value)}
571
- className="h-9"
572
- placeholder="Blank = self-referencing. Absolute https://… URL or /relative path to override."
573
- />
574
- </div>
575
- </div>
576
-
577
- {/* Social-specific copy Cortex AI suggested. It is shown, not stored: pages have no
578
- Open Graph columns, so the only way it can survive is by being folded into the
579
- meta fields — hence the one-click apply rather than a read-only curiosity. */}
580
- {socialSuggestion && (
581
- <div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
582
- <p className="text-[11px] font-medium text-muted-foreground">
583
- Cortex AI also drafted social-specific copy. Pages have no separate Open Graph
584
- fields, so this is only a suggestion for the meta fields above.
585
- </p>
586
- {socialSuggestion.ogTitle && (
587
- <p className="text-xs text-foreground"><strong>Social title:</strong> {socialSuggestion.ogTitle}</p>
588
- )}
589
- {socialSuggestion.ogDescription && (
590
- <p className="text-xs text-foreground"><strong>Social description:</strong> {socialSuggestion.ogDescription}</p>
591
- )}
592
- <div className="flex gap-2">
593
- <Button
594
- type="button"
595
- variant="outline"
596
- size="sm"
597
- className="h-7 px-2 text-[11px]"
598
- onClick={() => {
599
- if (socialSuggestion.ogTitle) setMetaTitle(socialSuggestion.ogTitle);
600
- if (socialSuggestion.ogDescription) setMetaDescription(socialSuggestion.ogDescription);
601
- }}
602
- >
603
- Use for meta fields
604
- </Button>
605
- <Button
606
- type="button"
607
- variant="ghost"
608
- size="sm"
609
- className="h-7 px-2 text-[11px]"
610
- onClick={() => setSocialSuggestion(null)}
611
- >
612
- Dismiss
613
- </Button>
614
- </div>
615
- </div>
616
- )}
617
-
618
- <FeatureImageField
619
- initialImageId={initialFeatureImageId || page?.feature_image_id || null}
620
- initialImageUrl={initialFeatureImageUrl || null}
621
- onImageIdChange={handleFeatureImageChange}
622
- uploadFolder={`pages/${(slug || 'untitled').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')}/`}
623
- />
624
-
625
- {!isEditing && (
626
- <div className="flex justify-end space-x-3">
627
- <Button
628
- type="button"
629
- variant="outline"
630
- onClick={() => router.push("/cms/pages")}
631
- disabled={isPending}
632
- >
633
- Cancel
634
- </Button>
635
- {/* Ensure button is not disabled due to removed languagesLoading */}
636
- <Button type="submit" disabled={isPending || authLoading || availableLanguages.length === 0}>
637
- {isPending ? (
638
- <>
639
- <Spinner className="mr-2 h-4 w-4" /> Saving...
640
- </>
641
- ) : (
642
- actionButtonText
643
- )}
644
- </Button>
645
- </div>
646
- )}
647
- </form>
648
- );
649
- }
1
+ // app/cms/pages/components/PageForm.tsx
2
+ "use client";
3
+
4
+ import { useEffect, useMemo, useRef, useState, useTransition } from "react";
5
+ import { useRouter, useSearchParams } from "next/navigation";
6
+ import { Button } from "@nextblock-cms/ui";
7
+ import { Spinner, Alert, AlertDescription } from "@nextblock-cms/ui";
8
+ import { Input } from "@nextblock-cms/ui";
9
+ import { Label } from "@nextblock-cms/ui";
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from "@nextblock-cms/ui";
17
+ import { Textarea } from "@nextblock-cms/ui";
18
+ import type { Database } from "@nextblock-cms/db";
19
+ import { useAuth } from '../../../../context/AuthContext';
20
+ import { useHotkeys } from '../../../../hooks/use-hotkeys';
21
+ import FeatureImageField from "../../components/FeatureImageField";
22
+ import { useCortexAiActive } from "../../components/CortexAiActiveContext";
23
+ import GenerateMetaButton, {
24
+ type GeneratedSeoMetadata,
25
+ } from "../../../../components/seo/GenerateMetaButton";
26
+ import SocialPreviewDialog from "../../../../components/seo/SocialPreviewDialog";
27
+ import { buildSeoContentForGeneration } from "../../../../lib/seo/block-content";
28
+ import { usePageSeo } from "../../../../lib/seo/page-audit-context";
29
+ import { resolveSiteUrl } from "../../../../lib/site-url";
30
+
31
+ type Page = Database['public']['Tables']['pages']['Row'];
32
+ type Language = Database['public']['Tables']['languages']['Row'];
33
+ // Remove: import { getActiveLanguagesClientSide } from "@nextblock-cms/db";
34
+
35
+ /**
36
+ * Google truncates a title around 60 characters and a description around 160. These are
37
+ * soft advice, not validation — the same numbers `SiteSeoSettingsForm` shows, and the same
38
+ * amber-past-the-limit treatment, so the counter means one thing everywhere in the CMS.
39
+ */
40
+ const META_TITLE_RECOMMENDED_MAX = 60;
41
+ const META_DESCRIPTION_RECOMMENDED_MAX = 160;
42
+
43
+ interface PageFormProps {
44
+ page?: (Page & { feature_image_id?: string | null }) | null;
45
+ formAction: (formData: FormData) => Promise<{ error?: string } | void>;
46
+ actionButtonText?: string;
47
+ isEditing?: boolean;
48
+ availableLanguagesProp: Language[]; // New prop
49
+ translationGroupId?: string;
50
+ target_lang_id?: string;
51
+ initialFeatureImageUrl?: string | null;
52
+ initialFeatureImageId?: string | null;
53
+ /**
54
+ * The page's block rows, read-only, purely so Cortex AI can be asked to summarize the
55
+ * page it is writing metadata for.
56
+ *
57
+ * Deliberately NOT a form field and NOT persisted by this form: nothing here is added to
58
+ * the FormData, to `hasChanges`, or to the autosave dependency list. It flows one way,
59
+ * parent → form → AI request. Blocks have their own editor and their own save path
60
+ * (`BlockEditorArea`); this form must never become a second writer of them.
61
+ */
62
+ contentBlocks?: unknown;
63
+ }
64
+
65
+ export default function PageForm({
66
+ page,
67
+ formAction,
68
+ actionButtonText = "Save Page",
69
+ isEditing = false,
70
+ availableLanguagesProp, // Use the new prop
71
+ translationGroupId,
72
+ target_lang_id,
73
+ initialFeatureImageUrl,
74
+ initialFeatureImageId,
75
+ contentBlocks,
76
+ }: PageFormProps) {
77
+ const router = useRouter();
78
+ const searchParams = useSearchParams();
79
+ const [isPending, startTransition] = useTransition();
80
+ const { user, isLoading: authLoading } = useAuth();
81
+
82
+ const [title, setTitle] = useState(page?.title || "");
83
+ const [slug, setSlug] = useState(page?.slug || "");
84
+ const [languageId, setLanguageId] = useState<string>(() => {
85
+ // If editing, use the page's language
86
+ if (page?.language_id) {
87
+ return page.language_id.toString();
88
+ }
89
+ // If creating a translation, use the target language
90
+ if (target_lang_id) {
91
+ return target_lang_id;
92
+ }
93
+ // Otherwise, find the default language from the available languages
94
+ if (availableLanguagesProp && availableLanguagesProp.length > 0) {
95
+ const defaultLang = availableLanguagesProp.find((l) => l.is_default);
96
+ if (defaultLang) {
97
+ return defaultLang.id.toString();
98
+ }
99
+ // As a fallback, use the first available language
100
+ return availableLanguagesProp[0].id.toString();
101
+ }
102
+ // If no languages are available, default to an empty string
103
+ return "";
104
+ });
105
+ const [metaTitle, setMetaTitle] = useState(page?.meta_title || "");
106
+ const [metaDescription, setMetaDescription] = useState(
107
+ page?.meta_description || ""
108
+ );
109
+ const [customCanonical, setCustomCanonical] = useState(page?.custom_canonical || "");
110
+ const [featureImageId, setFeatureImageId] = useState<string | null>(
111
+ initialFeatureImageId || page?.feature_image_id || null
112
+ );
113
+ /**
114
+ * The resolved URL of the feature image, for the share preview only.
115
+ *
116
+ * This one is exempt from everything the autosave path looks at, and the exemption is
117
+ * deliberate rather than an oversight: it is absent from the FormData, from `hasChanges`,
118
+ * and from the autosave effect's dependency array. `feature_image_id` is the persisted
119
+ * field — `FeatureImageField` renders it as the hidden input and the diff above already
120
+ * watches it — and the URL is nothing but that id resolved against the `media` row, which
121
+ * the server redoes on the next render. Persisting it would store a duplicate that can go
122
+ * stale, and adding it to the autosave deps would only schedule a second save for the
123
+ * change `featureImageId` already covers.
124
+ *
125
+ * It exists because the preview was previously bound to `initialFeatureImageUrl`, which
126
+ * is fixed for the life of the render — so picking an image left the share card showing
127
+ * the old one (or a placeholder) until a full reload, which defeats a live preview.
128
+ */
129
+ const [featureImageUrl, setFeatureImageUrl] = useState<string | null>(
130
+ initialFeatureImageUrl || null
131
+ );
132
+
133
+ const isCortexAiActive = useCortexAiActive();
134
+ /**
135
+ * The social-specific copy Cortex AI returns alongside the meta fields.
136
+ *
137
+ * This is held in component state and never written anywhere. There are no
138
+ * `og_title` / `og_description` columns on `pages`, and adding one is not a one-line
139
+ * change — a new persisted field needs the useState, the sync-from-prop effect AND its
140
+ * dependency array, `hasChanges`, the autosave deps, the FormData fallback list, the JSX
141
+ * `name`, the server action's rawFormData/updateData/payload type, and the draft overlay
142
+ * in the edit RSC, and missing any one of those makes the field silently never save.
143
+ * So the OG copy exists to inform the meta fields, which DO persist, and the operator can
144
+ * fold it in with one click. The live share card is built from the meta title and
145
+ * description below plus the feature image.
146
+ */
147
+ const [socialSuggestion, setSocialSuggestion] = useState<{
148
+ ogDescription: string;
149
+ ogTitle: string;
150
+ } | null>(null);
151
+
152
+ /**
153
+ * Prose for the metadata call. Memoized because this form re-renders on every keystroke
154
+ * (each one re-arms the autosave timer) and flattening a page's whole block tree on each
155
+ * of those would be real, pointless work — the blocks themselves cannot change while
156
+ * this form is mounted, since they are edited by `BlockEditorArea` below.
157
+ *
158
+ * On a brand-new page with no blocks the title is a thin but genuine brief, which beats
159
+ * a permanently disabled button on day one.
160
+ */
161
+ const seoContent = useMemo(
162
+ () => buildSeoContentForGeneration(contentBlocks, title),
163
+ [contentBlocks, title]
164
+ );
165
+
166
+ // Use the passed-in languages
167
+ const [availableLanguages] = useState<Language[]>(availableLanguagesProp);
168
+ // languagesLoading is no longer needed if languages are passed as props
169
+ // const [languagesLoading, setLanguagesLoading] = useState(true); // Remove or set to false initially
170
+
171
+ const [formMessage, setFormMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
172
+ const formRef = useRef<HTMLFormElement>(null);
173
+ const [isSaving, setIsSaving] = useState(false);
174
+ const [saveError, setSaveError] = useState<string | null>(null);
175
+ const [lastSaved, setLastSaved] = useState<Date | null>(null);
176
+ const isFirstRender = useRef(true);
177
+
178
+ useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
179
+
180
+ useEffect(() => {
181
+ const successMessage = searchParams.get('success');
182
+ const errorMessage = searchParams.get('error');
183
+ if (successMessage) {
184
+ setFormMessage({ type: 'success', text: successMessage });
185
+ } else if (errorMessage) {
186
+ setFormMessage({ type: 'error', text: errorMessage });
187
+ }
188
+ }, [searchParams]);
189
+
190
+ useEffect(() => {
191
+ if (!page) {
192
+ return;
193
+ }
194
+
195
+ setTitle(page.title || "");
196
+ setSlug(page.slug || "");
197
+ setLanguageId(page.language_id?.toString() || "");
198
+ setMetaTitle(page.meta_title || "");
199
+ setMetaDescription(page.meta_description || "");
200
+ setCustomCanonical(page.custom_canonical || "");
201
+ setFeatureImageId(initialFeatureImageId || page.feature_image_id || null);
202
+ // Re-seeded in lockstep with the id above. Both come from the same server-resolved
203
+ // media row, so letting the id snap back to the props while the URL kept a newer value
204
+ // would put the preview and the field it previews out of step.
205
+ setFeatureImageUrl(initialFeatureImageUrl || null);
206
+ }, [
207
+ initialFeatureImageId,
208
+ initialFeatureImageUrl,
209
+ page?.id,
210
+ page?.custom_canonical,
211
+ page?.language_id,
212
+ page?.meta_description,
213
+ page?.meta_title,
214
+ page?.slug,
215
+ page?.title,
216
+ page?.updated_at,
217
+ ]);
218
+
219
+ const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
220
+ const newTitle = e.target.value;
221
+ setTitle(newTitle);
222
+ if (!isEditing || !slug) {
223
+ setSlug(newTitle.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]+/g, ""));
224
+ }
225
+ };
226
+
227
+ const saveDraft = async (customFormData?: FormData) => {
228
+ if (!title.trim() || !slug.trim()) {
229
+ return;
230
+ }
231
+ setIsSaving(true);
232
+ setSaveError(null);
233
+
234
+ const formData = customFormData || (formRef.current ? new FormData(formRef.current) : new FormData());
235
+ if (!customFormData && !formRef.current) {
236
+ formData.append("title", title);
237
+ formData.append("slug", slug);
238
+ formData.append("language_id", languageId);
239
+ formData.append("meta_title", metaTitle);
240
+ formData.append("meta_description", metaDescription);
241
+ formData.append("custom_canonical", customCanonical);
242
+ formData.append("feature_image_id", featureImageId || "");
243
+ if (translationGroupId) {
244
+ formData.append("translation_group_id", translationGroupId);
245
+ }
246
+ }
247
+
248
+ try {
249
+ const result = await formAction(formData);
250
+ if (result && 'error' in result && result.error) {
251
+ setSaveError(result.error);
252
+ setFormMessage({ type: 'error', text: result.error });
253
+ } else {
254
+ setLastSaved(new Date());
255
+ setFormMessage(null);
256
+ router.refresh();
257
+ }
258
+ } catch (err: any) {
259
+ const msg = err.message || "Failed to save draft";
260
+ setSaveError(msg);
261
+ setFormMessage({ type: 'error', text: msg });
262
+ } finally {
263
+ setIsSaving(false);
264
+ }
265
+ };
266
+
267
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
268
+ event.preventDefault();
269
+ if (!isEditing) {
270
+ setFormMessage(null);
271
+ const formData = new FormData(event.currentTarget);
272
+
273
+ startTransition(async () => {
274
+ const result = await formAction(formData);
275
+ if (result?.error) {
276
+ setFormMessage({ type: 'error', text: result.error });
277
+ }
278
+ });
279
+ } else {
280
+ await saveDraft();
281
+ }
282
+ };
283
+
284
+ useEffect(() => {
285
+ if (!isEditing) return;
286
+
287
+ if (isFirstRender.current) {
288
+ isFirstRender.current = false;
289
+ return;
290
+ }
291
+
292
+ const hasChanges =
293
+ title !== (page?.title || "") ||
294
+ slug !== (page?.slug || "") ||
295
+ languageId !== (page?.language_id?.toString() || "") ||
296
+ metaTitle !== (page?.meta_title || "") ||
297
+ metaDescription !== (page?.meta_description || "") ||
298
+ customCanonical !== (page?.custom_canonical || "") ||
299
+ featureImageId !== (page?.feature_image_id || null);
300
+
301
+ if (!hasChanges) return;
302
+
303
+ const timer = setTimeout(() => {
304
+ saveDraft();
305
+ }, 1000);
306
+
307
+ return () => clearTimeout(timer);
308
+ // customCanonical belongs here too: hasChanges checks it, so leaving it out
309
+ // meant editing only the canonical URL never scheduled a save.
310
+ }, [title, slug, languageId, metaTitle, metaDescription, customCanonical, featureImageId, page, isEditing]);
311
+
312
+ /**
313
+ * Hand the live meta fields to the page-level SEO audit.
314
+ *
315
+ * The panel that grades a page cannot read this form's state directly — it renders as a
316
+ * sibling, not a child — so the title and description have to be published outward. This
317
+ * is a one-way broadcast of values this form ALREADY owns and already persists; it adds
318
+ * no field, changes nothing about what `hasChanges` above compares, and puts nothing in
319
+ * the FormData. The autosave diff is deliberately untouched, because publishing a value
320
+ * is not the same event as changing one: were `setMeta` wired into that diff, merely
321
+ * mounting the audit panel would look like an edit and schedule a draft write.
322
+ *
323
+ * `usePageSeo()` returns null when no provider is above this form, which is a supported
324
+ * arrangement rather than a bug — some surfaces embed these fields without a page-level
325
+ * panel — so the call is optional all the way through.
326
+ *
327
+ * The dependency is `setPageSeoMeta`, not the whole context value: the setter is a stable
328
+ * `useCallback`, whereas the context object is rebuilt whenever the blocks array changes,
329
+ * which would re-run this effect on every keystroke elsewhere in the editor. The setter
330
+ * also bails when the values are unchanged, so there is no path from this effect back into
331
+ * a render of this form and therefore no loop.
332
+ */
333
+ const setPageSeoMeta = usePageSeo()?.setMeta;
334
+ useEffect(() => {
335
+ setPageSeoMeta?.({ metaDescription, metaTitle });
336
+ }, [metaDescription, metaTitle, setPageSeoMeta]);
337
+
338
+ // Removed languagesLoading from this condition
339
+ if (authLoading) {
340
+ return <div>Loading form...</div>;
341
+ }
342
+
343
+ if (!user) {
344
+ return <div>Please log in to manage pages.</div>;
345
+ }
346
+
347
+ const selectedLanguageCode =
348
+ availableLanguages.find((lang) => lang.id.toString() === languageId)?.code ?? null;
349
+ // What the share card will actually show. Meta title wins when set, because that is what
350
+ // `buildSocialMetadata` uses; otherwise the page title, exactly as the public site does.
351
+ const previewTitle = metaTitle.trim() || title.trim();
352
+ const previewUrl = `${resolveSiteUrl()}/${(slug || "").replace(/^\/+/, "")}`;
353
+
354
+ /**
355
+ * Fold a generation into the form.
356
+ *
357
+ * Setting state is the whole persistence story here: the autosave effect above diffs
358
+ * this state against the `page` prop and calls `formAction` a second later, writing into
359
+ * `content_drafts`. A direct row UPDATE from this handler would bypass the draft and
360
+ * mutate the live page — precisely the thing draft mode exists to prevent.
361
+ */
362
+ /**
363
+ * Track the picked image for the preview as well as for the save.
364
+ *
365
+ * The id half feeds `hasChanges` and therefore the autosave; the URL half is purely
366
+ * presentational (see the state declaration above) and stops here.
367
+ */
368
+ const handleFeatureImageChange = (imageId: string | null, imageUrl: string | null) => {
369
+ setFeatureImageId(imageId);
370
+ setFeatureImageUrl(imageUrl);
371
+ };
372
+
373
+ const handleGeneratedMetadata = (result: GeneratedSeoMetadata) => {
374
+ if (result.metaTitle) {
375
+ setMetaTitle(result.metaTitle);
376
+ }
377
+ if (result.metaDescription) {
378
+ setMetaDescription(result.metaDescription);
379
+ }
380
+ setSocialSuggestion(
381
+ result.ogTitle || result.ogDescription
382
+ ? { ogDescription: result.ogDescription, ogTitle: result.ogTitle }
383
+ : null
384
+ );
385
+ };
386
+
387
+ return (
388
+ <form ref={formRef} onSubmit={handleSubmit} className="space-y-4 w-full mx-auto px-6">
389
+ {isEditing && (
390
+ <div className="flex items-center justify-between text-xs text-muted-foreground pb-2 border-b border-border/40 mb-2">
391
+ <span className="font-semibold text-[11px] uppercase tracking-wider text-muted-foreground/80">Page Settings</span>
392
+ <div className="flex items-center gap-1.5 min-h-[16px]">
393
+ {isSaving ? (
394
+ <>
395
+ <div className="relative flex h-2 w-2">
396
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
397
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
398
+ </div>
399
+ <span className="text-amber-600 dark:text-amber-400 font-medium">Autosaving settings...</span>
400
+ </>
401
+ ) : saveError ? (
402
+ <span className="text-red-500 font-medium">Error saving settings: {saveError}</span>
403
+ ) : lastSaved ? (
404
+ <span className="text-emerald-600 dark:text-emerald-400 font-medium">
405
+ Settings autosaved at {lastSaved.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
406
+ </span>
407
+ ) : (
408
+ <span className="text-muted-foreground/60">Settings autosave in draft mode</span>
409
+ )}
410
+ </div>
411
+ </div>
412
+ )}
413
+ {formMessage && (
414
+ <Alert variant={formMessage.type === 'success' ? 'success' : 'destructive'} className="mb-4">
415
+ <AlertDescription>{formMessage.text}</AlertDescription>
416
+ </Alert>
417
+ )}
418
+ {translationGroupId && (
419
+ <input type="hidden" name="translation_group_id" value={translationGroupId} />
420
+ )}
421
+
422
+ {/* Row 1: Basic Page Information */}
423
+ <div className="grid grid-cols-1 md:grid-cols-12 gap-4">
424
+ {/* Title */}
425
+ <div className="md:col-span-4 flex flex-col gap-1">
426
+ <Label htmlFor="title" className="text-xs font-medium">Title</Label>
427
+ <Input
428
+ id="title"
429
+ name="title"
430
+ value={title}
431
+ onChange={handleTitleChange}
432
+ required
433
+ className="h-9"
434
+ />
435
+ </div>
436
+
437
+ {/* Slug */}
438
+ <div className="md:col-span-4 flex flex-col gap-1">
439
+ <Label htmlFor="slug" className="text-xs font-medium">Slug</Label>
440
+ <Input
441
+ id="slug"
442
+ name="slug"
443
+ value={slug}
444
+ onChange={(e) => setSlug(e.target.value)}
445
+ required
446
+ className="h-9"
447
+ />
448
+ <p className="text-[10px] text-muted-foreground mt-0.5 leading-tight truncate" title="URL-friendly identifier. Auto-generated from title if left empty on creation.">
449
+ URL-friendly identifier. Auto-generated from title if left empty.
450
+ </p>
451
+ </div>
452
+
453
+ {/* Language. Visibility deliberately lives in the top bar, not here: this
454
+ form autosaves into the Live Draft on every keystroke, and publishing
455
+ must never be a side effect of editing settings. */}
456
+ <div className="md:col-span-4 flex flex-col gap-1">
457
+ <Label htmlFor="language_id" className="text-xs font-medium">Language</Label>
458
+ {availableLanguages.length > 0 ? (
459
+ <Select
460
+ name="language_id"
461
+ defaultValue={target_lang_id}
462
+ value={languageId}
463
+ onValueChange={setLanguageId}
464
+ required
465
+ >
466
+ <SelectTrigger className="h-9">
467
+ <SelectValue placeholder="Select language" />
468
+ </SelectTrigger>
469
+ <SelectContent>
470
+ {availableLanguages.map((lang) => (
471
+ <SelectItem key={lang.id} value={lang.id.toString()}>
472
+ {lang.name} ({lang.code})
473
+ </SelectItem>
474
+ ))}
475
+ </SelectContent>
476
+ </Select>
477
+ ) : (
478
+ <p className="text-xs text-muted-foreground py-2 leading-none">No languages available.</p>
479
+ )}
480
+ </div>
481
+ </div>
482
+
483
+ {/* Row 2: SEO Settings. Canonical override (optional): blank = self-referencing canonical.
484
+
485
+ This header is no longer gated on `isCortexAiActive`. It now carries the share-card
486
+ trigger, which has nothing to do with the AI package and must be reachable on every
487
+ install; only the generate button remains premium-gated, inside the row. */}
488
+ <div className="flex flex-wrap items-center justify-between gap-2 pt-1">
489
+ <span className="text-[11px] uppercase tracking-wider text-muted-foreground/80 font-semibold">
490
+ Search &amp; social
491
+ </span>
492
+ <div className="flex flex-wrap items-center gap-2">
493
+ {isCortexAiActive && (
494
+ <GenerateMetaButton
495
+ content={seoContent}
496
+ locale={selectedLanguageCode}
497
+ onGenerated={handleGeneratedMetadata}
498
+ title={title}
499
+ />
500
+ )}
501
+ {/* The share-card rehearsal lives in a modal rather than inline: it is a glance,
502
+ not a field, and inline it added roughly four hundred pixels to a form that
503
+ already scrolls. Sitting here it is adjacent to the two inputs it previews.
504
+ Every prop is live form state, so the card repaints while the dialog is open,
505
+ and none of it is written anywhere — see `SocialPreviewDialog`. */}
506
+ <SocialPreviewDialog
507
+ description={metaDescription}
508
+ imageUrl={featureImageUrl}
509
+ title={previewTitle}
510
+ url={previewUrl}
511
+ />
512
+ </div>
513
+ </div>
514
+ <div className="grid grid-cols-1 md:grid-cols-12 gap-4">
515
+ {/* Meta Title */}
516
+ <div className="md:col-span-4 flex flex-col gap-1">
517
+ <div className="flex items-center justify-between gap-2">
518
+ <Label htmlFor="meta_title" className="text-xs font-medium">Meta Title (SEO)</Label>
519
+ <span
520
+ className={`text-[10px] ${
521
+ metaTitle.length > META_TITLE_RECOMMENDED_MAX
522
+ ? 'text-amber-600'
523
+ : 'text-muted-foreground'
524
+ }`}
525
+ >
526
+ {metaTitle.length}/{META_TITLE_RECOMMENDED_MAX}
527
+ </span>
528
+ </div>
529
+ <Input
530
+ id="meta_title"
531
+ name="meta_title"
532
+ value={metaTitle}
533
+ onChange={(e) => setMetaTitle(e.target.value)}
534
+ className="h-9"
535
+ />
536
+ </div>
537
+
538
+ {/* Meta Description */}
539
+ <div className="md:col-span-4 flex flex-col gap-1">
540
+ <div className="flex items-center justify-between gap-2">
541
+ <Label htmlFor="meta_description" className="text-xs font-medium">Meta Description (SEO)</Label>
542
+ <span
543
+ className={`text-[10px] ${
544
+ metaDescription.length > META_DESCRIPTION_RECOMMENDED_MAX
545
+ ? 'text-amber-600'
546
+ : 'text-muted-foreground'
547
+ }`}
548
+ >
549
+ {metaDescription.length}/{META_DESCRIPTION_RECOMMENDED_MAX}
550
+ </span>
551
+ </div>
552
+ <Textarea
553
+ id="meta_description"
554
+ name="meta_description"
555
+ value={metaDescription}
556
+ onChange={(e) => setMetaDescription(e.target.value)}
557
+ className="min-h-[36px] h-9 py-1.5 resize-y text-sm leading-normal"
558
+ rows={1}
559
+ placeholder="Meta description for search engines..."
560
+ />
561
+ </div>
562
+
563
+ {/* Canonical URL */}
564
+ <div className="md:col-span-4 flex flex-col gap-1">
565
+ <Label htmlFor="custom_canonical" className="text-xs font-medium">Canonical URL (SEO, optional)</Label>
566
+ <Input
567
+ id="custom_canonical"
568
+ name="custom_canonical"
569
+ value={customCanonical}
570
+ onChange={(e) => setCustomCanonical(e.target.value)}
571
+ className="h-9"
572
+ placeholder="Blank = self-referencing. Absolute https://… URL or /relative path to override."
573
+ />
574
+ </div>
575
+ </div>
576
+
577
+ {/* Social-specific copy Cortex AI suggested. It is shown, not stored: pages have no
578
+ Open Graph columns, so the only way it can survive is by being folded into the
579
+ meta fields — hence the one-click apply rather than a read-only curiosity. */}
580
+ {socialSuggestion && (
581
+ <div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
582
+ <p className="text-[11px] font-medium text-muted-foreground">
583
+ Cortex AI also drafted social-specific copy. Pages have no separate Open Graph
584
+ fields, so this is only a suggestion for the meta fields above.
585
+ </p>
586
+ {socialSuggestion.ogTitle && (
587
+ <p className="text-xs text-foreground"><strong>Social title:</strong> {socialSuggestion.ogTitle}</p>
588
+ )}
589
+ {socialSuggestion.ogDescription && (
590
+ <p className="text-xs text-foreground"><strong>Social description:</strong> {socialSuggestion.ogDescription}</p>
591
+ )}
592
+ <div className="flex gap-2">
593
+ <Button
594
+ type="button"
595
+ variant="outline"
596
+ size="sm"
597
+ className="h-7 px-2 text-[11px]"
598
+ onClick={() => {
599
+ if (socialSuggestion.ogTitle) setMetaTitle(socialSuggestion.ogTitle);
600
+ if (socialSuggestion.ogDescription) setMetaDescription(socialSuggestion.ogDescription);
601
+ }}
602
+ >
603
+ Use for meta fields
604
+ </Button>
605
+ <Button
606
+ type="button"
607
+ variant="ghost"
608
+ size="sm"
609
+ className="h-7 px-2 text-[11px]"
610
+ onClick={() => setSocialSuggestion(null)}
611
+ >
612
+ Dismiss
613
+ </Button>
614
+ </div>
615
+ </div>
616
+ )}
617
+
618
+ <FeatureImageField
619
+ initialImageId={initialFeatureImageId || page?.feature_image_id || null}
620
+ initialImageUrl={initialFeatureImageUrl || null}
621
+ onImageIdChange={handleFeatureImageChange}
622
+ uploadFolder={`pages/${(slug || 'untitled').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')}/`}
623
+ />
624
+
625
+ {!isEditing && (
626
+ <div className="flex justify-end space-x-3">
627
+ <Button
628
+ type="button"
629
+ variant="outline"
630
+ onClick={() => router.push("/cms/pages")}
631
+ disabled={isPending}
632
+ >
633
+ Cancel
634
+ </Button>
635
+ {/* Ensure button is not disabled due to removed languagesLoading */}
636
+ <Button type="submit" disabled={isPending || authLoading || availableLanguages.length === 0}>
637
+ {isPending ? (
638
+ <>
639
+ <Spinner className="mr-2 h-4 w-4" /> Saving...
640
+ </>
641
+ ) : (
642
+ actionButtonText
643
+ )}
644
+ </Button>
645
+ </div>
646
+ )}
647
+ </form>
648
+ );
649
+ }