create-nextblock 0.14.1 → 0.14.4

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 (263) hide show
  1. package/docker-template/.dockerignore +23 -23
  2. package/docker-template/.env.docker.example +69 -69
  3. package/docker-template/docker/db/init/99-jwt.sql +6 -6
  4. package/docker-template/docker/db/init/99-roles.sql +25 -25
  5. package/docker-template/docker/kong/kong.yml +112 -112
  6. package/docker-template/scripts/docker-setup.mjs +310 -310
  7. package/libs/db/tsconfig.lib.json +3 -3
  8. package/libs/editor/tsconfig.lib.json +3 -3
  9. package/libs/ui/tsconfig.lib.json +3 -3
  10. package/libs/utils/tsconfig.json +3 -3
  11. package/package.json +1 -1
  12. package/project.json +19 -19
  13. package/templates/nextblock-template/.browserslistrc +11 -11
  14. package/templates/nextblock-template/.dockerignore +23 -23
  15. package/templates/nextblock-template/README.md +34 -34
  16. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  17. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  18. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
  19. package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
  20. package/templates/nextblock-template/app/ToasterProvider.tsx +17 -17
  21. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  22. package/templates/nextblock-template/app/actions/consent.ts +57 -57
  23. package/templates/nextblock-template/app/actions/postActions.ts +146 -146
  24. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
  25. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
  26. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
  27. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
  28. package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
  29. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
  30. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
  31. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
  32. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5881 -5623
  33. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
  34. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
  35. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
  36. package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
  37. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
  38. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  39. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  40. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
  41. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  42. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  43. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  44. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  45. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
  46. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  47. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  48. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
  49. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  50. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +4 -2
  51. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
  52. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
  53. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  54. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  55. package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
  56. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
  57. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
  58. package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
  59. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
  60. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
  61. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
  62. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
  63. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
  64. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
  65. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
  66. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  67. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
  68. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
  69. package/templates/nextblock-template/app/cms/layout.tsx +73 -73
  70. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  71. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  72. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
  73. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  74. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  75. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  76. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
  77. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  78. package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
  79. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  80. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  81. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
  82. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
  83. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
  86. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
  87. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
  88. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +688 -688
  90. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  91. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  92. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  95. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  97. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  98. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  99. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
  103. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +23 -21
  104. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
  105. package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
  106. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  107. package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
  108. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
  109. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
  110. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +9 -7
  111. package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
  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 +49 -0
  116. package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
  117. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  118. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  119. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  120. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  121. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  122. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  123. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  124. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  125. package/templates/nextblock-template/app/providers.tsx +16 -4
  126. package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
  127. package/templates/nextblock-template/app/setup/layout.tsx +13 -13
  128. package/templates/nextblock-template/app/setup/page.tsx +111 -111
  129. package/templates/nextblock-template/app/sitemap.ts +130 -130
  130. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  131. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  132. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  133. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  134. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  135. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  136. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  137. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  138. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  139. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  140. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  141. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  142. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  143. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  144. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  145. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
  146. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  147. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  148. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  149. package/templates/nextblock-template/components/form-message.tsx +32 -32
  150. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +105 -105
  151. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -32
  152. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  153. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  154. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  155. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  156. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -471
  157. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  158. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  159. package/templates/nextblock-template/components/theme-switcher.tsx +77 -82
  160. package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
  161. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  162. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  163. package/templates/nextblock-template/context/language-rest-client.ts +32 -32
  164. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  165. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  166. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  167. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  168. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  169. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +279 -202
  170. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
  171. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
  172. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  173. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  174. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
  175. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
  176. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
  177. package/templates/nextblock-template/docs/README.md +39 -39
  178. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  179. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  180. package/templates/nextblock-template/index.d.ts +7 -7
  181. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  182. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  183. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  184. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  185. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  186. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  187. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  188. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  189. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  190. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  191. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
  192. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  193. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  194. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  195. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  196. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  197. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  198. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  199. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  200. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  201. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  202. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  203. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  204. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  205. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  206. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  207. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  208. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  209. package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
  210. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  211. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
  212. package/templates/nextblock-template/lib/media/youtube.ts +99 -99
  213. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  214. package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
  215. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  216. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  217. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  218. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  219. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  220. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  221. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  222. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  223. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  224. package/templates/nextblock-template/lib/search/types.ts +27 -27
  225. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  226. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  227. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  228. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +15 -10
  229. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  230. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  231. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  232. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  233. package/templates/nextblock-template/lib/site-url.ts +48 -48
  234. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  235. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  236. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  237. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  238. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  239. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  240. package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
  241. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  242. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  243. package/templates/nextblock-template/next-env.d.ts +2 -2
  244. package/templates/nextblock-template/package.json +1 -1
  245. package/templates/nextblock-template/postcss.config.js +6 -6
  246. package/templates/nextblock-template/scripts/backup.js +115 -115
  247. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  248. package/templates/nextblock-template/scripts/restore.js +385 -385
  249. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  250. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  251. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  252. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  253. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  254. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  255. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  256. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  257. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  258. package/templates/nextblock-template/tailwind.config.js +25 -25
  259. package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
  260. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  261. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  262. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
  263. package/tsconfig.base.json +3 -3
