modern-cms 1.0.0

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 (180) hide show
  1. package/README.md +41 -0
  2. package/bin/lib.js +97 -0
  3. package/bin/modern-cms.js +197 -0
  4. package/package.json +37 -0
  5. package/template/.env.example +48 -0
  6. package/template/README.md +210 -0
  7. package/template/apps/api/Dockerfile +26 -0
  8. package/template/apps/api/package.json +45 -0
  9. package/template/apps/api/prisma/backfill-search-text.ts +31 -0
  10. package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
  11. package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
  12. package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
  13. package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
  14. package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
  15. package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
  16. package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
  17. package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
  18. package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
  19. package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
  20. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  21. package/template/apps/api/prisma/reset-admin.ts +19 -0
  22. package/template/apps/api/prisma/schema.prisma +251 -0
  23. package/template/apps/api/prisma/seed-homepage.ts +752 -0
  24. package/template/apps/api/prisma/seed-our-charities.ts +231 -0
  25. package/template/apps/api/prisma/seed-templates.ts +154 -0
  26. package/template/apps/api/prisma/seed.ts +191 -0
  27. package/template/apps/api/src/env.ts +61 -0
  28. package/template/apps/api/src/index.ts +66 -0
  29. package/template/apps/api/src/lib/revalidate.ts +28 -0
  30. package/template/apps/api/src/lib/storage/azure.ts +25 -0
  31. package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
  32. package/template/apps/api/src/lib/storage/index.ts +25 -0
  33. package/template/apps/api/src/lib/storage/s3.ts +39 -0
  34. package/template/apps/api/src/lib/storage/types.ts +8 -0
  35. package/template/apps/api/src/middleware/auditLog.ts +32 -0
  36. package/template/apps/api/src/middleware/auth.ts +53 -0
  37. package/template/apps/api/src/middleware/rateLimit.ts +31 -0
  38. package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
  39. package/template/apps/api/src/prisma.ts +3 -0
  40. package/template/apps/api/src/routes/audit.routes.ts +26 -0
  41. package/template/apps/api/src/routes/auth.routes.ts +46 -0
  42. package/template/apps/api/src/routes/backup.routes.ts +216 -0
  43. package/template/apps/api/src/routes/blocks.routes.ts +38 -0
  44. package/template/apps/api/src/routes/collections.routes.ts +185 -0
  45. package/template/apps/api/src/routes/forms.routes.ts +114 -0
  46. package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
  47. package/template/apps/api/src/routes/media.routes.ts +77 -0
  48. package/template/apps/api/src/routes/pages.routes.ts +319 -0
  49. package/template/apps/api/src/routes/redirects.routes.ts +52 -0
  50. package/template/apps/api/src/routes/search.routes.ts +83 -0
  51. package/template/apps/api/src/routes/templates.routes.ts +41 -0
  52. package/template/apps/api/src/routes/theme.routes.ts +75 -0
  53. package/template/apps/api/src/routes/users.routes.ts +39 -0
  54. package/template/apps/api/tsconfig.json +11 -0
  55. package/template/apps/web/Dockerfile +26 -0
  56. package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
  57. package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
  58. package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
  59. package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
  60. package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
  61. package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
  62. package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
  63. package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
  64. package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
  65. package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
  66. package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
  67. package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
  68. package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
  69. package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
  70. package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
  71. package/template/apps/web/app/admin/login/page.tsx +76 -0
  72. package/template/apps/web/app/api/revalidate/route.ts +34 -0
  73. package/template/apps/web/app/globals.css +120 -0
  74. package/template/apps/web/app/layout.tsx +39 -0
  75. package/template/apps/web/app/preview/[token]/page.tsx +70 -0
  76. package/template/apps/web/app/robots.ts +11 -0
  77. package/template/apps/web/app/sitemap.ts +27 -0
  78. package/template/apps/web/components/admin/AdminShell.tsx +104 -0
  79. package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
  80. package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
  81. package/template/apps/web/components/builder/Canvas.tsx +76 -0
  82. package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
  83. package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
  84. package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
  85. package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
  86. package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
  87. package/template/apps/web/components/builder/Inspector.tsx +956 -0
  88. package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
  89. package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
  90. package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
  91. package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
  92. package/template/apps/web/components/builder/Palette.tsx +106 -0
  93. package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
  94. package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
  95. package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
  96. package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
  97. package/template/apps/web/components/renderer/Renderer.tsx +146 -0
  98. package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
  99. package/template/apps/web/components/renderer/registry.tsx +87 -0
  100. package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
  101. package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
  102. package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
  103. package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
  104. package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
  105. package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
  106. package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
  107. package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
  108. package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
  109. package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
  110. package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
  111. package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
  112. package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
  113. package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
  114. package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
  115. package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
  116. package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
  117. package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
  118. package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
  119. package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
  120. package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
  121. package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
  122. package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
  123. package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
  124. package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
  125. package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
  126. package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
  127. package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
  128. package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
  129. package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
  130. package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
  131. package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
  132. package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
  133. package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
  134. package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
  135. package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
  136. package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
  137. package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
  138. package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
  139. package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
  140. package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
  141. package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
  142. package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
  143. package/template/apps/web/components/renderer/sections/types.ts +7 -0
  144. package/template/apps/web/components/seo/JsonLd.tsx +111 -0
  145. package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
  146. package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
  147. package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
  148. package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
  149. package/template/apps/web/eslint.config.mjs +16 -0
  150. package/template/apps/web/lib/api.ts +45 -0
  151. package/template/apps/web/lib/responsiveCss.ts +42 -0
  152. package/template/apps/web/lib/sanitizeHtml.ts +16 -0
  153. package/template/apps/web/lib/serverApi.ts +25 -0
  154. package/template/apps/web/lib/style.ts +73 -0
  155. package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
  156. package/template/apps/web/lib/useCurrentUser.ts +35 -0
  157. package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
  158. package/template/apps/web/next-env.d.ts +6 -0
  159. package/template/apps/web/next.config.mjs +32 -0
  160. package/template/apps/web/package.json +46 -0
  161. package/template/apps/web/postcss.config.mjs +6 -0
  162. package/template/apps/web/proxy.ts +23 -0
  163. package/template/apps/web/tailwind.config.ts +24 -0
  164. package/template/apps/web/tsconfig.json +41 -0
  165. package/template/docker-compose.yml +60 -0
  166. package/template/package.json +29 -0
  167. package/template/packages/shared/package.json +21 -0
  168. package/template/packages/shared/src/components.ts +1626 -0
  169. package/template/packages/shared/src/index.ts +9 -0
  170. package/template/packages/shared/src/schema/collections.ts +70 -0
  171. package/template/packages/shared/src/schema/fields.ts +26 -0
  172. package/template/packages/shared/src/schema/media.ts +13 -0
  173. package/template/packages/shared/src/schema/pageNode.ts +255 -0
  174. package/template/packages/shared/src/schema/templates.ts +20 -0
  175. package/template/packages/shared/src/schema/theme.ts +69 -0
  176. package/template/packages/shared/src/schema/user.ts +25 -0
  177. package/template/packages/shared/src/tree.test.ts +65 -0
  178. package/template/packages/shared/src/tree.ts +303 -0
  179. package/template/packages/shared/tsconfig.json +8 -0
  180. package/template/packages/shared/vitest.config.ts +8 -0
