From 1ba027b4357e078c3f177d9d07208049f0c1ce65 Mon Sep 17 00:00:00 2001 From: sct Date: Mon, 7 Dec 2020 10:29:25 +0000 Subject: [PATCH] feat(frontend): initial version of the requests page (no filtering/sorting) --- src/components/Common/Table/index.tsx | 81 +++++++ src/components/RequestCard/index.tsx | 28 +-- .../RequestList/RequestItem/index.tsx | 207 ++++++++++++++++++ src/components/RequestList/index.tsx | 96 ++++++++ src/components/Settings/SettingsJobs.tsx | 82 +++---- src/components/StatusBadge/index.tsx | 40 ++++ src/components/UserList/index.tsx | 171 +++++++-------- src/pages/requests/index.tsx | 9 + 8 files changed, 547 insertions(+), 167 deletions(-) create mode 100644 src/components/Common/Table/index.tsx create mode 100644 src/components/RequestList/RequestItem/index.tsx create mode 100644 src/components/RequestList/index.tsx create mode 100644 src/components/StatusBadge/index.tsx create mode 100644 src/pages/requests/index.tsx diff --git a/src/components/Common/Table/index.tsx b/src/components/Common/Table/index.tsx new file mode 100644 index 000000000..50b3e4929 --- /dev/null +++ b/src/components/Common/Table/index.tsx @@ -0,0 +1,81 @@ +import React, { HTMLAttributes, AllHTMLAttributes } from 'react'; +import { withProperties } from '../../../utils/typeHelpers'; + +const TBody: React.FC = ({ children }) => { + return ( + {children} + ); +}; + +const TH: React.FC> = ({ + children, + className, + ...props +}) => { + const style = [ + 'px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider', + ]; + + if (className) { + style.push(className); + } + + return {children}; +}; + +interface TDProps extends AllHTMLAttributes { + alignText?: 'left' | 'center' | 'right'; + noPadding?: boolean; +} + +const TD: React.FC = ({ + children, + alignText = 'left', + noPadding, + className, + ...props +}) => { + const style = ['whitespace-nowrap text-sm leading-5 text-white']; + + switch (alignText) { + case 'left': + style.push('text-left'); + break; + case 'center': + style.push('text-center'); + break; + case 'right': + style.push('text-right'); + break; + } + + if (!noPadding) { + style.push('px-6 py-4'); + } + + if (className) { + style.push(className); + } + + return ( + + {children} + + ); +}; + +const Table: React.FC = ({ children }) => { + return ( +
+
+
+
+ {children}
+
+
+
+
+ ); +}; + +export default withProperties(Table, { TH, TBody, TD }); diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 3e26415c8..14ecac074 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -16,6 +16,7 @@ import { withProperties } from '../../utils/typeHelpers'; import Link from 'next/link'; import { defineMessages, useIntl } from 'react-intl'; import globalMessages from '../../i18n/globalMessages'; +import StatusBadge from '../StatusBadge'; const messages = defineMessages({ requestedby: 'Requested by {username}', @@ -102,28 +103,11 @@ const RequestCard: React.FC = ({ request }) => { username: requestData.requestedBy.username, })} -
- {requestData.media.status === MediaStatus.AVAILABLE && ( - - {intl.formatMessage(globalMessages.available)} - - )} - {requestData.media.status === MediaStatus.PARTIALLY_AVAILABLE && ( - - {intl.formatMessage(globalMessages.partiallyavailable)} - - )} - {requestData.media.status === MediaStatus.PROCESSING && ( - - {intl.formatMessage(globalMessages.unavailable)} - - )} - {requestData.media.status === MediaStatus.PENDING && ( - - {intl.formatMessage(globalMessages.pending)} - - )} -
+ {requestData.media.status && ( +
+ +
+ )} {request.seasons.length > 0 && (
{intl.formatMessage(messages.seasons)} diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx new file mode 100644 index 000000000..6714bd06d --- /dev/null +++ b/src/components/RequestList/RequestItem/index.tsx @@ -0,0 +1,207 @@ +import React, { useContext } from 'react'; +import type { MediaRequest } from '../../../../server/entity/MediaRequest'; +import { useIntl, FormattedDate, FormattedRelativeTime } from 'react-intl'; +import { useUser, Permission } from '../../../hooks/useUser'; +import { LanguageContext } from '../../../context/LanguageContext'; +import type { MovieDetails } from '../../../../server/models/Movie'; +import type { TvDetails } from '../../../../server/models/Tv'; +import useSWR from 'swr'; +import Badge from '../../Common/Badge'; +import StatusBadge from '../../StatusBadge'; +import Table from '../../Common/Table'; +import { MediaRequestStatus } from '../../../../server/constants/media'; +import Button from '../../Common/Button'; +import axios from 'axios'; +import globalMessages from '../../../i18n/globalMessages'; + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +interface RequestItemProps { + request: MediaRequest; +} + +const RequestItem: React.FC = ({ request }) => { + const intl = useIntl(); + const { hasPermission } = useUser(); + const { locale } = useContext(LanguageContext); + const url = + request.type === 'movie' + ? `/api/v1/movie/${request.media.tmdbId}` + : `/api/v1/tv/${request.media.tmdbId}`; + const { data: title, error } = useSWR( + `${url}?language=${locale}` + ); + + const modifyRequest = async (type: 'approve' | 'decline') => { + const response = await axios.get(`/api/v1/request/${request.id}/${type}`); + + if (response) { + // revalidate(); + } + }; + + if (!title && !error) { + return ( + + + + ); + } + + if (!title) { + return ( + + + + ); + } + + return ( + + + + + +

+ {isMovie(title) ? title.title : title.name} +

+
+ Requested by {request.requestedBy.username} +
+ {request.seasons.length > 0 && ( +
+ Seasons + {request.seasons.map((season) => ( + + {season.seasonNumber} + + ))} +
+ )} +
+ + + + +
+ Requested at + + + +
+
+ +
+ Modified by + {request.modifiedBy ? ( + + {request.modifiedBy.username} ( + + ) + + ) : ( + N/A + )} +
+
+ + {request.status === MediaRequestStatus.PENDING && + hasPermission(Permission.MANAGE_REQUESTS) && ( + <> + + + + + + + + )} + + {/*
+
+
+ Requested at + + + +
+
+
+
+ Modified by + {request.modifiedBy ? ( + + {request.modifiedBy.username} ( + + ) + + ) : ( + N/A + )} +
+
+
*/} + + ); +}; + +export default RequestItem; diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx new file mode 100644 index 000000000..69d1e8fe7 --- /dev/null +++ b/src/components/RequestList/index.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import useSWR from 'swr'; +import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import RequestItem from './RequestItem'; +import Header from '../Common/Header'; +import Table from '../Common/Table'; +import Button from '../Common/Button'; + +const RequestList: React.FC = () => { + const [pageIndex, setPageIndex] = useState(0); + + const { data, error } = useSWR( + `/api/v1/request?take=10&skip=${pageIndex * 10}` + ); + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + const hasNextPage = data.pageInfo.pages > pageIndex + 1; + const hasPrevPage = pageIndex > 0; + + return ( + <> +
Requests
+ + + + Media Info + Status + + + + + + {data.results.map((request) => { + return ( + + ); + })} + + + + + + +
+ + ); +}; + +export default RequestList; diff --git a/src/components/Settings/SettingsJobs.tsx b/src/components/Settings/SettingsJobs.tsx index 3af92a74e..5e9364b04 100644 --- a/src/components/Settings/SettingsJobs.tsx +++ b/src/components/Settings/SettingsJobs.tsx @@ -3,6 +3,7 @@ import useSWR from 'swr'; import LoadingSpinner from '../Common/LoadingSpinner'; import { FormattedRelativeTime, defineMessages, useIntl } from 'react-intl'; import Button from '../Common/Button'; +import Table from '../Common/Table'; const messages = defineMessages({ jobname: 'Job Name', @@ -21,55 +22,38 @@ const SettingsJobs: React.FC = () => { } return ( -
-
-
-
- - - - - - - - - - {data?.map((job, index) => ( - - - - - - ))} - -
- {intl.formatMessage(messages.jobname)} - - {intl.formatMessage(messages.nextexecution)} -
-
- {job.name} -
-
-
- -
-
- -
-
-
-
-
+ + + {intl.formatMessage(messages.jobname)} + {intl.formatMessage(messages.nextexecution)} + + + + {data?.map((job, index) => ( + + +
{job.name}
+
+ +
+ +
+
+ + + + + ))} + +
); }; diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx new file mode 100644 index 000000000..f56e8ae3f --- /dev/null +++ b/src/components/StatusBadge/index.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { MediaStatus } from '../../../server/constants/media'; +import Badge from '../Common/Badge'; +import { useIntl } from 'react-intl'; +import globalMessages from '../../i18n/globalMessages'; + +interface StatusBadgeProps { + status: MediaStatus; +} + +const StatusBadge: React.FC = ({ status }) => { + const intl = useIntl(); + + return ( + <> + {status === MediaStatus.AVAILABLE && ( + + {intl.formatMessage(globalMessages.available)} + + )} + {status === MediaStatus.PARTIALLY_AVAILABLE && ( + + {intl.formatMessage(globalMessages.partiallyavailable)} + + )} + {status === MediaStatus.PROCESSING && ( + + {intl.formatMessage(globalMessages.unavailable)} + + )} + {status === MediaStatus.PENDING && ( + + {intl.formatMessage(globalMessages.pending)} + + )} + + ); +}; + +export default StatusBadge; diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 98a7d2522..e6b8135dd 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -9,6 +9,7 @@ import { hasPermission } from '../../../server/lib/permissions'; import { Permission } from '../../hooks/useUser'; import { useRouter } from 'next/router'; import Header from '../Common/Header'; +import Table from '../Common/Table'; const messages = defineMessages({ userlist: 'User List', @@ -37,102 +38,80 @@ const UserList: React.FC = () => { return ( <>
{intl.formatMessage(messages.userlist)}
-
-
-
-
- - - - - - - - - - - - - - {data?.map((user) => ( - - - - - - - - - - ))} - -
- {intl.formatMessage(messages.username)} - - {intl.formatMessage(messages.totalrequests)} - - {intl.formatMessage(messages.usertype)} - - {intl.formatMessage(messages.role)} - - {intl.formatMessage(messages.created)} - - {intl.formatMessage(messages.lastupdated)} -
-
-
- -
-
-
- {user.username} -
-
- {user.email} -
-
-
-
-
- {user.requestCount} -
-
- - {intl.formatMessage(messages.plexuser)} - - - {hasPermission(Permission.ADMIN, user.permissions) - ? intl.formatMessage(messages.admin) - : intl.formatMessage(messages.user)} - - - - - - - -
-
-
-
-
+ + + + {intl.formatMessage(messages.username)} + {intl.formatMessage(messages.totalrequests)} + {intl.formatMessage(messages.usertype)} + {intl.formatMessage(messages.role)} + {intl.formatMessage(messages.created)} + {intl.formatMessage(messages.lastupdated)} + + + + + {data?.map((user) => ( + + +
+
+ +
+
+
+ {user.username} +
+
+ {user.email} +
+
+
+
+ +
{user.requestCount}
+
+ + + {intl.formatMessage(messages.plexuser)} + + + + {hasPermission(Permission.ADMIN, user.permissions) + ? intl.formatMessage(messages.admin) + : intl.formatMessage(messages.user)} + + + + + + + + + + + + + ))} + +
); }; diff --git a/src/pages/requests/index.tsx b/src/pages/requests/index.tsx new file mode 100644 index 000000000..225331ed2 --- /dev/null +++ b/src/pages/requests/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import type { NextPage } from 'next'; +import RequestList from '../../components/RequestList'; + +const RequestsPage: NextPage = () => { + return ; +}; + +export default RequestsPage;