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,27 @@
1
+ import type { MetadataRoute } from "next";
2
+ import { serverGet } from "@/lib/serverApi";
3
+
4
+ const SITE_URL = process.env.SITE_URL ?? "http://localhost:3000";
5
+
6
+ type PublicPage = { slug: string; updatedAt?: string };
7
+ type SitemapItem = { path: string; updatedAt: string };
8
+
9
+ /** /sitemap.xml — every publicly-visible page and collection item, straight from the
10
+ * API at request time so it's always current without a rebuild. */
11
+ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
12
+ const [pages, items] = await Promise.all([
13
+ serverGet<PublicPage[]>("/api/pages/public").catch(() => [] as PublicPage[]),
14
+ serverGet<SitemapItem[]>("/api/collections/public-sitemap").catch(() => [] as SitemapItem[]),
15
+ ]);
16
+
17
+ return [
18
+ ...pages.map((p) => ({
19
+ url: `${SITE_URL}/${p.slug}`,
20
+ lastModified: p.updatedAt ? new Date(p.updatedAt) : undefined,
21
+ })),
22
+ ...items.map((i) => ({
23
+ url: `${SITE_URL}/${i.path}`,
24
+ lastModified: new Date(i.updatedAt),
25
+ })),
26
+ ];
27
+ }
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname, useRouter } from "next/navigation";
5
+ import clsx from "clsx";
6
+ import {
7
+ LayoutDashboard,
8
+ Image as ImageIcon,
9
+ Palette,
10
+ Menu as MenuIcon,
11
+ Users,
12
+ LogOut,
13
+ Inbox,
14
+ PanelTop,
15
+ Newspaper,
16
+ CornerUpRight,
17
+ ScrollText,
18
+ DatabaseBackup,
19
+ type LucideIcon,
20
+ } from "lucide-react";
21
+ import { api } from "@/lib/api";
22
+ import { useCurrentUser } from "@/lib/useCurrentUser";
23
+ import type { UserRole } from "@pgcms/shared";
24
+
25
+ type NavItem = {
26
+ href: string;
27
+ label: string;
28
+ icon: LucideIcon;
29
+ roles?: UserRole[];
30
+ };
31
+
32
+ const NAV: NavItem[] = [
33
+ { href: "/admin", label: "Pages", icon: LayoutDashboard },
34
+ { href: "/admin/collections", label: "Collections", icon: Newspaper },
35
+ { href: "/admin/media", label: "Media Library", icon: ImageIcon },
36
+ { href: "/admin/forms", label: "Form Submissions", icon: Inbox },
37
+ { href: "/admin/settings/global-sections", label: "Global Sections", icon: PanelTop },
38
+ { href: "/admin/settings/theme", label: "Theme", icon: Palette, roles: ["ADMIN"] },
39
+ { href: "/admin/settings/navigation", label: "Navigation", icon: MenuIcon, roles: ["ADMIN"] },
40
+ { href: "/admin/settings/redirects", label: "Redirects", icon: CornerUpRight, roles: ["ADMIN"] },
41
+ { href: "/admin/settings/backup", label: "Backup", icon: DatabaseBackup, roles: ["ADMIN"] },
42
+ { href: "/admin/users", label: "Users", icon: Users, roles: ["ADMIN"] },
43
+ { href: "/admin/audit", label: "Audit Log", icon: ScrollText, roles: ["ADMIN"] },
44
+ ];
45
+
46
+ export function AdminShell({ children }: { children: React.ReactNode }) {
47
+ const pathname = usePathname();
48
+ const router = useRouter();
49
+ const { user, loading } = useCurrentUser();
50
+
51
+ async function logout() {
52
+ await api.post("/api/auth/logout");
53
+ router.replace("/admin/login");
54
+ }
55
+
56
+ if (loading) {
57
+ return <div className="min-h-screen flex items-center justify-center text-slate-400 text-sm">Loading...</div>;
58
+ }
59
+ if (!user) return null;
60
+
61
+ const visibleNav = NAV.filter((item) => !item.roles || item.roles.includes(user.role));
62
+
63
+ return (
64
+ <div className="min-h-screen flex bg-slate-50">
65
+ <aside className="w-60 shrink-0 bg-white border-r flex flex-col">
66
+ <div className="px-5 py-5 border-b">
67
+ <span className="font-semibold text-lg">pg-cms</span>
68
+ </div>
69
+ <nav className="flex-1 px-3 py-4 space-y-1">
70
+ {visibleNav.map((item) => {
71
+ const active = pathname === item.href || (item.href !== "/admin" && pathname?.startsWith(item.href));
72
+ const Icon = item.icon;
73
+ return (
74
+ <Link
75
+ key={item.href}
76
+ href={item.href}
77
+ className={clsx(
78
+ "flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium",
79
+ active ? "bg-blue-50 text-blue-700" : "text-slate-600 hover:bg-slate-100",
80
+ )}
81
+ >
82
+ <Icon size={18} />
83
+ {item.label}
84
+ </Link>
85
+ );
86
+ })}
87
+ </nav>
88
+ <div className="px-3 py-4 border-t space-y-2">
89
+ <div className="px-3 text-xs text-slate-500 truncate">
90
+ {user.email} · {user.role}
91
+ </div>
92
+ <button
93
+ onClick={logout}
94
+ className="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium text-slate-600 hover:bg-slate-100"
95
+ >
96
+ <LogOut size={18} />
97
+ Log out
98
+ </button>
99
+ </div>
100
+ </aside>
101
+ <main className="flex-1 min-w-0">{children}</main>
102
+ </div>
103
+ );
104
+ }
@@ -0,0 +1,546 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { useSortable } from "@dnd-kit/sortable";
6
+ import { CSS } from "@dnd-kit/utilities";
7
+ import clsx from "clsx";
8
+ import {
9
+ GripVertical,
10
+ Copy,
11
+ Trash2,
12
+ Eye,
13
+ EyeOff,
14
+ CornerLeftUp,
15
+ Bookmark,
16
+ } from "lucide-react";
17
+ import {
18
+ getComponentDefinition,
19
+ resolveNodeStyle,
20
+ type NodeStyle,
21
+ type PageNode,
22
+ } from "@pgcms/shared";
23
+ import { SECTION_COMPONENTS } from "@/components/renderer/registry";
24
+ import {
25
+ NodeBackgroundLayers,
26
+ hasBackgroundLayers,
27
+ } from "@/components/renderer/NodeBackgroundLayers";
28
+ import { nodeStyleToCss, nodeCustomAttributes } from "@/lib/style";
29
+ import { api } from "@/lib/api";
30
+ import { DroppableChildren } from "./DroppableChildren";
31
+ import { useCanvasOverlay } from "./CanvasOverlayContext";
32
+ import { useBuilderDevice } from "./BuilderDeviceContext";
33
+ import { BLOCKS_CHANGED_EVENT } from "./Palette";
34
+
35
+ type ResizeAxis = "width" | "height" | "both";
36
+
37
+ export function BuilderCanvasNode({
38
+ node,
39
+ parentId,
40
+ depth = 0,
41
+ selectedId,
42
+ onSelect,
43
+ onDelete,
44
+ onDuplicate,
45
+ onToggleHidden,
46
+ onChangeStyle,
47
+ onResizeGridTrack,
48
+ cellIndex,
49
+ gridColumns,
50
+ }: {
51
+ node: PageNode;
52
+ /** The id of this node's own parent — lets the toolbar offer "select parent" once a
53
+ * Container/Column is filled edge-to-edge with content and has no background left to
54
+ * click on directly. Undefined for top-level nodes (their parent is the page root,
55
+ * which isn't a real editable node). */
56
+ parentId?: string;
57
+ /** Nesting depth, used to stagger toolbar chip position — a Container whose sole
58
+ * Column holds a sole Hero has all three boxes sharing the exact same top-left
59
+ * corner, so their chips would otherwise render at the identical screen position
60
+ * and only the topmost one could ever be clicked. */
61
+ depth?: number;
62
+ selectedId: string | null;
63
+ onSelect: (id: string) => void;
64
+ onDelete: (id: string) => void;
65
+ onDuplicate: (id: string) => void;
66
+ onToggleHidden: (id: string) => void;
67
+ onChangeStyle: (id: string, style: NodeStyle) => void;
68
+ /** Resizes one column/row track on the parent Grid (only meaningful when this node
69
+ * is a "gridCell" — a grid track is shared by every cell in that column/row, so the
70
+ * size has to live on the Grid itself, not this cell's own style). */
71
+ onResizeGridTrack?: (gridId: string, axis: "column" | "row", index: number, size: string) => void;
72
+ /** This node's position among its parent Grid's children — only set when the parent
73
+ * is a "grid", used with gridColumns to derive which column/row track a drag-resize
74
+ * on this cell should adjust. */
75
+ cellIndex?: number;
76
+ gridColumns?: number;
77
+ }) {
78
+ const {
79
+ attributes,
80
+ listeners,
81
+ setNodeRef,
82
+ transform,
83
+ transition,
84
+ isDragging,
85
+ } = useSortable({ id: node.id });
86
+ const Component = SECTION_COMPONENTS[node.type];
87
+ const def = getComponentDefinition(node.type);
88
+ const selected = selectedId === node.id;
89
+ const hidden = node.props.hidden === true;
90
+ const device = useBuilderDevice();
91
+ // Preview and resize against the device-effective style — the style-write handler
92
+ // in the builder page diffs against the same view, so edits land in the right layer.
93
+ // Memoized for referential stability: it feeds the box-measure effect's deps, and a
94
+ // fresh object every render would make that effect setState itself into a loop.
95
+ const effectiveStyle = useMemo(
96
+ () => resolveNodeStyle(node, device),
97
+ // eslint-disable-next-line react-hooks/exhaustive-deps
98
+ [node.style, node.styleTablet, node.styleMobile, device],
99
+ );
100
+ // Per-instance hover state, not Tailwind's `group-hover` — a shared group name at
101
+ // every nesting depth (Header > Zone > Logo) makes hovering one leaf mark its whole
102
+ // ancestor chain as ":hover", which lights up every *sibling's* toolbar too (e.g.
103
+ // hovering the Logo also revealed Nav Links' and Buttons' toolbars). Local state is
104
+ // scoped to exactly the boxes the pointer is actually inside.
105
+ const [isHovered, setIsHovered] = useState(false);
106
+ const [isResizing, setIsResizing] = useState(false);
107
+
108
+ const boxRef = useRef<HTMLDivElement | null>(null);
109
+ const setRefs = useCallback(
110
+ (el: HTMLDivElement | null) => {
111
+ setNodeRef(el);
112
+ boxRef.current = el;
113
+ },
114
+ [setNodeRef],
115
+ );
116
+ const sectionRef = useRef<HTMLElement | null>(null);
117
+ const overlayEl = useCanvasOverlay();
118
+ const [box, setBox] = useState<{
119
+ top: number;
120
+ left: number;
121
+ right: number;
122
+ width: number;
123
+ height: number;
124
+ } | null>(null);
125
+
126
+ // Measure the node's own <section>, not the sortable wrapper: a width-constrained
127
+ // section centers inside a still-full-width wrapper, and handles anchored to the
128
+ // wrapper would float in empty space beside the visible box.
129
+ const ownSection = useCallback((): HTMLElement | null => {
130
+ return sectionRef.current ?? boxRef.current;
131
+ }, [sectionRef]);
132
+
133
+ // The toolbar/handle chips are portaled into a flat overlay layer instead of nesting
134
+ // `position: absolute` inside this deeply-nested tree (see CanvasOverlayContext for
135
+ // why) — so their screen position has to be measured explicitly rather than
136
+ // expressed with CSS relative to a nearby ancestor.
137
+ useEffect(() => {
138
+ if (!overlayEl || !boxRef.current) {
139
+ setBox(null);
140
+ return;
141
+ }
142
+ const update = () => {
143
+ const target = ownSection();
144
+ if (!target) return;
145
+ const boxRect = target.getBoundingClientRect();
146
+ const overlayRect = overlayEl.getBoundingClientRect();
147
+ setBox({
148
+ top: boxRect.top - overlayRect.top,
149
+ left: boxRect.left - overlayRect.left,
150
+ right: overlayRect.right - boxRect.right,
151
+ width: boxRect.width,
152
+ height: boxRect.height,
153
+ });
154
+ };
155
+ update();
156
+ window.addEventListener("scroll", update, true);
157
+ window.addEventListener("resize", update);
158
+ return () => {
159
+ window.removeEventListener("scroll", update, true);
160
+ window.removeEventListener("resize", update);
161
+ };
162
+ }, [
163
+ overlayEl,
164
+ isHovered,
165
+ isResizing,
166
+ effectiveStyle,
167
+ node.props,
168
+ ownSection,
169
+ ]);
170
+
171
+ const startResize = useCallback(
172
+ (axis: ResizeAxis) => (e: React.PointerEvent) => {
173
+ e.preventDefault();
174
+ e.stopPropagation();
175
+ const target = ownSection();
176
+ if (!target) return;
177
+ const startRect = target.getBoundingClientRect();
178
+ const parentWidth = boxRef.current?.getBoundingClientRect().width ?? null;
179
+ const startX = e.clientX;
180
+ const startY = e.clientY;
181
+ setIsResizing(true);
182
+
183
+ // Preserve the unit an editor already typed (%, vw, rem...) instead of
184
+ // clobbering it with px — dragging then adjusts the value in that unit.
185
+ function toUnit(
186
+ px: number,
187
+ existing: string | undefined,
188
+ relativeTo: number | null,
189
+ ): string {
190
+ const match = existing?.match(/^[\d.]+(%|vw|vh|rem|em)$/);
191
+ if (match) {
192
+ const unit = match[1];
193
+ if (unit === "%" && relativeTo)
194
+ return `${Math.round((px / relativeTo) * 1000) / 10}%`;
195
+ if (unit === "vw")
196
+ return `${Math.round((px / window.innerWidth) * 1000) / 10}vw`;
197
+ if (unit === "vh")
198
+ return `${Math.round((px / window.innerHeight) * 1000) / 10}vh`;
199
+ if (unit === "rem" || unit === "em")
200
+ return `${Math.round((px / 16) * 100) / 100}${unit}`;
201
+ }
202
+ return `${Math.round(px)}px`;
203
+ }
204
+
205
+ function onMove(moveEvent: PointerEvent) {
206
+ const dx = moveEvent.clientX - startX;
207
+ const dy = moveEvent.clientY - startY;
208
+
209
+ // A Grid Cell's width/height is entirely governed by its parent Grid's column/
210
+ // row track sizes (every cell in that column/row shares one track) — writing
211
+ // width/maxWidth on the cell itself, like other nodes do, would have zero
212
+ // visual effect since a CSS grid item ignores its own explicit size in favor of
213
+ // the track. Resize the specific track on the Grid instead.
214
+ if (
215
+ node.type === "gridCell" &&
216
+ parentId &&
217
+ onResizeGridTrack &&
218
+ gridColumns &&
219
+ cellIndex !== undefined
220
+ ) {
221
+ const colIndex = cellIndex % gridColumns;
222
+ const rowIndex = Math.floor(cellIndex / gridColumns);
223
+ if (axis === "width" || axis === "both") {
224
+ const widthPx = Math.max(80, startRect.width + dx);
225
+ onResizeGridTrack(parentId, "column", colIndex, `${Math.round(widthPx)}px`);
226
+ }
227
+ if (axis === "height" || axis === "both") {
228
+ const heightPx = Math.max(40, startRect.height + dy);
229
+ onResizeGridTrack(parentId, "row", rowIndex, `${Math.round(heightPx)}px`);
230
+ }
231
+ return;
232
+ }
233
+
234
+ const next: NodeStyle = { ...effectiveStyle };
235
+ if (axis === "width" || axis === "both") {
236
+ const widthPx = Math.max(80, startRect.width + dx);
237
+ // A Column's width is a CSS Grid track size owned by its parent Container
238
+ // (see Container.tsx) — max-width has no effect on a grid track, only on
239
+ // content within it — so drag it via a distinct property. For other nodes,
240
+ // adjust an explicit `width` if one is set, else the usual maxWidth.
241
+ if (node.type === "column")
242
+ next.columnWidth = toUnit(
243
+ widthPx,
244
+ effectiveStyle.columnWidth,
245
+ parentWidth,
246
+ );
247
+ else if (effectiveStyle.width)
248
+ next.width = toUnit(widthPx, effectiveStyle.width, parentWidth);
249
+ else
250
+ next.maxWidth = toUnit(
251
+ widthPx,
252
+ effectiveStyle.maxWidth,
253
+ parentWidth,
254
+ );
255
+ }
256
+ if (axis === "height" || axis === "both") {
257
+ const heightPx = Math.max(40, startRect.height + dy);
258
+ if (effectiveStyle.height)
259
+ next.height = toUnit(heightPx, effectiveStyle.height, null);
260
+ else
261
+ next.minHeight = toUnit(heightPx, effectiveStyle.minHeight, null);
262
+ }
263
+ onChangeStyle(node.id, next);
264
+ }
265
+ function onUp() {
266
+ setIsResizing(false);
267
+ window.removeEventListener("pointermove", onMove);
268
+ window.removeEventListener("pointerup", onUp);
269
+ // The pointer has usually moved by the time it's released, so the browser's
270
+ // synthetic "click" lands wherever the cursor ended up — not on this handle —
271
+ // meaning stopPropagation() on the handle's own onClick never runs for it.
272
+ // Swallow that one click at the capture phase so it can't reach the canvas
273
+ // background's deselect-on-click handler.
274
+ window.addEventListener("click", suppressClick, {
275
+ capture: true,
276
+ once: true,
277
+ });
278
+ }
279
+ function suppressClick(clickEvent: MouseEvent) {
280
+ clickEvent.stopPropagation();
281
+ }
282
+ window.addEventListener("pointermove", onMove);
283
+ window.addEventListener("pointerup", onUp);
284
+ },
285
+ [
286
+ node.id,
287
+ effectiveStyle,
288
+ node.type,
289
+ onChangeStyle,
290
+ ownSection,
291
+ parentId,
292
+ onResizeGridTrack,
293
+ gridColumns,
294
+ cellIndex,
295
+ ],
296
+ );
297
+
298
+ const { attrs: customAttrs, extraClassName } =
299
+ nodeCustomAttributes(effectiveStyle);
300
+
301
+ const wrapperStyle: React.CSSProperties = {
302
+ transform: CSS.Transform.toString(transform),
303
+ transition,
304
+ ...(isDragging ? { opacity: 0.4 } : {}),
305
+ };
306
+
307
+ // A Grid Cell's own box needs to actually fill its (possibly custom-sized, see
308
+ // setGridTrackSize) row track, or a taller row just leaves dead space below the
309
+ // cell's natural content height instead of visibly growing — the cell IS the grid
310
+ // item here, two DOM layers up through plain (non-stretching) wrapper divs. Scoped to
311
+ // gridCell specifically: forcing height:100% on every node type would also stretch
312
+ // ordinary content (a Hero dropped into an already-tall Column, say) to fill space it
313
+ // was never meant to claim.
314
+ const isGridCell = node.type === "gridCell";
315
+
316
+ return (
317
+ <div ref={setRefs} style={wrapperStyle} className={clsx("relative", isGridCell && "h-full")}>
318
+ <div
319
+ className={clsx(
320
+ "relative",
321
+ isGridCell && "h-full",
322
+ selected
323
+ ? "ring-2 ring-blue-500 z-10"
324
+ : isHovered
325
+ ? "ring-1 ring-slate-300"
326
+ : "ring-1 ring-transparent",
327
+ hidden && "opacity-40",
328
+ )}
329
+ onMouseEnter={() => setIsHovered(true)}
330
+ onMouseLeave={() => setIsHovered(false)}
331
+ onClick={(e) => {
332
+ e.stopPropagation();
333
+ onSelect(node.id);
334
+ }}
335
+ >
336
+ <section
337
+ {...customAttrs}
338
+ ref={sectionRef}
339
+ style={isGridCell ? { ...nodeStyleToCss(effectiveStyle), height: effectiveStyle.height || "100%" } : nodeStyleToCss(effectiveStyle)}
340
+ className={extraClassName}
341
+ >
342
+ <NodeBackgroundLayers style={effectiveStyle} />
343
+ {Component ? (
344
+ // Absolute background layers paint above unpositioned in-flow content, so
345
+ // when they exist the content needs its own positioned wrapper to win.
346
+ <div
347
+ className={clsx(
348
+ hasBackgroundLayers(effectiveStyle) && "relative",
349
+ isGridCell && "h-full",
350
+ )}
351
+ >
352
+ <Component node={node}>
353
+ {def?.allowsChildren ? (
354
+ <DroppableChildren
355
+ parentId={node.id}
356
+ childIds={node.children.map((c) => c.id)}
357
+ transparent
358
+ >
359
+ {node.children.map((child, childIndex) => (
360
+ <BuilderCanvasNode
361
+ key={child.id}
362
+ node={child}
363
+ parentId={node.id}
364
+ depth={depth + 1}
365
+ selectedId={selectedId}
366
+ onSelect={onSelect}
367
+ onDelete={onDelete}
368
+ onDuplicate={onDuplicate}
369
+ onToggleHidden={onToggleHidden}
370
+ onChangeStyle={onChangeStyle}
371
+ onResizeGridTrack={onResizeGridTrack}
372
+ cellIndex={node.type === "grid" ? childIndex : undefined}
373
+ gridColumns={node.type === "grid" ? Math.max(1, Number(node.props.columns) || 2) : undefined}
374
+ />
375
+ ))}
376
+ </DroppableChildren>
377
+ ) : undefined}
378
+ </Component>
379
+ </div>
380
+ ) : (
381
+ <div className="p-4 text-sm text-red-600 bg-red-50">
382
+ Unknown component type: {node.type}
383
+ </div>
384
+ )}
385
+ </section>
386
+ </div>
387
+
388
+ {overlayEl &&
389
+ box &&
390
+ createPortal(
391
+ <>
392
+ {/* Locked structural slots (e.g. a header's zones) get no toolbar of their
393
+ own — "hide" is a property of the content you put in a zone, not the
394
+ zone slot, and a zone's tight-fitting box would otherwise sit right on
395
+ top of its single child's toolbar, each stealing the other's clicks. */}
396
+ {!def?.locked && isHovered && (
397
+ <div
398
+ className="absolute z-30 flex items-center gap-1.5 bg-slate-900 text-white text-xs rounded px-2 py-1 select-none pointer-events-auto"
399
+ // Nested ancestors sharing the same top-left corner (e.g. a Container
400
+ // whose only Column holds only a Hero) get staggered chips instead of
401
+ // perfectly overlapping ones that only the topmost could ever click.
402
+ style={{ top: box.top - 12, left: box.left + 8 + depth * 88 }}
403
+ // The chip is portaled outside the hoverable content's own DOM subtree,
404
+ // so moving the mouse onto it would otherwise register as leaving the
405
+ // content (mouseleave fires first) and unmount the chip before a click
406
+ // can land. Keep it open while the pointer is over the chip itself too.
407
+ onMouseEnter={() => setIsHovered(true)}
408
+ onMouseLeave={() => setIsHovered(false)}
409
+ >
410
+ <button
411
+ {...attributes}
412
+ {...listeners}
413
+ className="cursor-grab active:cursor-grabbing"
414
+ title="Drag to reorder"
415
+ >
416
+ <GripVertical size={12} />
417
+ </button>
418
+ <span>{def?.label ?? node.type}</span>
419
+ {parentId && (
420
+ <button
421
+ onClick={(e) => {
422
+ e.stopPropagation();
423
+ onSelect(parentId);
424
+ }}
425
+ title="Select parent"
426
+ >
427
+ <CornerLeftUp size={12} />
428
+ </button>
429
+ )}
430
+ <button
431
+ onClick={(e) => {
432
+ e.stopPropagation();
433
+ onToggleHidden(node.id);
434
+ }}
435
+ title={hidden ? "Show on live site" : "Hide from live site"}
436
+ >
437
+ {hidden ? <EyeOff size={12} /> : <Eye size={12} />}
438
+ </button>
439
+ <button
440
+ onClick={(e) => {
441
+ e.stopPropagation();
442
+ onDuplicate(node.id);
443
+ }}
444
+ title="Duplicate"
445
+ >
446
+ <Copy size={12} />
447
+ </button>
448
+ <button
449
+ onClick={async (e) => {
450
+ e.stopPropagation();
451
+ const name = window.prompt(
452
+ "Save this section as a reusable block — name it:",
453
+ );
454
+ if (!name) return;
455
+ await api.post("/api/blocks", { name, content: node });
456
+ window.dispatchEvent(new Event(BLOCKS_CHANGED_EVENT));
457
+ }}
458
+ title="Save as reusable block"
459
+ >
460
+ <Bookmark size={12} />
461
+ </button>
462
+ <button
463
+ onClick={(e) => {
464
+ e.stopPropagation();
465
+ onDelete(node.id);
466
+ }}
467
+ title="Delete"
468
+ >
469
+ <Trash2 size={12} />
470
+ </button>
471
+ </div>
472
+ )}
473
+ {hidden && (
474
+ <div
475
+ className="absolute z-30 bg-slate-900/80 text-white text-[10px] rounded px-1.5 py-0.5 flex items-center gap-1 pointer-events-none"
476
+ style={{ top: box.top + 4, right: box.right + 4 }}
477
+ >
478
+ <EyeOff size={10} /> Hidden
479
+ </div>
480
+ )}
481
+ {/* Drag-to-resize handles — only for the selected node, not locked structural
482
+ slots. Right edge drags width (style.maxWidth), bottom edge drags height
483
+ (style.minHeight), the corner drags both. */}
484
+ {/* Live dimensions readout while dragging a resize handle. */}
485
+ {isResizing && (
486
+ <div
487
+ className="absolute z-40 bg-slate-900 text-white text-[11px] font-mono rounded px-1.5 py-0.5 pointer-events-none"
488
+ style={{
489
+ top: box.top + box.height + 6,
490
+ left: box.left + box.width / 2 - 40,
491
+ }}
492
+ >
493
+ {Math.round(box.width)} × {Math.round(box.height)} px
494
+ </div>
495
+ )}
496
+ {!def?.locked && selected && (
497
+ <>
498
+ <div
499
+ onPointerDown={startResize("width")}
500
+ onClick={(e) => e.stopPropagation()}
501
+ className="absolute z-30 pointer-events-auto cursor-ew-resize flex items-center justify-end pr-0.5"
502
+ // Kept fully inside the box's right edge rather than straddling it —
503
+ // centering exactly on the boundary pixel makes the hit-test a coin
504
+ // flip against whatever sits just outside (e.g. the Inspector panel).
505
+ style={{
506
+ top: box.top + box.height / 2 - 10,
507
+ left: box.left + box.width - 10,
508
+ width: 10,
509
+ height: 20,
510
+ }}
511
+ title="Drag to resize width"
512
+ >
513
+ <div className="w-1.5 h-5 rounded-full bg-blue-500 border border-white shadow" />
514
+ </div>
515
+ <div
516
+ onPointerDown={startResize("height")}
517
+ onClick={(e) => e.stopPropagation()}
518
+ className="absolute z-30 pointer-events-auto cursor-ns-resize flex items-end justify-center pb-0.5"
519
+ style={{
520
+ top: box.top + box.height - 10,
521
+ left: box.left + box.width / 2 - 10,
522
+ width: 20,
523
+ height: 10,
524
+ }}
525
+ title="Drag to resize height"
526
+ >
527
+ <div className="h-1.5 w-5 rounded-full bg-blue-500 border border-white shadow" />
528
+ </div>
529
+ <div
530
+ onPointerDown={startResize("both")}
531
+ onClick={(e) => e.stopPropagation()}
532
+ className="absolute z-30 pointer-events-auto cursor-nwse-resize w-4 h-4 rounded-sm bg-blue-500 border-2 border-white shadow"
533
+ style={{
534
+ top: box.top + box.height - 8,
535
+ left: box.left + box.width - 8,
536
+ }}
537
+ title="Drag to resize"
538
+ />
539
+ </>
540
+ )}
541
+ </>,
542
+ overlayEl,
543
+ )}
544
+ </div>
545
+ );
546
+ }
@@ -0,0 +1,13 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+ import type { Breakpoint } from "@pgcms/shared";
5
+
6
+ /** Which breakpoint the builder is currently previewing/editing. Every canvas node
7
+ * renders its device-effective style, and every style edit is written into that
8
+ * device's override layer (see handleChangeStyle in the builder page). */
9
+ export const BuilderDeviceContext = createContext<Breakpoint>("desktop");
10
+
11
+ export function useBuilderDevice() {
12
+ return useContext(BuilderDeviceContext);
13
+ }