@@ -0,0 +1,560 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback, useRef } from "react";
4
+ import { useParams, useRouter } from "next/navigation";
5
+ import {
6
+ DndContext,
7
+ DragOverlay,
8
+ PointerSensor,
9
+ useSensor,
10
+ useSensors,
11
+ type DragEndEvent,
12
+ type DragStartEvent,
13
+ } from "@dnd-kit/core";
14
+ import { ArrowLeft, Save, Eye, Globe2, Settings, Undo2, Redo2, Monitor, Tablet, Smartphone } from "lucide-react";
15
+ import {
16
+ cloneWithNewIds,
17
+ createNode,
18
+ findNode,
19
+ findParent,
20
+ findAncestorChain,
21
+ insertNode,
22
+ isNodeOrDescendant,
23
+ moveNode,
24
+ removeNode,
25
+ duplicateNode,
26
+ updateNode,
27
+ setContainerColumnCount,
28
+ setGridDimensions,
29
+ setGridTrackSize,
30
+ setHeaderZoneCount,
31
+ ensureHeaderZones,
32
+ getComponentDefinition,
33
+ resolveNodeStyle,
34
+ type Breakpoint,
35
+ type Page,
36
+ type PageNode,
37
+ type NodeStyle,
38
+ type SiteTheme,
39
+ type NavItem,
40
+ } from "@pgcms/shared";
41
+ import { api } from "@/lib/api";
42
+ import { Palette } from "@/components/builder/Palette";
43
+ import { Canvas } from "@/components/builder/Canvas";
44
+ import { Inspector } from "@/components/builder/Inspector";
45
+ import { BuilderDeviceContext } from "@/components/builder/BuilderDeviceContext";
46
+ import { IconRenderer } from "@/components/builder/IconRenderer";
47
+ import { PageSettingsModal } from "@/components/builder/PageSettingsModal";
48
+
49
+ export default function PageBuilder() {
50
+ const params = useParams<{ id: string }>();
51
+ const router = useRouter();
52
+
53
+ const [page, setPage] = useState<Page | null>(null);
54
+ const [tree, setTree] = useState<PageNode | null>(null);
55
+ const [theme, setTheme] = useState<SiteTheme | null>(null);
56
+ const [navItems, setNavItems] = useState<NavItem[]>([]);
57
+ const [selectedId, setSelectedId] = useState<string | null>(null);
58
+ const [device, setDevice] = useState<Breakpoint>("desktop");
59
+ const [activeDragType, setActiveDragType] = useState<string | null>(null);
60
+ const [saving, setSaving] = useState(false);
61
+ const [savedAt, setSavedAt] = useState<Date | null>(null);
62
+ const [settingsOpen, setSettingsOpen] = useState(false);
63
+
64
+ // Undo/redo history — every content-mutating action goes through updateTree() so it
65
+ // gets recorded here, instead of calling setTree() directly.
66
+ const [past, setPast] = useState<PageNode[]>([]);
67
+ const [future, setFuture] = useState<PageNode[]>([]);
68
+ const HISTORY_LIMIT = 50;
69
+
70
+ // Continuous gestures (typing in a field, dragging a resize handle or color picker)
71
+ // fire onChange dozens of times for what a user experiences as ONE edit. Without
72
+ // coalescing, Ctrl+Z would undo one keystroke or one pixel of drag at a time.
73
+ // Passing the same `coalesceKey` for calls within COALESCE_WINDOW_MS merges them
74
+ // into a single history entry; omit it for discrete actions (delete, insert, toggle)
75
+ // that should always get their own undo step.
76
+ const lastCoalesceRef = useRef<{ key: string; time: number } | null>(null);
77
+ const COALESCE_WINDOW_MS = 800;
78
+
79
+ function updateTree(updater: (prev: PageNode) => PageNode, coalesceKey?: string) {
80
+ if (!tree) return;
81
+ const next = updater(tree);
82
+ if (next === tree) return;
83
+
84
+ const now = Date.now();
85
+ const last = lastCoalesceRef.current;
86
+ const shouldCoalesce = !!coalesceKey && !!last && last.key === coalesceKey && now - last.time < COALESCE_WINDOW_MS;
87
+
88
+ if (!shouldCoalesce) {
89
+ setPast((p) => [...p, tree].slice(-HISTORY_LIMIT));
90
+ setFuture([]);
91
+ }
92
+ lastCoalesceRef.current = coalesceKey ? { key: coalesceKey, time: now } : null;
93
+ setTree(next);
94
+ }
95
+
96
+ function undo() {
97
+ if (past.length === 0 || !tree) return;
98
+ const previous = past[past.length - 1];
99
+ setPast((p) => p.slice(0, -1));
100
+ setFuture((f) => [tree, ...f]);
101
+ setTree(previous);
102
+ }
103
+
104
+ function redo() {
105
+ if (future.length === 0 || !tree) return;
106
+ const next = future[0];
107
+ setFuture((f) => f.slice(1));
108
+ setPast((p) => [...p, tree]);
109
+ setTree(next);
110
+ }
111
+
112
+ const [draftPrompt, setDraftPrompt] = useState<{ content: PageNode; savedAt: string } | null>(null);
113
+ const lastPersistedRef = useRef<string | null>(null); // JSON of the last content saved to the server
114
+ const draftKey = `pgcms:draft:${params.id}`;
115
+
116
+ useEffect(() => {
117
+ api.get<Page>(`/api/pages/${params.id}`).then((p) => {
118
+ setPage(p);
119
+ setTree(p.content);
120
+ lastPersistedRef.current = JSON.stringify(p.content);
121
+
122
+ // A local draft newer than what the server has means a previous session ended
123
+ // (crash, closed tab) before it could save — offer to bring that work back.
124
+ try {
125
+ const raw = localStorage.getItem(draftKey);
126
+ if (raw) {
127
+ const draft = JSON.parse(raw) as { content: PageNode; savedAt: string };
128
+ if (new Date(draft.savedAt) > new Date(p.updatedAt ?? 0) && JSON.stringify(draft.content) !== JSON.stringify(p.content)) {
129
+ setDraftPrompt(draft);
130
+ } else {
131
+ localStorage.removeItem(draftKey);
132
+ }
133
+ }
134
+ } catch {
135
+ // Corrupt/unparseable draft — ignore it rather than block loading the page.
136
+ localStorage.removeItem(draftKey);
137
+ }
138
+ });
139
+ api.get<SiteTheme>("/api/theme").then(setTheme);
140
+ api.get<NavItem[]>("/api/theme/nav").then(setNavItems);
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ }, [params.id]);
143
+
144
+ // Local backup on every change — survives a crashed tab even if the network is down.
145
+ useEffect(() => {
146
+ if (!tree) return;
147
+ try {
148
+ localStorage.setItem(draftKey, JSON.stringify({ content: tree, savedAt: new Date().toISOString() }));
149
+ } catch {
150
+ // Storage full/unavailable — autosave to the server below still covers us.
151
+ }
152
+ // eslint-disable-next-line react-hooks/exhaustive-deps
153
+ }, [tree]);
154
+
155
+ // Silent autosave to the server every 20s, only when there's actually something new.
156
+ useEffect(() => {
157
+ const id = setInterval(() => {
158
+ if (!page || !tree) return;
159
+ const serialized = JSON.stringify(tree);
160
+ if (serialized === lastPersistedRef.current) return;
161
+ api.put<Page>(`/api/pages/${page.id}`, { content: tree }).then((updated) => {
162
+ lastPersistedRef.current = serialized;
163
+ setPage(updated);
164
+ setSavedAt(new Date());
165
+ });
166
+ }, 20000);
167
+ return () => clearInterval(id);
168
+ }, [page, tree]);
169
+
170
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
171
+
172
+ function handleDragStart(event: DragStartEvent) {
173
+ const id = String(event.active.id);
174
+ if (id.startsWith("palette:")) {
175
+ setActiveDragType(id.slice("palette:".length));
176
+ } else {
177
+ const node = tree && findNode(tree, id);
178
+ setActiveDragType(node?.type ?? null);
179
+ }
180
+ }
181
+
182
+ function handleDragEnd(event: DragEndEvent) {
183
+ setActiveDragType(null);
184
+ const { active, over } = event;
185
+ if (!over || !tree) return;
186
+
187
+ const activeId = String(active.id);
188
+ const overId = String(over.id);
189
+ const overData = over.data.current as { type?: string; parentId?: string } | undefined;
190
+
191
+ let targetParentId: string;
192
+ let targetIndex: number;
193
+
194
+ if (overData?.type === "container" && overData.parentId) {
195
+ targetParentId = overData.parentId;
196
+ targetIndex = findNode(tree, targetParentId)?.children.length ?? 0;
197
+ } else {
198
+ const parentInfo = findParent(tree, overId);
199
+ if (!parentInfo) return;
200
+ targetParentId = parentInfo.parent.id;
201
+ targetIndex = parentInfo.index;
202
+ }
203
+
204
+ const targetDef = getComponentDefinition(findNode(tree, targetParentId)?.type ?? "");
205
+ if (targetParentId !== tree.id && !targetDef?.allowsChildren) return;
206
+
207
+ if (activeId.startsWith("palette:")) {
208
+ const type = activeId.slice("palette:".length);
209
+ const newNode = createNode(type);
210
+ updateTree((prev) => insertNode(prev, targetParentId, newNode, targetIndex));
211
+ setSelectedId(newNode.id);
212
+ return;
213
+ }
214
+
215
+ if (activeId.startsWith("block:")) {
216
+ // Saved Block drop: fetch the stored template and insert an independent copy
217
+ // (fresh ids throughout, so repeated inserts never collide).
218
+ const blockId = activeId.slice("block:".length);
219
+ api.get<{ id: string; content: PageNode }[]>("/api/blocks")
220
+ .then((blocks) => {
221
+ const block = blocks.find((b) => b.id === blockId);
222
+ if (!block) return;
223
+ const copy = cloneWithNewIds(block.content);
224
+ updateTree((prev) => insertNode(prev, targetParentId, copy, targetIndex));
225
+ setSelectedId(copy.id);
226
+ })
227
+ .catch(console.error);
228
+ return;
229
+ }
230
+
231
+ const activeNode = findNode(tree, activeId);
232
+ if (!activeNode) return;
233
+ if (isNodeOrDescendant(activeNode, targetParentId)) return; // prevent dropping into itself/descendant
234
+ if (activeId === overId) return;
235
+
236
+ updateTree((prev) => {
237
+ const parentInfoBefore = findParent(prev, activeId);
238
+ let idx = targetIndex;
239
+ if (parentInfoBefore && parentInfoBefore.parent.id === targetParentId && parentInfoBefore.index < targetIndex) {
240
+ idx = targetIndex - 1;
241
+ }
242
+ return moveNode(prev, activeId, targetParentId, idx);
243
+ });
244
+ }
245
+
246
+ const handleSelect = useCallback((id: string) => setSelectedId(id === "" ? null : id), []);
247
+
248
+ function handleDelete(id: string) {
249
+ updateTree((prev) => removeNode(prev, id));
250
+ setSelectedId((sel) => (sel === id ? null : sel));
251
+ }
252
+
253
+ function handleDuplicate(id: string) {
254
+ updateTree((prev) => duplicateNode(prev, id));
255
+ }
256
+
257
+ function handleChangeProps(id: string, props: Record<string, unknown>) {
258
+ updateTree((prev) => updateNode(prev, id, (n) => ({ ...n, props })), `props:${id}`);
259
+ }
260
+
261
+ function handleChangeStyle(id: string, style: NodeStyle) {
262
+ // On desktop the incoming object IS the node's base style. On tablet/mobile the
263
+ // Inspector and resize handles construct it against the device-effective style, so
264
+ // only the keys that actually differ from effective belong in the override layer —
265
+ // and clearing an override key reveals the base value again.
266
+ if (device === "desktop") {
267
+ updateTree((prev) => updateNode(prev, id, (n) => ({ ...n, style })), `style:${id}`);
268
+ return;
269
+ }
270
+ const layerKey = device === "tablet" ? "styleTablet" : "styleMobile";
271
+ updateTree(
272
+ (prev) =>
273
+ updateNode(prev, id, (n) => {
274
+ const effective = resolveNodeStyle(n, device);
275
+ const layer: Record<string, unknown> = { ...(n[layerKey] ?? {}) };
276
+ const keys = new Set([...Object.keys(style), ...Object.keys(effective)]);
277
+ for (const key of keys) {
278
+ const next = (style as Record<string, unknown>)[key];
279
+ const current = (effective as Record<string, unknown>)[key];
280
+ if (next === current) continue;
281
+ if (next === undefined) delete layer[key];
282
+ else layer[key] = next;
283
+ }
284
+ return { ...n, [layerKey]: layer };
285
+ }),
286
+ `style:${id}:${device}`
287
+ );
288
+ }
289
+
290
+ function handleChangeColumnCount(id: string, count: number) {
291
+ updateTree((prev) => updateNode(prev, id, (n) => setContainerColumnCount(n, count)));
292
+ }
293
+
294
+ function handleChangeGridDimensions(id: string, rows: number, columns: number) {
295
+ updateTree((prev) => updateNode(prev, id, (n) => setGridDimensions(n, rows, columns)));
296
+ }
297
+
298
+ function handleResizeGridTrack(gridId: string, axis: "column" | "row", index: number, size: string) {
299
+ updateTree(
300
+ (prev) => updateNode(prev, gridId, (n) => setGridTrackSize(n, axis, index, size)),
301
+ `gridTrack:${gridId}:${axis}:${index}`
302
+ );
303
+ }
304
+
305
+ function handleChangeZoneCount(id: string, count: number) {
306
+ updateTree((prev) => updateNode(prev, id, (n) => setHeaderZoneCount(n, count)));
307
+ }
308
+
309
+ function handleToggleHidden(id: string) {
310
+ updateTree((prev) => updateNode(prev, id, (n) => ({ ...n, props: { ...n.props, hidden: !n.props.hidden } })));
311
+ }
312
+
313
+ function handleEnableHeaderZones(id: string) {
314
+ updateTree((prev) => updateNode(prev, id, ensureHeaderZones));
315
+ }
316
+
317
+ // Ctrl/Cmd+Z to undo, Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y to redo — skipped while focus is
318
+ // in a text input/textarea/contenteditable so it doesn't fight the browser's own
319
+ // native undo for text editing.
320
+ useEffect(() => {
321
+ function onKeyDown(e: KeyboardEvent) {
322
+ const target = e.target as HTMLElement | null;
323
+ const isEditableField =
324
+ target &&
325
+ (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
326
+ if (isEditableField) return;
327
+
328
+ const mod = e.ctrlKey || e.metaKey;
329
+ const key = e.key.toLowerCase();
330
+ if (!mod || (key !== "z" && key !== "y")) return;
331
+
332
+ if (key === "y" || (key === "z" && e.shiftKey)) {
333
+ e.preventDefault();
334
+ redo();
335
+ } else if (key === "z") {
336
+ e.preventDefault();
337
+ undo();
338
+ }
339
+ }
340
+ window.addEventListener("keydown", onKeyDown);
341
+ return () => window.removeEventListener("keydown", onKeyDown);
342
+ }, [past, future, tree]);
343
+
344
+ async function save(publish?: boolean) {
345
+ if (!page || !tree) return;
346
+ setSaving(true);
347
+ try {
348
+ const updated = await api.put<Page>(`/api/pages/${page.id}`, {
349
+ content: tree,
350
+ ...(publish !== undefined ? { status: publish ? "PUBLISHED" : "DRAFT" } : {}),
351
+ });
352
+ lastPersistedRef.current = JSON.stringify(tree);
353
+ localStorage.removeItem(draftKey);
354
+ setPage(updated);
355
+ setSavedAt(new Date());
356
+ } finally {
357
+ setSaving(false);
358
+ }
359
+ }
360
+
361
+ function acceptDraft() {
362
+ if (!draftPrompt) return;
363
+ updateTree(() => draftPrompt.content);
364
+ setDraftPrompt(null);
365
+ }
366
+
367
+ function discardDraft() {
368
+ localStorage.removeItem(draftKey);
369
+ setDraftPrompt(null);
370
+ }
371
+
372
+ if (!page || !tree || !theme) {
373
+ return <div className="p-8 text-sm text-slate-400">Loading builder...</div>;
374
+ }
375
+
376
+ const selectedNode = selectedId ? findNode(tree, selectedId) : null;
377
+ const ancestors = selectedId ? findAncestorChain(tree, selectedId) : [];
378
+
379
+ return (
380
+ <div className="flex flex-col h-screen">
381
+ <div className="flex items-center justify-between border-b bg-white px-4 py-2.5 shrink-0">
382
+ <div className="flex items-center gap-3">
383
+ <button onClick={() => router.push("/admin")} className="text-slate-500 hover:text-slate-800">
384
+ <ArrowLeft size={18} />
385
+ </button>
386
+ <div>
387
+ <div className="text-sm font-semibold">{page.title}</div>
388
+ <div className="text-xs text-slate-400">
389
+ /{page.slug || ""} · {page.status}
390
+ {page.status === "PUBLISHED" && page.publishAt && new Date(page.publishAt) > new Date() && (
391
+ <span className="text-amber-600"> · scheduled for {new Date(page.publishAt).toLocaleString()}</span>
392
+ )}
393
+ {savedAt && ` · saved ${savedAt.toLocaleTimeString()}`}
394
+ </div>
395
+ </div>
396
+ </div>
397
+ <div className="flex items-center gap-2">
398
+ <div className="flex items-center border rounded-md overflow-hidden mr-1" title="Preview & edit per device — styles set on tablet/mobile override desktop at that size">
399
+ {(
400
+ [
401
+ { bp: "desktop" as Breakpoint, Icon: Monitor, label: "Desktop" },
402
+ { bp: "tablet" as Breakpoint, Icon: Tablet, label: "Tablet (≤1023px)" },
403
+ { bp: "mobile" as Breakpoint, Icon: Smartphone, label: "Mobile (≤767px)" },
404
+ ]
405
+ ).map(({ bp, Icon, label }) => (
406
+ <button
407
+ key={bp}
408
+ onClick={() => setDevice(bp)}
409
+ title={label}
410
+ className={
411
+ device === bp
412
+ ? "p-1.5 bg-blue-600 text-white"
413
+ : "p-1.5 text-slate-500 hover:bg-slate-100"
414
+ }
415
+ >
416
+ <Icon size={15} />
417
+ </button>
418
+ ))}
419
+ </div>
420
+ <button
421
+ onClick={undo}
422
+ disabled={past.length === 0}
423
+ title="Undo (Ctrl+Z)"
424
+ className="p-1.5 text-slate-500 hover:bg-slate-100 rounded-md disabled:opacity-30"
425
+ >
426
+ <Undo2 size={16} />
427
+ </button>
428
+ <button
429
+ onClick={redo}
430
+ disabled={future.length === 0}
431
+ title="Redo (Ctrl+Y)"
432
+ className="p-1.5 text-slate-500 hover:bg-slate-100 rounded-md disabled:opacity-30 mr-1"
433
+ >
434
+ <Redo2 size={16} />
435
+ </button>
436
+ <button
437
+ onClick={() => setSettingsOpen(true)}
438
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-md"
439
+ >
440
+ <Settings size={15} /> Settings
441
+ </button>
442
+ {page.previewToken && (
443
+ <a
444
+ href={`/preview/${page.previewToken}`}
445
+ target="_blank"
446
+ rel="noreferrer"
447
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-md"
448
+ >
449
+ <Eye size={15} /> Preview
450
+ </a>
451
+ )}
452
+ {page.status === "PUBLISHED" && (
453
+ <a
454
+ href={`/${page.slug}`}
455
+ target="_blank"
456
+ rel="noreferrer"
457
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-md"
458
+ >
459
+ <Globe2 size={15} /> Live
460
+ </a>
461
+ )}
462
+ <button
463
+ onClick={() => save()}
464
+ disabled={saving}
465
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-md hover:bg-slate-50 disabled:opacity-50"
466
+ >
467
+ <Save size={15} /> Save
468
+ </button>
469
+ <button
470
+ onClick={() => save(page.status !== "PUBLISHED")}
471
+ disabled={saving}
472
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
473
+ >
474
+ <Globe2 size={15} /> {page.status === "PUBLISHED" ? "Unpublish" : "Publish"}
475
+ </button>
476
+ </div>
477
+ </div>
478
+
479
+ {draftPrompt && (
480
+ <div className="flex items-center justify-between gap-3 bg-amber-50 border-b border-amber-200 px-4 py-2 text-sm shrink-0">
481
+ <span className="text-amber-800">
482
+ Found unsaved changes from {new Date(draftPrompt.savedAt).toLocaleString()} that never made it to the
483
+ server — restore them?
484
+ </span>
485
+ <div className="flex items-center gap-2 shrink-0">
486
+ <button onClick={acceptDraft} className="bg-amber-600 text-white px-3 py-1 rounded-md text-xs font-medium hover:bg-amber-700">
487
+ Restore
488
+ </button>
489
+ <button onClick={discardDraft} className="text-amber-700 px-3 py-1 text-xs hover:underline">
490
+ Discard
491
+ </button>
492
+ </div>
493
+ </div>
494
+ )}
495
+
496
+ <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
497
+ <BuilderDeviceContext.Provider value={device}>
498
+ <div className="flex flex-1 overflow-hidden">
499
+ <Palette />
500
+ <div className={device === "desktop" ? "flex-1 min-w-0 flex flex-col" : "flex-1 min-w-0 flex flex-col items-center bg-slate-200 overflow-auto"}>
501
+ <div
502
+ className={
503
+ device === "desktop"
504
+ ? "flex-1 min-h-0 flex flex-col w-full"
505
+ : "flex-1 min-h-0 flex flex-col w-full bg-white shadow-xl my-3 border rounded-md overflow-hidden"
506
+ }
507
+ style={device === "tablet" ? { maxWidth: 768 } : device === "mobile" ? { maxWidth: 390 } : undefined}
508
+ >
509
+ <Canvas
510
+ tree={tree}
511
+ theme={theme}
512
+ navItems={navItems}
513
+ selectedId={selectedId}
514
+ onSelect={handleSelect}
515
+ onDelete={handleDelete}
516
+ onDuplicate={handleDuplicate}
517
+ onToggleHidden={handleToggleHidden}
518
+ onChangeStyle={handleChangeStyle}
519
+ onResizeGridTrack={handleResizeGridTrack}
520
+ />
521
+ </div>
522
+ </div>
523
+ <Inspector
524
+ // The Inspector edits the device-effective style: pass a shimmed node whose
525
+ // style is resolved for the active breakpoint; handleChangeStyle diffs the
526
+ // result back into the right override layer.
527
+ node={selectedNode ? { ...selectedNode, style: resolveNodeStyle(selectedNode, device) } : null}
528
+ device={device}
529
+ ancestors={ancestors}
530
+ onSelect={handleSelect}
531
+ onChangeProps={(props) => selectedId && handleChangeProps(selectedId, props)}
532
+ onChangeStyle={(style) => selectedId && handleChangeStyle(selectedId, style)}
533
+ onChangeColumnCount={(count) => selectedId && handleChangeColumnCount(selectedId, count)}
534
+ onChangeZoneCount={(count) => selectedId && handleChangeZoneCount(selectedId, count)}
535
+ onChangeGridDimensions={(rows, cols) => selectedId && handleChangeGridDimensions(selectedId, rows, cols)}
536
+ onEnableHeaderZones={() => selectedId && handleEnableHeaderZones(selectedId)}
537
+ />
538
+ </div>
539
+ </BuilderDeviceContext.Provider>
540
+ <DragOverlay>
541
+ {activeDragType && (
542
+ <div className="bg-slate-900 text-white text-xs rounded px-3 py-2 flex items-center gap-2 shadow-lg">
543
+ <IconRenderer name={getComponentDefinition(activeDragType)?.icon ?? "Square"} size={14} />
544
+ {getComponentDefinition(activeDragType)?.label ?? activeDragType}
545
+ </div>
546
+ )}
547
+ </DragOverlay>
548
+ </DndContext>
549
+
550
+ {settingsOpen && (
551
+ <PageSettingsModal
552
+ page={page}
553
+ onClose={() => setSettingsOpen(false)}
554
+ onSaved={setPage}
555
+ onRestore={(content) => setTree(content)}
556
+ />
557
+ )}
558
+ </div>
559
+ );
560
+ }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import { Download, Upload } from "lucide-react";
5
+ import { api, ApiError } from "@/lib/api";
6
+
7
+ type ImportSummary = { created: Record<string, number>; skipped: Record<string, number> };
8
+
9
+ export default function BackupPage() {
10
+ const [importing, setImporting] = useState(false);
11
+ const [summary, setSummary] = useState<ImportSummary | null>(null);
12
+ const [error, setError] = useState<string | null>(null);
13
+ const fileInput = useRef<HTMLInputElement>(null);
14
+
15
+ async function exportSite() {
16
+ const res = await fetch("/api/backup/export", { credentials: "include" });
17
+ const blob = await res.blob();
18
+ const url = URL.createObjectURL(blob);
19
+ const a = document.createElement("a");
20
+ a.href = url;
21
+ a.download = `pg-cms-backup-${Date.now()}.json`;
22
+ a.click();
23
+ URL.revokeObjectURL(url);
24
+ }
25
+
26
+ async function importSite(file: File) {
27
+ setError(null);
28
+ setSummary(null);
29
+ setImporting(true);
30
+ try {
31
+ const parsed = JSON.parse(await file.text());
32
+ const result = await api.post<ImportSummary>("/api/backup/import", parsed);
33
+ setSummary(result);
34
+ } catch (err) {
35
+ setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Import failed");
36
+ } finally {
37
+ setImporting(false);
38
+ if (fileInput.current) fileInput.current.value = "";
39
+ }
40
+ }
41
+
42
+ return (
43
+ <div className="p-8 max-w-2xl space-y-6">
44
+ <h1 className="text-2xl font-semibold">Backup</h1>
45
+ <p className="text-sm text-slate-500">
46
+ Export a full snapshot (theme, navigation, pages, collections, global sections, redirects, saved blocks,
47
+ templates) as one JSON file, or restore from a previous export. Restoring is additive-only — it never
48
+ overwrites or deletes anything already on this site; entries whose slug or name already exists are skipped.
49
+ </p>
50
+
51
+ <div className="bg-white border rounded-lg p-6 space-y-3">
52
+ <h2 className="font-medium text-sm">Export</h2>
53
+ <button
54
+ onClick={exportSite}
55
+ className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700"
56
+ >
57
+ <Download size={15} /> Export site as JSON
58
+ </button>
59
+ </div>
60
+
61
+ <div className="bg-white border rounded-lg p-6 space-y-3">
62
+ <h2 className="font-medium text-sm">Import</h2>
63
+ {error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">{error}</div>}
64
+ <input
65
+ ref={fileInput}
66
+ type="file"
67
+ accept="application/json"
68
+ onChange={(e) => e.target.files?.[0] && importSite(e.target.files[0])}
69
+ className="hidden"
70
+ id="backup-file"
71
+ />
72
+ <label
73
+ htmlFor="backup-file"
74
+ className={`flex items-center gap-2 border-2 border-dashed rounded-md px-4 py-3 text-sm cursor-pointer hover:border-blue-400 ${
75
+ importing ? "opacity-50 pointer-events-none" : "text-slate-600"
76
+ }`}
77
+ >
78
+ <Upload size={15} /> {importing ? "Importing..." : "Choose a backup JSON file to restore"}
79
+ </label>
80
+ {summary && (
81
+ <div className="text-xs space-y-1 border rounded-md p-3 bg-slate-50">
82
+ <div className="font-medium text-slate-700">Import complete</div>
83
+ {Object.keys({ ...summary.created, ...summary.skipped }).map((key) => (
84
+ <div key={key} className="flex justify-between text-slate-600">
85
+ <span>{key}</span>
86
+ <span>
87
+ {summary.created[key] ?? 0} created
88
+ {summary.skipped[key] ? `, ${summary.skipped[key]} skipped (already existed)` : ""}
89
+ </span>
90
+ </div>
91
+ ))}
92
+ </div>
93
+ )}
94
+ </div>
95
+ </div>
96
+ );
97
+ }