) : null;
}
return (
// The Kumo Sidebar wrapper sets `whitespace-nowrap` for its collapse
// animation, which would stop long field descriptions from wrapping.
{t`Bylines`}
entry.byline)}
bylinesLoaded={availableBylinesLoaded}
onChange={onBylinesChange}
onQuickCreate={onQuickCreateByline}
onQuickEdit={onQuickEditByline}
// Existing entry: use its own locale. New entry: use the
// URL `?locale=` (passed in via `entryLocale`).
entryLocale={item?.locale ?? entryLocale}
i18n={i18n}
/>
)}
{/* Do not register an empty sortable row when this collection has no taxonomies. */}
{item && hasApplicableTaxonomies && (
)}
{hasSeo && !isNew && onSeoChange && (
{t`SEO`}
)}
{portableTextEditor && (
)}
{!isNew && item && supportsRevisions && (
)}
{!isNew && onDelete && (
(
: }
>
{t`Move to Trash`}
)}
/>
)}
);
});
interface AuthorSelectorProps {
authorId: string | null;
users: UserListItem[];
onChange?: (authorId: string | null) => void;
}
interface BylineCreditsEditorProps {
credits: BylineCreditInput[];
bylines: BylineSummary[];
/**
* Full byline details for the entry's already-selected credits. Seeded from
* the saved entry so credited bylines always render their name/slug even when
* they fall outside the initial (unsearched) picker list.
*/
selectedBylineDetails?: BylineSummary[];
onChange: (bylines: BylineCreditInput[]) => void;
onQuickCreate?: (input: { slug: string; displayName: string }) => Promise;
onQuickEdit?: (
bylineId: string,
input: { slug: string; displayName: string },
) => Promise;
/**
* Locale of the entry being edited. When the picker comes back empty and
* the install is multi-locale, the empty-state copy and CTA link are
* scoped to this locale (post-migration 040, the picker is strict
* per-locale — see the bylines manager flow).
*/
entryLocale?: string | null;
/** i18n config from the manifest. When set with >1 locales, the editor renders the locale-scoped empty-state. */
i18n?: { defaultLocale: string; locales: string[] } | null;
/** Suppresses the empty-state until the picker query resolves. Defaults to true. */
bylinesLoaded?: boolean;
}
function BylineCreditsEditor({
credits,
bylines,
selectedBylineDetails,
onChange,
onQuickCreate,
onQuickEdit,
entryLocale,
i18n,
bylinesLoaded = true,
}: BylineCreditsEditorProps) {
const { t } = useLingui();
const [search, setSearch] = React.useState("");
const debouncedSearch = useDebouncedValue(search, 300);
const [quickName, setQuickName] = React.useState("");
const [quickSlug, setQuickSlug] = React.useState("");
const [quickError, setQuickError] = React.useState(null);
const [isCreating, setIsCreating] = React.useState(false);
const [editBylineId, setEditBylineId] = React.useState(null);
const [editName, setEditName] = React.useState("");
const [editSlug, setEditSlug] = React.useState("");
const [editError, setEditError] = React.useState(null);
const [isEditing, setIsEditing] = React.useState(false);
// Server-side search so the picker isn't limited to the first page of
// bylines (previously capped at 100 with no way to find the rest). When the
// search box is empty we fall back to the parent-provided initial list.
const trimmedSearch = debouncedSearch.trim();
const searchEnabled = trimmedSearch.length > 0;
const searchResults = useQuery({
queryKey: ["bylines", "credit-picker", entryLocale ?? null, trimmedSearch],
queryFn: () =>
fetchBylines({ search: trimmedSearch, locale: entryLocale ?? undefined, limit: 20 }),
enabled: searchEnabled,
placeholderData: keepPreviousData,
});
const resultPool = searchEnabled ? (searchResults.data?.items ?? []) : bylines;
const hasMoreResults = searchEnabled ? !!searchResults.data?.nextCursor : bylines.length >= 100;
// Resolve credited bylines to their full details for display. Selected rows
// come from the parent-provided details so they keep rendering even when the
// current search results no longer include them.
const bylineMap = React.useMemo(() => {
const map = new Map();
for (const b of selectedBylineDetails ?? []) map.set(b.id, b);
for (const b of bylines) map.set(b.id, b);
for (const b of searchResults.data?.items ?? []) map.set(b.id, b);
return map;
}, [selectedBylineDetails, bylines, searchResults.data?.items]);
const availableToAdd = resultPool.filter((b) => !credits.some((c) => c.bylineId === b.id));
const addByline = (bylineId: string) => {
if (credits.some((c) => c.bylineId === bylineId)) return;
onChange([...credits, { bylineId, roleLabel: null }]);
};
const move = (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= credits.length) return;
const next = [...credits];
const [moved] = next.splice(index, 1);
if (!moved) return;
next.splice(target, 0, moved);
onChange(next);
};
const resetQuickCreate = () => {
setQuickName("");
setQuickSlug("");
setQuickError(null);
};
const openEditByline = (byline: BylineSummary) => {
setEditBylineId(byline.id);
setEditName(byline.displayName);
setEditSlug(byline.slug);
setEditError(null);
};
const resetQuickEdit = () => {
setEditBylineId(null);
setEditName("");
setEditSlug("");
setEditError(null);
};
// Multi-locale install with no bylines at the entry's locale: show a
// CTA to the byline manager, scoped to that locale. Quick-create
// still works inline.
const isMultiLocale = !!i18n && i18n.locales.length > 1;
const showLocaleEmptyState =
isMultiLocale && bylinesLoaded && bylines.length === 0 && !!entryLocale;
return (
{showLocaleEmptyState && (
{t`No bylines available in ${entryLocale}. Create a variant from the Bylines page before crediting one on this entry.`}