PluginProbe
Extendify / 3.1.6
Extendify v3.1.6
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / QuickEdit / components / modals / UnsplashImagePickerModal.jsx

UnsplashImagePickerModal.jsx in Extendify 3.1.6, at src/QuickEdit/components/modals/UnsplashImagePickerModal.jsx

323 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { downloadImage } from '@shared/api/wp';
2 import { resolveImageSearch } from '@shared/lib/image-search-defaults';
3 import { track } from '@shared/lib/track';
4 import { fetchImages } from '@shared/lib/unsplash';
5 import {
6 Button,
7 Modal,
8 Notice,
9 SearchControl,
10 Spinner,
11 } from '@wordpress/components';
12 import { useEffect, useState } from '@wordpress/element';
13 import { __ } from '@wordpress/i18n';
14 import { loadProduct, save, saveProduct } from '../../lib/api';
15 import { invalidateBlockSource } from '../../lib/block-source-cache';
16 import { splice } from '../../lib/dom';
17 import { friendlyMessage } from '../../lib/errors';
18 import { QE_MODAL_BODY_OPEN_CLASS } from '../../lib/modal-root';
19 import { pushUndo } from '../../state/undo';
20 import { ModalCloseButton } from './ModalCloseButton';
21
22 // The modal portals outside `div.extendify-quick-edit`, so the bundle's
23 // prefix-scoped `.sr-only` doesn't reach it — hide the loading label inline.
24 const SR_ONLY_STYLE = {
25 position: 'absolute',
26 width: '1px',
27 height: '1px',
28 margin: '-1px',
29 padding: 0,
30 overflow: 'hidden',
31 clip: 'rect(0, 0, 0, 0)',
32 whiteSpace: 'nowrap',
33 border: 0,
34 };
35
36 // Local copy (vs. importing from InlineEditor) so this modal can be code-split later.
37 const readImageAttrs = (liveEl) => {
38 const img =
39 liveEl.querySelector('.wp-block-cover__image-background') ||
40 liveEl.querySelector('img');
41 if (!img) return null;
42 const url = img.getAttribute('src') || '';
43 const alt = img.getAttribute('alt') || '';
44 let id = null;
45 for (const cls of img.classList) {
46 const m = /^wp-image-(\d+)$/.exec(cls);
47 if (m) {
48 id = Number(m[1]);
49 break;
50 }
51 }
52 return { url, id, alt };
53 };
54
55 export const UnsplashImagePickerModal = ({ selected, field, onAfterSave }) => {
56 const [search, setSearch] = useState('');
57 const [debounced, setDebounced] = useState('');
58 const [images, setImages] = useState(null);
59 const [loadError, setLoadError] = useState(null);
60 const [pending, setPending] = useState(null);
61 const [error, setError] = useState(null);
62
63 useEffect(() => {
64 if (!search) {
65 setDebounced('');
66 return undefined;
67 }
68 const t = setTimeout(() => {
69 setDebounced(search);
70 track('unsplash_searched', { len: search.length });
71 }, 500);
72 return () => clearTimeout(t);
73 }, [search]);
74
75 useEffect(() => {
76 const ac = new AbortController();
77 setLoadError(null);
78 setImages(null);
79 // Not the shared prefetch cache: it holds a mix of every profile term,
80 // where one resolved term keeps the grid on a single subject.
81 fetchImages(resolveImageSearch(debounced), 'user')
82 .then((res) => {
83 if (ac.signal.aborted) return;
84 setImages(res || []);
85 })
86 .catch((err) => {
87 if (ac.signal.aborted) return;
88 setLoadError(friendlyMessage(err));
89 });
90 return () => ac.abort();
91 }, [debounced]);
92
93 const onPick = async (image) => {
94 if (pending) return;
95 setError(null);
96 setPending(image.id);
97 try {
98 const downloaded = await downloadImage(
99 image.requestMetadata?.id,
100 image.urls?.regular,
101 'unsplash',
102 image.id,
103 {
104 alt: image.alt_description || image.description || '',
105 caption: '',
106 },
107 );
108 const mediaId = downloaded?.id;
109 if (!mediaId) throw new Error('No media id returned');
110
111 // Product image: write through `saveProduct` and reload
112 // (cascades to product-collection cards / single-product
113 // page / related-products carousels — splice can't reach
114 // them all). Mirrors the wp.media + AI flows.
115 if (selected.source?.kind === 'product') {
116 let beforeImageId = 0;
117 try {
118 const cur = await loadProduct(selected.source.id);
119 beforeImageId = Number(cur?.image_id) || 0;
120 } catch (_) {
121 // non-fatal — undo entry just won't have a before-state
122 }
123 await saveProduct({
124 productId: selected.source.id,
125 field: 'image',
126 value: mediaId,
127 });
128 if (beforeImageId && beforeImageId !== mediaId) {
129 pushUndo({
130 kind: 'product-image',
131 productReplay: true,
132 productId: selected.source.id,
133 field: 'image',
134 beforeValue: beforeImageId,
135 });
136 }
137 track('image_replaced', { source: 'unsplash', kind: 'product' });
138 onAfterSave(true);
139 return;
140 }
141
142 const before = readImageAttrs(selected.mediaEl ?? selected.el);
143 const res = await save({
144 source: selected.source,
145 blockId: selected.blockId,
146 blockType: selected.blockName ?? selected.blockType,
147 patches: [
148 {
149 fieldKey: field,
150 value: {
151 url: downloaded.url || downloaded.source_url,
152 id: mediaId,
153 alt: downloaded.alt_text || image.alt_description || '',
154 },
155 },
156 ],
157 });
158 if (!res.rendered) throw new Error('No rendered HTML');
159 const newEl = splice(selected.el, res.rendered);
160 if (!newEl) throw new Error('Splice failed');
161 invalidateBlockSource(selected.source, selected.blockId);
162 if (before) {
163 pushUndo({
164 kind: 'image',
165 source: selected.source,
166 blockId: selected.blockId,
167 blockType: selected.blockName ?? selected.blockType,
168 patches: [{ fieldKey: field, value: before }],
169 });
170 }
171 track('image_replaced', { source: 'unsplash' });
172 onAfterSave(true);
173 } catch (err) {
174 track('save_failed', { kind: 'image', source: 'unsplash' });
175 setError(friendlyMessage(err));
176 setPending(null);
177 }
178 };
179
180 return (
181 <Modal
182 title={__('Search Unsplash', 'extendify-local')}
183 onRequestClose={() => onAfterSave(false)}
184 isDismissible={false}
185 headerActions={<ModalCloseButton onClick={() => onAfterSave(false)} />}
186 className="extendify-quick-edit-modal extendify-quick-edit-image-picker"
187 overlayClassName="extendify-quick-edit"
188 bodyOpenClassName={QE_MODAL_BODY_OPEN_CLASS}
189 size="large"
190 >
191 {error ? (
192 <Notice status="error" isDismissible={false}>
193 {error}
194 </Notice>
195 ) : null}
196 <SearchControl
197 value={search}
198 onChange={(v) => setSearch(v)}
199 placeholder={__(
200 'Describe an image to search Unsplash',
201 'extendify-local',
202 )}
203 disabled={!!pending}
204 autoFocus
205 __nextHasNoMarginBottom
206 />
207 <div
208 className="extendify-quick-edit-image-grid"
209 aria-busy={!images && !loadError}
210 >
211 {loadError ? (
212 <Notice status="error" isDismissible={false}>
213 {loadError}
214 </Notice>
215 ) : null}
216 {!images && !loadError ? (
217 // biome-ignore lint/a11y/useSemanticElements: deliberate live region; <output> changes display + semantics
218 <div role="status">
219 <Spinner />
220 <span style={SR_ONLY_STYLE}>
221 {__('Loading images…', 'extendify-local')}
222 </span>
223 </div>
224 ) : null}
225 {images?.length === 0 ? (
226 // biome-ignore lint/a11y/useSemanticElements: deliberate live region; <output> changes display + semantics
227 <p role="status">{__('No images found.', 'extendify-local')}</p>
228 ) : null}
229 {images?.map((image) => (
230 <button
231 key={image.id}
232 type="button"
233 className="extendify-quick-edit-image-grid-item"
234 onClick={() => onPick(image)}
235 disabled={!!pending}
236 aria-label={
237 image.alt_description || __('Use this image', 'extendify-local')
238 }
239 >
240 <img
241 src={image.urls?.small || image.urls?.thumb}
242 alt={image.alt_description || ''}
243 loading="lazy"
244 />
245 <UnsplashCredit image={image} />
246 {pending === image.id ? (
247 <span className="extendify-quick-edit-image-grid-item-overlay">
248 <Spinner />
249 </span>
250 ) : null}
251 </button>
252 ))}
253 </div>
254 {images?.length ? <UnsplashFooter /> : null}
255 <div className="extendify-quick-edit-modal-actions">
256 <Button variant="tertiary" onClick={() => onAfterSave(false)}>
257 {__('Cancel', 'extendify-local')}
258 </Button>
259 </div>
260 </Modal>
261 );
262 };
263
264 // Unsplash API guidelines (https://help.unsplash.com/en/articles/2511315):
265 // - Per-photo: credit the photographer with a link back to their
266 // Unsplash profile.
267 // - Hot-link the photographer + Unsplash links with the
268 // `utm_source` + `utm_medium=referral` query params so Unsplash
269 // can track downstream attribution.
270 // - Each link MUST open on Unsplash (target=_blank + rel safe defaults).
271 const UTM = 'utm_source=extendify&utm_medium=referral';
272 const withUtm = (url) => {
273 if (!url) return url;
274 return url.includes('?') ? `${url}&${UTM}` : `${url}?${UTM}`;
275 };
276
277 const UnsplashCredit = ({ image }) => {
278 const name = image?.user?.name;
279 const profile = image?.user?.links?.html;
280 if (!name) return null;
281 return (
282 <span className="extendify-quick-edit-image-grid-item-credit">
283 {
284 /* translators: image credit line; precedes the photographer's name. */ __(
285 'Photo by',
286 'extendify-local',
287 )
288 }{' '}
289 {profile ? (
290 <a
291 href={withUtm(profile)}
292 target="_blank"
293 rel="noopener noreferrer"
294 onClick={(e) => e.stopPropagation()}
295 >
296 {name}
297 </a>
298 ) : (
299 name
300 )}
301 </span>
302 );
303 };
304
305 const UnsplashFooter = () => (
306 <p className="extendify-quick-edit-image-attribution">
307 {
308 /* translators: precedes the "Unsplash" brand link. */ __(
309 'Photos powered by',
310 'extendify-local',
311 )
312 }{' '}
313 <a
314 href={withUtm('https://unsplash.com/')}
315 target="_blank"
316 rel="noopener noreferrer"
317 >
318 Unsplash
319 </a>
320 .
321 </p>
322 );
323