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,243 +1,243 @@
1
- 'use client';
2
-
3
- import { useMemo, useState } from 'react';
4
- import { ImageIcon, MessageCircle, Repeat2, Heart, ThumbsUp, Share2 } from 'lucide-react';
5
- import { cn } from '@nextblock-cms/utils';
6
-
7
- /**
8
- * A read-only rehearsal of the share card a page will produce.
9
- *
10
- * The value here is not decoration. Meta title and description are the two fields an
11
- * operator is most likely to get wrong in a way nothing else in the CMS will catch:
12
- * they are invisible on the page itself, and the consequence — a headline chopped
13
- * mid-word in someone's feed — only shows up after the link has already been shared.
14
- * Rendering the truncation locally turns that into a visible, immediate edit.
15
- *
16
- * The three tabs are genuinely different, not one box repainted, because the platforms
17
- * genuinely differ. Facebook shows a wide image above a grey caption bar with the domain
18
- * in small caps and a hard title clamp. X's summary_large_image card has no caption bar
19
- * and stamps the domain UNDER the text. LinkedIn keeps the image and gives the headline
20
- * more room but drops the description entirely — which is exactly the surprise worth
21
- * showing someone who has just spent five minutes wording a description. If all three
22
- * looked the same, the component would be teaching a falsehood.
23
- *
24
- * The numbers below approximate observed rendering rather than a published spec — every
25
- * platform reserves the right to reflow — so they are tuned to be slightly pessimistic.
26
- * A preview that clips a little early nudges toward safer copy; one that clips late gives
27
- * false confidence, which is the failure that actually costs something.
28
- */
29
- interface SocialPreviewProps {
30
- description: string;
31
- imageUrl?: string | null;
32
- siteName?: string | null;
33
- title: string;
34
- url: string;
35
- }
36
-
37
- type SocialPlatform = 'facebook' | 'linkedin' | 'x';
38
-
39
- const PLATFORM_TABS: ReadonlyArray<{ id: SocialPlatform; label: string }> = [
40
- { id: 'facebook', label: 'Facebook' },
41
- { id: 'x', label: 'X' },
42
- { id: 'linkedin', label: 'LinkedIn' },
43
- ];
44
-
45
- /**
46
- * Per-platform clamps, in characters. `description: 0` means the platform does not render
47
- * a description at all — a meaningfully different statement from "renders a short one",
48
- * and drawn differently below.
49
- */
50
- const PLATFORM_LIMITS: Record<SocialPlatform, { description: number; title: number }> = {
51
- facebook: { description: 110, title: 65 },
52
- linkedin: { description: 0, title: 90 },
53
- x: { description: 125, title: 70 },
54
- };
55
-
56
- /**
57
- * Clip to a character budget the way a feed does — at a word boundary, with an ellipsis —
58
- * rather than mid-word, so the preview shows the shape of the real damage.
59
- */
60
- function clampToLength(value: string, maxLength: number): string {
61
- const normalized = value.replace(/\s+/g, ' ').trim();
62
- if (maxLength <= 0 || normalized.length <= maxLength) {
63
- return normalized;
64
- }
65
-
66
- const sliced = normalized.slice(0, maxLength);
67
- const lastSpace = sliced.lastIndexOf(' ');
68
- const kept = lastSpace > maxLength * 0.6 ? sliced.slice(0, lastSpace) : sliced;
69
- return `${kept.trimEnd()}…`;
70
- }
71
-
72
- /**
73
- * The domain each platform stamps on the card. Falls back to the raw string when the URL
74
- * is not parseable — while a slug is being typed, `url` is briefly not a valid URL.
75
- */
76
- function toDisplayDomain(url: string): string {
77
- try {
78
- return new URL(url).hostname.replace(/^www\./, '');
79
- } catch {
80
- return url.replace(/^https?:\/\//i, '').split('/')[0] || url;
81
- }
82
- }
83
-
84
- /**
85
- * Remote share images render through a plain `<img>`, deliberately.
86
- *
87
- * `next/image` would require every host an operator might point a feature image at to be
88
- * pre-declared in `next.config` `images.remotePatterns`, and an undeclared host does not
89
- * degrade — it throws. The CMS already makes this call the same way: the external-URL
90
- * branch of `ImageBlockEditor` drops to a plain `<img>` for precisely this reason. A
91
- * preview thumbnail gains nothing from the optimizer in exchange for that fragility.
92
- */
93
- function PreviewImage({
94
- aspectClassName,
95
- imageUrl,
96
- title,
97
- }: {
98
- aspectClassName: string;
99
- imageUrl?: string | null;
100
- title: string;
101
- }) {
102
- if (!imageUrl) {
103
- return (
104
- <div
105
- className={cn(
106
- 'flex w-full flex-col items-center justify-center gap-1 bg-muted text-muted-foreground',
107
- aspectClassName
108
- )}
109
- >
110
- <ImageIcon aria-hidden="true" className="h-6 w-6" />
111
- <span className="text-[10px] font-medium uppercase tracking-wide">No share image</span>
112
- </div>
113
- );
114
- }
115
-
116
- return (
117
- // eslint-disable-next-line @next/next/no-img-element
118
- <img
119
- alt={`Share preview for ${title}`}
120
- className={cn('w-full bg-muted object-cover', aspectClassName)}
121
- loading="lazy"
122
- src={imageUrl}
123
- />
124
- );
125
- }
126
-
127
- export default function SocialPreview({
128
- description,
129
- imageUrl,
130
- siteName,
131
- title,
132
- url,
133
- }: SocialPreviewProps) {
134
- const [platform, setPlatform] = useState<SocialPlatform>('facebook');
135
-
136
- const domain = useMemo(() => toDisplayDomain(url), [url]);
137
- const limits = PLATFORM_LIMITS[platform];
138
- const clampedTitle = clampToLength(title || 'Untitled', limits.title);
139
- const clampedDescription = clampToLength(description, limits.description);
140
- // LinkedIn labels the card with the publisher name when one is known and quietly falls
141
- // back to the bare domain when it is not; Facebook and X only ever show the domain.
142
- const publisher = (siteName ?? '').trim() || domain;
143
-
144
- return (
145
- <div className="space-y-2">
146
- {/* Hand-rolled tabs: this design system ships no Tabs primitive, and the house
147
- pattern for a small switcher is buttons plus useState. The ARIA roles restore
148
- the semantics a real tab component would have provided for free. */}
149
- <div
150
- aria-label="Social platform preview"
151
- className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5"
152
- role="tablist"
153
- >
154
- {PLATFORM_TABS.map((tab) => (
155
- <button
156
- aria-selected={platform === tab.id}
157
- className={cn(
158
- 'rounded px-2.5 py-1 text-[11px] font-medium transition-colors',
159
- platform === tab.id
160
- ? 'bg-background text-foreground shadow-sm'
161
- : 'text-muted-foreground hover:text-foreground'
162
- )}
163
- key={tab.id}
164
- onClick={() => setPlatform(tab.id)}
165
- role="tab"
166
- type="button"
167
- >
168
- {tab.label}
169
- </button>
170
- ))}
171
- </div>
172
-
173
- {platform === 'facebook' && (
174
- <div className="max-w-[500px] overflow-hidden rounded-lg border border-border bg-card">
175
- <PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
176
- {/* Facebook's grey caption bar: domain in small caps above a bold headline. */}
177
- <div className="space-y-0.5 bg-muted/60 px-3 py-2">
178
- <p className="text-[11px] uppercase tracking-wide text-muted-foreground">{domain}</p>
179
- <p className="text-sm font-semibold leading-snug text-foreground">{clampedTitle}</p>
180
- {clampedDescription && (
181
- <p className="text-xs leading-snug text-muted-foreground">{clampedDescription}</p>
182
- )}
183
- </div>
184
- <div className="flex items-center gap-4 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
185
- <span className="flex items-center gap-1">
186
- <ThumbsUp aria-hidden="true" className="h-3 w-3" /> Like
187
- </span>
188
- <span className="flex items-center gap-1">
189
- <MessageCircle aria-hidden="true" className="h-3 w-3" /> Comment
190
- </span>
191
- <span className="flex items-center gap-1">
192
- <Share2 aria-hidden="true" className="h-3 w-3" /> Share
193
- </span>
194
- </div>
195
- </div>
196
- )}
197
-
198
- {platform === 'x' && (
199
- // X rounds the card hard, drops the caption bar, and puts the domain BELOW the
200
- // text instead of above the headline.
201
- <div className="max-w-[500px] overflow-hidden rounded-2xl border border-border bg-card">
202
- <PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
203
- <div className="space-y-0.5 px-3 py-2">
204
- <p className="text-sm font-medium leading-snug text-foreground">{clampedTitle}</p>
205
- {clampedDescription && (
206
- <p className="text-xs leading-snug text-muted-foreground">{clampedDescription}</p>
207
- )}
208
- <p className="text-xs text-muted-foreground">{domain}</p>
209
- </div>
210
- <div className="flex items-center gap-5 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
211
- <span className="flex items-center gap-1">
212
- <MessageCircle aria-hidden="true" className="h-3 w-3" /> Reply
213
- </span>
214
- <span className="flex items-center gap-1">
215
- <Repeat2 aria-hidden="true" className="h-3 w-3" /> Repost
216
- </span>
217
- <span className="flex items-center gap-1">
218
- <Heart aria-hidden="true" className="h-3 w-3" /> Like
219
- </span>
220
- </div>
221
- </div>
222
- )}
223
-
224
- {platform === 'linkedin' && (
225
- // LinkedIn is the outlier worth showing: square-ish chrome, a roomier headline,
226
- // and NO description at all — the meta description simply never appears here.
227
- <div className="max-w-[520px] overflow-hidden rounded-sm border border-border bg-card">
228
- <PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
229
- <div className="space-y-1 bg-muted/40 px-3 py-2.5">
230
- <p className="text-sm font-semibold leading-snug text-foreground">{clampedTitle}</p>
231
- <p className="text-[11px] text-muted-foreground">
232
- {publisher}
233
- {publisher === domain ? '' : ` • ${domain}`}
234
- </p>
235
- <p className="text-[10px] italic text-muted-foreground/70">
236
- LinkedIn does not display the meta description on link previews.
237
- </p>
238
- </div>
239
- </div>
240
- )}
241
- </div>
242
- );
243
- }
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import { ImageIcon, MessageCircle, Repeat2, Heart, ThumbsUp, Share2 } from 'lucide-react';
5
+ import { cn } from '@nextblock-cms/utils';
6
+
7
+ /**
8
+ * A read-only rehearsal of the share card a page will produce.
9
+ *
10
+ * The value here is not decoration. Meta title and description are the two fields an
11
+ * operator is most likely to get wrong in a way nothing else in the CMS will catch:
12
+ * they are invisible on the page itself, and the consequence — a headline chopped
13
+ * mid-word in someone's feed — only shows up after the link has already been shared.
14
+ * Rendering the truncation locally turns that into a visible, immediate edit.
15
+ *
16
+ * The three tabs are genuinely different, not one box repainted, because the platforms
17
+ * genuinely differ. Facebook shows a wide image above a grey caption bar with the domain
18
+ * in small caps and a hard title clamp. X's summary_large_image card has no caption bar
19
+ * and stamps the domain UNDER the text. LinkedIn keeps the image and gives the headline
20
+ * more room but drops the description entirely — which is exactly the surprise worth
21
+ * showing someone who has just spent five minutes wording a description. If all three
22
+ * looked the same, the component would be teaching a falsehood.
23
+ *
24
+ * The numbers below approximate observed rendering rather than a published spec — every
25
+ * platform reserves the right to reflow — so they are tuned to be slightly pessimistic.
26
+ * A preview that clips a little early nudges toward safer copy; one that clips late gives
27
+ * false confidence, which is the failure that actually costs something.
28
+ */
29
+ interface SocialPreviewProps {
30
+ description: string;
31
+ imageUrl?: string | null;
32
+ siteName?: string | null;
33
+ title: string;
34
+ url: string;
35
+ }
36
+
37
+ type SocialPlatform = 'facebook' | 'linkedin' | 'x';
38
+
39
+ const PLATFORM_TABS: ReadonlyArray<{ id: SocialPlatform; label: string }> = [
40
+ { id: 'facebook', label: 'Facebook' },
41
+ { id: 'x', label: 'X' },
42
+ { id: 'linkedin', label: 'LinkedIn' },
43
+ ];
44
+
45
+ /**
46
+ * Per-platform clamps, in characters. `description: 0` means the platform does not render
47
+ * a description at all — a meaningfully different statement from "renders a short one",
48
+ * and drawn differently below.
49
+ */
50
+ const PLATFORM_LIMITS: Record<SocialPlatform, { description: number; title: number }> = {
51
+ facebook: { description: 110, title: 65 },
52
+ linkedin: { description: 0, title: 90 },
53
+ x: { description: 125, title: 70 },
54
+ };
55
+
56
+ /**
57
+ * Clip to a character budget the way a feed does — at a word boundary, with an ellipsis —
58
+ * rather than mid-word, so the preview shows the shape of the real damage.
59
+ */
60
+ function clampToLength(value: string, maxLength: number): string {
61
+ const normalized = value.replace(/\s+/g, ' ').trim();
62
+ if (maxLength <= 0 || normalized.length <= maxLength) {
63
+ return normalized;
64
+ }
65
+
66
+ const sliced = normalized.slice(0, maxLength);
67
+ const lastSpace = sliced.lastIndexOf(' ');
68
+ const kept = lastSpace > maxLength * 0.6 ? sliced.slice(0, lastSpace) : sliced;
69
+ return `${kept.trimEnd()}…`;
70
+ }
71
+
72
+ /**
73
+ * The domain each platform stamps on the card. Falls back to the raw string when the URL
74
+ * is not parseable — while a slug is being typed, `url` is briefly not a valid URL.
75
+ */
76
+ function toDisplayDomain(url: string): string {
77
+ try {
78
+ return new URL(url).hostname.replace(/^www\./, '');
79
+ } catch {
80
+ return url.replace(/^https?:\/\//i, '').split('/')[0] || url;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Remote share images render through a plain `<img>`, deliberately.
86
+ *
87
+ * `next/image` would require every host an operator might point a feature image at to be
88
+ * pre-declared in `next.config` `images.remotePatterns`, and an undeclared host does not
89
+ * degrade — it throws. The CMS already makes this call the same way: the external-URL
90
+ * branch of `ImageBlockEditor` drops to a plain `<img>` for precisely this reason. A
91
+ * preview thumbnail gains nothing from the optimizer in exchange for that fragility.
92
+ */
93
+ function PreviewImage({
94
+ aspectClassName,
95
+ imageUrl,
96
+ title,
97
+ }: {
98
+ aspectClassName: string;
99
+ imageUrl?: string | null;
100
+ title: string;
101
+ }) {
102
+ if (!imageUrl) {
103
+ return (
104
+ <div
105
+ className={cn(
106
+ 'flex w-full flex-col items-center justify-center gap-1 bg-muted text-muted-foreground',
107
+ aspectClassName
108
+ )}
109
+ >
110
+ <ImageIcon aria-hidden="true" className="h-6 w-6" />
111
+ <span className="text-[10px] font-medium uppercase tracking-wide">No share image</span>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ return (
117
+ // eslint-disable-next-line @next/next/no-img-element
118
+ <img
119
+ alt={`Share preview for ${title}`}
120
+ className={cn('w-full bg-muted object-cover', aspectClassName)}
121
+ loading="lazy"
122
+ src={imageUrl}
123
+ />
124
+ );
125
+ }
126
+
127
+ export default function SocialPreview({
128
+ description,
129
+ imageUrl,
130
+ siteName,
131
+ title,
132
+ url,
133
+ }: SocialPreviewProps) {
134
+ const [platform, setPlatform] = useState<SocialPlatform>('facebook');
135
+
136
+ const domain = useMemo(() => toDisplayDomain(url), [url]);
137
+ const limits = PLATFORM_LIMITS[platform];
138
+ const clampedTitle = clampToLength(title || 'Untitled', limits.title);
139
+ const clampedDescription = clampToLength(description, limits.description);
140
+ // LinkedIn labels the card with the publisher name when one is known and quietly falls
141
+ // back to the bare domain when it is not; Facebook and X only ever show the domain.
142
+ const publisher = (siteName ?? '').trim() || domain;
143
+
144
+ return (
145
+ <div className="space-y-2">
146
+ {/* Hand-rolled tabs: this design system ships no Tabs primitive, and the house
147
+ pattern for a small switcher is buttons plus useState. The ARIA roles restore
148
+ the semantics a real tab component would have provided for free. */}
149
+ <div
150
+ aria-label="Social platform preview"
151
+ className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5"
152
+ role="tablist"
153
+ >
154
+ {PLATFORM_TABS.map((tab) => (
155
+ <button
156
+ aria-selected={platform === tab.id}
157
+ className={cn(
158
+ 'rounded px-2.5 py-1 text-[11px] font-medium transition-colors',
159
+ platform === tab.id
160
+ ? 'bg-background text-foreground shadow-sm'
161
+ : 'text-muted-foreground hover:text-foreground'
162
+ )}
163
+ key={tab.id}
164
+ onClick={() => setPlatform(tab.id)}
165
+ role="tab"
166
+ type="button"
167
+ >
168
+ {tab.label}
169
+ </button>
170
+ ))}
171
+ </div>
172
+
173
+ {platform === 'facebook' && (
174
+ <div className="max-w-[500px] overflow-hidden rounded-lg border border-border bg-card">
175
+ <PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
176
+ {/* Facebook's grey caption bar: domain in small caps above a bold headline. */}
177
+ <div className="space-y-0.5 bg-muted/60 px-3 py-2">
178
+ <p className="text-[11px] uppercase tracking-wide text-muted-foreground">{domain}</p>
179
+ <p className="text-sm font-semibold leading-snug text-foreground">{clampedTitle}</p>
180
+ {clampedDescription && (
181
+ <p className="text-xs leading-snug text-muted-foreground">{clampedDescription}</p>
182
+ )}
183
+ </div>
184
+ <div className="flex items-center gap-4 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
185
+ <span className="flex items-center gap-1">
186
+ <ThumbsUp aria-hidden="true" className="h-3 w-3" /> Like
187
+ </span>
188
+ <span className="flex items-center gap-1">
189
+ <MessageCircle aria-hidden="true" className="h-3 w-3" /> Comment
190
+ </span>
191
+ <span className="flex items-center gap-1">
192
+ <Share2 aria-hidden="true" className="h-3 w-3" /> Share
193
+ </span>
194
+ </div>
195
+ </div>
196
+ )}
197
+
198
+ {platform === 'x' && (
199
+ // X rounds the card hard, drops the caption bar, and puts the domain BELOW the
200
+ // text instead of above the headline.
201
+ <div className="max-w-[500px] overflow-hidden rounded-2xl border border-border bg-card">
202
+ <PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
203
+ <div className="space-y-0.5 px-3 py-2">
204
+ <p className="text-sm font-medium leading-snug text-foreground">{clampedTitle}</p>
205
+ {clampedDescription && (
206
+ <p className="text-xs leading-snug text-muted-foreground">{clampedDescription}</p>
207
+ )}
208
+ <p className="text-xs text-muted-foreground">{domain}</p>
209
+ </div>
210
+ <div className="flex items-center gap-5 border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
211
+ <span className="flex items-center gap-1">
212
+ <MessageCircle aria-hidden="true" className="h-3 w-3" /> Reply
213
+ </span>
214
+ <span className="flex items-center gap-1">
215
+ <Repeat2 aria-hidden="true" className="h-3 w-3" /> Repost
216
+ </span>
217
+ <span className="flex items-center gap-1">
218
+ <Heart aria-hidden="true" className="h-3 w-3" /> Like
219
+ </span>
220
+ </div>
221
+ </div>
222
+ )}
223
+
224
+ {platform === 'linkedin' && (
225
+ // LinkedIn is the outlier worth showing: square-ish chrome, a roomier headline,
226
+ // and NO description at all — the meta description simply never appears here.
227
+ <div className="max-w-[520px] overflow-hidden rounded-sm border border-border bg-card">
228
+ <PreviewImage aspectClassName="aspect-[1.91/1]" imageUrl={imageUrl} title={title} />
229
+ <div className="space-y-1 bg-muted/40 px-3 py-2.5">
230
+ <p className="text-sm font-semibold leading-snug text-foreground">{clampedTitle}</p>
231
+ <p className="text-[11px] text-muted-foreground">
232
+ {publisher}
233
+ {publisher === domain ? '' : ` • ${domain}`}
234
+ </p>
235
+ <p className="text-[10px] italic text-muted-foreground/70">
236
+ LinkedIn does not display the meta description on link previews.
237
+ </p>
238
+ </div>
239
+ </div>
240
+ )}
241
+ </div>
242
+ );
243
+ }