diff --git a/.changeset/content-list-byline-filter.md b/.changeset/content-list-byline-filter.md new file mode 100644 index 000000000..e9643ae6f --- /dev/null +++ b/.changeset/content-list-byline-filter.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Adds a byline filter to the admin content list. Pick one or more bylines to see entries credited to any of them, or filter to entries with no byline assigned. Bylines inferred from an entry's author are ignored unless you turn on "Include inferred bylines". diff --git a/packages/admin/src/components/BulkBylineApply.tsx b/packages/admin/src/components/BulkBylineApply.tsx new file mode 100644 index 000000000..376b83124 --- /dev/null +++ b/packages/admin/src/components/BulkBylineApply.tsx @@ -0,0 +1,124 @@ +import { Badge, Button, Checkbox, Input, Popover } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { CaretDown } from "@phosphor-icons/react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { fetchBylines } from "../lib/api"; +import { useDebouncedValue } from "../lib/hooks.js"; + +/** Matches the server's cap on credits per entry. */ +const MAX_SELECTED = 25; + +interface BulkBylineApplyProps { + /** How many entries the credits will be set on. */ + count: number; + disabled?: boolean; + /** Locale the list is showing, so the picker offers matching byline rows. */ + locale?: string; + /** Receives the chosen byline row ids (not translation groups). */ + onApply: (bylineIds: string[]) => void; +} + +/** + * Bulk byline picker for the content list's selection toolbar. The picked + * bylines become each selected entry's whole credit set — an entry's existing + * credits are replaced, not merged into. + */ +export function BulkBylineApply({ count, disabled, locale, onApply }: BulkBylineApplyProps) { + const { t } = useLingui(); + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(""); + const [selected, setSelected] = React.useState([]); + const debouncedSearch = useDebouncedValue(search, 300); + const trimmedSearch = debouncedSearch.trim(); + + const { data, isLoading } = useQuery({ + queryKey: ["bylines", "bulk-apply", locale ?? null, trimmedSearch], + queryFn: () => fetchBylines({ search: trimmedSearch || undefined, locale, limit: 20 }), + enabled: open, + placeholderData: keepPreviousData, + }); + + const options = data?.items ?? []; + const atLimit = selected.length >= MAX_SELECTED; + + const toggle = (id: string) => { + setSelected((prev) => { + if (prev.includes(id)) return prev.filter((value) => value !== id); + if (prev.length >= MAX_SELECTED) return prev; + return [...prev, id]; + }); + }; + + const apply = () => { + if (selected.length === 0) return; + onApply(selected); + setSelected([]); + setSearch(""); + setOpen(false); + }; + + return ( + + + + + + + setSearch(e.target.value)} + /> + +
+ {isLoading &&

{t`Loading…`}

} + + {!isLoading && options.length === 0 && ( +

{t`No bylines found`}

+ )} + + {options.map((byline) => { + const checked = selected.includes(byline.id); + return ( +
+ toggle(byline.id)} + label={{byline.displayName}} + /> +
+ ); + })} + + {data?.nextCursor && ( +

{t`Search to narrow the list`}

+ )} +
+ + {atLimit && ( + + {t`Up to ${MAX_SELECTED} bylines can be selected`} + + )} + +
+ + {t`Replaces the credits on ${count} entries`} + + +
+
+
+ ); +} diff --git a/packages/admin/src/components/BylineFilter.tsx b/packages/admin/src/components/BylineFilter.tsx new file mode 100644 index 000000000..d1ede2fc7 --- /dev/null +++ b/packages/admin/src/components/BylineFilter.tsx @@ -0,0 +1,184 @@ +import { Badge, Button, Checkbox, Input, Popover, Switch } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { CaretDown } from "@phosphor-icons/react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { fetchBylines, type BylineSummary } from "../lib/api"; +import { useDebouncedValue } from "../lib/hooks.js"; + +/** + * Byline filter state for the content list. + * + * `bylineIds` are translation groups, so a selection matches a byline across + * every locale it exists in. `none` is exclusive: it matches entries with no + * byline rather than a particular one. + */ +export interface BylineFilterState { + bylineIds: string[]; + none: boolean; + includeInferred: boolean; +} + +export const EMPTY_BYLINE_FILTER: BylineFilterState = { + bylineIds: [], + none: false, + includeInferred: false, +}; + +export function isBylineFilterActive(filter: BylineFilterState): boolean { + return filter.none || filter.bylineIds.length > 0; +} + +/** Server-side cap on how many bylines one filter may name. */ +const MAX_SELECTED = 25; + +/** The junction stores translation groups, so a filter matches every locale. */ +const groupOf = (byline: BylineSummary) => byline.translationGroup ?? byline.id; + +interface BylineFilterProps { + value: BylineFilterState; + onChange: (value: BylineFilterState) => void; + /** Locale the list is showing, so the picker offers matching byline rows. */ + locale?: string; +} + +/** + * Multi-select byline filter. Selecting several bylines matches entries + * credited to any of them; "No byline" matches entries with no credit at all. + * + * Bylines are searched server-side rather than listed exhaustively — the + * directory can be far longer than one page, and this is the one query in the + * feature that isn't index-served. + */ +export function BylineFilter({ value, onChange, locale }: BylineFilterProps) { + const { t } = useLingui(); + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(""); + const debouncedSearch = useDebouncedValue(search, 300); + const trimmedSearch = debouncedSearch.trim(); + + const { data, isLoading } = useQuery({ + queryKey: ["bylines", "content-filter", locale ?? null, trimmedSearch], + queryFn: () => fetchBylines({ search: trimmedSearch || undefined, locale, limit: 20 }), + enabled: open, + placeholderData: keepPreviousData, + }); + + const options = data?.items ?? []; + + // Selected bylines are remembered by group so their names keep rendering + // once the search moves on and the rows are no longer in `options`. + const [labels, setLabels] = React.useState>({}); + React.useEffect(() => { + if (options.length === 0) return; + setLabels((prev) => { + const next = { ...prev }; + for (const byline of options) next[groupOf(byline)] = byline.displayName; + return next; + }); + }, [options]); + + const toggle = (group: string) => { + const selected = value.bylineIds.includes(group); + if (!selected && value.bylineIds.length >= MAX_SELECTED) return; + onChange({ + ...value, + // Picking a byline leaves the "no byline" mode; the two are + // mutually exclusive. + none: false, + bylineIds: selected + ? value.bylineIds.filter((id) => id !== group) + : [...value.bylineIds, group], + }); + }; + + const toggleNone = () => { + const none = !value.none; + onChange({ ...value, none, bylineIds: none ? [] : value.bylineIds }); + }; + + const label = value.none + ? t`No byline` + : value.bylineIds.length === 0 + ? t`All bylines` + : value.bylineIds.length === 1 + ? (labels[value.bylineIds[0]!] ?? t`1 byline`) + : t`${value.bylineIds.length} bylines`; + + const atLimit = value.bylineIds.length >= MAX_SELECTED; + + return ( + + + + + + + setSearch(e.target.value)} + /> + +
+ +
+ +
+ {isLoading &&

{t`Loading…`}

} + + {!isLoading && options.length === 0 && ( +

{t`No bylines found`}

+ )} + + {options.map((byline) => { + const group = groupOf(byline); + const checked = value.bylineIds.includes(group); + return ( +
+ toggle(group)} + label={{byline.displayName}} + /> +
+ ); + })} + + {data?.nextCursor && ( +

{t`Search to narrow the list`}

+ )} +
+ + {atLimit && ( + + {t`Up to ${MAX_SELECTED} bylines can be selected`} + + )} + +
+ onChange({ ...value, includeInferred: checked })} + label={{t`Include inferred bylines`}} + /> +

