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,76 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { NavItem, NodeStyle, PageNode, SiteTheme } from "@pgcms/shared";
5
+ import { SiteDataContext } from "@/components/theme/SiteDataContext";
6
+ import { themeToCssVars } from "@/components/theme/ThemeProvider";
7
+ import { DroppableChildren } from "./DroppableChildren";
8
+ import { BuilderCanvasNode } from "./BuilderCanvasNode";
9
+ import { CanvasOverlayContext } from "./CanvasOverlayContext";
10
+
11
+ export function Canvas({
12
+ tree,
13
+ theme,
14
+ navItems,
15
+ selectedId,
16
+ onSelect,
17
+ onDelete,
18
+ onDuplicate,
19
+ onToggleHidden,
20
+ onChangeStyle,
21
+ onResizeGridTrack,
22
+ }: {
23
+ tree: PageNode;
24
+ theme: SiteTheme;
25
+ navItems: NavItem[];
26
+ selectedId: string | null;
27
+ onSelect: (id: string) => void;
28
+ onDelete: (id: string) => void;
29
+ onDuplicate: (id: string) => void;
30
+ onToggleHidden: (id: string) => void;
31
+ onChangeStyle: (id: string, style: NodeStyle) => void;
32
+ onResizeGridTrack?: (gridId: string, axis: "column" | "row", index: number, size: string) => void;
33
+ }) {
34
+ const [overlayEl, setOverlayEl] = useState<HTMLDivElement | null>(null);
35
+
36
+ return (
37
+ <div
38
+ className="flex-1 overflow-y-auto bg-slate-100"
39
+ onClick={() => onSelect("")}
40
+ // Real content (Logo, Nav Links, Buttons, CTA, and now linkable Containers/Columns)
41
+ // renders real <a>/<Link> elements straight from the same components used on the
42
+ // live site. A capture-phase preventDefault runs before Next.js's Link ever gets
43
+ // to its own bubble-phase click handler (which checks defaultPrevented and bails
44
+ // out), so clicking one here selects it for editing instead of navigating away.
45
+ onClickCapture={(e) => e.preventDefault()}
46
+ >
47
+ <div
48
+ className="relative isolate mx-auto bg-white min-h-full shadow-sm"
49
+ style={{ ...themeToCssVars(theme), fontFamily: "var(--theme-body-font)" }}
50
+ >
51
+ <SiteDataContext.Provider value={{ theme, navItems }}>
52
+ <CanvasOverlayContext.Provider value={overlayEl}>
53
+ <DroppableChildren parentId={tree.id} childIds={tree.children.map((c) => c.id)}>
54
+ {tree.children.map((child) => (
55
+ <BuilderCanvasNode
56
+ key={child.id}
57
+ node={child}
58
+ selectedId={selectedId}
59
+ onSelect={onSelect}
60
+ onDelete={onDelete}
61
+ onDuplicate={onDuplicate}
62
+ onToggleHidden={onToggleHidden}
63
+ onChangeStyle={onChangeStyle}
64
+ onResizeGridTrack={onResizeGridTrack}
65
+ />
66
+ ))}
67
+ </DroppableChildren>
68
+ </CanvasOverlayContext.Provider>
69
+ </SiteDataContext.Provider>
70
+ {/* Toolbar chips portal in here — a flat layer scrolling with the content,
71
+ immune to any ancestor's compositing/stacking-context quirks. */}
72
+ <div ref={setOverlayEl} className="absolute inset-0 z-40 pointer-events-none" />
73
+ </div>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,22 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ /**
6
+ * The scrollable canvas content area that floating toolbar chips portal into.
7
+ *
8
+ * Nesting a `position: absolute` toolbar deep inside the node tree isn't safe here:
9
+ * every node's wrapper carries dnd-kit's `transition`/`transform` styling for drag
10
+ * animation, and Chromium promotes elements like that to their own compositing layer
11
+ * whenever they're present — even at rest. That traps a nested toolbar's z-index
12
+ * inside its ancestor's layer instead of letting it win against sibling content
13
+ * elsewhere in the header/container (confirmed via document.elementFromPoint showing
14
+ * an unrelated ancestor div swallowing clicks meant for a deeply nested button).
15
+ * Portaling into one flat layer that sits alongside the scrollable content sidesteps
16
+ * the whole class of stacking bugs.
17
+ */
18
+ export const CanvasOverlayContext = createContext<HTMLDivElement | null>(null);
19
+
20
+ export function useCanvasOverlay() {
21
+ return useContext(CanvasOverlayContext);
22
+ }
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { HexAlphaColorPicker } from "react-colorful";
5
+
6
+ export function ColorPickerField({
7
+ label,
8
+ value,
9
+ onChange,
10
+ }: {
11
+ label: string;
12
+ value: string | undefined;
13
+ onChange: (value: string) => void;
14
+ }) {
15
+ const [open, setOpen] = useState(false);
16
+
17
+ return (
18
+ <div className="space-y-1 relative">
19
+ <label className="text-xs font-medium text-slate-600">{label}</label>
20
+ <div className="flex items-center gap-2">
21
+ <button
22
+ type="button"
23
+ onClick={() => setOpen((o) => !o)}
24
+ className="w-8 h-8 rounded border shrink-0"
25
+ style={{ backgroundColor: value || "#ffffff" }}
26
+ />
27
+ <input
28
+ value={value ?? ""}
29
+ onChange={(e) => onChange(e.target.value)}
30
+ placeholder="transparent"
31
+ className="flex-1 border rounded-md px-2 py-1.5 text-sm"
32
+ />
33
+ </div>
34
+ {open && (
35
+ <div className="absolute z-40 mt-1 bg-white border rounded-md shadow-lg p-2">
36
+ <HexAlphaColorPicker color={value || "#ffffff"} onChange={onChange} />
37
+ <button
38
+ className="text-xs text-slate-500 mt-2 w-full text-center"
39
+ onClick={() => setOpen(false)}
40
+ >
41
+ Close
42
+ </button>
43
+ </div>
44
+ )}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { useDroppable } from "@dnd-kit/core";
4
+ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
5
+ import clsx from "clsx";
6
+
7
+ export function DroppableChildren({
8
+ parentId,
9
+ childIds,
10
+ children,
11
+ transparent = false,
12
+ }: {
13
+ parentId: string;
14
+ childIds: string[];
15
+ children: React.ReactNode;
16
+ /**
17
+ * When true and non-empty, the wrapper renders with `display: contents` so its
18
+ * children (e.g. a Container's Column nodes) become direct layout children of
19
+ * whatever CSS grid/flex the surrounding section component declares — otherwise
20
+ * this wrapper div is the grid/flex's only item and everything collapses into one
21
+ * cell. Left opaque while empty so it stays a real, measurable drop target (a
22
+ * `display: contents` element has no box for dnd-kit to hit-test against).
23
+ */
24
+ transparent?: boolean;
25
+ }) {
26
+ const { setNodeRef, isOver } = useDroppable({
27
+ id: `dropzone:${parentId}`,
28
+ data: { type: "container", parentId },
29
+ });
30
+
31
+ const isEmpty = childIds.length === 0;
32
+ const useTransparent = transparent && !isEmpty;
33
+
34
+ return (
35
+ <SortableContext items={childIds} strategy={verticalListSortingStrategy}>
36
+ <div
37
+ ref={setNodeRef}
38
+ style={useTransparent ? { display: "contents" } : undefined}
39
+ className={clsx(!useTransparent && "min-h-[48px]", !useTransparent && isOver && "bg-blue-50/60")}
40
+ >
41
+ {isEmpty && (
42
+ <div className="text-xs text-slate-400 border border-dashed rounded p-6 text-center m-2">
43
+ Drop components here
44
+ </div>
45
+ )}
46
+ {children}
47
+ </div>
48
+ </SortableContext>
49
+ );
50
+ }
@@ -0,0 +1,277 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, Video as VideoIcon } from "lucide-react";
5
+ import type { FieldDescriptor } from "@pgcms/shared";
6
+ import { RichTextEditor } from "./RichTextEditor";
7
+ import { MediaPickerModal } from "./MediaPickerModal";
8
+ import { ColorPickerField } from "./ColorPickerField";
9
+
10
+ export function FieldRenderer({
11
+ field,
12
+ value,
13
+ onChange,
14
+ }: {
15
+ field: FieldDescriptor;
16
+ value: unknown;
17
+ onChange: (value: unknown) => void;
18
+ }) {
19
+ const [pickerOpen, setPickerOpen] = useState(false);
20
+
21
+ switch (field.type) {
22
+ case "text":
23
+ return (
24
+ <LabeledField label={field.label}>
25
+ <input
26
+ value={(value as string) ?? ""}
27
+ placeholder={field.placeholder}
28
+ onChange={(e) => onChange(e.target.value)}
29
+ className="w-full border rounded-md px-2 py-1.5 text-sm"
30
+ />
31
+ </LabeledField>
32
+ );
33
+ case "textarea":
34
+ return (
35
+ <LabeledField label={field.label}>
36
+ <textarea
37
+ value={(value as string) ?? ""}
38
+ placeholder={field.placeholder}
39
+ onChange={(e) => onChange(e.target.value)}
40
+ rows={3}
41
+ className="w-full border rounded-md px-2 py-1.5 text-sm"
42
+ />
43
+ </LabeledField>
44
+ );
45
+ case "url":
46
+ return (
47
+ <LabeledField label={field.label}>
48
+ <input
49
+ value={(value as string) ?? ""}
50
+ placeholder={field.placeholder ?? "https://"}
51
+ onChange={(e) => onChange(e.target.value)}
52
+ className="w-full border rounded-md px-2 py-1.5 text-sm"
53
+ />
54
+ </LabeledField>
55
+ );
56
+ case "datetime":
57
+ return (
58
+ <LabeledField label={field.label}>
59
+ <input
60
+ type="datetime-local"
61
+ value={(value as string) ?? ""}
62
+ onChange={(e) => onChange(e.target.value)}
63
+ className="w-full border rounded-md px-2 py-1.5 text-sm"
64
+ />
65
+ </LabeledField>
66
+ );
67
+ case "number":
68
+ return (
69
+ <LabeledField label={field.label}>
70
+ <input
71
+ type="number"
72
+ min={field.min}
73
+ max={field.max}
74
+ step={field.step ?? 1}
75
+ value={(value as number) ?? 0}
76
+ onChange={(e) => onChange(Number(e.target.value))}
77
+ className="w-full border rounded-md px-2 py-1.5 text-sm"
78
+ />
79
+ </LabeledField>
80
+ );
81
+ case "boolean":
82
+ return (
83
+ <label className="flex items-center gap-2 text-sm">
84
+ <input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} />
85
+ {field.label}
86
+ </label>
87
+ );
88
+ case "select":
89
+ return (
90
+ <LabeledField label={field.label}>
91
+ <select
92
+ value={(value as string) ?? ""}
93
+ onChange={(e) => onChange(e.target.value)}
94
+ className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
95
+ >
96
+ {field.options?.map((opt) => (
97
+ <option key={opt.value} value={opt.value}>
98
+ {opt.label}
99
+ </option>
100
+ ))}
101
+ </select>
102
+ </LabeledField>
103
+ );
104
+ case "color":
105
+ return <ColorPickerField label={field.label} value={value as string} onChange={onChange} />;
106
+ case "richtext":
107
+ return (
108
+ <LabeledField label={field.label}>
109
+ <RichTextEditor value={(value as string) ?? ""} onChange={onChange} />
110
+ </LabeledField>
111
+ );
112
+ case "image":
113
+ return (
114
+ <LabeledField label={field.label}>
115
+ <MediaField
116
+ kind="image"
117
+ value={value as string}
118
+ onChange={onChange}
119
+ open={pickerOpen}
120
+ setOpen={setPickerOpen}
121
+ />
122
+ </LabeledField>
123
+ );
124
+ case "video":
125
+ return (
126
+ <LabeledField label={field.label}>
127
+ <MediaField
128
+ kind="video"
129
+ value={value as string}
130
+ onChange={onChange}
131
+ open={pickerOpen}
132
+ setOpen={setPickerOpen}
133
+ />
134
+ </LabeledField>
135
+ );
136
+ case "array":
137
+ return (
138
+ <ArrayField
139
+ field={field}
140
+ value={(value as Record<string, unknown>[]) ?? []}
141
+ onChange={onChange as (v: Record<string, unknown>[]) => void}
142
+ />
143
+ );
144
+ default:
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function LabeledField({ label, children }: { label: string; children: React.ReactNode }) {
150
+ return (
151
+ <div className="space-y-1">
152
+ <label className="text-xs font-medium text-slate-600">{label}</label>
153
+ {children}
154
+ </div>
155
+ );
156
+ }
157
+
158
+ function MediaField({
159
+ kind,
160
+ value,
161
+ onChange,
162
+ open,
163
+ setOpen,
164
+ }: {
165
+ kind: "image" | "video";
166
+ value: string;
167
+ onChange: (v: string) => void;
168
+ open: boolean;
169
+ setOpen: (v: boolean) => void;
170
+ }) {
171
+ return (
172
+ <div className="space-y-2">
173
+ {value ? (
174
+ <div className="relative border rounded-md overflow-hidden h-28 bg-slate-50">
175
+ {kind === "image" ? (
176
+ // eslint-disable-next-line @next/next/no-img-element
177
+ <img src={value} alt="" className="w-full h-full object-cover" />
178
+ ) : (
179
+ <video src={value} className="w-full h-full object-cover" muted />
180
+ )}
181
+ </div>
182
+ ) : null}
183
+ <button
184
+ type="button"
185
+ onClick={() => setOpen(true)}
186
+ className="w-full flex items-center justify-center gap-2 border-2 border-dashed rounded-md py-2 text-xs text-slate-600 hover:border-blue-400"
187
+ >
188
+ {kind === "image" ? <ImagePlus size={14} /> : <VideoIcon size={14} />}
189
+ {value ? "Change" : "Select"} {kind}
190
+ </button>
191
+ {open && (
192
+ <MediaPickerModal
193
+ accept={kind}
194
+ onSelect={(url) => {
195
+ onChange(url);
196
+ setOpen(false);
197
+ }}
198
+ onClose={() => setOpen(false)}
199
+ />
200
+ )}
201
+ </div>
202
+ );
203
+ }
204
+
205
+ function ArrayField({
206
+ field,
207
+ value,
208
+ onChange,
209
+ }: {
210
+ field: FieldDescriptor;
211
+ value: Record<string, unknown>[];
212
+ onChange: (v: Record<string, unknown>[]) => void;
213
+ }) {
214
+ const itemFields = field.itemFields ?? [];
215
+
216
+ function updateItem(index: number, key: string, itemValue: unknown) {
217
+ const next = value.map((item, i) => (i === index ? { ...item, [key]: itemValue } : item));
218
+ onChange(next);
219
+ }
220
+
221
+ function addItem() {
222
+ const blank: Record<string, unknown> = {};
223
+ for (const f of itemFields) blank[f.key] = f.default ?? "";
224
+ onChange([...value, blank]);
225
+ }
226
+
227
+ function removeItem(index: number) {
228
+ onChange(value.filter((_, i) => i !== index));
229
+ }
230
+
231
+ function move(index: number, dir: -1 | 1) {
232
+ const target = index + dir;
233
+ if (target < 0 || target >= value.length) return;
234
+ const next = [...value];
235
+ [next[index], next[target]] = [next[target], next[index]];
236
+ onChange(next);
237
+ }
238
+
239
+ return (
240
+ <div className="space-y-2">
241
+ <div className="flex items-center justify-between">
242
+ <label className="text-xs font-medium text-slate-600">{field.label}</label>
243
+ <button
244
+ type="button"
245
+ onClick={addItem}
246
+ className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700"
247
+ >
248
+ <Plus size={12} /> Add
249
+ </button>
250
+ </div>
251
+ <div className="space-y-3">
252
+ {value.map((item, index) => (
253
+ <div key={index} className="border rounded-md p-2 space-y-2 bg-slate-50">
254
+ <div className="flex items-center justify-between">
255
+ <span className="text-xs text-slate-400">#{index + 1}</span>
256
+ <div className="flex items-center gap-1">
257
+ <button type="button" onClick={() => move(index, -1)} className="text-slate-400 hover:text-slate-700">
258
+ <ChevronUp size={14} />
259
+ </button>
260
+ <button type="button" onClick={() => move(index, 1)} className="text-slate-400 hover:text-slate-700">
261
+ <ChevronDown size={14} />
262
+ </button>
263
+ <button type="button" onClick={() => removeItem(index)} className="text-slate-400 hover:text-red-600">
264
+ <Trash2 size={14} />
265
+ </button>
266
+ </div>
267
+ </div>
268
+ {itemFields.map((f) => (
269
+ <FieldRenderer key={f.key} field={f} value={item[f.key]} onChange={(v) => updateItem(index, f.key, v)} />
270
+ ))}
271
+ </div>
272
+ ))}
273
+ {value.length === 0 && <div className="text-xs text-slate-400 italic">No items yet</div>}
274
+ </div>
275
+ </div>
276
+ );
277
+ }
@@ -0,0 +1,7 @@
1
+ import * as Icons from "lucide-react";
2
+ import type { LucideProps } from "lucide-react";
3
+
4
+ export function IconRenderer({ name, ...props }: { name: string } & LucideProps) {
5
+ const Icon = (Icons as unknown as Record<string, React.ComponentType<LucideProps>>)[name] ?? Icons.Square;
6
+ return <Icon {...props} />;
7
+ }