From 4471f1427e34f3fb01949fbc54beac5f0a4acac1 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 15 Jan 2025 10:41:35 +0100 Subject: [PATCH 01/13] Add `Shared` folder, move table code --- frontend/src/routes/manage/Series/index.tsx | 2 +- frontend/src/routes/manage/Shared/Table.tsx | 564 ++++++++++++++++++++ frontend/src/routes/manage/Video/index.tsx | 2 +- 3 files changed, 566 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/manage/Shared/Table.tsx diff --git a/frontend/src/routes/manage/Series/index.tsx b/frontend/src/routes/manage/Series/index.tsx index a98636b31..ed763bfab 100644 --- a/frontend/src/routes/manage/Series/index.tsx +++ b/frontend/src/routes/manage/Series/index.tsx @@ -16,7 +16,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..6efaf9805 --- /dev/null +++ b/frontend/src/routes/manage/Shared/Table.tsx @@ -0,0 +1,564 @@ +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 DateColumn: React.FC<{ date?: string }> = ({ date }) => { + const { t, i18n } = useTranslation(); + const isDark = useColorScheme().scheme === "dark"; + const parsedDate = date && new Date(date); + const greyColor = { color: isDark ? COLORS.neutral60 : COLORS.neutral50 }; + + return + {parsedDate + ? <> + {parsedDate.toLocaleDateString(i18n.language)} +
+ + {parsedDate.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 +) { + return (queryParams: URLSearchParams) => { + const { limit, offset, direction } = parsePaginationAndDirection(queryParams); + const sortBy = queryParams.get("sortBy"); + const column = parseColumnFn(sortBy); + + return { + order: { 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 c5650a0e3..a41551a3c 100644 --- a/frontend/src/routes/manage/Video/index.tsx +++ b/frontend/src/routes/manage/Video/index.tsx @@ -29,7 +29,7 @@ import { TableRow, thumbnailLinkStyle, titleLinkStyle, -} from "../shared"; +} from "../Shared/Table"; const PATH = "/~manage/videos" as const; From dea13398bcf12355bd95f8d6cef552f642a80432 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 15 Jan 2025 11:03:07 +0100 Subject: [PATCH 02/13] Refactor `manage` code and add series details page This generalizes the `Nav` and `Details` code that was used for videos, and repurposes it for series as well. With these changes, it should also be fairly easy to add this for playlists later on. --- frontend/src/i18n/locales/de.yaml | 13 +- frontend/src/i18n/locales/en.yaml | 16 +- frontend/src/router.tsx | 2 + frontend/src/routes/Video.tsx | 8 +- frontend/src/routes/manage/Series/Details.tsx | 30 + frontend/src/routes/manage/Series/Shared.tsx | 141 +++++ frontend/src/routes/manage/Series/index.tsx | 15 +- frontend/src/routes/manage/Shared/Details.tsx | 165 ++++++ frontend/src/routes/manage/Shared/Nav.tsx | 110 ++++ frontend/src/routes/manage/Shared/Table.tsx | 251 ++++---- frontend/src/routes/manage/Video/Details.tsx | 222 +++----- frontend/src/routes/manage/Video/Shared.tsx | 100 +--- frontend/src/routes/manage/shared.tsx | 535 ------------------ 13 files changed, 696 insertions(+), 912 deletions(-) create mode 100644 frontend/src/routes/manage/Series/Details.tsx create mode 100644 frontend/src/routes/manage/Series/Shared.tsx create mode 100644 frontend/src/routes/manage/Shared/Details.tsx create mode 100644 frontend/src/routes/manage/Shared/Nav.tsx delete mode 100644 frontend/src/routes/manage/shared.tsx diff --git a/frontend/src/i18n/locales/de.yaml b/frontend/src/i18n/locales/de.yaml index 8425c47d3..69a1483bf 100644 --- a/frontend/src/i18n/locales/de.yaml +++ b/frontend/src/i18n/locales/de.yaml @@ -126,8 +126,6 @@ login-page: video: video: Video video-page: 'Video Seite: „{{video}}“' - created: Erstellt - updated: Zuletzt verändert duration: Abspielzeit link: Zur Videoseite part-of-series: Teil der Serie @@ -442,6 +440,15 @@ manage: no-entries-found: Keine Einträge gefunden. missing-date: Unbekannt + shared: + created: Erstellt + updated: Zuletzt verändert + details: + share-direct-link: Via Direktlink teilen + copy-direct-link-to-clipboard: Videolink in Zwischenablage kopieren + acl: + title: Access policy + my-series: title: Meine Serien content: Inhalt @@ -451,9 +458,7 @@ manage: title: Meine Videos details: title: Videodetails - share-direct-link: Via Direktlink teilen set-time: 'Starten bei: ' - copy-direct-link-to-clipboard: Videolink in Zwischenablage kopieren open-in-editor: Im Videoeditor öffnen referencing-pages: Referenzierende Seiten referencing-pages-explanation: 'Dieses Video wird von den folgenden Seiten referenziert:' diff --git a/frontend/src/i18n/locales/en.yaml b/frontend/src/i18n/locales/en.yaml index 55e980649..b8c353863 100644 --- a/frontend/src/i18n/locales/en.yaml +++ b/frontend/src/i18n/locales/en.yaml @@ -124,8 +124,6 @@ login-page: video: video: Video video-page: 'Video page: “{{video}}”' - created: Created - updated: Last updated duration: Duration link: Go to video page part-of-series: Part of series @@ -198,6 +196,7 @@ playlist: series: series: Series + series-page: 'Series page: “{{series}}”' deleted: Deleted series deleted-series-block: The series referenced here was deleted. entry-of-series-thumbnail: "Thumbnail for entry of “{{series}}”" @@ -423,18 +422,27 @@ manage: no-entries-found: No entries found. missing-date: Unknown + shared: + created: Created + updated: Last updated + details: + share-direct-link: Share via direct link + copy-direct-link-to-clipboard: Copy link to clipboard + acl: + title: Access policy + my-series: title: My series content: Content no-of-videos: '{{count}} videos' + details: + title: Series details my-videos: title: My videos details: title: Video details - share-direct-link: Share via direct link set-time: 'Start at: ' - copy-direct-link-to-clipboard: Copy video link to clipboard open-in-editor: Open in video editor referencing-pages: Referencing pages referencing-pages-explanation: 'This video is referenced on the following pages:' diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index c9af6ef75..e0ff22ac5 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -30,6 +30,7 @@ import React from "react"; import { ManageVideoAccessRoute } from "./routes/manage/Video/Access"; import { DirectPlaylistOCRoute, DirectPlaylistRoute } from "./routes/Playlist"; import { ManageSeriesRoute } from "./routes/manage/Series"; +import { ManageSeriesDetailsRoute } from "./routes/manage/Series/Details"; @@ -67,6 +68,7 @@ const { ManageVideoTechnicalDetailsRoute, ManageRealmRoute, ManageSeriesRoute, + ManageSeriesDetailsRoute, UploadRoute, AddChildRoute, ManageRealmContentRoute, diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index f497c8882..cf2cfaab9 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -1103,7 +1103,7 @@ const ShareButton: React.FC<{ event: SyncedEvent }> = ({ event }) => { return <>
@@ -1285,7 +1285,7 @@ const VideoDate: React.FC = ({ event }) => { } {updatedFull && <>
- {t("video.updated")}: {updatedFull} + {t("manage.shared.updated")}: {updatedFull} } ; @@ -1307,11 +1307,11 @@ const VideoDate: React.FC = ({ event }) => { tooltip = <> {startedDate ? <>{t("video.started")}: {startFull} - : <>{t("video.created")}: {createdFull} + : <>{t("manage.shared.created")}: {createdFull} } {updatedFull && <>
- {t("video.updated")}: {updatedFull} + {t("manage.shared.updated")}: {updatedFull} } ; diff --git a/frontend/src/routes/manage/Series/Details.tsx b/frontend/src/routes/manage/Series/Details.tsx new file mode 100644 index 000000000..ec8f41b7a --- /dev/null +++ b/frontend/src/routes/manage/Series/Details.tsx @@ -0,0 +1,30 @@ +import i18n from "../../../i18n"; +import { makeManageSeriesRoute } from "./Shared"; +import { ManageSeriesRoute } from "."; +import { DirectSeriesRoute } from "../../Series"; +import { UpdatedCreatedInfo, DetailsPage, DirectLink, MetadataSection } from "../Shared/Details"; + + +export const ManageSeriesDetailsRoute = makeManageSeriesRoute( + "details", + "", + series => [ + , + , + , + ]} + />, +); diff --git a/frontend/src/routes/manage/Series/Shared.tsx b/frontend/src/routes/manage/Series/Shared.tsx new file mode 100644 index 000000000..2179e3a3b --- /dev/null +++ b/frontend/src/routes/manage/Series/Shared.tsx @@ -0,0 +1,141 @@ +import { useTranslation } from "react-i18next"; +import { LuShieldCheck, LuPenLine, LuEye } from "react-icons/lu"; +import { graphql } from "react-relay"; + +import { RootLoader } from "../../../layout/Root"; +import { makeRoute, Route } from "../../../rauta"; +import { loadQuery } from "../../../relay"; +import { NotFound } from "../../NotFound"; +import { b64regex } from "../../util"; +import { seriesId, keyOfId } from "../../../util"; +import CONFIG from "../../../config"; +import { ManageSeriesRoute, SeriesThumbnail } from "."; +import { SharedSeriesManageQuery } from "./__generated__/SharedSeriesManageQuery.graphql"; +import { DirectSeriesRoute } from "../../Series"; +import { ReturnLink, ManageNav, SharedManageNavProps } from "../Shared/Nav"; +import { COLORS } from "../../../color"; + + +export const PAGE_WIDTH = 1100; + +export type QueryResponse = SharedSeriesManageQuery["response"]; +export type Series = NonNullable; + +type ManageSeriesSubPageType = "details" | "acl"; + +/** Helper around `makeRoute` for manage single series subpages. */ +export const makeManageSeriesRoute = ( + page: ManageSeriesSubPageType, + path: string, + render: (series: Series, data: QueryResponse) => JSX.Element, +): Route & { url: (args: { seriesId: string }) => string } => ( + makeRoute({ + url: ({ seriesId }: { seriesId: string }) => `/~manage/series/${keyOfId(seriesId)}/${path}`, + match: url => { + const regex = new RegExp(`^/~manage/series/(${b64regex}+)${path}/?$`, "u"); + const params = regex.exec(url.pathname); + if (params === null) { + return null; + } + + const id = decodeURIComponent(params[1]); + const queryRef = loadQuery(query, { + id: seriesId(id), + }); + + return { + render: () => data.series ? [ + , + , + ] : []} + render={data => { + if (data.series == null) { + return ; + } + return render(data.series, data); + }} + />, + dispose: () => queryRef.dispose(), + }; + }, + }) +); + + +const query = graphql` + query SharedSeriesManageQuery($id: ID!) { + ...UserData + ...AccessKnownRolesData + series: seriesById(id: $id) { + id + title + created + updated + syncedData { description } + numVideos + thumbnailStack { thumbnails { url live audioOnly }} + } + } +`; + + +type ManageSeriesNavProps = SharedManageNavProps & { + series: Series; +}; + +const ManageSeriesNav: React.FC = ({ series, active }) => { + const { t } = useTranslation(); + + if (series == null) { + return null; + } + + const id = keyOfId(series.id); + + const navEntries = [ + { + url: `/~manage/series/${id}`, + page: "details", + body: <>{t("manage.my-series.details.title")}, + }, + ]; + + if (CONFIG.allowAclEdit) { + navEntries.splice(1, 0, { + url: `/~manage/series/${id}/access`, + page: "acl", + body: <>{t("manage.shared.acl.title")}, + }); + } + + const link = DirectSeriesRoute.url({ seriesId: id }); + const title = series.title; + const ariaLabel = t("series.series-page", { series: series.title }); + + const additionalStyles = { + padding: 8, + borderBottom: `2px solid ${COLORS.neutral05}`, + }; + + const thumbnail = <> + + + ; + + return ; +}; diff --git a/frontend/src/routes/manage/Series/index.tsx b/frontend/src/routes/manage/Series/index.tsx index ed763bfab..3ed604da5 100644 --- a/frontend/src/routes/manage/Series/index.tsx +++ b/frontend/src/routes/manage/Series/index.tsx @@ -24,8 +24,8 @@ import { } from "./__generated__/SeriesManageQuery.graphql"; import { Link } from "../../../router"; import { ThumbnailStack } from "../../Search"; -import { DirectSeriesRoute } from "../../Series"; import { SmallDescription } from "../../../ui/metadata"; +import { keyOfId } from "../../../util"; const PATH = "/~manage/series" as const; @@ -114,8 +114,7 @@ export const seriesColumns: ColumnProps[] = [ export const SeriesRow: React.FC<{ item: SingleSeries }> = ({ item }) => { - // Todo: change to "series details" route when available - const link = DirectSeriesRoute.url({ seriesId: item.id }); + const link = `${PATH}/${keyOfId(item.id)}`; return ( : "CREATED"; const queryParamsToSeriesVars = createQueryParamsParser(parseSeriesColumn); + +type SeriesThumbnailProps = { + series: SingleSeries; +} + +export const SeriesThumbnail: React.FC = ({ series }) => ( +
div": { width: "100%" } }}> + +
+); diff --git a/frontend/src/routes/manage/Shared/Details.tsx b/frontend/src/routes/manage/Shared/Details.tsx new file mode 100644 index 000000000..015e05d9b --- /dev/null +++ b/frontend/src/routes/manage/Shared/Details.tsx @@ -0,0 +1,165 @@ +import { ParseKeys } from "i18next"; +import { ReactNode, PropsWithChildren, useState, useId } from "react"; +import { useTranslation } from "react-i18next"; + +import { ManageRoute } from ".."; +import { COLORS } from "../../../color"; +import { PageTitle } from "../../../layout/header/ui"; +import { Breadcrumbs } from "../../../ui/Breadcrumbs"; +import { NotAuthorized } from "../../../ui/error"; +import { CopyableInput, InputWithCheckbox, TimeInput, Input, TextArea } from "../../../ui/Input"; +import { InputContainer, TitleLabel } from "../../../ui/metadata"; +import { useUser, isRealUser } from "../../../User"; +import { secondsToTimeString } from "../../../util"; +import { PAGE_WIDTH } from "./Nav"; +import { Form } from "../../../ui/Form"; + + +type UrlProps = { + url: URL; + withTimestamp?: boolean; +}; + +type PageProps = { + item: T; + pageTitle: ParseKeys; + breadcrumb: { + label: string; + link: string; + }; + sections: (item: T) => ReactNode[]; +}; + +export const DetailsPage = ({ + item, + pageTitle, + breadcrumb, + sections, +}: PageProps) => { + const { t } = useTranslation(); + const breadcrumbs = [ + { label: t("user.manage-content"), link: ManageRoute.url }, + breadcrumb, + ]; + + const user = useUser(); + if (!isRealUser(user)) { + return ; + } + + return <> + + + {sections(item).map((section, i) => ( + {section} + ))} + ; +}; + +const DetailsSection: React.FC = ({ children }) => ( +
+
+ {children} +
+
+); + +type UpdatedCreatedInfoProps = { + item: { + created?: string | null; + updated?: string | null; + }; +}; + +/** Shows the `created` and `updated` timestamps. */ +export const UpdatedCreatedInfo: React.FC = ({ item }) => { + const { t, i18n } = useTranslation(); + const created = item.created && new Date(item.created).toLocaleString(i18n.language); + + const updated = item.updated == null + ? null + : new Date(item.updated).toLocaleString(i18n.language); + + return ( +
+ {created && ( + + + + )} + {updated && } +
+ ); +}; + +type DateValueProps = { + label: string; + value: string; +}; + +const DateValue: React.FC = ({ label, value }) => <> + {label + ":"} + {value} +; + + +export const DirectLink: React.FC = ({ url, withTimestamp }) => { + const { t } = useTranslation(); + const [timestamp, setTimestamp] = useState(0); + const [checkboxChecked, setCheckboxChecked] = useState(false); + + const linkUrl = (withTimestamp && timestamp && checkboxChecked) + ? new URL(url + `?t=${secondsToTimeString(timestamp)}`) + : url; + + return ( +
+
+ {t("manage.shared.details.share-direct-link") + ":"} +
+ + {withTimestamp && } + />} +
+ ); +}; + +type MetadataSectionProps = { + title: string; + description?: string | null; +} + +export const MetadataSection: React.FC = ({ title, description }) => { + const { t } = useTranslation(); + const titleFieldId = useId(); + const descriptionFieldId = useId(); + + return
+ + + + + + + +