diff --git a/frontend/src/routes/manage/Series/index.tsx b/frontend/src/routes/manage/Series/index.tsx index 04c16a58b..22755d061 100644 --- a/frontend/src/routes/manage/Series/index.tsx +++ b/frontend/src/routes/manage/Series/index.tsx @@ -17,7 +17,7 @@ import { TableRow, thumbnailLinkStyle, titleLinkStyle, -} from "../shared"; +} from "../Shared/Table"; import { SeriesManageQuery, SeriesManageQuery$data, diff --git a/frontend/src/routes/manage/Shared/Table.tsx b/frontend/src/routes/manage/Shared/Table.tsx new file mode 100644 index 000000000..85f5bc1d8 --- /dev/null +++ b/frontend/src/routes/manage/Shared/Table.tsx @@ -0,0 +1,560 @@ +import { ReactNode, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + LuArrowDownNarrowWide, + LuArrowUpWideNarrow, + LuChevronLeft, + LuChevronRight, +} from "react-icons/lu"; +import { ParseKeys } from "i18next"; +import { Card, match, useColorScheme } from "@opencast/appkit"; + +import { seriesColumns, SeriesConnection, SeriesRow, SingleSeries } from "../Series"; +import { EventConnection, EventRow, videoColumns, Event } from "../Video"; +import { SortDirection, VideosSortColumn } from "../Video/__generated__/VideoManageQuery.graphql"; +import { Breadcrumbs } from "../../../ui/Breadcrumbs"; +import { PageTitle } from "../../../layout/header/ui"; +import { SeriesSortColumn } from "../Series/__generated__/SeriesManageQuery.graphql"; +import { COLORS } from "../../../color"; +import { ManageRoute } from ".."; +import { Link } from "../../../router"; +import FirstPage from "../../../icons/first-page.svg"; +import LastPage from "../../../icons/last-page.svg"; + +type Connection = EventConnection | SeriesConnection; +type AssetVars = { + order: { + column: SortColumn; + direction: SortDirection; + }; + limit: number; + offset: number; +}; + +type SharedProps = { + connection: Connection; + vars: AssetVars; +}; + +type ManageAssetsProps = SharedProps & { + titleKey: ParseKeys; +} + +const LIMIT = 15; + +export const ManageAssets: React.FC = ({ connection, vars, titleKey }) => { + const { t } = useTranslation(); + + const totalCount = connection.totalCount; + const limit = vars.limit ?? 15; + const pageParam = new URLSearchParams(document.location.search).get("page"); + const page = pageParam ? parseInt(pageParam, 10) : 1; + + useEffect(() => { + const maxPage = Math.max(Math.ceil(totalCount / limit), 1); + + if (page > maxPage) { + window.location.href = `?page=${maxPage}`; + } else if (page < 1) { + window.location.href = "?page=1"; + } + }, [page, totalCount, limit]); + + let inner; + if (connection.items.length === 0 && connection.totalCount === 0) { + inner = {t("manage.asset-table.no-entries-found")}; + } else { + inner = <> + +
+ +
+ + ; + } + + const title = t(titleKey); + + return ( +
+ + + {inner} +
+ ); +}; + +const THUMBNAIL_WIDTH = 16 * 8; + +type Asset = Event | SingleSeries; +type SortColumn = VideosSortColumn | SeriesSortColumn; + +export type ColumnProps = { + key: SortColumn; + label: ParseKeys; + headerWidth?: number; + column: (item: Asset) => ReactNode; +}; + +type GenericTableProps = SharedProps & { + thumbnailWidth?: number; +} + +const AssetTable: React.FC = ({ + connection, + vars, + thumbnailWidth, +}) => { + const { t } = useTranslation(); + + // We need to know whether the table header is in its "sticky" position to apply a box + // shadow to indicate that the user can still scroll up. This solution uses intersection + // observer. Compare: https://stackoverflow.com/a/57991537/2408867 + const [headerSticks, setHeaderSticks] = useState(false); + const tableHeaderRef = useRef(null); + useEffect(() => { + const tableHeader = tableHeaderRef.current; + if (tableHeader) { + const observer = new IntersectionObserver( + ([e]) => setHeaderSticks(!e.isIntersecting), + { threshold: [1], rootMargin: "-1px 0px 0px 0px" }, + ); + + observer.observe(tableHeader); + return () => observer.unobserve(tableHeader); + } + return () => {}; + }); + + const additionalColumns = match(connection.__typename, { + "EventConnection": () => videoColumns, + "SeriesConnection": () => seriesColumns, + }); + + return
+ thead": { + position: "sticky", + top: 0, + zIndex: 10, + backgroundColor: COLORS.neutral05, + "& > tr > th": { + borderBottom: `1px solid ${COLORS.neutral25}`, + textAlign: "left", + padding: "8px 12px", + }, + ...headerSticks && { + boxShadow: "0 0 20px rgba(0, 0, 0, 0.3)", + clipPath: "inset(0px 0px -20px 0px)", + }, + }, + "& > tbody": { + "& > tr:hover, tr:focus-within": { + backgroundColor: COLORS.neutral15, + }, + "& > tr:not(:first-child) > td": { + borderTop: `1px solid ${COLORS.neutral25}`, + }, + "& td": { + padding: 6, + verticalAlign: "top", + "&:not(:first-child)": { + padding: "8px 12px 8px 8px", + }, + }, + }, + }}> + + {/* Each table has thumbnails, but their width might vary */} + + {/* Each table has a title and description */} + + {/* + Additional columns can be declared in the specific column array. + */} + {additionalColumns?.map(col => + ) + } + + + + + {/* Thumbnail */} + + {/* Title */} + + {/* Sort columns */} + {additionalColumns?.map(col => ( + + ))} + + + + {connection.__typename === "EventConnection" && connection.items.map(event => + ) + } + {connection.__typename === "SeriesConnection" && connection.items.map(series => + ) + } + +
+
; +}; + +// Some styles are used by more than one row component. +// Declaring these here helps with keeping them in sync. +export const thumbnailLinkStyle = { + ":focus-visible": { outline: "none" }, + ":focus-within div:first-child": { + outline: `2.5px solid ${COLORS.focus}`, + outlineOffset: 1, + }, +} as const; + +export const titleLinkStyle = { + ":focus, :focus-visible": { + outline: "none", + }, + textDecoration: "none", +} as const; + +export const descriptionStyle = { + padding: "0 4px", +} as const; + +// Used for both `EventRow` and `SeriesRow`. +export const CreatedColumn: React.FC<{ created?: string }> = ({ created }) => { + const { t, i18n } = useTranslation(); + const isDark = useColorScheme().scheme === "dark"; + const createdDate = created && new Date(created); + const greyColor = { color: isDark ? COLORS.neutral60 : COLORS.neutral50 }; + + return + {createdDate + ? <> + {createdDate.toLocaleDateString(i18n.language)} +
+ + {createdDate.toLocaleTimeString(i18n.language)} + + + : + {t("manage.asset-table.missing-date")} + + } + ; +}; + +type TableRowProps = { + thumbnail: ReactNode; + title: ReactNode; + description: ReactNode; + customColumns?: ReactNode[]; + syncInfo?: { + isSynced: boolean; + notReadyLabel: ParseKeys; + }; +}; + +/** + * A row in the asset table + * This is assuming that each asset (video, series, playlist) has a thumbnail, title, + * and description. These can still be somewhat customized. + * Additional columns can be declared in the respective asset column arrays. + */ +export const TableRow: React.FC = ({ + thumbnail, + title, + description, + customColumns, + syncInfo, +}) => { + const { t } = useTranslation(); + + return + {/* Thumbnail */} + {thumbnail} + {/* Title & description */} + +
+
{title}
+ {syncInfo && !syncInfo.isSynced && ( + + {t(syncInfo.notReadyLabel)} + + )} +
+ {description} + + {customColumns} + ; +}; + +type ColumnHeaderProps = { + label: string; + sortKey: SortColumn; + vars: AssetVars; +}; + +const ColumnHeader: React.FC = ({ label, sortKey, vars }) => { + const { t } = useTranslation(); + const direction = vars.order.column === sortKey && vars.order.direction === "ASCENDING" + ? "DESCENDING" + : "ASCENDING"; + const directionTransKey = direction.toLowerCase() as Lowercase; + + return + svg": { + marginLeft: 6, + fontSize: 22, + }, + }} + > + {label} + {vars.order.column === sortKey && match(vars.order.direction, { + // Seems like this is flipped right? But no, a short internal + // poll showed that this matches the intuition of almost everyone. + "ASCENDING": () => , + "DESCENDING": () => , + }, () => null)} + + ; +}; + +const PageNavigation: React.FC = ({ connection, vars }) => { + const { t } = useTranslation(); + const pageInfo = connection.pageInfo; + const total = connection.totalCount; + + const limit = vars.limit ?? LIMIT; + const offset = vars.offset ?? 0; + + const prevOffset = Math.max(0, offset - limit); + const nextOffset = offset + limit; + const lastOffset = total > 0 + ? Math.floor((total - 1) / limit) * limit + : 0; + + return ( +
+
+ {t("manage.asset-table.page-showing-ids", { + start: connection.pageInfo.startIndex ?? "?", + end: connection.pageInfo.endIndex ?? "?", + total, + })} +
+
+ {/* First page */} + + {/* Previous page */} + + {/* Next page */} + + {/* Last page */} + +
+
+ ); +}; + +type PageLinkProps = { + vars: AssetVars; + disabled: boolean; + children: ReactNode; + label: string; +}; + +const PageLink: React.FC = ({ children, vars, disabled, label }) => ( + {children} +); + +// TODO: add default sort column of playlists +const DEFAULT_SORT_COLUMN = "CREATED"; +const DEFAULT_SORT_DIRECTION = "DESCENDING"; + +/** Helper functions to read URL query parameters and convert them into query variables */ +type QueryVars = { + limit: number; + offset: number; + direction: SortDirection; +} +export const parsePaginationAndDirection = ( + queryParams: URLSearchParams, + defaultDirection: SortDirection = "DESCENDING", +): QueryVars => { + const limitParam = queryParams.get("limit"); + const limit = limitParam ? parseInt(limitParam, 10) : LIMIT; + + const pageParam = queryParams.get("page"); + const page = pageParam ? parseInt(pageParam, 10) : 1; + const offset = Math.max(0, (page - 1) * limit); + + const sortOrder = queryParams.get("sortOrder"); + const direction = sortOrder !== null + ? match(sortOrder, { + desc: () => "DESCENDING", + asc: () => "ASCENDING", + }) + : defaultDirection; + + return { limit, offset, direction }; +}; + +/** + * Creates a parser function that extracts query variables for a specific resource + * (i.e. series, videos or playlists) from URL query parameters. + * This abstracts the shared logic for parsing pagination and sort direction + * but still allows specific handling of sort columns. + */ +export function createQueryParamsParser( + parseColumnFn: (sortBy: string | null) => ColumnType, + buildVars: (column: ColumnType, { limit, offset, direction }: QueryVars) => T, +) { + return (queryParams: URLSearchParams): T => { + const { limit, offset, direction } = parsePaginationAndDirection(queryParams); + const sortBy = queryParams.get("sortBy"); + const column = parseColumnFn(sortBy); + return buildVars(column, { direction, limit, offset }); + }; +} + +/** Converts query variables to URL query parameters */ +const varsToQueryParams = (vars: AssetVars): URLSearchParams => { + const searchParams = new URLSearchParams(); + + // Sort order + const isDefaultOrder = vars.order.column === DEFAULT_SORT_COLUMN + && vars.order.direction === DEFAULT_SORT_DIRECTION; + if (!isDefaultOrder) { + searchParams.set("sortBy", vars.order.column.toLowerCase()); + searchParams.set("sortOrder", match(vars.order.direction, { + "ASCENDING": () => "asc", + "DESCENDING": () => "desc", + }, () => "")); + } + + // Pagination + const limit = vars.limit ?? LIMIT; + const offset = vars.offset ?? 0; + const page = Math.floor(offset / limit) + 1; + + if (page !== 1) { + searchParams.set("page", String(page)); + } + if (limit !== LIMIT) { + searchParams.set("limit", String(limit)); + } + + return searchParams; +}; + +const varsToLink = (vars: AssetVars): string => { + const url = new URL(document.location.href); + url.search = varsToQueryParams(vars).toString(); + return url.href; +}; diff --git a/frontend/src/routes/manage/Video/index.tsx b/frontend/src/routes/manage/Video/index.tsx index e44d435b2..36d0fbe41 100644 --- a/frontend/src/routes/manage/Video/index.tsx +++ b/frontend/src/routes/manage/Video/index.tsx @@ -30,7 +30,7 @@ import { TableRow, thumbnailLinkStyle, titleLinkStyle, -} from "../shared"; +} from "../Shared/Table"; const PATH = "/~manage/videos" as const;