Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -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<typeof vi.fn>;
|
||||||
|
const listAssetsMock = listAssets as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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<T extends Element = Element>(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(<AssetPicker value="/storage/assets/existing.png" onChange={vi.fn()} />);
|
||||||
|
const preview = q<HTMLImageElement>('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(<AssetPicker value="" onChange={vi.fn()} />);
|
||||||
|
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(<AssetPicker value="/storage/assets/existing.png" onChange={onChange} />);
|
||||||
|
click(q('asset-picker-remove'));
|
||||||
|
expect(onChange).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Browse control is absent when window.WHP_CONFIG is undefined (standalone)', () => {
|
||||||
|
render(<AssetPicker value="" onChange={vi.fn()} />);
|
||||||
|
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(<AssetPicker value="" onChange={vi.fn()} mediaType="video" />);
|
||||||
|
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(<AssetPicker value="" onChange={onChange} mediaType="any" />);
|
||||||
|
|
||||||
|
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(<AssetPicker value="" onChange={onChange} />);
|
||||||
|
const input = q<HTMLInputElement>('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(<AssetPicker value="" onChange={onChange} />);
|
||||||
|
|
||||||
|
const fileInput = q<HTMLInputElement>('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(<AssetPicker value="" onChange={vi.fn()} variant="compact" />);
|
||||||
|
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(<AssetPicker value="" onChange={onChange} variant="compact" />);
|
||||||
|
const input = q<HTMLInputElement>('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(<AssetPicker value="" onChange={vi.fn()} mediaType="video" />);
|
||||||
|
expect(q<HTMLInputElement>('asset-picker-file-input')!.accept).toBe('video/*');
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(<AssetPicker value="" onChange={vi.fn()} mediaType="any" />);
|
||||||
|
expect(q<HTMLInputElement>('asset-picker-file-input')!.accept).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<input accept>` 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 `<input accept>` 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<AssetPickerProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
mediaType = 'image',
|
||||||
|
variant = 'full',
|
||||||
|
placeholder = 'Or paste URL...',
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [showBrowser, setShowBrowser] = useState(false);
|
||||||
|
const [browserAssets, setBrowserAssets] = useState<Asset[]>([]);
|
||||||
|
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 = (
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleUpload(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
data-testid="asset-picker-file-input"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const grid = showBrowser && (
|
||||||
|
<div style={gridStyle} data-testid="asset-picker-grid">
|
||||||
|
{browserAssets.map((asset) => (
|
||||||
|
<div
|
||||||
|
key={asset.name}
|
||||||
|
onClick={() => handleSelectAsset(asset)}
|
||||||
|
data-testid={`asset-picker-thumb-${asset.name}`}
|
||||||
|
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
||||||
|
>
|
||||||
|
<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 assets uploaded yet. Use Upload above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const browseButton = !standalone && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBrowseToggle}
|
||||||
|
data-testid="asset-picker-browse"
|
||||||
|
style={variant === 'full' ? browseBtnStyle(showBrowser) : { ...browseBtnStyle(showBrowser), flex: 'none', padding: '6px 8px' }}
|
||||||
|
>
|
||||||
|
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: variant === 'full' ? 4 : 0 }} />
|
||||||
|
{variant === 'full' && 'Browse'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'compact') {
|
||||||
|
return (
|
||||||
|
<div data-testid="asset-picker-compact">
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
{hasValue && (
|
||||||
|
<div style={{ width: 28, height: 28, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', flex: 'none', position: 'relative' }}>
|
||||||
|
<img src={value} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
title="Remove"
|
||||||
|
data-testid="asset-picker-remove"
|
||||||
|
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.5)', border: 'none', color: '#fff', fontSize: 9, cursor: 'pointer', opacity: 0 }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0'; }}
|
||||||
|
>
|
||||||
|
<i className="fa fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
data-testid="asset-picker-upload"
|
||||||
|
style={{ flex: 'none', padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff' }}
|
||||||
|
title="Upload"
|
||||||
|
>
|
||||||
|
<i className="fa fa-upload" />
|
||||||
|
</button>
|
||||||
|
{browseButton}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="guided-input"
|
||||||
|
value={urlValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => { 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 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{grid}
|
||||||
|
{fileInput}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="asset-picker-full">
|
||||||
|
{hasValue ? (
|
||||||
|
<div style={previewBoxStyle}>
|
||||||
|
<img src={value} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} data-testid="asset-picker-preview" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
title="Remove"
|
||||||
|
data-testid="asset-picker-remove"
|
||||||
|
style={removeBtnStyle}
|
||||||
|
>
|
||||||
|
<i className="fa fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={dropZoneStyle(dragOver)}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
data-testid="asset-picker-dropzone"
|
||||||
|
>
|
||||||
|
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
|
||||||
|
Drop {mediaType === 'video' ? 'video' : mediaType === 'any' ? 'file' : 'image'} here or click to upload
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
data-testid="asset-picker-upload"
|
||||||
|
style={uploadBtnStyle}
|
||||||
|
>
|
||||||
|
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||||
|
</button>
|
||||||
|
{browseButton}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{grid}
|
||||||
|
{fileInput}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<div className="guided-input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="guided-input"
|
||||||
|
value={urlValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => setUrlValue(e.target.value)}
|
||||||
|
data-testid="asset-picker-url-input"
|
||||||
|
style={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preset-btn apply-btn"
|
||||||
|
onClick={handleApplyUrl}
|
||||||
|
data-testid="asset-picker-apply"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user