PluginProbe
Extendify / 3.1.3
Extendify v3.1.3
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.3, at src/QuickEdit/components/modals/UnsplashImagePickerModal.jsx

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