Merge issue-55-fe (Refs #55)
# Conflicts: # craft/src/styles/editor.css
This commit is contained in:
commit
0ac420a23c
17 files changed
+928
-9
No files matched your search
@@ -56,3 +56,19 @@ describe('BackgroundSection.toHtml style-value XSS hardening', () => {
|
||||
expect(html).toContain('child');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackgroundSection.toHtml bgPosition focal point (#55)', () => {
|
||||
test('bgPosition is emitted as background-position', () => {
|
||||
const { html } = toHtml({ bgImage: 'assets/a.jpg', bgPosition: 'left top' }, 'child');
|
||||
expect(html).toContain('background-position:left top');
|
||||
expect(html).not.toContain('background-position:center');
|
||||
});
|
||||
test('blank or missing bgPosition keeps center', () => {
|
||||
expect(toHtml({ bgImage: 'assets/a.jpg', bgPosition: '' }, '').html).toContain('background-position:center');
|
||||
expect(toHtml({ bgImage: 'assets/a.jpg' }, '').html).toContain('background-position:center');
|
||||
});
|
||||
test('a malicious bgPosition cannot break out of the style attribute', () => {
|
||||
const { html } = toHtml({ bgImage: 'x', bgPosition: 'left";}</style><script>alert(1)</script>' }, '');
|
||||
expect(html).not.toContain('<script>alert(1)');
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr } from '../../utils/escape';
|
||||
import { escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface BackgroundSectionProps {
|
||||
bgImage?: string;
|
||||
/** CSS background-position (focal point); blank = center. */
|
||||
bgPosition?: string;
|
||||
bgColor?: string;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
@@ -17,6 +19,7 @@ interface BackgroundSectionProps {
|
||||
|
||||
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
bgImage = '',
|
||||
bgPosition = '',
|
||||
bgColor = '#1e293b',
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0.4,
|
||||
@@ -37,7 +40,7 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
backgroundColor: bgColor,
|
||||
backgroundImage: bgImage ? `url(${bgImage})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundPosition: cssValue(bgPosition) || 'center',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
@@ -75,6 +78,7 @@ BackgroundSection.craft = {
|
||||
displayName: 'Background Section',
|
||||
props: {
|
||||
bgImage: '',
|
||||
bgPosition: '',
|
||||
bgColor: '#1e293b',
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 0.4,
|
||||
@@ -99,7 +103,7 @@ BackgroundSection.craft = {
|
||||
backgroundColor: props.bgColor || '#1e293b',
|
||||
backgroundImage: props.bgImage ? `url(${props.bgImage})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundPosition: cssValue(props.bgPosition) || 'center',
|
||||
...props.style,
|
||||
});
|
||||
const overlayStyle = cssPropsToString({
|
||||
|
||||
@@ -48,3 +48,20 @@ describe('HeroSimple.toHtml palette token fallback (Refs #44)', () => {
|
||||
expect(html).not.toContain('var(--wsb-primary)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroSimple.toHtml bgPosition focal point (#55)', () => {
|
||||
const base = { heading: 'Hi', subtitle: 'There', bgType: 'image', bgImage: 'assets/a.jpg' };
|
||||
test('bgPosition is emitted as background-position', () => {
|
||||
const { html } = toHtml({ ...base, bgPosition: 'left top' }, '');
|
||||
expect(html).toContain('background-position:left top');
|
||||
expect(html).not.toContain('background-position:center');
|
||||
});
|
||||
test('blank or missing bgPosition keeps center', () => {
|
||||
expect(toHtml({ ...base, bgPosition: '' }, '').html).toContain('background-position:center');
|
||||
expect(toHtml(base, '').html).toContain('background-position:center');
|
||||
});
|
||||
test('a malicious bgPosition cannot break out of the style attribute', () => {
|
||||
const { html } = toHtml({ ...base, bgPosition: 'left";}</style><script>alert(1)</script>' }, '');
|
||||
expect(html).not.toContain('<script>alert(1)');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ interface HeroProps {
|
||||
bgGradientTo?: string;
|
||||
bgGradientAngle?: number;
|
||||
bgImage?: string;
|
||||
/** CSS background-position (focal point) for the image background; blank = center. */
|
||||
bgPosition?: string;
|
||||
bgVideo?: string;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
@@ -61,6 +63,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
bgGradientTo = '#764ba2',
|
||||
bgGradientAngle = 135,
|
||||
bgImage = '',
|
||||
bgPosition = '',
|
||||
bgVideo = '',
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0,
|
||||
@@ -97,7 +100,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
background: bgType !== 'image' ? bg : undefined,
|
||||
backgroundImage: bgType === 'image' && bgImage ? `url('${bgImage}')` : undefined,
|
||||
backgroundSize: bgType === 'image' ? 'cover' : undefined,
|
||||
backgroundPosition: bgType === 'image' ? 'center' : undefined,
|
||||
backgroundPosition: bgType === 'image' ? (cssValue(bgPosition) || 'center') : undefined,
|
||||
minHeight: minHeight === '100vh' ? '100vh' : minHeight,
|
||||
display: 'flex',
|
||||
alignItems: justifyMap[verticalAlign] || 'center',
|
||||
@@ -179,6 +182,7 @@ HeroSimple.craft = {
|
||||
bgGradientTo: '#764ba2',
|
||||
bgGradientAngle: 135,
|
||||
bgImage: '',
|
||||
bgPosition: '',
|
||||
bgVideo: '',
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 0,
|
||||
@@ -208,7 +212,7 @@ HeroSimple.craft = {
|
||||
background: props.bgType !== 'image' ? bg : undefined,
|
||||
backgroundImage: props.bgType === 'image' && props.bgImage ? `url('${props.bgImage}')` : undefined,
|
||||
backgroundSize: props.bgType === 'image' ? 'cover' : undefined,
|
||||
backgroundPosition: props.bgType === 'image' ? 'center' : undefined,
|
||||
backgroundPosition: props.bgType === 'image' ? (cssValue(props.bgPosition) || 'center') : undefined,
|
||||
minHeight: props.minHeight || '500px',
|
||||
display: 'flex',
|
||||
alignItems: justifyMap[props.verticalAlign || 'center'],
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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 { useStockPhotos, resetStockAvailability } from './useStockPhotos';
|
||||
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type Api = ReturnType<typeof useStockPhotos>;
|
||||
let api: Api;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const Probe: React.FC = () => { api = useStockPhotos(); return null; };
|
||||
|
||||
const json = (body: any, status = 200) =>
|
||||
Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => Promise.resolve(body) } as Response);
|
||||
|
||||
const PHOTO = { id: 7, thumb: 't.jpg', preview: 'p.jpg', width: 10, height: 20, alt: 'a cat', credit: { name: 'Ann', url: 'https://www.pexels.com/@ann' }, provider_url: 'https://www.pexels.com/photo/7' };
|
||||
|
||||
beforeEach(() => {
|
||||
resetStockAvailability();
|
||||
fetchMock.mockReset();
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
(window as any).WHP_CONFIG = { apiUrl: '/api/site-builder.php', siteId: 5, csrfToken: 'tok' };
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => { root = createRoot(container); root.render(React.createElement(Probe)); });
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
delete (window as any).WHP_CONFIG;
|
||||
});
|
||||
|
||||
describe('useStockPhotos.search', () => {
|
||||
test('hits stock_search with site, encoded query and page; marks available', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: true, provider: 'pexels', results: [PHOTO], next_page: 2 }));
|
||||
let res: any;
|
||||
await act(async () => { res = await api.search('red fox', 1); });
|
||||
const url = fetchMock.mock.calls[0][0] as string;
|
||||
expect(url).toBe('/api/site-builder.php?action=stock_search&site_id=5&q=red%20fox&page=1');
|
||||
expect(res.results).toHaveLength(1);
|
||||
expect(res.nextPage).toBe(2);
|
||||
expect(api.availability).toBe('available');
|
||||
});
|
||||
|
||||
test('caches per query+page (second identical search does not fetch)', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: true, results: [PHOTO], next_page: null }));
|
||||
await act(async () => { await api.search('fox', 1); });
|
||||
await act(async () => { await api.search('fox', 1); });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { await api.search('fox', 2); });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('stock_disabled flips availability to disabled and returns null', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: false, error: 'stock_disabled' }));
|
||||
let res: any = 'x';
|
||||
await act(async () => { res = await api.search('fox'); });
|
||||
expect(res).toBeNull();
|
||||
expect(api.availability).toBe('disabled');
|
||||
});
|
||||
|
||||
test('a 404 (backend without the action) also reads as disabled', async () => {
|
||||
fetchMock.mockReturnValue(json({}, 404));
|
||||
await act(async () => { await api.search('fox'); });
|
||||
expect(api.availability).toBe('disabled');
|
||||
});
|
||||
|
||||
test('other failures surface an error but do not disable the tab', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: false, error: 'rate limited' }, 429));
|
||||
await act(async () => { await api.search('fox'); });
|
||||
expect(api.error).toBe('rate limited');
|
||||
expect(api.availability).toBe('unknown');
|
||||
});
|
||||
|
||||
test('a new query aborts the in-flight one', async () => {
|
||||
const signals: AbortSignal[] = [];
|
||||
fetchMock.mockImplementation((_u: string, init: any) => {
|
||||
signals.push(init.signal);
|
||||
return new Promise(() => { /* never resolves */ });
|
||||
});
|
||||
act(() => { void api.search('one'); });
|
||||
act(() => { void api.search('two'); });
|
||||
expect(signals[0].aborted).toBe(true);
|
||||
expect(signals[1].aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStockPhotos.importPhoto', () => {
|
||||
test('POSTs provider/id/size with the CSRF header and resolves the asset url', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: true, url: '/api/assets-proxy/?site_id=5&filename=fox.jpg', name: 'fox.jpg', type: 'image/jpeg', credit: PHOTO.credit }));
|
||||
let res: any;
|
||||
await act(async () => { res = await api.importPhoto(7, 'large'); });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/site-builder.php?action=stock_import&site_id=5');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers['X-CSRF-Token']).toBe('tok');
|
||||
expect(JSON.parse(init.body)).toEqual({ provider: 'pexels', id: 7, size: 'large' });
|
||||
expect(res.url).toBe('/api/assets-proxy/?site_id=5&filename=fox.jpg');
|
||||
expect(res.credit.name).toBe('Ann');
|
||||
});
|
||||
|
||||
test('failure returns null and sets error', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: false, error: 'download failed' }, 502));
|
||||
let res: any = 'x';
|
||||
await act(async () => { res = await api.importPhoto(7); });
|
||||
expect(res).toBeNull();
|
||||
expect(api.error).toBe('download failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('standalone', () => {
|
||||
test('no WHP_CONFIG: no fetch', async () => {
|
||||
delete (window as any).WHP_CONFIG;
|
||||
await act(async () => { await api.search('fox'); await api.importPhoto(1); });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
/* ---------- Stock photo search (#55) ----------
|
||||
Talks ONLY to the WHP backend proxy (`stock_search` / `stock_import`); no
|
||||
provider key or provider API URL ever reaches the browser. Thumbs/previews
|
||||
returned by the proxy are for the editor grid only -- "Use photo" imports
|
||||
the file as a normal site asset so the published page never hotlinks. */
|
||||
|
||||
export interface StockCredit { name: string; url: string }
|
||||
|
||||
export interface StockPhoto {
|
||||
id: string | number;
|
||||
thumb: string;
|
||||
preview: string;
|
||||
width: number;
|
||||
height: number;
|
||||
alt: string;
|
||||
credit: StockCredit;
|
||||
provider_url?: string;
|
||||
}
|
||||
|
||||
export interface StockSearchResult {
|
||||
results: StockPhoto[];
|
||||
nextPage: number | null;
|
||||
}
|
||||
|
||||
export interface ImportedStockPhoto {
|
||||
url: string;
|
||||
name: string;
|
||||
type: string;
|
||||
credit?: StockCredit;
|
||||
}
|
||||
|
||||
export type StockAvailability = 'unknown' | 'available' | 'disabled';
|
||||
|
||||
/* Availability is shared across hook instances for the editor session so the
|
||||
picker does not re-probe on every open. 'unknown' keeps the tab visible
|
||||
until a request proves the backend action is absent or unconfigured. */
|
||||
let sharedAvailability: StockAvailability = 'unknown';
|
||||
const listeners = new Set<(a: StockAvailability) => void>();
|
||||
|
||||
function setSharedAvailability(a: StockAvailability) {
|
||||
if (sharedAvailability === a) return;
|
||||
sharedAvailability = a;
|
||||
listeners.forEach((l) => l(a));
|
||||
}
|
||||
|
||||
/** Test seam: forget what a previous request learned about the backend. */
|
||||
export function resetStockAvailability() {
|
||||
sharedAvailability = 'unknown';
|
||||
}
|
||||
|
||||
function getCfg(): { apiUrl: string; siteId: number; csrfToken: string } | null {
|
||||
return typeof window === 'undefined' ? null : ((window as any).WHP_CONFIG || null);
|
||||
}
|
||||
|
||||
/** The backend answers `stock_disabled` with no key; an old backend without
|
||||
* the action answers 404 or an "unknown action" error -- both hide the tab. */
|
||||
function isDisabledSignal(status: number, data: any): boolean {
|
||||
if (status === 404) return true;
|
||||
const err = String(data?.error || '');
|
||||
return err === 'stock_disabled' || /unknown action|invalid action/i.test(err);
|
||||
}
|
||||
|
||||
export function useStockPhotos() {
|
||||
const [availability, setAvailability] = useState<StockAvailability>(sharedAvailability);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cache = useRef(new Map<string, StockSearchResult>());
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(setAvailability);
|
||||
setAvailability(sharedAvailability);
|
||||
return () => {
|
||||
listeners.delete(setAvailability);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** Returns the page of results, or null on disabled/error/aborted (see `error`/`availability`). */
|
||||
const search = useCallback(async (q: string, page = 1): Promise<StockSearchResult | null> => {
|
||||
const cfg = getCfg();
|
||||
const query = q.trim();
|
||||
if (!cfg || !query) return null;
|
||||
const key = `${query}\u0000${page}`;
|
||||
const hit = cache.current.get(key);
|
||||
if (hit) { setError(null); return hit; }
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = `${cfg.apiUrl}?action=stock_search&site_id=${cfg.siteId}&q=${encodeURIComponent(query)}&page=${page}`;
|
||||
const resp = await fetch(url, { headers: { 'X-CSRF-Token': cfg.csrfToken }, signal: ctrl.signal });
|
||||
let data: any = null;
|
||||
try { data = await resp.json(); } catch { /* non-JSON body */ }
|
||||
if (isDisabledSignal(resp.status, data)) { setSharedAvailability('disabled'); return null; }
|
||||
if (!resp.ok || !data?.success || !Array.isArray(data.results)) {
|
||||
throw new Error(data?.error || `Search failed: ${resp.status}`);
|
||||
}
|
||||
setSharedAvailability('available');
|
||||
const result: StockSearchResult = {
|
||||
results: data.results,
|
||||
nextPage: typeof data.next_page === 'number' ? data.next_page : null,
|
||||
};
|
||||
cache.current.set(key, result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (ctrl.signal.aborted) return null;
|
||||
setError(e instanceof Error ? e.message : 'Search failed');
|
||||
return null;
|
||||
} finally {
|
||||
if (abortRef.current === ctrl) { abortRef.current = null; setLoading(false); }
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Downloads the chosen photo into the site's assets; resolves the asset URL. */
|
||||
const importPhoto = useCallback(async (id: string | number, size = 'large'): Promise<ImportedStockPhoto | null> => {
|
||||
const cfg = getCfg();
|
||||
if (!cfg) return null;
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=stock_import&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'pexels', id, size }),
|
||||
});
|
||||
let data: any = null;
|
||||
try { data = await resp.json(); } catch { /* non-JSON body */ }
|
||||
if (isDisabledSignal(resp.status, data)) { setSharedAvailability('disabled'); return null; }
|
||||
if (!resp.ok || !data?.success || !data.url) throw new Error(data?.error || `Import failed: ${resp.status}`);
|
||||
return { url: data.url, name: data.name || '', type: data.type || 'image', credit: data.credit };
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { availability, loading, error, search, importPhoto };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 { AssetsPanel } from './AssetsPanel';
|
||||
import { EditorConfigProvider } from '../../state/EditorConfigContext';
|
||||
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const CFG: any = { user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, siteDomain: 'd', siteName: 'n', backUrl: '', isRoot: false };
|
||||
const ASSETS = [
|
||||
{ name: 'Hero-Banner.png', url: '/a/1', type: 'image/png' },
|
||||
{ name: 'team.jpg', url: '/a/2', type: 'image/jpeg' },
|
||||
{ name: 'intro.mp4', url: '/a/3', type: 'video/mp4' },
|
||||
{ name: 'menu.pdf', url: '/a/4', type: 'application/pdf' },
|
||||
];
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const q = (id: string) => container.querySelector(`[data-testid="${id}"]`) as HTMLElement | null;
|
||||
const names = () => Array.from(container.querySelectorAll('[title^="Click to copy URL"]')).map((e) => (e.getAttribute('title') || '').replace('Click to copy URL: ', ''));
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
(globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({ success: true, assets: ASSETS }) });
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
await act(async () => {
|
||||
root = createRoot(container);
|
||||
root.render(<EditorConfigProvider config={CFG}><AssetsPanel /></EditorConfigProvider>);
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('AssetsPanel search + chips (#55)', () => {
|
||||
test('lists everything with no filter', () => {
|
||||
expect(names()).toEqual(['/a/1', '/a/2', '/a/3', '/a/4']);
|
||||
});
|
||||
|
||||
test('typing filters by name (case-insensitive) after the debounce', () => {
|
||||
const input = q('assets-filter-input') as HTMLInputElement;
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, 'TEAM');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
expect(names()).toHaveLength(4);
|
||||
act(() => { vi.advanceTimersByTime(160); });
|
||||
expect(names()).toEqual(['/a/2']);
|
||||
});
|
||||
|
||||
test('chips filter by type, and a no-match query shows the empty notice', () => {
|
||||
act(() => { q('assets-filter-chip-image')!.click(); });
|
||||
expect(names()).toEqual(['/a/1', '/a/2']);
|
||||
act(() => { q('assets-filter-chip-image')!.click(); });
|
||||
act(() => { q('assets-filter-chip-doc')!.click(); });
|
||||
expect(names()).toEqual(['/a/4']);
|
||||
const input = q('assets-filter-input') as HTMLInputElement;
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, 'zzzz');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
act(() => { vi.advanceTimersByTime(160); });
|
||||
expect(names()).toEqual([]);
|
||||
expect(q('assets-no-match')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useAssets } from '../../hooks/useAssets';
|
||||
import { clickableProps } from '../../utils/a11y';
|
||||
import { copyToClipboard } from '../../utils/clipboard';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { AssetFilterBar, useAssetFilter } from '../../ui/AssetFilter';
|
||||
|
||||
/** How long the "Delete?" confirm state stays armed before auto-resetting. */
|
||||
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
|
||||
@@ -10,6 +11,7 @@ const DELETE_CONFIRM_TIMEOUT_MS = 4000;
|
||||
export const AssetsPanel: React.FC = () => {
|
||||
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
|
||||
const isMobile = useIsMobile();
|
||||
const filter = useAssetFilter(assets);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||
@@ -162,6 +164,22 @@ export const AssetsPanel: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search + type chips (#55) -- only worth showing once there is something to filter */}
|
||||
{assets.length > 0 && (
|
||||
<AssetFilterBar
|
||||
query={filter.query}
|
||||
onQuery={filter.setQuery}
|
||||
kind={filter.kind}
|
||||
onKind={filter.setKind}
|
||||
testIdPrefix="assets-filter"
|
||||
/>
|
||||
)}
|
||||
{filter.active && filter.filtered.length === 0 && assets.length > 0 && (
|
||||
<div data-testid="assets-no-match" style={{ textAlign: 'center', padding: 10, color: 'var(--color-text-dim)', fontSize: 11 }}>
|
||||
No assets match your search.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset grid */}
|
||||
<div
|
||||
style={{
|
||||
@@ -170,7 +188,7 @@ export const AssetsPanel: React.FC = () => {
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{assets.map((asset) => {
|
||||
{filter.filtered.map((asset) => {
|
||||
const isConfirmingDelete = confirmDeleteName === asset.name;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
inputStyle,
|
||||
sectionGap,
|
||||
useNodeProp,
|
||||
FocalPointGrid,
|
||||
} from './shared';
|
||||
import { useResolvedStyle, PADDING_KEYS } from './responsive-panel';
|
||||
import { OverrideDot } from './OverrideDot';
|
||||
@@ -32,6 +33,10 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
|
||||
{/* Background Image */}
|
||||
<CollapsibleSection title="Background Image">
|
||||
<AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
|
||||
{/* #55: focal point -> background-position (only once an image is set) */}
|
||||
{nodeProps.bgImage && (
|
||||
<FocalPointGrid value={nodeProps.bgPosition || ''} onChange={(v) => setProp('bgPosition', v)} />
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Background Color */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
btnActiveStyle,
|
||||
FocalPointGrid,
|
||||
sectionGap,
|
||||
useNodeProp,
|
||||
} from './shared';
|
||||
@@ -81,6 +82,10 @@ export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Background Image</label>
|
||||
<AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
|
||||
{/* #55: focal point -> background-position (only once an image is set) */}
|
||||
{nodeProps.bgImage && (
|
||||
<FocalPointGrid value={nodeProps.bgPosition || ''} onChange={(v) => setProp('bgPosition', v)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
value={nodeProps.src || ''}
|
||||
onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url || PLACEHOLDER_SRC; })}
|
||||
variant="full"
|
||||
// #55: a stock photo carries its own alt/credit; only fill an EMPTY alt.
|
||||
onPick={(a) => {
|
||||
if (!nodeProps.alt && a.alt) actions.setProp(selectedId, (props: any) => { props.alt = a.alt; });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3187,3 +3187,34 @@ body {
|
||||
}
|
||||
.section-lib-empty { grid-column: 1 / -1; color: var(--color-text-muted); font-size: 13px; padding: 24px 0; text-align: center; }
|
||||
.block-browse-sections { display: block; width: calc(100% - 16px); margin: 0 8px 8px; }
|
||||
|
||||
/* ---------- #55 media search, stock photos, focal point ---------- */
|
||||
.asset-filter { display: flex; flex-direction: column; gap: 6px; }
|
||||
.asset-filter-input { width: 100%; font-size: 11px; box-sizing: border-box; }
|
||||
.asset-filter-chips { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.asset-filter-chip {
|
||||
padding: 2px 8px; font-size: 10px; border-radius: 999px; cursor: pointer;
|
||||
border: 1px solid var(--color-border); background: transparent; color: var(--color-text-muted);
|
||||
}
|
||||
.asset-filter-chip:hover { border-color: var(--color-text-muted); }
|
||||
.asset-filter-chip.active { background: var(--color-accent); border-color: var(--color-accent); color: #fff; }
|
||||
|
||||
.asset-picker-tabs { display: flex; gap: 2px; margin-top: 8px; border-bottom: 1px solid var(--color-border); }
|
||||
.asset-picker-tab {
|
||||
flex: 1; padding: 5px 4px; font-size: 11px; cursor: pointer; background: transparent;
|
||||
border: none; border-bottom: 2px solid transparent; color: var(--color-text-muted);
|
||||
}
|
||||
.asset-picker-tab.active { color: var(--color-text); border-bottom-color: var(--color-accent); }
|
||||
|
||||
.stock-tab { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||
.stock-grid { column-count: 2; column-gap: 4px; max-height: 240px; overflow-y: auto; }
|
||||
.stock-thumb {
|
||||
display: block; width: 100%; margin: 0 0 4px; padding: 0; break-inside: avoid; cursor: pointer;
|
||||
border: 2px solid transparent; border-radius: 4px; overflow: hidden; background: var(--color-bg-base);
|
||||
}
|
||||
.stock-thumb.selected { border-color: var(--color-accent); }
|
||||
.stock-thumb img { display: block; width: 100%; height: auto; }
|
||||
.stock-credit, .stock-footer { font-size: 10px; color: var(--color-text-dim); }
|
||||
.stock-credit a, .stock-footer a { color: inherit; text-decoration: underline; }
|
||||
.stock-status { font-size: 11px; color: var(--color-text-dim); text-align: center; padding: 10px 0; }
|
||||
.stock-status.error { color: var(--color-danger); }
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
/* ---------- Asset search + type chips (#55) ----------
|
||||
Shared by the Assets panel and the asset picker's Library grid. Filtering
|
||||
is client-side over `name` and `type`; the query is debounced 150 ms. */
|
||||
|
||||
export type AssetKind = 'image' | 'video' | 'doc';
|
||||
export const ASSET_FILTER_DEBOUNCE_MS = 150;
|
||||
|
||||
const KIND_CHIPS: { kind: AssetKind; label: string }[] = [
|
||||
{ kind: 'image', label: 'Images' },
|
||||
{ kind: 'video', label: 'Videos' },
|
||||
{ kind: 'doc', label: 'Docs' },
|
||||
];
|
||||
|
||||
interface Filterable { name: string; type: string }
|
||||
|
||||
/** Classify by MIME `type`, falling back to the file extension (type may be a bare 'file'). */
|
||||
export function assetKind(a: Filterable): AssetKind {
|
||||
const t = (a.type || '').toLowerCase();
|
||||
if (t.startsWith('image/') || /\.(jpe?g|png|gif|svg|webp|avif|ico)$/i.test(a.name || '')) return 'image';
|
||||
if (t.startsWith('video/') || /\.(mp4|webm|mov|ogv|m4v)$/i.test(a.name || '')) return 'video';
|
||||
return 'doc';
|
||||
}
|
||||
|
||||
/** Case-insensitive match over name and type; `kind` null means all types. */
|
||||
export function filterAssets<T extends Filterable>(assets: T[], query: string, kind: AssetKind | null): T[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return assets.filter((a) => {
|
||||
if (kind && assetKind(a) !== kind) return false;
|
||||
if (!q) return true;
|
||||
return (a.name || '').toLowerCase().includes(q) || (a.type || '').toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssetFilter<T extends Filterable>(assets: T[]) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [kind, setKind] = useState<AssetKind | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(query), ASSET_FILTER_DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
const filtered = useMemo(() => filterAssets(assets, debounced, kind), [assets, debounced, kind]);
|
||||
const active = debounced.trim() !== '' || kind !== null;
|
||||
return { query, setQuery, kind, setKind, filtered, active };
|
||||
}
|
||||
|
||||
interface BarProps {
|
||||
query: string;
|
||||
onQuery: (q: string) => void;
|
||||
kind: AssetKind | null;
|
||||
onKind: (k: AssetKind | null) => void;
|
||||
/** Hide chips that make no sense (e.g. picker with mediaType 'image'). */
|
||||
kinds?: AssetKind[];
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export const AssetFilterBar: React.FC<BarProps> = ({ query, onQuery, kind, onKind, kinds, testIdPrefix = 'asset-filter' }) => (
|
||||
<div className="asset-filter" data-testid={testIdPrefix}>
|
||||
<input
|
||||
type="search"
|
||||
className="guided-input asset-filter-input"
|
||||
value={query}
|
||||
placeholder="Search by name or type..."
|
||||
aria-label="Search assets"
|
||||
onChange={(e) => onQuery(e.target.value)}
|
||||
data-testid={`${testIdPrefix}-input`}
|
||||
/>
|
||||
{(!kinds || kinds.length > 0) && (
|
||||
<div className="asset-filter-chips" role="group" aria-label="Filter by type">
|
||||
{KIND_CHIPS.filter((c) => !kinds || kinds.includes(c.kind)).map((c) => (
|
||||
<button
|
||||
key={c.kind}
|
||||
type="button"
|
||||
className={`asset-filter-chip${kind === c.kind ? ' active' : ''}`}
|
||||
aria-pressed={kind === c.kind}
|
||||
onClick={() => onKind(kind === c.kind ? null : c.kind)}
|
||||
data-testid={`${testIdPrefix}-chip-${c.kind}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -11,6 +11,7 @@ vi.mock('../utils/assets', () => ({
|
||||
}));
|
||||
|
||||
import { uploadAsset, listAssets } from '../utils/assets';
|
||||
import { resetStockAvailability } from '../hooks/useStockPhotos';
|
||||
|
||||
const uploadAssetMock = uploadAsset as unknown as ReturnType<typeof vi.fn>;
|
||||
const listAssetsMock = listAssets as unknown as ReturnType<typeof vi.fn>;
|
||||
@@ -202,3 +203,89 @@ describe('AssetPicker component', () => {
|
||||
expect(q<HTMLInputElement>('asset-picker-file-input')!.accept).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- #55 library search/filter + Stock photos entry ---------- */
|
||||
describe('AssetPicker library filter and stock entry (#55)', () => {
|
||||
const MIXED: Asset[] = [
|
||||
{ name: 'Hero-Banner.png', url: '/a/1', type: 'image/png' },
|
||||
{ name: 'team.jpg', url: '/a/2', type: 'image/jpeg' },
|
||||
{ name: 'intro.mp4', url: '/a/3', type: 'video/mp4' },
|
||||
{ name: 'menu.pdf', url: '/a/4', type: 'application/pdf' },
|
||||
];
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetStockAvailability();
|
||||
fetchMock.mockReset();
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
listAssetsMock.mockResolvedValue(MIXED);
|
||||
(window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' };
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
delete (window as any).WHP_CONFIG;
|
||||
});
|
||||
|
||||
const thumbs = () => Array.from(container.querySelectorAll('[data-testid^="asset-picker-thumb-"]')).map((e) => e.getAttribute('data-testid')!.replace('asset-picker-thumb-', ''));
|
||||
|
||||
test('typing filters the grid by name, case-insensitively, after the 150 ms debounce', async () => {
|
||||
render(<AssetPicker value="" onChange={vi.fn()} mediaType="any" />);
|
||||
await act(async () => { click(q('asset-picker-browse')); await Promise.resolve(); });
|
||||
expect(thumbs()).toHaveLength(4);
|
||||
act(() => { setValue(q<HTMLInputElement>('asset-picker-filter-input')!, 'HERO'); });
|
||||
expect(thumbs()).toHaveLength(4); // not yet debounced
|
||||
act(() => { vi.advanceTimersByTime(160); });
|
||||
expect(thumbs()).toEqual(['Hero-Banner.png']);
|
||||
});
|
||||
|
||||
test('type chips filter by kind and toggle off', async () => {
|
||||
render(<AssetPicker value="" onChange={vi.fn()} mediaType="any" />);
|
||||
await act(async () => { click(q('asset-picker-browse')); await Promise.resolve(); });
|
||||
click(q('asset-picker-filter-chip-video'));
|
||||
expect(thumbs()).toEqual(['intro.mp4']);
|
||||
click(q('asset-picker-filter-chip-doc'));
|
||||
expect(thumbs()).toEqual(['menu.pdf']);
|
||||
click(q('asset-picker-filter-chip-doc'));
|
||||
expect(thumbs()).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('Stock entry is absent in standalone mode', () => {
|
||||
delete (window as any).WHP_CONFIG;
|
||||
render(<AssetPicker value="" onChange={vi.fn()} />);
|
||||
expect(q('asset-picker-stock')).toBeNull();
|
||||
});
|
||||
|
||||
test('Stock entry is absent for non-image pickers', () => {
|
||||
render(<AssetPicker value="" onChange={vi.fn()} mediaType="video" />);
|
||||
expect(q('asset-picker-stock')).toBeNull();
|
||||
});
|
||||
|
||||
test('Stock entry is present in WHP mode for images, and disappears once the API reports stock_disabled', async () => {
|
||||
render(<AssetPicker value="" onChange={vi.fn()} />);
|
||||
expect(q('asset-picker-stock')).not.toBeNull();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({ success: false, error: 'stock_disabled' }) });
|
||||
click(q('asset-picker-stock'));
|
||||
setValue(q<HTMLInputElement>('stock-search-input')!, 'fox');
|
||||
await act(async () => { q<HTMLElement>('stock-search-btn')!.click(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); });
|
||||
expect(q('asset-picker-stock')).toBeNull();
|
||||
});
|
||||
|
||||
test('picking a stock photo calls onChange(url) and onPick with alt/credit', async () => {
|
||||
const onChange = vi.fn();
|
||||
const onPick = vi.fn();
|
||||
render(<AssetPicker value="" onChange={onChange} onPick={onPick} />);
|
||||
click(q('asset-picker-stock'));
|
||||
const credit = { name: 'Ann', url: 'https://www.pexels.com/@ann' };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ success: true, results: [{ id: 7, thumb: 't', preview: 'p', width: 1, height: 1, alt: 'fox', credit }], next_page: null }) })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ success: true, url: '/api/assets-proxy/?site_id=1&filename=f.jpg', name: 'f.jpg', type: 'image/jpeg', credit }) });
|
||||
setValue(q<HTMLInputElement>('stock-search-input')!, 'fox');
|
||||
await act(async () => { q<HTMLElement>('stock-search-btn')!.click(); for (let i = 0; i < 5; i++) await Promise.resolve(); });
|
||||
click(q('stock-thumb-7'));
|
||||
await act(async () => { q<HTMLElement>('stock-use')!.click(); for (let i = 0; i < 5; i++) await Promise.resolve(); });
|
||||
expect(onChange).toHaveBeenCalledWith('/api/assets-proxy/?site_id=1&filename=f.jpg');
|
||||
expect(onPick).toHaveBeenCalledWith({ url: '/api/assets-proxy/?site_id=1&filename=f.jpg', alt: 'fox', credit });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react';
|
||||
import { uploadAsset, listAssets, Asset } from '../utils/assets';
|
||||
import { clickableProps } from '../utils/a11y';
|
||||
import { AssetFilterBar, useAssetFilter } from './AssetFilter';
|
||||
import { StockPhotoTab, PickedAsset } from './StockPhotoTab';
|
||||
import { useStockPhotos } from '../hooks/useStockPhotos';
|
||||
|
||||
export interface AssetPickerProps {
|
||||
value: string;
|
||||
@@ -10,6 +13,9 @@ export interface AssetPickerProps {
|
||||
/** 'full' mirrors ImageStylePanel's source UI; 'compact' is a single tight row for array-editor cards. */
|
||||
variant?: 'full' | 'compact';
|
||||
placeholder?: string;
|
||||
/** Richer pick callback (#55): fired alongside `onChange` for stock photos with the
|
||||
* photo's alt text and credit, so a caller (Image panel) can prefill `alt`. */
|
||||
onPick?: (asset: PickedAsset) => void;
|
||||
}
|
||||
|
||||
/* ---------- Pure helpers (exported for unit testing) ---------- */
|
||||
@@ -110,14 +116,20 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
mediaType = 'image',
|
||||
variant = 'full',
|
||||
placeholder = 'Or paste URL...',
|
||||
onPick,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
const [showStock, setShowStock] = 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();
|
||||
const filter = useAssetFilter(browserAssets);
|
||||
const { availability: stockAvailability } = useStockPhotos();
|
||||
// Stock photos: images only, WHP mode only, and hidden once the backend reports it disabled/absent.
|
||||
const stockEnabled = !standalone && mediaType === 'image' && stockAvailability !== 'disabled';
|
||||
|
||||
useEffect(() => {
|
||||
setUrlValue(value || '');
|
||||
@@ -136,6 +148,7 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
setShowBrowser(false);
|
||||
return;
|
||||
}
|
||||
setShowStock(false);
|
||||
setBrowserLoading(true);
|
||||
try {
|
||||
const assets = await listAssets(mediaType);
|
||||
@@ -152,6 +165,18 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
setShowBrowser(false);
|
||||
}, [onChange]);
|
||||
|
||||
const handleStockPick = useCallback((asset: PickedAsset) => {
|
||||
onChange(asset.url);
|
||||
setUrlValue(asset.url);
|
||||
onPick?.(asset);
|
||||
setShowStock(false);
|
||||
}, [onChange, onPick]);
|
||||
|
||||
const handleStockToggle = useCallback(() => {
|
||||
setShowBrowser(false);
|
||||
setShowStock((v) => !v);
|
||||
}, []);
|
||||
|
||||
const handleApplyUrl = useCallback(() => {
|
||||
onChange(urlValue);
|
||||
}, [urlValue, onChange]);
|
||||
@@ -188,9 +213,19 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const grid = showBrowser && (
|
||||
<div style={gridStyle} data-testid="asset-picker-grid">
|
||||
{browserAssets.map((asset) => (
|
||||
const grid = showBrowser && (<>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<AssetFilterBar
|
||||
query={filter.query}
|
||||
onQuery={filter.setQuery}
|
||||
kind={filter.kind}
|
||||
onKind={filter.setKind}
|
||||
kinds={mediaType === 'any' ? undefined : []}
|
||||
testIdPrefix="asset-picker-filter"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ ...gridStyle, marginTop: 4 }} data-testid="asset-picker-grid">
|
||||
{filter.filtered.map((asset) => (
|
||||
<div
|
||||
key={asset.name}
|
||||
{...clickableProps(() => handleSelectAsset(asset))}
|
||||
@@ -205,7 +240,27 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
No assets uploaded yet. Use Upload above.
|
||||
</p>
|
||||
)}
|
||||
{browserAssets.length > 0 && filter.filtered.length === 0 && (
|
||||
<p data-testid="asset-picker-no-match" style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>
|
||||
No assets match your search.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>);
|
||||
|
||||
const stockPanel = showStock && stockEnabled && <StockPhotoTab onPick={handleStockPick} />;
|
||||
|
||||
const stockButton = stockEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStockToggle}
|
||||
data-testid="asset-picker-stock"
|
||||
title="Stock photos"
|
||||
style={variant === 'full' ? browseBtnStyle(showStock) : { ...browseBtnStyle(showStock), flex: 'none', padding: '6px 8px' }}
|
||||
>
|
||||
<i className="fa fa-picture-o" style={{ marginRight: variant === 'full' ? 4 : 0 }} />
|
||||
{variant === 'full' && 'Stock'}
|
||||
</button>
|
||||
);
|
||||
|
||||
const browseButton = !standalone && (
|
||||
@@ -250,6 +305,7 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
<i className="fa fa-upload" />
|
||||
</button>
|
||||
{browseButton}
|
||||
{stockButton}
|
||||
<input
|
||||
type="text"
|
||||
className="guided-input"
|
||||
@@ -263,6 +319,7 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{grid}
|
||||
{stockPanel}
|
||||
{fileInput}
|
||||
</div>
|
||||
);
|
||||
@@ -307,9 +364,11 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
|
||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
||||
</button>
|
||||
{browseButton}
|
||||
{stockButton}
|
||||
</div>
|
||||
|
||||
{grid}
|
||||
{stockPanel}
|
||||
{fileInput}
|
||||
|
||||
<div style={{ marginTop: 6 }}>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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 { StockPhotoTab } from './StockPhotoTab';
|
||||
import { resetStockAvailability } from '../hooks/useStockPhotos';
|
||||
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const fetchMock = vi.fn();
|
||||
const json = (body: any, status = 200) =>
|
||||
Promise.resolve({ ok: status < 300, status, json: () => Promise.resolve(body) } as Response);
|
||||
const q = (id: string) => container.querySelector(`[data-testid="${id}"]`) as HTMLElement | null;
|
||||
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||
function setValue(input: HTMLInputElement, value: string) {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
const PHOTO = { id: 7, thumb: 't.jpg', preview: 'p.jpg', width: 10, height: 20, alt: 'a red fox', credit: { name: 'Ann', url: 'https://www.pexels.com/@ann' } };
|
||||
|
||||
beforeEach(() => {
|
||||
resetStockAvailability();
|
||||
fetchMock.mockReset();
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
(window as any).WHP_CONFIG = { apiUrl: '/api', siteId: 5, csrfToken: 'tok' };
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
delete (window as any).WHP_CONFIG;
|
||||
});
|
||||
|
||||
function mount(onPick = vi.fn()) {
|
||||
act(() => { root = createRoot(container); root.render(<StockPhotoTab onPick={onPick} />); });
|
||||
return onPick;
|
||||
}
|
||||
|
||||
async function searchAndSelect() {
|
||||
fetchMock.mockReturnValueOnce(json({ success: true, results: [PHOTO], next_page: null }));
|
||||
setValueIn('stock-search-input', 'fox');
|
||||
await act(async () => { q('stock-search-btn')!.click(); });
|
||||
await flush();
|
||||
act(() => { q('stock-thumb-7')!.click(); });
|
||||
}
|
||||
function setValueIn(id: string, v: string) { act(() => { setValue(q(id) as HTMLInputElement, v); }); }
|
||||
|
||||
describe('StockPhotoTab', () => {
|
||||
test('Use photo imports then calls onPick({url, alt, credit})', async () => {
|
||||
const onPick = mount();
|
||||
await searchAndSelect();
|
||||
expect(q('stock-credit')!.textContent).toBe('Photo by Ann on Pexels');
|
||||
fetchMock.mockReturnValueOnce(json({ success: true, url: '/api/assets-proxy/?site_id=5&filename=fox.jpg', name: 'fox.jpg', type: 'image/jpeg', credit: PHOTO.credit }));
|
||||
await act(async () => { q('stock-use')!.click(); });
|
||||
await flush();
|
||||
expect(onPick).toHaveBeenCalledWith({ url: '/api/assets-proxy/?site_id=5&filename=fox.jpg', alt: 'a red fox', credit: PHOTO.credit });
|
||||
});
|
||||
|
||||
test('empty alt falls back to the credit line', async () => {
|
||||
const onPick = mount();
|
||||
fetchMock.mockReturnValueOnce(json({ success: true, results: [{ ...PHOTO, alt: '' }], next_page: null }));
|
||||
setValueIn('stock-search-input', 'fox');
|
||||
await act(async () => { q('stock-search-btn')!.click(); });
|
||||
await flush();
|
||||
act(() => { q('stock-thumb-7')!.click(); });
|
||||
fetchMock.mockReturnValueOnce(json({ success: true, url: '/u', name: 'n', type: 'image/jpeg' }));
|
||||
await act(async () => { q('stock-use')!.click(); });
|
||||
await flush();
|
||||
expect(onPick.mock.calls[0][0].alt).toBe('Photo by Ann on Pexels');
|
||||
});
|
||||
|
||||
test('no results shows the empty state', async () => {
|
||||
mount();
|
||||
fetchMock.mockReturnValueOnce(json({ success: true, results: [], next_page: null }));
|
||||
setValueIn('stock-search-input', 'zzz');
|
||||
await act(async () => { q('stock-search-btn')!.click(); });
|
||||
await flush();
|
||||
expect(q('stock-empty')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('stock_disabled replaces the tab with the disabled notice', async () => {
|
||||
mount();
|
||||
fetchMock.mockReturnValueOnce(json({ success: false, error: 'stock_disabled' }));
|
||||
setValueIn('stock-search-input', 'fox');
|
||||
await act(async () => { q('stock-search-btn')!.click(); });
|
||||
await flush();
|
||||
expect(q('stock-disabled')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a failed import does not call onPick and shows the error', async () => {
|
||||
const onPick = mount();
|
||||
await searchAndSelect();
|
||||
fetchMock.mockReturnValueOnce(json({ success: false, error: 'download failed' }, 502));
|
||||
await act(async () => { q('stock-use')!.click(); });
|
||||
await flush();
|
||||
expect(onPick).not.toHaveBeenCalled();
|
||||
expect(q('stock-error')!.textContent).toBe('download failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useStockPhotos, StockPhoto, StockCredit } from '../hooks/useStockPhotos';
|
||||
|
||||
export interface PickedAsset {
|
||||
url: string;
|
||||
alt?: string;
|
||||
credit?: StockCredit;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Called with the imported (site-local) asset once "Use photo" completes. */
|
||||
onPick: (asset: PickedAsset) => void;
|
||||
}
|
||||
|
||||
const httpsOnly = (u?: string) => (u && /^https:\/\//i.test(u) ? u : undefined);
|
||||
|
||||
export const creditLine = (c?: StockCredit) => (c?.name ? `Photo by ${c.name} on Pexels` : 'Photo from Pexels');
|
||||
|
||||
export const StockPhotoTab: React.FC<Props> = ({ onPick }) => {
|
||||
const { availability, loading, error, search, importPhoto } = useStockPhotos();
|
||||
const [query, setQuery] = useState('');
|
||||
const [submitted, setSubmitted] = useState('');
|
||||
const [photos, setPhotos] = useState<StockPhoto[]>([]);
|
||||
const [nextPage, setNextPage] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<StockPhoto | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
const runSearch = useCallback(async () => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setSubmitted(q);
|
||||
setSelected(null);
|
||||
const res = await search(q, 1);
|
||||
if (res) { setPhotos(res.results); setNextPage(res.nextPage); setSearched(true); }
|
||||
else setPhotos([]);
|
||||
}, [query, search]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (nextPage == null) return;
|
||||
const res = await search(submitted, nextPage);
|
||||
if (res) { setPhotos((p) => [...p, ...res.results]); setNextPage(res.nextPage); }
|
||||
}, [nextPage, search, submitted]);
|
||||
|
||||
const usePhoto = useCallback(async () => {
|
||||
if (!selected) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const imported = await importPhoto(selected.id, 'large');
|
||||
if (!imported) return;
|
||||
const credit = imported.credit || selected.credit;
|
||||
onPick({ url: imported.url, alt: selected.alt || creditLine(credit), credit });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [selected, importPhoto, onPick]);
|
||||
|
||||
if (availability === 'disabled') {
|
||||
return (
|
||||
<div className="stock-tab" data-testid="stock-disabled">
|
||||
<div className="stock-status">Stock photos are not enabled for this server.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stock-tab" data-testid="stock-tab">
|
||||
<form
|
||||
className="guided-input-row"
|
||||
onSubmit={(e) => { e.preventDefault(); runSearch(); }}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
className="guided-input"
|
||||
value={query}
|
||||
placeholder="Search free photos..."
|
||||
aria-label="Search stock photos"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
data-testid="stock-search-input"
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
<button type="submit" className="preset-btn apply-btn" disabled={loading || !query.trim()} data-testid="stock-search-btn">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <div className="stock-status error" role="alert" data-testid="stock-error">{error}</div>}
|
||||
{loading && photos.length === 0 && <div className="stock-status">Searching...</div>}
|
||||
{!loading && !error && searched && photos.length === 0 && (
|
||||
<div className="stock-status" data-testid="stock-empty">No photos found for “{submitted}”.</div>
|
||||
)}
|
||||
|
||||
{photos.length > 0 && (
|
||||
<div className="stock-grid" data-testid="stock-grid">
|
||||
{photos.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`stock-thumb${selected?.id === p.id ? ' selected' : ''}`}
|
||||
onClick={() => setSelected(p)}
|
||||
aria-label={p.alt || `Photo ${p.id}`}
|
||||
aria-pressed={selected?.id === p.id}
|
||||
data-testid={`stock-thumb-${p.id}`}
|
||||
style={p.width && p.height ? { aspectRatio: `${p.width} / ${p.height}` } : undefined}
|
||||
>
|
||||
<img src={p.thumb} alt={p.alt || ''} loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{nextPage != null && photos.length > 0 && (
|
||||
<button type="button" className="preset-btn" onClick={loadMore} disabled={loading} data-testid="stock-more">
|
||||
{loading ? 'Loading...' : 'Load more'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div data-testid="stock-selected">
|
||||
<div className="stock-credit" data-testid="stock-credit">
|
||||
{httpsOnly(selected.credit?.url) ? (
|
||||
<>Photo by <a href={httpsOnly(selected.credit.url)} target="_blank" rel="noopener noreferrer">{selected.credit.name}</a> on Pexels</>
|
||||
) : creditLine(selected.credit)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="preset-btn apply-btn"
|
||||
onClick={usePhoto}
|
||||
disabled={importing}
|
||||
data-testid="stock-use"
|
||||
style={{ marginTop: 4, width: '100%' }}
|
||||
>
|
||||
{importing ? 'Importing...' : 'Use photo'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stock-footer">
|
||||
<a href="https://www.pexels.com" target="_blank" rel="noopener noreferrer">Photos from Pexels</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user