import { downloadImage } from '@shared/api/wp';
import { resolveImageSearch } from '@shared/lib/image-search-defaults';
import { track } from '@shared/lib/track';
import { fetchImages } from '@shared/lib/unsplash';
import {
Button,
Modal,
Notice,
SearchControl,
Spinner,
} from '@wordpress/components';
import { useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { loadProduct, save, saveProduct } from '../../lib/api';
import { invalidateBlockSource } from '../../lib/block-source-cache';
import { splice } from '../../lib/dom';
import { friendlyMessage } from '../../lib/errors';
import { QE_MODAL_BODY_OPEN_CLASS } from '../../lib/modal-root';
import { pushUndo } from '../../state/undo';
import { ModalCloseButton } from './ModalCloseButton';
// The modal portals outside `div.extendify-quick-edit`, so the bundle's
// prefix-scoped `.sr-only` doesn't reach it — hide the loading label inline.
const SR_ONLY_STYLE = {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: 0,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
};
// Local copy (vs. importing from InlineEditor) so this modal can be code-split later.
const readImageAttrs = (liveEl) => {
const img =
liveEl.querySelector('.wp-block-cover__image-background') ||
liveEl.querySelector('img');
if (!img) return null;
const url = img.getAttribute('src') || '';
const alt = img.getAttribute('alt') || '';
let id = null;
for (const cls of img.classList) {
const m = /^wp-image-(\d+)$/.exec(cls);
if (m) {
id = Number(m[1]);
break;
}
}
return { url, id, alt };
};
export const UnsplashImagePickerModal = ({ selected, field, onAfterSave }) => {
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [images, setImages] = useState(null);
const [loadError, setLoadError] = useState(null);
const [pending, setPending] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
if (!search) {
setDebounced('');
return undefined;
}
const t = setTimeout(() => {
setDebounced(search);
track('unsplash_searched', { len: search.length });
}, 500);
return () => clearTimeout(t);
}, [search]);
useEffect(() => {
const ac = new AbortController();
setLoadError(null);
setImages(null);
// Not the shared prefetch cache: it holds a mix of every profile term,
// where one resolved term keeps the grid on a single subject.
fetchImages(resolveImageSearch(debounced), 'user')
.then((res) => {
if (ac.signal.aborted) return;
setImages(res || []);
})
.catch((err) => {
if (ac.signal.aborted) return;
setLoadError(friendlyMessage(err));
});
return () => ac.abort();
}, [debounced]);
const onPick = async (image) => {
if (pending) return;
setError(null);
setPending(image.id);
try {
const downloaded = await downloadImage(
image.requestMetadata?.id,
image.urls?.regular,
'unsplash',
image.id,
{
alt: image.alt_description || image.description || '',
caption: '',
},
);
const mediaId = downloaded?.id;
if (!mediaId) throw new Error('No media id returned');
// Product image: write through `saveProduct` and reload
// (cascades to product-collection cards / single-product
// page / related-products carousels — splice can't reach
// them all). Mirrors the wp.media + AI flows.
if (selected.source?.kind === 'product') {
let beforeImageId = 0;
try {
const cur = await loadProduct(selected.source.id);
beforeImageId = Number(cur?.image_id) || 0;
} catch (_) {
// non-fatal — undo entry just won't have a before-state
}
await saveProduct({
productId: selected.source.id,
field: 'image',
value: mediaId,
});
if (beforeImageId && beforeImageId !== mediaId) {
pushUndo({
kind: 'product-image',
productReplay: true,
productId: selected.source.id,
field: 'image',
beforeValue: beforeImageId,
});
}
track('image_replaced', { source: 'unsplash', kind: 'product' });
onAfterSave(true);
return;
}
const before = readImageAttrs(selected.mediaEl ?? selected.el);
const res = await save({
source: selected.source,
blockId: selected.blockId,
blockType: selected.blockName ?? selected.blockType,
patches: [
{
fieldKey: field,
value: {
url: downloaded.url || downloaded.source_url,
id: mediaId,
alt: downloaded.alt_text || image.alt_description || '',
},
},
],
});
if (!res.rendered) throw new Error('No rendered HTML');
const newEl = splice(selected.el, res.rendered);
if (!newEl) throw new Error('Splice failed');
invalidateBlockSource(selected.source, selected.blockId);
if (before) {
pushUndo({
kind: 'image',
source: selected.source,
blockId: selected.blockId,
blockType: selected.blockName ?? selected.blockType,
patches: [{ fieldKey: field, value: before }],
});
}
track('image_replaced', { source: 'unsplash' });
onAfterSave(true);
} catch (err) {
track('save_failed', { kind: 'image', source: 'unsplash' });
setError(friendlyMessage(err));
setPending(null);
}
};
return (
{ /* translators: precedes the "Unsplash" brand link. */ __( 'Photos powered by', 'extendify-local', ) }{' '} Unsplash .
);