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.
- package/README.md +41 -0
- package/bin/lib.js +97 -0
- package/bin/modern-cms.js +197 -0
- package/package.json +37 -0
- package/template/.env.example +48 -0
- package/template/README.md +210 -0
- package/template/apps/api/Dockerfile +26 -0
- package/template/apps/api/package.json +45 -0
- package/template/apps/api/prisma/backfill-search-text.ts +31 -0
- package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
- package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
- package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
- package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
- package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
- package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
- package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
- package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
- package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
- package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/reset-admin.ts +19 -0
- package/template/apps/api/prisma/schema.prisma +251 -0
- package/template/apps/api/prisma/seed-homepage.ts +752 -0
- package/template/apps/api/prisma/seed-our-charities.ts +231 -0
- package/template/apps/api/prisma/seed-templates.ts +154 -0
- package/template/apps/api/prisma/seed.ts +191 -0
- package/template/apps/api/src/env.ts +61 -0
- package/template/apps/api/src/index.ts +66 -0
- package/template/apps/api/src/lib/revalidate.ts +28 -0
- package/template/apps/api/src/lib/storage/azure.ts +25 -0
- package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
- package/template/apps/api/src/lib/storage/index.ts +25 -0
- package/template/apps/api/src/lib/storage/s3.ts +39 -0
- package/template/apps/api/src/lib/storage/types.ts +8 -0
- package/template/apps/api/src/middleware/auditLog.ts +32 -0
- package/template/apps/api/src/middleware/auth.ts +53 -0
- package/template/apps/api/src/middleware/rateLimit.ts +31 -0
- package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
- package/template/apps/api/src/prisma.ts +3 -0
- package/template/apps/api/src/routes/audit.routes.ts +26 -0
- package/template/apps/api/src/routes/auth.routes.ts +46 -0
- package/template/apps/api/src/routes/backup.routes.ts +216 -0
- package/template/apps/api/src/routes/blocks.routes.ts +38 -0
- package/template/apps/api/src/routes/collections.routes.ts +185 -0
- package/template/apps/api/src/routes/forms.routes.ts +114 -0
- package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
- package/template/apps/api/src/routes/media.routes.ts +77 -0
- package/template/apps/api/src/routes/pages.routes.ts +319 -0
- package/template/apps/api/src/routes/redirects.routes.ts +52 -0
- package/template/apps/api/src/routes/search.routes.ts +83 -0
- package/template/apps/api/src/routes/templates.routes.ts +41 -0
- package/template/apps/api/src/routes/theme.routes.ts +75 -0
- package/template/apps/api/src/routes/users.routes.ts +39 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/web/Dockerfile +26 -0
- package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
- package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
- package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
- package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
- package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
- package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
- package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
- package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
- package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
- package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
- package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
- package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
- package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
- package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
- package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
- package/template/apps/web/app/admin/login/page.tsx +76 -0
- package/template/apps/web/app/api/revalidate/route.ts +34 -0
- package/template/apps/web/app/globals.css +120 -0
- package/template/apps/web/app/layout.tsx +39 -0
- package/template/apps/web/app/preview/[token]/page.tsx +70 -0
- package/template/apps/web/app/robots.ts +11 -0
- package/template/apps/web/app/sitemap.ts +27 -0
- package/template/apps/web/components/admin/AdminShell.tsx +104 -0
- package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
- package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
- package/template/apps/web/components/builder/Canvas.tsx +76 -0
- package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
- package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
- package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
- package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
- package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
- package/template/apps/web/components/builder/Inspector.tsx +956 -0
- package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
- package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
- package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
- package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
- package/template/apps/web/components/builder/Palette.tsx +106 -0
- package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
- package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
- package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
- package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
- package/template/apps/web/components/renderer/Renderer.tsx +146 -0
- package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
- package/template/apps/web/components/renderer/registry.tsx +87 -0
- package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
- package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
- package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
- package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
- package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
- package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
- package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
- package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
- package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
- package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
- package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
- package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
- package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
- package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
- package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
- package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
- package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
- package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
- package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
- package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
- package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
- package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
- package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
- package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
- package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
- package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
- package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
- package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
- package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
- package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
- package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
- package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
- package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
- package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
- package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
- package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
- package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
- package/template/apps/web/components/renderer/sections/types.ts +7 -0
- package/template/apps/web/components/seo/JsonLd.tsx +111 -0
- package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
- package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
- package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
- package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
- package/template/apps/web/eslint.config.mjs +16 -0
- package/template/apps/web/lib/api.ts +45 -0
- package/template/apps/web/lib/responsiveCss.ts +42 -0
- package/template/apps/web/lib/sanitizeHtml.ts +16 -0
- package/template/apps/web/lib/serverApi.ts +25 -0
- package/template/apps/web/lib/style.ts +73 -0
- package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
- package/template/apps/web/lib/useCurrentUser.ts +35 -0
- package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
- package/template/apps/web/next-env.d.ts +6 -0
- package/template/apps/web/next.config.mjs +32 -0
- package/template/apps/web/package.json +46 -0
- package/template/apps/web/postcss.config.mjs +6 -0
- package/template/apps/web/proxy.ts +23 -0
- package/template/apps/web/tailwind.config.ts +24 -0
- package/template/apps/web/tsconfig.json +41 -0
- package/template/docker-compose.yml +60 -0
- package/template/package.json +29 -0
- package/template/packages/shared/package.json +21 -0
- package/template/packages/shared/src/components.ts +1626 -0
- package/template/packages/shared/src/index.ts +9 -0
- package/template/packages/shared/src/schema/collections.ts +70 -0
- package/template/packages/shared/src/schema/fields.ts +26 -0
- package/template/packages/shared/src/schema/media.ts +13 -0
- package/template/packages/shared/src/schema/pageNode.ts +255 -0
- package/template/packages/shared/src/schema/templates.ts +20 -0
- package/template/packages/shared/src/schema/theme.ts +69 -0
- package/template/packages/shared/src/schema/user.ts +25 -0
- package/template/packages/shared/src/tree.test.ts +65 -0
- package/template/packages/shared/src/tree.ts +303 -0
- package/template/packages/shared/tsconfig.json +8 -0
- 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'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'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'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
|
+
}
|