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,956 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import clsx from "clsx";
5
+ import { ChevronDown, Code2 } from "lucide-react";
6
+ import { FONT_OPTIONS, getComponentDefinition, type Breakpoint, type NodeStyle, type PageNode } from "@pgcms/shared";
7
+ import { FieldRenderer } from "./FieldRenderer";
8
+ import { ColorPickerField } from "./ColorPickerField";
9
+ import { MediaPickerModal } from "./MediaPickerModal";
10
+ import { KeyValueListEditor } from "./KeyValueListEditor";
11
+ import { JsonEditor } from "./JsonEditor";
12
+
13
+ type Tab = "content" | "style" | "json";
14
+
15
+ const ANIMATION_TYPES: { label: string; value: NonNullable<NodeStyle["animationType"]> }[] = [
16
+ { label: "None (off)", value: "none" },
17
+ { label: "Fade In", value: "fadeIn" },
18
+ { label: "Slide Up", value: "slideUp" },
19
+ { label: "Slide Down", value: "slideDown" },
20
+ { label: "Slide Left", value: "slideLeft" },
21
+ { label: "Slide Right", value: "slideRight" },
22
+ { label: "Zoom In", value: "zoomIn" },
23
+ { label: "Rotate In", value: "rotateIn" },
24
+ { label: "Pulse", value: "pulse" },
25
+ { label: "Scroll Reveal", value: "scrollReveal" },
26
+ { label: "Blur In", value: "blurIn" },
27
+ { label: "Flip In", value: "flipIn" },
28
+ { label: "Bounce In", value: "bounceIn" },
29
+ ];
30
+
31
+ const HOVER_EFFECTS: { label: string; value: NonNullable<NodeStyle["hoverEffect"]>; hint: string }[] = [
32
+ { label: "None", value: "none", hint: "" },
33
+ { label: "Lift", value: "lift", hint: "Rises with a soft shadow on hover." },
34
+ { label: "3D Tilt", value: "tilt", hint: "Tilts in 3D toward the pointer as it moves." },
35
+ { label: "Glow", value: "glow", hint: "A soft glow in the theme's primary color on hover." },
36
+ { label: "Scale", value: "scale", hint: "Gently grows on hover." },
37
+ ];
38
+
39
+ const ANIMATION_TRIGGERS: { label: string; value: NonNullable<NodeStyle["animationTrigger"]>; hint: string }[] = [
40
+ { label: "On Load", value: "onLoad", hint: "Plays once, right away, when the page loads." },
41
+ { label: "On Scroll", value: "onScroll", hint: "Plays once, the first time this section scrolls into view." },
42
+ { label: "On Hover", value: "onHover", hint: "Plays while the mouse is over it, reverses on mouse-leave." },
43
+ { label: "On Focus", value: "onFocus", hint: "Plays while a button/link/input inside it has keyboard focus." },
44
+ { label: "On Click", value: "onClick", hint: "Replays from the start every time it's clicked." },
45
+ ];
46
+
47
+ const EASE_PRESETS = [
48
+ { label: "Smooth (power2.out)", value: "power2.out" },
49
+ { label: "Gentle (power1.out)", value: "power1.out" },
50
+ { label: "Sharp start (power2.in)", value: "power2.in" },
51
+ { label: "Smooth both ends (power2.inOut)", value: "power2.inOut" },
52
+ { label: "Overshoot (back.out)", value: "back.out(1.7)" },
53
+ { label: "Elastic", value: "elastic.out(1,0.5)" },
54
+ { label: "Bounce", value: "bounce.out" },
55
+ { label: "Linear", value: "linear" },
56
+ ];
57
+ const CUSTOM_EASE = "__custom__";
58
+
59
+ export function Inspector({
60
+ node,
61
+ device = "desktop",
62
+ ancestors,
63
+ onSelect,
64
+ onChangeProps,
65
+ onChangeStyle,
66
+ onChangeColumnCount,
67
+ onChangeZoneCount,
68
+ onChangeGridDimensions,
69
+ onEnableHeaderZones,
70
+ }: {
71
+ node: PageNode | null;
72
+ /** The breakpoint currently being edited — style changes land in this device's
73
+ * override layer (see the builder page's handleChangeStyle). */
74
+ device?: Breakpoint;
75
+ /** Chain from outermost ancestor down to `node` itself, for the breadcrumb. */
76
+ ancestors: PageNode[];
77
+ onSelect: (id: string) => void;
78
+ onChangeProps: (props: Record<string, unknown>) => void;
79
+ onChangeStyle: (style: NodeStyle) => void;
80
+ onChangeColumnCount: (count: number) => void;
81
+ onChangeZoneCount: (count: number) => void;
82
+ onChangeGridDimensions: (rows: number, columns: number) => void;
83
+ onEnableHeaderZones: () => void;
84
+ }) {
85
+ const [tab, setTab] = useState<Tab>("content");
86
+ const [bgImagePicker, setBgImagePicker] = useState(false);
87
+ const [bgVideoPicker, setBgVideoPicker] = useState(false);
88
+ const [devMode, setDevMode] = useState(false);
89
+
90
+ useEffect(() => {
91
+ setDevMode(localStorage.getItem("pgcms_dev_mode") === "1");
92
+ }, []);
93
+
94
+ function toggleDevMode() {
95
+ const next = !devMode;
96
+ setDevMode(next);
97
+ localStorage.setItem("pgcms_dev_mode", next ? "1" : "0");
98
+ if (!next && tab === "json") setTab("content");
99
+ }
100
+
101
+ if (!node) {
102
+ return (
103
+ <div className="w-80 shrink-0 border-l bg-white p-6 text-sm text-slate-400">
104
+ Select a component on the canvas to edit its content and style.
105
+ </div>
106
+ );
107
+ }
108
+
109
+ const def = getComponentDefinition(node.type);
110
+
111
+ function setProp(key: string, value: unknown) {
112
+ onChangeProps({ ...node!.props, [key]: value });
113
+ }
114
+
115
+ function setStyle<K extends keyof NodeStyle>(key: K, value: NodeStyle[K]) {
116
+ onChangeStyle({ ...node!.style, [key]: value });
117
+ }
118
+
119
+ return (
120
+ <div className="w-80 shrink-0 border-l bg-white overflow-y-auto">
121
+ <div className="px-4 py-3 border-b">
122
+ <div className="flex items-center justify-between">
123
+ <div className="font-semibold text-sm">{def?.label ?? node.type}</div>
124
+ <button
125
+ onClick={toggleDevMode}
126
+ title={devMode ? "Turn off developer mode" : "Turn on developer mode (raw JSON editing)"}
127
+ className={clsx(
128
+ "p-1 rounded-md",
129
+ devMode ? "text-blue-600 bg-blue-50" : "text-slate-300 hover:text-slate-500"
130
+ )}
131
+ >
132
+ <Code2 size={14} />
133
+ </button>
134
+ </div>
135
+ {ancestors.length > 1 && (
136
+ <div className="flex items-center gap-1 mt-1 text-xs text-slate-500 flex-wrap">
137
+ {ancestors.map((ancestor, i) => {
138
+ const ancestorDef = getComponentDefinition(ancestor.type);
139
+ const isLast = i === ancestors.length - 1;
140
+ return (
141
+ <span key={ancestor.id} className="flex items-center gap-1">
142
+ {i > 0 && <span className="text-slate-300">/</span>}
143
+ <button
144
+ onClick={() => onSelect(ancestor.id)}
145
+ disabled={isLast}
146
+ className={isLast ? "text-slate-700 font-medium cursor-default" : "hover:text-blue-600 hover:underline"}
147
+ title={isLast ? undefined : `Select parent: ${ancestorDef?.label ?? ancestor.type}`}
148
+ >
149
+ {ancestorDef?.label ?? ancestor.type}
150
+ </button>
151
+ </span>
152
+ );
153
+ })}
154
+ </div>
155
+ )}
156
+ </div>
157
+ <div className="flex border-b">
158
+ {(devMode ? (["content", "style", "json"] as Tab[]) : (["content", "style"] as Tab[])).map((t) => (
159
+ <button
160
+ key={t}
161
+ onClick={() => setTab(t)}
162
+ className={clsx(
163
+ "flex-1 py-2 text-sm font-medium capitalize",
164
+ tab === t ? "text-blue-600 border-b-2 border-blue-600" : "text-slate-500"
165
+ )}
166
+ >
167
+ {t}
168
+ </button>
169
+ ))}
170
+ </div>
171
+
172
+ {tab === "content" && (
173
+ <div className="p-4 space-y-4">
174
+ {node.type === "header" && node.children.length === 0 && (
175
+ <div className="space-y-2 border rounded-md p-3 bg-amber-50 border-amber-200">
176
+ <p className="text-xs text-slate-600">
177
+ This header predates the flexible layout. Upgrade it to get custom drop zones you can fill with a
178
+ Logo, Nav Links, Buttons, or anything else.
179
+ </p>
180
+ <button
181
+ onClick={onEnableHeaderZones}
182
+ className="w-full bg-slate-900 text-white rounded-md py-1.5 text-xs font-medium hover:bg-slate-800"
183
+ >
184
+ Enable custom zones
185
+ </button>
186
+ </div>
187
+ )}
188
+ {node.type === "container" && (
189
+ <CountStepper
190
+ label="Columns"
191
+ value={Number(node.props.columns) || 1}
192
+ min={1}
193
+ max={4}
194
+ onChange={onChangeColumnCount}
195
+ hint="Each column is its own drop zone — add or reorder components inside it independently. Lowering the count won't delete a column that already has content; remove it from the canvas instead."
196
+ />
197
+ )}
198
+ {node.type === "grid" && (
199
+ <>
200
+ <CountStepper
201
+ label="Columns"
202
+ value={Number(node.props.columns) || 2}
203
+ min={1}
204
+ max={12}
205
+ onChange={(cols) => onChangeGridDimensions(Number(node.props.rows) || 1, cols)}
206
+ hint="Each cell is its own drop zone. Lowering the count won't delete cells that already have content; remove them from the canvas instead."
207
+ />
208
+ <CountStepper
209
+ label="Rows"
210
+ value={Number(node.props.rows) || 1}
211
+ min={1}
212
+ max={12}
213
+ onChange={(rows) => onChangeGridDimensions(rows, Number(node.props.columns) || 2)}
214
+ hint="A cell spanning multiple columns/rows (set on the cell itself, in its Style tab) takes up more room, pushing later cells to wrap — same as any CSS grid."
215
+ />
216
+ </>
217
+ )}
218
+ {node.type === "header" && node.children.length > 0 && (
219
+ <CountStepper
220
+ label="Zones"
221
+ value={Number(node.props.zoneCount) || node.children.length}
222
+ min={1}
223
+ max={6}
224
+ onChange={onChangeZoneCount}
225
+ hint="Each zone is its own drop zone — the first hugs the left, the last hugs the right, and any in between share and center the space. Use the eye icon on a zone's contents to hide them without deleting."
226
+ />
227
+ )}
228
+ {def?.fields.map((field) => (
229
+ <FieldRenderer
230
+ key={field.key}
231
+ field={field}
232
+ value={node.props[field.key]}
233
+ onChange={(v) => setProp(field.key, v)}
234
+ />
235
+ ))}
236
+ {(!def || def.fields.length === 0) && (
237
+ <div className="text-sm text-slate-400">This component has no editable content.</div>
238
+ )}
239
+ </div>
240
+ )}
241
+
242
+ {tab === "style" && (
243
+ <div className="p-4 space-y-3">
244
+ {device !== "desktop" && (
245
+ <div className="text-xs bg-blue-50 border border-blue-200 text-blue-800 rounded-md px-3 py-2">
246
+ Editing <strong>{device}</strong> styles — changes here only apply at {device === "tablet" ? "≤1023px" : "≤767px"},
247
+ overriding the desktop values. Clearing a field reverts to the desktop value.
248
+ </div>
249
+ )}
250
+ <StyleGroup title="Basics" defaultOpen>
251
+ <ColorPickerField
252
+ label="Background color"
253
+ value={node.style.backgroundColor}
254
+ onChange={(v) => setStyle("backgroundColor", v)}
255
+ />
256
+ <ColorPickerField
257
+ label="Text color"
258
+ value={node.style.textColor}
259
+ onChange={(v) => setStyle("textColor", v)}
260
+ />
261
+
262
+ <div className="space-y-1">
263
+ <label className="text-xs font-medium text-slate-600">Font</label>
264
+ <select
265
+ value={node.style.fontFamily ?? ""}
266
+ onChange={(e) => setStyle("fontFamily", e.target.value || undefined)}
267
+ className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
268
+ >
269
+ <option value="">Theme default</option>
270
+ {FONT_OPTIONS.map((f) => (
271
+ <option key={f} value={f}>
272
+ {f}
273
+ </option>
274
+ ))}
275
+ </select>
276
+ </div>
277
+
278
+ <div className="space-y-1">
279
+ <label className="text-xs font-medium text-slate-600">Text align</label>
280
+ <div className="grid grid-cols-3 gap-1">
281
+ {(["left", "center", "right"] as const).map((align) => (
282
+ <button
283
+ key={align}
284
+ onClick={() => setStyle("textAlign", align)}
285
+ className={clsx(
286
+ "border rounded-md py-1.5 text-xs capitalize",
287
+ node.style.textAlign === align ? "bg-blue-50 border-blue-400 text-blue-700" : "bg-white"
288
+ )}
289
+ >
290
+ {align}
291
+ </button>
292
+ ))}
293
+ </div>
294
+ </div>
295
+
296
+ <div className="grid grid-cols-2 gap-3">
297
+ <TextInput label="Padding Y" value={node.style.paddingY} onChange={(v) => setStyle("paddingY", v)} />
298
+ <TextInput label="Padding X" value={node.style.paddingX} onChange={(v) => setStyle("paddingX", v)} />
299
+ <TextInput
300
+ label="Border radius"
301
+ value={node.style.borderRadius}
302
+ onChange={(v) => setStyle("borderRadius", v)}
303
+ />
304
+ <TextInput label="Font size" value={node.style.fontSize} onChange={(v) => setStyle("fontSize", v)} />
305
+ </div>
306
+
307
+ <details className="text-xs">
308
+ <summary className="cursor-pointer text-slate-500 hover:text-slate-700 font-medium">
309
+ Per-side spacing (overrides Y/X)
310
+ </summary>
311
+ <div className="grid grid-cols-2 gap-3 mt-2">
312
+ <TextInput label="Padding top" value={node.style.paddingTop} onChange={(v) => setStyle("paddingTop", v)} />
313
+ <TextInput label="Padding bottom" value={node.style.paddingBottom} onChange={(v) => setStyle("paddingBottom", v)} />
314
+ <TextInput label="Padding left" value={node.style.paddingLeft} onChange={(v) => setStyle("paddingLeft", v)} />
315
+ <TextInput label="Padding right" value={node.style.paddingRight} onChange={(v) => setStyle("paddingRight", v)} />
316
+ <TextInput label="Margin top" value={node.style.marginTop} onChange={(v) => setStyle("marginTop", v)} />
317
+ <TextInput label="Margin bottom" value={node.style.marginBottom} onChange={(v) => setStyle("marginBottom", v)} />
318
+ </div>
319
+ </details>
320
+
321
+ {(node.type === "container" || node.type === "column" || node.type === "grid" || node.type === "gridCell") && (
322
+ <div className="grid grid-cols-2 gap-3">
323
+ <div className="space-y-1">
324
+ <label className="text-xs font-medium text-slate-600">Align items</label>
325
+ <select
326
+ value={node.style.alignItems ?? ""}
327
+ onChange={(e) => setStyle("alignItems", (e.target.value || undefined) as NodeStyle["alignItems"])}
328
+ className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
329
+ >
330
+ <option value="">Default</option>
331
+ <option value="start">Top</option>
332
+ <option value="center">Center</option>
333
+ <option value="end">Bottom</option>
334
+ <option value="stretch">Stretch</option>
335
+ </select>
336
+ </div>
337
+ <div className="space-y-1">
338
+ <label className="text-xs font-medium text-slate-600">Justify</label>
339
+ <select
340
+ value={node.style.justifyContent ?? ""}
341
+ onChange={(e) => setStyle("justifyContent", (e.target.value || undefined) as NodeStyle["justifyContent"])}
342
+ className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
343
+ >
344
+ <option value="">Default</option>
345
+ <option value="start">Start</option>
346
+ <option value="center">Center</option>
347
+ <option value="end">End</option>
348
+ <option value="between">Space between</option>
349
+ </select>
350
+ </div>
351
+ </div>
352
+ )}
353
+
354
+ {node.type === "gridCell" && (
355
+ <div className="space-y-1">
356
+ <label className="text-xs font-medium text-slate-600">Grid placement</label>
357
+ <p className="text-xs text-slate-400">How many of the parent Grid&apos;s tracks this cell occupies.</p>
358
+ <div className="grid grid-cols-2 gap-3">
359
+ <NumberInput
360
+ label="Column span"
361
+ value={node.style.gridColumnSpan}
362
+ fallback={1}
363
+ min={1}
364
+ onChange={(v) => setStyle("gridColumnSpan", v)}
365
+ />
366
+ <NumberInput
367
+ label="Row span"
368
+ value={node.style.gridRowSpan}
369
+ fallback={1}
370
+ min={1}
371
+ onChange={(v) => setStyle("gridRowSpan", v)}
372
+ />
373
+ </div>
374
+ </div>
375
+ )}
376
+
377
+ <div className="space-y-2">
378
+ <label className="text-xs font-medium text-slate-600">Size</label>
379
+ <p className="text-xs text-slate-400">
380
+ Any dimension, any unit — the canvas resize handles adjust these too, keeping your unit.
381
+ </p>
382
+ <div className="grid grid-cols-2 gap-3">
383
+ <SizeInput label="Width" value={node.style.width} onChange={(v) => setStyle("width", v)} />
384
+ <SizeInput label="Height" value={node.style.height} onChange={(v) => setStyle("height", v)} />
385
+ <SizeInput label="Min width" value={node.style.minWidth} onChange={(v) => setStyle("minWidth", v)} />
386
+ <SizeInput label="Min height" value={node.style.minHeight} onChange={(v) => setStyle("minHeight", v)} />
387
+ <SizeInput label="Max width" value={node.style.maxWidth} onChange={(v) => setStyle("maxWidth", v)} />
388
+ <SizeInput label="Max height" value={node.style.maxHeight} onChange={(v) => setStyle("maxHeight", v)} />
389
+ </div>
390
+ </div>
391
+ </StyleGroup>
392
+
393
+ <StyleGroup title="Background">
394
+ <p className="text-xs text-slate-400">
395
+ Layers stack: color, then image, then video, then the tint overlay — with the section&apos;s own
396
+ components rendering on top. Any section can combine all of them.
397
+ </p>
398
+ <div className="space-y-2">
399
+ <label className="text-xs text-slate-500">Background image</label>
400
+ {node.style.backgroundImage && (
401
+ // eslint-disable-next-line @next/next/no-img-element
402
+ <img src={node.style.backgroundImage} alt="" className="w-full h-24 object-cover rounded-md border" />
403
+ )}
404
+ <div className="flex gap-2">
405
+ <button
406
+ onClick={() => setBgImagePicker(true)}
407
+ className="flex-1 border-2 border-dashed rounded-md py-2 text-xs text-slate-600 hover:border-blue-400"
408
+ >
409
+ {node.style.backgroundImage ? "Change" : "Select"} image
410
+ </button>
411
+ {node.style.backgroundImage && (
412
+ <button
413
+ onClick={() => setStyle("backgroundImage", undefined)}
414
+ className="px-3 border rounded-md text-xs text-slate-500 hover:text-red-600"
415
+ >
416
+ Remove
417
+ </button>
418
+ )}
419
+ </div>
420
+ {bgImagePicker && (
421
+ <MediaPickerModal
422
+ accept="image"
423
+ onSelect={(url) => {
424
+ setStyle("backgroundImage", url);
425
+ setBgImagePicker(false);
426
+ }}
427
+ onClose={() => setBgImagePicker(false)}
428
+ />
429
+ )}
430
+ </div>
431
+ <div className="space-y-2">
432
+ <label className="text-xs text-slate-500">Background video</label>
433
+ {node.style.backgroundVideo && (
434
+ <video src={node.style.backgroundVideo} className="w-full h-24 object-cover rounded-md border" muted />
435
+ )}
436
+ <div className="flex gap-2">
437
+ <button
438
+ onClick={() => setBgVideoPicker(true)}
439
+ className="flex-1 border-2 border-dashed rounded-md py-2 text-xs text-slate-600 hover:border-blue-400"
440
+ >
441
+ {node.style.backgroundVideo ? "Change" : "Select"} video
442
+ </button>
443
+ {node.style.backgroundVideo && (
444
+ <button
445
+ onClick={() => setStyle("backgroundVideo", undefined)}
446
+ className="px-3 border rounded-md text-xs text-slate-500 hover:text-red-600"
447
+ >
448
+ Remove
449
+ </button>
450
+ )}
451
+ </div>
452
+ {bgVideoPicker && (
453
+ <MediaPickerModal
454
+ accept="video"
455
+ onSelect={(url) => {
456
+ setStyle("backgroundVideo", url);
457
+ setBgVideoPicker(false);
458
+ }}
459
+ onClose={() => setBgVideoPicker(false)}
460
+ />
461
+ )}
462
+ </div>
463
+ <ColorPickerField
464
+ label="Overlay tint"
465
+ value={node.style.backgroundOverlay}
466
+ onChange={(v) => setStyle("backgroundOverlay", v || undefined)}
467
+ />
468
+ </StyleGroup>
469
+
470
+ <StyleGroup title="Border">
471
+ <div className="grid grid-cols-2 gap-3">
472
+ <div className="space-y-1">
473
+ <label className="text-xs text-slate-500">Style</label>
474
+ <select
475
+ value={node.style.borderStyle ?? "none"}
476
+ onChange={(e) =>
477
+ setStyle(
478
+ "borderStyle",
479
+ e.target.value === "none" ? undefined : (e.target.value as NodeStyle["borderStyle"])
480
+ )
481
+ }
482
+ className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
483
+ >
484
+ {(["none", "solid", "dashed", "dotted", "double"] as const).map((s) => (
485
+ <option key={s} value={s}>
486
+ {s}
487
+ </option>
488
+ ))}
489
+ </select>
490
+ </div>
491
+ <TextInput label="Width" value={node.style.borderWidth} onChange={(v) => setStyle("borderWidth", v)} />
492
+ </div>
493
+ <ColorPickerField
494
+ label="Border color"
495
+ value={node.style.borderColor}
496
+ onChange={(v) => setStyle("borderColor", v)}
497
+ />
498
+ </StyleGroup>
499
+
500
+ <StyleGroup title="Shadow & Opacity">
501
+ <div className="space-y-1">
502
+ <label className="text-xs text-slate-500">Shadow</label>
503
+ <div className="grid grid-cols-5 gap-1">
504
+ {(["none", "sm", "md", "lg", "xl"] as const).map((s) => (
505
+ <button
506
+ key={s}
507
+ onClick={() => setStyle("boxShadow", s === "none" ? undefined : s)}
508
+ className={clsx(
509
+ "border rounded-md py-1.5 text-xs uppercase",
510
+ (node.style.boxShadow ?? "none") === s ? "bg-blue-50 border-blue-400 text-blue-700" : "bg-white"
511
+ )}
512
+ >
513
+ {s}
514
+ </button>
515
+ ))}
516
+ </div>
517
+ </div>
518
+ <div className="space-y-1">
519
+ <label className="text-xs text-slate-500">
520
+ Opacity{node.style.opacity !== undefined ? ` (${Math.round(node.style.opacity * 100)}%)` : ""}
521
+ </label>
522
+ <input
523
+ type="range"
524
+ min={0}
525
+ max={1}
526
+ step={0.05}
527
+ value={node.style.opacity ?? 1}
528
+ onChange={(e) => setStyle("opacity", Number(e.target.value) === 1 ? undefined : Number(e.target.value))}
529
+ className="w-full"
530
+ />
531
+ </div>
532
+ </StyleGroup>
533
+
534
+ <StyleGroup title="Animation">
535
+ <p className="text-xs text-slate-400">
536
+ Plays on the live site (GSAP) — the builder canvas always shows the settled final state.
537
+ </p>
538
+ <div className="space-y-1">
539
+ <label className="text-xs text-slate-500">Effect</label>
540
+ <select
541
+ value={node.style.animationType ?? "none"}
542
+ onChange={(e) =>
543
+ setStyle(
544
+ "animationType",
545
+ e.target.value === "none" ? undefined : (e.target.value as NodeStyle["animationType"])
546
+ )
547
+ }
548
+ className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
549
+ >
550
+ {ANIMATION_TYPES.map((a) => (
551
+ <option key={a.value} value={a.value}>
552
+ {a.label}
553
+ </option>
554
+ ))}
555
+ </select>
556
+ </div>
557
+ {node.style.animationType && node.style.animationType !== "none" && (() => {
558
+ const activeTrigger = node.style.animationTrigger ?? "onScroll";
559
+ const triggerInfo = ANIMATION_TRIGGERS.find((t) => t.value === activeTrigger);
560
+ const isLoopable = activeTrigger === "onLoad" || activeTrigger === "onScroll";
561
+ const isCustomEase =
562
+ !!node.style.animationEasing && !EASE_PRESETS.some((p) => p.value === node.style.animationEasing);
563
+ return (
564
+ <>
565
+ <div className="space-y-1">
566
+ <label className="text-xs text-slate-500">Trigger</label>
567
+ <select
568
+ value={activeTrigger}
569
+ onChange={(e) => setStyle("animationTrigger", e.target.value as NodeStyle["animationTrigger"])}
570
+ className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
571
+ >
572
+ {ANIMATION_TRIGGERS.map((t) => (
573
+ <option key={t.value} value={t.value}>
574
+ {t.label}
575
+ </option>
576
+ ))}
577
+ </select>
578
+ {triggerInfo && <p className="text-xs text-slate-400">{triggerInfo.hint}</p>}
579
+ </div>
580
+ <div className="grid grid-cols-2 gap-3">
581
+ <NumberInput
582
+ label="Duration (s)"
583
+ value={node.style.animationDuration}
584
+ fallback={0.8}
585
+ step={0.1}
586
+ onChange={(v) => setStyle("animationDuration", v)}
587
+ />
588
+ <NumberInput
589
+ label="Delay (s)"
590
+ value={node.style.animationDelay}
591
+ fallback={0}
592
+ step={0.1}
593
+ onChange={(v) => setStyle("animationDelay", v)}
594
+ />
595
+ </div>
596
+ <div className="space-y-1">
597
+ <label className="text-xs text-slate-500">Easing</label>
598
+ <select
599
+ value={isCustomEase ? CUSTOM_EASE : node.style.animationEasing ?? "power2.out"}
600
+ onChange={(e) =>
601
+ setStyle(
602
+ "animationEasing",
603
+ e.target.value === CUSTOM_EASE ? node.style.animationEasing || "power2.out" : e.target.value
604
+ )
605
+ }
606
+ className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
607
+ >
608
+ {EASE_PRESETS.map((p) => (
609
+ <option key={p.value} value={p.value}>
610
+ {p.label}
611
+ </option>
612
+ ))}
613
+ <option value={CUSTOM_EASE}>Custom…</option>
614
+ </select>
615
+ {isCustomEase && (
616
+ <input
617
+ value={node.style.animationEasing ?? ""}
618
+ placeholder="e.g. power3.inOut"
619
+ onChange={(e) => setStyle("animationEasing", e.target.value || undefined)}
620
+ className="w-full border rounded-md px-2 py-1.5 text-sm font-mono"
621
+ />
622
+ )}
623
+ </div>
624
+ {isLoopable && (
625
+ <>
626
+ <label className="flex items-center gap-2 text-xs text-slate-600">
627
+ <input
628
+ type="checkbox"
629
+ checked={node.style.animationLoop ?? false}
630
+ onChange={(e) => setStyle("animationLoop", e.target.checked || undefined)}
631
+ />
632
+ Loop continuously (alternates back and forth instead of playing once)
633
+ </label>
634
+ <NumberInput
635
+ label="Stagger items (s) — 0 reveals the section as one block"
636
+ value={node.style.animationStagger}
637
+ fallback={0}
638
+ step={0.05}
639
+ onChange={(v) => setStyle("animationStagger", v)}
640
+ />
641
+ </>
642
+ )}
643
+ </>
644
+ );
645
+ })()}
646
+ </StyleGroup>
647
+
648
+ <StyleGroup title="Modern Effects">
649
+ <div className="space-y-1">
650
+ <label className="text-xs text-slate-500">Hover effect</label>
651
+ <select
652
+ value={node.style.hoverEffect ?? "none"}
653
+ onChange={(e) =>
654
+ setStyle(
655
+ "hoverEffect",
656
+ e.target.value === "none" ? undefined : (e.target.value as NodeStyle["hoverEffect"])
657
+ )
658
+ }
659
+ className="w-full border rounded-md px-2 py-1.5 text-sm bg-white"
660
+ >
661
+ {HOVER_EFFECTS.map((h) => (
662
+ <option key={h.value} value={h.value}>
663
+ {h.label}
664
+ </option>
665
+ ))}
666
+ </select>
667
+ {node.style.hoverEffect && node.style.hoverEffect !== "none" && (
668
+ <p className="text-xs text-slate-400">
669
+ {HOVER_EFFECTS.find((h) => h.value === node.style.hoverEffect)?.hint}
670
+ </p>
671
+ )}
672
+ </div>
673
+ <label className="flex items-center gap-2 text-xs text-slate-600">
674
+ <input
675
+ type="checkbox"
676
+ checked={node.style.glass ?? false}
677
+ onChange={(e) => setStyle("glass", e.target.checked || undefined)}
678
+ />
679
+ Glass panel (frosted, translucent — best over an image or gradient)
680
+ </label>
681
+ <div className="space-y-1">
682
+ <label className="text-xs text-slate-500">
683
+ Parallax drift{node.style.parallaxSpeed ? ` (${node.style.parallaxSpeed})` : " (off)"}
684
+ </label>
685
+ <input
686
+ type="range"
687
+ min={-1}
688
+ max={1}
689
+ step={0.1}
690
+ value={node.style.parallaxSpeed ?? 0}
691
+ onChange={(e) => setStyle("parallaxSpeed", Number(e.target.value) === 0 ? undefined : Number(e.target.value))}
692
+ className="w-full"
693
+ />
694
+ <p className="text-xs text-slate-400">
695
+ Drifts the section against the scroll — negative floats up, positive lags behind.
696
+ </p>
697
+ </div>
698
+ <div className="space-y-2">
699
+ <label className="text-xs text-slate-500">Gradient headings</label>
700
+ <ColorPickerField
701
+ label="From"
702
+ value={node.style.textGradientFrom}
703
+ onChange={(v) => setStyle("textGradientFrom", v || undefined)}
704
+ />
705
+ <ColorPickerField
706
+ label="To"
707
+ value={node.style.textGradientTo}
708
+ onChange={(v) => setStyle("textGradientTo", v || undefined)}
709
+ />
710
+ {(node.style.textGradientFrom || node.style.textGradientTo) && (
711
+ <button
712
+ // One onChangeStyle call, not two setStyle calls — each setStyle
713
+ // spreads the same pre-update node.style, so the second would
714
+ // silently resurrect the field the first just cleared.
715
+ onClick={() => onChangeStyle({ ...node!.style, textGradientFrom: undefined, textGradientTo: undefined })}
716
+ className="text-xs text-slate-500 hover:text-red-600 underline"
717
+ >
718
+ Clear gradient
719
+ </button>
720
+ )}
721
+ <p className="text-xs text-slate-400">
722
+ Set both colors to fill this section&apos;s headings with a gradient.
723
+ </p>
724
+ </div>
725
+ </StyleGroup>
726
+
727
+ <StyleGroup title="Advanced">
728
+ <p className="text-xs text-slate-400">
729
+ Escape hatch for anything not covered above — arbitrary CSS properties or HTML attributes.
730
+ </p>
731
+ <KeyValueListEditor
732
+ label="Custom CSS properties"
733
+ value={node.style.customCss}
734
+ onChange={(v) => setStyle("customCss", v)}
735
+ keyPlaceholder="letterSpacing"
736
+ valuePlaceholder="0.05em"
737
+ />
738
+ <KeyValueListEditor
739
+ label="Custom attributes"
740
+ value={node.style.customAttributes}
741
+ onChange={(v) => setStyle("customAttributes", v)}
742
+ keyPlaceholder="data-analytics-id"
743
+ valuePlaceholder="hero-cta"
744
+ />
745
+ </StyleGroup>
746
+ </div>
747
+ )}
748
+
749
+ {tab === "json" && (
750
+ <div className="p-4 space-y-4">
751
+ <JsonEditor nodeId={node.id} label="Props (content)" value={node.props} onApply={onChangeProps} />
752
+ <JsonEditor
753
+ nodeId={node.id}
754
+ label="Style"
755
+ value={node.style as Record<string, unknown>}
756
+ onApply={(v) => onChangeStyle(v as NodeStyle)}
757
+ />
758
+ </div>
759
+ )}
760
+ </div>
761
+ );
762
+ }
763
+
764
+ /** Collapsible group for the Style tab — the option count has outgrown a flat list,
765
+ * so each area folds away behind a header. Open state is per-mount (resets when you
766
+ * select another node), which keeps the panel predictable. */
767
+ function StyleGroup({
768
+ title,
769
+ defaultOpen = false,
770
+ children,
771
+ }: {
772
+ title: string;
773
+ defaultOpen?: boolean;
774
+ children: React.ReactNode;
775
+ }) {
776
+ const [open, setOpen] = useState(defaultOpen);
777
+ return (
778
+ <div className="border rounded-md">
779
+ <button
780
+ type="button"
781
+ onClick={() => setOpen((o) => !o)}
782
+ className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 rounded-md"
783
+ >
784
+ {title}
785
+ <ChevronDown size={14} className={clsx("transition-transform text-slate-400", open && "rotate-180")} />
786
+ </button>
787
+ {open && <div className="px-3 pb-3 pt-1 space-y-3 border-t">{children}</div>}
788
+ </div>
789
+ );
790
+ }
791
+
792
+ function CountStepper({
793
+ label,
794
+ value,
795
+ min,
796
+ max,
797
+ onChange,
798
+ hint,
799
+ }: {
800
+ label: string;
801
+ value: number;
802
+ min: number;
803
+ max: number;
804
+ onChange: (value: number) => void;
805
+ hint: string;
806
+ }) {
807
+ return (
808
+ <div className="space-y-1">
809
+ <label className="text-xs font-medium text-slate-600">{label}</label>
810
+ <div className="flex items-center gap-2">
811
+ <button
812
+ type="button"
813
+ onClick={() => onChange(Math.max(min, value - 1))}
814
+ className="w-8 h-8 border rounded-md text-sm hover:bg-slate-50"
815
+ >
816
+
817
+ </button>
818
+ <input
819
+ type="number"
820
+ min={min}
821
+ max={max}
822
+ value={value}
823
+ onChange={(e) => onChange(Number(e.target.value))}
824
+ className="w-14 border rounded-md px-2 py-1.5 text-sm text-center"
825
+ />
826
+ <button
827
+ type="button"
828
+ onClick={() => onChange(Math.min(max, value + 1))}
829
+ className="w-8 h-8 border rounded-md text-sm hover:bg-slate-50"
830
+ >
831
+ +
832
+ </button>
833
+ </div>
834
+ <p className="text-xs text-slate-400">{hint}</p>
835
+ </div>
836
+ );
837
+ }
838
+
839
+ const SIZE_UNITS = ["px", "%", "rem", "vw", "vh"] as const;
840
+
841
+ /** Number + unit pair for a CSS length. Values a plain input can't express (calc(),
842
+ * auto, keywords) still round-trip: unparsable values show in a raw text field. */
843
+ function SizeInput({
844
+ label,
845
+ value,
846
+ onChange,
847
+ }: {
848
+ label: string;
849
+ value: string | undefined;
850
+ onChange: (v: string | undefined) => void;
851
+ }) {
852
+ const match = value?.match(/^([\d.]+)(px|%|rem|vw|vh)$/);
853
+ const isRaw = !!value && !match;
854
+ const num = match ? match[1] : "";
855
+ const unit = match ? match[2] : "px";
856
+
857
+ if (isRaw) {
858
+ return (
859
+ <div className="space-y-1">
860
+ <label className="text-xs font-medium text-slate-600">{label}</label>
861
+ <input
862
+ value={value ?? ""}
863
+ onChange={(e) => onChange(e.target.value || undefined)}
864
+ className="w-full border rounded-md px-2 py-1.5 text-sm font-mono"
865
+ />
866
+ </div>
867
+ );
868
+ }
869
+
870
+ return (
871
+ <div className="space-y-1">
872
+ <label className="text-xs font-medium text-slate-600">{label}</label>
873
+ <div className="flex">
874
+ <input
875
+ type="number"
876
+ min={0}
877
+ value={num}
878
+ placeholder="auto"
879
+ onChange={(e) => onChange(e.target.value === "" ? undefined : `${e.target.value}${unit}`)}
880
+ className="w-full border rounded-l-md px-2 py-1.5 text-sm min-w-0"
881
+ />
882
+ <select
883
+ value={unit}
884
+ onChange={(e) => {
885
+ if (num) onChange(`${num}${e.target.value}`);
886
+ }}
887
+ className="border border-l-0 rounded-r-md px-1 py-1.5 text-xs bg-slate-50 text-slate-600"
888
+ >
889
+ {SIZE_UNITS.map((u) => (
890
+ <option key={u} value={u}>
891
+ {u}
892
+ </option>
893
+ ))}
894
+ </select>
895
+ </div>
896
+ </div>
897
+ );
898
+ }
899
+
900
+ function TextInput({
901
+ label,
902
+ value,
903
+ onChange,
904
+ }: {
905
+ label: string;
906
+ value: string | undefined;
907
+ onChange: (v: string | undefined) => void;
908
+ }) {
909
+ return (
910
+ <div className="space-y-1">
911
+ <label className="text-xs font-medium text-slate-600">{label}</label>
912
+ <input
913
+ value={value ?? ""}
914
+ placeholder="e.g. 48px"
915
+ onChange={(e) => onChange(e.target.value || undefined)}
916
+ className="w-full border rounded-md px-2 py-1.5 text-sm"
917
+ />
918
+ </div>
919
+ );
920
+ }
921
+
922
+ function NumberInput({
923
+ label,
924
+ value,
925
+ fallback,
926
+ step = 1,
927
+ min,
928
+ max,
929
+ onChange,
930
+ }: {
931
+ label: string;
932
+ value: number | undefined;
933
+ fallback: number;
934
+ step?: number;
935
+ min?: number;
936
+ max?: number;
937
+ onChange: (v: number | undefined) => void;
938
+ }) {
939
+ return (
940
+ <div className="space-y-1">
941
+ <label className="text-xs font-medium text-slate-600">{label}</label>
942
+ <input
943
+ type="number"
944
+ step={step}
945
+ min={min}
946
+ max={max}
947
+ value={value ?? fallback}
948
+ onChange={(e) => {
949
+ const num = Number(e.target.value);
950
+ onChange(Number.isNaN(num) || num === fallback ? undefined : num);
951
+ }}
952
+ className="w-full border rounded-md px-2 py-1.5 text-sm"
953
+ />
954
+ </div>
955
+ );
956
+ }