From 8c2b5a19d1d0bb129bd1a11cf6d6e806287a4f70 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 15 Jan 2025 11:03:07 +0100 Subject: [PATCH] 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 | 32 + frontend/src/routes/manage/Series/Shared.tsx | 146 +++++ frontend/src/routes/manage/Series/index.tsx | 48 +- frontend/src/routes/manage/Shared/Details.tsx | 154 +++++ frontend/src/routes/manage/Shared/Nav.tsx | 107 ++++ frontend/src/routes/manage/Shared/Table.tsx | 10 +- frontend/src/routes/manage/Video/Details.tsx | 221 +++---- frontend/src/routes/manage/Video/Shared.tsx | 100 +--- frontend/src/routes/manage/shared.tsx | 564 ------------------ 13 files changed, 598 insertions(+), 823 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 1b8523820..6c6bae842 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 77daee062..88b6d45bc 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..a59ef48b8 --- /dev/null +++ b/frontend/src/routes/manage/Series/Details.tsx @@ -0,0 +1,32 @@ +import i18n from "../../../i18n"; +import { makeManageSeriesRoute } from "./Shared"; +import { ManageSeriesRoute } from "."; +import { DirectSeriesRoute } from "../../Series"; +import { DetailsPage, UpdatedCreatedInfo, 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..ddb1ba44d --- /dev/null +++ b/frontend/src/routes/manage/Series/Shared.tsx @@ -0,0 +1,146 @@ +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 { BackLink, ManageAssetNav, SharedManageAssetNavProps } 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 } + entries { + __typename + ...on AuthorizedEvent { + isLive + syncedData { thumbnail audioOnly } + } + } + } + } +`; + + +type ManageSeriesNavProps = SharedManageAssetNavProps & { + 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 22755d061..d1143248b 100644 --- a/frontend/src/routes/manage/Series/index.tsx +++ b/frontend/src/routes/manage/Series/index.tsx @@ -25,8 +25,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; @@ -122,29 +122,12 @@ export const seriesColumns: ColumnProps[] = [ export const SeriesRow: React.FC<{ series: SingleSeries }> = ({ series }) => { // Todo: change to "series details" route when available - const link = DirectSeriesRoute.url({ seriesId: series.id }); - - // Seems odd, but simply checking `e => e.__typename === "AuthorizedEvent"` will produce - // TS2339 errors when compiling. - type Entry = SingleSeries["entries"][number]; - type AuthorizedEvent = Extract; - const isAuthorizedEvent = (e: Entry): e is AuthorizedEvent => - e.__typename === "AuthorizedEvent"; - - const thumbnails = series.entries - .filter(isAuthorizedEvent) - .map(e => ({ - isLive: e.isLive, - audioOnly: e.syncedData ? e.syncedData.audioOnly : false, - thumbnail: e.syncedData?.thumbnail, - })); + const link = `${PATH}/${keyOfId(series.id)}`; return ( - div": { width: "100%" } }}> - - + } title={{series.title}} description={series.syncedData && = ({ series }) => { + // Seems odd, but simply checking `e => e.__typename === "AuthorizedEvent"` will produce + // TS2339 errors when compiling. + type Entry = SingleSeries["entries"][number]; + type AuthorizedEvent = Extract; + const isAuthorizedEvent = (e: Entry): e is AuthorizedEvent => + e.__typename === "AuthorizedEvent"; + + const thumbnails = series.entries + .filter(isAuthorizedEvent) + .map(e => ({ + isLive: e.isLive, + audioOnly: e.syncedData ? e.syncedData.audioOnly : false, + thumbnail: e.syncedData?.thumbnail, + })); + + return
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..99e676b2b --- /dev/null +++ b/frontend/src/routes/manage/Shared/Details.tsx @@ -0,0 +1,154 @@ +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 NarrowedAssetType = { + id: string; + title: string; + created?: string | null; + updated?: string | null; + description? : string | null; + urlProps: { + url: URL; + withTimestamp?: boolean; + }; +} + +type SharedDetailsProps = { + asset: NarrowedAssetType; +} +type PageProps = SharedDetailsProps & { + pageTitle: ParseKeys; + breadcrumb: { + label: string; + link: string; + }; + sections: (item: NarrowedAssetType) => ReactNode[]; +}; +export const DetailsPage: React.FC = ({ asset, pageTitle, breadcrumb, sections }) => { + const { t } = useTranslation(); + const breadcrumbs = [ + { label: t("user.manage-content"), link: ManageRoute.url }, + breadcrumb, + ]; + + const user = useUser(); + if (!isRealUser(user)) { + return ; + } + + return <> + + + {sections(asset).map((section, i) => ( + {section} + ))} + ; +}; + +const DetailsSection: React.FC = ({ children }) => ( +
+
+ {children} +
+
+); + +/** Shows the `created` and `updated` timestamps. */ +export const UpdatedCreatedInfo: React.FC = ({ asset }) => { + const { t, i18n } = useTranslation(); + const created = asset.created && new Date(asset.created).toLocaleString(i18n.language); + + const updated = asset.updated == null + ? null + : new Date(asset.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 = ({ asset }) => { + const { t } = useTranslation(); + const [timestamp, setTimestamp] = useState(0); + const [checkboxChecked, setCheckboxChecked] = useState(false); + + const url = (asset.urlProps.withTimestamp && timestamp && checkboxChecked) + ? new URL(asset.urlProps.url + `?t=${secondsToTimeString(timestamp)}`) + : asset.urlProps.url; + + return ( +
+
+ {t("manage.shared.details.share-direct-link") + ":"} +
+ + {asset.urlProps.withTimestamp && } + />} +
+ ); +}; + +export const MetadataSection: React.FC = ({ asset }) => { + const { t } = useTranslation(); + const titleFieldId = useId(); + const descriptionFieldId = useId(); + + return ( +
+ + + + + + + +