feat(frontend): basic discover page (only movies) (#74)

pull/69/head
sct 4 years ago committed by GitHub
parent 258bb93be2
commit bbfe349b52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -21,6 +21,7 @@
"express": "^4.17.1",
"express-openapi-validator": "^3.16.15",
"express-session": "^1.17.1",
"lodash": "^4.17.20",
"next": "9.5.3",
"react": "16.13.1",
"react-dom": "16.13.1",
@ -39,6 +40,7 @@
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.8",
"@types/express-session": "^1.17.0",
"@types/lodash": "^4.14.161",
"@types/node": "^14.6.4",
"@types/react": "^16.9.49",
"@types/react-transition-group": "^4.4.0",

@ -0,0 +1,92 @@
import React, { useRef } from 'react';
import { useSWRInfinite } from 'swr';
import type { MovieResult } from '../../../server/models/Search';
import TitleCard from '../TitleCard';
import useVerticalScroll from '../../hooks/useVerticalScroll';
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const getKey = (pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/movies?page=${pageIndex + 1}`;
};
const Discover: React.FC = () => {
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(getKey, {
initialSize: 3,
});
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
useVerticalScroll(() => {
setSize(size + 1);
}, !isLoadingMore && !isLoadingInitialData);
if (error) {
return <div>{error}</div>;
}
if (!data && !error) {
return <div>loading!</div>;
}
const titles = data?.reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
return (
<>
<div className="md:flex md:items-center md:justify-between mb-8 mt-8">
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold leading-7 text-white sm:text-3xl sm:leading-9 sm:truncate">
Discover
</h2>
</div>
<div className="mt-4 flex md:mt-0 md:ml-4">
<span className="relative z-0 inline-flex shadow-sm rounded-md">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 rounded-l-md border border-indigo-900 bg-indigo-500 hover:bg-indigo-400 text-sm leading-5 font-medium text-cool-gray-100 hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
>
Movies
</button>
<button
type="button"
className="-ml-px relative inline-flex items-center px-4 py-2 rounded-r-md border border-indigo-900 bg-indigo-500 text-sm leading-5 font-medium text-cool-gray-100 hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
>
TV Shows
</button>
</span>
</div>
</div>
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{titles?.map((title) => (
<li key={title.id} className="col-span-1 flex flex-col text-center">
<TitleCard
image={`image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
status={'Not Requested'}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
/>
</li>
))}
</ul>
</>
);
};
export default Discover;

@ -67,16 +67,17 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
className="group flex items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white bg-gray-900 focus:outline-none focus:bg-gray-700 transition ease-in-out duration-150"
>
<svg
className="mr-4 h-6 w-6 text-gray-300 group-hover:text-gray-300 group-focus:text-gray-300 transition ease-in-out duration-150"
className="mr-3 h-6 w-6 text-gray-300 group-hover:text-gray-300 group-focus:text-gray-300 transition ease-in-out duration-150"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
</svg>
Dashboard
@ -93,7 +94,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</Transition>
</div>
<div className="hidden md:flex md:flex-shrink-0">
<div className="hidden md:flex md:flex-shrink-0 top-0 bottom-0 left-0 fixed">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
@ -108,14 +109,15 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<svg
className="mr-3 h-6 w-6 text-gray-300 group-hover:text-gray-300 group-focus:text-gray-300 transition ease-in-out duration-150"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
</svg>
Dashboard

