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:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-02 10:15:04 -08:00
1 parent da860e2dae
commit 8a503c4267
5 files changed
+486 -27

No files matched your search

+121 -4
View File
@@ -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;
+108 -9
View File
@@ -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 };
}
+1 -1
View File
@@ -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 }) });
+156 -5
View File
@@ -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();
});
});
+100 -8
View File
@@ -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) && (
<> &middot; <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>
);
};