@@ -1,1004 +1,1004 @@
1
- "use client";
2
-
3
- import { useRef, useState, useTransition } from "react";
4
- import { useRouter } from "next/navigation";
5
- import { toast } from "sonner";
6
- import {
7
- Alert,
8
- AlertDescription,
9
- AlertTitle,
10
- Badge,
11
- Button,
12
- Card,
13
- CardContent,
14
- CardDescription,
15
- CardHeader,
16
- CardTitle,
17
- Checkbox,
18
- Input,
19
- Label,
20
- Progress,
21
- RadioGroup,
22
- RadioGroupItem,
23
- Select,
24
- SelectContent,
25
- SelectItem,
26
- SelectTrigger,
27
- SelectValue,
28
- Separator,
29
- Spinner,
30
- } from "@nextblock-cms/ui";
31
- import { AlertTriangle, CheckCircle2, Download, Upload } from "lucide-react";
32
-
33
- import type {
34
- CmsContentType,
35
- CmsImportApplyMode,
36
- CmsImportConflictMode,
37
- CmsImportSummary,
38
- } from "../../../../lib/cms-transfer/types";
39
- import {
40
- applyCmsBackupBundleImportAction,
41
- dryRunCmsBackupBundleImportAction,
42
- exportCmsBackupBundleAction,
43
- } from "../../import-export/actions";
44
-
45
- type BackupMode = "content_json" | "full_zip";
46
- type FullBackupMediaUrlMode = "rewrite_to_destination" | "keep_exact_urls";
47
- type ProgressScope = "export" | "restore";
48
-
49
- interface OperationProgress {
50
- scope: ProgressScope;
51
- label: string;
52
- detail: string;
53
- value: number;
54
- cap: number;
55
- }
56
-
57
- interface FullBackupSummary {
58
- success: boolean;
59
- error?: string;
60
- exportedAt?: string;
61
- sourceBucket?: string | null;
62
- sourceBaseUrl?: string | null;
63
- databaseDumpBytes?: number;
64
- contentBundleBytes?: number;
65
- storageObjects?: number;
66
- missingStorageObjects?: number;
67
- warnings: string[];
68
- databaseRestored?: boolean;
69
- storageObjectsUploaded?: number;
70
- storageObjectsDeleted?: number;
71
- mediaUrlRewriteMode?: FullBackupMediaUrlMode;
72
- mediaUrlRowsUpdated?: number;
73
- }
74
-
75
- const CONTENT_TYPES: Array<{ value: CmsContentType; label: string }> = [
76
- { value: "pages", label: "Pages" },
77
- { value: "posts", label: "Posts" },
78
- { value: "products", label: "Products" },
79
- ];
80
-
81
- const FULL_RESTORE_CONFIRMATION = "RESTORE FULL BACKUP";
82
-
83
- function downloadTextFile(fileName: string, mimeType: string, content: string) {
84
- const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
85
- downloadBlobFile(fileName, blob);
86
- }
87
-
88
- function downloadBlobFile(fileName: string, blob: Blob) {
89
- const url = URL.createObjectURL(blob);
90
- const link = document.createElement("a");
91
- link.href = url;
92
- link.download = fileName;
93
- document.body.appendChild(link);
94
- link.click();
95
- link.remove();
96
- URL.revokeObjectURL(url);
97
- }
98
-
99
- function readFileAsText(file: File) {
100
- return new Promise<string>((resolve, reject) => {
101
- const reader = new FileReader();
102
- reader.onload = () => resolve(String(reader.result || ""));
103
- reader.onerror = () => reject(reader.error || new Error("Failed to read file."));
104
- reader.readAsText(file);
105
- });
106
- }
107
-
108
- function getDownloadFileName(response: Response, fallback: string) {
109
- const disposition = response.headers.get("content-disposition") || "";
110
- const match = disposition.match(/filename="?([^";]+)"?/i);
111
- return match?.[1] || fallback;
112
- }
113
-
114
- async function downloadResponseWithProgress(
115
- response: Response,
116
- onProgress: (received: number, total: number | null) => void
117
- ) {
118
- const total = Number(response.headers.get("content-length") || 0) || null;
119
- if (!response.body) {
120
- const blob = await response.blob();
121
- onProgress(blob.size, total);
122
- return blob;
123
- }
124
-
125
- const reader = response.body.getReader();
126
- const chunks: Uint8Array[] = [];
127
- let received = 0;
128
-
129
- for (;;) {
130
- const { done, value } = await reader.read();
131
- if (done) break;
132
- if (!value) continue;
133
- chunks.push(value);
134
- received += value.byteLength;
135
- onProgress(received, total);
136
- }
137
-
138
- return new Blob(chunks as BlobPart[], {
139
- type: response.headers.get("content-type") || "application/zip",
140
- });
141
- }
142
-
143
- function postFormDataWithProgress<T>(
144
- url: string,
145
- formData: FormData,
146
- onUploadProgress: (loaded: number, total: number | null) => void,
147
- onUploadComplete?: () => void
148
- ) {
149
- return new Promise<{ ok: boolean; status: number; body: T }>((resolve, reject) => {
150
- const request = new XMLHttpRequest();
151
- request.open("POST", url);
152
- request.upload.onprogress = (event) => {
153
- onUploadProgress(event.loaded, event.lengthComputable ? event.total : null);
154
- };
155
- request.upload.onload = () => onUploadComplete?.();
156
- request.onerror = () => reject(new Error("Network error while uploading backup."));
157
- request.onload = () => {
158
- const body = request.responseText ? JSON.parse(request.responseText) : null;
159
- resolve({
160
- ok: request.status >= 200 && request.status < 300,
161
- status: request.status,
162
- body: body as T,
163
- });
164
- };
165
- request.send(formData);
166
- });
167
- }
168
-
169
- function formatBytes(value?: number) {
170
- if (!value) return "0 B";
171
- const units = ["B", "KB", "MB", "GB"];
172
- let size = value;
173
- let unitIndex = 0;
174
- while (size >= 1024 && unitIndex < units.length - 1) {
175
- size /= 1024;
176
- unitIndex += 1;
177
- }
178
- return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
179
- }
180
-
181
- function Summary({ summary }: { summary: CmsImportSummary | null }) {
182
- if (!summary) return null;
183
-
184
- return (
185
- <div className="space-y-3">
186
- <div className="flex flex-wrap gap-2">
187
- <Badge variant={summary.success ? "default" : "destructive"}>
188
- {summary.success ? "Ready" : "Needs Fixes"}
189
- </Badge>
190
- <Badge variant="outline">{summary.totalRows} rows</Badge>
191
- <Badge variant="outline">{summary.created} create</Badge>
192
- <Badge variant="outline">{summary.updated} update</Badge>
193
- {summary.warnings.length ? (
194
- <Badge variant="secondary">{summary.warnings.length} warnings</Badge>
195
- ) : null}
196
- </div>
197
- {summary.errors.length ? (
198
- <Alert variant="destructive">
199
- <AlertDescription>
200
- {summary.errors.slice(0, 8).map((error) => (
201
- <span key={`${error.row}-${error.message}`} className="block">
202
- Row {error.row}: {error.message}
203
- </span>
204
- ))}
205
- </AlertDescription>
206
- </Alert>
207
- ) : null}
208
- {summary.warnings.length ? (
209
- <Alert>
210
- <AlertDescription>
211
- {summary.warnings.slice(0, 6).map((warning) => (
212
- <span key={`${warning.row}-${warning.message}`} className="block">
213
- Row {warning.row}: {warning.message}
214
- </span>
215
- ))}
216
- </AlertDescription>
217
- </Alert>
218
- ) : null}
219
- </div>
220
- );
221
- }
222
-
223
- function FullBackupSummaryPanel({
224
- summary,
225
- restored,
226
- }: {
227
- summary: FullBackupSummary | null;
228
- restored: boolean;
229
- }) {
230
- if (!summary) return null;
231
-
232
- return (
233
- <div className="space-y-3">
234
- <div className="flex flex-wrap gap-2">
235
- <Badge variant={summary.success ? "default" : "destructive"}>
236
- {restored ? "Restored" : summary.success ? "Ready" : "Needs Fixes"}
237
- </Badge>
238
- <Badge variant="outline">{summary.storageObjects || 0} R2 objects</Badge>
239
- <Badge variant="outline">{formatBytes(summary.databaseDumpBytes)} DB</Badge>
240
- <Badge variant="outline">{formatBytes(summary.contentBundleBytes)} content JSON</Badge>
241
- {summary.missingStorageObjects ? (
242
- <Badge variant="destructive">{summary.missingStorageObjects} missing files</Badge>
243
- ) : null}
244
- </div>
245
-
246
- {summary.error ? (
247
- <Alert variant="destructive">
248
- <AlertTriangle className="h-4 w-4" />
249
- <AlertTitle>Full Backup Review Failed</AlertTitle>
250
- <AlertDescription>{summary.error}</AlertDescription>
251
- </Alert>
252
- ) : null}
253
-
254
- {restored && summary.success ? (
255
- <Alert variant="success">
256
- <CheckCircle2 className="h-4 w-4" />
257
- <AlertTitle>Restoration Completed Successfully</AlertTitle>
258
- <AlertDescription>
259
- Uploaded {summary.storageObjectsUploaded || 0} R2 object(s), restored the database,
260
- and updated {summary.mediaUrlRowsUpdated || 0} media URL row(s).
261
- {summary.storageObjectsDeleted ? ` Deleted ${summary.storageObjectsDeleted} extra R2 object(s).` : ""}
262
- </AlertDescription>
263
- </Alert>
264
- ) : null}
265
-
266
- {summary.warnings?.length ? (
267
- <Alert variant="warning">
268
- <AlertTriangle className="h-4 w-4" />
269
- <AlertTitle>Warnings</AlertTitle>
270
- <AlertDescription>
271
- {summary.warnings.slice(0, 8).map((warning) => (
272
- <span key={warning} className="block">
273
- {warning}
274
- </span>
275
- ))}
276
- </AlertDescription>
277
- </Alert>
278
- ) : null}
279
- </div>
280
- );
281
- }
282
-
283
- function OperationProgressBar({
284
- progress,
285
- scope,
286
- }: {
287
- progress: OperationProgress | null;
288
- scope: ProgressScope;
289
- }) {
290
- if (!progress || progress.scope !== scope) return null;
291
-
292
- return (
293
- <div className="space-y-2 rounded-md border bg-muted/30 p-3">
294
- <div className="flex items-center justify-between gap-3 text-sm">
295
- <span className="font-medium">{progress.label}</span>
296
- <span className="tabular-nums text-muted-foreground">{Math.round(progress.value)}%</span>
297
- </div>
298
- <Progress value={progress.value} className="h-2" />
299
- <p className="text-xs text-muted-foreground">{progress.detail}</p>
300
- </div>
301
- );
302
- }
303
-
304
- export function BackupRestoreWorkspace({
305
- isEcommerceActive,
306
- }: {
307
- isEcommerceActive: boolean;
308
- }) {
309
- const router = useRouter();
310
- const availableContentTypes = isEcommerceActive
311
- ? CONTENT_TYPES
312
- : CONTENT_TYPES.filter((item) => item.value !== "products");
313
- const contentTypeLabel = isEcommerceActive ? "pages, posts, and products" : "pages and posts";
314
- const [backupMode, setBackupMode] = useState<BackupMode>("content_json");
315
- const [bundleJson, setBundleJson] = useState("");
316
- const [fileName, setFileName] = useState("");
317
- const [selectedTypes, setSelectedTypes] = useState<CmsContentType[]>(
318
- isEcommerceActive ? ["pages", "posts", "products"] : ["pages", "posts"]
319
- );
320
- const [includeBlocks, setIncludeBlocks] = useState(true);
321
- const [conflictMode, setConflictMode] =
322
- useState<CmsImportConflictMode>("overwrite_existing");
323
- const [applyMode, setApplyMode] = useState<CmsImportApplyMode>("draft");
324
- const [summary, setSummary] = useState<CmsImportSummary | null>(null);
325
- const [jsonRestoreComplete, setJsonRestoreComplete] = useState(false);
326
- const [isPending, startTransition] = useTransition();
327
- const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null);
328
- const progressTimerRef = useRef<number | null>(null);
329
- const clearProgressTimeoutRef = useRef<number | null>(null);
330
-
331
- const [fullZipFile, setFullZipFile] = useState<File | null>(null);
332
- const [fullSummary, setFullSummary] = useState<FullBackupSummary | null>(null);
333
- const [fullRestoreComplete, setFullRestoreComplete] = useState(false);
334
- const [isFullPending, setIsFullPending] = useState(false);
335
- const [mediaUrlMode, setMediaUrlMode] =
336
- useState<FullBackupMediaUrlMode>("rewrite_to_destination");
337
- const [destinationBaseUrl, setDestinationBaseUrl] = useState(
338
- process.env.NEXT_PUBLIC_R2_BASE_URL || ""
339
- );
340
- const [deleteExtraneousR2Objects, setDeleteExtraneousR2Objects] = useState(false);
341
- const [confirmation, setConfirmation] = useState("");
342
-
343
- const clearProgressTimers = () => {
344
- if (progressTimerRef.current) {
345
- window.clearInterval(progressTimerRef.current);
346
- progressTimerRef.current = null;
347
- }
348
- if (clearProgressTimeoutRef.current) {
349
- window.clearTimeout(clearProgressTimeoutRef.current);
350
- clearProgressTimeoutRef.current = null;
351
- }
352
- };
353
-
354
- const startProgress = (progress: OperationProgress) => {
355
- clearProgressTimers();
356
- setOperationProgress(progress);
357
- progressTimerRef.current = window.setInterval(() => {
358
- setOperationProgress((current) => {
359
- if (!current) return current;
360
- const remaining = Math.max(0, current.cap - current.value);
361
- if (remaining <= 0.5) return current;
362
- return {
363
- ...current,
364
- value: Math.min(current.cap, current.value + Math.max(0.7, remaining * 0.07)),
365
- };
366
- });
367
- }, 700) as unknown as number;
368
- };
369
-
370
- const updateProgress = (patch: Partial<OperationProgress>) => {
371
- setOperationProgress((current) => (current ? { ...current, ...patch } : current));
372
- };
373
-
374
- const finishProgress = (label: string, detail: string) => {
375
- clearProgressTimers();
376
- setOperationProgress((current) =>
377
- current
378
- ? {
379
- ...current,
380
- label,
381
- detail,
382
- value: 100,
383
- cap: 100,
384
- }
385
- : current
386
- );
387
- clearProgressTimeoutRef.current = window.setTimeout(() => {
388
- setOperationProgress(null);
389
- clearProgressTimeoutRef.current = null;
390
- }, 1400) as unknown as number;
391
- };
392
-
393
- const toggleContentType = (contentType: CmsContentType, checked: boolean) => {
394
- setSummary(null);
395
- setJsonRestoreComplete(false);
396
- setSelectedTypes((current) => {
397
- if (checked) {
398
- return Array.from(new Set([...current, contentType]));
399
- }
400
-
401
- return current.filter((item) => item !== contentType);
402
- });
403
- };
404
-
405
- const exportBundle = () => {
406
- startProgress({
407
- scope: "export",
408
- label: "Exporting Content JSON",
409
- detail: `Collecting ${contentTypeLabel} and blocks.`,
410
- value: 12,
411
- cap: 82,
412
- });
413
- startTransition(async () => {
414
- const result = await exportCmsBackupBundleAction();
415
- if (!result.success || !result.content || !result.fileName || !result.mimeType) {
416
- clearProgressTimers();
417
- setOperationProgress(null);
418
- toast.error(result.error || "Failed to export backup.");
419
- return;
420
- }
421
-
422
- updateProgress({
423
- label: "Downloading Content JSON",
424
- detail: "Preparing the browser download.",
425
- value: 92,
426
- cap: 96,
427
- });
428
- downloadTextFile(result.fileName, result.mimeType, result.content);
429
- finishProgress("Content JSON Exported", "The backup download has started.");
430
- toast.success("Content JSON backup exported.");
431
- });
432
- };
433
-
434
- const exportFullZip = async () => {
435
- setIsFullPending(true);
436
- startProgress({
437
- scope: "export",
438
- label: "Building Full Site ZIP",
439
- detail: "Running database backup and collecting R2 media files.",
440
- value: 5,
441
- cap: 64,
442
- });
443
- try {
444
- const response = await fetch("/api/cms/full-backup/export", {
445
- method: "POST",
446
- });
447
-
448
- if (!response.ok) {
449
- const result = (await response.json().catch(() => null)) as { error?: string } | null;
450
- throw new Error(result?.error || "Failed to export full ZIP backup.");
451
- }
452
-
453
- updateProgress({
454
- label: "Downloading Full Site ZIP",
455
- detail: "Receiving the archive from the server.",
456
- value: 65,
457
- cap: 96,
458
- });
459
- const blob = await downloadResponseWithProgress(response, (received, total) => {
460
- if (!total) {
461
- updateProgress({
462
- value: 84,
463
- detail: `Downloaded ${formatBytes(received)}.`,
464
- });
465
- return;
466
- }
467
- updateProgress({
468
- value: Math.min(98, 65 + (received / total) * 33),
469
- detail: `Downloaded ${formatBytes(received)} of ${formatBytes(total)}.`,
470
- });
471
- });
472
- downloadBlobFile(
473
- getDownloadFileName(response, "nextblock-full-site-backup.zip"),
474
- blob
475
- );
476
- finishProgress("Full Site ZIP Exported", "The migration archive download has started.");
477
- toast.success("Full site ZIP backup exported.");
478
- } catch (error) {
479
- clearProgressTimers();
480
- setOperationProgress(null);
481
- toast.error(error instanceof Error ? error.message : "Failed to export full ZIP backup.");
482
- } finally {
483
- setIsFullPending(false);
484
- }
485
- };
486
-
487
- const reviewImport = () => {
488
- if (!bundleJson.trim()) {
489
- toast.error("Choose a backup JSON file first.");
490
- return;
491
- }
492
- if (selectedTypes.length === 0 && !includeBlocks) {
493
- toast.error("Select at least one content type or the Blocks Library.");
494
- return;
495
- }
496
-
497
- startProgress({
498
- scope: "restore",
499
- label: "Reviewing Content Backup",
500
- detail: "Validating the JSON file before restore.",
501
- value: 15,
502
- cap: 86,
503
- });
504
- startTransition(async () => {
505
- const result = await dryRunCmsBackupBundleImportAction({
506
- bundleJson,
507
- contentTypes: selectedTypes,
508
- conflictMode,
509
- applyMode,
510
- includeBlocks,
511
- });
512
- setSummary(result);
513
- setJsonRestoreComplete(false);
514
- if (result.success) {
515
- finishProgress("Backup Review Complete", "The content backup is ready to restore.");
516
- toast.success("Backup review is ready.");
517
- } else {
518
- clearProgressTimers();
519
- setOperationProgress(null);
520
- toast.error("Backup has errors to fix.");
521
- }
522
- });
523
- };
524
-
525
- const applyImport = () => {
526
- if (!summary?.success || (selectedTypes.length === 0 && !includeBlocks)) return;
527
-
528
- startProgress({
529
- scope: "restore",
530
- label: "Restoring Content Backup",
531
- detail: "Writing content records and blocks.",
532
- value: 12,
533
- cap: 88,
534
- });
535
- startTransition(async () => {
536
- const result = await applyCmsBackupBundleImportAction({
537
- bundleJson,
538
- contentTypes: selectedTypes,
539
- conflictMode,
540
- applyMode,
541
- includeBlocks,
542
- });
543
- setSummary(result);
544
- if (!result.success) {
545
- setJsonRestoreComplete(false);
546
- clearProgressTimers();
547
- setOperationProgress(null);
548
- toast.error("Backup restore failed validation.");
549
- return;
550
- }
551
-
552
- setJsonRestoreComplete(true);
553
- finishProgress("Content Restore Complete", "The content backup was restored successfully.");
554
- toast.success("Restoration completed successfully.");
555
- router.refresh();
556
- });
557
- };
558
-
559
- const submitFullBackup = async (action: "review" | "restore") => {
560
- if (!fullZipFile) {
561
- toast.error("Choose a full backup ZIP file first.");
562
- return;
563
- }
564
- if (action === "restore" && confirmation.trim() !== FULL_RESTORE_CONFIRMATION) {
565
- toast.error(`Type ${FULL_RESTORE_CONFIRMATION} to confirm full restore.`);
566
- return;
567
- }
568
-
569
- const formData = new FormData();
570
- formData.set("action", action);
571
- formData.set("file", fullZipFile);
572
- formData.set("mediaUrlMode", mediaUrlMode);
573
- formData.set("destinationBaseUrl", destinationBaseUrl);
574
- formData.set("deleteExtraneousR2Objects", String(deleteExtraneousR2Objects));
575
- formData.set("confirmation", confirmation);
576
-
577
- setIsFullPending(true);
578
- startProgress({
579
- scope: "restore",
580
- label: action === "restore" ? "Uploading Full Backup" : "Uploading Backup for Review",
581
- detail: "Sending the ZIP archive to the server.",
582
- value: 8,
583
- cap: 46,
584
- });
585
- try {
586
- const response = await postFormDataWithProgress<FullBackupSummary>(
587
- "/api/cms/full-backup/restore",
588
- formData,
589
- (loaded, total) => {
590
- if (!total) {
591
- updateProgress({
592
- value: 34,
593
- detail: `Uploaded ${formatBytes(loaded)}.`,
594
- });
595
- return;
596
- }
597
- updateProgress({
598
- value: Math.min(48, 8 + (loaded / total) * 40),
599
- detail: `Uploaded ${formatBytes(loaded)} of ${formatBytes(total)}.`,
600
- });
601
- },
602
- () => {
603
- updateProgress({
604
- label: action === "restore" ? "Restoring Full Backup" : "Reviewing Full Backup",
605
- detail:
606
- action === "restore"
607
- ? "Re-uploading R2 objects, restoring the database, and rewriting media URLs."
608
- : "Inspecting the archive manifest and required files.",
609
- value: 52,
610
- cap: action === "restore" ? 94 : 86,
611
- });
612
- }
613
- );
614
- const result = response.body;
615
- setFullSummary(result);
616
- setFullRestoreComplete(action === "restore" && result.success);
617
-
618
- if (!response.ok || !result.success) {
619
- clearProgressTimers();
620
- setOperationProgress(null);
621
- toast.error(result.error || "Full backup needs attention.");
622
- return;
623
- }
624
-
625
- finishProgress(
626
- action === "restore" ? "Full Restore Complete" : "Full Backup Review Complete",
627
- action === "restore"
628
- ? "The database and R2 media archive were restored."
629
- : "The ZIP archive is ready to restore."
630
- );
631
- toast.success(
632
- action === "restore"
633
- ? "Restoration completed successfully."
634
- : "Full backup review is ready."
635
- );
636
- if (action === "restore") router.refresh();
637
- } catch (error) {
638
- clearProgressTimers();
639
- setOperationProgress(null);
640
- toast.error(error instanceof Error ? error.message : "Failed to process full backup.");
641
- } finally {
642
- setIsFullPending(false);
643
- }
644
- };
645
-
646
- return (
647
- <div className="mx-auto max-w-6xl space-y-6">
648
- <div>
649
- <h1 className="text-2xl font-semibold">Backup And Restore</h1>
650
- <p className="mt-1 text-sm text-muted-foreground">
651
- Export content-only JSON backups or full migration ZIP archives with database and R2 files.
652
- </p>
653
- </div>
654
-
655
- <RadioGroup
656
- value={backupMode}
657
- onValueChange={(value) => setBackupMode(value as BackupMode)}
658
- className="grid gap-3 md:grid-cols-2"
659
- >
660
- <label className="flex cursor-pointer items-start gap-3 rounded-md border p-4">
661
- <RadioGroupItem value="content_json" className="mt-1" />
662
- <span className="space-y-1">
663
- <span className="block text-sm font-medium">Content JSON</span>
664
- <span className="block text-sm text-muted-foreground">
665
- {isEcommerceActive
666
- ? "Pages, posts, products, metadata, translation groups, blocks, product variants, category slugs, media references, and custom block definitions (Blocks Library). Image binaries are not included."
667
- : "Pages, posts, metadata, translation groups, blocks, and custom block definitions (Blocks Library). Product content is included only when the ecommerce package is active. Image binaries are not included."}
668
- </span>
669
- </span>
670
- </label>
671
- <label className="flex cursor-pointer items-start gap-3 rounded-md border p-4">
672
- <RadioGroupItem value="full_zip" className="mt-1" />
673
- <span className="space-y-1">
674
- <span className="block text-sm font-medium">Full Site ZIP</span>
675
- <span className="block text-sm text-muted-foreground">
676
- Migration archive with a database dump and R2 media files. Restoring this is destructive
677
- and intended for moving an old server to a new one.
678
- </span>
679
- </span>
680
- </label>
681
- </RadioGroup>
682
-
683
- <div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
684
- <Card>
685
- <CardHeader>
686
- <CardTitle>Export Backup</CardTitle>
687
- <CardDescription>
688
- {backupMode === "content_json"
689
- ? `Download an editable JSON bundle for CMS ${contentTypeLabel}.`
690
- : "Download a full migration ZIP with database dump and R2 media objects."}
691
- </CardDescription>
692
- </CardHeader>
693
- <CardContent className="space-y-4">
694
- {backupMode === "content_json" ? (
695
- <>
696
- <Alert>
697
- <AlertDescription>
698
- The JSON file references existing media by ID and object key, but does not include
699
- the image or file binaries from R2.
700
- </AlertDescription>
701
- </Alert>
702
- <Button onClick={exportBundle} disabled={isPending}>
703
- {isPending ? <Spinner className="mr-2 h-4 w-4" /> : <Download className="mr-2 h-4 w-4" />}
704
- Export Content JSON
705
- </Button>
706
- <OperationProgressBar progress={operationProgress} scope="export" />
707
- </>
708
- ) : (
709
- <>
710
- <Alert variant="warning">
711
- <AlertTriangle className="h-4 w-4" />
712
- <AlertTitle>Sensitive Migration Archive</AlertTitle>
713
- <AlertDescription>
714
- The ZIP can include users, orders, customer data, payment records, settings,
715
- database schema, and media files depending on your database permissions.
716
- </AlertDescription>
717
- </Alert>
718
- <Button onClick={exportFullZip} disabled={isFullPending}>
719
- {isFullPending ? <Spinner className="mr-2 h-4 w-4" /> : <Download className="mr-2 h-4 w-4" />}
720
- Export Full Site ZIP
721
- </Button>
722
- <OperationProgressBar progress={operationProgress} scope="export" />
723
- </>
724
- )}
725
- </CardContent>
726
- </Card>
727
-
728
- <Card>
729
- <CardHeader>
730
- <CardTitle>Restore from Backup</CardTitle>
731
- <CardDescription>
732
- {backupMode === "content_json"
733
- ? `Review a content backup before restoring ${contentTypeLabel}.`
734
- : "Review a full ZIP first, then restore database and R2 media into this environment."}
735
- </CardDescription>
736
- </CardHeader>
737
- <CardContent className="space-y-5">
738
- {backupMode === "content_json" ? (
739
- <>
740
- <div className="space-y-2">
741
- <Label htmlFor="backup-json-file">Backup JSON file</Label>
742
- <Input
743
- id="backup-json-file"
744
- type="file"
745
- accept=".json,application/json"
746
- onChange={async (event) => {
747
- const file = event.target.files?.[0];
748
- setSummary(null);
749
- setJsonRestoreComplete(false);
750
- if (!file) return;
751
- setFileName(file.name);
752
- try {
753
- setBundleJson(await readFileAsText(file));
754
- } catch {
755
- toast.error("Failed to read backup file.");
756
- }
757
- }}
758
- />
759
- {fileName ? (
760
- <p className="text-xs text-muted-foreground">{fileName}</p>
761
- ) : null}
762
- </div>
763
-
764
- <div className="grid gap-3 sm:grid-cols-3">
765
- {availableContentTypes.map((item) => (
766
- <label
767
- key={item.value}
768
- className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
769
- >
770
- <Checkbox
771
- checked={selectedTypes.includes(item.value)}
772
- onCheckedChange={(checked) => toggleContentType(item.value, checked === true)}
773
- />
774
- {item.label}
775
- </label>
776
- ))}
777
- <label className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
778
- <Checkbox
779
- checked={includeBlocks}
780
- onCheckedChange={(checked) => {
781
- setIncludeBlocks(checked === true);
782
- setSummary(null);
783
- setJsonRestoreComplete(false);
784
- }}
785
- />
786
- Blocks Library
787
- </label>
788
- </div>
789
-
790
- <div className="grid gap-3 sm:grid-cols-2">
791
- <div className="space-y-2">
792
- <Label>Existing records</Label>
793
- <Select
794
- value={conflictMode}
795
- onValueChange={(value) => {
796
- setConflictMode(value as CmsImportConflictMode);
797
- setSummary(null);
798
- setJsonRestoreComplete(false);
799
- }}
800
- >
801
- <SelectTrigger>
802
- <SelectValue />
803
- </SelectTrigger>
804
- <SelectContent>
805
- <SelectItem value="overwrite_existing">Overwrite matches</SelectItem>
806
- <SelectItem value="create_new">Create new copies</SelectItem>
807
- </SelectContent>
808
- </Select>
809
- </div>
810
-
811
- <div className="space-y-2">
812
- <Label>Apply as</Label>
813
- <Select
814
- value={applyMode}
815
- onValueChange={(value) => {
816
- setApplyMode(value as CmsImportApplyMode);
817
- setSummary(null);
818
- setJsonRestoreComplete(false);
819
- }}
820
- >
821
- <SelectTrigger>
822
- <SelectValue />
823
- </SelectTrigger>
824
- <SelectContent>
825
- <SelectItem value="draft">Drafts</SelectItem>
826
- <SelectItem value="live">Live content</SelectItem>
827
- </SelectContent>
828
- </Select>
829
- </div>
830
- </div>
831
-
832
- <Summary summary={summary} />
833
- <OperationProgressBar progress={operationProgress} scope="restore" />
834
-
835
- {jsonRestoreComplete && summary?.success ? (
836
- <Alert variant="success">
837
- <CheckCircle2 className="h-4 w-4" />
838
- <AlertTitle>Restoration Completed Successfully</AlertTitle>
839
- <AlertDescription>
840
- Restored {summary.totalRows} content row(s): {summary.created} created and{" "}
841
- {summary.updated} updated.
842
- </AlertDescription>
843
- </Alert>
844
- ) : null}
845
-
846
- <div className="flex flex-wrap gap-2">
847
- <Button
848
- type="button"
849
- variant="outline"
850
- onClick={reviewImport}
851
- disabled={
852
- isPending ||
853
- !bundleJson.trim() ||
854
- (selectedTypes.length === 0 && !includeBlocks)
855
- }
856
- >
857
- {isPending ? <Spinner className="mr-2 h-4 w-4" /> : <Upload className="mr-2 h-4 w-4" />}
858
- Review Backup
859
- </Button>
860
- <Button
861
- type="button"
862
- onClick={applyImport}
863
- disabled={
864
- isPending ||
865
- !summary?.success ||
866
- (selectedTypes.length === 0 && !includeBlocks)
867
- }
868
- >
869
- {isPending ? <Spinner className="mr-2 h-4 w-4" /> : <Upload className="mr-2 h-4 w-4" />}
870
- Restore from Backup
871
- </Button>
872
- </div>
873
- </>
874
- ) : (
875
- <>
876
- <Alert variant="warning">
877
- <AlertTriangle className="h-4 w-4" />
878
- <AlertTitle>Full Restore Replaces This Site</AlertTitle>
879
- <AlertDescription>
880
- This restores R2 files and runs the archived SQL dump against the configured database.
881
- Use it for migration from an old server to a new server.
882
- </AlertDescription>
883
- </Alert>
884
-
885
- <div className="space-y-2">
886
- <Label htmlFor="backup-zip-file">Full backup ZIP file</Label>
887
- <Input
888
- id="backup-zip-file"
889
- type="file"
890
- accept=".zip,application/zip"
891
- onChange={(event) => {
892
- const file = event.target.files?.[0] || null;
893
- setFullZipFile(file);
894
- setFullSummary(null);
895
- setFullRestoreComplete(false);
896
- }}
897
- />
898
- {fullZipFile ? (
899
- <p className="text-xs text-muted-foreground">
900
- {fullZipFile.name} ({formatBytes(fullZipFile.size)})
901
- </p>
902
- ) : null}
903
- </div>
904
-
905
- <div className="grid gap-3 sm:grid-cols-2">
906
- <div className="space-y-2">
907
- <Label>Media URL mode</Label>
908
- <Select
909
- value={mediaUrlMode}
910
- onValueChange={(value) => {
911
- setMediaUrlMode(value as FullBackupMediaUrlMode);
912
- setFullRestoreComplete(false);
913
- }}
914
- >
915
- <SelectTrigger>
916
- <SelectValue />
917
- </SelectTrigger>
918
- <SelectContent>
919
- <SelectItem value="rewrite_to_destination">Use new bucket address</SelectItem>
920
- <SelectItem value="keep_exact_urls">Keep exact public URLs</SelectItem>
921
- </SelectContent>
922
- </Select>
923
- </div>
924
- <div className="space-y-2">
925
- <Label htmlFor="destination-base-url">New media base URL</Label>
926
- <Input
927
- id="destination-base-url"
928
- value={destinationBaseUrl}
929
- onChange={(event) => {
930
- setDestinationBaseUrl(event.target.value);
931
- setFullRestoreComplete(false);
932
- }}
933
- placeholder="https://assets.example.com"
934
- disabled={mediaUrlMode === "keep_exact_urls"}
935
- />
936
- </div>
937
- </div>
938
-
939
- <label className="flex items-start gap-2 rounded-md border p-3 text-sm">
940
- <Checkbox
941
- checked={deleteExtraneousR2Objects}
942
- onCheckedChange={(checked) => {
943
- setDeleteExtraneousR2Objects(checked === true);
944
- setFullRestoreComplete(false);
945
- }}
946
- />
947
- <span>
948
- <span className="block font-medium">Delete destination R2 files not in the archive</span>
949
- <span className="block text-muted-foreground">
950
- Advanced cleanup. Leave off unless this new bucket is dedicated to the restored site.
951
- </span>
952
- </span>
953
- </label>
954
-
955
- <FullBackupSummaryPanel summary={fullSummary} restored={fullRestoreComplete} />
956
- <OperationProgressBar progress={operationProgress} scope="restore" />
957
-
958
- <Separator />
959
-
960
- <div className="space-y-2">
961
- <Label htmlFor="full-restore-confirmation">Destructive restore confirmation</Label>
962
- <Input
963
- id="full-restore-confirmation"
964
- value={confirmation}
965
- onChange={(event) => setConfirmation(event.target.value)}
966
- placeholder={FULL_RESTORE_CONFIRMATION}
967
- />
968
- <p className="text-xs text-muted-foreground">
969
- Type {FULL_RESTORE_CONFIRMATION} before restoring the full ZIP.
970
- </p>
971
- </div>
972
-
973
- <div className="flex flex-wrap gap-2">
974
- <Button
975
- type="button"
976
- variant="outline"
977
- onClick={() => submitFullBackup("review")}
978
- disabled={isFullPending || !fullZipFile}
979
- >
980
- {isFullPending ? <Spinner className="mr-2 h-4 w-4" /> : <Upload className="mr-2 h-4 w-4" />}
981
- Review Full Backup
982
- </Button>
983
- <Button
984
- type="button"
985
- onClick={() => submitFullBackup("restore")}
986
- disabled={
987
- isFullPending ||
988
- !fullSummary?.success ||
989
- fullRestoreComplete ||
990
- confirmation.trim() !== FULL_RESTORE_CONFIRMATION
991
- }
992
- >
993
- {isFullPending ? <Spinner className="mr-2 h-4 w-4" /> : <Upload className="mr-2 h-4 w-4" />}
994
- Restore from Backup
995
- </Button>
996
- </div>
997
- </>
998
- )}
999
- </CardContent>
1000
- </Card>
1001
- </div>
1002
- </div>
1003
- );
1004
- }
1
+ "use client";
2
+
3
+ import { useRef, useState, useTransition } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { toast } from "sonner";
6
+ import {
7
+ Alert,
8
+ AlertDescription,
9
+ AlertTitle,
10
+ Badge,
11
+ Button,
12
+ Card,
13
+ CardContent,
14
+ CardDescription,
15
+ CardHeader,
16
+ CardTitle,
17
+ Checkbox,
18
+ Input,
19
+ Label,
20
+ Progress,
21
+ RadioGroup,
22
+ RadioGroupItem,
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ Separator,
29
+ Spinner,
30
+ } from "@nextblock-cms/ui";
31
+ import { AlertTriangle, CheckCircle2, Download, Upload } from "lucide-react";
32
+
33
+ import type {
34
+ CmsContentType,
35
+ CmsImportApplyMode,
36
+ CmsImportConflictMode,
37
+ CmsImportSummary,
38
+ } from "../../../../lib/cms-transfer/types";
39
+ import {
40
+ applyCmsBackupBundleImportAction,
41
+ dryRunCmsBackupBundleImportAction,
42
+ exportCmsBackupBundleAction,
43
+ } from "../../import-export/actions";
44
+
45
+ type BackupMode = "content_json" | "full_zip";
46
+ type FullBackupMediaUrlMode = "rewrite_to_destination" | "keep_exact_urls";
47
+ type ProgressScope = "export" | "restore";
48
+
49
+ interface OperationProgress {
50
+ scope: ProgressScope;
51
+ label: string;
52
+ detail: string;
53
+ value: number;
54
+ cap: number;
55
+ }
56
+
57
+ interface FullBackupSummary {
58
+ success: boolean;
59
+ error?: string;
60
+ exportedAt?: string;
61
+ sourceBucket?: string | null;
62
+ sourceBaseUrl?: string | null;
63
+ databaseDumpBytes?: number;
64
+ contentBundleBytes?: number;
65
+ storageObjects?: number;
66
+ missingStorageObjects?: number;
67
+ warnings: string[];
68
+ databaseRestored?: boolean;
69
+ storageObjectsUploaded?: number;
70
+ storageObjectsDeleted?: number;
71
+ mediaUrlRewriteMode?: FullBackupMediaUrlMode;
72
+ mediaUrlRowsUpdated?: number;
73
+ }
74
+
75
+ const CONTENT_TYPES: Array<{ value: CmsContentType; label: string }> = [
76
+ { value: "pages", label: "Pages" },
77
+ { value: "posts", label: "Posts" },
78
+ { value: "products", label: "Products" },
79
+ ];
80
+
81
+ const FULL_RESTORE_CONFIRMATION = "RESTORE FULL BACKUP";
82
+
83
+ function downloadTextFile(fileName: string, mimeType: string, content: string) {
84
+ const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
85
+ downloadBlobFile(fileName, blob);
86
+ }
87
+
88
+ function downloadBlobFile(fileName: string, blob: Blob) {
89
+ const url = URL.createObjectURL(blob);
90
+ const link = document.createElement("a");
91
+ link.href = url;
92
+ link.download = fileName;
93
+ document.body.appendChild(link);
94
+ link.click();
95
+ link.remove();
96
+ URL.revokeObjectURL(url);
97
+ }
98
+
99
+ function readFileAsText(file: File) {
100
+ return new Promise<string>((resolve, reject) => {
101
+ const reader = new FileReader();
102
+ reader.onload = () => resolve(String(reader.result || ""));
103
+ reader.onerror = () => reject(reader.error || new Error("Failed to read file."));
104
+ reader.readAsText(file);
105
+ });
106
+ }
107
+
108
+ function getDownloadFileName(response: Response, fallback: string) {
109
+ const disposition = response.headers.get("content-disposition") || "";
110
+ const match = disposition.match(/filename="?([^";]+)"?/i);
111
+ return match?.[1] || fallback;
112
+ }
113
+
114
+ async function downloadResponseWithProgress(
115
+ response: Response,
116
+ onProgress: (received: number, total: number | null) => void
117
+ ) {
118
+ const total = Number(response.headers.get("content-length") || 0) || null;
119
+ if (!response.body) {
120
+ const blob = await response.blob();
121
+ onProgress(blob.size, total);
122
+ return blob;
123
+ }
124
+
125
+ const reader = response.body.getReader();
126
+ const chunks: Uint8Array[] = [];
127
+ let received = 0;
128
+
129
+ for (;;) {
130
+ const { done, value } = await reader.read();
131
+ if (done) break;
132
+ if (!value) continue;
133
+ chunks.push(value);
134
+ received += value.byteLength;
135
+ onProgress(received, total);
136
+ }
137
+
138
+ return new Blob(chunks as BlobPart[], {
139
+ type: response.headers.get("content-type") || "application/zip",
140
+ });
141
+ }
142
+
143
+ function postFormDataWithProgress<T>(
144
+ url: string,
145
+ formData: FormData,
146
+ onUploadProgress: (loaded: number, total: number | null) => void,
147
+ onUploadComplete?: () => void
148
+ ) {
149
+ return new Promise<{ ok: boolean; status: number; body: T }>((resolve, reject) => {
150
+ const request = new XMLHttpRequest();
151
+ request.open("POST", url);
152
+ request.upload.onprogress = (event) => {
153
+ onUploadProgress(event.loaded, event.lengthComputable ? event.total : null);
154
+ };
155
+ request.upload.onload = () => onUploadComplete?.();
156
+ request.onerror = () => reject(new Error("Network error while uploading backup."));
157
+ request.onload = () => {
158
+ const body = request.responseText ? JSON.parse(request.responseText) : null;
159
+ resolve({
160
+ ok: request.status >= 200 && request.status < 300,
161
+ status: request.status,
162
+ body: body as T,
163
+ });
164
+ };
165
+ request.send(formData);
166
+ });
167
+ }
168
+
169
+ function formatBytes(value?: number) {
170
+ if (!value) return "0 B";
171
+ const units = ["B", "KB", "MB", "GB"];
172
+ let size = value;
173
+ let unitIndex = 0;
174
+ while (size >= 1024 && unitIndex < units.length - 1) {
175
+ size /= 1024;
176
+ unitIndex += 1;
177
+ }
178
+ return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
179
+ }
180
+
181
+ function Summary({ summary }: { summary: CmsImportSummary | null }) {
182
+ if (!summary) return null;
183
+
184
+ return (
185
+ <div className="space-y-3">
186
+ <div className="flex flex-wrap gap-2">
187
+ <Badge variant={summary.success ? "default" : "destructive"}>
188
+ {summary.success ? "Ready" : "Needs Fixes"}
189
+ </Badge>
190
+ <Badge variant="outline">{summary.totalRows} rows</Badge>
191
+ <Badge variant="outline">{summary.created} create</Badge>
192
+ <Badge variant="outline">{summary.updated} update</Badge>
193
+ {summary.warnings.length ? (
194
+ <Badge variant="secondary">{summary.warnings.length} warnings</Badge>
195
+ ) : null}
196
+ </div>
197
+ {summary.errors.length ? (
198
+ <Alert variant="destructive">
199
+ <AlertDescription>
200
+ {summary.errors.slice(0, 8).map((error) => (
201
+ <span key={`${error.row}-${error.message}`} className="block">
202
+ Row {error.row}: {error.message}
203
+ </span>
204
+ ))}
205
+ </AlertDescription>
206
+ </Alert>
207
+ ) : null}
208
+ {summary.warnings.length ? (
209
+ <Alert>
210
+ <AlertDescription>
211
+ {summary.warnings.slice(0, 6).map((warning) => (
212
+ <span key={`${warning.row}-${warning.message}`} className="block">
213
+ Row {warning.row}: {warning.message}
214
+ </span>
215
+ ))}
216
+ </AlertDescription>
217
+ </Alert>
218
+ ) : null}
219
+ </div>
220
+ );
221
+ }
222
+
223
+ function FullBackupSummaryPanel({
224
+ summary,
225
+ restored,
226
+ }: {
227
+ summary: FullBackupSummary | null;
228
+ restored: boolean;
229
+ }) {
230
+ if (!summary) return null;
231
+
232
+ return (
233
+ <div className="space-y-3">
234
+ <div className="flex flex-wrap gap-2">
235
+ <Badge variant={summary.success ? "default" : "destructive"}>
236
+ {restored ? "Restored" : summary.success ? "Ready" : "Needs Fixes"}
237
+ </Badge>
238
+ <Badge variant="outline">{summary.storageObjects || 0} R2 objects</Badge>
239
+ <Badge variant="outline">{formatBytes(summary.databaseDumpBytes)} DB</Badge>
240
+ <Badge variant="outline">{formatBytes(summary.contentBundleBytes)} content JSON</Badge>
241
+ {summary.missingStorageObjects ? (
242
+ <Badge variant="destructive">{summary.missingStorageObjects} missing files</Badge>
243
+ ) : null}
244
+ </div>
245
+
246
+ {summary.error ? (
247
+ <Alert variant="destructive">
248
+ <AlertTriangle className="h-4 w-4" />
249
+ <AlertTitle>Full Backup Review Failed</AlertTitle>
250
+ <AlertDescription>{summary.error}</AlertDescription>
251
+ </Alert>
252
+ ) : null}
253
+
254
+ {restored && summary.success ? (
255
+ <Alert variant="success">
256
+ <CheckCircle2 className="h-4 w-4" />
257
+ <AlertTitle>Restoration Completed Successfully</AlertTitle>
258
+ <AlertDescription>
259
+ Uploaded {summary.storageObjectsUploaded || 0} R2 object(s), restored the database,
260
+ and updated {summary.mediaUrlRowsUpdated || 0} media URL row(s).
261
+ {summary.storageObjectsDeleted ? ` Deleted ${summary.storageObjectsDeleted} extra R2 object(s).` : ""}
262
+ </AlertDescription>
263
+ </Alert>
264
+ ) : null}
265
+
266
+ {summary.warnings?.length ? (
267
+ <Alert variant="warning">
268
+ <AlertTriangle className="h-4 w-4" />
269
+ <AlertTitle>Warnings</AlertTitle>
270
+ <AlertDescription>
271
+ {summary.warnings.slice(0, 8).map((warning) => (
272
+ <span key={warning} className="block">
273
+ {warning}
274
+ </span>
275
+ ))}
276
+ </AlertDescription>
277
+ </Alert>
278
+ ) : null}
279
+ </div>
280
+ );
281
+ }
282
+
283
+ function OperationProgressBar({
284
+ progress,
285
+ scope,
286
+ }: {
287
+ progress: OperationProgress | null;
288
+ scope: ProgressScope;
289
+ }) {
290
+ if (!progress || progress.scope !== scope) return null;
291
+
292
+ return (
293
+ <div className="space-y-2 rounded-md border bg-muted/30 p-3">
294
+ <div className="flex items-center justify-between gap-3 text-sm">
295
+ <span className="font-medium">{progress.label}</span>
296
+ <span className="tabular-nums text-muted-foreground">{Math.round(progress.value)}%</span>
297
+ </div>
298
+ <Progress value={progress.value} className="h-2" />
299
+ <p className="text-xs text-muted-foreground">{progress.detail}</p>
300
+ </div>
301
+ );
302
+ }
303
+
304
+ export function BackupRestoreWorkspace({
305
+ isEcommerceActive,
306
+ }: {
307
+ isEcommerceActive: boolean;
308
+ }) {
309
+ const router = useRouter();
310
+ const availableContentTypes = isEcommerceActive
311
+ ? CONTENT_TYPES
312
+ : CONTENT_TYPES.filter((item) => item.value !== "products");
313
+ const contentTypeLabel = isEcommerceActive ? "pages, posts, and products" : "pages and posts";
314
+ const [backupMode, setBackupMode] = useState<BackupMode>("content_json");
315
+ const [bundleJson, setBundleJson] = useState("");
316
+ const [fileName, setFileName] = useState("");
317
+ const [selectedTypes, setSelectedTypes] = useState<CmsContentType[]>(
318
+ isEcommerceActive ? ["pages", "posts", "products"] : ["pages", "posts"]
319
+ );
320
+ const [includeBlocks, setIncludeBlocks] = useState(true);
321
+ const [conflictMode, setConflictMode] =
322
+ useState<CmsImportConflictMode>("overwrite_existing");
323
+ const [applyMode, setApplyMode] = useState<CmsImportApplyMode>("draft");
324
+ const [summary, setSummary] = useState<CmsImportSummary | null>(null);
325
+ const [jsonRestoreComplete, setJsonRestoreComplete] = useState(false);
326
+ const [isPending, startTransition] = useTransition();
327
+ const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null);
328
+ const progressTimerRef = useRef<number | null>(null);
329
+ const clearProgressTimeoutRef = useRef<number | null>(null);
330
+
331
+ const [fullZipFile, setFullZipFile] = useState<File | null>(null);
332
+ const [fullSummary, setFullSummary] = useState<FullBackupSummary | null>(null);
333
+ const [fullRestoreComplete, setFullRestoreComplete] = useState(false);
334
+ const [isFullPending, setIsFullPending] = useState(false);
335
+ const [mediaUrlMode, setMediaUrlMode] =
336
+ useState<FullBackupMediaUrlMode>("rewrite_to_destination");
337
+ const [destinationBaseUrl, setDestinationBaseUrl] = useState(
338
+ process.env.NEXT_PUBLIC_R2_BASE_URL || ""
339
+ );
340
+ const [deleteExtraneousR2Objects, setDeleteExtraneousR2Objects] = useState(false);
341
+ const [confirmation, setConfirmation] = useState("");
342
+
343
+ const clearProgressTimers = () => {
344
+ if (progressTimerRef.current) {
345
+ window.clearInterval(progressTimerRef.current);
346
+ progressTimerRef.current = null;
347
+ }
348
+ if (clearProgressTimeoutRef.current) {
349
+ window.clearTimeout(clearProgressTimeoutRef.current);
350
+ clearProgressTimeoutRef.current = null;
351
+ }
352
+ };
353
+
354
+ const startProgress = (progress: OperationProgress) => {
355
+ clearProgressTimers();
356
+ setOperationProgress(progress);
357
+ progressTimerRef.current = window.setInterval(() => {
358
+ setOperationProgress((current) => {
359
+ if (!current) return current;
360
+ const remaining = Math.max(0, current.cap - current.value);
361
+ if (remaining <= 0.5) return current;
362
+ return {
363
+ ...current,
364
+ value: Math.min(current.cap, current.value + Math.max(0.7, remaining * 0.07)),
365
+ };
366
+ });
367
+ }, 700) as unknown as number;
368
+ };
369
+
370
+ const updateProgress = (patch: Partial<OperationProgress>) => {
371
+ setOperationProgress((current) => (current ? { ...current, ...patch } : current));
372
+ };
373
+
374
+ const finishProgress = (label: string, detail: string) => {
375
+ clearProgressTimers();
376
+ setOperationProgress((current) =>
377
+ current
378
+ ? {
379
+ ...current,
380
+ label,
381
+ detail,
382
+ value: 100,
383
+ cap: 100,
384
+ }
385
+ : current
386
+ );
387
+ clearProgressTimeoutRef.current = window.setTimeout(() => {
388
+ setOperationProgress(null);
389
+ clearProgressTimeoutRef.current = null;
390
+ }, 1400) as unknown as number;
391
+ };
392
+
393
+ const toggleContentType = (contentType: CmsContentType, checked: boolean) => {
394
+ setSummary(null);
395
+ setJsonRestoreComplete(false);
396
+ setSelectedTypes((current) => {
397
+ if (checked) {
398
+ return Array.from(new Set([...current, contentType]));
399
+ }
400
+
401
+ return current.filter((item) => item !== contentType);
402
+ });
403
+ };
404
+
405
+ const exportBundle = () => {
406
+ startProgress({
407
+ scope: "export",
408
+ label: "Exporting Content JSON",
409
+ detail: `Collecting ${contentTypeLabel} and blocks.`,
410
+ value: 12,
411
+ cap: 82,
412
+ });
413
+ startTransition(async () => {
414
+ const result = await exportCmsBackupBundleAction();
415
+ if (!result.success || !result.content || !result.fileName || !result.mimeType) {
416
+ clearProgressTimers();
417
+ setOperationProgress(null);
418
+ toast.error(result.error || "Failed to export backup.");
419
+ return;
420
+ }
421
+
422
+ updateProgress({
423
+ label: "Downloading Content JSON",
424
+ detail: "Preparing the browser download.",
425
+ value: 92,
426
+ cap: 96,
427
+ });
428
+ downloadTextFile(result.fileName, result.mimeType, result.content);
429
+ finishProgress("Content JSON Exported", "The backup download has started.");
430
+ toast.success("Content JSON backup exported.");
431
+ });
432
+ };
433
+
434
+ const exportFullZip = async () => {
435
+ setIsFullPending(true);
436
+ startProgress({
437
+ scope: "export",
438
+ label: "Building Full Site ZIP",
439
+ detail: "Running database backup and collecting R2 media files.",
440
+ value: 5,
441
+ cap: 64,
442
+ });
443
+ try {
444
+ const response = await fetch("/api/cms/full-backup/export", {
445
+ method: "POST",
446
+ });
447
+
448
+ if (!response.ok) {
449
+ const result = (await response.json().catch(() => null)) as { error?: string } | null;
450
+ throw new Error(result?.error || "Failed to export full ZIP backup.");
451
+ }
452
+
453
+ updateProgress({
454
+ label: "Downloading Full Site ZIP",
455
+ detail: "Receiving the archive from the server.",
456
+ value: 65,
457
+ cap: 96,
458
+ });
459
+ const blob = await downloadResponseWithProgress(response, (received, total) => {
460
+ if (!total) {
461
+ updateProgress({
462
+ value: 84,
463
+ detail: `Downloaded ${formatBytes(received)}.`,
464
+ });
465
+ return;
466
+ }
467
+ updateProgress({
468
+ value: Math.min(98, 65 + (received / total) * 33),
469
+ detail: `Downloaded ${formatBytes(received)} of ${formatBytes(total)}.`,
470
+ });
471
+ });
472
+ downloadBlobFile(
473
+ getDownloadFileName(response, "nextblock-full-site-backup.zip"),
474
+ blob
475
+ );
476
+ finishProgress("Full Site ZIP Exported", "The migration archive download has started.");
477
+ toast.success("Full site ZIP backup exported.");
478
+ } catch (error) {
479
+ clearProgressTimers();
480
+ setOperationProgress(null);
481
+ toast.error(error instanceof Error ? error.message : "Failed to export full ZIP backup.");
482
+ } finally {
483
+ setIsFullPending(false);
484
+ }
485
+ };
486
+
487
+ const reviewImport = () => {
488
+ if (!bundleJson.trim()) {
489
+ toast.error("Choose a backup JSON file first.");
490
+ return;
491
+ }
492
+ if (selectedTypes.length === 0 && !includeBlocks) {
493
+ toast.error("Select at least one content type or the Blocks Library.");
494
+ return;
495
+ }
496
+
497
+ startProgress({
498
+ scope: "restore",
499
+ label: "Reviewing Content Backup",
500
+ detail: "Validating the JSON file before restore.",
501
+ value: 15,
502
+ cap: 86,
503
+ });
504
+ startTransition(async () => {
505
+ const result = await dryRunCmsBackupBundleImportAction({
506
+ bundleJson,
507
+ contentTypes: selectedTypes,
508
+ conflictMode,
509
+ applyMode,
510
+ includeBlocks,
511
+ });
512
+ setSummary(result);
513
+ setJsonRestoreComplete(false);
514
+ if (result.success) {
515
+ finishProgress("Backup Review Complete", "The content backup is ready to restore.");
516
+ toast.success("Backup review is ready.");
517
+ } else {
518
+ clearProgressTimers();
519
+ setOperationProgress(null);
520
+ toast.error("Backup has errors to fix.");
521
+ }
522
+ });
523
+ };
524
+
525
+ const applyImport = () => {
526
+ if (!summary?.success || (selectedTypes.length === 0 && !includeBlocks)) return;
527
+
528
+ startProgress({
529
+ scope: "restore",
530
+ label: "Restoring Content Backup",
531
+ detail: "Writing content records and blocks.",
532
+ value: 12,
533
+ cap: 88,
534
+ });
535
+ startTransition(async () => {
536
+ const result = await applyCmsBackupBundleImportAction({
537
+ bundleJson,
538
+ contentTypes: selectedTypes,
539
+ conflictMode,
540
+ applyMode,
541
+ includeBlocks,
542
+ });
543
+ setSummary(result);
544
+ if (!result.success) {
545
+ setJsonRestoreComplete(false);
546
+ clearProgressTimers();
547
+ setOperationProgress(null);
548
+ toast.error("Backup restore failed validation.");
549
+ return;
550
+ }
551
+
552
+ setJsonRestoreComplete(true);
553
+ finishProgress("Content Restore Complete", "The content backup was restored successfully.");
554
+ toast.success("Restoration completed successfully.");
555
+ router.refresh();
556
+ });
557
+ };
558
+
559
+ const submitFullBackup = async (action: "review" | "restore") => {
560
+ if (!fullZipFile) {
561
+ toast.error("Choose a full backup ZIP file first.");
562
+ return;
563
+ }
564
+ if (action === "restore" && confirmation.trim() !== FULL_RESTORE_CONFIRMATION) {
565
+ toast.error(`Type ${FULL_RESTORE_CONFIRMATION} to confirm full restore.`);
566
+ return;
567
+ }
568
+
569
+ const formData = new FormData();
570
+ formData.set("action", action);
571
+ formData.set("file", fullZipFile);
572
+ formData.set("mediaUrlMode", mediaUrlMode);
573
+ formData.set("destinationBaseUrl", destinationBaseUrl);
574
+ formData.set("deleteExtraneousR2Objects", String(deleteExtraneousR2Objects));
575
+ formData.set("confirmation", confirmation);
576
+
577
+ setIsFullPending(true);
578
+ startProgress({
579
+ scope: "restore",
580
+ label: action === "restore" ? "Uploading Full Backup" : "Uploading Backup for Review",
581
+ detail: "Sending the ZIP archive to the server.",
582
+ value: 8,
583
+ cap: 46,
584
+ });
585
+ try {
586
+ const response = await postFormDataWithProgress<FullBackupSummary>(
587
+ "/api/cms/full-backup/restore",
588
+ formData,
589
+ (loaded, total) => {
590
+ if (!total) {
591
+ updateProgress({
592
+ value: 34,
593
+ detail: `Uploaded ${formatBytes(loaded)}.`,
594
+ });
595
+ return;
596
+ }
597
+ updateProgress({
598
+ value: Math.min(48, 8 + (loaded / total) * 40),
599
+ detail: `Uploaded ${formatBytes(loaded)} of ${formatBytes(total)}.`,
600
+ });
601
+ },
602
+ () => {
603
+ updateProgress({
604
+ label: action === "restore" ? "Restoring Full Backup" : "Reviewing Full Backup",
605
+ detail:
606
+ action === "restore"
607
+ ? "Re-uploading R2 objects, restoring the database, and rewriting media URLs."
608
+ : "Inspecting the archive manifest and required files.",
609
+ value: 52,
610
+ cap: action === "restore" ? 94 : 86,
611
+ });
612
+ }
613
+ );
614
+ const result = response.body;
615
+ setFullSummary(result);
616
+ setFullRestoreComplete(action === "restore" && result.success);
617
+
618
+ if (!response.ok || !result.success) {
619
+ clearProgressTimers();
620
+ setOperationProgress(null);
621
+ toast.error(result.error || "Full backup needs attention.");
622
+ return;
623
+ }
624
+
625
+ finishProgress(
626
+ action === "restore" ? "Full Restore Complete" : "Full Backup Review Complete",
627
+ action === "restore"
628
+ ? "The database and R2 media archive were restored."
629
+ : "The ZIP archive is ready to restore."
630
+ );
631
+ toast.success(
632
+ action === "restore"
633
+ ? "Restoration completed successfully."
634
+ : "Full backup review is ready."
635
+ );
636
+ if (action === "restore") router.refresh();
637
+ } catch (error) {
638
+ clearProgressTimers();
639
+ setOperationProgress(null);
640
+ toast.error(error instanceof Error ? error.message : "Failed to process full backup.");
641
+ } finally {
642
+ setIsFullPending(false);
643
+ }
644
+ };
645
+
646
+ return (
647
+ <div className="mx-auto max-w-6xl space-y-6">
648
+ <div>
649
+ <h1 className="text-2xl font-semibold">Backup And Restore</h1>
650
+ <p className="mt-1 text-sm text-muted-foreground">
651
+ Export content-only JSON backups or full migration ZIP archives with database and R2 files.
652
+ </p>
653
+ </div>
654
+
655
+ <RadioGroup
656
+ value={backupMode}
657
+ onValueChange={(value) => setBackupMode(value as BackupMode)}
658
+ className="grid gap-3 md:grid-cols-2"
659
+ >
660
+ <label className="flex cursor-pointer items-start gap-3 rounded-md border p-4">
661
+ <RadioGroupItem value="content_json" className="mt-1" />
662
+ <span className="space-y-1">
663
+ <span className="block text-sm font-medium">Content JSON</span>
664
+ <span className="block text-sm text-muted-foreground">
665
+ {isEcommerceActive
666
+ ? "Pages, posts, products, metadata, translation groups, blocks, product variants, category slugs, media references, and custom block definitions (Blocks Library). Image binaries are not included."
667
+ : "Pages, posts, metadata, translation groups, blocks, and custom block definitions (Blocks Library). Product content is included only when the ecommerce package is active. Image binaries are not included."}
668
+ </span>
669
+ </span>
670
+ </label>
671
+ <label className="flex cursor-pointer items-start gap-3 rounded-md border p-4">
672
+ <RadioGroupItem value="full_zip" className="mt-1" />
673
+ <span className="space-y-1">
674
+ <span className="block text-sm font-medium">Full Site ZIP</span>
675
+ <span className="block text-sm text-muted-foreground">
676
+ Migration archive with a database dump and R2 media files. Restoring this is destructive
677
+ and intended for moving an old server to a new one.
678
+ </span>
679
+ </span>
680
+ </label>
681
+ </RadioGroup>
682
+
683
+ <div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
684
+ <Card>
685
+ <CardHeader>
686
+ <CardTitle>Export Backup</CardTitle>
687
+ <CardDescription>
688
+ {backupMode === "content_json"
689
+ ? `Download an editable JSON bundle for CMS ${contentTypeLabel}.`
690
+ : "Download a full migration ZIP with database dump and R2 media objects."}
691
+ </CardDescription>
692
+ </CardHeader>
693
+ <CardContent className="space-y-4">
694
+ {backupMode === "content_json" ? (
695
+ <>
696
+ <Alert>
697
+ <AlertDescription>
698
+ The JSON file references existing media by ID and object key, but does not include
699
+ the image or file binaries from R2.
700
+ </AlertDescription>
701
+ </Alert>
702
+ <Button onClick={exportBundle} disabled={isPending}>
703
+ {isPending ? <Spinner className="mr-2 h-4 w-4" /> : <Download className="mr-2 h-4 w-4" />}
704
+ Export Content JSON
705
+ </Button>
706
+ <OperationProgressBar progress={operationProgress} scope="export" />
707
+ </>
708
+ ) : (
709
+ <>
710
+ <Alert variant="warning">
711
+ <AlertTriangle className="h-4 w-4" />
712
+ <AlertTitle>Sensitive Migration Archive</AlertTitle>
713
+ <AlertDescription>
714
+ The ZIP can include users, orders, customer data, payment records, settings,
715
+ database schema, and media files depending on your database permissions.
716
+ </AlertDescription>
717
+ </Alert>
718
+ <Button onClick={exportFullZip} disabled={isFullPending}>
719
+ {isFullPending ? <Spinner className="mr-2 h-4 w-4" /> : <Download className="mr-2 h-4 w-4" />}
720
+ Export Full Site ZIP
721
+ </Button>
722
+ <OperationProgressBar progress={operationProgress} scope="export" />
723
+ </>
724
+ )}
725
+ </CardContent>
726
+ </Card>
727
+
728
+ <Card>
729
+ <CardHeader>
730
+ <CardTitle>Restore from Backup</CardTitle>
731
+ <CardDescription>
732
+ {backupMode === "content_json"
733
+ ? `Review a content backup before restoring ${contentTypeLabel}.`
734
+ : "Review a full ZIP first, then restore database and R2 media into this environment."}
735
+ </CardDescription>
736
+ </CardHeader>
737
+ <CardContent className="space-y-5">
738
+ {backupMode === "content_json" ? (
739
+ <>
740
+ <div className="space-y-2">
741
+ <Label htmlFor="backup-json-file">Backup JSON file</Label>
742
+ <Input
743
+ id="backup-json-file"
744
+ type="file"
745
+ accept=".json,application/json"
746
+ onChange={async (event) => {
747
+ const file = event.target.files?.[0];
748
+ setSummary(null);
749
+ setJsonRestoreComplete(false);
750
+ if (!file) return;
751
+ setFileName(file.name);
752
+ try {
753
+ setBundleJson(await readFileAsText(file));
754
+ } catch {
755
+ toast.error("Failed to read backup file.");
756
+ }
757
+ }}
758
+ />
759
+ {fileName ? (
760
+ <p className="text-xs text-muted-foreground">{fileName}</p>
761
+ ) : null}
762
+ </div>
763
+
764
+ <div className="grid gap-3 sm:grid-cols-3">
765
+ {availableContentTypes.map((item) => (
766
+ <label
767
+ key={item.value}
768
+ className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
769
+ >
770
+ <Checkbox
771
+ checked={selectedTypes.includes(item.value)}
772
+ onCheckedChange={(checked) => toggleContentType(item.value, checked === true)}
773
+ />
774
+ {item.label}
775
+ </label>
776
+ ))}
777
+ <label className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
778
+ <Checkbox
779
+ checked={includeBlocks}
780
+ onCheckedChange={(checked) => {
781
+ setIncludeBlocks(checked === true);
782
+ setSummary(null);
783
+ setJsonRestoreComplete(false);
784
+ }}
785
+ />
786
+ Blocks Library
787
+ </label>
788
+ </div>
789
+
790
+ <div className="grid gap-3 sm:grid-cols-2">
791
+ <div className="space-y-2">
792
+ <Label>Existing records</Label>
793
+ <Select
794
+ value={conflictMode}
795
+ onValueChange={(value) => {
796
+ setConflictMode(value as CmsImportConflictMode);
797
+ setSummary(null);
798
+ setJsonRestoreComplete(false);
799
+ }}
800
+ >
801
+ <SelectTrigger>
802
+ <SelectValue />
803
+ </SelectTrigger>
804
+ <SelectContent>
805
+ <SelectItem value="overwrite_existing">Overwrite matches</SelectItem>
806
+ <SelectItem value="create_new">Create new copies</SelectItem>
807
+ </SelectContent>
808
+ </Select>
809
+ </div>
810
+
811
+ <div className="space-y-2">
812
+ <Label>Apply as</Label>
813
+ <Select
814
+ value={applyMode}
815
+ onValueChange={(value) => {
816
+ setApplyMode(value as CmsImportApplyMode);
817
+ setSummary(null);
818
+ setJsonRestoreComplete(false);
819
+ }}
820
+ >
821
+ <SelectTrigger>
822
+ <SelectValue />
823
+ </SelectTrigger>
824
+ <SelectContent>
825
+ <SelectItem value="draft">Drafts</SelectItem>
826
+ <SelectItem value="live">Live content</SelectItem>
827
+ </SelectContent>
828
+ </Select>
829
+ </div>
830
+ </div>
831
+
832
+ <Summary summary={summary} />
833
+ <OperationProgressBar progress={operationProgress} scope="restore" />
834
+
835
+ {jsonRestoreComplete && summary?.success ? (
836
+ <Alert variant="success">
837
+ <CheckCircle2 className="h-4 w-4" />
838
+ <AlertTitle>Restoration Completed Successfully</AlertTitle>
839
+ <AlertDescription>
840
+ Restored {summary.totalRows} content row(s): {summary.created} created and{" "}
841
+ {summary.updated} updated.
842
+ </AlertDescription>
843
+ </Alert>
844
+ ) : null}
845
+
846
+ <div className="flex flex-wrap gap-2">
847
+ <Button
848
+ type="button"
849
+ variant="outline"
850
+ onClick={reviewImport}
851
+ disabled={
852
+ isPending ||
853
+ !bundleJson.trim() ||
854
+ (selectedTypes.length === 0 && !includeBlocks)
855
+ }
856
+ >
857
+ {isPending ? <Spinner className="mr-2 h-4 w-4" /> : <Upload className="mr-2 h-4 w-4" />}
858
+ Review Backup
859
+ </Button>
860
+ <Button
861
+ type="button"
862
+ onClick={applyImport}
863
+ disabled={
864
+ isPending ||
865
+ !summary?.success ||
866
+ (selectedTypes.length === 0 && !includeBlocks)
867
+ }
868
+ >
869
+ {isPending ? <Spinner className="mr-2 h-4 w-4" /> : <Upload className="mr-2 h-4 w-4" />}
870
+ Restore from Backup
871
+ </Button>
872
+ </div>
873
+ </>
874
+ ) : (
875
+ <>
876
+ <Alert variant="warning">
877
+ <AlertTriangle className="h-4 w-4" />
878
+ <AlertTitle>Full Restore Replaces This Site</AlertTitle>
879
+ <AlertDescription>
880
+ This restores R2 files and runs the archived SQL dump against the configured database.
881
+ Use it for migration from an old server to a new server.
882
+ </AlertDescription>
883
+ </Alert>
884
+
885
+ <div className="space-y-2">
886
+ <Label htmlFor="backup-zip-file">Full backup ZIP file</Label>
887
+ <Input
888
+ id="backup-zip-file"
889
+ type="file"
890
+ accept=".zip,application/zip"
891
+ onChange={(event) => {
892
+ const file = event.target.files?.[0] || null;
893
+ setFullZipFile(file);
894
+ setFullSummary(null);
895
+ setFullRestoreComplete(false);
896
+ }}
897
+ />
898
+ {fullZipFile ? (
899
+ <p className="text-xs text-muted-foreground">
900
+ {fullZipFile.name} ({formatBytes(fullZipFile.size)})
901
+ </p>
902
+ ) : null}
903
+ </div>
904
+
905
+ <div className="grid gap-3 sm:grid-cols-2">
906
+ <div className="space-y-2">
907
+ <Label>Media URL mode</Label>
908
+ <Select
909
+ value={mediaUrlMode}
910
+ onValueChange={(value) => {
911
+ setMediaUrlMode(value as FullBackupMediaUrlMode);
912
+ setFullRestoreComplete(false);
913
+ }}
914
+ >
915
+ <SelectTrigger>
916
+ <SelectValue />
917
+ </SelectTrigger>
918
+ <SelectContent>
919
+ <SelectItem value="rewrite_to_destination">Use new bucket address</SelectItem>
920
+ <SelectItem value="keep_exact_urls">Keep exact public URLs</SelectItem>
921
+ </SelectContent>
922
+ </Select>
923
+ </div>
924
+ <div className="space-y-2">
925
+ <Label htmlFor="destination-base-url">New media base URL</Label>
926
+ <Input
927
+ id="destination-base-url"
928
+ value={destinationBaseUrl}
929
+ onChange={(event) => {
930
+ setDestinationBaseUrl(event.target.value);
931
+ setFullRestoreComplete(false);
932
+ }}
933
+ placeholder="https://assets.example.com"
934
+ disabled={mediaUrlMode === "keep_exact_urls"}
935
+ />
936
+ </div>
937
+ </div>
938
+
939
+ <label className="flex items-start gap-2 rounded-md border p-3 text-sm">
940
+ <Checkbox
941
+ checked={deleteExtraneousR2Objects}
942
+ onCheckedChange={(checked) => {
943
+ setDeleteExtraneousR2Objects(checked === true);
944
+ setFullRestoreComplete(false);
945
+ }}
946
+ />
947
+ <span>
948
+ <span className="block font-medium">Delete destination R2 files not in the archive</span>
949
+ <span className="block text-muted-foreground">
950
+ Advanced cleanup. Leave off unless this new bucket is dedicated to the restored site.
951
+ </span>
952
+ </span>
953
+ </label>
954
+
955
+ <FullBackupSummaryPanel summary={fullSummary} restored={fullRestoreComplete} />
956
+ <OperationProgressBar progress={operationProgress} scope="restore" />
957
+
958
+ <Separator />
959
+
960
+ <div className="space-y-2">
961
+ <Label htmlFor="full-restore-confirmation">Destructive restore confirmation</Label>
962
+ <Input
963
+ id="full-restore-confirmation"
964
+ value={confirmation}
965
+ onChange={(event) => setConfirmation(event.target.value)}
966
+ placeholder={FULL_RESTORE_CONFIRMATION}
967
+ />
968
+ <p className="text-xs text-muted-foreground">
969
+ Type {FULL_RESTORE_CONFIRMATION} before restoring the full ZIP.
970
+ </p>
971
+ </div>
972
+
973
+ <div className="flex flex-wrap gap-2">
974
+ <Button
975
+ type="button"
976
+ variant="outline"
977
+ onClick={() => submitFullBackup("review")}
978
+ disabled={isFullPending || !fullZipFile}
979
+ >
980
+ {isFullPending ? <Spinner className="mr-2 h-4 w-4" /> : <Upload className="mr-2 h-4 w-4" />}
981
+ Review Full Backup
982
+ </Button>
983
+ <Button
984
+ type="button"
985
+ onClick={() => submitFullBackup("restore")}
986
+ disabled={
987
+ isFullPending ||
988
+ !fullSummary?.success ||
989
+ fullRestoreComplete ||
990
+ confirmation.trim() !== FULL_RESTORE_CONFIRMATION
991
+ }
992
+ >
993
+ {isFullPending ? <Spinner className="mr-2 h-4 w-4" /> : <Upload className="mr-2 h-4 w-4" />}
994
+ Restore from Backup
995
+ </Button>
996
+ </div>
997
+ </>
998
+ )}
999
+ </CardContent>
1000
+ </Card>
1001
+ </div>
1002
+ </div>
1003
+ );
1004
+ }