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,9 @@
1
+ export * from "./schema/pageNode.js";
2
+ export * from "./schema/theme.js";
3
+ export * from "./schema/user.js";
4
+ export * from "./schema/media.js";
5
+ export * from "./schema/fields.js";
6
+ export * from "./schema/collections.js";
7
+ export * from "./schema/templates.js";
8
+ export * from "./components.js";
9
+ export * from "./tree.js";
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ import { PageNodeSchema, PageStatusSchema } from "./pageNode.js";
3
+
4
+ export const CollectionSchema = z.object({
5
+ id: z.string(),
6
+ name: z.string(),
7
+ slug: z.string(),
8
+ createdAt: z.string().optional(),
9
+ });
10
+ export type Collection = z.infer<typeof CollectionSchema>;
11
+
12
+ export const CollectionItemSchema = z.object({
13
+ id: z.string(),
14
+ collectionId: z.string(),
15
+ slug: z.string(),
16
+ title: z.string(),
17
+ excerpt: z.string().nullable().optional(),
18
+ coverImage: z.string().nullable().optional(),
19
+ seoTitle: z.string().nullable().optional(),
20
+ seoDescription: z.string().nullable().optional(),
21
+ ogImage: z.string().nullable().optional(),
22
+ noIndex: z.boolean().optional(),
23
+ content: PageNodeSchema,
24
+ status: PageStatusSchema,
25
+ publishedAt: z.string().nullable().optional(),
26
+ locale: z.string().optional(),
27
+ translationOfId: z.string().nullable().optional(),
28
+ createdById: z.string().nullable().optional(),
29
+ createdAt: z.string().optional(),
30
+ updatedAt: z.string().optional(),
31
+ });
32
+ export type CollectionItem = z.infer<typeof CollectionItemSchema>;
33
+
34
+ export const CreateCollectionInputSchema = z.object({
35
+ name: z.string().min(1).max(80),
36
+ slug: z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and hyphens"),
37
+ });
38
+
39
+ export const CreateCollectionItemInputSchema = z.object({
40
+ title: z.string().min(1),
41
+ slug: z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and hyphens"),
42
+ locale: z.string().min(2).max(20).optional(),
43
+ });
44
+
45
+ export const UpdateCollectionItemInputSchema = z.object({
46
+ title: z.string().min(1).optional(),
47
+ slug: z
48
+ .string()
49
+ .regex(/^[a-z0-9-]+$/)
50
+ .optional(),
51
+ excerpt: z.string().nullable().optional(),
52
+ coverImage: z.string().nullable().optional(),
53
+ seoTitle: z.string().nullable().optional(),
54
+ seoDescription: z.string().nullable().optional(),
55
+ ogImage: z.string().nullable().optional(),
56
+ noIndex: z.boolean().optional(),
57
+ content: PageNodeSchema.optional(),
58
+ status: PageStatusSchema.optional(),
59
+ locale: z.string().min(2).max(20).optional(),
60
+ translationOfId: z.string().nullable().optional(),
61
+ });
62
+
63
+ /** The card-level view of an item, as returned by the public list endpoint. */
64
+ export type CollectionItemSummary = {
65
+ slug: string;
66
+ title: string;
67
+ excerpt: string | null;
68
+ coverImage: string | null;
69
+ publishedAt: string | null;
70
+ };
@@ -0,0 +1,26 @@
1
+ export type FieldType =
2
+ | "text"
3
+ | "textarea"
4
+ | "richtext"
5
+ | "image"
6
+ | "video"
7
+ | "url"
8
+ | "number"
9
+ | "boolean"
10
+ | "select"
11
+ | "array"
12
+ | "datetime"
13
+ | "color";
14
+
15
+ export type FieldDescriptor = {
16
+ key: string;
17
+ label: string;
18
+ type: FieldType;
19
+ default?: unknown;
20
+ options?: { label: string; value: string }[]; // for "select"
21
+ itemFields?: FieldDescriptor[]; // for "array" — schema of each item
22
+ min?: number;
23
+ max?: number;
24
+ step?: number; // for "number" — e.g. 0.1 for a speed/opacity slider-style field
25
+ placeholder?: string;
26
+ };
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+
3
+ export const MediaAssetSchema = z.object({
4
+ id: z.string(),
5
+ key: z.string(),
6
+ url: z.string(),
7
+ mimeType: z.string(),
8
+ width: z.number().nullable().optional(),
9
+ height: z.number().nullable().optional(),
10
+ size: z.number(),
11
+ createdAt: z.string().optional(),
12
+ });
13
+ export type MediaAsset = z.infer<typeof MediaAssetSchema>;
@@ -0,0 +1,255 @@
1
+ import { z } from "zod";
2
+
3
+ export const NodeStyleSchema = z.object({
4
+ backgroundColor: z.string().optional(),
5
+ backgroundImage: z.string().optional(),
6
+ backgroundVideo: z.string().optional(),
7
+ backgroundOverlay: z.string().optional(),
8
+ textColor: z.string().optional(),
9
+ fontFamily: z.string().optional(),
10
+ fontSize: z.string().optional(),
11
+ fontWeight: z.string().optional(),
12
+ paddingY: z.string().optional(),
13
+ paddingX: z.string().optional(),
14
+ // Individual sides win over the Y/X pair when both are set.
15
+ paddingTop: z.string().optional(),
16
+ paddingRight: z.string().optional(),
17
+ paddingBottom: z.string().optional(),
18
+ paddingLeft: z.string().optional(),
19
+ marginY: z.string().optional(),
20
+ marginTop: z.string().optional(),
21
+ marginBottom: z.string().optional(),
22
+ // Flex/grid child alignment — applied by Container (grid) and Column (flex column).
23
+ alignItems: z.enum(["start", "center", "end", "stretch"]).optional(),
24
+ justifyContent: z.enum(["start", "center", "end", "between"]).optional(),
25
+ // A Grid Cell's own placement within its parent Grid — how many of the Grid's
26
+ // declared column/row tracks this cell occupies. 1 (or unset) is a normal single
27
+ // cell; the parent Grid's row/column *counts* live in its own props, not here.
28
+ gridColumnSpan: z.number().optional(),
29
+ gridRowSpan: z.number().optional(),
30
+ textAlign: z.enum(["left", "center", "right"]).optional(),
31
+ // Full dimension control — all six accept any CSS length (px, %, rem, vw, vh...).
32
+ // Drag-resize writes width/height when set, else maxWidth/minHeight, preserving
33
+ // whichever unit the value already uses.
34
+ width: z.string().optional(),
35
+ height: z.string().optional(),
36
+ minWidth: z.string().optional(),
37
+ minHeight: z.string().optional(),
38
+ maxHeight: z.string().optional(),
39
+ borderRadius: z.string().optional(),
40
+ maxWidth: z.string().optional(),
41
+ borderWidth: z.string().optional(),
42
+ borderStyle: z.enum(["none", "solid", "dashed", "dotted", "double"]).optional(),
43
+ borderColor: z.string().optional(),
44
+ boxShadow: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
45
+ opacity: z.number().optional(),
46
+ // A Container's grid track size for one of its Columns — distinct from `maxWidth`
47
+ // (which constrains content *within* a track) because CSS Grid track width can't be
48
+ // driven by a child's own max-width; the Container reads this per-child to build its
49
+ // own grid-template-columns. Unset means "share remaining space equally" (1fr).
50
+ columnWidth: z.string().optional(),
51
+ // Universal animation, applied to any node type by Renderer.tsx via GSAP. "none"
52
+ // is the off switch — every other field below is ignored when animationType is
53
+ // unset or "none".
54
+ animationType: z
55
+ .enum([
56
+ "none",
57
+ "fadeIn",
58
+ "slideUp",
59
+ "slideDown",
60
+ "slideLeft",
61
+ "slideRight",
62
+ "zoomIn",
63
+ "scrollReveal",
64
+ "rotateIn",
65
+ "pulse",
66
+ "blurIn",
67
+ "flipIn",
68
+ "bounceIn",
69
+ ])
70
+ .optional(),
71
+ // onLoad/onScroll play once automatically (a reveal); onHover/onFocus play forward
72
+ // and reverse back out on mouse-leave/blur (a toggle); onClick replays from the start
73
+ // on every click (a tap acknowledgement).
74
+ animationTrigger: z.enum(["onLoad", "onScroll", "onHover", "onFocus", "onClick"]).optional(),
75
+ animationDuration: z.number().optional(),
76
+ animationDelay: z.number().optional(),
77
+ animationEasing: z.string().optional(),
78
+ // Only meaningful for onLoad/onScroll — repeats the reveal indefinitely, alternating
79
+ // direction each pass, for continuous decorative motion instead of a one-time reveal.
80
+ animationLoop: z.boolean().optional(),
81
+ // Only meaningful for onLoad/onScroll — instead of revealing the whole section as one
82
+ // block, its inner items (cards, paragraphs, buttons) reveal one after another,
83
+ // this many seconds apart.
84
+ animationStagger: z.number().optional(),
85
+ // Continuous scroll-linked drift: the section translates vertically as it moves
86
+ // through the viewport (-1..1, negative = drifts up against the scroll). Independent
87
+ // of the entrance animation.
88
+ parallaxSpeed: z.number().optional(),
89
+ // Always-on pointer effect, independent of animationType/Trigger — a modern card
90
+ // feel: lift (rise + shadow), tilt (3D follow the pointer), glow, or scale.
91
+ hoverEffect: z.enum(["none", "lift", "tilt", "glow", "scale"]).optional(),
92
+ // Glassmorphism: translucent frosted-glass panel (backdrop blur + soft border).
93
+ glass: z.boolean().optional(),
94
+ // Gradient-filled headings (background-clip: text) — set both to activate.
95
+ textGradientFrom: z.string().optional(),
96
+ textGradientTo: z.string().optional(),
97
+ // Escape hatch for anything not covered by a dedicated style field — arbitrary CSS
98
+ // properties (camelCase, merged into the node's inline style) and arbitrary HTML
99
+ // attributes (kebab-case, spread onto the rendered element) that a content editor can
100
+ // add without needing a code change.
101
+ customCss: z.record(z.string(), z.string()).optional(),
102
+ customAttributes: z.record(z.string(), z.string()).optional(),
103
+ });
104
+ export type NodeStyle = z.infer<typeof NodeStyleSchema>;
105
+
106
+ // Recursive PageNode type: id + component type + arbitrary content props + style + children.
107
+ // styleTablet/styleMobile are sparse per-breakpoint overrides merged over `style`:
108
+ // tablet applies ≤1023px, mobile applies ≤767px (on top of tablet).
109
+ export type PageNode = {
110
+ id: string;
111
+ type: string;
112
+ props: Record<string, unknown>;
113
+ style: NodeStyle;
114
+ styleTablet?: NodeStyle;
115
+ styleMobile?: NodeStyle;
116
+ children: PageNode[];
117
+ };
118
+
119
+ export const PageNodeSchema: z.ZodType<PageNode> = z.lazy(() =>
120
+ z.object({
121
+ id: z.string(),
122
+ type: z.string(),
123
+ props: z.record(z.string(), z.unknown()),
124
+ style: NodeStyleSchema,
125
+ styleTablet: NodeStyleSchema.optional(),
126
+ styleMobile: NodeStyleSchema.optional(),
127
+ children: z.array(PageNodeSchema),
128
+ })
129
+ );
130
+
131
+ export type Breakpoint = "desktop" | "tablet" | "mobile";
132
+
133
+ export const BREAKPOINT_MAX_WIDTH: Record<Exclude<Breakpoint, "desktop">, number> = {
134
+ tablet: 1023,
135
+ mobile: 767,
136
+ };
137
+
138
+ /** The style a node effectively has at a breakpoint: base, with the tablet override
139
+ * applied for tablet AND mobile, and the mobile override applied on top for mobile —
140
+ * mirroring how the emitted max-width media queries cascade on the public site. */
141
+ export function resolveNodeStyle(node: PageNode, breakpoint: Breakpoint): NodeStyle {
142
+ if (breakpoint === "desktop") return node.style;
143
+ if (breakpoint === "tablet") return { ...node.style, ...node.styleTablet };
144
+ return { ...node.style, ...node.styleTablet, ...node.styleMobile };
145
+ }
146
+
147
+ export const PageStatusSchema = z.enum(["DRAFT", "PUBLISHED"]);
148
+ export type PageStatus = z.infer<typeof PageStatusSchema>;
149
+
150
+ export const PageSchema = z.object({
151
+ id: z.string(),
152
+ slug: z.string(),
153
+ title: z.string(),
154
+ status: PageStatusSchema,
155
+ content: PageNodeSchema,
156
+ seoTitle: z.string().nullable().optional(),
157
+ seoDescription: z.string().nullable().optional(),
158
+ ogImage: z.string().nullable().optional(),
159
+ noIndex: z.boolean().optional(),
160
+ previewToken: z.string().optional(),
161
+ publishAt: z.string().nullable().optional(),
162
+ locale: z.string().optional(),
163
+ translationOfId: z.string().nullable().optional(),
164
+ createdById: z.string().nullable().optional(),
165
+ createdAt: z.string().optional(),
166
+ updatedAt: z.string().optional(),
167
+ });
168
+ export type Page = z.infer<typeof PageSchema>;
169
+
170
+ /** Locales offered in the page-settings dropdown. `label` is the language's own name
171
+ * (how switchers conventionally show it); any BCP-47 tag typed into the API works too. */
172
+ export const SUPPORTED_LOCALES: { code: string; label: string; rtl?: boolean }[] = [
173
+ { code: "en", label: "English" },
174
+ { code: "es", label: "Español" },
175
+ { code: "fr", label: "Français" },
176
+ { code: "de", label: "Deutsch" },
177
+ { code: "pt", label: "Português" },
178
+ { code: "it", label: "Italiano" },
179
+ { code: "bn", label: "বাংলা" },
180
+ { code: "hi", label: "हिन्दी" },
181
+ { code: "zh", label: "中文" },
182
+ { code: "ja", label: "日本語" },
183
+ { code: "ar", label: "العربية", rtl: true },
184
+ { code: "he", label: "עברית", rtl: true },
185
+ { code: "fa", label: "فارسی", rtl: true },
186
+ { code: "ur", label: "اردو", rtl: true },
187
+ ];
188
+
189
+ const RTL_PREFIXES = new Set(["ar", "he", "fa", "ur", "ps", "sd", "ckb", "dv", "yi"]);
190
+
191
+ /** Whether a locale is written right-to-left — drives dir="rtl" on the page wrapper. */
192
+ export function isRtlLocale(locale: string | undefined): boolean {
193
+ if (!locale) return false;
194
+ return RTL_PREFIXES.has(locale.toLowerCase().split(/[-_]/)[0]);
195
+ }
196
+
197
+ /** One entry of a page's translation group, as returned by the public translations
198
+ * endpoint — everything a language switcher needs. */
199
+ export type PageTranslationInfo = { locale: string; slug: string; title: string };
200
+
201
+ export const CreatePageInputSchema = z.object({
202
+ title: z.string().min(1),
203
+ slug: z
204
+ .string()
205
+ .regex(/^[a-z0-9/-]*$/, "Slug must be lowercase letters, numbers, hyphens, and slashes only"),
206
+ locale: z.string().min(2).max(20).optional(),
207
+ // When set, the new page's content is seeded from this PageTemplate (deep-cloned
208
+ // with fresh node ids) instead of starting blank.
209
+ templateId: z.string().optional(),
210
+ });
211
+ export type CreatePageInput = z.infer<typeof CreatePageInputSchema>;
212
+
213
+ export const UpdatePageInputSchema = z.object({
214
+ title: z.string().min(1).optional(),
215
+ slug: z
216
+ .string()
217
+ // Allow "/" so localized slugs can be path-prefixed, e.g. "es/servicios".
218
+ .regex(/^[a-z0-9/-]*$/)
219
+ .optional(),
220
+ content: PageNodeSchema.optional(),
221
+ status: PageStatusSchema.optional(),
222
+ seoTitle: z.string().nullable().optional(),
223
+ seoDescription: z.string().nullable().optional(),
224
+ ogImage: z.string().nullable().optional(),
225
+ noIndex: z.boolean().optional(),
226
+ publishAt: z.string().nullable().optional(),
227
+ locale: z.string().min(2).max(20).optional(),
228
+ translationOfId: z.string().nullable().optional(),
229
+ });
230
+ export type UpdatePageInput = z.infer<typeof UpdatePageInputSchema>;
231
+
232
+ export const PageRevisionSchema = z.object({
233
+ id: z.string(),
234
+ pageId: z.string(),
235
+ title: z.string(),
236
+ content: PageNodeSchema,
237
+ createdAt: z.string(),
238
+ });
239
+ export type PageRevision = z.infer<typeof PageRevisionSchema>;
240
+
241
+ /** Whether a page is actually visible to the public right now — PUBLISHED alone isn't
242
+ * enough if a future publishAt has been set (scheduled publishing). Accepts Date
243
+ * objects from Prisma as well as ISO strings from the API JSON layer. */
244
+ export function isPagePubliclyVisible(page: {
245
+ status: string;
246
+ publishAt?: string | Date | null;
247
+ }): boolean {
248
+ if (page.status !== "PUBLISHED") return false;
249
+ if (!page.publishAt) return true;
250
+ const at =
251
+ page.publishAt instanceof Date
252
+ ? page.publishAt.getTime()
253
+ : new Date(page.publishAt).getTime();
254
+ return at <= Date.now();
255
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import { PageNodeSchema } from "./pageNode.js";
3
+
4
+ export const PageTemplateSchema = z.object({
5
+ id: z.string(),
6
+ name: z.string(),
7
+ category: z.string(),
8
+ thumbnail: z.string().nullable().optional(),
9
+ content: PageNodeSchema,
10
+ createdAt: z.string().optional(),
11
+ });
12
+ export type PageTemplate = z.infer<typeof PageTemplateSchema>;
13
+
14
+ export const CreatePageTemplateInputSchema = z.object({
15
+ name: z.string().min(1).max(80),
16
+ category: z.string().min(1).max(40).optional(),
17
+ thumbnail: z.string().nullable().optional(),
18
+ content: PageNodeSchema,
19
+ });
20
+ export type CreatePageTemplateInput = z.infer<typeof CreatePageTemplateInputSchema>;
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+
3
+ export const SiteThemeSchema = z.object({
4
+ id: z.string().optional(),
5
+ primaryColor: z.string().default("#2563eb"),
6
+ secondaryColor: z.string().default("#0f172a"),
7
+ backgroundColor: z.string().default("#ffffff"),
8
+ textColor: z.string().default("#111827"),
9
+ headingFont: z.string().default("Poppins"),
10
+ bodyFont: z.string().default("Inter"),
11
+ logoUrl: z.string().nullable().optional(),
12
+ faviconUrl: z.string().nullable().optional(),
13
+ siteName: z.string().default("My Site"),
14
+ customCss: z.string().nullable().optional(),
15
+ darkModeEnabled: z.boolean().default(false),
16
+ primaryColorDark: z.string().nullable().optional(),
17
+ backgroundColorDark: z.string().nullable().optional(),
18
+ textColorDark: z.string().nullable().optional(),
19
+ analyticsProvider: z.enum(["none", "ga4", "plausible", "custom"]).default("none"),
20
+ analyticsId: z.string().nullable().optional(),
21
+ analyticsCustomScript: z.string().nullable().optional(),
22
+ });
23
+ export type SiteTheme = z.infer<typeof SiteThemeSchema>;
24
+
25
+ export const UpdateThemeInputSchema = SiteThemeSchema.partial();
26
+ export type UpdateThemeInput = z.infer<typeof UpdateThemeInputSchema>;
27
+
28
+ export const FONT_OPTIONS = [
29
+ "Inter",
30
+ "Poppins",
31
+ "Roboto",
32
+ "Montserrat",
33
+ "Playfair Display",
34
+ "Merriweather",
35
+ "Lato",
36
+ "Nunito",
37
+ "Oswald",
38
+ "Raleway",
39
+ ] as const;
40
+
41
+ export const NavItemSchema = z.object({
42
+ id: z.string(),
43
+ label: z.string(),
44
+ href: z.string(),
45
+ order: z.number(),
46
+ parentId: z.string().nullable().optional(),
47
+ });
48
+ export type NavItem = z.infer<typeof NavItemSchema>;
49
+
50
+ export const UpsertNavItemInputSchema = z.object({
51
+ label: z.string().min(1),
52
+ href: z.string().min(1),
53
+ order: z.number().default(0),
54
+ parentId: z.string().nullable().optional(),
55
+ });
56
+ export type UpsertNavItemInput = z.infer<typeof UpsertNavItemInputSchema>;
57
+
58
+ export type NavItemWithChildren = NavItem & { children: NavItem[] };
59
+
60
+ /** Groups flat nav items into top-level entries with their submenu items attached,
61
+ * each sorted by `order`. Only one level deep — a submenu item's own children (if any
62
+ * exist from bad data) are ignored, matching typical site nav patterns. */
63
+ export function buildNavTree(items: NavItem[]): NavItemWithChildren[] {
64
+ const topLevel = items.filter((n) => !n.parentId).sort((a, b) => a.order - b.order);
65
+ return topLevel.map((item) => ({
66
+ ...item,
67
+ children: items.filter((n) => n.parentId === item.id).sort((a, b) => a.order - b.order),
68
+ }));
69
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+
3
+ export const UserRoleSchema = z.enum(["ADMIN", "EDITOR", "AUTHOR"]);
4
+ export type UserRole = z.infer<typeof UserRoleSchema>;
5
+
6
+ export const UserSchema = z.object({
7
+ id: z.string(),
8
+ email: z.string().email(),
9
+ role: UserRoleSchema,
10
+ createdAt: z.string().optional(),
11
+ });
12
+ export type User = z.infer<typeof UserSchema>;
13
+
14
+ export const LoginInputSchema = z.object({
15
+ email: z.string().email(),
16
+ password: z.string().min(1),
17
+ });
18
+ export type LoginInput = z.infer<typeof LoginInputSchema>;
19
+
20
+ export const CreateUserInputSchema = z.object({
21
+ email: z.string().email(),
22
+ password: z.string().min(8),
23
+ role: UserRoleSchema.default("EDITOR"),
24
+ });
25
+ export type CreateUserInput = z.infer<typeof CreateUserInputSchema>;
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ createNode,
4
+ createEmptyPage,
5
+ insertNode,
6
+ removeNode,
7
+ moveNode,
8
+ duplicateNode,
9
+ findNode,
10
+ findParent,
11
+ cloneWithNewIds,
12
+ } from "./tree.js";
13
+
14
+ describe("tree helpers", () => {
15
+ it("creates an empty root page", () => {
16
+ const page = createEmptyPage();
17
+ expect(page.type).toBe("root");
18
+ expect(page.children).toEqual([]);
19
+ });
20
+
21
+ it("inserts and finds a node", () => {
22
+ const page = createEmptyPage();
23
+ const hero = createNode("hero");
24
+ const next = insertNode(page, page.id, hero, 0);
25
+ expect(findNode(next, hero.id)?.type).toBe("hero");
26
+ });
27
+
28
+ it("removes a node", () => {
29
+ const page = createEmptyPage();
30
+ const hero = createNode("hero");
31
+ const withHero = insertNode(page, page.id, hero, 0);
32
+ const without = removeNode(withHero, hero.id);
33
+ expect(findNode(without, hero.id)).toBeNull();
34
+ });
35
+
36
+ it("moves a node between parents", () => {
37
+ const page = createEmptyPage();
38
+ const container = createNode("container");
39
+ const col = container.children[0];
40
+ const hero = createNode("hero");
41
+ const cta = createNode("cta");
42
+ let tree = insertNode(page, page.id, container, 0);
43
+ tree = insertNode(tree, page.id, hero, 0);
44
+ tree = insertNode(tree, col.id, cta, 0);
45
+ tree = moveNode(tree, hero.id, col.id, 0);
46
+ expect(findParent(tree, hero.id)?.parent.id).toBe(col.id);
47
+ });
48
+
49
+ it("duplicates a subtree with fresh ids", () => {
50
+ const page = createEmptyPage();
51
+ const hero = createNode("hero");
52
+ const tree = insertNode(page, page.id, hero, 0);
53
+ const duped = duplicateNode(tree, hero.id);
54
+ const allHeroes = duped.children.filter((c) => c.type === "hero");
55
+ expect(allHeroes.length).toBe(2);
56
+ expect(allHeroes[0].id).not.toBe(allHeroes[1].id);
57
+ });
58
+
59
+ it("cloneWithNewIds produces independent copies", () => {
60
+ const hero = createNode("hero");
61
+ const copy = cloneWithNewIds(hero);
62
+ expect(copy.id).not.toBe(hero.id);
63
+ expect(copy.type).toBe("hero");
64
+ });
65
+ });