From 8a503c426785c2059157ff48a858920006e10f32 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:15:04 -0800 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq --- craft/src/hooks/useStockPhotos.test.ts | 125 ++++++++++++++++++- craft/src/hooks/useStockPhotos.ts | 117 ++++++++++++++++-- craft/src/ui/AssetPicker.test.tsx | 2 +- craft/src/ui/StockPhotoTab.test.tsx | 161 ++++++++++++++++++++++++- craft/src/ui/StockPhotoTab.tsx | 108 +++++++++++++++-- 5 files changed, 486 insertions(+), 27 deletions(-) diff --git a/craft/src/hooks/useStockPhotos.test.ts b/craft/src/hooks/useStockPhotos.test.ts index d066b94..1587b66 100644 --- a/craft/src/hooks/useStockPhotos.test.ts +++ b/craft/src/hooks/useStockPhotos.test.ts @@ -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; diff --git a/craft/src/hooks/useStockPhotos.ts b/craft/src/hooks/useStockPhotos.ts index 778ef6e..2a5c4d5 100644 --- a/craft/src/hooks/useStockPhotos.ts +++ b/craft/src/hooks/useStockPhotos.ts @@ -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(sharedAvailability); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [keyInfo, setKeyInfo] = useState(sharedKey); const cache = useRef(new Map()); const abortRef = useRef(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)['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 => { + 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 => { + 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 => { + 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 => { + 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 }; } diff --git a/craft/src/ui/AssetPicker.test.tsx b/craft/src/ui/AssetPicker.test.tsx index 29fd82f..b17b189 100644 --- a/craft/src/ui/AssetPicker.test.tsx +++ b/craft/src/ui/AssetPicker.test.tsx @@ -277,7 +277,7 @@ describe('AssetPicker library filter and stock entry (#55)', () => { const onPick = vi.fn(); render(); 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 }) }); diff --git a/craft/src/ui/StockPhotoTab.test.tsx b/craft/src/ui/StockPhotoTab.test.tsx index 6e0bbff..054d00c 100644 --- a/craft/src/ui/StockPhotoTab.test.tsx +++ b/craft/src/ui/StockPhotoTab.test.tsx @@ -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(); }); }); diff --git a/craft/src/ui/StockPhotoTab.tsx b/craft/src/ui/StockPhotoTab.tsx index 4487b88..6c43152 100644 --- a/craft/src/ui/StockPhotoTab.tsx +++ b/craft/src/ui/StockPhotoTab.tsx @@ -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 = () => ( + +); + +/** 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 ( +
{ e.preventDefault(); if (value.trim()) run(() => onSave(value), true, true); }} + > + setValue(e.target.value)} + data-testid="stock-key-input" + style={{ fontSize: 11, width: '100%' }} + /> +
+ + + {onRemove && last4 && ( + + )} +
+ {note && ( +
{note.text}
+ )} +
+ ); +}; export const StockPhotoTab: React.FC = ({ 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([]); @@ -25,6 +86,8 @@ export const StockPhotoTab: React.FC = ({ onPick }) => { const [selected, setSelected] = useState(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 = ({ 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 ( +
+
+ Stock photos come from Pixabay and use your own free Pixabay API key.{' '} + Get a free key{' '} + (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. +
+ {error &&
{error}
} + + +
+ ); + } + return (
= ({ onPick }) => {
{httpsOnly(selected.credit?.url) ? ( - <>Photo by {selected.credit.name} on Pexels + <>Photo by {selected.credit.name} on Pixabay ) : creditLine(selected.credit)} + {httpsOnly(selected.provider_url) && ( + <> · View on Pixabay + )}
)} - + + {keyInfo?.configured && ( +
+ Your Pixabay key ends in {keyInfo.last4}. + + {editingKey && ( + setEditingKey(false)} /> + )} +
+ )}
); };