From c50b3856610b0698f254e40ab69e6865998b76f3 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 12:49:34 -0700 Subject: [PATCH] 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) --- craft/src/ui/AssetPicker.test.tsx | 204 ++++++++++++++++++ craft/src/ui/AssetPicker.tsx | 337 ++++++++++++++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 craft/src/ui/AssetPicker.test.tsx create mode 100644 craft/src/ui/AssetPicker.tsx diff --git a/craft/src/ui/AssetPicker.test.tsx b/craft/src/ui/AssetPicker.test.tsx new file mode 100644 index 0000000..b7f908c --- /dev/null +++ b/craft/src/ui/AssetPicker.test.tsx @@ -0,0 +1,204 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { AssetPicker, acceptFor, matchesMediaType, isStandalone } from './AssetPicker'; +import type { Asset } from '../utils/assets'; + +vi.mock('../utils/assets', () => ({ + uploadAsset: vi.fn(), + listAssets: vi.fn(), +})); + +import { uploadAsset, listAssets } from '../utils/assets'; + +const uploadAssetMock = uploadAsset as unknown as ReturnType; +const listAssetsMock = listAssets as unknown as ReturnType; + +const GRID_ASSETS: Asset[] = [ + { name: 'a.png', url: '/storage/assets/a.png', type: 'image/png' }, + { name: 'b.mp4', url: '/storage/assets/b.mp4', type: 'video/mp4' }, +]; + +/* ---------- DOM test harness (no @testing-library/react in this repo, see + src/utils/assets.test.ts / navColorFields.test.ts for the plain-logic + pattern used elsewhere; component-level interaction here uses + react-dom/client + react-dom/test-utils `act`, both already transitive + deps of `react-dom`, so no new devDependency is introduced). ---------- */ +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 unmount() { + act(() => { root.unmount(); }); + container.remove(); +} + +function click(el: Element | null) { + if (!el) throw new Error('element not found'); + act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); }); +} + +function setValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +function q(testId: string): T | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +describe('AssetPicker pure helpers', () => { + test('acceptFor maps mediaType to the file input accept attribute', () => { + expect(acceptFor('image')).toBe('image/*'); + expect(acceptFor('video')).toBe('video/*'); + expect(acceptFor('any')).toBeUndefined(); + }); + + test('matchesMediaType guards drag-drop uploads by mediaType', () => { + expect(matchesMediaType('image/png', 'image')).toBe(true); + expect(matchesMediaType('video/mp4', 'image')).toBe(false); + expect(matchesMediaType('video/mp4', 'video')).toBe(true); + expect(matchesMediaType('application/pdf', 'any')).toBe(true); + }); + + test('isStandalone reflects presence of window.WHP_CONFIG', () => { + delete (window as any).WHP_CONFIG; + expect(isStandalone()).toBe(true); + (window as any).WHP_CONFIG = { apiUrl: 'x', siteId: 1, csrfToken: 't' }; + expect(isStandalone()).toBe(false); + delete (window as any).WHP_CONFIG; + }); +}); + +describe('AssetPicker component', () => { + beforeEach(() => { + uploadAssetMock.mockReset(); + listAssetsMock.mockReset(); + listAssetsMock.mockResolvedValue(GRID_ASSETS); + delete (window as any).WHP_CONFIG; + }); + + afterEach(() => { + if (container) unmount(); + delete (window as any).WHP_CONFIG; + }); + + test('renders the current value as a preview thumbnail (full variant)', () => { + render(); + const preview = q('asset-picker-preview'); + expect(preview).not.toBeNull(); + expect(preview!.src).toContain('/storage/assets/existing.png'); + expect(q('asset-picker-dropzone')).toBeNull(); + }); + + test('renders a dropzone (no preview) when value is empty', () => { + render(); + expect(q('asset-picker-dropzone')).not.toBeNull(); + expect(q('asset-picker-preview')).toBeNull(); + }); + + test('remove button clears the value via onChange("")', () => { + const onChange = vi.fn(); + render(); + click(q('asset-picker-remove')); + expect(onChange).toHaveBeenCalledWith(''); + }); + + test('Browse control is absent when window.WHP_CONFIG is undefined (standalone)', () => { + render(); + expect(q('asset-picker-browse')).toBeNull(); + }); + + test('Browse control is present and lists assets filtered by mediaType when WHP_CONFIG is set', async () => { + (window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' }; + render(); + const browseBtn = q('asset-picker-browse'); + expect(browseBtn).not.toBeNull(); + + await act(async () => { click(browseBtn); await Promise.resolve(); }); + + expect(listAssetsMock).toHaveBeenCalledWith('video'); + expect(q('asset-picker-grid')).not.toBeNull(); + }); + + test('selecting a grid asset invokes onChange with that asset url and closes the grid', async () => { + (window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' }; + const onChange = vi.fn(); + render(); + + await act(async () => { click(q('asset-picker-browse')); await Promise.resolve(); }); + expect(q('asset-picker-grid')).not.toBeNull(); + + click(q(`asset-picker-thumb-${GRID_ASSETS[0].name}`)); + + expect(onChange).toHaveBeenCalledWith(GRID_ASSETS[0].url); + expect(q('asset-picker-grid')).toBeNull(); + }); + + test('URL apply invokes onChange with the typed url (full variant Apply button)', () => { + const onChange = vi.fn(); + render(); + const input = q('asset-picker-url-input')!; + setValue(input, 'https://example.com/pic.jpg'); + click(q('asset-picker-apply')); + expect(onChange).toHaveBeenCalledWith('https://example.com/pic.jpg'); + }); + + test('uploading a file calls uploadAsset and applies the resolved url via onChange', async () => { + uploadAssetMock.mockResolvedValue('/storage/assets/uploaded.png'); + const onChange = vi.fn(); + render(); + + const fileInput = q('asset-picker-file-input')!; + const file = new File(['x'], 'photo.png', { type: 'image/png' }); + const dt = { files: [file] }; + Object.defineProperty(fileInput, 'files', { value: dt.files }); + + await act(async () => { + fileInput.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(uploadAssetMock).toHaveBeenCalledWith(file); + expect(onChange).toHaveBeenCalledWith('/storage/assets/uploaded.png'); + }); + + test('variant="compact" renders the compact single-row structure instead of the full structure', () => { + render(); + expect(q('asset-picker-compact')).not.toBeNull(); + expect(q('asset-picker-full')).toBeNull(); + expect(q('asset-picker-dropzone')).toBeNull(); + expect(q('asset-picker-url-input')).not.toBeNull(); + expect(q('asset-picker-upload')).not.toBeNull(); + }); + + test('compact variant: URL input applies onChange on blur', () => { + const onChange = vi.fn(); + render(); + const input = q('asset-picker-url-input')!; + setValue(input, 'https://example.com/vid.mp4'); + // React delegates onBlur via the bubbling 'focusout' event (blur itself doesn't bubble). + act(() => { input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); + expect(onChange).toHaveBeenCalledWith('https://example.com/vid.mp4'); + }); + + test('mediaType="video" sets file input accept to video/* and mediaType="any" omits accept', () => { + render(); + expect(q('asset-picker-file-input')!.accept).toBe('video/*'); + unmount(); + + render(); + expect(q('asset-picker-file-input')!.accept).toBe(''); + }); +}); diff --git a/craft/src/ui/AssetPicker.tsx b/craft/src/ui/AssetPicker.tsx new file mode 100644 index 0000000..1975e37 --- /dev/null +++ b/craft/src/ui/AssetPicker.tsx @@ -0,0 +1,337 @@ +import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react'; +import { uploadAsset, listAssets, Asset } from '../utils/assets'; + +export interface AssetPickerProps { + value: string; + onChange: (url: string) => void; + /** Drives the file `` and the `listAssets` grid filter. Default 'image'. */ + mediaType?: 'image' | 'video' | 'any'; + /** 'full' mirrors ImageStylePanel's source UI; 'compact' is a single tight row for array-editor cards. */ + variant?: 'full' | 'compact'; + placeholder?: string; +} + +/* ---------- Pure helpers (exported for unit testing) ---------- */ + +/** File `` value for a given mediaType; `undefined` (any file) for 'any'. */ +export function acceptFor(mediaType: 'image' | 'video' | 'any'): string | undefined { + if (mediaType === 'image') return 'image/*'; + if (mediaType === 'video') return 'video/*'; + return undefined; +} + +/** Whether a dropped/selected file's MIME type is acceptable for the given mediaType. */ +export function matchesMediaType(fileType: string, mediaType: 'image' | 'video' | 'any'): boolean { + if (mediaType === 'any') return true; + return fileType.startsWith(mediaType); +} + +/** True when running outside WHP (no `window.WHP_CONFIG`) -- Browse should be hidden. */ +export function isStandalone(): boolean { + return typeof window === 'undefined' || !(window as any).WHP_CONFIG; +} + +const previewBoxStyle: CSSProperties = { + marginBottom: 8, + borderRadius: 6, + overflow: 'hidden', + border: '1px solid #3f3f46', + position: 'relative', +}; + +const removeBtnStyle: CSSProperties = { + 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', +}; + +const dropZoneStyle = (active: boolean): CSSProperties => ({ + padding: '20px 12px', + border: `2px dashed ${active ? '#3b82f6' : '#3f3f46'}`, + borderRadius: 6, + textAlign: 'center', + color: '#71717a', + fontSize: 12, + cursor: 'pointer', + marginBottom: 8, + transition: 'border-color 0.15s', +}); + +const uploadBtnStyle: CSSProperties = { + flex: 1, + padding: '8px 10px', + fontSize: 12, + borderRadius: 4, + cursor: 'pointer', + border: '1px solid #3f3f46', + background: '#3b82f6', + color: '#fff', + fontWeight: 500, +}; + +const browseBtnStyle = (active: boolean): CSSProperties => ({ + flex: 1, + padding: '8px 10px', + fontSize: 12, + borderRadius: 4, + cursor: 'pointer', + border: '1px solid #3f3f46', + background: active ? '#3b82f6' : '#27272a', + color: active ? '#fff' : '#e4e4e7', +}); + +const gridStyle: CSSProperties = { + maxHeight: 200, + overflowY: 'auto', + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: 4, + marginTop: 8, + background: '#18181b', + borderRadius: 6, + padding: 4, +}; + +export const AssetPicker: React.FC = ({ + value, + onChange, + mediaType = 'image', + variant = 'full', + placeholder = 'Or paste URL...', +}) => { + const fileInputRef = useRef(null); + const [showBrowser, setShowBrowser] = useState(false); + const [browserAssets, setBrowserAssets] = useState([]); + const [browserLoading, setBrowserLoading] = useState(false); + const [urlValue, setUrlValue] = useState(value || ''); + const [dragOver, setDragOver] = useState(false); + const standalone = isStandalone(); + + useEffect(() => { + setUrlValue(value || ''); + }, [value]); + + const handleUpload = useCallback(async (file: File) => { + const url = await uploadAsset(file); + if (url) { + onChange(url); + setUrlValue(url); + } + }, [onChange]); + + const handleBrowseToggle = useCallback(async () => { + if (showBrowser) { + setShowBrowser(false); + return; + } + setBrowserLoading(true); + try { + const assets = await listAssets(mediaType); + setBrowserAssets(assets); + setShowBrowser(true); + } finally { + setBrowserLoading(false); + } + }, [showBrowser, mediaType]); + + const handleSelectAsset = useCallback((asset: Asset) => { + onChange(asset.url); + setUrlValue(asset.url); + setShowBrowser(false); + }, [onChange]); + + const handleApplyUrl = useCallback(() => { + onChange(urlValue); + }, [urlValue, onChange]); + + const handleRemove = useCallback(() => { + onChange(''); + setUrlValue(''); + }, [onChange]); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file && matchesMediaType(file.type, mediaType)) { + await handleUpload(file); + } + }, [handleUpload, mediaType]); + + const accept = acceptFor(mediaType); + const hasValue = !!value; + + const fileInput = ( + { + const file = e.target.files?.[0]; + if (file) handleUpload(file); + e.target.value = ''; + }} + data-testid="asset-picker-file-input" + /> + ); + + const grid = showBrowser && ( +
+ {browserAssets.map((asset) => ( +
handleSelectAsset(asset)} + data-testid={`asset-picker-thumb-${asset.name}`} + style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }} + > + {asset.name} +
+ ))} + {browserAssets.length === 0 && ( +

+ No assets uploaded yet. Use Upload above. +

+ )} +
+ ); + + const browseButton = !standalone && ( + + ); + + if (variant === 'compact') { + return ( +
+
+ {hasValue && ( +
+ + +
+ )} + + {browseButton} + { setUrlValue(e.target.value); }} + onBlur={handleApplyUrl} + onKeyDown={(e) => { if (e.key === 'Enter') handleApplyUrl(); }} + data-testid="asset-picker-url-input" + style={{ flex: 1, fontSize: 11, minWidth: 0 }} + /> +
+ {grid} + {fileInput} +
+ ); + } + + return ( +
+ {hasValue ? ( +
+ + +
+ ) : ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + data-testid="asset-picker-dropzone" + > + + Drop {mediaType === 'video' ? 'video' : mediaType === 'any' ? 'file' : 'image'} here or click to upload +
+ )} + +
+ + {browseButton} +
+ + {grid} + {fileInput} + +
+
+ setUrlValue(e.target.value)} + data-testid="asset-picker-url-input" + style={{ fontSize: 11 }} + /> + +
+
+
+ ); +};