import { Button, Input, InputArea, Loader, Select, Switch } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { IdentificationCard } from "@phosphor-icons/react"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useSearch } from "@tanstack/react-router"; import * as React from "react"; import { BylineAvatarField } from "../components/BylineAvatarField.js"; import { ConfirmDialog } from "../components/ConfirmDialog.js"; import { DialogError, getMutationError } from "../components/DialogError.js"; import { LocaleSwitcher, useI18nConfig } from "../components/LocaleSwitcher.js"; import { RouterLinkButton } from "../components/RouterLinkButton.js"; import { BYLINE_SCHEMA_NAV_ITEM } from "../components/Sidebar.js"; import { TranslationsPanel } from "../components/TranslationsPanel.js"; import { createByline, createBylineTranslation, deleteByline, fetchByline, fetchBylineTranslations, fetchBylines, fetchUsers, updateByline, type BylineSummary, type UserListItem, } from "../lib/api"; import { listBylineFields, type BylineFieldDefinition } from "../lib/api/byline-fields.js"; import { fetchManifest } from "../lib/api/client.js"; import { useCurrentUser } from "../lib/api/current-user.js"; import { useDebouncedValue } from "../lib/hooks.js"; interface BylineFormState { slug: string; displayName: string; bio: string; websiteUrl: string; userId: string | null; isGuest: boolean; /** Media id of the byline's avatar image, or null when unset (#1250). */ avatarMediaId: string | null; /** * Custom-field values keyed by field slug (Phase 6 of #1174). Always * a defined object — `{}` when no fields are registered or the byline * has no stored values — so callers can spread it into update bodies * unconditionally. */ customFields: Record; } export interface LoadMoreSnapshot { search: string; guestFilter: "all" | "guest" | "linked"; locale: string | undefined; cursor: string; } /** * True when the load-more snapshot still matches the current filter state. * Used to discard appends from requests whose filters have changed mid-flight. */ export function loadMoreSnapshotMatches( snapshot: LoadMoreSnapshot, current: Omit, ): boolean { return ( snapshot.search === current.search && snapshot.guestFilter === current.guestFilter && snapshot.locale === current.locale ); } function toFormState(byline?: BylineSummary | null): BylineFormState { if (!byline) { return { slug: "", displayName: "", bio: "", websiteUrl: "", userId: null, isGuest: false, avatarMediaId: null, customFields: {}, }; } return { slug: byline.slug, displayName: byline.displayName, bio: byline.bio ?? "", websiteUrl: byline.websiteUrl ?? "", userId: byline.userId, isGuest: byline.isGuest, avatarMediaId: byline.avatarMediaId ?? null, customFields: byline.customFields ?? {}, }; } function getUserLabel(user: UserListItem): string { if (user.name) return `${user.name} (${user.email})`; return user.email; } export function BylinesPage() { const { t } = useLingui(); const queryClient = useQueryClient(); const navigate = useNavigate(); const { locale: routeLocale } = useSearch({ from: "/_admin/bylines" }); const [search, setSearch] = React.useState(""); // Debounce the search before it feeds the query key/fetch so typing stays // responsive — the input stays bound to raw `search` while only the // debounced value drives refetches. const debouncedSearch = useDebouncedValue(search, 300); const [guestFilter, setGuestFilter] = React.useState<"all" | "guest" | "linked">("all"); const [selectedId, setSelectedId] = React.useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const [allItems, setAllItems] = React.useState([]); const [nextCursor, setNextCursor] = React.useState(undefined); // Manifest powers the locale switcher: the configured locales + default // locale come from the site's emdash config, exposed on the manifest. const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, }); const i18n = useI18nConfig(manifest); const isMultiLocale = !!i18n && i18n.locales.length > 1; const { data: currentUser } = useCurrentUser(); const canManageBylineSchema = (currentUser?.role ?? 0) >= BYLINE_SCHEMA_NAV_ITEM.minRole; // `activeLocale` is the URL search param when present, else the default. // Picker on a translated post can be expected to scope to the post's // locale (Phase 4 wires that up); for the bylines manager itself the // active locale just filters the list and seeds new bylines. const activeLocale = routeLocale ?? i18n?.defaultLocale ?? undefined; const handleLocaleChange = (locale: string) => { void navigate({ to: "/bylines", search: { locale: locale || undefined }, }); // Switching locales invalidates the previously-selected byline (it // belongs to a different list); clear selection so the editor opens // in "create" mode at the new locale. setSelectedId(null); }; const { data, isLoading, error } = useQuery({ queryKey: ["bylines", debouncedSearch, guestFilter, activeLocale ?? null], queryFn: () => fetchBylines({ search: debouncedSearch || undefined, isGuest: guestFilter === "all" ? undefined : guestFilter === "guest", locale: activeLocale, limit: 50, }), // Keep the previous results on screen while a new search/filter query // loads. Without this, changing the query key drops `data` to // `undefined`, the `isLoading && !data` gate re-engages, and the whole // page collapses into the full-page loader on every settled keystroke — // the focus-losing "reload" reported in #1220 that the debounce alone // only reduced in frequency. Matches ContentEditor's search pattern. placeholderData: keepPreviousData, }); // Reset accumulated items when filters change React.useEffect(() => { if (data) { setAllItems(data.items); setNextCursor(data.nextCursor); } }, [data]); const { data: usersData } = useQuery({ queryKey: ["users", "byline-linking"], queryFn: () => fetchUsers({ limit: 100 }), }); const users = usersData?.items ?? []; // Phase 6 of #1174: render registered custom fields as inputs in the // edit form. List is fetched once per page mount; the registry's // version counter invalidates content-side caches but the admin UI // just relies on react-query's staleTime for now — admins rarely // add/remove fields while another admin is editing a byline, and the // next page navigation refetches anyway. const { data: customFieldsList, error: customFieldsError } = useQuery({ queryKey: ["byline-fields"], queryFn: listBylineFields, staleTime: 60 * 1000, }); const customFieldDefs = customFieldsList?.items ?? []; // Snapshot filters at click-time and discard the response if the user // changed any of them while the request was in flight — otherwise stale // pages from a different filter set get appended to the visible list. const loadMoreMutation = useMutation({ mutationFn: async (snapshot: LoadMoreSnapshot) => { const result = await fetchBylines({ search: snapshot.search || undefined, isGuest: snapshot.guestFilter === "all" ? undefined : snapshot.guestFilter === "guest", locale: snapshot.locale, limit: 50, cursor: snapshot.cursor, }); return { result, snapshot }; }, onSuccess: ({ result, snapshot }) => { if ( !loadMoreSnapshotMatches(snapshot, { search: debouncedSearch, guestFilter, locale: activeLocale, }) ) { return; } setAllItems((prev) => [...prev, ...result.items]); setNextCursor(result.nextCursor); }, }); const items = allItems; // The selected row may live in `allItems` (visible at the active locale) // or be a sibling of the open byline reached via TranslationsPanel. Fetch // directly by id so the editor stays consistent when the selection // crosses locale boundaries. const { data: selectedRemote } = useQuery({ queryKey: ["byline", selectedId], queryFn: () => (selectedId ? fetchByline(selectedId) : Promise.resolve(null)), enabled: !!selectedId, }); const selected = selectedRemote ?? items.find((item) => item.id === selectedId) ?? null; const [form, setForm] = React.useState(() => toFormState(null)); React.useEffect(() => { setForm(toFormState(selected)); }, [selected]); // Translations: only fetched when a multi-locale install has a byline // open. The panel renders one row per configured locale, with Translate // or Edit buttons depending on which siblings exist. const { data: translationsData } = useQuery({ queryKey: ["byline-translations", selectedId], queryFn: () => selectedId ? fetchBylineTranslations(selectedId) : Promise.resolve({ items: [] }), enabled: !!selectedId && isMultiLocale, }); const createMutation = useMutation({ mutationFn: () => { // Mirrors updateMutation's customFields guard: omit the key // when field-defs failed to load so the new row starts blank // instead of echoing an empty hydration back. const body: Parameters[0] = { slug: form.slug, displayName: form.displayName, bio: form.bio || null, websiteUrl: form.websiteUrl || null, userId: form.userId, isGuest: form.isGuest, avatarMediaId: form.avatarMediaId, locale: activeLocale, }; if (!customFieldsError && Object.keys(form.customFields).length > 0) { body.customFields = form.customFields; } return createByline(body); }, onSuccess: (created) => { void queryClient.invalidateQueries({ queryKey: ["bylines"] }); void queryClient.invalidateQueries({ queryKey: ["byline", created.id] }); setSelectedId(created.id); }, }); const updateMutation = useMutation({ mutationFn: () => { if (!selectedId) throw new Error("No byline selected"); // Phase 6 of #1174: forward registered custom-field values // when we have field-defs to render them. If the // `byline-fields` list failed to load, the inputs aren't // rendered so the editor cannot see what they'd be saving; // omit the key entirely so the server-side repo skips the // customFields branch and preserves stored values verbatim // (`undefined` triggers the skip path in // `BylineRepository.update`). Sending `form.customFields` // would echo the hydrated values back — usually a no-op, // but in a "field deleted server-side mid-session" scenario // it would surface as a 400, surprising the editor. const body: Parameters[1] = { slug: form.slug, displayName: form.displayName, bio: form.bio || null, websiteUrl: form.websiteUrl || null, userId: form.userId, isGuest: form.isGuest, avatarMediaId: form.avatarMediaId, }; if (!customFieldsError) { body.customFields = form.customFields; } return updateByline(selectedId, body); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["bylines"] }); if (selectedId) { void queryClient.invalidateQueries({ queryKey: ["byline", selectedId] }); } }, }); const deleteMutation = useMutation({ mutationFn: () => { if (!selectedId) throw new Error("No byline selected"); return deleteByline(selectedId); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["bylines"] }); setSelectedId(null); setShowDeleteConfirm(false); }, }); // Translate-this-byline action: creates a sibling row in the target locale // joined to the same translation_group. We track `pendingTranslationLocale` // so the TranslationsPanel can disable the right button while in flight. const [pendingTranslationLocale, setPendingTranslationLocale] = React.useState( null, ); const translateMutation = useMutation({ mutationFn: (targetLocale: string) => { if (!selectedId) throw new Error("No byline selected"); setPendingTranslationLocale(targetLocale); return createBylineTranslation(selectedId, { locale: targetLocale }); }, onSettled: () => { setPendingTranslationLocale(null); }, onSuccess: (created) => { void queryClient.invalidateQueries({ queryKey: ["bylines"] }); if (selectedId) { void queryClient.invalidateQueries({ queryKey: ["byline-translations", selectedId], }); } // Switch the admin locale to the new sibling's locale and open it // in the editor — same flow as menus/taxonomies after Translate. void navigate({ to: "/bylines", search: { locale: created.locale }, }); setSelectedId(created.id); }, }); if (isLoading && !data) { return (
); } if (error) { return
{t`Failed to load bylines: ${error.message}`}
; } const isSaving = createMutation.isPending || updateMutation.isPending; const mutationError = createMutation.error || updateMutation.error || deleteMutation.error || translateMutation.error; return (

{t`Bylines`}

{canManageBylineSchema && ( } > {t`Byline schema`} )} {isMultiLocale && i18n && activeLocale && ( )}
setSearch(e.target.value)} />
setForm((prev) => ({ ...prev, displayName: e.target.value }))} /> setForm((prev) => ({ ...prev, slug: e.target.value }))} /> setForm((prev) => ({ ...prev, websiteUrl: e.target.value }))} /> setForm((prev) => ({ ...prev, bio: e.target.value }))} rows={5} /> setForm((prev) => ({ ...prev, avatarMediaId: mediaId }))} /> onChange(e.target.value === "" ? null : e.target.value)} /> ); case "text": return ( onChange(e.target.value === "" ? null : e.target.value)} rows={3} /> ); case "url": return ( onChange(e.target.value === "" ? null : e.target.value)} /> ); case "boolean": // Booleans are always definite once the field is registered — // `null` would mean "no row stored", which conceptually maps // to `false` for a yes/no toggle. The Switch sends a real // boolean and the storage path persists it verbatim. return ( onChange(checked)} /> ); case "select": { const options = field.validation?.options ?? []; // Null-prototype object so options that collide with // `Object.prototype` keys (`__proto__`, `toString`) survive. const items: Record = Object.create(null); items[""] = t`-- Select --`; for (const opt of options) items[opt] = opt; return (