create-nextblock 0.16.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -221
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -186
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +4 -4
  30. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4542 -2904
  31. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  32. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  33. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  34. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  35. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  37. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  38. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  39. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  40. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  41. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -558
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -406
  46. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  47. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  48. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  49. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -254
  50. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  51. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  52. package/templates/nextblock-template/app/cms/components/SeoScoreBadge.tsx +40 -0
  53. package/templates/nextblock-template/app/cms/components/TablePagination.tsx +136 -0
  54. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  55. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  56. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  58. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  59. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  60. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  61. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  63. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  64. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  65. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -649
  74. package/templates/nextblock-template/app/cms/pages/page.tsx +273 -227
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +2 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +624 -618
  77. package/templates/nextblock-template/app/cms/posts/page.tsx +251 -194
  78. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  79. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  80. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  81. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  82. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  83. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  84. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  85. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  86. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  90. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  91. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  92. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  93. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  98. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  99. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  100. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  101. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  102. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  103. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  104. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -514
  105. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -529
  106. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -57
  107. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -448
  108. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -93
  109. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -46
  110. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -47
  111. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  112. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  113. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/layout.tsx +671 -671
  116. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  117. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  118. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  119. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  120. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  121. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  122. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  123. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  124. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  125. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  126. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  127. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  128. package/templates/nextblock-template/app/providers.tsx +96 -96
  129. package/templates/nextblock-template/app/robots.ts +123 -123
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -137
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +251 -244
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -749
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -195
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -144
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -243
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -110
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -86
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -60
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -154
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -109
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -123
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -242
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -204
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +157 -140
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +384 -350
  231. package/templates/nextblock-template/lib/seo/page-document.ts +463 -412
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -479
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -466
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -102
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -41
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -370
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -510
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -22
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +1 -2
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/scripts/backup.js +115 -115
  256. package/templates/nextblock-template/scripts/restore.js +385 -385
  257. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  262. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  263. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  264. package/tsconfig.base.json +3 -3
