feat(frontend): initial version of the requests page (no filtering/sorting)

pull/202/head
sct 4 years ago
parent c5c73c96d6
commit 1ba027b435

@ -0,0 +1,81 @@
import React, { HTMLAttributes, AllHTMLAttributes } from 'react';
import { withProperties } from '../../../utils/typeHelpers';
const TBody: React.FC = ({ children }) => {
return (
<tbody className="bg-gray-600 divide-y divide-gray-700">{children}</tbody>
);
};
const TH: React.FC<AllHTMLAttributes<HTMLTableHeaderCellElement>> = ({
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 <th className={style.join(' ')}>{children}</th>;
};
interface TDProps extends AllHTMLAttributes<HTMLTableCellElement> {
alignText?: 'left' | 'center' | 'right';
noPadding?: boolean;
}
const TD: React.FC<TDProps> = ({
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 (
<td className={style.join(' ')} {...props}>
{children}
</td>
);
};
const Table: React.FC = ({ children }) => {
return (
<div className="flex flex-col">
<div className="my-2 overflow-x-auto -mx-6 sm:-mx-6 md:mx-4 lg:mx-4">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden sm:rounded-lg">
<table className="min-w-full">{children}</table>
</div>
</div>
</div>
</div>
);
};
export default withProperties(Table, { TH, TBody, TD });

@ -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<RequestCardProps> = ({ request }) => {
username: requestData.requestedBy.username,
})}
</div>
<div className="mt-1 sm:mt-2">
{requestData.media.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{requestData.media.status === MediaStatus.PARTIALLY_AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.partiallyavailable)}
</Badge>
)}
{requestData.media.status === MediaStatus.PROCESSING && (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.unavailable)}
</Badge>
)}
{requestData.media.status === MediaStatus.PENDING && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
</div>
{requestData.media.status && (
<div className="mt-1 sm:mt-2">
<StatusBadge status={requestData.media.status} />
</div>
)}
{request.seasons.length > 0 && (
<div className="hidden mt-2 text-sm sm:flex items-center">
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>

@ -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<RequestItemProps> = ({ 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<MovieDetails | TvDetails>(
`${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 (
<tr className="w-full bg-gray-800 animate-pulse h-24">
<td colSpan={6}></td>
</tr>
);
}
if (!title) {
return (
<tr className="w-full bg-gray-800 animate-pulse h-24">
<td colSpan={6}></td>
</tr>
);
}
return (
<tr className="w-full bg-gray-800 h-24 p-2 relative text-white">
<Table.TD
noPadding
className="w-20 px-4 relative hidden sm:table-cell align-middle"
>
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
alt=""
className="rounded-md shadow-sm cursor-pointer transition transform-gpu duration-300 scale-100 hover:scale-105 hover:shadow-md"
/>
</Table.TD>
<Table.TD>
<h2 className="text-white text-xl mr-2">
{isMovie(title) ? title.title : title.name}
</h2>
<div className="text-sm">
Requested by {request.requestedBy.username}
</div>
{request.seasons.length > 0 && (
<div className="hidden mt-2 text-sm sm:flex items-center">
<span className="mr-2">Seasons</span>
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
)}
</Table.TD>
<Table.TD>
<StatusBadge status={request.media.status} />
</Table.TD>
<Table.TD>
<div className="flex flex-col">
<span>Requested at</span>
<span className="text-sm text-gray-300">
<FormattedDate value={request.createdAt} />
</span>
</div>
</Table.TD>
<Table.TD>
<div className="flex flex-col">
<span>Modified by</span>
{request.modifiedBy ? (
<span className="text-sm text-gray-300">
{request.modifiedBy.username} (
<FormattedRelativeTime
value={Math.floor(
(new Date(request.updatedAt).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
/>
)
</span>
) : (
<span className="text-sm text-gray-300">N/A</span>
)}
</div>
</Table.TD>
<Table.TD alignText="right">
{request.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<span className="mr-2">
<Button
buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.approve)}
</span>
</Button>
</span>
<span>
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.decline)}
</span>
</Button>
</span>
</>
)}
</Table.TD>
{/* <div className="flex justify-start flex-1 items-center">
<div className="mr-12">
<div className="flex flex-col">
<span>Requested at</span>
<span className="text-sm text-gray-300">
<FormattedDate value={request.createdAt} />
</span>
</div>
</div>
<div>
<div className="flex flex-col">
<span>Modified by</span>
{request.modifiedBy ? (
<span className="text-sm text-gray-300">
{request.modifiedBy.username} (
<FormattedRelativeTime
value={Math.floor(
(new Date(request.updatedAt).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
/>
)
</span>
) : (
<span className="text-sm text-gray-300">N/A</span>
)}
</div>
</div>
</div> */}
</tr>
);
};
export default RequestItem;

@ -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<RequestResultsResponse>(
`/api/v1/request?take=10&skip=${pageIndex * 10}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <LoadingSpinner />;
}
const hasNextPage = data.pageInfo.pages > pageIndex + 1;
const hasPrevPage = pageIndex > 0;
return (
<>
<Header>Requests</Header>
<Table>
<thead>
<Table.TH className="hidden sm:table-cell"></Table.TH>
<Table.TH>Media Info</Table.TH>
<Table.TH>Status</Table.TH>
<Table.TH></Table.TH>
<Table.TH></Table.TH>
<Table.TH></Table.TH>
</thead>
<Table.TBody>
{data.results.map((request) => {
return (
<RequestItem
request={request}
key={`request-list-${request.id}`}
/>
);
})}
<tr>
<Table.TD colSpan={6} noPadding>
<nav
className="bg-gray-700 px-4 py-3 flex items-center justify-between text-white sm:px-6"
aria-label="Pagination"
>
<div className="hidden sm:block">
<p className="text-sm">
Showing
<span className="font-medium px-1">{pageIndex * 10}</span>
to
<span className="font-medium px-1">
{data.results.length < 10
? pageIndex * 10 + data.results.length
: pageIndex + 1 * 10}
</span>
of
<span className="font-medium px-1">
{data.pageInfo.results}
</span>
results
</p>
</div>
<div className="flex-1 flex justify-start sm:justify-end">
<span className="mr-2">
<Button
disabled={!hasPrevPage}
onClick={() => setPageIndex((current) => current - 1)}
>
Previous
</Button>
</span>
<Button
disabled={!hasNextPage}
onClick={() => setPageIndex((current) => current + 1)}
>
Next
</Button>
</div>
</nav>
</Table.TD>
</tr>
</Table.TBody>
</Table>
</>
);
};
export default RequestList;

@ -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 (
<div className="flex flex-col">
<div className="my-2 overflow-x-auto -mx-6 sm:-mx-6 md:mx-4 lg:mx-4">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
{intl.formatMessage(messages.jobname)}
</th>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
{intl.formatMessage(messages.nextexecution)}
</th>
<th className="px-6 py-3 bg-gray-500"></th>
</tr>
</thead>
<tbody className="bg-gray-600 divide-y divide-gray-700">
{data?.map((job, index) => (
<tr key={`job-list-${index}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm leading-5 text-white">
{job.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm leading-5 text-white">
<FormattedRelativeTime
value={Math.floor(
(new Date(job.nextExecutionTime).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
/>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm leading-5 font-medium">
<Button buttonType="primary">
{intl.formatMessage(messages.runnow)}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
<Table>
<thead>
<Table.TH>{intl.formatMessage(messages.jobname)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.nextexecution)}</Table.TH>
<Table.TH></Table.TH>
</thead>
<Table.TBody>
{data?.map((job, index) => (
<tr key={`job-list-${index}`}>
<Table.TD>
<div className="text-sm leading-5 text-white">{job.name}</div>
</Table.TD>
<Table.TD>
<div className="text-sm leading-5 text-white">
<FormattedRelativeTime
value={Math.floor(
(new Date(job.nextExecutionTime).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
/>
</div>
</Table.TD>
<Table.TD alignText="right">
<Button buttonType="primary">
{intl.formatMessage(messages.runnow)}
</Button>
</Table.TD>
</tr>
))}
</Table.TBody>
</Table>
);
};

@ -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<StatusBadgeProps> = ({ status }) => {
const intl = useIntl();
return (
<>
{status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{status === MediaStatus.PARTIALLY_AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.partiallyavailable)}
</Badge>
)}
{status === MediaStatus.PROCESSING && (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.unavailable)}
</Badge>
)}
{status === MediaStatus.PENDING && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
</>
);
};
export default StatusBadge;

@ -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 (
<>
<Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header>
<div className="flex flex-col">
<div className="my-2 overflow-x-auto -mx-6 sm:-mx-6 md:mx-4 lg:mx-4">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
{intl.formatMessage(messages.username)}
</th>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
{intl.formatMessage(messages.totalrequests)}
</th>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
{intl.formatMessage(messages.usertype)}
</th>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
{intl.formatMessage(messages.role)}
</th>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
{intl.formatMessage(messages.created)}
</th>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
{intl.formatMessage(messages.lastupdated)}
</th>
<th className="px-6 py-3 bg-gray-500"></th>
</tr>
</thead>
<tbody className="bg-gray-600 divide-y divide-gray-700">
{data?.map((user) => (
<tr key={`user-list-${user.id}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<img
className="h-10 w-10 rounded-full"
src={user.avatar}
alt=""
/>
</div>
<div className="ml-4">
<div className="text-sm leading-5 font-medium text-white">
{user.username}
</div>
<div className="text-sm leading-5 text-gray-300">
{user.email}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm leading-5 text-white">
{user.requestCount}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
</Badge>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm leading-5 text-white">
{hasPermission(Permission.ADMIN, user.permissions)
? intl.formatMessage(messages.admin)
: intl.formatMessage(messages.user)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm leading-5 text-white">
<FormattedDate value={user.createdAt} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm leading-5 text-white">
<FormattedDate value={user.updatedAt} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm leading-5 font-medium">
<Button
buttonType="warning"
className="mr-2"
onClick={() =>
router.push(
'/users/[userId]/edit',
`/users/${user.id}/edit`
)
}
>
{intl.formatMessage(messages.edit)}
</Button>
<Button buttonType="danger">
{intl.formatMessage(messages.delete)}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
<Table>
<thead>
<tr>
<Table.TH>{intl.formatMessage(messages.username)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.totalrequests)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.usertype)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.role)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.created)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.lastupdated)}</Table.TH>
<Table.TH></Table.TH>
</tr>
</thead>
<Table.TBody>
{data?.map((user) => (
<tr key={`user-list-${user.id}`}>
<Table.TD>
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<img
className="h-10 w-10 rounded-full"
src={user.avatar}
alt=""
/>
</div>
<div className="ml-4">
<div className="text-sm leading-5 font-medium">
{user.username}
</div>
<div className="text-sm leading-5 text-gray-300">
{user.email}
</div>
</div>
</div>
</Table.TD>
<Table.TD>
<div className="text-sm leading-5">{user.requestCount}</div>
</Table.TD>
<Table.TD>
<Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
</Badge>
</Table.TD>
<Table.TD>
{hasPermission(Permission.ADMIN, user.permissions)
? intl.formatMessage(messages.admin)
: intl.formatMessage(messages.user)}
</Table.TD>
<Table.TD>
<FormattedDate value={user.createdAt} />
</Table.TD>
<Table.TD>
<FormattedDate value={user.updatedAt} />
</Table.TD>
<Table.TD alignText="right">
<Button
buttonType="warning"
className="mr-2"
onClick={() =>
router.push(
'/users/[userId]/edit',
`/users/${user.id}/edit`
)
}
>
{intl.formatMessage(messages.edit)}
</Button>
<Button buttonType="danger">
{intl.formatMessage(messages.delete)}
</Button>
</Table.TD>
</tr>
))}
</Table.TBody>
</Table>
</>
);
};

@ -0,0 +1,9 @@
import React from 'react';
import type { NextPage } from 'next';
import RequestList from '../../components/RequestList';
const RequestsPage: NextPage = () => {
return <RequestList />;
};
export default RequestsPage;
Loading…
Cancel
Save