Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.2 KiB
Site Builder — unified image/asset picker (2026-07-12)
Wherever an image is entered by URL in the Craft.js site builder
(/workspace/site-builder/craft/), the user should also be able to pick an
image already uploaded to the site builder (and upload a new one). Today this
"Browse uploaded assets" affordance exists but is copy-pasted into ~6 components
and missing entirely from ~7 other image fields. Scope decisions were made with
the user before writing this spec.
Problem
The "select an uploaded image" capability (a Browse button opening an inline
thumbnail grid backed by ?action=list_assets) is implemented — six independent
times:
craft/src/components/media/ImageBlock.tsxcraft/src/components/basic/Logo.tsxcraft/src/components/basic/Navbar.tsx(logo)craft/src/panels/right/styles/ImageStylePanel.tsxcraft/src/panels/right/styles/HeroStylePanel.tsx(AssetBrowsersub-component)craft/src/components/media/VideoBlock.tsx(video assets)
…and is absent (plain URL <input> only) from:
craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx(bgImage)craft/src/panels/right/styles/MediaStylePanel.tsx(two image fields)craft/src/panels/right/styles/FeaturesEditor.tsx(per-feature image — has upload + URL, no browse)craft/src/components/sections/FeaturesGrid.tsx(per-feature image, settings)craft/src/components/sections/Gallery.tsx(per-imagesrc)craft/src/components/sections/ContentSlider.tsx(per-slideimageSrc)craft/src/components/layout/Container.tsx(background image URL)
Same capability, inconsistent presence — a UX gap for users and 6 divergent copies to maintain.
Solution — one reusable component, two presentation variants
New: craft/src/ui/AssetPicker.tsx
Single source of truth for choosing an image (or, later, video). Lives in
src/ui/ — the existing neutral home for reusable settings controls
(BorderControl, SpacingInput, AnchorIdField, …), importable by BOTH
src/components/** and src/panels/**. (No component currently imports from
panels/right/styles, so the shared piece must not live there.)
It composes the three affordances that already exist today:
- Upload a new file (drag-drop zone + button)
- Browse uploaded site assets (inline thumbnail grid)
- Paste a URL (escape hatch for external images)
interface AssetPickerProps {
value: string; // current url ('' = none)
onChange: (url: string) => void;
mediaType?: 'image' | 'video' | 'any'; // default 'image' — filters grid + file accept
variant?: 'full' | 'compact'; // default 'full'
placeholder?: string; // URL input placeholder
}
variant="full"— the rich layout: preview thumbnail with remove-✕, dashed drag-drop zone when empty, Upload + Browse buttons, inline grid, URL apply row. Mirrors today'sImageStylePanellayout. Consumers: ImageStylePanel, Logo settings, Navbar logo, BackgroundSectionStylePanel, MediaStylePanel, Hero bg image, Container bg image.variant="compact"— one tight row (small thumbnail + Upload + Browse + inline URL input; grid expands below on Browse) that fits inside array-editor item cards. Consumers: FeaturesEditor, FeaturesGrid settings, Gallery rows, ContentSlider slides.
Both variants share ALL logic (upload, browse fetch + grid, URL state, WHP/
standalone gating); only the markup differs, selected by variant.
Supporting change: craft/src/utils/assets.ts (de-duplicate)
uploadToWhp is duplicated in at least panels/right/styles/shared.tsx,
panels/right/styles/FeaturesEditor.tsx, and inline in several components; every
Browse re-implements the list_assets fetch + type filter. Add a neutral util:
// src/utils/assets.ts
export async function uploadAsset(file: File): Promise<string | null>;
export async function listAssets(mediaType?: 'image' | 'video' | 'any'): Promise<Asset[]>;
export interface Asset { name: string; url: string; type: string; }
uploadAsset= the existinguploadToWhpbody (POSTupload_asset, blob fallback when noWHP_CONFIG).listAssets= fetchlist_assets, return[]when noWHP_CONFIG, filter bymediaType(typestartsWithimage/video;any= no filter).
AssetPicker uses both. shared.tsx keeps re-exporting uploadToWhp (thin
wrapper delegating to uploadAsset) so nothing else breaks; local copies in
components are removed as each is migrated to AssetPicker.
Behavior details
- Standalone mode (no
window.WHP_CONFIG): Browse is hidden (no assets API to call); Upload still works viablob:fallback. Matches today's behavior. - Video-ready:
mediaTypedrives both the grid filter and the file-inputaccept. Image fields are wired now with the default'image'. VideoBlock / Hero bg-video may adopt the same component withmediaType="video"— done in this work if cheap, otherwise a clean follow-up. - No
toHtmlchanges. This is purely settings-panel input UX; published HTML is byte-for-byte unaffected, so all existing*.toHtml.test.tsstay green. - Prop wiring unchanged. Each consumer keeps writing to its own prop
(
src,imageSrc,logoImage,bgImage, per-itemimage/imageSrc) — only the input UI is swapped.AssetPickeris controlled viavalue/onChange.
Migration order (each independently verifiable)
utils/assets.ts+AssetPicker.tsx+ unit test (no consumers yet).- Migrate the "full" panel fields: ImageStylePanel, MediaStylePanel, BackgroundSectionStylePanel, HeroStylePanel (image), Container bg.
- Migrate the "full" component settings: Logo, Navbar logo.
- Migrate ImageBlock to the shared component.
- Migrate the "compact" array editors: FeaturesEditor, FeaturesGrid, Gallery, ContentSlider.
- Remove now-dead duplicated upload/browse state and local
uploadToWhpcopies.
Testing
- New
craft/src/ui/AssetPicker.test.tsx:- renders the current
value(thumbnail/URL shown), onChangefires with the asset URL on grid-select and on URL "Apply",- grid requests + shows only assets matching
mediaType, - Browse hidden when
window.WHP_CONFIGis absent (mockfetch+ togglewindow.WHP_CONFIG).
- renders the current
- New
craft/src/utils/assets.test.ts:uploadAssetblob fallback + POST shape;listAssetsfilter + empty-without-config. - Regression: full
cd craft && npx vitest runandnpm run build(tsc) green.
Out of scope / follow-ups
- Rewriting the asset grid UX itself (search, pagination, folders) — this reuses the current grid look, just makes it universal.
- Migrating VideoBlock/Hero video pickers is optional in this pass (component is built video-ready; wire if cheap, else follow-up).
- A separate, parallel Fable-agent audit of the whole site builder may surface additional improvements; those are tracked independently, not in this spec.
Deploy
Ships inside the WHP release, per the standard site-builder pipeline
(build craft bundle → copy dist/{index.html,js,css} into
whp/web-files/site-builder/ → build-release.sh → download-update.sh across
the fleet). Handled at ship time via the whp-deploy-release skill, after
implementation and review.