import { Badge, Button, Dialog, Input, LinkButton, Loader, Select, Tabs } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { Plus, Pencil, Trash, ArrowCounterClockwise, ArrowSquareOut, Copy, MagnifyingGlass, CaretUp, CaretDown, CaretUpDown, X, } from "@phosphor-icons/react"; import { Link } from "@tanstack/react-router"; import * as React from "react"; import type { ContentAuthor, ContentDateField, ContentItem, TrashedContentItem } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks.js"; import { contentUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; import { CaretNext, CaretPrev } from "./ArrowIcons.js"; import { LocaleSwitcher } from "./LocaleSwitcher"; import { RouterLinkButton } from "./RouterLinkButton.js"; /** Sortable content list columns. Maps to the server's order field whitelist. */ export type ContentListSortField = "title" | "status" | "locale" | "updatedAt"; export interface ContentListSort { field: ContentListSortField; direction: "asc" | "desc"; } /** Status filter values. `"all"` clears the status filter. */ export type ContentStatusFilter = "all" | "published" | "draft" | "scheduled" | "archived"; /** * Date-range filter state. `from`/`to` are raw `YYYY-MM-DD` values from the * date inputs (empty string = unset); the parent converts them to UTC day * boundaries before calling the API. */ export interface ContentDateFilter { field: ContentDateField; from: string; to: string; } /** An empty (inactive) date filter, defaulting to the created-at column. */ export const EMPTY_DATE_FILTER: ContentDateFilter = { field: "createdAt", from: "", to: "" }; export interface ContentListProps { collection: string; collectionLabel: string; items: ContentItem[]; trashedItems?: TrashedContentItem[]; isLoading?: boolean; isTrashedLoading?: boolean; onDelete?: (id: string) => void; onDuplicate?: (id: string) => void; onRestore?: (id: string) => void; onPermanentDelete?: (id: string) => void; onLoadMore?: () => void; onLoadMoreTrashed?: () => void; hasMore?: boolean; hasMoreTrashed?: boolean; trashedCount?: number; /** i18n config — present when multiple locales are configured */ i18n?: { defaultLocale: string; locales: string[] }; /** Currently active locale filter */ activeLocale?: string; /** Callback when locale filter changes */ onLocaleChange?: (locale: string) => void; /** URL pattern for published content links (e.g. `/blog/{slug}`) */ urlPattern?: string; /** * Controlled sort state. When `onSortChange` is also provided, the column * headers become sort controls that invoke it. Uncontrolled sort keeps * the backward-compatible "static headers, server-default ordering" * behavior for callers that haven't opted in yet. */ sort?: ContentListSort; onSortChange?: (sort: ContentListSort) => void; /** * Total rows matching the current filters (ignoring pagination). When * set, the pagination denominator reflects this stable count instead of * growing as more API pages are fetched. */ total?: number; /** * When provided, search is performed server-side: the (debounced) query is * reported here so the caller can refetch, and `items`/`total` are assumed * to already reflect the filter. Without it, the list falls back to * filtering the loaded page client-side (legacy behavior). */ onSearchChange?: (q: string) => void; /** * Filter controls. The whole bar is opt-in: it only renders when * `onStatusFilterChange` is provided, keeping the component * backward-compatible for callers that haven't wired filters yet. Each * control renders independently based on the presence of its callback * (and, for the author filter, a non-empty `authors` list). */ statusFilter?: ContentStatusFilter; onStatusFilterChange?: (status: ContentStatusFilter) => void; /** Authors who have content in this collection, for the author filter. */ authors?: ContentAuthor[]; /** Selected author id; empty string means "all authors". */ authorFilter?: string; onAuthorFilterChange?: (authorId: string) => void; /** Controlled date-range filter state. */ dateFilter?: ContentDateFilter; onDateFilterChange?: (filter: ContentDateFilter) => void; } type ViewTab = "all" | "trash"; const PAGE_SIZE = 20; function getItemTitle(item: { data: Record; slug: string | null; id: string }) { const rawTitle = item.data.title; const rawName = item.data.name; return ( (typeof rawTitle === "string" ? rawTitle : "") || (typeof rawName === "string" ? rawName : "") || item.slug || item.id ); } /** * Content list view with table display and trash tab */ export function ContentList({ collection, collectionLabel, items, trashedItems = [], isLoading, isTrashedLoading, onDelete, onDuplicate, onRestore, onPermanentDelete, onLoadMore, onLoadMoreTrashed, hasMore, hasMoreTrashed, trashedCount = 0, i18n, activeLocale, onLocaleChange, urlPattern, sort, onSortChange, total, onSearchChange, statusFilter = "all", onStatusFilterChange, authors, authorFilter = "", onAuthorFilterChange, dateFilter = EMPTY_DATE_FILTER, onDateFilterChange, }: ContentListProps) { const { t } = useLingui(); const [activeTab, setActiveTab] = React.useState("all"); const [searchQuery, setSearchQuery] = React.useState(""); const [page, setPage] = React.useState(0); // Server-side search mode: the caller refetches based on the (debounced) // query, so `items`/`total` already reflect the filter and we must not // re-filter client-side (that would re-introduce the "only matches the // loaded page" bug for non-title columns). const serverSearch = !!onSearchChange; const debouncedSearch = useDebouncedValue(searchQuery, 300); React.useEffect(() => { if (onSearchChange) onSearchChange(debouncedSearch.trim()); }, [debouncedSearch, onSearchChange]); // Reset page when search changes const handleSearchChange = (e: React.ChangeEvent) => { setSearchQuery(e.target.value); setPage(0); }; const filteredItems = React.useMemo(() => { if (serverSearch || !searchQuery) return items; const query = searchQuery.toLowerCase(); return items.filter((item) => getItemTitle(item).toLowerCase().includes(query)); }, [items, searchQuery, serverSearch]); // The query the current `items` reflect: server-side filtering lags behind // typing by the debounce, so the empty-state message must use the debounced // term; client-side filtering is immediate, so it uses the live query. const activeSearch = serverSearch ? debouncedSearch.trim() : searchQuery; // When the server reports a total, it's the source of truth for the // denominator. In server-search mode that total already reflects the query, // so we use it even while searching; in client mode an active query falls // back to the filtered client count. const effectiveTotal = typeof total === "number" && (serverSearch || !searchQuery) ? total : filteredItems.length; const totalPages = Math.max(1, Math.ceil(effectiveTotal / PAGE_SIZE)); // Clamp the current page in case filters collapse the count (user was on // page 5 of 10, then typed a query narrowing to 1 page). Without clamping // we'd render an empty table until the next refetch. const clampedPage = Math.min(page, totalPages - 1); const paginatedItems = filteredItems.slice( clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE, ); // Auto-fetch the next API page when the user is on a client page whose // items haven't been loaded yet. Skip during client-side search because // filtering can collapse `filteredItems` below the loaded count and // trigger a spurious fetch. // // Safety: relies on `onLoadMore` being deduped against concurrent calls. // The router wires this to TanStack Query's `fetchNextPage`, which is // idempotent while a fetch is in flight. React.useEffect(() => { // In client-search mode we skip auto-fetch while a query is active // (filtering can collapse the list). In server-search mode the loaded // items already are the matches, so paging forward should keep fetching. if (!hasMore || !onLoadMore || (!serverSearch && searchQuery)) return; const loadedPages = Math.ceil(filteredItems.length / PAGE_SIZE); if (clampedPage >= loadedPages - 1) { onLoadMore(); } }, [clampedPage, filteredItems.length, hasMore, onLoadMore, searchQuery, serverSearch]); return (
{/* Header */}

