Compare commits

..
Author SHA1 Message Date
shadowdao 99cc4c79f2 fix(builder): clickableProps must ignore keydown bubbled from nested controls
Enter/Space on a clickableProps row unconditionally called
e.preventDefault() + onActivate(), even when the keydown bubbled up from a
nested interactive child (e.g. the Delete/Rename icon buttons in
AssetsPanel/PagesPanel). preventDefault() anywhere in the propagation path
cancels the browser's native click synthesis for the focused child button,
so its onClick never fired and the row's onActivate hijacked the action
instead. Guard on e.target !== e.currentTarget so only keydowns targeted at
the row itself are handled.
2026-07-12 14:58:13 -07:00
shadowdaoandClaude Opus 4.8 4b8dd8baee ux: in-app confirm for asset/sitesmith delete + safe copy
- AssetsPanel: asset delete now requires an in-app two-step confirm
  (tile-button turns into "Delete?" + cancel, auto-resets after 4s or on
  click-elsewhere) instead of deleting with no confirmation at all.
- AssetsPanel: copyUrl uses a new copyToClipboard() helper that tries the
  async Clipboard API and falls back to a hidden-textarea execCommand copy
  in non-secure contexts, surfacing a visible "Copy failed" state instead
  of silently doing nothing.
- SitesmithModal: replaced window.confirm(...) for "Clear chat" with the
  same in-app two-step confirm pattern -- no native dialogs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:51:00 -07:00
shadowdaoandClaude Opus 4.8 46ebd253f3 a11y: keyboard-operable editor chrome + topbar aria-labels
Clickable <div> rows/tiles (page list, layer tree, template cards, asset
picker grid) now expose role="button", a tab stop, and Enter/Space
activation via a shared clickableProps() helper, matching their existing
onClick behavior. TopBar icon-only controls (device switcher, undo/redo,
save, publish, templates, code, preview, back) gain aria-label alongside
their existing title tooltips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:50:50 -07:00
shadowdao a036843728 fix(builder): deterministic ids for NumberCounter + form-relay marker
Two components of the det-id bug class were missed in the earlier migration:
NumberCounter's wrapper/counter ids and count-up script, and the shared
form-relay-wiring fid (used by ContactForm/FormContainer). Both used
Math.random() for exported HTML ids, breaking caching/diffing across exports.

Migrate both to scopeId(nodeId, fallbackSeed, prefix), threading nodeId
through NumberCounter.toHtml and relayFormWiring (via ContactForm.toHtml and
FormContainer.toHtml, both now passing nodeId as their 3rd arg).
2026-07-12 14:43:31 -07:00
shadowdaoandClaude Opus 4.8 177eda93d0 script nits: slider hover/visibility pause + silent autoplay + iframe amp guard
ContentSlider: autoplay's setInterval now pauses on mouseenter and on
document visibilitychange (tab hidden), resumes on mouseleave/visible,
and is always clearable (timer var + clearInterval). The aria-live
region is "off" while autoplay is silently auto-rotating and only
flips to "polite" on manual next/prev/dot navigation, so screen readers
aren't spammed with an announcement every `interval` ms (F-export
review Minor). Updates the one existing test that hard-coded
aria-live="polite" as always-on to match this intentional behavior
change.

(Countdown's ticking-interval-stops-at-zero nit shipped in the previous
commit alongside its node-id migration, since both touched the same
inline script.)

Adds regression tests locking in that MapEmbed/VideoBlock iframe src
attributes (built by string concatenation with literal `&` query
params) are HTML-entity-encoded via the existing escapeAttr(safeUrl())
pipeline -- verified already correct, no source change needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:35:27 -07:00
shadowdaoandClaude Opus 4.8 8029126ab7 deterministic + unique ids in 6 components (node-derived scope)
Migrates Menu, ColumnLayout, Countdown, Gallery, Tabs, InputField, and
TextareaField from Math.random()/content-hash scope ids to the threaded
Craft node id (via scopeId()), keeping every emitted element id,
aria-controls/aria-labelledby/for, and inline <script> function
name/getElementById() call consistently scoped per component.

Resolves the two Important id-collision review findings:
- Tabs: tabId was djb2(anchorId||labels) -- two default Tabs instances
  produced identical aria-controls/aria-labelledby ids, so one instance's
  arrow-key script clobbered the other's tab/panel wiring.
- InputField/TextareaField: fieldId was `field-${name}` -- two fields
  sharing a (often default) name produced duplicate <label for>/<input
  id> pairs, breaking the for/id association for one of them.

Also eliminates the remaining Math.random() scope ids in Menu (hover CSS
class scope) and ColumnLayout (nth-child width CSS class scope), so no
export component is non-deterministic anymore.

All fall back to a stable content hash (never Math.random) for legacy
2-arg toHtml() call sites without a node id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:35:12 -07:00
shadowdaoandClaude Opus 4.8 c2aac870e7 thread node id through toHtml for deterministic+unique export ids
renderNode now passes the Craft node id as toHtml's 3rd argument
(props, childrenHtml, nodeId). Backward compatible: the resolver map is
untyped (any), so existing 2-arg toHtml implementations/tests are
unaffected. Adds scopeId()/stableHash() helpers in utils/escape.ts:
scopeId derives a scope string from the node id (deterministic AND
unique, since Craft node ids are unique per node and stable across
repeated exports of the same page) with a stableHash(seed) fallback for
legacy call sites without a node id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:34:53 -07:00
shadowdaoandClaude Opus 4.8 9969adca72 a11y: exported semantics (forms/rating/nav/iframe/icons)
- InputField/TextareaField/ContactForm: every control gets a
  deterministic id (slugId() in utils/escape.ts, derived from the
  field's name/label + index for ContactForm's looped fields -- no
  Math.random) with a matching <label for=>; fields with no visible
  label get an aria-label from the placeholder/name instead.
- StarRating: wrapped in role="img" aria-label="Rating: N out of M",
  individual star glyphs marked aria-hidden.
- Navbar: the mobile hamburger toggle gets aria-label="Toggle
  navigation menu", aria-controls="navbar-links", and aria-expanded
  wired to flip true/false in the inline onclick handler.
- VideoBlock and MapEmbed: every exported <iframe> gets a title
  (generic "Embedded video", or "Map of {address}" for MapEmbed).
- Decorative Font Awesome icons (ContentSlider arrows already covered
  in the prior commit; SocialLinks, SearchBar, Testimonials stars) are
  aria-hidden; SocialLinks' icon-only links get an aria-label naming
  the platform alongside the existing title tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:19:01 -07:00
shadowdaoandClaude Opus 4.8 9b532e36e8 a11y: exported widget ARIA (slider/tabs/gallery)
ContentSlider, Tabs, and Gallery toHtml exports and their inline scripts
now carry ARIA semantics and keyboard support:

- ContentSlider: prev/next arrows and dot buttons get aria-labels, the
  slide stack is wrapped in an aria-live="polite" region, and the
  decorative chevron icons are aria-hidden.
- Tabs: tablist/tab/tabpanel roles, aria-selected/aria-controls/
  aria-labelledby, and Left/Right/Home/End arrow-key navigation in the
  inline script. The tab<->panel id scope is now derived from a
  deterministic hash of anchorId/labels instead of Math.random, since
  the ARIA linking ids must be stable across export runs.
- Gallery: the lightbox overlay gets role="dialog"/aria-modal/aria-label,
  Escape closes it, and thumbnails are keyboard-operable (role="button"
  tabindex="0" plus Enter/Space handling in the existing delegated
  listener).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:18:49 -07:00
shadowdaoandClaude Opus 4.8 c83db99ae4 fix(builder): update_props no longer wipes style on style: null
style: null fell through the shallow-merge branch's `value && typeof
value === 'object'` guard and hit the `p[key] = value` fallback, setting
p.style = null and losing every existing style key. Treat style: null (or
any other non-object style value) as "ignore this key" instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:05:59 -07:00
shadowdaoandClaude Opus 4.8 605a6ba9f3 refactor(builder): consolidate buildNodeTree onto shared flattener; delete dead serializeTreeForCraft
buildNodeTree now delegates its structural walk (ColumnLayout linkedNodes,
SHELL_INNER wrapping, style:[]->{} normalization) to flattenTreeForCraft,
materializing each flat node into a real Craft.js Node via
query.parseFreshNode — except the synthetic SHELL_INNER wrapper, which is
still hand-built (parseFreshNode would merge in Container's default
craft.props and change its look). sanitizeAiTree now lives in craft-tree.ts
and is re-exported here for existing callers.

Delete the dead serializeTreeForCraft (only its own tests called it) and
its now-orphaned tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:05:37 -07:00
shadowdaoandClaude Opus 4.8 f3c175436a fix(builder): guard PageContext.treeToState against unknown resolvedNames
treeToState had no resolvedName allowlist, so an AI `replace` response
(scope site/page/header/footer, reaches this function via
actions.deserialize with no further validation downstream) containing an
unknown component could produce a state that throws at deserialize/render.
Extract the core transform into standalone treeToCraftState (directly
unit-testable without mounting a Craft.js Editor), route it through
sanitizeAiTree for the same resolvedName allowlist + id/ROOT repair
buildNodeTree already had, and fall back to the empty canvas if the tree's
own root is invalid. Delegate the structural walk to the shared
flattenTreeForCraft extracted in the previous commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:04:57 -07:00
shadowdaoandClaude Opus 4.8 b2b9278d3c refactor(builder): extract shared tree-flattener into craft-tree.ts
PageContext.treeToState, apply-ai-response.buildNodeTree, and the dead
serializeTreeForCraft each re-implemented the same ColumnLayout-linkedNodes +
SHELL_INNER walk and had drifted (buildNodeTree gained guards the others
lacked). Move sanitizeAiTree (resolvedName allowlist + id/ROOT repair) here
too and add flattenTreeForCraft, the shared structural walk (linkedNodes,
SHELL_INNER wrapping, style:[]->{} normalization) both real callers will
consolidate onto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:04:43 -07:00
shadowdaoandClaude Opus 4.8 71e675489c fix(builder): Footer commits in-progress edits on deselect, not just blur (D7)
Footer only committed edited text via onBlur, and its effect rewrote
innerText from the (stale) text prop whenever selected became false --
if selection cleared without a real DOM blur, the in-progress edit was
silently lost. Adopt Heading.tsx's exact mechanism: an editedTextRef
updated on onInput, committed to the prop via an effect keyed on the
selected->false transition (in addition to the existing onBlur commit).
Preserves the 500ms setProp debounce Footer already had for undo grouping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:46:52 -07:00
shadowdaoandClaude Opus 4.8 fdd088b4bf fix(builder): VideoBlock parses more YouTube/Vimeo URL shapes (D4)
Extend detectVideoType to handle youtube.com/shorts/ID, youtube.com/live/ID,
youtube.com/watch?...&v=ID (v not the first query param), and Vimeo
private-hash URLs (vimeo.com/ID/HASH -> ?h=HASH player param). Existing
shapes (youtu.be/ID, youtube.com/embed/ID, youtube.com/watch?v=ID,
vimeo.com/ID, direct files) keep working. The emitted embed src still
passes through safeUrl unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:46:48 -07:00
shadowdaoandClaude Opus 4.8 cdcc3969bc fix(builder): ColumnLayout.toHtml derives column widths from split (D2b)
toHtml emitted only the wrapper's flex/gap style and passed childrenHtml
through untouched, so a non-default split percentage never showed up in
the export -- each column's width silently reverted to whatever was baked
into its own inline style at node-creation time. toHtml has no direct
handle on individual children to rewrite their styles (it only receives
the already-concatenated childrenHtml string), so emit a scoped <style>
block with nth-child rules (!important, to win over any stale per-child
inline flex) computed via the same getWidths(split, columns) mapping the
editor render uses. Precedent: Menu/Navbar toHtml already emit scoped
<style> blocks for hover CSS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:46:44 -07:00
shadowdaoandClaude Opus 4.8 2887cc9bdf fix(builder): Testimonials single-layout static-parity export (D2a)
The editor rendered layout="single" as an interactive carousel (prev/next
buttons, dot indicators, currentIndex state) but toHtml exported a static
stacked list of ALL testimonials -- a structural mismatch. This codebase's
static export has no published-JS interactivity for this component, so
static-parity is the lower-risk fix: the editor's single view now renders
just the first testimonial (no carousel chrome), and toHtml exports
exactly that one card. Grid layout is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:46:38 -07:00
shadowdaoandClaude Opus 4.8 a0cdbd1d82 refactor(builder): remove dead FeaturesGrid mediaType field (D1c)
FeatureItem.mediaType was never read anywhere -- the icon-vs-image choice
is (correctly) driven by feat.image truthiness, set via the AssetPicker in
FeaturesEditor. Neither the default items nor FeaturesEditor's "add item"
object ever set it. Remove the dead field; image-precedence render/export
behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:46:33 -07:00
shadowdaoandClaude Opus 4.8 f0728a9070 fix(builder): emit ContactForm successMessage as a data attribute (D1b)
successMessage was collected in props (and FormStylePanel already had a
live "Success Message" input for it) but toHtml never emitted it anywhere.
The form-sender relay delivers success via a full-page 303 redirect to
thankYouUrl or a hosted thanks.php page -- there is no in-page JS today
that could reveal an inline success element. Emit it as
data-whp-success-message (escaped) on the <form>: forward-compatible for a
future AJAX submission mode, without implying a live mechanism that
doesn't exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:46:29 -07:00
shadowdaoandClaude Opus 4.8 36ce256760 fix(builder): emit Container cssId/cssClass with live panel controls (D1a)
Container.craft.props collected cssId/cssClass since before Phase E1 but
nothing rendered or exported them. Add live "CSS ID" / "CSS Class" text
inputs to ContainerStylePanel (guarded on nodeProps.cssId/cssClass
!== undefined) and emit id=/class= in both the editor render and toHtml.
cssId takes precedence over the existing anchorId prop when both are set
(only one id attribute can be emitted); anchorId is used as a fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:46:23 -07:00
shadowdaoandClaude Opus 4.8 cf56f2a388 refactor(builder): remove dead per-page headCode field (D9)
PageData.headCode was never populated from anything but '' and never
read anywhere -- the real, live concept is SiteDesign.headCode (set
via the Code modal, wired into Preview/export in D8). Remove the dead
field from the PageData interface and its ~7 hardcoded headCode: ''
seeds/discards in PageContext.tsx (DEFAULT_PAGE, DEFAULT_HEADER,
DEFAULT_FOOTER, the context default value, addPage, setPagesCraftState,
replaceAllPages). Update the PageData literals in
useWhpApi.save.test.ts to match the trimmed interface.

Verified via grep that nothing reads page.headCode, and `npm run
build` (strict tsc) is clean -- no live reader broke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:30:17 -07:00
shadowdaoandClaude Opus 4.8 e1d381dc8a fix(builder): Preview includes site headCode and is \$-safe (D8)
Two bugs in TopBar's Preview handler:

1. exportToHtml() was called without a headCode option, so Preview
   silently ignored SiteDesign.headCode (custom <head> code the user
   set via the Code modal). Pull `design.headCode` from useSiteDesign()
   and pass it through, matching exportToHtml's ExportOptions.headCode
   signature.

2. html.replace(bodyMatch[1], composedBody) used the string-replacer
   overload of String.prototype.replace, which treats `$&`, `$1`,
   `$$`, etc. in the replacement string as special patterns -- any
   user content containing a literal `$` sequence would corrupt the
   preview HTML. Switch to a function replacer
   (`.replace(bodyMatch[1], () => composedBody)`) so the composed body
   is inserted literally.

Verified by reading + `npm run build`; no existing TopBar test harness
to extend (dynamic import + window.open/Blob in the click handler).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:29:02 -07:00
shadowdaoandClaude Opus 4.8 d4ee09e54c fix(builder): make switchPage/deletePage state updaters pure (D6)
switchPage and deletePage ran side effects (loadState -> actions.deserialize,
setActivePageId, activePageIdRef mutation) INSIDE setPages/setHeaderPage/
setFooterPage updater callbacks -- using the functional-updater form purely
to peek at the latest `prev` value. React (in StrictMode dev builds)
double-invokes updater functions passed to setState to catch exactly this
kind of impurity, so every page switch deserialized the target's craft
state twice.

Add pagesRef/headerPageRef/footerPageRef mirroring the latest state on
every render (same pattern already used for activePageIdRef), so
switchPage/deletePage can read "what's current" synchronously in the
event-handler body and run their side effects there -- after the state
update is computed, not inside the updater. deletePage now passes a plain
next-value to setPages instead of a function updater. Switching/deleting
behavior (correct page loads, can't delete the last page) is unchanged.

Verified the added test fails against the pre-fix code (deserialize called
2x) and passes against the fix (1x).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:28:27 -07:00
shadowdaoandClaude Opus 4.8 7973ee9ba8 fix(builder): await TemplateModal's load sequence before closing (D5)
The template-load sequence (clear canvas -> add components -> add
pages -> apply header/footer) ran as a chain of setTimeout callbacks,
but onClose() fired synchronously right after kicking it off. The
modal disappeared immediately, so "Loading..." never had a chance to
show, and the staged mutations kept running against a dialog the
caller had already dismissed.

Convert the staged steps to async/await (a promise-based `wait()`
replaces the setTimeout chaining) and only call onClose() in a
finally block once the whole sequence has settled. Add a mountedRef
guard so the trailing setLoading(false) is skipped if the component
happens to unmount mid-sequence. End result (pages/header/footer/
design applied) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:26:46 -07:00
shadowdaoandClaude Opus 4.8 2ee66ebae9 fix(builder): dedupe page slugs on add/rename/replaceAll (D3)
Two pages that slugify to the same string (e.g. both named "About")
previously both published to about.html, silently overwriting each
other on publish. Add a pure uniqueSlug(base, existingSlugs) helper
that appends -2, -3, ... on collision, and apply it in addPage,
renamePage, and replaceAllPages. The landing page's slug stays locked
to 'index' regardless of collisions, matching existing behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:25:41 -07:00
shadowdaoandClaude Opus 4.8 a7c00119e6 refactor(builder): remove dead HeaderZone/FooterZone components
HeaderZone and FooterZone are absent from componentResolver (so they can
never be deserialized/rendered) and are not imported by PageContext,
Canvas, or any other live code -- verified via repo-wide grep returning
only their own self-references. The site-wide header/footer feature uses
Container with tag="header"/"footer", not these zone wrappers. Deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:17:03 -07:00
shadowdaoandClaude Opus 4.8 232dc3c980 refactor(builder): remove orphaned ui/ settings controls
After removing the dead component settings UI, these ui/ controls have
zero importers: AdvancedTab, TypographyControl, BorderControl,
SpacingInput, AnchorIdField, SettingsTabs. Verified no live *StylePanel
references them (the style panels import only AssetPicker, which is
kept). Deleted the six orphans.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:16:57 -07:00
shadowdaoandClaude Opus 4.8 65a10a1ef9 refactor(builder): remove dead component settings UI
Each component defined a .craft.related.settings panel that was never
rendered -- the right panel renders only GuidedStyles (per-type
*StylePanel components), never .related.settings. Removed all dead
settings components across every component, their settings-only helpers
(including the dead uploadToWhp/showBrowser/handleBrowse asset-browse
blocks in Logo/Navbar/VideoBlock/FeaturesGrid, and CtasEditor in
_cta-helpers), and dropped the now-empty related keys. Render output,
.craft props/rules, and toHtml statics are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:16:53 -07:00
shadowdaoandClaude Opus 4.8 4674a99ec9 feat(builder): unify StylePanel image fields on AssetPicker
Replace the ad-hoc upload/browse/URL blocks in ImageStylePanel, HeroStylePanel
(bgImage + bgVideo), BackgroundSectionStylePanel, NavStylePanel (standalone
Logo imageSrc + Navbar logoImage), MediaStylePanel (Gallery/ContentSlider
array items), and FeaturesEditor (per-feature image) with the shared
AssetPicker component, dropping each panel's duplicated local
showBrowser/browserAssets/handleUpload state and inline asset-browser JSX.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:00:10 -07:00
shadowdaoandClaude Opus 4.8 c50b385661 feat(builder): reusable AssetPicker (full/compact, media-typed)
Introduces src/ui/AssetPicker.tsx, a single component that replaces the
~6 copy-pasted image-source UIs (upload/browse/URL) with one that also
supports video via `mediaType`. Full variant mirrors ImageStylePanel's
existing look; compact variant is a tight single row for array-editor
item cards. Both share all state/handler logic, built on the C1
uploadAsset/listAssets util.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:49:34 -07:00
shadowdaoandClaude Opus 4.8 296c10d019 feat(builder): shared asset upload/list util
Extract uploadAsset/listAssets into utils/assets.ts, lifting the exact
uploadToWhp body and list_assets fetch pattern already duplicated across
shared.tsx/ImageBlock/Logo/Navbar/etc. shared.tsx's uploadToWhp is now a
thin re-export (`export const uploadToWhp = uploadAsset`) so its ~6
existing callers are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:43:59 -07:00
shadowdaoandClaude Opus 4.8 b46b6915a5 fix(builder): validate AI response before deserializing into craft state
Adds an AI-boundary validation/sanitization pass in apply-ai-response.ts so
malformed or malicious Sitesmith output can never crash addNodeTree or
corrupt live craft state:

- resolvedName allowlist: any node whose type.resolvedName is not a
  registered component in componentResolver is dropped (with its subtree)
  and a console.warn is logged; buildNodeTree throws before touching
  Craft.js if the tree ROOT itself is invalid, which existing call sites
  already catch and warn on (fail soft, never throws into addNodeTree).
- update_props protected-key list (node_id) — an AI-supplied node_id can no
  longer overwrite the target node's real id; style is merged shallowly
  instead of replaced wholesale so unrelated existing style keys survive.
- id policy: regenerate, never skip. Any AI-supplied node id that is
  'ROOT', empty/non-string, or collides with an existing/already-used id is
  replaced with a fresh ai-auto-N id; the node itself is kept.

Extends apply-ai-response.test.ts with coverage for all of the above plus
regression tests proving fully-valid AI responses still apply exactly as
before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:37:25 -07:00
shadowdaoandClaude Opus 4.8 4b36ce0d6a fix(builder): route live header/footer edits correctly on save
Editing the Header/Footer sets activePageId to '__header__'/'__footer__',
which matches no entry in `pages`. save() was serializing the live canvas
into the top-level page slots (mislabeled as page content, matching no
page) while exporting header/footer from stale stored state — auto-save
every 30s silently dropped header/footer edits.

Extract buildSavePayload() as a pure, unit-tested helper: header/footer
craft state now comes from the live canvas when that zone is being
edited (else stored state), and the top-level page fields fall back to
the landing page's stored state when a header/footer zone is active,
so page content is never clobbered or mislabeled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:28:38 -07:00
shadowdaoandClaude Opus 4.8 1a88baa95d fix(builder): deep-clone node data on id regeneration to avoid shared props
regenerateTreeIds shallow-copied each node's data, leaving data.props (and
data.custom) as the same object reference between the original node and its
duplicate/pasted copy. Craft.js's setProp mutates data.props in place, so
editing the duplicate's props silently mutated the original too. Deep-clone
data via structuredClone before applying id remaps so no mutable sub-object
is shared between original and regenerated nodes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:25:22 -07:00
shadowdaoandClaude Opus 4.8 97123c4c58 fix(builder): regenerate node ids on duplicate/paste to prevent state corruption
Craft.js duplicate (ContextMenu + keyboard shortcut) and paste were reusing
the original node's toNodeTree() output verbatim, so addNodeTree() inserted
duplicate node ids into the editor tree. Added regenerateTreeIds() which
deep-clones a NodeTree and remaps rootNodeId, node map keys, node.id,
internal node.data.parent, node.data.nodes, and node.data.linkedNodes via
Craft.js's own getRandomId(). Also fixed pasteNode to insert as a sibling
of the right-clicked node (using its parent) instead of using a leaf node
as the new parent, which previously threw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:18:44 -07:00
shadowdaoandClaude Opus 4.8 4c001e1af4 fix(builder): preserve data-URI semicolons in css value sanitizer
sanitizeCssValue's blanket `;` strip ran on the whole value AFTER url(...)
content was already safely re-wrapped, corrupting legitimate
data:image/png;base64,... URLs pasted into Background Image fields
(the MIME/base64 separator `;` was deleted, breaking the data URI in
exported/published HTML). Scope the `;`/`"` breakout sanitization to the
segments outside url(...) matches only -- the url() branch is already
fully safe via escapeAttr(safeUrl(...)) and must not be re-stripped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:15:23 -07:00
shadowdaoandClaude Opus 4.8 fb4e9f87be fix(builder): sanitize style-string emission
cssPropsToString() joined raw CSSProperties values into a style="..."
attribute with zero escaping, so any component spreading user-controlled
values into inline styles (background-image url(), etc.) could break
out of the attribute or inject a second declaration -- this is what
made BackgroundSection/HeroSimple/CallToAction/Section's bg-image
url() sites (flagged in the A3 brief) safe without needing a per-call-
site fix, since they already route through this helper.

Each string value is now sanitized: url(...) contents are validated
through safeUrl and re-wrapped escaped, stray `;` (the only way to
inject a second live declaration) is stripped, and any raw `"` is
entity-encoded so it can't terminate the attribute early. Legitimate
multi-part values (box-shadow, gradients) that contain none of these
characters pass through byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:06:16 -07:00
shadowdaoandClaude Opus 4.8 7179287087 fix(builder): sanitize HtmlBlock/Countdown/Gallery JS contexts
Entity-escaping alone doesn't protect JS-string or raw-HTML sinks:

- HtmlBlock.toHtml exported props.code raw; now runs it through the
  same purifyHtml (DOMPurify) config already used for the live editor
  preview, so <script>/on*= payloads can't survive export either.
- Countdown.toHtml interpolated targetDate directly into
  `new Date("${targetDate}")` inside an inline <script> -- a value
  like `2026-01-01");alert(1)//` broke out of the string literal. Now
  validated against a strict date/datetime shape and JSON.stringify'd
  before embedding, falling back to `new Date()` for anything invalid.
- Gallery.toHtml's lightbox used
  `onclick="${id}_open('${esc(img.src)}')"`, which a single quote in
  img.src could break out of. Replaced with a `data-lb-src` attribute
  per thumbnail and one delegated click listener on the grid
  (`e.target.closest('[data-lb-src]')`) instead of a per-item inline
  handler string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:06:07 -07:00
