import { useEffect, useMemo, useRef, useState } from 'react'; import { LoaderCircleIcon, SearchIcon, XIcon } from 'lucide-react'; import { Controller, type Control, type FieldPath, type FieldValues, } from 'react-hook-form'; import type { ISeoObject } from '../../service/seo/seo.interface'; import { fetchSeoObjects } from '../../service/seo/seo.service'; import { Badge } from '../ui/badge'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { OptionRow, type OptionCopy } from './option-row'; const DEBOUNCE_MS = 350; /** The IDs a field holds, as the option stores them: "12, 40, 107". */ const parseIds = (value: string): number[] => value .split(',') .map(part => Number(part.trim())) .filter(id => Number.isInteger(id) && id > 0); const asList = (ids: number[]): string => ids.join(', '); /** * Searches the site and reports what is chosen. Used both by the row that * holds a list of IDs and by the button that fills an address. */ export const ObjectSearch = ({ kind, chosen, onPick, placeholder, publishedOnly = false, }: { kind: 'post' | 'term'; chosen: number[]; onPick: (item: ISeoObject) => void; placeholder: string; /** Only what visitors can open: a redirect destination, never a draft. */ publishedOnly?: boolean; }): JSX.Element => { const [search, setSearch] = useState(''); const [items, setItems] = useState(null); const [busy, setBusy] = useState(false); const request = useRef(0); useEffect(() => { const term = search.trim(); if (term === '') { setItems(null); return undefined; } setBusy(true); const ticket = ++request.current; const timer = window.setTimeout(() => { fetchSeoObjects({ kind, search: term, published: publishedOnly }) .then(data => { if (ticket === request.current) { setItems(data.items); } }) .catch(() => { if (ticket === request.current) { setItems([]); } }) .finally(() => { if (ticket === request.current) { setBusy(false); } }); }, DEBOUNCE_MS); return () => window.clearTimeout(timer); }, [kind, search, publishedOnly]); return (
setSearch(event.target.value)} /> {busy && ( )}
{items !== null && ( )}
); }; /** * The chips of what is chosen and the search under them. */ const ObjectsField = ({ value, kind, placeholder, onChange, }: { value: string; kind: 'post' | 'term'; placeholder: string; onChange: (value: string) => void; }): JSX.Element => { const [known, setKnown] = useState>({}); const ids = useMemo(() => parseIds(value), [value]); const missing = useMemo( () => ids.filter(id => !known[id]).join(','), [ids, known] ); // The names behind the IDs a setting already held, asked for once. useEffect(() => { if (missing === '') { return; } fetchSeoObjects({ kind, ids: parseIds(missing) }) .then(data => setKnown(current => ({ ...current, ...Object.fromEntries(data.items.map(item => [item.id, item])), })) ) .catch(() => undefined); }, [kind, missing]); return (
{ids.length > 0 && (
    {ids.map(id => (
  • {known[id]?.title ?? `#${id}`}
  • ))}
)} { setKnown(current => ({ ...current, [item.id]: item })); onChange(asList([...ids, item.id])); }} />
); }; /** * A setting that holds a list of IDs, shown as the names behind them: the * merchant searches the site and picks, instead of looking up that the * page they mean is number 412. What is stored stays the comma separated * list, so nothing else has to change. */ export function ObjectsRow({ control, name, copy, kind, error, placeholder, }: { control: Control; name: FieldPath; copy: OptionCopy; kind: 'post' | 'term'; error?: string; placeholder: string; }): JSX.Element { return ( ( )} /> ); } /** * The custom fields the pages of a post type actually carry, offered under * the setting that feeds them to the analysis: the merchant clicks one * instead of recalling how the theme spelled it. */ export const MetaKeyHint = ({ postType, value, onChange, }: { postType: string; value: string; onChange: (value: string) => void; }): JSX.Element => { const [items, setItems] = useState(null); const [busy, setBusy] = useState(false); const chosen = value .split('\n') .map(line => line.trim()) .filter(Boolean); const load = (): void => { setBusy(true); fetchSeoObjects({ kind: 'meta', postType }) .then(data => setItems(data.items)) .catch(() => setItems([])) .finally(() => setBusy(false)); }; if (items === null) { return ( ); } if (items.length === 0) { return These pages carry no custom fields of their own.; } return (
    {items .filter(item => !chosen.includes(item.title)) .map(item => (
  • ))}
); };