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,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
+ }