@ -8,11 +8,11 @@ const Layout: React.FC = ({ children }) => {
const [isSidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="h-screen flex overflow-hidden bg-gray-100">
<div className="min-h-full h-full flex bg-cool-gray-900">
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
<div className="flex flex-col w-0 flex-1 overflow-hidden">
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white shadow">
<div className="flex flex-col w-0 flex-1 md:ml-64 relative mb-16">
<div className="z-10 flex-shrink-0 flex h-16 bg-cool-gray-600 shadow fixed right-0 left-0 md:left-64">
<button
className="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:bg-gray-100 focus:text-gray-600 md:hidden"
aria-label="Open sidebar"
@ -42,7 +42,7 @@ const Layout: React.FC = ({ children }) => {
</div>
<main
className="flex-1 relative z-0 overflow-y-auto focus:outline-none"
className="relative z-0 top-16 focus:outline-none right-0"
tabIndex={0}
>
<div className="pt-2 pb-6 md:py-6">

@ -35,15 +35,15 @@ const Login: React.FC = () => {
}, [user, router]);
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="min-h-screen bg-cool-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Logo className="mx-auto max-h-32 w-auto" />
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-cool-gray-100">
Log in to continue
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div className="bg-cool-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<PlexLoginButton
onAuthToken={(authToken) => setAuthToken(authToken)}
/>

@ -7,7 +7,7 @@ const Search: React.FC = () => {
<label htmlFor="search_field" className="sr-only">
Search
</label>
<div className="relative w-full text-gray-400 focus-within:text-gray-600">
<div className="relative w-full text-white focus-within:text-gray-200">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
@ -19,7 +19,7 @@ const Search: React.FC = () => {
</div>
<input
id="search_field"
className="block w-full h-full pl-8 pr-3 py-2 rounded-md text-gray-900 placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 sm:text-sm"
className="block w-full h-full pl-8 pr-3 py-2 rounded-md bg-cool-gray-600 text-white placeholder-gray-300 focus:outline-none focus:placeholder-gray-400 sm:text-sm"
placeholder="Search"
type="search"
/>

@ -61,7 +61,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
</span>
<span className="mt-3 text-purple-800 text-sm font-semibold leading-4 ">
User Score: {userScore}/100
User Score: {userScore}
</span>
</div>
</div>

@ -0,0 +1,80 @@
import { useState, useEffect, useRef, MutableRefObject } from 'react';
import { debounce } from 'lodash';
const IS_SCROLLING_CHECK_THROTTLE = 200;
const BUFFER_HEIGHT = 200;
/**
* useVerticalScroll is a custom hook to handle infinite scrolling
*
* @param callback Callback is executed when page reaches bottom
* @param shouldFetch Disables callback if true
*/
const useVerticalScroll = (
callback: () => void,
shouldFetch: boolean
): boolean => {
const [isScrolling, setScrolling] = useState(false);
type SetTimeoutReturnType = ReturnType<typeof setTimeout>;
const scrollingTimer: MutableRefObject<
SetTimeoutReturnType | undefined
> = useRef();
const runCallback = () => {
if (shouldFetch) {
const scrollTop = Math.max(
window.pageYOffset,
document.documentElement.scrollTop,
document.body.scrollTop
);
if (
window.innerHeight + scrollTop >=
document.documentElement.offsetHeight - BUFFER_HEIGHT
) {
callback();
}
}
};
const debouncedCallback = debounce(runCallback, 50);
useEffect(() => {
runCallback();
});
useEffect(() => {
const onScroll = () => {
if (scrollingTimer.current !== undefined) {
clearTimeout(scrollingTimer.current);
}
if (!isScrolling) {
setScrolling(true);
}
scrollingTimer.current = setTimeout(() => {
setScrolling(false);
}, IS_SCROLLING_CHECK_THROTTLE);
debouncedCallback();
};
const onResize = () => {
debouncedCallback();
};
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize, { passive: true });
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
if (scrollingTimer.current !== undefined) {
clearTimeout(scrollingTimer.current);
}
};
});
return isScrolling;
};
export default useVerticalScroll;

@ -1,20 +1,9 @@
import React from 'react';
import type { NextPage } from 'next';
import TitleCard from '../components/TitleCard';
import Discover from '../components/Discover';
const Index: NextPage = () => {
return (
<>
<TitleCard
image="image.tmdb.org/t/p/w600_and_h900_bestv2/iZf0KyrE25z1sage4SYFLCCrMi9.jpg"
year="2019"
summary="Greatest movie ever"
title="1918"
userScore={98}
status="Not Requested"
/>
</>
);
return <Discover />;
};
export default Index;

@ -2,6 +2,10 @@
@tailwind components;
@tailwind utilities;
body {
@apply bg-cool-gray-900;
}
.plex-button {
@apply w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 transition ease-in-out duration-150 text-center;
background-color: #cc7b19;

@ -1520,6 +1520,11 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd"
integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==
"@types/lodash@^4.14.161":
version "4.14.161"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.161.tgz#a21ca0777dabc6e4f44f3d07f37b765f54188b18"
integrity sha512-EP6O3Jkr7bXvZZSZYlsgt5DIjiGr0dXP1/jVEwVLTFgg0d+3lWVQkRavYVQszV7dYUwvg0B8R0MBDpcmXg7XIA==
"@types/mime@*":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"

Loading…
Cancel
Save