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,79 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Star } from "lucide-react";
5
+ import { CmsImage } from "../CmsImage";
6
+ import type { SectionProps } from "./types";
7
+
8
+ type TestimonialItem = { avatar?: string; name: string; role?: string; quote: string; rating?: number };
9
+
10
+ function Card({ item }: { item: TestimonialItem }) {
11
+ return (
12
+ <div className="flex flex-col gap-4 rounded-lg border bg-white p-6">
13
+ {item.rating ? (
14
+ <div className="flex gap-0.5 text-amber-400">
15
+ {Array.from({ length: 5 }, (_, i) => (
16
+ <Star key={i} size={16} fill={i < item.rating! ? "currentColor" : "none"} className={i < item.rating! ? "" : "opacity-30"} />
17
+ ))}
18
+ </div>
19
+ ) : null}
20
+ <p className="text-sm opacity-80 flex-1">&ldquo;{item.quote}&rdquo;</p>
21
+ <div className="flex items-center gap-3">
22
+ {item.avatar && (
23
+ <div className="relative w-10 h-10 rounded-full overflow-hidden shrink-0">
24
+ <CmsImage src={item.avatar} alt={item.name} fill className="object-cover" />
25
+ </div>
26
+ )}
27
+ <div>
28
+ <div className="text-sm font-semibold">{item.name}</div>
29
+ {item.role && <div className="text-xs opacity-60">{item.role}</div>}
30
+ </div>
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export function Testimonial({ node }: SectionProps) {
37
+ const heading = node.props.heading as string;
38
+ const items = (node.props.items as TestimonialItem[]) ?? [];
39
+ const layout = (node.props.layout as string) || "grid";
40
+ const columns = Math.max(1, Math.min(4, Number(node.props.columns) || 3));
41
+ const [index, setIndex] = useState(0);
42
+
43
+ useEffect(() => {
44
+ if (layout !== "carousel" || items.length < 2) return;
45
+ const id = setInterval(() => setIndex((i) => (i + 1) % items.length), 6000);
46
+ return () => clearInterval(id);
47
+ }, [layout, items.length]);
48
+
49
+ if (items.length === 0) return null;
50
+
51
+ return (
52
+ <div>
53
+ {heading && <h2 className="text-2xl md:text-3xl font-bold mb-8 text-center">{heading}</h2>}
54
+ {layout === "carousel" ? (
55
+ <div className="max-w-xl mx-auto">
56
+ <Card item={items[Math.min(index, items.length - 1)]} />
57
+ {items.length > 1 && (
58
+ <div className="flex justify-center gap-2 mt-4">
59
+ {items.map((_, i) => (
60
+ <button
61
+ key={i}
62
+ onClick={() => setIndex(i)}
63
+ className={`h-2 w-2 rounded-full ${i === index ? "bg-theme-primary" : "bg-slate-300"}`}
64
+ aria-label={`Show testimonial ${i + 1}`}
65
+ />
66
+ ))}
67
+ </div>
68
+ )}
69
+ </div>
70
+ ) : (
71
+ <div className="grid gap-6" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
72
+ {items.map((item, i) => (
73
+ <Card key={i} item={item} />
74
+ ))}
75
+ </div>
76
+ )}
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { useRef } from "react";
4
+ import { useThreeBackgroundScene, type ThreeBackgroundPreset } from "@/lib/threeBackgroundScene";
5
+ import { Button } from "./Button";
6
+ import type { SectionProps } from "./types";
7
+
8
+ export function ThreeBackground({ node, children }: SectionProps) {
9
+ const { heading, subheading, buttonText, buttonHref } = node.props as Record<string, string>;
10
+ // Default true: pages built before the toggle existed have no `enabled` prop and
11
+ // should keep animating exactly as they did.
12
+ const enabled = node.props.enabled !== false;
13
+ const preset = ((node.props.preset as ThreeBackgroundPreset) || "particles") as ThreeBackgroundPreset;
14
+ const color = (node.props.color as string) || "#2563eb";
15
+ const secondColor = (node.props.secondColor as string) || "";
16
+ const density = Number(node.props.density) || 80;
17
+ const speed = Number(node.props.speed) || 1;
18
+ const mouseFollow = node.props.mouseFollow === true;
19
+ const bgOpacity = node.props.bgOpacity !== undefined ? Number(node.props.bgOpacity) : 1;
20
+
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+ const canvasRef = useRef<HTMLCanvasElement>(null);
23
+ useThreeBackgroundScene(canvasRef, containerRef, enabled, {
24
+ preset,
25
+ color,
26
+ secondColor: secondColor || undefined,
27
+ density,
28
+ speed,
29
+ mouseFollow,
30
+ });
31
+
32
+ return (
33
+ <div ref={containerRef} className="relative flex flex-col items-center justify-center h-full min-h-[inherit] text-center gap-4 overflow-hidden">
34
+ {enabled && <canvas ref={canvasRef} className="absolute inset-0 w-full h-full" style={{ opacity: bgOpacity }} />}
35
+ <div className="relative z-10 flex flex-col items-center gap-4 max-w-2xl mx-auto">
36
+ {heading && <h2 className="text-3xl md:text-4xl font-bold">{heading}</h2>}
37
+ {subheading && <p className="text-lg opacity-90">{subheading}</p>}
38
+ {buttonText && <Button text={buttonText} href={buttonHref || "#"} variant="primary" />}
39
+ {children && <div className="w-full">{children}</div>}
40
+ </div>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,29 @@
1
+ import type { SectionProps } from "./types";
2
+
3
+ type TimelineItem = { date?: string; title: string; description?: string };
4
+
5
+ export function Timeline({ node }: SectionProps) {
6
+ const heading = node.props.heading as string;
7
+ const items = (node.props.items as TimelineItem[]) ?? [];
8
+
9
+ if (items.length === 0) return null;
10
+
11
+ return (
12
+ <div>
13
+ {heading && <h2 className="text-2xl md:text-3xl font-bold mb-8 text-center">{heading}</h2>}
14
+ <div className="relative max-w-2xl mx-auto pl-8">
15
+ <div className="absolute left-[7px] top-1 bottom-1 w-px bg-slate-200" aria-hidden="true" />
16
+ <div className="flex flex-col gap-8">
17
+ {items.map((item, i) => (
18
+ <div key={i} className="relative">
19
+ <span className="absolute -left-8 top-1 w-3.5 h-3.5 rounded-full bg-theme-primary border-2 border-white shadow" />
20
+ {item.date && <div className="text-xs font-semibold uppercase tracking-wide text-theme-primary mb-1">{item.date}</div>}
21
+ <h3 className="font-semibold">{item.title}</h3>
22
+ {item.description && <p className="text-sm opacity-75 mt-1">{item.description}</p>}
23
+ </div>
24
+ ))}
25
+ </div>
26
+ </div>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,20 @@
1
+ import { Button } from "./Button";
2
+ import type { SectionProps } from "./types";
3
+
4
+ /** The video + overlay layers themselves now render universally for every node type
5
+ * (see NodeBackgroundLayers) — this component only lays out the built-in content and
6
+ * any child components dropped into the section. */
7
+ export function VideoBackgroundSection({ node, children }: SectionProps) {
8
+ const { heading, subheading, buttonText, buttonHref } = node.props as Record<string, string>;
9
+
10
+ return (
11
+ <div className="flex flex-col items-center justify-center h-full min-h-[inherit] text-center gap-4">
12
+ <div className="flex flex-col items-center gap-4 max-w-2xl mx-auto">
13
+ {heading && <h2 className="text-3xl md:text-4xl font-bold">{heading}</h2>}
14
+ {subheading && <p className="text-lg opacity-90">{subheading}</p>}
15
+ {buttonText && <Button text={buttonText} href={buttonHref || "#"} variant="primary" />}
16
+ </div>
17
+ {children && <div className="w-full">{children}</div>}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Play } from "lucide-react";
5
+ import { CmsImage } from "../CmsImage";
6
+ import type { SectionProps } from "./types";
7
+
8
+ type ParsedVideo = { provider: "youtube" | "vimeo" | "mp4" | null; embedUrl?: string; id?: string };
9
+
10
+ function parseVideoUrl(url: string): ParsedVideo {
11
+ if (!url) return { provider: null };
12
+ const yt = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/);
13
+ if (yt) return { provider: "youtube", id: yt[1], embedUrl: `https://www.youtube-nocookie.com/embed/${yt[1]}?autoplay=1` };
14
+ const vim = url.match(/vimeo\.com\/(\d+)/);
15
+ if (vim) return { provider: "vimeo", id: vim[1], embedUrl: `https://player.vimeo.com/video/${vim[1]}?autoplay=1` };
16
+ if (/\.(mp4|webm|ogg)(\?|$)/i.test(url)) return { provider: "mp4" };
17
+ return { provider: null };
18
+ }
19
+
20
+ // Renders a static thumbnail + play button instead of an always-live iframe, so a page
21
+ // with several videos doesn't pay YouTube/Vimeo's heavy embed script cost until a
22
+ // visitor actually presses play.
23
+ export function VideoEmbed({ node }: SectionProps) {
24
+ const url = (node.props.url as string) || "";
25
+ const customThumbnail = (node.props.thumbnail as string) || "";
26
+ const aspectRatio = (node.props.aspectRatio as string) || "16/9";
27
+ const [playing, setPlaying] = useState(false);
28
+ const parsed = parseVideoUrl(url);
29
+
30
+ if (!url || !parsed.provider) {
31
+ return (
32
+ <div className="h-48 flex items-center justify-center bg-slate-100 text-slate-400 text-sm rounded-lg">
33
+ Add a YouTube, Vimeo, or direct MP4 URL
34
+ </div>
35
+ );
36
+ }
37
+
38
+ if (parsed.provider === "mp4") {
39
+ return (
40
+ <div className="rounded-lg overflow-hidden" style={{ aspectRatio }}>
41
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
42
+ <video src={url} controls className="w-full h-full object-cover" />
43
+ </div>
44
+ );
45
+ }
46
+
47
+ const thumbnail =
48
+ customThumbnail || (parsed.provider === "youtube" ? `https://img.youtube.com/vi/${parsed.id}/hqdefault.jpg` : "");
49
+
50
+ return (
51
+ <div className="relative rounded-lg overflow-hidden bg-slate-900" style={{ aspectRatio }}>
52
+ {playing ? (
53
+ <iframe
54
+ src={parsed.embedUrl}
55
+ title="Video player"
56
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
57
+ allowFullScreen
58
+ className="absolute inset-0 w-full h-full border-0"
59
+ />
60
+ ) : (
61
+ <button onClick={() => setPlaying(true)} className="absolute inset-0 flex items-center justify-center group" aria-label="Play video">
62
+ {thumbnail && <CmsImage src={thumbnail} alt="" fill className="object-cover opacity-90" />}
63
+ <span className="relative z-10 flex items-center justify-center w-16 h-16 rounded-full bg-white/90 group-hover:bg-white transition-colors">
64
+ <Play size={28} className="text-slate-900 ml-1" fill="currentColor" />
65
+ </span>
66
+ </button>
67
+ )}
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,7 @@
1
+ import type { PageNode } from "@pgcms/shared";
2
+ import type { ReactNode } from "react";
3
+
4
+ export type SectionProps = {
5
+ node: PageNode;
6
+ children?: ReactNode;
7
+ };
@@ -0,0 +1,111 @@
1
+ import type { Page, PageNode, SiteTheme } from "@pgcms/shared";
2
+
3
+ const SITE_URL = process.env.SITE_URL ?? "http://localhost:3000";
4
+
5
+ type FaqItem = { question: string; answer: string };
6
+
7
+ /** Collects every accordion's question/answer pairs across the whole tree — an
8
+ * accordion is the CMS's FAQ block (see components.ts), so any page using one for
9
+ * real Q&A content earns FAQPage rich-result eligibility for free. */
10
+ function collectFaqItems(node: PageNode): FaqItem[] {
11
+ const items: FaqItem[] = [];
12
+ function walk(n: PageNode) {
13
+ if (n.type === "accordion") {
14
+ const raw = (n.props.items as { question?: string; answer?: string }[]) ?? [];
15
+ for (const it of raw) {
16
+ if (it.question && it.answer) items.push({ question: it.question, answer: it.answer });
17
+ }
18
+ }
19
+ n.children.forEach(walk);
20
+ }
21
+ walk(node);
22
+ return items;
23
+ }
24
+
25
+ /** JSON-LD structured data for public pages — helps search engines understand content.
26
+ * Emits one primary entity (WebPage for ordinary pages, Article for collection items
27
+ * like blog posts) plus a BreadcrumbList derived from the URL, plus an FAQPage entity
28
+ * whenever the content actually contains FAQ-shaped content (see collectFaqItems). */
29
+ export function JsonLd({
30
+ page,
31
+ theme,
32
+ url,
33
+ contentType = "page",
34
+ datePublished,
35
+ }: {
36
+ page: Pick<Page, "title" | "seoTitle" | "seoDescription" | "ogImage" | "content">;
37
+ theme: Pick<SiteTheme, "siteName">;
38
+ url: string;
39
+ /** "collectionItem" (a blog post, a news item...) renders as Article instead of
40
+ * WebPage — Article is what search engines expect for dated, authored content. */
41
+ contentType?: "page" | "collectionItem";
42
+ datePublished?: string | null;
43
+ }) {
44
+ const name = page.seoTitle || page.title;
45
+ const description = page.seoDescription ?? undefined;
46
+
47
+ const primary =
48
+ contentType === "collectionItem"
49
+ ? {
50
+ "@type": "Article",
51
+ headline: name,
52
+ description,
53
+ url,
54
+ ...(page.ogImage ? { image: page.ogImage } : {}),
55
+ ...(datePublished ? { datePublished } : {}),
56
+ publisher: { "@type": "Organization", name: theme.siteName },
57
+ }
58
+ : {
59
+ "@type": "WebPage",
60
+ name,
61
+ description,
62
+ url,
63
+ isPartOf: { "@type": "WebSite", name: theme.siteName, url: SITE_URL },
64
+ ...(page.ogImage ? { image: page.ogImage } : {}),
65
+ };
66
+
67
+ const segments = new URL(url).pathname.split("/").filter(Boolean);
68
+ const breadcrumb =
69
+ segments.length > 0
70
+ ? {
71
+ "@type": "BreadcrumbList",
72
+ itemListElement: [
73
+ { "@type": "ListItem", position: 1, name: theme.siteName, item: SITE_URL },
74
+ ...segments.map((seg, i) => ({
75
+ "@type": "ListItem",
76
+ position: i + 2,
77
+ name: decodeURIComponent(seg).replace(/-/g, " "),
78
+ item: `${SITE_URL}/${segments.slice(0, i + 1).join("/")}`,
79
+ })),
80
+ ],
81
+ }
82
+ : null;
83
+
84
+ const faqItems = collectFaqItems(page.content);
85
+ const faq =
86
+ faqItems.length > 0
87
+ ? {
88
+ "@type": "FAQPage",
89
+ mainEntity: faqItems.map((item) => ({
90
+ "@type": "Question",
91
+ name: item.question,
92
+ acceptedAnswer: { "@type": "Answer", text: item.answer },
93
+ })),
94
+ }
95
+ : null;
96
+
97
+ const schemas = [
98
+ { "@context": "https://schema.org", ...primary },
99
+ ...(breadcrumb ? [{ "@context": "https://schema.org", ...breadcrumb }] : []),
100
+ ...(faq ? [{ "@context": "https://schema.org", ...faq }] : []),
101
+ ];
102
+
103
+ return (
104
+ <>
105
+ {schemas.map((schema, i) => (
106
+ // eslint-disable-next-line react/no-array-index-key
107
+ <script key={i} type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
108
+ ))}
109
+ </>
110
+ );
111
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import Script from "next/script";
5
+ import type { SiteTheme } from "@pgcms/shared";
6
+
7
+ /** Browsers never execute <script> tags inserted via innerHTML — parses the admin's
8
+ * pasted snippet and recreates each script node with document.createElement, which
9
+ * DOES execute, so a pasted "<script src=...></script><script>...</script>" snippet
10
+ * (the common shape third-party analytics vendors hand out) actually runs. */
11
+ function CustomScript({ html }: { html: string }) {
12
+ useEffect(() => {
13
+ const container = document.createElement("div");
14
+ container.innerHTML = html;
15
+ const nodes = Array.from(container.childNodes);
16
+ const inserted: HTMLElement[] = [];
17
+ for (const node of nodes) {
18
+ if (node.nodeName === "SCRIPT") {
19
+ const source = node as HTMLScriptElement;
20
+ const script = document.createElement("script");
21
+ for (const attr of Array.from(source.attributes)) script.setAttribute(attr.name, attr.value);
22
+ script.text = source.text;
23
+ document.body.appendChild(script);
24
+ inserted.push(script);
25
+ } else {
26
+ document.body.appendChild(node.cloneNode(true) as HTMLElement);
27
+ }
28
+ }
29
+ return () => inserted.forEach((el) => el.remove());
30
+ }, [html]);
31
+
32
+ return null;
33
+ }
34
+
35
+ /** Renders whichever analytics snippet the site owner picked in Theme Settings.
36
+ * Lives alongside ThemeStyleTag/JsonLd (per-request, where theme data is already
37
+ * loaded) rather than the static root layout. */
38
+ export function AnalyticsScripts({
39
+ theme,
40
+ }: {
41
+ theme: Pick<SiteTheme, "analyticsProvider" | "analyticsId" | "analyticsCustomScript">;
42
+ }) {
43
+ if (theme.analyticsProvider === "ga4" && theme.analyticsId) {
44
+ return (
45
+ <>
46
+ <Script src={`https://www.googletagmanager.com/gtag/js?id=${theme.analyticsId}`} strategy="afterInteractive" />
47
+ <Script id="ga4-init" strategy="afterInteractive">
48
+ {`window.dataLayer = window.dataLayer || [];
49
+ function gtag(){dataLayer.push(arguments);}
50
+ gtag('js', new Date());
51
+ gtag('config', '${theme.analyticsId}');`}
52
+ </Script>
53
+ </>
54
+ );
55
+ }
56
+
57
+ if (theme.analyticsProvider === "plausible" && theme.analyticsId) {
58
+ return <Script defer data-domain={theme.analyticsId} src="https://plausible.io/js/script.js" strategy="afterInteractive" />;
59
+ }
60
+
61
+ if (theme.analyticsProvider === "custom" && theme.analyticsCustomScript) {
62
+ // Trusted-admin escape hatch, same trust boundary as the existing customCss field —
63
+ // whoever can edit Theme Settings can already inject arbitrary CSS/attributes.
64
+ return <CustomScript html={theme.analyticsCustomScript} />;
65
+ }
66
+
67
+ return null;
68
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Moon, Sun } from "lucide-react";
5
+
6
+ const STORAGE_KEY = "pgcms-theme";
7
+
8
+ // Rendered only when the site theme has dark mode enabled (see [[...slug]]/page.tsx).
9
+ // Flips the `data-theme` attribute the layout's beforeInteractive script already sets
10
+ // from localStorage/system preference, and persists the explicit choice going forward.
11
+ export function DarkModeToggle() {
12
+ const [theme, setThemeState] = useState<"light" | "dark">("light");
13
+
14
+ useEffect(() => {
15
+ setThemeState((document.documentElement.getAttribute("data-theme") as "light" | "dark") || "light");
16
+ }, []);
17
+
18
+ function toggle() {
19
+ const next = theme === "dark" ? "light" : "dark";
20
+ setThemeState(next);
21
+ document.documentElement.setAttribute("data-theme", next);
22
+ try {
23
+ localStorage.setItem(STORAGE_KEY, next);
24
+ } catch {
25
+ // Storage disabled (private browsing) — the toggle still works for this page view.
26
+ }
27
+ }
28
+
29
+ return (
30
+ <button
31
+ onClick={toggle}
32
+ aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
33
+ className="fixed top-6 right-6 z-50 flex items-center justify-center w-11 h-11 rounded-full shadow-lg transition-transform hover:scale-110"
34
+ style={{ backgroundColor: "var(--theme-primary)", color: "#ffffff" }}
35
+ >
36
+ {theme === "dark" ? <Sun size={20} /> : <Moon size={20} />}
37
+ </button>
38
+ );
39
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+ import type { NavItem, PageTranslationInfo, SiteTheme } from "@pgcms/shared";
5
+
6
+ export type SiteDataValue = {
7
+ theme: SiteTheme;
8
+ navItems: NavItem[];
9
+ /** The current page's locale + its translation group — feeds the Language Switcher
10
+ * component. Absent in contexts without a current page (builder canvas). */
11
+ pageLocale?: string;
12
+ translations?: PageTranslationInfo[];
13
+ };
14
+
15
+ const defaultTheme: SiteTheme = {
16
+ primaryColor: "#2563eb",
17
+ secondaryColor: "#0f172a",
18
+ backgroundColor: "#ffffff",
19
+ textColor: "#111827",
20
+ headingFont: "Poppins",
21
+ bodyFont: "Inter",
22
+ siteName: "My Site",
23
+ logoUrl: null,
24
+ faviconUrl: null,
25
+ customCss: null,
26
+ darkModeEnabled: false,
27
+ primaryColorDark: null,
28
+ backgroundColorDark: null,
29
+ textColorDark: null,
30
+ analyticsProvider: "none",
31
+ analyticsId: null,
32
+ analyticsCustomScript: null,
33
+ };
34
+
35
+ export const SiteDataContext = createContext<SiteDataValue>({ theme: defaultTheme, navItems: [] });
36
+
37
+ export function useSiteData() {
38
+ return useContext(SiteDataContext);
39
+ }
@@ -0,0 +1,58 @@
1
+ import type { SiteTheme } from "@pgcms/shared";
2
+
3
+ export function themeToCssVars(theme: Partial<SiteTheme>): React.CSSProperties {
4
+ return {
5
+ ["--theme-primary" as any]: theme.primaryColor,
6
+ ["--theme-secondary" as any]: theme.secondaryColor,
7
+ ["--theme-bg" as any]: theme.backgroundColor,
8
+ ["--theme-text" as any]: theme.textColor,
9
+ ["--theme-heading-font" as any]: theme.headingFont ? `"${theme.headingFont}", sans-serif` : undefined,
10
+ ["--theme-body-font" as any]: theme.bodyFont ? `"${theme.bodyFont}", sans-serif` : undefined,
11
+ };
12
+ }
13
+
14
+ /** Dark-token overrides, applied under `:root[data-theme="dark"]` (see the
15
+ * beforeInteractive script in layout.tsx that sets the attribute). Blank fields fall
16
+ * back to sensible computed defaults rather than the light colors, so turning dark
17
+ * mode on without picking custom dark colors still looks like a real dark theme. */
18
+ function darkThemeToCssVars(theme: Partial<SiteTheme>): React.CSSProperties {
19
+ return {
20
+ ["--theme-primary" as any]: theme.primaryColorDark || theme.primaryColor,
21
+ ["--theme-bg" as any]: theme.backgroundColorDark || "#0f172a",
22
+ ["--theme-text" as any]: theme.textColorDark || "#f1f5f9",
23
+ };
24
+ }
25
+
26
+ export function ThemeStyleTag({ theme }: { theme: Partial<SiteTheme> }) {
27
+ const vars = themeToCssVars(theme);
28
+ const cssVarString = Object.entries(vars)
29
+ .filter(([, v]) => v !== undefined)
30
+ .map(([k, v]) => `${k}: ${v};`)
31
+ .join(" ");
32
+
33
+ const darkVars = darkThemeToCssVars(theme);
34
+ const darkCssVarString = Object.entries(darkVars)
35
+ .filter(([, v]) => v !== undefined)
36
+ .map(([k, v]) => `${k}: ${v};`)
37
+ .join(" ");
38
+
39
+ const fontFamilies = [theme.headingFont, theme.bodyFont].filter(Boolean) as string[];
40
+ const fontsHref = fontFamilies.length
41
+ ? `https://fonts.googleapis.com/css2?${fontFamilies
42
+ .map((f) => `family=${encodeURIComponent(f)}:wght@400;500;600;700`)
43
+ .join("&")}&display=swap`
44
+ : null;
45
+
46
+ return (
47
+ <>
48
+ {fontsHref && <link rel="stylesheet" href={fontsHref} />}
49
+ <style
50
+ dangerouslySetInnerHTML={{
51
+ __html: `:root { ${cssVarString} } ${
52
+ theme.darkModeEnabled ? `:root[data-theme="dark"] { ${darkCssVarString} }` : ""
53
+ } ${theme.customCss ?? ""}`,
54
+ }}
55
+ />
56
+ </>
57
+ );
58
+ }
@@ -0,0 +1,16 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTypescript from "eslint-config-next/typescript";
4
+
5
+ export default defineConfig([
6
+ ...nextVitals,
7
+ ...nextTypescript,
8
+ {
9
+ settings: {
10
+ next: {
11
+ rootDir: ".",
12
+ },
13
+ },
14
+ },
15
+ globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
16
+ ]);
@@ -0,0 +1,45 @@
1
+ export class ApiError extends Error {
2
+ status: number;
3
+ constructor(status: number, message: string) {
4
+ super(message);
5
+ this.status = status;
6
+ }
7
+ }
8
+
9
+ // Browser client: always relative — requests go through the Next.js dev/prod server,
10
+ // which rewrites /api/* to the Express API (see next.config.mjs). This keeps the auth
11
+ // cookie first-party even though Express runs on a different port.
12
+ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
13
+ const res = await fetch(path, {
14
+ ...options,
15
+ credentials: "include",
16
+ headers: {
17
+ ...(options.body && !(options.body instanceof FormData) ? { "Content-Type": "application/json" } : {}),
18
+ ...options.headers,
19
+ },
20
+ cache: "no-store",
21
+ });
22
+
23
+ if (!res.ok) {
24
+ let message = res.statusText;
25
+ try {
26
+ const body = await res.json();
27
+ message = body.error ? (typeof body.error === "string" ? body.error : JSON.stringify(body.error)) : message;
28
+ } catch {
29
+ // ignore
30
+ }
31
+ throw new ApiError(res.status, message);
32
+ }
33
+
34
+ if (res.status === 204) return undefined as T;
35
+ return res.json() as Promise<T>;
36
+ }
37
+
38
+ export const api = {
39
+ get: <T>(path: string) => request<T>(path),
40
+ post: <T>(path: string, body?: unknown) =>
41
+ request<T>(path, { method: "POST", body: body instanceof FormData ? body : JSON.stringify(body) }),
42
+ put: <T>(path: string, body?: unknown) =>
43
+ request<T>(path, { method: "PUT", body: body instanceof FormData ? body : JSON.stringify(body) }),
44
+ delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
45
+ };