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) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions

6
craft/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.playwright-mcp/
test-results/
playwright-report/
*.tsbuildinfo

463
craft/CLAUDE.md Normal file
View File

@@ -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>
│ ├── App.tsx # Wraps <Editor> 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 <Frame> 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 <a> 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 `<Frame>` 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<MyComponentProps> = ({ text, style }) => {
const { connectors: { connect, drag } } = useNode();
return <div ref={(r) => { if (r) connect(drag(r)); }} style={style}>{text}</div>;
};
// 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 <div>/* preset buttons, inputs, etc. */</div>;
};
// 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: `<div style="...">${childrenHtml}</div>` };
};
```
### Component Resolver
All components must be registered in `src/components/resolver.ts`. This map is passed to `<Editor resolver={componentResolver}>` 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 `<script>` tag before the app bundle
```javascript
window.WHP_CONFIG = {
user: "username",
apiUrl: "/panel/api/site-builder",
csrfToken: "abc123...",
siteId: 42,
siteDomain: "example.com",
siteName: "My Site",
backUrl: "/panel/sites",
isRoot: false
};
```
### API Endpoints
The editor communicates with WHP through these endpoints (all require CSRF token):
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/panel/api/site-builder?action=save` | Save project (JSON body with site_id, html, css, craft state) |
| GET | `/panel/api/site-builder?action=load&site_id=N` | Load project for a site |
| POST | `/panel/api/site-builder?action=upload` | Upload asset (multipart) |
| GET | `/panel/api/site-builder?action=assets&site_id=N` | List assets for a site |
| DELETE | `/panel/api/site-builder?action=delete_asset` | Delete an asset |
| POST | `/panel/api/site-builder?action=deploy&site_id=N` | Deploy/publish site to document root |
### Auto-Save
The editor auto-saves every 30 seconds when running inside WHP. The save status is displayed in the top bar.
### EditorConfigContext
`useEditorConfig()` provides access to `WHP_CONFIG` throughout the React tree:
- `whpConfig` - The full config object (or null in standalone mode)
- `isWHP` - Boolean shorthand for whether we're running inside WHP
## All Components (22)
| # | Component | Type | File | Features |
|---|-----------|------|------|----------|
| 1 | Container | Layout | `layout/Container.tsx` | Generic wrapper, tag selector (div/section/article/header/footer/main), bg color, padding, radius |
| 2 | Section | Layout | `layout/Section.tsx` | Full-width with centered inner container, bg color/gradient, vertical padding, inner max-width |
| 3 | ColumnLayout | Layout | `layout/ColumnLayout.tsx` | 1-6 columns, split ratios (50-50, 30-70, 70-30, 33-33-33, 25-25-25-25, etc.), gap control |
| 4 | BackgroundSection | Layout | `layout/BackgroundSection.tsx` | Section with background image, gradient overlay, parallax-ready |
| 5 | HeaderZone | Layout | `layout/HeaderZone.tsx` | Page-level header wrapper zone, used by PageContext |
| 6 | FooterZone | Layout | `layout/FooterZone.tsx` | Page-level footer wrapper zone, used by PageContext |
| 7 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align |
| 8 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height |
| 9 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size |
| 10 | Navbar | Basic | `basic/Navbar.tsx` | Text or image logo, page links, external links, CTA buttons, light/dark nav style |
| 11 | Footer | Basic | `basic/Footer.tsx` | Footer with links, copyright, social links |
| 12 | Divider | Basic | `basic/Divider.tsx` | Horizontal rule with color and thickness controls |
| 13 | Spacer | Basic | `basic/Spacer.tsx` | Vertical spacing element with height control |
| 14 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius |
| 15 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop |
| 16 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background |
| 17 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions |
| 18 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button |
| 19 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method |
| 20 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) |
| 21 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder |
| 22 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls |
## Site Design Tokens
The `SiteDesignContext` provides 17 design properties organized into Basic and Advanced tabs:
### Basic Tab (6 properties)
| Property | Default | Description |
|----------|---------|-------------|
| primaryColor | `#3b82f6` | Primary brand color |
| secondaryColor | `#8b5cf6` | Secondary brand color |
| accentColor | `#10b981` | Accent/highlight color |
| headingFont | `Inter, sans-serif` | Font for headings |
| bodyFont | `Inter, sans-serif` | Font for body text |
| linkColor | `#3b82f6` | Default link color |
### Advanced Tab (11 properties)
| Property | Default | Description |
|----------|---------|-------------|
| successColor | `#10b981` | Success state color |
| warningColor | `#f59e0b` | Warning state color |
| errorColor | `#ef4444` | Error state color |
| backgroundColor | `#ffffff` | Page background |
| textColor | `#1f2937` | Default text color |
| mutedTextColor | `#6b7280` | Muted/secondary text |
| borderColor | `#e5e7eb` | Default border color |
| borderRadius | `8px` | Global border radius |
| buttonFont | `Inter, sans-serif` | Button font family |
| buttonRadius | `8px` | Button border radius |
| navStyle | `light` | Navbar style (light/dark) |
Design tokens are imported from templates and can be edited via the Site Design panel. Components can read these tokens via `useSiteDesign()`.
## Templates
16 pre-built templates organized into 4 categories:
| Category | Templates |
|----------|-----------|
| Business | Restaurant, Small Business, SaaS Landing, Agency, Medical |
| Creative | Portfolio, Photography, Content Creator, Event/Conference |
| Personal | Resume/CV, Blog, Wedding, Coming Soon |
| Community | Church, Non-Profit, Fitness/Gym |
Each template includes:
- Site design tokens (color palette, fonts)
- Header content (Navbar)
- Footer content
- One or more pages with pre-built content
- SVG thumbnail preview
Templates are loaded via the Template Modal (opened from TopBar). Loading a template replaces all pages, header, footer, and optionally imports the design tokens.
## Multi-Page System
Pages are managed through `PageContext`:
- Each page has: `id`, `name`, `slug`, `craftState`, `headCode`
- Header and Footer are stored as separate "page" entries with fixed IDs (`__header__`, `__footer__`)
- Page switching serializes the current canvas, stores it, then deserializes the target page
- Header/Footer editing puts the canvas in a distinct mode
- The PagesPanel provides add, rename, delete, reorder, and header/footer edit buttons
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| Ctrl/Cmd + Z | Undo |
| Ctrl/Cmd + Shift + Z | Redo |
| Ctrl/Cmd + Y | Redo (alternative) |
| Delete / Backspace | Delete selected element |
Shortcuts are disabled when focus is on input, textarea, select, or contentEditable elements.
## Context Menu (Right-Click)
The context menu appears on right-click within the canvas and provides:
- Duplicate element
- Copy / Paste
- Move Up / Move Down
- Select Parent
- Delete (with danger styling)
Items are disabled contextually (e.g., cannot delete ROOT, cannot move if already first/last).
## Layers Panel
The Layers panel shows a hierarchical tree of all components on the current page. Clicking a layer selects the corresponding component. The tree displays component display names and nests children with indentation.
## Asset Management
The Assets panel (`AssetsPanel.tsx`) provides:
- Upload button and drag-and-drop zone
- Thumbnail grid of uploaded assets
- Copy URL to clipboard
- Delete asset
- Integration with WHP API for server-side storage
Image and Video components also have inline asset selection (browse button in settings).
## Adding New Components
1. Create `src/components/<category>/<ComponentName>.tsx` following the pattern above
2. Add the component to `src/components/resolver.ts`
3. Add a block entry in `src/panels/left/BlocksPanel.tsx` under the appropriate category
4. Implement the `toHtml` static for HTML export
5. Build and test: `npm run dev`, drag the block onto the canvas, verify settings panel, verify HTML export
### Checklist for a new component:
- [ ] Props interface with `style?: CSSProperties`
- [ ] `useNode()` with `connect(drag(ref))` on the root element
- [ ] Settings panel using `useNode()` with `setProp()`
- [ ] `.craft` config with `displayName`, default `props`, `rules`, `related.settings`
- [ ] `.toHtml()` static method using `cssPropsToString()`
- [ ] Registered in `resolver.ts`
- [ ] Block added to `BlocksPanel.tsx`
## CSS / Theme
The editor uses a dark theme defined via CSS custom properties in `src/styles/editor.css`:
- **Base:** `#16161a`
- **Surface:** `#1c1c24`
- **Accent:** `#3b82f6` (blue)
- **Text:** `#e4e4e7`
- **Border:** `#2d2d3a`
- **Font:** Inter
All editor chrome (panels, topbar, settings) is styled via `editor.css`. User content on the canvas uses inline styles exclusively.
## Presets
Style presets are defined in `src/constants/presets.ts`:
- `TEXT_COLORS` - 8 text color swatches
- `BG_COLORS` - 8 background color swatches
- `FONT_FAMILIES` - 8 Google Fonts
- `TEXT_SIZES` - XS through 2XL
- `FONT_WEIGHTS` - Light through Bold
- `SPACING_PRESETS` - None through XL
- `RADIUS_PRESETS` - None through Full (9999px)
- `GRADIENTS` - 12 gradient presets
- `DEVICE_WIDTHS` - Desktop (100%), Tablet (768px), Mobile (375px)
## HTML Export
Every component has a static `toHtml(props, childrenHtml)` method. The `html-export.ts` utility recursively walks the Craft.js node tree and calls each component's `toHtml` to produce a complete HTML document. Export includes:
- Full `<!DOCTYPE html>` document structure
- Google Fonts preload links (optional)
- Inline styles throughout
- Header and footer wrapping each page
## Testing Approach
- **Manual testing:** Run `npm run dev`, drag components, edit props, verify settings panels
- **Type checking:** `tsc --noEmit` (part of build step)
- **HTML export:** Verify `toHtml()` output matches expected HTML structure
- **Device preview:** Switch between desktop/tablet/mobile and verify responsive behavior
- **WHP integration:** Deploy to staging, verify save/load, verify PHP wrapper injection
## Development Notes
- Path alias `@/` maps to `./src/` (configured in both tsconfig.json and vite.config.ts)
- `GuidedStyles` shows selected component type and delegates to per-component settings panels
- Text components (Heading, TextBlock) use `contentEditable` for inline editing when selected
- Button/link navigation is prevented in the editor via `e.preventDefault()`
- Image upload integrates with WHP API; in standalone mode falls back to local `blob:` URLs
- Auto-save runs every 30 seconds when connected to WHP API
- The SettingsTabs UI component provides a reusable General/Style/Advanced tab layout for component settings

102
craft/FEATURES.md Normal file
View File

@@ -0,0 +1,102 @@
# WHP Site Builder - Features
## Visual Editor
- Drag-and-drop page building with real-time preview
- No iframe -- content renders directly in the editor
- Responsive device preview (Desktop / Tablet / Mobile)
- Undo / Redo with full history
- Auto-save every 30 seconds
- Save as draft / Publish workflow
## Components (36 modules)
### Layout
- **Container** -- Generic wrapper with bg color/gradient/image, parallax, overlay
- **Section** -- Full-width section with centered content area
- **Column Layout** -- 1-10 columns with preset and custom splits
- **Background Section** -- Section with background image/gradient overlay
### Content
- **Heading** -- H1-H6 with inline editing, full typography controls
- **Text Block** -- Paragraph with inline editing
- **Button / Link** -- Styled button with color presets, radius, padding
- **Divider** -- Horizontal rule with color and thickness
- **Spacer** -- Adjustable vertical spacing
- **Icon** -- Font Awesome icon with size, color, background shape
- **Star Rating** -- Decorative star display (0-5 stars)
- **Social Links** -- Social media icon links (10+ platforms)
### Navigation
- **Logo** -- Text or image logo with link
- **Menu** -- Navigation links with page integration, CTA support
- **Navbar** -- Combined logo + menu layout (convenience block)
### Media
- **Image** -- Upload, browse, drag-drop, sizing controls (width/height/max-width with units)
- **Video** -- YouTube, Vimeo, direct files, background mode with overlay
- **Gallery** -- Image grid (2-6 columns) with optional lightbox
- **Map Embed** -- Google Maps with address, zoom, height
### Sections (Pre-built)
- **Hero** -- Gradient hero with heading, subtitle, CTA button
- **Features Grid** -- Multi-column feature cards
- **Call to Action** -- Full CTA section with bg options and dual buttons
- **Pricing Table** -- 2-4 tier comparison cards with featured plan
- **Testimonials** -- Quote cards with star ratings (grid or single view)
- **Accordion** -- Expandable FAQ/content panels
- **Tabs** -- Tabbed content panels
- **Countdown** -- Date countdown timer with live updating
- **Footer** -- Footer component with copyright text
### Forms
- **Contact Form** -- Complete form with configurable fields
- **Form Container** -- Wrapper for custom form layouts
- **Input Field** -- Text/email/tel/password/number inputs
- **Textarea** -- Multi-line text input
- **Form Button** -- Submit button with styling
## Site Design System
- **Basic mode**: Primary, secondary, accent colors + heading/body fonts + link color
- **Advanced mode**: Success/warning/error colors, background/text/border colors, border radius, button styling, nav style
- Design tokens applied across all pages and components
- Reset to defaults button
## Pages & Structure
- Multi-page support with unlimited pages
- **Header** -- Shared across all pages, edited separately
- **Footer** -- Shared across all pages, edited separately
- Clean URLs via .htaccess (no .html extension)
- Page management: add, edit, rename, delete, reorder
## Templates (16 pre-built)
- **Business**: Restaurant, Small Business, SaaS, Agency, Medical
- **Creative**: Portfolio, Photography, Content Creator, Event
- **Personal**: Resume, Blog, Wedding, Coming Soon
- **Community**: Church, Non-Profit, Fitness
- Each includes header, footer, and design tokens
- One-click loading with optional design token import
## Asset Management
- Image/video upload with drag-and-drop
- Asset browser with thumbnails
- Inline asset selection in component settings
- Assets stored in staging area, deployed on publish
## Settings Per Component (3-tab system)
- **General** -- Content-specific settings
- **Style** -- Typography, colors, spacing, borders, backgrounds
- **Advanced** -- Margin/padding (4-sided), CSS class/ID, responsive visibility, entrance animations
## Editor Features
- Component tree (Layers panel)
- Right-click context menu (duplicate, copy, paste, move, delete)
- Keyboard shortcuts (Ctrl+Z/Y/C/V/D, Delete, Escape)
- Dashed outlines for all containers/rows/columns
- Component selection with blue outline indicator
## Publishing
- **Save** stores to staging area (.site-builder/) -- does not affect live site
- **Publish** deploys to document root with header + body + footer composition
- Preview in new tab with header and footer included
- Coming Soon / Go Live toggle
- Clean HTML output with Google Fonts and responsive CSS

75
craft/README.md Normal file
View File

@@ -0,0 +1,75 @@
# WHP Site Builder v2
A visual drag-and-drop website builder for WHP, rebuilt from the ground up with Craft.js, React 18, and TypeScript. Replaces the legacy GrapesJS-based editor.
## Quick Start
```bash
npm install
npm run dev
```
Opens at http://localhost:5173. The editor runs in standalone mode without WHP integration.
## Build and Deploy
```bash
# Build production bundle
npm run build
# Deploy to WHP
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 (`index.php`) injects `WHP_CONFIG` (user session, CSRF token, site ID) into the HTML before serving.
## Architecture
- **Craft.js** - React-based visual editor framework (no iframe, direct DOM rendering)
- **Inline styles** - All user content uses React CSSProperties, no class-based CSS
- **Component pattern** - Each component is a self-contained file with: render logic, settings panel, Craft.js config, and HTML export method
- **3-panel layout** - Left (blocks/pages/layers/assets), Center (canvas with device preview), Right (styles/settings/head)
- **Dark theme** - CSS custom properties, Inter font, blue accent
## Components (22)
| Category | Components |
|----------|-----------|
| Layout | Container, Section, ColumnLayout (1-6 cols), BackgroundSection, HeaderZone, FooterZone |
| Basic | Heading (H1-H6), TextBlock, ButtonLink, Navbar, Footer, Divider, Spacer |
| Media | ImageBlock (upload/browse/drag-drop), VideoBlock (YouTube/Vimeo/direct/background) |
| Sections | HeroSimple, FeaturesGrid, CTASection |
| Forms | FormContainer, InputField, TextareaField, FormButton |
## Features
- **Visual Editor** - Drag-and-drop building, real-time preview, responsive device preview (Desktop/Tablet/Mobile)
- **Site Design Tokens** - 17 site-wide properties (colors, fonts, radii, nav style) with Basic/Advanced tabs
- **Multi-Page** - Unlimited pages with shared Header and Footer across all pages
- **16 Templates** - Pre-built designs across 4 categories (Business, Creative, Personal, Community)
- **Asset Management** - Upload, browse, drag-drop, thumbnails, server-side storage via WHP API
- **HTML Export** - Full document export with Google Fonts and inline styles
- **Auto-Save** - Saves every 30 seconds when connected to WHP
- **Context Menu** - Right-click for duplicate, copy, paste, move, delete
- **Keyboard Shortcuts** - Undo, redo, delete
- **Layers Panel** - Component hierarchy tree view
- **Undo/Redo** - Full history support
## Key Files
| File | Purpose |
|------|---------|
| `src/main.tsx` | Entry point, reads WHP_CONFIG |
| `src/App.tsx` | Editor + providers (EditorConfig, SiteDesign, Pages) |
| `src/components/resolver.ts` | Component registry (20 components) for serialization |
| `src/editor/EditorShell.tsx` | 3-panel layout + context menu + keyboard shortcuts |
| `src/editor/Canvas.tsx` | Craft.js Frame with device switching |
| `src/state/PageContext.tsx` | Multi-page state + header/footer |
| `src/state/SiteDesignContext.tsx` | 17 site-wide design tokens |
| `src/templates/definitions.ts` | 16 template definitions |
| `src/constants/presets.ts` | Color, font, spacing presets |
| `src/utils/html-export.ts` | Node-tree to HTML renderer |
See `CLAUDE.md` for full documentation. See `FEATURES.md` for a complete feature list.

16
craft/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Site Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
craft/package.json Normal file
View File

@@ -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"
}
}

26
craft/src/App.tsx Normal file
View File

@@ -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<AppProps> = ({ whpConfig }) => {
return (
<EditorConfigProvider config={whpConfig}>
<SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true}>
<PageProvider>
<EditorShell />
</PageProvider>
</Editor>
</SiteDesignProvider>
</EditorConfigProvider>
);
};

View File

@@ -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<ButtonLinkProps> = ({
text = 'Click Me',
href = '#',
target = '_self',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<a
ref={(ref: HTMLAnchorElement | null) => { 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}
</a>
);
};
/* ---------- 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Link URL</label>
<input
type="text"
value={props.href || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Target</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['_self', '_blank'] as const).map((t) => (
<button
key={t}
onClick={() => setProp((p: ButtonLinkProps) => { p.target = t; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.target === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{t === '_self' ? 'Same Tab' : 'New Tab'}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((preset) => (
<button
key={preset.label}
onClick={() => setProp((p: ButtonLinkProps) => {
p.style = {
...p.style,
backgroundColor: preset.bg,
color: preset.color,
border: preset.bg === 'transparent' ? `1px solid ${preset.color}` : 'none',
};
})}
title={preset.label}
style={{
width: 24, height: 24, borderRadius: 4,
border: preset.bg === 'transparent' ? `2px solid ${preset.color}` : '1px solid #3f3f46',
backgroundColor: preset.bg, cursor: 'pointer',
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{radiusPresets.map((r) => (
<button
key={r}
onClick={() => setProp((p: ButtonLinkProps) => { p.style = { ...p.style, borderRadius: r }; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{r}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{paddingPresets.map((p) => (
<button
key={p}
onClick={() => setProp((pr: ButtonLinkProps) => { pr.style = { ...pr.style, padding: p }; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{p}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Font Size</label>
<input
type="text"
placeholder="e.g. 16px"
value={(props.style?.fontSize as string) || ''}
onChange={(e) => 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 }}
/>
</div>
</div>
);
};
/* ---------- 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, '&lt;').replace(/>/g, '&gt;');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return {
html: `<a href="${props.href || '#'}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
};
};

View File

@@ -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<DividerProps> = ({
color = '#e4e4e7',
thickness = '1px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<hr
ref={(ref: HTMLHRElement | null): void => { 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: DividerProps) => { p.color = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.color === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thickness</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{thicknessPresets.map((t) => (
<button
key={t}
onClick={() => setProp((p: DividerProps) => { p.thickness = t; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.thickness === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{t}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- 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: `<hr${styleStr ? ` style="${styleStr}"` : ''} />` };
};

View File

@@ -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<FooterProps> = ({
text = '© 2026 MySite. All rights reserved.',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
actions: { setProp },
} = useNode((node) => ({
selected: node.events.selected,
}));
const elRef = useRef<HTMLElement | null>(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 (
<footer
ref={(ref: HTMLElement | null): void => {
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 || '')}
</footer>
);
};
/* ---------- 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Footer Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FooterProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FooterProps) => { p.style = { ...p.style, color: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.color === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- 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, '&lt;').replace(/>/g, '&gt;');
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` };
};

View File

@@ -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<HeadingProps> = ({
text = 'Heading',
level = 'h2',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
actions: { setProp },
} = useNode((node) => ({
selected: node.events.selected,
}));
const elRef = useRef<HTMLElement | null>(null);
const editedTextRef = useRef<string | null>(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 (
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Heading Level</label>
<div style={{ display: 'flex', gap: 4 }}>
{levels.map((l) => (
<button
key={l}
onClick={() => setProp((p: HeadingProps) => { p.level = l; })}
style={{
flex: 1, padding: '4px 0', borderRadius: 4, border: '1px solid #3f3f46', cursor: 'pointer',
background: props.level === l ? '#3b82f6' : '#27272a', color: props.level === l ? '#fff' : '#a1a1aa',
fontSize: 12, fontWeight: 600,
}}
>{l.toUpperCase()}</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => 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 }}
/>
</div>
</div>
}
style={
<TypographyControl
style={props.style || {}}
onChange={(updates) => setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
/>
}
advanced={
<AdvancedTab
style={props.style || {}}
onStyleChange={(updates) => 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, '&lt;').replace(/>/g, '&gt;');
const styleStr = cssPropsToString(props.style);
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
};

View File

@@ -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<HtmlBlockProps> = ({
code = '',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLElement | null): void => { 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div style={{
padding: '8px 10px',
background: '#44200a',
border: '1px solid #92400e',
borderRadius: 6,
fontSize: 11,
color: '#fbbf24',
lineHeight: 1.4,
}}>
This block renders raw HTML. Use with caution.
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>HTML Code</label>
<textarea
value={props.code || ''}
onChange={(e) => setProp((p: HtmlBlockProps) => { p.code = e.target.value; })}
placeholder="<div>Your HTML here...</div>"
rows={16}
style={{
width: '100%',
padding: '10px',
background: '#1a1a2e',
color: '#a5f3fc',
border: '1px solid #3f3f46',
borderRadius: 6,
fontSize: 12,
fontFamily: '"Source Code Pro", "Fira Code", monospace',
lineHeight: 1.5,
resize: 'vertical',
boxSizing: 'border-box',
whiteSpace: 'pre',
tabSize: 2,
}}
/>
</div>
{/* Outer container style */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['0px', '8px', '16px', '24px', '32px'].map((p) => (
<button
key={p}
onClick={() => setProp((pr: HtmlBlockProps) => { pr.style = { ...pr.style, padding: p }; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{p}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
HtmlBlock.craft = {
displayName: 'HTML',
props: {
code: '',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: HtmlBlockSettings,
},
};
/* ---------- HTML export ---------- */
(HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => {
// Output the raw code as-is
return { html: props.code || '' };
};

View File

@@ -0,0 +1,325 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface IconProps {
icon?: string;
size?: string;
color?: string;
bgColor?: string;
bgShape?: 'none' | 'circle' | 'square' | 'rounded';
bgSize?: string;
link?: string;
style?: CSSProperties;
}
const COMMON_ICONS = [
'fa-star', 'fa-heart', 'fa-check', 'fa-phone', 'fa-envelope',
'fa-map-marker', 'fa-globe', 'fa-facebook', 'fa-twitter', 'fa-instagram',
'fa-linkedin', 'fa-youtube', 'fa-github', 'fa-arrow-right', 'fa-arrow-down',
'fa-play', 'fa-search', 'fa-user', 'fa-lock', 'fa-cog',
'fa-home', 'fa-comment', 'fa-camera', 'fa-music', 'fa-shopping-cart',
'fa-calendar', 'fa-clock-o', 'fa-thumbs-up', 'fa-lightbulb-o', 'fa-rocket',
];
function getBgBorderRadius(shape: string): string {
if (shape === 'circle') return '50%';
if (shape === 'rounded') return '8px';
if (shape === 'square') return '0px';
return '0px';
}
export const Icon: UserComponent<IconProps> = ({
icon = 'fa-star',
size = '32px',
color = '#3b82f6',
bgColor = 'transparent',
bgShape = 'none',
bgSize = '56px',
link = '',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const iconEl = (
<i
className={`fa ${icon}`}
style={{ fontSize: size, color, lineHeight: 1 }}
/>
);
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
const wrapperEl = hasBg ? (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: bgSize,
height: bgSize,
backgroundColor: bgColor,
borderRadius: getBgBorderRadius(bgShape || 'none'),
}}
>
{iconEl}
</div>
) : iconEl;
const content = link ? (
<a href={link} onClick={(e) => e.preventDefault()} style={{ textDecoration: 'none', color: 'inherit' }}>
{wrapperEl}
</a>
) : wrapperEl;
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'inline-block',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{content}
</div>
);
};
/* ---------- Settings panel ---------- */
const IconSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as IconProps,
}));
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 sizePresets = ['24px', '32px', '48px', '64px'];
const colorPresets = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#18181b', '#ffffff'];
const shapePresets: Array<{ label: string; value: IconProps['bgShape'] }> = [
{ label: 'None', value: 'none' },
{ label: 'Circle', value: 'circle' },
{ label: 'Square', value: 'square' },
{ label: 'Rounded', value: 'rounded' },
];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Icon picker */}
<div>
<label style={labelStyle}>Icon</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 4, maxHeight: 200, overflowY: 'auto' }}>
{COMMON_ICONS.map((ic) => (
<button
key={ic}
onClick={() => setProp((p: IconProps) => { p.icon = ic; })}
title={ic}
style={{
padding: '6px', fontSize: 16, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.icon === ic ? '#3b82f6' : '#27272a',
color: props.icon === ic ? '#fff' : '#e4e4e7',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<i className={`fa ${ic}`} />
</button>
))}
</div>
</div>
{/* Custom icon class */}
<div>
<label style={labelStyle}>Custom Icon Class</label>
<input
type="text"
value={props.icon || ''}
onChange={(e) => setProp((p: IconProps) => { p.icon = e.target.value; })}
placeholder="fa-star"
style={inputStyle}
/>
</div>
{/* Size */}
<div>
<label style={labelStyle}>Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: IconProps) => { p.size = s; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.size === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Color */}
<div>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: IconProps) => { p.color = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.color === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background shape */}
<div>
<label style={labelStyle}>Background Shape</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{shapePresets.map((s) => (
<button
key={s.value}
onClick={() => setProp((p: IconProps) => { p.bgShape = s.value; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.bgShape === s.value ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s.label}
</button>
))}
</div>
</div>
{/* Background color */}
{props.bgShape !== 'none' && (
<div>
<label style={labelStyle}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#18181b', '#f1f5f9', '#ffffff'].map((c) => (
<button
key={c}
onClick={() => setProp((p: IconProps) => { p.bgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{/* Background size */}
{props.bgShape !== 'none' && (
<div>
<label style={labelStyle}>Background Size</label>
<input
type="text"
value={props.bgSize || '56px'}
onChange={(e) => setProp((p: IconProps) => { p.bgSize = e.target.value; })}
placeholder="56px"
style={inputStyle}
/>
</div>
)}
{/* Link */}
<div>
<label style={labelStyle}>Link URL</label>
<input
type="text"
value={props.link || ''}
onChange={(e) => setProp((p: IconProps) => { p.link = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Icon.craft = {
displayName: 'Icon',
props: {
icon: 'fa-star',
size: '32px',
color: '#3b82f6',
bgColor: 'transparent',
bgShape: 'none',
bgSize: '56px',
link: '',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: IconSettings,
},
};
/* ---------- HTML export ---------- */
(Icon as any).toHtml = (props: IconProps, _childrenHtml: string) => {
const {
icon = 'fa-star',
size = '32px',
color = '#3b82f6',
bgColor = 'transparent',
bgShape = 'none',
bgSize = '56px',
link = '',
style = {},
} = props;
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
let iconHtml = `<i class="fa ${icon}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
if (hasBg) {
const bgStyle = cssPropsToString({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: bgSize,
height: bgSize,
backgroundColor: bgColor,
borderRadius: getBgBorderRadius(bgShape || 'none'),
});
iconHtml = `<div${bgStyle ? ` style="${bgStyle}"` : ''}>${iconHtml}</div>`;
}
if (link) {
iconHtml = `<a href="${link}" style="text-decoration:none;color:inherit">${iconHtml}</a>`;
}
const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style });
return { html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${iconHtml}</div>` };
};

View File

@@ -0,0 +1,418 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
/* ---------- Types ---------- */
interface LogoProps {
type?: 'text' | 'image';
text?: string;
imageSrc?: string;
imageWidth?: string;
href?: string;
fontFamily?: string;
fontSize?: string;
fontWeight?: string;
color?: string;
style?: CSSProperties;
}
/* ---------- Image upload helper ---------- */
async function uploadToWhp(file: File): Promise<string | null> {
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; }
}
/* ---------- Helper: escape HTML ---------- */
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ---------- Component ---------- */
export const Logo: UserComponent<LogoProps> = ({
type = 'text',
text = 'MySite',
imageSrc = '',
imageWidth = '120px',
href = '/',
fontFamily = 'Inter, sans-serif',
fontSize = '20px',
fontWeight = '700',
color,
style = {},
}) => {
const {
connectors: { connect, drag },
} = useNode();
const { design } = useSiteDesign();
const resolvedColor = color || design.textColor;
return (
<a
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
href={href}
onClick={(e) => e.preventDefault()}
style={{
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
flexShrink: 0,
...style,
}}
>
{type === 'image' && imageSrc ? (
<img
src={imageSrc}
alt={text || 'Logo'}
style={{ width: imageWidth, height: 'auto', display: 'block' }}
/>
) : (
<span style={{
fontWeight,
fontSize,
fontFamily,
color: resolvedColor,
}}>
{text}
</span>
)}
</a>
);
};
/* ---------- Settings panel ---------- */
const LogoSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as LogoProps,
}));
const { design } = useSiteDesign();
const logoType = props.type || 'text';
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
const fontFamilies = [
{ label: 'Inter', value: 'Inter, sans-serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Poppins', value: 'Poppins, sans-serif' },
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
{ label: 'Playfair', value: 'Playfair Display, serif' },
{ label: 'Merriweather', value: 'Merriweather, serif' },
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
];
const handleLogoUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp((p: LogoProps) => { p.imageSrc = 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]);
/* ---- Shared styles ---- */
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const btnSmall: CSSProperties = {
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
const btnActive: CSSProperties = {
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Type toggle */}
<div>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: LogoProps) => { p.type = 'text'; })}
style={logoType === 'text' ? btnActive : btnSmall}
>
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
</button>
<button
onClick={() => setProp((p: LogoProps) => { p.type = 'image'; })}
style={logoType === 'image' ? btnActive : btnSmall}
>
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
</button>
</div>
</div>
{logoType === 'text' ? (
<>
<div>
<label style={labelStyle}>Logo Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: LogoProps) => { p.text = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Font Family</label>
<select
value={props.fontFamily || 'Inter, sans-serif'}
onChange={(e) => setProp((p: LogoProps) => { p.fontFamily = e.target.value; })}
style={{ ...inputStyle, cursor: 'pointer' }}
>
{fontFamilies.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Size</label>
<input
type="text"
value={props.fontSize || '20px'}
onChange={(e) => setProp((p: LogoProps) => { p.fontSize = e.target.value; })}
placeholder="20px"
style={inputStyle}
/>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Weight</label>
<select
value={props.fontWeight || '700'}
onChange={(e) => setProp((p: LogoProps) => { p.fontWeight = e.target.value; })}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
<option value="800">Extra Bold</option>
</select>
</div>
</div>
<div>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="color"
value={props.color || design.textColor}
onChange={(e) => setProp((p: LogoProps) => { p.color = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<span style={{ fontSize: 10, color: '#71717a' }}>{props.color || 'Auto'}</span>
<button
onClick={() => setProp((p: LogoProps) => { p.color = undefined; })}
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
title="Reset to auto"
>Auto</button>
</div>
</div>
</>
) : (
<>
{/* Image logo controls */}
{props.imageSrc ? (
<div style={{ borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.imageSrc} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
<button
onClick={() => setProp((p: LogoProps) => { p.imageSrc = ''; })}
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Remove image"
>
<i className="fa fa-times" />
</button>
</div>
) : (
<div
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer' }}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { 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 handleLogoUpload(file);
}}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
Drop logo or click to upload
</div>
)}
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
>
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 3 }} /> Browse
</button>
</div>
{/* Browse grid */}
{showBrowser && (
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map(asset => (
<div
key={asset.name}
onClick={() => { setProp((p: LogoProps) => { p.imageSrc = asset.url; }); setShowBrowser(false); }}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
>
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{browserAssets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '8px 0', margin: 0 }}>No images uploaded yet.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
{/* URL paste input */}
<div>
<input
type="text"
value={props.imageSrc || ''}
onChange={(e) => setProp((p: LogoProps) => { p.imageSrc = e.target.value; })}
placeholder="Or paste image URL..."
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
/>
</div>
<div>
<label style={labelStyle}>Logo Width</label>
<input
type="text"
value={props.imageWidth || '120px'}
onChange={(e) => setProp((p: LogoProps) => { p.imageWidth = e.target.value; })}
placeholder="120px"
style={inputStyle}
/>
</div>
</>
)}
{/* Link URL */}
<div>
<label style={labelStyle}>Link URL</label>
<input
type="text"
value={props.href || '/'}
onChange={(e) => setProp((p: LogoProps) => { p.href = e.target.value; })}
placeholder="/"
style={inputStyle}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Logo.craft = {
displayName: 'Logo',
props: {
type: 'text',
text: 'MySite',
imageSrc: '',
imageWidth: '120px',
href: '/',
fontFamily: 'Inter, sans-serif',
fontSize: '20px',
fontWeight: '700',
color: undefined,
style: {},
} as LogoProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: LogoSettings,
},
};
/* ---------- HTML export ---------- */
(Logo as any).toHtml = (props: LogoProps, _childrenHtml: string) => {
const href = props.href || '/';
let innerHtml: string;
if (props.type === 'image' && props.imageSrc) {
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
innerHtml = `<img src="${esc(props.imageSrc)}" alt="${esc(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
} else {
const spanStyle = cssPropsToString({
fontWeight: props.fontWeight || '700',
fontSize: props.fontSize || '20px',
fontFamily: props.fontFamily || 'Inter, sans-serif',
color: props.color || '#1f2937',
});
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${esc(props.text || 'MySite')}</span>`;
}
const aStyle = cssPropsToString({
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
flexShrink: '0',
...props.style,
});
return {
html: `<a href="${esc(href)}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
};
};

View File

@@ -0,0 +1,510 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext';
/* ---------- Types ---------- */
interface MenuLink {
text: string;
href: string;
isExternal?: boolean;
isCta?: boolean;
}
interface MenuProps {
links?: MenuLink[];
alignment?: 'left' | 'center' | 'right';
linkColor?: string;
linkHoverColor?: string;
ctaBgColor?: string;
ctaTextColor?: string;
gap?: string;
orientation?: 'horizontal' | 'vertical';
fontSize?: string;
style?: CSSProperties;
}
/* ---------- Defaults ---------- */
const defaultLinks: MenuLink[] = [
{ text: 'Home', href: '/' },
{ text: 'About', href: '#about' },
{ text: 'Services', href: '#services' },
{ text: 'Contact', href: '#contact', isCta: true },
];
/* ---------- Helper: escape HTML ---------- */
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ---------- Component ---------- */
export const Menu: UserComponent<MenuProps> = ({
links = defaultLinks,
alignment = 'right',
linkColor = '#3f3f46',
linkHoverColor = '#3b82f6',
ctaBgColor = '#3b82f6',
ctaTextColor = '#ffffff',
gap = '24px',
orientation = 'horizontal',
fontSize = '14px',
style = {},
}) => {
const {
connectors: { connect, drag },
} = useNode();
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const justifyMap = { left: 'flex-start', center: 'center', right: 'flex-end' };
return (
<nav
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexDirection: orientation === 'vertical' ? 'column' : 'row',
alignItems: orientation === 'vertical' ? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start') : 'center',
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
gap,
flexWrap: 'wrap',
...style,
}}
>
{links.map((link, i) => (
<a
key={i}
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
style={{
textDecoration: 'none',
fontSize,
fontWeight: link.isCta ? '600' : '400',
color: link.isCta
? ctaTextColor
: (hoveredLink === i ? linkHoverColor : linkColor),
backgroundColor: link.isCta ? ctaBgColor : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
}}
>
{link.text}
</a>
))}
</nav>
);
};
/* ---------- Settings panel ---------- */
const MenuSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as MenuProps,
}));
const { pages } = usePages();
const links = props.links || defaultLinks;
/* Drag state for reordering */
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
/* ---- Link management ---- */
const updateLink = (index: number, field: keyof MenuLink, value: string | boolean) => {
setProp((p: MenuProps) => {
const updated = [...(p.links || defaultLinks)];
updated[index] = { ...updated[index], [field]: value };
p.links = updated;
});
};
const addLink = (link?: Partial<MenuLink>) => {
setProp((p: MenuProps) => {
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
});
};
const removeLink = (index: number) => {
setProp((p: MenuProps) => {
const updated = [...(p.links || defaultLinks)];
updated.splice(index, 1);
p.links = updated;
});
};
const moveLink = (fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return;
setProp((p: MenuProps) => {
const updated = [...(p.links || defaultLinks)];
const [moved] = updated.splice(fromIdx, 1);
updated.splice(toIdx, 0, moved);
p.links = updated;
});
};
/* ---- Shared styles ---- */
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const sectionStyle: CSSProperties = {
borderBottom: '1px solid #27272a', paddingBottom: 12,
};
const btnSmall: CSSProperties = {
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
const btnActive: CSSProperties = {
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
};
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
const gapPresets = ['8px', '16px', '24px', '32px', '40px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* ===== Style Section ===== */}
<div style={sectionStyle}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Menu Style</label>
{/* Link color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Link Color</label>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: MenuProps) => { p.linkColor = c; })}
style={{
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.linkColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
<input
type="color"
value={props.linkColor || '#3f3f46'}
onChange={(e) => setProp((p: MenuProps) => { p.linkColor = e.target.value; })}
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
title="Custom color"
/>
</div>
</div>
{/* Hover color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Hover Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="color"
value={props.linkHoverColor || '#3b82f6'}
onChange={(e) => setProp((p: MenuProps) => { p.linkHoverColor = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<span style={{ fontSize: 10, color: '#71717a' }}>{props.linkHoverColor || '#3b82f6'}</span>
</div>
</div>
{/* CTA button colors */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>CTA Button</label>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
<input
type="color"
value={props.ctaBgColor || '#3b82f6'}
onChange={(e) => setProp((p: MenuProps) => { p.ctaBgColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
<input
type="color"
value={props.ctaTextColor || '#ffffff'}
onChange={(e) => setProp((p: MenuProps) => { p.ctaTextColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
</div>
</div>
{/* Font size */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Font Size</label>
<input
type="text"
value={props.fontSize || '14px'}
onChange={(e) => setProp((p: MenuProps) => { p.fontSize = e.target.value; })}
placeholder="14px"
style={inputStyle}
/>
</div>
{/* Alignment */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button
key={a}
onClick={() => setProp((p: MenuProps) => { p.alignment = a; })}
style={(props.alignment || 'right') === a ? btnActive : btnSmall}
>
{a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
</div>
{/* Orientation */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Orientation</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['horizontal', 'vertical'] as const).map((o) => (
<button
key={o}
onClick={() => setProp((p: MenuProps) => { p.orientation = o; })}
style={(props.orientation || 'horizontal') === o ? btnActive : btnSmall}
>
{o.charAt(0).toUpperCase() + o.slice(1)}
</button>
))}
</div>
</div>
{/* Gap */}
<div>
<label style={labelStyle}>Gap</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gapPresets.map((g) => (
<button
key={g}
onClick={() => setProp((p: MenuProps) => { p.gap = g; })}
style={(props.gap || '24px') === g ? btnActive : btnSmall}
>
{g}
</button>
))}
</div>
</div>
</div>
{/* ===== Links Section ===== */}
<div>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => (
<div
key={i}
draggable
onDragStart={() => setDragIdx(i)}
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
onDragEnd={() => {
if (dragIdx !== null && dragOverIdx !== null) {
moveLink(dragIdx, dragOverIdx);
}
setDragIdx(null);
setDragOverIdx(null);
}}
style={{
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
borderRadius: 6,
padding: 8,
display: 'flex',
flexDirection: 'column',
gap: 4,
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
transition: 'background 0.1s, border-color 0.1s',
}}
>
{/* Row 1: drag handle + text + delete */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
title="Drag to reorder"
>
<i className="fa fa-bars" />
</span>
<input
type="text"
value={link.text}
onChange={(e) => updateLink(i, 'text', e.target.value)}
placeholder="Text"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeLink(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
title="Delete link"
>
<i className="fa fa-trash" />
</button>
</div>
{/* Row 2: URL */}
<input
type="text"
value={link.href}
onChange={(e) => updateLink(i, 'href', e.target.value)}
placeholder="URL (e.g. /about or https://...)"
style={inputStyle}
/>
{/* Row 3: checkboxes */}
<div style={{ display: 'flex', gap: 8 }}>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
External
</label>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
CTA
</label>
</div>
</div>
))}
</div>
{/* Add link button */}
<button
onClick={() => addLink()}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Link
</button>
{/* Add page dropdown */}
<select
onChange={(e) => {
const page = pages.find(p => p.id === e.target.value);
if (page) {
addLink({
text: page.name,
href: page.slug === 'index' ? '/' : page.slug,
isExternal: false,
isCta: false,
});
}
e.target.value = '';
}}
value=""
style={{
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
background: '#1e293b', color: '#93c5fd',
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
}}
>
<option value="">+ Add Page...</option>
{pages.map(p => (
<option key={p.id} value={p.id}>
{p.name} ({p.slug === 'index' ? '/' : p.slug})
</option>
))}
</select>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Menu.craft = {
displayName: 'Menu',
props: {
links: defaultLinks,
alignment: 'right',
linkColor: '#3f3f46',
linkHoverColor: '#3b82f6',
ctaBgColor: '#3b82f6',
ctaTextColor: '#ffffff',
gap: '24px',
orientation: 'horizontal',
fontSize: '14px',
style: {},
} as MenuProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: MenuSettings,
},
};
/* ---------- HTML export ---------- */
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string) => {
const linkCol = props.linkColor || '#3f3f46';
const hoverCol = props.linkHoverColor || '#3b82f6';
const ctaBg = props.ctaBgColor || '#3b82f6';
const ctaText = props.ctaTextColor || '#ffffff';
const gap = props.gap || '24px';
const orientation = props.orientation || 'horizontal';
const alignment = props.alignment || 'right';
const fSize = props.fontSize || '14px';
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
const navStyle = cssPropsToString({
display: 'flex',
flexDirection: orientation === 'vertical' ? 'column' : 'row',
alignItems: orientation === 'vertical'
? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start')
: 'center',
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
gap,
flexWrap: 'wrap',
...props.style,
});
const links = props.links || defaultLinks;
// Unique ID suffix for scoped CSS
const scopeId = `menu-${Math.random().toString(36).slice(2, 8)}`;
const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const cls = link.isCta ? `${scopeId}-cta` : `${scopeId}-link`;
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: fSize,
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaText : linkCol,
backgroundColor: link.isCta ? ctaBg : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
}).join('\n ');
const hoverCss = `<style>
.${scopeId}-link:hover { color: ${hoverCol} !important; }
.${scopeId}-cta:hover { filter: brightness(1.1); }
</style>`;
return {
html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}"` : ''}>
${linksHtml}
</nav>`,
};
};

View File

@@ -0,0 +1,929 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
/* ---------- Types ---------- */
interface NavLink {
text: string;
href: string;
isExternal?: boolean;
isCta?: boolean;
}
interface NavbarProps {
logoType?: 'text' | 'image';
logoText?: string;
logoImage?: string;
logoWidth?: string;
logoUrl?: string;
logoFontFamily?: string;
logoFontSize?: string;
logoColor?: string;
links?: NavLink[];
backgroundColor?: string;
textColor?: string;
hoverColor?: string;
ctaColor?: string;
ctaTextColor?: string;
padding?: string;
navAlignment?: 'left' | 'center' | 'right' | 'space-between';
isSticky?: boolean;
showMobileMenu?: boolean;
style?: CSSProperties;
}
/* ---------- Defaults ---------- */
const defaultLinks: NavLink[] = [
{ text: 'Home', href: '/' },
{ text: 'About', href: '#about' },
{ text: 'Services', href: '#services' },
{ text: 'Contact', href: '#contact', isCta: true },
];
const PADDING_PRESETS = [
{ label: 'Compact', value: '8px 16px' },
{ label: 'Normal', value: '16px 24px' },
{ label: 'Relaxed', value: '20px 32px' },
{ label: 'Spacious', value: '24px 48px' },
];
/* ---------- Image upload helper (same as ImageBlock) ---------- */
async function uploadToWhp(file: File): Promise<string | null> {
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; }
}
/* ---------- Helper: escape HTML ---------- */
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ---------- Component ---------- */
export const Navbar: UserComponent<NavbarProps> = ({
logoType = 'text',
logoText = 'MySite',
logoImage = '',
logoWidth = '120px',
logoUrl = '/',
logoFontFamily = 'Inter, sans-serif',
logoFontSize = '20px',
logoColor,
links = defaultLinks,
backgroundColor = '#ffffff',
textColor = '#3f3f46',
hoverColor = '#3b82f6',
ctaColor = '#3b82f6',
ctaTextColor = '#ffffff',
padding = '16px 24px',
navAlignment = 'space-between',
isSticky = false,
showMobileMenu = false,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const { design } = useSiteDesign();
const resolvedLogoColor = logoColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? design.textColor : '#ffffff');
const resolvedTextColor = textColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? '#3f3f46' : '#e4e4e7');
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
return (
<nav
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: navAlignment,
padding,
backgroundColor,
...(isSticky ? { position: 'sticky' as const, top: 0, zIndex: 1000 } : {}),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{/* Logo */}
<a
href={logoUrl}
onClick={(e) => e.preventDefault()}
style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}
>
{logoType === 'image' && logoImage ? (
<img
src={logoImage}
alt={logoText || 'Logo'}
style={{ width: logoWidth, height: 'auto', display: 'block' }}
/>
) : (
<span style={{
fontWeight: '700',
fontSize: logoFontSize,
fontFamily: logoFontFamily,
color: resolvedLogoColor,
}}>
{logoText}
</span>
)}
</a>
{/* Links */}
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
{showMobileMenu && (
<div
style={{
display: 'none', /* Hidden in editor, shown via media query in export */
flexDirection: 'column',
gap: '4px',
cursor: 'pointer',
padding: '4px',
}}
className="navbar-hamburger"
>
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
</div>
)}
{links.map((link, i) => (
<a
key={i}
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
style={{
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta
? ctaTextColor
: (hoveredLink === i ? hoverColor : resolvedTextColor),
backgroundColor: link.isCta ? ctaColor : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
}}
>
{link.text}
</a>
))}
</div>
</nav>
);
};
/* ---------- Settings panel ---------- */
const NavbarSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as NavbarProps,
}));
const { pages } = usePages();
const { design } = useSiteDesign();
const links = props.links || defaultLinks;
const logoType = props.logoType || 'text';
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
/* Drag state for reordering */
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const bgPresets = ['#ffffff', '#f8fafc', '#f9fafb', '#18181b', '#0f172a', '#1e293b', '#1f2937', '#111827'];
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
const fontFamilies = [
{ label: 'Inter', value: 'Inter, sans-serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Poppins', value: 'Poppins, sans-serif' },
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
{ label: 'Playfair', value: 'Playfair Display, serif' },
{ label: 'Merriweather', value: 'Merriweather, serif' },
];
/* ---- Link management ---- */
const updateLink = (index: number, field: keyof NavLink, value: string | boolean) => {
setProp((p: NavbarProps) => {
const updated = [...(p.links || defaultLinks)];
updated[index] = { ...updated[index], [field]: value };
p.links = updated;
});
};
const addLink = (link?: Partial<NavLink>) => {
setProp((p: NavbarProps) => {
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
});
};
const removeLink = (index: number) => {
setProp((p: NavbarProps) => {
const updated = [...(p.links || defaultLinks)];
updated.splice(index, 1);
p.links = updated;
});
};
const moveLink = (fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return;
setProp((p: NavbarProps) => {
const updated = [...(p.links || defaultLinks)];
const [moved] = updated.splice(fromIdx, 1);
updated.splice(toIdx, 0, moved);
p.links = updated;
});
};
/* ---- Image upload for logo ---- */
const handleLogoUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp((p: NavbarProps) => { p.logoImage = 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]);
/* ---- Shared styles ---- */
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const sectionStyle: CSSProperties = {
borderBottom: '1px solid #27272a', paddingBottom: 12,
};
const swatchStyle = (color: string, active: boolean): CSSProperties => ({
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: color, cursor: 'pointer',
outline: active ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
});
const btnSmall: CSSProperties = {
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
const btnActive: CSSProperties = {
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* ===== Logo Section ===== */}
<div style={sectionStyle}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo</label>
{/* Logo type toggle */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
<button
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'text'; })}
style={logoType === 'text' ? btnActive : btnSmall}
>
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
</button>
<button
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'image'; })}
style={logoType === 'image' ? btnActive : btnSmall}
>
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
</button>
</div>
{logoType === 'text' ? (
<>
{/* Text logo controls */}
<div style={{ marginBottom: 6 }}>
<label style={labelStyle}>Logo Text</label>
<input
type="text"
value={props.logoText || ''}
onChange={(e) => setProp((p: NavbarProps) => { p.logoText = e.target.value; })}
style={inputStyle}
/>
</div>
<div style={{ marginBottom: 6 }}>
<label style={labelStyle}>Font Family</label>
<select
value={props.logoFontFamily || 'Inter, sans-serif'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontFamily = e.target.value; })}
style={{ ...inputStyle, cursor: 'pointer' }}
>
{fontFamilies.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Size</label>
<input
type="text"
value={props.logoFontSize || '20px'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontSize = e.target.value; })}
placeholder="20px"
style={inputStyle}
/>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<input
type="color"
value={props.logoColor || design.textColor}
onChange={(e) => setProp((p: NavbarProps) => { p.logoColor = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<button
onClick={() => setProp((p: NavbarProps) => { p.logoColor = undefined; })}
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
title="Reset to auto"
>Auto</button>
</div>
</div>
</div>
</>
) : (
<>
{/* Image logo controls */}
{props.logoImage ? (
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.logoImage} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
<button
onClick={() => setProp((p: NavbarProps) => { p.logoImage = ''; })}
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Remove image"
>
<i className="fa fa-times" />
</button>
</div>
) : (
<div
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer', marginBottom: 8 }}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { 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 handleLogoUpload(file);
}}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
Drop logo or click to upload
</div>
)}
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
>
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 3 }} /> Browse
</button>
</div>
{/* Browse grid */}
{showBrowser && (
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginBottom: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map(asset => (
<div
key={asset.name}
onClick={() => { setProp((p: NavbarProps) => { p.logoImage = asset.url; }); setShowBrowser(false); }}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
>
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{browserAssets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '8px 0', margin: 0 }}>No images uploaded yet.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
{/* URL input */}
<div style={{ marginBottom: 6 }}>
<input
type="text"
value={props.logoImage || ''}
onChange={(e) => setProp((p: NavbarProps) => { p.logoImage = e.target.value; })}
placeholder="Or paste image URL..."
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
/>
</div>
<div>
<label style={labelStyle}>Logo Width</label>
<input
type="text"
value={props.logoWidth || '120px'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoWidth = e.target.value; })}
placeholder="120px"
style={inputStyle}
/>
</div>
</>
)}
{/* Logo link URL (shared) */}
<div style={{ marginTop: 6 }}>
<label style={labelStyle}>Logo Link URL</label>
<input
type="text"
value={props.logoUrl || '/'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoUrl = e.target.value; })}
placeholder="/"
style={inputStyle}
/>
</div>
</div>
{/* ===== Nav Style Section ===== */}
<div style={sectionStyle}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Nav Style</label>
{/* Background color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NavbarProps) => { p.backgroundColor = c; })}
style={swatchStyle(c, props.backgroundColor === c)}
/>
))}
<input
type="color"
value={props.backgroundColor || '#ffffff'}
onChange={(e) => setProp((p: NavbarProps) => { p.backgroundColor = e.target.value; })}
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
title="Custom color"
/>
</div>
</div>
{/* Text color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Text Color</label>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NavbarProps) => { p.textColor = c; })}
style={swatchStyle(c, props.textColor === c)}
/>
))}
<input
type="color"
value={props.textColor || '#3f3f46'}
onChange={(e) => setProp((p: NavbarProps) => { p.textColor = e.target.value; })}
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
title="Custom color"
/>
</div>
</div>
{/* Link hover color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Hover Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="color"
value={props.hoverColor || '#3b82f6'}
onChange={(e) => setProp((p: NavbarProps) => { p.hoverColor = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<span style={{ fontSize: 10, color: '#71717a' }}>{props.hoverColor || '#3b82f6'}</span>
</div>
</div>
{/* CTA button colors */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>CTA Button</label>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
<input
type="color"
value={props.ctaColor || '#3b82f6'}
onChange={(e) => setProp((p: NavbarProps) => { p.ctaColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
<input
type="color"
value={props.ctaTextColor || '#ffffff'}
onChange={(e) => setProp((p: NavbarProps) => { p.ctaTextColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
</div>
</div>
{/* Padding presets */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Padding</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{PADDING_PRESETS.map((p) => (
<button
key={p.label}
onClick={() => setProp((pr: NavbarProps) => { pr.padding = p.value; })}
style={props.padding === p.value ? btnActive : btnSmall}
>
{p.label}
</button>
))}
</div>
</div>
{/* Alignment */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right', 'space-between'] as const).map((a) => (
<button
key={a}
onClick={() => setProp((p: NavbarProps) => { p.navAlignment = a; })}
style={props.navAlignment === a || (!props.navAlignment && a === 'space-between') ? btnActive : btnSmall}
>
{a === 'space-between' ? 'Spread' : a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
</div>
{/* Sticky toggle */}
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!props.isSticky}
onChange={(e) => setProp((p: NavbarProps) => { p.isSticky = e.target.checked; })}
/>
Sticky
</label>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!props.showMobileMenu}
onChange={(e) => setProp((p: NavbarProps) => { p.showMobileMenu = e.target.checked; })}
/>
Mobile Menu
</label>
</div>
{/* Design token quick apply */}
<div>
<label style={labelStyle}>Apply Design Token</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: NavbarProps) => {
p.backgroundColor = '#ffffff';
p.textColor = design.textColor;
p.hoverColor = design.primaryColor;
p.ctaColor = design.primaryColor;
p.ctaTextColor = '#ffffff';
})}
style={btnSmall}
>
<i className="fa fa-sun-o" style={{ marginRight: 3 }} />Light
</button>
<button
onClick={() => setProp((p: NavbarProps) => {
p.backgroundColor = '#0f172a';
p.textColor = '#e4e4e7';
p.hoverColor = design.primaryColor;
p.ctaColor = design.primaryColor;
p.ctaTextColor = '#ffffff';
p.logoColor = '#ffffff';
})}
style={btnSmall}
>
<i className="fa fa-moon-o" style={{ marginRight: 3 }} />Dark
</button>
</div>
</div>
</div>
{/* ===== Links Section ===== */}
<div>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => (
<div
key={i}
draggable
onDragStart={() => setDragIdx(i)}
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
onDragEnd={() => {
if (dragIdx !== null && dragOverIdx !== null) {
moveLink(dragIdx, dragOverIdx);
}
setDragIdx(null);
setDragOverIdx(null);
}}
style={{
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
borderRadius: 6,
padding: 8,
display: 'flex',
flexDirection: 'column',
gap: 4,
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
transition: 'background 0.1s, border-color 0.1s',
}}
>
{/* Row 1: drag handle + text + delete */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
title="Drag to reorder"
>
<i className="fa fa-bars" />
</span>
<input
type="text"
value={link.text}
onChange={(e) => updateLink(i, 'text', e.target.value)}
placeholder="Text"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeLink(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
title="Delete link"
>
<i className="fa fa-trash" />
</button>
</div>
{/* Row 2: URL */}
<input
type="text"
value={link.href}
onChange={(e) => updateLink(i, 'href', e.target.value)}
placeholder="URL (e.g. /about or https://...)"
style={inputStyle}
/>
{/* Row 3: checkboxes */}
<div style={{ display: 'flex', gap: 8 }}>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
External
</label>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
CTA
</label>
</div>
</div>
))}
</div>
{/* Add link button */}
<button
onClick={() => addLink()}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Link
</button>
{/* Add page dropdown */}
<select
onChange={(e) => {
const page = pages.find(p => p.id === e.target.value);
if (page) {
addLink({
text: page.name,
href: page.slug === 'index' ? '/' : page.slug,
isExternal: false,
isCta: false,
});
}
e.target.value = '';
}}
value=""
style={{
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
background: '#1e293b', color: '#93c5fd',
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
}}
>
<option value="">+ Add Page...</option>
{pages.map(p => (
<option key={p.id} value={p.id}>
{p.name} ({p.slug === 'index' ? '/' : p.slug})
</option>
))}
</select>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Navbar.craft = {
displayName: 'Navbar',
props: {
logoType: 'text',
logoText: 'MySite',
logoImage: '',
logoWidth: '120px',
logoUrl: '/',
logoFontFamily: 'Inter, sans-serif',
logoFontSize: '20px',
logoColor: undefined,
links: defaultLinks,
backgroundColor: '#ffffff',
textColor: '#3f3f46',
hoverColor: '#3b82f6',
ctaColor: '#3b82f6',
ctaTextColor: '#ffffff',
padding: '16px 24px',
navAlignment: 'space-between',
isSticky: false,
showMobileMenu: false,
style: {
borderBottom: '1px solid #e4e4e7',
},
} as NavbarProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: NavbarSettings,
},
};
/* ---------- HTML export ---------- */
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => {
const bgColor = props.backgroundColor || '#ffffff';
const textCol = props.textColor || '#3f3f46';
const hoverCol = props.hoverColor || '#3b82f6';
const ctaCol = props.ctaColor || '#3b82f6';
const ctaTextCol = props.ctaTextColor || '#ffffff';
const pad = props.padding || '16px 24px';
const alignment = props.navAlignment || 'space-between';
const sticky = props.isSticky;
const mobile = props.showMobileMenu;
const logoUrl = props.logoUrl || '/';
const navStyle = cssPropsToString({
display: 'flex',
alignItems: 'center',
justifyContent: alignment,
padding: pad,
backgroundColor: bgColor,
...(sticky ? { position: 'sticky', top: '0', zIndex: '1000' } : {}),
...props.style,
});
// Logo HTML
let logoHtml: string;
if (props.logoType === 'image' && props.logoImage) {
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
logoHtml = `<a href="${esc(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${esc(props.logoImage)}" alt="${esc(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
} else {
const logoStyle = cssPropsToString({
fontWeight: '700',
fontSize: props.logoFontSize || '20px',
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
color: props.logoColor || textCol,
});
logoHtml = `<a href="${esc(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${esc(props.logoText || 'MySite')}</span></a>`;
}
// Links HTML
const links = props.links || defaultLinks;
const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaTextCol : textCol,
backgroundColor: link.isCta ? ctaCol : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${esc(link.href)}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
}).join('\n ');
// Hamburger HTML for mobile
const hamburgerHtml = mobile
? `\n <button class="navbar-hamburger" onclick="this.parentElement.querySelector('.navbar-links').classList.toggle('navbar-open')" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
</button>`
: '';
// Hover CSS
const hoverCss = `<style>
.navbar-link:hover { color: ${hoverCol} !important; }
.navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
@media (max-width: 768px) {
.navbar-hamburger { display: flex !important; }
.navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.navbar-links.navbar-open { display: flex !important; }
}` : ''}
</style>`;
// Add CSS class to each link for hover
const linksHtmlWithClass = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaTextCol : textCol,
backgroundColor: link.isCta ? ctaCol : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
}).join('\n ');
return {
html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${logoHtml}${hamburgerHtml}
<div class="navbar-links" style="display:flex;align-items:center;gap:24px">
${linksHtmlWithClass}
</div>
</nav>`,
};
};

View File

@@ -0,0 +1,204 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface SearchBarProps {
placeholder?: string;
buttonText?: string;
showButton?: boolean;
style?: CSSProperties;
}
export const SearchBar: UserComponent<SearchBarProps> = ({
placeholder = 'Search...',
buttonText = 'Search',
showButton = true,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<form
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
role="search"
onSubmit={(e) => e.preventDefault()}
style={{
display: 'flex',
alignItems: 'center',
maxWidth: '560px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ position: 'relative', flex: 1 }}>
<i
className="fa fa-search"
style={{
position: 'absolute',
left: '14px',
top: '50%',
transform: 'translateY(-50%)',
color: '#9ca3af',
fontSize: '14px',
pointerEvents: 'none',
}}
/>
<input
type="search"
placeholder={placeholder}
style={{
width: '100%',
padding: '12px 16px 12px 40px',
fontSize: '15px',
fontFamily: 'Inter, sans-serif',
border: '1px solid #d1d5db',
borderRadius: showButton ? '8px 0 0 8px' : '8px',
backgroundColor: '#ffffff',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
{showButton && (
<button
type="submit"
style={{
padding: '12px 20px',
fontSize: '15px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
backgroundColor: '#3b82f6',
border: 'none',
borderRadius: '0 8px 8px 0',
cursor: 'pointer',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<i className="fa fa-search" style={{ fontSize: '13px' }} />
{buttonText}
</button>
)}
</form>
);
};
/* ---------- Settings panel ---------- */
const SearchBarSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SearchBarProps,
}));
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,
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Placeholder */}
<div>
<label style={labelStyle}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: SearchBarProps) => { p.placeholder = e.target.value; })}
placeholder="Search..."
style={inputStyle}
/>
</div>
{/* Show Button */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.showButton !== false}
onChange={(e) => setProp((p: SearchBarProps) => { p.showButton = e.target.checked; })}
/>
Show Button
</label>
</div>
{/* Button Text */}
{props.showButton !== false && (
<div>
<label style={labelStyle}>Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: SearchBarProps) => { p.buttonText = e.target.value; })}
placeholder="Search"
style={inputStyle}
/>
</div>
)}
</div>
);
};
/* ---------- Craft config ---------- */
SearchBar.craft = {
displayName: 'Search Bar',
props: {
placeholder: 'Search...',
buttonText: 'Search',
showButton: true,
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SearchBarSettings,
},
};
/* ---------- HTML export ---------- */
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
placeholder = 'Search...',
buttonText = 'Search',
showButton = true,
style = {},
} = props;
const formStyle = cssPropsToString({
display: 'flex',
alignItems: 'center',
maxWidth: '560px',
...style,
});
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
const btnHtml = showButton
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px"></i>${esc(buttonText)}</button>`
: '';
return {
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
<div style="position:relative;flex:1">
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none"></i>
<input type="search" placeholder="${esc(placeholder)}" style="${inputStyleStr}" />
</div>
${btnHtml}
</form>`,
};
};

View File

@@ -0,0 +1,444 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface SocialLink {
platform: string;
url: string;
}
interface SocialLinksProps {
links?: SocialLink[];
iconSize?: string;
iconColor?: string;
iconBgColor?: string;
iconShape?: 'none' | 'circle' | 'square' | 'rounded';
gap?: string;
alignment?: 'left' | 'center' | 'right';
style?: CSSProperties;
}
const platformIcons: Record<string, string> = {
facebook: 'fa-facebook',
twitter: 'fa-twitter',
instagram: 'fa-instagram',
linkedin: 'fa-linkedin',
youtube: 'fa-youtube',
github: 'fa-github',
tiktok: 'fa-music',
pinterest: 'fa-pinterest',
snapchat: 'fa-snapchat',
whatsapp: 'fa-whatsapp',
};
const platformLabels: Record<string, string> = {
facebook: 'Facebook',
twitter: 'Twitter / X',
instagram: 'Instagram',
linkedin: 'LinkedIn',
youtube: 'YouTube',
github: 'GitHub',
tiktok: 'TikTok',
pinterest: 'Pinterest',
snapchat: 'Snapchat',
whatsapp: 'WhatsApp',
};
const allPlatforms = Object.keys(platformIcons);
const defaultLinks: SocialLink[] = [
{ platform: 'facebook', url: '#' },
{ platform: 'twitter', url: '#' },
{ platform: 'instagram', url: '#' },
{ platform: 'linkedin', url: '#' },
];
const getShapeStyle = (shape: string, size: string): CSSProperties => {
if (shape === 'none') return {};
const numSize = parseInt(size) || 24;
const boxSize = `${numSize + 16}px`;
const base: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: boxSize,
height: boxSize,
};
if (shape === 'circle') return { ...base, borderRadius: '50%' };
if (shape === 'square') return { ...base, borderRadius: '0' };
if (shape === 'rounded') return { ...base, borderRadius: '6px' };
return base;
};
const alignMap: Record<string, string> = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
};
export const SocialLinks: UserComponent<SocialLinksProps> = ({
links = defaultLinks,
iconSize = '20px',
iconColor = '#ffffff',
iconBgColor = '#374151',
iconShape = 'circle',
gap = '10px',
alignment = 'center',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexWrap: 'wrap',
gap,
justifyContent: alignMap[alignment] || 'center',
alignItems: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{links.map((link, i) => {
const iconClass = platformIcons[link.platform] || 'fa-link';
const shapeStyle = getShapeStyle(iconShape, iconSize);
const hasBg = iconShape !== 'none';
return (
<a
key={i}
href={link.url || '#'}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.preventDefault()}
title={platformLabels[link.platform] || link.platform}
style={{
textDecoration: 'none',
color: iconColor,
backgroundColor: hasBg ? iconBgColor : 'transparent',
transition: 'opacity 0.2s',
...shapeStyle,
}}
>
<i className={`fa ${iconClass}`} style={{ fontSize: iconSize }} />
</a>
);
})}
</div>
);
};
/* ---------- Settings panel ---------- */
const SocialLinksSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SocialLinksProps,
}));
const links = props.links || defaultLinks;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateLink = (index: number, field: keyof SocialLink, value: string) => {
setProp((p: SocialLinksProps) => {
const updated = [...(p.links || defaultLinks)];
updated[index] = { ...updated[index], [field]: value };
p.links = updated;
});
};
const addLink = (platform: string) => {
setProp((p: SocialLinksProps) => {
p.links = [...(p.links || defaultLinks), { platform, url: '#' }];
});
};
const removeLink = (index: number) => {
setProp((p: SocialLinksProps) => {
const updated = [...(p.links || defaultLinks)];
updated.splice(index, 1);
p.links = updated;
});
};
const usedPlatforms = new Set(links.map((l) => l.platform));
const availablePlatforms = allPlatforms.filter((p) => !usedPlatforms.has(p));
const sizePresets = ['14px', '18px', '20px', '24px', '28px', '32px'];
const gapPresets = ['4px', '8px', '10px', '14px', '20px'];
const iconColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#a1a1aa'];
const bgColorPresets = ['#374151', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#0ea5e9', 'transparent'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Links Editor */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Social Links</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{links.map((link, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 14, width: 20, textAlign: 'center', flex: 'none' }}>
<i className={`fa ${platformIcons[link.platform] || 'fa-link'}`} style={{ color: '#a1a1aa' }} />
</span>
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1 }}>
{platformLabels[link.platform] || link.platform}
</span>
<button
onClick={() => removeLink(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
<input
type="text"
value={link.url}
onChange={(e) => updateLink(i, 'url', e.target.value)}
placeholder="https://..."
style={inputStyle}
/>
</div>
))}
</div>
{availablePlatforms.length > 0 && (
<div style={{ marginTop: 6 }}>
<select
onChange={(e) => {
if (e.target.value) {
addLink(e.target.value);
e.target.value = '';
}
}}
defaultValue=""
style={{ ...inputStyle, width: '100%', padding: '6px', cursor: 'pointer' }}
>
<option value="">+ Add Platform...</option>
{availablePlatforms.map((p) => (
<option key={p} value={p}>{platformLabels[p]}</option>
))}
</select>
</div>
)}
</div>
{/* Alignment */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button
key={a}
onClick={() => setProp((p: SocialLinksProps) => { p.alignment = a; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.alignment === a ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{a}
</button>
))}
</div>
</div>
{/* Icon Shape */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Shape</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(['none', 'circle', 'square', 'rounded'] as const).map((s) => (
<button
key={s}
onClick={() => setProp((p: SocialLinksProps) => { p.iconShape = s; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.iconShape === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Icon Size */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: SocialLinksProps) => { p.iconSize = s; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.iconSize === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Icon Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{iconColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SocialLinksProps) => { p.iconColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.iconColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Icon Background Color */}
{props.iconShape !== 'none' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SocialLinksProps) => { p.iconBgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4,
border: c === 'transparent' ? '2px dashed #3f3f46' : '1px solid #3f3f46',
backgroundColor: c === 'transparent' ? undefined : c,
cursor: 'pointer',
outline: props.iconBgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{/* Gap */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gap</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gapPresets.map((g) => (
<button
key={g}
onClick={() => setProp((p: SocialLinksProps) => { p.gap = g; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.gap === g ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{g}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
SocialLinks.craft = {
displayName: 'Social Links',
props: {
links: defaultLinks,
iconSize: '20px',
iconColor: '#ffffff',
iconBgColor: '#374151',
iconShape: 'circle',
gap: '10px',
alignment: 'center',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SocialLinksSettings,
},
};
/* ---------- HTML export ---------- */
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
const links = props.links || defaultLinks;
const iconSize = props.iconSize || '20px';
const iconColor = props.iconColor || '#ffffff';
const iconBgColor = props.iconBgColor || '#374151';
const iconShape = props.iconShape || 'circle';
const gap = props.gap || '10px';
const alignment = props.alignment || 'center';
const hasBg = iconShape !== 'none';
const wrapperStyle = cssPropsToString({
display: 'flex',
flexWrap: 'wrap',
gap,
justifyContent: alignMap[alignment] || 'center',
alignItems: 'center',
...props.style,
});
const numSize = parseInt(iconSize) || 20;
const boxSize = `${numSize + 16}px`;
const getShapeStr = (): string => {
const parts: string[] = [
`display:inline-flex`,
`align-items:center`,
`justify-content:center`,
`width:${boxSize}`,
`height:${boxSize}`,
];
if (iconShape === 'circle') parts.push('border-radius:50%');
else if (iconShape === 'square') parts.push('border-radius:0');
else if (iconShape === 'rounded') parts.push('border-radius:6px');
return parts.join(';');
};
const linksHtml = links.map((link) => {
const iconClass = platformIcons[link.platform] || 'fa-link';
const title = platformLabels[link.platform] || link.platform;
let aStyle = `text-decoration:none;color:${iconColor};background-color:${hasBg ? iconBgColor : 'transparent'}`;
if (hasBg) {
aStyle += `;${getShapeStr()}`;
}
return `<a href="${link.url || '#'}" target="_blank" rel="noopener noreferrer" title="${title}" style="${aStyle}"><i class="fa ${iconClass}" style="font-size:${iconSize}"></i></a>`;
}).join('\n ');
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
${linksHtml}
</div>`,
};
};

View File

@@ -0,0 +1,107 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface SpacerProps {
height?: string;
style?: CSSProperties;
}
export const Spacer: UserComponent<SpacerProps> = ({
height = '40px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
height,
outline: selected ? '2px dashed #3b82f6' : 'none',
...style,
...(selected && !style.backgroundColor && !style.background
? { background: 'rgba(59,130,246,0.05)' }
: {}),
}}
/>
);
};
/* ---------- Settings panel ---------- */
const SpacerSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SpacerProps,
}));
const heightPresets = ['20px', '40px', '60px', '80px', '120px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Height</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{heightPresets.map((h) => (
<button
key={h}
onClick={() => setProp((p: SpacerProps) => { p.height = h; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.height === h ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{h}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Custom Height</label>
<input
type="text"
value={props.height || ''}
onChange={(e) => setProp((p: SpacerProps) => { p.height = e.target.value; })}
placeholder="e.g. 50px, 5rem"
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Spacer.craft = {
displayName: 'Spacer',
props: {
height: '40px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SpacerSettings,
},
};
/* ---------- HTML export ---------- */
(Spacer as any).toHtml = (props: SpacerProps, _childrenHtml: string) => {
const styleStr = cssPropsToString({
height: props.height || '40px',
...props.style,
});
return { html: `<div${styleStr ? ` style="${styleStr}"` : ''}></div>` };
};

View File

@@ -0,0 +1,230 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface StarRatingProps {
rating?: number;
maxStars?: number;
size?: string;
filledColor?: string;
emptyColor?: string;
style?: CSSProperties;
}
export const StarRating: UserComponent<StarRatingProps> = ({
rating = 4.5,
maxStars = 5,
size = '24px',
filledColor = '#f59e0b',
emptyColor = '#d1d5db',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const stars: React.ReactNode[] = [];
for (let i = 1; i <= maxStars; i++) {
if (i <= Math.floor(rating)) {
// Full star
stars.push(
<i
key={i}
className="fa fa-star"
style={{ color: filledColor, fontSize: size }}
/>
);
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
// Half star
stars.push(
<span key={i} style={{ position: 'relative', display: 'inline-block', fontSize: size }}>
<i className="fa fa-star" style={{ color: emptyColor }} />
<span style={{ position: 'absolute', left: 0, top: 0, overflow: 'hidden', width: '50%' }}>
<i className="fa fa-star" style={{ color: filledColor }} />
</span>
</span>
);
} else {
// Empty star
stars.push(
<i
key={i}
className="fa fa-star"
style={{ color: emptyColor, fontSize: size }}
/>
);
}
}
return (
<span
ref={(ref: HTMLSpanElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{stars}
</span>
);
};
/* ---------- Settings panel ---------- */
const StarRatingSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as StarRatingProps,
}));
const sizePresets = ['16px', '20px', '24px', '32px', '40px'];
const filledColorPresets = ['#f59e0b', '#eab308', '#f97316', '#ef4444', '#ec4899', '#3b82f6', '#10b981', '#18181b'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Rating: {props.rating ?? 4.5}
</label>
<input
type="range"
min={0}
max={props.maxStars || 5}
step={0.5}
value={props.rating ?? 4.5}
onChange={(e) => setProp((p: StarRatingProps) => { p.rating = parseFloat(e.target.value); })}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Max Stars</label>
<div style={{ display: 'flex', gap: 4 }}>
{[3, 4, 5, 6, 7, 10].map((n) => (
<button
key={n}
onClick={() => setProp((p: StarRatingProps) => {
p.maxStars = n;
if ((p.rating || 0) > n) p.rating = n;
})}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.maxStars === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Star Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: StarRatingProps) => { p.size = s; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.size === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Filled Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{filledColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: StarRatingProps) => { p.filledColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.filledColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Empty Color</label>
<input
type="color"
value={props.emptyColor || '#d1d5db'}
onChange={(e) => setProp((p: StarRatingProps) => { p.emptyColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
StarRating.craft = {
displayName: 'Star Rating',
props: {
rating: 4.5,
maxStars: 5,
size: '24px',
filledColor: '#f59e0b',
emptyColor: '#d1d5db',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: StarRatingSettings,
},
};
/* ---------- HTML export ---------- */
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
const rating = props.rating ?? 4.5;
const maxStars = props.maxStars || 5;
const size = props.size || '24px';
const filledColor = props.filledColor || '#f59e0b';
const emptyColor = props.emptyColor || '#d1d5db';
const wrapperStyle = cssPropsToString({
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
...props.style,
});
let starsHtml = '';
for (let i = 1; i <= maxStars; i++) {
if (i <= Math.floor(rating)) {
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}"></i>`;
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
} else {
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}"></i>`;
}
}
return {
html: `<span${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
};
};

View File

@@ -0,0 +1,158 @@
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';
interface TextBlockProps {
text?: string;
style?: CSSProperties;
cssId?: string;
cssClass?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
export const TextBlock: UserComponent<TextBlockProps> = ({
text = 'Start typing here...',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
actions: { setProp },
} = useNode((node) => ({
selected: node.events.selected,
}));
const elRef = useRef<HTMLParagraphElement | null>(null);
const editedTextRef = useRef<string | null>(null);
const commitText = useCallback(() => {
if (elRef.current) {
const newText = elRef.current.innerText;
editedTextRef.current = newText;
setProp((p: TextBlockProps) => { p.text = newText; });
}
}, [setProp]);
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
useEffect(() => {
if (!selected && editedTextRef.current !== null) {
setProp((p: TextBlockProps) => { p.text = editedTextRef.current!; });
editedTextRef.current = null;
}
}, [selected, setProp]);
useEffect(() => {
if (elRef.current && !selected && editedTextRef.current === null) {
elRef.current.innerText = text || '';
}
}, [text, selected]);
return (
<p
ref={(ref: HTMLParagraphElement | null) => {
elRef.current = ref;
if (ref) connect(drag(ref));
}}
contentEditable={selected}
suppressContentEditableWarning
onBlur={handleBlur}
onInput={() => { if (elRef.current) editedTextRef.current = elRef.current.innerText; }}
style={{
outline: 'none',
cursor: selected ? 'text' : 'pointer',
minHeight: '1em',
...style,
}}
/>
);
};
/* ---------- Settings panel ---------- */
const TextBlockSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TextBlockProps,
}));
return (
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text Content</label>
<textarea
value={props.text || ''}
onChange={(e) => setProp((p: TextBlockProps) => { p.text = e.target.value; })}
rows={4}
style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
/>
</div>
</div>
}
style={
<TypographyControl
style={props.style || {}}
onChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
/>
}
advanced={
<AdvancedTab
style={props.style || {}}
onStyleChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
cssId={props.cssId || ''}
onCssIdChange={(id) => setProp((p: TextBlockProps) => { p.cssId = id; })}
cssClass={props.cssClass || ''}
onCssClassChange={(cls) => setProp((p: TextBlockProps) => { p.cssClass = cls; })}
hideOnDesktop={props.hideOnDesktop}
onHideOnDesktopChange={(v) => setProp((p: TextBlockProps) => { p.hideOnDesktop = v; })}
hideOnTablet={props.hideOnTablet}
onHideOnTabletChange={(v) => setProp((p: TextBlockProps) => { p.hideOnTablet = v; })}
hideOnMobile={props.hideOnMobile}
onHideOnMobileChange={(v) => setProp((p: TextBlockProps) => { p.hideOnMobile = v; })}
animation={props.animation}
onAnimationChange={(v) => setProp((p: TextBlockProps) => { p.animation = v; })}
animationDelay={props.animationDelay}
onAnimationDelayChange={(v) => setProp((p: TextBlockProps) => { p.animationDelay = v; })}
/>
}
/>
);
};
/* ---------- Craft config ---------- */
TextBlock.craft = {
displayName: 'Text',
props: {
text: 'Start typing here...',
style: {
fontSize: '16px',
lineHeight: '1.6',
color: '#3f3f46',
},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TextBlockSettings,
},
};
/* ---------- HTML export ---------- */
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
const styleStr = cssPropsToString(props.style);
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
};

View File

@@ -0,0 +1,423 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
label: string;
name: string;
placeholder: string;
required: boolean;
options?: string[];
}
interface ContactFormProps {
fields?: ContactFormField[];
submitText?: string;
submitColor?: string;
formAction?: string;
successMessage?: string;
style?: CSSProperties;
labelColor?: string;
inputBg?: string;
inputBorder?: string;
}
const defaultFields: ContactFormField[] = [
{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true },
{ type: 'email', label: 'Email', name: 'email', placeholder: 'your@email.com', required: true },
{ type: 'tel', label: 'Phone', name: 'phone', placeholder: '(555) 123-4567', required: false },
{ type: 'textarea', label: 'Message', name: 'message', placeholder: 'How can we help you?', required: true },
];
export const ContactForm: UserComponent<ContactFormProps> = ({
fields = defaultFields,
submitText = 'Send Message',
submitColor = '#3b82f6',
formAction = '#',
successMessage = 'Thank you! We\'ll get back to you soon.',
style = {},
labelColor = '#374151',
inputBg = '#ffffff',
inputBorder = '#d1d5db',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const inputBaseStyle: CSSProperties = {
width: '100%',
padding: '10px 14px',
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
border: `1px solid ${inputBorder}`,
borderRadius: '6px',
backgroundColor: inputBg,
color: '#1f2937',
boxSizing: 'border-box',
outline: 'none',
};
return (
<form
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
action={formAction}
method="POST"
onSubmit={(e) => e.preventDefault()}
style={{
padding: '32px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{fields.map((field, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label style={{ fontSize: '14px', fontWeight: '500', color: labelColor }}>
{field.label}
{field.required && <span style={{ color: '#ef4444', marginLeft: '2px' }}>*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
name={field.name}
placeholder={field.placeholder}
required={field.required}
rows={4}
style={{ ...inputBaseStyle, resize: 'vertical' }}
/>
) : field.type === 'select' ? (
<select
name={field.name}
required={field.required}
style={{ ...inputBaseStyle, cursor: 'pointer' }}
>
<option value="">{field.placeholder || 'Select...'}</option>
{(field.options || []).map((opt, j) => (
<option key={j} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type={field.type}
name={field.name}
placeholder={field.placeholder}
required={field.required}
style={inputBaseStyle}
/>
)}
</div>
))}
<button
type="submit"
style={{
padding: '12px 32px',
fontSize: '16px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
backgroundColor: submitColor,
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
alignSelf: 'flex-start',
}}
>
{submitText}
</button>
</form>
);
};
/* ---------- Settings panel ---------- */
const fieldTypes: ContactFormField['type'][] = ['text', 'email', 'tel', 'textarea', 'select'];
const ContactFormSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ContactFormProps,
}));
const fields = props.fields || defaultFields;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateField = (index: number, key: keyof ContactFormField, value: any) => {
setProp((p: ContactFormProps) => {
const updated = [...(p.fields || defaultFields)];
updated[index] = { ...updated[index], [key]: value };
p.fields = updated;
});
};
const addField = () => {
setProp((p: ContactFormProps) => {
p.fields = [...(p.fields || defaultFields), { type: 'text', label: 'New Field', name: 'new_field', placeholder: '', required: false }];
});
};
const removeField = (index: number) => {
setProp((p: ContactFormProps) => {
const updated = [...(p.fields || defaultFields)];
updated.splice(index, 1);
p.fields = updated;
});
};
const submitColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Form Action */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
<input
type="text"
value={props.formAction || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.formAction = e.target.value; })}
placeholder="https://... or /api/submit"
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
/>
</div>
{/* Success Message */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Success Message</label>
<input
type="text"
value={props.successMessage || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.successMessage = e.target.value; })}
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
/>
</div>
{/* Submit Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Text</label>
<input
type="text"
value={props.submitText || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.submitText = e.target.value; })}
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{submitColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: ContactFormProps) => { p.submitColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.submitColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Label Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label Color</label>
<input
type="color"
value={props.labelColor || '#374151'}
onChange={(e) => setProp((p: ContactFormProps) => { p.labelColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
{/* Input Background */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Background</label>
<input
type="color"
value={props.inputBg || '#ffffff'}
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBg = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
{/* Input Border */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Border Color</label>
<input
type="color"
value={props.inputBorder || '#d1d5db'}
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBorder = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
{/* Fields Editor */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Fields</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fields.map((field, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<select
value={field.type}
onChange={(e) => updateField(i, 'type', e.target.value)}
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
>
{fieldTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<input
type="text"
value={field.label}
onChange={(e) => updateField(i, 'label', e.target.value)}
placeholder="Label"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeField(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
value={field.name}
onChange={(e) => updateField(i, 'name', e.target.value)}
placeholder="name attr"
style={{ ...inputStyle, flex: 1 }}
/>
<input
type="text"
value={field.placeholder}
onChange={(e) => updateField(i, 'placeholder', e.target.value)}
placeholder="Placeholder"
style={{ ...inputStyle, flex: 1 }}
/>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(i, 'required', e.target.checked)}
/>
Required
</label>
</div>
{field.type === 'select' && (
<div>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Options (one per line)</label>
<textarea
value={(field.options || []).join('\n')}
onChange={(e) => updateField(i, 'options', e.target.value.split('\n').filter((s: string) => s.trim()))}
rows={3}
placeholder="Option 1&#10;Option 2&#10;Option 3"
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
)}
</div>
))}
</div>
<button
onClick={addField}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Field
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
ContactForm.craft = {
displayName: 'Contact Form',
props: {
fields: defaultFields,
submitText: 'Send Message',
submitColor: '#3b82f6',
formAction: '#',
successMessage: 'Thank you! We\'ll get back to you soon.',
style: {
backgroundColor: '#ffffff',
borderRadius: '12px',
border: '1px solid #e5e7eb',
},
labelColor: '#374151',
inputBg: '#ffffff',
inputBorder: '#d1d5db',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ContactFormSettings,
},
};
/* ---------- HTML export ---------- */
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const formStyle = cssPropsToString({
padding: '32px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
...props.style,
});
const labelColor = props.labelColor || '#374151';
const inputBg = props.inputBg || '#ffffff';
const inputBorder = props.inputBorder || '#d1d5db';
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
const fieldsHtml = (props.fields || defaultFields).map((field) => {
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
const labelHtml = `<label style="font-size:14px;font-weight:500;color:${labelColor}">${esc(field.label)}${reqStar}</label>`;
const reqAttr = field.required ? ' required' : '';
let inputHtml = '';
if (field.type === 'textarea') {
inputHtml = `<textarea name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
} else if (field.type === 'select') {
const opts = (field.options || []).map((o) => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
inputHtml = `<select name="${esc(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${esc(field.placeholder || 'Select...')}</option>${opts}</select>`;
} else {
inputHtml = `<input type="${field.type}" name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
}
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
}).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: `<form action="${esc(props.formAction || '#')}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
${fieldsHtml}
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
</form>`,
};
};

View File

@@ -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<FormButtonProps> = ({
text = 'Submit',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<button
ref={(ref: HTMLButtonElement | null): void => { if (ref) connect(drag(ref)); }}
type="submit"
onClick={(e) => e.preventDefault()}
style={{
padding: '12px 32px',
backgroundColor: '#3b82f6',
color: '#ffffff',
border: 'none',
borderRadius: '6px',
fontSize: '16px',
fontWeight: '600',
cursor: 'pointer',
outline: selected ? '2px solid #3b82f6' : 'none',
outlineOffset: selected ? '2px' : '0',
...style,
}}
>
{text}
</button>
);
};
/* ---------- 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((preset) => (
<button
key={preset.label}
onClick={() => setProp((p: FormButtonProps) => {
p.style = { ...p.style, backgroundColor: preset.bg, color: preset.color };
})}
title={preset.label}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: preset.bg, cursor: 'pointer',
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{radiusPresets.map((r) => (
<button
key={r}
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, borderRadius: r }; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{r}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Width</label>
<div style={{ display: 'flex', gap: 4 }}>
{widthPresets.map((w) => (
<button
key={w}
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, width: w }; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.width === w ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{w === 'auto' ? 'Auto' : 'Full Width'}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- 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, '&lt;').replace(/>/g, '&gt;');
return {
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
};
};

View File

@@ -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<FormContainerProps> = ({
action = '#',
method = 'POST',
style = {},
}) => {
const { connectors: { connect, drag } } = useNode();
return (
<form
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
action={action}
method={method}
onSubmit={(e) => e.preventDefault()}
style={{
padding: '24px',
minHeight: '80px',
...style,
}}
>
<Element
id="form-inner"
is={Container}
canvas
style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '0' }}
tag="div"
/>
</form>
);
};
/* ---------- 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
<input
type="text"
value={props.action || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Method</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['GET', 'POST'] as const).map((m) => (
<button
key={m}
onClick={() => setProp((p: FormContainerProps) => { p.method = m; })}
style={{
padding: '4px 12px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.method === m ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{m}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FormContainerProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- 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: `<form action="${props.action || '#'}" method="${props.method || 'POST'}"${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</form>`,
};
};

View File

@@ -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<InputFieldProps> = ({
label = 'Label',
type = 'text',
name = 'field',
placeholder = '',
required = false,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
outline: selected ? '2px solid #3b82f6' : 'none',
borderRadius: '4px',
...style,
}}
>
{label && (
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
</label>
)}
<input
type={type}
name={name}
placeholder={placeholder}
required={required}
style={{
padding: '10px 12px',
border: '1px solid #d4d4d8',
borderRadius: '6px',
fontSize: '14px',
color: '#18181b',
backgroundColor: '#ffffff',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
}}
readOnly
/>
</div>
);
};
/* ---------- 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
<input
type="text"
value={props.label || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Type</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{typeOptions.map((t) => (
<button
key={t}
onClick={() => setProp((p: InputFieldProps) => { p.type = t; })}
style={{
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.type === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{t}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
<input
type="text"
value={props.name || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={!!props.required}
onChange={(e) => setProp((p: InputFieldProps) => { p.required = e.target.checked; })}
/>
Required
</label>
</div>
</div>
);
};
/* ---------- 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, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({
display: 'flex',
flexDirection: 'column',
gap: '4px',
...props.style,
});
const reqAttr = props.required ? ' required' : '';
const labelHtml = props.label
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
: '';
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml}
<input type="${props.type || 'text'}" name="${esc(props.name || 'field')}" placeholder="${esc(props.placeholder || '')}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
</div>`,
};
};

View File

@@ -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<SubscribeFormProps> = ({
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 (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '40px 24px',
textAlign: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{heading && (
<h3 style={{
fontSize: '22px',
fontWeight: '600',
color: '#1f2937',
marginBottom: '20px',
fontFamily: 'Inter, sans-serif',
}}>
{heading}
</h3>
)}
<form
onSubmit={(e) => e.preventDefault()}
style={{
display: 'flex',
flexDirection: isInline ? 'row' : 'column',
gap: isInline ? '0' : '12px',
maxWidth: isInline ? '480px' : '360px',
margin: '0 auto',
alignItems: 'stretch',
}}
>
<input
type="email"
placeholder={placeholder}
style={{
flex: 1,
padding: '12px 16px',
fontSize: '15px',
fontFamily: 'Inter, sans-serif',
border: '1px solid #d1d5db',
borderRadius: isInline ? '8px 0 0 8px' : '8px',
backgroundColor: '#ffffff',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
/>
<button
type="submit"
style={{
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',
}}
>
{buttonText}
</button>
</form>
</div>
);
};
/* ---------- 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Heading */}
<div>
<label style={labelStyle}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: SubscribeFormProps) => { p.heading = e.target.value; })}
placeholder="Subscribe to our newsletter"
style={inputStyle}
/>
</div>
{/* Placeholder */}
<div>
<label style={labelStyle}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: SubscribeFormProps) => { p.placeholder = e.target.value; })}
placeholder="Enter your email"
style={inputStyle}
/>
</div>
{/* Button Text */}
<div>
<label style={labelStyle}>Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: SubscribeFormProps) => { p.buttonText = e.target.value; })}
placeholder="Subscribe"
style={inputStyle}
/>
</div>
{/* Layout */}
<div>
<label style={labelStyle}>Layout</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'inline'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.layout || 'inline') === 'inline' ? '#3b82f6' : '#27272a',
color: (props.layout || 'inline') === 'inline' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Inline
</button>
<button
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'stacked'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'stacked' ? '#3b82f6' : '#27272a',
color: props.layout === 'stacked' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Stacked
</button>
</div>
</div>
{/* Button Color */}
<div>
<label style={labelStyle}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{buttonColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SubscribeFormProps) => { p.buttonColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: (props.buttonColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SubscribeFormProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- 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, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
? `<h3 style="font-size:22px;font-weight:600;color:#1f2937;margin-bottom:20px;font-family:Inter,sans-serif">${esc(heading)}</h3>`
: '';
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: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
${headingHtml}
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
<input type="email" name="email" placeholder="${esc(placeholder)}" required style="${inputStyleStr}" />
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(buttonText)}</button>
</form>
</div>`,
};
};

View File

@@ -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<TextareaFieldProps> = ({
label = 'Message',
name = 'message',
placeholder = '',
rows = 4,
required = false,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
outline: selected ? '2px solid #3b82f6' : 'none',
borderRadius: '4px',
...style,
}}
>
{label && (
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
</label>
)}
<textarea
name={name}
placeholder={placeholder}
rows={rows}
required={required}
readOnly
style={{
padding: '10px 12px',
border: '1px solid #d4d4d8',
borderRadius: '6px',
fontSize: '14px',
color: '#18181b',
backgroundColor: '#ffffff',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
resize: 'vertical',
fontFamily: 'inherit',
}}
/>
</div>
);
};
/* ---------- Settings panel ---------- */
const TextareaFieldSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TextareaFieldProps,
}));
const rowsPresets = [2, 3, 4, 6, 8];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
<input
type="text"
value={props.label || ''}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.label = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
<input
type="text"
value={props.name || ''}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.name = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.placeholder = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Rows</label>
<div style={{ display: 'flex', gap: 4 }}>
{rowsPresets.map((r) => (
<button
key={r}
onClick={() => setProp((p: TextareaFieldProps) => { p.rows = r; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.rows === r ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{r}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={!!props.required}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.required = e.target.checked; })}
/>
Required
</label>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
TextareaField.craft = {
displayName: 'Textarea',
props: {
label: 'Message',
name: 'message',
placeholder: 'Enter your message',
rows: 4,
required: false,
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TextareaFieldSettings,
},
};
/* ---------- HTML export ---------- */
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({
display: 'flex',
flexDirection: 'column',
gap: '4px',
...props.style,
});
const reqAttr = props.required ? ' required' : '';
const labelHtml = props.label
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
: '';
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml}
<textarea name="${esc(props.name || 'message')}" placeholder="${esc(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
</div>`,
};
};

View File

@@ -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<BackgroundSectionProps> = ({
bgImage = '',
bgColor = '#1e293b',
overlayColor = '#000000',
overlayOpacity = 0.4,
innerMaxWidth = '1200px',
style = {},
}) => {
const { connectors: { connect, drag } } = useNode();
return (
<section
ref={(ref: HTMLElement | null): void => { 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 */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: overlayColor,
opacity: overlayOpacity,
pointerEvents: 'none',
}}
/>
{/* Content */}
<Element
id="bg-section-inner"
is={Container}
canvas
style={{
position: 'relative',
zIndex: 1,
maxWidth: innerMaxWidth,
margin: '0 auto',
padding: '60px 20px',
}}
tag="div"
/>
</section>
);
};
/* ---------- 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
<input
type="text"
value={props.bgImage || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: BackgroundSectionProps) => { p.bgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{overlayPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: BackgroundSectionProps) => { p.overlayColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.overlayColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Overlay Opacity: {Math.round((props.overlayOpacity ?? 0.4) * 100)}%
</label>
<input
type="range"
min={0}
max={100}
value={Math.round((props.overlayOpacity ?? 0.4) * 100)}
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.overlayOpacity = parseInt(e.target.value, 10) / 100; })}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
<input
type="text"
value={props.innerMaxWidth || '1200px'}
onChange={(e) => 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 }}
/>
</div>
</div>
);
};
/* ---------- 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: `<section${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
};
};

View File

@@ -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<string, string[]> = {
'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<ColumnLayoutProps> = ({
columns = 2,
split = '50-50',
gap = '16px',
style = {},
}) => {
const { connectors: { connect, drag } } = useNode();
const widths = getWidths(split, columns);
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexWrap: 'wrap',
gap,
width: '100%',
minHeight: '60px',
...style,
}}
>
{widths.map((w, i) => (
<Element
key={`col-${i}`}
id={`col-${i}`}
is={Container}
canvas
custom={{ className: 'craft-column' }}
style={{ flex: `0 0 calc(${w} - ${gap})`, minHeight: '60px', padding: '8px' }}
tag="div"
/>
))}
</div>
);
};
/* ---------- 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Preset layouts */}
<div>
<label style={labelStyle}>Column Layout</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{presetOptions.map((opt) => (
<button
key={opt.label}
onClick={() => {
setProp((p: ColumnLayoutProps) => { p.columns = opt.columns; p.split = opt.split; });
setShowCustom(false);
}}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.split === opt.split && props.columns === opt.columns ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Custom column count (7-10) */}
<div>
<button
onClick={() => setShowCustom(!showCustom)}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: showCustom ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
width: '100%',
}}
>
{showCustom ? 'Hide Custom' : 'Custom (7-10 columns)'}
</button>
{showCustom && (
<div style={{ marginTop: 8 }}>
<label style={labelStyle}>Number of Columns</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="range"
min={1}
max={10}
value={props.columns || 2}
onChange={(e) => {
const cols = parseInt(e.target.value);
setProp((p: ColumnLayoutProps) => { p.columns = cols; p.split = 'equal'; });
}}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 12, color: '#e4e4e7', minWidth: 24, textAlign: 'center' }}>{props.columns || 2}</span>
</div>
</div>
)}
</div>
{/* Gap */}
<div>
<label style={labelStyle}>Gap</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gapPresets.map((g) => (
<button
key={g}
onClick={() => setProp((p: ColumnLayoutProps) => { p.gap = g; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.gap === g ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{g}
</button>
))}
</div>
</div>
{/* Individual Column Widths */}
<div>
<label style={labelStyle}>Column Widths (%)</label>
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 6 }}>
Adjust each column's width. Values should roughly total 100%.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{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 (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 10, color: '#71717a', minWidth: 40 }}>Col {i + 1}</span>
<input
type="range"
min={10}
max={90}
step={5}
value={Math.round(currentPct)}
onChange={(e) => {
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 }}
/>
<span style={{ fontSize: 11, color: '#e4e4e7', minWidth: 35, textAlign: 'right' }}>
{Math.round(currentPct)}%
</span>
</div>
);
})}
</div>
</div>
</div>
);
};
/* ---------- 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: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
};
};

View File

@@ -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<ContainerProps> = ({
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 (
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Tag */}
<div>
<label style={cLabelStyle}>HTML Element</label>
<select
value={props.tag || 'div'}
onChange={(e) => setProp((p: ContainerProps) => { p.tag = e.target.value as ContainerProps['tag']; })}
style={cInputStyle}
>
{['div', 'section', 'article', 'header', 'footer', 'main'].map((t) => (
<option key={t} value={t}>&lt;{t}&gt;</option>
))}
</select>
</div>
{/* Full Width */}
<div>
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500, cursor: 'pointer' }}>
<input
type="checkbox"
checked={props.fullWidth || false}
onChange={(e) => setProp((p: ContainerProps) => { p.fullWidth = e.target.checked; })}
/>
Full Width
</label>
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 2 }}>
Breaks out of parent constraints to fill the viewport width
</span>
</div>
{/* Content Width */}
<div>
<label style={cLabelStyle}>Content Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'full'; })}
style={cToggleBtnStyle((props.contentWidth || 'full') === 'full')}
>
Full
</button>
<button
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'boxed'; })}
style={cToggleBtnStyle(props.contentWidth === 'boxed')}
>
Boxed (1200px)
</button>
</div>
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 4 }}>
{props.contentWidth === 'boxed'
? 'Content is centered with a max-width of 1200px'
: 'Content fills the full container width'}
</span>
</div>
</div>
}
style={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Background Color */}
<div>
<label style={cLabelStyle}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgColors.map((c) => (
<button key={c} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundColor: c, backgroundImage: 'none' }; })}
style={cSwatchStyle(c === 'transparent' ? '#fff' : c, currentBg === c)} title={c} />
))}
</div>
</div>
{/* Background Gradient */}
<div>
<label style={cLabelStyle}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradients.map((g) => (
<button key={g.value} onClick={() => setProp((p: ContainerProps) => {
p.style = { ...p.style, backgroundImage: g.value === 'none' ? 'none' : g.value, backgroundColor: 'transparent' };
})} style={{
width: 32, height: 24, borderRadius: 4, cursor: 'pointer',
border: currentBgImage === g.value ? '2px solid #3b82f6' : '1px solid #3f3f46',
background: g.value === 'none' ? '#27272a' : g.value,
}} title={g.label} />
))}
</div>
</div>
{/* Background Image */}
<div>
<label style={cLabelStyle}>Background Image</label>
<input type="text" placeholder="Image URL..."
value={(props.style?.backgroundImage || '').replace(/^url\(['"]?|['"]?\)$/g, '')}
onChange={(e) => {
const val = e.target.value.trim();
setProp((p: ContainerProps) => {
p.style = { ...p.style, backgroundImage: val ? `url('${val}')` : 'none', backgroundSize: 'cover', backgroundPosition: 'center' };
});
}}
style={cInputStyle} />
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{['cover', 'contain', 'auto'].map((s) => (
<button key={s} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundSize: s }; })}
style={cPresetBtnStyle(props.style?.backgroundSize === s)}>{s}</button>
))}
{['center', 'top', 'bottom'].map((pos) => (
<button key={pos} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundPosition: pos }; })}
style={cPresetBtnStyle(props.style?.backgroundPosition === pos)}>{pos}</button>
))}
</div>
</div>
{/* Overlay */}
<div>
<label style={cLabelStyle}>Overlay Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={props.style?.['--overlayColor' as keyof CSSProperties] || '#000000'}
onChange={(e) => 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' }} />
<span style={{ fontSize: 11, color: '#71717a' }}>Overlay (via CSS custom property)</span>
</div>
</div>
{/* Parallax */}
<div>
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500 }}>
<input type="checkbox"
checked={props.style?.backgroundAttachment === 'fixed'}
onChange={(e) => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundAttachment: e.target.checked ? 'fixed' : 'scroll' }; })} />
Parallax Effect
</label>
</div>
{/* Text Alignment */}
<div>
<label style={cLabelStyle}>Content Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{alignPresets.map((a) => (
<button key={a.value} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, textAlign: a.value as any }; })}
style={{ ...cPresetBtnStyle(props.style?.textAlign === a.value), flex: 1 }}>
<i className={`fa ${a.icon}`} />
</button>
))}
</div>
</div>
{/* Border */}
<BorderControl
style={props.style || {}}
onChange={(updates) => setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })}
/>
</div>
}
advanced={
<AdvancedTab
style={props.style || {}}
onStyleChange={(updates) => 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}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
}
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
};

View File

@@ -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<FooterZoneProps> = ({ style = {}, children }) => {
const { connectors: { connect, drag } } = useNode();
return (
<footer
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
data-zone="footer"
style={{
width: '100%',
minHeight: '50px',
borderTop: '1px solid rgba(148,163,184,0.15)',
...style,
}}
>
<Element id="footer-content" is={Container} canvas tag="div" style={{ padding: '0' }}>
{children}
</Element>
</footer>
);
};
const FooterZoneSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FooterZoneProps,
}));
const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a'];
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ fontSize: 11, color: '#f59e0b', margin: 0 }}>
<strong>Footer Zone</strong> -- This section appears on all pages.
</p>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }}>Background</label>
<div style={{ display: 'flex', gap: 4 }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FooterZoneProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: c, cursor: 'pointer' }}
/>
))}
</div>
</div>
</div>
);
};
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: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</footer>` };
};

View File

@@ -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<HeaderZoneProps> = ({ style = {}, children }) => {
const { connectors: { connect, drag } } = useNode();
return (
<header
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
data-zone="header"
style={{
width: '100%',
minHeight: '50px',
borderBottom: '1px solid rgba(148,163,184,0.15)',
...style,
}}
>
<Element id="header-content" is={Container} canvas tag="div" style={{ padding: '0' }}>
{children}
</Element>
</header>
);
};
const HeaderZoneSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as HeaderZoneProps,
}));
const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a'];
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ fontSize: 11, color: '#f59e0b', margin: 0 }}>
<strong>Header Zone</strong> -- This section appears on all pages.
</p>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }}>Background</label>
<div style={{ display: 'flex', gap: 4 }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: HeaderZoneProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: c, cursor: 'pointer' }}
/>
))}
</div>
</div>
</div>
);
};
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: `<header${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</header>` };
};

View File

@@ -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<Exclude<DividerShape, 'none'>, 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 (
<div
style={{
position: 'absolute',
[position]: 0,
left: 0,
right: 0,
height: height || '50px',
overflow: 'hidden',
lineHeight: 0,
pointerEvents: 'none',
}}
>
<svg
viewBox="0 0 1200 120"
preserveAspectRatio="none"
style={{
width: '100%',
height: '100%',
fill: color || '#ffffff',
transform: isTop ? 'rotate(180deg)' : undefined,
display: 'block',
}}
>
<path d={path} />
</svg>
</div>
);
};
/* ---------- Component ---------- */
export const Section: UserComponent<SectionProps> = ({
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 (
<section
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
style={{
width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...style,
}}
>
{hasTopDivider && (
<ShapeDivider
shape={topDivider}
color={topDividerColor}
height={topDividerHeight}
position="top"
/>
)}
<Element
id="section-inner"
is={Container}
canvas
style={{ maxWidth: innerMaxWidth, margin: '0 auto', position: 'relative', zIndex: 1 }}
tag="div"
>
{children}
</Element>
{hasBottomDivider && (
<ShapeDivider
shape={bottomDivider}
color={bottomDividerColor}
height={bottomDividerHeight}
position="bottom"
/>
)}
</section>
);
};
/* ---------- 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={sLabelStyle}>{label}</label>
{/* Shape selector */}
<select
value={shape || 'none'}
onChange={(e) => onShapeChange(e.target.value as DividerShape)}
style={sSelectStyle}
>
{DIVIDER_SHAPES.map((s) => (
<option key={s} value={s}>{s === 'none' ? 'None' : s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
{shape && shape !== 'none' && (
<>
{/* Color picker */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="color"
value={color || '#ffffff'}
onChange={(e) => onColorChange(e.target.value)}
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
/>
<input
type="text"
value={color || '#ffffff'}
onChange={(e) => onColorChange(e.target.value)}
style={{ ...sInputStyle, flex: 1 }}
placeholder="#ffffff"
/>
</div>
{/* Height slider */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 10, color: '#71717a' }}>Height</span>
<span style={{ fontSize: 10, color: '#a1a1aa' }}>{heightNum}px</span>
</div>
<input
type="range"
min={10}
max={200}
value={heightNum}
onChange={(e) => onHeightChange(`${e.target.value}px`)}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
{/* Small SVG preview */}
<div style={{ background: '#18181b', borderRadius: 4, padding: 4, border: '1px solid #3f3f46', overflow: 'hidden' }}>
<svg viewBox="0 0 1200 120" preserveAspectRatio="none" style={{ width: '100%', height: 30, fill: color || '#ffffff', display: 'block' }}>
<path d={DIVIDER_PATHS[shape]} />
</svg>
</div>
</>
)}
</div>
);
};
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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SectionProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Gradient</label>
<input
type="text"
placeholder="e.g. linear-gradient(135deg, #667eea, #764ba2)"
value={(props.style?.background as string) || ''}
onChange={(e) => 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 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding (top/bottom)</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{paddingPresets.map((p) => (
<button
key={p}
onClick={() => setProp((pr: SectionProps) => {
pr.style = { ...pr.style, paddingTop: p, paddingBottom: p };
})}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.paddingTop === p ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{p}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
<input
type="text"
value={props.innerMaxWidth || '1200px'}
onChange={(e) => 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 }}
/>
</div>
{/* Divider separator */}
<div style={{ borderTop: '1px solid #3f3f46', paddingTop: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7', marginBottom: 10 }}>Shape Dividers</div>
<DividerSettings
label="Top Divider"
shape={props.topDivider || 'none'}
color={props.topDividerColor || '#ffffff'}
height={props.topDividerHeight || '50px'}
onShapeChange={(s) => setProp((p: SectionProps) => { p.topDivider = s; })}
onColorChange={(c) => setProp((p: SectionProps) => { p.topDividerColor = c; })}
onHeightChange={(h) => setProp((p: SectionProps) => { p.topDividerHeight = h; })}
/>
<div style={{ height: 10 }} />
<DividerSettings
label="Bottom Divider"
shape={props.bottomDivider || 'none'}
color={props.bottomDividerColor || '#ffffff'}
height={props.bottomDividerHeight || '50px'}
onShapeChange={(s) => setProp((p: SectionProps) => { p.bottomDivider = s; })}
onColorChange={(c) => setProp((p: SectionProps) => { p.bottomDividerColor = c; })}
onHeightChange={(h) => setProp((p: SectionProps) => { p.bottomDividerHeight = h; })}
/>
</div>
</div>
);
};
/* ---------- 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 `<div style="${wrapperStyle}"><svg viewBox="0 0 1200 120" preserveAspectRatio="none" style="width:100%;height:100%;fill:${c};display:block;${svgTransform}"><path d="${path}"/></svg></div>`;
}
(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: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
};
};

View File

@@ -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<string | null> {
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<ImageBlockProps> = ({
src = PLACEHOLDER_SRC,
alt = '',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
actions: { setProp },
} = useNode((node) => ({ selected: node.events.selected }));
const imgRef = useRef<HTMLImageElement | null>(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 (
<img
ref={(ref: HTMLImageElement | null) => {
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<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
// Sizing unit state
const widthParsed = parseSizeValue(props.style?.width);
const [widthUnit, setWidthUnit] = useState<SizeUnit>(widthParsed.unit === 'auto' ? 'px' : widthParsed.unit);
const heightParsed = parseSizeValue(props.style?.height);
const [heightUnit, setHeightUnit] = useState<SizeUnit>(heightParsed.unit === 'auto' ? 'px' : heightParsed.unit);
const maxWidthParsed = parseSizeValue(props.style?.maxWidth);
const [maxWidthUnit, setMaxWidthUnit] = useState<SizeUnit>(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 (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Image preview */}
<div>
<label style={labelStyle}>Image Source</label>
{!isPlaceholder ? (
<>
{/* Current image thumbnail + filename + remove */}
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
<button onClick={() => setProp((p: ImageBlockProps) => { p.src = PLACEHOLDER_SRC; })}
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Remove image">
<i className="fa fa-times" />
</button>
</div>
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(props.src || '')}</span>
</div>
</>
) : (
/* Drop zone when no image set */
<div
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
onDragOver={(e) => { 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()}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
Drop image here or click to upload
</div>
)}
{/* Action buttons: Upload + Browse */}
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
>
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
</button>
</div>
{/* Inline asset browser grid */}
{showBrowser && (
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map(asset => (
<div
key={asset.name}
onClick={() => { 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'; }}
>
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{browserAssets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
{/* URL input (collapsed, for advanced users) */}
<div style={{ marginTop: 6 }}>
<input type="text"
value={isPlaceholder ? '' : (props.src || '')}
onChange={(e) => 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 }}
/>
</div>
</div>
{/* Alt Text */}
<div>
<label style={labelStyle}>Alt Text</label>
<input
type="text"
value={props.alt || ''}
onChange={(e) => 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 }}
/>
</div>
{/* Width */}
<div>
<label style={labelStyle}>Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={widthParsed.num}
disabled={props.style?.width === 'auto'}
onChange={(e) => {
const val = buildSizeString(e.target.value, widthUnit);
setPropStyle('width', val || 'auto');
}}
placeholder="auto"
style={inputStyle}
/>
<select
value={props.style?.width === 'auto' ? 'auto' : widthUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
if (unit === 'auto') {
setPropStyle('width', 'auto');
} else {
setWidthUnit(unit);
const num = widthParsed.num || '100';
setPropStyle('width', `${num}${unit}`);
}
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="%">%</option>
<option value="auto">auto</option>
</select>
</div>
</div>
{/* Max Width */}
<div>
<label style={labelStyle}>Max Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={maxWidthParsed.num}
onChange={(e) => {
const val = buildSizeString(e.target.value, maxWidthUnit);
setPropStyle('maxWidth', val || '100%');
}}
placeholder="100%"
style={inputStyle}
/>
<select
value={maxWidthUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
setMaxWidthUnit(unit);
const num = maxWidthParsed.num || '100';
setPropStyle('maxWidth', `${num}${unit}`);
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="%">%</option>
</select>
</div>
</div>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={heightParsed.num}
disabled={props.style?.height === 'auto'}
onChange={(e) => {
const val = buildSizeString(e.target.value, heightUnit);
setPropStyle('height', val || 'auto');
}}
placeholder="auto"
style={inputStyle}
/>
<select
value={props.style?.height === 'auto' ? 'auto' : heightUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
if (unit === 'auto') {
setPropStyle('height', 'auto');
} else {
setHeightUnit(unit);
const num = heightParsed.num || '300';
setPropStyle('height', `${num}${unit}`);
}
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="auto">auto</option>
</select>
</div>
</div>
{/* 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' && (
<div>
<label style={labelStyle}>Object Fit</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['cover', 'contain', 'fill', 'none'] as const).map((fit) => (
<button
key={fit}
onClick={() => setPropStyle('objectFit', fit)}
style={btnStyle(props.style?.objectFit === fit)}
>
{fit}
</button>
))}
</div>
</div>
)}
{/* Alignment */}
<div>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => setAlignment('left')} style={btnStyle(alignment === 'left')}>
<i className="fa fa-align-left" style={{ marginRight: 3 }} />Left
</button>
<button onClick={() => setAlignment('center')} style={btnStyle(alignment === 'center')}>
<i className="fa fa-align-center" style={{ marginRight: 3 }} />Center
</button>
<button onClick={() => setAlignment('right')} style={btnStyle(alignment === 'right')}>
<i className="fa fa-align-right" style={{ marginRight: 3 }} />Right
</button>
</div>
</div>
{/* Border Radius */}
<div>
<label style={labelStyle}>Border Radius</label>
<div style={{ display: 'flex', gap: 4 }}>
{radiusPresets.map((r) => (
<button key={r} onClick={() => setPropStyle('borderRadius', r)}
style={btnStyle(props.style?.borderRadius === r)}
>{r}</button>
))}
</div>
</div>
</div>
);
};
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, '&quot;')}"` : ' alt=""';
return { html: `<img src="${src}"${alt}${s ? ` style="${s}"` : ''} />` };
};

View File

@@ -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<MapEmbedProps> = ({
address = 'New York, NY',
zoom = 14,
height = '400px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
width: '100%',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<iframe
src={buildMapUrl(address, zoom)}
style={{
width: '100%',
height,
border: 'none',
borderRadius: (style as any)?.borderRadius || '0px',
display: 'block',
}}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>
);
};
/* ---------- Settings panel ---------- */
const MapEmbedSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as MapEmbedProps,
}));
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 heightPresets = ['300px', '400px', '500px', '600px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Address */}
<div>
<label style={labelStyle}>Address</label>
<input
type="text"
value={props.address || ''}
onChange={(e) => setProp((p: MapEmbedProps) => { p.address = e.target.value; })}
placeholder="Enter an address or location..."
style={inputStyle}
/>
</div>
{/* Zoom */}
<div>
<label style={labelStyle}>Zoom: {props.zoom || 14}</label>
<input
type="range"
min={1}
max={20}
value={props.zoom || 14}
onChange={(e) => setProp((p: MapEmbedProps) => { p.zoom = parseInt(e.target.value, 10); })}
style={{ width: '100%' }}
/>
</div>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
{heightPresets.map((h) => (
<button
key={h}
onClick={() => setProp((p: MapEmbedProps) => { p.height = h; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.height === h ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{h}
</button>
))}
</div>
<input
type="text"
value={props.height || ''}
onChange={(e) => setProp((p: MapEmbedProps) => { p.height = e.target.value; })}
placeholder="e.g. 400px"
style={inputStyle}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
MapEmbed.craft = {
displayName: 'Map',
props: {
address: 'New York, NY',
zoom: 14,
height: '400px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: MapEmbedSettings,
},
};
/* ---------- HTML export ---------- */
(MapEmbed as any).toHtml = (props: MapEmbedProps, _childrenHtml: string) => {
const {
address = 'New York, NY',
zoom = 14,
height = '400px',
style = {},
} = props;
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
const iframeStyle = cssPropsToString({
width: '100%',
height,
border: 'none',
borderRadius: (style as any)?.borderRadius || undefined,
display: 'block',
});
const src = buildMapUrl(address, zoom);
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${src}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
};
};

View File

@@ -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<string | null> {
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 = (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 8,
width: '100%',
aspectRatio: '16 / 9',
background: '#27272a',
borderRadius: 8,
border: '2px dashed #3f3f46',
color: '#71717a',
fontFamily: 'sans-serif',
fontSize: 14,
textAlign: 'center' as const,
}}
>
<i className="fa fa-play-circle" style={{ fontSize: 36, opacity: 0.5 }} />
<span>Add a video URL in settings</span>
</div>
);
/* ========================================================================
Normal (non-background) Video Component
======================================================================== */
export const VideoBlock: UserComponent<VideoBlockProps> = ({
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 (
<section
ref={(ref: HTMLElement | null): void => {
if (ref) connect(drag(ref));
}}
style={{
position: 'relative',
width: '100%',
minHeight: '300px',
overflow: 'hidden',
...style,
}}
>
{/* Background video layer */}
{type === 'file' && embedUrl && (
<video
src={embedUrl}
autoPlay
muted
loop
playsInline
style={{
position: 'absolute',
top: '50%',
left: '50%',
minWidth: '100%',
minHeight: '100%',
width: 'auto',
height: 'auto',
transform: 'translate(-50%, -50%)',
objectFit: 'cover',
zIndex: 0,
pointerEvents: 'none',
}}
/>
)}
{(type === 'youtube' || type === 'vimeo') && embedUrl && (
<iframe
src={buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false })}
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: '177.78vh', // 16:9 ratio overflow
height: '100vh',
minWidth: '100%',
minHeight: '100%',
transform: 'translate(-50%, -50%)',
border: 'none',
zIndex: 0,
pointerEvents: 'none',
}}
allow="autoplay; encrypted-media"
allowFullScreen
/>
)}
{type === 'none' && (
<div
style={{
position: 'absolute',
inset: 0,
background: '#1e293b',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#71717a',
fontSize: 14,
fontFamily: 'sans-serif',
zIndex: 0,
}}
>
<i className="fa fa-film" style={{ fontSize: 48, opacity: 0.3 }} />
</div>
)}
{/* Overlay */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: overlayColor,
opacity: (overlayOpacity ?? 50) / 100,
zIndex: 1,
pointerEvents: 'none',
}}
/>
{/* Content drop zone */}
<Element
id="video-bg-inner"
is={Container}
canvas
style={{
position: 'relative',
zIndex: 2,
maxWidth: innerMaxWidth,
margin: '0 auto',
padding: '80px 20px',
}}
tag="div"
/>
</section>
);
}
/* ---- Normal mode ---- */
return (
<div
ref={(ref: HTMLDivElement | null): void => {
if (ref) connect(drag(ref));
}}
style={{
width: '100%',
...style,
}}
>
{type === 'none' && VIDEO_PLACEHOLDER}
{(type === 'youtube' || type === 'vimeo') && (
<div
style={{
position: 'relative',
paddingBottom: '56.25%',
height: 0,
overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined,
}}
>
<iframe
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
border: 'none',
}}
allow="autoplay; encrypted-media; picture-in-picture"
allowFullScreen
/>
</div>
)}
{type === 'file' && (
<video
src={embedUrl}
autoPlay={autoplay}
muted={muted}
loop={loop}
controls={controls}
playsInline
style={{
display: 'block',
width: '100%',
borderRadius: (style as any)?.borderRadius || undefined,
}}
/>
)}
</div>
);
};
/* ========================================================================
Settings Panel
======================================================================== */
const VideoBlockSettings: React.FC = () => {
const {
actions: { setProp },
props,
} = useNode((node) => ({
props: node.data.props as VideoBlockProps,
}));
const [urlInput, setUrlInput] = useState(props.videoUrl || '');
const fileInputRef = useRef<HTMLInputElement>(null);
const detected = props.videoUrl ? detectVideoType(props.videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
const applyUrl = useCallback(
(url: string) => {
const info = detectVideoType(url);
setProp((p: VideoBlockProps) => {
p.videoUrl = url;
p.videoType = info.type;
p.embedUrl = info.embedUrl;
});
},
[setProp]
);
const handleUpload = useCallback(
async (file: File) => {
const url = await uploadToWhp(file);
if (url) {
setUrlInput(url);
applyUrl(url);
}
},
[applyUrl]
);
const handleBrowse = useCallback(async () => {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return;
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 videos = data.assets.filter(
(a: any) => (a.type || '').startsWith('video') || (a.name || '').match(/\.(mp4|webm|ogg|mov)$/i)
);
if (videos.length === 0) {
alert('No video assets uploaded yet. Use the Upload button to add one.');
return;
}
const names = videos.map((a: any, i: number) => `${i + 1}. ${a.name}`).join('\n');
const choice = prompt(`Select a video (enter number):\n\n${names}`);
if (choice) {
const idx = parseInt(choice, 10) - 1;
if (videos[idx]) {
setUrlInput(videos[idx].url);
applyUrl(videos[idx].url);
}
}
}
} catch (e) {
console.error('Browse failed:', e);
}
}, [applyUrl]);
const typeBadge = (label: string, color: string) => (
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
background: color,
color: '#fff',
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{label}
</span>
);
const overlayPresets = ['#000000', '#1e293b', '#0f172a', '#312e81', '#064e3b', '#7f1d1d'];
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%',
padding: '4px 8px',
background: '#27272a',
color: '#e4e4e7',
border: '1px solid #3f3f46',
borderRadius: 4,
fontSize: 12,
};
const checkboxRowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: '#e4e4e7',
cursor: 'pointer',
};
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Video URL */}
<div>
<label style={labelStyle}>Video URL</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applyUrl(urlInput);
}}
placeholder="YouTube, Vimeo, or direct video URL..."
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => applyUrl(urlInput)}
style={{
padding: '4px 12px',
fontSize: 11,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: '#3b82f6',
color: '#fff',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
Apply
</button>
</div>
{/* Detected type badge */}
{detected.type !== 'none' && (
<div style={{ marginTop: 6 }}>
{detected.type === 'youtube' && typeBadge('YouTube', '#dc2626')}
{detected.type === 'vimeo' && typeBadge('Vimeo', '#1ab7ea')}
{detected.type === 'file' && typeBadge('Video File', '#16a34a')}
</div>
)}
</div>
{/* Upload / Browse */}
<div>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{
flex: 1,
padding: '8px 10px',
fontSize: 12,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: '#3b82f6',
color: '#fff',
fontWeight: 500,
}}
>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{
flex: 1,
padding: '8px 10px',
fontSize: 12,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: '#27272a',
color: '#e4e4e7',
}}
>
<i className="fa fa-folder-open" style={{ marginRight: 4 }} /> Browse
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.target.value = '';
}}
/>
</div>
{/* Playback options */}
<div>
<label style={labelStyle}>Playback</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.autoplay ?? false}
onChange={(e) => setProp((p: VideoBlockProps) => { p.autoplay = e.target.checked; })}
/>
Autoplay
</label>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.muted ?? true}
onChange={(e) => setProp((p: VideoBlockProps) => { p.muted = e.target.checked; })}
/>
Muted
</label>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.loop ?? false}
onChange={(e) => setProp((p: VideoBlockProps) => { p.loop = e.target.checked; })}
/>
Loop
</label>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.controls ?? true}
onChange={(e) => setProp((p: VideoBlockProps) => { p.controls = e.target.checked; })}
/>
Show Controls
</label>
</div>
</div>
{/* Mode toggle */}
<div>
<label style={labelStyle}>Display Mode</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = false; })}
style={{
flex: 1,
padding: '6px 10px',
fontSize: 11,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: !props.isBackground ? '#3b82f6' : '#27272a',
color: !props.isBackground ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Normal
</button>
<button
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = true; })}
style={{
flex: 1,
padding: '6px 10px',
fontSize: 11,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.isBackground ? '#3b82f6' : '#27272a',
color: props.isBackground ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Background
</button>
</div>
</div>
{/* Background mode options */}
{props.isBackground && (
<>
<div>
<label style={labelStyle}>Overlay Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{overlayPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: VideoBlockProps) => { p.overlayColor = c; })}
style={{
width: 24,
height: 24,
borderRadius: 4,
border: '1px solid #3f3f46',
backgroundColor: c,
cursor: 'pointer',
outline: props.overlayColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>
Overlay Opacity: {props.overlayOpacity ?? 50}%
</label>
<input
type="range"
min={0}
max={100}
value={props.overlayOpacity ?? 50}
onChange={(e) =>
setProp((p: VideoBlockProps) => {
p.overlayOpacity = parseInt(e.target.value, 10);
})
}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={labelStyle}>Inner Max Width</label>
<input
type="text"
value={props.innerMaxWidth || '1200px'}
onChange={(e) => setProp((p: VideoBlockProps) => { p.innerMaxWidth = e.target.value; })}
style={inputStyle}
/>
</div>
</>
)}
</div>
);
};
/* ========================================================================
Craft Config
======================================================================== */
VideoBlock.craft = {
displayName: 'Video',
props: {
videoUrl: '',
videoType: 'none',
embedUrl: '',
autoplay: false,
muted: true,
loop: false,
controls: true,
isBackground: false,
overlayColor: '#000000',
overlayOpacity: 50,
innerMaxWidth: '1200px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => true,
canMoveOut: () => true,
},
related: {
settings: VideoBlockSettings,
},
};
/* ========================================================================
HTML Export
======================================================================== */
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
const {
videoUrl = '',
autoplay = false,
muted = true,
loop: doLoop = false,
controls = true,
isBackground = false,
overlayColor = '#000000',
overlayOpacity = 50,
innerMaxWidth = '1200px',
style = {},
} = props;
const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
/* ---- Background mode export ---- */
if (isBackground) {
const outerStyle = cssPropsToString({
position: 'relative',
width: '100%',
minHeight: '300px',
overflow: 'hidden',
...style,
});
const overlayStyle = cssPropsToString({
position: 'absolute',
inset: '0',
backgroundColor: overlayColor,
opacity: String(overlayOpacity / 100),
zIndex: '1',
pointerEvents: 'none',
});
const innerStyle = cssPropsToString({
position: 'relative',
zIndex: '2',
maxWidth: innerMaxWidth,
margin: '0 auto',
padding: '80px 20px',
});
let videoHtml = '';
if (type === 'file' && embedUrl) {
const vidStyle = cssPropsToString({
position: 'absolute',
top: '50%',
left: '50%',
minWidth: '100%',
minHeight: '100%',
width: 'auto',
height: 'auto',
transform: 'translate(-50%, -50%)',
objectFit: 'cover',
zIndex: '0',
pointerEvents: 'none',
});
videoHtml = `<video src="${embedUrl}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
} else if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
const ifrStyle = cssPropsToString({
position: 'absolute',
top: '50%',
left: '50%',
width: '177.78vh',
height: '100vh',
minWidth: '100%',
minHeight: '100%',
transform: 'translate(-50%, -50%)',
border: 'none',
zIndex: '0',
pointerEvents: 'none',
});
videoHtml = `<iframe src="${iframeSrc}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
}
return {
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${videoHtml}<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
};
}
/* ---- 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: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${iframeSrc}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
};
}
// 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: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${embedUrl}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
};
};

View File

@@ -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,
};

View File

@@ -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<AccordionProps> = ({
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<Set<number>>(() => {
const initial = new Set<number>();
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 (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '800px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '0px' }}>
{items.map((item, i) => {
const isOpen = openIndexes.has(i);
return (
<div
key={i}
style={{
border: `1px solid ${borderColor}`,
borderBottom: i === items.length - 1 ? `1px solid ${borderColor}` : 'none',
...(i === 0 ? { borderTopLeftRadius: '8px', borderTopRightRadius: '8px' } : {}),
...(i === items.length - 1 ? { borderBottomLeftRadius: '8px', borderBottomRightRadius: '8px', borderBottom: `1px solid ${borderColor}` } : {}),
}}
>
<div
onClick={() => 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' } : {}),
}}
>
<span>{item.title}</span>
<span style={{ fontSize: '12px', transition: 'transform 0.2s', transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}>&#9660;</span>
</div>
{isOpen && (
<div
style={{
padding: '16px 20px',
backgroundColor: contentBg,
color: '#4b5563',
fontSize: '14px',
lineHeight: '1.6',
borderTop: `1px solid ${borderColor}`,
...(i === items.length - 1 ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}),
}}
>
{item.content}
</div>
)}
</div>
);
})}
</div>
</section>
);
};
/* ---------- 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 (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Header Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorSwatches.map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.headerBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.headerBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Header Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#18181b', '#1f2937', '#374151', '#ffffff', '#e2e8f0', '#3b82f6'].map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.headerColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.headerColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Content Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.contentBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Border Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#e2e8f0', '#cbd5e1', '#d1d5db', '#3f3f46', '#52525b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.borderColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.borderColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Items</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={item.title} onChange={(e) => updateItem(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeItem(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<textarea
value={item.content}
onChange={(e) => updateItem(i, 'content', e.target.value)}
placeholder="Content"
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
))}
</div>
<button
onClick={addItem}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Item
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Accordion.craft = {
displayName: 'Accordion',
props: {
items: defaultItems,
style: { backgroundColor: '#ffffff' },
headerBg: '#f8fafc',
headerColor: '#18181b',
contentBg: '#ffffff',
borderColor: '#e2e8f0',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: AccordionSettings,
},
};
/* ---------- HTML export ---------- */
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
const headerBg = props.headerBg || '#f8fafc';
const headerColor = props.headerColor || '#18181b';
const contentBg = props.contentBg || '#ffffff';
const borderColor = props.borderColor || '#e2e8f0';
const items = props.items || defaultItems;
const panels = items.map((item, i) => {
const openAttr = item.isOpen ? ' open' : '';
const topRadius = i === 0 ? 'border-top-left-radius:8px;border-top-right-radius:8px;' : '';
const bottomRadius = i === items.length - 1 ? 'border-bottom-left-radius:8px;border-bottom-right-radius:8px;' : '';
const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`;
return `<details${openAttr} style="${borderBottom}${topRadius}${bottomRadius}">
<summary style="padding:16px 20px;background-color:${headerBg};color:${headerColor};cursor:pointer;font-weight:600;font-size:16px;list-style:none;display:flex;justify-content:space-between;align-items:center">
${esc(item.title)}
</summary>
<div style="padding:16px 20px;background-color:${contentBg};color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}">
${esc(item.content)}
</div>
</details>`;
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
${panels}
</div>
<style>details summary::-webkit-details-marker{display:none}details summary::marker{display:none}</style>
</section>`,
};
};

View File

@@ -0,0 +1,192 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface CTASectionProps {
heading?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
gradient?: string;
style?: CSSProperties;
}
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
export const CTASection: UserComponent<CTASectionProps> = ({
heading = 'Ready to Get Started?',
description = 'Join thousands of satisfied users and start building your dream website today.',
buttonText = 'Start Free Trial',
buttonHref = '#',
gradient = defaultGradient,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
background: gradient,
padding: '80px 20px',
textAlign: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '700px', margin: '0 auto' }}>
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px' }}>
{heading}
</h2>
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
{description}
</p>
<a
href={buttonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: '#ffffff',
color: '#18181b',
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
}}
>
{buttonText}
</a>
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const CTASectionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CTASectionProps,
}));
const gradientPresets = [
{ label: 'Blue-Purple', value: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)' },
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Teal', value: 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' },
{ label: 'Sunset', value: 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)' },
{ label: 'Dark', value: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' },
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.heading = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Description</label>
<textarea
value={props.description || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.description = e.target.value; })}
rows={2}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonText = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button URL</label>
<input
type="text"
value={props.buttonHref || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonHref = e.target.value; })}
placeholder="https://..."
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradientPresets.map((g) => (
<button
key={g.label}
onClick={() => setProp((p: CTASectionProps) => { p.gradient = g.value; })}
title={g.label}
style={{
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
background: g.value, cursor: 'pointer',
outline: props.gradient === g.value ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
CTASection.craft = {
displayName: 'CTA Section',
props: {
heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.',
buttonText: 'Start Free Trial',
buttonHref: '#',
gradient: defaultGradient,
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CTASectionSettings,
},
};
/* ---------- HTML export ---------- */
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({
background: props.gradient || defaultGradient,
padding: '80px 20px',
textAlign: 'center',
...props.style,
});
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:700px;margin:0 auto">
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${esc(props.heading || '')}</h2>
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText || '')}</a>
</div>
</section>`,
};
};

View File

@@ -0,0 +1,486 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface CallToActionProps {
heading?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
secondaryButtonHref?: string;
bgType?: 'color' | 'gradient' | 'image';
bgValue?: string;
overlayColor?: string;
overlayOpacity?: number;
textColor?: string;
buttonColor?: string;
style?: CSSProperties;
}
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
export const CallToAction: UserComponent<CallToActionProps> = ({
heading = 'Ready to Get Started?',
description = 'Join thousands of satisfied users and start building your dream website today.',
buttonText = 'Get Started',
buttonHref = '#',
secondaryButtonText = '',
secondaryButtonHref = '#',
bgType = 'gradient',
bgValue = defaultGradient,
overlayColor = '#000000',
overlayOpacity = 0,
textColor = '#ffffff',
buttonColor = '#ffffff',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const bgStyle: CSSProperties = {};
if (bgType === 'color') {
bgStyle.backgroundColor = bgValue;
} else if (bgType === 'gradient') {
bgStyle.background = bgValue;
} else if (bgType === 'image') {
bgStyle.backgroundImage = `url(${bgValue})`;
bgStyle.backgroundSize = 'cover';
bgStyle.backgroundPosition = 'center';
}
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
position: 'relative',
padding: '80px 20px',
textAlign: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...bgStyle,
...style,
}}
>
{/* Overlay */}
{bgType === 'image' && overlayOpacity > 0 && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: overlayColor,
opacity: overlayOpacity / 100,
pointerEvents: 'none',
}}
/>
)}
<div style={{ maxWidth: '700px', margin: '0 auto', position: 'relative', zIndex: 1 }}>
<h2 style={{ fontSize: '36px', fontWeight: '700', color: textColor, marginBottom: '12px' }}>
{heading}
</h2>
<p style={{ fontSize: '18px', color: textColor, opacity: 0.85, marginBottom: '28px', lineHeight: '1.6' }}>
{description}
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
<a
href={buttonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: buttonColor,
color: buttonTextColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
}}
>
{buttonText}
</a>
{secondaryButtonText && (
<a
href={secondaryButtonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: 'transparent',
color: textColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
border: `2px solid ${textColor}`,
}}
>
{secondaryButtonText}
</a>
)}
</div>
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const CallToActionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CallToActionProps,
}));
const gradientPresets = [
{ label: 'Blue-Purple', value: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)' },
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Teal', value: 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' },
{ label: 'Sunset', value: 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)' },
{ label: 'Dark', value: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' },
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
];
const colorPresets = ['#2563eb', '#7c3aed', '#0d9488', '#18181b', '#0f172a', '#1e293b', '#dc2626', '#f97316'];
const buttonColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#ec4899'];
const textColorPresets = ['#ffffff', '#f8fafc', '#e2e8f0', '#18181b', '#1e293b', '#fef3c7'];
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.heading = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Description</label>
<textarea
value={props.description || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.description = e.target.value; })}
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
{/* Primary Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonText = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button URL</label>
<input
type="text"
value={props.buttonHref || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonHref = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
{/* Secondary Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button Text <span style={{ opacity: 0.5 }}>(leave empty to hide)</span></label>
<input
type="text"
value={props.secondaryButtonText || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonText = e.target.value; })}
placeholder="e.g. Learn More"
style={inputStyle}
/>
</div>
{props.secondaryButtonText && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button URL</label>
<input
type="text"
value={props.secondaryButtonHref || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonHref = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
)}
{/* Background Type */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Type</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['color', 'gradient', 'image'] as const).map((t) => (
<button
key={t}
onClick={() => {
setProp((p: CallToActionProps) => {
p.bgType = t;
if (t === 'color') p.bgValue = '#2563eb';
if (t === 'gradient') p.bgValue = defaultGradient;
if (t === 'image') p.bgValue = '';
});
}}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.bgType === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{t}
</button>
))}
</div>
</div>
{/* Background sub-controls */}
{props.bgType === 'color' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgValue === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{props.bgType === 'gradient' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradientPresets.map((g) => (
<button
key={g.label}
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = g.value; })}
title={g.label}
style={{
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
background: g.value, cursor: 'pointer',
outline: props.bgValue === g.value ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{props.bgType === 'image' && (
<>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Image URL</label>
<input
type="text"
value={props.bgValue || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.bgValue = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Overlay Opacity: {props.overlayOpacity ?? 0}%
</label>
<input
type="range"
min={0}
max={100}
value={props.overlayOpacity ?? 0}
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayOpacity = parseInt(e.target.value); })}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
<input
type="color"
value={props.overlayColor || '#000000'}
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
</>
)}
{/* Text Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.textColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.textColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Button Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{buttonColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.buttonColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.buttonColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
CallToAction.craft = {
displayName: 'Call to Action',
props: {
heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.',
buttonText: 'Get Started',
buttonHref: '#',
secondaryButtonText: 'Learn More',
secondaryButtonHref: '#',
bgType: 'gradient',
bgValue: defaultGradient,
overlayColor: '#000000',
overlayOpacity: 0,
textColor: '#ffffff',
buttonColor: '#ffffff',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CallToActionSettings,
},
};
/* ---------- HTML export ---------- */
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bgType = props.bgType || 'gradient';
const bgValue = props.bgValue || defaultGradient;
const textColor = props.textColor || '#ffffff';
const buttonColor = props.buttonColor || '#ffffff';
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
const sectionCss: CSSProperties = {
position: 'relative',
padding: '80px 20px',
textAlign: 'center',
...props.style,
};
if (bgType === 'color') {
sectionCss.backgroundColor = bgValue;
} else if (bgType === 'gradient') {
sectionCss.background = bgValue;
} else if (bgType === 'image') {
sectionCss.backgroundImage = `url(${bgValue})`;
sectionCss.backgroundSize = 'cover';
sectionCss.backgroundPosition = 'center';
}
const sectionStyle = cssPropsToString(sectionCss);
let overlayHtml = '';
if (bgType === 'image' && (props.overlayOpacity || 0) > 0) {
const overlayStyle = cssPropsToString({
position: 'absolute',
inset: '0',
backgroundColor: props.overlayColor || '#000000',
opacity: String((props.overlayOpacity || 0) / 100) as any,
pointerEvents: 'none',
});
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
}
let secondaryBtnHtml = '';
if (props.secondaryButtonText) {
const secStyle = cssPropsToString({
display: 'inline-block',
padding: '14px 36px',
backgroundColor: 'transparent',
color: textColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
border: `2px solid ${textColor}`,
});
secondaryBtnHtml = `\n <a href="${props.secondaryButtonHref || '#'}"${secStyle ? ` style="${secStyle}"` : ''}>${esc(props.secondaryButtonText)}</a>`;
}
const btnStyle = cssPropsToString({
display: 'inline-block',
padding: '14px 36px',
backgroundColor: buttonColor,
color: buttonTextColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
});
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${overlayHtml}<div style="max-width:700px;margin:0 auto;position:relative;z-index:1">
<h2 style="font-size:36px;font-weight:700;color:${textColor};margin-bottom:12px">${esc(props.heading || '')}</h2>
<p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<a href="${props.buttonHref || '#'}"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.buttonText || '')}</a>${secondaryBtnHtml}
</div>
</div>
</section>`,
};
};

View File

@@ -0,0 +1,530 @@
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface Slide {
type: 'image' | 'content';
imageSrc?: string;
heading?: string;
text?: string;
buttonText?: string;
buttonHref?: string;
bgColor?: string;
}
interface ContentSliderProps {
slides?: Slide[];
autoplay?: boolean;
interval?: number;
showDots?: boolean;
showArrows?: boolean;
height?: string;
style?: CSSProperties;
}
const defaultSlides: Slide[] = [
{
type: 'image',
imageSrc: '',
heading: 'First Slide',
text: 'Welcome to our showcase',
buttonText: 'Learn More',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
},
{
type: 'image',
imageSrc: '',
heading: 'Second Slide',
text: 'Discover something amazing',
buttonText: 'Get Started',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
},
{
type: 'image',
imageSrc: '',
heading: 'Third Slide',
text: 'Build your future today',
buttonText: 'Contact Us',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
},
];
export const ContentSlider: UserComponent<ContentSliderProps> = ({
slides = defaultSlides,
autoplay = true,
interval = 5000,
showDots = true,
showArrows = true,
height = '400px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [activeIndex, setActiveIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const items = slides.length > 0 ? slides : defaultSlides;
const goTo = useCallback((index: number) => {
setActiveIndex(((index % items.length) + items.length) % items.length);
}, [items.length]);
const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]);
const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]);
useEffect(() => {
if (autoplay && items.length > 1) {
timerRef.current = setInterval(goNext, interval);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}
}, [autoplay, interval, goNext, items.length]);
const arrowStyle: CSSProperties = {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
width: '40px',
height: '40px',
borderRadius: '50%',
border: 'none',
background: 'rgba(255,255,255,0.9)',
color: '#18181b',
fontSize: '16px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
};
const renderSlide = (slide: Slide, i: number) => {
const bg = slide.imageSrc
? { backgroundImage: `url(${slide.imageSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: slide.bgColor?.startsWith('linear-gradient')
? { backgroundImage: slide.bgColor }
: { backgroundColor: slide.bgColor || '#3b82f6' };
return (
<div
key={i}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: i === activeIndex ? 1 : 0,
transition: 'opacity 0.5s ease-in-out',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
...bg,
}}
>
{(slide.heading || slide.text || slide.buttonText) && (
<div style={{ textAlign: 'center', padding: '20px', zIndex: 1 }}>
{slide.heading && (
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px', fontFamily: 'Inter, sans-serif', textShadow: '0 2px 8px rgba(0,0,0,0.3)' }}>
{slide.heading}
</h2>
)}
{slide.text && (
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.9)', marginBottom: '20px', fontFamily: 'Inter, sans-serif', textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
{slide.text}
</p>
)}
{slide.buttonText && (
<a
href={slide.buttonHref || '#'}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '12px 28px',
background: '#ffffff',
color: '#18181b',
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '15px',
fontFamily: 'Inter, sans-serif',
}}
>
{slide.buttonText}
</a>
)}
</div>
)}
</div>
);
};
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
position: 'relative',
width: '100%',
height,
overflow: 'hidden',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{items.map((slide, i) => renderSlide(slide, i))}
{showArrows && items.length > 1 && (
<>
<button onClick={goPrev} style={{ ...arrowStyle, left: '16px' }}>
<i className="fa fa-chevron-left" />
</button>
<button onClick={goNext} style={{ ...arrowStyle, right: '16px' }}>
<i className="fa fa-chevron-right" />
</button>
</>
)}
{showDots && items.length > 1 && (
<div style={{ position: 'absolute', bottom: '16px', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '8px', zIndex: 2 }}>
{items.map((_, i) => (
<button
key={i}
onClick={() => goTo(i)}
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
backgroundColor: i === activeIndex ? '#ffffff' : 'rgba(255,255,255,0.5)',
transition: 'background-color 0.3s',
}}
/>
))}
</div>
)}
</section>
);
};
/* ---------- Settings panel ---------- */
const ContentSliderSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ContentSliderProps,
}));
const items = props.slides || defaultSlides;
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,
};
const heightPresets = ['300px', '400px', '500px', '600px', '80vh'];
const intervalPresets = [3000, 4000, 5000, 7000, 10000];
const bgPresets = [
'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
'linear-gradient(135deg, #10b981 0%, #059669 100%)',
'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
'linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%)',
'#18181b',
'#0f172a',
'#1e293b',
'#3b82f6',
];
const updateSlide = (index: number, field: keyof Slide, value: string) => {
setProp((p: ContentSliderProps) => {
const updated = [...(p.slides || defaultSlides)];
updated[index] = { ...updated[index], [field]: value };
p.slides = updated;
});
};
const addSlide = () => {
setProp((p: ContentSliderProps) => {
const current = p.slides || defaultSlides;
p.slides = [...current, {
type: 'image',
imageSrc: '',
heading: 'New Slide',
text: 'Add your content here',
buttonText: '',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
}];
});
};
const removeSlide = (index: number) => {
setProp((p: ContentSliderProps) => {
const updated = [...(p.slides || defaultSlides)];
updated.splice(index, 1);
p.slides = updated;
});
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{heightPresets.map((h) => (
<button
key={h}
onClick={() => setProp((p: ContentSliderProps) => { p.height = h; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.height === h ? '#3b82f6' : '#27272a',
color: props.height === h ? '#fff' : '#e4e4e7',
}}
>
{h}
</button>
))}
</div>
</div>
{/* Autoplay */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.autoplay !== false}
onChange={(e) => setProp((p: ContentSliderProps) => { p.autoplay = e.target.checked; })}
/>
Autoplay
</label>
</div>
{/* Interval */}
{props.autoplay !== false && (
<div>
<label style={labelStyle}>Interval (ms)</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{intervalPresets.map((ms) => (
<button
key={ms}
onClick={() => setProp((p: ContentSliderProps) => { p.interval = ms; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.interval || 5000) === ms ? '#3b82f6' : '#27272a',
color: (props.interval || 5000) === ms ? '#fff' : '#e4e4e7',
}}
>
{ms / 1000}s
</button>
))}
</div>
</div>
)}
{/* Show Arrows */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.showArrows !== false}
onChange={(e) => setProp((p: ContentSliderProps) => { p.showArrows = e.target.checked; })}
/>
Show Arrows
</label>
</div>
{/* Show Dots */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.showDots !== false}
onChange={(e) => setProp((p: ContentSliderProps) => { p.showDots = e.target.checked; })}
/>
Show Dots
</label>
</div>
{/* Slides */}
<div>
<label style={labelStyle}>Slides</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((slide, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#a1a1aa', flex: 'none', width: 18 }}>{i + 1}.</span>
<select
value={slide.type}
onChange={(e) => updateSlide(i, 'type', e.target.value)}
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
>
<option value="image">Image</option>
<option value="content">Content</option>
</select>
<input type="text" value={slide.heading || ''} onChange={(e) => updateSlide(i, 'heading', e.target.value)} placeholder="Heading" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeSlide(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
<input type="text" value={slide.imageSrc || ''} onChange={(e) => updateSlide(i, 'imageSrc', e.target.value)} placeholder="Image URL (optional)" style={inputStyle} />
<input type="text" value={slide.text || ''} onChange={(e) => updateSlide(i, 'text', e.target.value)} placeholder="Text" style={inputStyle} />
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={slide.buttonText || ''} onChange={(e) => updateSlide(i, 'buttonText', e.target.value)} placeholder="Button text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={slide.buttonHref || ''} onChange={(e) => updateSlide(i, 'buttonHref', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
</div>
<div>
<span style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Background</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((bg) => (
<button
key={bg}
onClick={() => updateSlide(i, 'bgColor', bg)}
style={{
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
background: bg, cursor: 'pointer',
outline: slide.bgColor === bg ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
))}
</div>
<button
onClick={addSlide}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Slide
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
ContentSlider.craft = {
displayName: 'Content Slider',
props: {
slides: defaultSlides,
autoplay: true,
interval: 5000,
showDots: true,
showArrows: true,
height: '400px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ContentSliderSettings,
},
};
/* ---------- HTML export ---------- */
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
slides = defaultSlides,
autoplay = true,
interval = 5000,
showDots = true,
showArrows = true,
height = '400px',
style = {},
} = props;
const items = slides.length > 0 ? slides : defaultSlides;
const uid = 'cs_' + Math.random().toString(36).slice(2, 8);
const sectionStyle = cssPropsToString({
position: 'relative',
width: '100%',
height,
overflow: 'hidden',
...style,
});
const slidesHtml = items.map((slide, i) => {
const hasBgImage = slide.imageSrc;
const bgStyle = hasBgImage
? `background-image:url(${esc(slide.imageSrc!)});background-size:cover;background-position:center`
: slide.bgColor?.startsWith('linear-gradient')
? `background-image:${slide.bgColor}`
: `background-color:${slide.bgColor || '#3b82f6'}`;
const contentParts: string[] = [];
if (slide.heading) {
contentParts.push(`<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px;font-family:Inter,sans-serif;text-shadow:0 2px 8px rgba(0,0,0,0.3)">${esc(slide.heading)}</h2>`);
}
if (slide.text) {
contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${esc(slide.text)}</p>`);
}
if (slide.buttonText) {
contentParts.push(`<a href="${esc(slide.buttonHref || '#')}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${esc(slide.buttonText)}</a>`);
}
const innerHtml = contentParts.length > 0
? `<div style="text-align:center;padding:20px;z-index:1">${contentParts.join('\n ')}</div>`
: '';
return `<div id="${uid}_s${i}" style="position:absolute;top:0;left:0;width:100%;height:100%;opacity:${i === 0 ? 1 : 0};transition:opacity 0.5s ease-in-out;display:flex;flex-direction:column;align-items:center;justify-content:center;${bgStyle}">
${innerHtml}
</div>`;
}).join('\n ');
const arrowsHtml = showArrows && items.length > 1
? `<button onclick="${uid}_prev()" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left"></i></button>
<button onclick="${uid}_next()" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right"></i></button>`
: '';
const dotsHtml = showDots && items.length > 1
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2">
${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" style="width:10px;height:10px;border-radius:50%;border:none;cursor:pointer;background-color:${i === 0 ? '#ffffff' : 'rgba(255,255,255,0.5)'};transition:background-color 0.3s"></button>`).join('\n ')}
</div>`
: '';
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${slidesHtml}
${arrowsHtml}
${dotsHtml}
<script>
(function(){
var current=0, total=${items.length}, uid="${uid}";
function show(idx){
document.getElementById(uid+"_s"+current).style.opacity="0";
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
current=((idx%total)+total)%total;
document.getElementById(uid+"_s"+current).style.opacity="1";
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
}
window["${uid}_go"]=show;
window["${uid}_next"]=function(){show(current+1);};
window["${uid}_prev"]=function(){show(current-1);};
${autoplay && items.length > 1 ? `setInterval(function(){show(current+1);},${interval});` : ''}
})();
</script>
</section>`,
};
};

View File

@@ -0,0 +1,311 @@
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface CountdownProps {
targetDate?: string;
heading?: string;
style?: CSSProperties;
digitColor?: string;
labelColor?: string;
bgColor?: string;
}
interface TimeLeft {
days: number;
hours: number;
minutes: number;
seconds: number;
}
function getDefaultTargetDate(): string {
const d = new Date();
d.setDate(d.getDate() + 30);
return d.toISOString().split('T')[0];
}
function calcTimeLeft(target: string): TimeLeft {
const diff = new Date(target).getTime() - Date.now();
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
return {
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
minutes: Math.floor((diff / (1000 * 60)) % 60),
seconds: Math.floor((diff / 1000) % 60),
};
}
const DEFAULT_TARGET = getDefaultTargetDate();
export const Countdown: UserComponent<CountdownProps> = ({
targetDate = DEFAULT_TARGET,
heading = 'Coming Soon',
style = {},
digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [timeLeft, setTimeLeft] = useState<TimeLeft>(() => calcTimeLeft(targetDate));
useEffect(() => {
setTimeLeft(calcTimeLeft(targetDate));
const interval = setInterval(() => {
setTimeLeft(calcTimeLeft(targetDate));
}, 1000);
return () => clearInterval(interval);
}, [targetDate]);
const pad = (n: number) => String(n).padStart(2, '0');
const boxStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
minWidth: '80px',
};
const digitStyle: CSSProperties = {
fontSize: '48px',
fontWeight: '700',
color: digitColor,
lineHeight: '1',
fontFamily: 'Inter, sans-serif',
};
const unitLabelStyle: CSSProperties = {
fontSize: '12px',
color: labelColor,
textTransform: 'uppercase',
letterSpacing: '0.1em',
fontFamily: 'Inter, sans-serif',
};
const units: Array<{ label: string; value: number }> = [
{ label: 'Days', value: timeLeft.days },
{ label: 'Hours', value: timeLeft.hours },
{ label: 'Minutes', value: timeLeft.minutes },
{ label: 'Seconds', value: timeLeft.seconds },
];
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
textAlign: 'center',
backgroundColor: bgColor,
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{heading && (
<h2 style={{ fontSize: '32px', fontWeight: '700', color: digitColor, marginBottom: '32px', fontFamily: 'Inter, sans-serif' }}>
{heading}
</h2>
)}
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', flexWrap: 'wrap' }}>
{units.map((u) => (
<div key={u.label} style={boxStyle}>
<span style={digitStyle}>{pad(u.value)}</span>
<span style={unitLabelStyle}>{u.label}</span>
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const CountdownSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CountdownProps,
}));
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 colorPresets = ['#ffffff', '#f8fafc', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
const bgPresets = ['#18181b', '#0f172a', '#1e293b', '#1e1b4b', '#042f2e', '#27272a', '#ffffff', '#f8fafc'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Target date */}
<div>
<label style={labelStyle}>Target Date</label>
<input
type="date"
value={props.targetDate || DEFAULT_TARGET}
onChange={(e) => setProp((p: CountdownProps) => { p.targetDate = e.target.value; })}
style={inputStyle}
/>
</div>
{/* Heading */}
<div>
<label style={labelStyle}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CountdownProps) => { p.heading = e.target.value; })}
placeholder="Coming Soon"
style={inputStyle}
/>
</div>
{/* Digit color */}
<div>
<label style={labelStyle}>Digit Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CountdownProps) => { p.digitColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.digitColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Label color */}
<div>
<label style={labelStyle}>Label Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CountdownProps) => { p.labelColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.labelColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background color */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CountdownProps) => { p.bgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Countdown.craft = {
displayName: 'Countdown',
props: {
targetDate: DEFAULT_TARGET,
heading: 'Coming Soon',
style: {},
digitColor: '#ffffff',
labelColor: 'rgba(255,255,255,0.7)',
bgColor: '#18181b',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CountdownSettings,
},
};
/* ---------- HTML export ---------- */
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
targetDate = DEFAULT_TARGET,
heading = 'Coming Soon',
style = {},
digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b',
} = props;
const sectionStyle = cssPropsToString({
padding: '60px 20px',
textAlign: 'center',
backgroundColor: bgColor,
...style,
});
const headingHtml = heading
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>`
: '';
const boxStyle = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:80px';
const dStyle = `font-size:48px;font-weight:700;color:${digitColor};line-height:1;font-family:Inter,sans-serif`;
const lStyle = `font-size:12px;color:${labelColor};text-transform:uppercase;letter-spacing:0.1em;font-family:Inter,sans-serif`;
// Generate a unique ID for this countdown instance
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${headingHtml}
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
<div style="${boxStyle}"><span id="${uid}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
<div style="${boxStyle}"><span id="${uid}_h" style="${dStyle}">00</span><span style="${lStyle}">Hours</span></div>
<div style="${boxStyle}"><span id="${uid}_m" style="${dStyle}">00</span><span style="${lStyle}">Minutes</span></div>
<div style="${boxStyle}"><span id="${uid}_s" style="${dStyle}">00</span><span style="${lStyle}">Seconds</span></div>
</div>
<script>
(function(){
var target = new Date("${targetDate}").getTime();
function pad(n){ return String(n).padStart(2,'0'); }
function update(){
var diff = target - Date.now();
if(diff<=0){ diff=0; }
var d = Math.floor(diff/(1000*60*60*24));
var h = Math.floor((diff/(1000*60*60))%24);
var m = Math.floor((diff/(1000*60))%60);
var s = Math.floor((diff/1000)%60);
document.getElementById("${uid}_d").textContent = pad(d);
document.getElementById("${uid}_h").textContent = pad(h);
document.getElementById("${uid}_m").textContent = pad(m);
document.getElementById("${uid}_s").textContent = pad(s);
}
update();
setInterval(update,1000);
})();
</script>
</section>`,
};
};

View File

@@ -0,0 +1,200 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface FeatureItem {
title: string;
description: string;
icon: string;
}
interface FeaturesGridProps {
features?: FeatureItem[];
style?: CSSProperties;
}
const defaultFeatures: FeatureItem[] = [
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡' },
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨' },
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱' },
];
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
features = defaultFeatures,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '1100px', margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '32px' }}>
{(Array.isArray(features) ? features : []).map((feat, i) => (
<div
key={i}
style={{
textAlign: 'center',
padding: '32px 24px',
borderRadius: '12px',
backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0',
}}
>
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const FeaturesGridSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FeaturesGridProps,
}));
const features = props.features || defaultFeatures;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateFeature = (index: number, field: keyof FeatureItem, value: string) => {
setProp((p: FeaturesGridProps) => {
const updated = [...(p.features || defaultFeatures)];
updated[index] = { ...updated[index], [field]: value };
p.features = updated;
});
};
const addFeature = () => {
setProp((p: FeaturesGridProps) => {
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧' }];
});
};
const removeFeature = (index: number) => {
setProp((p: FeaturesGridProps) => {
const updated = [...(p.features || defaultFeatures)];
updated.splice(index, 1);
p.features = updated;
});
};
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FeaturesGridProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Features</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(Array.isArray(features) ? features : []).map((feat, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={feat.icon} onChange={(e) => updateFeature(i, 'icon', e.target.value)} placeholder="Icon" style={{ ...inputStyle, width: 40, flex: 'none', textAlign: 'center' }} />
<input type="text" value={feat.title} onChange={(e) => updateFeature(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeFeature(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<textarea
value={feat.description}
onChange={(e) => updateFeature(i, 'description', e.target.value)}
placeholder="Description"
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
))}
</div>
<button
onClick={addFeature}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Feature
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
FeaturesGrid.craft = {
displayName: 'Features Grid',
props: {
features: defaultFeatures,
style: { backgroundColor: '#ffffff' },
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: FeaturesGridSettings,
},
};
/* ---------- HTML export ---------- */
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({
padding: '80px 20px',
...props.style,
});
const cards = (props.features || defaultFeatures).map((feat) => {
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>
</div>`;
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
${cards}
</div>
</section>`,
};
};

View File

@@ -0,0 +1,322 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface GalleryImage {
src: string;
alt: string;
caption?: string;
}
interface GalleryProps {
images?: GalleryImage[];
columns?: number;
gap?: string;
style?: CSSProperties;
lightbox?: boolean;
}
const placeholderSvg = (index: number) => {
const colors = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#ec4899'];
const color = colors[index % colors.length];
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300"><rect fill="${color}" width="400" height="300" opacity="0.15"/><rect fill="${color}" x="150" y="100" width="100" height="100" rx="12" opacity="0.3"/><text x="200" y="160" text-anchor="middle" font-family="sans-serif" font-size="24" fill="${color}" opacity="0.6">${index + 1}</text></svg>`)}`;
};
const defaultImages: GalleryImage[] = [
{ src: placeholderSvg(0), alt: 'Gallery image 1', caption: 'First image' },
{ src: placeholderSvg(1), alt: 'Gallery image 2', caption: 'Second image' },
{ src: placeholderSvg(2), alt: 'Gallery image 3', caption: 'Third image' },
{ src: placeholderSvg(3), alt: 'Gallery image 4', caption: 'Fourth image' },
{ src: placeholderSvg(4), alt: 'Gallery image 5', caption: 'Fifth image' },
{ src: placeholderSvg(5), alt: 'Gallery image 6', caption: 'Sixth image' },
];
export const Gallery: UserComponent<GalleryProps> = ({
images = defaultImages,
columns = 3,
gap = '16px',
style = {},
lightbox = false,
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div
style={{
maxWidth: '1100px',
margin: '0 auto',
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: gap,
}}
>
{images.map((img, i) => (
<div key={i} style={{ position: 'relative', overflow: 'hidden', borderRadius: '8px' }}>
<img
src={img.src}
alt={img.alt}
style={{
width: '100%',
height: '200px',
objectFit: 'cover',
display: 'block',
borderRadius: '8px',
backgroundColor: '#f1f5f9',
}}
/>
{img.caption && (
<div
style={{
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
padding: '8px 12px',
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
color: '#ffffff',
fontSize: '12px',
borderBottomLeftRadius: '8px',
borderBottomRightRadius: '8px',
}}
>
{img.caption}
</div>
)}
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const GallerySettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as GalleryProps,
}));
const images = props.images || defaultImages;
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 updateImage = (index: number, field: keyof GalleryImage, value: string) => {
setProp((p: GalleryProps) => {
const updated = [...(p.images || defaultImages)];
updated[index] = { ...updated[index], [field]: value };
p.images = updated;
});
};
const addImage = () => {
setProp((p: GalleryProps) => {
const current = p.images || defaultImages;
p.images = [...current, { src: placeholderSvg(current.length), alt: `Gallery image ${current.length + 1}`, caption: '' }];
});
};
const removeImage = (index: number) => {
setProp((p: GalleryProps) => {
const updated = [...(p.images || defaultImages)];
updated.splice(index, 1);
p.images = updated;
});
};
const columnOptions = [2, 3, 4, 5, 6];
const gapOptions = ['8px', '12px', '16px', '24px', '32px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
<button
key={c}
onClick={() => setProp((p: GalleryProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{columnOptions.map((n) => (
<button
key={n}
onClick={() => setProp((p: GalleryProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.columns === n ? '#3b82f6' : '#27272a',
color: props.columns === n ? '#fff' : '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
<div>
<label style={labelStyle}>Gap</label>
<div style={{ display: 'flex', gap: 4 }}>
{gapOptions.map((g) => (
<button
key={g}
onClick={() => setProp((p: GalleryProps) => { p.gap = g; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.gap === g ? '#3b82f6' : '#27272a',
color: props.gap === g ? '#fff' : '#e4e4e7',
}}
>
{g}
</button>
))}
</div>
</div>
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.lightbox || false}
onChange={(e) => setProp((p: GalleryProps) => { p.lightbox = e.target.checked; })}
/>
Lightbox (click to enlarge in exported HTML)
</label>
</div>
<div>
<label style={labelStyle}>Images</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{images.map((img, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<img
src={img.src}
alt=""
style={{ width: 32, height: 32, objectFit: 'cover', borderRadius: 4, flexShrink: 0, backgroundColor: '#27272a' }}
/>
<input type="text" value={img.src} onChange={(e) => updateImage(i, 'src', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeImage(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={img.alt} onChange={(e) => updateImage(i, 'alt', e.target.value)} placeholder="Alt text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={img.caption || ''} onChange={(e) => updateImage(i, 'caption', e.target.value)} placeholder="Caption" style={{ ...inputStyle, flex: 1 }} />
</div>
</div>
))}
</div>
<button
onClick={addImage}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Image
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Gallery.craft = {
displayName: 'Gallery',
props: {
images: defaultImages,
columns: 3,
gap: '16px',
style: { backgroundColor: '#ffffff' },
lightbox: false,
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: GallerySettings,
},
};
/* ---------- HTML export ---------- */
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
const images = props.images || defaultImages;
const columns = props.columns || 3;
const gap = props.gap || '16px';
const lightbox = props.lightbox || false;
const galleryId = 'gallery_' + Math.random().toString(36).slice(2, 8);
const items = images.map((img) => {
const caption = img.caption
? `<div style="position:absolute;bottom:0;left:0;right:0;padding:8px 12px;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#ffffff;font-size:12px;border-bottom-left-radius:8px;border-bottom-right-radius:8px">${esc(img.caption)}</div>`
: '';
const clickAttr = lightbox ? ` onclick="${galleryId}_open('${esc(img.src)}')" style="cursor:pointer;position:relative;overflow:hidden;border-radius:8px"` : ' style="position:relative;overflow:hidden;border-radius:8px"';
return `<div${clickAttr}>
<img src="${esc(img.src)}" alt="${esc(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
${caption}
</div>`;
}).join('\n ');
let lightboxHtml = '';
if (lightbox) {
lightboxHtml = `
<div id="${galleryId}_overlay" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
</div>
<script>
function ${galleryId}_open(src){var o=document.getElementById('${galleryId}_overlay');document.getElementById('${galleryId}_img').src=src;o.style.display='flex';}
function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';}
</script>`;
}
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:${gap}">
${items}
</div>${lightboxHtml}
</section>`,
};
};

View File

@@ -0,0 +1,457 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface HeroProps {
heading?: string;
subtitle?: string;
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
secondaryButtonHref?: string;
bgType?: 'color' | 'gradient' | 'image' | 'video';
bgColor?: string;
bgGradientFrom?: string;
bgGradientTo?: string;
bgGradientAngle?: number;
bgImage?: string;
bgVideo?: string;
overlayColor?: string;
overlayOpacity?: number;
textColor?: string;
buttonBgColor?: string;
buttonTextColor?: string;
minHeight?: string;
verticalAlign?: 'top' | 'center' | 'bottom';
textAlign?: 'left' | 'center' | 'right';
style?: CSSProperties;
}
// Helper: build the background CSS value
function buildBackground(props: HeroProps): string {
switch (props.bgType) {
case 'gradient':
return `linear-gradient(${props.bgGradientAngle || 135}deg, ${props.bgGradientFrom || '#667eea'}, ${props.bgGradientTo || '#764ba2'})`;
case 'image':
return props.bgImage ? `url('${props.bgImage}') center/cover no-repeat` : '#1e293b';
case 'color':
default:
return props.bgColor || '#1e293b';
}
}
export const HeroSimple: UserComponent<HeroProps> = ({
heading = 'Build Something Amazing',
subtitle = 'Create beautiful websites without writing a single line of code.',
buttonText = 'Get Started',
buttonHref = '#',
secondaryButtonText = '',
secondaryButtonHref = '#',
bgType = 'color',
bgColor = '#1e293b',
bgGradientFrom = '#667eea',
bgGradientTo = '#764ba2',
bgGradientAngle = 135,
bgImage = '',
bgVideo = '',
overlayColor = '#000000',
overlayOpacity = 0,
textColor = '#ffffff',
buttonBgColor = '#3b82f6',
buttonTextColor = '#ffffff',
minHeight = '500px',
verticalAlign = 'center',
textAlign = 'center',
style = {},
}) => {
const { connectors: { connect, drag } } = useNode();
const bg = buildBackground({
bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage,
} as HeroProps);
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
...style,
background: bgType !== 'image' ? bg : undefined,
backgroundImage: bgType === 'image' && bgImage ? `url('${bgImage}')` : undefined,
backgroundSize: bgType === 'image' ? 'cover' : undefined,
backgroundPosition: bgType === 'image' ? 'center' : undefined,
minHeight: minHeight === '100vh' ? '100vh' : minHeight,
display: 'flex',
alignItems: justifyMap[verticalAlign] || 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
padding: '60px 20px',
}}
>
{/* Video background */}
{bgType === 'video' && bgVideo && (
<video
src={bgVideo}
autoPlay muted loop playsInline
style={{
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
objectFit: 'cover', zIndex: 0,
}}
/>
)}
{/* Overlay (renders AFTER video so it sits on top) */}
{overlayOpacity > 0 && (
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: overlayColor,
opacity: overlayOpacity / 100,
zIndex: 1,
}} />
)}
{/* Content */}
<div style={{
maxWidth: '800px',
width: '100%',
position: 'relative',
zIndex: 2,
textAlign: textAlign as any,
}}>
<h1 style={{
fontSize: '48px', fontWeight: '700', color: textColor,
marginBottom: '16px', lineHeight: '1.2',
}}>
{heading}
</h1>
<p style={{
fontSize: '20px', color: textColor,
opacity: 0.85, marginBottom: '32px', lineHeight: '1.6',
whiteSpace: 'pre-line',
}}>
{subtitle}
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
{buttonText && (
<a href={buttonHref} onClick={(e) => e.preventDefault()} style={{
display: 'inline-block', padding: '14px 36px', backgroundColor: buttonBgColor,
color: buttonTextColor, textDecoration: 'none', borderRadius: '8px',
fontWeight: '600', fontSize: '16px',
}}>
{buttonText}
</a>
)}
{secondaryButtonText && (
<a href={secondaryButtonHref} onClick={(e) => e.preventDefault()} style={{
display: 'inline-block', padding: '14px 36px',
backgroundColor: 'transparent', color: textColor,
textDecoration: 'none', borderRadius: '8px', fontWeight: '600',
fontSize: '16px', border: `2px solid ${textColor}`,
}}>
{secondaryButtonText}
</a>
)}
</div>
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const inputStyle: React.CSSProperties = {
width: '100%', padding: '6px 8px', background: '#27272a',
color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const labelStyle: React.CSSProperties = {
fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4,
};
const btnStyle = (active: boolean): React.CSSProperties => ({
flex: 1, padding: '6px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
fontWeight: active ? 600 : 400,
});
const HeroSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as HeroProps,
}));
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Content */}
<div>
<label style={labelStyle}>Heading</label>
<input type="text" value={props.heading || ''} onChange={(e) => setProp((p: HeroProps) => { p.heading = e.target.value; })} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Subtitle</label>
<textarea value={props.subtitle || ''} onChange={(e) => setProp((p: HeroProps) => { p.subtitle = e.target.value; })} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
</div>
<div>
<label style={labelStyle}>Button Text</label>
<input type="text" value={props.buttonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonText = e.target.value; })} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Button URL</label>
<input type="text" value={props.buttonHref || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonHref = e.target.value; })} placeholder="#" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Secondary Button Text</label>
<input type="text" value={props.secondaryButtonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.secondaryButtonText = e.target.value; })} placeholder="Leave blank to hide" style={inputStyle} />
</div>
{/* Background Type */}
<div>
<label style={labelStyle}>Background Type</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
<button key={t} onClick={() => setProp((p: HeroProps) => { p.bgType = t; })}
style={btnStyle(props.bgType === t)}>
{t === 'color' ? 'Color' : t === 'gradient' ? 'Gradient' : t === 'image' ? 'Image' : 'Video'}
</button>
))}
</div>
</div>
{/* Background controls based on type */}
{props.bgType === 'color' && (
<div>
<label style={labelStyle}>Background Color</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" value={props.bgColor || '#1e293b'}
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="text" value={props.bgColor || '#1e293b'}
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
style={{ ...inputStyle, flex: 1 }} />
</div>
</div>
)}
{props.bgType === 'gradient' && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>From</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={props.bgGradientFrom || '#667eea'}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="text" value={props.bgGradientFrom || '#667eea'}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
style={{ ...inputStyle, fontSize: 10 }} />
</div>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>To</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={props.bgGradientTo || '#764ba2'}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="text" value={props.bgGradientTo || '#764ba2'}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
style={{ ...inputStyle, fontSize: 10 }} />
</div>
</div>
</div>
<div>
<label style={labelStyle}>Angle: {props.bgGradientAngle || 135}°</label>
<input type="range" min={0} max={360} value={props.bgGradientAngle || 135}
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientAngle = parseInt(e.target.value); })}
style={{ width: '100%' }} />
</div>
</>
)}
{props.bgType === 'image' && (
<div>
<label style={labelStyle}>Background Image URL</label>
<input type="text" value={props.bgImage || ''} placeholder="https://..."
onChange={(e) => setProp((p: HeroProps) => { p.bgImage = e.target.value; })} style={inputStyle} />
</div>
)}
{props.bgType === 'video' && (
<div>
<label style={labelStyle}>Background Video URL</label>
<input type="text" value={props.bgVideo || ''} placeholder="https://...mp4"
onChange={(e) => setProp((p: HeroProps) => { p.bgVideo = e.target.value; })} style={inputStyle} />
</div>
)}
{/* Overlay */}
{(props.bgType === 'image' || props.bgType === 'video') && (
<div>
<label style={labelStyle}>Overlay ({props.overlayOpacity || 0}%)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" value={props.overlayColor || '#000000'}
onChange={(e) => setProp((p: HeroProps) => { p.overlayColor = e.target.value; })}
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="range" min={0} max={100} value={props.overlayOpacity || 0}
onChange={(e) => setProp((p: HeroProps) => { p.overlayOpacity = parseInt(e.target.value); })}
style={{ flex: 1 }} />
</div>
</div>
)}
{/* Text & Button Colors */}
<div>
<label style={labelStyle}>Text Color</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" value={props.textColor || '#ffffff'}
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="text" value={props.textColor || '#ffffff'}
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
style={{ ...inputStyle, flex: 1 }} />
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Button BG</label>
<input type="color" value={props.buttonBgColor || '#3b82f6'}
onChange={(e) => setProp((p: HeroProps) => { p.buttonBgColor = e.target.value; })}
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Button Text</label>
<input type="color" value={props.buttonTextColor || '#ffffff'}
onChange={(e) => setProp((p: HeroProps) => { p.buttonTextColor = e.target.value; })}
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
</div>
</div>
{/* Layout */}
<div>
<label style={labelStyle}>Min Height</label>
<div style={{ display: 'flex', gap: 4 }}>
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
<button key={h} onClick={() => setProp((p: HeroProps) => { p.minHeight = h; })}
style={btnStyle(props.minHeight === h)}>{h === '100vh' ? 'Full' : h}</button>
))}
</div>
</div>
<div>
<label style={labelStyle}>Vertical Align</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['top', 'center', 'bottom'] as const).map((v) => (
<button key={v} onClick={() => setProp((p: HeroProps) => { p.verticalAlign = v; })}
style={btnStyle(props.verticalAlign === v)}>{v}</button>
))}
</div>
</div>
<div>
<label style={labelStyle}>Text Align</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} onClick={() => setProp((p: HeroProps) => { p.textAlign = a; })}
style={btnStyle(props.textAlign === a)}>
<i className={`fa fa-align-${a}`} />
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
HeroSimple.craft = {
displayName: 'Hero',
props: {
heading: 'Build Something Amazing',
subtitle: 'Create beautiful websites without writing a single line of code.',
buttonText: 'Get Started',
buttonHref: '#',
secondaryButtonText: '',
secondaryButtonHref: '#',
bgType: 'color',
bgColor: '#1e293b',
bgGradientFrom: '#667eea',
bgGradientTo: '#764ba2',
bgGradientAngle: 135,
bgImage: '',
bgVideo: '',
overlayColor: '#000000',
overlayOpacity: 0,
textColor: '#ffffff',
buttonBgColor: '#3b82f6',
buttonTextColor: '#ffffff',
minHeight: '500px',
verticalAlign: 'center',
textAlign: 'center',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: HeroSettings,
},
};
/* ---------- HTML export ---------- */
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bg = buildBackground(props);
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
const sectionStyle = cssPropsToString({
background: props.bgType !== 'image' ? bg : undefined,
backgroundImage: props.bgType === 'image' && props.bgImage ? `url('${props.bgImage}')` : undefined,
backgroundSize: props.bgType === 'image' ? 'cover' : undefined,
backgroundPosition: props.bgType === 'image' ? 'center' : undefined,
minHeight: props.minHeight || '500px',
display: 'flex',
alignItems: justifyMap[props.verticalAlign || 'center'],
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
padding: '60px 20px',
...props.style,
});
let overlayHtml = '';
if ((props.overlayOpacity || 0) > 0) {
overlayHtml = `<div style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:${props.overlayColor || '#000'};opacity:${(props.overlayOpacity || 0) / 100};z-index:1"></div>`;
}
let videoHtml = '';
if (props.bgType === 'video' && props.bgVideo) {
videoHtml = `<video src="${props.bgVideo}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`;
}
const textAlign = props.textAlign || 'center';
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
let buttonsHtml = '';
if (props.buttonText) {
buttonsHtml += `<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:${props.buttonBgColor || '#3b82f6'};color:${props.buttonTextColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText)}</a>`;
}
if (props.secondaryButtonText) {
buttonsHtml += `<a href="${props.secondaryButtonHref || '#'}" style="display:inline-block;padding:14px 36px;background:transparent;color:${props.textColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${props.textColor || '#fff'}">${esc(props.secondaryButtonText)}</a>`;
}
return {
html: `<section style="${sectionStyle}">
${videoHtml}${overlayHtml}
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
<h1 style="font-size:48px;font-weight:700;color:${props.textColor || '#fff'};margin-bottom:16px;line-height:1.2">${esc(props.heading || '')}</h1>
<p style="font-size:20px;color:${props.textColor || '#fff'};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${esc(props.subtitle || '')}</p>
<div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
</div>
</section>`,
};
};

View File

@@ -0,0 +1,368 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface Counter {
number: number;
suffix: string;
label: string;
}
interface NumberCounterProps {
counters?: Counter[];
columns?: number;
numberColor?: string;
labelColor?: string;
numberSize?: string;
style?: CSSProperties;
}
const defaultCounters: Counter[] = [
{ number: 150, suffix: '+', label: 'Projects' },
{ number: 50, suffix: '+', label: 'Clients' },
{ number: 10, suffix: '', label: 'Years' },
{ number: 99, suffix: '%', label: 'Satisfaction' },
];
export const NumberCounter: UserComponent<NumberCounterProps> = ({
counters = defaultCounters,
columns = 4,
numberColor = '#3b82f6',
labelColor = '#6b7280',
numberSize = '48px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const items = counters.length > 0 ? counters : defaultCounters;
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div
style={{
maxWidth: '1100px',
margin: '0 auto',
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: '32px',
textAlign: 'center',
}}
>
{items.map((counter, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: numberSize,
fontWeight: '700',
color: numberColor,
lineHeight: '1.1',
fontFamily: 'Inter, sans-serif',
}}>
{counter.number}{counter.suffix}
</span>
<span style={{
fontSize: '15px',
color: labelColor,
fontFamily: 'Inter, sans-serif',
fontWeight: '500',
}}>
{counter.label}
</span>
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const NumberCounterSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as NumberCounterProps,
}));
const items = props.counters || defaultCounters;
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,
};
const columnOptions = [2, 3, 4, 5, 6];
const sizePresets = ['32px', '40px', '48px', '56px', '64px'];
const numberColorPresets = ['#3b82f6', '#10b981', '#8b5cf6', '#ef4444', '#f59e0b', '#18181b', '#ec4899', '#0ea5e9'];
const labelColorPresets = ['#6b7280', '#374151', '#9ca3af', '#a1a1aa', '#64748b', '#18181b'];
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
const updateCounter = (index: number, field: keyof Counter, value: string | number) => {
setProp((p: NumberCounterProps) => {
const updated = [...(p.counters || defaultCounters)];
updated[index] = { ...updated[index], [field]: value };
p.counters = updated;
});
};
const addCounter = () => {
setProp((p: NumberCounterProps) => {
p.counters = [...(p.counters || defaultCounters), { number: 100, suffix: '+', label: 'New Stat' }];
});
};
const removeCounter = (index: number) => {
setProp((p: NumberCounterProps) => {
const updated = [...(p.counters || defaultCounters)];
updated.splice(index, 1);
p.counters = updated;
});
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Columns */}
<div>
<label style={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{columnOptions.map((n) => (
<button
key={n}
onClick={() => setProp((p: NumberCounterProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.columns || 4) === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
{/* Number Size */}
<div>
<label style={labelStyle}>Number Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: NumberCounterProps) => { p.numberSize = s; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.numberSize || '48px') === s ? '#3b82f6' : '#27272a',
color: (props.numberSize || '48px') === s ? '#fff' : '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Number Color */}
<div>
<label style={labelStyle}>Number Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{numberColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NumberCounterProps) => { p.numberColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: (props.numberColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Label Color */}
<div>
<label style={labelStyle}>Label Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{labelColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NumberCounterProps) => { p.labelColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: (props.labelColor || '#6b7280') === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NumberCounterProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Counters */}
<div>
<label style={labelStyle}>Counters</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((counter, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="number"
value={counter.number}
onChange={(e) => updateCounter(i, 'number', parseInt(e.target.value) || 0)}
placeholder="Number"
style={{ ...inputStyle, width: 70, flex: 'none' }}
/>
<input
type="text"
value={counter.suffix}
onChange={(e) => updateCounter(i, 'suffix', e.target.value)}
placeholder="Suffix"
style={{ ...inputStyle, width: 40, flex: 'none' }}
/>
<input
type="text"
value={counter.label}
onChange={(e) => updateCounter(i, 'label', e.target.value)}
placeholder="Label"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeCounter(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
</div>
))}
</div>
<button
onClick={addCounter}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Counter
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
NumberCounter.craft = {
displayName: 'Number Counter',
props: {
counters: defaultCounters,
columns: 4,
numberColor: '#3b82f6',
labelColor: '#6b7280',
numberSize: '48px',
style: { backgroundColor: '#ffffff' },
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: NumberCounterSettings,
},
};
/* ---------- HTML export ---------- */
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
counters = defaultCounters,
columns = 4,
numberColor = '#3b82f6',
labelColor = '#6b7280',
numberSize = '48px',
style = {},
} = props;
const items = counters.length > 0 ? counters : defaultCounters;
const uid = 'nc_' + Math.random().toString(36).slice(2, 8);
const sectionStyle = cssPropsToString({
padding: '60px 20px',
backgroundColor: '#ffffff',
...style,
});
const countersHtml = items.map((counter, i) => {
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
<span id="${uid}_n${i}" data-target="${counter.number}" data-suffix="${esc(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${esc(counter.suffix)}</span>
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${esc(counter.label)}</span>
</div>`;
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div id="${uid}" style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:32px;text-align:center">
${countersHtml}
</div>
<script>
(function(){
var uid="${uid}",started=false;
function animate(){
if(started)return;started=true;
for(var i=0;i<${items.length};i++){
(function(el){
var target=parseInt(el.getAttribute("data-target")),
suffix=el.getAttribute("data-suffix")||"",
current=0,
step=Math.max(1,Math.floor(target/60)),
timer=setInterval(function(){
current+=step;
if(current>=target){current=target;clearInterval(timer);}
el.textContent=current+suffix;
},16);
})(document.getElementById(uid+"_n"+i));
}
}
if("IntersectionObserver"in window){
var obs=new IntersectionObserver(function(entries){
entries.forEach(function(e){if(e.isIntersecting){animate();obs.disconnect();}});
},{threshold:0.2});
obs.observe(document.getElementById(uid));
}else{animate();}
})();
</script>
</section>`,
};
};

View File

@@ -0,0 +1,451 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface PricingPlan {
name: string;
price: string;
period: string;
features: string[];
buttonText: string;
buttonHref: string;
isFeatured: boolean;
}
interface PricingTableProps {
plans?: PricingPlan[];
style?: CSSProperties;
featuredBg?: string;
bulletType?: string;
}
const bulletChars: Record<string, string> = {
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
};
const defaultPlans: PricingPlan[] = [
{
name: 'Basic',
price: '$9',
period: '/month',
features: ['1 Website', '10 GB Storage', 'Free SSL Certificate', 'Email Support'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: false,
},
{
name: 'Pro',
price: '$29',
period: '/month',
features: ['10 Websites', '100 GB Storage', 'Free SSL Certificate', 'Priority Support', 'Custom Domain', 'Analytics Dashboard'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: true,
},
{
name: 'Enterprise',
price: '$99',
period: '/month',
features: ['Unlimited Websites', '1 TB Storage', 'Free SSL Certificate', '24/7 Phone Support', 'Custom Domain', 'Advanced Analytics', 'API Access', 'Team Collaboration'],
buttonText: 'Contact Sales',
buttonHref: '#',
isFeatured: false,
},
];
export const PricingTable: UserComponent<PricingTableProps> = ({
plans = defaultPlans,
style = {},
featuredBg = '#3b82f6',
bulletType = 'check',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{
maxWidth: '1100px',
margin: '0 auto',
display: 'flex',
gap: '24px',
justifyContent: 'center',
alignItems: 'stretch',
flexWrap: 'wrap',
}}>
{plans.map((plan, i) => (
<div
key={i}
style={{
flex: '1 1 280px',
maxWidth: '360px',
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff',
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
borderRadius: '16px',
padding: '40px 32px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
position: 'relative',
transform: plan.isFeatured ? 'scale(1.05)' : 'none',
boxShadow: plan.isFeatured ? '0 20px 60px rgba(59,130,246,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
}}
>
{plan.isFeatured && (
<div style={{
position: 'absolute',
top: '-12px',
backgroundColor: '#facc15',
color: '#18181b',
padding: '4px 16px',
borderRadius: '9999px',
fontSize: '12px',
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}>
Most Popular
</div>
)}
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: plan.isFeatured ? '#ffffff' : '#18181b',
marginBottom: '8px',
marginTop: plan.isFeatured ? '8px' : '0',
}}>
{plan.name}
</h3>
<div style={{ marginBottom: '24px' }}>
<span style={{
fontSize: '48px',
fontWeight: '700',
color: plan.isFeatured ? '#ffffff' : '#18181b',
lineHeight: '1',
}}>
{plan.price}
</span>
<span style={{
fontSize: '16px',
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b',
}}>
{plan.period}
</span>
</div>
<ul style={{
listStyle: 'none',
padding: '0',
margin: '0 0 32px 0',
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}>
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
<li key={fi} style={{
fontSize: '14px',
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{ color: plan.isFeatured ? '#bbf7d0' : '#10b981', fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
{feature}
</li>
))}
</ul>
<a
href={plan.buttonHref}
onClick={(e) => e.preventDefault()}
style={{
marginTop: 'auto',
display: 'inline-block',
padding: '14px 32px',
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg,
color: plan.isFeatured ? featuredBg : '#ffffff',
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '14px',
width: '100%',
textAlign: 'center',
}}
>
{plan.buttonText}
</a>
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const PricingTableSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as PricingTableProps,
}));
const plans = props.plans || defaultPlans;
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 updatePlan = (index: number, field: keyof PricingPlan, value: any) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated[index] = { ...updated[index], [field]: value };
p.plans = updated;
});
};
const updateFeature = (planIndex: number, featureIndex: number, value: string) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
features[featureIndex] = value;
updated[planIndex] = { ...updated[planIndex], features };
p.plans = updated;
});
};
const addFeature = (planIndex: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated[planIndex] = { ...updated[planIndex], features: [...updated[planIndex].features, 'New Feature'] };
p.plans = updated;
});
};
const removeFeature = (planIndex: number, featureIndex: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
features.splice(featureIndex, 1);
updated[planIndex] = { ...updated[planIndex], features };
p.plans = updated;
});
};
const addPlan = () => {
setProp((p: PricingTableProps) => {
p.plans = [...(p.plans || defaultPlans), {
name: 'New Plan',
price: '$19',
period: '/month',
features: ['Feature 1', 'Feature 2'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: false,
}];
});
};
const removePlan = (index: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated.splice(index, 1);
p.plans = updated;
});
};
const bgPresets = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#0f172a', '#7c3aed'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
<button
key={c}
onClick={() => setProp((p: PricingTableProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Featured Plan Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: PricingTableProps) => { p.featuredBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.featuredBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Plans</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{plans.map((plan, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.name} onChange={(e) => updatePlan(i, 'name', e.target.value)} placeholder="Plan Name" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removePlan(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.price} onChange={(e) => updatePlan(i, 'price', e.target.value)} placeholder="$29" style={{ ...inputStyle, width: '60px', flex: 'none' }} />
<input type="text" value={plan.period} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={{ ...inputStyle, width: '70px', flex: 'none' }} />
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', marginLeft: 'auto' }}>
<input
type="checkbox"
checked={plan.isFeatured}
onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)}
/>
Featured
</label>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.buttonText} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} placeholder="Button Text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={plan.buttonHref} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} placeholder="URL" style={{ ...inputStyle, flex: 1 }} />
</div>
{/* Features */}
<div style={{ marginTop: 4 }}>
<span style={{ fontSize: 10, color: '#71717a' }}>Features:</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 }}>
{(Array.isArray(plan.features) ? plan.features : []).map((feat, fi) => (
<div key={fi} style={{ display: 'flex', gap: 2 }}>
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeFeature(i, fi)}
style={{ padding: '1px 4px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, cursor: 'pointer' }}
>
X
</button>
</div>
))}
</div>
<button
onClick={() => addFeature(i)}
style={{ marginTop: 2, width: '100%', padding: '3px', fontSize: 10, background: '#27272a', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer' }}
>
+ Feature
</button>
</div>
</div>
))}
</div>
<button
onClick={addPlan}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Plan
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
PricingTable.craft = {
displayName: 'Pricing Table',
props: {
plans: defaultPlans,
style: { backgroundColor: '#ffffff' },
featuredBg: '#3b82f6',
bulletType: 'check',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: PricingTableSettings,
},
};
/* ---------- HTML export ---------- */
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bulletType = props.bulletType || 'check';
const sectionStyle = cssPropsToString({
padding: '80px 20px',
...props.style,
});
const plans = props.plans || defaultPlans;
const featuredBg = props.featuredBg || '#3b82f6';
const cards = plans.map((plan) => {
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
const textColor = plan.isFeatured ? '#ffffff' : '#18181b';
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b';
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563';
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981';
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg;
const btnColor = plan.isFeatured ? featuredBg : '#ffffff';
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
const featuresHtml = (Array.isArray(plan.features) ? plan.features : []).map((f) =>
`<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${esc(f)}</li>`
).join('\n ');
const badge = plan.isFeatured
? `<div style="position:absolute;top:-12px;background-color:#facc15;color:#18181b;padding:4px 16px;border-radius:9999px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Most Popular</div>`
: '';
return `<div style="flex:1 1 280px;max-width:360px;background-color:${cardBg};${cardBorder}border-radius:16px;padding:40px 32px;display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;${scale}${shadow}">
${badge}
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${esc(plan.name)}</h3>
<div style="margin-bottom:24px">
<span style="font-size:48px;font-weight:700;color:${textColor};line-height:1">${esc(plan.price)}</span>
<span style="font-size:16px;color:${subColor}">${esc(plan.period)}</span>
</div>
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
${featuresHtml}
</ul>
<a href="${plan.buttonHref || '#'}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${esc(plan.buttonText)}</a>
</div>`;
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
${cards}
</div>
</section>`,
};
};

View File

@@ -0,0 +1,339 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface TabItem {
label: string;
content: string;
}
interface TabsProps {
tabs?: TabItem[];
style?: CSSProperties;
activeTabBg?: string;
activeTabColor?: string;
inactiveTabBg?: string;
inactiveTabColor?: string;
contentBg?: string;
}
const defaultTabs: TabItem[] = [
{ label: 'Overview', content: 'Welcome to our platform. We provide the tools you need to build, launch, and grow your online presence. Our intuitive interface makes it simple to get started in minutes.' },
{ label: 'Features', content: 'Drag-and-drop editor, responsive templates, custom domains, analytics dashboard, SEO tools, and integrations with your favorite services. Everything you need in one place.' },
{ label: 'Support', content: 'Our dedicated support team is available 24/7 to help you with any questions. Access our knowledge base, community forums, or reach out directly via live chat or email.' },
];
export const Tabs: UserComponent<TabsProps> = ({
tabs = defaultTabs,
style = {},
activeTabBg = '#3b82f6',
activeTabColor = '#ffffff',
inactiveTabBg = '#f1f5f9',
inactiveTabColor = '#64748b',
contentBg = '#ffffff',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [activeIndex, setActiveIndex] = useState(0);
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
{/* Tab buttons */}
<div style={{ display: 'flex', gap: '2px', borderBottom: '2px solid #e2e8f0' }}>
{tabs.map((tab, i) => (
<button
key={i}
onClick={() => setActiveIndex(i)}
style={{
padding: '12px 24px',
fontSize: '14px',
fontWeight: '600',
border: 'none',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
cursor: 'pointer',
backgroundColor: i === activeIndex ? activeTabBg : inactiveTabBg,
color: i === activeIndex ? activeTabColor : inactiveTabColor,
transition: 'background-color 0.2s, color 0.2s',
}}
>
{tab.label}
</button>
))}
</div>
{/* Content panel */}
<div
style={{
padding: '24px',
backgroundColor: contentBg,
border: '1px solid #e2e8f0',
borderTop: 'none',
borderBottomLeftRadius: '8px',
borderBottomRightRadius: '8px',
fontSize: '14px',
lineHeight: '1.7',
color: '#4b5563',
minHeight: '100px',
}}
>
{tabs[activeIndex]?.content || ''}
</div>
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const TabsSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TabsProps,
}));
const tabs = props.tabs || defaultTabs;
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 updateTab = (index: number, field: keyof TabItem, value: string) => {
setProp((p: TabsProps) => {
const updated = [...(p.tabs || defaultTabs)];
updated[index] = { ...updated[index], [field]: value };
p.tabs = updated;
});
};
const addTab = () => {
setProp((p: TabsProps) => {
p.tabs = [...(p.tabs || defaultTabs), { label: 'New Tab', content: 'Tab content goes here.' }];
});
};
const removeTab = (index: number) => {
setProp((p: TabsProps) => {
const updated = [...(p.tabs || defaultTabs)];
updated.splice(index, 1);
p.tabs = updated;
});
};
const colorSwatches = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#ffffff', '#f1f5f9'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Active Tab Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorSwatches.map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.activeTabBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.activeTabBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Active Tab Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#18181b', '#1f2937', '#e2e8f0'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.activeTabColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.activeTabColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Inactive Tab Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#f1f5f9', '#e2e8f0', '#f8fafc', '#ffffff', '#27272a', '#18181b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.inactiveTabBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.inactiveTabBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Inactive Tab Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#64748b', '#94a3b8', '#18181b', '#ffffff'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.inactiveTabColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.inactiveTabColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Content Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.contentBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Tabs</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{tabs.map((tab, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={tab.label} onChange={(e) => updateTab(i, 'label', e.target.value)} placeholder="Label" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeTab(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<textarea
value={tab.content}
onChange={(e) => updateTab(i, 'content', e.target.value)}
placeholder="Content"
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
))}
</div>
<button
onClick={addTab}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Tab
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Tabs.craft = {
displayName: 'Tabs',
props: {
tabs: defaultTabs,
style: { backgroundColor: '#ffffff' },
activeTabBg: '#3b82f6',
activeTabColor: '#ffffff',
inactiveTabBg: '#f1f5f9',
inactiveTabColor: '#64748b',
contentBg: '#ffffff',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TabsSettings,
},
};
/* ---------- HTML export ---------- */
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
const tabs = props.tabs || defaultTabs;
const activeTabBg = props.activeTabBg || '#3b82f6';
const activeTabColor = props.activeTabColor || '#ffffff';
const inactiveTabBg = props.inactiveTabBg || '#f1f5f9';
const inactiveTabColor = props.inactiveTabColor || '#64748b';
const contentBg = props.contentBg || '#ffffff';
const tabId = 'tabs_' + Math.random().toString(36).slice(2, 8);
const tabButtons = tabs.map((tab, i) => {
const isActive = i === 0;
return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" style="padding:12px 24px;font-size:14px;font-weight:600;border:none;border-top-left-radius:8px;border-top-right-radius:8px;cursor:pointer;background-color:${isActive ? activeTabBg : inactiveTabBg};color:${isActive ? activeTabColor : inactiveTabColor}">${esc(tab.label)}</button>`;
}).join('\n ');
const tabPanels = tabs.map((tab, i) => {
return `<div id="${tabId}_panel_${i}" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${esc(tab.content)}</div>`;
}).join('\n ');
const switchScript = `<script>
function ${tabId}_switch(idx){
var total=${tabs.length};
for(var i=0;i<total;i++){
document.getElementById('${tabId}_panel_'+i).style.display=i===idx?'':'none';
var btn=document.getElementById('${tabId}_btn_'+i);
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
}
}
</script>`;
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto">
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
${tabButtons}
</div>
${tabPanels}
</div>
${switchScript}
</section>`,
};
};

View File

@@ -0,0 +1,421 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface Testimonial {
quote: string;
name: string;
title: string;
rating: number;
}
interface TestimonialsProps {
testimonials?: Testimonial[];
layout?: 'grid' | 'single';
columns?: number;
style?: CSSProperties;
cardBg?: string;
starColor?: string;
}
const defaultTestimonials: Testimonial[] = [
{ quote: 'This product has completely transformed our workflow. Highly recommended for any team.', name: 'Sarah Johnson', title: 'Marketing Director', rating: 5 },
{ quote: 'Outstanding support and an incredibly intuitive interface. We saw results from day one.', name: 'Michael Chen', title: 'CTO, TechStart', rating: 5 },
{ quote: 'The best investment we have made this year. Simple, powerful, and reliable.', name: 'Emily Rodriguez', title: 'Founder, DesignLab', rating: 4 },
];
function renderStars(count: number, color: string): React.ReactNode {
return (
<div style={{ display: 'flex', gap: '2px', justifyContent: 'center', marginBottom: '12px' }}>
{[1, 2, 3, 4, 5].map((i) => (
<i
key={i}
className={`fa ${i <= count ? 'fa-star' : 'fa-star-o'}`}
style={{ color, fontSize: '14px' }}
/>
))}
</div>
);
}
function starsHtml(count: number, color: string): string {
const stars = [1, 2, 3, 4, 5].map((i) =>
`<i class="fa ${i <= count ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px"></i>`
).join('');
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px">${stars}</div>`;
}
export const Testimonials: UserComponent<TestimonialsProps> = ({
testimonials = defaultTestimonials,
layout = 'grid',
columns = 3,
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [currentIndex, setCurrentIndex] = useState(0);
const cardStyle: CSSProperties = {
backgroundColor: cardBg,
borderRadius: '12px',
padding: '32px 24px',
textAlign: 'center',
border: '1px solid #e2e8f0',
};
const renderCard = (t: Testimonial, i: number) => (
<div key={i} style={cardStyle}>
{renderStars(t.rating, starColor)}
<p style={{ fontSize: '15px', color: '#374151', lineHeight: '1.7', marginBottom: '16px', fontStyle: 'italic', fontFamily: 'Inter, sans-serif' }}>
&ldquo;{t.quote}&rdquo;
</p>
<div style={{ fontWeight: '600', fontSize: '14px', color: '#18181b', fontFamily: 'Inter, sans-serif' }}>{t.name}</div>
<div style={{ fontSize: '13px', color: '#64748b', fontFamily: 'Inter, sans-serif' }}>{t.title}</div>
</div>
);
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '1100px', margin: '0 auto' }}>
{layout === 'grid' ? (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '24px' }}>
{items.map((t, i) => renderCard(t, i))}
</div>
) : (
<div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}>
{renderCard(items[currentIndex] || items[0], currentIndex)}
{items.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '20px' }}>
<button
onClick={() => setCurrentIndex((prev) => (prev - 1 + items.length) % items.length)}
style={{
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: '#374151',
}}
>
<i className="fa fa-chevron-left" />
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{items.map((_, i) => (
<div
key={i}
onClick={() => setCurrentIndex(i)}
style={{
width: 8, height: 8, borderRadius: '50%', cursor: 'pointer',
backgroundColor: i === currentIndex ? '#3b82f6' : '#d1d5db',
}}
/>
))}
</div>
<button
onClick={() => setCurrentIndex((prev) => (prev + 1) % items.length)}
style={{
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: '#374151',
}}
>
<i className="fa fa-chevron-right" />
</button>
</div>
)}
</div>
)}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const TestimonialsSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TestimonialsProps,
}));
const items = props.testimonials || defaultTestimonials;
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,
};
const updateTestimonial = (index: number, field: keyof Testimonial, value: string | number) => {
setProp((p: TestimonialsProps) => {
const updated = [...(p.testimonials || defaultTestimonials)];
updated[index] = { ...updated[index], [field]: value };
p.testimonials = updated;
});
};
const addTestimonial = () => {
setProp((p: TestimonialsProps) => {
p.testimonials = [...(p.testimonials || defaultTestimonials), { quote: 'Great experience!', name: 'New Person', title: 'Role', rating: 5 }];
});
};
const removeTestimonial = (index: number) => {
setProp((p: TestimonialsProps) => {
const updated = [...(p.testimonials || defaultTestimonials)];
updated.splice(index, 1);
p.testimonials = updated;
});
};
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
const cardBgPresets = ['#f8fafc', '#ffffff', '#f1f5f9', '#e2e8f0', '#27272a', '#1e293b'];
const starColorPresets = ['#f59e0b', '#eab308', '#ef4444', '#3b82f6', '#10b981', '#8b5cf6'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Layout */}
<div>
<label style={labelStyle}>Layout</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'grid'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'grid' ? '#3b82f6' : '#27272a',
color: props.layout === 'grid' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Grid
</button>
<button
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'single'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'single' ? '#3b82f6' : '#27272a',
color: props.layout === 'single' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Single
</button>
</div>
</div>
{/* Columns (only for grid) */}
{props.layout === 'grid' && (
<div>
<label style={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{[1, 2, 3, 4].map((n) => (
<button
key={n}
onClick={() => setProp((p: TestimonialsProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.columns === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
)}
{/* Section background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Card background */}
<div>
<label style={labelStyle}>Card Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{cardBgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.cardBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.cardBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Star color */}
<div>
<label style={labelStyle}>Star Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{starColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.starColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.starColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Testimonials list */}
<div>
<label style={labelStyle}>Testimonials</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((t, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={t.name} onChange={(e) => updateTestimonial(i, 'name', e.target.value)} placeholder="Name" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeTestimonial(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<input type="text" value={t.title} onChange={(e) => updateTestimonial(i, 'title', e.target.value)} placeholder="Title/Role" style={inputStyle} />
<textarea
value={t.quote}
onChange={(e) => updateTestimonial(i, 'quote', e.target.value)}
placeholder="Quote..."
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 11, color: '#a1a1aa' }}>Rating:</span>
{[1, 2, 3, 4, 5].map((n) => (
<i
key={n}
className={`fa ${n <= t.rating ? 'fa-star' : 'fa-star-o'}`}
onClick={() => updateTestimonial(i, 'rating', n)}
style={{ color: '#f59e0b', cursor: 'pointer', fontSize: 14 }}
/>
))}
</div>
</div>
))}
</div>
<button
onClick={addTestimonial}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Testimonial
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Testimonials.craft = {
displayName: 'Testimonials',
props: {
testimonials: defaultTestimonials,
layout: 'grid',
columns: 3,
style: { backgroundColor: '#ffffff' },
cardBg: '#f8fafc',
starColor: '#f59e0b',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TestimonialsSettings,
},
};
/* ---------- HTML export ---------- */
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
testimonials = defaultTestimonials,
layout = 'grid',
columns = 3,
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
} = props;
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
const sectionStyle = cssPropsToString({
padding: '80px 20px',
backgroundColor: '#ffffff',
...style,
});
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
const cards = items.map((t) => {
return `<div style="${cardCss}">
${starsHtml(t.rating, starColor)}
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">&ldquo;${esc(t.quote)}&rdquo;</p>
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif">${esc(t.name)}</div>
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${esc(t.title)}</div>
</div>`;
}).join('\n ');
if (layout === 'single') {
// For single layout, export as grid with 1 column (simpler static export)
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
${cards}
</div>
</section>`,
};
}
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
${cards}
</div>
</section>`,
};
};

View File

@@ -0,0 +1,86 @@
export const TEXT_COLORS = [
{ label: 'Dark', value: '#1f2937' },
{ label: 'Medium', value: '#374151' },
{ label: 'Light', value: '#6b7280' },
{ label: 'White', value: '#ffffff' },
{ label: 'Blue', value: '#3b82f6' },
{ label: 'Green', value: '#10b981' },
{ label: 'Red', value: '#ef4444' },
{ label: 'Orange', value: '#f59e0b' },
];
export const BG_COLORS = [
{ label: 'White', value: '#ffffff' },
{ label: 'Off-white', value: '#f9fafb' },
{ label: 'Dark', value: '#1f2937' },
{ label: 'Darker', value: '#111827' },
{ label: 'Blue', value: '#3b82f6' },
{ label: 'Green', value: '#10b981' },
{ label: 'Purple', value: '#8b5cf6' },
{ label: 'Pink', value: '#ec4899' },
];
export const FONT_FAMILIES = [
{ label: 'Inter', value: 'Inter, sans-serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
{ label: 'Poppins', value: 'Poppins, sans-serif' },
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
{ label: 'Playfair', value: 'Playfair Display, serif' },
{ label: 'Merriweather', value: 'Merriweather, serif' },
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
];
export const TEXT_SIZES = [
{ label: 'XS', value: '12px' },
{ label: 'S', value: '14px' },
{ label: 'M', value: '16px' },
{ label: 'L', value: '20px' },
{ label: 'XL', value: '24px' },
{ label: '2XL', value: '32px' },
];
export const FONT_WEIGHTS = [
{ label: 'Light', value: '300' },
{ label: 'Normal', value: '400' },
{ label: 'Medium', value: '500' },
{ label: 'Semi', value: '600' },
{ label: 'Bold', value: '700' },
];
export const SPACING_PRESETS = [
{ label: 'None', value: '0' },
{ label: 'S', value: '8px' },
{ label: 'M', value: '16px' },
{ label: 'L', value: '24px' },
{ label: 'XL', value: '32px' },
];
export const RADIUS_PRESETS = [
{ label: 'None', value: '0' },
{ label: 'S', value: '4px' },
{ label: 'M', value: '8px' },
{ label: 'L', value: '16px' },
{ label: 'Full', value: '9999px' },
];
export const GRADIENTS = [
{ label: 'Purple Dream', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Pink Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
{ label: 'Ocean Blue', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ label: 'Green Teal', value: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
{ label: 'Warm Sunrise', value: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
{ label: 'Soft Pastel', value: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)' },
{ label: 'Peach', value: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)' },
{ label: 'Warm Sand', value: 'linear-gradient(135deg, #f5af19 0%, #f12711 100%)' },
{ label: 'Dark Purple', value: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' },
{ label: 'Dark Blue', value: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)' },
{ label: 'Dark Gray', value: 'linear-gradient(135deg, #1f2937 0%, #111827 100%)' },
{ label: 'None', value: 'none' },
];
export const DEVICE_WIDTHS: Record<string, string> = {
desktop: '100%',
tablet: '768px',
mobile: '375px',
};

157
craft/src/editor/Canvas.tsx Normal file
View File

@@ -0,0 +1,157 @@
import React, { useMemo, useRef, useEffect } from 'react';
import { Frame, Element } from '@craftjs/core';
import { Container } from '../components/layout/Container';
import { usePages } from '../state/PageContext';
import { DeviceMode } from '../types';
import { DEVICE_WIDTHS } from '../constants/presets';
import { exportBodyHtml } from '../utils/html-export';
interface CanvasProps {
device: DeviceMode;
}
/**
* Renders the actual header/footer content from the Craft.js state as a
* non-interactive preview. This is the user's own authored content.
*/
const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'footer' }> = ({
craftState,
zone,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const renderedHtml = useMemo(() => {
if (!craftState) return null;
try {
const result = exportBodyHtml(craftState);
return result.html || null;
} catch {
return null;
}
}, [craftState]);
// Set the rendered HTML into the container via ref (user-authored content)
useEffect(() => {
if (containerRef.current && renderedHtml) {
containerRef.current.textContent = '';
const wrapper = document.createElement('div');
wrapper.innerHTML = renderedHtml; // user's own site content
while (wrapper.firstChild) {
containerRef.current.appendChild(wrapper.firstChild);
}
}
}, [renderedHtml]);
if (!renderedHtml) {
return (
<div
data-zone-preview={zone}
style={{
width: '100%',
minHeight: 40,
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
padding: '12px 24px',
color: zone === 'header' ? '#9ca3af' : '#64748b',
textAlign: 'center',
fontSize: 11,
fontStyle: 'italic',
borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
position: 'relative',
pointerEvents: 'none',
userSelect: 'none',
}}
>
{zone === 'header'
? 'Header (empty -- click Edit Header in Pages tab)'
: 'Footer (empty -- click Edit Footer in Pages tab)'}
<div style={{
position: 'absolute', top: 2, right: 6,
fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.5px',
color: '#f59e0b', background: 'rgba(245,158,11,0.12)', padding: '1px 5px', borderRadius: 3,
}}>
{zone}
</div>
</div>
);
}
return (
<div
ref={containerRef}
data-zone-preview={zone}
style={{
width: '100%',
position: 'relative',
pointerEvents: 'none',
userSelect: 'none',
borderBottom: zone === 'header' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
borderTop: zone === 'footer' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
}}
/>
);
};
export const Canvas: React.FC<CanvasProps> = ({ device }) => {
const width = DEVICE_WIDTHS[device];
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
const frameStyle = isEditingHeader
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
: isEditingFooter
? { minHeight: '60px', backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 24px', textAlign: 'center' as const }
: { minHeight: '100vh', backgroundColor: '#ffffff' };
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
return (
<div className="editor-canvas">
<div
className="canvas-device-frame"
style={{
width,
maxWidth: '100%',
margin: '0 auto',
transition: 'width 0.3s ease',
minHeight: '100%',
}}
>
{(isEditingHeader || isEditingFooter) && (
<div style={{
background: 'rgba(245, 158, 11, 0.1)',
borderBottom: '1px solid rgba(245, 158, 11, 0.3)',
padding: '6px 12px',
fontSize: 11,
fontWeight: 600,
color: '#f59e0b',
display: 'flex',
alignItems: 'center',
gap: 6,
}}>
<i className={`fa ${isEditingHeader ? 'fa-window-maximize' : 'fa-window-minimize'}`} />
Editing {isEditingHeader ? 'Header' : 'Footer'} -- This content will appear on all pages
</div>
)}
{isEditingRegularPage && (
<ZonePreview craftState={headerPage.craftState} zone="header" />
)}
<Frame>
<Element
is={Container}
canvas
tag={frameTag}
style={frameStyle}
/>
</Frame>
{isEditingRegularPage && (
<ZonePreview craftState={footerPage.craftState} zone="footer" />
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
import React, { useState, useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import { TopBar } from '../panels/topbar/TopBar';
import { LeftPanel } from '../panels/left/LeftPanel';
import { RightPanel } from '../panels/right/RightPanel';
import { Canvas } from './Canvas';
import { ContextMenu } from '../panels/context-menu/ContextMenu';
import { useContextMenu } from '../hooks/useContextMenu';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { DeviceMode } from '../types';
export const EditorShell: React.FC = () => {
const [device, setDevice] = useState<DeviceMode>('desktop');
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
const { query } = useEditor();
// Register keyboard shortcuts
useKeyboardShortcuts();
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
// Find the selected node id
let nodeId: string | null = null;
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0) {
nodeId = selected[0];
}
} catch {
// No selection
}
showMenu(e.clientX, e.clientY, nodeId);
}, [query, showMenu]);
return (
<div className="editor-app">
<TopBar device={device} onDeviceChange={setDevice} />
<div className="editor-container">
<LeftPanel />
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} />
</div>
<RightPanel />
</div>
<ContextMenu
visible={menuState.visible}
x={menuState.x}
y={menuState.y}
nodeId={menuState.nodeId}
onClose={hideMenu}
/>
</div>
);
};

View File

@@ -0,0 +1,97 @@
import { useState, useCallback } from 'react';
import { useEditorConfig } from '../state/EditorConfigContext';
import { AssetData } from '../types';
export function useAssets() {
const { whpConfig, isWHP } = useEditorConfig();
const [assets, setAssets] = useState<AssetData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAssets = useCallback(async () => {
if (!isWHP || !whpConfig) return;
setLoading(true);
setError(null);
try {
const resp = await fetch(
`${whpConfig.apiUrl}?action=list_assets&site_id=${whpConfig.siteId}`,
{ headers: { 'X-CSRF-Token': whpConfig.csrfToken } }
);
if (!resp.ok) throw new Error(`Failed to load assets: ${resp.status}`);
const data = await resp.json();
if (data.success && Array.isArray(data.assets)) {
setAssets(data.assets.map((item: any) => ({
name: item.name || '',
url: item.url || '',
type: item.type || 'file',
size: item.size,
modified: item.modified,
})));
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to load assets';
setError(msg);
} finally {
setLoading(false);
}
}, [isWHP, whpConfig]);
const uploadAsset = useCallback(async (file: File): Promise<string | null> => {
if (!isWHP || !whpConfig) return null;
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append('file', file);
const resp = await fetch(
`${whpConfig.apiUrl}?action=upload_asset&site_id=${whpConfig.siteId}`,
{
method: 'POST',
headers: { 'X-CSRF-Token': whpConfig.csrfToken },
body: formData,
}
);
if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`);
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Upload failed');
const newAsset: AssetData = {
name: data.name || file.name,
url: data.url || '',
type: data.type || file.type || 'file',
size: file.size,
modified: Date.now(),
};
setAssets((prev) => [newAsset, ...prev]);
return newAsset.url;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Upload failed';
setError(msg);
return null;
} finally {
setLoading(false);
}
}, [isWHP, whpConfig]);
const deleteAsset = useCallback(async (filename: string) => {
if (!isWHP || !whpConfig) return;
setError(null);
try {
const resp = await fetch(
`${whpConfig.apiUrl}?action=delete_asset&site_id=${whpConfig.siteId}&filename=${encodeURIComponent(filename)}`,
{
method: 'POST',
headers: { 'X-CSRF-Token': whpConfig.csrfToken },
}
);
if (!resp.ok) throw new Error(`Delete failed: ${resp.status}`);
setAssets((prev) => prev.filter((a) => a.name !== filename));
} catch (e) {
const msg = e instanceof Error ? e.message : 'Delete failed';
setError(msg);
}
}, [isWHP, whpConfig]);
return { assets, loading, error, loadAssets, uploadAsset, deleteAsset };
}

View File

@@ -0,0 +1,27 @@
import { useState, useCallback } from 'react';
interface ContextMenuState {
visible: boolean;
x: number;
y: number;
nodeId: string | null;
}
export function useContextMenu() {
const [menuState, setMenuState] = useState<ContextMenuState>({
visible: false,
x: 0,
y: 0,
nodeId: null,
});
const show = useCallback((x: number, y: number, nodeId: string | null) => {
setMenuState({ visible: true, x, y, nodeId });
}, []);
const hide = useCallback(() => {
setMenuState((prev) => ({ ...prev, visible: false }));
}, []);
return { menuState, show, hide };
}

View File

@@ -0,0 +1,103 @@
import { useEffect } from 'react';
import { useEditor } from '@craftjs/core';
function isInputFocused(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if ((el as HTMLElement).isContentEditable) return true;
return false;
}
export function useKeyboardShortcuts() {
const { actions, query } = useEditor((state) => {
const selectedIds = state.events.selected;
const selectedId = selectedIds ? Array.from(selectedIds)[0] : null;
return { selectedId };
});
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip when typing in inputs
if (isInputFocused()) return;
const ctrl = e.ctrlKey || e.metaKey;
// Ctrl+Z: Undo
if (ctrl && !e.shiftKey && e.key === 'z') {
e.preventDefault();
try {
actions.history.undo();
} catch (err) {
// No more undo steps
}
return;
}
// Ctrl+Y or Ctrl+Shift+Z: Redo
if ((ctrl && e.key === 'y') || (ctrl && e.shiftKey && e.key === 'z') || (ctrl && e.shiftKey && e.key === 'Z')) {
e.preventDefault();
try {
actions.history.redo();
} catch (err) {
// No more redo steps
}
return;
}
// Delete/Backspace: delete selected node
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0) {
const nodeId = selected[0];
if (nodeId !== 'ROOT') {
actions.delete(nodeId);
}
}
} catch (err) {
console.error('Delete failed:', err);
}
return;
}
// Ctrl+D: duplicate selected
if (ctrl && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0) {
const nodeId = selected[0];
if (nodeId !== 'ROOT') {
const node = query.node(nodeId).get();
const parentId = node?.data?.parent;
if (parentId) {
const tree = query.node(nodeId).toNodeTree();
actions.addNodeTree(tree, parentId);
}
}
}
} catch (err) {
console.error('Duplicate failed:', err);
}
return;
}
// Escape: deselect all
if (e.key === 'Escape') {
e.preventDefault();
try {
actions.clearEvents();
} catch (err) {
// Ignore
}
return;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [actions, query]);
}

View File

@@ -0,0 +1,199 @@
import { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../state/EditorConfigContext';
import { usePages } from '../state/PageContext';
import { exportBodyHtml } from '../utils/html-export';
export function useWhpApi() {
const { query, actions } = useEditor();
const { whpConfig, isWHP } = useEditorConfig();
const { pages, headerPage, footerPage, activePageId, setHeaderCraftState, setFooterCraftState, setPagesCraftState } = usePages();
const save = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
// Serialize the current canvas state (whatever page is active)
const currentCraftState = query.serialize();
// Export body HTML for the current page
let currentHtml = '';
let css = '';
try {
const result = exportBodyHtml(currentCraftState);
currentHtml = result.html;
css = result.css;
} catch (e) {
console.error('HTML export failed, saving state only:', e);
}
// Export header HTML from its craft state
let headerHtml = '';
try {
if (headerPage.craftState) {
const hResult = exportBodyHtml(headerPage.craftState);
headerHtml = hResult.html;
}
} catch (e) {
console.error('Header HTML export failed:', e);
}
// Export footer HTML from its craft state
let footerHtml = '';
try {
if (footerPage.craftState) {
const fResult = exportBodyHtml(footerPage.craftState);
footerHtml = fResult.html;
}
} catch (e) {
console.error('Footer HTML export failed:', e);
}
// Build the pages array with HTML for each page
// For the active page, use the freshly exported HTML from the canvas;
// for others, export from their stored craft state
const pagesPayload = pages.map((page) => {
const filename = (page.slug === 'index' ? 'index' : page.slug) + '.html';
let pageHtml = '';
if (page.id === activePageId) {
// Active page: use the current canvas HTML (already exported above)
pageHtml = currentHtml;
} else if (page.craftState) {
try {
const pResult = exportBodyHtml(page.craftState);
pageHtml = pResult.html;
} catch (e) {
console.error(`HTML export failed for page ${page.name}:`, e);
}
}
return {
filename,
title: page.name,
html: pageHtml,
};
});
// Build pages_craft_state array: for each page, store its craft state
// For the currently active page, always use the fresh canvas state (currentCraftState)
// since page.craftState may be stale (not updated until page switch)
const pagesGrapesjs = pages.map((page) => ({
id: page.id,
name: page.name,
slug: page.slug,
craftState: page.id === activePageId ? currentCraftState : (page.craftState || null),
}));
const payload = {
site_id: whpConfig.siteId,
name: whpConfig.siteName,
html: currentHtml,
css,
pages: pagesPayload,
header_html: headerHtml,
footer_html: footerHtml,
craft_state: currentCraftState,
header_craft_state: headerPage.craftState || null,
footer_craft_state: footerPage.craftState || null,
pages_craft_state: pagesGrapesjs,
};
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify(payload),
});
return resp.json();
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage]);
const publish = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
// First save to ensure staging is up to date
await save();
// Then publish from staging to live
const resp = await fetch(
`${whpConfig.apiUrl}?action=publish&site_id=${whpConfig.siteId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify({ site_id: whpConfig.siteId }),
},
);
return resp.json();
}, [isWHP, whpConfig, save]);
const load = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
const resp = await fetch(
`${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`,
);
const data = await resp.json();
if (data.success && data.project) {
const proj = data.project;
// Restore header craft state
if (proj.header_craft_state) {
setHeaderCraftState(typeof proj.header_craft_state === 'string'
? proj.header_craft_state : JSON.stringify(proj.header_craft_state));
}
// Restore footer craft state
if (proj.footer_craft_state) {
setFooterCraftState(typeof proj.footer_craft_state === 'string'
? proj.footer_craft_state : JSON.stringify(proj.footer_craft_state));
}
// Restore pages and load the first page into the canvas
if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) {
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null }) => ({
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null,
})));
// Load the first page (home) into the canvas
const firstPage = proj.pages_craft_state[0];
if (firstPage.craftState) {
try {
const state = typeof firstPage.craftState === 'string'
? firstPage.craftState : JSON.stringify(firstPage.craftState);
actions.deserialize(state);
} catch (e) {
console.warn('Failed to load page state:', e);
}
}
}
}
return data;
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState]);
const uploadAsset = useCallback(
async (file: File) => {
if (!isWHP || !whpConfig) return null;
const formData = new FormData();
formData.append('file', file);
const resp = await fetch(
`${whpConfig.apiUrl}?action=upload_asset&site_id=${whpConfig.siteId}`,
{
method: 'POST',
headers: { 'X-CSRF-Token': whpConfig.csrfToken },
body: formData,
},
);
return resp.json();
},
[isWHP, whpConfig],
);
return { save, publish, load, uploadAsset, isWHP };
}

14
craft/src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import { WhpConfig } from './types';
import './styles/editor.css';
// Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev)
const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null;
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App whpConfig={whpConfig} />
</React.StrictMode>
);

View File

@@ -0,0 +1,283 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core';
interface ContextMenuProps {
visible: boolean;
x: number;
y: number;
nodeId: string | null;
onClose: () => void;
}
interface MenuItem {
label: string;
shortcut?: string;
action: () => void;
danger?: boolean;
disabled?: boolean;
dividerAfter?: boolean;
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
visible,
x,
y,
nodeId,
onClose,
}) => {
const { actions, query } = useEditor();
const menuRef = useRef<HTMLDivElement>(null);
const clipboardRef = useRef<string | null>(null);
// Close on click outside
useEffect(() => {
if (!visible) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEsc);
};
}, [visible, onClose]);
const getParentId = useCallback((): string | null => {
if (!nodeId) return null;
try {
const node = query.node(nodeId).get();
return node?.data?.parent || null;
} catch {
return null;
}
}, [nodeId, query]);
const duplicate = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const tree = query.node(nodeId).toSerializedNode();
const parentId = getParentId();
if (!parentId) return;
// Get the full subtree
const freshTree = query.node(nodeId).toNodeTree();
const clonedTree = query.parseSerializedNode(freshTree.nodes[freshTree.rootNodeId].data).toNode();
actions.addNodeTree(freshTree, parentId);
} catch (e) {
console.error('Duplicate failed:', e);
}
onClose();
}, [nodeId, actions, query, getParentId, onClose]);
const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
clipboardRef.current = nodeId;
} catch (e) {
console.error('Copy failed:', e);
}
onClose();
}, [nodeId, onClose]);
const pasteNode = useCallback(() => {
const sourceId = clipboardRef.current;
if (!sourceId) return;
try {
const targetParent = nodeId || 'ROOT';
const tree = query.node(sourceId).toNodeTree();
actions.addNodeTree(tree, targetParent);
} catch (e) {
console.error('Paste failed:', e);
}
onClose();
}, [nodeId, actions, query, onClose]);
const moveUp = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx > 0) {
actions.move(nodeId, parentId, idx - 1);
}
} catch (e) {
console.error('Move up failed:', e);
}
onClose();
}, [nodeId, actions, query, getParentId, onClose]);
const moveDown = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx < children.length - 1) {
actions.move(nodeId, parentId, idx + 2);
}
} catch (e) {
console.error('Move down failed:', e);
}
onClose();
}, [nodeId, actions, query, getParentId, onClose]);
const selectParent = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
const parentId = getParentId();
if (parentId) {
actions.selectNode(parentId);
}
onClose();
}, [nodeId, actions, getParentId, onClose]);
const deleteNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
actions.delete(nodeId);
} catch (e) {
console.error('Delete failed:', e);
}
onClose();
}, [nodeId, actions, onClose]);
if (!visible) return null;
const isRoot = nodeId === 'ROOT' || !nodeId;
const items: MenuItem[] = [
{
label: 'Duplicate',
shortcut: 'Ctrl+D',
action: duplicate,
disabled: isRoot,
},
{
label: 'Copy',
shortcut: 'Ctrl+C',
action: copyNode,
disabled: isRoot,
},
{
label: 'Paste',
shortcut: 'Ctrl+V',
action: pasteNode,
disabled: !clipboardRef.current,
dividerAfter: true,
},
{
label: 'Move Up',
action: moveUp,
disabled: isRoot,
},
{
label: 'Move Down',
action: moveDown,
disabled: isRoot,
},
{
label: 'Select Parent',
action: selectParent,
disabled: isRoot,
dividerAfter: true,
},
{
label: 'Delete',
shortcut: 'Del',
action: deleteNode,
danger: true,
disabled: isRoot,
},
];
// Adjust position to stay within viewport
const adjustedX = Math.min(x, window.innerWidth - 200);
const adjustedY = Math.min(y, window.innerHeight - items.length * 34 - 10);
return (
<div
ref={menuRef}
style={{
position: 'fixed',
top: adjustedY,
left: adjustedX,
zIndex: 10000,
minWidth: 180,
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
padding: '4px 0',
overflow: 'hidden',
}}
>
{items.map((item, i) => (
<React.Fragment key={item.label}>
<button
onClick={item.disabled ? undefined : item.action}
disabled={item.disabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '7px 12px',
fontSize: 12,
color: item.disabled
? 'var(--color-text-dim)'
: item.danger
? 'var(--color-danger)'
: 'var(--color-text)',
background: 'transparent',
border: 'none',
cursor: item.disabled ? 'default' : 'pointer',
textAlign: 'left',
transition: 'background var(--transition-fast)',
}}
onMouseEnter={(e) => {
if (!item.disabled) {
(e.target as HTMLElement).style.background = 'var(--color-bg-hover)';
}
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = 'transparent';
}}
>
<span>{item.label}</span>
{item.shortcut && (
<span
style={{
fontSize: 10,
color: 'var(--color-text-dim)',
marginLeft: 16,
}}
>
{item.shortcut}
</span>
)}
</button>
{item.dividerAfter && (
<div
style={{
height: 1,
background: 'var(--color-border)',
margin: '4px 0',
}}
/>
)}
</React.Fragment>
))}
</div>
);
};

View File

@@ -0,0 +1,249 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useAssets } from '../../hooks/useAssets';
export const AssetsPanel: React.FC = () => {
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
useEffect(() => {
loadAssets();
}, [loadAssets]);
const handleFileSelect = useCallback(async (files: FileList | null) => {
if (!files) return;
for (let i = 0; i < files.length; i++) {
await uploadAsset(files[i]);
}
}, [uploadAsset]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const copyUrl = useCallback((url: string) => {
navigator.clipboard.writeText(url).then(() => {
setCopiedUrl(url);
setTimeout(() => setCopiedUrl(null), 2000);
});
}, []);
const isImage = (type: string) =>
type.startsWith('image/') || /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i.test(type);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Upload button */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => handleFileSelect(e.target.files)}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={loading}
style={{
width: '100%',
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
color: '#fff',
background: loading ? 'var(--color-bg-active)' : 'var(--color-accent)',
border: 'none',
borderRadius: 'var(--radius-md)',
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'all var(--transition-fast)',
}}
>
{loading ? 'Uploading...' : 'Upload File'}
</button>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
style={{
padding: 20,
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)',
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
textAlign: 'center',
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
fontSize: 11,
transition: 'all var(--transition-fast)',
}}
>
Drop files here to upload
</div>
{/* Error message */}
{error && (
<div
style={{
padding: '6px 10px',
fontSize: 11,
color: 'var(--color-danger)',
background: 'rgba(239, 68, 68, 0.1)',
borderRadius: 'var(--radius-sm)',
border: '1px solid rgba(239, 68, 68, 0.2)',
}}
>
{error}
</div>
)}
{/* Asset grid */}
{assets.length === 0 && !loading && (
<div
style={{
textAlign: 'center',
padding: 20,
color: 'var(--color-text-dim)',
fontSize: 12,
fontStyle: 'italic',
}}
>
No assets uploaded yet
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 6,
}}
>
{assets.map((asset) => (
<div
key={asset.name}
style={{
position: 'relative',
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
cursor: 'pointer',
transition: 'border-color var(--transition-fast)',
}}
onClick={() => copyUrl(asset.url)}
title={`Click to copy URL: ${asset.url}`}
>
{/* Thumbnail */}
<div
style={{
width: '100%',
aspectRatio: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-bg-base)',
overflow: 'hidden',
}}
>
{isImage(asset.type) ? (
<img
src={asset.url}
alt={asset.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
loading="lazy"
/>
) : (
<span
style={{
fontSize: 20,
color: 'var(--color-text-dim)',
}}
>
&#128196;
</span>
)}
</div>
{/* Name */}
<div
style={{
padding: '4px 6px',
fontSize: 10,
color: 'var(--color-text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{copiedUrl === asset.url ? 'Copied!' : asset.name}
</div>
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation();
deleteAsset(asset.name);
}}
title="Delete asset"
style={{
position: 'absolute',
top: 4,
right: 4,
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
color: '#fff',
background: 'rgba(0,0,0,0.6)',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
opacity: 0.7,
transition: 'opacity var(--transition-fast)',
}}
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
>
&#10005;
</button>
</div>
))}
</div>
{/* Loading indicator */}
{loading && assets.length > 0 && (
<div
style={{
textAlign: 'center',
padding: 10,
color: 'var(--color-text-dim)',
fontSize: 11,
}}
>
Loading...
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,211 @@
import React, { useState } from 'react';
import { useEditor } from '@craftjs/core';
import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout';
import { BackgroundSection } from '../../components/layout/BackgroundSection';
import { Heading } from '../../components/basic/Heading';
import { TextBlock } from '../../components/basic/TextBlock';
import { ButtonLink } from '../../components/basic/ButtonLink';
import { Logo } from '../../components/basic/Logo';
import { Menu } from '../../components/basic/Menu';
import { Footer } from '../../components/basic/Footer';
import { Divider } from '../../components/basic/Divider';
import { Spacer } from '../../components/basic/Spacer';
import { Icon } from '../../components/basic/Icon';
import { HtmlBlock } from '../../components/basic/HtmlBlock';
import { ImageBlock } from '../../components/media/ImageBlock';
import { VideoBlock } from '../../components/media/VideoBlock';
import { MapEmbed } from '../../components/media/MapEmbed';
import { HeroSimple } from '../../components/sections/HeroSimple';
import { FeaturesGrid } from '../../components/sections/FeaturesGrid';
import { CallToAction } from '../../components/sections/CallToAction';
import { Countdown } from '../../components/sections/Countdown';
import { Testimonials } from '../../components/sections/Testimonials';
import { Accordion } from '../../components/sections/Accordion';
import { Tabs } from '../../components/sections/Tabs';
import { PricingTable } from '../../components/sections/PricingTable';
import { Gallery } from '../../components/sections/Gallery';
import { ContentSlider } from '../../components/sections/ContentSlider';
import { NumberCounter } from '../../components/sections/NumberCounter';
import { FormContainer } from '../../components/forms/FormContainer';
import { InputField } from '../../components/forms/InputField';
import { TextareaField } from '../../components/forms/TextareaField';
import { FormButton } from '../../components/forms/FormButton';
import { ContactForm } from '../../components/forms/ContactForm';
import { SubscribeForm } from '../../components/forms/SubscribeForm';
import { StarRating } from '../../components/basic/StarRating';
import { SocialLinks } from '../../components/basic/SocialLinks';
interface BlockDef {
id: string;
label: string;
icon: string;
component: React.ReactElement;
}
interface CategoryDef {
id: string;
label: string;
blocks: BlockDef[];
}
const categories: CategoryDef[] = [
{
id: 'basic',
label: 'Basic',
blocks: [
{ id: 'heading', label: 'Heading', icon: 'fa-header',
component: <Heading text="Your Heading" level="h2" style={{ fontSize: '36px', fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#1f2937', marginBottom: '16px' }} /> },
{ id: 'text', label: 'Text', icon: 'fa-paragraph',
component: <TextBlock text="Add your text here. Click to edit." style={{ fontSize: '16px', lineHeight: '1.6', color: '#4b5563', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'button', label: 'Button', icon: 'fa-square',
component: <ButtonLink text="Click Me" href="#" style={{ display: 'inline-block', padding: '14px 32px', background: '#3b82f6', color: '#ffffff', textDecoration: 'none', borderRadius: '8px', fontWeight: '600', fontSize: '16px', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'logo', label: 'Logo', icon: 'fa-bookmark', component: <Logo /> },
{ id: 'menu', label: 'Menu', icon: 'fa-bars', component: <Menu /> },
{ id: 'footer', label: 'Footer', icon: 'fa-window-minimize', component: <Footer /> },
{ id: 'divider', label: 'Divider', icon: 'fa-minus', component: <Divider /> },
{ id: 'spacer', label: 'Spacer', icon: 'fa-arrows-v', component: <Spacer /> },
{ id: 'icon', label: 'Icon', icon: 'fa-star', component: <Icon icon="fa-star" size="48px" color="#3b82f6" /> },
{ id: 'star-rating', label: 'Star Rating', icon: 'fa-star-half-o', component: <StarRating /> },
{ id: 'social-links', label: 'Social Links', icon: 'fa-share-alt', component: <SocialLinks /> },
{ id: 'html-block', label: 'HTML', icon: 'fa-code', component: <HtmlBlock code="<div>Custom HTML</div>" /> },
],
},
{
id: 'layout',
label: 'Layout',
blocks: [
{ id: 'section', label: 'Section', icon: 'fa-window-maximize',
component: <Section style={{ padding: '60px 20px', backgroundColor: '#ffffff' }} /> },
{ id: 'container', label: 'Container', icon: 'fa-square-o',
component: <Container style={{ padding: '20px', minHeight: '100px' }} /> },
{ id: 'columns-1', label: '1 Column', icon: 'fa-stop',
component: <ColumnLayout columns={1} split="100" /> },
{ id: 'columns-2', label: '2 Columns', icon: 'fa-th-large',
component: <ColumnLayout columns={2} split="50-50" /> },
{ id: 'columns-3', label: '3 Columns', icon: 'fa-th',
component: <ColumnLayout columns={3} split="33-33-33" /> },
{ id: 'columns-4', label: '4 Columns', icon: 'fa-th',
component: <ColumnLayout columns={4} split="25-25-25-25" /> },
{ id: 'columns-5', label: '5 Columns', icon: 'fa-th',
component: <ColumnLayout columns={5} split="20-20-20-20-20" /> },
{ id: 'columns-6', label: '6 Columns', icon: 'fa-th',
component: <ColumnLayout columns={6} split="16-16-16-16-16-16" /> },
{ id: 'sidebar-left', label: 'Sidebar Left', icon: 'fa-columns',
component: <ColumnLayout columns={2} split="30-70" /> },
{ id: 'sidebar-right', label: 'Sidebar Right', icon: 'fa-columns',
component: <ColumnLayout columns={2} split="70-30" /> },
{ id: 'bg-section', label: 'BG Section', icon: 'fa-picture-o', component: <BackgroundSection /> },
],
},
{
id: 'sections',
label: 'Sections',
blocks: [
{ id: 'hero-simple', label: 'Hero', icon: 'fa-star', component: <HeroSimple /> },
{ id: 'features-grid', label: 'Features', icon: 'fa-th-large', component: <FeaturesGrid /> },
{ id: 'cta-section', label: 'CTA', icon: 'fa-bullhorn', component: <CallToAction /> },
{ id: 'accordion', label: 'Accordion', icon: 'fa-list', component: <Accordion /> },
{ id: 'tabs', label: 'Tabs', icon: 'fa-folder-o', component: <Tabs /> },
{ id: 'pricing-table', label: 'Pricing', icon: 'fa-usd', component: <PricingTable /> },
{ id: 'gallery', label: 'Gallery', icon: 'fa-th', component: <Gallery /> },
{ id: 'countdown', label: 'Countdown', icon: 'fa-clock-o',
component: <Countdown targetDate={new Date(Date.now() + 30 * 86400000).toISOString()} /> },
{ id: 'testimonials', label: 'Testimonials', icon: 'fa-quote-left',
component: <Testimonials testimonials={[{ quote: "Great service!", name: "John", title: "CEO", rating: 5 }]} layout="grid" columns={3} /> },
{ id: 'content-slider', label: 'Slider', icon: 'fa-sliders', component: <ContentSlider /> },
{ id: 'number-counter', label: 'Counters', icon: 'fa-sort-numeric-asc', component: <NumberCounter /> },
],
},
{
id: 'media',
label: 'Media',
blocks: [
{ id: 'image', label: 'Image', icon: 'fa-image',
component: <ImageBlock src="" alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
{ id: 'video', label: 'Video', icon: 'fa-play-circle',
component: <VideoBlock videoUrl="" isBackground={false} /> },
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
component: <MapEmbed address="New York, NY" zoom={14} height="400px" /> },
],
},
{
id: 'forms',
label: 'Forms',
blocks: [
{ id: 'contact-form', label: 'Contact Form', icon: 'fa-envelope', component: <ContactForm /> },
{ id: 'subscribe-form', label: 'Subscribe', icon: 'fa-paper-plane', component: <SubscribeForm /> },
{ id: 'form-container', label: 'Form', icon: 'fa-wpforms', component: <FormContainer /> },
{ id: 'input-field', label: 'Input', icon: 'fa-i-cursor', component: <InputField /> },
{ id: 'textarea-field', label: 'Textarea', icon: 'fa-align-left', component: <TextareaField /> },
{ id: 'form-button', label: 'Submit', icon: 'fa-paper-plane', component: <FormButton /> },
],
},
];
export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
categories.forEach((cat, index) => {
initial[cat.id] = index !== 0; // First category open
});
return initial;
});
const toggleCategory = (categoryId: string) => {
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
};
return (
<div>
{categories.map((category) => {
const isCollapsed = collapsed[category.id];
return (
<div key={category.id} className="block-category">
<div
className={`block-category-header ${isCollapsed ? 'collapsed' : ''}`}
onClick={() => toggleCategory(category.id)}
>
<span>{category.label}</span>
<i className="fa fa-chevron-down chevron" />
</div>
<div className={`block-category-items ${isCollapsed ? 'collapsed' : ''}`}>
<div className="block-grid">
{category.blocks.map((block) => (
<div
key={block.id}
className="block-item"
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
onDoubleClick={() => {
try {
const serialized = JSON.parse(query.serialize());
const nodeIds = Object.keys(serialized);
let canvasId = 'ROOT';
for (const nodeId of nodeIds) {
if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
canvasId = nodeId;
break;
}
}
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
actions.addNodeTree(tree, canvasId);
} catch (e) {
console.error('Failed to add block:', e);
}
}}
title={`Drag or double-click to add ${block.label}`}
>
<i className={`fa ${block.icon} block-item-icon`} />
<span className="block-item-label">{block.label}</span>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,137 @@
import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
interface LayerNodeProps {
nodeId: string;
depth: number;
}
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const { node, selectedId, actions } = useEditor((state) => {
const n = state.nodes[nodeId];
const selectedIds = state.events.selected;
const selId = selectedIds ? Array.from(selectedIds)[0] : null;
return {
node: n,
selectedId: selId,
};
});
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
actions.selectNode(nodeId);
}, [actions, nodeId]);
if (!node) return null;
const isSelected = selectedId === nodeId;
const nodeType = node.data.type;
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
? (nodeType as any).resolvedName
: typeof nodeType === 'string' ? nodeType : undefined;
const displayName = node.data.displayName || resolvedName || 'Component';
const childNodeIds: string[] = node.data.nodes || [];
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
const allChildren = [...childNodeIds, ...linkedNodeIds];
const isRoot = nodeId === 'ROOT';
return (
<div>
<div
onClick={handleClick}
style={{
display: 'flex',
alignItems: 'center',
padding: '5px 8px',
paddingLeft: `${8 + depth * 16}px`,
fontSize: 12,
fontWeight: isSelected ? 600 : 400,
color: isSelected ? 'var(--color-accent)' : 'var(--color-text)',
background: isSelected ? 'var(--color-accent-subtle)' : 'transparent',
borderLeft: isSelected ? '2px solid var(--color-accent)' : '2px solid transparent',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'none',
}}
onMouseEnter={(e) => {
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'transparent';
}
}}
>
{/* Indentation indicator */}
{allChildren.length > 0 && (
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)' }}>
&#9660;
</span>
)}
{allChildren.length === 0 && (
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent' }}>
&#9660;
</span>
)}
{/* Component type icon and name */}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{isRoot ? 'Canvas (Root)' : displayName}
</span>
</div>
{/* Render children */}
{allChildren.map((childId) => (
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</div>
);
};
export const LayersPanel: React.FC = () => {
const { nodeIds } = useEditor((state) => {
return {
nodeIds: Object.keys(state.nodes),
};
});
const hasRoot = nodeIds.includes('ROOT');
if (!hasRoot) {
return (
<div className="panel-placeholder">
No content on canvas
</div>
);
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
margin: '-12px',
}}
>
<div
style={{
padding: '8px 12px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-text-muted)',
borderBottom: '1px solid var(--color-border)',
}}
>
Component Tree
</div>
<LayerNode nodeId="ROOT" depth={0} />
</div>
);
};

View File

@@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { BlocksPanel } from './BlocksPanel';
import { PagesPanel } from './PagesPanel';
import { LayersPanel } from './LayersPanel';
import { AssetsPanel } from './AssetsPanel';
type LeftTab = 'blocks' | 'pages' | 'layers' | 'assets';
export const LeftPanel: React.FC = () => {
const [activeTab, setActiveTab] = useState<LeftTab>('blocks');
const tabs: { id: LeftTab; label: string }[] = [
{ id: 'blocks', label: 'Blocks' },
{ id: 'pages', label: 'Pages' },
{ id: 'layers', label: 'Layers' },
{ id: 'assets', label: 'Assets' },
];
return (
<div className="panel-left">
<div className="panel-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`panel-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="panel-content">
{activeTab === 'blocks' && <BlocksPanel />}
{activeTab === 'pages' && <PagesPanel />}
{activeTab === 'layers' && <LayersPanel />}
{activeTab === 'assets' && <AssetsPanel />}
</div>
</div>
);
};

View File

@@ -0,0 +1,470 @@
import React, { useState } from 'react';
import { usePages } from '../../state/PageContext';
export const PagesPanel: React.FC = () => {
const {
pages,
activePageId,
isEditingHeader,
isEditingFooter,
switchPage,
editHeader,
editFooter,
addPage,
deletePage,
renamePage,
} = usePages();
const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState('');
const [newSlug, setNewSlug] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editSlug, setEditSlug] = useState('');
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const handleAdd = () => {
if (!newName.trim()) return;
addPage(newName.trim(), newSlug.trim());
setNewName('');
setNewSlug('');
setIsAdding(false);
};
const handleRename = (pageId: string) => {
if (!editName.trim()) return;
renamePage(pageId, editName.trim(), editSlug.trim());
setEditingId(null);
};
const handleDelete = (pageId: string) => {
deletePage(pageId);
setDeleteConfirmId(null);
};
const startEditing = (page: { id: string; name: string; slug: string }) => {
setEditingId(page.id);
setEditName(page.name);
setEditSlug(page.slug);
setDeleteConfirmId(null);
};
const autoSlug = (name: string): string => {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
};
/* ---------- Zone button style ---------- */
const zoneButtonStyle = (isActive: boolean): React.CSSProperties => ({
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '10px 12px',
fontSize: 12,
fontWeight: 600,
color: isActive ? '#f59e0b' : '#fbbf24',
background: isActive ? 'rgba(245, 158, 11, 0.15)' : 'rgba(245, 158, 11, 0.06)',
border: `1px solid ${isActive ? 'rgba(245, 158, 11, 0.5)' : 'rgba(245, 158, 11, 0.2)'}`,
borderRadius: 'var(--radius-md)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
textAlign: 'left' as const,
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Header/Footer zone buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
<button
onClick={editHeader}
style={zoneButtonStyle(isEditingHeader)}
>
<i className="fa fa-window-maximize" style={{ fontSize: 13 }} />
<div style={{ flex: 1 }}>
<div>Edit Header</div>
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
Appears on all pages
</div>
</div>
{isEditingHeader && (
<span style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
background: 'rgba(245, 158, 11, 0.25)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
Editing
</span>
)}
</button>
<button
onClick={editFooter}
style={zoneButtonStyle(isEditingFooter)}
>
<i className="fa fa-window-minimize" style={{ fontSize: 13 }} />
<div style={{ flex: 1 }}>
<div>Edit Footer</div>
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
Appears on all pages
</div>
</div>
{isEditingFooter && (
<span style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
background: 'rgba(245, 158, 11, 0.25)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
Editing
</span>
)}
</button>
</div>
{/* Section label for pages */}
<div style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-text-dim)',
padding: '0 2px',
}}>
Pages
</div>
{/* Page list */}
{pages.map((page) => (
<div key={page.id}>
{editingId === page.id ? (
/* Editing mode */
<div
style={{
padding: 10,
background: 'var(--color-bg-elevated)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-accent)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<input
type="text"
value={editName}
onChange={(e) => {
setEditName(e.target.value);
setEditSlug(autoSlug(e.target.value));
}}
placeholder="Page name"
className="control-input"
style={{ fontSize: 12 }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(page.id);
if (e.key === 'Escape') setEditingId(null);
}}
/>
<input
type="text"
value={editSlug}
onChange={(e) => setEditSlug(e.target.value)}
placeholder="page-slug"
className="control-input"
style={{ fontSize: 11 }}
/>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleRename(page.id)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: '#fff',
background: 'var(--color-accent)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Save
</button>
<button
onClick={() => setEditingId(null)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : deleteConfirmId === page.id ? (
/* Delete confirmation */
<div
style={{
padding: 10,
background: 'var(--color-bg-elevated)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-danger)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text)' }}>
Delete "{page.name}"?
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleDelete(page.id)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: '#fff',
background: 'var(--color-danger)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Delete
</button>
<button
onClick={() => setDeleteConfirmId(null)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : (
/* Normal page item */
<div
onClick={() => switchPage(page.id)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 10px',
background:
page.id === activePageId
? 'var(--color-accent-subtle)'
: 'var(--color-bg-elevated)',
border: `1px solid ${page.id === activePageId ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: page.id === activePageId ? 600 : 500,
color:
page.id === activePageId
? 'var(--color-accent)'
: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{page.name}
</div>
<div
style={{
fontSize: 10,
color: 'var(--color-text-dim)',
marginTop: 2,
}}
>
/{page.slug}
</div>
</div>
<div
style={{ display: 'flex', gap: 4, flexShrink: 0 }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => startEditing(page)}
title="Rename"
style={{
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
&#9998;
</button>
{pages.length > 1 && (
<button
onClick={() => setDeleteConfirmId(page.id)}
title="Delete"
style={{
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
&#10005;
</button>
)}
</div>
</div>
)}
</div>
))}
{/* Add page section */}
{isAdding ? (
<div
style={{
padding: 10,
background: 'var(--color-bg-elevated)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<input
type="text"
value={newName}
onChange={(e) => {
setNewName(e.target.value);
setNewSlug(autoSlug(e.target.value));
}}
placeholder="Page name"
className="control-input"
style={{ fontSize: 12 }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleAdd();
if (e.key === 'Escape') {
setIsAdding(false);
setNewName('');
setNewSlug('');
}
}}
/>
<input
type="text"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
placeholder="page-slug"
className="control-input"
style={{ fontSize: 11 }}
/>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={handleAdd}
disabled={!newName.trim()}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: '#fff',
background: newName.trim() ? 'var(--color-accent)' : 'var(--color-bg-active)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: newName.trim() ? 'pointer' : 'not-allowed',
}}
>
Add Page
</button>
<button
onClick={() => {
setIsAdding(false);
setNewName('');
setNewSlug('');
}}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setIsAdding(true)}
style={{
width: '100%',
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-accent)',
background: 'var(--color-accent-subtle)',
border: '1px dashed var(--color-accent)',
borderRadius: 'var(--radius-md)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
}}
>
+ Add Page
</button>
)}
</div>
);
};

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { useEditor } from '@craftjs/core';
import { componentResolver } from '../../components/resolver';
import { SiteDesignPanel } from './SiteDesignPanel';
import {
TextStylePanel,
ButtonStylePanel,
ImageStylePanel,
ContainerStylePanel,
HeroStylePanel,
NavStylePanel,
MediaStylePanel,
FormStylePanel,
SocialStylePanel,
SectionTypePanel,
PricingStylePanel,
BackgroundSectionStylePanel,
GenericPropsEditor,
} from './styles';
/* ================================================================
IMPORTANT: None of these panels use useNode().
All prop mutations go through useEditor().actions.setProp(nodeId, ...)
so they work from outside the node's render tree.
================================================================ */
/* ================================================================
Main GuidedStyles component
================================================================ */
export const GuidedStyles: React.FC = () => {
const resolverMap = componentResolver as Record<string, any>;
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
const currentNodeId = state.events.selected
? Array.from(state.events.selected)[0]
: undefined;
let selectedType: string | null = null;
let nodeProps: Record<string, any> = {};
let resolvedName: string | null = null;
if (currentNodeId) {
const node = state.nodes[currentNodeId];
if (node) {
selectedType = node.data.displayName || node.data.name || null;
nodeProps = node.data.props || {};
const nodeType = node.data.type as any;
if (nodeType && typeof nodeType === 'object' && nodeType.resolvedName) {
resolvedName = nodeType.resolvedName;
}
}
}
return {
selected: currentNodeId || null,
selectedType,
nodeProps,
resolvedName,
};
});
if (!selected) {
return <SiteDesignPanel />;
}
// Determine which panel to show based on displayName
const typeName = selectedType || '';
// ---- Type classification (covers ALL 40 component display names) ----
const isText = /^heading$|^text$/i.test(typeName);
const isButton = /^button$/i.test(typeName);
const isImage = /^image$/i.test(typeName);
const isBgSection = /^background section$/i.test(typeName);
const isContainer = /^container$|^section$|^columns$|^header zone$|^footer zone$/i.test(typeName);
const isHero = /hero/i.test(typeName);
const isNav = /^menu$|^logo$|^navbar$|^footer$/i.test(typeName);
const isMedia = /^video$|^gallery$|^map$|^content slider$/i.test(typeName);
const isForm = /^form$|^input$|^textarea$|^subscribe|^contact form$|^submit button$|^search bar$/i.test(typeName);
const isSocial = /^social links$|^icon$|^star rating$/i.test(typeName);
const isPricing = /^pricing/i.test(typeName);
const isSection = /^accordion$|^tabs$|^testimonial|^countdown$|^number counter$|^cta section$|^call to action$|^features grid$/i.test(typeName);
// Utility types that need minimal controls
const isUtility = /^divider$|^spacer$|^html$/i.test(typeName);
// Icon for the type badge
const typeIcon = isText ? 'fa-font'
: isButton ? 'fa-hand-pointer-o'
: isImage ? 'fa-image'
: isBgSection ? 'fa-picture-o'
: isContainer ? 'fa-object-group'
: isHero ? 'fa-star'
: isNav ? 'fa-bars'
: isMedia ? 'fa-play-circle'
: isForm ? 'fa-wpforms'
: isSocial ? 'fa-share-alt'
: isSection ? 'fa-th-large'
: isUtility ? 'fa-ellipsis-h'
: 'fa-cube';
return (
<div className="guided-styles">
{/* Component type badge */}
<div className="guided-section guided-type-header">
<span className="guided-type-badge">
<i className={`fa ${typeIcon}`} />
{' '}{typeName}
</span>
</div>
{/* TEXT */}
{isText && <TextStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* BUTTON */}
{isButton && <ButtonStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* IMAGE */}
{isImage && <ImageStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* BACKGROUND SECTION */}
{isBgSection && <BackgroundSectionStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* CONTAINER / SECTION / COLUMNS */}
{isContainer && <ContainerStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* HERO */}
{isHero && <HeroStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* NAV / MENU / LOGO / FOOTER */}
{isNav && <NavStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* MEDIA (Video, Gallery, Map, Slider) */}
{isMedia && <MediaStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* FORM (Form, Input, Textarea, Subscribe, Contact, FormButton, Search) */}
{isForm && <FormStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* SOCIAL / ICON / STAR RATING */}
{isSocial && <SocialStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* PRICING TABLE */}
{isPricing && <PricingStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* SECTION-TYPE (Accordion, Tabs, Testimonials, Countdown, Counter, CTA, Features) */}
{isSection && <SectionTypePanel selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
{/* UTILITY (Divider, Spacer, HTML) -- use generic but it works well for these */}
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
{/* FALLBACK: Anything not matched above */}
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && (
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
)}
</div>
);
};

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { GuidedStyles } from './GuidedStyles';
/**
* Right panel -- shows component settings when an element is selected,
* or Site Design Tokens (including Head code) when nothing is selected.
*/
export const RightPanel: React.FC = () => {
return (
<div className="panel-right">
<div className="panel-tabs">
<button className="panel-tab active">Styles</button>
</div>
<div className="panel-content">
<GuidedStyles />
</div>
</div>
);
};

View File

@@ -0,0 +1,362 @@
import React, { useState } from 'react';
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
import { FONT_FAMILIES } from '../../constants/presets';
type DesignTab = 'basic' | 'advanced';
/* ---------- Color picker with preset swatches ---------- */
const COLOR_SWATCHES = [
'#3b82f6', '#8b5cf6', '#10b981', '#ef4444',
'#f59e0b', '#ec4899', '#06b6d4', '#f97316',
];
const NEUTRAL_SWATCHES = [
'#ffffff', '#f9fafb', '#e5e7eb', '#9ca3af',
'#6b7280', '#374151', '#1f2937', '#111827',
];
interface ColorFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
swatches?: string[];
}
const ColorField: React.FC<ColorFieldProps> = ({ label, value, onChange, swatches = COLOR_SWATCHES }) => (
<div className="guided-section">
<label className="guided-section-label">{label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
width: 32,
height: 32,
padding: 0,
border: '2px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
background: 'none',
}}
/>
<input
type="text"
className="guided-input"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ flex: 1, fontSize: 11, fontFamily: 'monospace' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
{swatches.map((c) => (
<button
key={c}
onClick={() => onChange(c)}
style={{
width: '100%',
aspectRatio: '1',
borderRadius: 'var(--radius-sm)',
border: value === c ? '2px solid var(--color-accent)' : '2px solid var(--color-border)',
background: c,
cursor: 'pointer',
boxShadow: value === c ? '0 0 0 2px var(--color-accent)' : 'none',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
title={c}
/>
))}
</div>
</div>
);
/* ---------- Font dropdown ---------- */
interface FontFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
}
const FontField: React.FC<FontFieldProps> = ({ label, value, onChange }) => (
<div className="guided-section">
<label className="guided-section-label">{label}</label>
<select
className="control-select"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ fontSize: 12 }}
>
{FONT_FAMILIES.map((f) => (
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>
{f.label}
</option>
))}
</select>
</div>
);
/* ---------- Border radius presets ---------- */
const BORDER_RADIUS_PRESETS = [
{ label: '0', value: '0' },
{ label: '4px', value: '4px' },
{ label: '8px', value: '8px' },
{ label: '12px', value: '12px' },
{ label: '16px', value: '16px' },
];
interface RadiusFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
}
const RadiusField: React.FC<RadiusFieldProps> = ({ label, value, onChange }) => (
<div className="guided-section">
<label className="guided-section-label">{label}</label>
<div className="preset-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
{BORDER_RADIUS_PRESETS.map((p) => (
<button
key={p.value}
className={`preset-btn ${value === p.value ? 'active' : ''}`}
onClick={() => onChange(p.value)}
>
{p.label}
</button>
))}
</div>
</div>
);
/* ---------- Nav style toggle ---------- */
interface NavStyleFieldProps {
value: 'light' | 'dark';
onChange: (value: 'light' | 'dark') => void;
}
const NavStyleField: React.FC<NavStyleFieldProps> = ({ value, onChange }) => (
<div className="guided-section">
<label className="guided-section-label">Nav Style</label>
<div style={{ display: 'flex', gap: 6 }}>
{(['light', 'dark'] as const).map((s) => (
<button
key={s}
className={`preset-btn ${value === s ? 'active' : ''}`}
style={{ flex: 1, textTransform: 'capitalize' }}
onClick={() => onChange(s)}
>
{s === 'light' ? 'Light' : 'Dark'}
</button>
))}
</div>
</div>
);
/* ---------- Main SiteDesignPanel ---------- */
export const SiteDesignPanel: React.FC = () => {
const { design, updateDesign, resetToDefaults } = useSiteDesign();
const [tab, setTab] = useState<DesignTab>('basic');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Header */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<i className="fa fa-paint-brush" style={{ color: 'var(--color-accent)', fontSize: 14 }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Site Design Tokens
</span>
</div>
{/* Tab switcher */}
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
{(['basic', 'advanced'] as DesignTab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
flex: 1,
padding: '6px 12px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: tab === t ? '#fff' : 'var(--color-text-muted)',
background: tab === t ? 'var(--color-accent)' : 'var(--color-bg-elevated)',
border: tab === t ? '1px solid var(--color-accent)' : '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
{t}
</button>
))}
</div>
{/* Basic tab */}
{tab === 'basic' && (
<>
<ColorField
label="Primary Color"
value={design.primaryColor}
onChange={(v) => updateDesign({ primaryColor: v })}
/>
<ColorField
label="Secondary Color"
value={design.secondaryColor}
onChange={(v) => updateDesign({ secondaryColor: v })}
/>
<ColorField
label="Accent Color"
value={design.accentColor}
onChange={(v) => updateDesign({ accentColor: v })}
/>
<FontField
label="Heading Font"
value={design.headingFont}
onChange={(v) => updateDesign({ headingFont: v })}
/>
<FontField
label="Body Font"
value={design.bodyFont}
onChange={(v) => updateDesign({ bodyFont: v })}
/>
<ColorField
label="Link Color"
value={design.linkColor}
onChange={(v) => updateDesign({ linkColor: v })}
/>
</>
)}
{/* Advanced tab */}
{tab === 'advanced' && (
<>
<ColorField
label="Primary Color"
value={design.primaryColor}
onChange={(v) => updateDesign({ primaryColor: v })}
/>
<ColorField
label="Secondary Color"
value={design.secondaryColor}
onChange={(v) => updateDesign({ secondaryColor: v })}
/>
<ColorField
label="Accent Color"
value={design.accentColor}
onChange={(v) => updateDesign({ accentColor: v })}
/>
<ColorField
label="Success Color"
value={design.successColor}
onChange={(v) => updateDesign({ successColor: v })}
/>
<ColorField
label="Warning Color"
value={design.warningColor}
onChange={(v) => updateDesign({ warningColor: v })}
/>
<ColorField
label="Error Color"
value={design.errorColor}
onChange={(v) => updateDesign({ errorColor: v })}
/>
<ColorField
label="Background Color"
value={design.backgroundColor}
onChange={(v) => updateDesign({ backgroundColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<ColorField
label="Text Color"
value={design.textColor}
onChange={(v) => updateDesign({ textColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<ColorField
label="Muted Text Color"
value={design.mutedTextColor}
onChange={(v) => updateDesign({ mutedTextColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<ColorField
label="Border Color"
value={design.borderColor}
onChange={(v) => updateDesign({ borderColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<FontField
label="Heading Font"
value={design.headingFont}
onChange={(v) => updateDesign({ headingFont: v })}
/>
<FontField
label="Body Font"
value={design.bodyFont}
onChange={(v) => updateDesign({ bodyFont: v })}
/>
<FontField
label="Button Font"
value={design.buttonFont}
onChange={(v) => updateDesign({ buttonFont: v })}
/>
<ColorField
label="Link Color"
value={design.linkColor}
onChange={(v) => updateDesign({ linkColor: v })}
/>
<RadiusField
label="Default Border Radius"
value={design.borderRadius}
onChange={(v) => updateDesign({ borderRadius: v })}
/>
<RadiusField
label="Button Radius"
value={design.buttonRadius}
onChange={(v) => updateDesign({ buttonRadius: v })}
/>
<NavStyleField
value={design.navStyle}
onChange={(v) => updateDesign({ navStyle: v })}
/>
</>
)}
{/* Reset button */}
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--color-border)' }}>
<button
onClick={resetToDefaults}
style={{
width: '100%',
padding: '8px 12px',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
Reset to Defaults
</button>
<p style={{
fontSize: 10,
color: 'var(--color-text-dim)',
textAlign: 'center',
margin: '8px 0 0',
lineHeight: 1.4,
}}>
Design tokens are saved with your project and applied to new components.
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,97 @@
import React, { useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
SPACING_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
ColorSwatchGrid,
GradientSwatchGrid,
PresetButtonGrid,
CollapsibleSection,
ColorPickerField,
uploadToWhp,
labelStyle,
inputStyle,
smallInputStyle,
btnActiveStyle,
sectionGap,
} from './shared';
/* ---------- BACKGROUND SECTION ---------- */
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const fileInputRef = useRef<HTMLInputElement>(null);
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const handleUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp('bgImage', url);
}, [setProp]);
const style = nodeProps.style || {};
return (
<>
{/* Background Image */}
<CollapsibleSection title="Background Image">
{nodeProps.bgImage && (
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<i className="fa fa-times" />
</button>
</div>
)}
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
</div>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = ''; }} />
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste image URL..."
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
</CollapsibleSection>
{/* Background Color */}
<CollapsibleSection title="Background Color">
<ColorPickerField label="Background" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
</CollapsibleSection>
{/* Overlay */}
<CollapsibleSection title="Overlay">
<ColorPickerField label="Overlay Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}%</label>
<input type="range" min={0} max={100} value={Math.round((nodeProps.overlayOpacity ?? 0.4) * 100)}
onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value) / 100)} style={{ width: '100%' }} />
</div>
</CollapsibleSection>
{/* Layout */}
<CollapsibleSection title="Layout" defaultOpen={false}>
<div style={sectionGap}>
<label style={labelStyle}>Inner Max Width</label>
<input type="text" value={nodeProps.innerMaxWidth || '1200px'}
onChange={(e) => setProp('innerMaxWidth', e.target.value)} style={inputStyle} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
</CollapsibleSection>
</>
);
};

View File

@@ -0,0 +1,88 @@
import React, { useCallback, CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
RADIUS_PRESETS,
SPACING_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
ColorSwatchGrid,
PresetButtonGrid,
TextInputField,
autoTextColor,
} from './shared';
/* ---------- BUTTON ---------- */
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const setPropStyle = useCallback(
(property: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [property]: value };
});
},
[actions, selectedId],
);
const setButtonColor = useCallback(
(bgColor: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = {
...props.style,
backgroundColor: bgColor,
color: autoTextColor(bgColor),
};
});
},
[actions, selectedId],
);
return (
<>
<div className="guided-section">
<SectionLabel>Button Color</SectionLabel>
<ColorSwatchGrid
colors={BG_COLORS}
activeValue={style.backgroundColor as string}
onSelect={setButtonColor}
/>
</div>
<TextInputField
label="Button Text"
value={nodeProps.text || ''}
placeholder="Click Me"
onChange={(v) => {
actions.setProp(selectedId, (props: any) => { props.text = v; });
}}
/>
<TextInputField
label="Link URL"
value={nodeProps.href || ''}
placeholder="https://..."
onChange={(v) => {
actions.setProp(selectedId, (props: any) => { props.href = v; });
}}
/>
<div className="guided-section">
<SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid
presets={RADIUS_PRESETS}
activeValue={style.borderRadius as string}
onSelect={(v) => setPropStyle('borderRadius', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid
presets={SPACING_PRESETS}
activeValue={style.padding as string}
onSelect={(v) => setPropStyle('padding', v)}
/>
</div>
</>
);
};

View File

@@ -0,0 +1,80 @@
import React, { useCallback, CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
SPACING_PRESETS,
RADIUS_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
ColorSwatchGrid,
GradientSwatchGrid,
PresetButtonGrid,
} from './shared';
/* ---------- CONTAINER / SECTION ---------- */
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const setPropStyle = useCallback(
(property: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [property]: value };
});
},
[actions, selectedId],
);
return (
<>
<div className="guided-section">
<SectionLabel>Background Color</SectionLabel>
<ColorSwatchGrid
colors={BG_COLORS}
activeValue={style.backgroundColor as string}
onSelect={(v) => setPropStyle('backgroundColor', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Background Gradient</SectionLabel>
<GradientSwatchGrid
activeValue={style.background as string}
onSelect={(v) => setPropStyle('background', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid
presets={SPACING_PRESETS}
activeValue={style.padding as string}
onSelect={(v) => setPropStyle('padding', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid
presets={RADIUS_PRESETS}
activeValue={style.borderRadius as string}
onSelect={(v) => setPropStyle('borderRadius', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Alignment</SectionLabel>
<div className="preset-grid align-grid">
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
<button
key={a}
className={`preset-btn ${style.textAlign === a ? 'active' : ''}`}
onClick={() => setPropStyle('textAlign', a)}
title={a}
>
<i className={`fa fa-align-${a}`} />
</button>
))}
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,139 @@
import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
SPACING_PRESETS,
RADIUS_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
ColorSwatchGrid,
PresetButtonGrid,
CollapsibleSection,
labelStyle,
inputStyle,
btnActiveStyle,
sectionGap,
} from './shared';
/* ---------- FORM ---------- */
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const style = nodeProps.style || {};
return (
<>
{/* Form action/method */}
{nodeProps.action !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Form Action URL</label>
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="https://..." style={inputStyle} />
</div>
)}
{nodeProps.method !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Method</label>
<div style={{ display: 'flex', gap: 4 }}>
{['GET', 'POST'].map((m) => (
<button key={m} onClick={() => setProp('method', m)} style={btnActiveStyle(nodeProps.method === m)}>{m}</button>
))}
</div>
</div>
)}
{/* Input field props */}
{nodeProps.label !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Label</label>
<input type="text" value={nodeProps.label || ''} onChange={(e) => setProp('label', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.placeholder !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Placeholder</label>
<input type="text" value={nodeProps.placeholder || ''} onChange={(e) => setProp('placeholder', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.name !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Field Name</label>
<input type="text" value={nodeProps.name || ''} onChange={(e) => setProp('name', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.type !== undefined && typeof nodeProps.type === 'string' && (
<div style={sectionGap}>
<label style={labelStyle}>Input Type</label>
<select value={nodeProps.type} onChange={(e) => setProp('type', e.target.value)} style={{ ...inputStyle, cursor: 'pointer' }}>
{['text', 'email', 'password', 'number', 'tel', 'url'].map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
)}
{nodeProps.required !== undefined && (
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={nodeProps.required || false} onChange={(e) => setProp('required', e.target.checked)} />
Required
</label>
</div>
)}
{/* Button text */}
{nodeProps.text !== undefined && nodeProps.label === undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Button Text</label>
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
</div>
)}
{/* Subscribe form props */}
{nodeProps.buttonText !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Button Text</label>
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.placeholderText !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Placeholder Text</label>
<input type="text" value={nodeProps.placeholderText || ''} onChange={(e) => setProp('placeholderText', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.successMessage !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Success Message</label>
<input type="text" value={nodeProps.successMessage || ''} onChange={(e) => setProp('successMessage', e.target.value)} style={inputStyle} />
</div>
)}
{/* Style */}
<CollapsibleSection title="Style" defaultOpen={false}>
<div className="guided-section">
<SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
<div className="guided-section">
<SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
</div>
</CollapsibleSection>
</>
);
};

View File

@@ -0,0 +1,251 @@
import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import {
TEXT_COLORS,
BG_COLORS,
SPACING_PRESETS,
RADIUS_PRESETS,
} from '../../../constants/presets';
import {
SectionLabel,
ColorSwatchGrid,
PresetButtonGrid,
CollapsibleSection,
ColorPickerField,
ArrayPropEditor,
labelStyle,
inputStyle,
smallInputStyle,
sectionGap,
} from './shared';
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
selectedId, nodeProps, typeName,
}) => {
const { actions } = useEditor();
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
const setPropValue = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setStyleValue = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
// Categorize all props
const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key));
const colorProps = allProps.filter(([key, val]) => typeof val === 'string' && /color/i.test(key));
const boolProps = allProps.filter(([_, val]) => typeof val === 'boolean');
const numberProps = allProps.filter(([key, val]) => typeof val === 'number' && !/color/i.test(key));
const stringProps = allProps.filter(([key, val]) => typeof val === 'string' && !/color/i.test(key));
const arrayProps = allProps.filter(([_, val]) => Array.isArray(val));
const style = nodeProps.style || {};
return (
<>
{/* String props */}
{stringProps.length > 0 && (
<CollapsibleSection title="Properties">
{stringProps.map(([key, val]) => {
const humanLabel = key.replace(/([A-Z])/g, ' $1').trim();
const isLong = String(val).length > 60 || key === 'description' || key === 'text' || key === 'content';
return (
<div key={key} style={sectionGap}>
<label style={labelStyle}>{humanLabel}</label>
{isLong ? (
<textarea value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} style={inputStyle} />
)}
</div>
);
})}
</CollapsibleSection>
)}
{/* Number props */}
{numberProps.length > 0 && (
<CollapsibleSection title="Numbers" defaultOpen={true}>
{numberProps.map(([key, val]) => (
<div key={key} style={sectionGap}>
<label style={labelStyle}>{key.replace(/([A-Z])/g, ' $1').trim()}</label>
<input type="number" value={val as number} onChange={(e) => setPropValue(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
</div>
))}
</CollapsibleSection>
)}
{/* Boolean props */}
{boolProps.length > 0 && (
<CollapsibleSection title="Options" defaultOpen={true}>
{boolProps.map(([key, val]) => (
<div key={key} style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={val as boolean} onChange={(e) => setPropValue(key, e.target.checked)} />
{key.replace(/([A-Z])/g, ' $1').trim()}
</label>
</div>
))}
</CollapsibleSection>
)}
{/* Color props */}
{colorProps.length > 0 && (
<CollapsibleSection title="Colors">
{colorProps.map(([key, val]) => (
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setPropValue(key, v)} />
))}
</CollapsibleSection>
)}
{/* Array props */}
{arrayProps.map(([key, items]) => {
const arrayItems = items as any[];
const sampleItem = arrayItems[0] || {};
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
return (
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
<ArrayPropEditor
selectedId={selectedId}
propKey={key}
items={arrayItems}
renderItem={(item: any, index: number) => {
if (typeof item !== 'object' || item === null) {
return (
<input
type="text"
value={String(item)}
onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = e.target.value;
props[key] = updated;
});
}}
style={smallInputStyle}
/>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{itemFields.map((field) => {
const fieldVal = item[field];
if (typeof fieldVal === 'boolean') {
return (
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.checked };
props[key] = updated;
});
}} />
{field}
</label>
);
}
if (typeof fieldVal === 'number') {
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
<input type="number" value={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
props[key] = updated;
});
}} style={smallInputStyle} />
</div>
);
}
if (/color/i.test(field) && typeof fieldVal === 'string') {
return (
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
</div>
);
}
const strVal = String(fieldVal ?? '');
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
{isLongField ? (
<textarea value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={smallInputStyle} />
)}
</div>
);
})}
</div>
);
}}
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
: ''
}
/>
</CollapsibleSection>
);
})}
{/* Style controls */}
<CollapsibleSection title="Style">
<div className="guided-section">
<SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setStyleValue('backgroundColor', v)} />
</div>
{/* Text color in style */}
<div className="guided-section">
<SectionLabel>Text Color</SectionLabel>
<ColorSwatchGrid colors={TEXT_COLORS} activeValue={style.color as string} onSelect={(v: string) => setStyleValue('color', v)} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setStyleValue('padding', v)} />
</div>
<div className="guided-section">
<SectionLabel>Text Alignment</SectionLabel>
<div className="preset-grid align-grid">
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setStyleValue('textAlign', a)} title={a}>
<i className={`fa fa-align-${a}`} />
</button>
))}
</div>
</div>
<div className="guided-section">
<SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setStyleValue('borderRadius', v)} />
</div>
</CollapsibleSection>
</>
);
};

View File

@@ -0,0 +1,269 @@
import React, { useCallback, useState, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import {
StylePanelProps,
CollapsibleSection,
ColorPickerField,
uploadToWhp,
labelStyle,
inputStyle,
smallInputStyle,
btnActiveStyle,
sectionGap,
} from './shared';
/* ---------- Asset Browser Inline ---------- */
const AssetBrowser: React.FC<{
filter: 'image' | 'video' | 'all';
onSelect: (url: string) => void;
}> = ({ filter, onSelect }) => {
const [assets, setAssets] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const loadAssets = useCallback(async () => {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return;
setLoading(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 filtered = filter === 'all' ? data.assets : data.assets.filter((a: any) => {
const t = (a.type || '').toLowerCase();
return filter === 'image' ? t.startsWith('image') : t.startsWith('video');
});
setAssets(filtered);
}
} catch (e) { console.error('Load assets failed:', e); }
setLoading(false);
}, [filter]);
const handleToggle = useCallback(() => {
if (!open) loadAssets();
setOpen(!open);
}, [open, loadAssets]);
return (
<div>
<button onClick={handleToggle} style={{
...btnActiveStyle(open), width: '100%', marginTop: 4,
}}>
<i className={`fa ${loading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} />
{open ? 'Close' : 'Browse Assets'}
</button>
{open && (
<div style={{ maxHeight: 160, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
{assets.map((asset, i) => (
<div key={i} onClick={() => { onSelect(asset.url); setOpen(false); }}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s', background: '#27272a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}>
{(asset.type || '').startsWith('image') ? (
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (
<div style={{ textAlign: 'center', padding: 4 }}>
<i className="fa fa-film" style={{ fontSize: 20, color: '#71717a' }} />
<div style={{ fontSize: 8, color: '#71717a', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 70 }}>
{asset.name?.replace(/^\d+_[a-f0-9]+_/, '')}
</div>
</div>
)}
</div>
))}
{assets.length === 0 && (
<p style={{ gridColumn: '1/-1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: 12, margin: 0 }}>
No {filter} assets uploaded yet
</p>
)}
</div>
)}
</div>
);
};
/* ---------- HERO STYLE PANEL ---------- */
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const fileInputRef = useRef<HTMLInputElement>(null);
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const handleUpload = useCallback(async (file: File, propKey: string) => {
const url = await uploadToWhp(file);
if (url) setProp(propKey, url);
}, [setProp]);
const bgType = nodeProps.bgType || 'color';
return (
<>
<CollapsibleSection title="Content">
<div style={sectionGap}>
<label style={labelStyle}>Heading</label>
<input type="text" value={nodeProps.heading || ''} onChange={(e) => setProp('heading', e.target.value)} style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Subtitle</label>
<textarea value={nodeProps.subtitle || ''} onChange={(e) => setProp('subtitle', e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Button Text</label>
<input type="text" value={nodeProps.buttonText || ''} onChange={(e) => setProp('buttonText', e.target.value)} style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Button URL</label>
<input type="text" value={nodeProps.buttonHref || ''} onChange={(e) => setProp('buttonHref', e.target.value)} placeholder="#" style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Secondary Button</label>
<input type="text" value={nodeProps.secondaryButtonText || ''} onChange={(e) => setProp('secondaryButtonText', e.target.value)} placeholder="Leave blank to hide" style={inputStyle} />
</div>
</CollapsibleSection>
<CollapsibleSection title="Background">
<div style={sectionGap}>
<label style={labelStyle}>Type</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
<button key={t} onClick={() => setProp('bgType', t)} style={btnActiveStyle(bgType === t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
</div>
{bgType === 'color' && (
<ColorPickerField label="Color" value={nodeProps.bgColor || '#1e293b'} onChange={(v) => setProp('bgColor', v)} />
)}
{bgType === 'gradient' && (
<>
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
<div style={{ flex: 1 }}>
<ColorPickerField label="From" value={nodeProps.bgGradientFrom || '#667eea'} onChange={(v) => setProp('bgGradientFrom', v)} />
</div>
<div style={{ flex: 1 }}>
<ColorPickerField label="To" value={nodeProps.bgGradientTo || '#764ba2'} onChange={(v) => setProp('bgGradientTo', v)} />
</div>
</div>
<div style={sectionGap}>
<label style={labelStyle}>Angle: {nodeProps.bgGradientAngle || 135}°</label>
<input type="range" min={0} max={360} value={nodeProps.bgGradientAngle || 135} onChange={(e) => setProp('bgGradientAngle', parseInt(e.target.value))} style={{ width: '100%' }} />
</div>
{/* Gradient preview */}
<div style={{ height: 24, borderRadius: 4, background: `linear-gradient(${nodeProps.bgGradientAngle || 135}deg, ${nodeProps.bgGradientFrom || '#667eea'}, ${nodeProps.bgGradientTo || '#764ba2'})`, border: '1px solid #3f3f46' }} />
</>
)}
{bgType === 'image' && (
<div style={sectionGap}>
<label style={labelStyle}>Background Image</label>
{nodeProps.bgImage && (
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<i className="fa fa-times" />
</button>
</div>
)}
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
</div>
<AssetBrowser filter="image" onSelect={(url) => setProp('bgImage', url)} />
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f, 'bgImage'); e.target.value = ''; }} />
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste URL..."
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
</div>
)}
{bgType === 'video' && (
<div style={sectionGap}>
<label style={labelStyle}>Background Video</label>
{nodeProps.bgVideo && (
<div style={{ marginBottom: 6, padding: 8, background: '#18181b', borderRadius: 4, border: '1px solid #3f3f46', display: 'flex', alignItems: 'center', gap: 6 }}>
<i className="fa fa-film" style={{ color: '#3b82f6' }} />
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{nodeProps.bgVideo.replace(/.*filename=/, '').replace(/^\d+_[a-f0-9]+_/, '') || nodeProps.bgVideo.split('/').pop()}
</span>
<button onClick={() => setProp('bgVideo', '')} style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12 }}>
<i className="fa fa-times" />
</button>
</div>
)}
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'video/*';
input.onchange = () => { const f = input.files?.[0]; if (f) handleUpload(f, 'bgVideo'); };
input.click();
}} style={{ ...btnActiveStyle(false), flex: 1 }}>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload Video
</button>
</div>
<AssetBrowser filter="video" onSelect={(url) => setProp('bgVideo', url)} />
<input type="text" value={nodeProps.bgVideo || ''} placeholder="YouTube, Vimeo, or .mp4 URL"
onChange={(e) => setProp('bgVideo', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
</div>
)}
<div style={sectionGap}>
<label style={labelStyle}>Overlay ({nodeProps.overlayOpacity || 0}%)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" value={nodeProps.overlayColor || '#000000'} onChange={(e) => setProp('overlayColor', e.target.value)} style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity || 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ flex: 1 }} />
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Colors">
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#ffffff'} onChange={(v) => setProp('textColor', v)} />
<div style={{ display: 'flex', gap: 8, ...sectionGap }}>
<div style={{ flex: 1 }}>
<ColorPickerField label="Button BG" value={nodeProps.buttonBgColor || '#3b82f6'} onChange={(v) => setProp('buttonBgColor', v)} />
</div>
<div style={{ flex: 1 }}>
<ColorPickerField label="Button Text" value={nodeProps.buttonTextColor || '#ffffff'} onChange={(v) => setProp('buttonTextColor', v)} />
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Layout">
<div style={sectionGap}>
<label style={labelStyle}>Minimum Height</label>
<div style={{ display: 'flex', gap: 4 }}>
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
<button key={h} onClick={() => setProp('minHeight', h)} style={btnActiveStyle(nodeProps.minHeight === h)}>
{h === '100vh' ? 'Full' : h}
</button>
))}
</div>
</div>
<div style={sectionGap}>
<label style={labelStyle}>Vertical</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['top', 'center', 'bottom'] as const).map((v) => (
<button key={v} onClick={() => setProp('verticalAlign', v)} style={btnActiveStyle(nodeProps.verticalAlign === v)}>{v}</button>
))}
</div>
</div>
<div style={sectionGap}>
<label style={labelStyle}>Text Align</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} onClick={() => setProp('textAlign', a)} style={btnActiveStyle(nodeProps.textAlign === a)}>
<i className={`fa fa-align-${a}`} />
</button>
))}
</div>
</div>
</CollapsibleSection>
</>
);
};

View File

@@ -0,0 +1,215 @@
import React, { useState, useCallback, useRef, CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import {
RADIUS_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
PresetButtonGrid,
TextInputField,
uploadToWhp,
} from './shared';
/* ---------- IMAGE (with upload/browse/drop) ---------- */
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
const [imgUrl, setImgUrl] = useState(nodeProps.src || '');
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg";
const isPlaceholder = !nodeProps.src || nodeProps.src.startsWith('data:image/svg');
const setPropStyle = useCallback(
(property: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [property]: value };
});
},
[actions, selectedId],
);
const handleUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) {
actions.setProp(selectedId, (props: any) => { props.src = url; });
setImgUrl(url);
}
}, [actions, selectedId]);
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 maxWidthPresets = [
{ label: '25%', value: '25%' },
{ label: '50%', value: '50%' },
{ label: '75%', value: '75%' },
{ label: '100%', value: '100%' },
];
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';
};
return (
<>
{/* Image source with upload/browse */}
<div className="guided-section">
<SectionLabel>Image Source</SectionLabel>
{!isPlaceholder && nodeProps.src ? (
<>
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={nodeProps.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
<button
onClick={() => { actions.setProp(selectedId, (props: any) => { props.src = ''; }); setImgUrl(''); }}
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Remove image"
>
<i className="fa fa-times" />
</button>
</div>
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(nodeProps.src || '')}</span>
</div>
</>
) : (
<div
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
onDragOver={(e) => { 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()}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
Drop image here or click to upload
</div>
)}
{/* Upload + Browse buttons */}
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
>
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
</button>
</div>
{/* Inline asset browser grid */}
{showBrowser && (
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map((asset) => (
<div
key={asset.name}
onClick={() => {
actions.setProp(selectedId, (props: any) => { props.src = asset.url; });
setImgUrl(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'; }}
>
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{browserAssets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
{/* URL input for advanced users */}
<div style={{ marginTop: 6 }}>
<div className="guided-input-row">
<input
type="text"
className="guided-input"
value={imgUrl}
placeholder="Or paste image URL..."
onChange={(e) => setImgUrl(e.target.value)}
style={{ fontSize: 11 }}
/>
<button
className="preset-btn apply-btn"
onClick={() => {
actions.setProp(selectedId, (props: any) => { props.src = imgUrl; });
}}
>
Apply
</button>
</div>
</div>
</div>
{/* Alt Text */}
<TextInputField
label="Alt Text"
value={nodeProps.alt || ''}
placeholder="Describe the image..."
onChange={(v) => {
actions.setProp(selectedId, (props: any) => { props.alt = v; });
}}
/>
{/* Border Radius */}
<div className="guided-section">
<SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid
presets={RADIUS_PRESETS}
activeValue={style.borderRadius as string}
onSelect={(v) => setPropStyle('borderRadius', v)}
/>
</div>
{/* Max Width */}
<div className="guided-section">
<SectionLabel>Max Width</SectionLabel>
<PresetButtonGrid
presets={maxWidthPresets}
activeValue={style.maxWidth as string}
onSelect={(v) => setPropStyle('maxWidth', v)}
/>
</div>
</>
);
};

View File

@@ -0,0 +1,197 @@
import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
SPACING_PRESETS,
RADIUS_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
ColorSwatchGrid,
PresetButtonGrid,
CollapsibleSection,
ColorPickerField,
ArrayPropEditor,
labelStyle,
inputStyle,
smallInputStyle,
sectionGap,
} from './shared';
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const style = nodeProps.style || {};
return (
<>
{/* Video URL */}
{nodeProps.videoUrl !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Video URL</label>
<input type="text" value={nodeProps.videoUrl || ''} onChange={(e) => setProp('videoUrl', e.target.value)} placeholder="YouTube, Vimeo, or .mp4 URL" style={inputStyle} />
</div>
)}
{/* Map URL */}
{nodeProps.embedUrl !== undefined && nodeProps.videoUrl === undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Embed URL</label>
<input type="text" value={nodeProps.embedUrl || ''} onChange={(e) => setProp('embedUrl', e.target.value)} placeholder="Google Maps embed URL" style={inputStyle} />
</div>
)}
{/* Map address */}
{nodeProps.address !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Address</label>
<input type="text" value={nodeProps.address || ''} onChange={(e) => setProp('address', e.target.value)} placeholder="123 Main St..." style={inputStyle} />
</div>
)}
{/* Video options */}
{nodeProps.autoplay !== undefined && (
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={nodeProps.autoplay || false} onChange={(e) => setProp('autoplay', e.target.checked)} />
Autoplay
</label>
</div>
)}
{nodeProps.loop !== undefined && (
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={nodeProps.loop || false} onChange={(e) => setProp('loop', e.target.checked)} />
Loop
</label>
</div>
)}
{nodeProps.controls !== undefined && (
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={nodeProps.controls !== false} onChange={(e) => setProp('controls', e.target.checked)} />
Show Controls
</label>
</div>
)}
{/* Gallery items */}
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
<CollapsibleSection title="Images">
<ArrayPropEditor
selectedId={selectedId}
propKey="images"
items={nodeProps.images}
renderItem={(item: any, index: number) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{item.src !== undefined && (
<input type="text" value={item.src || ''} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.images || [])];
updated[index] = { ...updated[index], src: e.target.value };
props.images = updated;
});
}} placeholder="Image URL" style={smallInputStyle} />
)}
{item.caption !== undefined && (
<input type="text" value={item.caption || ''} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.images || [])];
updated[index] = { ...updated[index], caption: e.target.value };
props.images = updated;
});
}} placeholder="Caption" style={smallInputStyle} />
)}
</div>
)}
emptyItem={{ src: '', caption: '' }}
/>
</CollapsibleSection>
)}
{/* Slides */}
{nodeProps.slides !== undefined && Array.isArray(nodeProps.slides) && (
<CollapsibleSection title="Slides">
<ArrayPropEditor
selectedId={selectedId}
propKey="slides"
items={nodeProps.slides}
renderItem={(item: any, index: number) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{item.heading !== undefined && (
<input type="text" value={item.heading || ''} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.slides || [])];
updated[index] = { ...updated[index], heading: e.target.value };
props.slides = updated;
});
}} placeholder="Heading" style={smallInputStyle} />
)}
{item.text !== undefined && (
<textarea value={item.text || ''} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.slides || [])];
updated[index] = { ...updated[index], text: e.target.value };
props.slides = updated;
});
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
)}
{item.image !== undefined && (
<input type="text" value={item.image || ''} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.slides || [])];
updated[index] = { ...updated[index], image: e.target.value };
props.slides = updated;
});
}} placeholder="Image URL" style={smallInputStyle} />
)}
</div>
)}
emptyItem={{ heading: 'New Slide', text: '', image: '' }}
/>
</CollapsibleSection>
)}
{/* Overlay */}
{nodeProps.overlayColor !== undefined && (
<CollapsibleSection title="Overlay" defaultOpen={false}>
<ColorPickerField label="Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setProp('overlayColor', v)} />
{nodeProps.overlayOpacity !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {nodeProps.overlayOpacity ?? 0}%</label>
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity ?? 0} onChange={(e) => setProp('overlayOpacity', parseInt(e.target.value))} style={{ width: '100%' }} />
</div>
)}
</CollapsibleSection>
)}
{/* Background & padding */}
<CollapsibleSection title="Style" defaultOpen={false}>
<div className="guided-section">
<SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
<div className="guided-section">
<SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
</div>
</CollapsibleSection>
</>
);
};

View File

@@ -0,0 +1,192 @@
import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import {
SPACING_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
PresetButtonGrid,
CollapsibleSection,
ColorPickerField,
labelStyle,
inputStyle,
smallInputStyle,
sectionGap,
} from './shared';
/* ---------- NAV / MENU / LOGO ---------- */
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const links: any[] = nodeProps.links || [];
const updateLink = useCallback((index: number, field: string, value: any) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.links || [])];
updated[index] = { ...updated[index], [field]: value };
props.links = updated;
});
}, [actions, selectedId]);
const addLink = useCallback(() => {
actions.setProp(selectedId, (props: any) => {
props.links = [...(props.links || []), { text: 'New Link', href: '#' }];
});
}, [actions, selectedId]);
const removeLink = useCallback((index: number) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.links || [])];
updated.splice(index, 1);
props.links = updated;
});
}, [actions, selectedId]);
/* Detect standalone Logo vs Navbar/Menu */
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
return (
<>
{/* Standalone Logo component settings */}
{isStandaloneLogo && (
<CollapsibleSection title="Logo" defaultOpen={true}>
<div style={sectionGap}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp('type', 'text')}
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'text' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'text' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'text' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
>
<i className="fa fa-font" style={{ marginRight: 4 }} />Text
</button>
<button
onClick={() => setProp('type', 'image')}
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'image' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'image' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'image' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
>
<i className="fa fa-image" style={{ marginRight: 4 }} />Image
</button>
</div>
</div>
{nodeProps.type === 'text' && (
<>
<div style={sectionGap}>
<label style={labelStyle}>Logo Text</label>
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Font Size</label>
<input type="text" value={nodeProps.fontSize || '20px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="20px" style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Font Weight</label>
<select value={nodeProps.fontWeight || '700'} onChange={(e) => setProp('fontWeight', e.target.value)} style={{ ...inputStyle, cursor: 'pointer' }}>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
<option value="800">Extra Bold</option>
</select>
</div>
<ColorPickerField label="Text Color" value={nodeProps.color || '#1f2937'} onChange={(v) => setProp('color', v)} />
</>
)}
{nodeProps.type === 'image' && (
<>
<div style={sectionGap}>
<label style={labelStyle}>Image URL</label>
<input type="text" value={nodeProps.imageSrc || ''} onChange={(e) => setProp('imageSrc', e.target.value)} placeholder="https://..." style={inputStyle} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Image Width</label>
<input type="text" value={nodeProps.imageWidth || '120px'} onChange={(e) => setProp('imageWidth', e.target.value)} placeholder="120px" style={inputStyle} />
</div>
</>
)}
<div style={sectionGap}>
<label style={labelStyle}>Link URL</label>
<input type="text" value={nodeProps.href || '/'} onChange={(e) => setProp('href', e.target.value)} placeholder="/" style={inputStyle} />
</div>
</CollapsibleSection>
)}
{/* Navbar Logo settings */}
{nodeProps.logoText !== undefined && (
<CollapsibleSection title="Logo">
<div style={sectionGap}>
<label style={labelStyle}>Logo Text</label>
<input type="text" value={nodeProps.logoText || ''} onChange={(e) => setProp('logoText', e.target.value)} style={inputStyle} />
</div>
{nodeProps.logoImage !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Logo Image URL</label>
<input type="text" value={nodeProps.logoImage || ''} onChange={(e) => setProp('logoImage', e.target.value)} placeholder="https://..." style={inputStyle} />
</div>
)}
{nodeProps.logoUrl !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Logo Link URL</label>
<input type="text" value={nodeProps.logoUrl || ''} onChange={(e) => setProp('logoUrl', e.target.value)} placeholder="/" style={inputStyle} />
</div>
)}
</CollapsibleSection>
)}
{/* Links (not shown for standalone Logo) */}
{!isStandaloneLogo && (
<CollapsibleSection title="Links">
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={link.text || ''} onChange={(e) => updateLink(i, 'text', e.target.value)} placeholder="Text" style={{ ...smallInputStyle, flex: 1 }} />
<button onClick={() => removeLink(i)} style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
<i className="fa fa-times" />
</button>
</div>
<input type="text" value={link.href || ''} onChange={(e) => updateLink(i, 'href', e.target.value)} placeholder="URL" style={{ ...smallInputStyle, color: '#71717a' }} />
</div>
))}
</div>
<button onClick={addLink} style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}>
+ Add Link
</button>
</CollapsibleSection>
)}
{/* Colors (not shown for standalone Logo - it has its own color picker) */}
{!isStandaloneLogo && (
<CollapsibleSection title="Colors">
{nodeProps.backgroundColor !== undefined && (
<ColorPickerField label="Background" value={nodeProps.backgroundColor || '#ffffff'} onChange={(v) => setProp('backgroundColor', v)} />
)}
{nodeProps.textColor !== undefined && (
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
)}
{nodeProps.ctaColor !== undefined && (
<ColorPickerField label="CTA Color" value={nodeProps.ctaColor || '#3b82f6'} onChange={(v) => setProp('ctaColor', v)} />
)}
</CollapsibleSection>
)}
{/* Style overrides */}
<CollapsibleSection title="Spacing" defaultOpen={false}>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={(nodeProps.style || {}).padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
</CollapsibleSection>
</>
);
};

View File

@@ -0,0 +1,210 @@
import React, { useCallback, useState } from 'react';
import { useEditor } from '@craftjs/core';
import {
StylePanelProps,
CollapsibleSection,
ColorPickerField,
labelStyle,
inputStyle,
smallInputStyle,
btnActiveStyle,
sectionGap,
} from './shared';
const bulletOptions = [
{ label: '✓', value: 'check' },
{ label: '●', value: 'dot' },
{ label: '→', value: 'arrow' },
{ label: '★', value: 'star' },
{ label: '—', value: 'dash' },
{ label: 'None', value: 'none' },
];
const bulletChar: Record<string, string> = {
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
};
export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const [expandedPlan, setExpandedPlan] = useState<number>(0);
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
const currentBullet = nodeProps.bulletType || 'check';
const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
updated[planIndex] = { ...updated[planIndex], [field]: value };
props.plans = updated;
});
}, [actions, selectedId]);
const addPlan = useCallback(() => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
updated.push({
name: 'New Plan',
price: '$0',
period: '/month',
features: ['Feature 1'],
buttonText: 'Choose Plan',
buttonHref: '#',
isFeatured: false,
});
props.plans = updated;
});
}, [actions, selectedId]);
const removePlan = useCallback((index: number) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
updated.splice(index, 1);
props.plans = updated;
});
}, [actions, selectedId]);
const addFeature = useCallback((planIndex: number) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
features.push('New feature');
updated[planIndex] = { ...updated[planIndex], features };
props.plans = updated;
});
}, [actions, selectedId]);
const updateFeature = useCallback((planIndex: number, featureIndex: number, value: string) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
features[featureIndex] = value;
updated[planIndex] = { ...updated[planIndex], features };
props.plans = updated;
});
}, [actions, selectedId]);
const removeFeature = useCallback((planIndex: number, featureIndex: number) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
features.splice(featureIndex, 1);
updated[planIndex] = { ...updated[planIndex], features };
props.plans = updated;
});
}, [actions, selectedId]);
return (
<>
{/* Bullet type */}
<CollapsibleSection title="Bullet Style">
<div style={{ display: 'flex', gap: 4 }}>
{bulletOptions.map((b) => (
<button key={b.value} onClick={() => actions.setProp(selectedId, (p: any) => { p.bulletType = b.value; })}
style={{ ...btnActiveStyle(currentBullet === b.value), flex: 1, fontSize: 14 }}>
{b.label}
</button>
))}
</div>
</CollapsibleSection>
{/* Plans */}
<CollapsibleSection title={`Plans (${plans.length})`}>
{plans.map((plan, i) => {
const isExpanded = expandedPlan === i;
const features: string[] = Array.isArray(plan.features) ? plan.features : [];
return (
<div key={i} style={{
marginBottom: 8, background: '#18181b', borderRadius: 6,
border: plan.isFeatured ? '1px solid #3b82f6' : '1px solid #27272a',
}}>
{/* Plan header - click to expand */}
<div onClick={() => setExpandedPlan(isExpanded ? -1 : i)} style={{
padding: '8px 10px', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7' }}>
{plan.name || 'Plan'} {plan.isFeatured && <span style={{ fontSize: 9, background: '#3b82f6', color: '#fff', padding: '1px 5px', borderRadius: 3, marginLeft: 4 }}>Featured</span>}
</span>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#71717a' }}>{plan.price}</span>
<i className={`fa fa-chevron-${isExpanded ? 'up' : 'down'}`} style={{ fontSize: 10, color: '#71717a' }} />
</div>
</div>
{/* Expanded plan settings */}
{isExpanded && (
<div style={{ padding: '0 10px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 9, color: '#52525b' }}>Name</label>
<input type="text" value={plan.name || ''} onChange={(e) => updatePlan(i, 'name', e.target.value)} style={smallInputStyle} />
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 9, color: '#52525b' }}>Price</label>
<input type="text" value={plan.price || ''} onChange={(e) => updatePlan(i, 'price', e.target.value)} style={smallInputStyle} />
</div>
</div>
<div>
<label style={{ fontSize: 9, color: '#52525b' }}>Period</label>
<input type="text" value={plan.period || ''} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={smallInputStyle} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 9, color: '#52525b' }}>Button Text</label>
<input type="text" value={plan.buttonText || ''} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} style={smallInputStyle} />
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 9, color: '#52525b' }}>Button URL</label>
<input type="text" value={plan.buttonHref || ''} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} style={smallInputStyle} />
</div>
</div>
<label style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={!!plan.isFeatured} onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)} />
Featured (highlighted)
</label>
{/* Features list */}
<div>
<label style={{ fontSize: 9, color: '#52525b', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Features ({features.length})</span>
<button onClick={() => addFeature(i)} style={{ fontSize: 9, background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 3, padding: '2px 6px', cursor: 'pointer' }}>
+ Add
</button>
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4 }}>
{features.map((feat, fi) => (
<div key={fi} style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#10b981', width: 14, textAlign: 'center' }}>{bulletChar[currentBullet] || '✓'}</span>
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...smallInputStyle, flex: 1 }} />
<button onClick={() => removeFeature(i, fi)} style={{ fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, padding: '1px 4px', cursor: 'pointer', lineHeight: 1 }}>
×
</button>
</div>
))}
</div>
</div>
{/* Remove plan */}
{plans.length > 1 && (
<button onClick={() => removePlan(i)} style={{ fontSize: 10, background: 'none', color: '#ef4444', border: '1px solid #ef4444', borderRadius: 4, padding: '3px 8px', cursor: 'pointer', marginTop: 4 }}>
Remove Plan
</button>
)}
</div>
)}
</div>
);
})}
<button onClick={addPlan} style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', marginTop: 4 }}>
+ Add Plan
</button>
</CollapsibleSection>
{/* Colors */}
<CollapsibleSection title="Colors" defaultOpen={false}>
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
</CollapsibleSection>
</>
);
};

View File

@@ -0,0 +1,239 @@
import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
SPACING_PRESETS,
RADIUS_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
ColorSwatchGrid,
PresetButtonGrid,
CollapsibleSection,
ColorPickerField,
ArrayPropEditor,
labelStyle,
inputStyle,
smallInputStyle,
sectionGap,
} from './shared';
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
const { actions } = useEditor();
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const style = nodeProps.style || {};
// Find all string/number/boolean props
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
const scalarProps = Object.entries(nodeProps).filter(
([key, val]) => !SKIP_PROPS.has(key) && (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean')
);
const colorProps = scalarProps.filter(([key]) => /color/i.test(key));
const otherScalarProps = scalarProps.filter(([key]) => !/color/i.test(key));
const arrayProps = Object.entries(nodeProps).filter(([key, val]) => !SKIP_PROPS.has(key) && Array.isArray(val));
return (
<>
{/* Content props */}
{otherScalarProps.length > 0 && (
<CollapsibleSection title="Content">
{otherScalarProps.map(([key, val]) => {
const humanLabel = key.replace(/([A-Z])/g, ' $1').trim();
if (typeof val === 'boolean') {
return (
<div key={key} style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={val} onChange={(e) => setProp(key, e.target.checked)} />
{humanLabel}
</label>
</div>
);
}
if (typeof val === 'number') {
return (
<div key={key} style={sectionGap}>
<label style={labelStyle}>{humanLabel}</label>
<input type="number" value={val} onChange={(e) => setProp(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
</div>
);
}
// String - use textarea for long values
const isLong = String(val).length > 60;
return (
<div key={key} style={sectionGap}>
<label style={labelStyle}>{humanLabel}</label>
{isLong ? (
<textarea value={String(val)} onChange={(e) => setProp(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={String(val)} onChange={(e) => setProp(key, e.target.value)} style={inputStyle} />
)}
</div>
);
})}
</CollapsibleSection>
)}
{/* Color props */}
{colorProps.length > 0 && (
<CollapsibleSection title="Colors">
{colorProps.map(([key, val]) => (
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setProp(key, v)} />
))}
</CollapsibleSection>
)}
{/* Array props (features, items, plans, testimonials, etc.) */}
{arrayProps.map(([key, items]) => {
const arrayItems = items as any[];
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
const sampleItem = arrayItems[0] || {};
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
return (
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
<ArrayPropEditor
selectedId={selectedId}
propKey={key}
items={arrayItems}
renderItem={(item: any, index: number) => {
if (typeof item !== 'object' || item === null) {
return (
<input
type="text"
value={String(item)}
onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = e.target.value;
props[key] = updated;
});
}}
style={smallInputStyle}
/>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{itemFields.map((field) => {
const fieldVal = item[field];
if (typeof fieldVal === 'boolean') {
return (
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.checked };
props[key] = updated;
});
}} />
{field}
</label>
);
}
if (typeof fieldVal === 'number') {
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
<input type="number" value={fieldVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
props[key] = updated;
});
}} style={smallInputStyle} />
</div>
);
}
// color fields
if (/color/i.test(field) && typeof fieldVal === 'string') {
return (
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
</div>
);
}
// long text
const strVal = String(fieldVal ?? '');
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
return (
<div key={field}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
{isLongField ? (
<textarea value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
) : (
<input type="text" value={strVal} onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[key] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[key] = updated;
});
}} style={smallInputStyle} />
)}
</div>
);
})}
</div>
);
}}
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
: ''
}
/>
</CollapsibleSection>
);
})}
{/* Style */}
<CollapsibleSection title="Style" defaultOpen={false}>
<div className="guided-section">
<SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
<div className="guided-section">
<SectionLabel>Text Alignment</SectionLabel>
<div className="preset-grid align-grid">
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setPropStyle('textAlign', a)} title={a}>
<i className={`fa fa-align-${a}`} />
</button>
))}
</div>
</div>
<div className="guided-section">
<SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
</div>
</CollapsibleSection>
</>
);
};

View File

@@ -0,0 +1,177 @@
import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
SPACING_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
ColorSwatchGrid,
PresetButtonGrid,
CollapsibleSection,
ColorPickerField,
labelStyle,
inputStyle,
smallInputStyle,
btnActiveStyle,
sectionGap,
} from './shared';
/* ---------- SOCIAL / ICON / STAR RATING ---------- */
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]);
const setPropStyle = useCallback((key: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [key]: value };
});
}, [actions, selectedId]);
const style = nodeProps.style || {};
return (
<>
{/* Social Links list */}
{nodeProps.links !== undefined && Array.isArray(nodeProps.links) && (
<CollapsibleSection title="Links">
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(nodeProps.links || []).map((link: any, i: number) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#a1a1aa', width: 60, textTransform: 'capitalize' }}>{link.platform || 'link'}</span>
<input
type="text"
value={link.url || ''}
onChange={(e) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.links || [])];
updated[i] = { ...updated[i], url: e.target.value };
props.links = updated;
});
}}
placeholder="URL"
style={{ ...smallInputStyle, flex: 1 }}
/>
<button
onClick={() => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.links || [])];
updated.splice(i, 1);
props.links = updated;
});
}}
style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
<i className="fa fa-times" />
</button>
</div>
))}
</div>
<div style={{ marginTop: 6 }}>
<select
onChange={(e) => {
if (!e.target.value) return;
actions.setProp(selectedId, (props: any) => {
props.links = [...(props.links || []), { platform: e.target.value, url: '#' }];
});
e.target.value = '';
}}
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
<option value="">+ Add Platform...</option>
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp'].map((p) => (
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
))}
</select>
</div>
</CollapsibleSection>
)}
{/* Icon name */}
{nodeProps.iconName !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Icon Name</label>
<input type="text" value={nodeProps.iconName || ''} onChange={(e) => setProp('iconName', e.target.value)} placeholder="fa-star" style={inputStyle} />
</div>
)}
{nodeProps.icon !== undefined && typeof nodeProps.icon === 'string' && (
<div style={sectionGap}>
<label style={labelStyle}>Icon</label>
<input type="text" value={nodeProps.icon || ''} onChange={(e) => setProp('icon', e.target.value)} placeholder="fa-star or emoji" style={inputStyle} />
</div>
)}
{/* Star rating */}
{nodeProps.rating !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Rating: {nodeProps.rating || 0}</label>
<input type="range" min={0} max={5} step={0.5} value={nodeProps.rating || 0} onChange={(e) => setProp('rating', parseFloat(e.target.value))} style={{ width: '100%' }} />
</div>
)}
{nodeProps.maxStars !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Max Stars</label>
<input type="number" min={1} max={10} value={nodeProps.maxStars || 5} onChange={(e) => setProp('maxStars', parseInt(e.target.value) || 5)} style={inputStyle} />
</div>
)}
{/* Colors */}
{nodeProps.iconColor !== undefined && (
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setProp('iconColor', v)} />
)}
{nodeProps.iconBgColor !== undefined && (
<ColorPickerField label="Icon Background" value={nodeProps.iconBgColor || 'transparent'} onChange={(v) => setProp('iconBgColor', v)} />
)}
{nodeProps.starColor !== undefined && (
<ColorPickerField label="Star Color" value={nodeProps.starColor || '#f59e0b'} onChange={(v) => setProp('starColor', v)} />
)}
{nodeProps.color !== undefined && typeof nodeProps.color === 'string' && (
<ColorPickerField label="Color" value={nodeProps.color || '#3b82f6'} onChange={(v) => setProp('color', v)} />
)}
{/* Size */}
{nodeProps.iconSize !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Icon Size</label>
<input type="text" value={nodeProps.iconSize || '24px'} onChange={(e) => setProp('iconSize', e.target.value)} style={inputStyle} />
</div>
)}
{nodeProps.size !== undefined && typeof nodeProps.size === 'string' && (
<div style={sectionGap}>
<label style={labelStyle}>Size</label>
<input type="text" value={nodeProps.size || '24px'} onChange={(e) => setProp('size', e.target.value)} style={inputStyle} />
</div>
)}
{/* Alignment */}
{nodeProps.alignment !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} onClick={() => setProp('alignment', a)} style={btnActiveStyle(nodeProps.alignment === a)}>
<i className={`fa fa-align-${a}`} />
</button>
))}
</div>
</div>
)}
{/* Background & padding */}
<CollapsibleSection title="Style" defaultOpen={false}>
<div className="guided-section">
<SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
</CollapsibleSection>
</>
);
};

View File

@@ -0,0 +1,81 @@
import React, { useCallback, CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import {
TEXT_COLORS,
FONT_FAMILIES,
TEXT_SIZES,
FONT_WEIGHTS,
} from '../../../constants/presets';
import {
StylePanelProps,
SectionLabel,
ColorSwatchGrid,
PresetButtonGrid,
} from './shared';
/* ---------- TEXT ---------- */
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const setPropStyle = useCallback(
(property: string, value: string) => {
actions.setProp(selectedId, (props: any) => {
props.style = { ...props.style, [property]: value };
});
},
[actions, selectedId],
);
return (
<>
<div className="guided-section">
<SectionLabel>Text Color</SectionLabel>
<ColorSwatchGrid
colors={TEXT_COLORS}
activeValue={style.color as string}
onSelect={(v) => setPropStyle('color', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Font Family</SectionLabel>
<PresetButtonGrid
presets={FONT_FAMILIES}
activeValue={style.fontFamily as string}
onSelect={(v) => setPropStyle('fontFamily', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Text Size</SectionLabel>
<PresetButtonGrid
presets={TEXT_SIZES}
activeValue={style.fontSize as string}
onSelect={(v) => setPropStyle('fontSize', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Font Weight</SectionLabel>
<PresetButtonGrid
presets={FONT_WEIGHTS}
activeValue={String(style.fontWeight || '')}
onSelect={(v) => setPropStyle('fontWeight', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Alignment</SectionLabel>
<div className="preset-grid align-grid">
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
<button
key={a}
className={`preset-btn ${style.textAlign === a ? 'active' : ''}`}
onClick={() => setPropStyle('textAlign', a)}
title={a}
>
<i className={`fa fa-align-${a}`} />
</button>
))}
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,13 @@
export { TextStylePanel } from './TextStylePanel';
export { ButtonStylePanel } from './ButtonStylePanel';
export { ImageStylePanel } from './ImageStylePanel';
export { ContainerStylePanel } from './ContainerStylePanel';
export { HeroStylePanel } from './HeroStylePanel';
export { NavStylePanel } from './NavStylePanel';
export { MediaStylePanel } from './MediaStylePanel';
export { FormStylePanel } from './FormStylePanel';
export { SocialStylePanel } from './SocialStylePanel';
export { SectionTypePanel } from './SectionTypePanel';
export { PricingStylePanel } from './PricingStylePanel';
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
export { GenericPropsEditor } from './GenericPropsEditor';

View File

@@ -0,0 +1,322 @@
import React, { useState, useCallback, CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import {
GRADIENTS,
} from '../../../constants/presets';
/* ---------- Helper: auto text color for bg ---------- */
export function autoTextColor(bg: string): string {
if (bg.startsWith('#')) {
const hex = bg.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#18181b' : '#ffffff';
}
return '#ffffff';
}
/* ---------- Helper: upload to WHP ---------- */
export async function uploadToWhp(file: File): Promise<string | null> {
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; }
}
/* ---------- Shared inline styles ---------- */
export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' };
export const inputStyle: CSSProperties = { width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, boxSizing: 'border-box' };
export const smallInputStyle: CSSProperties = { ...inputStyle, fontSize: 11, padding: '3px 6px' };
export const btnActiveStyle = (active: boolean): CSSProperties => ({
flex: 1, padding: '5px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
fontWeight: active ? 600 : 400,
});
export const sectionGap: CSSProperties = { marginBottom: 14 };
/* ---------- Reusable sub-components ---------- */
interface SectionLabelProps { children: React.ReactNode; }
export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => (
<label className="guided-section-label">{children}</label>
);
interface ColorSwatchGridProps {
colors: { label: string; value: string }[];
activeValue: string | undefined;
onSelect: (value: string) => void;
}
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect }) => (
<div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="color"
value={activeValue || '#000000'}
onChange={(e) => onSelect(e.target.value)}
style={{ width: 32, height: 28, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
/>
<input
type="text"
value={activeValue || ''}
onChange={(e) => onSelect(e.target.value)}
placeholder="#000000"
style={{ flex: 1, padding: '3px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, fontFamily: 'monospace', boxSizing: 'border-box' as const }}
/>
</div>
<div className="preset-grid">
{colors.map((c) => (
<button
key={c.value}
className={`preset-swatch ${activeValue === c.value ? 'active' : ''}`}
style={{ background: c.value }}
onClick={() => onSelect(c.value)}
title={c.label}
/>
))}
</div>
</div>
);
interface PresetButtonGridProps {
presets: { label: string; value: string }[];
activeValue: string | undefined;
onSelect: (value: string) => void;
}
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect }) => (
<div className="preset-grid">
{presets.map((p) => (
<button
key={p.value}
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`}
onClick={() => onSelect(p.value)}
>
{p.label}
</button>
))}
</div>
);
interface GradientSwatchGridProps {
activeValue: string | undefined;
onSelect: (value: string) => void;
}
/* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */
function parseGradient(val: string | undefined): { angle: number; from: string; to: string } {
if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' };
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8})/);
if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] };
return { angle: 135, from: '#667eea', to: '#764ba2' };
}
export const GradientSwatchGrid: React.FC<GradientSwatchGridProps> = ({ activeValue, onSelect }) => {
const [showCustom, setShowCustom] = useState(false);
const parsed = parseGradient(activeValue);
const [customFrom, setCustomFrom] = useState(parsed.from);
const [customTo, setCustomTo] = useState(parsed.to);
const [customAngle, setCustomAngle] = useState(parsed.angle);
const applyCustomGradient = (from: string, to: string, angle: number) => {
setCustomFrom(from);
setCustomTo(to);
setCustomAngle(angle);
onSelect(`linear-gradient(${angle}deg, ${from} 0%, ${to} 100%)`);
};
return (
<div>
{/* Custom gradient builder toggle */}
<button
onClick={() => setShowCustom(!showCustom)}
style={{
width: '100%', padding: '5px 8px', fontSize: 11, marginBottom: 6,
background: showCustom ? '#3b82f6' : '#27272a', color: showCustom ? '#fff' : '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
<i className={`fa fa-${showCustom ? 'chevron-down' : 'sliders'}`} style={{ fontSize: 10 }} />
Custom Gradient
</button>
{showCustom && (
<div style={{ padding: 8, background: '#1e1e22', borderRadius: 6, marginBottom: 8 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>From</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
<input type="text" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
</div>
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>To</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
<input type="text" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
</div>
</div>
</div>
<div>
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>Angle: {customAngle}°</label>
<input type="range" min={0} max={360} value={customAngle} onChange={(e) => applyCustomGradient(customFrom, customTo, parseInt(e.target.value))}
style={{ width: '100%' }} />
</div>
{/* Live preview */}
<div style={{ height: 20, borderRadius: 4, marginTop: 6, background: `linear-gradient(${customAngle}deg, ${customFrom}, ${customTo})`, border: '1px solid #3f3f46' }} />
</div>
)}
{/* Preset swatches */}
<div className="preset-grid gradient-grid">
{GRADIENTS.map((g) => (
<button
key={g.label}
className={`preset-swatch gradient-swatch ${activeValue === g.value ? 'active' : ''}`}
style={{ background: g.value === 'none' ? '#27272a' : g.value }}
onClick={() => onSelect(g.value)}
title={g.label}
>
{g.value === 'none' ? '\u00D7' : ''}
</button>
))}
</div>
</div>
);
};
interface TextInputFieldProps {
label: string;
value: string;
placeholder?: string;
onChange: (value: string) => void;
}
export const TextInputField: React.FC<TextInputFieldProps> = ({ label, value, placeholder, onChange }) => (
<div className="guided-section">
<SectionLabel>{label}</SectionLabel>
<input
type="text"
className="guided-input"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
/* ---------- Color picker with hex input ---------- */
interface ColorPickerFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
}
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange }) => (
<div style={sectionGap}>
<label style={labelStyle}>{label}</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="color"
value={value || '#000000'}
onChange={(e) => onChange(e.target.value)}
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
/>
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="#000000"
style={{ ...inputStyle, flex: 1 }}
/>
</div>
</div>
);
/* ---------- Collapsible section ---------- */
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div style={{ borderTop: '1px solid #2d2d3a', paddingTop: 8, marginTop: 4 }}>
<button
onClick={() => setOpen(!open)}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', background: 'none', border: 'none', color: '#a1a1aa', fontSize: 11, fontWeight: 600, cursor: 'pointer', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
<i className={`fa fa-chevron-${open ? 'down' : 'right'}`} style={{ fontSize: 8, width: 10 }} />
{title}
</button>
{open && <div style={{ paddingTop: 8 }}>{children}</div>}
</div>
);
};
/* ---------- StylePanelProps interface ---------- */
export interface StylePanelProps {
selectedId: string;
nodeProps: Record<string, any>;
}
/* ---------- Array Prop Editor (reusable for features, items, plans, etc.) ---------- */
interface ArrayPropEditorProps {
selectedId: string;
propKey: string;
items: any[];
renderItem: (item: any, index: number) => React.ReactNode;
emptyItem: any;
}
export const ArrayPropEditor: React.FC<ArrayPropEditorProps> = ({ selectedId, propKey, items, renderItem, emptyItem }) => {
const { actions } = useEditor();
const addItem = useCallback(() => {
actions.setProp(selectedId, (props: any) => {
props[propKey] = [...(props[propKey] || []), typeof emptyItem === 'object' ? { ...emptyItem } : emptyItem];
});
}, [actions, selectedId, propKey, emptyItem]);
const removeItem = useCallback((index: number) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props[propKey] || [])];
updated.splice(index, 1);
props[propKey] = updated;
});
}, [actions, selectedId, propKey]);
return (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{items.map((item, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, position: 'relative' }}>
<button
onClick={() => removeItem(i)}
style={{ position: 'absolute', top: 4, right: 4, padding: '1px 5px', fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', zIndex: 1 }}
title="Remove"
>
<i className="fa fa-times" />
</button>
{renderItem(item, i)}
</div>
))}
</div>
<button
onClick={addItem}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Item
</button>
</div>
);
};

View File

@@ -0,0 +1,159 @@
import React, { useEffect } from 'react';
import { useSiteDesign } from '../../state/SiteDesignContext';
interface HeadCodeModalProps {
open: boolean;
onClose: () => void;
}
export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) => {
const { design, updateDesign } = useSiteDesign();
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
return (
<div style={backdropStyle} onClick={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div style={modalHeaderStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<i className="fa fa-code" style={{ color: 'var(--color-accent)', fontSize: 16 }} />
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Custom Head Code
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
Add tracking scripts, meta tags, or custom CSS to your site's &lt;head&gt; section.
</div>
</div>
</div>
<button onClick={onClose} style={closeButtonStyle}>
<i className="fa fa-times" />
</button>
</div>
{/* Body */}
<div style={{ padding: 20, flex: 1, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{
padding: '10px 14px',
background: 'rgba(59,130,246,0.08)',
border: '1px solid rgba(59,130,246,0.2)',
borderRadius: 6,
fontSize: 12,
color: 'var(--color-text-muted)',
lineHeight: 1.5,
}}>
<i className="fa fa-info-circle" style={{ color: 'var(--color-accent)', marginRight: 6 }} />
Code added here will be injected into the <code style={{ background: 'rgba(255,255,255,0.08)', padding: '1px 4px', borderRadius: 3, fontSize: 11 }}>&lt;head&gt;</code> of every page on your site. Use it for analytics, custom fonts, or global CSS.
</div>
<textarea
value={design.headCode || ''}
onChange={(e) => updateDesign({ headCode: e.target.value })}
placeholder={"<!-- Google Analytics -->\n<script async src=\"https://...\"></script>\n\n<!-- Custom Fonts -->\n<link href=\"https://fonts.googleapis.com/...\" rel=\"stylesheet\">\n\n<style>\n /* Global CSS overrides */\n body { }\n</style>"}
style={{
flex: 1,
minHeight: 300,
padding: 14,
background: '#0d0d0f',
color: '#e4e4e7',
border: '1px solid #3f3f46',
borderRadius: 8,
fontFamily: 'Source Code Pro, Consolas, monospace',
fontSize: 13,
lineHeight: 1.6,
resize: 'vertical',
outline: 'none',
tabSize: 2,
}}
spellCheck={false}
/>
</div>
{/* Footer */}
<div style={{
padding: '12px 20px',
borderTop: '1px solid var(--color-border)',
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
}}>
<button onClick={onClose} style={doneButtonStyle}>
Done
</button>
</div>
</div>
</div>
);
};
/* ---------- Styles ---------- */
const backdropStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.65)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
};
const modalStyle: React.CSSProperties = {
width: '90vw',
maxWidth: 700,
maxHeight: '80vh',
backgroundColor: 'var(--color-bg-surface)',
borderRadius: 12,
border: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
};
const modalHeaderStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
};
const closeButtonStyle: React.CSSProperties = {
width: 32,
height: 32,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: '1px solid var(--color-border)',
borderRadius: 6,
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: 14,
};
const doneButtonStyle: React.CSSProperties = {
padding: '8px 24px',
fontSize: 13,
fontWeight: 600,
background: 'var(--color-accent)',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
};

View File

@@ -0,0 +1,607 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import {
allTemplates,
TemplateDefinition,
TemplateComponent,
TemplateCategory,
} from '../../templates';
import { componentResolver } from '../../components/resolver';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TemplateModalProps {
open: boolean;
onClose: () => void;
}
type FilterTab = 'all' | TemplateCategory;
const TABS: { label: string; value: FilterTab }[] = [
{ label: 'All', value: 'all' },
{ label: 'Business', value: 'business' },
{ label: 'Creative', value: 'creative' },
{ label: 'Personal', value: 'personal' },
{ label: 'Community', value: 'community' },
];
const CATEGORY_COLORS: Record<TemplateCategory, string> = {
business: '#3b82f6',
creative: '#a855f7',
personal: '#f59e0b',
community: '#10b981',
};
// ---------------------------------------------------------------------------
// Modal Component
// ---------------------------------------------------------------------------
export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) => {
const { actions, query } = useEditor();
const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages();
const { updateDesign } = useSiteDesign();
const [activeTab, setActiveTab] = useState<FilterTab>('all');
const [confirmTemplate, setConfirmTemplate] = useState<TemplateDefinition | null>(null);
const [applyDesign, setApplyDesign] = useState(true);
const [loading, setLoading] = useState(false);
// Close on Escape key
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (confirmTemplate) setConfirmTemplate(null);
else onClose();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, confirmTemplate, onClose]);
// Filter templates by category
const filtered = useMemo(() => {
if (activeTab === 'all') return allTemplates;
return allTemplates.filter((t) => t.category === activeTab);
}, [activeTab]);
// Resolve a TemplateComponent type name to its React component
const resolverMap = componentResolver as Record<string, React.ComponentType<any>>;
/**
* Add all components from a template definition onto the current (empty) canvas ROOT.
* Uses Craft.js parseReactElement + addNodeTree which correctly builds valid node structures.
*/
const addTemplateComponents = useCallback(
(components: TemplateComponent[]) => {
for (const comp of components) {
const Component = resolverMap[comp.type];
if (!Component) {
console.warn(`Template references unknown component type: ${comp.type}`);
continue;
}
const element = React.createElement(Component, comp.props);
const tree = query.parseReactElement(element).toNodeTree();
actions.addNodeTree(tree, 'ROOT');
}
},
[query, actions, resolverMap],
);
/**
* Clear the current canvas by deleting all children of ROOT.
*/
const clearCanvas = useCallback(() => {
try {
const rootNode = query.node('ROOT').get();
const childIds = [...(rootNode.data.nodes || [])];
childIds.forEach((id) => {
try {
actions.delete(id);
} catch {
// Node may already be removed
}
});
} catch {
// ROOT doesn't exist yet or is empty -- that's fine
}
}, [query, actions]);
/**
* After all pages are loaded, apply header and footer template content.
* Switches to header/footer editing mode, clears, adds components, then returns to firstPageId.
*/
const applyHeaderFooter = useCallback(
(tpl: TemplateDefinition, firstPageId: string) => {
const hasHeader = tpl.header?.components?.length > 0;
const hasFooter = tpl.footer?.components?.length > 0;
if (!hasHeader && !hasFooter) {
setLoading(false);
return;
}
const applyZone = (
switchFn: () => void,
components: TemplateComponent[],
next: () => void,
) => {
switchFn();
setTimeout(() => {
try {
clearCanvas();
setTimeout(() => {
try {
addTemplateComponents(components);
} catch (e) {
console.warn('Failed to add zone components:', e);
}
setTimeout(next, 30);
}, 20);
} catch (e) {
console.warn('Failed to clear zone:', e);
setTimeout(next, 30);
}
}, 30);
};
const finish = () => {
// Switch back to the first page
switchPage(firstPageId);
setTimeout(() => setLoading(false), 30);
};
const doFooter = () => {
if (hasFooter) {
applyZone(editFooter, tpl.footer.components, finish);
} else {
finish();
}
};
if (hasHeader) {
applyZone(editHeader, tpl.header.components, doFooter);
} else {
doFooter();
}
},
[clearCanvas, addTemplateComponents, switchPage, editHeader, editFooter],
);
// Load the selected template
const handleLoad = useCallback(() => {
if (!confirmTemplate) return;
setLoading(true);
const tpl = confirmTemplate;
try {
// 1. Optionally apply design tokens
if (applyDesign && tpl.design) {
updateDesign(tpl.design);
}
// 2. Remove all pages except the first
const currentPages = [...pages];
const keepId = currentPages[0]?.id;
for (let i = currentPages.length - 1; i >= 1; i--) {
deletePage(currentPages[i].id);
}
// 3. Clear the current canvas and add the first template page's components
clearCanvas();
// Use a short delay to let the clear settle before adding new nodes
setTimeout(() => {
try {
const firstPage = tpl.pages[0];
addTemplateComponents(firstPage.content.components);
// 4. Add remaining pages (if multi-page template)
const finishPages = () => {
// 5. Apply header and footer template content
if (keepId) {
applyHeaderFooter(tpl, keepId);
} else {
setLoading(false);
}
};
if (tpl.pages.length > 1) {
let pageIndex = 1;
const addNextPage = () => {
if (pageIndex >= tpl.pages.length) {
// Switch back to first page, then apply header/footer
if (keepId) switchPage(keepId);
setTimeout(finishPages, 30);
return;
}
const pageDef = tpl.pages[pageIndex];
addPage(pageDef.name, pageDef.slug);
// After addPage, the new page is active with an empty canvas.
// Give a tick for Craft.js to settle, then add components.
setTimeout(() => {
try {
addTemplateComponents(pageDef.content.components);
} catch (e) {
console.warn('Failed to add components for page', pageDef.name, e);
}
pageIndex++;
setTimeout(addNextPage, 30);
}, 30);
};
setTimeout(addNextPage, 30);
} else {
finishPages();
}
} catch (e) {
console.error('Failed to load template:', e);
setLoading(false);
}
}, 20);
} catch (e) {
console.error('Failed to load template:', e);
setLoading(false);
}
setConfirmTemplate(null);
onClose();
}, [confirmTemplate, applyDesign, query, actions, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, onClose]);
// Close on backdrop click
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
if (confirmTemplate) {
setConfirmTemplate(null);
} else {
onClose();
}
}
},
[confirmTemplate, onClose],
);
if (!open) return null;
return (
<div style={backdropStyle} onClick={handleBackdropClick}>
<div style={modalStyle}>
{/* Header */}
<div style={modalHeaderStyle}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#e4e4e7' }}>
Templates
</h2>
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#71717a' }}>
Choose a template to get started quickly
</p>
</div>
<button onClick={onClose} style={closeButtonStyle} title="Close">
&#10005;
</button>
</div>
{/* Filter Tabs */}
<div style={tabBarStyle}>
{TABS.map((tab) => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
style={{
...tabStyle,
...(activeTab === tab.value ? tabActiveStyle : {}),
}}
>
{tab.label}
</button>
))}
</div>
{/* Template Grid */}
<div style={gridContainerStyle}>
<div style={gridStyle}>
{filtered.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
onSelect={() => setConfirmTemplate(tpl)}
/>
))}
{filtered.length === 0 && (
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: 40, color: '#71717a' }}>
No templates in this category.
</div>
)}
</div>
</div>
{/* Confirmation Dialog */}
{confirmTemplate && (
<div style={confirmOverlayStyle} onClick={() => setConfirmTemplate(null)}>
<div style={confirmDialogStyle} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: '#e4e4e7' }}>
Load "{confirmTemplate.name}"?
</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: '#a1a1aa' }}>
This will replace your current content with the template pages and components.
</p>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 13,
color: '#e4e4e7',
cursor: 'pointer',
marginBottom: 24,
padding: '10px 12px',
backgroundColor: 'var(--color-bg-elevated)',
borderRadius: 6,
border: '1px solid var(--color-border)',
}}
>
<input
type="checkbox"
checked={applyDesign}
onChange={(e) => setApplyDesign(e.target.checked)}
style={{ width: 16, height: 16, accentColor: '#3b82f6', cursor: 'pointer' }}
/>
Apply template colors and fonts to site design
</label>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setConfirmTemplate(null)}
style={{
padding: '8px 20px',
fontSize: 13,
fontWeight: 600,
color: '#a1a1aa',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 6,
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleLoad}
disabled={loading}
style={{
padding: '8px 24px',
fontSize: 13,
fontWeight: 600,
color: '#ffffff',
background: '#3b82f6',
border: 'none',
borderRadius: 6,
cursor: loading ? 'wait' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? 'Loading...' : 'Load Template'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// TemplateCard sub-component
// ---------------------------------------------------------------------------
const TemplateCard: React.FC<{
template: TemplateDefinition;
onSelect: () => void;
}> = ({ template, onSelect }) => {
const [hovered, setHovered] = useState(false);
return (
<div
onClick={onSelect}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
borderRadius: 8,
border: `1px solid ${hovered ? 'var(--color-accent)' : 'var(--color-border)'}`,
backgroundColor: hovered ? 'var(--color-bg-elevated)' : 'var(--color-bg-surface)',
cursor: 'pointer',
overflow: 'hidden',
transition: 'all 0.2s ease',
transform: hovered ? 'translateY(-2px)' : 'none',
boxShadow: hovered ? '0 4px 12px rgba(0,0,0,0.3)' : 'none',
}}
>
{/* Thumbnail */}
<div
style={{
width: '100%',
height: 120,
backgroundColor: '#1a1a24',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<img
src={template.thumbnail}
alt={template.name}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
{/* Info */}
<div style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#e4e4e7' }}>
{template.name}
</span>
<span
style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: CATEGORY_COLORS[template.category],
backgroundColor: `${CATEGORY_COLORS[template.category]}18`,
padding: '2px 6px',
borderRadius: 4,
}}
>
{template.category}
</span>
</div>
<p style={{ margin: 0, fontSize: 11, color: '#71717a', lineHeight: 1.4 }}>
{template.description}
</p>
<div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}>
<span
style={{
fontSize: 10,
color: '#52525b',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<i className="fa fa-file-o" style={{ fontSize: 9 }} />
{template.isMultiPage ? `${template.pages.length} pages` : 'Single page'}
</span>
</div>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const backdropStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.65)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
};
const modalStyle: React.CSSProperties = {
width: '90vw',
maxWidth: 900,
maxHeight: '85vh',
backgroundColor: 'var(--color-bg-surface)',
borderRadius: 12,
border: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
};
const modalHeaderStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
};
const closeButtonStyle: React.CSSProperties = {
width: 32,
height: 32,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
color: '#71717a',
background: 'transparent',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
};
const tabBarStyle: React.CSSProperties = {
display: 'flex',
gap: 4,
padding: '12px 20px',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
overflowX: 'auto',
};
const tabStyle: React.CSSProperties = {
padding: '6px 16px',
fontSize: 12,
fontWeight: 500,
color: '#71717a',
background: 'transparent',
border: '1px solid transparent',
borderRadius: 6,
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.15s ease',
};
const tabActiveStyle: React.CSSProperties = {
color: '#e4e4e7',
background: 'var(--color-bg-elevated)',
borderColor: 'var(--color-border)',
};
const gridContainerStyle: React.CSSProperties = {
flex: 1,
overflowY: 'auto',
padding: '16px 20px 20px',
};
const gridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 16,
};
const confirmOverlayStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
zIndex: 1,
};
const confirmDialogStyle: React.CSSProperties = {
width: '90%',
maxWidth: 420,
padding: '24px',
backgroundColor: 'var(--color-bg-surface)',
borderRadius: 10,
border: '1px solid var(--color-border)',
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
};

View File

@@ -0,0 +1,274 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { useWhpApi } from '../../hooks/useWhpApi';
import { usePages } from '../../state/PageContext';
import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal';
interface TopBarProps {
device: DeviceMode;
onDeviceChange: (device: DeviceMode) => void;
}
export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
const { whpConfig, isWHP } = useEditorConfig();
const { actions, query, canUndo, canRedo } = useEditor((_state, query) => ({
canUndo: query.history.canUndo(),
canRedo: query.history.canRedo(),
}));
const { save, publish, load } = useWhpApi();
const { headerPage, footerPage } = usePages();
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
const [isDraft, setIsDraft] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasLoadedRef = useRef(false);
// Load saved state on mount
useEffect(() => {
if (!isWHP || hasLoadedRef.current) return;
hasLoadedRef.current = true;
load().catch((e) => {
console.warn('Failed to load project from WHP API:', e);
});
}, [isWHP, load]);
// Auto-save every 30 seconds
useEffect(() => {
if (!isWHP) return;
const interval = setInterval(() => {
save()
.then((result) => {
if (result?.success) {
setSaveStatus('saved');
setIsDraft(true);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
}
})
.catch(() => {
// Silent fail for auto-save
});
}, 30000);
return () => clearInterval(interval);
}, [isWHP, save]);
const handleSave = useCallback(async () => {
setSaveStatus('saving');
try {
const result = await save();
if (result?.success) {
setSaveStatus('saved');
setIsDraft(true);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2500);
} else {
setSaveStatus('error');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
}
} catch (e) {
console.error('Save failed:', e);
setSaveStatus('error');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
}
}, [save]);
const handlePublish = useCallback(async () => {
setPublishStatus('publishing');
try {
const result = await publish();
if (result?.success) {
setPublishStatus('published');
setIsDraft(false);
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
} else {
setPublishStatus('error');
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
}
} catch (e) {
console.error('Publish failed:', e);
setPublishStatus('error');
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
}
}, [publish]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
};
}, []);
return (
<nav className="topbar">
<div className="topbar-left">
{isWHP && (
<a href={whpConfig!.backUrl} className="topbar-btn back-btn">
<i className="fa fa-arrow-left" /> Back to Panel
</a>
)}
<span className="topbar-title">Site Builder</span>
{isWHP && (
<span className="topbar-domain">{whpConfig!.siteDomain}</span>
)}
</div>
<div className="topbar-center">
<div className="device-switcher">
{(['desktop', 'tablet', 'mobile'] as DeviceMode[]).map((d) => (
<button
key={d}
className={`device-btn ${device === d ? 'active' : ''}`}
onClick={() => onDeviceChange(d)}
title={d.charAt(0).toUpperCase() + d.slice(1)}
>
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
</button>
))}
</div>
</div>
<div className="topbar-right">
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo">
<i className="fa fa-undo" />
</button>
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo">
<i className="fa fa-repeat" />
</button>
<span className="topbar-divider" />
<button className="topbar-btn" title="Templates" onClick={() => setTemplateModalOpen(true)}>
<i className="fa fa-th-large" /> Templates
</button>
<button className="topbar-btn" title="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}>
<i className="fa fa-code" /> Code
</button>
<button className="topbar-btn" title="Preview" onClick={() => {
try {
const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
// Get header HTML
let headerHtml = '';
try {
if (headerPage.craftState) {
headerHtml = exportBodyHtml(headerPage.craftState).html;
}
} catch (e) { console.warn('Header export failed:', e); }
// Get page body HTML
const bodyResult = exportBodyHtml(serialized);
const bodyHtml = bodyResult.html;
// Get footer HTML
let footerHtml = '';
try {
if (footerPage.craftState) {
footerHtml = exportBodyHtml(footerPage.craftState).html;
}
} catch (e) { console.warn('Footer export failed:', e); }
// Compose full page: header + body + footer
const composedBody = headerHtml + bodyHtml + footerHtml;
const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview',
includeFonts: true,
});
// Replace the body in the full document with our composed version
let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) {
html = html.replace(bodyMatch[1], composedBody);
}
// Make proxy URLs absolute so they work from the blob: context
const origin = window.location.origin;
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
});
} catch (e) {
console.error('Preview failed:', e);
}
}}>
<i className="fa fa-eye" /> Preview
</button>
{/* Draft/Published status badge */}
{isWHP && isDraft && publishStatus !== 'published' && (
<span className="publish-badge draft">
<i className="fa fa-pencil" /> Draft
</span>
)}
{publishStatus === 'published' && (
<span className="publish-badge published">
<i className="fa fa-check-circle" /> Published
</span>
)}
{/* Save status indicator */}
{saveStatus === 'saved' && (
<span className="save-indicator saved">
<i className="fa fa-check" /> Saved!
</span>
)}
{saveStatus === 'error' && (
<span className="save-indicator error">
<i className="fa fa-exclamation-triangle" /> Save Error
</span>
)}
{publishStatus === 'error' && (
<span className="save-indicator error">
<i className="fa fa-exclamation-triangle" /> Publish Error
</span>
)}
<button
className="topbar-btn primary"
onClick={handleSave}
disabled={saveStatus === 'saving'}
title="Save Draft"
>
{saveStatus === 'saving' ? (
<><i className="fa fa-spinner fa-spin" /> Saving...</>
) : (
<><i className="fa fa-save" /> Save</>
)}
</button>
{isWHP && (
<button
className="topbar-btn publish"
onClick={handlePublish}
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
title="Publish to live site"
>
{publishStatus === 'publishing' ? (
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
) : (
<><i className="fa fa-globe" /> Publish</>
)}
</button>
)}
</div>
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
</nav>
);
};

View File

@@ -0,0 +1,22 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { WhpConfig } from '../types';
interface EditorConfigContextValue {
whpConfig: WhpConfig | null;
isWHP: boolean;
}
const EditorConfigContext = createContext<EditorConfigContextValue>({
whpConfig: null,
isWHP: false,
});
export const useEditorConfig = () => useContext(EditorConfigContext);
export const EditorConfigProvider: React.FC<{ config: WhpConfig | null; children: ReactNode }> = ({ config, children }) => {
return (
<EditorConfigContext.Provider value={{ whpConfig: config, isWHP: !!config }}>
{children}
</EditorConfigContext.Provider>
);
};

View File

@@ -0,0 +1,300 @@
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
import { useEditor } from '@craftjs/core';
import { PageData } from '../types';
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
interface PageContextValue {
pages: PageData[];
headerPage: PageData;
footerPage: PageData;
activePageId: string;
isEditingHeader: boolean;
isEditingFooter: boolean;
switchPage: (pageId: string) => void;
editHeader: () => void;
editFooter: () => void;
addPage: (name: string, slug: string) => void;
deletePage: (pageId: string) => void;
renamePage: (pageId: string, name: string, slug: string) => void;
setHeaderCraftState: (craftState: string) => void;
setFooterCraftState: (craftState: string) => void;
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
siteDesign: SiteDesign;
}
const HEADER_ID = '__header__';
const FOOTER_ID = '__footer__';
const EMPTY_CANVAS =
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
const EMPTY_HEADER =
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#ffffff","padding":"12px 24px","display":"flex","alignItems":"center"},"tag":"header"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
const EMPTY_FOOTER =
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#0f172a","color":"#94a3b8","padding":"40px 24px","textAlign":"center"},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
const PageContext = createContext<PageContextValue>({
pages: [],
headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '' },
footerPage: { id: FOOTER_ID, name: 'Footer', slug: '__footer__', craftState: null, headCode: '' },
activePageId: 'home',
isEditingHeader: false,
isEditingFooter: false,
switchPage: () => {},
editHeader: () => {},
editFooter: () => {},
addPage: () => {},
deletePage: () => {},
renamePage: () => {},
setHeaderCraftState: () => {},
setFooterCraftState: () => {},
setPagesCraftState: () => {},
siteDesign: {} as SiteDesign,
});
export const usePages = () => useContext(PageContext);
function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
const DEFAULT_PAGE: PageData = {
id: 'home',
name: 'Home',
slug: 'index',
craftState: null,
headCode: '',
};
const DEFAULT_HEADER: PageData = {
id: HEADER_ID,
name: 'Header',
slug: '__header__',
craftState: null,
headCode: '',
};
const DEFAULT_FOOTER: PageData = {
id: FOOTER_ID,
name: 'Footer',
slug: '__footer__',
craftState: null,
headCode: '',
};
export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { query, actions } = useEditor();
const { design } = useSiteDesign();
const [pages, setPages] = useState<PageData[]>([DEFAULT_PAGE]);
const [headerPage, setHeaderPage] = useState<PageData>(DEFAULT_HEADER);
const [footerPage, setFooterPage] = useState<PageData>(DEFAULT_FOOTER);
const [activePageId, setActivePageId] = useState('home');
const activePageIdRef = useRef(activePageId);
activePageIdRef.current = activePageId;
const isEditingHeader = activePageId === HEADER_ID;
const isEditingFooter = activePageId === FOOTER_ID;
/** Save whatever is on the current Frame back to the right state slot */
const saveCurrentState = useCallback(() => {
const currentState = query.serialize();
const currentId = activePageIdRef.current;
if (currentId === HEADER_ID) {
setHeaderPage((prev) => ({ ...prev, craftState: currentState }));
} else if (currentId === FOOTER_ID) {
setFooterPage((prev) => ({ ...prev, craftState: currentState }));
} else {
setPages((prev) =>
prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)),
);
}
}, [query]);
/** Load a craft state into the Frame */
const loadState = useCallback(
(craftState: string | null, fallback: string) => {
setTimeout(() => {
try {
actions.deserialize(craftState || fallback);
} catch (e) {
console.error('Failed to deserialize state:', e);
try {
actions.deserialize(fallback);
} catch (_e2) {
// give up
}
}
}, 0);
},
[actions],
);
const switchPage = useCallback(
(pageId: string) => {
if (pageId === activePageIdRef.current) return;
// Serialize the current Craft.js state synchronously BEFORE switching
const currentState = query.serialize();
const currentId = activePageIdRef.current;
// Persist the serialized state to the correct page slot
if (currentId === HEADER_ID) {
setHeaderPage((prev) => ({ ...prev, craftState: currentState }));
} else if (currentId === FOOTER_ID) {
setFooterPage((prev) => ({ ...prev, craftState: currentState }));
} else {
setPages((prev) =>
prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)),
);
}
// Load target page state.
// For header/footer we need the latest saved state. Since setState above is async,
// read from the ref-like state getter. For header/footer, we read the current
// state value and fall back to what we just saved if the target is the same slot.
if (pageId === HEADER_ID) {
// Use functional state read to get the latest value
setHeaderPage((prev) => {
loadState(prev.craftState, EMPTY_HEADER);
return prev;
});
} else if (pageId === FOOTER_ID) {
setFooterPage((prev) => {
loadState(prev.craftState, EMPTY_FOOTER);
return prev;
});
} else {
setPages((prev) => {
const target = prev.find((p) => p.id === pageId);
loadState(target?.craftState || null, EMPTY_CANVAS);
return prev;
});
}
setActivePageId(pageId);
activePageIdRef.current = pageId;
},
[query, loadState],
);
const editHeader = useCallback(() => {
switchPage(HEADER_ID);
}, [switchPage]);
const editFooter = useCallback(() => {
switchPage(FOOTER_ID);
}, [switchPage]);
const addPage = useCallback(
(name: string, slug: string) => {
const finalSlug = slug || slugify(name);
const id = `page_${Date.now()}`;
// Save current page first
saveCurrentState();
setPages((prev) => [
...prev,
{
id,
name,
slug: finalSlug,
craftState: null,
headCode: '',
},
]);
// Switch to the new page with empty canvas
loadState(null, EMPTY_CANVAS);
setActivePageId(id);
activePageIdRef.current = id;
},
[saveCurrentState, loadState],
);
const deletePage = useCallback(
(pageId: string) => {
// Can't delete header/footer
if (pageId === HEADER_ID || pageId === FOOTER_ID) return;
setPages((prev) => {
if (prev.length <= 1) return prev;
const filtered = prev.filter((p) => p.id !== pageId);
// If deleting the active page, switch to the first remaining
if (pageId === activePageIdRef.current) {
const nextPage = filtered[0];
setActivePageId(nextPage.id);
activePageIdRef.current = nextPage.id;
loadState(nextPage.craftState, EMPTY_CANVAS);
}
return filtered;
});
},
[loadState],
);
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
setPages((prev) =>
prev.map((p) =>
p.id === pageId ? { ...p, name, slug: slug || slugify(name) } : p,
),
);
}, []);
/** Allow external code (e.g., load from API) to set the header craft state */
const setHeaderCraftState = useCallback((craftState: string) => {
setHeaderPage((prev) => ({ ...prev, craftState }));
}, []);
/** Allow external code (e.g., load from API) to set the footer craft state */
const setFooterCraftState = useCallback((craftState: string) => {
setFooterPage((prev) => ({ ...prev, craftState }));
}, []);
/** Allow external code (e.g., load from API) to restore pages with craft states */
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => {
setPages(pagesData.map((p) => ({
id: p.id,
name: p.name,
slug: p.slug,
craftState: p.craftState,
headCode: '',
})));
}, []);
return (
<PageContext.Provider
value={{
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
switchPage,
editHeader,
editFooter,
addPage,
deletePage,
renamePage,
setHeaderCraftState,
setFooterCraftState,
setPagesCraftState,
siteDesign: design,
}}
>
{children}
</PageContext.Provider>
);
};

View File

@@ -0,0 +1,81 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export interface SiteDesign {
// Basic
primaryColor: string;
secondaryColor: string;
accentColor: string;
headingFont: string;
bodyFont: string;
linkColor: string;
// Advanced
successColor: string;
warningColor: string;
errorColor: string;
backgroundColor: string;
textColor: string;
mutedTextColor: string;
borderColor: string;
borderRadius: string;
buttonFont: string;
buttonRadius: string;
navStyle: 'light' | 'dark';
// Site-wide custom code
headCode: string;
}
export interface SiteDesignContextValue {
design: SiteDesign;
updateDesign: (updates: Partial<SiteDesign>) => void;
resetToDefaults: () => void;
}
export const DEFAULT_SITE_DESIGN: SiteDesign = {
primaryColor: '#3b82f6',
secondaryColor: '#8b5cf6',
accentColor: '#10b981',
headingFont: 'Inter, sans-serif',
bodyFont: 'Inter, sans-serif',
linkColor: '#3b82f6',
successColor: '#10b981',
warningColor: '#f59e0b',
errorColor: '#ef4444',
backgroundColor: '#ffffff',
textColor: '#1f2937',
mutedTextColor: '#6b7280',
borderColor: '#e5e7eb',
borderRadius: '8px',
buttonFont: 'Inter, sans-serif',
buttonRadius: '8px',
navStyle: 'light',
headCode: '',
};
const SiteDesignContext = createContext<SiteDesignContextValue>({
design: DEFAULT_SITE_DESIGN,
updateDesign: () => {},
resetToDefaults: () => {},
});
export const useSiteDesign = () => useContext(SiteDesignContext);
export const SiteDesignProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [design, setDesign] = useState<SiteDesign>(DEFAULT_SITE_DESIGN);
const updateDesign = useCallback((updates: Partial<SiteDesign>) => {
setDesign((prev) => ({ ...prev, ...updates }));
}, []);
const resetToDefaults = useCallback(() => {
setDesign(DEFAULT_SITE_DESIGN);
}, []);
return (
<SiteDesignContext.Provider value={{ design, updateDesign, resetToDefaults }}>
{children}
</SiteDesignContext.Provider>
);
};

1308
craft/src/styles/editor.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
export type {
TemplateDefinition,
TemplateComponent,
TemplatePageContent,
TemplatePageDef,
TemplateCategory,
} from './definitions';
export { allTemplates } from './definitions';

34
craft/src/types/index.ts Normal file
View File

@@ -0,0 +1,34 @@
import { CSSProperties } from 'react';
export interface WhpConfig {
user: string;
apiUrl: string;
csrfToken: string;
siteId: number;
siteDomain: string;
siteName: string;
backUrl: string;
isRoot: boolean;
}
export interface PageData {
id: string;
name: string;
slug: string;
craftState: string | null;
headCode: string;
}
export interface AssetData {
name: string;
url: string;
type: string;
size?: number;
modified?: number;
}
export interface StyleProps {
style?: CSSProperties;
}
export type DeviceMode = 'desktop' | 'tablet' | 'mobile';

View File

@@ -0,0 +1,233 @@
import React, { CSSProperties } from 'react';
import { SpacingInput, SpacingValue, parseSpacingShorthand, spacingToShorthand } from './SpacingInput';
interface AdvancedTabProps {
style: CSSProperties;
onStyleChange: (updates: CSSProperties) => void;
/** Optional: current CSS ID value */
cssId?: string;
onCssIdChange?: (id: string) => void;
/** Optional: current CSS class value */
cssClass?: string;
onCssClassChange?: (cls: string) => void;
/** Optional: show HTML tag selector (for containers) */
showTagSelector?: boolean;
tag?: string;
onTagChange?: (tag: string) => void;
/** Responsive visibility */
hideOnDesktop?: boolean;
onHideOnDesktopChange?: (hide: boolean) => void;
hideOnTablet?: boolean;
onHideOnTabletChange?: (hide: boolean) => void;
hideOnMobile?: boolean;
onHideOnMobileChange?: (hide: boolean) => void;
/** Entrance animation */
animation?: string;
onAnimationChange?: (anim: string) => void;
animationDelay?: string;
onAnimationDelayChange?: (delay: string) => void;
}
const labelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const selectStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const TAG_OPTIONS = ['div', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav'];
export const AdvancedTab: React.FC<AdvancedTabProps> = ({
style,
onStyleChange,
cssId = '',
onCssIdChange,
cssClass = '',
onCssClassChange,
showTagSelector = false,
tag,
onTagChange,
hideOnDesktop = false,
onHideOnDesktopChange,
hideOnTablet = false,
onHideOnTabletChange,
hideOnMobile = false,
onHideOnMobileChange,
animation = 'none',
onAnimationChange,
animationDelay = '0',
onAnimationDelayChange,
}) => {
// Parse margin and padding from style
const margin: SpacingValue = {
top: (style.marginTop as string) || '0',
right: (style.marginRight as string) || '0',
bottom: (style.marginBottom as string) || '0',
left: (style.marginLeft as string) || '0',
};
// If there's a shorthand margin, parse it
const marginShorthand = style.margin as string | undefined;
const resolvedMargin = marginShorthand ? parseSpacingShorthand(marginShorthand) : margin;
const padding: SpacingValue = {
top: (style.paddingTop as string) || '0',
right: (style.paddingRight as string) || '0',
bottom: (style.paddingBottom as string) || '0',
left: (style.paddingLeft as string) || '0',
};
const paddingShorthand = style.padding as string | undefined;
const resolvedPadding = paddingShorthand ? parseSpacingShorthand(paddingShorthand) : padding;
const handleMarginChange = (val: SpacingValue) => {
onStyleChange({
margin: spacingToShorthand(val),
marginTop: undefined,
marginRight: undefined,
marginBottom: undefined,
marginLeft: undefined,
} as CSSProperties);
};
const handlePaddingChange = (val: SpacingValue) => {
onStyleChange({
padding: spacingToShorthand(val),
paddingTop: undefined,
paddingRight: undefined,
paddingBottom: undefined,
paddingLeft: undefined,
} as CSSProperties);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Margin */}
<SpacingInput
label="Margin"
value={resolvedMargin}
onChange={handleMarginChange}
/>
{/* Padding */}
<SpacingInput
label="Padding"
value={resolvedPadding}
onChange={handlePaddingChange}
/>
{/* HTML Tag (for containers) */}
{showTagSelector && onTagChange && (
<div>
<label style={labelStyle}>HTML Tag</label>
<select
value={tag || 'div'}
onChange={(e) => onTagChange(e.target.value)}
style={selectStyle}
>
{TAG_OPTIONS.map((t) => (
<option key={t} value={t}>&lt;{t}&gt;</option>
))}
</select>
</div>
)}
{/* CSS ID */}
{onCssIdChange && (
<div>
<label style={labelStyle}>CSS ID</label>
<input
type="text"
value={cssId}
onChange={(e) => onCssIdChange(e.target.value.replace(/\s/g, '-'))}
placeholder="my-element"
style={inputStyle}
/>
</div>
)}
{/* CSS Class */}
{onCssClassChange && (
<div>
<label style={labelStyle}>CSS Class</label>
<input
type="text"
value={cssClass}
onChange={(e) => onCssClassChange(e.target.value)}
placeholder="class-one class-two"
style={inputStyle}
/>
</div>
)}
{/* Responsive Visibility */}
{(onHideOnDesktopChange || onHideOnTabletChange || onHideOnMobileChange) && (
<div>
<label style={labelStyle}>Visibility</label>
<div style={{ display: 'flex', gap: 8 }}>
{([
{ key: 'hideOnDesktop' as const, label: 'Desktop', icon: 'fa-desktop', value: hideOnDesktop, onChange: onHideOnDesktopChange },
{ key: 'hideOnTablet' as const, label: 'Tablet', icon: 'fa-tablet', value: hideOnTablet, onChange: onHideOnTabletChange },
{ key: 'hideOnMobile' as const, label: 'Mobile', icon: 'fa-mobile', value: hideOnMobile, onChange: onHideOnMobileChange },
] as const).map(({ key, label, icon, value, onChange }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!value}
onChange={(e) => onChange && onChange(!e.target.checked)}
/>
<i className={`fa ${icon}`} />
<span>{label}</span>
</label>
))}
</div>
</div>
)}
{/* Entrance Animation */}
{onAnimationChange && (
<div>
<label style={labelStyle}>Entrance Animation</label>
<select
value={animation || 'none'}
onChange={(e) => onAnimationChange(e.target.value)}
style={selectStyle}
>
<option value="none">None</option>
<option value="fade-in">Fade In</option>
<option value="slide-up">Slide Up</option>
<option value="slide-left">Slide from Left</option>
<option value="slide-right">Slide from Right</option>
<option value="zoom-in">Zoom In</option>
<option value="bounce">Bounce</option>
</select>
{animation && animation !== 'none' && onAnimationDelayChange && (
<div style={{ marginTop: 6 }}>
<label style={labelStyle}>Delay</label>
<select
value={animationDelay || '0'}
onChange={(e) => onAnimationDelayChange(e.target.value)}
style={selectStyle}
>
<option value="0">None</option>
<option value="0.2s">0.2s</option>
<option value="0.4s">0.4s</option>
<option value="0.6s">0.6s</option>
<option value="0.8s">0.8s</option>
<option value="1s">1s</option>
</select>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,218 @@
import React, { useState } from 'react';
import { CSSProperties } from 'react';
interface BorderControlProps {
style: CSSProperties;
onChange: (updates: CSSProperties) => void;
}
const BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted'] as const;
const labelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '4px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, textAlign: 'center',
};
const btnStyle = (active: boolean): React.CSSProperties => ({
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
flex: 1, textTransform: 'capitalize',
});
const sideLabel: React.CSSProperties = {
fontSize: 9, color: '#71717a', textAlign: 'center', marginTop: 2, textTransform: 'uppercase',
letterSpacing: '0.5px',
};
interface FourSidedValue {
top: string;
right: string;
bottom: string;
left: string;
}
function parseFourSided(val: string | undefined): FourSidedValue {
if (!val) return { top: '0', right: '0', bottom: '0', left: '0' };
const parts = val.trim().split(/\s+/);
if (parts.length === 1) return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
if (parts.length === 2) return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
if (parts.length === 3) return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
}
function fourSidedToString(val: FourSidedValue): string {
const { top, right, bottom, left } = val;
if (top === right && right === bottom && bottom === left) return top || '0';
if (top === bottom && right === left) return `${top} ${right}`;
if (right === left) return `${top} ${right} ${bottom}`;
return `${top} ${right} ${bottom} ${left}`;
}
function getNumeric(val: string): string {
return val.replace(/[^0-9.]/g, '') || '0';
}
export const BorderControl: React.FC<BorderControlProps> = ({ style, onChange }) => {
const [widthLinked, setWidthLinked] = useState(true);
const [radiusLinked, setRadiusLinked] = useState(true);
const currentBorderStyle = (style.borderStyle as string) || 'none';
const currentBorderColor = (style.borderColor as string) || '#3f3f46';
// Border width as 4-sided
const borderWidth = parseFourSided(style.borderWidth as string);
// Border radius as 4 corners (TL, TR, BR, BL)
const borderRadius = parseFourSided(style.borderRadius as string);
const handleWidthChange = (side: keyof FourSidedValue, raw: string) => {
const num = raw.replace(/[^0-9.]/g, '');
const newVal = num ? `${num}px` : '0';
let updated: FourSidedValue;
if (widthLinked) {
updated = { top: newVal, right: newVal, bottom: newVal, left: newVal };
} else {
updated = { ...borderWidth, [side]: newVal };
}
onChange({ borderWidth: fourSidedToString(updated) });
};
const handleRadiusChange = (corner: keyof FourSidedValue, raw: string) => {
const num = raw.replace(/[^0-9.]/g, '');
const newVal = num ? `${num}px` : '0';
let updated: FourSidedValue;
if (radiusLinked) {
updated = { top: newVal, right: newVal, bottom: newVal, left: newVal };
} else {
updated = { ...borderRadius, [corner]: newVal };
}
onChange({ borderRadius: fourSidedToString(updated) });
};
const widthSides: { key: keyof FourSidedValue; label: string }[] = [
{ key: 'top', label: 'T' },
{ key: 'right', label: 'R' },
{ key: 'bottom', label: 'B' },
{ key: 'left', label: 'L' },
];
const radiusCorners: { key: keyof FourSidedValue; label: string }[] = [
{ key: 'top', label: 'TL' },
{ key: 'right', label: 'TR' },
{ key: 'bottom', label: 'BR' },
{ key: 'left', label: 'BL' },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Border Style */}
<div>
<label style={labelStyle}>Border Style</label>
<div style={{ display: 'flex', gap: 4 }}>
{BORDER_STYLES.map((bs) => (
<button
key={bs}
onClick={() => onChange({ borderStyle: bs })}
style={btnStyle(currentBorderStyle === bs)}
>{bs}</button>
))}
</div>
</div>
{/* Border Width (4-sided) */}
{currentBorderStyle !== 'none' && (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ ...labelStyle, marginBottom: 0 }}>Border Width</label>
<button
onClick={() => setWidthLinked(!widthLinked)}
title={widthLinked ? 'Unlink sides' : 'Link all sides'}
style={{
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
border: '1px solid #3f3f46',
background: widthLinked ? '#3b82f6' : '#27272a',
color: widthLinked ? '#fff' : '#71717a',
}}
>
<i className={`fa ${widthLinked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
{widthSides.map((s) => (
<div key={s.key}>
<input
type="text"
value={getNumeric(borderWidth[s.key])}
onChange={(e) => handleWidthChange(s.key, e.target.value)}
style={inputStyle}
/>
<div style={sideLabel}>{s.label}</div>
</div>
))}
</div>
</div>
)}
{/* Border Color */}
{currentBorderStyle !== 'none' && (
<div>
<label style={labelStyle}>Border Color</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="color"
value={currentBorderColor}
onChange={(e) => onChange({ borderColor: e.target.value })}
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
/>
<input
type="text"
value={currentBorderColor}
onChange={(e) => onChange({ borderColor: e.target.value })}
style={{ ...inputStyle, flex: 1, textAlign: 'left' }}
placeholder="#000000"
/>
</div>
</div>
)}
{/* Border Radius (4 corners) */}
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ ...labelStyle, marginBottom: 0 }}>Border Radius</label>
<button
onClick={() => setRadiusLinked(!radiusLinked)}
title={radiusLinked ? 'Unlink corners' : 'Link all corners'}
style={{
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
border: '1px solid #3f3f46',
background: radiusLinked ? '#3b82f6' : '#27272a',
color: radiusLinked ? '#fff' : '#71717a',
}}
>
<i className={`fa ${radiusLinked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
{radiusCorners.map((c) => (
<div key={c.key}>
<input
type="text"
value={getNumeric(borderRadius[c.key])}
onChange={(e) => handleRadiusChange(c.key, e.target.value)}
style={inputStyle}
/>
<div style={sideLabel}>{c.label}</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import React, { useState } from 'react';
interface SettingsTabsProps {
general: React.ReactNode;
style: React.ReactNode;
advanced: React.ReactNode;
}
export const SettingsTabs: React.FC<SettingsTabsProps> = ({ general, style, advanced }) => {
const [tab, setTab] = useState<'general' | 'style' | 'advanced'>('general');
return (
<div>
<div className="settings-tabs">
<button className={tab === 'general' ? 'active' : ''} onClick={() => setTab('general')}>General</button>
<button className={tab === 'style' ? 'active' : ''} onClick={() => setTab('style')}>Style</button>
<button className={tab === 'advanced' ? 'active' : ''} onClick={() => setTab('advanced')}>Advanced</button>
</div>
<div className="settings-content">
{tab === 'general' && general}
{tab === 'style' && style}
{tab === 'advanced' && advanced}
</div>
</div>
);
};

View File

@@ -0,0 +1,141 @@
import React, { useState } from 'react';
export interface SpacingValue {
top: string;
right: string;
bottom: string;
left: string;
}
interface SpacingInputProps {
label: string;
value: SpacingValue;
onChange: (value: SpacingValue) => void;
}
const UNITS = ['px', 'em', '%'] as const;
const labelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '4px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, textAlign: 'center',
};
const sideLabel: React.CSSProperties = {
fontSize: 9, color: '#71717a', textAlign: 'center', marginTop: 2, textTransform: 'uppercase',
letterSpacing: '0.5px',
};
function parseValue(val: string): { num: string; unit: string } {
const match = val.match(/^(-?\d*\.?\d+)\s*(px|em|%|rem)?$/);
if (match) return { num: match[1], unit: match[2] || 'px' };
return { num: val.replace(/[^0-9.-]/g, '') || '0', unit: 'px' };
}
export const SpacingInput: React.FC<SpacingInputProps> = ({ label, value, onChange }) => {
const [linked, setLinked] = useState(false);
const [unit, setUnit] = useState<string>(() => parseValue(value.top).unit || 'px');
const handleSideChange = (side: keyof SpacingValue, raw: string) => {
const numericPart = raw.replace(/[^0-9.-]/g, '');
const newVal = numericPart ? `${numericPart}${unit}` : '0';
if (linked) {
onChange({ top: newVal, right: newVal, bottom: newVal, left: newVal });
} else {
onChange({ ...value, [side]: newVal });
}
};
const handleUnitChange = (newUnit: string) => {
setUnit(newUnit);
// Re-apply current numeric values with new unit
const updated: SpacingValue = { top: '', right: '', bottom: '', left: '' };
for (const side of ['top', 'right', 'bottom', 'left'] as const) {
const { num } = parseValue(value[side]);
updated[side] = num && num !== '0' ? `${num}${newUnit}` : '0';
}
onChange(updated);
};
const sides: { key: keyof SpacingValue; label: string }[] = [
{ key: 'top', label: 'T' },
{ key: 'right', label: 'R' },
{ key: 'bottom', label: 'B' },
{ key: 'left', label: 'L' },
];
return (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ ...labelStyle, marginBottom: 0 }}>{label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Unit selector */}
<div style={{ display: 'flex', gap: 2 }}>
{UNITS.map((u) => (
<button
key={u}
onClick={() => handleUnitChange(u)}
style={{
padding: '1px 5px', fontSize: 9, borderRadius: 3, cursor: 'pointer',
border: '1px solid #3f3f46',
background: unit === u ? '#3b82f6' : '#27272a',
color: unit === u ? '#fff' : '#71717a',
}}
>{u}</button>
))}
</div>
{/* Link toggle */}
<button
onClick={() => setLinked(!linked)}
title={linked ? 'Unlink sides' : 'Link all sides'}
style={{
padding: '2px 6px', fontSize: 11, borderRadius: 3, cursor: 'pointer',
border: '1px solid #3f3f46',
background: linked ? '#3b82f6' : '#27272a',
color: linked ? '#fff' : '#71717a',
}}
>
<i className={`fa ${linked ? 'fa-link' : 'fa-unlink'}`} style={{ fontSize: 9 }} />
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
{sides.map((s) => (
<div key={s.key}>
<input
type="text"
value={parseValue(value[s.key]).num}
onChange={(e) => handleSideChange(s.key, e.target.value)}
style={inputStyle}
/>
<div style={sideLabel}>{s.label}</div>
</div>
))}
</div>
</div>
);
};
/** Parse a CSS shorthand like "10px 20px 10px 20px" or "10px" into SpacingValue */
export function parseSpacingShorthand(val: string | undefined): SpacingValue {
if (!val) return { top: '0', right: '0', bottom: '0', left: '0' };
const parts = val.trim().split(/\s+/);
if (parts.length === 1) return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
if (parts.length === 2) return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
if (parts.length === 3) return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
}
/** Convert SpacingValue back to CSS shorthand */
export function spacingToShorthand(val: SpacingValue): string {
const { top, right, bottom, left } = val;
if (top === right && right === bottom && bottom === left) return top || '0';
if (top === bottom && right === left) return `${top} ${right}`;
if (right === left) return `${top} ${right} ${bottom}`;
return `${top} ${right} ${bottom} ${left}`;
}

View File

@@ -0,0 +1,221 @@
import React from 'react';
import { CSSProperties } from 'react';
interface TypographyControlProps {
style: CSSProperties;
onChange: (updates: CSSProperties) => void;
}
const FONT_FAMILIES = [
{ label: 'Inter', value: 'Inter, sans-serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
{ label: 'Poppins', value: 'Poppins, sans-serif' },
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
{ label: 'Playfair', value: 'Playfair Display, serif' },
{ label: 'Merriweather', value: 'Merriweather, serif' },
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
];
const FONT_WEIGHTS = [
{ label: 'Light', value: '300' },
{ label: 'Normal', value: '400' },
{ label: 'Medium', value: '500' },
{ label: 'Semi', value: '600' },
{ label: 'Bold', value: '700' },
];
const SIZE_UNITS = ['px', 'em', 'rem'] as const;
const TEXT_TRANSFORMS: { label: string; value: string }[] = [
{ label: 'Aa', value: 'none' },
{ label: 'AA', value: 'uppercase' },
{ label: 'aa', value: 'lowercase' },
{ label: 'Aa', value: 'capitalize' },
];
const TEXT_ALIGNS = ['left', 'center', 'right', 'justify'] as const;
const ALIGN_ICONS: Record<string, string> = {
left: 'fa-align-left',
center: 'fa-align-center',
right: 'fa-align-right',
justify: 'fa-align-justify',
};
const labelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const selectStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const btnStyle = (active: boolean): React.CSSProperties => ({
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
});
function parseSizeValue(val: string | number | undefined): { num: string; unit: string } {
if (val === undefined || val === '') return { num: '', unit: 'px' };
const s = String(val);
const match = s.match(/^(-?\d*\.?\d+)\s*(px|em|rem|%)?$/);
if (match) return { num: match[1], unit: match[2] || 'px' };
return { num: s.replace(/[^0-9.-]/g, ''), unit: 'px' };
}
export const TypographyControl: React.FC<TypographyControlProps> = ({ style, onChange }) => {
const currentFamily = (style.fontFamily as string) || '';
const currentWeight = String(style.fontWeight || '');
const currentAlign = (style.textAlign as string) || '';
const currentTransform = (style.textTransform as string) || 'none';
const currentColor = (style.color as string) || '#1f2937';
const fontSize = parseSizeValue(style.fontSize);
const lineHeight = String(style.lineHeight || '');
const letterSpacing = String(style.letterSpacing || '');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Font Family */}
<div>
<label style={labelStyle}>Font Family</label>
<select
value={currentFamily}
onChange={(e) => onChange({ fontFamily: e.target.value })}
style={selectStyle}
>
<option value="">Default</option>
{FONT_FAMILIES.map((f) => (
<option key={f.value} value={f.value} style={{ fontFamily: f.value }}>{f.label}</option>
))}
</select>
</div>
{/* Font Weight */}
<div>
<label style={labelStyle}>Font Weight</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{FONT_WEIGHTS.map((w) => (
<button
key={w.value}
onClick={() => onChange({ fontWeight: w.value })}
style={{ ...btnStyle(currentWeight === w.value), flex: 1 }}
>{w.label}</button>
))}
</div>
</div>
{/* Font Size + Line Height row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label style={labelStyle}>Font Size</label>
<div style={{ display: 'flex', gap: 2 }}>
<input
type="text"
value={fontSize.num}
onChange={(e) => {
const num = e.target.value.replace(/[^0-9.]/g, '');
onChange({ fontSize: num ? `${num}${fontSize.unit}` : '' });
}}
placeholder="16"
style={{ ...inputStyle, flex: 1 }}
/>
<select
value={fontSize.unit}
onChange={(e) => {
const newUnit = e.target.value;
onChange({ fontSize: fontSize.num ? `${fontSize.num}${newUnit}` : '' });
}}
style={{ ...selectStyle, width: 52, padding: '4px 2px', fontSize: 10 }}
>
{SIZE_UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
</select>
</div>
</div>
<div>
<label style={labelStyle}>Line Height</label>
<input
type="text"
value={lineHeight}
onChange={(e) => onChange({ lineHeight: e.target.value })}
placeholder="1.6"
style={inputStyle}
/>
</div>
</div>
{/* Letter Spacing */}
<div>
<label style={labelStyle}>Letter Spacing</label>
<input
type="text"
value={letterSpacing}
onChange={(e) => onChange({ letterSpacing: e.target.value })}
placeholder="0px"
style={inputStyle}
/>
</div>
{/* Text Transform */}
<div>
<label style={labelStyle}>Text Transform</label>
<div style={{ display: 'flex', gap: 4 }}>
{TEXT_TRANSFORMS.map((t, i) => (
<button
key={`${t.value}-${i}`}
onClick={() => onChange({ textTransform: t.value as CSSProperties['textTransform'] })}
style={{ ...btnStyle(currentTransform === t.value), flex: 1, fontStyle: t.value === 'none' ? 'italic' : undefined }}
title={t.value}
>{t.label}</button>
))}
</div>
</div>
{/* Text Align */}
<div>
<label style={labelStyle}>Text Align</label>
<div style={{ display: 'flex', gap: 4 }}>
{TEXT_ALIGNS.map((a) => (
<button
key={a}
onClick={() => onChange({ textAlign: a as CSSProperties['textAlign'] })}
style={{ ...btnStyle(currentAlign === a), flex: 1 }}
title={a}
>
<i className={`fa ${ALIGN_ICONS[a]}`} />
</button>
))}
</div>
</div>
{/* Color */}
<div>
<label style={labelStyle}>Text Color</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="color"
value={currentColor}
onChange={(e) => onChange({ color: e.target.value })}
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
/>
<input
type="text"
value={currentColor}
onChange={(e) => onChange({ color: e.target.value })}
style={{ ...inputStyle, flex: 1 }}
placeholder="#000000"
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,243 @@
import { componentResolver } from '../components/resolver';
import { cssPropsToString } from './style-helpers';
export interface ExportOptions {
title?: string;
includeFonts?: boolean;
minifyCss?: boolean;
headCode?: string;
}
interface ResolverMap {
[key: string]: any;
}
const resolver: ResolverMap = componentResolver;
/**
* Build data attribute string for responsive visibility and animations.
*/
function buildDataAttrs(props: Record<string, any>): string {
let attrs = '';
if (props.hideOnDesktop) attrs += ' data-hide-desktop';
if (props.hideOnTablet) attrs += ' data-hide-tablet';
if (props.hideOnMobile) attrs += ' data-hide-mobile';
if (props.animation && props.animation !== 'none') {
attrs += ` data-animation="${props.animation}"`;
if (props.animationDelay && props.animationDelay !== '0') {
attrs += ` data-animation-delay="${props.animationDelay}"`;
}
}
return attrs;
}
/**
* Inject data attributes into the first HTML opening tag of a rendered string.
*/
function injectAttrs(html: string, attrs: string): string {
if (!attrs) return html;
// Find the first > of the opening tag and inject before it
const idx = html.indexOf('>');
if (idx === -1) return html;
return html.slice(0, idx) + attrs + html.slice(idx);
}
/**
* Recursively render a Craft.js node tree to HTML.
*/
function renderNode(nodes: Record<string, any>, nodeId: string): { html: string } {
const node = nodes[nodeId];
if (!node) return { html: '' };
const typeName = node.type?.resolvedName || node.type;
const props = node.props || {};
// Collect children HTML
const childNodeIds: string[] = node.nodes || [];
const linkedNodes: Record<string, string> = node.linkedNodes || {};
// Render direct child nodes
let childrenHtml = childNodeIds
.map((childId: string) => renderNode(nodes, childId).html)
.join('');
// Render linked nodes (e.g., Section's inner container)
const linkedHtml = Object.values(linkedNodes)
.map((linkedId: string) => renderNode(nodes, linkedId).html)
.join('');
// For linked nodes, the component's toHtml should handle them via childrenHtml
// We prioritize linked nodes if direct children are empty
const allChildrenHtml = childrenHtml + linkedHtml;
// Build data attributes for responsive visibility and animations
const dataAttrs = buildDataAttrs(props);
// Look up component in resolver and call toHtml
const component = resolver[typeName];
if (component && typeof component.toHtml === 'function') {
const result = component.toHtml(props, allChildrenHtml);
const html = result.html || '';
return { html: injectAttrs(html, dataAttrs) };
}
// Fallback: wrap children in a div with inline styles
if (typeName === 'Container' || typeName === 'div') {
const styleStr = cssPropsToString(props.style);
const tag = props.tag || 'div';
return {
html: `<${tag}${dataAttrs}${styleStr ? ` style="${styleStr}"` : ''}>${allChildrenHtml}</${tag}>`,
};
}
// For unrecognized types, just return children
return { html: allChildrenHtml };
}
const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`;
const CSS_RESET_PRETTY = `*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1f2937;
-webkit-font-smoothing: antialiased;
}
img {
max-width: 100%;
height: auto;
display: block;
}
a {
color: inherit;
}`;
const GOOGLE_FONTS_LINK = `<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">`;
const RESPONSIVE_CSS = `
@media (max-width: 768px) {
[style*="display: flex"][style*="flex-direction: row"],
[style*="display:flex"][style*="flex-direction:row"] {
flex-direction: column !important;
}
}`;
const RESPONSIVE_CSS_MINIFIED = `@media(max-width:768px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}`;
const VISIBILITY_CSS = `
@media (min-width: 992px) { [data-hide-desktop] { display: none !important; } }
@media (min-width: 768px) and (max-width: 991px) { [data-hide-tablet] { display: none !important; } }
@media (max-width: 767px) { [data-hide-mobile] { display: none !important; } }`;
const VISIBILITY_CSS_MINIFIED = `@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}`;
const ANIMATION_CSS = `
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
@keyframes bounce { 0% { opacity: 0; transform: translateY(30px); } 60% { opacity: 1; transform: translateY(-5px); } 100% { transform: translateY(0); } }
[data-animation] { opacity: 0; }
[data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; }
[data-animation="fade-in"].animated { animation-name: fadeIn; }
[data-animation="slide-up"].animated { animation-name: slideUp; }
[data-animation="slide-left"].animated { animation-name: slideLeft; }
[data-animation="slide-right"].animated { animation-name: slideRight; }
[data-animation="zoom-in"].animated { animation-name: zoomIn; }
[data-animation="bounce"].animated { animation-name: bounce; }`;
const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(30px)}60%{opacity:1;transform:translateY(-5px)}100%{transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}`;
const ANIMATION_SCRIPT = `<script>
document.querySelectorAll('[data-animation]').forEach(function(el) {
var delay = el.getAttribute('data-animation-delay');
if (delay) el.style.animationDelay = delay;
new IntersectionObserver(function(entries) {
entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } });
}, { threshold: 0.1 }).observe(el);
});
</script>`;
function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
const title = options.title || 'Untitled Page';
const minify = options.minifyCss !== false;
const reset = minify ? CSS_RESET : CSS_RESET_PRETTY;
const responsive = minify ? RESPONSIVE_CSS_MINIFIED : RESPONSIVE_CSS;
const visibility = minify ? VISIBILITY_CSS_MINIFIED : VISIBILITY_CSS;
const animation = minify ? ANIMATION_CSS_MINIFIED : ANIMATION_CSS;
const fonts = options.includeFonts !== false ? `\n ${GOOGLE_FONTS_LINK}` : '';
const headCode = options.headCode ? `\n ${options.headCode}` : '';
// Only include animation CSS + script if body contains data-animation
const hasAnimations = bodyHtml.includes('data-animation');
const animationBlock = hasAnimations ? animation : '';
const animationScript = hasAnimations ? `\n${ANIMATION_SCRIPT}` : '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>${fonts}
<style>${reset}${responsive}${visibility}${animationBlock}</style>${headCode}
</head>
<body>
${bodyHtml}${animationScript}
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Export serialized Craft.js state to standalone HTML.
*/
/**
* Export as a full HTML document (for preview).
*/
export function exportToHtml(
serializedState: string,
options: ExportOptions = {},
): { html: string; css: string } {
try {
const nodes = JSON.parse(serializedState);
const { html: bodyHtml } = renderNode(nodes, 'ROOT');
const fullHtml = wrapInDocument(bodyHtml, options);
return { html: fullHtml, css: '' };
} catch (e) {
console.error('Export to HTML failed:', e);
return {
html: wrapInDocument('<p>Export failed. Please try again.</p>', options),
css: '',
};
}
}
/**
* Export just the body HTML + CSS (for WHP API save -- PHP wraps in document).
*/
export function exportBodyHtml(
serializedState: string,
): { html: string; css: string } {
try {
const nodes = JSON.parse(serializedState);
const { html } = renderNode(nodes, 'ROOT');
return { html, css: '' };
} catch (e) {
console.error('Body export failed:', e);
return { html: '', css: '' };
}
}

View File

@@ -0,0 +1,16 @@
import { CSSProperties } from 'react';
const camelToKebab = (str: string): string =>
str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
export function cssPropsToString(style: CSSProperties | undefined): string {
if (!style) return '';
return Object.entries(style)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${camelToKebab(k)}:${v}`)
.join(';');
}
export function mergeStyles(...styles: (CSSProperties | undefined)[]): CSSProperties {
return Object.assign({}, ...styles.filter(Boolean));
}

View File

@@ -0,0 +1,153 @@
/**
* Component Validation Tests
*
* Tests that every component in the resolver:
* 1. Has a valid .craft config with displayName, props, rules, related.settings
* 2. Has a .toHtml static method for export
* 3. Can be instantiated with default props
* 4. Settings panel exists and is a valid React component
*/
import { componentResolver } from '../src/components/resolver';
// All components that should be in the resolver
const EXPECTED_COMPONENTS = [
'Container', 'Section', 'ColumnLayout', 'BackgroundSection',
'Heading', 'TextBlock', 'ButtonLink', 'Logo', 'Menu', 'Navbar',
'Footer', 'Divider', 'Spacer', 'Icon', 'HtmlBlock',
'ImageBlock', 'VideoBlock', 'MapEmbed',
'HeroSimple', 'FeaturesGrid', 'CTASection', 'CallToAction',
'Countdown', 'Testimonials', 'Accordion', 'Tabs', 'PricingTable',
'Gallery', 'ContentSlider', 'NumberCounter',
'FormContainer', 'InputField', 'TextareaField', 'FormButton',
'ContactForm', 'SubscribeForm',
'StarRating', 'SocialLinks', 'SearchBar',
];
const resolver = componentResolver as Record<string, any>;
describe('Component Resolver', () => {
test('contains all expected components', () => {
const keys = Object.keys(resolver);
for (const name of EXPECTED_COMPONENTS) {
expect(keys).toContain(name);
}
});
test('has no undefined entries', () => {
for (const [key, comp] of Object.entries(resolver)) {
expect(comp).toBeDefined();
expect(typeof comp).toBe('function');
}
});
});
describe('Component Craft Config', () => {
for (const [name, Component] of Object.entries(resolver)) {
describe(name, () => {
test('has .craft config', () => {
expect(Component.craft).toBeDefined();
expect(typeof Component.craft).toBe('object');
});
test('has displayName', () => {
expect(Component.craft.displayName).toBeDefined();
expect(typeof Component.craft.displayName).toBe('string');
expect(Component.craft.displayName.length).toBeGreaterThan(0);
});
test('has default props', () => {
expect(Component.craft.props).toBeDefined();
expect(typeof Component.craft.props).toBe('object');
});
test('has rules', () => {
expect(Component.craft.rules).toBeDefined();
expect(typeof Component.craft.rules.canDrag).toBe('function');
});
test('has related.settings', () => {
expect(Component.craft.related).toBeDefined();
expect(Component.craft.related.settings).toBeDefined();
expect(typeof Component.craft.related.settings).toBe('function');
});
});
}
});
describe('Component HTML Export', () => {
for (const [name, Component] of Object.entries(resolver)) {
test(`${name} has .toHtml method`, () => {
expect(typeof Component.toHtml).toBe('function');
});
test(`${name} .toHtml returns valid HTML`, () => {
const result = Component.toHtml(Component.craft.props || {}, '');
expect(result).toBeDefined();
expect(typeof result.html).toBe('string');
});
}
});
describe('Component Default Props', () => {
for (const [name, Component] of Object.entries(resolver)) {
test(`${name} has no undefined required props`, () => {
const props = Component.craft.props;
// All props should be defined (not undefined)
for (const [key, val] of Object.entries(props)) {
expect(val).not.toBeUndefined();
}
});
test(`${name} array props are actual arrays`, () => {
const props = Component.craft.props;
for (const [key, val] of Object.entries(props)) {
if (key.includes('features') || key.includes('items') || key.includes('plans') ||
key.includes('links') || key.includes('testimonials') || key.includes('tabs') ||
key.includes('images') || key.includes('slides') || key.includes('counters') ||
key.includes('fields')) {
if (val !== undefined && val !== null) {
expect(Array.isArray(val)).toBe(true);
}
}
}
});
}
});
describe('Display Name Uniqueness', () => {
test('all display names are unique', () => {
const names = Object.values(resolver).map((c: any) => c.craft?.displayName);
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(names.length);
});
});
describe('Style Panel Type Coverage', () => {
// These regex patterns match what GuidedStyles uses
const patterns: Record<string, RegExp> = {
isText: /^heading$|^text$/i,
isButton: /^button$/i,
isImage: /^image$/i,
isContainer: /^container$|^section$|^columns$|^background section$|^header zone$|^footer zone$/i,
isHero: /hero/i,
isNav: /^menu$|^logo$|^navbar$|^footer$/i,
isMedia: /^video$|^gallery$|^map$|^content slider$/i,
isForm: /^form$|^input$|^textarea$|^subscribe|^contact form$|^submit button$|^search bar$/i,
isSocial: /^social links$|^icon$|^star rating$/i,
isPricing: /^pricing/i,
isSection: /^accordion$|^tabs$|^testimonial|^countdown$|^number counter$|^cta section$|^call to action$|^features grid$/i,
isUtility: /^divider$|^spacer$|^html$/i,
};
test('every component is matched by at least one pattern', () => {
for (const [name, Component] of Object.entries(resolver)) {
const displayName = Component.craft?.displayName || '';
const matched = Object.values(patterns).some(pattern => pattern.test(displayName));
if (!matched) {
console.warn(`WARNING: ${name} (displayName: "${displayName}") is not matched by any style panel pattern`);
}
// This is a warning, not a hard fail -- generic editor handles unmatched types
}
});
});

View File

@@ -0,0 +1,12 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60000,
retries: 0,
use: {
baseURL: 'http://192.168.1.105:8080',
headless: true,
screenshot: 'only-on-failure',
},
});

Some files were not shown because too many files have changed in this diff Show More