stock photos: Pixabay with the user's own API key (Add-key prompt, attribution)
useStockPhotos gains key status/save/test/remove (key only ever sent in a POST body; the backend returns configured + last4). The Stock tab shows an Add your Pixabay key prompt when no key is saved or Pixabay rejects it, a masked change/remove row, 'Images by Pixabay' attribution link, contributor and photo-page links. Provider is pixabay; Pexels references removed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
This commit is contained in:
1 parent
da860e2dae
commit
8a503c4267
5 files changed
+486
-27
No files matched your search
@@ -17,7 +17,7 @@ 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' };
|
||||
const PHOTO = { id: 7, thumb: 't.jpg', preview: 'p.jpg', width: 10, height: 20, alt: 'a cat', credit: { name: 'Ann', url: 'https://pixabay.com/users/Ann-1/' }, provider_url: 'https://pixabay.com/photos/cat-7/' };
|
||||
|
||||
beforeEach(() => {
|
||||
resetStockAvailability();
|
||||
@@ -36,7 +36,7 @@ afterEach(() => {
|
||||
|
||||
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 }));
|
||||
fetchMock.mockReturnValue(json({ success: true, provider: 'pixabay', 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;
|
||||
@@ -55,7 +55,7 @@ describe('useStockPhotos.search', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('stock_disabled flips availability to disabled and returns null', async () => {
|
||||
test('legacy 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'); });
|
||||
@@ -146,7 +146,7 @@ describe('useStockPhotos.importPhoto', () => {
|
||||
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(JSON.parse(init.body)).toEqual({ provider: 'pixabay', id: 7, size: 'large' });
|
||||
expect(res.url).toBe('/api/assets-proxy/?site_id=5&filename=fox.jpg');
|
||||
expect(res.credit.name).toBe('Ann');
|
||||
});
|
||||
@@ -160,6 +160,123 @@ describe('useStockPhotos.importPhoto', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStockPhotos: the user\'s own Pixabay key', () => {
|
||||
const NO_KEY = { success: false, error: 'Add your free Pixabay API key to search stock photos.', code: 'no_key' };
|
||||
|
||||
test('search answering no_key shows the key prompt state, does NOT hide the tab and sets no error', async () => {
|
||||
fetchMock.mockReturnValue(json(NO_KEY));
|
||||
let res: any = 'x';
|
||||
await act(async () => { res = await api.search('fox'); });
|
||||
expect(res).toBeNull();
|
||||
expect(api.keyInfo).toEqual({ configured: false, last4: '' });
|
||||
expect(api.availability).toBe('available');
|
||||
expect(api.error).toBeNull();
|
||||
});
|
||||
|
||||
test('key_rejected also flips to the prompt, with the backend message as the error', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: false, error: 'Pixabay did not accept your API key.', code: 'key_rejected' }, 422));
|
||||
await act(async () => { await api.search('fox'); });
|
||||
expect(api.keyInfo?.configured).toBe(false);
|
||||
expect(api.error).toBe('Pixabay did not accept your API key.');
|
||||
expect(api.availability).not.toBe('disabled');
|
||||
});
|
||||
|
||||
test('import answering no_key returns null and flips to the prompt', async () => {
|
||||
fetchMock.mockReturnValue(json(NO_KEY));
|
||||
let res: any = 'x';
|
||||
await act(async () => { res = await api.importPhoto(7); });
|
||||
expect(res).toBeNull();
|
||||
expect(api.keyInfo?.configured).toBe(false);
|
||||
});
|
||||
|
||||
test('provider rate limit (429) is an ordinary error with the backend text; the tab stays and keyInfo is untouched', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: false, error: 'Pixabay\'s request limit for your API key was reached.', code: 'provider_rate_limited' }, 429));
|
||||
await act(async () => { await api.search('fox'); });
|
||||
expect(api.error).toBe('Pixabay\'s request limit for your API key was reached.');
|
||||
expect(api.keyInfo).toBeNull();
|
||||
expect(api.availability).toBe('unknown');
|
||||
});
|
||||
|
||||
test('refreshKey GETs stock_key_status and keeps only configured + last4', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: true, configured: true, last4: 'ab12', key: 'LEAK', key_enc: 'LEAK' }));
|
||||
let info: any;
|
||||
await act(async () => { info = await api.refreshKey(); });
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/site-builder.php?action=stock_key_status');
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe('GET');
|
||||
expect(info).toEqual({ configured: true, last4: 'ab12' });
|
||||
expect(api.keyInfo).toEqual({ configured: true, last4: 'ab12' });
|
||||
});
|
||||
|
||||
test('saveKey POSTs the key in the BODY (not the URL) with the CSRF header and trims it', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: true, configured: true, last4: '6789' }));
|
||||
let r: any;
|
||||
await act(async () => { r = await api.saveKey(' 1234567-abcdef0123456789 '); });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/site-builder.php?action=stock_key_save');
|
||||
expect(url).not.toContain('abcdef');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers['X-CSRF-Token']).toBe('tok');
|
||||
expect(JSON.parse(init.body)).toEqual({ key: '1234567-abcdef0123456789' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(api.keyInfo).toEqual({ configured: true, last4: '6789' });
|
||||
});
|
||||
|
||||
test('saveKey failure returns the backend message and leaves keyInfo alone', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: false, error: 'That does not look like a Pixabay API key.', code: 'invalid_key_format' }, 400));
|
||||
let r: any;
|
||||
await act(async () => { r = await api.saveKey('nope'); });
|
||||
expect(r).toEqual({ ok: false, message: 'That does not look like a Pixabay API key.' });
|
||||
expect(api.keyInfo).toBeNull();
|
||||
});
|
||||
|
||||
test('saving a key clears the client-side search cache (a new key is a new quota bucket)', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: true, results: [PHOTO], next_page: null }));
|
||||
await act(async () => { await api.search('fox', 1); });
|
||||
fetchMock.mockReturnValue(json({ success: true, configured: true, last4: 'abcd' }));
|
||||
await act(async () => { await api.saveKey('abcdefghijklmnopabcd'); });
|
||||
fetchMock.mockReturnValue(json({ success: true, results: [PHOTO], next_page: null }));
|
||||
await act(async () => { await api.search('fox', 1); });
|
||||
expect(fetchMock.mock.calls.filter((c) => String(c[0]).includes('stock_search'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('testKey posts the typed key, or an empty body for the saved one; reports the backend message on failure', async () => {
|
||||
fetchMock.mockReturnValueOnce(json({ success: true, valid: true }));
|
||||
let r: any;
|
||||
await act(async () => { r = await api.testKey('abcdefghijklmnopabcd'); });
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/site-builder.php?action=stock_key_test');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ key: 'abcdefghijklmnopabcd' });
|
||||
expect(r.ok).toBe(true);
|
||||
fetchMock.mockReturnValueOnce(json({ success: false, error: 'Pixabay did not accept your API key.', code: 'key_rejected' }, 422));
|
||||
await act(async () => { r = await api.testKey(''); });
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({});
|
||||
expect(r).toEqual({ ok: false, message: 'Pixabay did not accept your API key.' });
|
||||
});
|
||||
|
||||
test('removeKey POSTs stock_key_delete and flips to unconfigured', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: true, configured: false, last4: '' }));
|
||||
let r: any;
|
||||
await act(async () => { r = await api.removeKey(); });
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/site-builder.php?action=stock_key_delete');
|
||||
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(api.keyInfo).toEqual({ configured: false, last4: '' });
|
||||
});
|
||||
|
||||
test('network failure on a key call is a message, never a throw', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
let r: any;
|
||||
await act(async () => { r = await api.saveKey('abcdefghijklmnopabcd'); });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(typeof r.message).toBe('string');
|
||||
});
|
||||
|
||||
test('old backend without the key actions (404 on status) hides the tab', async () => {
|
||||
fetchMock.mockReturnValue(json({}, 404));
|
||||
await act(async () => { await api.refreshKey(); });
|
||||
expect(api.availability).toBe('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('standalone', () => {
|
||||
test('no WHP_CONFIG: no fetch', async () => {
|
||||
delete (window as any).WHP_CONFIG;
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
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. */
|
||||
Photos come from Pixabay, searched with the CURRENT USER's own Pixabay API
|
||||
key. The browser talks ONLY to the WHP backend proxy (`stock_search` /
|
||||
`stock_import` / `stock_key_*`): the key is sent once to be saved and is
|
||||
never returned (the backend gives back `configured` + `last4` only), and no
|
||||
Pixabay API URL is called from here. Thumbs returned by the proxy are for the
|
||||
editor grid only (Pixabay allows hotlinking search results temporarily) --
|
||||
"Use photo" imports the file as a normal site asset so the published page
|
||||
never hotlinks. */
|
||||
|
||||
export const PIXABAY_HOME = 'https://pixabay.com/';
|
||||
export const PIXABAY_KEY_URL = 'https://pixabay.com/api/docs/';
|
||||
/** Pixabay API terms: "show your users where the images ... are from, whenever search results are displayed." */
|
||||
export const PIXABAY_ATTRIBUTION = 'Images by Pixabay';
|
||||
|
||||
export interface StockCredit { name: string; url: string }
|
||||
|
||||
@@ -29,15 +38,31 @@ export interface ImportedStockPhoto {
|
||||
name: string;
|
||||
type: string;
|
||||
credit?: StockCredit;
|
||||
/** The photo's page on Pixabay. */
|
||||
providerUrl?: string;
|
||||
}
|
||||
|
||||
export type StockAvailability = 'unknown' | 'available' | 'disabled';
|
||||
|
||||
/** What the backend tells us about the user's own key: never the key itself. */
|
||||
export interface StockKeyInfo { configured: boolean; last4: string }
|
||||
export interface StockKeyResult { ok: boolean; message: string }
|
||||
|
||||
/* 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>();
|
||||
/* The user's key state, shared the same way. null = not asked yet. A search or
|
||||
import that answers `no_key` / `key_rejected` forces configured=false so the
|
||||
Stock tab shows the "Add your Pixabay key" prompt. */
|
||||
let sharedKey: StockKeyInfo | null = null;
|
||||
const keyListeners = new Set<(k: StockKeyInfo | null) => void>();
|
||||
|
||||
function setSharedKey(k: StockKeyInfo | null) {
|
||||
sharedKey = k;
|
||||
keyListeners.forEach((l) => l(k));
|
||||
}
|
||||
|
||||
function setSharedAvailability(a: StockAvailability) {
|
||||
if (sharedAvailability === a) return;
|
||||
@@ -48,14 +73,19 @@ function setSharedAvailability(a: StockAvailability) {
|
||||
/** Test seam: forget what a previous request learned about the backend. */
|
||||
export function resetStockAvailability() {
|
||||
sharedAvailability = 'unknown';
|
||||
sharedKey = null;
|
||||
}
|
||||
|
||||
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 an "unknown/invalid action" error -- both hide the tab.
|
||||
const KEY_CODES = new Set(['no_key', 'key_rejected']);
|
||||
|
||||
/** The backend answers `no_key` / `key_rejected` (code) when the user has no usable
|
||||
* Pixabay key -- that shows the key prompt, it does NOT hide the tab. An old backend
|
||||
* without the action answers an "unknown/invalid action" error (or the legacy
|
||||
* `stock_disabled`) -- those hide the tab.
|
||||
* A bare 404 only counts for the `stock_search` availability probe
|
||||
* (`probe`): on stock_import a 404 is an ordinary failure (e.g. a photo id
|
||||
* that no longer exists) and must not hide the tab. */
|
||||
@@ -69,14 +99,18 @@ export function useStockPhotos() {
|
||||
const [availability, setAvailability] = useState<StockAvailability>(sharedAvailability);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [keyInfo, setKeyInfo] = useState<StockKeyInfo | null>(sharedKey);
|
||||
const cache = useRef(new Map<string, StockSearchResult>());
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(setAvailability);
|
||||
keyListeners.add(setKeyInfo);
|
||||
setAvailability(sharedAvailability);
|
||||
setKeyInfo(sharedKey);
|
||||
return () => {
|
||||
listeners.delete(setAvailability);
|
||||
keyListeners.delete(setKeyInfo);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
@@ -107,6 +141,12 @@ export function useStockPhotos() {
|
||||
let data: any = null;
|
||||
try { data = await resp.json(); } catch { /* non-JSON body */ }
|
||||
if (isDisabledSignal(resp.status, data, true)) { setSharedAvailability('disabled'); return null; }
|
||||
if (KEY_CODES.has(data?.code)) {
|
||||
setSharedAvailability('available');
|
||||
setSharedKey({ configured: false, last4: '' });
|
||||
if (data.code === 'key_rejected') setError(data.error || 'Pixabay did not accept your API key.');
|
||||
return null;
|
||||
}
|
||||
if (!resp.ok || !data?.success || !Array.isArray(data.results)) {
|
||||
throw new Error(data?.error || `Search failed: ${resp.status}`);
|
||||
}
|
||||
@@ -135,18 +175,77 @@ export function useStockPhotos() {
|
||||
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 }),
|
||||
body: JSON.stringify({ provider: 'pixabay', id, size }),
|
||||
});
|
||||
let data: any = null;
|
||||
try { data = await resp.json(); } catch { /* non-JSON body */ }
|
||||
if (isDisabledSignal(resp.status, data, false)) { setSharedAvailability('disabled'); return null; }
|
||||
if (KEY_CODES.has(data?.code)) {
|
||||
setSharedKey({ configured: false, last4: '' });
|
||||
if (data.code === 'key_rejected') setError(data.error || 'Pixabay did not accept your API key.');
|
||||
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 };
|
||||
return { url: data.url, name: data.name || '', type: data.type || 'image', credit: data.credit, providerUrl: data.provider_url };
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { availability, loading, error, search, importPhoto };
|
||||
/** POST/GET one of the stock_key_* actions. Never throws; the key goes in the body only. */
|
||||
const keyCall = useCallback(async (action: string, method: 'GET' | 'POST', body?: object): Promise<{ status: number; data: any } | null> => {
|
||||
const cfg = getCfg();
|
||||
if (!cfg) return null;
|
||||
try {
|
||||
const init: RequestInit = { method, headers: { 'X-CSRF-Token': cfg.csrfToken } };
|
||||
if (body) {
|
||||
(init.headers as Record<string, string>)['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=${action}`, init);
|
||||
let data: any = null;
|
||||
try { data = await resp.json(); } catch { /* non-JSON body */ }
|
||||
return { status: resp.status, data };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Asks the backend whether this user has a key saved (last 4 characters only). */
|
||||
const refreshKey = useCallback(async (): Promise<StockKeyInfo | null> => {
|
||||
const r = await keyCall('stock_key_status', 'GET');
|
||||
if (!r) return null;
|
||||
if (isDisabledSignal(r.status, r.data, true)) { setSharedAvailability('disabled'); return null; }
|
||||
if (!r.data?.success) return null;
|
||||
const info = { configured: !!r.data.configured, last4: String(r.data.last4 || '') };
|
||||
setSharedKey(info);
|
||||
return info;
|
||||
}, [keyCall]);
|
||||
|
||||
const saveKey = useCallback(async (key: string): Promise<StockKeyResult> => {
|
||||
const r = await keyCall('stock_key_save', 'POST', { key: key.trim() });
|
||||
if (!r?.data?.success) return { ok: false, message: r?.data?.error || 'Could not save the key.' };
|
||||
setSharedKey({ configured: !!r.data.configured, last4: String(r.data.last4 || '') });
|
||||
cache.current.clear();
|
||||
setError(null);
|
||||
return { ok: true, message: 'Key saved.' };
|
||||
}, [keyCall]);
|
||||
|
||||
/** One cheap Pixabay request with the typed key (or the saved one when `key` is empty). */
|
||||
const testKey = useCallback(async (key?: string): Promise<StockKeyResult> => {
|
||||
const r = await keyCall('stock_key_test', 'POST', key && key.trim() ? { key: key.trim() } : {});
|
||||
if (!r?.data?.success) return { ok: false, message: r?.data?.error || 'The key could not be tested.' };
|
||||
return { ok: true, message: 'Pixabay accepted the key.' };
|
||||
}, [keyCall]);
|
||||
|
||||
const removeKey = useCallback(async (): Promise<StockKeyResult> => {
|
||||
const r = await keyCall('stock_key_delete', 'POST', {});
|
||||
if (!r?.data?.success) return { ok: false, message: r?.data?.error || 'Could not remove the key.' };
|
||||
setSharedKey({ configured: false, last4: '' });
|
||||
cache.current.clear();
|
||||
return { ok: true, message: 'Key removed.' };
|
||||
}, [keyCall]);
|
||||
|
||||
return { availability, loading, error, keyInfo, search, importPhoto, refreshKey, saveKey, testKey, removeKey };
|
||||
}
|
||||
@@ -277,7 +277,7 @@ describe('AssetPicker library filter and stock entry (#55)', () => {
|
||||
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' };
|
||||
const credit = { name: 'Ann', url: 'https://pixabay.com/users/Ann-1/' };
|
||||
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 }) });
|
||||
|
||||
@@ -19,11 +19,15 @@ function setValue(input: HTMLInputElement, value: string) {
|
||||
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' } };
|
||||
const PHOTO = { id: 7, thumb: 't.jpg', preview: 'p.jpg', width: 10, height: 20, alt: 'a red fox', credit: { name: 'Ann', url: 'https://pixabay.com/users/Ann-1/' } };
|
||||
|
||||
beforeEach(() => {
|
||||
resetStockAvailability();
|
||||
fetchMock.mockReset();
|
||||
// Default: the mount-time key probe says "key saved"; per-test mockReturnValueOnce values still win.
|
||||
fetchMock.mockImplementation((url: string) => String(url).includes('stock_key_status')
|
||||
? json({ success: true, configured: true, last4: '1234' })
|
||||
: json({ success: false, error: 'unexpected request ' + url }, 500));
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
(window as any).WHP_CONFIG = { apiUrl: '/api', siteId: 5, csrfToken: 'tok' };
|
||||
container = document.createElement('div');
|
||||
@@ -53,7 +57,7 @@ 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');
|
||||
expect(q('stock-credit')!.textContent).toContain('Photo by Ann on Pixabay');
|
||||
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();
|
||||
@@ -70,7 +74,7 @@ describe('StockPhotoTab', () => {
|
||||
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');
|
||||
expect(onPick.mock.calls[0][0].alt).toBe('Photo by Ann on Pixabay');
|
||||
});
|
||||
|
||||
test('no results shows the empty state', async () => {
|
||||
@@ -82,7 +86,7 @@ describe('StockPhotoTab', () => {
|
||||
expect(q('stock-empty')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('stock_disabled replaces the tab with the disabled notice', async () => {
|
||||
test('legacy stock_disabled replaces the tab with the disabled notice', async () => {
|
||||
mount();
|
||||
fetchMock.mockReturnValueOnce(json({ success: false, error: 'stock_disabled' }));
|
||||
setValueIn('stock-search-input', 'fox');
|
||||
@@ -125,6 +129,153 @@ describe('StockPhotoTab: stale responses (#55 review)', () => {
|
||||
await flush();
|
||||
expect(q('stock-thumb-7')).not.toBeNull();
|
||||
expect(q('stock-thumb-99')).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls.filter((c) => String(c[0]).includes('stock_search'))).toHaveLength(2); // the mount-time key probe is the third call
|
||||
});
|
||||
});
|
||||
|
||||
describe('StockPhotoTab: Pixabay attribution (API terms: show where images come from whenever results are displayed)', () => {
|
||||
test('"Images by Pixabay" links to pixabay.com in the search state, with results, and on the key prompt', async () => {
|
||||
mount();
|
||||
await flush();
|
||||
const a = () => q('stock-attribution')!.querySelector('a') as HTMLAnchorElement;
|
||||
expect(a().textContent).toBe('Images by Pixabay');
|
||||
expect(a().href).toBe('https://pixabay.com/');
|
||||
expect(a().rel).toContain('noopener');
|
||||
await searchAndSelect();
|
||||
expect(q('stock-thumb-7')).not.toBeNull();
|
||||
expect(a().textContent).toBe('Images by Pixabay');
|
||||
});
|
||||
|
||||
test('a selected photo shows the contributor link and a link to the photo on Pixabay; non-https links are not rendered', async () => {
|
||||
mount();
|
||||
fetchMock.mockReturnValueOnce(json({ success: true, results: [{ ...PHOTO, provider_url: 'https://pixabay.com/photos/fox-7/' }, { ...PHOTO, id: 8, credit: { name: 'Bob', url: 'javascript:alert(1)' }, provider_url: 'javascript:alert(1)' }], next_page: null }));
|
||||
setValueIn('stock-search-input', 'fox');
|
||||
await act(async () => { q('stock-search-btn')!.click(); });
|
||||
await flush();
|
||||
act(() => { q('stock-thumb-7')!.click(); });
|
||||
const links = Array.from(q('stock-credit')!.querySelectorAll('a')).map((l) => (l as HTMLAnchorElement).href);
|
||||
expect(links).toEqual(['https://pixabay.com/users/Ann-1/', 'https://pixabay.com/photos/fox-7/']);
|
||||
act(() => { q('stock-thumb-8')!.click(); });
|
||||
expect(q('stock-credit')!.querySelectorAll('a')).toHaveLength(0);
|
||||
expect(q('stock-credit')!.textContent).toBe('Photo by Bob on Pixabay');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StockPhotoTab: the user\'s own Pixabay key', () => {
|
||||
const noKeyStatus = () => fetchMock.mockImplementation((url: string) => String(url).includes('stock_key_status')
|
||||
? json({ success: true, configured: false, last4: '' }) : json({ success: false, error: 'unexpected ' + url }, 500));
|
||||
|
||||
test('no key saved: the tab is an "Add your Pixabay key" prompt with a link to get one and a password field; no search box', async () => {
|
||||
noKeyStatus();
|
||||
mount();
|
||||
await flush();
|
||||
expect(q('stock-needs-key')).not.toBeNull();
|
||||
expect(q('stock-search-input')).toBeNull();
|
||||
expect(q('stock-key-prompt')!.textContent).toContain('your own free Pixabay API key');
|
||||
const link = q('stock-key-link') as HTMLAnchorElement;
|
||||
expect(link.href).toBe('https://pixabay.com/api/docs/');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect((q('stock-key-input') as HTMLInputElement).type).toBe('password');
|
||||
expect(q('stock-attribution')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('saving a key POSTs it in the body and then shows the search UI with the masked "ends in" line (never the key)', async () => {
|
||||
noKeyStatus();
|
||||
mount();
|
||||
await flush();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockReturnValue(json({ success: true, configured: true, last4: 'cdef' }));
|
||||
setValueIn('stock-key-input', '1234567-0123456789abcdef');
|
||||
await act(async () => { q('stock-key-save')!.click(); });
|
||||
await flush();
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain('stock_key_save');
|
||||
expect(String(url)).not.toContain('0123456789');
|
||||
expect(JSON.parse(init.body)).toEqual({ key: '1234567-0123456789abcdef' });
|
||||
expect(q('stock-search-input')).not.toBeNull();
|
||||
expect(q('stock-keyrow')!.textContent).toContain('ends in cdef');
|
||||
expect(container.innerHTML).not.toContain('0123456789abcdef');
|
||||
});
|
||||
|
||||
test('a rejected save shows the backend message and keeps the prompt', async () => {
|
||||
noKeyStatus();
|
||||
mount();
|
||||
await flush();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockReturnValue(json({ success: false, error: 'That does not look like a Pixabay API key.', code: 'invalid_key_format' }, 400));
|
||||
setValueIn('stock-key-input', 'nope nope nope nope');
|
||||
await act(async () => { q('stock-key-save')!.click(); });
|
||||
await flush();
|
||||
expect(q('stock-key-note')!.textContent).toBe('That does not look like a Pixabay API key.');
|
||||
expect(q('stock-needs-key')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('Test key calls stock_key_test with the typed key and shows the result', async () => {
|
||||
noKeyStatus();
|
||||
mount();
|
||||
await flush();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockReturnValue(json({ success: true, valid: true }));
|
||||
setValueIn('stock-key-input', '1234567-0123456789abcdef');
|
||||
await act(async () => { q('stock-key-test')!.click(); });
|
||||
await flush();
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('stock_key_test');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ key: '1234567-0123456789abcdef' });
|
||||
expect(q('stock-key-note')!.textContent).toBe('Pixabay accepted the key.');
|
||||
});
|
||||
|
||||
test('a search answered with no_key swaps the search UI for the prompt', async () => {
|
||||
mount();
|
||||
await flush();
|
||||
expect(q('stock-search-input')).not.toBeNull();
|
||||
fetchMock.mockReturnValueOnce(json({ success: false, error: 'Add your free Pixabay API key to search stock photos.', code: 'no_key' }));
|
||||
setValueIn('stock-search-input', 'fox');
|
||||
await act(async () => { q('stock-search-btn')!.click(); });
|
||||
await flush();
|
||||
expect(q('stock-needs-key')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('key_rejected shows the prompt AND the message', async () => {
|
||||
mount();
|
||||
await flush();
|
||||
fetchMock.mockReturnValueOnce(json({ success: false, error: 'Pixabay did not accept your API key.', code: 'key_rejected' }, 422));
|
||||
setValueIn('stock-search-input', 'fox');
|
||||
await act(async () => { q('stock-search-btn')!.click(); });
|
||||
await flush();
|
||||
expect(q('stock-needs-key')).not.toBeNull();
|
||||
expect(q('stock-error')!.textContent).toBe('Pixabay did not accept your API key.');
|
||||
});
|
||||
|
||||
test('Pixabay rate limit (429) keeps the search UI and shows the message', async () => {
|
||||
mount();
|
||||
await flush();
|
||||
fetchMock.mockReturnValueOnce(json({ success: false, error: 'Pixabay\'s request limit for your API key was reached (100 requests per minute). Please wait a minute and try again.', code: 'provider_rate_limited' }, 429));
|
||||
setValueIn('stock-search-input', 'fox');
|
||||
await act(async () => { q('stock-search-btn')!.click(); });
|
||||
await flush();
|
||||
expect(q('stock-search-input')).not.toBeNull();
|
||||
expect(q('stock-error')!.textContent).toContain('100 requests per minute');
|
||||
});
|
||||
|
||||
test('with a key saved, "Change or remove key" opens the form; Remove returns to the prompt', async () => {
|
||||
mount();
|
||||
await flush();
|
||||
expect(q('stock-keyrow')!.textContent).toContain('ends in 1234');
|
||||
expect(q('stock-keyform')).toBeNull();
|
||||
act(() => { q('stock-key-manage')!.click(); });
|
||||
expect(q('stock-keyform')).not.toBeNull();
|
||||
expect((q('stock-key-input') as HTMLInputElement).placeholder).toContain('ends in 1234');
|
||||
fetchMock.mockReturnValueOnce(json({ success: true, configured: false, last4: '' }));
|
||||
await act(async () => { q('stock-key-remove')!.click(); });
|
||||
await flush();
|
||||
expect(String(fetchMock.mock.calls[fetchMock.mock.calls.length - 1][0])).toContain('stock_key_delete');
|
||||
expect(q('stock-needs-key')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('legacy backend without the key actions (404 on the probe) still shows the disabled notice', async () => {
|
||||
fetchMock.mockImplementation(() => json({}, 404));
|
||||
mount();
|
||||
await flush();
|
||||
expect(q('stock-disabled')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useStockPhotos, StockPhoto, StockCredit } from '../hooks/useStockPhotos';
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useStockPhotos, StockPhoto, StockCredit, PIXABAY_HOME, PIXABAY_KEY_URL, PIXABAY_ATTRIBUTION } from '../hooks/useStockPhotos';
|
||||
|
||||
export interface PickedAsset {
|
||||
url: string;
|
||||
@@ -14,10 +14,71 @@ interface Props {
|
||||
|
||||
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 creditLine = (c?: StockCredit) => (c?.name ? `Photo by ${c.name} on Pixabay` : 'Photo from Pixabay');
|
||||
|
||||
/** "Images by Pixabay" -- shown wherever stock results (or the prompt that leads to them) are displayed. */
|
||||
const Attribution: React.FC = () => (
|
||||
<div className="stock-footer" data-testid="stock-attribution">
|
||||
<a href={PIXABAY_HOME} target="_blank" rel="noopener noreferrer">{PIXABAY_ATTRIBUTION}</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Add / replace the user's own Pixabay key. The key is typed here once; only "ends in ...1234" ever comes back. */
|
||||
const KeyForm: React.FC<{
|
||||
last4: string | null;
|
||||
onSave: (k: string) => Promise<{ ok: boolean; message: string }>;
|
||||
onTest: (k: string) => Promise<{ ok: boolean; message: string }>;
|
||||
onRemove?: () => Promise<{ ok: boolean; message: string }>;
|
||||
onDone?: () => void;
|
||||
}> = ({ last4, onSave, onTest, onRemove, onDone }) => {
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const run = async (fn: () => Promise<{ ok: boolean; message: string }>, clear = false, done = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await fn();
|
||||
setNote({ ok: r.ok, text: r.message });
|
||||
if (r.ok && clear) setValue('');
|
||||
if (r.ok && done) onDone?.();
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<form
|
||||
className="stock-keyform"
|
||||
data-testid="stock-keyform"
|
||||
onSubmit={(e) => { e.preventDefault(); if (value.trim()) run(() => onSave(value), true, true); }}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
className="guided-input"
|
||||
value={value}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={64}
|
||||
placeholder={last4 ? `Replace key (current ends in ${last4})` : 'Paste your Pixabay API key'}
|
||||
aria-label="Pixabay API key"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
data-testid="stock-key-input"
|
||||
style={{ fontSize: 11, width: '100%' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
<button type="submit" className="preset-btn apply-btn" disabled={busy || !value.trim()} data-testid="stock-key-save">Save key</button>
|
||||
<button type="button" className="preset-btn" disabled={busy || (!value.trim() && !last4)} onClick={() => run(() => onTest(value))} data-testid="stock-key-test">Test key</button>
|
||||
{onRemove && last4 && (
|
||||
<button type="button" className="preset-btn" disabled={busy} onClick={() => run(onRemove, true, true)} data-testid="stock-key-remove">Remove key</button>
|
||||
)}
|
||||
</div>
|
||||
{note && (
|
||||
<div className={`stock-status${note.ok ? '' : ' error'}`} role={note.ok ? 'status' : 'alert'} data-testid="stock-key-note">{note.text}</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const StockPhotoTab: React.FC<Props> = ({ onPick }) => {
|
||||
const { availability, loading, error, search, importPhoto } = useStockPhotos();
|
||||
const { availability, loading, error, keyInfo, search, importPhoto, refreshKey, saveKey, testKey, removeKey } = useStockPhotos();
|
||||
const [editingKey, setEditingKey] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [submitted, setSubmitted] = useState('');
|
||||
const [photos, setPhotos] = useState<StockPhoto[]>([]);
|
||||
@@ -25,6 +86,8 @@ export const StockPhotoTab: React.FC<Props> = ({ onPick }) => {
|
||||
const [selected, setSelected] = useState<StockPhoto | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
// Ask once per mount whether this user already has a key (answers configured + last4 only).
|
||||
useEffect(() => { if (keyInfo === null) void refreshKey(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Only the newest request may update the grid: an older (aborted or slow)
|
||||
// one resolving late must not clear or replace newer results.
|
||||
const seq = useRef(0);
|
||||
@@ -70,6 +133,23 @@ export const StockPhotoTab: React.FC<Props> = ({ onPick }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// No key yet (or Pixabay refused it): the tab is a prompt to add the user's own free Pixabay key.
|
||||
if (keyInfo?.configured === false) {
|
||||
return (
|
||||
<div className="stock-tab" data-testid="stock-needs-key">
|
||||
<div className="stock-status" data-testid="stock-key-prompt">
|
||||
Stock photos come from Pixabay and use <strong>your own free Pixabay API key</strong>.{' '}
|
||||
<a href={PIXABAY_KEY_URL} target="_blank" rel="noopener noreferrer" data-testid="stock-key-link">Get a free key</a>{' '}
|
||||
(sign in to Pixabay and copy the key shown in the API documentation), then paste it here.
|
||||
Your key is stored encrypted on the server and is only used for your searches.
|
||||
</div>
|
||||
{error && <div className="stock-status error" role="alert" data-testid="stock-error">{error}</div>}
|
||||
<KeyForm last4={null} onSave={saveKey} onTest={testKey} />
|
||||
<Attribution />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stock-tab" data-testid="stock-tab">
|
||||
<form
|
||||
@@ -125,8 +205,11 @@ export const StockPhotoTab: React.FC<Props> = ({ onPick }) => {
|
||||
<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</>
|
||||
<>Photo by <a href={httpsOnly(selected.credit.url)} target="_blank" rel="noopener noreferrer">{selected.credit.name}</a> on Pixabay</>
|
||||
) : creditLine(selected.credit)}
|
||||
{httpsOnly(selected.provider_url) && (
|
||||
<> · <a href={httpsOnly(selected.provider_url)} target="_blank" rel="noopener noreferrer" data-testid="stock-source-link">View on Pixabay</a></>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -141,9 +224,18 @@ export const StockPhotoTab: React.FC<Props> = ({ onPick }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stock-footer">
|
||||
<a href="https://www.pexels.com" target="_blank" rel="noopener noreferrer">Photos from Pexels</a>
|
||||
</div>
|
||||
<Attribution />
|
||||
{keyInfo?.configured && (
|
||||
<div className="stock-keyrow" data-testid="stock-keyrow">
|
||||
<span>Your Pixabay key ends in {keyInfo.last4}. </span>
|
||||
<button type="button" className="preset-btn" onClick={() => setEditingKey((v) => !v)} data-testid="stock-key-manage">
|
||||
{editingKey ? 'Close' : 'Change or remove key'}
|
||||
</button>
|
||||
{editingKey && (
|
||||
<KeyForm last4={keyInfo.last4} onSave={saveKey} onTest={testKey} onRemove={removeKey} onDone={() => setEditingKey(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user