/** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ /** * MediaListView — card-grid list view for the Media collection. * * Registered via `CollectionAdminConfig.listView` in MediaAdmin, replacing * the default table-based ListView. Receives the same paginated API data, so * no additional API parameters or endpoints are required. * * Controls: * - Search bar (delegates to `?query=` URL param) * - Order-by dropdown (maps to `?order=` + `?desc=` URL params) * - Top + bottom pagination (RouterPager) */ import { useRouterState } from '@tanstack/react-router' import type { ListViewComponentProps, StoredFileValue, WorkflowStatus } from '@byline/core' import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas' import { RouterPager } from '@byline/host-tanstack-start/admin-shell/chrome/router-pager' import { Link, useNavigate, } from '@byline/host-tanstack-start/admin-shell/chrome/loose-router' import { getAdminRoutePath } from '@byline/host-tanstack-start/routes/admin-path' import { Container, IconButton, LoaderRing, PlusIcon, Search, Section, Select } from '@byline/ui/react' import { LocalDateTime } from '@byline/admin/react' import styles from './media-list-view.module.css' import { FormatBadge } from './media-thumbnail' function formatNumber(number: number, decimalPlaces: number) { if (typeof number !== 'number' || Number.isNaN(number)) { throw new TypeError('Input must be a valid number') } const options = { minimumFractionDigits: decimalPlaces !== undefined ? decimalPlaces : 0, maximumFractionDigits: decimalPlaces !== undefined ? decimalPlaces : 20, } return number.toLocaleString('en-US', options) } // --------------------------------------------------------------------------- // Order-by config // --------------------------------------------------------------------------- /** * Composite order values that encode both `order` field and `desc` direction. * Format: `"_"` — split at the last underscore when applied. */ const ORDER_OPTIONS = [ { value: 'updated_at_desc', label: 'Recently Updated' }, { value: 'updated_at_asc', label: 'Oldest Updated' }, { value: 'title_asc', label: 'Title A–Z' }, { value: 'title_desc', label: 'Title Z–A' }, { value: 'created_at_desc', label: 'Newest Created' }, { value: 'created_at_asc', label: 'Oldest Created' }, ] as const type OrderValue = (typeof ORDER_OPTIONS)[number]['value'] function parseOrderValue(order?: string, desc?: boolean): OrderValue { if (!order) return 'updated_at_desc' const candidate = `${order}_${desc ? 'desc' : 'asc'}` as OrderValue return ORDER_OPTIONS.some((o) => o.value === candidate) ? candidate : 'updated_at_desc' } function splitOrderValue(value: OrderValue): { order: string; desc: boolean } { const i = value.lastIndexOf('_') return { order: value.slice(0, i), desc: value.slice(i + 1) === 'desc' } } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function Stats({ total }: { total: number }) { return {formatNumber(total, 0)} } function statusClassName(status: string): string { if (status === 'published') return styles['status-published'] if (status === 'archived') return styles['status-archived'] return styles['status-default'] } function StatusBadge({ status, workflowStatuses, }: { status: string workflowStatuses: WorkflowStatus[] }) { const label = workflowStatuses.find((s) => s.name === status)?.label ?? status return {label} } function EmptyState() { return (

No media items found.

) } // --------------------------------------------------------------------------- // MediaListView // --------------------------------------------------------------------------- export function MediaListView({ data, workflowStatuses = [], }: ListViewComponentProps) { const navigate = useNavigate() const location = useRouterState({ select: (s) => s.location }) const collectionPath = data.included.collection.path const search = location.search as Record // ---- search ---- const handleOnSearch = (query: string): void => { if (query != null && query.length > 0) { const params = structuredClone(search) delete params.page params.query = query navigate({ to: getAdminRoutePath('collections', '$collection'), params: { collection: collectionPath }, search: params, }) } } const handleOnClear = (): void => { const params = structuredClone(search) delete params.page delete params.query navigate({ to: getAdminRoutePath('collections', '$collection'), params: { collection: collectionPath }, search: params, }) } // ---- order-by ---- const currentOrder = parseOrderValue(search.order, search.desc) const handleOrderChange = (value: string | null): void => { if (value == null) return const { order, desc } = splitOrderValue(value as OrderValue) const params = structuredClone(search) delete params.page params.order = order params.desc = desc navigate({ to: getAdminRoutePath('collections', '$collection'), params: { collection: collectionPath }, search: params, }) } // ---- render ---- return (
{/* ---- Header ---- */}

{data.included.collection.labels.plural as string}

} >
{/* ---- Toolbar ---- */}
{/* Order-by */}