create-nextblock 0.13.9 → 0.13.11

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 (260) 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/productGridActions.ts +40 -40
  25. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
  26. package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
  27. package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
  28. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
  29. package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
  30. package/templates/nextblock-template/app/api/cms/ecommerce/product-picker/route.ts +151 -151
  31. package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
  32. package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
  33. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
  34. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5749 -5609
  35. package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
  36. package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
  37. package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
  38. package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
  39. package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
  40. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  41. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  42. package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
  43. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  44. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  45. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +10 -1
  46. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +5 -1
  47. package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
  48. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +55 -24
  49. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  50. package/templates/nextblock-template/app/cms/blocks/components/MultiEntityPicker.tsx +263 -251
  51. package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
  52. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
  53. package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
  54. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  55. package/templates/nextblock-template/app/cms/components/EcommerceActiveContext.tsx +27 -27
  56. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  57. package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
  58. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
  59. package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
  60. package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
  61. package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
  62. package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
  63. package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
  64. package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
  65. package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
  66. package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
  67. package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
  68. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  69. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
  70. package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
  71. package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -189
  72. package/templates/nextblock-template/app/cms/layout.tsx +73 -73
  73. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  74. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  75. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
  76. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  77. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  78. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  79. package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
  80. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  81. package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
  82. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  83. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  84. package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
  85. package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
  86. package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
  87. package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
  88. package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
  89. package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
  90. package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
  91. package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
  92. package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +497 -497
  93. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  94. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  95. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
  96. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  97. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  98. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
  99. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageDetectionPanel.tsx +188 -188
  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 +20 -20
  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/page.tsx +33 -33
  111. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  112. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  113. package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -217
  114. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  115. package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -44
  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/setup/SetupWizard.tsx +678 -678
  126. package/templates/nextblock-template/app/setup/layout.tsx +13 -13
  127. package/templates/nextblock-template/app/setup/page.tsx +111 -111
  128. package/templates/nextblock-template/app/sitemap.ts +130 -130
  129. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  130. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  131. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
  132. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  133. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  134. package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
  135. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  136. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  137. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  138. package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
  139. package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
  140. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
  141. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  142. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  143. package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -114
  144. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  145. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +8 -1
  146. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
  147. package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +29 -23
  148. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  149. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  150. package/templates/nextblock-template/components/form-message.tsx +32 -32
  151. package/templates/nextblock-template/components/media/YouTubeFacade.tsx +69 -0
  152. package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -0
  153. package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
  154. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
  155. package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
  156. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
  157. package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -464
  158. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  159. package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
  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/language-rest-client.ts +32 -32
  163. package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
  164. package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
  165. package/templates/nextblock-template/docker/kong/kong.yml +112 -112
  166. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  167. package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
  168. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -202
  169. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
  170. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
  171. package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
  172. package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
  173. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
  174. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
  175. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
  176. package/templates/nextblock-template/docs/README.md +39 -39
  177. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  178. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  179. package/templates/nextblock-template/index.d.ts +7 -7
  180. package/templates/nextblock-template/lib/app-secrets.ts +39 -39
  181. package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
  182. package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
  183. package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
  184. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  185. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -78
  186. package/templates/nextblock-template/lib/blocks/README.md +13 -13
  187. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +21 -1
  188. package/templates/nextblock-template/lib/blocks/product-grid-data.ts +210 -210
  189. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
  190. package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
  191. package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
  192. package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
  193. package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
  194. package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
  195. package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
  196. package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
  197. package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
  198. package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
  199. package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
  200. package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
  201. package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
  202. package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
  203. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
  204. package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
  205. package/templates/nextblock-template/lib/email/branding.ts +76 -76
  206. package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
  207. package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
  208. package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
  209. package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -247
  210. package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -197
  211. package/templates/nextblock-template/lib/i18n/detection.ts +192 -192
  212. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
  213. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
  214. package/templates/nextblock-template/lib/media/youtube.ts +81 -0
  215. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
  216. package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
  217. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  218. package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
  219. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
  220. package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
  221. package/templates/nextblock-template/lib/privacy/types.ts +69 -69
  222. package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
  223. package/templates/nextblock-template/lib/promotions/server.ts +741 -741
  224. package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
  225. package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
  226. package/templates/nextblock-template/lib/search/types.ts +27 -27
  227. package/templates/nextblock-template/lib/setup/actions.ts +460 -460
  228. package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
  229. package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
  230. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +87 -82
  231. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
  232. package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
  233. package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
  234. package/templates/nextblock-template/lib/setup/types.ts +18 -18
  235. package/templates/nextblock-template/lib/site-url.ts +48 -48
  236. package/templates/nextblock-template/lib/storage/provider.ts +66 -66
  237. package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
  238. package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
  239. package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
  240. package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
  241. package/templates/nextblock-template/next-env.d.ts +1 -1
  242. package/templates/nextblock-template/package.json +1 -1
  243. package/templates/nextblock-template/postcss.config.js +6 -6
  244. package/templates/nextblock-template/scripts/backup.js +115 -115
  245. package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
  246. package/templates/nextblock-template/scripts/restore.js +385 -385
  247. package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
  248. package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
  249. package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
  250. package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
  251. package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
  252. package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
  253. package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
  254. package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
  255. package/templates/nextblock-template/tailwind.config.js +25 -25
  256. package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
  257. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  258. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
  259. package/templates/nextblock-template/types/jsdom.d.ts +6 -6
  260. 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
+ }