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.
- package/README.md +41 -0
- package/bin/lib.js +97 -0
- package/bin/modern-cms.js +197 -0
- package/package.json +37 -0
- package/template/.env.example +48 -0
- package/template/README.md +210 -0
- package/template/apps/api/Dockerfile +26 -0
- package/template/apps/api/package.json +45 -0
- package/template/apps/api/prisma/backfill-search-text.ts +31 -0
- package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
- package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
- package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
- package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
- package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
- package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
- package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
- package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
- package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
- package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/reset-admin.ts +19 -0
- package/template/apps/api/prisma/schema.prisma +251 -0
- package/template/apps/api/prisma/seed-homepage.ts +752 -0
- package/template/apps/api/prisma/seed-our-charities.ts +231 -0
- package/template/apps/api/prisma/seed-templates.ts +154 -0
- package/template/apps/api/prisma/seed.ts +191 -0
- package/template/apps/api/src/env.ts +61 -0
- package/template/apps/api/src/index.ts +66 -0
- package/template/apps/api/src/lib/revalidate.ts +28 -0
- package/template/apps/api/src/lib/storage/azure.ts +25 -0
- package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
- package/template/apps/api/src/lib/storage/index.ts +25 -0
- package/template/apps/api/src/lib/storage/s3.ts +39 -0
- package/template/apps/api/src/lib/storage/types.ts +8 -0
- package/template/apps/api/src/middleware/auditLog.ts +32 -0
- package/template/apps/api/src/middleware/auth.ts +53 -0
- package/template/apps/api/src/middleware/rateLimit.ts +31 -0
- package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
- package/template/apps/api/src/prisma.ts +3 -0
- package/template/apps/api/src/routes/audit.routes.ts +26 -0
- package/template/apps/api/src/routes/auth.routes.ts +46 -0
- package/template/apps/api/src/routes/backup.routes.ts +216 -0
- package/template/apps/api/src/routes/blocks.routes.ts +38 -0
- package/template/apps/api/src/routes/collections.routes.ts +185 -0
- package/template/apps/api/src/routes/forms.routes.ts +114 -0
- package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
- package/template/apps/api/src/routes/media.routes.ts +77 -0
- package/template/apps/api/src/routes/pages.routes.ts +319 -0
- package/template/apps/api/src/routes/redirects.routes.ts +52 -0
- package/template/apps/api/src/routes/search.routes.ts +83 -0
- package/template/apps/api/src/routes/templates.routes.ts +41 -0
- package/template/apps/api/src/routes/theme.routes.ts +75 -0
- package/template/apps/api/src/routes/users.routes.ts +39 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/web/Dockerfile +26 -0
- package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
- package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
- package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
- package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
- package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
- package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
- package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
- package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
- package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
- package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
- package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
- package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
- package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
- package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
- package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
- package/template/apps/web/app/admin/login/page.tsx +76 -0
- package/template/apps/web/app/api/revalidate/route.ts +34 -0
- package/template/apps/web/app/globals.css +120 -0
- package/template/apps/web/app/layout.tsx +39 -0
- package/template/apps/web/app/preview/[token]/page.tsx +70 -0
- package/template/apps/web/app/robots.ts +11 -0
- package/template/apps/web/app/sitemap.ts +27 -0
- package/template/apps/web/components/admin/AdminShell.tsx +104 -0
- package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
- package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
- package/template/apps/web/components/builder/Canvas.tsx +76 -0
- package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
- package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
- package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
- package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
- package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
- package/template/apps/web/components/builder/Inspector.tsx +956 -0
- package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
- package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
- package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
- package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
- package/template/apps/web/components/builder/Palette.tsx +106 -0
- package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
- package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
- package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
- package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
- package/template/apps/web/components/renderer/Renderer.tsx +146 -0
- package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
- package/template/apps/web/components/renderer/registry.tsx +87 -0
- package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
- package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
- package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
- package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
- package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
- package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
- package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
- package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
- package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
- package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
- package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
- package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
- package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
- package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
- package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
- package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
- package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
- package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
- package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
- package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
- package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
- package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
- package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
- package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
- package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
- package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
- package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
- package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
- package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
- package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
- package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
- package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
- package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
- package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
- package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
- package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
- package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
- package/template/apps/web/components/renderer/sections/types.ts +7 -0
- package/template/apps/web/components/seo/JsonLd.tsx +111 -0
- package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
- package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
- package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
- package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
- package/template/apps/web/eslint.config.mjs +16 -0
- package/template/apps/web/lib/api.ts +45 -0
- package/template/apps/web/lib/responsiveCss.ts +42 -0
- package/template/apps/web/lib/sanitizeHtml.ts +16 -0
- package/template/apps/web/lib/serverApi.ts +25 -0
- package/template/apps/web/lib/style.ts +73 -0
- package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
- package/template/apps/web/lib/useCurrentUser.ts +35 -0
- package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
- package/template/apps/web/next-env.d.ts +6 -0
- package/template/apps/web/next.config.mjs +32 -0
- package/template/apps/web/package.json +46 -0
- package/template/apps/web/postcss.config.mjs +6 -0
- package/template/apps/web/proxy.ts +23 -0
- package/template/apps/web/tailwind.config.ts +24 -0
- package/template/apps/web/tsconfig.json +41 -0
- package/template/docker-compose.yml +60 -0
- package/template/package.json +29 -0
- package/template/packages/shared/package.json +21 -0
- package/template/packages/shared/src/components.ts +1626 -0
- package/template/packages/shared/src/index.ts +9 -0
- package/template/packages/shared/src/schema/collections.ts +70 -0
- package/template/packages/shared/src/schema/fields.ts +26 -0
- package/template/packages/shared/src/schema/media.ts +13 -0
- package/template/packages/shared/src/schema/pageNode.ts +255 -0
- package/template/packages/shared/src/schema/templates.ts +20 -0
- package/template/packages/shared/src/schema/theme.ts +69 -0
- package/template/packages/shared/src/schema/user.ts +25 -0
- package/template/packages/shared/src/tree.test.ts +65 -0
- package/template/packages/shared/src/tree.ts +303 -0
- package/template/packages/shared/tsconfig.json +8 -0
- 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
|
+
}
|