{collectionLabel}

{i18n && activeLocale && onLocaleChange && ( )}
} > {t`Add New`}
{/* Search */} {(serverSearch || items.length > 0) && (
)} {/* Tabs */} { if (v === "all" || v === "trash") setActiveTab(v); }} tabs={[ { value: "all", label: t`All` }, { value: "trash", label: ( ), }, ]} /> {/* Content based on active tab */} {activeTab === "all" ? ( <> {/* Filters */} {onStatusFilterChange && ( )} {/* Table */}
{i18n && ( )} {isLoading && items.length === 0 ? ( ) : items.length === 0 ? ( ) : paginatedItems.length === 0 ? ( ) : ( paginatedItems.map((item) => ( )) )}
{t`Actions`}
{t`Loading...`}
{activeSearch ? ( t`No results for "${activeSearch}"` ) : ( <> {t`No ${collectionLabel.toLowerCase()} yet.`}{" "} {t`Create your first one`} )}
{t`No results for "${activeSearch}"`}
{/* Pagination */} {totalPages > 1 && (
{renderItemCount({ searchQuery: activeSearch, filteredCount: filteredItems.length, total, hasMore, serverSearch, })}
{clampedPage + 1} / {totalPages}
)} {/* Load more */} {hasMore && (
)} ) : ( <> {/* Trash Table */}
{isTrashedLoading && trashedItems.length === 0 ? ( ) : trashedItems.length === 0 ? ( ) : ( trashedItems.map((item) => ( )) )}
{t`Title`} {t`Deleted`} {t`Actions`}
{t`Loading...`}
{t`Trash is empty`}
{/* Load more trashed */} {hasMoreTrashed && (
)} )}
); } interface FilterBarProps { statusFilter: ContentStatusFilter; onStatusFilterChange: (status: ContentStatusFilter) => void; authors?: ContentAuthor[]; authorFilter: string; onAuthorFilterChange?: (authorId: string) => void; dateFilter: ContentDateFilter; onDateFilterChange?: (filter: ContentDateFilter) => void; } /** * 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. */ function FilterBar({ statusFilter, onStatusFilterChange, authors, authorFilter, onAuthorFilterChange, dateFilter, onDateFilterChange, }: FilterBarProps) { const { t } = useLingui(); const showAuthorFilter = !!onAuthorFilterChange && !!authors && authors.length > 0; const showDateFilter = !!onDateFilterChange; const statusItems: Record = { all: t`All statuses`, published: t`Published`, draft: t`Draft`, scheduled: t`Scheduled`, archived: t`Archived`, }; const dateFieldItems: Record = { createdAt: t`Created`, updatedAt: t`Updated`, publishedAt: t`Published`, }; const hasActiveFilter = statusFilter !== "all" || authorFilter !== "" || !!dateFilter.from || !!dateFilter.to; const handleClear = () => { onStatusFilterChange("all"); onAuthorFilterChange?.(""); onDateFilterChange?.(EMPTY_DATE_FILTER); }; return (
{showAuthorFilter && ( )} {showDateFilter && (
onDateFilterChange?.({ ...dateFilter, from: e.target.value })} /> {t`to`} onDateFilterChange?.({ ...dateFilter, to: e.target.value })} />
)} {hasActiveFilter && ( )}
); } interface SortableThProps { field: ContentListSortField; sort: ContentListSort | undefined; onSortChange: ((sort: ContentListSort) => void) | undefined; label: string; } /** * Table header that doubles as a sort control when the parent opted in by * passing `onSortChange`. When no callback is provided we fall back to a * plain `` so legacy callers (and screen readers) see exactly the same * markup as before this change. * * The button's accessible name is just the column label — the sort state * is conveyed via `aria-sort` on the , which screen readers announce * automatically. Adding a verbose aria-label would make each header re-read * the sort instruction on every focus, which is noisy. */ function SortableTh({ field, sort, onSortChange, label }: SortableThProps) { const isActive = sort?.field === field; const direction = isActive ? sort?.direction : undefined; if (!onSortChange) { return ( {label} ); } const ariaSort: "ascending" | "descending" | "none" = isActive ? direction === "asc" ? "ascending" : "descending" : "none"; const handleClick = () => { // Default to descending for a new column; toggle direction when // clicking the already-active one. if (isActive) { onSortChange({ field, direction: direction === "asc" ? "desc" : "asc" }); } else { onSortChange({ field, direction: "desc" }); } }; const Icon = isActive ? (direction === "asc" ? CaretUp : CaretDown) : CaretUpDown; return ( ); } /** * Render the row-count line above pagination. The rules are: * - A search query always wins — say how many matches there are. In * server-search mode the server reports the full match count via `total`; * `filteredCount` is only the loaded page, so it would undercount. * - When the server reported a total, use it (no `+` suffix needed — * we know the count). * - Otherwise fall back to the pre-refactor behavior: loaded count, * with `+` when there are more pages the user hasn't fetched yet. */ function renderItemCount({ searchQuery, filteredCount, total, hasMore, serverSearch, }: { searchQuery: string; filteredCount: number; total: number | undefined; hasMore: boolean | undefined; serverSearch: boolean; }): string { if (searchQuery) { const matchCount = serverSearch && typeof total === "number" ? total : filteredCount; return plural(matchCount, { one: `# item matching "${searchQuery}"`, other: `# items matching "${searchQuery}"`, }); } if (typeof total === "number") { return plural(total, { one: `# item`, other: `# items`, }); } return plural(filteredCount, { one: `#${hasMore ? "+" : ""} item`, other: `#${hasMore ? "+" : ""} items`, }); } interface ContentListItemProps { item: ContentItem; collection: string; onDelete?: (id: string) => void; onDuplicate?: (id: string) => void; showLocale?: boolean; urlPattern?: string; } function ContentListItem({ item, collection, onDelete, onDuplicate, showLocale, urlPattern, }: ContentListItemProps) { const { t } = useLingui(); const title = getItemTitle(item); const date = new Date(item.updatedAt || item.createdAt); return ( {title} {showLocale && ( {item.locale} )} {date.toLocaleDateString()}
{item.status === "published" && item.slug && ( } /> )} } /> ( )} /> {t`Move to Trash?`} {t`Move "${title}" to trash? You can restore it later.`}
( )} /> ( )} />
); } interface TrashedListItemProps { item: TrashedContentItem; onRestore?: (id: string) => void; onPermanentDelete?: (id: string) => void; } function TrashedListItem({ item, onRestore, onPermanentDelete }: TrashedListItemProps) { const { t } = useLingui(); const title = getItemTitle(item); const deletedDate = new Date(item.deletedAt); return ( {title} {deletedDate.toLocaleDateString()}
( )} /> {t`Delete Permanently?`} {t`Permanently delete "${title}"? This cannot be undone.`}
( )} /> ( )} />
); } function StatusBadge({ status, hasPendingChanges, }: { status: string; hasPendingChanges?: boolean; }) { const { t } = useLingui(); const statusLabel = status === "published" ? t`published` : status === "draft" ? t`draft` : status === "scheduled" ? t`scheduled` : status === "archived" ? t`archived` : status; return ( {statusLabel} {hasPendingChanges && {t`pending`}} ); }