UI polish Phase 1: quick wins (empty-canvas state, selection badge, contrast, FA icons, preset grid, assets empty state) #6
+2
-1
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Editor } from '@craftjs/core';
|
||||
import { EditorShell } from './editor/EditorShell';
|
||||
import { RenderNode } from './editor/RenderNode';
|
||||
import { componentResolver } from './components/resolver';
|
||||
import { WhpConfig } from './types';
|
||||
import { EditorConfigProvider } from './state/EditorConfigContext';
|
||||
@@ -23,7 +24,7 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
||||
return (
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<Editor resolver={componentResolver} enabled={true}>
|
||||
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
|
||||
<PageProvider>
|
||||
<SitesmithProvider>
|
||||
<EditorShell />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import { Frame, Element } from '@craftjs/core';
|
||||
import { Frame, Element, useEditor } from '@craftjs/core';
|
||||
import { Container } from '../components/layout/Container';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { DeviceMode } from '../types';
|
||||
@@ -93,6 +93,33 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* First-run hint shown over the canvas drop area once the current page's
|
||||
* root node exists and has no children yet. Hidden the instant something
|
||||
* is dropped in, and while a drag is in progress (so it never fights the
|
||||
* drop-target UI). `pointer-events: none` (see .empty-canvas-hint in
|
||||
* editor.css) keeps it from intercepting clicks/drops meant for the
|
||||
* underlying empty canvas.
|
||||
*/
|
||||
export const EmptyCanvasHint: React.FC = () => {
|
||||
const { isEmpty, isDragging } = useEditor((state) => {
|
||||
const root = state.nodes['ROOT'];
|
||||
return {
|
||||
isEmpty: !!root && root.data.nodes.length === 0,
|
||||
isDragging: state.events.dragged.size > 0,
|
||||
};
|
||||
});
|
||||
|
||||
if (!isEmpty || isDragging) return null;
|
||||
|
||||
return (
|
||||
<div className="empty-canvas-hint">
|
||||
<i className="fa fa-cubes" aria-hidden />
|
||||
<span>Drag blocks from the left panel, or pick a Template to start.</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
const width = DEVICE_WIDTHS[device];
|
||||
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
|
||||
@@ -140,14 +167,17 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
<ZonePreview craftState={headerPage.craftState} zone="header" />
|
||||
)}
|
||||
|
||||
<Frame>
|
||||
<Element
|
||||
is={Container}
|
||||
canvas
|
||||
tag={frameTag}
|
||||
style={frameStyle}
|
||||
/>
|
||||
</Frame>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Frame>
|
||||
<Element
|
||||
is={Container}
|
||||
canvas
|
||||
tag={frameTag}
|
||||
style={frameStyle}
|
||||
/>
|
||||
</Frame>
|
||||
{isEditingRegularPage && <EmptyCanvasHint />}
|
||||
</div>
|
||||
|
||||
{isEditingRegularPage && (
|
||||
<ZonePreview craftState={footerPage.craftState} zone="footer" />
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core's
|
||||
useEditor so we can drive editor state without a real <Editor> tree. */
|
||||
let mockNodes: Record<string, { data: { nodes: string[] } }> = {};
|
||||
let mockDraggedSize = 0;
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collect: (state: any) => any) =>
|
||||
collect({
|
||||
nodes: mockNodes,
|
||||
events: { dragged: { size: mockDraggedSize } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { EmptyCanvasHint } from './Canvas';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockNodes = {};
|
||||
mockDraggedSize = 0;
|
||||
});
|
||||
|
||||
describe('EmptyCanvasHint', () => {
|
||||
test('renders nothing before ROOT has mounted (no root node yet)', () => {
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('renders the hint once ROOT exists with zero children', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: [] } } };
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).not.toBeNull();
|
||||
expect(container.textContent).toContain('Drag blocks from the left panel');
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('hides once the page has content', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: ['node-1'] } } };
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('hides while a drag is in progress, even on an empty root', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: [] } } };
|
||||
mockDraggedSize = 1;
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core
|
||||
so RenderNode (the <Editor onRender> override) can be driven without a
|
||||
real Editor tree. document.body doubles as the portal target, same as
|
||||
the component itself uses. */
|
||||
let mockNode: {
|
||||
id: string;
|
||||
selected: boolean;
|
||||
dom: HTMLElement | null;
|
||||
displayName: string;
|
||||
parent: string | null;
|
||||
};
|
||||
const selectNodeSpy = vi.fn();
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { selectNode: selectNodeSpy } }),
|
||||
useNode: (collect?: (node: any) => any) => {
|
||||
const node = {
|
||||
events: { selected: mockNode.selected },
|
||||
dom: mockNode.dom,
|
||||
data: { custom: {}, displayName: mockNode.displayName, parent: mockNode.parent },
|
||||
};
|
||||
return { id: mockNode.id, ...(collect ? collect(node) : {}) };
|
||||
},
|
||||
}));
|
||||
|
||||
import { RenderNode } from './RenderNode';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let nodeDom: HTMLElement;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nodeDom = document.createElement('div');
|
||||
document.body.appendChild(nodeDom);
|
||||
mockNode = { id: 'node-1', selected: false, dom: nodeDom, displayName: 'Heading', parent: 'ROOT' };
|
||||
selectNodeSpy.mockClear();
|
||||
});
|
||||
|
||||
const rendered = <span data-testid="inner">hello</span>;
|
||||
|
||||
describe('RenderNode (Editor onRender override)', () => {
|
||||
test('passes render through untouched when not selected', () => {
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(container.querySelector('[data-testid="inner"]')).not.toBeNull();
|
||||
expect(document.querySelector('.component-indicator')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('shows the badge with the displayName when selected', () => {
|
||||
mockNode.selected = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
const badge = document.querySelector('.component-indicator');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.textContent).toContain('Heading');
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('never shows a badge for ROOT even if "selected"', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.id = 'ROOT';
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(document.querySelector('.component-indicator')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('chevron click selects the parent node', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.parent = 'parent-42';
|
||||
render(<RenderNode render={rendered} />);
|
||||
const chevron = document.querySelector('.component-indicator-parent-btn') as HTMLElement;
|
||||
expect(chevron).not.toBeNull();
|
||||
act(() => {
|
||||
chevron.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
});
|
||||
expect(selectNodeSpy).toHaveBeenCalledWith('parent-42');
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('no chevron when there is no parent', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.parent = null;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(document.querySelector('.component-indicator-parent-btn')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEditor, useNode } from '@craftjs/core';
|
||||
|
||||
interface RenderNodeProps {
|
||||
render: React.ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Craft.js `<Editor onRender>` override -- wraps every node's render output.
|
||||
* For the currently-selected node it portals a floating badge (component
|
||||
* displayName + a "select parent" chevron) positioned over the node's real
|
||||
* DOM element. Non-selected nodes (the overwhelming majority) and ROOT pass
|
||||
* straight through as a Fragment, so this never touches layout, never
|
||||
* appears in `toHtml` export (that walks the Craft node tree, not this
|
||||
* portal), and doesn't wrap every node in extra DOM.
|
||||
*/
|
||||
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
||||
const { actions } = useEditor();
|
||||
const { id, isSelected, dom, name, parent } = useNode((node) => ({
|
||||
isSelected: node.events.selected,
|
||||
dom: node.dom,
|
||||
name: (node.data.props?.aiName as string) || node.data.displayName,
|
||||
parent: node.data.parent,
|
||||
}));
|
||||
|
||||
const badgeRef = useRef<HTMLDivElement>(null);
|
||||
const active = isSelected && id !== 'ROOT' && !!dom;
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!dom || !badgeRef.current) return;
|
||||
const rect = dom.getBoundingClientRect();
|
||||
const badgeHeight = 22;
|
||||
badgeRef.current.style.left = `${Math.max(rect.left, 0)}px`;
|
||||
badgeRef.current.style.top = `${Math.max(rect.top - badgeHeight, 0)}px`;
|
||||
}, [dom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
document.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
document.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [active, updatePosition]);
|
||||
|
||||
if (!active) return <>{render}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{render}
|
||||
{createPortal(
|
||||
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
|
||||
<span>{name}</span>
|
||||
{parent && (
|
||||
<button
|
||||
type="button"
|
||||
className="component-indicator-parent-btn"
|
||||
title="Select parent"
|
||||
aria-label={`Select parent of ${name}`}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
actions.selectNode(parent);
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-chevron-up" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,8 @@ interface ContextMenuProps {
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
/** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
danger?: boolean;
|
||||
@@ -189,7 +191,8 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: '✨ Ask Sitesmith',
|
||||
label: 'Ask Sitesmith',
|
||||
icon: 'magic',
|
||||
action: askSitesmith,
|
||||
disabled: isRoot,
|
||||
dividerAfter: true,
|
||||
@@ -291,7 +294,10 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
(e.target as HTMLElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>
|
||||
{item.icon && <i className={`fa fa-${item.icon}`} style={{ marginRight: 6, width: 12 }} />}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.shortcut && (
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -107,23 +107,39 @@ export const AssetsPanel: React.FC = () => {
|
||||
{loading ? 'Uploading...' : 'Upload File'}
|
||||
</button>
|
||||
|
||||
{/* Drop zone */}
|
||||
{/* Drop zone -- a single element that doubles as the empty state.
|
||||
Previously this was a small always-visible dropzone PLUS a
|
||||
separate italic "No assets uploaded yet" line stacked underneath
|
||||
it when empty; merged into one tall dropzone (icon + copy,
|
||||
click-or-drag) so the empty state isn't two redundant messages.
|
||||
Once assets exist it collapses back to a slim persistent drop
|
||||
target above the grid. */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
{...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})}
|
||||
style={{
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: assets.length === 0 ? '36px 20px' : 16,
|
||||
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,
|
||||
cursor: assets.length === 0 ? 'pointer' : 'default',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
Drop files here to upload
|
||||
{assets.length === 0 && (
|
||||
<i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
|
||||
)}
|
||||
{assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
@@ -143,20 +159,6 @@ export const AssetsPanel: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 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',
|
||||
@@ -296,7 +298,7 @@ export const AssetsPanel: React.FC = () => {
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
<i className="fa fa-times" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -328,7 +330,7 @@ export const AssetsPanel: React.FC = () => {
|
||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
|
||||
>
|
||||
✕
|
||||
<i className="fa fa-times" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -394,7 +394,7 @@ export const PagesPanel: React.FC = () => {
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
<i className="fa fa-trash" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defaultPresetGridColumns } from './shared';
|
||||
import { RADIUS_PRESETS, SPACING_PRESETS, IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, TEXT_SIZES, FONT_FAMILIES } from '../../../constants/presets';
|
||||
|
||||
/*
|
||||
* Regression: 5-item preset sets (RADIUS_PRESETS, SPACING_PRESETS,
|
||||
* IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, and NavStylePanel's ad-hoc
|
||||
* GAP_PRESETS) left a lone orphan button on its own row under the old
|
||||
* fixed 4-column .preset-grid. PresetButtonGrid now derives a column
|
||||
* count from the preset length instead.
|
||||
*/
|
||||
describe('defaultPresetGridColumns', () => {
|
||||
it('gives 5-item sets their own single row (no orphan)', () => {
|
||||
expect(defaultPresetGridColumns(RADIUS_PRESETS.length)).toBe(5);
|
||||
expect(defaultPresetGridColumns(SPACING_PRESETS.length)).toBe(5);
|
||||
expect(defaultPresetGridColumns(IMAGE_RADIUS_PRESETS.length)).toBe(5);
|
||||
expect(defaultPresetGridColumns(FONT_WEIGHTS.length)).toBe(5);
|
||||
expect(defaultPresetGridColumns(5)).toBe(5); // NavStylePanel's GAP_PRESETS
|
||||
});
|
||||
|
||||
it('splits 6-item sets into two even rows of 3 (was 4+2 uneven)', () => {
|
||||
expect(defaultPresetGridColumns(TEXT_SIZES.length)).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps the classic 4-column grid for sets that already divide evenly', () => {
|
||||
expect(defaultPresetGridColumns(FONT_FAMILIES.length)).toBe(4); // 8 items
|
||||
expect(defaultPresetGridColumns(4)).toBe(4);
|
||||
expect(defaultPresetGridColumns(3)).toBe(4);
|
||||
});
|
||||
|
||||
it('never leaves a single orphan on the final row for any count 1-12', () => {
|
||||
for (let n = 1; n <= 12; n++) {
|
||||
const cols = defaultPresetGridColumns(n);
|
||||
const isLoneOrphan = n > cols && n % cols === 1;
|
||||
expect(isLoneOrphan).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -84,20 +84,42 @@ interface PresetButtonGridProps {
|
||||
presets: { label: string; value: string }[];
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
/** Explicit column count. When omitted, a column count is derived from
|
||||
* `presets.length` (see `defaultPresetGridColumns`) so odd-sized preset
|
||||
* sets (5, 6, ...) don't leave a lone orphan button dangling on its own
|
||||
* row under the fixed 4-column grid. */
|
||||
columns?: number;
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
||||
/** Picks a column count that avoids a single orphan on the last row.
|
||||
* 4-or-fewer presets keep the classic single row of 4. 5 gets its own
|
||||
* row (5 cols). 6 splits into two even rows of 3. Anything else falls
|
||||
* back to a 4- or 3-column grid depending on which divides evenly. */
|
||||
export function defaultPresetGridColumns(count: number): number {
|
||||
if (count <= 4) return 4;
|
||||
if (count === 5) return 5;
|
||||
if (count === 6) return 3;
|
||||
if (count % 4 === 0) return 4;
|
||||
if (count % 3 === 0) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect, columns }) => {
|
||||
const cols = columns ?? defaultPresetGridColumns(presets.length);
|
||||
return (
|
||||
<div className="preset-grid" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
|
||||
{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;
|
||||
|
||||
@@ -21,8 +21,8 @@ export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
|
||||
color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
✨ Sitesmith
|
||||
{locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 12 }}>🔒</span>}
|
||||
<i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} /> Sitesmith
|
||||
{locked && <i className="fa fa-lock" aria-hidden style={{ marginLeft: 6, fontSize: 12 }} />}
|
||||
{capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -103,7 +103,10 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
||||
>
|
||||
<div style={panel}>
|
||||
<div style={header}>
|
||||
<div style={{ fontWeight: 600, color: '#fff' }}>✨ Sitesmith</div>
|
||||
<div style={{ fontWeight: 600, color: '#fff' }}>
|
||||
<i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} />
|
||||
Sitesmith
|
||||
</div>
|
||||
{summary && summary.enabled && (
|
||||
<div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}>
|
||||
{summary.monthly_used} / {summary.monthly_cap} this month
|
||||
@@ -136,7 +139,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button>
|
||||
<button onClick={onClose} aria-label="Close" style={closeBtn}><i className="fa fa-times" aria-hidden /></button>
|
||||
</div>
|
||||
<div style={body}>
|
||||
<UpgradeBanner summary={summary} />
|
||||
|
||||
@@ -248,7 +248,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeButtonStyle} title="Close">
|
||||
✕
|
||||
<i className="fa fa-times" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
--color-border: #2d2d3a;
|
||||
--color-border-light: #3f3f46;
|
||||
--color-text: #e4e4e7;
|
||||
--color-text-muted: #71717a;
|
||||
--color-text-dim: #52525b;
|
||||
--color-text-muted: #8b8b96;
|
||||
--color-text-dim: #6e6e78;
|
||||
--color-accent: #3b82f6;
|
||||
--color-accent-hover: #2563eb;
|
||||
--color-accent-subtle: rgba(59, 130, 246, 0.12);
|
||||
@@ -704,7 +704,7 @@ body {
|
||||
}
|
||||
|
||||
.block-item-icon {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
color: var(--color-text-muted);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
@@ -714,7 +714,7 @@ body {
|
||||
}
|
||||
|
||||
.block-item-label {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
@@ -1074,12 +1074,16 @@ body {
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Component indicator badge */
|
||||
/* Component indicator badge (floats over the selected node via a portal --
|
||||
position/top/left are set inline per-node, see RenderNode.tsx) */
|
||||
.component-indicator {
|
||||
position: absolute;
|
||||
top: -22px;
|
||||
left: 0;
|
||||
padding: 2px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 6px 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
@@ -1090,6 +1094,28 @@ body {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.component-indicator-parent-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.component-indicator-parent-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Scrollbar Styling
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -1225,6 +1251,8 @@ body {
|
||||
Empty Canvas State
|
||||
-------------------------------------------------------------------------- */
|
||||
.empty-canvas-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -1234,6 +1262,9 @@ body {
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
/* Overlays the drop area without intercepting drags/clicks meant for the
|
||||
underlying (empty) canvas root -- see Canvas.tsx. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-canvas-hint i {
|
||||
|
||||
Reference in New Issue
Block a user