feat(frontend): modal component and basic request hookup (#91)

pull/77/head
sct 4 years ago committed by GitHub
parent 42cf45fa19
commit 626099a2c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -25,6 +25,7 @@
"next": "9.5.3",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-spring": "^8.0.27",
"react-transition-group": "^4.4.1",
"reflect-metadata": "^0.1.13",
"sqlite3": "^5.0.0",
@ -43,6 +44,7 @@
"@types/lodash": "^4.14.161",
"@types/node": "^14.10.0",
"@types/react": "^16.9.49",
"@types/react-dom": "^16.9.8",
"@types/react-transition-group": "^4.4.0",
"@types/swagger-ui-express": "^4.1.2",
"@types/yamljs": "^0.2.31",

@ -1,7 +1,14 @@
import React, { ButtonHTMLAttributes } from 'react';
export type ButtonType =
| 'default'
| 'primary'
| 'danger'
| 'warning'
| 'success';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
buttonType?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
buttonType?: ButtonType;
buttonSize?: 'default' | 'lg' | 'md' | 'sm';
}
@ -38,7 +45,7 @@ const Button: React.FC<ButtonProps> = ({
break;
default:
buttonStyle.push(
'border-gray-300 leading-5 font-medium rounded-md text-gray-700 bg-white hover:text-gray-500 focus:border-blue-300 focus:shadow-outline-blue active:text-gray-800 active:bg-gray-50'
'leading-5 font-medium rounded-md text-gray-200 bg-cool-gray-500 hover:bg-cool-gray-400 hover:text-white focus:border-blue-300 focus:shadow-outline-blue active:text-gray-200 active:bg-cool-gray-400'
);
}

@ -0,0 +1,143 @@
import React, { MouseEvent, ReactNode } from 'react';
import ReactDOM from 'react-dom';
import Button, { ButtonType } from '../Button';
import { useTransition, animated } from 'react-spring';
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
interface ModalProps {
title?: string;
onCancel?: (e: MouseEvent<HTMLElement>) => void;
onOk?: (e: MouseEvent<HTMLButtonElement>) => void;
cancelText?: string;
okText?: string;
cancelButtonType?: ButtonType;
okButtonType?: ButtonType;
visible?: boolean;
disableScrollLock?: boolean;
backgroundClickable?: boolean;
iconSvg?: ReactNode;
}
const Modal: React.FC<ModalProps> = ({
title,
onCancel,
onOk,
cancelText,
okText,
cancelButtonType,
okButtonType,
children,
visible,
disableScrollLock,
backgroundClickable = true,
iconSvg,
}) => {
useLockBodyScroll(!!visible, disableScrollLock);
const transitions = useTransition(visible, null, {
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0 },
config: { tension: 500, velocity: 40, friction: 60 },
});
const containerTransitions = useTransition(visible, null, {
from: { opacity: 0, transform: 'scale(0.5)' },
enter: { opacity: 1, transform: 'scale(1)' },
leave: { opacity: 0, transform: 'scale(0.5)' },
config: { tension: 500, velocity: 40, friction: 60 },
});
const cancelType = cancelButtonType ?? 'default';
const okType = okButtonType ?? 'primary';
return (
<>
{transitions.map(
({ props, item, key }) =>
item &&
ReactDOM.createPortal(
<animated.div
className="fixed top-0 left-0 right-0 bottom-0 bg-cool-gray-800 bg-opacity-50 w-full h-full z-50 flex justify-center items-center"
style={props}
key={key}
onClick={
typeof onCancel === 'function' && backgroundClickable
? onCancel
: undefined
}
onKeyDown={(e) => {
if (e.key === 'Escape') {
typeof onCancel === 'function' && backgroundClickable
? onCancel
: undefined;
}
}}
>
{containerTransitions.map(
({ props, item, key }) =>
item && (
<animated.div
style={props}
className="inline-block align-bottom bg-cool-gray-700 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
key={key}
>
<div className="sm:flex sm:items-start">
{iconSvg && (
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-cool-gray-600 text-white sm:mx-0 sm:h-10 sm:w-10">
{iconSvg}
</div>
)}
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
{title && (
<h3
className="text-lg leading-6 font-medium text-white"
id="modal-headline"
>
{title}
</h3>
)}
{children && (
<div className="mt-2">
<p className="text-sm leading-5 text-cool-gray-300">
{children}
</p>
</div>
)}
</div>
</div>
{(onCancel || onOk) && (
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
{typeof onOk === 'function' && (
<Button
buttonType={okType}
onClick={onOk}
className="ml-3"
>
{okText ? okText : 'Ok'}
</Button>
)}
{typeof onCancel === 'function' && (
<Button
buttonType={cancelType}
onClick={onCancel}
className="px-4"
>
{cancelText ? cancelText : 'Cancel'}
</Button>
)}
</div>
)}
</animated.div>
)
)}
</animated.div>,
document.body
)
)}
</>
);
};
export default Modal;