+ {t`Also match the byline linked to an entry's author when it has none assigned.`} +

+
+
+
+ ); +} diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 8cecd6b37..d9328816d 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -32,6 +32,13 @@ import { useDebouncedValue } from "../lib/hooks.js"; import { contentUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; import { CaretNext, CaretPrev } from "./ArrowIcons.js"; +import { BulkBylineApply } from "./BulkBylineApply.js"; +import { + BylineFilter, + EMPTY_BYLINE_FILTER, + isBylineFilterActive, + type BylineFilterState, +} from "./BylineFilter.js"; import { LocaleSwitcher } from "./LocaleSwitcher"; import { RouterLinkButton } from "./RouterLinkButton.js"; @@ -121,6 +128,9 @@ export interface ContentListProps { /** Controlled date-range filter state. */ dateFilter?: ContentDateFilter; onDateFilterChange?: (filter: ContentDateFilter) => void; + /** Controlled byline filter state. */ + bylineFilter?: BylineFilterState; + onBylineFilterChange?: (filter: BylineFilterState) => void; /** * Bulk actions. Each is opt-in: the selection checkboxes only appear when at * least one bulk handler is provided, and each toolbar button renders only @@ -131,6 +141,8 @@ export interface ContentListProps { onBulkPublish?: BulkActionHandler; onBulkUnpublish?: BulkActionHandler; onBulkDelete?: BulkActionHandler; + /** Replaces every selected entry's credits with the picked bylines (row ids). */ + onBulkSetBylines?: (ids: string[], bylineIds: string[]) => Promise; } type BulkActionHandler = (ids: string[]) => Promise; @@ -184,9 +196,12 @@ export function ContentList({ onAuthorFilterChange, dateFilter = EMPTY_DATE_FILTER, onDateFilterChange, + bylineFilter = EMPTY_BYLINE_FILTER, + onBylineFilterChange, onBulkPublish, onBulkUnpublish, onBulkDelete, + onBulkSetBylines, }: ContentListProps) { const { t } = useLingui(); const [activeTab, setActiveTab] = React.useState("all"); @@ -196,7 +211,7 @@ export function ContentList({ // Bulk selection is opt-in: the checkbox column + toolbar only render when // the parent wired at least one bulk handler. - const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete); + const bulkEnabled = !!(onBulkPublish || onBulkUnpublish || onBulkDelete || onBulkSetBylines); // Server-side search mode: the caller refetches based on the (debounced) // query, so `items`/`total` already reflect the filter and we must not @@ -393,6 +408,9 @@ export function ContentList({ onAuthorFilterChange={onAuthorFilterChange} dateFilter={dateFilter} onDateFilterChange={onDateFilterChange} + bylineFilter={bylineFilter} + onBylineFilterChange={onBylineFilterChange} + locale={activeLocale ?? undefined} /> )} @@ -425,6 +443,14 @@ export function ContentList({ {t`Set to draft`} )} + {onBulkSetBylines && ( + runBulk((ids) => onBulkSetBylines(ids, bylineIds))} + /> + )} {onBulkDelete && ( void; dateFilter: ContentDateFilter; onDateFilterChange?: (filter: ContentDateFilter) => void; + bylineFilter: BylineFilterState; + onBylineFilterChange?: (filter: BylineFilterState) => void; + /** Locale the list is showing, so the byline picker offers matching rows. */ + locale?: string; } /** - * Filter controls for the content list: status, author, and a date range over - * a chosen timestamp column (#1288). All controls report changes to the - * parent, which owns the state and refetches. Filtering happens server-side, - * so it works across the whole collection rather than the loaded page. + * Filter controls for the content list: status, author, byline, and a date + * range over a chosen timestamp column (#1288). All controls report changes to + * the parent, which owns the state and refetches. Filtering happens + * server-side, so it works across the whole collection rather than the loaded + * page. */ function FilterBar({ statusFilter, @@ -718,6 +749,9 @@ function FilterBar({ onAuthorFilterChange, dateFilter, onDateFilterChange, + bylineFilter, + onBylineFilterChange, + locale, }: FilterBarProps) { const { t } = useLingui(); @@ -739,12 +773,22 @@ function FilterBar({ }; const hasActiveFilter = - statusFilter !== "all" || authorFilter !== "" || !!dateFilter.from || !!dateFilter.to; + statusFilter !== "all" || + authorFilter !== "" || + !!dateFilter.from || + !!dateFilter.to || + isBylineFilterActive(bylineFilter); const handleClear = () => { onStatusFilterChange("all"); onAuthorFilterChange?.(""); onDateFilterChange?.(EMPTY_DATE_FILTER); + // Clearing drops the selection but keeps the inferred-byline + // preference, which is a display choice rather than an active filter. + onBylineFilterChange?.({ + ...EMPTY_BYLINE_FILTER, + includeInferred: bylineFilter.includeInferred, + }); }; return ( @@ -783,6 +827,10 @@ function FilterBar({ )} + {onBylineFilterChange && ( + + )} + {showDateFilter && (