@@ -1,514 +1,514 @@
1
- 'use client';
2
-
3
- import React, { useMemo, useState, useTransition } from 'react';
4
- import { useRouter } from 'next/navigation';
5
- import {
6
- Button,
7
- Card,
8
- CardContent,
9
- CardDescription,
10
- CardHeader,
11
- CardTitle,
12
- Checkbox,
13
- ConfirmationDialog,
14
- Input,
15
- Label,
16
- Table,
17
- TableBody,
18
- TableCell,
19
- TableHead,
20
- TableHeader,
21
- TableRow,
22
- } from '@nextblock-cms/ui';
23
- import {
24
- isExternalDestination,
25
- normalizeRedirectPath,
26
- validateRedirectRule,
27
- type RedirectRule,
28
- type RedirectStatusCode,
29
- } from '@nextblock-cms/utils/seo';
30
- import { ArrowRight, Check, ExternalLink, Pencil, Plus, Trash2, X } from 'lucide-react';
31
- import { toast } from 'sonner';
32
-
33
- import type { SettingsActionResult } from '../../../../lib/cms/action-result';
34
- import { createRedirect, deleteRedirect, toggleRedirect, updateRedirect } from './actions';
35
- import { toRedirectInput, toRedirectRules, type CmsRedirect, type RedirectInput } from './mappers';
36
-
37
- /** Matches the select styling used by the other settings screens (see SiteScriptManager). */
38
- const selectClass =
39
- 'h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
40
-
41
- /**
42
- * Active / paused styling, following the house convention of a
43
- * `Record<State, string>` of raw palette classes with dark: variants — emerald for
44
- * the good state, amber for the one that needs attention (see VisibilityControl).
45
- * A paused redirect is amber rather than red because pausing is a legitimate
46
- * operation, not a fault.
47
- */
48
- const ACTIVE_STYLE: Record<'active' | 'paused', string> = {
49
- active: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
50
- paused: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300',
51
- };
52
-
53
- const EMPTY_DRAFT: RedirectInput = {
54
- destinationPath: '',
55
- isActive: true,
56
- sourcePath: '',
57
- statusCode: 301,
58
- };
59
-
60
- /**
61
- * What a path will actually be stored as.
62
- *
63
- * A destination that leaves the site is passed through untouched — rewriting
64
- * somebody's `https://` target would corrupt it — which is exactly the distinction
65
- * `validateRedirectRule` makes internally, so the hint cannot promise something the
66
- * server would not do.
67
- */
68
- function previewStoredPath(value: string, allowExternal: boolean): string {
69
- const trimmed = value.trim();
70
-
71
- return allowExternal && isExternalDestination(trimmed) ? trimmed : normalizeRedirectPath(trimmed);
72
- }
73
-
74
- /**
75
- * "will be saved as /foo", shown only when it differs from what was typed.
76
- *
77
- * Silent normalisation is the single most confusing thing this screen could do: an
78
- * operator types `/blog/post/`, sees `/blog/post` appear in the table, and reasonably
79
- * concludes the form ate their input. Naming the transformation before it happens
80
- * costs one line of muted text and removes the whole category of confusion.
81
- */
82
- function NormalizationHint({
83
- allowExternal,
84
- value,
85
- }: {
86
- allowExternal: boolean;
87
- value: string;
88
- }) {
89
- const trimmed = value.trim();
90
- if (trimmed === '') {
91
- return null;
92
- }
93
-
94
- const stored = previewStoredPath(trimmed, allowExternal);
95
- if (stored === trimmed) {
96
- return null;
97
- }
98
-
99
- return (
100
- <p className="text-xs text-muted-foreground">
101
- Will be saved as <span className="font-mono">{stored}</span>
102
- </p>
103
- );
104
- }
105
-
106
- /** The inline complaint under a field. Never rendered before the operator has typed. */
107
- function ValidationMessage({ error }: { error: string | null }) {
108
- if (!error) {
109
- return null;
110
- }
111
-
112
- return <p className="text-xs text-destructive">{error}</p>;
113
- }
114
-
115
- type RedirectsCardProps = {
116
- redirects: CmsRedirect[];
117
- };
118
-
119
- /**
120
- * Create, edit, pause and delete the managed 301/302 redirects in `cms_redirects`.
121
- *
122
- * WHY THE BROWSER VALIDATES TOO. Every rule is checked twice, by the very same
123
- * function: `validateRedirectRule` from the pure SEO engine runs here as the operator
124
- * types, and again inside the server action before anything is written. The client
125
- * pass is UX and nothing more — it is trivially bypassed, and it is not the boundary.
126
- * What it buys is that the operator learns "another redirect already handles /pricing"
127
- * while their cursor is still in the field, rather than after a round trip, and that
128
- * the sentence they read then is byte-for-byte the sentence the server would have
129
- * returned, because it came from the same code. A second, hand-written "quick check"
130
- * here would eventually disagree with the server about some edge case, and the
131
- * operator would be the one to discover it.
132
- *
133
- * The rule set both passes compare against includes paused rules, which is what stops
134
- * an operator from parking a duplicate on a paused source and creating a conflict that
135
- * only appears when the older rule is resumed.
136
- */
137
- export function RedirectsCard({ redirects }: RedirectsCardProps) {
138
- const router = useRouter();
139
- const [isPending, startTransition] = useTransition();
140
- const [draft, setDraft] = useState<RedirectInput>(EMPTY_DRAFT);
141
- const [editingId, setEditingId] = useState<string | null>(null);
142
- const [editDraft, setEditDraft] = useState<RedirectInput>(EMPTY_DRAFT);
143
- const [pendingDelete, setPendingDelete] = useState<CmsRedirect | null>(null);
144
-
145
- // The engine's camelCase view of what is currently stored, mapped with the same
146
- // function the proxy uses, so "what the admin screen thinks the rules are" and
147
- // "what the proxy will actually match" cannot drift.
148
- const rules: RedirectRule[] = useMemo(() => toRedirectRules(redirects), [redirects]);
149
-
150
- const draftValidation = useMemo(
151
- () =>
152
- validateRedirectRule(
153
- { destinationPath: draft.destinationPath, sourcePath: draft.sourcePath },
154
- rules
155
- ),
156
- [draft.destinationPath, draft.sourcePath, rules]
157
- );
158
-
159
- const editValidation = useMemo(
160
- () =>
161
- editingId === null
162
- ? ({ ok: true } as const)
163
- : validateRedirectRule(
164
- {
165
- destinationPath: editDraft.destinationPath,
166
- id: editingId,
167
- sourcePath: editDraft.sourcePath,
168
- },
169
- rules
170
- ),
171
- [editDraft.destinationPath, editDraft.sourcePath, editingId, rules]
172
- );
173
-
174
- // An empty form is invalid — "enter a source path" is true but not yet useful — so
175
- // the complaint stays hidden until the operator has typed something. The submit
176
- // button is disabled the whole time regardless, so nothing invalid can be sent.
177
- const draftTouched = draft.sourcePath.trim() !== '' || draft.destinationPath.trim() !== '';
178
- const draftError = !draftValidation.ok && draftTouched ? draftValidation.error : null;
179
- const editError = !editValidation.ok ? editValidation.error : null;
180
-
181
- const run = (action: () => Promise<SettingsActionResult>, onSuccess?: () => void) => {
182
- startTransition(async () => {
183
- const result = await action();
184
-
185
- if (result.ok) {
186
- toast.success(result.message);
187
- onSuccess?.();
188
- // The list is server-rendered, so a successful write has to pull the new rows
189
- // down rather than being patched into local state — that way the table and the
190
- // rule set the validators compare against stay the database's version of
191
- // events, not this component's guess at it.
192
- router.refresh();
193
- } else {
194
- toast.error(result.error);
195
- }
196
- });
197
- };
198
-
199
- const submitDraft = (event: React.FormEvent<HTMLFormElement>) => {
200
- // This codebase drives client forms through onSubmit + a direct action call rather
201
- // than `<form action={…}>`, because the returned SettingsActionResult is what
202
- // produces the toast; a form action would discard it.
203
- event.preventDefault();
204
-
205
- if (!draftValidation.ok || isPending) {
206
- return;
207
- }
208
-
209
- run(() => createRedirect(draft), () => setDraft(EMPTY_DRAFT));
210
- };
211
-
212
- const startEditing = (redirect: CmsRedirect) => {
213
- setEditingId(redirect.id);
214
- setEditDraft(toRedirectInput(redirect));
215
- };
216
-
217
- const cancelEditing = () => {
218
- setEditingId(null);
219
- setEditDraft(EMPTY_DRAFT);
220
- };
221
-
222
- const saveEdit = () => {
223
- if (editingId === null || !editValidation.ok || isPending) {
224
- return;
225
- }
226
-
227
- run(() => updateRedirect(editingId, editDraft), cancelEditing);
228
- };
229
-
230
- return (
231
- <Card>
232
- <CardHeader>
233
- <CardTitle>Redirects</CardTitle>
234
- <CardDescription>
235
- Send an old URL to a new one. Use this whenever a page&apos;s slug changes or a page is
236
- removed — without a redirect the old address returns a 404 and whatever ranking and
237
- inbound links it had are lost. <strong className="text-foreground">301</strong> means the
238
- move is permanent and tells search engines to transfer the old page&apos;s standing to the
239
- new one; <strong className="text-foreground">302</strong> means temporary and transfers
240
- nothing. Matching is exact — one rule per address, no wildcards — and a destination may be
241
- a path on this site or a full <span className="font-mono">https://</span> URL.
242
- </CardDescription>
243
- </CardHeader>
244
-
245
- <CardContent className="space-y-6">
246
- <form className="space-y-4 rounded-lg border bg-muted/30 p-4" onSubmit={submitDraft}>
247
- <h3 className="text-sm font-semibold">Add a redirect</h3>
248
-
249
- <div className="grid gap-4 sm:grid-cols-2">
250
- <div className="space-y-2">
251
- <Label htmlFor="redirect-source">Redirect from</Label>
252
- <Input
253
- id="redirect-source"
254
- autoComplete="off"
255
- className="font-mono"
256
- onChange={(event) => setDraft({ ...draft, sourcePath: event.target.value })}
257
- placeholder="/old-page"
258
- spellCheck={false}
259
- value={draft.sourcePath}
260
- />
261
- <NormalizationHint allowExternal={false} value={draft.sourcePath} />
262
- <p className="text-xs text-muted-foreground">
263
- A path on this site, starting with a slash.
264
- </p>
265
- </div>
266
-
267
- <div className="space-y-2">
268
- <Label htmlFor="redirect-destination">Redirect to</Label>
269
- <Input
270
- id="redirect-destination"
271
- autoComplete="off"
272
- className="font-mono"
273
- onChange={(event) => setDraft({ ...draft, destinationPath: event.target.value })}
274
- placeholder="/new-page"
275
- spellCheck={false}
276
- value={draft.destinationPath}
277
- />
278
- <NormalizationHint allowExternal value={draft.destinationPath} />
279
- <p className="text-xs text-muted-foreground">
280
- A path on this site, or a full https:// URL to move somewhere else entirely.
281
- </p>
282
- </div>
283
- </div>
284
-
285
- <div className="grid gap-4 sm:grid-cols-2">
286
- <div className="space-y-2">
287
- <Label htmlFor="redirect-status">Type</Label>
288
- <select
289
- className={selectClass}
290
- id="redirect-status"
291
- onChange={(event) =>
292
- setDraft({
293
- ...draft,
294
- statusCode: Number(event.target.value) as RedirectStatusCode,
295
- })
296
- }
297
- value={draft.statusCode}
298
- >
299
- <option value={301}>301 — permanent (the page moved for good)</option>
300
- <option value={302}>302 — temporary (it will come back)</option>
301
- </select>
302
- </div>
303
-
304
- <div className="flex items-end">
305
- <label className="flex items-center gap-2 text-sm" htmlFor="redirect-active">
306
- <Checkbox
307
- checked={draft.isActive}
308
- id="redirect-active"
309
- onCheckedChange={(checked) => setDraft({ ...draft, isActive: checked === true })}
310
- />
311
- Active — start redirecting visitors immediately
312
- </label>
313
- </div>
314
- </div>
315
-
316
- <ValidationMessage error={draftError} />
317
-
318
- <Button disabled={!draftValidation.ok || isPending} type="submit">
319
- <Plus className="mr-2 h-4 w-4" />
320
- {isPending ? 'Saving…' : 'Add redirect'}
321
- </Button>
322
- </form>
323
-
324
- {redirects.length === 0 ? (
325
- <p className="text-sm text-muted-foreground">
326
- No redirects yet. Nothing is being rewritten, and every address on this site resolves
327
- exactly as it is written.
328
- </p>
329
- ) : (
330
- <div className="overflow-x-auto">
331
- <Table>
332
- <TableHeader>
333
- <TableRow>
334
- <TableHead>Redirect from</TableHead>
335
- <TableHead>Redirect to</TableHead>
336
- <TableHead>Type</TableHead>
337
- <TableHead>Status</TableHead>
338
- <TableHead className="text-right">Actions</TableHead>
339
- </TableRow>
340
- </TableHeader>
341
- <TableBody>
342
- {redirects.map((redirect) =>
343
- editingId === redirect.id ? (
344
- <TableRow key={redirect.id}>
345
- <TableCell className="align-top">
346
- <Input
347
- aria-label="Redirect from"
348
- className="font-mono"
349
- onChange={(event) =>
350
- setEditDraft({ ...editDraft, sourcePath: event.target.value })
351
- }
352
- spellCheck={false}
353
- value={editDraft.sourcePath}
354
- />
355
- <NormalizationHint allowExternal={false} value={editDraft.sourcePath} />
356
- </TableCell>
357
- <TableCell className="align-top">
358
- <Input
359
- aria-label="Redirect to"
360
- className="font-mono"
361
- onChange={(event) =>
362
- setEditDraft({ ...editDraft, destinationPath: event.target.value })
363
- }
364
- spellCheck={false}
365
- value={editDraft.destinationPath}
366
- />
367
- <NormalizationHint allowExternal value={editDraft.destinationPath} />
368
- <ValidationMessage error={editError} />
369
- </TableCell>
370
- <TableCell className="align-top">
371
- <select
372
- aria-label="Redirect type"
373
- className={selectClass}
374
- onChange={(event) =>
375
- setEditDraft({
376
- ...editDraft,
377
- statusCode: Number(event.target.value) as RedirectStatusCode,
378
- })
379
- }
380
- value={editDraft.statusCode}
381
- >
382
- <option value={301}>301</option>
383
- <option value={302}>302</option>
384
- </select>
385
- </TableCell>
386
- <TableCell className="align-top">
387
- <label className="flex items-center gap-2 text-xs">
388
- <Checkbox
389
- checked={editDraft.isActive}
390
- onCheckedChange={(checked) =>
391
- setEditDraft({ ...editDraft, isActive: checked === true })
392
- }
393
- />
394
- Active
395
- </label>
396
- </TableCell>
397
- <TableCell className="align-top text-right">
398
- <div className="flex justify-end gap-2">
399
- <Button
400
- disabled={!editValidation.ok || isPending}
401
- onClick={saveEdit}
402
- size="sm"
403
- >
404
- <Check className="mr-1 h-4 w-4" />
405
- Save
406
- </Button>
407
- <Button
408
- disabled={isPending}
409
- onClick={cancelEditing}
410
- size="sm"
411
- variant="outline"
412
- >
413
- <X className="h-4 w-4" />
414
- <span className="sr-only">Cancel</span>
415
- </Button>
416
- </div>
417
- </TableCell>
418
- </TableRow>
419
- ) : (
420
- <TableRow key={redirect.id}>
421
- <TableCell className="font-mono text-xs">{redirect.source_path}</TableCell>
422
- <TableCell className="font-mono text-xs">
423
- <span className="inline-flex items-center gap-1.5">
424
- <ArrowRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
425
- {redirect.destination_path}
426
- {isExternalDestination(redirect.destination_path) && (
427
- <ExternalLink
428
- aria-label="Leaves this site"
429
- className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
430
- />
431
- )}
432
- </span>
433
- </TableCell>
434
- <TableCell className="text-xs">{redirect.status_code}</TableCell>
435
- <TableCell>
436
- <button
437
- className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${
438
- redirect.is_active ? ACTIVE_STYLE.active : ACTIVE_STYLE.paused
439
- }`}
440
- disabled={isPending}
441
- onClick={() => run(() => toggleRedirect(redirect.id, !redirect.is_active))}
442
- title={
443
- redirect.is_active
444
- ? 'Pause this redirect — the old address will return a 404 again'
445
- : 'Resume this redirect'
446
- }
447
- type="button"
448
- >
449
- {redirect.is_active ? 'Active' : 'Paused'}
450
- </button>
451
- </TableCell>
452
- <TableCell className="text-right">
453
- <div className="flex justify-end gap-2">
454
- <Button
455
- disabled={isPending}
456
- onClick={() => startEditing(redirect)}
457
- size="sm"
458
- variant="outline"
459
- >
460
- <Pencil className="h-4 w-4" />
461
- <span className="sr-only">Edit</span>
462
- </Button>
463
- <Button
464
- disabled={isPending}
465
- onClick={() => setPendingDelete(redirect)}
466
- size="sm"
467
- variant="destructive"
468
- >
469
- <Trash2 className="h-4 w-4" />
470
- <span className="sr-only">Delete</span>
471
- </Button>
472
- </div>
473
- </TableCell>
474
- </TableRow>
475
- )
476
- )}
477
- </TableBody>
478
- </Table>
479
- </div>
480
- )}
481
- </CardContent>
482
-
483
- {/*
484
- Deleting is behind a dialog rather than an immediate action because it is the
485
- one operation here with no undo and an effect the operator cannot see: the old
486
- address silently starts returning 404 to everyone still following an old link.
487
- Pausing is the reversible alternative, so the dialog names it.
488
- */}
489
- <ConfirmationDialog
490
- confirmText="Delete redirect"
491
- description={
492
- pendingDelete
493
- ? `${pendingDelete.source_path} will stop redirecting to ${pendingDelete.destination_path} and will return a 404 again. Anyone following an old link or an old search result lands on an error page. If you only want to stop it for now, close this and use Pause instead — that is reversible.`
494
- : ''
495
- }
496
- isDestructive
497
- isOpen={pendingDelete !== null}
498
- onConfirm={() => {
499
- const target = pendingDelete;
500
- setPendingDelete(null);
501
- if (target) {
502
- run(() => deleteRedirect(target.id));
503
- }
504
- }}
505
- onOpenChange={(open) => {
506
- if (!open) {
507
- setPendingDelete(null);
508
- }
509
- }}
510
- title="Delete this redirect?"
511
- />
512
- </Card>
513
- );
514
- }
1
+ 'use client';
2
+
3
+ import React, { useMemo, useState, useTransition } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import {
6
+ Button,
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ Checkbox,
13
+ ConfirmationDialog,
14
+ Input,
15
+ Label,
16
+ Table,
17
+ TableBody,
18
+ TableCell,
19
+ TableHead,
20
+ TableHeader,
21
+ TableRow,
22
+ } from '@nextblock-cms/ui';
23
+ import {
24
+ isExternalDestination,
25
+ normalizeRedirectPath,
26
+ validateRedirectRule,
27
+ type RedirectRule,
28
+ type RedirectStatusCode,
29
+ } from '@nextblock-cms/utils/seo';
30
+ import { ArrowRight, Check, ExternalLink, Pencil, Plus, Trash2, X } from 'lucide-react';
31
+ import { toast } from 'sonner';
32
+
33
+ import type { SettingsActionResult } from '../../../../lib/cms/action-result';
34
+ import { createRedirect, deleteRedirect, toggleRedirect, updateRedirect } from './actions';
35
+ import { toRedirectInput, toRedirectRules, type CmsRedirect, type RedirectInput } from './mappers';
36
+
37
+ /** Matches the select styling used by the other settings screens (see SiteScriptManager). */
38
+ const selectClass =
39
+ 'h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
40
+
41
+ /**
42
+ * Active / paused styling, following the house convention of a
43
+ * `Record<State, string>` of raw palette classes with dark: variants — emerald for
44
+ * the good state, amber for the one that needs attention (see VisibilityControl).
45
+ * A paused redirect is amber rather than red because pausing is a legitimate
46
+ * operation, not a fault.
47
+ */
48
+ const ACTIVE_STYLE: Record<'active' | 'paused', string> = {
49
+ active: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
50
+ paused: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300',
51
+ };
52
+
53
+ const EMPTY_DRAFT: RedirectInput = {
54
+ destinationPath: '',
55
+ isActive: true,
56
+ sourcePath: '',
57
+ statusCode: 301,
58
+ };
59
+
60
+ /**
61
+ * What a path will actually be stored as.
62
+ *
63
+ * A destination that leaves the site is passed through untouched — rewriting
64
+ * somebody's `https://` target would corrupt it — which is exactly the distinction
65
+ * `validateRedirectRule` makes internally, so the hint cannot promise something the
66
+ * server would not do.
67
+ */
68
+ function previewStoredPath(value: string, allowExternal: boolean): string {
69
+ const trimmed = value.trim();
70
+
71
+ return allowExternal && isExternalDestination(trimmed) ? trimmed : normalizeRedirectPath(trimmed);
72
+ }
73
+
74
+ /**
75
+ * "will be saved as /foo", shown only when it differs from what was typed.
76
+ *
77
+ * Silent normalisation is the single most confusing thing this screen could do: an
78
+ * operator types `/blog/post/`, sees `/blog/post` appear in the table, and reasonably
79
+ * concludes the form ate their input. Naming the transformation before it happens
80
+ * costs one line of muted text and removes the whole category of confusion.
81
+ */
82
+ function NormalizationHint({
83
+ allowExternal,
84
+ value,
85
+ }: {
86
+ allowExternal: boolean;
87
+ value: string;
88
+ }) {
89
+ const trimmed = value.trim();
90
+ if (trimmed === '') {
91
+ return null;
92
+ }
93
+
94
+ const stored = previewStoredPath(trimmed, allowExternal);
95
+ if (stored === trimmed) {
96
+ return null;
97
+ }
98
+
99
+ return (
100
+ <p className="text-xs text-muted-foreground">
101
+ Will be saved as <span className="font-mono">{stored}</span>
102
+ </p>
103
+ );
104
+ }
105
+
106
+ /** The inline complaint under a field. Never rendered before the operator has typed. */
107
+ function ValidationMessage({ error }: { error: string | null }) {
108
+ if (!error) {
109
+ return null;
110
+ }
111
+
112
+ return <p className="text-xs text-destructive">{error}</p>;
113
+ }
114
+
115
+ type RedirectsCardProps = {
116
+ redirects: CmsRedirect[];
117
+ };
118
+
119
+ /**
120
+ * Create, edit, pause and delete the managed 301/302 redirects in `cms_redirects`.
121
+ *
122
+ * WHY THE BROWSER VALIDATES TOO. Every rule is checked twice, by the very same
123
+ * function: `validateRedirectRule` from the pure SEO engine runs here as the operator
124
+ * types, and again inside the server action before anything is written. The client
125
+ * pass is UX and nothing more — it is trivially bypassed, and it is not the boundary.
126
+ * What it buys is that the operator learns "another redirect already handles /pricing"
127
+ * while their cursor is still in the field, rather than after a round trip, and that
128
+ * the sentence they read then is byte-for-byte the sentence the server would have
129
+ * returned, because it came from the same code. A second, hand-written "quick check"
130
+ * here would eventually disagree with the server about some edge case, and the
131
+ * operator would be the one to discover it.
132
+ *
133
+ * The rule set both passes compare against includes paused rules, which is what stops
134
+ * an operator from parking a duplicate on a paused source and creating a conflict that
135
+ * only appears when the older rule is resumed.
136
+ */
137
+ export function RedirectsCard({ redirects }: RedirectsCardProps) {
138
+ const router = useRouter();
139
+ const [isPending, startTransition] = useTransition();
140
+ const [draft, setDraft] = useState<RedirectInput>(EMPTY_DRAFT);
141
+ const [editingId, setEditingId] = useState<string | null>(null);
142
+ const [editDraft, setEditDraft] = useState<RedirectInput>(EMPTY_DRAFT);
143
+ const [pendingDelete, setPendingDelete] = useState<CmsRedirect | null>(null);
144
+
145
+ // The engine's camelCase view of what is currently stored, mapped with the same
146
+ // function the proxy uses, so "what the admin screen thinks the rules are" and
147
+ // "what the proxy will actually match" cannot drift.
148
+ const rules: RedirectRule[] = useMemo(() => toRedirectRules(redirects), [redirects]);
149
+
150
+ const draftValidation = useMemo(
151
+ () =>
152
+ validateRedirectRule(
153
+ { destinationPath: draft.destinationPath, sourcePath: draft.sourcePath },
154
+ rules
155
+ ),
156
+ [draft.destinationPath, draft.sourcePath, rules]
157
+ );
158
+
159
+ const editValidation = useMemo(
160
+ () =>
161
+ editingId === null
162
+ ? ({ ok: true } as const)
163
+ : validateRedirectRule(
164
+ {
165
+ destinationPath: editDraft.destinationPath,
166
+ id: editingId,
167
+ sourcePath: editDraft.sourcePath,
168
+ },
169
+ rules
170
+ ),
171
+ [editDraft.destinationPath, editDraft.sourcePath, editingId, rules]
172
+ );
173
+
174
+ // An empty form is invalid — "enter a source path" is true but not yet useful — so
175
+ // the complaint stays hidden until the operator has typed something. The submit
176
+ // button is disabled the whole time regardless, so nothing invalid can be sent.
177
+ const draftTouched = draft.sourcePath.trim() !== '' || draft.destinationPath.trim() !== '';
178
+ const draftError = !draftValidation.ok && draftTouched ? draftValidation.error : null;
179
+ const editError = !editValidation.ok ? editValidation.error : null;
180
+
181
+ const run = (action: () => Promise<SettingsActionResult>, onSuccess?: () => void) => {
182
+ startTransition(async () => {
183
+ const result = await action();
184
+
185
+ if (result.ok) {
186
+ toast.success(result.message);
187
+ onSuccess?.();
188
+ // The list is server-rendered, so a successful write has to pull the new rows
189
+ // down rather than being patched into local state — that way the table and the
190
+ // rule set the validators compare against stay the database's version of
191
+ // events, not this component's guess at it.
192
+ router.refresh();
193
+ } else {
194
+ toast.error(result.error);
195
+ }
196
+ });
197
+ };
198
+
199
+ const submitDraft = (event: React.FormEvent<HTMLFormElement>) => {
200
+ // This codebase drives client forms through onSubmit + a direct action call rather
201
+ // than `<form action={…}>`, because the returned SettingsActionResult is what
202
+ // produces the toast; a form action would discard it.
203
+ event.preventDefault();
204
+
205
+ if (!draftValidation.ok || isPending) {
206
+ return;
207
+ }
208
+
209
+ run(() => createRedirect(draft), () => setDraft(EMPTY_DRAFT));
210
+ };
211
+
212
+ const startEditing = (redirect: CmsRedirect) => {
213
+ setEditingId(redirect.id);
214
+ setEditDraft(toRedirectInput(redirect));
215
+ };
216
+
217
+ const cancelEditing = () => {
218
+ setEditingId(null);
219
+ setEditDraft(EMPTY_DRAFT);
220
+ };
221
+
222
+ const saveEdit = () => {
223
+ if (editingId === null || !editValidation.ok || isPending) {
224
+ return;
225
+ }
226
+
227
+ run(() => updateRedirect(editingId, editDraft), cancelEditing);
228
+ };
229
+
230
+ return (
231
+ <Card>
232
+ <CardHeader>
233
+ <CardTitle>Redirects</CardTitle>
234
+ <CardDescription>
235
+ Send an old URL to a new one. Use this whenever a page&apos;s slug changes or a page is
236
+ removed — without a redirect the old address returns a 404 and whatever ranking and
237
+ inbound links it had are lost. <strong className="text-foreground">301</strong> means the
238
+ move is permanent and tells search engines to transfer the old page&apos;s standing to the
239
+ new one; <strong className="text-foreground">302</strong> means temporary and transfers
240
+ nothing. Matching is exact — one rule per address, no wildcards — and a destination may be
241
+ a path on this site or a full <span className="font-mono">https://</span> URL.
242
+ </CardDescription>
243
+ </CardHeader>
244
+
245
+ <CardContent className="space-y-6">
246
+ <form className="space-y-4 rounded-lg border bg-muted/30 p-4" onSubmit={submitDraft}>
247
+ <h3 className="text-sm font-semibold">Add a redirect</h3>
248
+
249
+ <div className="grid gap-4 sm:grid-cols-2">
250
+ <div className="space-y-2">
251
+ <Label htmlFor="redirect-source">Redirect from</Label>
252
+ <Input
253
+ id="redirect-source"
254
+ autoComplete="off"
255
+ className="font-mono"
256
+ onChange={(event) => setDraft({ ...draft, sourcePath: event.target.value })}
257
+ placeholder="/old-page"
258
+ spellCheck={false}
259
+ value={draft.sourcePath}
260
+ />
261
+ <NormalizationHint allowExternal={false} value={draft.sourcePath} />
262
+ <p className="text-xs text-muted-foreground">
263
+ A path on this site, starting with a slash.
264
+ </p>
265
+ </div>
266
+
267
+ <div className="space-y-2">
268
+ <Label htmlFor="redirect-destination">Redirect to</Label>
269
+ <Input
270
+ id="redirect-destination"
271
+ autoComplete="off"
272
+ className="font-mono"
273
+ onChange={(event) => setDraft({ ...draft, destinationPath: event.target.value })}
274
+ placeholder="/new-page"
275
+ spellCheck={false}
276
+ value={draft.destinationPath}
277
+ />
278
+ <NormalizationHint allowExternal value={draft.destinationPath} />
279
+ <p className="text-xs text-muted-foreground">
280
+ A path on this site, or a full https:// URL to move somewhere else entirely.
281
+ </p>
282
+ </div>
283
+ </div>
284
+
285
+ <div className="grid gap-4 sm:grid-cols-2">
286
+ <div className="space-y-2">
287
+ <Label htmlFor="redirect-status">Type</Label>
288
+ <select
289
+ className={selectClass}
290
+ id="redirect-status"
291
+ onChange={(event) =>
292
+ setDraft({
293
+ ...draft,
294
+ statusCode: Number(event.target.value) as RedirectStatusCode,
295
+ })
296
+ }
297
+ value={draft.statusCode}
298
+ >
299
+ <option value={301}>301 — permanent (the page moved for good)</option>
300
+ <option value={302}>302 — temporary (it will come back)</option>
301
+ </select>
302
+ </div>
303
+
304
+ <div className="flex items-end">
305
+ <label className="flex items-center gap-2 text-sm" htmlFor="redirect-active">
306
+ <Checkbox
307
+ checked={draft.isActive}
308
+ id="redirect-active"
309
+ onCheckedChange={(checked) => setDraft({ ...draft, isActive: checked === true })}
310
+ />
311
+ Active — start redirecting visitors immediately
312
+ </label>
313
+ </div>
314
+ </div>
315
+
316
+ <ValidationMessage error={draftError} />
317
+
318
+ <Button disabled={!draftValidation.ok || isPending} type="submit">
319
+ <Plus className="mr-2 h-4 w-4" />
320
+ {isPending ? 'Saving…' : 'Add redirect'}
321
+ </Button>
322
+ </form>
323
+
324
+ {redirects.length === 0 ? (
325
+ <p className="text-sm text-muted-foreground">
326
+ No redirects yet. Nothing is being rewritten, and every address on this site resolves
327
+ exactly as it is written.
328
+ </p>
329
+ ) : (
330
+ <div className="overflow-x-auto">
331
+ <Table>
332
+ <TableHeader>
333
+ <TableRow>
334
+ <TableHead>Redirect from</TableHead>
335
+ <TableHead>Redirect to</TableHead>
336
+ <TableHead>Type</TableHead>
337
+ <TableHead>Status</TableHead>
338
+ <TableHead className="text-right">Actions</TableHead>
339
+ </TableRow>
340
+ </TableHeader>
341
+ <TableBody>
342
+ {redirects.map((redirect) =>
343
+ editingId === redirect.id ? (
344
+ <TableRow key={redirect.id}>
345
+ <TableCell className="align-top">
346
+ <Input
347
+ aria-label="Redirect from"
348
+ className="font-mono"
349
+ onChange={(event) =>
350
+ setEditDraft({ ...editDraft, sourcePath: event.target.value })
351
+ }
352
+ spellCheck={false}
353
+ value={editDraft.sourcePath}
354
+ />
355
+ <NormalizationHint allowExternal={false} value={editDraft.sourcePath} />
356
+ </TableCell>
357
+ <TableCell className="align-top">
358
+ <Input
359
+ aria-label="Redirect to"
360
+ className="font-mono"
361
+ onChange={(event) =>
362
+ setEditDraft({ ...editDraft, destinationPath: event.target.value })
363
+ }
364
+ spellCheck={false}
365
+ value={editDraft.destinationPath}
366
+ />
367
+ <NormalizationHint allowExternal value={editDraft.destinationPath} />
368
+ <ValidationMessage error={editError} />
369
+ </TableCell>
370
+ <TableCell className="align-top">
371
+ <select
372
+ aria-label="Redirect type"
373
+ className={selectClass}
374
+ onChange={(event) =>
375
+ setEditDraft({
376
+ ...editDraft,
377
+ statusCode: Number(event.target.value) as RedirectStatusCode,
378
+ })
379
+ }
380
+ value={editDraft.statusCode}
381
+ >
382
+ <option value={301}>301</option>
383
+ <option value={302}>302</option>
384
+ </select>
385
+ </TableCell>
386
+ <TableCell className="align-top">
387
+ <label className="flex items-center gap-2 text-xs">
388
+ <Checkbox
389
+ checked={editDraft.isActive}
390
+ onCheckedChange={(checked) =>
391
+ setEditDraft({ ...editDraft, isActive: checked === true })
392
+ }
393
+ />
394
+ Active
395
+ </label>
396
+ </TableCell>
397
+ <TableCell className="align-top text-right">
398
+ <div className="flex justify-end gap-2">
399
+ <Button
400
+ disabled={!editValidation.ok || isPending}
401
+ onClick={saveEdit}
402
+ size="sm"
403
+ >
404
+ <Check className="mr-1 h-4 w-4" />
405
+ Save
406
+ </Button>
407
+ <Button
408
+ disabled={isPending}
409
+ onClick={cancelEditing}
410
+ size="sm"
411
+ variant="outline"
412
+ >
413
+ <X className="h-4 w-4" />
414
+ <span className="sr-only">Cancel</span>
415
+ </Button>
416
+ </div>
417
+ </TableCell>
418
+ </TableRow>
419
+ ) : (
420
+ <TableRow key={redirect.id}>
421
+ <TableCell className="font-mono text-xs">{redirect.source_path}</TableCell>
422
+ <TableCell className="font-mono text-xs">
423
+ <span className="inline-flex items-center gap-1.5">
424
+ <ArrowRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
425
+ {redirect.destination_path}
426
+ {isExternalDestination(redirect.destination_path) && (
427
+ <ExternalLink
428
+ aria-label="Leaves this site"
429
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
430
+ />
431
+ )}
432
+ </span>
433
+ </TableCell>
434
+ <TableCell className="text-xs">{redirect.status_code}</TableCell>
435
+ <TableCell>
436
+ <button
437
+ className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${
438
+ redirect.is_active ? ACTIVE_STYLE.active : ACTIVE_STYLE.paused
439
+ }`}
440
+ disabled={isPending}
441
+ onClick={() => run(() => toggleRedirect(redirect.id, !redirect.is_active))}
442
+ title={
443
+ redirect.is_active
444
+ ? 'Pause this redirect — the old address will return a 404 again'
445
+ : 'Resume this redirect'
446
+ }
447
+ type="button"
448
+ >
449
+ {redirect.is_active ? 'Active' : 'Paused'}
450
+ </button>
451
+ </TableCell>
452
+ <TableCell className="text-right">
453
+ <div className="flex justify-end gap-2">
454
+ <Button
455
+ disabled={isPending}
456
+ onClick={() => startEditing(redirect)}
457
+ size="sm"
458
+ variant="outline"
459
+ >
460
+ <Pencil className="h-4 w-4" />
461
+ <span className="sr-only">Edit</span>
462
+ </Button>
463
+ <Button
464
+ disabled={isPending}
465
+ onClick={() => setPendingDelete(redirect)}
466
+ size="sm"
467
+ variant="destructive"
468
+ >
469
+ <Trash2 className="h-4 w-4" />
470
+ <span className="sr-only">Delete</span>
471
+ </Button>
472
+ </div>
473
+ </TableCell>
474
+ </TableRow>
475
+ )
476
+ )}
477
+ </TableBody>
478
+ </Table>
479
+ </div>
480
+ )}
481
+ </CardContent>
482
+
483
+ {/*
484
+ Deleting is behind a dialog rather than an immediate action because it is the
485
+ one operation here with no undo and an effect the operator cannot see: the old
486
+ address silently starts returning 404 to everyone still following an old link.
487
+ Pausing is the reversible alternative, so the dialog names it.
488
+ */}
489
+ <ConfirmationDialog
490
+ confirmText="Delete redirect"
491
+ description={
492
+ pendingDelete
493
+ ? `${pendingDelete.source_path} will stop redirecting to ${pendingDelete.destination_path} and will return a 404 again. Anyone following an old link or an old search result lands on an error page. If you only want to stop it for now, close this and use Pause instead — that is reversible.`
494
+ : ''
495
+ }
496
+ isDestructive
497
+ isOpen={pendingDelete !== null}
498
+ onConfirm={() => {
499
+ const target = pendingDelete;
500
+ setPendingDelete(null);
501
+ if (target) {
502
+ run(() => deleteRedirect(target.id));
503
+ }
504
+ }}
505
+ onOpenChange={(open) => {
506
+ if (!open) {
507
+ setPendingDelete(null);
508
+ }
509
+ }}
510
+ title="Delete this redirect?"
511
+ />
512
+ </Card>
513
+ );
514
+ }