From 91a6b6f34b22a4863478fab1c7d78dd1e08ca15f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 5 Apr 2026 18:31:16 -0700 Subject: [PATCH] Add Craft.js site builder (v2) - complete rebuild from GrapesJS Rebuilt the visual site builder from scratch using Craft.js, React 18, and TypeScript. The new editor renders directly in the DOM (no iframe), supports 40+ components, multi-page with shared header/footer, 16 templates, full-spectrum color/gradient controls, custom head code injection, save/publish workflow, and auto-save. Co-Authored-By: Claude Opus 4.6 (1M context) --- craft/.gitignore | 6 + craft/CLAUDE.md | 463 +++ craft/FEATURES.md | 102 + craft/README.md | 75 + craft/index.html | 16 + craft/package.json | 27 + craft/src/App.tsx | 26 + craft/src/components/basic/ButtonLink.tsx | 232 ++ craft/src/components/basic/Divider.tsx | 119 + craft/src/components/basic/Footer.tsx | 153 + craft/src/components/basic/Heading.tsx | 181 + craft/src/components/basic/HtmlBlock.tsx | 127 + craft/src/components/basic/Icon.tsx | 325 ++ craft/src/components/basic/Logo.tsx | 418 +++ craft/src/components/basic/Menu.tsx | 510 +++ craft/src/components/basic/Navbar.tsx | 929 ++++++ craft/src/components/basic/SearchBar.tsx | 204 ++ craft/src/components/basic/SocialLinks.tsx | 444 +++ craft/src/components/basic/Spacer.tsx | 107 + craft/src/components/basic/StarRating.tsx | 230 ++ craft/src/components/basic/TextBlock.tsx | 158 + craft/src/components/forms/ContactForm.tsx | 423 +++ craft/src/components/forms/FormButton.tsx | 179 + craft/src/components/forms/FormContainer.tsx | 140 + craft/src/components/forms/InputField.tsx | 185 ++ craft/src/components/forms/SubscribeForm.tsx | 307 ++ craft/src/components/forms/TextareaField.tsx | 187 ++ .../components/layout/BackgroundSection.tsx | 206 ++ craft/src/components/layout/ColumnLayout.tsx | 298 ++ craft/src/components/layout/Container.tsx | 324 ++ craft/src/components/layout/FooterZone.tsx | 81 + craft/src/components/layout/HeaderZone.tsx | 81 + craft/src/components/layout/Section.tsx | 401 +++ craft/src/components/media/ImageBlock.tsx | 480 +++ craft/src/components/media/MapEmbed.tsx | 173 + craft/src/components/media/VideoBlock.tsx | 794 +++++ craft/src/components/resolver.ts | 81 + craft/src/components/sections/Accordion.tsx | 330 ++ craft/src/components/sections/CTASection.tsx | 192 ++ .../src/components/sections/CallToAction.tsx | 486 +++ .../src/components/sections/ContentSlider.tsx | 530 +++ craft/src/components/sections/Countdown.tsx | 311 ++ .../src/components/sections/FeaturesGrid.tsx | 200 ++ craft/src/components/sections/Gallery.tsx | 322 ++ craft/src/components/sections/HeroSimple.tsx | 457 +++ .../src/components/sections/NumberCounter.tsx | 368 ++ .../src/components/sections/PricingTable.tsx | 451 +++ craft/src/components/sections/Tabs.tsx | 339 ++ .../src/components/sections/Testimonials.tsx | 421 +++ craft/src/constants/presets.ts | 86 + craft/src/editor/Canvas.tsx | 157 + craft/src/editor/EditorShell.tsx | 54 + craft/src/hooks/useAssets.ts | 97 + craft/src/hooks/useContextMenu.ts | 27 + craft/src/hooks/useKeyboardShortcuts.ts | 103 + craft/src/hooks/useWhpApi.ts | 199 ++ craft/src/main.tsx | 14 + craft/src/panels/context-menu/ContextMenu.tsx | 283 ++ craft/src/panels/left/AssetsPanel.tsx | 249 ++ craft/src/panels/left/BlocksPanel.tsx | 211 ++ craft/src/panels/left/LayersPanel.tsx | 137 + craft/src/panels/left/LeftPanel.tsx | 40 + craft/src/panels/left/PagesPanel.tsx | 470 +++ craft/src/panels/right/GuidedStyles.tsx | 155 + craft/src/panels/right/RightPanel.tsx | 19 + craft/src/panels/right/SiteDesignPanel.tsx | 362 ++ .../styles/BackgroundSectionStylePanel.tsx | 97 + .../panels/right/styles/ButtonStylePanel.tsx | 88 + .../right/styles/ContainerStylePanel.tsx | 80 + .../panels/right/styles/FormStylePanel.tsx | 139 + .../right/styles/GenericPropsEditor.tsx | 251 ++ .../panels/right/styles/HeroStylePanel.tsx | 269 ++ .../panels/right/styles/ImageStylePanel.tsx | 215 ++ .../panels/right/styles/MediaStylePanel.tsx | 197 ++ .../src/panels/right/styles/NavStylePanel.tsx | 192 ++ .../panels/right/styles/PricingStylePanel.tsx | 210 ++ .../panels/right/styles/SectionTypePanel.tsx | 239 ++ .../panels/right/styles/SocialStylePanel.tsx | 177 + .../panels/right/styles/TextStylePanel.tsx | 81 + craft/src/panels/right/styles/index.ts | 13 + craft/src/panels/right/styles/shared.tsx | 322 ++ craft/src/panels/topbar/HeadCodeModal.tsx | 159 + craft/src/panels/topbar/TemplateModal.tsx | 607 ++++ craft/src/panels/topbar/TopBar.tsx | 274 ++ craft/src/state/EditorConfigContext.tsx | 22 + craft/src/state/PageContext.tsx | 300 ++ craft/src/state/SiteDesignContext.tsx | 81 + craft/src/styles/editor.css | 1308 ++++++++ craft/src/templates/definitions.ts | 2957 +++++++++++++++++ craft/src/templates/index.ts | 9 + craft/src/types/index.ts | 34 + craft/src/ui/AdvancedTab.tsx | 233 ++ craft/src/ui/BorderControl.tsx | 218 ++ craft/src/ui/SettingsTabs.tsx | 26 + craft/src/ui/SpacingInput.tsx | 141 + craft/src/ui/TypographyControl.tsx | 221 ++ craft/src/utils/html-export.ts | 243 ++ craft/src/utils/style-helpers.ts | 16 + craft/tests/components.test.ts | 153 + craft/tests/playwright.config.ts | 12 + craft/tests/site-builder.spec.ts | 394 +++ craft/tsconfig.json | 26 + craft/vite.config.ts | 30 + 103 files changed, 26296 insertions(+) create mode 100644 craft/.gitignore create mode 100644 craft/CLAUDE.md create mode 100644 craft/FEATURES.md create mode 100644 craft/README.md create mode 100644 craft/index.html create mode 100644 craft/package.json create mode 100644 craft/src/App.tsx create mode 100644 craft/src/components/basic/ButtonLink.tsx create mode 100644 craft/src/components/basic/Divider.tsx create mode 100644 craft/src/components/basic/Footer.tsx create mode 100644 craft/src/components/basic/Heading.tsx create mode 100644 craft/src/components/basic/HtmlBlock.tsx create mode 100644 craft/src/components/basic/Icon.tsx create mode 100644 craft/src/components/basic/Logo.tsx create mode 100644 craft/src/components/basic/Menu.tsx create mode 100644 craft/src/components/basic/Navbar.tsx create mode 100644 craft/src/components/basic/SearchBar.tsx create mode 100644 craft/src/components/basic/SocialLinks.tsx create mode 100644 craft/src/components/basic/Spacer.tsx create mode 100644 craft/src/components/basic/StarRating.tsx create mode 100644 craft/src/components/basic/TextBlock.tsx create mode 100644 craft/src/components/forms/ContactForm.tsx create mode 100644 craft/src/components/forms/FormButton.tsx create mode 100644 craft/src/components/forms/FormContainer.tsx create mode 100644 craft/src/components/forms/InputField.tsx create mode 100644 craft/src/components/forms/SubscribeForm.tsx create mode 100644 craft/src/components/forms/TextareaField.tsx create mode 100644 craft/src/components/layout/BackgroundSection.tsx create mode 100644 craft/src/components/layout/ColumnLayout.tsx create mode 100644 craft/src/components/layout/Container.tsx create mode 100644 craft/src/components/layout/FooterZone.tsx create mode 100644 craft/src/components/layout/HeaderZone.tsx create mode 100644 craft/src/components/layout/Section.tsx create mode 100644 craft/src/components/media/ImageBlock.tsx create mode 100644 craft/src/components/media/MapEmbed.tsx create mode 100644 craft/src/components/media/VideoBlock.tsx create mode 100644 craft/src/components/resolver.ts create mode 100644 craft/src/components/sections/Accordion.tsx create mode 100644 craft/src/components/sections/CTASection.tsx create mode 100644 craft/src/components/sections/CallToAction.tsx create mode 100644 craft/src/components/sections/ContentSlider.tsx create mode 100644 craft/src/components/sections/Countdown.tsx create mode 100644 craft/src/components/sections/FeaturesGrid.tsx create mode 100644 craft/src/components/sections/Gallery.tsx create mode 100644 craft/src/components/sections/HeroSimple.tsx create mode 100644 craft/src/components/sections/NumberCounter.tsx create mode 100644 craft/src/components/sections/PricingTable.tsx create mode 100644 craft/src/components/sections/Tabs.tsx create mode 100644 craft/src/components/sections/Testimonials.tsx create mode 100644 craft/src/constants/presets.ts create mode 100644 craft/src/editor/Canvas.tsx create mode 100644 craft/src/editor/EditorShell.tsx create mode 100644 craft/src/hooks/useAssets.ts create mode 100644 craft/src/hooks/useContextMenu.ts create mode 100644 craft/src/hooks/useKeyboardShortcuts.ts create mode 100644 craft/src/hooks/useWhpApi.ts create mode 100644 craft/src/main.tsx create mode 100644 craft/src/panels/context-menu/ContextMenu.tsx create mode 100644 craft/src/panels/left/AssetsPanel.tsx create mode 100644 craft/src/panels/left/BlocksPanel.tsx create mode 100644 craft/src/panels/left/LayersPanel.tsx create mode 100644 craft/src/panels/left/LeftPanel.tsx create mode 100644 craft/src/panels/left/PagesPanel.tsx create mode 100644 craft/src/panels/right/GuidedStyles.tsx create mode 100644 craft/src/panels/right/RightPanel.tsx create mode 100644 craft/src/panels/right/SiteDesignPanel.tsx create mode 100644 craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx create mode 100644 craft/src/panels/right/styles/ButtonStylePanel.tsx create mode 100644 craft/src/panels/right/styles/ContainerStylePanel.tsx create mode 100644 craft/src/panels/right/styles/FormStylePanel.tsx create mode 100644 craft/src/panels/right/styles/GenericPropsEditor.tsx create mode 100644 craft/src/panels/right/styles/HeroStylePanel.tsx create mode 100644 craft/src/panels/right/styles/ImageStylePanel.tsx create mode 100644 craft/src/panels/right/styles/MediaStylePanel.tsx create mode 100644 craft/src/panels/right/styles/NavStylePanel.tsx create mode 100644 craft/src/panels/right/styles/PricingStylePanel.tsx create mode 100644 craft/src/panels/right/styles/SectionTypePanel.tsx create mode 100644 craft/src/panels/right/styles/SocialStylePanel.tsx create mode 100644 craft/src/panels/right/styles/TextStylePanel.tsx create mode 100644 craft/src/panels/right/styles/index.ts create mode 100644 craft/src/panels/right/styles/shared.tsx create mode 100644 craft/src/panels/topbar/HeadCodeModal.tsx create mode 100644 craft/src/panels/topbar/TemplateModal.tsx create mode 100644 craft/src/panels/topbar/TopBar.tsx create mode 100644 craft/src/state/EditorConfigContext.tsx create mode 100644 craft/src/state/PageContext.tsx create mode 100644 craft/src/state/SiteDesignContext.tsx create mode 100644 craft/src/styles/editor.css create mode 100644 craft/src/templates/definitions.ts create mode 100644 craft/src/templates/index.ts create mode 100644 craft/src/types/index.ts create mode 100644 craft/src/ui/AdvancedTab.tsx create mode 100644 craft/src/ui/BorderControl.tsx create mode 100644 craft/src/ui/SettingsTabs.tsx create mode 100644 craft/src/ui/SpacingInput.tsx create mode 100644 craft/src/ui/TypographyControl.tsx create mode 100644 craft/src/utils/html-export.ts create mode 100644 craft/src/utils/style-helpers.ts create mode 100644 craft/tests/components.test.ts create mode 100644 craft/tests/playwright.config.ts create mode 100644 craft/tests/site-builder.spec.ts create mode 100644 craft/tsconfig.json create mode 100644 craft/vite.config.ts diff --git a/craft/.gitignore b/craft/.gitignore new file mode 100644 index 0000000..9c44321 --- /dev/null +++ b/craft/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.playwright-mcp/ +test-results/ +playwright-report/ +*.tsbuildinfo diff --git a/craft/CLAUDE.md b/craft/CLAUDE.md new file mode 100644 index 0000000..b918c25 --- /dev/null +++ b/craft/CLAUDE.md @@ -0,0 +1,463 @@ +# WHP Site Builder v2 (Craft.js) - Project Documentation + +## Overview + +A visual drag-and-drop website builder rebuilt from the ground up using Craft.js, React 18, and TypeScript. Replaces the legacy GrapesJS-based editor (`/workspace/site-builder/`). Users create multi-page websites without writing code, with server-side storage through WHP's PHP API layer. + +**Stack:** Vite 6 + React 18 + TypeScript 5 + @craftjs/core 0.2.x +**Bundle:** ~460KB JS + ~15KB CSS +**Version:** 2.0.0 + +## File Structure + +``` +craft/ +├── index.html # HTML shell (loads fonts, FA icons, mounts React) +├── package.json # Dependencies and scripts (v2.0.0) +├── tsconfig.json # TypeScript config (ES2020, strict, path aliases) +├── vite.config.ts # Vite config (builds to dist/js/editor.js + dist/css/editor.css) +├── CLAUDE.md # This file +├── README.md # Brief project readme +├── FEATURES.md # User-facing features list +├── dist/ # Build output (not committed) +│ ├── index.html +│ ├── js/editor.js +│ ├── css/editor.css +│ └── assets/ +├── src/ +│ ├── main.tsx # Entry point: reads WHP_CONFIG, mounts +│ ├── App.tsx # Wraps with providers, passes resolver +│ │ +│ ├── types/ +│ │ └── index.ts # WhpConfig, PageData, AssetData, StyleProps, DeviceMode +│ │ +│ ├── state/ +│ │ ├── EditorConfigContext.tsx # React context for WHP_CONFIG (useEditorConfig hook) +│ │ ├── PageContext.tsx # Multi-page state (pages, header, footer, CRUD, switching) +│ │ └── SiteDesignContext.tsx # Site-wide design tokens (17 properties, Basic/Advanced) +│ │ +│ ├── editor/ +│ │ ├── EditorShell.tsx # 3-panel layout: TopBar + LeftPanel + Canvas + RightPanel + ContextMenu +│ │ └── Canvas.tsx # Craft.js with device-width switching +│ │ +│ ├── components/ +│ │ ├── resolver.ts # Component map for Craft.js serialization (20 components) +│ │ ├── layout/ +│ │ │ ├── Container.tsx # Generic container (div/section/article/header/footer/main) +│ │ │ ├── Section.tsx # Full-width section with centered inner container +│ │ │ ├── ColumnLayout.tsx # Flex columns (1-6, with split ratios) +│ │ │ ├── BackgroundSection.tsx # Section with background image/gradient overlay +│ │ │ ├── HeaderZone.tsx # Page-level header zone wrapper +│ │ │ └── FooterZone.tsx # Page-level footer zone wrapper +│ │ ├── basic/ +│ │ │ ├── Heading.tsx # Inline-editable heading (h1-h6) +│ │ │ ├── TextBlock.tsx # Inline-editable paragraph +│ │ │ ├── ButtonLink.tsx # Styled with color presets +│ │ │ ├── Navbar.tsx # Navigation bar (text/image logo, page links, external links, CTA) +│ │ │ ├── Footer.tsx # Footer component (links, copyright, social) +│ │ │ ├── Divider.tsx # Horizontal rule (color, thickness) +│ │ │ └── Spacer.tsx # Vertical spacing element +│ │ ├── media/ +│ │ │ ├── ImageBlock.tsx # Image with placeholder, upload, browse, sizing +│ │ │ └── VideoBlock.tsx # Video embed (YouTube, Vimeo, direct files, background mode) +│ │ ├── sections/ +│ │ │ ├── HeroSimple.tsx # Pre-built hero section with heading, subtext, CTA +│ │ │ ├── FeaturesGrid.tsx # 3-column feature cards grid +│ │ │ └── CTASection.tsx # Call-to-action banner section +│ │ └── forms/ +│ │ ├── FormContainer.tsx # Form wrapper with action/method +│ │ ├── InputField.tsx # Input field with label and placeholder +│ │ ├── TextareaField.tsx # Textarea field with label +│ │ └── FormButton.tsx # Submit button with styling +│ │ +│ ├── panels/ +│ │ ├── topbar/ +│ │ │ ├── TopBar.tsx # Back button, domain badge, device switcher, undo/redo, save, templates +│ │ │ └── TemplateModal.tsx # Template browser with categories and one-click loading +│ │ ├── left/ +│ │ │ ├── LeftPanel.tsx # Tabs: Blocks | Pages | Layers | Assets +│ │ │ ├── BlocksPanel.tsx # Draggable block toolbox with categories +│ │ │ ├── PagesPanel.tsx # Multi-page CRUD, header/footer editing +│ │ │ ├── LayersPanel.tsx # Component hierarchy tree view +│ │ │ └── AssetsPanel.tsx # Asset browser with upload, drag-drop, thumbnails +│ │ ├── right/ +│ │ │ ├── RightPanel.tsx # Tabs: Styles | Settings | Head +│ │ │ ├── GuidedStyles.tsx # Context-aware style panel (shows selected type) +│ │ │ └── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs) +│ │ └── context-menu/ +│ │ └── ContextMenu.tsx # Right-click context menu (duplicate, copy, paste, delete, etc.) +│ │ +│ ├── hooks/ +│ │ ├── useWhpApi.ts # Save/load/deploy via WHP API (with auto-save) +│ │ ├── useAssets.ts # Asset upload, browse, delete via WHP API +│ │ ├── useContextMenu.ts # Right-click menu state management +│ │ └── useKeyboardShortcuts.ts # Keyboard shortcut handler (undo, redo, delete, etc.) +│ │ +│ ├── templates/ +│ │ ├── index.ts # Template exports +│ │ └── definitions.ts # 16 template definitions across 4 categories +│ │ +│ ├── ui/ +│ │ └── SettingsTabs.tsx # Reusable General/Style/Advanced tabs for component settings +│ │ +│ ├── constants/ +│ │ └── presets.ts # Color, font, spacing, radius, gradient, device width presets +│ │ +│ ├── utils/ +│ │ ├── style-helpers.ts # cssPropsToString(), mergeStyles() +│ │ └── html-export.ts # Recursive node-to-HTML renderer, full page export +│ │ +│ └── styles/ +│ └── editor.css # Dark theme, CSS variables +``` + +## Running Locally + +```bash +cd /workspace/site-builder/craft +npm install +npm run dev +``` + +Opens at `http://localhost:5173`. The Vite dev server proxies `/api` requests to `http://192.168.1.105:8080` (the WHP staging server) for save/load during development. + +In standalone mode (no WHP_CONFIG on `window`), the editor runs fully client-side without save/load functionality. + +## Building + +```bash +npm run build +``` + +Runs `tsc && vite build`. Output goes to `dist/`: +- `dist/index.html` - HTML shell +- `dist/js/editor.js` - Single JS bundle +- `dist/css/editor.css` - All styles +- `dist/assets/` - Static assets (if any) + +## Deploying to WHP + +Copy the built `dist/` contents into the WHP site-builder web directory: + +```bash +# Build +cd /workspace/site-builder/craft && npm run build + +# Deploy to WHP Docker container +cp dist/index.html /docker/whp/web/site-builder/editor.html +cp -r dist/js/ /docker/whp/web/site-builder/js/ +cp -r dist/css/ /docker/whp/web/site-builder/css/ +``` + +The PHP wrapper (`/docker/whp/web/site-builder/index.php`) injects `WHP_CONFIG` into the HTML before serving it, so the editor gets the current user's session, CSRF token, site ID, etc. + +## Architecture + +### Key Decisions + +1. **No iframe** - The canvas renders directly in the DOM (unlike GrapesJS which uses an iframe). This simplifies drag-and-drop and avoids cross-origin issues but means editor CSS must not leak into user content. + +2. **Inline styles** - All component styling uses React `CSSProperties` (inline styles). No class-based CSS for user content. This makes HTML export trivial and avoids stylesheet management. + +3. **Single Frame, multi-page** - Craft.js `` holds one page at a time. Page switching serializes the current state, stores it, and deserializes the new page's state. + +4. **Header/Footer as separate pages** - Header and Footer are stored as independent Craft.js states (like pages) that render above and below every page. Editing them uses the same canvas but with a distinct editing mode. This provides site-wide shared navigation and footer. + +5. **API compatibility** - The save endpoint sends data in the same format as the GrapesJS version (`{ site_id, name, html, css, grapesjs: serializedJson }`), so the PHP backend doesn't need changes. + +6. **Component-based architecture** - Each visual element is a React component that doubles as a Craft.js `UserComponent`. All rendering, settings UI, and HTML export are co-located in one file. + +7. **Site Design Tokens** - A `SiteDesignContext` provides 17 design properties (colors, fonts, radii, nav style) that components can reference. Templates import their own design tokens when loaded. + +### Component Architecture + +Every component in `src/components/` follows this pattern: + +```typescript +import { UserComponent, useNode } from '@craftjs/core'; + +// 1. Props interface +interface MyComponentProps { + text?: string; + style?: CSSProperties; +} + +// 2. The component itself (renders in editor canvas) +export const MyComponent: UserComponent = ({ text, style }) => { + const { connectors: { connect, drag } } = useNode(); + return
{ if (r) connect(drag(r)); }} style={style}>{text}
; +}; + +// 3. Settings panel (rendered in right panel when selected) +const MyComponentSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as MyComponentProps, + })); + return
/* preset buttons, inputs, etc. */
; +}; + +// 4. Craft config (displayName, default props, rules, related settings) +MyComponent.craft = { + displayName: 'My Component', + props: { text: 'Default text', style: {} }, + rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true }, + related: { settings: MyComponentSettings }, +}; + +// 5. HTML export (static method for serializing to HTML string) +(MyComponent as any).toHtml = (props: MyComponentProps, childrenHtml: string) => { + return { html: `
${childrenHtml}
` }; +}; +``` + +### Component Resolver + +All components must be registered in `src/components/resolver.ts`. This map is passed to `` so Craft.js can serialize/deserialize the node tree. + +```typescript +export const componentResolver = { + Container, Section, ColumnLayout, BackgroundSection, + Heading, TextBlock, ButtonLink, Navbar, Footer, Divider, Spacer, + ImageBlock, VideoBlock, + HeroSimple, FeaturesGrid, CTASection, + FormContainer, InputField, TextareaField, FormButton, +}; +``` + +## WHP Integration + +### PHP Wrapper + +The WHP control panel serves the editor through `index.php`, which: +1. Verifies user authentication +2. Validates the `site_id` parameter +3. Generates a CSRF token +4. Injects a `WHP_CONFIG` object into the HTML as a ` + + diff --git a/craft/package.json b/craft/package.json new file mode 100644 index 0000000..b95bbb7 --- /dev/null +++ b/craft/package.json @@ -0,0 +1,27 @@ +{ + "name": "whp-site-builder", + "private": true, + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "playwright test tests/site-builder.spec.ts --reporter=list", + "test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed" + }, + "dependencies": { + "@craftjs/core": "^0.2.10", + "@craftjs/layers": "^0.2.7", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } +} diff --git a/craft/src/App.tsx b/craft/src/App.tsx new file mode 100644 index 0000000..aee8e4a --- /dev/null +++ b/craft/src/App.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Editor } from '@craftjs/core'; +import { EditorShell } from './editor/EditorShell'; +import { componentResolver } from './components/resolver'; +import { WhpConfig } from './types'; +import { EditorConfigProvider } from './state/EditorConfigContext'; +import { SiteDesignProvider } from './state/SiteDesignContext'; +import { PageProvider } from './state/PageContext'; + +interface AppProps { + whpConfig: WhpConfig | null; +} + +export const App: React.FC = ({ whpConfig }) => { + return ( + + + + + + + + + + ); +}; diff --git a/craft/src/components/basic/ButtonLink.tsx b/craft/src/components/basic/ButtonLink.tsx new file mode 100644 index 0000000..6cbfdcb --- /dev/null +++ b/craft/src/components/basic/ButtonLink.tsx @@ -0,0 +1,232 @@ +import React, { CSSProperties } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface ButtonLinkProps { + text?: string; + href?: string; + target?: '_self' | '_blank'; + style?: CSSProperties; +} + +export const ButtonLink: UserComponent = ({ + text = 'Click Me', + href = '#', + target = '_self', + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + return ( +
{ if (ref) connect(drag(ref)); }} + href={href} + target={target} + onClick={(e) => { + // Prevent navigation inside editor + e.preventDefault(); + }} + style={{ + display: 'inline-block', + textDecoration: 'none', + cursor: 'pointer', + outline: selected ? '2px solid #3b82f6' : 'none', + ...style, + }} + > + {text} + + ); +}; + +/* ---------- Settings panel ---------- */ + +const ButtonLinkSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as ButtonLinkProps, + })); + + const colorPresets = [ + { bg: '#3b82f6', color: '#ffffff', label: 'Blue' }, + { bg: '#10b981', color: '#ffffff', label: 'Green' }, + { bg: '#ef4444', color: '#ffffff', label: 'Red' }, + { bg: '#f59e0b', color: '#18181b', label: 'Amber' }, + { bg: '#8b5cf6', color: '#ffffff', label: 'Purple' }, + { bg: '#18181b', color: '#ffffff', label: 'Dark' }, + { bg: '#ffffff', color: '#18181b', label: 'White' }, + { bg: 'transparent', color: '#3b82f6', label: 'Ghost' }, + ]; + const radiusPresets = ['0px', '4px', '8px', '12px', '9999px']; + const paddingPresets = ['8px 16px', '10px 20px', '12px 24px', '14px 32px', '16px 40px']; + + return ( +
+
+ + setProp((p: ButtonLinkProps) => { p.text = e.target.value; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ +
+ + setProp((p: ButtonLinkProps) => { p.href = e.target.value; })} + placeholder="https://..." + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ +
+ +
+ {(['_self', '_blank'] as const).map((t) => ( + + ))} +
+
+ +
+ +
+ {colorPresets.map((preset) => ( +
+
+ +
+ +
+ {radiusPresets.map((r) => ( + + ))} +
+
+ +
+ +
+ {paddingPresets.map((p) => ( + + ))} +
+
+ +
+ + setProp((p: ButtonLinkProps) => { p.style = { ...p.style, fontSize: e.target.value }; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }} + /> +
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +ButtonLink.craft = { + displayName: 'Button', + props: { + text: 'Click Me', + href: '#', + target: '_self', + style: { + backgroundColor: '#3b82f6', + color: '#ffffff', + padding: '12px 24px', + borderRadius: '8px', + fontWeight: '600', + fontSize: '16px', + border: 'none', + }, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: ButtonLinkSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => { + const styleStr = cssPropsToString({ + display: 'inline-block', + textDecoration: 'none', + ...props.style, + }); + const escapedText = (props.text || '').replace(//g, '>'); + const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''; + return { + html: `${escapedText}`, + }; +}; diff --git a/craft/src/components/basic/Divider.tsx b/craft/src/components/basic/Divider.tsx new file mode 100644 index 0000000..aa349f5 --- /dev/null +++ b/craft/src/components/basic/Divider.tsx @@ -0,0 +1,119 @@ +import React, { CSSProperties } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface DividerProps { + color?: string; + thickness?: string; + style?: CSSProperties; +} + +export const Divider: UserComponent = ({ + color = '#e4e4e7', + thickness = '1px', + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + border: 'none', + borderTop: `${thickness} solid ${color}`, + margin: '16px 0', + outline: selected ? '2px solid #3b82f6' : 'none', + ...style, + }} + /> + ); +}; + +/* ---------- Settings panel ---------- */ + +const DividerSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as DividerProps, + })); + + const colorPresets = ['#e4e4e7', '#d4d4d8', '#a1a1aa', '#3f3f46', '#18181b', '#3b82f6', '#ef4444', '#10b981']; + const thicknessPresets = ['1px', '2px', '3px', '4px', '6px']; + + return ( +
+
+ +
+ {colorPresets.map((c) => ( +
+
+ +
+ +
+ {thicknessPresets.map((t) => ( + + ))} +
+
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +Divider.craft = { + displayName: 'Divider', + props: { + color: '#e4e4e7', + thickness: '1px', + style: {}, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: DividerSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(Divider as any).toHtml = (props: DividerProps, _childrenHtml: string) => { + const styleStr = cssPropsToString({ + border: 'none', + borderTop: `${props.thickness || '1px'} solid ${props.color || '#e4e4e7'}`, + margin: '16px 0', + ...props.style, + }); + return { html: `` }; +}; diff --git a/craft/src/components/basic/Footer.tsx b/craft/src/components/basic/Footer.tsx new file mode 100644 index 0000000..4b75040 --- /dev/null +++ b/craft/src/components/basic/Footer.tsx @@ -0,0 +1,153 @@ +import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface FooterProps { + text?: string; + style?: CSSProperties; +} + +export const Footer: UserComponent = ({ + text = '© 2026 MySite. All rights reserved.', + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + actions: { setProp }, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + const elRef = useRef(null); + + const handleBlur = useCallback(() => { + if (elRef.current) { + const newText = elRef.current.innerText; + setProp((p: FooterProps) => { p.text = newText; }, 500); + } + }, [setProp]); + + useEffect(() => { + if (elRef.current && !selected) { + elRef.current.innerText = text || ''; + } + }, [text, selected]); + + return ( +
{ + elRef.current = ref; + if (ref) connect(drag(ref)); + }} + contentEditable={selected} + suppressContentEditableWarning + onBlur={handleBlur} + style={{ + padding: '24px 20px', + textAlign: 'center', + outline: 'none', + cursor: selected ? 'text' : 'pointer', + ...style, + }} + > + {selected ? undefined : (text || '')} +
+ ); +}; + +/* ---------- Settings panel ---------- */ + +const FooterSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as FooterProps, + })); + + const bgPresets = ['#ffffff', '#f8fafc', '#18181b', '#0f172a', '#1e293b']; + const colorPresets = ['#18181b', '#3f3f46', '#71717a', '#a1a1aa', '#e4e4e7', '#ffffff']; + + return ( +
+
+ + setProp((p: FooterProps) => { p.text = e.target.value; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ +
+ +
+ {bgPresets.map((c) => ( +
+
+ +
+ +
+ {colorPresets.map((c) => ( +
+
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +Footer.craft = { + displayName: 'Footer', + props: { + text: '© 2026 MySite. All rights reserved.', + style: { + backgroundColor: '#18181b', + color: '#a1a1aa', + fontSize: '14px', + padding: '24px 20px', + }, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: FooterSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(Footer as any).toHtml = (props: FooterProps, _childrenHtml: string) => { + const styleStr = cssPropsToString({ + padding: '24px 20px', + textAlign: 'center', + ...props.style, + }); + const escapedText = (props.text || '').replace(//g, '>'); + return { html: `${escapedText}` }; +}; diff --git a/craft/src/components/basic/Heading.tsx b/craft/src/components/basic/Heading.tsx new file mode 100644 index 0000000..3131596 --- /dev/null +++ b/craft/src/components/basic/Heading.tsx @@ -0,0 +1,181 @@ +import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; +import { SettingsTabs } from '../../ui/SettingsTabs'; +import { TypographyControl } from '../../ui/TypographyControl'; +import { AdvancedTab } from '../../ui/AdvancedTab'; + +type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +interface HeadingProps { + text?: string; + level?: HeadingLevel; + style?: CSSProperties; + cssId?: string; + cssClass?: string; + hideOnDesktop?: boolean; + hideOnTablet?: boolean; + hideOnMobile?: boolean; + animation?: string; + animationDelay?: string; +} + +export const Heading: UserComponent = ({ + text = 'Heading', + level = 'h2', + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + actions: { setProp }, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + const elRef = useRef(null); + const editedTextRef = useRef(null); + + const commitText = useCallback(() => { + if (elRef.current) { + const newText = elRef.current.innerText; + editedTextRef.current = newText; + setProp((p: HeadingProps) => { p.text = newText; }); + } + }, [setProp]); + + // Commit on blur + const handleBlur = useCallback(() => { commitText(); }, [commitText]); + + // Also commit on deselect via effect + useEffect(() => { + if (!selected && editedTextRef.current !== null) { + setProp((p: HeadingProps) => { p.text = editedTextRef.current!; }); + editedTextRef.current = null; + } + }, [selected, setProp]); + + // Set DOM text on mount and when text prop changes externally (not during editing) + useEffect(() => { + if (elRef.current && !selected && editedTextRef.current === null) { + elRef.current.innerText = text || ''; + } + }, [text, selected]); + + return React.createElement(level, { + ref: (ref: HTMLElement | null): void => { + elRef.current = ref; + if (ref) connect(drag(ref)); + }, + contentEditable: selected, + suppressContentEditableWarning: true, + onBlur: handleBlur, + onInput: () => { + // Track that we have unsaved edits + if (elRef.current) { + editedTextRef.current = elRef.current.innerText; + } + }, + style: { outline: 'none', cursor: selected ? 'text' : 'pointer', minHeight: '1em', ...style }, + }); +}; + +/* ---------- Settings panel ---------- */ + +const HeadingSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as HeadingProps, + })); + + const levels: HeadingLevel[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + + return ( + +
+ +
+ {levels.map((l) => ( + + ))} +
+
+
+ + setProp((p: HeadingProps) => { p.text = e.target.value; })} + style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 13 }} + /> +
+ + } + style={ + setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })} + /> + } + advanced={ + setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })} + cssId={props.cssId || ''} + onCssIdChange={(id) => setProp((p: HeadingProps) => { p.cssId = id; })} + cssClass={props.cssClass || ''} + onCssClassChange={(cls) => setProp((p: HeadingProps) => { p.cssClass = cls; })} + hideOnDesktop={props.hideOnDesktop} + onHideOnDesktopChange={(v) => setProp((p: HeadingProps) => { p.hideOnDesktop = v; })} + hideOnTablet={props.hideOnTablet} + onHideOnTabletChange={(v) => setProp((p: HeadingProps) => { p.hideOnTablet = v; })} + hideOnMobile={props.hideOnMobile} + onHideOnMobileChange={(v) => setProp((p: HeadingProps) => { p.hideOnMobile = v; })} + animation={props.animation} + onAnimationChange={(v) => setProp((p: HeadingProps) => { p.animation = v; })} + animationDelay={props.animationDelay} + onAnimationDelayChange={(v) => setProp((p: HeadingProps) => { p.animationDelay = v; })} + /> + } + /> + ); +}; + +Heading.craft = { + displayName: 'Heading', + props: { + text: 'Your Heading', + level: 'h2' as HeadingLevel, + style: { + fontSize: '36px', + fontWeight: '700', + fontFamily: 'Inter, sans-serif', + color: '#1f2937', + marginBottom: '16px', + }, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: HeadingSettings, + }, +}; + +(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => { + const tag = props.level || 'h2'; + const safeText = (props.text || '').replace(//g, '>'); + const styleStr = cssPropsToString(props.style); + return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}` }; +}; diff --git a/craft/src/components/basic/HtmlBlock.tsx b/craft/src/components/basic/HtmlBlock.tsx new file mode 100644 index 0000000..c9ff9e5 --- /dev/null +++ b/craft/src/components/basic/HtmlBlock.tsx @@ -0,0 +1,127 @@ +import React, { CSSProperties } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface HtmlBlockProps { + code: string; + style?: CSSProperties; +} + +export const HtmlBlock: UserComponent = ({ + code = '', + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + minHeight: '40px', + outline: selected ? '2px solid #3b82f6' : 'none', + ...style, + }} + dangerouslySetInnerHTML={{ __html: code }} + /> + ); +}; + +/* ---------- Settings panel ---------- */ + +const HtmlBlockSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as HtmlBlockProps, + })); + + return ( +
+
+ This block renders raw HTML. Use with caution. +
+ +
+ + `; + } else if (field.type === 'select') { + const opts = (field.options || []).map((o) => ``).join(''); + inputHtml = ``; + } else { + inputHtml = ``; + } + return `
${labelHtml}${inputHtml}
`; + }).join('\n '); + + const btnStyle = cssPropsToString({ + padding: '12px 32px', + fontSize: '16px', + fontWeight: '600', + fontFamily: 'Inter, sans-serif', + color: '#ffffff', + backgroundColor: props.submitColor || '#3b82f6', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + alignSelf: 'flex-start', + }); + + return { + html: `
+ ${fieldsHtml} + +
`, + }; +}; diff --git a/craft/src/components/forms/FormButton.tsx b/craft/src/components/forms/FormButton.tsx new file mode 100644 index 0000000..e469423 --- /dev/null +++ b/craft/src/components/forms/FormButton.tsx @@ -0,0 +1,179 @@ +import React, { CSSProperties } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface FormButtonProps { + text?: string; + style?: CSSProperties; +} + +export const FormButton: UserComponent = ({ + text = 'Submit', + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + return ( + + ); +}; + +/* ---------- Settings panel ---------- */ + +const FormButtonSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as FormButtonProps, + })); + + const colorPresets = [ + { bg: '#3b82f6', color: '#ffffff', label: 'Blue' }, + { bg: '#10b981', color: '#ffffff', label: 'Green' }, + { bg: '#ef4444', color: '#ffffff', label: 'Red' }, + { bg: '#f59e0b', color: '#18181b', label: 'Amber' }, + { bg: '#8b5cf6', color: '#ffffff', label: 'Purple' }, + { bg: '#18181b', color: '#ffffff', label: 'Dark' }, + ]; + + const radiusPresets = ['0px', '4px', '6px', '8px', '9999px']; + const widthPresets = ['auto', '100%']; + + return ( +
+
+ + setProp((p: FormButtonProps) => { p.text = e.target.value; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ +
+ +
+ {colorPresets.map((preset) => ( +
+
+ +
+ +
+ {radiusPresets.map((r) => ( + + ))} +
+
+ +
+ +
+ {widthPresets.map((w) => ( + + ))} +
+
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +FormButton.craft = { + displayName: 'Submit Button', + props: { + text: 'Submit', + style: { + backgroundColor: '#3b82f6', + color: '#ffffff', + padding: '12px 32px', + borderRadius: '6px', + fontWeight: '600', + fontSize: '16px', + border: 'none', + }, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: FormButtonSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(FormButton as any).toHtml = (props: FormButtonProps, _childrenHtml: string) => { + const styleStr = cssPropsToString({ + padding: '12px 32px', + border: 'none', + cursor: 'pointer', + ...props.style, + }); + const escapedText = (props.text || 'Submit').replace(//g, '>'); + return { + html: ``, + }; +}; diff --git a/craft/src/components/forms/FormContainer.tsx b/craft/src/components/forms/FormContainer.tsx new file mode 100644 index 0000000..95f6d43 --- /dev/null +++ b/craft/src/components/forms/FormContainer.tsx @@ -0,0 +1,140 @@ +import React, { CSSProperties } from 'react'; +import { useNode, Element, UserComponent } from '@craftjs/core'; +import { Container } from '../layout/Container'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface FormContainerProps { + action?: string; + method?: 'GET' | 'POST'; + style?: CSSProperties; + children?: React.ReactNode; +} + +export const FormContainer: UserComponent = ({ + action = '#', + method = 'POST', + style = {}, +}) => { + const { connectors: { connect, drag } } = useNode(); + + return ( +
{ if (ref) connect(drag(ref)); }} + action={action} + method={method} + onSubmit={(e) => e.preventDefault()} + style={{ + padding: '24px', + minHeight: '80px', + ...style, + }} + > + + + ); +}; + +/* ---------- Settings panel ---------- */ + +const FormContainerSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as FormContainerProps, + })); + + const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a']; + + return ( +
+
+ + setProp((p: FormContainerProps) => { p.action = e.target.value; })} + placeholder="https://... or /api/submit" + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ +
+ +
+ {(['GET', 'POST'] as const).map((m) => ( + + ))} +
+
+ +
+ +
+ {bgPresets.map((c) => ( +
+
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +FormContainer.craft = { + displayName: 'Form', + props: { + action: '#', + method: 'POST', + style: { + padding: '24px', + backgroundColor: '#ffffff', + borderRadius: '8px', + border: '1px solid #e4e4e7', + }, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: FormContainerSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string) => { + const styleStr = cssPropsToString({ + padding: '24px', + ...props.style, + }); + return { + html: `
${childrenHtml}
`, + }; +}; diff --git a/craft/src/components/forms/InputField.tsx b/craft/src/components/forms/InputField.tsx new file mode 100644 index 0000000..8f74072 --- /dev/null +++ b/craft/src/components/forms/InputField.tsx @@ -0,0 +1,185 @@ +import React, { CSSProperties } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface InputFieldProps { + label?: string; + type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'; + name?: string; + placeholder?: string; + required?: boolean; + style?: CSSProperties; +} + +export const InputField: UserComponent = ({ + label = 'Label', + type = 'text', + name = 'field', + placeholder = '', + required = false, + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + display: 'flex', + flexDirection: 'column', + gap: '4px', + outline: selected ? '2px solid #3b82f6' : 'none', + borderRadius: '4px', + ...style, + }} + > + {label && ( + + )} + +
+ ); +}; + +/* ---------- Settings panel ---------- */ + +const InputFieldSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as InputFieldProps, + })); + + const typeOptions: InputFieldProps['type'][] = ['text', 'email', 'password', 'number', 'tel', 'url']; + + return ( +
+
+ + setProp((p: InputFieldProps) => { p.label = e.target.value; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ +
+ +
+ {typeOptions.map((t) => ( + + ))} +
+
+ +
+ + setProp((p: InputFieldProps) => { p.name = e.target.value; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ +
+ + setProp((p: InputFieldProps) => { p.placeholder = e.target.value; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ +
+ +
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +InputField.craft = { + displayName: 'Input', + props: { + label: 'Your Name', + type: 'text', + name: 'name', + placeholder: 'Enter your name', + required: false, + style: {}, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: InputFieldSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => { + const esc = (s: string) => s.replace(//g, '>').replace(/"/g, '"'); + const wrapStyle = cssPropsToString({ + display: 'flex', + flexDirection: 'column', + gap: '4px', + ...props.style, + }); + const reqAttr = props.required ? ' required' : ''; + const labelHtml = props.label + ? `` + : ''; + return { + html: ` + ${labelHtml} + +
`, + }; +}; diff --git a/craft/src/components/forms/SubscribeForm.tsx b/craft/src/components/forms/SubscribeForm.tsx new file mode 100644 index 0000000..46c031b --- /dev/null +++ b/craft/src/components/forms/SubscribeForm.tsx @@ -0,0 +1,307 @@ +import React, { CSSProperties } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface SubscribeFormProps { + heading?: string; + placeholder?: string; + buttonText?: string; + buttonColor?: string; + layout?: 'inline' | 'stacked'; + style?: CSSProperties; +} + +export const SubscribeForm: UserComponent = ({ + heading = 'Subscribe to our newsletter', + placeholder = 'Enter your email', + buttonText = 'Subscribe', + buttonColor = '#3b82f6', + layout = 'inline', + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + const isInline = layout === 'inline'; + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + padding: '40px 24px', + textAlign: 'center', + outline: selected ? '2px solid #3b82f6' : 'none', + ...style, + }} + > + {heading && ( +

+ {heading} +

+ )} +
e.preventDefault()} + style={{ + display: 'flex', + flexDirection: isInline ? 'row' : 'column', + gap: isInline ? '0' : '12px', + maxWidth: isInline ? '480px' : '360px', + margin: '0 auto', + alignItems: 'stretch', + }} + > + + +
+
+ ); +}; + +/* ---------- Settings panel ---------- */ + +const SubscribeFormSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as SubscribeFormProps, + })); + + const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }; + const inputStyle: CSSProperties = { + width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', + border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, + }; + + const buttonColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899']; + const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a', '#eff6ff', '#f0fdf4', '#fef3c7']; + + return ( +
+ {/* Heading */} +
+ + setProp((p: SubscribeFormProps) => { p.heading = e.target.value; })} + placeholder="Subscribe to our newsletter" + style={inputStyle} + /> +
+ + {/* Placeholder */} +
+ + setProp((p: SubscribeFormProps) => { p.placeholder = e.target.value; })} + placeholder="Enter your email" + style={inputStyle} + /> +
+ + {/* Button Text */} +
+ + setProp((p: SubscribeFormProps) => { p.buttonText = e.target.value; })} + placeholder="Subscribe" + style={inputStyle} + /> +
+ + {/* Layout */} +
+ +
+ + +
+
+ + {/* Button Color */} +
+ +
+ {buttonColorPresets.map((c) => ( +
+
+ + {/* Background */} +
+ +
+ {bgPresets.map((c) => ( +
+
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +SubscribeForm.craft = { + displayName: 'Subscribe Form', + props: { + heading: 'Subscribe to our newsletter', + placeholder: 'Enter your email', + buttonText: 'Subscribe', + buttonColor: '#3b82f6', + layout: 'inline', + style: { backgroundColor: '#f8fafc' }, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: SubscribeFormSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => { + const esc = (s: string) => s.replace(//g, '>').replace(/"/g, '"'); + const { + heading = 'Subscribe to our newsletter', + placeholder = 'Enter your email', + buttonText = 'Subscribe', + buttonColor = '#3b82f6', + layout = 'inline', + style = {}, + } = props; + + const isInline = layout === 'inline'; + + const wrapperStyle = cssPropsToString({ + padding: '40px 24px', + textAlign: 'center', + ...style, + }); + + const headingHtml = heading + ? `

${esc(heading)}

` + : ''; + + const formStyle = cssPropsToString({ + display: 'flex', + flexDirection: isInline ? 'row' : 'column', + gap: isInline ? '0' : '12px', + maxWidth: isInline ? '480px' : '360px', + margin: '0 auto', + alignItems: 'stretch', + }); + + const inputStyleStr = `flex:1;padding:12px 16px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${isInline ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`; + + const btnStyle = cssPropsToString({ + padding: '12px 24px', + fontSize: '15px', + fontWeight: '600', + fontFamily: 'Inter, sans-serif', + color: '#ffffff', + backgroundColor: buttonColor, + border: 'none', + borderRadius: isInline ? '0 8px 8px 0' : '8px', + cursor: 'pointer', + whiteSpace: 'nowrap', + }); + + return { + html: ` + ${headingHtml} +
+ + +
+
`, + }; +}; diff --git a/craft/src/components/forms/TextareaField.tsx b/craft/src/components/forms/TextareaField.tsx new file mode 100644 index 0000000..2cb7ae9 --- /dev/null +++ b/craft/src/components/forms/TextareaField.tsx @@ -0,0 +1,187 @@ +import React, { CSSProperties } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface TextareaFieldProps { + label?: string; + name?: string; + placeholder?: string; + rows?: number; + required?: boolean; + style?: CSSProperties; +} + +export const TextareaField: UserComponent = ({ + label = 'Message', + name = 'message', + placeholder = '', + rows = 4, + required = false, + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + display: 'flex', + flexDirection: 'column', + gap: '4px', + outline: selected ? '2px solid #3b82f6' : 'none', + borderRadius: '4px', + ...style, + }} + > + {label && ( + + )} + +
`, + }; +}; diff --git a/craft/src/components/layout/BackgroundSection.tsx b/craft/src/components/layout/BackgroundSection.tsx new file mode 100644 index 0000000..03e1ef2 --- /dev/null +++ b/craft/src/components/layout/BackgroundSection.tsx @@ -0,0 +1,206 @@ +import React, { CSSProperties } from 'react'; +import { useNode, Element, UserComponent } from '@craftjs/core'; +import { Container } from './Container'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface BackgroundSectionProps { + bgImage?: string; + bgColor?: string; + overlayColor?: string; + overlayOpacity?: number; + innerMaxWidth?: string; + style?: CSSProperties; + children?: React.ReactNode; +} + +export const BackgroundSection: UserComponent = ({ + bgImage = '', + bgColor = '#1e293b', + overlayColor = '#000000', + overlayOpacity = 0.4, + innerMaxWidth = '1200px', + style = {}, +}) => { + const { connectors: { connect, drag } } = useNode(); + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + position: 'relative', + width: '100%', + minHeight: '200px', + backgroundColor: bgColor, + backgroundImage: bgImage ? `url(${bgImage})` : undefined, + backgroundSize: 'cover', + backgroundPosition: 'center', + ...style, + }} + > + {/* Overlay */} +
+ {/* Content */} + +
+ ); +}; + +/* ---------- Settings panel ---------- */ + +const BackgroundSectionSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as BackgroundSectionProps, + })); + + const bgColorPresets = ['#1e293b', '#0f172a', '#18181b', '#1e3a5f', '#312e81', '#064e3b', '#7f1d1d', '#ffffff']; + const overlayPresets = ['#000000', '#1e293b', '#0f172a', '#312e81', '#064e3b', '#7f1d1d']; + + return ( +
+
+ + setProp((p: BackgroundSectionProps) => { p.bgImage = e.target.value; })} + placeholder="https://... or /storage/assets/..." + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ +
+ +
+ {bgColorPresets.map((c) => ( +
+
+ +
+ +
+ {overlayPresets.map((c) => ( +
+
+ +
+ + setProp((p: BackgroundSectionProps) => { p.overlayOpacity = parseInt(e.target.value, 10) / 100; })} + style={{ width: '100%' }} + /> +
+ +
+ + setProp((p: BackgroundSectionProps) => { p.innerMaxWidth = e.target.value; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }} + /> +
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +BackgroundSection.craft = { + displayName: 'Background Section', + props: { + bgImage: '', + bgColor: '#1e293b', + overlayColor: '#000000', + overlayOpacity: 0.4, + innerMaxWidth: '1200px', + style: { padding: '0' }, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: BackgroundSectionSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => { + const outerStyle = cssPropsToString({ + position: 'relative', + width: '100%', + minHeight: '200px', + backgroundColor: props.bgColor || '#1e293b', + backgroundImage: props.bgImage ? `url(${props.bgImage})` : undefined, + backgroundSize: 'cover', + backgroundPosition: 'center', + ...props.style, + }); + const overlayStyle = cssPropsToString({ + position: 'absolute', + inset: '0', + backgroundColor: props.overlayColor || '#000000', + opacity: String(props.overlayOpacity ?? 0.4), + pointerEvents: 'none', + }); + const innerStyle = cssPropsToString({ + position: 'relative', + zIndex: '1', + maxWidth: props.innerMaxWidth || '1200px', + margin: '0 auto', + padding: '60px 20px', + }); + return { + html: `
${childrenHtml}`, + }; +}; diff --git a/craft/src/components/layout/ColumnLayout.tsx b/craft/src/components/layout/ColumnLayout.tsx new file mode 100644 index 0000000..22306b3 --- /dev/null +++ b/craft/src/components/layout/ColumnLayout.tsx @@ -0,0 +1,298 @@ +import React, { CSSProperties, useState } from 'react'; +import { useNode, Element, UserComponent } from '@craftjs/core'; +import { Container } from './Container'; +import { cssPropsToString } from '../../utils/style-helpers'; + +type SplitOption = + | '100' + | '50-50' | '30-70' | '70-30' | '40-60' | '60-40' + | '33-33-33' | '25-50-25' + | '25-25-25-25' + | '20-20-20-20-20' + | '16-16-16-16-16-16' + | 'equal'; + +interface ColumnLayoutProps { + columns?: number; + split?: SplitOption; + gap?: string; + style?: CSSProperties; + children?: React.ReactNode; +} + +const splitToWidths: Record = { + '100': ['100%'], + '50-50': ['50%', '50%'], + '30-70': ['30%', '70%'], + '70-30': ['70%', '30%'], + '40-60': ['40%', '60%'], + '60-40': ['60%', '40%'], + '33-33-33': ['33.333%', '33.333%', '33.333%'], + '25-50-25': ['25%', '50%', '25%'], + '25-25-25-25': ['25%', '25%', '25%', '25%'], + '20-20-20-20-20': ['20%', '20%', '20%', '20%', '20%'], + '16-16-16-16-16-16': ['16.666%', '16.666%', '16.666%', '16.666%', '16.666%', '16.666%'], +}; + +function getWidths(split: SplitOption, columns: number): string[] { + // Check predefined splits first + if (split !== 'equal') { + const defined = splitToWidths[split]; + if (defined && defined.length === columns) return defined; + } + + // Try parsing custom split string (e.g., "35-65" or "25-50-25") + if (split && split !== 'equal' && split.includes('-')) { + const parts = split.split('-').map(Number); + if (parts.length === columns && parts.every(n => !isNaN(n) && n > 0)) { + return parts.map(n => `${n}%`); + } + } + + // Fallback: equal widths + const w = `${(100 / columns).toFixed(3)}%`; + return Array.from({ length: columns }, () => w); +} + +export const ColumnLayout: UserComponent = ({ + columns = 2, + split = '50-50', + gap = '16px', + style = {}, +}) => { + const { connectors: { connect, drag } } = useNode(); + const widths = getWidths(split, columns); + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + display: 'flex', + flexWrap: 'wrap', + gap, + width: '100%', + minHeight: '60px', + ...style, + }} + > + {widths.map((w, i) => ( + + ))} +
+ ); +}; + +/* ---------- Settings panel ---------- */ + +const ColumnLayoutSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as ColumnLayoutProps, + })); + + const [showCustom, setShowCustom] = useState(false); + + /* Preset options -- common splits up to 6 columns */ + const presetOptions: { columns: number; split: SplitOption; label: string }[] = [ + { columns: 1, split: '100', label: '1 Col' }, + { columns: 2, split: '50-50', label: '2 Equal' }, + { columns: 2, split: '30-70', label: '30/70' }, + { columns: 2, split: '70-30', label: '70/30' }, + { columns: 2, split: '40-60', label: '40/60' }, + { columns: 2, split: '60-40', label: '60/40' }, + { columns: 3, split: '33-33-33', label: '3 Equal' }, + { columns: 3, split: '25-50-25', label: '25/50/25' }, + { columns: 4, split: '25-25-25-25', label: '4 Equal' }, + { columns: 5, split: '20-20-20-20-20', label: '5 Equal' }, + { columns: 6, split: '16-16-16-16-16-16', label: '6 Equal' }, + ]; + + const gapPresets = ['0px', '8px', '16px', '24px', '32px']; + + const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }; + const inputStyle: CSSProperties = { + width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7', + border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, + }; + + return ( +
+ {/* Preset layouts */} +
+ +
+ {presetOptions.map((opt) => ( + + ))} +
+
+ + {/* Custom column count (7-10) */} +
+ + {showCustom && ( +
+ +
+ { + const cols = parseInt(e.target.value); + setProp((p: ColumnLayoutProps) => { p.columns = cols; p.split = 'equal'; }); + }} + style={{ flex: 1 }} + /> + {props.columns || 2} +
+
+ )} +
+ + {/* Gap */} +
+ +
+ {gapPresets.map((g) => ( + + ))} +
+
+ + {/* Individual Column Widths */} +
+ +

+ Adjust each column's width. Values should roughly total 100%. +

+
+ {Array.from({ length: props.columns || 2 }).map((_, i) => { + const currentWidths = getWidths(props.split || 'equal', props.columns || 2); + const currentPct = parseFloat(currentWidths[i]) || (100 / (props.columns || 2)); + return ( +
+ Col {i + 1} + { + const newPct = parseInt(e.target.value); + const cols = props.columns || 2; + const widths = getWidths(props.split || 'equal', cols).map(w => parseFloat(w)); + const oldPct = widths[i]; + const diff = newPct - oldPct; + widths[i] = newPct; + // Distribute the difference across other columns proportionally + const others = widths.filter((_, j) => j !== i); + const otherTotal = others.reduce((a, b) => a + b, 0); + if (otherTotal > 0) { + for (let j = 0; j < widths.length; j++) { + if (j !== i) { + widths[j] = widths[j] - (diff * (widths[j] / otherTotal)); + if (widths[j] < 5) widths[j] = 5; + } + } + } + // Normalize to 100% + const total = widths.reduce((a, b) => a + b, 0); + const normalized = widths.map(w => ((w / total) * 100).toFixed(1) + '%'); + const customSplit = normalized.map(w => parseFloat(w).toFixed(0)).join('-') as SplitOption; + setProp((p: ColumnLayoutProps) => { p.split = customSplit; }); + }} + style={{ flex: 1 }} + /> + + {Math.round(currentPct)}% + +
+ ); + })} +
+
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +ColumnLayout.craft = { + displayName: 'Columns', + props: { + columns: 2, + split: '50-50', + gap: '16px', + style: {}, + }, + rules: { + canDrag: () => true, + canMoveIn: () => false, + canMoveOut: () => true, + }, + related: { + settings: ColumnLayoutSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => { + const gap = props.gap || '16px'; + const outerStyle = cssPropsToString({ + display: 'flex', + flexWrap: 'wrap', + gap, + width: '100%', + ...props.style, + }); + return { + html: `${childrenHtml}`, + }; +}; diff --git a/craft/src/components/layout/Container.tsx b/craft/src/components/layout/Container.tsx new file mode 100644 index 0000000..22b39fe --- /dev/null +++ b/craft/src/components/layout/Container.tsx @@ -0,0 +1,324 @@ +import React, { CSSProperties } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; +import { SettingsTabs } from '../../ui/SettingsTabs'; +import { BorderControl } from '../../ui/BorderControl'; +import { AdvancedTab } from '../../ui/AdvancedTab'; + +interface ContainerProps { + style?: CSSProperties; + tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'; + children?: React.ReactNode; + cssId?: string; + cssClass?: string; + hideOnDesktop?: boolean; + hideOnTablet?: boolean; + hideOnMobile?: boolean; + animation?: string; + animationDelay?: string; + fullWidth?: boolean; + contentWidth?: 'boxed' | 'full'; +} + +export const Container: UserComponent = ({ + style = {}, + tag = 'div', + children, + fullWidth = false, + contentWidth = 'full', +}) => { + const { connectors: { connect, drag } } = useNode(); + + const outerStyle: CSSProperties = { + minHeight: '40px', + ...style, + ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), + }; + + const needsBoxedWrapper = contentWidth === 'boxed'; + + const el = React.createElement( + tag, + { + ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }, + style: outerStyle, + 'data-craft-container': 'true', + }, + needsBoxedWrapper + ? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto' } }, children) + : children, + ); + + return el; +}; + +/* ---------- Settings panel ---------- */ + +const cLabelStyle: React.CSSProperties = { + fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, + textTransform: 'uppercase', letterSpacing: '0.3px', +}; +const cInputStyle: React.CSSProperties = { + width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7', + border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, +}; +const cPresetBtnStyle = (active: boolean): React.CSSProperties => ({ + padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', + border: '1px solid #3f3f46', background: active ? '#3b82f6' : '#27272a', color: active ? '#fff' : '#e4e4e7', +}); +const cSwatchStyle = (color: string, active: boolean): React.CSSProperties => ({ + width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46', backgroundColor: color, cursor: 'pointer', + outline: active ? '2px solid #3b82f6' : 'none', outlineOffset: 1, +}); + +const cToggleBtnStyle = (active: boolean): React.CSSProperties => ({ + flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', + border: '1px solid #3f3f46', + background: active ? '#3b82f6' : '#27272a', + color: active ? '#fff' : '#e4e4e7', + fontWeight: active ? 600 : 400, + textAlign: 'center', +}); + +const ContainerSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as ContainerProps, + })); + + const bgColors = ['transparent', '#ffffff', '#f9fafb', '#f1f5f9', '#1f2937', '#111827', '#0f172a', '#3b82f6', '#10b981', '#8b5cf6', '#ec4899', '#f59e0b']; + const gradients = [ + { label: 'None', value: 'none' }, + { label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }, + { label: 'Blue', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' }, + { label: 'Sunset', value: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' }, + { label: 'Dark', value: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)' }, + { label: 'Green', value: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' }, + ]; + const alignPresets = [ + { label: 'Left', value: 'left', icon: 'fa-align-left' }, + { label: 'Center', value: 'center', icon: 'fa-align-center' }, + { label: 'Right', value: 'right', icon: 'fa-align-right' }, + ]; + + const currentBg = props.style?.backgroundColor || ''; + const currentBgImage = props.style?.backgroundImage || ''; + + return ( + + {/* Tag */} +
+ + +
+ + {/* Full Width */} +
+ + + Breaks out of parent constraints to fill the viewport width + +
+ + {/* Content Width */} +
+ +
+ + +
+ + {props.contentWidth === 'boxed' + ? 'Content is centered with a max-width of 1200px' + : 'Content fills the full container width'} + +
+ + } + style={ +
+ {/* Background Color */} +
+ +
+ {bgColors.map((c) => ( +
+
+ + {/* Background Gradient */} +
+ +
+ {gradients.map((g) => ( +
+
+ + {/* Background Image */} +
+ + { + const val = e.target.value.trim(); + setProp((p: ContainerProps) => { + p.style = { ...p.style, backgroundImage: val ? `url('${val}')` : 'none', backgroundSize: 'cover', backgroundPosition: 'center' }; + }); + }} + style={cInputStyle} /> +
+ {['cover', 'contain', 'auto'].map((s) => ( + + ))} + {['center', 'top', 'bottom'].map((pos) => ( + + ))} +
+
+ + {/* Overlay */} +
+ +
+ setProp((p: ContainerProps) => { p.style = { ...p.style, ['--overlayColor' as keyof CSSProperties]: e.target.value }; })} + style={{ width: 32, height: 24, border: 'none', background: 'none', cursor: 'pointer' }} /> + Overlay (via CSS custom property) +
+
+ + {/* Parallax */} +
+ +
+ + {/* Text Alignment */} +
+ +
+ {alignPresets.map((a) => ( + + ))} +
+
+ + {/* Border */} + setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })} + /> +
+ } + advanced={ + setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })} + showTagSelector + tag={props.tag || 'div'} + onTagChange={(tag) => setProp((p: ContainerProps) => { p.tag = tag as ContainerProps['tag']; })} + cssId={props.cssId || ''} + onCssIdChange={(id) => setProp((p: ContainerProps) => { p.cssId = id; })} + cssClass={props.cssClass || ''} + onCssClassChange={(cls) => setProp((p: ContainerProps) => { p.cssClass = cls; })} + hideOnDesktop={props.hideOnDesktop} + onHideOnDesktopChange={(v) => setProp((p: ContainerProps) => { p.hideOnDesktop = v; })} + hideOnTablet={props.hideOnTablet} + onHideOnTabletChange={(v) => setProp((p: ContainerProps) => { p.hideOnTablet = v; })} + hideOnMobile={props.hideOnMobile} + onHideOnMobileChange={(v) => setProp((p: ContainerProps) => { p.hideOnMobile = v; })} + animation={props.animation} + onAnimationChange={(v) => setProp((p: ContainerProps) => { p.animation = v; })} + animationDelay={props.animationDelay} + onAnimationDelayChange={(v) => setProp((p: ContainerProps) => { p.animationDelay = v; })} + /> + } + /> + ); +}; + +/* ---------- Craft config ---------- */ + +Container.craft = { + displayName: 'Container', + props: { + style: { padding: '20px', minHeight: '100px' }, + tag: 'div', + fullWidth: false, + contentWidth: 'full', + }, + rules: { + canDrag: () => true, + canMoveIn: () => true, + canMoveOut: () => true, + }, + related: { + settings: ContainerSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => { + const tag = props.tag || 'div'; + const outerCss: CSSProperties = { ...props.style }; + + if (props.fullWidth) { + outerCss.width = '100vw'; + outerCss.marginLeft = 'calc(-50vw + 50%)'; + } + + const styleStr = cssPropsToString(outerCss); + + if (props.contentWidth === 'boxed') { + const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto' }); + return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; + } + + return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; +}; diff --git a/craft/src/components/layout/FooterZone.tsx b/craft/src/components/layout/FooterZone.tsx new file mode 100644 index 0000000..5908bbd --- /dev/null +++ b/craft/src/components/layout/FooterZone.tsx @@ -0,0 +1,81 @@ +import React, { CSSProperties } from 'react'; +import { useNode, Element, UserComponent } from '@craftjs/core'; +import { Container } from './Container'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface FooterZoneProps { + style?: CSSProperties; + children?: React.ReactNode; +} + +export const FooterZone: UserComponent = ({ style = {}, children }) => { + const { connectors: { connect, drag } } = useNode(); + + return ( +
{ if (ref) connect(drag(ref)); }} + data-zone="footer" + style={{ + width: '100%', + minHeight: '50px', + borderTop: '1px solid rgba(148,163,184,0.15)', + ...style, + }} + > + + {children} + +
+ ); +}; + +const FooterZoneSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as FooterZoneProps, + })); + + const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a']; + + return ( +
+

+ Footer Zone -- This section appears on all pages. +

+
+ +
+ {bgPresets.map((c) => ( +
+
+
+ ); +}; + +FooterZone.craft = { + displayName: 'Footer Zone', + props: { + style: { backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 20px', textAlign: 'center' as const }, + }, + rules: { + canDrag: () => false, + canMoveIn: () => true, + canMoveOut: () => true, + }, + related: { + settings: FooterZoneSettings, + }, +}; + +(FooterZone as any).toHtml = (props: FooterZoneProps, childrenHtml: string) => { + const styleStr = cssPropsToString({ + width: '100%', + ...props.style, + }); + return { html: `${childrenHtml}` }; +}; diff --git a/craft/src/components/layout/HeaderZone.tsx b/craft/src/components/layout/HeaderZone.tsx new file mode 100644 index 0000000..2f4ab6b --- /dev/null +++ b/craft/src/components/layout/HeaderZone.tsx @@ -0,0 +1,81 @@ +import React, { CSSProperties } from 'react'; +import { useNode, Element, UserComponent } from '@craftjs/core'; +import { Container } from './Container'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface HeaderZoneProps { + style?: CSSProperties; + children?: React.ReactNode; +} + +export const HeaderZone: UserComponent = ({ style = {}, children }) => { + const { connectors: { connect, drag } } = useNode(); + + return ( +
{ if (ref) connect(drag(ref)); }} + data-zone="header" + style={{ + width: '100%', + minHeight: '50px', + borderBottom: '1px solid rgba(148,163,184,0.15)', + ...style, + }} + > + + {children} + +
+ ); +}; + +const HeaderZoneSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as HeaderZoneProps, + })); + + const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a']; + + return ( +
+

+ Header Zone -- This section appears on all pages. +

+
+ +
+ {bgPresets.map((c) => ( +
+
+
+ ); +}; + +HeaderZone.craft = { + displayName: 'Header Zone', + props: { + style: { backgroundColor: '#ffffff' }, + }, + rules: { + canDrag: () => false, // Header stays at the top, can't be moved + canMoveIn: () => true, + canMoveOut: () => true, + }, + related: { + settings: HeaderZoneSettings, + }, +}; + +(HeaderZone as any).toHtml = (props: HeaderZoneProps, childrenHtml: string) => { + const styleStr = cssPropsToString({ + width: '100%', + ...props.style, + }); + return { html: `${childrenHtml}` }; +}; diff --git a/craft/src/components/layout/Section.tsx b/craft/src/components/layout/Section.tsx new file mode 100644 index 0000000..d959875 --- /dev/null +++ b/craft/src/components/layout/Section.tsx @@ -0,0 +1,401 @@ +import React, { CSSProperties } from 'react'; +import { useNode, Element, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; +import { Container } from './Container'; + +/* ---------- Shape Divider SVG Paths ---------- */ + +type DividerShape = 'none' | 'wave' | 'angle' | 'curve' | 'triangle' | 'zigzag'; + +const DIVIDER_PATHS: Record, string> = { + wave: 'M0,0 C150,120 350,0 600,60 C850,120 1050,0 1200,60 L1200,120 L0,120 Z', + angle: 'M0,0 L1200,120 L0,120 Z', + curve: 'M0,0 Q600,140 1200,0 L1200,120 L0,120 Z', + triangle: 'M0,120 L600,0 L1200,120 Z', + zigzag: 'M0,120 L100,40 L200,120 L300,40 L400,120 L500,40 L600,120 L700,40 L800,120 L900,40 L1000,120 L1100,40 L1200,120 Z', +}; + +const DIVIDER_SHAPES: DividerShape[] = ['none', 'wave', 'angle', 'curve', 'triangle', 'zigzag']; + +interface SectionProps { + style?: CSSProperties; + innerMaxWidth?: string; + children?: React.ReactNode; + topDivider?: DividerShape; + topDividerColor?: string; + topDividerHeight?: string; + bottomDivider?: DividerShape; + bottomDividerColor?: string; + bottomDividerHeight?: string; +} + +/* ---------- Divider renderer ---------- */ + +const ShapeDivider: React.FC<{ + shape: DividerShape; + color: string; + height: string; + position: 'top' | 'bottom'; +}> = ({ shape, color, height, position }) => { + if (!shape || shape === 'none') return null; + const path = DIVIDER_PATHS[shape]; + if (!path) return null; + + const isTop = position === 'top'; + + return ( +
+ + + +
+ ); +}; + +/* ---------- Component ---------- */ + +export const Section: UserComponent = ({ + style = {}, + innerMaxWidth = '1200px', + children, + topDivider = 'none', + topDividerColor = '#ffffff', + topDividerHeight = '50px', + bottomDivider = 'none', + bottomDividerColor = '#ffffff', + bottomDividerHeight = '50px', +}) => { + const { connectors: { connect, drag } } = useNode(); + + const hasTopDivider = topDivider && topDivider !== 'none'; + const hasBottomDivider = bottomDivider && bottomDivider !== 'none'; + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + width: '100%', + position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, + ...style, + }} + > + {hasTopDivider && ( + + )} + + {children} + + {hasBottomDivider && ( + + )} +
+ ); +}; + +/* ---------- Settings panel ---------- */ + +const sLabelStyle: React.CSSProperties = { + fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, + textTransform: 'uppercase', letterSpacing: '0.3px', +}; + +const sInputStyle: React.CSSProperties = { + width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', + border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, +}; + +const sSelectStyle: React.CSSProperties = { + width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7', + border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, +}; + +const DividerSettings: React.FC<{ + label: string; + shape: DividerShape; + color: string; + height: string; + onShapeChange: (s: DividerShape) => void; + onColorChange: (c: string) => void; + onHeightChange: (h: string) => void; +}> = ({ label, shape, color, height, onShapeChange, onColorChange, onHeightChange }) => { + const heightNum = parseInt(height, 10) || 50; + + return ( +
+ + + {/* Shape selector */} + + + {shape && shape !== 'none' && ( + <> + {/* Color picker */} +
+ onColorChange(e.target.value)} + style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }} + /> + onColorChange(e.target.value)} + style={{ ...sInputStyle, flex: 1 }} + placeholder="#ffffff" + /> +
+ + {/* Height slider */} +
+
+ Height + {heightNum}px +
+ onHeightChange(`${e.target.value}px`)} + style={{ width: '100%', accentColor: '#3b82f6' }} + /> +
+ + {/* Small SVG preview */} +
+ + + +
+ + )} +
+ ); +}; + +const SectionSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as SectionProps, + })); + + const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#0f172a', '#1e293b', '#18181b', '#f0fdf4', '#eff6ff']; + const paddingPresets = ['0px', '20px', '40px', '60px', '80px', '120px']; + + return ( +
+
+ +
+ {bgPresets.map((c) => ( +
+
+ +
+ + setProp((p: SectionProps) => { p.style = { ...p.style, background: e.target.value }; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }} + /> +
+ +
+ +
+ {paddingPresets.map((p) => ( + + ))} +
+
+ +
+ + setProp((p: SectionProps) => { p.innerMaxWidth = e.target.value; })} + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }} + /> +
+ + {/* Divider separator */} +
+
Shape Dividers
+ + setProp((p: SectionProps) => { p.topDivider = s; })} + onColorChange={(c) => setProp((p: SectionProps) => { p.topDividerColor = c; })} + onHeightChange={(h) => setProp((p: SectionProps) => { p.topDividerHeight = h; })} + /> + +
+ + setProp((p: SectionProps) => { p.bottomDivider = s; })} + onColorChange={(c) => setProp((p: SectionProps) => { p.bottomDividerColor = c; })} + onHeightChange={(h) => setProp((p: SectionProps) => { p.bottomDividerHeight = h; })} + /> +
+
+ ); +}; + +/* ---------- Craft config ---------- */ + +Section.craft = { + displayName: 'Section', + props: { + style: { padding: '40px 0', backgroundColor: '#ffffff' }, + innerMaxWidth: '1200px', + topDivider: 'none', + topDividerColor: '#ffffff', + topDividerHeight: '50px', + bottomDivider: 'none', + bottomDividerColor: '#ffffff', + bottomDividerHeight: '50px', + }, + rules: { + canDrag: () => true, + canMoveIn: () => true, + canMoveOut: () => true, + }, + related: { + settings: SectionSettings, + }, +}; + +/* ---------- HTML export ---------- */ + +function buildDividerHtml( + shape: DividerShape | undefined, + color: string | undefined, + height: string | undefined, + position: 'top' | 'bottom', +): string { + if (!shape || shape === 'none') return ''; + const path = DIVIDER_PATHS[shape]; + if (!path) return ''; + + const isTop = position === 'top'; + const h = height || '50px'; + const c = color || '#ffffff'; + + const wrapperStyle = cssPropsToString({ + position: 'absolute', + [position]: '0', + left: '0', + right: '0', + height: h, + overflow: 'hidden', + lineHeight: '0', + pointerEvents: 'none', + } as CSSProperties); + + const svgTransform = isTop ? ' transform:rotate(180deg);' : ''; + + return `
`; +} + +(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => { + const hasTopDivider = props.topDivider && props.topDivider !== 'none'; + const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none'; + + const outerStyle = cssPropsToString({ + width: '100%', + position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, + ...props.style, + }); + const innerStyle = cssPropsToString({ + maxWidth: props.innerMaxWidth || '1200px', + margin: '0 auto', + position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, + zIndex: (hasTopDivider || hasBottomDivider) ? 1 : undefined, + } as CSSProperties); + + const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top'); + const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom'); + + return { + html: `${topHtml}${childrenHtml}
${bottomHtml}`, + }; +}; diff --git a/craft/src/components/media/ImageBlock.tsx b/craft/src/components/media/ImageBlock.tsx new file mode 100644 index 0000000..986762f --- /dev/null +++ b/craft/src/components/media/ImageBlock.tsx @@ -0,0 +1,480 @@ +import React, { CSSProperties, useCallback, useRef, useState } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E"; + +interface ImageBlockProps { + src?: string; + alt?: string; + style?: CSSProperties; +} + +// Helper: upload a file to the WHP API and return the proxy URL +async function uploadToWhp(file: File): Promise { + const cfg = (window as any).WHP_CONFIG; + if (!cfg) return URL.createObjectURL(file); // Standalone fallback + + const formData = new FormData(); + formData.append('file', file); + try { + const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, { + method: 'POST', + headers: { 'X-CSRF-Token': cfg.csrfToken }, + body: formData, + }); + const data = await resp.json(); + if (data.success && data.url) return data.url; + return null; + } catch { return null; } +} + +export const ImageBlock: UserComponent = ({ + src = PLACEHOLDER_SRC, + alt = '', + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + actions: { setProp }, + } = useNode((node) => ({ selected: node.events.selected })); + + const imgRef = useRef(null); + const isPlaceholder = !src || src === PLACEHOLDER_SRC || src.startsWith('data:image/svg'); + + // Handle drag-and-drop of files directly onto the image + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const file = e.dataTransfer.files?.[0]; + if (file && file.type.startsWith('image/')) { + const url = await uploadToWhp(file); + if (url) setProp((p: ImageBlockProps) => { p.src = url; }); + } + }, [setProp]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }, []); + + return ( + { + imgRef.current = ref; + if (ref) connect(drag(ref)); + }} + src={src} + alt={alt || 'Image'} + onDrop={handleDrop} + onDragOver={handleDragOver} + style={{ + display: 'block', + maxWidth: '100%', + outline: 'none', + cursor: selected ? 'move' : 'pointer', + ...style, + }} + /> + ); +}; + +/* ---------- Helpers for parsing CSS unit values ---------- */ + +type SizeUnit = 'px' | '%' | 'auto'; + +function parseSizeValue(value: string | number | undefined): { num: string; unit: SizeUnit } { + if (!value || value === 'auto') return { num: '', unit: 'auto' }; + const str = String(value); + if (str === 'auto') return { num: '', unit: 'auto' }; + const match = str.match(/^(\d+(?:\.\d+)?)\s*(px|%)$/); + if (match) return { num: match[1], unit: match[2] as SizeUnit }; + // Pure number = px + if (/^\d+(?:\.\d+)?$/.test(str)) return { num: str, unit: 'px' }; + return { num: '', unit: 'px' }; +} + +function buildSizeString(num: string, unit: SizeUnit): string | undefined { + if (unit === 'auto') return 'auto'; + if (!num) return undefined; + return `${num}${unit}`; +} + +type Alignment = 'left' | 'center' | 'right'; + +function detectAlignment(style: CSSProperties | undefined): Alignment { + if (!style) return 'left'; + const ml = style.marginLeft; + const mr = style.marginRight; + if (ml === 'auto' && mr === 'auto') return 'center'; + if (ml === 'auto' && mr !== 'auto') return 'right'; + return 'left'; +} + +/* ---------- Settings panel ---------- */ + +const ImageBlockSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as ImageBlockProps, + })); + + const isPlaceholder = !props.src || props.src === PLACEHOLDER_SRC || props.src?.startsWith('data:image/svg'); + const fileInputRef = useRef(null); + const [showBrowser, setShowBrowser] = useState(false); + const [browserAssets, setBrowserAssets] = useState([]); + const [browserLoading, setBrowserLoading] = useState(false); + + // Sizing unit state + const widthParsed = parseSizeValue(props.style?.width); + const [widthUnit, setWidthUnit] = useState(widthParsed.unit === 'auto' ? 'px' : widthParsed.unit); + const heightParsed = parseSizeValue(props.style?.height); + const [heightUnit, setHeightUnit] = useState(heightParsed.unit === 'auto' ? 'px' : heightParsed.unit); + const maxWidthParsed = parseSizeValue(props.style?.maxWidth); + const [maxWidthUnit, setMaxWidthUnit] = useState(maxWidthParsed.unit === 'auto' ? '%' : maxWidthParsed.unit); + + const alignment = detectAlignment(props.style); + + const handleUpload = useCallback(async (file: File) => { + const url = await uploadToWhp(file); + if (url) setProp((p: ImageBlockProps) => { p.src = url; }); + }, [setProp]); + + const handleBrowse = useCallback(async () => { + if (showBrowser) { setShowBrowser(false); return; } + const cfg = (window as any).WHP_CONFIG; + if (!cfg) return; + setBrowserLoading(true); + try { + const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`); + const data = await resp.json(); + if (data.success && Array.isArray(data.assets)) { + const images = data.assets.filter((a: any) => (a.type || '').startsWith('image')); + setBrowserAssets(images); + setShowBrowser(true); + } + } catch (e) { + console.error('Browse failed:', e); + } finally { + setBrowserLoading(false); + } + }, [showBrowser]); + + const radiusPresets = ['0', '4px', '8px', '16px', '50%']; + + const setPropStyle = useCallback((key: string, value: string | undefined) => { + setProp((p: ImageBlockProps) => { + p.style = { ...p.style, [key]: value }; + }); + }, [setProp]); + + const setAlignment = useCallback((align: Alignment) => { + setProp((p: ImageBlockProps) => { + const s = { ...p.style }; + if (align === 'center') { + s.marginLeft = 'auto'; + s.marginRight = 'auto'; + s.display = 'block'; + } else if (align === 'right') { + s.marginLeft = 'auto'; + s.marginRight = undefined; + s.display = 'block'; + } else { + s.marginLeft = undefined; + s.marginRight = undefined; + s.display = 'block'; + } + p.style = s; + }); + }, [setProp]); + + // Extract friendly filename from URL + const getFriendlyName = (src: string) => { + const match = src.match(/filename=([^&]+)/); + if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, ''); + return src.split('/').pop() || 'image'; + }; + + const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }; + const inputStyle: CSSProperties = { flex: 1, minWidth: 0, padding: '4px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }; + const selectStyle: CSSProperties = { padding: '4px 2px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, cursor: 'pointer' }; + const btnStyle = (active: boolean): CSSProperties => ({ + flex: 1, padding: '4px', fontSize: 11, borderRadius: 4, cursor: 'pointer', + border: '1px solid #3f3f46', + background: active ? '#3b82f6' : '#27272a', + color: active ? '#fff' : '#a1a1aa', + }); + + return ( +
+ {/* Image preview */} +
+ + + {!isPlaceholder ? ( + <> + {/* Current image thumbnail + filename + remove */} +
+ + +
+
+ + {getFriendlyName(props.src || '')} +
+ + ) : ( + /* Drop zone when no image set */ +
{ e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }} + onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }} + onDrop={async (e) => { + e.preventDefault(); + e.currentTarget.style.borderColor = '#3f3f46'; + const file = e.dataTransfer.files?.[0]; + if (file && file.type.startsWith('image/')) await handleUpload(file); + }} + onClick={() => fileInputRef.current?.click()} + > + + Drop image here or click to upload +
+ )} + + {/* Action buttons: Upload + Browse */} +
+ + +
+ + {/* Inline asset browser grid */} + {showBrowser && ( +
+ {browserAssets.map(asset => ( +
{ setProp((p: ImageBlockProps) => { p.src = asset.url; }); setShowBrowser(false); }} + style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }} + onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }} + onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }} + > + {asset.name} +
+ ))} + {browserAssets.length === 0 && ( +

No images uploaded yet. Use Upload above.

+ )} +
+ )} + + { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} /> + + {/* URL input (collapsed, for advanced users) */} +
+ setProp((p: ImageBlockProps) => { p.src = e.target.value || PLACEHOLDER_SRC; })} + placeholder="Or paste image URL..." + style={{ width: '100%', padding: '4px 8px', background: '#1e1e2a', color: '#71717a', border: '1px solid #27272a', borderRadius: 4, fontSize: 10 }} + /> +
+
+ + {/* Alt Text */} +
+ + setProp((p: ImageBlockProps) => { p.alt = e.target.value; })} + placeholder="Describe the image..." + style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }} + /> +
+ + {/* Width */} +
+ +
+ { + const val = buildSizeString(e.target.value, widthUnit); + setPropStyle('width', val || 'auto'); + }} + placeholder="auto" + style={inputStyle} + /> + +
+
+ + {/* Max Width */} +
+ +
+ { + const val = buildSizeString(e.target.value, maxWidthUnit); + setPropStyle('maxWidth', val || '100%'); + }} + placeholder="100%" + style={inputStyle} + /> + +
+
+ + {/* Height */} +
+ +
+ { + const val = buildSizeString(e.target.value, heightUnit); + setPropStyle('height', val || 'auto'); + }} + placeholder="auto" + style={inputStyle} + /> + +
+
+ + {/* Object Fit (visible when both width and height are explicit values) */} + {props.style?.width && props.style.width !== 'auto' && props.style?.height && props.style.height !== 'auto' && ( +
+ +
+ {(['cover', 'contain', 'fill', 'none'] as const).map((fit) => ( + + ))} +
+
+ )} + + {/* Alignment */} +
+ +
+ + + +
+
+ + {/* Border Radius */} +
+ +
+ {radiusPresets.map((r) => ( + + ))} +
+
+
+ ); +}; + +ImageBlock.craft = { + displayName: 'Image', + props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } }, + rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true }, + related: { settings: ImageBlockSettings }, +}; + +(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => { + // Skip placeholder/empty images in export + const src = props.src || ''; + if (!src || src.startsWith('data:image/svg') || src === PLACEHOLDER_SRC) { + return { html: '' }; + } + const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style }); + const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '"')}"` : ' alt=""'; + return { html: `` }; +}; diff --git a/craft/src/components/media/MapEmbed.tsx b/craft/src/components/media/MapEmbed.tsx new file mode 100644 index 0000000..6fb9b55 --- /dev/null +++ b/craft/src/components/media/MapEmbed.tsx @@ -0,0 +1,173 @@ +import React, { CSSProperties } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface MapEmbedProps { + address?: string; + zoom?: number; + height?: string; + style?: CSSProperties; +} + +function buildMapUrl(address: string, zoom: number): string { + const encoded = encodeURIComponent(address); + return `https://maps.google.com/maps?q=${encoded}&z=${zoom}&output=embed`; +} + +export const MapEmbed: UserComponent = ({ + address = 'New York, NY', + zoom = 14, + height = '400px', + style = {}, +}) => { + const { + connectors: { connect, drag }, + selected, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + width: '100%', + outline: selected ? '2px solid #3b82f6' : 'none', + ...style, + }} + > +
`, + }; +}; diff --git a/craft/src/components/media/VideoBlock.tsx b/craft/src/components/media/VideoBlock.tsx new file mode 100644 index 0000000..c8f97b2 --- /dev/null +++ b/craft/src/components/media/VideoBlock.tsx @@ -0,0 +1,794 @@ +import React, { CSSProperties, useCallback, useRef, useState } from 'react'; +import { useNode, Element, UserComponent } from '@craftjs/core'; +import { Container } from '../layout/Container'; +import { cssPropsToString } from '../../utils/style-helpers'; + +/* ---------- Types ---------- */ + +type VideoType = 'youtube' | 'vimeo' | 'file' | 'none'; + +interface VideoBlockProps { + videoUrl?: string; + videoType?: VideoType; + embedUrl?: string; + autoplay?: boolean; + muted?: boolean; + loop?: boolean; + controls?: boolean; + isBackground?: boolean; + overlayColor?: string; + overlayOpacity?: number; + innerMaxWidth?: string; + style?: CSSProperties; + children?: React.ReactNode; +} + +/* ---------- URL detection ---------- */ + +function detectVideoType(url: string): { type: VideoType; embedUrl: string } { + if (!url) return { type: 'none', embedUrl: '' }; + + // YouTube + const ytMatch = url.match( + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/ + ); + if (ytMatch) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}?rel=0` }; + + // Vimeo + const vmMatch = url.match(/vimeo\.com\/(\d+)/); + if (vmMatch) return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}` }; + + // Direct file + if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url }; + + // Uploaded asset (proxy URL) + if (url.includes('assets-proxy') || url.includes('serve_asset')) return { type: 'file', embedUrl: url }; + + return { type: 'none', embedUrl: url }; +} + +/** Build embed params for YouTube/Vimeo iframes */ +function buildEmbedParams( + baseUrl: string, + opts: { autoplay?: boolean; muted?: boolean; loop?: boolean; controls?: boolean } +): string { + const url = new URL(baseUrl); + if (opts.autoplay) url.searchParams.set('autoplay', '1'); + if (opts.muted) url.searchParams.set('mute', '1'); + if (opts.loop) url.searchParams.set('loop', '1'); + if (opts.controls === false) url.searchParams.set('controls', '0'); + return url.toString(); +} + +/* ---------- Upload helper ---------- */ + +async function uploadToWhp(file: File): Promise { + const cfg = (window as any).WHP_CONFIG; + if (!cfg) return URL.createObjectURL(file); + + const formData = new FormData(); + formData.append('file', file); + try { + const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, { + method: 'POST', + headers: { 'X-CSRF-Token': cfg.csrfToken }, + body: formData, + }); + const data = await resp.json(); + if (data.success && data.url) return data.url; + return null; + } catch { + return null; + } +} + +/* ---------- Placeholder ---------- */ + +const VIDEO_PLACEHOLDER = ( +
+ + Add a video URL in settings +
+); + +/* ======================================================================== + Normal (non-background) Video Component + ======================================================================== */ + +export const VideoBlock: UserComponent = ({ + videoUrl = '', + videoType: _videoTypeProp, + embedUrl: _embedUrlProp, + autoplay = false, + muted = true, + loop = false, + controls = true, + isBackground = false, + overlayColor = '#000000', + overlayOpacity = 50, + innerMaxWidth = '1200px', + style = {}, +}) => { + const { + connectors: { connect, drag }, + } = useNode(); + + // Detect type from URL + const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' }; + + /* ---- Background mode ---- */ + if (isBackground) { + return ( +
{ + if (ref) connect(drag(ref)); + }} + style={{ + position: 'relative', + width: '100%', + minHeight: '300px', + overflow: 'hidden', + ...style, + }} + > + {/* Background video layer */} + {type === 'file' && embedUrl && ( +
`, + }; + } + + /* ---- Normal mode export ---- */ + const wrapperStyle = cssPropsToString({ width: '100%', ...style }); + + if (type === 'none' || !embedUrl) { + return { html: '' }; + } + + if (type === 'youtube' || type === 'vimeo') { + const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls }); + const containerStyle = cssPropsToString({ + position: 'relative', + paddingBottom: '56.25%', + height: '0', + overflow: 'hidden', + borderRadius: (style as any)?.borderRadius || undefined, + }); + const iframeStyle = cssPropsToString({ + position: 'absolute', + top: '0', + left: '0', + width: '100%', + height: '100%', + border: 'none', + }); + return { + html: ``, + }; + } + + // Direct file + const vidAttrs: string[] = []; + if (autoplay) vidAttrs.push('autoplay'); + if (muted) vidAttrs.push('muted'); + if (doLoop) vidAttrs.push('loop'); + if (controls) vidAttrs.push('controls'); + vidAttrs.push('playsinline'); + const vidStyle = cssPropsToString({ + display: 'block', + width: '100%', + borderRadius: (style as any)?.borderRadius || undefined, + }); + + return { + html: ``, + }; +}; diff --git a/craft/src/components/resolver.ts b/craft/src/components/resolver.ts new file mode 100644 index 0000000..4b5d6fe --- /dev/null +++ b/craft/src/components/resolver.ts @@ -0,0 +1,81 @@ +import { Container } from './layout/Container'; +import { Section } from './layout/Section'; +import { ColumnLayout } from './layout/ColumnLayout'; +import { BackgroundSection } from './layout/BackgroundSection'; +import { Heading } from './basic/Heading'; +import { TextBlock } from './basic/TextBlock'; +import { ButtonLink } from './basic/ButtonLink'; +import { Logo } from './basic/Logo'; +import { Menu } from './basic/Menu'; +import { Navbar } from './basic/Navbar'; +import { Footer } from './basic/Footer'; +import { Divider } from './basic/Divider'; +import { Spacer } from './basic/Spacer'; +import { Icon } from './basic/Icon'; +import { ImageBlock } from './media/ImageBlock'; +import { VideoBlock } from './media/VideoBlock'; +import { MapEmbed } from './media/MapEmbed'; +import { HeroSimple } from './sections/HeroSimple'; +import { FeaturesGrid } from './sections/FeaturesGrid'; +import { CTASection } from './sections/CTASection'; +import { Countdown } from './sections/Countdown'; +import { Testimonials } from './sections/Testimonials'; +import { FormContainer } from './forms/FormContainer'; +import { InputField } from './forms/InputField'; +import { TextareaField } from './forms/TextareaField'; +import { FormButton } from './forms/FormButton'; +import { ContactForm } from './forms/ContactForm'; +import { StarRating } from './basic/StarRating'; +import { SocialLinks } from './basic/SocialLinks'; +import { CallToAction } from './sections/CallToAction'; +import { Accordion } from './sections/Accordion'; +import { Tabs } from './sections/Tabs'; +import { PricingTable } from './sections/PricingTable'; +import { Gallery } from './sections/Gallery'; +import { ContentSlider } from './sections/ContentSlider'; +import { NumberCounter } from './sections/NumberCounter'; +import { SubscribeForm } from './forms/SubscribeForm'; +import { SearchBar } from './basic/SearchBar'; +import { HtmlBlock } from './basic/HtmlBlock'; + +export const componentResolver = { + Container, + Section, + ColumnLayout, + BackgroundSection, + Heading, + TextBlock, + ButtonLink, + Logo, + Menu, + Navbar, + Footer, + Divider, + Spacer, + Icon, + ImageBlock, + VideoBlock, + MapEmbed, + HeroSimple, + FeaturesGrid, + CTASection, + Countdown, + Testimonials, + FormContainer, + InputField, + TextareaField, + FormButton, + ContactForm, + StarRating, + SocialLinks, + CallToAction, + Accordion, + Tabs, + PricingTable, + Gallery, + ContentSlider, + NumberCounter, + SubscribeForm, + SearchBar, + HtmlBlock, +}; diff --git a/craft/src/components/sections/Accordion.tsx b/craft/src/components/sections/Accordion.tsx new file mode 100644 index 0000000..092777a --- /dev/null +++ b/craft/src/components/sections/Accordion.tsx @@ -0,0 +1,330 @@ +import React, { CSSProperties, useState } from 'react'; +import { useNode, UserComponent } from '@craftjs/core'; +import { cssPropsToString } from '../../utils/style-helpers'; + +interface AccordionItem { + title: string; + content: string; + isOpen?: boolean; +} + +interface AccordionProps { + items?: AccordionItem[]; + style?: CSSProperties; + headerBg?: string; + headerColor?: string; + contentBg?: string; + borderColor?: string; +} + +const defaultItems: AccordionItem[] = [ + { title: 'What is this product?', content: 'Our product is a powerful yet easy-to-use tool designed to help you build beautiful websites without writing a single line of code.', isOpen: true }, + { title: 'How do I get started?', content: 'Simply sign up for a free account, choose a template, and start customizing. Our drag-and-drop editor makes it easy to create professional pages in minutes.', isOpen: false }, + { title: 'Is there a free plan?', content: 'Yes! We offer a generous free tier that includes all core features. Upgrade anytime to unlock advanced capabilities like custom domains and analytics.', isOpen: false }, +]; + +export const Accordion: UserComponent = ({ + items = defaultItems, + style = {}, + headerBg = '#f8fafc', + headerColor = '#18181b', + contentBg = '#ffffff', + borderColor = '#e2e8f0', +}) => { + const { + connectors: { connect, drag }, + selected, + } = useNode((node) => ({ + selected: node.events.selected, + })); + + const [openIndexes, setOpenIndexes] = useState>(() => { + const initial = new Set(); + items.forEach((item, i) => { if (item.isOpen) initial.add(i); }); + return initial; + }); + + const toggle = (index: number) => { + setOpenIndexes((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + }; + + return ( +
{ if (ref) connect(drag(ref)); }} + style={{ + padding: '60px 20px', + backgroundColor: '#ffffff', + outline: selected ? '2px solid #3b82f6' : 'none', + ...style, + }} + > +
+ {items.map((item, i) => { + const isOpen = openIndexes.has(i); + return ( +
+
toggle(i)} + style={{ + padding: '16px 20px', + backgroundColor: headerBg, + color: headerColor, + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + fontWeight: '600', + fontSize: '16px', + userSelect: 'none', + ...(i === 0 ? { borderTopLeftRadius: '7px', borderTopRightRadius: '7px' } : {}), + ...(i === items.length - 1 && !isOpen ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}), + }} + > + {item.title} + +
+ {isOpen && ( +
+ {item.content} +
+ )} +
+ ); + })} +
+
+ ); +}; + +/* ---------- Settings panel ---------- */ + +const AccordionSettings: React.FC = () => { + const { actions: { setProp }, props } = useNode((node) => ({ + props: node.data.props as AccordionProps, + })); + + const items = props.items || defaultItems; + + const inputStyle: CSSProperties = { + width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7', + border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, + }; + + const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }; + + const updateItem = (index: number, field: keyof AccordionItem, value: string | boolean) => { + setProp((p: AccordionProps) => { + const updated = [...(p.items || defaultItems)]; + updated[index] = { ...updated[index], [field]: value }; + p.items = updated; + }); + }; + + const addItem = () => { + setProp((p: AccordionProps) => { + p.items = [...(p.items || defaultItems), { title: 'New Question', content: 'Answer goes here.', isOpen: false }]; + }); + }; + + const removeItem = (index: number) => { + setProp((p: AccordionProps) => { + const updated = [...(p.items || defaultItems)]; + updated.splice(index, 1); + p.items = updated; + }); + }; + + const colorSwatches = ['#f8fafc', '#f1f5f9', '#e2e8f0', '#ffffff', '#18181b', '#1e293b', '#3b82f6', '#8b5cf6']; + + return ( +
+
+ +
+ {colorSwatches.map((c) => ( +
+
+ +
+ +
+ {['#18181b', '#1f2937', '#374151', '#ffffff', '#e2e8f0', '#3b82f6'].map((c) => ( +
+
+ +
+ +
+ {['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => ( +
+
+ +
+ +
+ {['#e2e8f0', '#cbd5e1', '#d1d5db', '#3f3f46', '#52525b'].map((c) => ( +
+
+ +
+ +
+ {items.map((item, i) => ( +
+
+ updateItem(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} /> + +
+