Skip to content
6 changes: 6 additions & 0 deletions .changeset/content-list-byline-filter.md
Original file line number Diff line number Diff line change
@@ -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".
181 changes: 181 additions & 0 deletions packages/admin/src/components/BylineFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { Badge, Button, Checkbox, Input, Popover, Switch } from "@cloudflare/kumo";
import { plural } from "@lingui/core/macro";
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.
*/
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<Record<string, string>>({});
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]!] ?? plural(1, { one: "# byline", other: "# bylines" }))
: plural(value.bylineIds.length, { one: "# byline", other: "# bylines" });

const atLimit = value.bylineIds.length >= MAX_SELECTED;

return (
<Popover open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<Button variant="secondary" size="sm" aria-label={t`Filter by byline`} className="gap-2">
<span className="max-w-[140px] truncate">{label}</span>
<CaretDown className="h-4 w-4 shrink-0" aria-hidden="true" />
</Button>
</Popover.Trigger>

<Popover.Content className="w-72 p-2" align="start">
<Input
size="sm"
type="search"
aria-label={t`Search bylines`}
placeholder={t`Search bylines…`}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>

<div className="mt-2 border-b pb-2">
<Checkbox
checked={value.none}
onCheckedChange={toggleNone}
label={t`No byline assigned`}
/>
</div>

<div className="mt-2 max-h-64 overflow-y-auto" role="group" aria-label={t`Bylines`}>
{isLoading && <p className="p-2 text-sm text-kumo-subtle">{t`Loading…`}</p>}

{!isLoading && options.length === 0 && (
<p className="p-2 text-sm text-kumo-subtle">{t`No bylines found`}</p>
)}

{options.map((byline) => {
const group = groupOf(byline);
const checked = value.bylineIds.includes(group);
return (
<div key={byline.id} className="rounded px-2 py-1 hover:bg-kumo-tint/50">
<Checkbox
checked={checked}
disabled={!checked && (atLimit || value.none)}
onCheckedChange={() => toggle(group)}
label={<span className="text-sm">{byline.displayName}</span>}
/>
</div>
);
})}

{data?.nextCursor && (
<p className="p-2 text-sm text-kumo-subtle">{t`Search to narrow the list`}</p>
)}
</div>

{atLimit && (
<Badge className="mt-2" variant="warning">
{t`Up to ${MAX_SELECTED} bylines can be selected`}
</Badge>
)}

<div className="mt-2 border-t pt-2">
<Switch
checked={value.includeInferred}
onCheckedChange={(checked) => onChange({ ...value, includeInferred: checked })}
label={<span className="text-sm">{t`Include inferred bylines`}</span>}
/>
<p className="mt-1 text-xs text-kumo-subtle">
{t`Also match the byline linked to an entry's author when it has none assigned.`}
</p>
</div>
</Popover.Content>
</Popover>
);
}
46 changes: 41 additions & 5 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ import { useDebouncedValue } from "../lib/hooks.js";
import { contentUrl } from "../lib/url.js";
import { cn } from "../lib/utils";
import { CaretNext, CaretPrev } from "./ArrowIcons.js";
import {
BylineFilter,
EMPTY_BYLINE_FILTER,
isBylineFilterActive,
type BylineFilterState,
} from "./BylineFilter.js";
import {
ContentStatusBadge,
ContentStatusLabel,
Expand Down Expand Up @@ -127,6 +133,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
Expand Down Expand Up @@ -190,6 +199,8 @@ export function ContentList({
onAuthorFilterChange,
dateFilter = EMPTY_DATE_FILTER,
onDateFilterChange,
bylineFilter = EMPTY_BYLINE_FILTER,
onBylineFilterChange,
onBulkPublish,
onBulkUnpublish,
onBulkDelete,
Expand Down Expand Up @@ -399,6 +410,9 @@ export function ContentList({
onAuthorFilterChange={onAuthorFilterChange}
dateFilter={dateFilter}
onDateFilterChange={onDateFilterChange}
bylineFilter={bylineFilter}
onBylineFilterChange={onBylineFilterChange}
locale={activeLocale ?? undefined}
/>
)}

Expand Down Expand Up @@ -709,13 +723,18 @@ interface FilterBarProps {
onAuthorFilterChange?: (authorId: string) => 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. 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,
Expand All @@ -725,6 +744,9 @@ function FilterBar({
onAuthorFilterChange,
dateFilter,
onDateFilterChange,
bylineFilter,
onBylineFilterChange,
locale,
}: FilterBarProps) {
const { t } = useLingui();

Expand All @@ -748,12 +770,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 (
Expand Down Expand Up @@ -795,6 +827,10 @@ function FilterBar({
</Select>
)}

{onBylineFilterChange && (
<BylineFilter value={bylineFilter} onChange={onBylineFilterChange} locale={locale} />
)}

{showDateFilter && (
<div className="flex flex-wrap items-end gap-2">
<Select
Expand Down
22 changes: 22 additions & 0 deletions packages/admin/src/lib/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ export async function fetchContentList(
dateFrom?: string;
/** Inclusive upper bound (ISO date or datetime). Requires `dateField`. */
dateTo?: string;
/**
* Byline ids (translation groups) to match; an entry matches if it is
* credited to any of them. Ignored when `bylinesNone` is set.
*/
bylines?: string[];
/** Match entries with no byline instead of a specific one. */
bylinesNone?: boolean;
/**
* Count the byline inferred from an entry's author when it has no
* explicit credit. Off by default: the filter matches real credits.
*/
includeInferredBylines?: boolean;
},
): Promise<FindManyResult<ContentItem>> {
const params = new URLSearchParams();
Expand All @@ -175,6 +187,16 @@ export async function fetchContentList(
if (options.dateFrom) params.set("dateFrom", options.dateFrom);
if (options.dateTo) params.set("dateTo", options.dateTo);
}
// `none` is the server's sentinel for "no byline assigned"; it takes
// precedence over a stale selection so the two can't be sent together.
if (options?.bylinesNone) {
params.set("bylines", "none");
} else if (options?.bylines && options.bylines.length > 0) {
params.set("bylines", options.bylines.join(","));
}
if (options?.includeInferredBylines && (options.bylinesNone || options.bylines?.length)) {
params.set("includeInferredBylines", "1");
}

const url = `${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
Expand Down
17 changes: 17 additions & 0 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "@tanstack/react-router";
import * as React from "react";

import { EMPTY_BYLINE_FILTER, type BylineFilterState } from "./components/BylineFilter";
import { CommentInbox } from "./components/comments/CommentInbox";
import { ContentEditor } from "./components/ContentEditor";
import {
Expand Down Expand Up @@ -353,6 +354,18 @@ function ContentListPage() {
const [statusFilter, setStatusFilter] = React.useState<ContentStatusFilter>("all");
const [authorFilter, setAuthorFilter] = React.useState("");
const [dateFilter, setDateFilter] = React.useState<ContentDateFilter>(EMPTY_DATE_FILTER);
const [bylineFilter, setBylineFilter] = React.useState<BylineFilterState>(EMPTY_BYLINE_FILTER);

// Only the parts that change the result set belong in the query key —
// `includeInferred` alone, with nothing selected, filters nothing.
const bylineApiParams = React.useMemo(() => {
if (!bylineFilter.none && bylineFilter.bylineIds.length === 0) return undefined;
return {
bylines: bylineFilter.none ? undefined : bylineFilter.bylineIds,
bylinesNone: bylineFilter.none,
includeInferredBylines: bylineFilter.includeInferred,
};
}, [bylineFilter]);

// The date inputs yield calendar dates; widen them to UTC day boundaries so
// the inclusive `dateTo` covers the whole day (timestamps are stored in UTC).
Expand Down Expand Up @@ -387,6 +400,7 @@ function ContentListPage() {
status: statusFilter,
author: authorFilter,
date: dateApiParams,
byline: bylineApiParams,
},
],
queryFn: ({ pageParam }) =>
Expand All @@ -400,6 +414,7 @@ function ContentListPage() {
status: statusFilter === "all" ? undefined : statusFilter,
authorId: authorFilter || undefined,
...dateApiParams,
...bylineApiParams,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
Expand Down Expand Up @@ -611,6 +626,8 @@ function ContentListPage() {
onAuthorFilterChange={setAuthorFilter}
dateFilter={dateFilter}
onDateFilterChange={setDateFilter}
bylineFilter={bylineFilter}
onBylineFilterChange={setBylineFilter}
onBulkPublish={(ids) => bulkPublishMutation.mutateAsync(ids).then((r) => r.failedIds)}
onBulkUnpublish={(ids) => bulkUnpublishMutation.mutateAsync(ids).then((r) => r.failedIds)}
onBulkDelete={(ids) => bulkDeleteMutation.mutateAsync(ids).then((r) => r.failedIds)}
Expand Down
Loading
Loading