shadowdaoandClaude Opus 4.8 48d0441be3 fix(builder): neutralize javascript:/breakout URLs in export
Every user-controlled URL emitted by a component's static toHtml (href,
src, action, and CSS url()) now runs through escapeAttr(safeUrl(...))
before hitting the exported HTML string, closing the XSS gaps flagged
in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/
HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a
few more found via a grep sweep of href=/src=/action=/url( inside
toHtml template strings: MapEmbed's iframe src and the shared
form-relay-wiring fallback form action (a javascript: form action
executes on submit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:05:57 -07:00
shadowdaoandClaude Opus 4.8 fad1882117 refactor(builder): use shared escaper everywhere, drop 26 local copies
Replace divergent, buggy local esc/escapeHtml helpers across 26 files with
imports from src/utils/escape (escapeHtml/escapeAttr). Attribute call sites use
escapeAttr, text-content sites use escapeHtml. Several toHtml outputs now
correctly escape & where old local escapers omitted it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:49:10 -07:00
shadowdaoandClaude Opus 4.8 cce984508f feat(builder): add shared escape/safeUrl util
Adds craft/src/utils/escape.ts as the single exported escaping/URL-safety
util (escapeHtml, escapeAttr, safeUrl) for later hardening tasks to
consolidate the 27 divergent local copies into. html-export.ts now
imports escapeHtml from it instead of keeping a private copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:41:11 -07:00
shadowdaoandClaude Opus 4.8 94140990c2 Add implementation plan: site builder hardening + asset picker
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:36:27 -07:00
shadowdaoandClaude Opus 4.8 02e99f7623 Add design spec: site builder security & data-loss hardening
Covers Critical (stored-XSS escaping cluster) + High (copy/paste id reuse,
header/footer save corruption, AI-boundary validation) findings from the
2026-07-12 audit. All claims verified against code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:27:57 -07:00
shadowdaoandClaude Opus 4.8 aaa305cc3e Add design spec: unified image/asset picker for site builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:14:00 -07:00
shadowdaoandClaude Opus 4.8 97cb439508 site-builder: fix Menu guided panel (empty Colors) + surface layout/nav colors
The shared NavStylePanel gated its Colors controls on the Navbar's prop
names (backgroundColor/textColor/ctaColor), so a Menu -- whose color props
are linkColor/linkHoverColor/ctaBgColor/ctaTextColor -- rendered an empty
Colors section (customer report: 'nothing to select').

- Add navColorFields() helper: derives the visible color controls from the
  props actually present, covering both the Navbar and Menu schemas
  (+ Navbar's hoverColor, which render/toHtml consume but had no control).
- Add a Menu Layout section (alignment/orientation/gap/font size), guarded
  on Menu's own props so it never leaks into Navbar or a standalone Logo.
- Unit test (navColorFields.test.ts) locks each component to its real props.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:10:46 -07:00
shadowdao d20b77e66d site-builder: dedicated Features editor in guided panel (per-card icon/image+upload/button, robust to template items missing keys) 2026-07-07 17:19:37 -07:00
shadowdao 5a26e4ef43 site-builder: FeaturesGrid renders image when 'image' set + expose image/button keys so the guided array editor edits them 2026-07-07 14:16:14 -07:00
shadowdao 1cfb51f181 site-builder: surface Spotify/Twitch + contact-form recipient in the Styles (guided) panel
The right panel only renders GuidedStyles (there is no Settings tab), so the
per-component Settings panels I'd edited never showed. Add Spotify/Twitch to
SocialStylePanel's platform dropdown and the 'Send submissions to' + thank-you
fields to FormStylePanel (shown for any form with a recipientEmail prop).
2026-07-07 14:06:24 -07:00
shadowdao 814ad29b91 Merge branch 'site-builder-feedback-batch' 2026-07-07 13:50:12 -07:00
shadowdao b9c5d3dd1c site-builder: relay wiring on FormContainer (template forms) + shared helper
The recipient field was only on the ContactForm block; templates build forms
from FormContainer + InputField, so template-based contact forms had no way to
set a target address. Add 'Send submissions to' + thank-you fields to
FormContainer, and extract the marker/placeholder/honeypot into a shared
form-relay-wiring helper so ContactForm and FormContainer can't drift.
2026-07-07 13:35:18 -07:00
jknapp 6b9c258d26 Merge pull request 'ContactForm relay wiring (recipient, thank-you, honeypot, marker)' (#2) from contact-form-relay into main 2026-07-07 19:35:27 +00:00
shadowdao 4877a63a3b site-builder: pin non-relay byte-identity on the realistic (non-empty fields) case [PR #2 review] 2026-07-07 12:22:28 -07:00
shadowdaoandClaude Opus 4.8 cf5d30382a site-builder: ContactForm non-relay output byte-identical (honeypot whitespace fix) + test guard
Fix non-relay form output to match pre-change byte-for-byte by conditionally omitting the honeypot and fields lines when empty. Add backward-compat regex assertion to catch extra blank lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:30:36 -07:00
shadowdaoandClaude Opus 4.8 66117d375e site-builder: ContactForm relay wiring (recipient, thank-you, honeypot, marker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:24:56 -07:00
shadowdaoandClaude Opus 4.8 53c40f856f site-builder: feedback batch (social, features, header menu, spacer)
Five of six items from user feedback (Contact Form email delivery split
into a focused, live-tested follow-up):

- Social Links: add Spotify + Twitch (FA 4.7.0 already ships both glyphs).
- Features Grid: per-feature icon/image toggle (upload + URL) and an
  optional button (text + url); render, settings, and HTML export updated,
  backward compatible with existing icon-only features.
- Header: seed the default header with a Navbar (logo + Home/About/Services/
  Contact) so new sites open with an editable menu-with-links instead of an
  empty header zone. Adds a vitest guard that the seed deserializes and
  exports a real <nav>.
- Canvas: slim the empty header/footer placeholder from a padded band to a
  thin hint line so an empty zone no longer reads as a stray spacer.

Design spec: docs/superpowers/specs/2026-07-06-site-builder-feedback-batch-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:53:22 -07:00
shadowdaoandClaude Opus 4.7 d0925d9e2d site-builder: dynamic CTAs, section anchors, edit-with-Sitesmith
Three related features:

1. Dynamic CTA buttons on HeroSimple, CTASection, CallToAction.
   New shared ctas[] array (text + href + variant + target) replaces the
   primary/secondary pair. Settings panel gets add/remove/reorder controls.
   Legacy fields stay readable for backwards compat — first user edit
   migrates the section onto the new array.

2. Anchor IDs on all layout/section components (Container, Section,
   BackgroundSection, ColumnLayout, plus 6 section blocks done by parallel
   subagent, plus Hero/CTA/CallToAction). Anchor input lives in the
   settings panel with an "auto from heading" button that walks the
   subtree for the first Heading.text. Renders as id="..." on the
   outermost element so #anchor URLs resolve.

3. Edit-with-Sitesmith targeted invocation. Right-click → "Ask Sitesmith"
   and a button at the top of the right-side settings panel both open the
   modal pre-targeted at the selected node. The node's serialized subtree
   is sent to the server; system prompt is augmented to require a patch
   with replace_node. Editor lifts modal state into a new SitesmithContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:43:28 -07:00
shadowdaoandClaude Opus 4.7 7b747f775f site-builder: lock landing page to index.html regardless of name
The first page is now treated as the landing page: it always publishes to
index.html no matter what the user names it, and its slug is forced to
'index' in state so .htaccess clean-URL rewrites stay consistent.

- useWhpApi.ts: force pages[0].filename='index.html' at save time
- PageContext.tsx: heal pages[0].slug to 'index' on load and on rename
- PagesPanel.tsx: "LANDING" badge on first page, slug shown as '/',
  rename hides slug input (locked), delete button hidden

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:14:26 -07:00
shadowdao 330032eea3 sitesmith: publish home page to index.html, not home.html
replaceAllPages was slugifying every page name including the first, so
the home page landed at home.html. Apache resolves '/' to index.html, so
the published root URL appeared blank while the actual content was at
/home.html. First page now hard-codes slug='index'.
2026-05-24 17:50:08 -07:00
shadowdao 5e60415311 sitesmith: strip diagnostic shim + state-dump now that the fix is verified
Apply path is stable end-to-end with the linkedNode pre-creation patch;
diagnostic shim + window.__sitesmithLastState dump are no longer earning
their footprint. Reverts:
  - vite.config.ts: drops the tiny-invariant alias
  - src/utils/tiny-invariant-shim.ts: deleted
  - PageContext.tsx: removes the post-walk dump/scan block
2026-05-24 17:32:38 -07:00
shadowdao 87dd4340f7 sitesmith: pre-create section-inner/bg-section-inner/form-inner linkedNodes
The Invariant 'component type (undefined) does not exist in the resolver'
was Craft.js's toNodeTree choking on the linkedNode that <Element id="X">
auto-creates at render time inside Section / BackgroundSection /
FormContainer. The auto-created node stores its type as the Container
React component class itself, not as {resolvedName:'Container'}, so the
later type.resolvedName lookup returns undefined.

For each shell, treeToState (and apply-ai-response's buildNodeTree) now
synthesizes the linkedNode container up-front with a proper serialized
type, moves the AI's direct children into it, and reparents them. This
matches the canonical shape Craft.js writes when the user manually builds
a site, so Craft.js never has to materialize the linkedNode itself.
2026-05-24 17:22:40 -07:00
shadowdao a1ec51afc3 sitesmith: filter known-benign invariants from diagnostic shim
Craft.js uses several invariants as try/catched control-flow checks
(notably isDraggable -> 'A top-level Node cannot be moved' for ROOT and
linkedNode children). These fire on every render and are NOT errors —
they're how Craft.js asks 'should I attach drag to this node?'. Filter
them out of the shim's console.error so only genuinely-broken invariants
show up.
2026-05-24 16:37:08 -07:00
shadowdao 43627bddb0 sitesmith: alias tiny-invariant to a diagnostic shim
The prod build of tiny-invariant strips all failure messages, leaving
us with bare 'Error: Invariant failed' and no actionable info. Aliasing
the package to a shim that always emits the message + a stack-trace
console.error before throwing — so the next Craft.js invariant we hit
tells us which assertion (ERROR_NOT_IN_RESOLVER, ERROR_NOPARENT,
ERROR_INVALID_NODE_ID, etc.) is actually failing.

Temporary; will revert once the Sitesmith apply flow is stable.
2026-05-24 16:32:50 -07:00
shadowdao 849f432330 sitesmith: narrow CANVAS_TYPES to just Container
The canonical Craft.js state from real saves shows that layout shells
(Section, BackgroundSection, HeroSimple, FeaturesGrid, ColumnLayout,
CTASection, FormContainer, Navbar, Footer) all serialize with
isCanvas:false. Only Container instances are canvases. The shells use
internal <Element canvas id="..."> linkedNodes for their drop targets.

Our previous CANVAS_TYPES set claimed all those shells were canvases,
which made Craft.js's toNodeTree walker hit an uncaught Invariant —
the shell asserted "I'm a canvas" but its render ignores data.nodes,
so the walker would chase phantom children.
2026-05-24 16:27:38 -07:00
shadowdao 6428f93cec sitesmith: route ColumnLayout children through linkedNodes (Invariant fix)
ColumnLayout's render uses <Element id="col-0" is={Container} canvas>
which expects the columns to live in linkedNodes, not data.nodes. The
AI nests its column containers as direct children, so they'd land in
data.nodes — Craft.js's render ignores them (the layout draws fresh
empty Elements), but the orphaned children remain in state with
parent: <columnlayout-id>. Any subsequent toNodeTree walk then trips
on this inconsistency and the uncaught Invariant kills the editor.

Normalizer added in two places — treeToState (for scope=site/page
replaces) and buildNodeTree (for scope=section inserts and patch ops):
when we see a ColumnLayout with direct children, move them into
linkedNodes keyed col-0/col-1/col-2..., clear data.nodes, set the
column nodes' isCanvas to true (they hold content), and sync the
"columns" prop to the actual count.
2026-05-24 16:17:25 -07:00
shadowdao 906695379b sitesmith: null-safe esc() in Navbar/Menu/Logo + clear chat button
The prior null-safe esc patch only matched 'const esc =' declarations;
Menu/Navbar/Logo use 'function esc(str: string)' syntax and slipped
through. Patched those three to coerce non-strings the same way.

Added "Clear chat" button in the modal header that appears when there's
any message history. Confirms with the user before posting to the new
clear_history endpoint, which deletes all messages + the thread row
for the current site (usage rows are preserved for billing).
2026-05-24 16:03:02 -07:00
shadowdao 069ea1235a sitesmith: null-safe esc() across all toHtml + WorkingIndicator
Real-world AI output frequently sends mismatched prop names (e.g.
items vs features, cta object vs buttonText/Href). The toHtml functions
of section/form/sections-folder components each defined a local
esc = (s: string) => s.replace(...) that crashed when called with
undefined, taking the auto-save export with it.

Patched every local esc() to coerce non-strings:
  const esc = (s: any) => String(s ?? "").replace(...)
17 files touched; behavior unchanged for valid string inputs.

Also adds a WorkingIndicator (Claude Code-style spinner + rotating
phrase + elapsed seconds) shown in the modal footer while a generation
is in flight, replacing the disabled "Thinking..." placeholder.
2026-05-24 15:54:48 -07:00
shadowdao ac0347ae5f sitesmith: fix blank canvas on Replace site
treeToState() was setting isCanvas:true on every node, including leaf
components (Heading, TextBlock, ButtonLink, Spacer, ImageBlock). Craft.js
then renders those as empty drop-canvas wrappers instead of their actual
content, so the canvas appears blank after applying an AI-generated
'replace' response.

Now uses a CANVAS_TYPES set matching the apply-ai-response utility:
only the layout wrappers (Container, Section, ColumnLayout, Hero/Features/
CTA sections, FormContainer, Navbar, Footer, etc.) are canvases. ROOT is
forced to be a canvas regardless of source type so children render.

Also defensively normalizes props.style: AI sometimes emits an empty
array instead of an object, which can confuse downstream consumers.
2026-05-24 15:35:05 -07:00
jknapp 5c5066c20b Merge pull request 'Sitesmith: AI site builder addon (frontend)' (#1) from sitesmith-ai-builder into main
Reviewed-on: #1
2026-05-24 17:11:03 +00:00
shadowdaoandClaude Sonnet 4.6 0f943bacc7 sitesmith: playwright e2e suite (locked/cap/bonus/build+patch)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:27:34 -07:00
shadowdao 2ca1ff0cf9 sitesmith: layers panel prefers props.aiName when present 2026-05-23 14:25:43 -07:00
shadowdao e651becdbe sitesmith: chat modal (messages, input, banner, scope confirm) 2026-05-23 14:25:28 -07:00
shadowdao b4d71340e1 sitesmith: upgrade banner + scope-replace confirmation dialog 2026-05-23 14:24:20 -07:00
shadowdao bf55ee85b9 sitesmith: topbar button with locked/capped states 2026-05-23 14:23:51 -07:00
shadowdaoandClaude Sonnet 4.6 cf3457aa15 sitesmith: apply-ai-response utility (replace + patch + ask) + PageContext helpers
Add apply-ai-response.ts with serializeTreeForCraft, buildNodeTree, findNodeIdByAiNodeId,
and useApplyAiResponse hook covering replace (site/page/section), patch (5 ops), and ask.
Extend PageContext with replaceAllPages, replaceCurrentPage, setHeader, setFooter helpers
that mirror the existing actions.deserialize/loadState pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:20:51 -07:00
shadowdao f6243d3ffe sitesmith: useSitesmith hook (entitlement, history, send) 2026-05-23 14:16:20 -07:00
shadowdao 8d094a9c67 sitesmith: typescript types for messages, responses, patch ops 2026-05-23 14:15:15 -07:00
shadowdao 14a957f57c sitesmith: canvas summary serializer with unit tests 2026-05-23 14:14:38 -07:00
shadowdaoandClaude Sonnet 4.6 bd15a33984 sitesmith: harden HtmlBlock with DOMPurify + add Vitest setup
Closes XSS hole in HtmlBlock by sanitizing user/AI-supplied markup
through DOMPurify before passing to dangerouslySetInnerHTML. Adds
Vitest + jsdom for unit testing with 5 passing tests covering script
stripping, on-event handler removal, javascript: URL blocking, iframe
allowlist, and form/input stripping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:13:42 -07:00
141 changed files with 10801 additions and 8541 deletions
+3189
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -8,20 +8,28 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "playwright test tests/site-builder.spec.ts --reporter=list", "test": "playwright test tests/site-builder.spec.ts --reporter=list",
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed" "test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed",
"test:e2e:sitesmith": "playwright test tests/sitesmith.spec.ts --reporter=list",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@craftjs/core": "^0.2.10", "@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7", "@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/ui": "^4.1.7",
"jsdom": "^29.1.1",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^6.0.5" "vite": "^6.0.5",
"vitest": "^4.1.7"
} }
} }
+12 -1
View File
@@ -6,18 +6,29 @@ import { WhpConfig } from './types';
import { EditorConfigProvider } from './state/EditorConfigContext'; import { EditorConfigProvider } from './state/EditorConfigContext';
import { SiteDesignProvider } from './state/SiteDesignContext'; import { SiteDesignProvider } from './state/SiteDesignContext';
import { PageProvider } from './state/PageContext'; import { PageProvider } from './state/PageContext';
import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext';
import { SitesmithModal } from './panels/sitesmith/SitesmithModal';
interface AppProps { interface AppProps {
whpConfig: WhpConfig | null; whpConfig: WhpConfig | null;
} }
const SitesmithModalMount: React.FC = () => {
const { isOpen, target, close } = useSitesmithModal();
if (!isOpen) return null;
return <SitesmithModal target={target} onClose={close} />;
};
export const App: React.FC<AppProps> = ({ whpConfig }) => { export const App: React.FC<AppProps> = ({ whpConfig }) => {
return ( return (
<EditorConfigProvider config={whpConfig}> <EditorConfigProvider config={whpConfig}>
<SiteDesignProvider> <SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true}> <Editor resolver={componentResolver} enabled={true}>
<PageProvider> <PageProvider>
<EditorShell /> <SitesmithProvider>
<EditorShell />
<SitesmithModalMount />
</SitesmithProvider>
</PageProvider> </PageProvider>
</Editor> </Editor>
</SiteDesignProvider> </SiteDesignProvider>
+2 -148
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface ButtonLinkProps { interface ButtonLinkProps {
text?: string; text?: string;
@@ -44,150 +45,6 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
ButtonLink.craft = { ButtonLink.craft = {
@@ -211,9 +68,6 @@ ButtonLink.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: ButtonLinkSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -227,6 +81,6 @@ ButtonLink.craft = {
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''; const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return { return {
html: `<a href="${props.href || '#'}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`, html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
}; };
}; };
-56
View File
@@ -34,59 +34,6 @@ export const Divider: UserComponent<DividerProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Divider.craft = { Divider.craft = {
@@ -101,9 +48,6 @@ Divider.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: DividerSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -0,0 +1,105 @@
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';
/* Footer only needs useNode from @craftjs/core. Mock it following the
DOM-harness pattern in src/state/PageContext.slug.test.tsx (no
@testing-library/react in this repo) so we can drive `selected` across
re-renders and observe setProp calls without a real <Editor> tree. */
let mockSelected = false;
let lastCommittedProps: { text: string } = { text: '' };
const setPropSpy = vi.fn((updater: (p: any) => void) => {
updater(lastCommittedProps);
});
vi.mock('@craftjs/core', () => ({
useNode: (collect?: (node: any) => any) => {
const node = { events: { selected: mockSelected } };
return {
connectors: { connect: (el: any) => el, drag: (el: any) => el },
actions: { setProp: setPropSpy },
...(collect ? collect(node) : {}),
};
},
}));
import { Footer } from './Footer';
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);
});
}
function rerender(ui: React.ReactElement) {
act(() => {
root.render(ui);
});
}
beforeEach(() => {
mockSelected = false;
lastCommittedProps = { text: 'Original' };
setPropSpy.mockClear();
});
describe('Footer edit-guard (mirrors Heading.tsx mechanism)', () => {
test('deselecting without a real blur still commits the in-progress edit', () => {
mockSelected = true;
render(<Footer text="Original" />);
const el = container.querySelector('footer')!;
act(() => {
el.innerText = 'Edited footer text';
el.dispatchEvent(new Event('input', { bubbles: true }));
});
// No blur event fired -- simulate selection clearing (e.g. clicking
// elsewhere) which is the scenario that used to lose the edit.
mockSelected = false;
rerender(<Footer text="Original" />);
expect(setPropSpy).toHaveBeenCalled();
expect(lastCommittedProps.text).toBe('Edited footer text');
container.remove();
});
test('a real blur still commits the edit (existing behavior preserved)', () => {
mockSelected = true;
render(<Footer text="Original" />);
const el = container.querySelector('footer')!;
act(() => {
el.innerText = 'Blurred edit';
el.dispatchEvent(new Event('input', { bubbles: true }));
// React delegates onBlur via native 'focusout' (which bubbles) rather
// than 'blur' (which doesn't) -- dispatch what React actually listens for.
el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
});
expect(setPropSpy).toHaveBeenCalled();
expect(lastCommittedProps.text).toBe('Blurred edit');
container.remove();
});
test('deselecting with no edit made does not call setProp', () => {
mockSelected = true;
render(<Footer text="Original" />);
mockSelected = false;
rerender(<Footer text="Original" />);
expect(setPropSpy).not.toHaveBeenCalled();
container.remove();
});
});
+25 -66
View File
@@ -20,16 +20,33 @@ export const Footer: UserComponent<FooterProps> = ({
})); }));
const elRef = useRef<HTMLElement | null>(null); const elRef = useRef<HTMLElement | null>(null);
const editedTextRef = useRef<string | null>(null);
const handleBlur = useCallback(() => { const commitText = useCallback(() => {
if (elRef.current) { if (elRef.current) {
const newText = elRef.current.innerText; const newText = elRef.current.innerText;
editedTextRef.current = newText;
setProp((p: FooterProps) => { p.text = newText; }, 500); setProp((p: FooterProps) => { p.text = newText; }, 500);
} }
}, [setProp]); }, [setProp]);
// Commit on blur
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
// Also commit on deselect via effect -- covers the case where selection
// clears without a real blur (e.g. clicking a different element that
// steals selection programmatically), which used to lose the in-progress
// edit. Mirrors Heading.tsx's mechanism.
useEffect(() => { useEffect(() => {
if (elRef.current && !selected) { if (!selected && editedTextRef.current !== null) {
setProp((p: FooterProps) => { p.text = editedTextRef.current!; }, 500);
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 || ''; elRef.current.innerText = text || '';
} }
}, [text, selected]); }, [text, selected]);
@@ -43,6 +60,12 @@ export const Footer: UserComponent<FooterProps> = ({
contentEditable={selected} contentEditable={selected}
suppressContentEditableWarning suppressContentEditableWarning
onBlur={handleBlur} onBlur={handleBlur}
onInput={() => {
// Track that we have unsaved edits
if (elRef.current) {
editedTextRef.current = elRef.current.innerText;
}
}}
style={{ style={{
padding: '24px 20px', padding: '24px 20px',
textAlign: 'center', textAlign: 'center',
@@ -56,67 +79,6 @@ export const Footer: UserComponent<FooterProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Footer.craft = { Footer.craft = {
@@ -135,9 +97,6 @@ Footer.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: FooterSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
-76
View File
@@ -1,9 +1,6 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; 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'; type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
@@ -80,76 +77,6 @@ export const Heading: UserComponent<HeadingProps> = ({
}); });
}; };
/* ---------- 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 = { Heading.craft = {
displayName: 'Heading', displayName: 'Heading',
props: { props: {
@@ -168,9 +95,6 @@ Heading.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: HeadingSettings,
},
}; };
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => { (Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
@@ -0,0 +1,23 @@
import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock';
describe('purifyHtml', () => {
test('strips script tags', () => {
expect(purifyHtml('<p>ok</p><script>alert(1)</script>')).not.toContain('<script');
});
test('strips on-event handlers', () => {
const out = purifyHtml('<a onclick="bad()" href="/x">x</a>');
expect(out).not.toContain('onclick');
expect(out).toContain('href="/x"');
});
test('blocks javascript: URLs', () => {
expect(purifyHtml('<a href="javascript:void(0)">x</a>')).not.toContain('javascript:');
});
test('allows YouTube iframe', () => {
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
expect(out).toContain('youtube.com/embed/abc');
});
test('strips form/input', () => {
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
});
});
@@ -0,0 +1,13 @@
import { describe, test, expect } from 'vitest';
import { HtmlBlock } from './HtmlBlock';
const toHtml = (HtmlBlock as any).toHtml;
describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
test('strips <script> and on-handlers from exported output', () => {
const { html } = toHtml({ code: '<script>alert(1)</script><p onclick="x">hi</p>' }, '');
expect(html).not.toContain('<script');
expect(html).not.toContain('onclick');
expect(html).toContain('<p>hi</p>');
});
});
+42 -96
View File
@@ -1,104 +1,52 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties, useMemo } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import DOMPurify from 'dompurify';
interface HtmlBlockProps { interface HtmlBlockProps {
code: string; code: string;
style?: CSSProperties; style?: CSSProperties;
aiName?: string;
node_id?: string;
} }
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ const PURIFY_CONFIG = {
code = '', ALLOWED_TAGS: [
style = {}, 'a','p','br','hr','div','span','section','article',
}) => { 'header','footer','main','aside','nav',
const { 'ul','ol','li',
connectors: { connect, drag }, 'h1','h2','h3','h4','h5','h6',
selected, 'em','strong','b','i','u','s',
} = useNode((node) => ({ 'blockquote','code','pre',
selected: node.events.selected, 'img','figure','figcaption',
})); 'iframe',
],
return ( ALLOWED_ATTR: [
<div 'href','src','alt','title','target','rel',
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} 'width','height','class',
style={{ 'allowfullscreen','allow','frameborder',
minHeight: '40px', ],
outline: selected ? '2px solid #3b82f6' : 'none', ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
...style, FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
}} FORBID_ATTR: [/^on/i],
dangerouslySetInnerHTML={{ __html: code }}
/>
);
}; };
/* ---------- Settings panel ---------- */ export function purifyHtml(input: string): string {
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
}
const HtmlBlockSettings: React.FC = () => { export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
const { actions: { setProp }, props } = useNode((node) => ({ const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
props: node.data.props as HtmlBlockProps, const clean = useMemo(() => purifyHtml(code), [code]);
})); const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
return React.createElement('div', {
return ( ref: setRef,
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}> style: {
<div style={{ minHeight: '40px',
padding: '8px 10px', outline: selected ? '2px solid #3b82f6' : 'none',
background: '#44200a', ...style,
border: '1px solid #92400e', },
borderRadius: 6, dangerouslySetInnerHTML: { __html: clean },
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 ---------- */ /* ---------- Craft config ---------- */
@@ -114,14 +62,12 @@ HtmlBlock.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: HtmlBlockSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => { (HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => {
// Output the raw code as-is // Run through the same DOMPurify config used for the live editor preview
return { html: props.code || '' }; // so exported pages can't carry <script>/on*= payloads either.
return { html: purifyHtml(props.code || '') };
}; };
+3 -184
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface IconProps { interface IconProps {
icon?: string; icon?: string;
@@ -13,15 +14,6 @@ interface IconProps {
style?: CSSProperties; 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 { function getBgBorderRadius(shape: string): string {
if (shape === 'circle') return '50%'; if (shape === 'circle') return '50%';
if (shape === 'rounded') return '8px'; if (shape === 'rounded') return '8px';
@@ -91,176 +83,6 @@ export const Icon: UserComponent<IconProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Icon.craft = { Icon.craft = {
@@ -280,9 +102,6 @@ Icon.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: IconSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -300,7 +119,7 @@ Icon.craft = {
} = props; } = props;
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' }); const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
let iconHtml = `<i class="fa ${icon}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`; let iconHtml = `<i class="fa ${escapeAttr(icon)}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
const hasBg = bgShape !== 'none' && bgColor !== 'transparent'; const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
if (hasBg) { if (hasBg) {
@@ -317,7 +136,7 @@ Icon.craft = {
} }
if (link) { if (link) {
iconHtml = `<a href="${link}" style="text-decoration:none;color:inherit">${iconHtml}</a>`; iconHtml = `<a href="${escapeAttr(safeUrl(link))}" style="text-decoration:none;color:inherit">${iconHtml}</a>`;
} }
const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style }); const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style });
+5 -294
View File
@@ -1,7 +1,8 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -18,30 +19,6 @@ interface LogoProps {
style?: CSSProperties; 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 ---------- */ /* ---------- Component ---------- */
export const Logo: UserComponent<LogoProps> = ({ export const Logo: UserComponent<LogoProps> = ({
@@ -96,269 +73,6 @@ export const Logo: UserComponent<LogoProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Logo.craft = { Logo.craft = {
@@ -380,9 +94,6 @@ Logo.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: LogoSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -393,7 +104,7 @@ Logo.craft = {
let innerHtml: string; let innerHtml: string;
if (props.type === 'image' && props.imageSrc) { if (props.type === 'image' && props.imageSrc) {
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' }); 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}"` : ''} />`; innerHtml = `<img src="${escapeAttr(safeUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
} else { } else {
const spanStyle = cssPropsToString({ const spanStyle = cssPropsToString({
fontWeight: props.fontWeight || '700', fontWeight: props.fontWeight || '700',
@@ -401,7 +112,7 @@ Logo.craft = {
fontFamily: props.fontFamily || 'Inter, sans-serif', fontFamily: props.fontFamily || 'Inter, sans-serif',
color: props.color || '#1f2937', color: props.color || '#1f2937',
}); });
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${esc(props.text || 'MySite')}</span>`; innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${escapeHtml(props.text || 'MySite')}</span>`;
} }
const aStyle = cssPropsToString({ const aStyle = cssPropsToString({
@@ -413,6 +124,6 @@ Logo.craft = {
}); });
return { return {
html: `<a href="${esc(href)}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`, html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
}; };
}; };
@@ -0,0 +1,32 @@
import { describe, test, expect } from 'vitest';
import { Menu } from './Menu';
const toHtml = (Menu as any).toHtml;
describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
test('same node id -> identical output across calls (deterministic)', () => {
const { html: html1 } = toHtml({}, '', 'node-menu1');
const { html: html2 } = toHtml({}, '', 'node-menu1');
expect(html1).toBe(html2);
});
test('different node ids -> different, non-colliding scope classes (identical default links, no collision)', () => {
const { html: html1 } = toHtml({}, '', 'node-menu1');
const { html: html2 } = toHtml({}, '', 'node-menu2');
const cls1 = html1.match(/\.([a-z0-9_]+-link):hover/)![1];
const cls2 = html2.match(/\.([a-z0-9_]+-link):hover/)![1];
expect(cls1).not.toBe(cls2);
});
test('the anchor class= and the <style> hover rule use the SAME scope', () => {
const { html } = toHtml({}, '', 'node-menu1');
const hoverCls = html.match(/\.([a-z0-9_]+-link):hover/)![1];
expect(html).toContain(`class="${hoverCls}"`);
});
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
const { html: html1 } = toHtml({}, '');
const { html: html2 } = toHtml({}, '');
expect(html1).toBe(html2);
});
});
+11 -335
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState } from 'react'; import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext'; import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -34,11 +34,6 @@ const defaultLinks: MenuLink[] = [
{ text: 'Contact', href: '#contact', isCta: true }, { 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 ---------- */ /* ---------- Component ---------- */
export const Menu: UserComponent<MenuProps> = ({ export const Menu: UserComponent<MenuProps> = ({
@@ -104,325 +99,6 @@ export const Menu: UserComponent<MenuProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Menu.craft = { Menu.craft = {
@@ -444,14 +120,11 @@ Menu.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: MenuSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string) => { (Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
const linkCol = props.linkColor || '#3f3f46'; const linkCol = props.linkColor || '#3f3f46';
const hoverCol = props.linkHoverColor || '#3b82f6'; const hoverCol = props.linkHoverColor || '#3b82f6';
const ctaBg = props.ctaBgColor || '#3b82f6'; const ctaBg = props.ctaBgColor || '#3b82f6';
@@ -477,12 +150,15 @@ Menu.craft = {
const links = props.links || defaultLinks; const links = props.links || defaultLinks;
// Unique ID suffix for scoped CSS // Scope for the hover CSS classes below. Deterministic AND unique: scoped
const scopeId = `menu-${Math.random().toString(36).slice(2, 8)}`; // on the Craft node id so two Menu instances with identical/default links
// don't collide on the same `.menu-link`/`.menu-cta` class names (which
// would let one instance's hover styling bleed into the other's).
const scope = scopeId(nodeId, JSON.stringify(links) + orientation + alignment, 'menu');
const linksHtml = links.map((link) => { const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''; const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const cls = link.isCta ? `${scopeId}-cta` : `${scopeId}-link`; const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
const linkStyle = cssPropsToString({ const linkStyle = cssPropsToString({
textDecoration: 'none', textDecoration: 'none',
fontSize: fSize, fontSize: fSize,
@@ -493,12 +169,12 @@ Menu.craft = {
borderRadius: link.isCta ? '6px' : '0', borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s', transition: 'color 0.15s, background-color 0.15s',
}); });
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`; return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n '); }).join('\n ');
const hoverCss = `<style> const hoverCss = `<style>
.${scopeId}-link:hover { color: ${hoverCol} !important; } .${scope}-link:hover { color: ${hoverCol} !important; }
.${scopeId}-cta:hover { filter: brightness(1.1); } .${scope}-cta:hover { filter: brightness(1.1); }
</style>`; </style>`;
return { return {
@@ -0,0 +1,28 @@
import { describe, test, expect } from 'vitest';
import { Navbar } from './Navbar';
const toHtml = (Navbar as any).toHtml;
describe('Navbar.toHtml hamburger accessibility (F2.3)', () => {
test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => {
const { html } = toHtml({ showMobileMenu: true }, '');
expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/);
expect(html).toMatch(/aria-expanded="false"/);
expect(html).toMatch(/aria-controls="navbar-links"/);
});
test('aria-controls target id exists on the links container', () => {
const { html } = toHtml({ showMobileMenu: true }, '');
expect(html).toContain('id="navbar-links"');
});
test('toggle script flips aria-expanded on click', () => {
const { html } = toHtml({ showMobileMenu: true }, '');
expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/);
});
test('no mobile menu: no hamburger button emitted', () => {
const { html } = toHtml({ showMobileMenu: false }, '');
expect(html).not.toContain('navbar-hamburger');
});
});
+15 -628
View File
@@ -1,8 +1,8 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react'; import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -51,30 +51,6 @@ const PADDING_PRESETS = [
{ label: 'Spacious', value: '24px 48px' }, { 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 ---------- */ /* ---------- Component ---------- */
export const Navbar: UserComponent<NavbarProps> = ({ export const Navbar: UserComponent<NavbarProps> = ({
@@ -198,595 +174,6 @@ export const Navbar: UserComponent<NavbarProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Navbar.craft = { Navbar.craft = {
@@ -819,9 +206,6 @@ Navbar.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: NavbarSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -852,7 +236,7 @@ Navbar.craft = {
let logoHtml: string; let logoHtml: string;
if (props.logoType === 'image' && props.logoImage) { if (props.logoType === 'image' && props.logoImage) {
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' }); 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>`; logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
} else { } else {
const logoStyle = cssPropsToString({ const logoStyle = cssPropsToString({
fontWeight: '700', fontWeight: '700',
@@ -860,7 +244,7 @@ Navbar.craft = {
fontFamily: props.logoFontFamily || 'Inter, sans-serif', fontFamily: props.logoFontFamily || 'Inter, sans-serif',
color: props.logoColor || textCol, 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>`; logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${escapeHtml(props.logoText || 'MySite')}</span></a>`;
} }
// Links HTML // Links HTML
@@ -877,15 +261,18 @@ Navbar.craft = {
borderRadius: link.isCta ? '6px' : '0', borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s', transition: 'color 0.15s, background-color 0.15s',
}); });
return `<a href="${esc(link.href)}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`; return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n '); }).join('\n ');
// Hamburger HTML for mobile // Hamburger HTML for mobile. The toggle needs an accessible name (there's
// no visible text, just three bars) and must report its open/closed state
// via aria-expanded, kept in sync with the .navbar-open class by the
// inline onclick handler.
const hamburgerHtml = 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"> ? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="navbar-links" onclick="var m=this.parentElement.querySelector('.navbar-links');var open=m.classList.toggle('navbar-open');this.setAttribute('aria-expanded', open ? 'true' : 'false');" 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:${escapeAttr(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:${escapeAttr(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:${escapeAttr(textCol)}"></span>
</button>` </button>`
: ''; : '';
@@ -914,14 +301,14 @@ Navbar.craft = {
borderRadius: link.isCta ? '6px' : '0', borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s', transition: 'color 0.15s, background-color 0.15s',
}); });
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`; return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n '); }).join('\n ');
return { return {
html: `${hoverCss} html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}> <nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${logoHtml}${hamburgerHtml} ${logoHtml}${hamburgerHtml}
<div class="navbar-links" style="display:flex;align-items:center;gap:24px"> <div class="navbar-links" id="navbar-links" style="display:flex;align-items:center;gap:24px">
${linksHtmlWithClass} ${linksHtmlWithClass}
</div> </div>
</nav>`, </nav>`,
@@ -0,0 +1,13 @@
import { describe, test, expect } from 'vitest';
import { SearchBar } from './SearchBar';
const toHtml = (SearchBar as any).toHtml;
describe('SearchBar.toHtml decorative icons (F2.5)', () => {
test('the input-adjacent search icon is aria-hidden', () => {
const { html } = toHtml({}, '');
const icons = html.match(/<i class="fa fa-search"[^>]*>/g) || [];
expect(icons.length).toBeGreaterThan(0);
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
});
+4 -63
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface SearchBarProps { interface SearchBarProps {
placeholder?: string; placeholder?: string;
@@ -92,62 +93,6 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
SearchBar.craft = { SearchBar.craft = {
@@ -163,15 +108,11 @@ SearchBar.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: SearchBarSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => { (SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
placeholder = 'Search...', placeholder = 'Search...',
buttonText = 'Search', buttonText = 'Search',
@@ -189,14 +130,14 @@ SearchBar.craft = {
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 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 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>` ? `<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" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
: ''; : '';
return { return {
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}> html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
<div style="position:relative;flex:1"> <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> <i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
<input type="search" placeholder="${esc(placeholder)}" style="${inputStyleStr}" /> <input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
</div> </div>
${btnHtml} ${btnHtml}
</form>`, </form>`,
@@ -0,0 +1,16 @@
import { describe, test, expect } from 'vitest';
import { SocialLinks } from './SocialLinks';
const toHtml = (SocialLinks as any).toHtml;
describe('SocialLinks.toHtml accessibility (F2.5)', () => {
test('icon-only links get an aria-label naming the platform', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: 'https://fb.example/x' }] }, '');
expect(html).toMatch(/<a[^>]*aria-label="Facebook"/);
});
test('the icon glyph itself is aria-hidden', () => {
const { html } = toHtml({ links: [{ platform: 'twitter', url: '#' }] }, '');
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
});
});
+10 -235
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface SocialLink { interface SocialLink {
platform: string; platform: string;
@@ -29,6 +30,8 @@ const platformIcons: Record<string, string> = {
pinterest: 'fa-pinterest', pinterest: 'fa-pinterest',
snapchat: 'fa-snapchat', snapchat: 'fa-snapchat',
whatsapp: 'fa-whatsapp', whatsapp: 'fa-whatsapp',
spotify: 'fa-spotify',
twitch: 'fa-twitch',
}; };
const platformLabels: Record<string, string> = { const platformLabels: Record<string, string> = {
@@ -42,10 +45,10 @@ const platformLabels: Record<string, string> = {
pinterest: 'Pinterest', pinterest: 'Pinterest',
snapchat: 'Snapchat', snapchat: 'Snapchat',
whatsapp: 'WhatsApp', whatsapp: 'WhatsApp',
spotify: 'Spotify',
twitch: 'Twitch',
}; };
const allPlatforms = Object.keys(platformIcons);
const defaultLinks: SocialLink[] = [ const defaultLinks: SocialLink[] = [
{ platform: 'facebook', url: '#' }, { platform: 'facebook', url: '#' },
{ platform: 'twitter', url: '#' }, { platform: 'twitter', url: '#' },
@@ -135,235 +138,6 @@ export const SocialLinks: UserComponent<SocialLinksProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
SocialLinks.craft = { SocialLinks.craft = {
@@ -383,9 +157,6 @@ SocialLinks.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: SocialLinksSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -433,7 +204,11 @@ SocialLinks.craft = {
if (hasBg) { if (hasBg) {
aStyle += `;${getShapeStr()}`; 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>`; // The link's only content is the icon glyph, so the glyph itself is
// aria-hidden and the accessible name lives on the link (aria-label,
// mirroring the existing `title` tooltip since title support in
// screen readers is inconsistent).
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
}).join('\n '); }).join('\n ');
return { return {
-48
View File
@@ -33,51 +33,6 @@ export const Spacer: UserComponent<SpacerProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Spacer.craft = { Spacer.craft = {
@@ -91,9 +46,6 @@ Spacer.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: SpacerSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -0,0 +1,23 @@
import { describe, test, expect } from 'vitest';
import { StarRating } from './StarRating';
const toHtml = (StarRating as any).toHtml;
describe('StarRating.toHtml accessibility (F2.2)', () => {
test('wrapper has role="img" and a "Rating: N out of maxStars" aria-label', () => {
const { html } = toHtml({ rating: 4.5, maxStars: 5 }, '');
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
});
test('individual star glyphs are aria-hidden', () => {
const { html } = toHtml({ rating: 3, maxStars: 5 }, '');
const glyphs = html.match(/<i class="fa fa-star"[^>]*>/g) || [];
expect(glyphs.length).toBeGreaterThan(0);
glyphs.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
test('respects custom maxStars in the aria-label', () => {
const { html } = toHtml({ rating: 2, maxStars: 10 }, '');
expect(html).toContain('aria-label="Rating: 2 out of 10"');
});
});
+7 -108
View File
@@ -75,107 +75,6 @@ export const StarRating: UserComponent<StarRatingProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
StarRating.craft = { StarRating.craft = {
@@ -193,9 +92,6 @@ StarRating.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: StarRatingSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -216,15 +112,18 @@ StarRating.craft = {
let starsHtml = ''; let starsHtml = '';
for (let i = 1; i <= maxStars; i++) { for (let i = 1; i <= maxStars; i++) {
if (i <= Math.floor(rating)) { if (i <= Math.floor(rating)) {
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}"></i>`; starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
} else if (i === Math.ceil(rating) && rating % 1 !== 0) { } 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>`; starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><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 { } else {
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}"></i>`; starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
} }
} }
// The star glyphs convey nothing to assistive tech on their own -- wrap
// in role="img" with a textual equivalent, and hide the decorative glyphs
// themselves (aria-hidden above) so AT doesn't announce each icon.
return { return {
html: `<span${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`, html: `<span role="img" aria-label="Rating: ${rating} out of ${maxStars}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
}; };
}; };
-58
View File
@@ -1,9 +1,6 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { SettingsTabs } from '../../ui/SettingsTabs';
import { TypographyControl } from '../../ui/TypographyControl';
import { AdvancedTab } from '../../ui/AdvancedTab';
interface TextBlockProps { interface TextBlockProps {
text?: string; text?: string;
@@ -75,58 +72,6 @@ export const TextBlock: UserComponent<TextBlockProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
TextBlock.craft = { TextBlock.craft = {
@@ -144,9 +89,6 @@ TextBlock.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: TextBlockSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -0,0 +1,112 @@
import { describe, test, expect } from 'vitest';
import { ContactForm } from './ContactForm';
const toHtml = (ContactForm as any).toHtml;
describe('ContactForm.toHtml relay wiring', () => {
test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '');
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(html).toContain('method="POST"');
expect(html).toContain('name="_gotcha"');
// marker id and action id match
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
});
test('without recipientEmail: no marker, falls back to formAction', () => {
const { html } = toHtml({ formAction: '/legacy', fields: [] }, '');
expect(html).not.toContain('WHP-FORM');
expect(html).toContain('action="/legacy"');
expect(html).not.toContain('_gotcha');
// Backward-compat: ensure non-relay output is byte-identical (no extra blank lines from honeypot)
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
});
test('without recipientEmail + real fields: byte-clean legacy output (realistic case)', () => {
// The empty-fields case is NOT byte-identical to the old code (the old
// template emitted a stray whitespace line when fields was empty; the new
// ternary drops it). Real forms always have fields, so pin THAT scenario:
// no marker, no honeypot, and no whitespace-only line between <form> and
// the first field.
const fields = [{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true }];
const { html } = toHtml({ formAction: '/legacy', fields }, '');
expect(html).not.toContain('WHP-FORM');
expect(html).not.toContain('_gotcha');
expect(html).toContain('action="/legacy"');
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
// First field renders directly after the form tag (no stray blank line).
expect(html).toMatch(/<form[^>]*>\n\s*<div/);
expect(html).toContain('Name');
});
});
describe('ContactForm.toHtml successMessage', () => {
// The published form-sender relay (form-sender/app/submit.php) delivers
// success via a full-page 303 redirect to thankYouUrl or a hosted
// thanks.php page -- there is no in-page JS to reveal an inline success
// element. So successMessage is emitted as a forward-compatible data
// attribute for a future AJAX/JS submission mode, not a live DOM element.
test('with successMessage set: emits it as an escaped data attribute on the form', () => {
const { html } = toHtml({ successMessage: "We'll be in touch!", fields: [] }, '');
expect(html).toContain('data-whp-success-message="We&#39;ll be in touch!"');
});
test('without successMessage: no data attribute emitted', () => {
const { html } = toHtml({ fields: [] }, '');
expect(html).not.toContain('data-whp-success-message');
});
test('escapes attribute-breakout attempts in successMessage', () => {
const { html } = toHtml({ successMessage: 'x" onerror="alert(1)', fields: [] }, '');
expect(html).not.toContain('onerror="alert(1)"');
});
});
describe('ContactForm.toHtml relay marker deterministic + unique via node id (no Math.random)', () => {
test('same node id -> identical marker+placeholder ids across two calls', () => {
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
expect(html1).toBe(html2);
});
test('marker id always equals the placeholder id it pairs with', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
const mid = html.match(/<!--WHP-FORM id="([^"]+)"/)![1];
expect(html).toContain(`action="__WHP_FORM_ACTION__${mid}__"`);
});
test('two different node ids -> different fids', () => {
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf2');
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
expect(mid1).not.toBe(mid2);
});
});
describe('ContactForm.toHtml accessibility (F2.1)', () => {
const fields = [
{ type: 'text' as const, label: 'Name', name: 'name', placeholder: 'Your name', required: true },
{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: true },
];
test('each field label for= matches its control id=, and ids are unique', () => {
const { html } = toHtml({ fields }, '');
const labelIds = [...html.matchAll(/<label for="([^"]+)"/g)].map((m) => m[1]);
const controlIds = [...html.matchAll(/<(?:input|textarea|select) id="([^"]+)"/g)].map((m) => m[1]);
expect(labelIds.length).toBe(2);
expect(controlIds.length).toBe(2);
expect(labelIds).toEqual(controlIds);
expect(new Set(controlIds).size).toBe(2);
});
test('ids are deterministic across repeated calls with the same fields', () => {
const { html: html1 } = toHtml({ fields }, '');
const { html: html2 } = toHtml({ fields }, '');
const ids1 = [...html1.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
const ids2 = [...html2.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
expect(ids1).toEqual(ids2);
});
});
+27 -221
View File
@@ -1,6 +1,8 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr, slugId } from '../../utils/escape';
interface ContactFormField { interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select'; type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
@@ -21,6 +23,8 @@ interface ContactFormProps {
labelColor?: string; labelColor?: string;
inputBg?: string; inputBg?: string;
inputBorder?: string; inputBorder?: string;
recipientEmail?: string;
thankYouUrl?: string;
} }
const defaultFields: ContactFormField[] = [ const defaultFields: ContactFormField[] = [
@@ -133,213 +137,6 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
ContactForm.craft = { ContactForm.craft = {
@@ -358,21 +155,19 @@ ContactForm.craft = {
labelColor: '#374151', labelColor: '#374151',
inputBg: '#ffffff', inputBg: '#ffffff',
inputBorder: '#d1d5db', inputBorder: '#d1d5db',
recipientEmail: '',
thankYouUrl: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: ContactFormSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => { (ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string, nodeId?: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const formStyle = cssPropsToString({ const formStyle = cssPropsToString({
padding: '32px', padding: '32px',
display: 'flex', display: 'flex',
@@ -385,18 +180,21 @@ ContactForm.craft = {
const inputBorder = props.inputBorder || '#d1d5db'; 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 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 fieldsHtml = (props.fields || defaultFields).map((field, i) => {
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : ''; 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>`; // Deterministic id: index + slugified name, so repeated fields with the
// same name (or no name) still get unique, stable ids -- no Math.random.
const fieldId = `field-${i}-${slugId(field.name)}`;
const labelHtml = `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:${labelColor}">${escapeHtml(field.label)}${reqStar}</label>`;
const reqAttr = field.required ? ' required' : ''; const reqAttr = field.required ? ' required' : '';
let inputHtml = ''; let inputHtml = '';
if (field.type === 'textarea') { if (field.type === 'textarea') {
inputHtml = `<textarea name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`; inputHtml = `<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
} else if (field.type === 'select') { } else if (field.type === 'select') {
const opts = (field.options || []).map((o) => `<option value="${esc(o)}">${esc(o)}</option>`).join(''); const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
inputHtml = `<select name="${esc(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${esc(field.placeholder || 'Select...')}</option>${opts}</select>`; inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
} else { } else {
inputHtml = `<input type="${field.type}" name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`; inputHtml = `<input id="${escapeAttr(fieldId)}" type="${field.type}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
} }
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`; return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
}).join('\n '); }).join('\n ');
@@ -414,10 +212,18 @@ ContactForm.craft = {
alignSelf: 'flex-start', alignSelf: 'flex-start',
}); });
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
// The form-sender relay delivers success via a full-page 303 redirect
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
// that reveals an inline success element today. Emit successMessage as a
// forward-compatible data attribute so a future AJAX/JS submission mode
// can read it, without implying a live mechanism that doesn't exist yet.
const successAttr = props.successMessage ? ` data-whp-success-message="${escapeAttr(props.successMessage)}"` : '';
return { return {
html: `<form action="${esc(props.formAction || '#')}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}> html: `${marker}<form action="${actionAttr}" method="POST"${successAttr}${formStyle ? ` style="${formStyle}"` : ''}>
${fieldsHtml} ${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(props.submitText || 'Send Message')}</button>
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
</form>`, </form>`,
}; };
}; };
-98
View File
@@ -42,101 +42,6 @@ export const FormButton: UserComponent<FormButtonProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
FormButton.craft = { FormButton.craft = {
@@ -158,9 +63,6 @@ FormButton.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: FormButtonSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -0,0 +1,41 @@
import { describe, test, expect } from 'vitest';
import { FormContainer } from './FormContainer';
const toHtml = (FormContainer as any).toHtml;
describe('FormContainer.toHtml relay wiring', () => {
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
expect(html).toContain('name="_gotcha"');
// honeypot precedes the form's children
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
// marker id === action id
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
});
test('without recipientEmail: legacy action/method, no marker or honeypot', () => {
const { html } = toHtml({ action: '/legacy', method: 'POST' }, '<input name="email">');
expect(html).not.toContain('WHP-FORM');
expect(html).not.toContain('_gotcha');
expect(html).toContain('action="/legacy"');
expect(html).toContain('<input name="email">');
});
test('same node id -> identical marker+placeholder ids across two calls', () => {
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
expect(html1).toBe(html2);
});
test('two different node ids -> different fids', () => {
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc2');
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
expect(mid1).not.toBe(mid2);
});
});
+10 -68
View File
@@ -2,10 +2,13 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core'; import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container'; import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
interface FormContainerProps { interface FormContainerProps {
action?: string; action?: string;
method?: 'GET' | 'POST'; method?: 'GET' | 'POST';
recipientEmail?: string;
thankYouUrl?: string;
style?: CSSProperties; style?: CSSProperties;
children?: React.ReactNode; children?: React.ReactNode;
} }
@@ -40,69 +43,6 @@ export const FormContainer: UserComponent<FormContainerProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
FormContainer.craft = { FormContainer.craft = {
@@ -110,6 +50,8 @@ FormContainer.craft = {
props: { props: {
action: '#', action: '#',
method: 'POST', method: 'POST',
recipientEmail: '',
thankYouUrl: '',
style: { style: {
padding: '24px', padding: '24px',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
@@ -122,19 +64,19 @@ FormContainer.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: FormContainerSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string) => { (FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string, nodeId?: string) => {
const styleStr = cssPropsToString({ const styleStr = cssPropsToString({
padding: '24px', padding: '24px',
...props.style, ...props.style,
}); });
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
const method = useRelay ? 'POST' : (props.method || 'POST'); // relay requires POST
const body = honeypot + childrenHtml; // honeypot as first child
return { return {
html: `<form action="${props.action || '#'}" method="${props.method || 'POST'}"${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</form>`, html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
}; };
}; };
@@ -0,0 +1,60 @@
import { describe, test, expect } from 'vitest';
import { InputField } from './InputField';
const toHtml = (InputField as any).toHtml;
describe('InputField.toHtml accessibility (F2.1)', () => {
test('label for= matches input id=', () => {
const { html } = toHtml({ label: 'Your Name', name: 'name' }, '');
const forMatch = html.match(/<label for="([^"]+)"/);
const idMatch = html.match(/<input id="([^"]+)"/);
expect(forMatch).toBeTruthy();
expect(idMatch).toBeTruthy();
expect(forMatch![1]).toBe(idMatch![1]);
});
test('id is deterministic (derived from name, not random) -- stable across calls', () => {
const { html: html1 } = toHtml({ label: 'Email', name: 'email' }, '');
const { html: html2 } = toHtml({ label: 'Email', name: 'email' }, '');
const id1 = html1.match(/<input id="([^"]+)"/)![1];
const id2 = html2.match(/<input id="([^"]+)"/)![1];
expect(id1).toBe(id2);
});
test('no visible label: input gets aria-label from placeholder', () => {
const { html } = toHtml({ label: '', name: 'phone', placeholder: 'Phone number' }, '');
expect(html).not.toContain('<label');
expect(html).toContain('aria-label="Phone number"');
});
});
describe('InputField.toHtml deterministic + unique ids (thread node id, resolves id-collision finding)', () => {
test('label for= still matches input id= after threading the node id', () => {
const { html } = toHtml({ label: 'Your Name', name: 'name' }, '', 'node-in1');
const forMatch = html.match(/<label for="([^"]+)"/);
const idMatch = html.match(/<input id="([^"]+)"/);
expect(forMatch![1]).toBe(idMatch![1]);
});
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
const { html: html1 } = toHtml({ label: 'Name', name: 'name' }, '', 'node-in1');
const { html: html2 } = toHtml({ label: 'Name', name: 'name' }, '', 'node-in1');
expect(html1).toBe(html2);
});
test('two instances with the SAME default name but different node ids do not collide', () => {
const { html: html1 } = toHtml({ label: 'Your Name', name: 'name' }, '', 'node-in1');
const { html: html2 } = toHtml({ label: 'Your Name', name: 'name' }, '', 'node-in2');
const id1 = html1.match(/<input id="([^"]+)"/)![1];
const id2 = html2.match(/<input id="([^"]+)"/)![1];
expect(id1).not.toBe(id2);
});
test('no nodeId (legacy 2-arg call): id derivation stays deterministic, not random', () => {
const { html: html1 } = toHtml({ label: 'Email', name: 'email' }, '');
const { html: html2 } = toHtml({ label: 'Email', name: 'email' }, '');
const id1 = html1.match(/<input id="([^"]+)"/)![1];
const id2 = html2.match(/<input id="([^"]+)"/)![1];
expect(id1).toBe(id2);
});
});
+14 -82
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
interface InputFieldProps { interface InputFieldProps {
label?: string; label?: string;
@@ -65,81 +66,6 @@ export const InputField: UserComponent<InputFieldProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
InputField.craft = { InputField.craft = {
@@ -157,15 +83,11 @@ InputField.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: InputFieldSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => { (InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string, nodeId?: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({ const wrapStyle = cssPropsToString({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -173,13 +95,23 @@ InputField.craft = {
...props.style, ...props.style,
}); });
const reqAttr = props.required ? ' required' : ''; const reqAttr = props.required ? ' required' : '';
// Deterministic AND unique id: scoped on the Craft node id so the
// <label for> always matches the <input id> AND two InputField instances
// that share the same (often default) `name` -- e.g. two untouched
// "Input" blocks both named "name" -- don't collide on `field-name` and
// clobber each other's for/id wiring. Falls back to the old name-derived
// hash for legacy 2-arg call sites without a node id.
const fieldId = scopeId(nodeId, props.name || 'field', 'field');
const labelHtml = props.label 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>` ? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
: '';
const ariaLabelAttr = !props.label
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Input field')}"`
: ''; : '';
return { return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}> html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml} ${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" /> <input id="${escapeAttr(fieldId)}" type="${props.type || 'text'}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} 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>`, </div>`,
}; };
}; };
+4 -133
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface SubscribeFormProps { interface SubscribeFormProps {
heading?: string; heading?: string;
@@ -98,132 +99,6 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
SubscribeForm.craft = { SubscribeForm.craft = {
@@ -241,15 +116,11 @@ SubscribeForm.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: SubscribeFormSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => { (SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
heading = 'Subscribe to our newsletter', heading = 'Subscribe to our newsletter',
placeholder = 'Enter your email', placeholder = 'Enter your email',
@@ -268,7 +139,7 @@ SubscribeForm.craft = {
}); });
const headingHtml = heading const headingHtml = heading
? `<h3 style="font-size:22px;font-weight:600;color:#1f2937;margin-bottom:20px;font-family:Inter,sans-serif">${esc(heading)}</h3>` ? `<h3 style="font-size:22px;font-weight:600;color:#1f2937;margin-bottom:20px;font-family:Inter,sans-serif">${escapeHtml(heading)}</h3>`
: ''; : '';
const formStyle = cssPropsToString({ const formStyle = cssPropsToString({
@@ -299,8 +170,8 @@ SubscribeForm.craft = {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}> html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
${headingHtml} ${headingHtml}
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}> <form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
<input type="email" name="email" placeholder="${esc(placeholder)}" required style="${inputStyleStr}" /> <input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(buttonText)}</button> <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button>
</form> </form>
</div>`, </div>`,
}; };
@@ -0,0 +1,52 @@
import { describe, test, expect } from 'vitest';
import { TextareaField } from './TextareaField';
const toHtml = (TextareaField as any).toHtml;
describe('TextareaField.toHtml accessibility (F2.1)', () => {
test('label for= matches textarea id=', () => {
const { html } = toHtml({ label: 'Message', name: 'message' }, '');
const forMatch = html.match(/<label for="([^"]+)"/);
const idMatch = html.match(/<textarea id="([^"]+)"/);
expect(forMatch).toBeTruthy();
expect(idMatch).toBeTruthy();
expect(forMatch![1]).toBe(idMatch![1]);
});
test('no visible label: textarea gets aria-label from placeholder', () => {
const { html } = toHtml({ label: '', name: 'notes', placeholder: 'Anything else?' }, '');
expect(html).not.toContain('<label');
expect(html).toContain('aria-label="Anything else?"');
});
});
describe('TextareaField.toHtml deterministic + unique ids (thread node id, resolves id-collision finding)', () => {
test('label for= still matches textarea id= after threading the node id', () => {
const { html } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
const forMatch = html.match(/<label for="([^"]+)"/);
const idMatch = html.match(/<textarea id="([^"]+)"/);
expect(forMatch![1]).toBe(idMatch![1]);
});
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
const { html: html1 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
const { html: html2 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
expect(html1).toBe(html2);
});
test('two instances with the SAME default name but different node ids do not collide', () => {
const { html: html1 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta1');
const { html: html2 } = toHtml({ label: 'Message', name: 'message' }, '', 'node-ta2');
const id1 = html1.match(/<textarea id="([^"]+)"/)![1];
const id2 = html2.match(/<textarea id="([^"]+)"/)![1];
expect(id1).not.toBe(id2);
});
test('no nodeId (legacy 2-arg call): id derivation stays deterministic, not random', () => {
const { html: html1 } = toHtml({ label: 'Message', name: 'message' }, '');
const { html: html2 } = toHtml({ label: 'Message', name: 'message' }, '');
const id1 = html1.match(/<textarea id="([^"]+)"/)![1];
const id2 = html2.match(/<textarea id="([^"]+)"/)![1];
expect(id1).toBe(id2);
});
});
+14 -82
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
interface TextareaFieldProps { interface TextareaFieldProps {
label?: string; label?: string;
@@ -67,81 +68,6 @@ export const TextareaField: UserComponent<TextareaFieldProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
TextareaField.craft = { TextareaField.craft = {
@@ -159,15 +85,11 @@ TextareaField.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: TextareaFieldSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => { (TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string, nodeId?: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({ const wrapStyle = cssPropsToString({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -175,13 +97,23 @@ TextareaField.craft = {
...props.style, ...props.style,
}); });
const reqAttr = props.required ? ' required' : ''; const reqAttr = props.required ? ' required' : '';
// Deterministic AND unique id: scoped on the Craft node id so the
// <label for> always matches the <textarea id> AND two TextareaField
// instances that share the same (often default) `name` -- e.g. two
// untouched "Textarea" blocks both named "message" -- don't collide on
// `field-message`. Falls back to the old name-derived hash for legacy
// 2-arg call sites without a node id.
const fieldId = scopeId(nodeId, props.name || 'message', 'field');
const labelHtml = props.label 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>` ? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
: '';
const ariaLabelAttr = !props.label
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Textarea field')}"`
: ''; : '';
return { return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}> html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml} ${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> <textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr}${ariaLabelAttr} 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>`, </div>`,
}; };
}; };
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core'; import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container'; import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr } from '../../utils/escape';
interface BackgroundSectionProps { interface BackgroundSectionProps {
bgImage?: string; bgImage?: string;
@@ -11,6 +12,7 @@ interface BackgroundSectionProps {
innerMaxWidth?: string; innerMaxWidth?: string;
style?: CSSProperties; style?: CSSProperties;
children?: React.ReactNode; children?: React.ReactNode;
anchorId?: string;
} }
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
@@ -20,12 +22,14 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
overlayOpacity = 0.4, overlayOpacity = 0.4,
innerMaxWidth = '1200px', innerMaxWidth = '1200px',
style = {}, style = {},
anchorId,
}) => { }) => {
const { connectors: { connect, drag } } = useNode(); const { connectors: { connect, drag } } = useNode();
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
position: 'relative', position: 'relative',
width: '100%', width: '100%',
@@ -65,92 +69,6 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
BackgroundSection.craft = { BackgroundSection.craft = {
@@ -162,15 +80,13 @@ BackgroundSection.craft = {
overlayOpacity: 0.4, overlayOpacity: 0.4,
innerMaxWidth: '1200px', innerMaxWidth: '1200px',
style: { padding: '0' }, style: { padding: '0' },
anchorId: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: BackgroundSectionSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -200,7 +116,8 @@ BackgroundSection.craft = {
margin: '0 auto', margin: '0 auto',
padding: '60px 20px', padding: '60px 20px',
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
return { return {
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`, html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
}; };
}; };
@@ -0,0 +1,61 @@
import { describe, test, expect } from 'vitest';
import { ColumnLayout } from './ColumnLayout';
const toHtml = (ColumnLayout as any).toHtml;
describe('ColumnLayout.toHtml width export from split', () => {
test('non-default split (70-30) exports per-column width CSS matching each column', () => {
const { html } = toHtml({ columns: 2, split: '70-30', gap: '16px' }, '<div>A</div><div>B</div>');
// First column gets 70%, second gets 30% (same mapping as getWidths()).
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(70% - 16px\)/);
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(30% - 16px\)/);
});
test('default 50-50 split still exports equal widths', () => {
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px' }, '<div>A</div><div>B</div>');
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(50% - 16px\)/);
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(50% - 16px\)/);
});
test('3-column 33-33-33 split exports three width rules', () => {
const { html } = toHtml({ columns: 3, split: '33-33-33', gap: '16px' }, '<div>A</div><div>B</div><div>C</div>');
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(33\.333% - 16px\)/);
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(33\.333% - 16px\)/);
expect(html).toMatch(/nth-child\(3\)[^}]*calc\(33\.333% - 16px\)/);
});
test('childrenHtml is preserved in the output', () => {
const { html } = toHtml({ columns: 2, split: '70-30', gap: '16px' }, '<div>A</div><div>B</div>');
expect(html).toContain('<div>A</div><div>B</div>');
});
});
describe('ColumnLayout.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
const props = { columns: 2, split: '50-50', gap: '16px' };
test('same node id -> identical output across calls (deterministic)', () => {
const { html: html1 } = toHtml(props, '', 'node-col1');
const { html: html2 } = toHtml(props, '', 'node-col1');
expect(html1).toBe(html2);
});
test('different node ids -> different, non-colliding scope classes (identical columns/split/gap, no collision)', () => {
const { html: html1 } = toHtml(props, '', 'node-col1');
const { html: html2 } = toHtml(props, '', 'node-col2');
const cls1 = html1.match(/class="([^"]+)"/)![1];
const cls2 = html2.match(/class="([^"]+)"/)![1];
expect(cls1).not.toBe(cls2);
});
test('the <style> nth-child rule and the div class= use the SAME scope', () => {
const { html } = toHtml(props, '', 'node-col1');
const cls = html.match(/class="([^"]+)"/)![1];
expect(html).toContain(`.${cls} > :nth-child(1)`);
});
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
const { html: html1 } = toHtml(props, '<div>A</div><div>B</div>');
const { html: html2 } = toHtml(props, '<div>A</div><div>B</div>');
expect(html1).toBe(html2);
});
});
+28 -177
View File
@@ -1,7 +1,8 @@
import React, { CSSProperties, useState } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core'; import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container'; import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, scopeId } from '../../utils/escape';
type SplitOption = type SplitOption =
| '100' | '100'
@@ -18,6 +19,7 @@ interface ColumnLayoutProps {
gap?: string; gap?: string;
style?: CSSProperties; style?: CSSProperties;
children?: React.ReactNode; children?: React.ReactNode;
anchorId?: string;
} }
const splitToWidths: Record<string, string[]> = { const splitToWidths: Record<string, string[]> = {
@@ -59,6 +61,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
split = '50-50', split = '50-50',
gap = '16px', gap = '16px',
style = {}, style = {},
anchorId,
}) => { }) => {
const { connectors: { connect, drag } } = useNode(); const { connectors: { connect, drag } } = useNode();
const widths = getWidths(split, columns); const widths = getWidths(split, columns);
@@ -66,6 +69,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
return ( return (
<div <div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',
@@ -90,177 +94,6 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
ColumnLayout.craft = { ColumnLayout.craft = {
@@ -270,21 +103,23 @@ ColumnLayout.craft = {
split: '50-50', split: '50-50',
gap: '16px', gap: '16px',
style: {}, style: {},
anchorId: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: ColumnLayoutSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => { (ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => {
const columns = props.columns || 2;
const split = props.split || '50-50';
const gap = props.gap || '16px'; const gap = props.gap || '16px';
const widths = getWidths(split, columns);
const outerStyle = cssPropsToString({ const outerStyle = cssPropsToString({
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',
@@ -292,7 +127,23 @@ ColumnLayout.craft = {
width: '100%', width: '100%',
...props.style, ...props.style,
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
// Each column is exported as an independently-serialized child node, so
// toHtml has no direct handle on individual children to rewrite their
// inline flex-basis. Instead, scope an nth-child CSS rule (with
// !important, to win over any stale inline flex baked into a child at
// creation time) to a generated class -- same width mapping (getWidths)
// the editor render uses. Precedent: Menu/Navbar toHtml already emit
// scoped <style> blocks for hover CSS. The class is scoped on the Craft
// node id so two ColumnLayout instances with identical columns/split/gap
// don't collide on the same class and cross-apply each other's widths.
const scope = scopeId(nodeId, `${columns}:${split}:${gap}`, 'cols');
const widthCss = widths
.map((w, i) => `.${scope} > :nth-child(${i + 1}) { flex: 0 0 calc(${w} - ${gap}) !important; }`)
.join('\n ');
return { return {
html: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`, html: `<style>\n ${widthCss}\n</style>\n<div class="${scope}"${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
}; };
}; };
@@ -0,0 +1,35 @@
import { describe, test, expect } from 'vitest';
import { Container } from './Container';
const toHtml = (Container as any).toHtml;
describe('Container.toHtml cssId/cssClass', () => {
test('emits id and class when both set', () => {
const { html } = toHtml({ cssId: 'my-id', cssClass: 'my-class' }, 'child');
expect(html).toContain('id="my-id"');
expect(html).toContain('class="my-class"');
});
test('emits neither id nor class when empty/unset', () => {
const { html } = toHtml({}, 'child');
expect(html).not.toContain(' id="');
expect(html).not.toContain(' class="');
});
test('escapes cssId/cssClass values', () => {
const { html } = toHtml({ cssId: 'x" onerror="alert(1)', cssClass: 'y" onerror="alert(1)' }, 'child');
expect(html).not.toContain('onerror="alert(1)"');
});
test('cssId takes precedence over anchorId when both set (no duplicate id attrs)', () => {
const { html } = toHtml({ cssId: 'explicit-id', anchorId: 'anchor-id' }, 'child');
const idMatches = html.match(/ id="/g) || [];
expect(idMatches.length).toBe(1);
expect(html).toContain('id="explicit-id"');
});
test('falls back to anchorId when cssId is not set', () => {
const { html } = toHtml({ anchorId: 'anchor-id' }, 'child');
expect(html).toContain('id="anchor-id"');
});
});
+21 -238
View File
@@ -1,9 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { SettingsTabs } from '../../ui/SettingsTabs'; import { escapeAttr } from '../../utils/escape';
import { BorderControl } from '../../ui/BorderControl';
import { AdvancedTab } from '../../ui/AdvancedTab';
interface ContainerProps { interface ContainerProps {
style?: CSSProperties; style?: CSSProperties;
@@ -11,6 +9,7 @@ interface ContainerProps {
children?: React.ReactNode; children?: React.ReactNode;
cssId?: string; cssId?: string;
cssClass?: string; cssClass?: string;
anchorId?: string;
hideOnDesktop?: boolean; hideOnDesktop?: boolean;
hideOnTablet?: boolean; hideOnTablet?: boolean;
hideOnMobile?: boolean; hideOnMobile?: boolean;
@@ -37,6 +36,9 @@ export const Container: UserComponent<ContainerProps> = ({
children, children,
fullWidth = false, fullWidth = false,
contentWidth = 'full', contentWidth = 'full',
anchorId,
cssId,
cssClass,
}) => { }) => {
const { connectors: { connect, drag } } = useNode(); const { connectors: { connect, drag } } = useNode();
@@ -50,12 +52,19 @@ export const Container: UserComponent<ContainerProps> = ({
...(needsBoxedWrapper ? {} : flexStyles), ...(needsBoxedWrapper ? {} : flexStyles),
}; };
// cssId is the user-facing "CSS ID" advanced field; it takes precedence
// over anchorId (the scroll-jump anchor) when both happen to be set, since
// only one `id` attribute can be emitted on the element.
const idValue = cssId || anchorId || undefined;
const el = React.createElement( const el = React.createElement(
tag, tag,
{ {
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }, ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
style: outerStyle, style: outerStyle,
'data-craft-container': 'true', 'data-craft-container': 'true',
id: idValue,
className: cssClass || undefined,
}, },
needsBoxedWrapper needsBoxedWrapper
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children) ? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
@@ -65,236 +74,6 @@ export const Container: UserComponent<ContainerProps> = ({
return el; 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 ---------- */ /* ---------- Craft config ---------- */
Container.craft = { Container.craft = {
@@ -304,15 +83,15 @@ Container.craft = {
tag: 'div', tag: 'div',
fullWidth: false, fullWidth: false,
contentWidth: 'full', contentWidth: 'full',
anchorId: '',
cssId: '',
cssClass: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => true, canMoveIn: () => true,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: ContainerSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -333,11 +112,15 @@ Container.craft = {
} }
const styleStr = cssPropsToString(outerCss); const styleStr = cssPropsToString(outerCss);
// cssId wins over anchorId when both are set (see the render fn above for why).
const idValue = props.cssId || props.anchorId;
const idAttr = idValue ? ` id="${escapeAttr(idValue)}"` : '';
const classAttr = props.cssClass ? ` class="${escapeAttr(props.cssClass)}"` : '';
if (isBoxed) { if (isBoxed) {
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles }); const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` }; return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
} }
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` }; return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
}; };
@@ -1,81 +0,0 @@
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>` };
};
@@ -1,81 +0,0 @@
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>` };
};
+7 -197
View File
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core'; import { useNode, Element, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { Container } from './Container'; import { Container } from './Container';
import { escapeAttr } from '../../utils/escape';
/* ---------- Shape Divider SVG Paths ---------- */ /* ---------- Shape Divider SVG Paths ---------- */
@@ -15,8 +16,6 @@ const DIVIDER_PATHS: Record<Exclude<DividerShape, 'none'>, string> = {
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', 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 { interface SectionProps {
style?: CSSProperties; style?: CSSProperties;
innerMaxWidth?: string; innerMaxWidth?: string;
@@ -27,6 +26,7 @@ interface SectionProps {
bottomDivider?: DividerShape; bottomDivider?: DividerShape;
bottomDividerColor?: string; bottomDividerColor?: string;
bottomDividerHeight?: string; bottomDividerHeight?: string;
anchorId?: string;
} }
/* ---------- Divider renderer ---------- */ /* ---------- Divider renderer ---------- */
@@ -85,6 +85,7 @@ export const Section: UserComponent<SectionProps> = ({
bottomDivider = 'none', bottomDivider = 'none',
bottomDividerColor = '#ffffff', bottomDividerColor = '#ffffff',
bottomDividerHeight = '50px', bottomDividerHeight = '50px',
anchorId,
}) => { }) => {
const { connectors: { connect, drag } } = useNode(); const { connectors: { connect, drag } } = useNode();
@@ -94,6 +95,7 @@ export const Section: UserComponent<SectionProps> = ({
return ( return (
<section <section
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
width: '100%', width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
@@ -129,197 +131,6 @@ export const Section: UserComponent<SectionProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Section.craft = { Section.craft = {
@@ -333,15 +144,13 @@ Section.craft = {
bottomDivider: 'none', bottomDivider: 'none',
bottomDividerColor: '#ffffff', bottomDividerColor: '#ffffff',
bottomDividerHeight: '50px', bottomDividerHeight: '50px',
anchorId: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => true, canMoveIn: () => true,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: SectionSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -394,8 +203,9 @@ function buildDividerHtml(
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top'); const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom'); const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
return { return {
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`, html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
}; };
}; };
+4 -385
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react'; import React, { CSSProperties, useCallback, useRef } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
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"; 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";
@@ -80,392 +81,10 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
); );
}; };
/* ---------- 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', '8px', '16px', '32px', '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 = { ImageBlock.craft = {
displayName: 'Image', displayName: 'Image',
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } }, props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
related: { settings: ImageBlockSettings },
}; };
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => { (ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
@@ -475,6 +94,6 @@ ImageBlock.craft = {
return { html: '' }; return { html: '' };
} }
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style }); const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '&quot;')}"` : ' alt=""'; const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
return { html: `<img src="${src}"${alt}${s ? ` style="${s}"` : ''} />` }; return { html: `<img src="${escapeAttr(safeUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
}; };
@@ -0,0 +1,29 @@
import { describe, test, expect } from 'vitest';
import { MapEmbed } from './MapEmbed';
const toHtml = (MapEmbed as any).toHtml;
describe('MapEmbed.toHtml iframe accessibility (F2.4)', () => {
test('iframe has a non-empty title attribute', () => {
const { html } = toHtml({ address: 'New York, NY' }, '');
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
});
test('title reflects the configured address', () => {
const { html } = toHtml({ address: 'Golden Gate Bridge' }, '');
expect(html).toContain('title="Map of Golden Gate Bridge"');
});
});
describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
test('the iframe src (built by string concatenation with literal &) emits &amp; in the attribute, not a raw &', () => {
const { html } = toHtml({ address: 'New York, NY', zoom: 14 }, '');
const srcMatch = html.match(/<iframe src="([^"]+)"/);
expect(srcMatch).toBeTruthy();
// The raw src is `...q=...&z=14&output=embed` -- concatenated with
// literal `&`s -- so the emitted attribute must HTML-encode them.
expect(srcMatch![1]).toContain('&amp;z=14');
expect(srcMatch![1]).toContain('&amp;output=embed');
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
});
});
+2 -77
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface MapEmbedProps { interface MapEmbedProps {
address?: string; address?: string;
@@ -53,79 +54,6 @@ export const MapEmbed: UserComponent<MapEmbedProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
MapEmbed.craft = { MapEmbed.craft = {
@@ -141,9 +69,6 @@ MapEmbed.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: MapEmbedSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
@@ -168,6 +93,6 @@ MapEmbed.craft = {
const src = buildMapUrl(address, zoom); const src = buildMapUrl(address, zoom);
return { return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${src}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`, html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(src))}" title="${escapeAttr(`Map of ${address}`)}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
}; };
}; };
@@ -0,0 +1,88 @@
import { describe, test, expect } from 'vitest';
import { VideoBlock } from './VideoBlock';
const toHtml = (VideoBlock as any).toHtml;
function embedSrc(videoUrl: string): string {
const { html } = toHtml({ videoUrl }, '');
const m = html.match(/<iframe src="([^"]+)"/) || html.match(/<video src="([^"]+)"/);
return m ? m[1].replace(/&amp;/g, '&') : '';
}
describe('VideoBlock URL parsing (D4)', () => {
test('youtube.com/watch?v=ID (existing case) resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtu.be/ID resolves to embed URL', () => {
expect(embedSrc('https://youtu.be/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtube.com/embed/ID (existing case) resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/embed/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtube.com/shorts/ID resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/shorts/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtube.com/live/ID resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/live/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('youtube.com/watch?...&v=ID (v not first param) resolves to embed URL', () => {
expect(embedSrc('https://www.youtube.com/watch?list=PLxyz&v=dQw4w9WgXcQ&index=3')).toContain('youtube.com/embed/dQw4w9WgXcQ');
});
test('vimeo.com/ID (existing case) resolves to player URL', () => {
expect(embedSrc('https://vimeo.com/123456789')).toContain('https://player.vimeo.com/video/123456789');
});
test('vimeo.com/ID/HASH (private video) resolves to player URL with hash param', () => {
const src = embedSrc('https://vimeo.com/123456789/abcdef1234');
expect(src).toContain('https://player.vimeo.com/video/123456789');
expect(src).toContain('h=abcdef1234');
});
test('direct .mp4 file still works', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
expect(html).toContain('<video src="https://example.com/clip.mp4"');
});
test('unrecognized URL yields no output (type "none")', () => {
const { html } = toHtml({ videoUrl: 'not-a-real-video-url' }, '');
expect(html).toBe('');
});
test('emitted src is safeUrl-wrapped: javascript: scheme never reaches output', () => {
const { html } = toHtml({ videoUrl: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:');
});
});
describe('VideoBlock.toHtml iframe accessibility (F2.4)', () => {
test('normal-mode YouTube/Vimeo iframe has a title attribute', () => {
const { html } = toHtml({ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, '');
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
});
test('background-mode YouTube/Vimeo iframe has a title attribute', () => {
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true }, '');
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
});
});
describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
test('embed params joined with literal & are HTML-entity-encoded in the emitted src attribute', () => {
// autoplay+muted+controls=false forces buildEmbedParams to concatenate
// multiple query params onto the URL with literal `&`s.
const { html } = toHtml(
{ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', autoplay: true, muted: true, controls: false },
''
);
const srcMatch = html.match(/<iframe src="([^"]+)"/);
expect(srcMatch).toBeTruthy();
expect(srcMatch![1]).toMatch(/&amp;/);
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
});
});
+38 -373
View File
@@ -1,7 +1,8 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core'; import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container'; import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -25,18 +26,43 @@ interface VideoBlockProps {
/* ---------- URL detection ---------- */ /* ---------- URL detection ---------- */
/**
* Extract a YouTube video ID from any of the URL shapes WHP users paste:
* youtu.be/ID, youtube.com/embed/ID, youtube.com/shorts/ID,
* youtube.com/live/ID (path-based), and youtube.com/watch?...v=ID where `v`
* may appear anywhere in the query string (not just as the first param).
*/
function extractYouTubeId(url: string): string | null {
const pathMatch = url.match(
/(?:youtube\.com\/(?:embed|shorts|live)\/|youtu\.be\/)([a-zA-Z0-9_-]+)/
);
if (pathMatch) return pathMatch[1];
const queryMatch = url.match(/youtube\.com\/watch\?([^\s#]+)/);
if (queryMatch) {
const v = new URLSearchParams(queryMatch[1]).get('v');
if (v) return v;
}
return null;
}
function detectVideoType(url: string): { type: VideoType; embedUrl: string } { function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
if (!url) return { type: 'none', embedUrl: '' }; if (!url) return { type: 'none', embedUrl: '' };
// YouTube // YouTube
const ytMatch = url.match( const ytId = extractYouTubeId(url);
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/ if (ytId) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytId}?rel=0` };
);
if (ytMatch) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}?rel=0` };
// Vimeo // Vimeo: vimeo.com/ID, or vimeo.com/ID/HASH for unlisted/private videos
const vmMatch = url.match(/vimeo\.com\/(\d+)/); // (the hash becomes the player's `h` query param).
if (vmMatch) return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}` }; const vmMatch = url.match(/vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?/);
if (vmMatch) {
const embedUrl = vmMatch[2]
? `https://player.vimeo.com/video/${vmMatch[1]}?h=${vmMatch[2]}`
: `https://player.vimeo.com/video/${vmMatch[1]}`;
return { type: 'vimeo', embedUrl };
}
// Direct file // Direct file
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url }; if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
@@ -60,28 +86,6 @@ function buildEmbedParams(
return url.toString(); 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 ---------- */ /* ---------- Placeholder ---------- */
const VIDEO_PLACEHOLDER = ( const VIDEO_PLACEHOLDER = (
@@ -296,342 +300,6 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
); );
}; };
/* ========================================================================
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 Craft Config
======================================================================== */ ======================================================================== */
@@ -657,9 +325,6 @@ VideoBlock.craft = {
canMoveIn: () => true, canMoveIn: () => true,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: VideoBlockSettings,
},
}; };
/* ======================================================================== /* ========================================================================
@@ -722,7 +387,7 @@ VideoBlock.craft = {
zIndex: '0', zIndex: '0',
pointerEvents: 'none', pointerEvents: 'none',
}); });
videoHtml = `<video src="${embedUrl}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`; videoHtml = `<video src="${escapeAttr(safeUrl(embedUrl))}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
} else if ((type === 'youtube' || type === 'vimeo') && embedUrl) { } else if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false }); const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
const ifrStyle = cssPropsToString({ const ifrStyle = cssPropsToString({
@@ -738,7 +403,7 @@ VideoBlock.craft = {
zIndex: '0', zIndex: '0',
pointerEvents: 'none', pointerEvents: 'none',
}); });
videoHtml = `<iframe src="${iframeSrc}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`; videoHtml = `<iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
} }
return { return {
@@ -771,7 +436,7 @@ VideoBlock.craft = {
border: 'none', border: 'none',
}); });
return { 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>`, html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
}; };
} }
@@ -789,6 +454,6 @@ VideoBlock.craft = {
}); });
return { return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${embedUrl}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`, html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
}; };
}; };
+9 -156
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useState } from 'react'; import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface AccordionItem { interface AccordionItem {
title: string; title: string;
@@ -15,6 +16,7 @@ interface AccordionProps {
headerColor?: string; headerColor?: string;
contentBg?: string; contentBg?: string;
borderColor?: string; borderColor?: string;
anchorId?: string;
} }
const defaultItems: AccordionItem[] = [ const defaultItems: AccordionItem[] = [
@@ -30,6 +32,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
headerColor = '#18181b', headerColor = '#18181b',
contentBg = '#ffffff', contentBg = '#ffffff',
borderColor = '#e2e8f0', borderColor = '#e2e8f0',
anchorId,
}) => { }) => {
const { const {
connectors: { connect, drag }, connectors: { connect, drag },
@@ -56,6 +59,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
padding: '60px 20px', padding: '60px 20px',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
@@ -119,155 +123,6 @@ export const Accordion: UserComponent<AccordionProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Accordion.craft = { Accordion.craft = {
@@ -279,25 +134,23 @@ Accordion.craft = {
headerColor: '#18181b', headerColor: '#18181b',
contentBg: '#ffffff', contentBg: '#ffffff',
borderColor: '#e2e8f0', borderColor: '#e2e8f0',
anchorId: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: AccordionSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => { (Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '60px 20px', padding: '60px 20px',
...props.style, ...props.style,
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const headerBg = props.headerBg || '#f8fafc'; const headerBg = props.headerBg || '#f8fafc';
const headerColor = props.headerColor || '#18181b'; const headerColor = props.headerColor || '#18181b';
const contentBg = props.contentBg || '#ffffff'; const contentBg = props.contentBg || '#ffffff';
@@ -311,16 +164,16 @@ Accordion.craft = {
const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`; const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`;
return `<details${openAttr} style="${borderBottom}${topRadius}${bottomRadius}"> 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"> <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)} ${escapeHtml(item.title)}
</summary> </summary>
<div style="padding:16px 20px;background-color:${contentBg};color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}"> <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)} ${escapeHtml(item.content)}
</div> </div>
</details>`; </details>`;
}).join('\n '); }).join('\n ');
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column"> <div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
${panels} ${panels}
</div> </div>
+31 -108
View File
@@ -1,13 +1,18 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface CTASectionProps { interface CTASectionProps {
heading?: string; heading?: string;
description?: string; description?: string;
ctas?: CtaButton[];
/** Legacy props kept for backward compat with saved projects. */
buttonText?: string; buttonText?: string;
buttonHref?: string; buttonHref?: string;
gradient?: string; gradient?: string;
anchorId?: string;
style?: CSSProperties; style?: CSSProperties;
} }
@@ -16,9 +21,11 @@ const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
export const CTASection: UserComponent<CTASectionProps> = ({ export const CTASection: UserComponent<CTASectionProps> = ({
heading = 'Ready to Get Started?', heading = 'Ready to Get Started?',
description = 'Join thousands of satisfied users and start building your dream website today.', description = 'Join thousands of satisfied users and start building your dream website today.',
buttonText = 'Start Free Trial', ctas,
buttonHref = '#', buttonText,
buttonHref,
gradient = defaultGradient, gradient = defaultGradient,
anchorId,
style = {}, style = {},
}) => { }) => {
const { const {
@@ -28,9 +35,13 @@ export const CTASection: UserComponent<CTASectionProps> = ({
selected: node.events.selected, selected: node.events.selected,
})); }));
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref });
const ctaDefaults = { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' };
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
background: gradient, background: gradient,
padding: '80px 20px', padding: '80px 20px',
@@ -46,105 +57,16 @@ export const CTASection: UserComponent<CTASectionProps> = ({
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}> <p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
{description} {description}
</p> </p>
<a <div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
href={buttonHref} {effectiveCtas.map((cta, i) => (
onClick={(e) => e.preventDefault()} <a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
style={{ style={ctaInlineStyle(cta, ctaDefaults)}>
display: 'inline-block', {cta.text}
padding: '14px 36px', </a>
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> </div>
</div> </section>
); );
}; };
@@ -155,9 +77,11 @@ CTASection.craft = {
props: { props: {
heading: 'Ready to Get Started?', heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.', description: 'Join thousands of satisfied users and start building your dream website today.',
buttonText: 'Start Free Trial', ctas: [
buttonHref: '#', { text: 'Start Free Trial', href: '#', variant: 'primary' },
] as CtaButton[],
gradient: defaultGradient, gradient: defaultGradient,
anchorId: '',
style: {}, style: {},
}, },
rules: { rules: {
@@ -165,27 +89,26 @@ CTASection.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: CTASectionSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => { (CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
background: props.gradient || defaultGradient, background: props.gradient || defaultGradient,
padding: '80px 20px', padding: '80px 20px',
textAlign: 'center', textAlign: 'center',
...props.style, ...props.style,
}); });
const ctas = normalizeCtas(props);
const buttonsHtml = ctasToHtml(ctas, { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:700px;margin:0 auto"> <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> <h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${escapeHtml(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> <p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${escapeHtml(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 style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
</div> </div>
</section>`, </section>`,
}; };
+32 -322
View File
@@ -1,10 +1,14 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface CallToActionProps { interface CallToActionProps {
heading?: string; heading?: string;
description?: string; description?: string;
ctas?: CtaButton[];
/** Legacy props kept for backward compat with saved projects. */
buttonText?: string; buttonText?: string;
buttonHref?: string; buttonHref?: string;
secondaryButtonText?: string; secondaryButtonText?: string;
@@ -15,6 +19,7 @@ interface CallToActionProps {
overlayOpacity?: number; overlayOpacity?: number;
textColor?: string; textColor?: string;
buttonColor?: string; buttonColor?: string;
anchorId?: string;
style?: CSSProperties; style?: CSSProperties;
} }
@@ -23,16 +28,18 @@ const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
export const CallToAction: UserComponent<CallToActionProps> = ({ export const CallToAction: UserComponent<CallToActionProps> = ({
heading = 'Ready to Get Started?', heading = 'Ready to Get Started?',
description = 'Join thousands of satisfied users and start building your dream website today.', description = 'Join thousands of satisfied users and start building your dream website today.',
buttonText = 'Get Started', ctas,
buttonHref = '#', buttonText,
secondaryButtonText = '', buttonHref,
secondaryButtonHref = '#', secondaryButtonText,
secondaryButtonHref,
bgType = 'gradient', bgType = 'gradient',
bgValue = defaultGradient, bgValue = defaultGradient,
overlayColor = '#000000', overlayColor = '#000000',
overlayOpacity = 0, overlayOpacity = 0,
textColor = '#ffffff', textColor = '#ffffff',
buttonColor = '#ffffff', buttonColor = '#ffffff',
anchorId,
style = {}, style = {},
}) => { }) => {
const { const {
@@ -56,9 +63,13 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc'; const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff'; const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
const ctaDefaults = { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor };
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
position: 'relative', position: 'relative',
padding: '80px 20px', padding: '80px 20px',
@@ -89,290 +100,18 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
{description} {description}
</p> </p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
<a {effectiveCtas.map((cta, i) => (
href={buttonHref} <a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
onClick={(e) => e.preventDefault()} style={ctaInlineStyle(cta, ctaDefaults)}>
style={{ {cta.text}
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> </a>
)} ))}
</div> </div>
</div> </div>
</section> </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 ---------- */ /* ---------- Craft config ---------- */
CallToAction.craft = { CallToAction.craft = {
@@ -380,10 +119,11 @@ CallToAction.craft = {
props: { props: {
heading: 'Ready to Get Started?', heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.', description: 'Join thousands of satisfied users and start building your dream website today.',
buttonText: 'Get Started', ctas: [
buttonHref: '#', { text: 'Get Started', href: '#', variant: 'primary' },
secondaryButtonText: 'Learn More', { text: 'Learn More', href: '#', variant: 'outline' },
secondaryButtonHref: '#', ] as CtaButton[],
anchorId: '',
bgType: 'gradient', bgType: 'gradient',
bgValue: defaultGradient, bgValue: defaultGradient,
overlayColor: '#000000', overlayColor: '#000000',
@@ -397,16 +137,11 @@ CallToAction.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: CallToActionSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => { (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 bgType = props.bgType || 'gradient';
const bgValue = props.bgValue || defaultGradient; const bgValue = props.bgValue || defaultGradient;
const textColor = props.textColor || '#ffffff'; const textColor = props.textColor || '#ffffff';
@@ -445,41 +180,16 @@ CallToAction.craft = {
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`; overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
} }
let secondaryBtnHtml = ''; const ctas = normalizeCtas(props);
if (props.secondaryButtonText) { const buttonsHtml = ctasToHtml(ctas, { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor });
const secStyle = cssPropsToString({ const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
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 { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${overlayHtml}<div style="max-width:700px;margin:0 auto;position:relative;z-index:1"> ${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> <h2 style="font-size:36px;font-weight:700;color:${textColor};margin-bottom:12px">${escapeHtml(props.heading || '')}</h2>
<p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p> <p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${escapeHtml(props.description || '')}</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap"> <div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
<a href="${props.buttonHref || '#'}"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.buttonText || '')}</a>${secondaryBtnHtml}
</div>
</div> </div>
</section>`, </section>`,
}; };
@@ -0,0 +1,93 @@
import { describe, test, expect } from 'vitest';
import { ContentSlider } from './ContentSlider';
const toHtml = (ContentSlider as any).toHtml;
const slides = [
{ type: 'image' as const, heading: 'One' },
{ type: 'image' as const, heading: 'Two' },
{ type: 'image' as const, heading: 'Three' },
];
describe('ContentSlider.toHtml accessibility (F1.1)', () => {
test('prev/next arrows get aria-labels and are real buttons', () => {
const { html } = toHtml({ slides, showArrows: true }, '');
expect(html).toMatch(/<button[^>]*aria-label="Previous slide"/);
expect(html).toMatch(/<button[^>]*aria-label="Next slide"/);
});
test('dot buttons get "Go to slide N" aria-labels', () => {
const { html } = toHtml({ slides, showDots: true }, '');
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 1"/);
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 2"/);
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 3"/);
});
test('slides are wrapped in an aria-live region', () => {
const { html } = toHtml({ slides }, '');
expect(html).toMatch(/aria-live="(polite|off)"/);
});
test('decorative chevron icons in arrows are aria-hidden', () => {
const { html } = toHtml({ slides, showArrows: true }, '');
expect(html).toMatch(/<i class="fa fa-chevron-left" aria-hidden="true"><\/i>/);
expect(html).toMatch(/<i class="fa fa-chevron-right" aria-hidden="true"><\/i>/);
});
});
describe('ContentSlider.toHtml deterministic + unique scope ids (thread node id)', () => {
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
const { html: html2 } = toHtml({ slides }, '', 'node-cs1');
expect(html1).toBe(html2);
});
test('different node ids -> different, non-colliding scope ids (identical slides, no collision)', () => {
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
const { html: html2 } = toHtml({ slides }, '', 'node-cs2');
const id1 = html1.match(/<section id="([^"]+)"/)![1];
const id2 = html2.match(/<section id="([^"]+)"/)![1];
expect(id1).not.toBe(id2);
});
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
const { html: html1 } = toHtml({ slides }, '');
const { html: html2 } = toHtml({ slides }, '');
expect(html1).toBe(html2);
});
});
describe('ContentSlider.toHtml autoplay silences aria-live and is pausable (F-export review Minor)', () => {
test('aria-live is "off" while autoplay is running, to avoid announcing every auto-rotation', () => {
const { html } = toHtml({ slides, autoplay: true }, '');
expect(html).toContain('aria-live="off"');
});
test('aria-live stays "polite" when autoplay is disabled', () => {
const { html } = toHtml({ slides, autoplay: false }, '');
expect(html).toContain('aria-live="polite"');
});
test('manual navigation (next/prev/dot) marks the live region "polite"', () => {
const { html } = toHtml({ slides, autoplay: true }, '');
expect(html).toMatch(/setAttribute\(["']aria-live["'],\s*["']polite["']\)/);
});
test('autoplay pauses on hover and resumes on mouse leave', () => {
const { html } = toHtml({ slides, autoplay: true }, '');
expect(html).toMatch(/addEventListener\(["']mouseenter["']/);
expect(html).toMatch(/addEventListener\(["']mouseleave["']/);
});
test('autoplay pauses when the tab is hidden (visibilitychange) and the interval is clearable', () => {
const { html } = toHtml({ slides, autoplay: true }, '');
expect(html).toMatch(/visibilitychange/);
expect(html).toMatch(/clearInterval\(/);
});
test('no autoplay: no setInterval/hover/visibility wiring at all', () => {
const { html } = toHtml({ slides, autoplay: false }, '');
expect(html).not.toMatch(/setInterval\(/);
expect(html).not.toMatch(/visibilitychange/);
});
});
+45 -220
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react'; import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
interface Slide { interface Slide {
type: 'image' | 'content'; type: 'image' | 'content';
@@ -215,208 +216,6 @@ export const ContentSlider: UserComponent<ContentSliderProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
ContentSlider.craft = { ContentSlider.craft = {
@@ -435,15 +234,11 @@ ContentSlider.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: ContentSliderSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => { (ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string, nodeId?: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
slides = defaultSlides, slides = defaultSlides,
autoplay = true, autoplay = true,
@@ -455,7 +250,11 @@ ContentSlider.craft = {
} = props; } = props;
const items = slides.length > 0 ? slides : defaultSlides; const items = slides.length > 0 ? slides : defaultSlides;
const uid = 'cs_' + Math.random().toString(36).slice(2, 8); // Deterministic AND unique id, scoped on the Craft node id, for this
// slider's slide/dot element ids and inline-script globals -- so two
// ContentSlider instances (e.g. both left at default slides) don't
// collide and end up driving each other's rotation.
const uid = scopeId(nodeId, JSON.stringify(items) + interval, 'cs');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
position: 'relative', position: 'relative',
@@ -468,20 +267,20 @@ ContentSlider.craft = {
const slidesHtml = items.map((slide, i) => { const slidesHtml = items.map((slide, i) => {
const hasBgImage = slide.imageSrc; const hasBgImage = slide.imageSrc;
const bgStyle = hasBgImage const bgStyle = hasBgImage
? `background-image:url(${esc(slide.imageSrc!)});background-size:cover;background-position:center` ? `background-image:url('${escapeAttr(safeUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
: slide.bgColor?.startsWith('linear-gradient') : slide.bgColor?.startsWith('linear-gradient')
? `background-image:${slide.bgColor}` ? `background-image:${slide.bgColor}`
: `background-color:${slide.bgColor || '#3b82f6'}`; : `background-color:${slide.bgColor || '#3b82f6'}`;
const contentParts: string[] = []; const contentParts: string[] = [];
if (slide.heading) { 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>`); 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)">${escapeHtml(slide.heading)}</h2>`);
} }
if (slide.text) { 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>`); 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)">${escapeHtml(slide.text)}</p>`);
} }
if (slide.buttonText) { 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>`); contentParts.push(`<a href="${escapeAttr(safeUrl(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">${escapeHtml(slide.buttonText)}</a>`);
} }
const innerHtml = contentParts.length > 0 const innerHtml = contentParts.length > 0
@@ -494,24 +293,37 @@ ContentSlider.craft = {
}).join('\n '); }).join('\n ');
const arrowsHtml = showArrows && items.length > 1 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}_prev()" aria-label="Previous slide" 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" aria-hidden="true"></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>` <button onclick="${uid}_next()" aria-label="Next slide" 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" aria-hidden="true"></i></button>`
: ''; : '';
const dotsHtml = showDots && items.length > 1 const dotsHtml = showDots && items.length > 1
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2"> ? `<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 ')} ${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" aria-label="Go to slide ${i + 1}" 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>` </div>`
: ''; : '';
// Autoplay ticks call show() directly (internal), while manual nav goes
// through the exposed window[...] functions -- that split lets us mark
// the live region "polite" only on manual navigation, and keep it "off"
// while autoplay is silently auto-rotating, so screen readers aren't
// spammed with an announcement every `interval` ms (F-export review
// Minor).
const autoplayActive = autoplay && items.length > 1;
const liveAttr = autoplayActive ? 'off' : 'polite';
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section id="${uid}"${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div id="${uid}_live" aria-live="${liveAttr}">
${slidesHtml} ${slidesHtml}
</div>
${arrowsHtml} ${arrowsHtml}
${dotsHtml} ${dotsHtml}
<script> <script>
(function(){ (function(){
var current=0, total=${items.length}, uid="${uid}"; var current=0, total=${items.length}, uid="${uid}";
var liveRegion=document.getElementById(uid+"_live");
function markManualNav(){ if(liveRegion){ liveRegion.setAttribute("aria-live","polite"); } }
function show(idx){ function show(idx){
document.getElementById(uid+"_s"+current).style.opacity="0"; document.getElementById(uid+"_s"+current).style.opacity="0";
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''} ${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
@@ -519,10 +331,23 @@ ContentSlider.craft = {
document.getElementById(uid+"_s"+current).style.opacity="1"; document.getElementById(uid+"_s"+current).style.opacity="1";
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''} ${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
} }
window["${uid}_go"]=show; window["${uid}_go"]=function(idx){ markManualNav(); show(idx); };
window["${uid}_next"]=function(){show(current+1);}; window["${uid}_next"]=function(){ markManualNav(); show(current+1); };
window["${uid}_prev"]=function(){show(current-1);}; window["${uid}_prev"]=function(){ markManualNav(); show(current-1); };
${autoplay && items.length > 1 ? `setInterval(function(){show(current+1);},${interval});` : ''} ${autoplayActive ? `
var timer=null;
function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${interval}); } }
function stop(){ if(timer){ clearInterval(timer); timer=null; } }
var root=document.getElementById(uid);
if(root){
root.addEventListener("mouseenter", stop);
root.addEventListener("mouseleave", start);
}
document.addEventListener("visibilitychange", function(){
if(document.visibilityState==="hidden"){ stop(); } else { start(); }
});
start();
` : ''}
})(); })();
</script> </script>
</section>`, </section>`,
@@ -0,0 +1,64 @@
import { describe, test, expect } from 'vitest';
import { Countdown } from './Countdown';
const toHtml = (Countdown as any).toHtml;
describe('Countdown.toHtml validates targetDate before inline-script injection (A4.2)', () => {
test('malicious targetDate cannot break out of the new Date(...) call', () => {
const { html } = toHtml({ targetDate: '2026-01-01");alert(1)//' }, '');
expect(html).not.toContain('alert(');
expect(html).not.toContain('");');
});
test('valid date is JSON-encoded into the script', () => {
const { html } = toHtml({ targetDate: '2026-01-01' }, '');
expect(html).toContain('new Date("2026-01-01")');
});
test('valid date+time is preserved', () => {
const { html } = toHtml({ targetDate: '2026-01-01T12:30:00' }, '');
expect(html).toContain('new Date("2026-01-01T12:30:00")');
});
test('invalid/empty targetDate falls back safely (no injected literal)', () => {
const { html } = toHtml({ targetDate: 'not-a-date' }, '');
expect(html).not.toContain('not-a-date');
expect(html).toMatch(/new Date\(\)\.getTime\(\)|new Date\(Date\.now\(\)\)\.getTime\(\)/);
});
});
describe('Countdown.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
const props = { targetDate: '2026-01-01' };
test('same node id -> identical output across calls (deterministic)', () => {
const { html: html1 } = toHtml(props, '', 'node-cd1');
const { html: html2 } = toHtml(props, '', 'node-cd1');
expect(html1).toBe(html2);
});
test('different node ids -> different, non-colliding element ids (identical props, no collision)', () => {
const { html: html1 } = toHtml(props, '', 'node-cd1');
const { html: html2 } = toHtml(props, '', 'node-cd2');
const id1 = html1.match(/id="([^"]+)_d"/)![1];
const id2 = html2.match(/id="([^"]+)_d"/)![1];
expect(id1).not.toBe(id2);
});
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
const { html: html1 } = toHtml(props, '');
const { html: html2 } = toHtml(props, '');
expect(html1).toBe(html2);
});
});
describe('Countdown.toHtml script nit: ticking interval stops at zero', () => {
test('inline script clears its own interval once the countdown reaches zero', () => {
const { html } = toHtml({ targetDate: '2026-01-01' }, '');
expect(html).toMatch(/clearInterval\(/);
});
test('an already-expired target never schedules a running interval', () => {
const { html } = toHtml({ targetDate: '2020-01-01' }, '');
expect(html).toMatch(/if\s*\(\s*target\s*-\s*Date\.now\(\)\s*>\s*0\s*\)\s*\{/);
});
});
+32 -114
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useEffect, useState, useCallback } from 'react'; import React, { CSSProperties, useEffect, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
interface CountdownProps { interface CountdownProps {
targetDate?: string; targetDate?: string;
@@ -9,6 +10,7 @@ interface CountdownProps {
digitColor?: string; digitColor?: string;
labelColor?: string; labelColor?: string;
bgColor?: string; bgColor?: string;
anchorId?: string;
} }
interface TimeLeft { interface TimeLeft {
@@ -44,6 +46,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
digitColor = '#ffffff', digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)', labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b', bgColor = '#18181b',
anchorId,
}) => { }) => {
const { const {
connectors: { connect, drag }, connectors: { connect, drag },
@@ -98,6 +101,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
padding: '60px 20px', padding: '60px 20px',
textAlign: 'center', textAlign: 'center',
@@ -123,107 +127,6 @@ export const Countdown: UserComponent<CountdownProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Countdown.craft = { Countdown.craft = {
@@ -235,21 +138,18 @@ Countdown.craft = {
digitColor: '#ffffff', digitColor: '#ffffff',
labelColor: 'rgba(255,255,255,0.7)', labelColor: 'rgba(255,255,255,0.7)',
bgColor: '#18181b', bgColor: '#18181b',
anchorId: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: CountdownSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => { (Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string, nodeId?: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
targetDate = DEFAULT_TARGET, targetDate = DEFAULT_TARGET,
heading = 'Coming Soon', heading = 'Coming Soon',
@@ -265,20 +165,32 @@ Countdown.craft = {
backgroundColor: bgColor, backgroundColor: bgColor,
...style, ...style,
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const headingHtml = heading const headingHtml = heading
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>` ? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${escapeHtml(heading)}</h2>`
: ''; : '';
const boxStyle = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:80px'; 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 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`; 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 // Deterministic AND unique id for this countdown instance's span ids and
const uid = 'cd_' + Math.random().toString(36).slice(2, 8); // getElementById() calls inside its inline script -- scoped on the Craft
// node id so two Countdown instances (e.g. both left at default props)
// don't collide and end up writing each other's digits.
const uid = scopeId(nodeId, targetDate + '::' + heading, 'cd');
// Only accept a strict date/datetime shape before it's embedded in the
// inline <script>; anything else falls back to "now" instead of letting
// arbitrary text (e.g. `");alert(1)//`) break out of the new Date(...) call.
const VALID_DATE_RE = /^\d{4}-\d{2}-\d{2}([T ][0-9:.\-+Z]*)?$/;
const dateExpr = typeof targetDate === 'string' && VALID_DATE_RE.test(targetDate)
? `new Date(${JSON.stringify(targetDate)})`
: 'new Date()';
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${headingHtml} ${headingHtml}
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap"> <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}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
@@ -288,11 +200,15 @@ Countdown.craft = {
</div> </div>
<script> <script>
(function(){ (function(){
var target = new Date("${targetDate}").getTime(); var target = ${dateExpr}.getTime();
var timer = null;
function pad(n){ return String(n).padStart(2,'0'); } function pad(n){ return String(n).padStart(2,'0'); }
function update(){ function update(){
var diff = target - Date.now(); var diff = target - Date.now();
if(diff<=0){ diff=0; } if(diff<=0){
diff=0;
if(timer){ clearInterval(timer); timer=null; }
}
var d = Math.floor(diff/(1000*60*60*24)); var d = Math.floor(diff/(1000*60*60*24));
var h = 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 m = Math.floor((diff/(1000*60))%60);
@@ -303,7 +219,9 @@ Countdown.craft = {
document.getElementById("${uid}_s").textContent = pad(s); document.getElementById("${uid}_s").textContent = pad(s);
} }
update(); update();
setInterval(update,1000); if(target - Date.now() > 0){
timer = setInterval(update,1000);
}
})(); })();
</script> </script>
</section>`, </section>`,
+44 -106
View File
@@ -1,27 +1,37 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface FeatureItem { interface FeatureItem {
title: string; title: string;
description: string; description: string;
icon: string; icon: string;
image?: string;
imageAlt?: string;
buttonText?: string;
buttonUrl?: string;
} }
interface FeaturesGridProps { interface FeaturesGridProps {
features?: FeatureItem[]; features?: FeatureItem[];
style?: CSSProperties; style?: CSSProperties;
anchorId?: string;
} }
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
// the guided panel's generic array editor (which derives fields from the first
// item's keys) exposes inputs for them. An image renders whenever `image` is set.
const defaultFeatures: FeatureItem[] = [ const defaultFeatures: FeatureItem[] = [
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡' }, { title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨' }, { title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱' }, { title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
]; ];
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
features = defaultFeatures, features = defaultFeatures,
style = {}, style = {},
anchorId,
}) => { }) => {
const { const {
connectors: { connect, drag }, connectors: { connect, drag },
@@ -33,6 +43,7 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
padding: '80px 20px', padding: '80px 20px',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
@@ -52,9 +63,26 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
border: '1px solid #e2e8f0', border: '1px solid #e2e8f0',
}} }}
> >
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div> {feat.image ? (
<img
src={feat.image}
alt={feat.imageAlt || feat.title || ''}
style={{ maxWidth: '100%', height: 'auto', marginBottom: '16px', borderRadius: '8px' }}
/>
) : (
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
)}
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3> <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> <p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
{feat.buttonText ? (
<a
href={feat.buttonUrl || '#'}
onClick={(e) => e.preventDefault()}
style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: '#3b82f6', color: '#fff', borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }}
>
{feat.buttonText}
</a>
) : null}
</div> </div>
))} ))}
</div> </div>
@@ -62,100 +90,6 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
FeaturesGrid.craft = { FeaturesGrid.craft = {
@@ -163,35 +97,39 @@ FeaturesGrid.craft = {
props: { props: {
features: defaultFeatures, features: defaultFeatures,
style: { backgroundColor: '#ffffff' }, style: { backgroundColor: '#ffffff' },
anchorId: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: FeaturesGridSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => { (FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '80px 20px', padding: '80px 20px',
...props.style, ...props.style,
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const cards = (props.features || defaultFeatures).map((feat) => { const cards = (props.features || defaultFeatures).map((feat) => {
const media = feat.image
? `<img src="${escapeAttr(safeUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
: `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
const button = feat.buttonText
? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>`
: '';
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0"> 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> ${media}
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3> <h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${escapeHtml(feat.title)}</h3>
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p> <p style="font-size:14px;color:#64748b;line-height:1.6">${escapeHtml(feat.description)}</p>${button}
</div>`; </div>`;
}).join('\n '); }).join('\n ');
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px"> <div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
${cards} ${cards}
</div> </div>
@@ -0,0 +1,95 @@
import { describe, test, expect } from 'vitest';
import { Gallery } from './Gallery';
const toHtml = (Gallery as any).toHtml;
describe('Gallery.toHtml lightbox uses a delegated listener, not per-item onclick (A4.3)', () => {
test('no per-item inline onclick with interpolated src', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
expect(html).not.toMatch(/onclick="[^"]*_open\(/);
expect(html).toContain('data-lb-src="/a.jpg"');
});
test('a single-quote in src cannot break the handler (no per-item onclick at all)', () => {
const { html } = toHtml({ images: [{ src: "/a'.jpg", alt: 'a' }], lightbox: true }, '');
// no per-item onclick handler exists at all (delegated listener only)
expect(html).not.toMatch(/onclick="[^"]*_open\(/);
// the quote in src is entity-escaped in the data attribute, not raw
expect(html).toContain('data-lb-src="/a&#39;.jpg"');
expect(html).not.toContain(`data-lb-src="/a'.jpg"`);
});
test('emits exactly one delegated click listener', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg' }, { src: '/b.jpg' }], lightbox: true }, '');
const matches = html.match(/addEventListener\(['"]click['"]/g) || [];
expect(matches.length).toBe(1);
});
test('lightbox=false: no data-lb-src, no script', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg' }], lightbox: false }, '');
expect(html).not.toContain('data-lb-src');
expect(html).not.toContain('<script>');
});
});
describe('Gallery.toHtml lightbox accessibility (F1.3)', () => {
test('lightbox overlay has role="dialog", aria-modal, and aria-label', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
expect(html).toMatch(/role="dialog"/);
expect(html).toMatch(/aria-modal="true"/);
expect(html).toMatch(/aria-label="[^"]+"/);
});
test('Escape closes the lightbox via the inline script', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
expect(html).toMatch(/Escape/);
});
test('thumbnails are keyboard-operable when lightbox is enabled (role=button + tabindex=0)', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
expect(html).toMatch(/data-lb-src="[^"]*"[^>]*role="button"[^>]*tabindex="0"/);
});
test('delegated listener handles Enter/Space for keyboard activation', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg' }, { src: '/b.jpg' }], lightbox: true }, '');
expect(html).toMatch(/addEventListener\(['"]keydown['"]/);
});
test('lightbox=false: no role="dialog", no role="button" thumbnails', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg' }], lightbox: false }, '');
expect(html).not.toContain('role="dialog"');
expect(html).not.toContain('role="button"');
});
});
describe('Gallery.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
const props = { images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true };
test('same node id -> identical output across calls (deterministic)', () => {
const { html: html1 } = toHtml(props, '', 'node-gal1');
const { html: html2 } = toHtml(props, '', 'node-gal1');
expect(html1).toBe(html2);
});
test('different node ids -> different, non-colliding gallery scope ids (identical images, no collision)', () => {
const { html: html1 } = toHtml(props, '', 'node-gal1');
const { html: html2 } = toHtml(props, '', 'node-gal2');
const id1 = html1.match(/id="([^"]+)_overlay"/)![1];
const id2 = html2.match(/id="([^"]+)_overlay"/)![1];
expect(id1).not.toBe(id2);
});
test('overlay/grid ids and the script function names all use the SAME scope', () => {
const { html } = toHtml(props, '', 'node-gal1');
const scope = html.match(/id="([^"]+)_overlay"/)![1];
expect(html).toContain(`id="${scope}_grid"`);
expect(html).toContain(`function ${scope}_close()`);
expect(html).toContain(`function ${scope}_open(`);
});
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
const { html: html1 } = toHtml(props, '');
const { html: html2 } = toHtml(props, '');
expect(html1).toBe(html2);
});
});
+40 -163
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
interface GalleryImage { interface GalleryImage {
src: string; src: string;
@@ -103,156 +104,6 @@ export const Gallery: UserComponent<GalleryProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Gallery.craft = { Gallery.craft = {
@@ -269,15 +120,11 @@ Gallery.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: GallerySettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => { (Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string, nodeId?: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '60px 20px', padding: '60px 20px',
...props.style, ...props.style,
@@ -287,34 +134,64 @@ Gallery.craft = {
const gap = props.gap || '16px'; const gap = props.gap || '16px';
const lightbox = props.lightbox || false; const lightbox = props.lightbox || false;
const galleryId = 'gallery_' + Math.random().toString(36).slice(2, 8); // Deterministic AND unique id, scoped on the Craft node id, for this
// gallery's overlay/grid element ids and inline-script function names --
// so two Gallery instances (e.g. both left at default images) don't
// collide and end up sharing/clobbering one lightbox overlay.
const galleryId = scopeId(nodeId, JSON.stringify(images) + columns + gap, 'gallery');
const items = images.map((img) => { const items = images.map((img) => {
const caption = img.caption 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>` ? `<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">${escapeHtml(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"'; // Lightbox items carry the image URL as a data attribute rather than an
return `<div${clickAttr}> // inline onclick with an interpolated src -- a single delegated click
<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" /> // listener below reads it, so a src containing a quote can't break out
// of a per-item event-handler string.
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeUrl(img.src || ''))}" role="button" tabindex="0"` : '';
const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px';
return `<div${lbAttr} style="${itemStyle}">
<img src="${escapeAttr(safeUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
${caption} ${caption}
</div>`; </div>`;
}).join('\n '); }).join('\n ');
let lightboxHtml = ''; let lightboxHtml = '';
let gridIdAttr = '';
if (lightbox) { if (lightbox) {
gridIdAttr = ` id="${galleryId}_grid"`;
lightboxHtml = ` 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"> <div id="${galleryId}_overlay" role="dialog" aria-modal="true" aria-label="Image preview" 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" /> <img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
</div> </div>
<script> <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';} function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';}
function ${galleryId}_open(src){
var o = document.getElementById('${galleryId}_overlay');
document.getElementById('${galleryId}_img').src = src;
o.style.display = 'flex';
}
document.getElementById('${galleryId}_grid').addEventListener('click', function(e){
var t = e.target.closest('[data-lb-src]');
if(!t) return;
${galleryId}_open(t.getAttribute('data-lb-src'));
});
document.getElementById('${galleryId}_grid').addEventListener('keydown', function(e){
if(e.key!=='Enter' && e.key!==' ') return;
var t = e.target.closest('[data-lb-src]');
if(!t) return;
e.preventDefault();
${galleryId}_open(t.getAttribute('data-lb-src'));
});
document.addEventListener('keydown', function(e){
if(e.key==='Escape'){ ${galleryId}_close(); }
});
</script>`; </script>`;
} }
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:${gap}"> <div${gridIdAttr} style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:${gap}">
${items} ${items}
</div>${lightboxHtml} </div>${lightboxHtml}
</section>`, </section>`,
+40 -245
View File
@@ -1,10 +1,15 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface HeroProps { interface HeroProps {
heading?: string; heading?: string;
subtitle?: string; subtitle?: string;
/** New dynamic CTAs. When set (length > 0), legacy primary/secondary fields are ignored. */
ctas?: CtaButton[];
/** Legacy — kept for backwards compatibility with saved projects. */
buttonText?: string; buttonText?: string;
buttonHref?: string; buttonHref?: string;
secondaryButtonText?: string; secondaryButtonText?: string;
@@ -24,6 +29,7 @@ interface HeroProps {
minHeight?: string; minHeight?: string;
verticalAlign?: 'top' | 'center' | 'bottom'; verticalAlign?: 'top' | 'center' | 'bottom';
textAlign?: 'left' | 'center' | 'right'; textAlign?: 'left' | 'center' | 'right';
anchorId?: string;
style?: CSSProperties; style?: CSSProperties;
} }
@@ -43,10 +49,11 @@ function buildBackground(props: HeroProps): string {
export const HeroSimple: UserComponent<HeroProps> = ({ export const HeroSimple: UserComponent<HeroProps> = ({
heading = 'Build Something Amazing', heading = 'Build Something Amazing',
subtitle = 'Create beautiful websites without writing a single line of code.', subtitle = 'Create beautiful websites without writing a single line of code.',
buttonText = 'Get Started', ctas,
buttonHref = '#', buttonText,
secondaryButtonText = '', buttonHref,
secondaryButtonHref = '#', secondaryButtonText,
secondaryButtonHref,
bgType = 'color', bgType = 'color',
bgColor = '#1e293b', bgColor = '#1e293b',
bgGradientFrom = '#667eea', bgGradientFrom = '#667eea',
@@ -62,6 +69,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
minHeight = '500px', minHeight = '500px',
verticalAlign = 'center', verticalAlign = 'center',
textAlign = 'center', textAlign = 'center',
anchorId,
style = {}, style = {},
}) => { }) => {
const { connectors: { connect, drag } } = useNode(); const { connectors: { connect, drag } } = useNode();
@@ -72,9 +80,17 @@ export const HeroSimple: UserComponent<HeroProps> = ({
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' }; const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
const ctaDefaults = {
primaryBg: buttonBgColor,
primaryText: buttonTextColor,
outlineText: textColor,
};
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
...style, ...style,
background: bgType !== 'image' ? bg : undefined, background: bgType !== 'image' ? bg : undefined,
@@ -134,235 +150,18 @@ export const HeroSimple: UserComponent<HeroProps> = ({
{subtitle} {subtitle}
</p> </p>
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
{buttonText && ( {effectiveCtas.map((cta, i) => (
<a href={buttonHref} onClick={(e) => e.preventDefault()} style={{ <a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
display: 'inline-block', padding: '14px 36px', backgroundColor: buttonBgColor, style={ctaInlineStyle(cta, ctaDefaults)}>
color: buttonTextColor, textDecoration: 'none', borderRadius: '8px', {cta.text}
fontWeight: '600', fontSize: '16px',
}}>
{buttonText}
</a> </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>
</div> </div>
</section> </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 ---------- */ /* ---------- Craft config ---------- */
HeroSimple.craft = { HeroSimple.craft = {
@@ -370,10 +169,9 @@ HeroSimple.craft = {
props: { props: {
heading: 'Build Something Amazing', heading: 'Build Something Amazing',
subtitle: 'Create beautiful websites without writing a single line of code.', subtitle: 'Create beautiful websites without writing a single line of code.',
buttonText: 'Get Started', ctas: [
buttonHref: '#', { text: 'Get Started', href: '#', variant: 'primary' },
secondaryButtonText: '', ] as CtaButton[],
secondaryButtonHref: '#',
bgType: 'color', bgType: 'color',
bgColor: '#1e293b', bgColor: '#1e293b',
bgGradientFrom: '#667eea', bgGradientFrom: '#667eea',
@@ -389,6 +187,7 @@ HeroSimple.craft = {
minHeight: '500px', minHeight: '500px',
verticalAlign: 'center', verticalAlign: 'center',
textAlign: 'center', textAlign: 'center',
anchorId: '',
style: {}, style: {},
}, },
rules: { rules: {
@@ -396,15 +195,11 @@ HeroSimple.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: HeroSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => { (HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bg = buildBackground(props); const bg = buildBackground(props);
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' }; const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
@@ -430,26 +225,26 @@ HeroSimple.craft = {
let videoHtml = ''; let videoHtml = '';
if (props.bgType === 'video' && props.bgVideo) { 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>`; videoHtml = `<video src="${escapeAttr(safeUrl(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 textAlign = props.textAlign || 'center';
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start'; const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
let buttonsHtml = ''; const ctas = normalizeCtas(props);
if (props.buttonText) { const buttonsHtml = ctasToHtml(ctas, {
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>`; primaryBg: props.buttonBgColor || '#3b82f6',
} primaryText: props.buttonTextColor || '#fff',
if (props.secondaryButtonText) { outlineText: props.textColor || '#fff',
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>`; });
}
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
return { return {
html: `<section style="${sectionStyle}"> html: `<section${idAttr} style="${sectionStyle}">
${videoHtml}${overlayHtml} ${videoHtml}${overlayHtml}
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}"> <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> <h1 style="font-size:48px;font-weight:700;color:${props.textColor || '#fff'};margin-bottom:16px;line-height:1.2">${escapeHtml(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> <p style="font-size:20px;color:${props.textColor || '#fff'};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${escapeHtml(props.subtitle || '')}</p>
<div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div> <div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
</div> </div>
</section>`, </section>`,
@@ -0,0 +1,49 @@
import { describe, test, expect } from 'vitest';
import { NumberCounter } from './NumberCounter';
const toHtml = (NumberCounter as any).toHtml;
const counters = [
{ number: 150, suffix: '+', label: 'Projects' },
{ number: 50, suffix: '+', label: 'Clients' },
];
describe('NumberCounter.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
test('same node id -> identical output across calls (deterministic)', () => {
const { html: html1 } = toHtml({ counters }, '', 'node-nc1');
const { html: html2 } = toHtml({ counters }, '', 'node-nc1');
expect(html1).toBe(html2);
});
test('different node ids -> distinct, non-colliding nc_ scopes (identical props, no collision)', () => {
const { html: html1 } = toHtml({ counters }, '', 'node-nc1');
const { html: html2 } = toHtml({ counters }, '', 'node-nc2');
const wrapId1 = html1.match(/<div id="(nc_[^"]+)"/)![1];
const wrapId2 = html2.match(/<div id="(nc_[^"]+)"/)![1];
expect(wrapId1).not.toBe(wrapId2);
});
test('wrapper id, per-counter ids, and inline script agree on the same uid', () => {
const { html } = toHtml({ counters }, '', 'node-nc1');
const wrapId = html.match(/<div id="(nc_[^"]+)"/)![1];
expect(html).toContain(`id="${wrapId}_n0"`);
expect(html).toContain(`id="${wrapId}_n1"`);
expect(html).toContain(`var uid="${wrapId}"`);
expect(html).toContain('document.getElementById(uid)');
expect(html).toContain('document.getElementById(uid+"_n"+i)');
});
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
const { html: html1 } = toHtml({ counters }, '');
const { html: html2 } = toHtml({ counters }, '');
expect(html1).toBe(html2);
});
test('two different node ids never collide even with default (no counters override) props', () => {
const { html: html1 } = toHtml({}, '', 'node-a');
const { html: html2 } = toHtml({}, '', 'node-b');
const wrapId1 = html1.match(/<div id="(nc_[^"]+)"/)![1];
const wrapId2 = html2.match(/<div id="(nc_[^"]+)"/)![1];
expect(wrapId1).not.toBe(wrapId2);
});
});
+11 -201
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
interface Counter { interface Counter {
number: number; number: number;
@@ -87,199 +88,6 @@ export const NumberCounter: UserComponent<NumberCounterProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
NumberCounter.craft = { NumberCounter.craft = {
@@ -297,15 +105,11 @@ NumberCounter.craft = {
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: NumberCounterSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => { (NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
counters = defaultCounters, counters = defaultCounters,
columns = 4, columns = 4,
@@ -316,7 +120,13 @@ NumberCounter.craft = {
} = props; } = props;
const items = counters.length > 0 ? counters : defaultCounters; const items = counters.length > 0 ? counters : defaultCounters;
const uid = 'nc_' + Math.random().toString(36).slice(2, 8);
// Deterministic AND unique id for this counter instance's wrapper/span
// ids and getElementById() calls inside its inline script -- scoped on
// the Craft node id so two NumberCounter instances (e.g. both left at
// default props) don't collide and end up animating each other's digits.
const seed = items.map((c) => `${c.number}${c.suffix}::${c.label}`).join('|');
const uid = scopeId(nodeId, seed, 'nc');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '60px 20px', padding: '60px 20px',
@@ -326,8 +136,8 @@ NumberCounter.craft = {
const countersHtml = items.map((counter, i) => { const countersHtml = items.map((counter, i) => {
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px"> 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 id="${uid}_n${i}" data-target="${counter.number}" data-suffix="${escapeAttr(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${escapeHtml(counter.suffix)}</span>
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${esc(counter.label)}</span> <span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${escapeHtml(counter.label)}</span>
</div>`; </div>`;
}).join('\n '); }).join('\n ');
+12 -192
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface PricingPlan { interface PricingPlan {
name: string; name: string;
@@ -17,6 +18,7 @@ interface PricingTableProps {
style?: CSSProperties; style?: CSSProperties;
featuredBg?: string; featuredBg?: string;
bulletType?: string; bulletType?: string;
anchorId?: string;
} }
const bulletChars: Record<string, string> = { const bulletChars: Record<string, string> = {
@@ -58,6 +60,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
style = {}, style = {},
featuredBg = '#3b82f6', featuredBg = '#3b82f6',
bulletType = 'check', bulletType = 'check',
anchorId,
}) => { }) => {
const { const {
connectors: { connect, drag }, connectors: { connect, drag },
@@ -69,6 +72,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
padding: '80px 20px', padding: '80px 20px',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
@@ -193,188 +197,6 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
PricingTable.craft = { PricingTable.craft = {
@@ -384,26 +206,24 @@ PricingTable.craft = {
style: { backgroundColor: '#ffffff' }, style: { backgroundColor: '#ffffff' },
featuredBg: '#3b82f6', featuredBg: '#3b82f6',
bulletType: 'check', bulletType: 'check',
anchorId: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: PricingTableSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => { (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 bulletType = props.bulletType || 'check';
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '80px 20px', padding: '80px 20px',
...props.style, ...props.style,
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const plans = props.plans || defaultPlans; const plans = props.plans || defaultPlans;
const featuredBg = props.featuredBg || '#3b82f6'; const featuredBg = props.featuredBg || '#3b82f6';
@@ -420,7 +240,7 @@ PricingTable.craft = {
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 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) => 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>` `<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${escapeHtml(f)}</li>`
).join('\n '); ).join('\n ');
const badge = plan.isFeatured const badge = plan.isFeatured
@@ -429,20 +249,20 @@ PricingTable.craft = {
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}"> 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} ${badge}
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${esc(plan.name)}</h3> <h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${escapeHtml(plan.name)}</h3>
<div style="margin-bottom:24px"> <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:48px;font-weight:700;color:${textColor};line-height:1">${escapeHtml(plan.price)}</span>
<span style="font-size:16px;color:${subColor}">${esc(plan.period)}</span> <span style="font-size:16px;color:${subColor}">${escapeHtml(plan.period)}</span>
</div> </div>
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px"> <ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
${featuresHtml} ${featuresHtml}
</ul> </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> <a href="${escapeAttr(safeUrl(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">${escapeHtml(plan.buttonText)}</a>
</div>`; </div>`;
}).join('\n '); }).join('\n ');
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap"> <div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
${cards} ${cards}
</div> </div>
@@ -0,0 +1,82 @@
import { describe, test, expect } from 'vitest';
import { Tabs } from './Tabs';
const toHtml = (Tabs as any).toHtml;
const tabs = [
{ label: 'Overview', content: 'Overview content' },
{ label: 'Features', content: 'Features content' },
{ label: 'Support', content: 'Support content' },
];
describe('Tabs.toHtml accessibility (F1.2)', () => {
test('tab button container has role="tablist"', () => {
const { html } = toHtml({ tabs }, '');
expect(html).toMatch(/role="tablist"/);
});
test('each tab button has role="tab", aria-selected, aria-controls', () => {
const { html } = toHtml({ tabs }, '');
const buttonMatches = html.match(/<button[^>]*role="tab"[^>]*>/g) || [];
expect(buttonMatches.length).toBe(3);
expect(html).toMatch(/aria-selected="true"/);
expect(html).toMatch(/aria-selected="false"/);
expect(html).toMatch(/role="tab"[^>]*aria-controls="[^"]+"/);
});
test('each panel has role="tabpanel" and aria-labelledby matching a tab id', () => {
const { html } = toHtml({ tabs }, '');
const panelMatches = html.match(/role="tabpanel"/g) || [];
expect(panelMatches.length).toBe(3);
// aria-controls on the first tab button should point at an id that
// actually exists as a panel's id.
const controlsMatch = html.match(/role="tab"[^>]*aria-controls="([^"]+)"/);
expect(controlsMatch).toBeTruthy();
const controlledId = controlsMatch![1];
expect(html).toContain(`id="${controlledId}"`);
});
test('ids linking tab<->panel are deterministic (stable across repeated calls, no randomness)', () => {
const { html: html1 } = toHtml({ tabs }, '');
const { html: html2 } = toHtml({ tabs }, '');
const id1 = html1.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
const id2 = html2.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
expect(id1).toBe(id2);
});
test('arrow-key navigation is wired in the inline script', () => {
const { html } = toHtml({ tabs }, '');
expect(html).toMatch(/ArrowRight/);
expect(html).toMatch(/ArrowLeft/);
});
});
describe('Tabs.toHtml deterministic + unique ids (thread node id, resolves id-collision finding)', () => {
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
const { html: html1 } = toHtml({ tabs }, '', 'node-tabs1');
const { html: html2 } = toHtml({ tabs }, '', 'node-tabs1');
expect(html1).toBe(html2);
});
test('two instances with IDENTICAL default tab content but different node ids do not collide', () => {
const { html: html1 } = toHtml({ tabs }, '', 'node-tabs1');
const { html: html2 } = toHtml({ tabs }, '', 'node-tabs2');
const id1 = html1.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
const id2 = html2.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
expect(id1).not.toBe(id2);
});
test('aria-controls still matches an existing panel id after the node-id change (internal consistency preserved)', () => {
const { html } = toHtml({ tabs }, '', 'node-tabs1');
const controlsMatch = html.match(/role="tab"[^>]*aria-controls="([^"]+)"/);
expect(controlsMatch).toBeTruthy();
expect(html).toContain(`id="${controlsMatch![1]}"`);
});
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
const { html: html1 } = toHtml({ tabs }, '');
const { html: html2 } = toHtml({ tabs }, '');
expect(html1).toBe(html2);
});
});
+45 -177
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useState } from 'react'; import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
interface TabItem { interface TabItem {
label: string; label: string;
@@ -15,6 +16,7 @@ interface TabsProps {
inactiveTabBg?: string; inactiveTabBg?: string;
inactiveTabColor?: string; inactiveTabColor?: string;
contentBg?: string; contentBg?: string;
anchorId?: string;
} }
const defaultTabs: TabItem[] = [ const defaultTabs: TabItem[] = [
@@ -31,6 +33,7 @@ export const Tabs: UserComponent<TabsProps> = ({
inactiveTabBg = '#f1f5f9', inactiveTabBg = '#f1f5f9',
inactiveTabColor = '#64748b', inactiveTabColor = '#64748b',
contentBg = '#ffffff', contentBg = '#ffffff',
anchorId,
}) => { }) => {
const { const {
connectors: { connect, drag }, connectors: { connect, drag },
@@ -44,6 +47,7 @@ export const Tabs: UserComponent<TabsProps> = ({
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
padding: '60px 20px', padding: '60px 20px',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
@@ -97,173 +101,6 @@ export const Tabs: UserComponent<TabsProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Tabs.craft = { Tabs.craft = {
@@ -276,25 +113,23 @@ Tabs.craft = {
inactiveTabBg: '#f1f5f9', inactiveTabBg: '#f1f5f9',
inactiveTabColor: '#64748b', inactiveTabColor: '#64748b',
contentBg: '#ffffff', contentBg: '#ffffff',
anchorId: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: TabsSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => { (Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string, nodeId?: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
padding: '60px 20px', padding: '60px 20px',
...props.style, ...props.style,
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const tabs = props.tabs || defaultTabs; const tabs = props.tabs || defaultTabs;
const activeTabBg = props.activeTabBg || '#3b82f6'; const activeTabBg = props.activeTabBg || '#3b82f6';
const activeTabColor = props.activeTabColor || '#ffffff'; const activeTabColor = props.activeTabColor || '#ffffff';
@@ -302,15 +137,26 @@ Tabs.craft = {
const inactiveTabColor = props.inactiveTabColor || '#64748b'; const inactiveTabColor = props.inactiveTabColor || '#64748b';
const contentBg = props.contentBg || '#ffffff'; const contentBg = props.contentBg || '#ffffff';
const tabId = 'tabs_' + Math.random().toString(36).slice(2, 8); // tabId scopes the functional wiring (onclick/getElementById) as well as
// the ARIA tab<->panel linking ids. It must be BOTH deterministic (so
// aria-controls/aria-labelledby reference the SAME id across repeated
// exports of the same page) AND unique (so two Tabs instances with
// identical/default content -- e.g. both left at the default tab set --
// don't collide and clobber each other's script globals / ARIA links).
// Scoping on the Craft node id gives both properties; when it's
// unavailable (legacy 2-arg call sites) we fall back to a stable hash of
// the tab content, matching the old (collision-prone but never random)
// behavior.
const scopeSeed = props.anchorId || tabs.map((t) => t.label).join('|') + '::' + tabs.length;
const tabId = scopeId(nodeId, scopeSeed, 'tabs');
const tabButtons = tabs.map((tab, i) => { const tabButtons = tabs.map((tab, i) => {
const isActive = i === 0; 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>`; return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-controls="${tabId}_panel_${i}" tabindex="${isActive ? '0' : '-1'}" 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}">${escapeHtml(tab.label)}</button>`;
}).join('\n '); }).join('\n ');
const tabPanels = tabs.map((tab, i) => { 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>`; return `<div id="${tabId}_panel_${i}" role="tabpanel" aria-labelledby="${tabId}_btn_${i}" tabindex="0" 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' : ''}">${escapeHtml(tab.content)}</div>`;
}).join('\n '); }).join('\n ');
const switchScript = `<script> const switchScript = `<script>
@@ -321,14 +167,36 @@ function ${tabId}_switch(idx){
var btn=document.getElementById('${tabId}_btn_'+i); var btn=document.getElementById('${tabId}_btn_'+i);
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}'; btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}'; btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
btn.setAttribute('aria-selected', i===idx ? 'true' : 'false');
btn.setAttribute('tabindex', i===idx ? '0' : '-1');
} }
} }
(function(){
var total=${tabs.length};
for(var i=0;i<total;i++){
(function(idx){
var btn=document.getElementById('${tabId}_btn_'+idx);
btn.addEventListener('keydown', function(e){
var next=null;
if(e.key==='ArrowRight'){ next=(idx+1)%total; }
else if(e.key==='ArrowLeft'){ next=(idx-1+total)%total; }
else if(e.key==='Home'){ next=0; }
else if(e.key==='End'){ next=total-1; }
if(next!==null){
e.preventDefault();
${tabId}_switch(next);
document.getElementById('${tabId}_btn_'+next).focus();
}
});
})(i);
}
})();
</script>`; </script>`;
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto"> <div style="max-width:800px;margin:0 auto">
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0"> <div role="tablist" style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
${tabButtons} ${tabButtons}
</div> </div>
${tabPanels} ${tabPanels}
@@ -0,0 +1,48 @@
import { describe, test, expect } from 'vitest';
import { Testimonials } from './Testimonials';
const toHtml = (Testimonials as any).toHtml;
const testimonials = [
{ quote: 'Quote one', name: 'Name One', title: 'Title One', rating: 5 },
{ quote: 'Quote two', name: 'Name Two', title: 'Title Two', rating: 4 },
{ quote: 'Quote three', name: 'Name Three', title: 'Title Three', rating: 3 },
];
describe('Testimonials.toHtml single-layout export parity', () => {
// The editor's "single" layout shows exactly one testimonial (a single
// card, no stacked list). Static-parity fix: toHtml exports exactly one
// card too (the first testimonial), matching what the editor displays by
// default -- not a stacked list of all testimonials, and not a JS carousel
// (this codebase's static export has no published-JS interactivity for
// this component).
test('single layout: exports exactly one testimonial card, not all of them', () => {
const { html } = toHtml({ testimonials, layout: 'single' }, '');
expect(html).toContain('Name One');
expect(html).not.toContain('Name Two');
expect(html).not.toContain('Name Three');
expect(html).toContain('Quote one');
});
test('single layout: no carousel controls (prev/next/dots) in static export', () => {
const { html } = toHtml({ testimonials, layout: 'single' }, '');
expect(html).not.toContain('fa-chevron-left');
expect(html).not.toContain('fa-chevron-right');
});
test('grid layout: still exports all testimonials (unchanged behavior)', () => {
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
expect(html).toContain('Name One');
expect(html).toContain('Name Two');
expect(html).toContain('Name Three');
});
});
describe('Testimonials.toHtml decorative star icons (F2.5)', () => {
test('star glyphs are aria-hidden', () => {
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
const stars = html.match(/<i class="fa fa-star[^"]*"[^>]*>/g) || [];
expect(stars.length).toBeGreaterThan(0);
stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
});
+26 -259
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useState } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
interface Testimonial { interface Testimonial {
quote: string; quote: string;
@@ -16,6 +17,7 @@ interface TestimonialsProps {
style?: CSSProperties; style?: CSSProperties;
cardBg?: string; cardBg?: string;
starColor?: string; starColor?: string;
anchorId?: string;
} }
const defaultTestimonials: Testimonial[] = [ const defaultTestimonials: Testimonial[] = [
@@ -40,9 +42,9 @@ function renderStars(count: number, color: string): React.ReactNode {
function starsHtml(count: number, color: string): string { function starsHtml(count: number, color: string): string {
const stars = [1, 2, 3, 4, 5].map((i) => 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>` `<i class="fa ${i <= count ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px" aria-hidden="true"></i>`
).join(''); ).join('');
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px">${stars}</div>`; return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px" role="img" aria-label="Rating: ${count} out of 5">${stars}</div>`;
} }
export const Testimonials: UserComponent<TestimonialsProps> = ({ export const Testimonials: UserComponent<TestimonialsProps> = ({
@@ -52,6 +54,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
style = {}, style = {},
cardBg = '#f8fafc', cardBg = '#f8fafc',
starColor = '#f59e0b', starColor = '#f59e0b',
anchorId,
}) => { }) => {
const { const {
connectors: { connect, drag }, connectors: { connect, drag },
@@ -60,8 +63,6 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
selected: node.events.selected, selected: node.events.selected,
})); }));
const [currentIndex, setCurrentIndex] = useState(0);
const cardStyle: CSSProperties = { const cardStyle: CSSProperties = {
backgroundColor: cardBg, backgroundColor: cardBg,
borderRadius: '12px', borderRadius: '12px',
@@ -86,6 +87,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{ style={{
padding: '80px 20px', padding: '80px 20px',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
@@ -99,44 +101,11 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
{items.map((t, i) => renderCard(t, i))} {items.map((t, i) => renderCard(t, i))}
</div> </div>
) : ( ) : (
// Static single testimonial (parity with the static toHtml export --
// no carousel controls, since the published site has no JS for this
// component). Always shows the first testimonial.
<div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}> <div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}>
{renderCard(items[currentIndex] || items[0], currentIndex)} {renderCard(items[0], 0)}
{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>
)} )}
</div> </div>
@@ -144,208 +113,6 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
); );
}; };
/* ---------- 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 ---------- */ /* ---------- Craft config ---------- */
Testimonials.craft = { Testimonials.craft = {
@@ -357,21 +124,18 @@ Testimonials.craft = {
style: { backgroundColor: '#ffffff' }, style: { backgroundColor: '#ffffff' },
cardBg: '#f8fafc', cardBg: '#f8fafc',
starColor: '#f59e0b', starColor: '#f59e0b',
anchorId: '',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
canMoveIn: () => false, canMoveIn: () => false,
canMoveOut: () => true, canMoveOut: () => true,
}, },
related: {
settings: TestimonialsSettings,
},
}; };
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => { (Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const { const {
testimonials = defaultTestimonials, testimonials = defaultTestimonials,
layout = 'grid', layout = 'grid',
@@ -388,31 +152,34 @@ Testimonials.craft = {
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
...style, ...style,
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`; const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
const cards = items.map((t) => { const cardHtml = (t: Testimonial): string => `<div style="${cardCss}">
return `<div style="${cardCss}">
${starsHtml(t.rating, starColor)} ${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> <p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">&ldquo;${escapeHtml(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-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif">${escapeHtml(t.name)}</div>
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${esc(t.title)}</div> <div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${escapeHtml(t.title)}</div>
</div>`; </div>`;
}).join('\n ');
if (layout === 'single') { if (layout === 'single') {
// For single layout, export as grid with 1 column (simpler static export) // Static parity with the editor's single-layout render: exactly ONE
// testimonial card (the first), no carousel controls -- the published
// export has no JS for this component.
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px"> <div style="max-width:600px;margin:0 auto">
${cards} ${cardHtml(items[0])}
</div> </div>
</section>`, </section>`,
}; };
} }
const cards = items.map(cardHtml).join('\n ');
return { return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}> html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px"> <div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
${cards} ${cards}
</div> </div>
@@ -0,0 +1,88 @@
import { CSSProperties } from 'react';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
export type CtaVariant = 'primary' | 'outline' | 'ghost';
export interface CtaButton {
text: string;
href: string;
variant?: CtaVariant;
target?: '_blank';
}
export interface CtaStyleDefaults {
primaryBg: string;
primaryText: string;
outlineText: string;
}
/**
* Read the effective list of CTAs for a section, falling back to legacy
* primary/secondary props when ctas[] is absent. New sections write ctas[]
* directly; old sections keep rendering until the user touches the settings.
*/
export function normalizeCtas(props: {
ctas?: CtaButton[];
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
secondaryButtonHref?: string;
}): CtaButton[] {
if (Array.isArray(props.ctas) && props.ctas.length > 0) {
return props.ctas.filter((c) => c && (c.text || c.href));
}
const legacy: CtaButton[] = [];
if (props.buttonText) legacy.push({ text: props.buttonText, href: props.buttonHref || '#', variant: 'primary' });
if (props.secondaryButtonText) legacy.push({ text: props.secondaryButtonText, href: props.secondaryButtonHref || '#', variant: 'outline' });
return legacy;
}
export function ctaInlineStyle(cta: CtaButton, defaults: CtaStyleDefaults): CSSProperties {
const variant = cta.variant || 'primary';
switch (variant) {
case 'outline':
return {
display: 'inline-block', padding: '14px 36px',
backgroundColor: 'transparent', color: defaults.outlineText,
textDecoration: 'none', borderRadius: '8px',
fontWeight: 600, fontSize: '16px',
border: `2px solid ${defaults.outlineText}`,
};
case 'ghost':
return {
display: 'inline-block', padding: '14px 24px',
backgroundColor: 'transparent', color: defaults.outlineText,
textDecoration: 'underline', borderRadius: '8px',
fontWeight: 600, fontSize: '16px',
};
case 'primary':
default:
return {
display: 'inline-block', padding: '14px 36px',
backgroundColor: defaults.primaryBg, color: defaults.primaryText,
textDecoration: 'none', borderRadius: '8px',
fontWeight: 600, fontSize: '16px',
};
}
}
export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string {
const variant = cta.variant || 'primary';
switch (variant) {
case 'outline':
return `display:inline-block;padding:14px 36px;background-color:transparent;color:${defaults.outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${defaults.outlineText}`;
case 'ghost':
return `display:inline-block;padding:14px 24px;background-color:transparent;color:${defaults.outlineText};text-decoration:underline;border-radius:8px;font-weight:600;font-size:16px`;
case 'primary':
default:
return `display:inline-block;padding:14px 36px;background-color:${defaults.primaryBg};color:${defaults.primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`;
}
}
export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string {
return ctas.map((c) => {
const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return `<a href="${escapeAttr(safeUrl(c.href || '#'))}"${target} style="${ctaCssString(c, defaults)}">${escapeHtml(c.text || '')}</a>`;
}).join('');
}
@@ -0,0 +1,159 @@
import { describe, test, expect } from 'vitest';
import { ButtonLink } from './basic/ButtonLink';
import { Icon } from './basic/Icon';
import { SocialLinks } from './basic/SocialLinks';
import { Logo } from './basic/Logo';
import { Menu } from './basic/Menu';
import { Navbar } from './basic/Navbar';
import { ContentSlider } from './sections/ContentSlider';
import { FeaturesGrid } from './sections/FeaturesGrid';
import { PricingTable } from './sections/PricingTable';
import { ImageBlock } from './media/ImageBlock';
import { VideoBlock } from './media/VideoBlock';
import { Gallery } from './sections/Gallery';
import { BackgroundSection } from './layout/BackgroundSection';
import { HeroSimple } from './sections/HeroSimple';
import { CallToAction } from './sections/CallToAction';
import { MapEmbed } from './media/MapEmbed';
import { FormContainer } from './forms/FormContainer';
const XSS = 'javascript:alert(1)';
const QUOTE_BREAKOUT = 'x" onerror="alert(1)"';
function toHtmlOf(Component: any) {
return Component.toHtml as (props: any, childrenHtml: string) => { html: string };
}
describe('A3: exported URLs are wrapped in safeUrl + escapeAttr', () => {
test('ButtonLink href', () => {
const html = toHtmlOf(ButtonLink)({ href: XSS, text: 'Go' }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(ButtonLink)({ href: QUOTE_BREAKOUT, text: 'Go' }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('Icon href (link), class is escaped but NOT safeUrl-filtered', () => {
const html = toHtmlOf(Icon)({ link: XSS, icon: 'fa-star' }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Icon)({ link: QUOTE_BREAKOUT, icon: 'fa-star' }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
// class attribute is a CSS class, not a URL -- still escaped for attr safety
const html3 = toHtmlOf(Icon)({ icon: 'fa-star" onerror="alert(1)' }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('SocialLinks href', () => {
const html = toHtmlOf(SocialLinks)({ links: [{ platform: 'facebook', url: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(SocialLinks)({ links: [{ platform: 'facebook', url: QUOTE_BREAKOUT }] }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('Logo href + image src', () => {
const html = toHtmlOf(Logo)({ href: XSS, type: 'text', text: 'Site' }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Logo)({ type: 'image', imageSrc: XSS }, '').html;
expect(html2).not.toContain('javascript:');
const html3 = toHtmlOf(Logo)({ type: 'image', imageSrc: QUOTE_BREAKOUT }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('Menu link href', () => {
const html = toHtmlOf(Menu)({ links: [{ text: 'x', href: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Menu)({ links: [{ text: 'x', href: QUOTE_BREAKOUT }] }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('Navbar logo href, logo image src, link hrefs', () => {
const html = toHtmlOf(Navbar)({ logoUrl: XSS, logoType: 'text', logoText: 'Site', links: [{ text: 'x', href: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Navbar)({ logoType: 'image', logoImage: XSS }, '').html;
expect(html2).not.toContain('javascript:');
const html3 = toHtmlOf(Navbar)({ logoUrl: QUOTE_BREAKOUT, links: [{ text: 'x', href: QUOTE_BREAKOUT }] }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('ContentSlider button href + slide image src (background url)', () => {
const html = toHtmlOf(ContentSlider)({ slides: [{ buttonText: 'Go', buttonHref: XSS, imageSrc: '' }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(ContentSlider)({ slides: [{ imageSrc: XSS }] }, '').html;
expect(html2).not.toContain('javascript:');
const html3 = toHtmlOf(ContentSlider)({ slides: [{ buttonText: 'Go', buttonHref: QUOTE_BREAKOUT }] }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('FeaturesGrid image src + button url', () => {
const html = toHtmlOf(FeaturesGrid)({ features: [{ title: 't', description: 'd', image: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(FeaturesGrid)({ features: [{ title: 't', description: 'd', buttonText: 'Go', buttonUrl: XSS }] }, '').html;
expect(html2).not.toContain('javascript:');
});
test('PricingTable button href', () => {
const html = toHtmlOf(PricingTable)({ plans: [{ name: 'p', price: '$1', period: '/mo', features: [], buttonText: 'Buy', buttonHref: XSS }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(PricingTable)({ plans: [{ name: 'p', price: '$1', period: '/mo', features: [], buttonText: 'Buy', buttonHref: QUOTE_BREAKOUT }] }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('ImageBlock img src', () => {
const html = toHtmlOf(ImageBlock)({ src: XSS }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(ImageBlock)({ src: QUOTE_BREAKOUT }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('VideoBlock direct-file src is safeUrl-filtered on final emitted src', () => {
// matches the .mp4 extension sniff in detectVideoType but carries a javascript: scheme
const html = toHtmlOf(VideoBlock)({ videoUrl: 'javascript:alert(1)//x.mp4' }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(VideoBlock)({ videoUrl: '"><script>alert(1)</script>.mp4' }, '').html;
expect(html2).not.toContain('<script>alert(1)</script>');
});
test('VideoBlock youtube embed still works after safeUrl pass', () => {
const html = toHtmlOf(VideoBlock)({ videoUrl: 'https://www.youtube.com/watch?v=abc123' }, '').html;
expect(html).toContain('https://www.youtube.com/embed/abc123');
});
test('Gallery img src', () => {
const html = toHtmlOf(Gallery)({ images: [{ src: XSS, alt: 'a' }] }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(Gallery)({ images: [{ src: QUOTE_BREAKOUT, alt: 'a' }] }, '').html;
expect(html2).not.toContain('onerror="alert(1)"');
});
test('BackgroundSection bg image url()', () => {
const html = toHtmlOf(BackgroundSection)({ bgImage: XSS }, '').html;
expect(html).not.toContain('javascript:');
});
test('HeroSimple bg image url() and bg video src', () => {
const html = toHtmlOf(HeroSimple)({ bgType: 'image', bgImage: XSS }, '').html;
expect(html).not.toContain('javascript:');
const html2 = toHtmlOf(HeroSimple)({ bgType: 'video', bgVideo: XSS }, '').html;
expect(html2).not.toContain('javascript:');
const html3 = toHtmlOf(HeroSimple)({ bgType: 'video', bgVideo: QUOTE_BREAKOUT }, '').html;
expect(html3).not.toContain('onerror="alert(1)"');
});
test('CallToAction bg image url()', () => {
const html = toHtmlOf(CallToAction)({ bgType: 'image', bgValue: XSS }, '').html;
expect(html).not.toContain('javascript:');
});
test('MapEmbed iframe src (found beyond the brief-listed sites via grep sweep)', () => {
const html = toHtmlOf(MapEmbed)({ address: 'New York, NY', zoom: 14 }, '').html;
expect(html).toContain('maps.google.com');
expect(html).not.toContain('javascript:');
});
test('FormContainer legacy form action is safeUrl-filtered (found via grep sweep)', () => {
const html = toHtmlOf(FormContainer)({ action: XSS, method: 'GET' }, '').html;
expect(html).not.toContain('javascript:');
// non-relay legacy path (no recipientEmail) still works normally
const html2 = toHtmlOf(FormContainer)({ action: '/legacy', method: 'POST' }, '').html;
expect(html2).toContain('action="/legacy"');
});
});
+3 -2
View File
@@ -48,9 +48,10 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
data-zone-preview={zone} data-zone-preview={zone}
style={{ style={{
width: '100%', width: '100%',
minHeight: 40, // Slim hint bar, not a content-height band — an empty zone should not
// read as a stray spacer between the page and the header/footer.
padding: '5px 12px',
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a', backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
padding: '12px 24px',
color: zone === 'header' ? '#9ca3af' : '#64748b', color: zone === 'header' ? '#9ca3af' : '#64748b',
textAlign: 'center', textAlign: 'center',
fontSize: 11, fontSize: 11,
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers'; import { findDeletableTarget } from '../utils/craft-helpers';
import { regenerateTreeIds } from '../utils/craft-tree';
function isInputFocused(): boolean { function isInputFocused(): boolean {
const el = document.activeElement; const el = document.activeElement;
@@ -73,7 +74,7 @@ export function useKeyboardShortcuts() {
const node = query.node(nodeId).get(); const node = query.node(nodeId).get();
const parentId = node?.data?.parent; const parentId = node?.data?.parent;
if (parentId) { if (parentId) {
const tree = query.node(nodeId).toNodeTree(); const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
actions.addNodeTree(tree, parentId); actions.addNodeTree(tree, parentId);
} }
} }
+76
View File
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useState } from 'react';
import { useEditorConfig } from '../state/EditorConfigContext';
import { SitesmithSummary, SitesmithMessage, SendResult } from '../types/sitesmith';
function apiBase(apiUrl: string): string {
return apiUrl.replace(/site-builder\.php$/, 'sitesmith.php');
}
export function useSitesmith(siteId: number) {
const { whpConfig } = useEditorConfig();
const [summary, setSummary] = useState<SitesmithSummary | null>(null);
const [messages, setMessages] = useState<SitesmithMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refreshEntitlement = useCallback(async () => {
if (!whpConfig) return;
try {
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=entitlement`, { credentials: 'include' });
const j = await r.json();
if (j.ok) setSummary(j.summary);
} catch (e: any) { setError(String(e?.message ?? e)); }
}, [whpConfig]);
const fetchHistory = useCallback(async () => {
if (!whpConfig) { setLoading(false); return; }
try {
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=history&site_id=${siteId}`, { credentials: 'include' });
const j = await r.json();
if (j.ok) setMessages(j.messages);
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setLoading(false); }
}, [whpConfig, siteId]);
useEffect(() => { void refreshEntitlement(); void fetchHistory(); }, [refreshEntitlement, fetchHistory]);
const send = useCallback(async (
userText: string,
canvasSummary: string,
target?: { node_id: string; display_name: string; tree_json: string },
): Promise<SendResult> => {
if (!whpConfig) return { ok: false, status: 'BLOCKED', message: 'No WHP config' };
setMessages((m) => [...m, { role: 'user', content: userText, response_type: null, created_at: new Date().toISOString() }]);
const body: Record<string, unknown> = { site_id: siteId, message: userText, canvas_summary: canvasSummary };
if (target) body.target = target;
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=send`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken },
body: JSON.stringify(body),
});
const j: SendResult = await r.json();
void fetchHistory();
void refreshEntitlement();
return j;
}, [whpConfig, siteId, fetchHistory, refreshEntitlement]);
const clearHistory = useCallback(async (): Promise<{ ok: boolean; cleared?: number; error?: string }> => {
if (!whpConfig) return { ok: false, error: 'No WHP config' };
try {
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=clear_history`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken },
body: JSON.stringify({ site_id: siteId }),
});
const j = await r.json();
if (j.ok) setMessages([]);
return j;
} catch (e: any) {
return { ok: false, error: String(e?.message ?? e) };
}
}, [whpConfig, siteId]);
return { summary, messages, loading, error, send, refreshEntitlement, clearHistory };
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, test, expect } from 'vitest';
import { buildSavePayload } from './useWhpApi';
import { PageData } from '../types';
const pageA: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: 'STORED_HOME' };
const pageB: PageData = { id: 'page_2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT' };
const headerPage: PageData = { id: '__header__', name: 'Header', slug: '__header__', craftState: 'STALE_HEADER' };
const footerPage: PageData = { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: 'STALE_FOOTER' };
describe('buildSavePayload', () => {
test('editing header: live serialize lands in header_craft_state, not in any page slot', () => {
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_HEADER',
pages: [pageA, pageB],
headerPage,
footerPage,
activePageId: '__header__',
isEditingHeader: true,
isEditingFooter: false,
});
// The fresh live canvas must be reflected in header_craft_state, not the stale stored one.
expect(payload.header_craft_state).toBe('LIVE_HEADER');
expect(payload.header_craft_state).not.toBe('STALE_HEADER');
// Footer must remain untouched (stored state, since we're not editing it).
expect(payload.footer_craft_state).toBe('STALE_FOOTER');
// Live header content must never leak into a page slot or the top-level page fields.
expect(payload.craft_state).not.toBe('LIVE_HEADER');
for (const p of payload.pages_craft_state) {
expect(p.craftState).not.toBe('LIVE_HEADER');
}
// Top-level page fields should fall back to the landing page's own stored state.
expect(payload.craft_state).toBe('STORED_HOME');
// Per-page slots must reflect each page's own stored state, untouched.
expect(payload.pages_craft_state.find((p) => p.id === 'home')?.craftState).toBe('STORED_HOME');
expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.craftState).toBe('STORED_ABOUT');
});
test('editing footer: live serialize lands in footer_craft_state, not in any page slot', () => {
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_FOOTER',
pages: [pageA, pageB],
headerPage,
footerPage,
activePageId: '__footer__',
isEditingHeader: false,
isEditingFooter: true,
});
expect(payload.footer_craft_state).toBe('LIVE_FOOTER');
expect(payload.footer_craft_state).not.toBe('STALE_FOOTER');
expect(payload.header_craft_state).toBe('STALE_HEADER');
expect(payload.craft_state).not.toBe('LIVE_FOOTER');
for (const p of payload.pages_craft_state) {
expect(p.craftState).not.toBe('LIVE_FOOTER');
}
});
test('editing a real page: behavior unchanged — live serialize goes to that page + top-level, header/footer come from stored state', () => {
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_PAGE_HOME',
pages: [pageA, pageB],
headerPage,
footerPage,
activePageId: 'home',
isEditingHeader: false,
isEditingFooter: false,
});
// Live canvas goes to the active page and top-level slots.
expect(payload.craft_state).toBe('LIVE_PAGE_HOME');
expect(payload.pages_craft_state.find((p) => p.id === 'home')?.craftState).toBe('LIVE_PAGE_HOME');
// Other pages keep their stored state.
expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.craftState).toBe('STORED_ABOUT');
// Header/footer come from stored (stale-but-correct, since we're not editing them) state.
expect(payload.header_craft_state).toBe('STALE_HEADER');
expect(payload.footer_craft_state).toBe('STALE_FOOTER');
});
});
+179 -78
View File
@@ -3,21 +3,87 @@ import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../state/EditorConfigContext'; import { useEditorConfig } from '../state/EditorConfigContext';
import { usePages } from '../state/PageContext'; import { usePages } from '../state/PageContext';
import { exportBodyHtml } from '../utils/html-export'; import { exportBodyHtml } from '../utils/html-export';
import { PageData } from '../types';
export function useWhpApi() { export interface BuildSavePayloadInput {
const { query, actions } = useEditor(); siteId: number;
const { whpConfig, isWHP } = useEditorConfig(); siteName: string;
const { pages, headerPage, footerPage, activePageId, setHeaderCraftState, setFooterCraftState, setPagesCraftState } = usePages(); /** query.serialize() of whatever is currently on the live canvas. */
liveCraftState: string;
pages: PageData[];
headerPage: PageData;
footerPage: PageData;
activePageId: string;
/** True when the live canvas is showing the header zone (activePageId === '__header__'). */
isEditingHeader: boolean;
/** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */
isEditingFooter: boolean;
}
const save = useCallback(async () => { /**
if (!isWHP || !whpConfig) return null; * Pure payload builder for the save() API call. Extracted so the
* header/footer-vs-page routing logic can be unit-tested without mounting
* React/Craft.js.
*
* Bug this fixes: when the user is editing the Header or Footer,
* `activePageId` is `'__header__'`/`'__footer__'` — which matches no entry
* in `pages`. The live canvas serialization must be routed into
* `header_craft_state`/`footer_craft_state` in that case, NOT into a page
* slot or the top-level `craft_state`/`html` (which must always represent
* an actual page). Conversely, header/footer must be sourced from the FRESH
* live state when that zone is being edited, not from the stale stored
* `headerPage.craftState`/`footerPage.craftState`.
*/
export function buildSavePayload(input: BuildSavePayloadInput) {
const {
siteId,
siteName,
liveCraftState,
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
} = input;
// Serialize the current canvas state (whatever page is active) const isPageActive = !isEditingHeader && !isEditingFooter;
const currentCraftState = query.serialize();
// Export body HTML for the current page // Fresh header/footer state: the live canvas wins when that zone is the
let currentHtml = ''; // one currently being edited; otherwise fall back to the last-committed
let css = ''; // stored state (updated on zone switch by PageContext's saveCurrentState).
const headerCraftState = isEditingHeader ? liveCraftState : (headerPage.craftState || null);
const footerCraftState = isEditingFooter ? liveCraftState : (footerPage.craftState || null);
let headerHtml = '';
try {
if (headerCraftState) {
headerHtml = exportBodyHtml(headerCraftState).html;
}
} catch (e) {
console.error('Header HTML export failed:', e);
}
let footerHtml = '';
try {
if (footerCraftState) {
footerHtml = exportBodyHtml(footerCraftState).html;
}
} catch (e) {
console.error('Footer HTML export failed:', e);
}
// The top-level `craft_state`/`html` fields (and the matching per-page
// slot below) must always represent an actual PAGE. When editing the
// header/footer, activePageId matches no page — use the landing page's
// own stored state instead of leaking the live header/footer canvas into
// a page slot or mislabeling it as page content.
let currentCraftState: string | null;
let currentHtml = '';
let css = '';
if (isPageActive) {
currentCraftState = liveCraftState;
try { try {
const result = exportBodyHtml(currentCraftState); const result = exportBodyHtml(currentCraftState);
currentHtml = result.html; currentHtml = result.html;
@@ -25,78 +91,113 @@ export function useWhpApi() {
} catch (e) { } catch (e) {
console.error('HTML export failed, saving state only:', e); console.error('HTML export failed, saving state only:', e);
} }
} else {
// Export header HTML from its craft state const landingPage = pages[0] ?? null;
let headerHtml = ''; currentCraftState = landingPage?.craftState ?? null;
try { try {
if (headerPage.craftState) { if (currentCraftState) {
const hResult = exportBodyHtml(headerPage.craftState); const result = exportBodyHtml(currentCraftState);
headerHtml = hResult.html; currentHtml = result.html;
css = result.css;
} }
} catch (e) { } catch (e) {
console.error('Header HTML export failed:', e); console.error('HTML export failed, saving state only:', e);
}
}
// Build the pages array with HTML for each page. For the active page (only
// possible when a real page is active), use the freshly exported HTML from
// the canvas; for others, export from their stored craft state.
const pagesPayload = pages.map((page, i) => {
// The first page is ALWAYS the landing page → publishes to index.html
// regardless of the page name/slug. Apache serves '/' from index.html,
// and renaming the first page should not break the root URL.
const filename = i === 0 ? 'index.html' : page.slug + '.html';
let pageHtml = '';
if (isPageActive && page.id === activePageId) {
// Active page: use the current canvas HTML (already exported above)
pageHtml = currentHtml;
} else if (page.craftState) {
try {
pageHtml = exportBodyHtml(page.craftState).html;
} catch (e) {
console.error(`HTML export failed for page ${page.name}:`, e);
}
} }
// Export footer HTML from its craft state return {
let footerHtml = ''; filename,
try { title: page.name,
if (footerPage.craftState) { html: pageHtml,
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,
}; };
});
// Build pages_craft_state array: for each page, store its craft state.
// For the currently active page (only when a real page is active), always
// use the fresh canvas state since page.craftState may be stale (not
// updated until page switch). When editing header/footer, activePageId
// matches no page, so every page correctly falls back to its own stored
// state below.
const pagesGrapesjs = pages.map((page, i) => ({
id: page.id,
name: page.name,
// Pin the landing page's slug to 'index' on the wire too, so that on
// reload the editor's clean-URL routing (.htaccess rewrite of /name →
// name.html) lines up with the file we just wrote (index.html).
slug: i === 0 ? 'index' : page.slug,
craftState: (isPageActive && page.id === activePageId) ? liveCraftState : (page.craftState || null),
}));
return {
site_id: siteId,
name: siteName,
html: currentHtml,
css,
pages: pagesPayload,
header_html: headerHtml,
footer_html: footerHtml,
craft_state: currentCraftState,
header_craft_state: headerCraftState,
footer_craft_state: footerCraftState,
pages_craft_state: pagesGrapesjs,
};
}
export function useWhpApi() {
const { query, actions } = useEditor();
const { whpConfig, isWHP } = useEditorConfig();
const {
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
setHeaderCraftState,
setFooterCraftState,
setPagesCraftState,
} = usePages();
const save = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
// Serialize whatever is currently on the live canvas (a page, the
// header, or the footer — depending on activePageId/isEditingHeader/
// isEditingFooter).
const liveCraftState = query.serialize();
const payload = buildSavePayload({
siteId: whpConfig.siteId,
siteName: whpConfig.siteName,
liveCraftState,
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
});
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, { const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
method: 'POST', method: 'POST',
@@ -107,7 +208,7 @@ export function useWhpApi() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
return resp.json(); return resp.json();
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage]); }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter]);
const publish = useCallback(async () => { const publish = useCallback(async () => {
if (!isWHP || !whpConfig) return null; if (!isWHP || !whpConfig) return null;
+41 -9
View File
@@ -1,6 +1,9 @@
import React, { useEffect, useCallback, useRef } from 'react'; import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../../utils/craft-helpers'; import { findDeletableTarget } from '../../utils/craft-helpers';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
interface ContextMenuProps { interface ContextMenuProps {
visible: boolean; visible: boolean;
@@ -27,6 +30,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
onClose, onClose,
}) => { }) => {
const { actions, query } = useEditor(); const { actions, query } = useEditor();
const { open: openSitesmith } = useSitesmithModal();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const clipboardRef = useRef<string | null>(null); const clipboardRef = useRef<string | null>(null);
@@ -62,15 +66,11 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const duplicate = useCallback(() => { const duplicate = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
try { try {
const tree = query.node(nodeId).toSerializedNode();
const parentId = getParentId(); const parentId = getParentId();
if (!parentId) return; if (!parentId) return;
// Get the full subtree const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
const freshTree = query.node(nodeId).toNodeTree(); actions.addNodeTree(tree, parentId);
const clonedTree = query.parseSerializedNode(freshTree.nodes[freshTree.rootNodeId].data).toNode();
actions.addNodeTree(freshTree, parentId);
} catch (e) { } catch (e) {
console.error('Duplicate failed:', e); console.error('Duplicate failed:', e);
} }
@@ -89,10 +89,25 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const pasteNode = useCallback(() => { const pasteNode = useCallback(() => {
const sourceId = clipboardRef.current; const sourceId = clipboardRef.current;
if (!sourceId) return; if (!sourceId) {
onClose();
return;
}
try { try {
const targetParent = nodeId || 'ROOT'; if (!query.node(sourceId).get()) {
const tree = query.node(sourceId).toNodeTree(); onClose();
return;
}
// Paste as a SIBLING of the right-clicked node, not as its child --
// using the clicked node itself as the parent throws when it's a leaf.
let targetParent = 'ROOT';
if (nodeId && nodeId !== 'ROOT') {
const clickedNode = query.node(nodeId).get();
targetParent = clickedNode?.data?.parent || 'ROOT';
}
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
actions.addNodeTree(tree, targetParent); actions.addNodeTree(tree, targetParent);
} catch (e) { } catch (e) {
console.error('Paste failed:', e); console.error('Paste failed:', e);
@@ -143,6 +158,17 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
onClose(); onClose();
}, [nodeId, actions, getParentId, onClose]); }, [nodeId, actions, getParentId, onClose]);
const askSitesmith = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const target = buildSitesmithTarget(query, nodeId);
if (target) openSitesmith(target);
} catch (e) {
console.error('Ask Sitesmith failed:', e);
}
onClose();
}, [nodeId, query, openSitesmith, onClose]);
const deleteNode = useCallback(() => { const deleteNode = useCallback(() => {
const target = findDeletableTarget(query, nodeId); const target = findDeletableTarget(query, nodeId);
if (!target) { if (!target) {
@@ -162,6 +188,12 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const isRoot = nodeId === 'ROOT' || !nodeId; const isRoot = nodeId === 'ROOT' || !nodeId;
const items: MenuItem[] = [ const items: MenuItem[] = [
{
label: '✨ Ask Sitesmith',
action: askSitesmith,
disabled: isRoot,
dividerAfter: true,
},
{ {
label: 'Duplicate', label: 'Duplicate',
shortcut: 'Ctrl+D', shortcut: 'Ctrl+D',
+145 -40
View File
@@ -1,11 +1,36 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useAssets } from '../../hooks/useAssets'; import { useAssets } from '../../hooks/useAssets';
import { clickableProps } from '../../utils/a11y';
import { copyToClipboard } from '../../utils/clipboard';
/** How long the "Delete?" confirm state stays armed before auto-resetting. */
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
export const AssetsPanel: React.FC = () => { export const AssetsPanel: React.FC = () => {
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets(); const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null); const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
const [copyErrorUrl, setCopyErrorUrl] = useState<string | null>(null);
const [confirmDeleteName, setConfirmDeleteName] = useState<string | null>(null);
const deleteConfirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
};
}, []);
const resetDeleteConfirm = useCallback(() => {
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
setConfirmDeleteName(null);
}, []);
const armDeleteConfirm = useCallback((name: string) => {
setConfirmDeleteName(name);
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
deleteConfirmTimeoutRef.current = setTimeout(() => setConfirmDeleteName(null), DELETE_CONFIRM_TIMEOUT_MS);
}, []);
useEffect(() => { useEffect(() => {
loadAssets(); loadAssets();
@@ -37,11 +62,17 @@ export const AssetsPanel: React.FC = () => {
setIsDragOver(false); setIsDragOver(false);
}, []); }, []);
const copyUrl = useCallback((url: string) => { const copyUrl = useCallback(async (url: string) => {
navigator.clipboard.writeText(url).then(() => { const ok = await copyToClipboard(url);
if (ok) {
setCopyErrorUrl(null);
setCopiedUrl(url); setCopiedUrl(url);
setTimeout(() => setCopiedUrl(null), 2000); setTimeout(() => setCopiedUrl(null), 2000);
}); } else {
setCopiedUrl(null);
setCopyErrorUrl(url);
setTimeout(() => setCopyErrorUrl(null), 2500);
}
}, []); }, []);
const isImage = (type: string) => const isImage = (type: string) =>
@@ -133,19 +164,30 @@ export const AssetsPanel: React.FC = () => {
gap: 6, gap: 6,
}} }}
> >
{assets.map((asset) => ( {assets.map((asset) => {
const isConfirmingDelete = confirmDeleteName === asset.name;
return (
<div <div
key={asset.name} key={asset.name}
{...clickableProps(() => {
// While armed, clicking anywhere on the tile (other than the
// delete control itself) counts as "click elsewhere" and
// disarms the confirm instead of copying the URL.
if (isConfirmingDelete) {
resetDeleteConfirm();
return;
}
copyUrl(asset.url);
})}
style={{ style={{
position: 'relative', position: 'relative',
background: 'var(--color-bg-elevated)', background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
overflow: 'hidden', overflow: 'visible',
cursor: 'pointer', cursor: 'pointer',
transition: 'border-color var(--transition-fast)', transition: 'border-color var(--transition-fast)',
}} }}
onClick={() => copyUrl(asset.url)}
title={`Click to copy URL: ${asset.url}`} title={`Click to copy URL: ${asset.url}`}
> >
{/* Thumbnail */} {/* Thumbnail */}
@@ -158,6 +200,7 @@ export const AssetsPanel: React.FC = () => {
justifyContent: 'center', justifyContent: 'center',
background: 'var(--color-bg-base)', background: 'var(--color-bg-base)',
overflow: 'hidden', overflow: 'hidden',
borderRadius: 'var(--radius-md) var(--radius-md) 0 0',
}} }}
> >
{isImage(asset.type) ? ( {isImage(asset.type) ? (
@@ -183,52 +226,114 @@ export const AssetsPanel: React.FC = () => {
)} )}
</div> </div>
{/* Name */} {/* Name / status */}
<div <div
style={{ style={{
padding: '4px 6px', padding: '4px 6px',
fontSize: 10, fontSize: 10,
color: 'var(--color-text-muted)', color: copyErrorUrl === asset.url ? 'var(--color-danger)' : 'var(--color-text-muted)',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
{copiedUrl === asset.url ? 'Copied!' : asset.name} {copyErrorUrl === asset.url ? 'Copy failed' : copiedUrl === asset.url ? 'Copied!' : asset.name}
</div> </div>
{/* Delete button */} {/* Delete control: idle icon, or an in-app two-step confirm */}
<button {isConfirmingDelete ? (
onClick={(e) => { <div
e.stopPropagation(); style={{
deleteAsset(asset.name); position: 'absolute',
}} top: 4,
title="Delete asset" right: 4,
style={{ display: 'flex',
position: 'absolute', gap: 3,
top: 4, zIndex: 1,
right: 4, }}
width: 20, >
height: 20, <button
display: 'flex', onClick={(e) => {
alignItems: 'center', e.stopPropagation();
justifyContent: 'center', resetDeleteConfirm();
fontSize: 10, deleteAsset(asset.name);
color: '#fff', }}
background: 'rgba(0,0,0,0.6)', title="Click again to permanently delete"
border: 'none', aria-label={`Confirm delete ${asset.name}`}
borderRadius: '50%', autoFocus
cursor: 'pointer', style={{
opacity: 0.7, padding: '2px 6px',
transition: 'opacity var(--transition-fast)', fontSize: 9,
}} fontWeight: 700,
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }} color: '#fff',
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }} background: 'var(--color-danger)',
> border: 'none',
&#10005; borderRadius: 'var(--radius-sm)',
</button> cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
Delete?
</button>
<button
onClick={(e) => {
e.stopPropagation();
resetDeleteConfirm();
}}
title="Cancel"
aria-label={`Cancel delete ${asset.name}`}
style={{
width: 18,
height: 18,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 9,
color: '#fff',
background: 'rgba(0,0,0,0.6)',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
}}
>
&#10005;
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation();
armDeleteConfirm(asset.name);
}}
title="Delete asset"
aria-label={`Delete ${asset.name}`}
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>
))} );
})}
</div> </div>
{/* Loading indicator */} {/* Loading indicator */}
+4 -4
View File
@@ -1,5 +1,6 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { clickableProps } from '../../utils/a11y';
interface LayerNodeProps { interface LayerNodeProps {
nodeId: string; nodeId: string;
@@ -17,8 +18,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
}; };
}); });
const handleClick = useCallback((e: React.MouseEvent) => { const handleActivate = useCallback(() => {
e.stopPropagation();
actions.selectNode(nodeId); actions.selectNode(nodeId);
}, [actions, nodeId]); }, [actions, nodeId]);
@@ -29,7 +29,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
? (nodeType as any).resolvedName ? (nodeType as any).resolvedName
: typeof nodeType === 'string' ? nodeType : undefined; : typeof nodeType === 'string' ? nodeType : undefined;
const displayName = node.data.displayName || resolvedName || 'Component'; const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node';
const childNodeIds: string[] = node.data.nodes || []; const childNodeIds: string[] = node.data.nodes || [];
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[]; const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
const allChildren = [...childNodeIds, ...linkedNodeIds]; const allChildren = [...childNodeIds, ...linkedNodeIds];
@@ -38,7 +38,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
return ( return (
<div> <div>
<div <div
onClick={handleClick} {...clickableProps(handleActivate)}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
+55 -16
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { clickableProps } from '../../utils/a11y';
export const PagesPanel: React.FC = () => { export const PagesPanel: React.FC = () => {
const { const {
@@ -145,7 +146,9 @@ export const PagesPanel: React.FC = () => {
</div> </div>
{/* Page list */} {/* Page list */}
{pages.map((page) => ( {pages.map((page, pageIndex) => {
const isLanding = pageIndex === 0;
return (
<div key={page.id}> <div key={page.id}>
{editingId === page.id ? ( {editingId === page.id ? (
/* Editing mode */ /* Editing mode */
@@ -176,14 +179,25 @@ export const PagesPanel: React.FC = () => {
if (e.key === 'Escape') setEditingId(null); if (e.key === 'Escape') setEditingId(null);
}} }}
/> />
<input {isLanding ? (
type="text" <div style={{
value={editSlug} fontSize: 10,
onChange={(e) => setEditSlug(e.target.value)} color: 'var(--color-text-dim)',
placeholder="page-slug" padding: '4px 2px',
className="control-input" fontStyle: 'italic',
style={{ fontSize: 11 }} }}>
/> Landing page — URL locked to <code>/</code>
</div>
) : (
<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 }}> <div style={{ display: 'flex', gap: 6 }}>
<button <button
onClick={() => handleRename(page.id)} onClick={() => handleRename(page.id)}
@@ -273,7 +287,7 @@ export const PagesPanel: React.FC = () => {
) : ( ) : (
/* Normal page item */ /* Normal page item */
<div <div
onClick={() => switchPage(page.id)} {...clickableProps(() => switchPage(page.id))}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -298,12 +312,36 @@ export const PagesPanel: React.FC = () => {
page.id === activePageId page.id === activePageId
? 'var(--color-accent)' ? 'var(--color-accent)'
: 'var(--color-text)', : 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: 6,
overflow: 'hidden',
}}
>
<span style={{
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}>{page.name}</span>
> {isLanding && (
{page.name} <span
title="This is the landing page — published as the root URL (index.html)"
style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: '#fbbf24',
background: 'rgba(245, 158, 11, 0.15)',
border: '1px solid rgba(245, 158, 11, 0.35)',
padding: '1px 5px',
borderRadius: 'var(--radius-sm)',
flexShrink: 0,
}}
>
<i className="fa fa-home" style={{ marginRight: 3 }} />Landing
</span>
)}
</div> </div>
<div <div
style={{ style={{
@@ -312,7 +350,7 @@ export const PagesPanel: React.FC = () => {
marginTop: 2, marginTop: 2,
}} }}
> >
/{page.slug} {isLanding ? '/' : '/' + page.slug}
</div> </div>
</div> </div>
<div <div
@@ -338,7 +376,7 @@ export const PagesPanel: React.FC = () => {
> >
&#9998; &#9998;
</button> </button>
{pages.length > 1 && ( {pages.length > 1 && !isLanding && (
<button <button
onClick={() => setDeleteConfirmId(page.id)} onClick={() => setDeleteConfirmId(page.id)}
title="Delete" title="Delete"
@@ -363,7 +401,8 @@ export const PagesPanel: React.FC = () => {
</div> </div>
)} )}
</div> </div>
))} );
})}
{/* Add page section */} {/* Add page section */}
{isAdding ? ( {isAdding ? (
+26 -3
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { componentResolver } from '../../components/resolver'; import { componentResolver } from '../../components/resolver';
import { SiteDesignPanel } from './SiteDesignPanel'; import { SiteDesignPanel } from './SiteDesignPanel';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { import {
TextStylePanel, TextStylePanel,
ButtonStylePanel, ButtonStylePanel,
@@ -30,6 +32,8 @@ import {
export const GuidedStyles: React.FC = () => { export const GuidedStyles: React.FC = () => {
const resolverMap = componentResolver as Record<string, any>; const resolverMap = componentResolver as Record<string, any>;
const { open: openSitesmith } = useSitesmithModal();
const { query } = useEditor();
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => { const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
const currentNodeId = state.events.selected const currentNodeId = state.events.selected
@@ -97,14 +101,33 @@ export const GuidedStyles: React.FC = () => {
: isUtility ? 'fa-ellipsis-h' : isUtility ? 'fa-ellipsis-h'
: 'fa-cube'; : 'fa-cube';
const handleAskSitesmith = () => {
if (!selected || selected === 'ROOT') return;
const target = buildSitesmithTarget(query, selected);
if (target) openSitesmith(target);
};
return ( return (
<div className="guided-styles"> <div className="guided-styles">
{/* Component type badge */} {/* Component type badge + Sitesmith shortcut */}
<div className="guided-section guided-type-header"> <div className="guided-section guided-type-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="guided-type-badge"> <span className="guided-type-badge" style={{ flex: 1 }}>
<i className={`fa ${typeIcon}`} /> <i className={`fa ${typeIcon}`} />
{' '}{typeName} {' '}{typeName}
</span> </span>
<button
onClick={handleAskSitesmith}
title="Ask Sitesmith to edit this block"
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 8px', fontSize: 11, fontWeight: 600,
color: '#a78bfa', background: 'rgba(139,92,246,0.12)',
border: '1px solid rgba(139,92,246,0.4)',
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
}}
>
<i className="fa fa-magic" /> Ask Sitesmith
</button>
</div> </div>
{/* TEXT */} {/* TEXT */}
@@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
@@ -12,18 +12,15 @@ import {
PresetButtonGrid, PresetButtonGrid,
CollapsibleSection, CollapsibleSection,
ColorPickerField, ColorPickerField,
uploadToWhp,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle,
btnActiveStyle,
sectionGap, sectionGap,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- BACKGROUND SECTION ---------- */ /* ---------- BACKGROUND SECTION ---------- */
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const fileInputRef = useRef<HTMLInputElement>(null);
const setProp = useCallback((key: string, value: any) => { const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; }); actions.setProp(selectedId, (props: any) => { props[key] = value; });
@@ -35,34 +32,13 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
}); });
}, [actions, selectedId]); }, [actions, selectedId]);
const handleUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp('bgImage', url);
}, [setProp]);
const style = nodeProps.style || {}; const style = nodeProps.style || {};
return ( return (
<> <>
{/* Background Image */} {/* Background Image */}
<CollapsibleSection title="Background Image"> <CollapsibleSection title="Background Image">
{nodeProps.bgImage && ( <AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
<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> </CollapsibleSection>
{/* Background Color */} {/* Background Color */}
@@ -11,6 +11,9 @@ import {
ColorSwatchGrid, ColorSwatchGrid,
GradientSwatchGrid, GradientSwatchGrid,
PresetButtonGrid, PresetButtonGrid,
labelStyle,
inputStyle,
sectionGap,
} from './shared'; } from './shared';
/* ---------- CONTAINER / SECTION ---------- */ /* ---------- CONTAINER / SECTION ---------- */
@@ -27,8 +30,39 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
[actions, selectedId], [actions, selectedId],
); );
const setProp = useCallback(
(key: string, value: string) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; });
},
[actions, selectedId],
);
return ( return (
<> <>
{nodeProps.cssId !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>CSS ID</label>
<input
type="text"
value={nodeProps.cssId || ''}
onChange={(e) => setProp('cssId', e.target.value)}
placeholder="my-element-id"
style={inputStyle}
/>
</div>
)}
{nodeProps.cssClass !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>CSS Class</label>
<input
type="text"
value={nodeProps.cssClass || ''}
onChange={(e) => setProp('cssClass', e.target.value)}
placeholder="my-custom-class"
style={inputStyle}
/>
</div>
)}
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Background Color</SectionLabel> <SectionLabel>Background Color</SectionLabel>
<ColorSwatchGrid <ColorSwatchGrid
@@ -0,0 +1,60 @@
import React from 'react';
import { useEditor } from '@craftjs/core';
import { labelStyle, inputStyle, sectionGap } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
interface Feature {
title?: string; description?: string; icon?: string;
image?: string; imageAlt?: string; buttonText?: string; buttonUrl?: string;
}
/**
* Dedicated per-feature editor for the FeaturesGrid guided panel. Unlike the
* generic array editor (which only shows fields already present on the item),
* this always exposes icon / image (upload or URL) / button controls, so it
* works on template-created features that lack those keys. An image renders in
* place of the icon whenever `image` is set.
*/
export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }> = ({ selectedId, features }) => {
const { actions } = useEditor();
const list: Feature[] = Array.isArray(features) ? (features as Feature[]) : [];
const mutate = (fn: (arr: Feature[]) => Feature[]) =>
actions.setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
const update = (i: number, field: keyof Feature, value: string) =>
mutate((arr) => { arr[i] = { ...arr[i], [field]: value }; return arr; });
const add = () =>
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{list.map((feat, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={feat.title || ''} onChange={(e) => update(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
<button onClick={() => remove(i)} title="Remove" style={{ padding: '2px 8px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}>×</button>
</div>
<textarea value={feat.description || ''} onChange={(e) => update(i, 'description', e.target.value)} placeholder="Description" rows={2} style={{ ...inputStyle, resize: 'vertical' }} />
{/* Media: image (upload/URL) takes precedence over the icon when set */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={feat.icon || ''} onChange={(e) => update(i, 'icon', e.target.value)} placeholder="Icon (emoji)" style={{ ...inputStyle, width: 60, flex: 'none', textAlign: 'center' }} />
<div style={{ flex: 1 }}>
<AssetPicker variant="compact" value={feat.image || ''} onChange={(url) => update(i, 'image', url)} placeholder="or image URL" />
</div>
</div>
{/* Optional button */}
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={feat.buttonText || ''} onChange={(e) => update(i, 'buttonText', e.target.value)} placeholder="Button text (optional)" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={feat.buttonUrl || ''} onChange={(e) => update(i, 'buttonUrl', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
</div>
</div>
))}
<button onClick={add} style={{ ...labelStyle, padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', textAlign: 'center' }}>
+ Add Feature
</button>
</div>
);
};
@@ -35,6 +35,24 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
return ( return (
<> <>
{/* Contact-form relay: where submissions are emailed. Present on ContactForm
and FormContainer (both have recipientEmail/thankYouUrl props). */}
{nodeProps.recipientEmail !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Send submissions to (email)</label>
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead.
</p>
</div>
)}
{nodeProps.thankYouUrl !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Thank-you page URL (optional)</label>
<input type="text" value={nodeProps.thankYouUrl || ''} onChange={(e) => setProp('thankYouUrl', e.target.value)} placeholder="/thank-you (blank = hosted page)" style={inputStyle} />
</div>
)}
{/* Form action/method */} {/* Form action/method */}
{nodeProps.action !== undefined && ( {nodeProps.action !== undefined && (
<div style={sectionGap}> <div style={sectionGap}>
+10 -122
View File
@@ -1,101 +1,24 @@
import React, { useCallback, useState, useRef } from 'react'; import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
StylePanelProps, StylePanelProps,
CollapsibleSection, CollapsibleSection,
ColorPickerField, ColorPickerField,
uploadToWhp,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle,
btnActiveStyle, btnActiveStyle,
sectionGap, sectionGap,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- 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 ---------- */ /* ---------- HERO STYLE PANEL ---------- */
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const fileInputRef = useRef<HTMLInputElement>(null);
const setProp = useCallback((key: string, value: any) => { const setProp = useCallback((key: string, value: any) => {
actions.setProp(selectedId, (props: any) => { props[key] = value; }); actions.setProp(selectedId, (props: any) => { props[key] = value; });
}, [actions, selectedId]); }, [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'; const bgType = nodeProps.bgType || 'color';
return ( return (
@@ -161,55 +84,20 @@ export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
{bgType === 'image' && ( {bgType === 'image' && (
<div style={sectionGap}> <div style={sectionGap}>
<label style={labelStyle}>Background Image</label> <label style={labelStyle}>Background Image</label>
{nodeProps.bgImage && ( <AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
<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> </div>
)} )}
{bgType === 'video' && ( {bgType === 'video' && (
<div style={sectionGap}> <div style={sectionGap}>
<label style={labelStyle}>Background Video</label> <label style={labelStyle}>Background Video</label>
{nodeProps.bgVideo && ( <AssetPicker
<div style={{ marginBottom: 6, padding: 8, background: '#18181b', borderRadius: 4, border: '1px solid #3f3f46', display: 'flex', alignItems: 'center', gap: 6 }}> mediaType="video"
<i className="fa fa-film" style={{ color: '#3b82f6' }} /> variant="full"
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> value={nodeProps.bgVideo || ''}
{nodeProps.bgVideo.replace(/.*filename=/, '').replace(/^\d+_[a-f0-9]+_/, '') || nodeProps.bgVideo.split('/').pop()} onChange={(url) => setProp('bgVideo', url)}
</span> placeholder="YouTube, Vimeo, or .mp4 URL"
<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>
)} )}
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, CSSProperties } from 'react'; import React, { useCallback, CSSProperties } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
IMAGE_RADIUS_PRESETS, IMAGE_RADIUS_PRESETS,
@@ -8,21 +8,13 @@ import {
SectionLabel, SectionLabel,
PresetButtonGrid, PresetButtonGrid,
TextInputField, TextInputField,
uploadToWhp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- IMAGE (with upload/browse/drop) ---------- */ /* ---------- IMAGE (with upload/browse/drop) ---------- */
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {}; 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( const setPropStyle = useCallback(
(property: string, value: string) => { (property: string, value: string) => {
@@ -33,34 +25,6 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
[actions, selectedId], [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 = [ const maxWidthPresets = [
{ label: '25%', value: '25%' }, { label: '25%', value: '25%' },
{ label: '50%', value: '50%' }, { label: '50%', value: '50%' },
@@ -68,117 +32,16 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{ label: '100%', value: '100%' }, { 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 ( return (
<> <>
{/* Image source with upload/browse */} {/* Image source */}
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Image Source</SectionLabel> <SectionLabel>Image Source</SectionLabel>
<AssetPicker
{!isPlaceholder && nodeProps.src ? ( value={nodeProps.src || ''}
<> onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url; })}
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}> variant="full"
<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> </div>
{/* Alt Text */} {/* Alt Text */}
@@ -18,6 +18,7 @@ import {
smallInputStyle, smallInputStyle,
sectionGap, sectionGap,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */ /* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
@@ -97,13 +98,17 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
renderItem={(item: any, index: number) => ( renderItem={(item: any, index: number) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{item.src !== undefined && ( {item.src !== undefined && (
<input type="text" value={item.src || ''} onChange={(e) => { <AssetPicker
actions.setProp(selectedId, (props: any) => { variant="compact"
const updated = [...(props.images || [])]; value={item.src || ''}
updated[index] = { ...updated[index], src: e.target.value }; onChange={(url) => {
props.images = updated; actions.setProp(selectedId, (props: any) => {
}); const updated = [...(props.images || [])];
}} placeholder="Image URL" style={smallInputStyle} /> updated[index] = { ...updated[index], src: url };
props.images = updated;
});
}}
/>
)} )}
{item.caption !== undefined && ( {item.caption !== undefined && (
<input type="text" value={item.caption || ''} onChange={(e) => { <input type="text" value={item.caption || ''} onChange={(e) => {
@@ -149,13 +154,17 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
}} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} /> }} placeholder="Text" rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
)} )}
{item.image !== undefined && ( {item.image !== undefined && (
<input type="text" value={item.image || ''} onChange={(e) => { <AssetPicker
actions.setProp(selectedId, (props: any) => { variant="compact"
const updated = [...(props.slides || [])]; value={item.image || ''}
updated[index] = { ...updated[index], image: e.target.value }; onChange={(url) => {
props.slides = updated; actions.setProp(selectedId, (props: any) => {
}); const updated = [...(props.slides || [])];
}} placeholder="Image URL" style={smallInputStyle} /> updated[index] = { ...updated[index], image: url };
props.slides = updated;
});
}}
/>
)} )}
</div> </div>
)} )}
+74 -12
View File
@@ -9,11 +9,14 @@ import {
PresetButtonGrid, PresetButtonGrid,
CollapsibleSection, CollapsibleSection,
ColorPickerField, ColorPickerField,
navColorFields,
btnActiveStyle,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle, smallInputStyle,
sectionGap, sectionGap,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- NAV / MENU / LOGO ---------- */ /* ---------- NAV / MENU / LOGO ---------- */
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
@@ -56,6 +59,21 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
/* Detect standalone Logo vs Navbar/Menu */ /* Detect standalone Logo vs Navbar/Menu */
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined; const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
/* Color controls, derived from the props the selected component actually has */
const colorFields = navColorFields(nodeProps);
/* Menu layout controls (alignment/orientation/gap/font size). These props are
unique to the Menu component — Navbar uses navAlignment and has no
orientation/gap/fontSize — so guarding on their presence scopes this section
to the Menu without leaking into Navbar or a standalone Logo. */
const hasMenuLayout = !isStandaloneLogo && (
nodeProps.alignment !== undefined ||
nodeProps.orientation !== undefined ||
nodeProps.gap !== undefined ||
nodeProps.fontSize !== undefined
);
const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g }));
return ( return (
<> <>
{/* Standalone Logo component settings */} {/* Standalone Logo component settings */}
@@ -105,8 +123,8 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
{nodeProps.type === 'image' && ( {nodeProps.type === 'image' && (
<> <>
<div style={sectionGap}> <div style={sectionGap}>
<label style={labelStyle}>Image URL</label> <label style={labelStyle}>Logo Image</label>
<input type="text" value={nodeProps.imageSrc || ''} onChange={(e) => setProp('imageSrc', e.target.value)} placeholder="https://..." style={inputStyle} /> <AssetPicker value={nodeProps.imageSrc || ''} onChange={(url) => setProp('imageSrc', url)} variant="full" />
</div> </div>
<div style={sectionGap}> <div style={sectionGap}>
<label style={labelStyle}>Image Width</label> <label style={labelStyle}>Image Width</label>
@@ -130,8 +148,8 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</div> </div>
{nodeProps.logoImage !== undefined && ( {nodeProps.logoImage !== undefined && (
<div style={sectionGap}> <div style={sectionGap}>
<label style={labelStyle}>Logo Image URL</label> <label style={labelStyle}>Logo Image</label>
<input type="text" value={nodeProps.logoImage || ''} onChange={(e) => setProp('logoImage', e.target.value)} placeholder="https://..." style={inputStyle} /> <AssetPicker value={nodeProps.logoImage || ''} onChange={(url) => setProp('logoImage', url)} variant="full" />
</div> </div>
)} )}
{nodeProps.logoUrl !== undefined && ( {nodeProps.logoUrl !== undefined && (
@@ -165,17 +183,61 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</CollapsibleSection> </CollapsibleSection>
)} )}
{/* Colors (not shown for standalone Logo - it has its own color picker) */} {/* Colors (not shown for standalone Logo - it has its own color picker).
{!isStandaloneLogo && ( Fields are derived from whichever color props the selected component
actually has, so Menu (linkColor/ctaBg/…) and Navbar (backgroundColor/…)
each get the right controls instead of an empty section. */}
{!isStandaloneLogo && colorFields.length > 0 && (
<CollapsibleSection title="Colors"> <CollapsibleSection title="Colors">
{nodeProps.backgroundColor !== undefined && ( {colorFields.map((f) => (
<ColorPickerField label="Background" value={nodeProps.backgroundColor || '#ffffff'} onChange={(v) => setProp('backgroundColor', v)} /> <ColorPickerField
key={f.key}
label={f.label}
value={nodeProps[f.key] || f.fallback}
onChange={(v) => setProp(f.key, v)}
/>
))}
</CollapsibleSection>
)}
{/* Menu layout (alignment / orientation / gap / font size) */}
{hasMenuLayout && (
<CollapsibleSection title="Layout">
{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 || 'right') === a)}>
{a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
</div>
)} )}
{nodeProps.textColor !== undefined && ( {nodeProps.orientation !== undefined && (
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} /> <div style={sectionGap}>
<label style={labelStyle}>Orientation</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['horizontal', 'vertical'] as const).map((o) => (
<button key={o} onClick={() => setProp('orientation', o)} style={btnActiveStyle((nodeProps.orientation || 'horizontal') === o)}>
{o.charAt(0).toUpperCase() + o.slice(1)}
</button>
))}
</div>
</div>
)} )}
{nodeProps.ctaColor !== undefined && ( {nodeProps.gap !== undefined && (
<ColorPickerField label="CTA Color" value={nodeProps.ctaColor || '#3b82f6'} onChange={(v) => setProp('ctaColor', v)} /> <div style={sectionGap}>
<label style={labelStyle}>Gap</label>
<PresetButtonGrid presets={GAP_PRESETS} activeValue={nodeProps.gap} onSelect={(v) => setProp('gap', v)} />
</div>
)}
{nodeProps.fontSize !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Font Size</label>
<input type="text" value={nodeProps.fontSize || '14px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="14px" style={inputStyle} />
</div>
)} )}
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -18,6 +18,7 @@ import {
smallInputStyle, smallInputStyle,
sectionGap, sectionGap,
} from './shared'; } from './shared';
import { FeaturesEditor } from './FeaturesEditor';
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */ /* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => { export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
@@ -96,6 +97,15 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
{/* Array props (features, items, plans, testimonials, etc.) */} {/* Array props (features, items, plans, testimonials, etc.) */}
{arrayProps.map(([key, items]) => { {arrayProps.map(([key, items]) => {
// FeaturesGrid gets a dedicated editor (icon/image+upload/button per card),
// robust to template features that lack the image/button keys.
if (key === 'features') {
return (
<CollapsibleSection key={key} title="Features" defaultOpen>
<FeaturesEditor selectedId={selectedId} features={items} />
</CollapsibleSection>
);
}
const arrayItems = items as any[]; const arrayItems = items as any[];
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null; if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
const sampleItem = arrayItems[0] || {}; const sampleItem = arrayItems[0] || {};
@@ -83,7 +83,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
> >
<option value="">+ Add Platform...</option> <option value="">+ Add Platform...</option>
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp'].map((p) => ( {['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch'].map((p) => (
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option> <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
))} ))}
</select> </select>
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { navColorFields } from './shared';
import { Menu } from '../../../components/basic/Menu';
import { Navbar } from '../../../components/basic/Navbar';
/*
* Regression: the Menu component's color props (linkColor/linkHoverColor/
* ctaBgColor/ctaTextColor) differ from the Navbar's (backgroundColor/textColor/
* ctaColor). NavStylePanel's "Colors" section is shared across both, so it must
* derive its fields from the actual props present — otherwise a Menu shows an
* empty "Colors" dropdown with nothing to select.
*/
describe('navColorFields', () => {
it('surfaces the Menu color props (bug: was empty for Menu)', () => {
const menuProps = (Menu as any).craft.props;
const keys = navColorFields(menuProps).map((f) => f.key);
expect(keys).toContain('linkColor');
expect(keys).toContain('linkHoverColor');
expect(keys).toContain('ctaBgColor');
expect(keys).toContain('ctaTextColor');
});
it('never returns an empty list for a Menu (no empty Colors dropdown)', () => {
const menuProps = (Menu as any).craft.props;
expect(navColorFields(menuProps).length).toBeGreaterThan(0);
});
it('surfaces the Navbar color props (incl. hover/ctaText the panel never exposed before)', () => {
const navProps = (Navbar as any).craft.props;
const keys = navColorFields(navProps).map((f) => f.key);
// every field surfaced must be a real, consumed prop on the Navbar
expect(keys).toContain('backgroundColor');
expect(keys).toContain('textColor');
expect(keys).toContain('hoverColor');
expect(keys).toContain('ctaColor');
expect(keys).toContain('ctaTextColor');
// must NOT invent Menu-only props on a Navbar
expect(keys).not.toContain('linkColor');
expect(keys).not.toContain('ctaBgColor');
});
it('does not surface Navbar-only props on a Menu', () => {
const menuProps = (Menu as any).craft.props;
const keys = navColorFields(menuProps).map((f) => f.key);
expect(keys).not.toContain('backgroundColor');
expect(keys).not.toContain('hoverColor'); // Menu uses linkHoverColor
expect(keys).not.toContain('ctaColor');
});
it('only returns fields whose prop is actually present', () => {
// A bare Logo (text logo) has no link/cta color props -> should not invent them
const keys = navColorFields({ backgroundColor: '#fff' }).map((f) => f.key);
expect(keys).toEqual(['backgroundColor']);
});
});
+35 -17
View File
@@ -3,6 +3,7 @@ import { useEditor } from '@craftjs/core';
import { import {
GRADIENTS, GRADIENTS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { uploadAsset } from '../../../utils/assets';
/* ---------- Helper: auto text color for bg ---------- */ /* ---------- Helper: auto text color for bg ---------- */
export function autoTextColor(bg: string): string { export function autoTextColor(bg: string): string {
@@ -17,23 +18,11 @@ export function autoTextColor(bg: string): string {
return '#ffffff'; return '#ffffff';
} }
/* ---------- Helper: upload to WHP ---------- */ /* ---------- Helper: upload to WHP ----------
export async function uploadToWhp(file: File): Promise<string | null> { Thin re-export over the shared `uploadAsset` util (`@/utils/assets`) so
const cfg = (window as any).WHP_CONFIG; the existing callers importing `uploadToWhp` from here keep working
if (!cfg) return URL.createObjectURL(file); unchanged. */
const formData = new FormData(); export const uploadToWhp = uploadAsset;
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 ---------- */ /* ---------- Shared inline styles ---------- */
export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' }; export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' };
@@ -247,6 +236,35 @@ export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value
</div> </div>
); );
/* ---------- Nav-family color fields ----------
The Colors section is shared across the whole nav family (Navbar / Logo /
Footer / Menu). Those components DON'T share a color-prop schema:
Navbar/Logo/Footer use backgroundColor/textColor/ctaColor, while Menu uses
linkColor/linkHoverColor/ctaBgColor/ctaTextColor. Deriving the visible fields
from whichever props actually exist keeps the section from rendering empty
(the "Colors dropdown with nothing to select" bug on Menu). */
export interface NavColorField {
key: string;
label: string;
fallback: string;
}
const NAV_COLOR_FIELDS: NavColorField[] = [
// Navbar / Logo / Footer schema
{ key: 'backgroundColor', label: 'Background', fallback: '#ffffff' },
{ key: 'textColor', label: 'Text Color', fallback: '#18181b' },
{ key: 'hoverColor', label: 'Hover Color', fallback: '#3b82f6' },
{ key: 'ctaColor', label: 'CTA Color', fallback: '#3b82f6' },
// Menu schema (distinct prop names)
{ key: 'linkColor', label: 'Link Color', fallback: '#3f3f46' },
{ key: 'linkHoverColor', label: 'Hover Color', fallback: '#3b82f6' },
{ key: 'ctaBgColor', label: 'CTA Background', fallback: '#3b82f6' },
// ctaTextColor is shared: Menu's CTA text AND Navbar's CTA text
{ key: 'ctaTextColor', label: 'CTA Text', fallback: '#ffffff' },
];
export function navColorFields(nodeProps: Record<string, any>): NavColorField[] {
return NAV_COLOR_FIELDS.filter((f) => nodeProps[f.key] !== undefined);
}
/* ---------- Collapsible section ---------- */ /* ---------- Collapsible section ---------- */
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => { export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
@@ -0,0 +1,21 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import { uploadToWhp } from './shared';
import { uploadAsset } from '../../../utils/assets';
describe('shared.uploadToWhp', () => {
afterEach(() => {
vi.unstubAllGlobals();
delete (window as any).WHP_CONFIG;
});
test('is a thin re-export of uploadAsset (same function reference)', () => {
expect(uploadToWhp).toBe(uploadAsset);
});
test('still works standalone (no WHP_CONFIG -> blob: URL) for existing callers', async () => {
(URL as any).createObjectURL = vi.fn(() => 'blob:mock-url');
const file = new File(['x'], 'photo.png', { type: 'image/png' });
const url = await uploadToWhp(file);
expect(url).toBe('blob:mock-url');
});
});
+25
View File
@@ -0,0 +1,25 @@
import React, { useState, KeyboardEvent } from 'react';
interface Props { disabled?: boolean; placeholder?: string; onSend: (text: string) => void; }
export const ChatInput: React.FC<Props> = ({ disabled, placeholder, onSend }) => {
const [v, setV] = useState('');
const fire = () => { const t = v.trim(); if (!t || disabled) return; onSend(t); setV(''); };
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); fire(); } };
return (
<div style={{ display: 'flex', gap: 8, padding: '8px 0' }}>
<textarea value={v} onChange={(e) => setV(e.target.value)} onKeyDown={onKey} rows={2} disabled={disabled}
placeholder={placeholder || 'Describe what you want...'}
style={{
flex: 1, background: disabled ? '#1f1f24' : '#0f0f17', color: '#e5e7eb',
border: '1px solid #3f3f46', borderRadius: 6, padding: 10, fontSize: 14, resize: 'none',
}} />
<button onClick={fire} disabled={disabled || v.trim() === ''}
style={{
background: disabled ? '#27272a' : '#7c3aed', color: '#fff',
border: 'none', padding: '0 16px', borderRadius: 6,
cursor: disabled ? 'not-allowed' : 'pointer', fontWeight: 500,
}}>→</button>
</div>
);
};
@@ -0,0 +1,32 @@
import React from 'react';
import { SitesmithMessage } from '../../types/sitesmith';
export const MessageList: React.FC<{ messages: SitesmithMessage[] }> = ({ messages }) => {
const extract = (m: SitesmithMessage): string => {
if (m.role === 'user') return m.content;
try { const obj = JSON.parse(m.content); if (obj.type === 'ask') return obj.question; if (obj.message) return obj.message; } catch {}
return m.content;
};
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
{messages.length === 0 && (
<div style={{ color: '#71717a', fontSize: 13, textAlign: 'center', padding: 30 }}>
Describe the site you want and Sitesmith builds it. e.g. "A two-page site for a small bakery, friendly tone, photo of cupcakes in the hero."
</div>
)}
{messages.map((m, i) => {
const isUser = m.role === 'user';
return (
<div key={i} style={{
alignSelf: isUser ? 'flex-end' : 'flex-start',
maxWidth: '80%', padding: '10px 14px', borderRadius: 10,
background: isUser ? '#312e81' : '#1f2937', color: '#f3f4f6',
fontSize: 14, whiteSpace: 'pre-wrap',
}}>
{extract(m)}
</div>
);
})}
</div>
);
};
@@ -0,0 +1,36 @@
import React from 'react';
interface Props {
open: boolean;
pendingMessage?: string;
onConfirm: () => void;
onCancel: () => void;
}
export const ScopeConfirmDialog: React.FC<Props> = ({ open, pendingMessage, onConfirm, onCancel }) => {
if (!open) return null;
const overlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
const box: React.CSSProperties = { background: '#1a1a2e', border: '1px solid #3f3f46', borderRadius: 10, padding: 22, maxWidth: 480, color: '#fff' };
const cancel: React.CSSProperties = { background: '#27272a', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6, cursor: 'pointer' };
const ok: React.CSSProperties = { background: '#b91c1c', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6, cursor: 'pointer' };
return (
<div role="dialog" aria-modal="true" style={overlay}>
<div style={box}>
<h3 style={{ margin: 0 }}>Replace your entire site?</h3>
<p style={{ color: '#cbd5e1', fontSize: 14 }}>
Sitesmith will replace every page, your header, and your footer with the new design.
Manual edits will be lost.
</p>
{pendingMessage && (
<blockquote style={{ borderLeft: '3px solid #7c3aed', paddingLeft: 12, color: '#a5b4fc', fontSize: 13 }}>
{pendingMessage}
</blockquote>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14 }}>
<button onClick={onCancel} style={cancel}>Cancel</button>
<button onClick={onConfirm} style={ok}>Replace site</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,29 @@
import React from 'react';
import { useSitesmith } from '../../hooks/useSitesmith';
import { useEditorConfig } from '../../state/EditorConfigContext';
interface Props { onClick: () => void; }
export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
const cfg = useEditorConfig();
const siteId = cfg.whpConfig?.siteId ?? 0;
const { summary } = useSitesmith(siteId);
const locked = summary?.status === 'DISABLED';
const capped = summary?.status === 'CAP_REACHED';
return (
<button
type="button"
onClick={onClick}
className="topbar-btn sitesmith-btn"
title={locked ? 'Sitesmith — paid addon (click to learn more)' : 'Sitesmith AI Builder'}
style={{
background: locked ? '#1f1f24' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
}}
>
✨ Sitesmith
{locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 12 }}>🔒</span>}
{capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
</button>
);
};
@@ -0,0 +1,171 @@
import React, { useState, useRef, useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { useSitesmith } from '../../hooks/useSitesmith';
import { useApplyAiResponse } from '../../utils/apply-ai-response';
import { summarizeCanvas } from '../../utils/canvas-summary';
import { SitesmithTarget } from '../../state/SitesmithContext';
import { UpgradeBanner } from './UpgradeBanner';
import { ScopeConfirmDialog } from './ScopeConfirmDialog';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { WorkingIndicator } from './WorkingIndicator';
import { SitesmithResponse } from '../../types/sitesmith';
interface Props {
onClose: () => void;
/** When set, the chat is biased toward editing this specific node and the AI is
* instructed to return a `patch` op. The node's serialized tree is sent along. */
target?: SitesmithTarget | null;
}
export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const cfg = useEditorConfig();
const siteId = cfg.whpConfig?.siteId ?? 0;
const { query } = useEditor();
const { summary, messages, send, loading, clearHistory } = useSitesmith(siteId);
const apply = useApplyAiResponse();
const [busy, setBusy] = useState(false);
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [confirmClearChat, setConfirmClearChat] = useState(false);
const clearChatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearChatTimeoutRef.current) clearTimeout(clearChatTimeoutRef.current);
};
}, []);
const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
const overlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)', zIndex: 9000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' };
const header: React.CSSProperties = { display: 'flex', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid #27272a', gap: 8 };
const body: React.CSSProperties = { flex: 1, padding: '14px 18px', overflowY: 'auto', display: 'flex', flexDirection: 'column' };
const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a' };
const closeBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: 'none', fontSize: 18, cursor: 'pointer' };
const clearBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer', marginRight: 8 };
const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '8px 12px', borderRadius: 6, marginBottom: 10, fontSize: 13 };
const handleSend = async (text: string) => {
setBusy(true); setError(null);
try {
const canvas = summarizeCanvas(query.getSerializedNodes());
const result = await send(text, canvas, target ? {
node_id: target.nodeId,
display_name: target.displayName,
tree_json: target.treeJson,
} : undefined);
if (!result.ok) { setError(result.message || 'Failed'); return; }
if (result.response.type === 'replace' && result.response.scope === 'site') {
setPendingReplace(result.response);
return;
}
const applied = await apply(result.response, target?.nodeId);
if (!applied.ok) setError(applied.message || 'Apply failed');
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setBusy(false); }
};
const confirmReplace = async () => {
if (!pendingReplace) return;
const r = await apply(pendingReplace);
setPendingReplace(null);
if (!r.ok) setError(r.message || 'Apply failed');
};
const cancelClearChat = () => {
if (clearChatTimeoutRef.current) clearTimeout(clearChatTimeoutRef.current);
setConfirmClearChat(false);
};
const handleClearChatClick = async () => {
if (!confirmClearChat) {
setConfirmClearChat(true);
if (clearChatTimeoutRef.current) clearTimeout(clearChatTimeoutRef.current);
clearChatTimeoutRef.current = setTimeout(() => setConfirmClearChat(false), 4000);
return;
}
cancelClearChat();
const r = await clearHistory();
if (!r.ok) setError(r.error || 'Failed to clear history');
};
return (
<div role="dialog" aria-modal="true" style={overlay}>
<div style={panel}>
<div style={header}>
<div style={{ fontWeight: 600, color: '#fff' }}>✨ Sitesmith</div>
{summary && summary.enabled && (
<div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}>
{summary.monthly_used} / {summary.monthly_cap} this month
{summary.bonus_credits > 0 && ` • +${summary.bonus_credits} bonus`}
</div>
)}
<div style={{ flex: 1 }} />
{messages.length > 0 && (
confirmClearChat ? (
<>
<button
onClick={handleClearChatClick}
style={{ ...clearBtn, background: '#b91c1c', color: '#fff', borderColor: '#b91c1c' }}
title="Click again to permanently clear chat history"
autoFocus
>
Confirm clear?
</button>
<button onClick={cancelClearChat} style={clearBtn} title="Cancel">
Cancel
</button>
</>
) : (
<button
onClick={handleClearChatClick}
style={clearBtn}
title="Clear all Sitesmith chat history for this site (canvas is unaffected)"
>
Clear chat
</button>
)
)}
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button>
</div>
<div style={body}>
<UpgradeBanner summary={summary} />
{target && (
<div style={{
background: 'rgba(59,130,246,0.12)', border: '1px solid rgba(59,130,246,0.4)',
borderRadius: 6, padding: '8px 12px', marginBottom: 10, fontSize: 13, color: '#bfdbfe',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<i className="fa fa-magic" style={{ color: '#60a5fa' }} />
<span>Editing <strong style={{ color: '#fff' }}>{target.displayName}</strong> — describe the change you want and Sitesmith will modify just this block.</span>
</div>
)}
{error && <div role="alert" style={errBox}>{error}</div>}
{loading
? <div style={{ color: '#71717a', textAlign: 'center', padding: 30 }}>Loading…</div>
: <MessageList messages={messages} />}
</div>
<div style={footer}>
{busy ? (
<WorkingIndicator />
) : (
<ChatInput
disabled={!canChat}
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
onSend={handleSend}
/>
)}
</div>
<ScopeConfirmDialog
open={!!pendingReplace}
pendingMessage={pendingReplace && 'message' in pendingReplace ? (pendingReplace as any).message : undefined}
onConfirm={confirmReplace}
onCancel={() => setPendingReplace(null)}
/>
</div>
</div>
);
};
@@ -0,0 +1,35 @@
import React from 'react';
import { SitesmithSummary } from '../../types/sitesmith';
interface Props { summary: SitesmithSummary | null; }
export const UpgradeBanner: React.FC<Props> = ({ summary }) => {
if (!summary) return null;
if (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY') return null;
const isLocked = summary.status === 'DISABLED';
const isCapped = summary.status === 'CAP_REACHED';
return (
<div role="status" style={{
background: isLocked ? '#3b1d4d' : '#3b2d1d',
border: `1px solid ${isLocked ? '#7c3aed' : '#b45309'}`,
color: '#fbcfe8', padding: '14px 18px', borderRadius: 8, marginBottom: 14,
}}>
{isLocked && (<>
<div style={{ fontWeight: 600, marginBottom: 6 }}>Sitesmith is a paid addon</div>
<div style={{ fontSize: 13, marginBottom: 10 }}>
Describe the site you want and our AI builds it. You can edit everything afterward.
</div>
<a href="https://anhonesthost.com/clientarea.php?action=services" target="_blank" rel="noopener noreferrer"
style={{ color: '#fff', background: '#7c3aed', padding: '8px 14px', borderRadius: 6, textDecoration: 'none' }}>
Upgrade your plan →
</a>
</>)}
{isCapped && (<>
<div style={{ fontWeight: 600, marginBottom: 6 }}>Monthly cap reached</div>
<div style={{ fontSize: 13 }}>
You've used {summary.monthly_used} of {summary.monthly_cap} Sitesmith builds this month. Resets on {summary.resets_on}.
</div>
</>)}
</div>
);
};
@@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react';
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const PHRASES = [
'Thinking',
'Sketching layout',
'Choosing colors',
'Writing copy',
'Picking components',
'Wiring up the hero',
'Polishing typography',
'Arranging sections',
'Composing the layout',
'Adding finishing touches',
];
/**
* Animated "AI is working" indicator. Modeled after Claude Code's bottom-bar
* status: a Braille-cycle spinner, a phrase that rotates every few seconds,
* and an elapsed-seconds counter. Mounts only while a request is in flight.
*/
export const WorkingIndicator: React.FC = () => {
const [frame, setFrame] = useState(0);
const [phrase, setPhrase] = useState('Thinking');
const [elapsed, setElapsed] = useState(0);
const [startTime] = useState(() => Date.now());
useEffect(() => {
const spinnerTimer = window.setInterval(() => {
setFrame((f) => (f + 1) % SPINNER.length);
}, 80);
const phraseTimer = window.setInterval(() => {
setPhrase(PHRASES[Math.floor(Math.random() * PHRASES.length)]);
}, 2500);
const elapsedTimer = window.setInterval(() => {
setElapsed(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
return () => {
window.clearInterval(spinnerTimer);
window.clearInterval(phraseTimer);
window.clearInterval(elapsedTimer);
};
}, [startTime]);
return (
<div style={containerStyle} role="status" aria-live="polite">
<span style={spinnerStyle} aria-hidden>{SPINNER[frame]}</span>
<span style={phraseStyle}>{phrase}…</span>
<span style={metaStyle}>({elapsed}s)</span>
</div>
);
};
const containerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '14px 4px',
fontSize: 14,
};
const spinnerStyle: React.CSSProperties = {
color: '#8b5cf6',
fontSize: 18,
width: 18,
display: 'inline-block',
textAlign: 'center',
fontFamily: 'monospace',
};
const phraseStyle: React.CSSProperties = {
color: '#e4e4e7',
fontStyle: 'italic',
fontWeight: 500,
};
const metaStyle: React.CSSProperties = {
color: '#71717a',
fontSize: 12,
marginLeft: 'auto',
};
+85 -105
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react'; import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
@@ -9,6 +9,7 @@ import {
TemplateCategory, TemplateCategory,
} from '../../templates'; } from '../../templates';
import { componentResolver } from '../../components/resolver'; import { componentResolver } from '../../components/resolver';
import { clickableProps } from '../../utils/a11y';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -50,6 +51,15 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
const [applyDesign, setApplyDesign] = useState(true); const [applyDesign, setApplyDesign] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Guards state updates after the component has unmounted mid-sequence.
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Close on Escape key // Close on Escape key
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -112,73 +122,63 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
} }
}, [query, actions]); }, [query, actions]);
/** Promise-based `setTimeout` — lets the staged apply steps below be awaited
* in sequence instead of chained via nested callbacks. */
const wait = useCallback((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)), []);
/**
* Switch to a zone (header/footer), clear it, and add the template's
* components, giving Craft.js a tick to settle between each step.
*/
const applyZone = useCallback(
async (switchFn: () => void, components: TemplateComponent[]) => {
switchFn();
await wait(30);
try {
clearCanvas();
await wait(20);
try {
addTemplateComponents(components);
} catch (e) {
console.warn('Failed to add zone components:', e);
}
} catch (e) {
console.warn('Failed to clear zone:', e);
}
await wait(30);
},
[wait, clearCanvas, addTemplateComponents],
);
/** /**
* After all pages are loaded, apply header and footer template content. * After all pages are loaded, apply header and footer template content.
* Switches to header/footer editing mode, clears, adds components, then returns to firstPageId. * Switches to header/footer editing mode, clears, adds components, then returns to firstPageId.
*/ */
const applyHeaderFooter = useCallback( const applyHeaderFooter = useCallback(
(tpl: TemplateDefinition, firstPageId: string) => { async (tpl: TemplateDefinition, firstPageId: string) => {
const hasHeader = tpl.header?.components?.length > 0; const hasHeader = tpl.header?.components?.length > 0;
const hasFooter = tpl.footer?.components?.length > 0; const hasFooter = tpl.footer?.components?.length > 0;
if (!hasHeader && !hasFooter) { if (hasHeader) await applyZone(editHeader, tpl.header.components);
setLoading(false); if (hasFooter) await applyZone(editFooter, tpl.footer.components);
return;
}
const applyZone = ( // Switch back to the first page
switchFn: () => void, switchPage(firstPageId);
components: TemplateComponent[], await wait(30);
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], [applyZone, switchPage, editHeader, editFooter, wait],
); );
// Load the selected template // Load the selected template. Awaits the full staged sequence (clear, add
const handleLoad = useCallback(() => { // components, add pages, apply header/footer) before closing the modal, so
// "Loading…" stays visible for the duration and onClose() never fires
// while the sequence is still mutating a canvas the dialog just tore down.
const handleLoad = useCallback(async () => {
if (!confirmTemplate) return; if (!confirmTemplate) return;
setLoading(true); setLoading(true);
const tpl = confirmTemplate; const tpl = confirmTemplate;
setConfirmTemplate(null);
try { try {
// 1. Optionally apply design tokens // 1. Optionally apply design tokens
@@ -195,66 +195,46 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
// 3. Clear the current canvas and add the first template page's components // 3. Clear the current canvas and add the first template page's components
clearCanvas(); clearCanvas();
// Short delay to let the clear settle before adding new nodes
await wait(20);
// Use a short delay to let the clear settle before adding new nodes const firstPage = tpl.pages[0];
setTimeout(() => { addTemplateComponents(firstPage.content.components);
try {
const firstPage = tpl.pages[0];
addTemplateComponents(firstPage.content.components);
// 4. Add remaining pages (if multi-page template) // 4. Add remaining pages (if multi-page template)
const finishPages = () => { if (tpl.pages.length > 1) {
// 5. Apply header and footer template content await wait(30);
if (keepId) { for (let pageIndex = 1; pageIndex < tpl.pages.length; pageIndex++) {
applyHeaderFooter(tpl, keepId); const pageDef = tpl.pages[pageIndex];
} else { addPage(pageDef.name, pageDef.slug);
setLoading(false);
}
};
if (tpl.pages.length > 1) { // After addPage, the new page is active with an empty canvas.
let pageIndex = 1; // Give a tick for Craft.js to settle, then add components.
const addNextPage = () => { await wait(30);
if (pageIndex >= tpl.pages.length) { try {
// Switch back to first page, then apply header/footer addTemplateComponents(pageDef.content.components);
if (keepId) switchPage(keepId); } catch (e) {
setTimeout(finishPages, 30); console.warn('Failed to add components for page', pageDef.name, e);
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) { await wait(30);
console.error('Failed to load template:', e);
setLoading(false);
} }
}, 20);
// Switch back to the first page before applying header/footer
if (keepId) switchPage(keepId);
await wait(30);
}
// 5. Apply header and footer template content
if (keepId) {
await applyHeaderFooter(tpl, keepId);
}
} catch (e) { } catch (e) {
console.error('Failed to load template:', e); console.error('Failed to load template:', e);
setLoading(false); } finally {
if (mountedRef.current) setLoading(false);
onClose();
} }
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]);
setConfirmTemplate(null);
onClose();
}, [confirmTemplate, applyDesign, query, actions, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, onClose]);
// Close on backdrop click // Close on backdrop click
const handleBackdropClick = useCallback( const handleBackdropClick = useCallback(
@@ -413,7 +393,7 @@ const TemplateCard: React.FC<{
return ( return (
<div <div
onClick={onSelect} {...clickableProps(onSelect)}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
style={{ style={{
+23 -8
View File
@@ -3,9 +3,12 @@ import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../../state/EditorConfigContext'; import { useEditorConfig } from '../../state/EditorConfigContext';
import { useWhpApi } from '../../hooks/useWhpApi'; import { useWhpApi } from '../../hooks/useWhpApi';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { DeviceMode } from '../../types'; import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal'; import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal'; import { HeadCodeModal } from './HeadCodeModal';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
interface TopBarProps { interface TopBarProps {
device: DeviceMode; device: DeviceMode;
@@ -20,12 +23,14 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
})); }));
const { save, publish, load } = useWhpApi(); const { save, publish, load } = useWhpApi();
const { headerPage, footerPage } = usePages(); const { headerPage, footerPage } = usePages();
const { design } = useSiteDesign();
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle'); const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
const [isDraft, setIsDraft] = useState(false); const [isDraft, setIsDraft] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false); const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false); const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasLoadedRef = useRef(false); const hasLoadedRef = useRef(false);
@@ -118,7 +123,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
<nav className="topbar"> <nav className="topbar">
<div className="topbar-left"> <div className="topbar-left">
{isWHP && ( {isWHP && (
<a href={whpConfig!.backUrl} className="topbar-btn back-btn"> <a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
<i className="fa fa-arrow-left" /> Back to Panel <i className="fa fa-arrow-left" /> Back to Panel
</a> </a>
)} )}
@@ -136,6 +141,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
className={`device-btn ${device === d ? 'active' : ''}`} className={`device-btn ${device === d ? 'active' : ''}`}
onClick={() => onDeviceChange(d)} onClick={() => onDeviceChange(d)}
title={d.charAt(0).toUpperCase() + d.slice(1)} title={d.charAt(0).toUpperCase() + d.slice(1)}
aria-label={d.charAt(0).toUpperCase() + d.slice(1)}
aria-pressed={device === d}
> >
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} /> <i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
</button> </button>
@@ -144,20 +151,20 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
</div> </div>
<div className="topbar-right"> <div className="topbar-right">
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo"> <button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo" aria-label="Undo">
<i className="fa fa-undo" /> <i className="fa fa-undo" />
</button> </button>
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo"> <button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo" aria-label="Redo">
<i className="fa fa-repeat" /> <i className="fa fa-repeat" />
</button> </button>
<span className="topbar-divider" /> <span className="topbar-divider" />
<button className="topbar-btn" title="Templates" onClick={() => setTemplateModalOpen(true)}> <button className="topbar-btn" title="Templates" aria-label="Templates" onClick={() => setTemplateModalOpen(true)}>
<i className="fa fa-th-large" /> Templates <i className="fa fa-th-large" /> Templates
</button> </button>
<button className="topbar-btn" title="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}> <button className="topbar-btn" title="Custom Head Code" aria-label="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}>
<i className="fa fa-code" /> Code <i className="fa fa-code" /> Code
</button> </button>
<button className="topbar-btn" title="Preview" onClick={() => { <button className="topbar-btn" title="Preview" aria-label="Preview" onClick={() => {
try { try {
const serialized = query.serialize(); const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => { import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
@@ -186,13 +193,17 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
const result = exportToHtml(serialized, { const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview', title: whpConfig?.siteName || 'Preview',
includeFonts: true, includeFonts: true,
headCode: design.headCode,
}); });
// Replace the body in the full document with our composed version // Replace the body in the full document with our composed version.
// Use a function replacer -- a plain string replacer treats
// `$&`/`$1`/`$$` sequences in user content as replacement
// patterns, silently corrupting the output.
let html = result.html; let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i); const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) { if (bodyMatch) {
html = html.replace(bodyMatch[1], composedBody); html = html.replace(bodyMatch[1], () => composedBody);
} }
// Make proxy URLs absolute so they work from the blob: context // Make proxy URLs absolute so they work from the blob: context
@@ -239,11 +250,14 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
</span> </span>
)} )}
<SitesmithButton onClick={() => openSitesmith()} />
<button <button
className="topbar-btn primary" className="topbar-btn primary"
onClick={handleSave} onClick={handleSave}
disabled={saveStatus === 'saving'} disabled={saveStatus === 'saving'}
title="Save Draft" title="Save Draft"
aria-label="Save Draft"
> >
{saveStatus === 'saving' ? ( {saveStatus === 'saving' ? (
<><i className="fa fa-spinner fa-spin" /> Saving...</> <><i className="fa fa-spinner fa-spin" /> Saving...</>
@@ -258,6 +272,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
onClick={handlePublish} onClick={handlePublish}
disabled={publishStatus === 'publishing' || saveStatus === 'saving'} disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
title="Publish to live site" title="Publish to live site"
aria-label="Publish to live site"
> >
{publishStatus === 'publishing' ? ( {publishStatus === 'publishing' ? (
<><i className="fa fa-spinner fa-spin" /> Publishing...</> <><i className="fa fa-spinner fa-spin" /> Publishing...</>

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