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