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,106 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useDraggable } from "@dnd-kit/core";
5
+ import { Trash2 } from "lucide-react";
6
+ import { COMPONENT_CATEGORIES, COMPONENT_REGISTRY, type PageNode } from "@pgcms/shared";
7
+ import { api } from "@/lib/api";
8
+ import { IconRenderer } from "./IconRenderer";
9
+
10
+ export type SavedBlock = { id: string; name: string; content: PageNode };
11
+
12
+ /** Fired (on window) whenever a block is saved or deleted, so every mounted Palette
13
+ * refreshes its Saved Blocks list without prop-drilling through the builder. */
14
+ export const BLOCKS_CHANGED_EVENT = "pgcms:blocks-changed";
15
+
16
+ function PaletteItem({ type, label, icon }: { type: string; label: string; icon: string }) {
17
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
18
+ id: `palette:${type}`,
19
+ data: { type: "palette", componentType: type },
20
+ });
21
+
22
+ return (
23
+ <button
24
+ ref={setNodeRef}
25
+ {...listeners}
26
+ {...attributes}
27
+ className="w-full flex items-center gap-2 px-3 py-2 rounded-md border bg-white text-sm text-left hover:border-blue-400 hover:bg-blue-50 cursor-grab active:cursor-grabbing"
28
+ style={{ opacity: isDragging ? 0.4 : 1 }}
29
+ >
30
+ <IconRenderer name={icon} size={16} className="text-slate-500" />
31
+ {label}
32
+ </button>
33
+ );
34
+ }
35
+
36
+ function BlockItem({ block, onDelete }: { block: SavedBlock; onDelete: (id: string) => void }) {
37
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
38
+ id: `block:${block.id}`,
39
+ data: { type: "block", blockId: block.id },
40
+ });
41
+
42
+ return (
43
+ <div
44
+ ref={setNodeRef}
45
+ className="w-full flex items-center gap-2 px-3 py-2 rounded-md border bg-white text-sm hover:border-blue-400 hover:bg-blue-50"
46
+ style={{ opacity: isDragging ? 0.4 : 1 }}
47
+ >
48
+ <button {...listeners} {...attributes} className="flex items-center gap-2 flex-1 text-left cursor-grab active:cursor-grabbing min-w-0">
49
+ <IconRenderer name="Bookmark" size={16} className="text-amber-500 shrink-0" />
50
+ <span className="truncate">{block.name}</span>
51
+ </button>
52
+ <button
53
+ onClick={() => onDelete(block.id)}
54
+ title="Delete saved block"
55
+ className="text-slate-300 hover:text-red-600 shrink-0"
56
+ >
57
+ <Trash2 size={14} />
58
+ </button>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ export function Palette() {
64
+ const [blocks, setBlocks] = useState<SavedBlock[]>([]);
65
+
66
+ useEffect(() => {
67
+ const load = () => api.get<SavedBlock[]>("/api/blocks").then(setBlocks).catch(() => setBlocks([]));
68
+ load();
69
+ window.addEventListener(BLOCKS_CHANGED_EVENT, load);
70
+ return () => window.removeEventListener(BLOCKS_CHANGED_EVENT, load);
71
+ }, []);
72
+
73
+ async function deleteBlock(id: string) {
74
+ await api.delete(`/api/blocks/${id}`);
75
+ window.dispatchEvent(new Event(BLOCKS_CHANGED_EVENT));
76
+ }
77
+
78
+ return (
79
+ <div className="w-64 shrink-0 border-r bg-slate-50 overflow-y-auto p-3 space-y-5">
80
+ {blocks.length > 0 && (
81
+ <div>
82
+ <div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">Saved Blocks</div>
83
+ <div className="space-y-2">
84
+ {blocks.map((block) => (
85
+ <BlockItem key={block.id} block={block} onDelete={deleteBlock} />
86
+ ))}
87
+ </div>
88
+ </div>
89
+ )}
90
+ {COMPONENT_CATEGORIES.map((category) => {
91
+ const items = COMPONENT_REGISTRY.filter((c) => c.category === category && !c.hidden);
92
+ if (items.length === 0) return null;
93
+ return (
94
+ <div key={category}>
95
+ <div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">{category}</div>
96
+ <div className="space-y-2">
97
+ {items.map((item) => (
98
+ <PaletteItem key={item.type} type={item.type} label={item.label} icon={item.icon} />
99
+ ))}
100
+ </div>
101
+ </div>
102
+ );
103
+ })}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,140 @@
1
+ "use client";
2
+
3
+ import { useEditor, EditorContent } from "@tiptap/react";
4
+ import StarterKit from "@tiptap/starter-kit";
5
+ import Link from "@tiptap/extension-link";
6
+ import Underline from "@tiptap/extension-underline";
7
+ import TextAlign from "@tiptap/extension-text-align";
8
+ import TextStyle from "@tiptap/extension-text-style";
9
+ import Color from "@tiptap/extension-color";
10
+ import {
11
+ Bold,
12
+ Italic,
13
+ UnderlineIcon,
14
+ Strikethrough,
15
+ Link as LinkIcon,
16
+ List,
17
+ ListOrdered,
18
+ Heading1,
19
+ Heading2,
20
+ Heading3,
21
+ Quote,
22
+ AlignLeft,
23
+ AlignCenter,
24
+ AlignRight,
25
+ Undo2,
26
+ Redo2,
27
+ Eraser,
28
+ } from "lucide-react";
29
+ import clsx from "clsx";
30
+
31
+ const TEXT_COLORS = ["#0f172a", "#dc2626", "#d97706", "#16a34a", "#2563eb", "#7c3aed", "#ffffff"];
32
+
33
+ export function RichTextEditor({ value, onChange }: { value: string; onChange: (html: string) => void }) {
34
+ const editor = useEditor({
35
+ extensions: [
36
+ StarterKit,
37
+ Link.configure({ openOnClick: false }),
38
+ Underline,
39
+ TextStyle,
40
+ Color,
41
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
42
+ ],
43
+ content: value,
44
+ immediatelyRender: false,
45
+ onUpdate: ({ editor }) => onChange(editor.getHTML()),
46
+ });
47
+
48
+ if (!editor) return null;
49
+
50
+ const btn = (active: boolean) => clsx("p-1.5 rounded hover:bg-slate-100 shrink-0", active && "bg-slate-200");
51
+
52
+ return (
53
+ <div className="border rounded-md">
54
+ <div className="flex items-center gap-0.5 border-b px-2 py-1 flex-wrap">
55
+ <button type="button" className={btn(false)} onClick={() => editor.chain().focus().undo().run()} title="Undo">
56
+ <Undo2 size={14} />
57
+ </button>
58
+ <button type="button" className={btn(false)} onClick={() => editor.chain().focus().redo().run()} title="Redo">
59
+ <Redo2 size={14} />
60
+ </button>
61
+ <span className="w-px h-4 bg-slate-200 mx-1" />
62
+ <button type="button" className={btn(editor.isActive("bold"))} onClick={() => editor.chain().focus().toggleBold().run()} title="Bold">
63
+ <Bold size={14} />
64
+ </button>
65
+ <button type="button" className={btn(editor.isActive("italic"))} onClick={() => editor.chain().focus().toggleItalic().run()} title="Italic">
66
+ <Italic size={14} />
67
+ </button>
68
+ <button type="button" className={btn(editor.isActive("underline"))} onClick={() => editor.chain().focus().toggleUnderline().run()} title="Underline">
69
+ <UnderlineIcon size={14} />
70
+ </button>
71
+ <button type="button" className={btn(editor.isActive("strike"))} onClick={() => editor.chain().focus().toggleStrike().run()} title="Strikethrough">
72
+ <Strikethrough size={14} />
73
+ </button>
74
+ <span className="w-px h-4 bg-slate-200 mx-1" />
75
+ <button type="button" className={btn(editor.isActive("heading", { level: 1 }))} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} title="Heading 1">
76
+ <Heading1 size={14} />
77
+ </button>
78
+ <button type="button" className={btn(editor.isActive("heading", { level: 2 }))} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} title="Heading 2">
79
+ <Heading2 size={14} />
80
+ </button>
81
+ <button type="button" className={btn(editor.isActive("heading", { level: 3 }))} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} title="Heading 3">
82
+ <Heading3 size={14} />
83
+ </button>
84
+ <span className="w-px h-4 bg-slate-200 mx-1" />
85
+ <button type="button" className={btn(editor.isActive("bulletList"))} onClick={() => editor.chain().focus().toggleBulletList().run()} title="Bullet list">
86
+ <List size={14} />
87
+ </button>
88
+ <button type="button" className={btn(editor.isActive("orderedList"))} onClick={() => editor.chain().focus().toggleOrderedList().run()} title="Numbered list">
89
+ <ListOrdered size={14} />
90
+ </button>
91
+ <button type="button" className={btn(editor.isActive("blockquote"))} onClick={() => editor.chain().focus().toggleBlockquote().run()} title="Quote">
92
+ <Quote size={14} />
93
+ </button>
94
+ <span className="w-px h-4 bg-slate-200 mx-1" />
95
+ <button type="button" className={btn(editor.isActive({ textAlign: "left" }))} onClick={() => editor.chain().focus().setTextAlign("left").run()} title="Align left">
96
+ <AlignLeft size={14} />
97
+ </button>
98
+ <button type="button" className={btn(editor.isActive({ textAlign: "center" }))} onClick={() => editor.chain().focus().setTextAlign("center").run()} title="Align center">
99
+ <AlignCenter size={14} />
100
+ </button>
101
+ <button type="button" className={btn(editor.isActive({ textAlign: "right" }))} onClick={() => editor.chain().focus().setTextAlign("right").run()} title="Align right">
102
+ <AlignRight size={14} />
103
+ </button>
104
+ <span className="w-px h-4 bg-slate-200 mx-1" />
105
+ <button
106
+ type="button"
107
+ className={btn(editor.isActive("link"))}
108
+ title="Link"
109
+ onClick={() => {
110
+ const url = window.prompt("Link URL");
111
+ if (url) editor.chain().focus().setLink({ href: url }).run();
112
+ }}
113
+ >
114
+ <LinkIcon size={14} />
115
+ </button>
116
+ <div className="flex items-center gap-0.5">
117
+ {TEXT_COLORS.map((c) => (
118
+ <button
119
+ key={c}
120
+ type="button"
121
+ title={`Text color ${c}`}
122
+ onClick={() => editor.chain().focus().setColor(c).run()}
123
+ className="w-4 h-4 rounded-full border shrink-0"
124
+ style={{ backgroundColor: c }}
125
+ />
126
+ ))}
127
+ </div>
128
+ <button
129
+ type="button"
130
+ className={btn(false)}
131
+ title="Clear formatting"
132
+ onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
133
+ >
134
+ <Eraser size={14} />
135
+ </button>
136
+ </div>
137
+ <EditorContent editor={editor} className="prose prose-sm max-w-none p-3 min-h-[120px] [&_.ProseMirror]:outline-none" />
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,46 @@
1
+ import Image from "next/image";
2
+ import type { CSSProperties } from "react";
3
+
4
+ type CmsImageProps = {
5
+ src: string;
6
+ alt?: string;
7
+ className?: string;
8
+ style?: CSSProperties;
9
+ /** Fills a positioned ancestor (that ancestor must set its own height/aspect ratio)
10
+ * — for cover-cropped photos (gallery tiles, card thumbnails, hero backgrounds).
11
+ * Omit for intrinsic-aspect images (logos, inline icons) sized by `width`/`height`. */
12
+ fill?: boolean;
13
+ width?: number;
14
+ height?: number;
15
+ sizes?: string;
16
+ };
17
+
18
+ /** Generalizes ImageBlock's next/image-vs-plain-<img> decision (relative/absolute URLs
19
+ * get Next's optimizer; anything else — a bare filename, a data URI — falls back to a
20
+ * plain tag) so every renderer component gets optimization/lazy-loading consistently
21
+ * instead of each reimplementing the same check. */
22
+ export function CmsImage({ src, alt = "", className, style, fill, width, height, sizes }: CmsImageProps) {
23
+ if (!src) return null;
24
+ const useOptimized = src.startsWith("http://") || src.startsWith("https://") || src.startsWith("/");
25
+
26
+ if (!useOptimized) {
27
+ // eslint-disable-next-line @next/next/no-img-element
28
+ return <img src={src} alt={alt} className={className} style={style} />;
29
+ }
30
+
31
+ if (fill) {
32
+ return <Image src={src} alt={alt} fill className={className} style={style} sizes={sizes ?? "100vw"} />;
33
+ }
34
+
35
+ return (
36
+ <Image
37
+ src={src}
38
+ alt={alt}
39
+ width={width ?? 1200}
40
+ height={height ?? 800}
41
+ className={className}
42
+ style={style}
43
+ sizes={sizes}
44
+ />
45
+ );
46
+ }
@@ -0,0 +1,35 @@
1
+ import type { NodeStyle } from "@pgcms/shared";
2
+
3
+ /** Whether a node's style needs the layered-background treatment (video and/or a
4
+ * tint overlay). A plain background image needs no layer — nodeStyleToCss handles it
5
+ * as CSS background-image — so image-only sections skip the extra wrapper. */
6
+ export function hasBackgroundLayers(style: NodeStyle): boolean {
7
+ return Boolean(style.backgroundVideo || style.backgroundOverlay);
8
+ }
9
+
10
+ /** Absolute-positioned background media layers for a section: looping muted video,
11
+ * then a tint overlay above it. Rendered inside the section element (which is always
12
+ * position: relative via nodeStyleToCss); the section's content must sit in a
13
+ * `position: relative` wrapper to paint above these. Works on ANY node type — this is
14
+ * what lets one section combine an image background (CSS), a video, an overlay, and
15
+ * arbitrary child components at once. */
16
+ export function NodeBackgroundLayers({ style }: { style: NodeStyle }) {
17
+ if (!hasBackgroundLayers(style)) return null;
18
+ return (
19
+ <>
20
+ {style.backgroundVideo && (
21
+ <video
22
+ src={style.backgroundVideo}
23
+ autoPlay
24
+ muted
25
+ loop
26
+ playsInline
27
+ className="absolute inset-0 w-full h-full object-cover"
28
+ />
29
+ )}
30
+ {style.backgroundOverlay && (
31
+ <div className="absolute inset-0" style={{ backgroundColor: style.backgroundOverlay }} />
32
+ )}
33
+ </>
34
+ );
35
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { treeContainsType, type NavItem, type Page, type PageNode, type PageTranslationInfo, type SiteTheme } from "@pgcms/shared";
4
+ import { SiteDataContext } from "@/components/theme/SiteDataContext";
5
+ import { RendererContext } from "./RendererContext";
6
+ import { Renderer } from "./Renderer";
7
+
8
+ /** When a global section actually carries the site's real chrome (a Header component
9
+ * in the global header, a Footer in the global footer), pages that still contain
10
+ * their own copy would show it twice — logo and nav doubled. The global one wins;
11
+ * page-level copies of that type are skipped. A global tree that's only decoration
12
+ * (utility bar, announcement strip) suppresses nothing. */
13
+ function suppressDuplicateChrome(content: PageNode, globalHeader: PageNode | null, globalFooter: PageNode | null): PageNode {
14
+ const dropHeader = !!globalHeader && treeContainsType(globalHeader, "header");
15
+ const dropFooter = !!globalFooter && treeContainsType(globalFooter, "footer");
16
+ if (!dropHeader && !dropFooter) return content;
17
+ return {
18
+ ...content,
19
+ children: content.children.filter(
20
+ (child) => !(dropHeader && child.type === "header") && !(dropFooter && child.type === "footer")
21
+ ),
22
+ };
23
+ }
24
+
25
+ export function PublicPageView({
26
+ page,
27
+ theme,
28
+ navItems,
29
+ globalHeader = null,
30
+ globalFooter = null,
31
+ globalGoToTop = null,
32
+ pageLocale,
33
+ translations,
34
+ }: {
35
+ page: Page;
36
+ theme: SiteTheme;
37
+ navItems: NavItem[];
38
+ /** Site-wide chrome (root trees) rendered above/below every page's own content —
39
+ * edited once in Settings → Global Sections instead of per page. */
40
+ globalHeader?: PageNode | null;
41
+ globalFooter?: PageNode | null;
42
+ globalGoToTop?: PageNode | null;
43
+ pageLocale?: string;
44
+ translations?: PageTranslationInfo[];
45
+ }) {
46
+ return (
47
+ <SiteDataContext.Provider value={{ theme, navItems, pageLocale, translations }}>
48
+ <RendererContext.Provider value={{ mode: "view", selectedId: null }}>
49
+ {globalHeader && <Renderer node={globalHeader} />}
50
+ <Renderer node={suppressDuplicateChrome(page.content, globalHeader, globalFooter)} />
51
+ {globalFooter && <Renderer node={globalFooter} />}
52
+ {globalGoToTop && <Renderer node={globalGoToTop} />}
53
+ </RendererContext.Provider>
54
+ </SiteDataContext.Provider>
55
+ );
56
+ }
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ import { useRef } from "react";
4
+ import type { PageNode } from "@pgcms/shared";
5
+ import { nodeStyleToCss, nodeCustomAttributes } from "@/lib/style";
6
+ import { buildNodeResponsiveCss } from "@/lib/responsiveCss";
7
+ import {
8
+ useEntranceAnimation,
9
+ useModernEffects,
10
+ } from "@/lib/useEntranceAnimation";
11
+ import {
12
+ NodeBackgroundLayers,
13
+ hasBackgroundLayers,
14
+ } from "./NodeBackgroundLayers";
15
+ import { SECTION_COMPONENTS } from "./registry";
16
+ import { useRendererContext } from "./RendererContext";
17
+ import clsx from "clsx";
18
+
19
+ export function Renderer({ node }: { node: PageNode }) {
20
+ const { mode, selectedId, onSelect } = useRendererContext();
21
+ const ref = useRef<HTMLElement | null>(null);
22
+ // In edit mode the canvas shows the settled final state — no entrance/pointer motion
23
+ // fighting selection and resize.
24
+ useEntranceAnimation(ref, mode === "edit" ? {} : node.style);
25
+ useModernEffects(ref, mode === "edit" ? {} : node.style);
26
+
27
+ // Non-destructive show/hide: hidden nodes keep their configured content but never
28
+ // render on the live site. They still render in the builder (see BuilderCanvasNode)
29
+ // so the user can find and re-enable them.
30
+ if (node.props.hidden === true) return null;
31
+
32
+ if (node.type === "root") {
33
+ return (
34
+ // display: contents — the wrapper must not create a box, or it becomes a sticky
35
+ // header's containing block (exactly the header's own height, so it could never
36
+ // stick). With no box, sections sit directly in the page-height wrapper and
37
+ // position: sticky gets the whole page to travel.
38
+ <div style={{ display: "contents" }}>
39
+ {node.children.map((child) => (
40
+ <Renderer key={child.id} node={child} />
41
+ ))}
42
+ </div>
43
+ );
44
+ }
45
+
46
+ const Component = SECTION_COMPONENTS[node.type];
47
+ if (!Component) {
48
+ return (
49
+ <div className="p-4 text-sm text-red-600 bg-red-50 border border-red-200">
50
+ Unknown component type: {node.type}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ const children = node.children.map((child) => (
56
+ <Renderer key={child.id} node={child} />
57
+ ));
58
+ const { attrs: customAttrs, extraClassName } = nodeCustomAttributes(
59
+ node.style,
60
+ );
61
+ const responsiveCss = buildNodeResponsiveCss(node);
62
+
63
+ const hasTextGradient =
64
+ node.style.textGradientFrom && node.style.textGradientTo;
65
+ const css = nodeStyleToCss(
66
+ node.style,
67
+ node.type === "header" || node.type === "goToTop",
68
+ ) as React.CSSProperties & Record<string, string | number | undefined>;
69
+ if (hasTextGradient) {
70
+ css["--pgcms-tg-from"] = node.style.textGradientFrom;
71
+ css["--pgcms-tg-to"] = node.style.textGradientTo;
72
+ }
73
+ // Sticky lives on the section element itself, not inside the Header component — an
74
+ // inner sticky div's containing block would be this very section (exactly its own
75
+ // height), leaving it zero room to stick. Only on the live site: a header pinned to
76
+ // the canvas viewport while editing would sit on top of unrelated sections.
77
+ if (node.type === "header" && node.props.sticky !== false && mode !== "edit") {
78
+ css.position = "sticky";
79
+ css.top = 0;
80
+ css.zIndex = 50;
81
+ }
82
+
83
+ // Don't wrap goToTop in a section in view mode
84
+ if (node.type === "goToTop" && mode !== "edit") {
85
+ return <Component node={node}>{children}</Component>;
86
+ }
87
+
88
+ const content = (
89
+ <section
90
+ ref={ref}
91
+ {...customAttrs}
92
+ style={css}
93
+ className={clsx(
94
+ mode === "edit" && "pgcms-editable-hover",
95
+ node.style.glass && "pgcms-glass",
96
+ hasTextGradient && "pgcms-text-gradient",
97
+ // Tilt is JS-driven (useModernEffects); disable all pointer effects in the
98
+ // builder so selecting/resizing doesn't fight a moving target.
99
+ mode !== "edit" &&
100
+ node.style.hoverEffect === "lift" &&
101
+ "pgcms-hover-lift",
102
+ mode !== "edit" &&
103
+ node.style.hoverEffect === "scale" &&
104
+ "pgcms-hover-scale",
105
+ mode !== "edit" &&
106
+ node.style.hoverEffect === "glow" &&
107
+ "pgcms-hover-glow",
108
+ extraClassName,
109
+ )}
110
+ onClick={
111
+ mode === "edit"
112
+ ? (e) => {
113
+ e.stopPropagation();
114
+ onSelect?.(node.id);
115
+ }
116
+ : undefined
117
+ }
118
+ data-node-id={node.id}
119
+ >
120
+ {responsiveCss && (
121
+ <style dangerouslySetInnerHTML={{ __html: responsiveCss }} />
122
+ )}
123
+ <NodeBackgroundLayers style={node.style} />
124
+ {/* Absolute background layers paint above in-flow content unless the content is
125
+ itself positioned — only add the wrapper when layers exist, so pages without
126
+ video/overlay keep their exact current DOM. */}
127
+ {hasBackgroundLayers(node.style) ? (
128
+ <div
129
+ className={
130
+ node.type === "header" || node.type === "goToTop" ? "" : "relative"
131
+ }
132
+ >
133
+ <Component node={node}>{children}</Component>
134
+ </div>
135
+ ) : (
136
+ <Component node={node}>{children}</Component>
137
+ )}
138
+ </section>
139
+ );
140
+
141
+ if (mode === "edit" && selectedId === node.id) {
142
+ return <div className="pgcms-editable-selected">{content}</div>;
143
+ }
144
+
145
+ return content;
146
+ }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ export type RendererMode = "view" | "edit";
6
+
7
+ export type RendererContextValue = {
8
+ mode: RendererMode;
9
+ selectedId: string | null;
10
+ onSelect?: (id: string) => void;
11
+ };
12
+
13
+ export const RendererContext = createContext<RendererContextValue>({
14
+ mode: "view",
15
+ selectedId: null,
16
+ });
17
+
18
+ export function useRendererContext() {
19
+ return useContext(RendererContext);
20
+ }
@@ -0,0 +1,87 @@
1
+ import type { ComponentType } from "react";
2
+ import type { SectionProps } from "./sections/types";
3
+ import { Header } from "./sections/Header";
4
+ import { Footer } from "./sections/Footer";
5
+ import { Container } from "./sections/Container";
6
+ import { Column } from "./sections/Column";
7
+ import { Grid } from "./sections/Grid";
8
+ import { GridCell } from "./sections/GridCell";
9
+ import { NavZone } from "./sections/NavZone";
10
+ import { Logo } from "./sections/Logo";
11
+ import { NavLinks } from "./sections/NavLinks";
12
+ import { Hero } from "./sections/Hero";
13
+ import { RichText } from "./sections/RichText";
14
+ import { ImageBlock } from "./sections/ImageBlock";
15
+ import { Slider } from "./sections/Slider";
16
+ import { ImageBackgroundSection } from "./sections/ImageBackgroundSection";
17
+ import { VideoBackgroundSection } from "./sections/VideoBackgroundSection";
18
+ import { ThreeBackground } from "./sections/ThreeBackground";
19
+ import { MapEmbed } from "./sections/MapEmbed";
20
+ import { ButtonGroup } from "./sections/ButtonGroup";
21
+ import { Gallery } from "./sections/Gallery";
22
+ import { Cta } from "./sections/Cta";
23
+ import { Countdown } from "./sections/Countdown";
24
+ import { Stats } from "./sections/Stats";
25
+ import { CardGrid } from "./sections/CardGrid";
26
+ import { Accordion } from "./sections/Accordion";
27
+ import { Tabs } from "./sections/Tabs";
28
+ import { Marquee } from "./sections/Marquee";
29
+ import { LanguageSwitcher } from "./sections/LanguageSwitcher";
30
+ import { CollectionList } from "./sections/CollectionList";
31
+ import { ContactForm } from "./sections/ContactForm";
32
+ import { SiteSearch } from "./sections/SiteSearch";
33
+ import { EmbedBlock } from "./sections/EmbedBlock";
34
+ import { GoToTop } from "./sections/GoToTop";
35
+ import { Testimonial } from "./sections/Testimonial";
36
+ import { PricingTable } from "./sections/PricingTable";
37
+ import { VideoEmbed } from "./sections/VideoEmbed";
38
+ import { TeamGrid } from "./sections/TeamGrid";
39
+ import { LogoCloud } from "./sections/LogoCloud";
40
+ import { Timeline } from "./sections/Timeline";
41
+ import { BeforeAfter } from "./sections/BeforeAfter";
42
+ import { Divider } from "./sections/Divider";
43
+ import { Spacer } from "./sections/Spacer";
44
+
45
+ export const SECTION_COMPONENTS: Record<string, ComponentType<SectionProps>> = {
46
+ goToTop: GoToTop,
47
+ header: Header,
48
+ footer: Footer,
49
+ container: Container,
50
+ column: Column,
51
+ grid: Grid,
52
+ gridCell: GridCell,
53
+ navZone: NavZone,
54
+ logo: Logo,
55
+ navLinks: NavLinks,
56
+ hero: Hero,
57
+ richText: RichText,
58
+ imageBlock: ImageBlock,
59
+ slider: Slider,
60
+ imageBackgroundSection: ImageBackgroundSection,
61
+ videoBackgroundSection: VideoBackgroundSection,
62
+ threeBackground: ThreeBackground,
63
+ mapEmbed: MapEmbed,
64
+ buttonGroup: ButtonGroup,
65
+ gallery: Gallery,
66
+ cta: Cta,
67
+ countdown: Countdown,
68
+ stats: Stats,
69
+ cardGrid: CardGrid,
70
+ accordion: Accordion,
71
+ tabs: Tabs,
72
+ marquee: Marquee,
73
+ languageSwitcher: LanguageSwitcher,
74
+ collectionList: CollectionList,
75
+ contactForm: ContactForm,
76
+ siteSearch: SiteSearch,
77
+ embed: EmbedBlock,
78
+ testimonial: Testimonial,
79
+ pricingTable: PricingTable,
80
+ videoEmbed: VideoEmbed,
81
+ teamGrid: TeamGrid,
82
+ logoCloud: LogoCloud,
83
+ timeline: Timeline,
84
+ beforeAfter: BeforeAfter,
85
+ divider: Divider,
86
+ spacer: Spacer,
87
+ };
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import type { SectionProps } from "./types";
6
+
7
+ type AccordionItem = { question: string; answer: string };
8
+
9
+ export function Accordion({ node }: SectionProps) {
10
+ const heading = node.props.heading as string;
11
+ const items = (node.props.items as AccordionItem[]) ?? [];
12
+ const [openIndex, setOpenIndex] = useState<number | null>(0);
13
+
14
+ if (items.length === 0) return null;
15
+
16
+ return (
17
+ <div>
18
+ {heading && <h2 className="text-2xl md:text-3xl font-bold mb-8 text-center">{heading}</h2>}
19
+ <div className="divide-y border rounded-lg">
20
+ {items.map((item, i) => {
21
+ const open = openIndex === i;
22
+ return (
23
+ <div key={i}>
24
+ <button
25
+ onClick={() => setOpenIndex(open ? null : i)}
26
+ className="w-full flex items-center justify-between gap-4 px-5 py-4 text-left font-medium"
27
+ aria-expanded={open}
28
+ >
29
+ <span>{item.question}</span>
30
+ <ChevronDown size={18} className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
31
+ </button>
32
+ {open && <div className="px-5 pb-4 text-sm opacity-80">{item.answer}</div>}
33
+ </div>
34
+ );
35
+ })}
36
+ </div>
37
+ </div>
38
+ );
39
+ }