@ -1,8 +1,7 @@
import React, { useRef } from 'react';
import useSWR, { useSWRInfinite } from 'swr';
import React from 'react';
import useSWR from 'swr';
import type { MovieResult, TvResult } from '../../../server/models/Search';
import TitleCard from '../TitleCard';
import useVerticalScroll from '../../hooks/useVerticalScroll';
interface MovieDiscoverResult {
page: number;
@ -18,17 +17,6 @@ interface TvDiscoverResult {
results: TvResult[];
}
const getKey = (
pageIndex: number,
previousPageData: MovieDiscoverResult | null
) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/movies?page=${pageIndex + 1}`;
};
const Discover: React.FC = () => {
const { data: movieData, error: movieError } = useSWR<MovieDiscoverResult>(
'/api/v1/discover/movies'
@ -47,12 +35,16 @@ const Discover: React.FC = () => {
</div>
</div>
<div
className="overflow-x-scroll whitespace-no-wrap hide-scrollbar scrolling-touch overscroll-x-contain -ml-2 -mr-4"
className="overflow-x-scroll whitespace-no-wrap hide-scrollbar scrolling-touch overscroll-x-contain -ml-4 -mr-4"
style={{ height: 295 }}
>
{movieData?.results.map((title) => (
<div key={title.id} className="px-2 inline-block">
<div
key={title.id}
className="first:px-4 last:px-4 px-2 inline-block"
>
<TitleCard
id={title.id}
image={title.posterPath}
status={title.request?.status}
summary={title.overview}
@ -66,7 +58,10 @@ const Discover: React.FC = () => {
{!movieData &&
!movieError &&
[...Array(10)].map((_item, i) => (
<div key={`placeholder-${i}`} className="px-2 inline-block">
<div
key={`placeholder-${i}`}
className="first:px-4 last:px-4 px-2 inline-block"
>
<TitleCard.Placeholder />
</div>
))}
@ -79,12 +74,16 @@ const Discover: React.FC = () => {
</div>
</div>
<div
className="overflow-x-scroll whitespace-no-wrap hide-scrollbar scrolling-touch overscroll-x-contain -ml-2 -mr-4"
className="overflow-x-scroll whitespace-no-wrap hide-scrollbar scrolling-touch overscroll-x-contain -ml-4 -mr-4"
style={{ height: 295 }}
>
{tvData?.results.map((title) => (
<div key={title.id} className="px-2 inline-block">
<div
key={title.id}
className="first:px-4 last:px-4 px-2 inline-block"
>
<TitleCard
id={title.id}
image={title.posterPath}
status={title.request?.status}
summary={title.overview}
@ -98,7 +97,10 @@ const Discover: React.FC = () => {
{!tvData &&
!tvError &&
[...Array(10)].map((_item, i) => (
<div key={`placeholder-${i}`} className="px-2 inline-block">
<div
key={`placeholder-${i}`}
className="first:px-4 last:px-4 px-2 inline-block"
>
<TitleCard.Placeholder />
</div>
))}

@ -89,6 +89,7 @@ const Search: React.FC = () => {
case 'movie':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
status={title.request?.status}
summary={title.overview}
@ -102,6 +103,7 @@ const Search: React.FC = () => {
case 'tv':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
status={title.request?.status}
summary={title.overview}

@ -6,8 +6,13 @@ import Unavailable from '../../assets/unavailable.svg';
import { withProperties } from '../../utils/typeHelpers';
import Transition from '../Transition';
import Placeholder from './Placeholder';
import Modal from '../Common/Modal';
import { useUser, Permission } from '../../hooks/useUser';
import axios from 'axios';
import { MediaRequest } from '../../../server/entity/MediaRequest';
interface TitleCardProps {
id: number;
image?: string;
summary: string;
year: string;
@ -25,6 +30,7 @@ enum MediaRequestStatus {
}
const TitleCard: React.FC<TitleCardProps> = ({
id,
image,
summary,
year,
@ -32,7 +38,21 @@ const TitleCard: React.FC<TitleCardProps> = ({
status,
mediaType,
}) => {
const [currentStatus, setCurrentStatus] = useState(status);
const { hasPermission } = useUser();
const [showDetail, setShowDetail] = useState(false);
const [showRequestModal, setShowRequestModal] = useState(false);
const request = async () => {
const response = await axios.post<MediaRequest>('/api/v1/request', {
mediaId: id,
mediaType,
});
if (response.data) {
setCurrentStatus(response.data.status);
}
};
// Just to get the year from the date
if (year) {
@ -46,6 +66,34 @@ const TitleCard: React.FC<TitleCardProps> = ({
height: 270,
}}
>
<Modal
visible={showRequestModal}
backgroundClickable
onCancel={() => setShowRequestModal(false)}
onOk={() => request()}
title={`Request ${title}`}
okText="Request"
iconSvg={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
}
>
{hasPermission(Permission.MANAGE_REQUESTS)
? 'Your request will be immediately approved. Do you wish to continue?'
: undefined}
</Modal>
<div
className="titleCard"
style={{
@ -71,19 +119,19 @@ const TitleCard: React.FC<TitleCardProps> = ({
right: '-1px',
}}
>
{status === MediaRequestStatus.AVAILABLE && (
{currentStatus === MediaRequestStatus.AVAILABLE && (
<Available className="rounded-tr-md" />
)}
{status === MediaRequestStatus.PENDING && (
{currentStatus === MediaRequestStatus.PENDING && (
<Requested className="rounded-tr-md" />
)}
{status === MediaRequestStatus.APPROVED && (
{currentStatus === MediaRequestStatus.APPROVED && (
<Unavailable className="rounded-tr-md" />
)}
</div>
<Transition
show={!image || showDetail}
show={!image || showDetail || showRequestModal}
enter="transition ease-in-out duration-300 transform opacity-0"
enterFrom="opacity-0"
enterTo="opacity-100"
@ -138,7 +186,10 @@ const TitleCard: React.FC<TitleCardProps> = ({
/>
</svg>
</button>
<button className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-1 hover:bg-indigo-400 focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition ease-in-out duration-150">
<button
onClick={() => setShowRequestModal(true)}
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-1 hover:bg-indigo-400 focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition ease-in-out duration-150"
>
<svg
className="w-4 mx-auto"
fill="none"

@ -0,0 +1,28 @@
import { useEffect } from 'react';
/**
* Hook to lock the body scroll whenever a component is mounted or
* whenever isLocked is set to true.
*
* You can pass in true always to cause a lock on mount/dismount of the component
* using this hook.
*
* @param isLocked Toggle the scroll lock
* @param disabled Disables the entire hook (allows conditional skipping of the lock)
*/
export const useLockBodyScroll = (
isLocked: boolean,
disabled?: boolean
): void => {
useEffect(() => {
const originalStyle = window.getComputedStyle(document.body).overflow;
if (isLocked && !disabled) {
document.body.style.overflow = 'hidden';
}
return () => {
if (!disabled) {
document.body.style.overflow = originalStyle;
}
};
}, [isLocked, disabled]);
};

@ -1,5 +1,7 @@
import useSwr from 'swr';
import { useRef } from 'react';
import { hasPermission } from '../../server/lib/permissions';
export interface User {
id: number;
email: string;
@ -7,11 +9,23 @@ export interface User {
permissions: number;
}
export enum Permission {
NONE = 0,
ADMIN = 2,
MANAGE_SETTINGS = 4,
MANAGE_USERS = 8,
MANAGE_REQUESTS = 16,
REQUEST = 32,
VOTE = 64,
AUTO_APPROVE = 128,
}
interface UserHookResponse {
user?: User;
loading: boolean;
error: string;
revalidate: () => Promise<boolean>;
hasPermission: (permission: Permission | Permission[]) => boolean;
}
export const useUser = ({
@ -29,10 +43,15 @@ export const useUser = ({
}
);
const checkPermission = (permission: Permission | Permission[]): boolean => {
return hasPermission(permission, data?.permissions ?? 0);
};
return {
user: data,
loading: !data && !error,
error,
revalidate,
hasPermission: checkPermission,
};
};

@ -1036,7 +1036,7 @@
core-js-pure "^3.0.0"
regenerator-runtime "^0.13.4"
"@babel/runtime@7.11.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.6":
"@babel/runtime@7.11.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.6":
version "7.11.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
@ -1570,6 +1570,13 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
"@types/react-dom@^16.9.8":
version "16.9.8"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423"
integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.0.tgz#882839db465df1320e4753e6e9f70ca7e9b4d46d"
@ -7621,7 +7628,7 @@ promise-inflight@^1.0.1:
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
prop-types@15.7.2, prop-types@^15.6.2, prop-types@^15.7.2:
prop-types@15.7.2, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@ -7817,6 +7824,14 @@ react-refresh@0.8.3:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==
react-spring@^8.0.27:
version "8.0.27"
resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-8.0.27.tgz#97d4dee677f41e0b2adcb696f3839680a3aa356a"
integrity sha512-nDpWBe3ZVezukNRandTeLSPcwwTMjNVu1IDq9qA/AMiUqHuRN4BeSWvKr3eIxxg1vtiYiOLy4FqdfCP5IoP77g==
dependencies:
"@babel/runtime" "^7.3.1"
prop-types "^15.5.8"
react-transition-group@^4.4.1:
version "4.4.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"

Loading…
Cancel
Save