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,333 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
DndContext,
|
|
6
|
+
DragOverlay,
|
|
7
|
+
PointerSensor,
|
|
8
|
+
useSensor,
|
|
9
|
+
useSensors,
|
|
10
|
+
type DragEndEvent,
|
|
11
|
+
type DragStartEvent,
|
|
12
|
+
} from "@dnd-kit/core";
|
|
13
|
+
import { Save } from "lucide-react";
|
|
14
|
+
import {
|
|
15
|
+
cloneWithNewIds,
|
|
16
|
+
createNode,
|
|
17
|
+
createEmptyPage,
|
|
18
|
+
findNode,
|
|
19
|
+
findParent,
|
|
20
|
+
findAncestorChain,
|
|
21
|
+
insertNode,
|
|
22
|
+
isNodeOrDescendant,
|
|
23
|
+
moveNode,
|
|
24
|
+
removeNode,
|
|
25
|
+
duplicateNode,
|
|
26
|
+
updateNode,
|
|
27
|
+
getComponentDefinition,
|
|
28
|
+
type NavItem,
|
|
29
|
+
type NodeStyle,
|
|
30
|
+
type PageNode,
|
|
31
|
+
type SiteTheme,
|
|
32
|
+
} from "@pgcms/shared";
|
|
33
|
+
import { api } from "@/lib/api";
|
|
34
|
+
import { Palette } from "@/components/builder/Palette";
|
|
35
|
+
import { Canvas } from "@/components/builder/Canvas";
|
|
36
|
+
import { Inspector } from "@/components/builder/Inspector";
|
|
37
|
+
import { IconRenderer } from "@/components/builder/IconRenderer";
|
|
38
|
+
|
|
39
|
+
type Kind = "header" | "footer" | "goToTop";
|
|
40
|
+
type GlobalSectionRow = { kind: string; enabled: boolean; content: PageNode };
|
|
41
|
+
|
|
42
|
+
/** Edit-once site chrome: the same Palette/Canvas/Inspector as the page builder, but
|
|
43
|
+
* saving into the GlobalSection rows that render above/below every page. */
|
|
44
|
+
export default function GlobalSectionsEditor() {
|
|
45
|
+
const [kind, setKind] = useState<Kind>("header");
|
|
46
|
+
const [trees, setTrees] = useState<Record<Kind, PageNode>>({
|
|
47
|
+
header: createEmptyPage(),
|
|
48
|
+
footer: createEmptyPage(),
|
|
49
|
+
goToTop: createEmptyPage(),
|
|
50
|
+
});
|
|
51
|
+
const [enabled, setEnabled] = useState<Record<Kind, boolean>>({
|
|
52
|
+
header: false,
|
|
53
|
+
footer: false,
|
|
54
|
+
goToTop: false,
|
|
55
|
+
});
|
|
56
|
+
const [theme, setTheme] = useState<SiteTheme | null>(null);
|
|
57
|
+
const [navItems, setNavItems] = useState<NavItem[]>([]);
|
|
58
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
59
|
+
const [activeDragType, setActiveDragType] = useState<string | null>(null);
|
|
60
|
+
const [saving, setSaving] = useState(false);
|
|
61
|
+
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
|
62
|
+
const [loaded, setLoaded] = useState(false);
|
|
63
|
+
|
|
64
|
+
const sensors = useSensors(
|
|
65
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
66
|
+
);
|
|
67
|
+
const tree = trees[kind];
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
(async () => {
|
|
71
|
+
const [sections, themeData, nav] = await Promise.all([
|
|
72
|
+
api.get<GlobalSectionRow[]>("/api/global-sections"),
|
|
73
|
+
api.get<SiteTheme>("/api/theme"),
|
|
74
|
+
api.get<NavItem[]>("/api/theme/nav"),
|
|
75
|
+
]);
|
|
76
|
+
setTheme(themeData);
|
|
77
|
+
setNavItems(nav);
|
|
78
|
+
const next = {
|
|
79
|
+
header: createEmptyPage(),
|
|
80
|
+
footer: createEmptyPage(),
|
|
81
|
+
goToTop: createEmptyPage(),
|
|
82
|
+
} as Record<Kind, PageNode>;
|
|
83
|
+
const nextEnabled = {
|
|
84
|
+
header: false,
|
|
85
|
+
footer: false,
|
|
86
|
+
goToTop: false,
|
|
87
|
+
} as Record<Kind, boolean>;
|
|
88
|
+
for (const row of sections) {
|
|
89
|
+
if (
|
|
90
|
+
row.kind === "header" ||
|
|
91
|
+
row.kind === "footer" ||
|
|
92
|
+
row.kind === "goToTop"
|
|
93
|
+
) {
|
|
94
|
+
next[row.kind as Kind] = row.content;
|
|
95
|
+
nextEnabled[row.kind as Kind] = row.enabled;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
setTrees(next);
|
|
99
|
+
setEnabled(nextEnabled);
|
|
100
|
+
setLoaded(true);
|
|
101
|
+
})().catch(console.error);
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
function updateTree(updater: (prev: PageNode) => PageNode) {
|
|
105
|
+
setTrees((prev) => ({ ...prev, [kind]: updater(prev[kind]) }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleDragStart(event: DragStartEvent) {
|
|
109
|
+
const id = String(event.active.id);
|
|
110
|
+
if (id.startsWith("palette:"))
|
|
111
|
+
setActiveDragType(id.slice("palette:".length));
|
|
112
|
+
else setActiveDragType(findNode(tree, id)?.type ?? null);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function handleDragEnd(event: DragEndEvent) {
|
|
116
|
+
setActiveDragType(null);
|
|
117
|
+
const { active, over } = event;
|
|
118
|
+
if (!over) return;
|
|
119
|
+
|
|
120
|
+
const activeId = String(active.id);
|
|
121
|
+
const overId = String(over.id);
|
|
122
|
+
const overData = over.data.current as
|
|
123
|
+
| { type?: string; parentId?: string }
|
|
124
|
+
| undefined;
|
|
125
|
+
|
|
126
|
+
let targetParentId: string;
|
|
127
|
+
let targetIndex: number;
|
|
128
|
+
if (overData?.type === "container" && overData.parentId) {
|
|
129
|
+
targetParentId = overData.parentId;
|
|
130
|
+
targetIndex = findNode(tree, targetParentId)?.children.length ?? 0;
|
|
131
|
+
} else {
|
|
132
|
+
const parentInfo = findParent(tree, overId);
|
|
133
|
+
if (!parentInfo) return;
|
|
134
|
+
targetParentId = parentInfo.parent.id;
|
|
135
|
+
targetIndex = parentInfo.index;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const targetDef = getComponentDefinition(
|
|
139
|
+
findNode(tree, targetParentId)?.type ?? "",
|
|
140
|
+
);
|
|
141
|
+
if (targetParentId !== tree.id && !targetDef?.allowsChildren) return;
|
|
142
|
+
|
|
143
|
+
if (activeId.startsWith("palette:")) {
|
|
144
|
+
const newNode = createNode(activeId.slice("palette:".length));
|
|
145
|
+
updateTree((prev) =>
|
|
146
|
+
insertNode(prev, targetParentId, newNode, targetIndex),
|
|
147
|
+
);
|
|
148
|
+
setSelectedId(newNode.id);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (activeId.startsWith("block:")) {
|
|
153
|
+
const blockId = activeId.slice("block:".length);
|
|
154
|
+
api
|
|
155
|
+
.get<{ id: string; content: PageNode }[]>("/api/blocks")
|
|
156
|
+
.then((blocks) => {
|
|
157
|
+
const block = blocks.find((b) => b.id === blockId);
|
|
158
|
+
if (!block) return;
|
|
159
|
+
const copy = cloneWithNewIds(block.content);
|
|
160
|
+
updateTree((prev) =>
|
|
161
|
+
insertNode(prev, targetParentId, copy, targetIndex),
|
|
162
|
+
);
|
|
163
|
+
setSelectedId(copy.id);
|
|
164
|
+
})
|
|
165
|
+
.catch(console.error);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const activeNode = findNode(tree, activeId);
|
|
170
|
+
if (
|
|
171
|
+
!activeNode ||
|
|
172
|
+
isNodeOrDescendant(activeNode, targetParentId) ||
|
|
173
|
+
activeId === overId
|
|
174
|
+
)
|
|
175
|
+
return;
|
|
176
|
+
updateTree((prev) => {
|
|
177
|
+
const before = findParent(prev, activeId);
|
|
178
|
+
let idx = targetIndex;
|
|
179
|
+
if (
|
|
180
|
+
before &&
|
|
181
|
+
before.parent.id === targetParentId &&
|
|
182
|
+
before.index < targetIndex
|
|
183
|
+
)
|
|
184
|
+
idx = targetIndex - 1;
|
|
185
|
+
return moveNode(prev, activeId, targetParentId, idx);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function save() {
|
|
190
|
+
setSaving(true);
|
|
191
|
+
try {
|
|
192
|
+
await Promise.all(
|
|
193
|
+
(["header", "footer", "goToTop"] as Kind[]).map((k) =>
|
|
194
|
+
api.put(`/api/global-sections/${k}`, { enabled: enabled[k], content: trees[k] })
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
setSavedAt(new Date());
|
|
198
|
+
} finally {
|
|
199
|
+
setSaving(false);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const selectedNode = selectedId ? findNode(tree, selectedId) : null;
|
|
204
|
+
const ancestors =
|
|
205
|
+
selectedId && tree ? findAncestorChain(tree, selectedId) : [];
|
|
206
|
+
|
|
207
|
+
if (!loaded || !theme) {
|
|
208
|
+
return (
|
|
209
|
+
<div className="p-8 text-sm text-slate-400">Loading global sections…</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className="flex flex-col h-full">
|
|
215
|
+
<div className="flex items-center justify-between border-b bg-white px-4 py-2 shrink-0">
|
|
216
|
+
<div className="flex items-center gap-4">
|
|
217
|
+
<div className="font-semibold text-sm">Global Sections</div>
|
|
218
|
+
<div className="flex items-center border rounded-md overflow-hidden">
|
|
219
|
+
{(["header", "footer", "goToTop"] as Kind[]).map((k) => (
|
|
220
|
+
<button
|
|
221
|
+
key={k}
|
|
222
|
+
onClick={() => {
|
|
223
|
+
setKind(k);
|
|
224
|
+
setSelectedId(null);
|
|
225
|
+
}}
|
|
226
|
+
className={
|
|
227
|
+
kind === k ? "px-3 py-1.5 text-xs font-medium bg-blue-600 text-white" : "px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-50"
|
|
228
|
+
}
|
|
229
|
+
>
|
|
230
|
+
{k === "header" ? "Header" : k === "footer" ? "Footer" : "Go to Top"}
|
|
231
|
+
</button>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
<label className="flex items-center gap-2 text-xs text-slate-600">
|
|
235
|
+
<input
|
|
236
|
+
type="checkbox"
|
|
237
|
+
checked={enabled[kind]}
|
|
238
|
+
onChange={(e) =>
|
|
239
|
+
setEnabled((prev) => ({ ...prev, [kind]: e.target.checked }))
|
|
240
|
+
}
|
|
241
|
+
/>
|
|
242
|
+
Show on every page
|
|
243
|
+
</label>
|
|
244
|
+
<span className="text-xs text-slate-400">
|
|
245
|
+
Pages that still contain their own header/footer sections will show
|
|
246
|
+
both — remove the per-page copies once this is enabled.
|
|
247
|
+
</span>
|
|
248
|
+
</div>
|
|
249
|
+
<div className="flex items-center gap-3">
|
|
250
|
+
{savedAt && (
|
|
251
|
+
<span className="text-xs text-slate-400">
|
|
252
|
+
saved {savedAt.toLocaleTimeString()}
|
|
253
|
+
</span>
|
|
254
|
+
)}
|
|
255
|
+
<button
|
|
256
|
+
onClick={save}
|
|
257
|
+
disabled={saving}
|
|
258
|
+
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"
|
|
259
|
+
>
|
|
260
|
+
<Save size={15} /> Save
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<DndContext
|
|
266
|
+
sensors={sensors}
|
|
267
|
+
onDragStart={handleDragStart}
|
|
268
|
+
onDragEnd={handleDragEnd}
|
|
269
|
+
>
|
|
270
|
+
<div className="flex flex-1 overflow-hidden">
|
|
271
|
+
<Palette />
|
|
272
|
+
<Canvas
|
|
273
|
+
tree={tree}
|
|
274
|
+
theme={theme}
|
|
275
|
+
navItems={navItems}
|
|
276
|
+
selectedId={selectedId}
|
|
277
|
+
onSelect={(id) => setSelectedId(id === "" ? null : id)}
|
|
278
|
+
onDelete={(id) => {
|
|
279
|
+
updateTree((prev) => removeNode(prev, id));
|
|
280
|
+
setSelectedId((sel) => (sel === id ? null : sel));
|
|
281
|
+
}}
|
|
282
|
+
onDuplicate={(id) => updateTree((prev) => duplicateNode(prev, id))}
|
|
283
|
+
onToggleHidden={(id) =>
|
|
284
|
+
updateTree((prev) =>
|
|
285
|
+
updateNode(prev, id, (n) => ({
|
|
286
|
+
...n,
|
|
287
|
+
props: { ...n.props, hidden: !n.props.hidden },
|
|
288
|
+
})),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
onChangeStyle={(id, style) =>
|
|
292
|
+
updateTree((prev) =>
|
|
293
|
+
updateNode(prev, id, (n) => ({ ...n, style })),
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
/>
|
|
297
|
+
<Inspector
|
|
298
|
+
node={selectedNode ?? null}
|
|
299
|
+
ancestors={ancestors}
|
|
300
|
+
onSelect={(id) => setSelectedId(id)}
|
|
301
|
+
onChangeProps={(props) =>
|
|
302
|
+
selectedId &&
|
|
303
|
+
updateTree((prev) =>
|
|
304
|
+
updateNode(prev, selectedId, (n) => ({ ...n, props })),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
onChangeStyle={(style: NodeStyle) =>
|
|
308
|
+
selectedId &&
|
|
309
|
+
updateTree((prev) =>
|
|
310
|
+
updateNode(prev, selectedId, (n) => ({ ...n, style })),
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
onChangeColumnCount={() => {}}
|
|
314
|
+
onChangeGridDimensions={() => {}}
|
|
315
|
+
onChangeZoneCount={() => {}}
|
|
316
|
+
onEnableHeaderZones={() => {}}
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
319
|
+
<DragOverlay>
|
|
320
|
+
{activeDragType && (
|
|
321
|
+
<div className="bg-slate-900 text-white text-xs rounded px-3 py-2 flex items-center gap-2 shadow-lg">
|
|
322
|
+
<IconRenderer
|
|
323
|
+
name={getComponentDefinition(activeDragType)?.icon ?? "Square"}
|
|
324
|
+
size={14}
|
|
325
|
+
/>
|
|
326
|
+
{getComponentDefinition(activeDragType)?.label ?? activeDragType}
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
</DragOverlay>
|
|
330
|
+
</DndContext>
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Plus, Trash2, ChevronUp, ChevronDown, CornerDownRight } from "lucide-react";
|
|
5
|
+
import { buildNavTree, type NavItem } from "@pgcms/shared";
|
|
6
|
+
import { api } from "@/lib/api";
|
|
7
|
+
|
|
8
|
+
export default function NavigationSettingsPage() {
|
|
9
|
+
const [items, setItems] = useState<NavItem[] | null>(null);
|
|
10
|
+
const [label, setLabel] = useState("");
|
|
11
|
+
const [href, setHref] = useState("");
|
|
12
|
+
const [parentId, setParentId] = useState<string>("");
|
|
13
|
+
|
|
14
|
+
function refresh() {
|
|
15
|
+
api.get<NavItem[]>("/api/theme/nav").then((data) => setItems([...data].sort((a, b) => a.order - b.order)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
useEffect(refresh, []);
|
|
19
|
+
|
|
20
|
+
const tree = items ? buildNavTree(items) : [];
|
|
21
|
+
const topLevelItems = items?.filter((i) => !i.parentId) ?? [];
|
|
22
|
+
|
|
23
|
+
async function addItem() {
|
|
24
|
+
if (!label || !href) return;
|
|
25
|
+
const siblingCount = (items?.filter((i) => (i.parentId ?? "") === (parentId || "")) ?? []).length;
|
|
26
|
+
await api.post("/api/theme/nav", {
|
|
27
|
+
label,
|
|
28
|
+
href,
|
|
29
|
+
order: siblingCount * 10,
|
|
30
|
+
parentId: parentId || null,
|
|
31
|
+
});
|
|
32
|
+
setLabel("");
|
|
33
|
+
setHref("");
|
|
34
|
+
setParentId("");
|
|
35
|
+
refresh();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function removeItem(id: string) {
|
|
39
|
+
await api.delete(`/api/theme/nav/${id}`);
|
|
40
|
+
refresh();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function changeParent(item: NavItem, newParentId: string) {
|
|
44
|
+
const siblingCount = (items?.filter((i) => (i.parentId ?? "") === newParentId) ?? []).length;
|
|
45
|
+
await api.put(`/api/theme/nav/${item.id}`, { parentId: newParentId || null, order: siblingCount * 10 });
|
|
46
|
+
refresh();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function move(siblings: NavItem[], index: number, dir: -1 | 1) {
|
|
50
|
+
const target = index + dir;
|
|
51
|
+
if (target < 0 || target >= siblings.length) return;
|
|
52
|
+
const a = siblings[index];
|
|
53
|
+
const b = siblings[target];
|
|
54
|
+
await Promise.all([
|
|
55
|
+
api.put(`/api/theme/nav/${a.id}`, { order: b.order }),
|
|
56
|
+
api.put(`/api/theme/nav/${b.id}`, { order: a.order }),
|
|
57
|
+
]);
|
|
58
|
+
refresh();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="p-8 max-w-2xl space-y-6">
|
|
63
|
+
<h1 className="text-2xl font-semibold">Navigation</h1>
|
|
64
|
+
<p className="text-sm text-slate-500">
|
|
65
|
+
Links shown in the site header. Give an item a parent to make it a submenu — it opens on hover under its
|
|
66
|
+
parent link.
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<div className="bg-white border rounded-lg p-4 space-y-3">
|
|
70
|
+
<div className="flex gap-3">
|
|
71
|
+
<input
|
|
72
|
+
placeholder="Label"
|
|
73
|
+
value={label}
|
|
74
|
+
onChange={(e) => setLabel(e.target.value)}
|
|
75
|
+
className="flex-1 border rounded-md px-3 py-2 text-sm"
|
|
76
|
+
/>
|
|
77
|
+
<input
|
|
78
|
+
placeholder="/path or https://..."
|
|
79
|
+
value={href}
|
|
80
|
+
onChange={(e) => setHref(e.target.value)}
|
|
81
|
+
className="flex-1 border rounded-md px-3 py-2 text-sm"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="flex gap-3">
|
|
85
|
+
<select
|
|
86
|
+
value={parentId}
|
|
87
|
+
onChange={(e) => setParentId(e.target.value)}
|
|
88
|
+
className="flex-1 border rounded-md px-3 py-2 text-sm bg-white"
|
|
89
|
+
>
|
|
90
|
+
<option value="">No parent (top-level menu item)</option>
|
|
91
|
+
{topLevelItems.map((p) => (
|
|
92
|
+
<option key={p.id} value={p.id}>
|
|
93
|
+
Submenu under "{p.label}"
|
|
94
|
+
</option>
|
|
95
|
+
))}
|
|
96
|
+
</select>
|
|
97
|
+
<button
|
|
98
|
+
onClick={addItem}
|
|
99
|
+
className="flex items-center gap-1.5 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700"
|
|
100
|
+
>
|
|
101
|
+
<Plus size={15} /> Add
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="bg-white border rounded-lg divide-y">
|
|
107
|
+
{tree.length === 0 && <div className="p-6 text-sm text-slate-500">No navigation links yet.</div>}
|
|
108
|
+
{tree.map((item, index) => (
|
|
109
|
+
<div key={item.id}>
|
|
110
|
+
<NavRow
|
|
111
|
+
item={item}
|
|
112
|
+
topLevelItems={topLevelItems}
|
|
113
|
+
onMoveUp={() => move(topLevelItems, index, -1)}
|
|
114
|
+
onMoveDown={() => move(topLevelItems, index, 1)}
|
|
115
|
+
onRemove={() => removeItem(item.id)}
|
|
116
|
+
onChangeParent={(newParentId) => changeParent(item, newParentId)}
|
|
117
|
+
/>
|
|
118
|
+
{item.children.map((child, childIndex) => (
|
|
119
|
+
<div key={child.id} className="pl-8 border-t">
|
|
120
|
+
<NavRow
|
|
121
|
+
item={{ ...child, children: [] }}
|
|
122
|
+
topLevelItems={topLevelItems}
|
|
123
|
+
isChild
|
|
124
|
+
onMoveUp={() => move(item.children, childIndex, -1)}
|
|
125
|
+
onMoveDown={() => move(item.children, childIndex, 1)}
|
|
126
|
+
onRemove={() => removeItem(child.id)}
|
|
127
|
+
onChangeParent={(newParentId) => changeParent(child, newParentId)}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function NavRow({
|
|
139
|
+
item,
|
|
140
|
+
topLevelItems,
|
|
141
|
+
isChild,
|
|
142
|
+
onMoveUp,
|
|
143
|
+
onMoveDown,
|
|
144
|
+
onRemove,
|
|
145
|
+
onChangeParent,
|
|
146
|
+
}: {
|
|
147
|
+
item: NavItem & { children: NavItem[] };
|
|
148
|
+
topLevelItems: NavItem[];
|
|
149
|
+
isChild?: boolean;
|
|
150
|
+
onMoveUp: () => void;
|
|
151
|
+
onMoveDown: () => void;
|
|
152
|
+
onRemove: () => void;
|
|
153
|
+
onChangeParent: (parentId: string) => void;
|
|
154
|
+
}) {
|
|
155
|
+
return (
|
|
156
|
+
<div className="flex items-center justify-between px-4 py-3 gap-3">
|
|
157
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
158
|
+
{isChild && <CornerDownRight size={14} className="text-slate-300 shrink-0" />}
|
|
159
|
+
<div className="min-w-0">
|
|
160
|
+
<div className="font-medium text-sm truncate">{item.label}</div>
|
|
161
|
+
<div className="text-xs text-slate-400 truncate">{item.href}</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
165
|
+
<select
|
|
166
|
+
value={item.parentId ?? ""}
|
|
167
|
+
onChange={(e) => onChangeParent(e.target.value)}
|
|
168
|
+
className="text-xs border rounded-md px-2 py-1.5 bg-white text-slate-600"
|
|
169
|
+
title="Parent menu item"
|
|
170
|
+
>
|
|
171
|
+
<option value="">Top-level</option>
|
|
172
|
+
{topLevelItems
|
|
173
|
+
.filter((p) => p.id !== item.id)
|
|
174
|
+
.map((p) => (
|
|
175
|
+
<option key={p.id} value={p.id}>
|
|
176
|
+
Under "{p.label}"
|
|
177
|
+
</option>
|
|
178
|
+
))}
|
|
179
|
+
</select>
|
|
180
|
+
<button onClick={onMoveUp} className="text-slate-400 hover:text-slate-700">
|
|
181
|
+
<ChevronUp size={16} />
|
|
182
|
+
</button>
|
|
183
|
+
<button onClick={onMoveDown} className="text-slate-400 hover:text-slate-700">
|
|
184
|
+
<ChevronDown size={16} />
|
|
185
|
+
</button>
|
|
186
|
+
<button onClick={onRemove} className="text-slate-400 hover:text-red-600">
|
|
187
|
+
<Trash2 size={16} />
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ArrowRight, Plus, Trash2 } from "lucide-react";
|
|
5
|
+
import { api } from "@/lib/api";
|
|
6
|
+
|
|
7
|
+
type Redirect = { id: string; fromPath: string; toPath: string; permanent: boolean; createdAt: string };
|
|
8
|
+
|
|
9
|
+
/** Old URL → new URL mappings. Consulted whenever a public path matches neither a page
|
|
10
|
+
* nor a collection item, so restructured sites keep their inbound links working. */
|
|
11
|
+
export default function RedirectsPage() {
|
|
12
|
+
const [redirects, setRedirects] = useState<Redirect[]>([]);
|
|
13
|
+
const [fromPath, setFromPath] = useState("");
|
|
14
|
+
const [toPath, setToPath] = useState("");
|
|
15
|
+
const [permanent, setPermanent] = useState(true);
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [saving, setSaving] = useState(false);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
api.get<Redirect[]>("/api/redirects").then(setRedirects).catch(() => {});
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
async function add() {
|
|
24
|
+
setError(null);
|
|
25
|
+
if (!fromPath.trim() || !toPath.trim()) {
|
|
26
|
+
setError("Both paths are required.");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
setSaving(true);
|
|
30
|
+
try {
|
|
31
|
+
const created = await api.post<Redirect>("/api/redirects", { fromPath, toPath, permanent });
|
|
32
|
+
setRedirects((prev) => [created, ...prev]);
|
|
33
|
+
setFromPath("");
|
|
34
|
+
setToPath("");
|
|
35
|
+
} catch (err) {
|
|
36
|
+
setError(err instanceof Error ? err.message : "Failed to create redirect");
|
|
37
|
+
} finally {
|
|
38
|
+
setSaving(false);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function remove(id: string) {
|
|
43
|
+
await api.delete(`/api/redirects/${id}`);
|
|
44
|
+
setRedirects((prev) => prev.filter((r) => r.id !== id));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="p-8 max-w-3xl">
|
|
49
|
+
<h1 className="text-xl font-semibold mb-1">Redirects</h1>
|
|
50
|
+
<p className="text-sm text-slate-500 mb-6">
|
|
51
|
+
When a visitor hits a URL that no longer exists, they're sent to the new address instead of a 404. Use these
|
|
52
|
+
after renaming slugs or restructuring the site.
|
|
53
|
+
</p>
|
|
54
|
+
|
|
55
|
+
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4">{error}</div>}
|
|
56
|
+
|
|
57
|
+
<div className="flex items-end gap-2 mb-8">
|
|
58
|
+
<div className="flex-1 space-y-1">
|
|
59
|
+
<label className="text-xs font-medium text-slate-600">From (old path)</label>
|
|
60
|
+
<input
|
|
61
|
+
value={fromPath}
|
|
62
|
+
onChange={(e) => setFromPath(e.target.value)}
|
|
63
|
+
placeholder="/old-page"
|
|
64
|
+
className="w-full border rounded-md px-3 py-2 text-sm"
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
<ArrowRight size={16} className="mb-2.5 text-slate-400 shrink-0" />
|
|
68
|
+
<div className="flex-1 space-y-1">
|
|
69
|
+
<label className="text-xs font-medium text-slate-600">To (new path or URL)</label>
|
|
70
|
+
<input
|
|
71
|
+
value={toPath}
|
|
72
|
+
onChange={(e) => setToPath(e.target.value)}
|
|
73
|
+
placeholder="/new-page"
|
|
74
|
+
className="w-full border rounded-md px-3 py-2 text-sm"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
<label className="flex items-center gap-1.5 text-xs text-slate-600 mb-2.5 shrink-0">
|
|
78
|
+
<input type="checkbox" checked={permanent} onChange={(e) => setPermanent(e.target.checked)} />
|
|
79
|
+
Permanent (301)
|
|
80
|
+
</label>
|
|
81
|
+
<button
|
|
82
|
+
onClick={add}
|
|
83
|
+
disabled={saving}
|
|
84
|
+
className="flex items-center gap-1.5 bg-blue-600 text-white rounded-md px-3 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50 shrink-0"
|
|
85
|
+
>
|
|
86
|
+
<Plus size={15} /> Add
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="space-y-2">
|
|
91
|
+
{redirects.length === 0 && <div className="text-sm text-slate-400">No redirects yet.</div>}
|
|
92
|
+
{redirects.map((r) => (
|
|
93
|
+
<div key={r.id} className="flex items-center justify-between border rounded-md px-3 py-2 text-sm">
|
|
94
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
95
|
+
<code className="truncate">{r.fromPath}</code>
|
|
96
|
+
<ArrowRight size={13} className="text-slate-400 shrink-0" />
|
|
97
|
+
<code className="truncate">{r.toPath}</code>
|
|
98
|
+
<span className="text-xs text-slate-400 shrink-0">{r.permanent ? "301" : "302"}</span>
|
|
99
|
+
</div>
|
|
100
|
+
<button onClick={() => remove(r.id)} className="text-slate-300 hover:text-red-600 shrink-0 ml-3">
|
|
101
|
+
<Trash2 size={15} />
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|