Merge issue-55-fe (Refs #55)

# Conflicts:
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 12:09:09 -07:00
commit 0ac420a23c
17 files changed
+928 -9

No files matched your search

@@ -56,3 +56,19 @@ describe('BackgroundSection.toHtml style-value XSS hardening', () => {
expect(html).toContain('child');
});
});
describe('BackgroundSection.toHtml bgPosition focal point (#55)', () => {
test('bgPosition is emitted as background-position', () => {
const { html } = toHtml({ bgImage: 'assets/a.jpg', bgPosition: 'left top' }, 'child');
expect(html).toContain('background-position:left top');
expect(html).not.toContain('background-position:center');
});
test('blank or missing bgPosition keeps center', () => {
expect(toHtml({ bgImage: 'assets/a.jpg', bgPosition: '' }, '').html).toContain('background-position:center');
expect(toHtml({ bgImage: 'assets/a.jpg' }, '').html).toContain('background-position:center');
});
test('a malicious bgPosition cannot break out of the style attribute', () => {
const { html } = toHtml({ bgImage: 'x', bgPosition: 'left";}</style><script>alert(1)</script>' }, '');
expect(html).not.toContain('<script>alert(1)');
});
});
@@ -2,10 +2,12 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr } from '../../utils/escape';
import { escapeAttr, cssValue } from '../../utils/escape';
interface BackgroundSectionProps {
bgImage?: string;
/** CSS background-position (focal point); blank = center. */
bgPosition?: string;
bgColor?: string;
overlayColor?: string;
overlayOpacity?: number;
@@ -17,6 +19,7 @@ interface BackgroundSectionProps {
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
bgImage = '',
bgPosition = '',
bgColor = '#1e293b',
overlayColor = '#000000',
overlayOpacity = 0.4,
@@ -37,7 +40,7 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundPosition: cssValue(bgPosition) || 'center',
...style,
}}
>
@@ -75,6 +78,7 @@ BackgroundSection.craft = {
displayName: 'Background Section',
props: {
bgImage: '',
bgPosition: '',
bgColor: '#1e293b',
overlayColor: '#000000',
overlayOpacity: 0.4,
@@ -99,7 +103,7 @@ BackgroundSection.craft = {
backgroundColor: props.bgColor || '#1e293b',
backgroundImage: props.bgImage ? `url(${props.bgImage})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundPosition: cssValue(props.bgPosition) || 'center',
...props.style,
});
const overlayStyle = cssPropsToString({
@@ -48,3 +48,20 @@ describe('HeroSimple.toHtml palette token fallback (Refs #44)', () => {
expect(html).not.toContain('var(--wsb-primary)');
});
});
describe('HeroSimple.toHtml bgPosition focal point (#55)', () => {
const base = { heading: 'Hi', subtitle: 'There', bgType: 'image', bgImage: 'assets/a.jpg' };
test('bgPosition is emitted as background-position', () => {
const { html } = toHtml({ ...base, bgPosition: 'left top' }, '');
expect(html).toContain('background-position:left top');
expect(html).not.toContain('background-position:center');
});
test('blank or missing bgPosition keeps center', () => {
expect(toHtml({ ...base, bgPosition: '' }, '').html).toContain('background-position:center');
expect(toHtml(base, '').html).toContain('background-position:center');
});
test('a malicious bgPosition cannot break out of the style attribute', () => {
const { html } = toHtml({ ...base, bgPosition: 'left";}</style><script>alert(1)</script>' }, '');
expect(html).not.toContain('<script>alert(1)');
});
});
+6 -2
View File
@@ -21,6 +21,8 @@ interface HeroProps {
bgGradientTo?: string;
bgGradientAngle?: number;
bgImage?: string;
/** CSS background-position (focal point) for the image background; blank = center. */
bgPosition?: string;
bgVideo?: string;
overlayColor?: string;
overlayOpacity?: number;
@@ -61,6 +63,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
bgGradientTo = '#764ba2',
bgGradientAngle = 135,
bgImage = '',
bgPosition = '',
bgVideo = '',
overlayColor = '#000000',
overlayOpacity = 0,
@@ -97,7 +100,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
background: bgType !== 'image' ? bg : undefined,
backgroundImage: bgType === 'image' && bgImage ? `url('${bgImage}')` : undefined,
backgroundSize: bgType === 'image' ? 'cover' : undefined,
backgroundPosition: bgType === 'image' ? 'center' : undefined,
backgroundPosition: bgType === 'image' ? (cssValue(bgPosition) || 'center') : undefined,
minHeight: minHeight === '100vh' ? '100vh' : minHeight,
display: 'flex',
alignItems: justifyMap[verticalAlign] || 'center',
@@ -179,6 +182,7 @@ HeroSimple.craft = {
bgGradientTo: '#764ba2',
bgGradientAngle: 135,
bgImage: '',
bgPosition: '',
bgVideo: '',
overlayColor: '#000000',
overlayOpacity: 0,
@@ -208,7 +212,7 @@ HeroSimple.craft = {
background: props.bgType !== 'image' ? bg : undefined,
backgroundImage: props.bgType === 'image' && props.bgImage ? `url('${props.bgImage}')` : undefined,
backgroundSize: props.bgType === 'image' ? 'cover' : undefined,
backgroundPosition: props.bgType === 'image' ? 'center' : undefined,
backgroundPosition: props.bgType === 'image' ? (cssValue(props.bgPosition) || 'center') : undefined,
minHeight: props.minHeight || '500px',
display: 'flex',
alignItems: justifyMap[props.verticalAlign || 'center'],
+121
View File
@@ -0,0 +1,121 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { useStockPhotos, resetStockAvailability } from './useStockPhotos';
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
type Api = ReturnType<typeof useStockPhotos>;
let api: Api;
let root: Root;
let container: HTMLDivElement;
const fetchMock = vi.fn();
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' };
beforeEach(() => {
resetStockAvailability();
fetchMock.mockReset();
(globalThis as any).fetch = fetchMock;
(window as any).WHP_CONFIG = { apiUrl: '/api/site-builder.php', siteId: 5, csrfToken: 'tok' };
container = document.createElement('div');
document.body.appendChild(container);
act(() => { root = createRoot(container); root.render(React.createElement(Probe)); });
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
delete (window as any).WHP_CONFIG;
});
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 }));
let res: any;
await act(async () => { res = await api.search('red fox', 1); });
const url = fetchMock.mock.calls[0][0] as string;
expect(url).toBe('/api/site-builder.php?action=stock_search&site_id=5&q=red%20fox&page=1');
expect(res.results).toHaveLength(1);
expect(res.nextPage).toBe(2);
expect(api.availability).toBe('available');
});
test('caches per query+page (second identical search does not fetch)', async () => {
fetchMock.mockReturnValue(json({ success: true, results: [PHOTO], next_page: null }));
await act(async () => { await api.search('fox', 1); });
await act(async () => { await api.search('fox', 1); });
expect(fetchMock).toHaveBeenCalledTimes(1);
await act(async () => { await api.search('fox', 2); });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('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'); });
expect(res).toBeNull();
expect(api.availability).toBe('disabled');
});
test('a 404 (backend without the action) also reads as disabled', async () => {
fetchMock.mockReturnValue(json({}, 404));
await act(async () => { await api.search('fox'); });
expect(api.availability).toBe('disabled');
});
test('other failures surface an error but do not disable the tab', async () => {
fetchMock.mockReturnValue(json({ success: false, error: 'rate limited' }, 429));
await act(async () => { await api.search('fox'); });
expect(api.error).toBe('rate limited');
expect(api.availability).toBe('unknown');
});
test('a new query aborts the in-flight one', async () => {
const signals: AbortSignal[] = [];
fetchMock.mockImplementation((_u: string, init: any) => {
signals.push(init.signal);
return new Promise(() => { /* never resolves */ });
});
act(() => { void api.search('one'); });
act(() => { void api.search('two'); });
expect(signals[0].aborted).toBe(true);
expect(signals[1].aborted).toBe(false);
});
});
describe('useStockPhotos.importPhoto', () => {
test('POSTs provider/id/size with the CSRF header and resolves the asset url', async () => {
fetchMock.mockReturnValue(json({ success: true, url: '/api/assets-proxy/?site_id=5&filename=fox.jpg', name: 'fox.jpg', type: 'image/jpeg', credit: PHOTO.credit }));
let res: any;
await act(async () => { res = await api.importPhoto(7, 'large'); });
const [url, init] = fetchMock.mock.calls[0];
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(res.url).toBe('/api/assets-proxy/?site_id=5&filename=fox.jpg');
expect(res.credit.name).toBe('Ann');
});
test('failure returns null and sets error', async () => {
fetchMock.mockReturnValue(json({ success: false, error: 'download failed' }, 502));
let res: any = 'x';
await act(async () => { res = await api.importPhoto(7); });
expect(res).toBeNull();
expect(api.error).toBe('download failed');
});
});
describe('standalone', () => {
test('no WHP_CONFIG: no fetch', async () => {
delete (window as any).WHP_CONFIG;
await act(async () => { await api.search('fox'); await api.importPhoto(1); });
expect(fetchMock).not.toHaveBeenCalled();
});
});
+143
View File
@@ -0,0 +1,143 @@
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. */
export interface StockCredit { name: string; url: string }
export interface StockPhoto {
id: string | number;
thumb: string;
preview: string;
width: number;
height: number;
alt: string;
credit: StockCredit;
provider_url?: string;
}
export interface StockSearchResult {
results: StockPhoto[];
nextPage: number | null;
}
export interface ImportedStockPhoto {
url: string;
name: string;
type: string;
credit?: StockCredit;
}
export type StockAvailability = 'unknown' | 'available' | 'disabled';
/* 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>();
function setSharedAvailability(a: StockAvailability) {
if (sharedAvailability === a) return;
sharedAvailability = a;
listeners.forEach((l) => l(a));
}
/** Test seam: forget what a previous request learned about the backend. */
export function resetStockAvailability() {
sharedAvailability = 'unknown';
}
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 404 or an "unknown action" error -- both hide the tab. */
function isDisabledSignal(status: number, data: any): boolean {
if (status === 404) return true;
const err = String(data?.error || '');
return err === 'stock_disabled' || /unknown action|invalid action/i.test(err);
}
export function useStockPhotos() {
const [availability, setAvailability] = useState<StockAvailability>(sharedAvailability);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const cache = useRef(new Map<string, StockSearchResult>());
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
listeners.add(setAvailability);
setAvailability(sharedAvailability);
return () => {
listeners.delete(setAvailability);
abortRef.current?.abort();
};
}, []);
/** Returns the page of results, or null on disabled/error/aborted (see `error`/`availability`). */
const search = useCallback(async (q: string, page = 1): Promise<StockSearchResult | null> => {
const cfg = getCfg();
const query = q.trim();
if (!cfg || !query) return null;
const key = `${query}\u0000${page}`;
const hit = cache.current.get(key);
if (hit) { setError(null); return hit; }
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
try {
const url = `${cfg.apiUrl}?action=stock_search&site_id=${cfg.siteId}&q=${encodeURIComponent(query)}&page=${page}`;
const resp = await fetch(url, { headers: { 'X-CSRF-Token': cfg.csrfToken }, signal: ctrl.signal });
let data: any = null;
try { data = await resp.json(); } catch { /* non-JSON body */ }
if (isDisabledSignal(resp.status, data)) { setSharedAvailability('disabled'); return null; }
if (!resp.ok || !data?.success || !Array.isArray(data.results)) {
throw new Error(data?.error || `Search failed: ${resp.status}`);
}
setSharedAvailability('available');
const result: StockSearchResult = {
results: data.results,
nextPage: typeof data.next_page === 'number' ? data.next_page : null,
};
cache.current.set(key, result);
return result;
} catch (e) {
if (ctrl.signal.aborted) return null;
setError(e instanceof Error ? e.message : 'Search failed');
return null;
} finally {
if (abortRef.current === ctrl) { abortRef.current = null; setLoading(false); }
}
}, []);
/** Downloads the chosen photo into the site's assets; resolves the asset URL. */
const importPhoto = useCallback(async (id: string | number, size = 'large'): Promise<ImportedStockPhoto | null> => {
const cfg = getCfg();
if (!cfg) return null;
setError(null);
try {
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 }),
});
let data: any = null;
try { data = await resp.json(); } catch { /* non-JSON body */ }
if (isDisabledSignal(resp.status, data)) { setSharedAvailability('disabled'); 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 };
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed');
return null;
}
}, []);
return { availability, loading, error, search, importPhoto };
}
@@ -0,0 +1,71 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { AssetsPanel } from './AssetsPanel';
import { EditorConfigProvider } from '../../state/EditorConfigContext';
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const CFG: any = { user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, siteDomain: 'd', siteName: 'n', backUrl: '', isRoot: false };
const ASSETS = [
{ name: 'Hero-Banner.png', url: '/a/1', type: 'image/png' },
{ name: 'team.jpg', url: '/a/2', type: 'image/jpeg' },
{ name: 'intro.mp4', url: '/a/3', type: 'video/mp4' },
{ name: 'menu.pdf', url: '/a/4', type: 'application/pdf' },
];
let container: HTMLDivElement;
let root: Root;
const q = (id: string) => container.querySelector(`[data-testid="${id}"]`) as HTMLElement | null;
const names = () => Array.from(container.querySelectorAll('[title^="Click to copy URL"]')).map((e) => (e.getAttribute('title') || '').replace('Click to copy URL: ', ''));
beforeEach(async () => {
vi.useFakeTimers();
(globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({ success: true, assets: ASSETS }) });
container = document.createElement('div');
document.body.appendChild(container);
await act(async () => {
root = createRoot(container);
root.render(<EditorConfigProvider config={CFG}><AssetsPanel /></EditorConfigProvider>);
for (let i = 0; i < 5; i++) await Promise.resolve();
});
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
vi.useRealTimers();
});
describe('AssetsPanel search + chips (#55)', () => {
test('lists everything with no filter', () => {
expect(names()).toEqual(['/a/1', '/a/2', '/a/3', '/a/4']);
});
test('typing filters by name (case-insensitive) after the debounce', () => {
const input = q('assets-filter-input') as HTMLInputElement;
act(() => {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, 'TEAM');
input.dispatchEvent(new Event('input', { bubbles: true }));
});
expect(names()).toHaveLength(4);
act(() => { vi.advanceTimersByTime(160); });
expect(names()).toEqual(['/a/2']);
});
test('chips filter by type, and a no-match query shows the empty notice', () => {
act(() => { q('assets-filter-chip-image')!.click(); });
expect(names()).toEqual(['/a/1', '/a/2']);
act(() => { q('assets-filter-chip-image')!.click(); });
act(() => { q('assets-filter-chip-doc')!.click(); });
expect(names()).toEqual(['/a/4']);
const input = q('assets-filter-input') as HTMLInputElement;
act(() => {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, 'zzzz');
input.dispatchEvent(new Event('input', { bubbles: true }));
});
act(() => { vi.advanceTimersByTime(160); });
expect(names()).toEqual([]);
expect(q('assets-no-match')).not.toBeNull();
});
});
+19 -1
View File
@@ -3,6 +3,7 @@ import { useAssets } from '../../hooks/useAssets';
import { clickableProps } from '../../utils/a11y';
import { copyToClipboard } from '../../utils/clipboard';
import { useIsMobile } from '../../hooks/useIsMobile';
import { AssetFilterBar, useAssetFilter } from '../../ui/AssetFilter';
/** How long the "Delete?" confirm state stays armed before auto-resetting. */
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
@@ -10,6 +11,7 @@ const DELETE_CONFIRM_TIMEOUT_MS = 4000;
export const AssetsPanel: React.FC = () => {
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
const isMobile = useIsMobile();
const filter = useAssetFilter(assets);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
@@ -162,6 +164,22 @@ export const AssetsPanel: React.FC = () => {
</div>
)}
{/* Search + type chips (#55) -- only worth showing once there is something to filter */}
{assets.length > 0 && (
<AssetFilterBar
query={filter.query}
onQuery={filter.setQuery}
kind={filter.kind}
onKind={filter.setKind}
testIdPrefix="assets-filter"
/>
)}
{filter.active && filter.filtered.length === 0 && assets.length > 0 && (
<div data-testid="assets-no-match" style={{ textAlign: 'center', padding: 10, color: 'var(--color-text-dim)', fontSize: 11 }}>
No assets match your search.
</div>
)}
{/* Asset grid */}
<div
style={{
@@ -170,7 +188,7 @@ export const AssetsPanel: React.FC = () => {
gap: 6,
}}
>
{assets.map((asset) => {
{filter.filtered.map((asset) => {
const isConfirmingDelete = confirmDeleteName === asset.name;
return (
<div
@@ -15,6 +15,7 @@ import {
inputStyle,
sectionGap,
useNodeProp,
FocalPointGrid,
} from './shared';
import { useResolvedStyle, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
@@ -32,6 +33,10 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
{/* Background Image */}
<CollapsibleSection title="Background Image">
<AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
{/* #55: focal point -> background-position (only once an image is set) */}
{nodeProps.bgImage && (
<FocalPointGrid value={nodeProps.bgPosition || ''} onChange={(v) => setProp('bgPosition', v)} />
)}
</CollapsibleSection>
{/* Background Color */}
@@ -6,6 +6,7 @@ import {
labelStyle,
inputStyle,
btnActiveStyle,
FocalPointGrid,
sectionGap,
useNodeProp,
} from './shared';
@@ -81,6 +82,10 @@ export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<div style={sectionGap}>
<label style={labelStyle}>Background Image</label>
<AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
{/* #55: focal point -> background-position (only once an image is set) */}
{nodeProps.bgImage && (
<FocalPointGrid value={nodeProps.bgPosition || ''} onChange={(v) => setProp('bgPosition', v)} />
)}
</div>
)}
@@ -51,6 +51,10 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
value={nodeProps.src || ''}
onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url || PLACEHOLDER_SRC; })}
variant="full"
// #55: a stock photo carries its own alt/credit; only fill an EMPTY alt.
onPick={(a) => {
if (!nodeProps.alt && a.alt) actions.setProp(selectedId, (props: any) => { props.alt = a.alt; });
}}
/>
</div>
+31
View File
@@ -3187,3 +3187,34 @@ body {
}
.section-lib-empty { grid-column: 1 / -1; color: var(--color-text-muted); font-size: 13px; padding: 24px 0; text-align: center; }
.block-browse-sections { display: block; width: calc(100% - 16px); margin: 0 8px 8px; }
/* ---------- #55 media search, stock photos, focal point ---------- */
.asset-filter { display: flex; flex-direction: column; gap: 6px; }
.asset-filter-input { width: 100%; font-size: 11px; box-sizing: border-box; }
.asset-filter-chips { display: flex; gap: 4px; flex-wrap: wrap; }
.asset-filter-chip {
padding: 2px 8px; font-size: 10px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--color-border); background: transparent; color: var(--color-text-muted);
}
.asset-filter-chip:hover { border-color: var(--color-text-muted); }
.asset-filter-chip.active { background: var(--color-accent); border-color: var(--color-accent); color: #fff; }
.asset-picker-tabs { display: flex; gap: 2px; margin-top: 8px; border-bottom: 1px solid var(--color-border); }
.asset-picker-tab {
flex: 1; padding: 5px 4px; font-size: 11px; cursor: pointer; background: transparent;
border: none; border-bottom: 2px solid transparent; color: var(--color-text-muted);
}
.asset-picker-tab.active { color: var(--color-text); border-bottom-color: var(--color-accent); }
.stock-tab { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.stock-grid { column-count: 2; column-gap: 4px; max-height: 240px; overflow-y: auto; }
.stock-thumb {
display: block; width: 100%; margin: 0 0 4px; padding: 0; break-inside: avoid; cursor: pointer;
border: 2px solid transparent; border-radius: 4px; overflow: hidden; background: var(--color-bg-base);
}
.stock-thumb.selected { border-color: var(--color-accent); }
.stock-thumb img { display: block; width: 100%; height: auto; }
.stock-credit, .stock-footer { font-size: 10px; color: var(--color-text-dim); }
.stock-credit a, .stock-footer a { color: inherit; text-decoration: underline; }
.stock-status { font-size: 11px; color: var(--color-text-dim); text-align: center; padding: 10px 0; }
.stock-status.error { color: var(--color-danger); }
+89
View File
@@ -0,0 +1,89 @@
import React, { useEffect, useMemo, useState } from 'react';
/* ---------- Asset search + type chips (#55) ----------
Shared by the Assets panel and the asset picker's Library grid. Filtering
is client-side over `name` and `type`; the query is debounced 150 ms. */
export type AssetKind = 'image' | 'video' | 'doc';
export const ASSET_FILTER_DEBOUNCE_MS = 150;
const KIND_CHIPS: { kind: AssetKind; label: string }[] = [
{ kind: 'image', label: 'Images' },
{ kind: 'video', label: 'Videos' },
{ kind: 'doc', label: 'Docs' },
];
interface Filterable { name: string; type: string }
/** Classify by MIME `type`, falling back to the file extension (type may be a bare 'file'). */
export function assetKind(a: Filterable): AssetKind {
const t = (a.type || '').toLowerCase();
if (t.startsWith('image/') || /\.(jpe?g|png|gif|svg|webp|avif|ico)$/i.test(a.name || '')) return 'image';
if (t.startsWith('video/') || /\.(mp4|webm|mov|ogv|m4v)$/i.test(a.name || '')) return 'video';
return 'doc';
}
/** Case-insensitive match over name and type; `kind` null means all types. */
export function filterAssets<T extends Filterable>(assets: T[], query: string, kind: AssetKind | null): T[] {
const q = query.trim().toLowerCase();
return assets.filter((a) => {
if (kind && assetKind(a) !== kind) return false;
if (!q) return true;
return (a.name || '').toLowerCase().includes(q) || (a.type || '').toLowerCase().includes(q);
});
}
export function useAssetFilter<T extends Filterable>(assets: T[]) {
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
const [kind, setKind] = useState<AssetKind | null>(null);
useEffect(() => {
const t = setTimeout(() => setDebounced(query), ASSET_FILTER_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [query]);
const filtered = useMemo(() => filterAssets(assets, debounced, kind), [assets, debounced, kind]);
const active = debounced.trim() !== '' || kind !== null;
return { query, setQuery, kind, setKind, filtered, active };
}
interface BarProps {
query: string;
onQuery: (q: string) => void;
kind: AssetKind | null;
onKind: (k: AssetKind | null) => void;
/** Hide chips that make no sense (e.g. picker with mediaType 'image'). */
kinds?: AssetKind[];
testIdPrefix?: string;
}
export const AssetFilterBar: React.FC<BarProps> = ({ query, onQuery, kind, onKind, kinds, testIdPrefix = 'asset-filter' }) => (
<div className="asset-filter" data-testid={testIdPrefix}>
<input
type="search"
className="guided-input asset-filter-input"
value={query}
placeholder="Search by name or type..."
aria-label="Search assets"
onChange={(e) => onQuery(e.target.value)}
data-testid={`${testIdPrefix}-input`}
/>
{(!kinds || kinds.length > 0) && (
<div className="asset-filter-chips" role="group" aria-label="Filter by type">
{KIND_CHIPS.filter((c) => !kinds || kinds.includes(c.kind)).map((c) => (
<button
key={c.kind}
type="button"
className={`asset-filter-chip${kind === c.kind ? ' active' : ''}`}
aria-pressed={kind === c.kind}
onClick={() => onKind(kind === c.kind ? null : c.kind)}
data-testid={`${testIdPrefix}-chip-${c.kind}`}
>
{c.label}
</button>
))}
</div>
)}
</div>
);
+87
View File
@@ -11,6 +11,7 @@ vi.mock('../utils/assets', () => ({
}));
import { uploadAsset, listAssets } from '../utils/assets';
import { resetStockAvailability } from '../hooks/useStockPhotos';
const uploadAssetMock = uploadAsset as unknown as ReturnType<typeof vi.fn>;
const listAssetsMock = listAssets as unknown as ReturnType<typeof vi.fn>;
@@ -202,3 +203,89 @@ describe('AssetPicker component', () => {
expect(q<HTMLInputElement>('asset-picker-file-input')!.accept).toBe('');
});
});
/* ---------- #55 library search/filter + Stock photos entry ---------- */
describe('AssetPicker library filter and stock entry (#55)', () => {
const MIXED: Asset[] = [
{ name: 'Hero-Banner.png', url: '/a/1', type: 'image/png' },
{ name: 'team.jpg', url: '/a/2', type: 'image/jpeg' },
{ name: 'intro.mp4', url: '/a/3', type: 'video/mp4' },
{ name: 'menu.pdf', url: '/a/4', type: 'application/pdf' },
];
const fetchMock = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
resetStockAvailability();
fetchMock.mockReset();
(globalThis as any).fetch = fetchMock;
listAssetsMock.mockResolvedValue(MIXED);
(window as any).WHP_CONFIG = { apiUrl: 'https://x', siteId: 1, csrfToken: 't' };
});
afterEach(() => {
vi.useRealTimers();
unmount();
delete (window as any).WHP_CONFIG;
});
const thumbs = () => Array.from(container.querySelectorAll('[data-testid^="asset-picker-thumb-"]')).map((e) => e.getAttribute('data-testid')!.replace('asset-picker-thumb-', ''));
test('typing filters the grid by name, case-insensitively, after the 150 ms debounce', async () => {
render(<AssetPicker value="" onChange={vi.fn()} mediaType="any" />);
await act(async () => { click(q('asset-picker-browse')); await Promise.resolve(); });
expect(thumbs()).toHaveLength(4);
act(() => { setValue(q<HTMLInputElement>('asset-picker-filter-input')!, 'HERO'); });
expect(thumbs()).toHaveLength(4); // not yet debounced
act(() => { vi.advanceTimersByTime(160); });
expect(thumbs()).toEqual(['Hero-Banner.png']);
});
test('type chips filter by kind and toggle off', async () => {
render(<AssetPicker value="" onChange={vi.fn()} mediaType="any" />);
await act(async () => { click(q('asset-picker-browse')); await Promise.resolve(); });
click(q('asset-picker-filter-chip-video'));
expect(thumbs()).toEqual(['intro.mp4']);
click(q('asset-picker-filter-chip-doc'));
expect(thumbs()).toEqual(['menu.pdf']);
click(q('asset-picker-filter-chip-doc'));
expect(thumbs()).toHaveLength(4);
});
test('Stock entry is absent in standalone mode', () => {
delete (window as any).WHP_CONFIG;
render(<AssetPicker value="" onChange={vi.fn()} />);
expect(q('asset-picker-stock')).toBeNull();
});
test('Stock entry is absent for non-image pickers', () => {
render(<AssetPicker value="" onChange={vi.fn()} mediaType="video" />);
expect(q('asset-picker-stock')).toBeNull();
});
test('Stock entry is present in WHP mode for images, and disappears once the API reports stock_disabled', async () => {
render(<AssetPicker value="" onChange={vi.fn()} />);
expect(q('asset-picker-stock')).not.toBeNull();
fetchMock.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({ success: false, error: 'stock_disabled' }) });
click(q('asset-picker-stock'));
setValue(q<HTMLInputElement>('stock-search-input')!, 'fox');
await act(async () => { q<HTMLElement>('stock-search-btn')!.click(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); });
expect(q('asset-picker-stock')).toBeNull();
});
test('picking a stock photo calls onChange(url) and onPick with alt/credit', async () => {
const onChange = vi.fn();
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' };
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 }) });
setValue(q<HTMLInputElement>('stock-search-input')!, 'fox');
await act(async () => { q<HTMLElement>('stock-search-btn')!.click(); for (let i = 0; i < 5; i++) await Promise.resolve(); });
click(q('stock-thumb-7'));
await act(async () => { q<HTMLElement>('stock-use')!.click(); for (let i = 0; i < 5; i++) await Promise.resolve(); });
expect(onChange).toHaveBeenCalledWith('/api/assets-proxy/?site_id=1&filename=f.jpg');
expect(onPick).toHaveBeenCalledWith({ url: '/api/assets-proxy/?site_id=1&filename=f.jpg', alt: 'fox', credit });
});
});
+62 -3
View File
@@ -1,6 +1,9 @@
import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react';
import { uploadAsset, listAssets, Asset } from '../utils/assets';
import { clickableProps } from '../utils/a11y';
import { AssetFilterBar, useAssetFilter } from './AssetFilter';
import { StockPhotoTab, PickedAsset } from './StockPhotoTab';
import { useStockPhotos } from '../hooks/useStockPhotos';
export interface AssetPickerProps {
value: string;
@@ -10,6 +13,9 @@ export interface AssetPickerProps {
/** 'full' mirrors ImageStylePanel's source UI; 'compact' is a single tight row for array-editor cards. */
variant?: 'full' | 'compact';
placeholder?: string;
/** Richer pick callback (#55): fired alongside `onChange` for stock photos with the
* photo's alt text and credit, so a caller (Image panel) can prefill `alt`. */
onPick?: (asset: PickedAsset) => void;
}
/* ---------- Pure helpers (exported for unit testing) ---------- */
@@ -110,14 +116,20 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
mediaType = 'image',
variant = 'full',
placeholder = 'Or paste URL...',
onPick,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [showStock, setShowStock] = useState(false);
const [browserAssets, setBrowserAssets] = useState<Asset[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
const [urlValue, setUrlValue] = useState(value || '');
const [dragOver, setDragOver] = useState(false);
const standalone = isStandalone();
const filter = useAssetFilter(browserAssets);
const { availability: stockAvailability } = useStockPhotos();
// Stock photos: images only, WHP mode only, and hidden once the backend reports it disabled/absent.
const stockEnabled = !standalone && mediaType === 'image' && stockAvailability !== 'disabled';
useEffect(() => {
setUrlValue(value || '');
@@ -136,6 +148,7 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
setShowBrowser(false);
return;
}
setShowStock(false);
setBrowserLoading(true);
try {
const assets = await listAssets(mediaType);
@@ -152,6 +165,18 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
setShowBrowser(false);
}, [onChange]);
const handleStockPick = useCallback((asset: PickedAsset) => {
onChange(asset.url);
setUrlValue(asset.url);
onPick?.(asset);
setShowStock(false);
}, [onChange, onPick]);
const handleStockToggle = useCallback(() => {
setShowBrowser(false);
setShowStock((v) => !v);
}, []);
const handleApplyUrl = useCallback(() => {
onChange(urlValue);
}, [urlValue, onChange]);
@@ -188,9 +213,19 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
/>
);
const grid = showBrowser && (
<div style={gridStyle} data-testid="asset-picker-grid">
{browserAssets.map((asset) => (
const grid = showBrowser && (<>
<div style={{ marginTop: 8 }}>
<AssetFilterBar
query={filter.query}
onQuery={filter.setQuery}
kind={filter.kind}
onKind={filter.setKind}
kinds={mediaType === 'any' ? undefined : []}
testIdPrefix="asset-picker-filter"
/>
</div>
<div style={{ ...gridStyle, marginTop: 4 }} data-testid="asset-picker-grid">
{filter.filtered.map((asset) => (
<div
key={asset.name}
{...clickableProps(() => handleSelectAsset(asset))}
@@ -205,7 +240,27 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
No assets uploaded yet. Use Upload above.
</p>
)}
{browserAssets.length > 0 && filter.filtered.length === 0 && (
<p data-testid="asset-picker-no-match" style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>
No assets match your search.
</p>
)}
</div>
</>);
const stockPanel = showStock && stockEnabled && <StockPhotoTab onPick={handleStockPick} />;
const stockButton = stockEnabled && (
<button
type="button"
onClick={handleStockToggle}
data-testid="asset-picker-stock"
title="Stock photos"
style={variant === 'full' ? browseBtnStyle(showStock) : { ...browseBtnStyle(showStock), flex: 'none', padding: '6px 8px' }}
>
<i className="fa fa-picture-o" style={{ marginRight: variant === 'full' ? 4 : 0 }} />
{variant === 'full' && 'Stock'}
</button>
);
const browseButton = !standalone && (
@@ -250,6 +305,7 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
<i className="fa fa-upload" />
</button>
{browseButton}
{stockButton}
<input
type="text"
className="guided-input"
@@ -263,6 +319,7 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
/>
</div>
{grid}
{stockPanel}
{fileInput}
</div>
);
@@ -307,9 +364,11 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
{browseButton}
{stockButton}
</div>
{grid}
{stockPanel}
{fileInput}
<div style={{ marginTop: 6 }}>
+103
View File
@@ -0,0 +1,103 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { StockPhotoTab } from './StockPhotoTab';
import { resetStockAvailability } from '../hooks/useStockPhotos';
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
const fetchMock = vi.fn();
const json = (body: any, status = 200) =>
Promise.resolve({ ok: status < 300, status, json: () => Promise.resolve(body) } as Response);
const q = (id: string) => container.querySelector(`[data-testid="${id}"]`) as HTMLElement | null;
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
function setValue(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, value);
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' } };
beforeEach(() => {
resetStockAvailability();
fetchMock.mockReset();
(globalThis as any).fetch = fetchMock;
(window as any).WHP_CONFIG = { apiUrl: '/api', siteId: 5, csrfToken: 'tok' };
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
delete (window as any).WHP_CONFIG;
});
function mount(onPick = vi.fn()) {
act(() => { root = createRoot(container); root.render(<StockPhotoTab onPick={onPick} />); });
return onPick;
}
async function searchAndSelect() {
fetchMock.mockReturnValueOnce(json({ success: true, results: [PHOTO], next_page: null }));
setValueIn('stock-search-input', 'fox');
await act(async () => { q('stock-search-btn')!.click(); });
await flush();
act(() => { q('stock-thumb-7')!.click(); });
}
function setValueIn(id: string, v: string) { act(() => { setValue(q(id) as HTMLInputElement, v); }); }
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');
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();
expect(onPick).toHaveBeenCalledWith({ url: '/api/assets-proxy/?site_id=5&filename=fox.jpg', alt: 'a red fox', credit: PHOTO.credit });
});
test('empty alt falls back to the credit line', async () => {
const onPick = mount();
fetchMock.mockReturnValueOnce(json({ success: true, results: [{ ...PHOTO, alt: '' }], next_page: null }));
setValueIn('stock-search-input', 'fox');
await act(async () => { q('stock-search-btn')!.click(); });
await flush();
act(() => { q('stock-thumb-7')!.click(); });
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');
});
test('no results shows the empty state', async () => {
mount();
fetchMock.mockReturnValueOnce(json({ success: true, results: [], next_page: null }));
setValueIn('stock-search-input', 'zzz');
await act(async () => { q('stock-search-btn')!.click(); });
await flush();
expect(q('stock-empty')).not.toBeNull();
});
test('stock_disabled replaces the tab with the disabled notice', async () => {
mount();
fetchMock.mockReturnValueOnce(json({ success: false, error: 'stock_disabled' }));
setValueIn('stock-search-input', 'fox');
await act(async () => { q('stock-search-btn')!.click(); });
await flush();
expect(q('stock-disabled')).not.toBeNull();
});
test('a failed import does not call onPick and shows the error', async () => {
const onPick = mount();
await searchAndSelect();
fetchMock.mockReturnValueOnce(json({ success: false, error: 'download failed' }, 502));
await act(async () => { q('stock-use')!.click(); });
await flush();
expect(onPick).not.toHaveBeenCalled();
expect(q('stock-error')!.textContent).toBe('download failed');
});
});
+142
View File
@@ -0,0 +1,142 @@
import React, { useState, useCallback } from 'react';
import { useStockPhotos, StockPhoto, StockCredit } from '../hooks/useStockPhotos';
export interface PickedAsset {
url: string;
alt?: string;
credit?: StockCredit;
}
interface Props {
/** Called with the imported (site-local) asset once "Use photo" completes. */
onPick: (asset: PickedAsset) => void;
}
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 StockPhotoTab: React.FC<Props> = ({ onPick }) => {
const { availability, loading, error, search, importPhoto } = useStockPhotos();
const [query, setQuery] = useState('');
const [submitted, setSubmitted] = useState('');
const [photos, setPhotos] = useState<StockPhoto[]>([]);
const [nextPage, setNextPage] = useState<number | null>(null);
const [selected, setSelected] = useState<StockPhoto | null>(null);
const [importing, setImporting] = useState(false);
const [searched, setSearched] = useState(false);
const runSearch = useCallback(async () => {
const q = query.trim();
if (!q) return;
setSubmitted(q);
setSelected(null);
const res = await search(q, 1);
if (res) { setPhotos(res.results); setNextPage(res.nextPage); setSearched(true); }
else setPhotos([]);
}, [query, search]);
const loadMore = useCallback(async () => {
if (nextPage == null) return;
const res = await search(submitted, nextPage);
if (res) { setPhotos((p) => [...p, ...res.results]); setNextPage(res.nextPage); }
}, [nextPage, search, submitted]);
const usePhoto = useCallback(async () => {
if (!selected) return;
setImporting(true);
try {
const imported = await importPhoto(selected.id, 'large');
if (!imported) return;
const credit = imported.credit || selected.credit;
onPick({ url: imported.url, alt: selected.alt || creditLine(credit), credit });
} finally {
setImporting(false);
}
}, [selected, importPhoto, onPick]);
if (availability === 'disabled') {
return (
<div className="stock-tab" data-testid="stock-disabled">
<div className="stock-status">Stock photos are not enabled for this server.</div>
</div>
);
}
return (
<div className="stock-tab" data-testid="stock-tab">
<form
className="guided-input-row"
onSubmit={(e) => { e.preventDefault(); runSearch(); }}
>
<input
type="search"
className="guided-input"
value={query}
placeholder="Search free photos..."
aria-label="Search stock photos"
onChange={(e) => setQuery(e.target.value)}
data-testid="stock-search-input"
style={{ fontSize: 11 }}
/>
<button type="submit" className="preset-btn apply-btn" disabled={loading || !query.trim()} data-testid="stock-search-btn">
Search
</button>
</form>
{error && <div className="stock-status error" role="alert" data-testid="stock-error">{error}</div>}
{loading && photos.length === 0 && <div className="stock-status">Searching...</div>}
{!loading && !error && searched && photos.length === 0 && (
<div className="stock-status" data-testid="stock-empty">No photos found for &ldquo;{submitted}&rdquo;.</div>
)}
{photos.length > 0 && (
<div className="stock-grid" data-testid="stock-grid">
{photos.map((p) => (
<button
key={p.id}
type="button"
className={`stock-thumb${selected?.id === p.id ? ' selected' : ''}`}
onClick={() => setSelected(p)}
aria-label={p.alt || `Photo ${p.id}`}
aria-pressed={selected?.id === p.id}
data-testid={`stock-thumb-${p.id}`}
style={p.width && p.height ? { aspectRatio: `${p.width} / ${p.height}` } : undefined}
>
<img src={p.thumb} alt={p.alt || ''} loading="lazy" />
</button>
))}
</div>
)}
{nextPage != null && photos.length > 0 && (
<button type="button" className="preset-btn" onClick={loadMore} disabled={loading} data-testid="stock-more">
{loading ? 'Loading...' : 'Load more'}
</button>
)}
{selected && (
<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</>
) : creditLine(selected.credit)}
</div>
<button
type="button"
className="preset-btn apply-btn"
onClick={usePhoto}
disabled={importing}
data-testid="stock-use"
style={{ marginTop: 4, width: '100%' }}
>
{importing ? 'Importing...' : 'Use photo'}
</button>
</div>
)}
<div className="stock-footer">
<a href="https://www.pexels.com" target="_blank" rel="noopener noreferrer">Photos from Pexels</a>
</div>
</div>
);
};