chore: protect pages with auth

This commit is contained in:
Sean Norwood 2022-04-13 18:08:32 +00:00
parent 6bf77b05aa
commit 85f1a62e84
9 changed files with 193 additions and 165 deletions

View File

@ -1,5 +1,3 @@
const { withPlaiceholder } = require("@plaiceholder/next");
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
images: { images: {
@ -8,4 +6,4 @@ const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
}; };
module.exports = withPlaiceholder(nextConfig); module.exports = nextConfig;

View File

@ -0,0 +1,3 @@
export const BoxedContent: React.FC = ({ children }) => (
<div className="boxed-content">{children}</div>
);

View File

@ -1,18 +1,22 @@
import { useSession } from "next-auth/react";
import NextImage from "next/image"; import NextImage from "next/image";
import { useState } from "react"; import { useState } from "react";
import ReactPlayer from "react-player/file";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import IconClose from "../images/icon-close.svg";
import IconPlay from "../images/icon-play.svg"; import IconPlay from "../images/icon-play.svg";
import { TA_BASE_URL } from "../lib/constants"; import { TA_BASE_URL } from "../lib/constants";
import { getVideos } from "../lib/getVideos"; import { getVideos } from "../lib/getVideos";
import { formatNumbers } from "../lib/utils"; import type { Datum } from "../types/video";
import { Datum, Videos } from "../types/video"; import { VideoPlayer } from "./VideoPlayer";
type ViewStyle = "grid" | "list";
export const VideoList = () => { export const VideoList = () => {
const [selectedVideoUrl, setSelectedVideoUrl] = useState<Datum>(); const [selectedVideoUrl, setSelectedVideoUrl] = useState<Datum>();
const { data: queryData } = useQuery("videos", getVideos); const [viewStyle, setViewStyle] = useState<ViewStyle>("grid");
const { data: videos } = queryData; const { data: session } = useSession();
const { data, error, isLoading } = useQuery("videos", () =>
getVideos(session.ta_token.token)
);
const handleSelectedVideo = (video: Datum) => { const handleSelectedVideo = (video: Datum) => {
setSelectedVideoUrl(video); setSelectedVideoUrl(video);
@ -22,12 +26,16 @@ export const VideoList = () => {
setSelectedVideoUrl(undefined); setSelectedVideoUrl(undefined);
}; };
if (!videos) { const handleSetViewstyle = (selectedViewStyle: ViewStyle) => {
setViewStyle(selectedViewStyle);
};
if (!isLoading && !data?.data) {
return ( return (
<div className="boxed-content"> <div className="boxed-content">
<h2>No videos found...</h2> <h2>No videos found...</h2>
<p> <p>
If you`&apos;`ve already added a channel or playlist, try going to the{" "} If you&apos;ve already added a channel or playlist, try going to the{" "}
<a href="{% url 'downloads">downloads page</a> to start the scan and <a href="{% url 'downloads">downloads page</a> to start the scan and
download tasks. download tasks.
</p> </p>
@ -37,63 +45,10 @@ export const VideoList = () => {
return ( return (
<> <>
{selectedVideoUrl && ( <VideoPlayer
<> handleRemoveVideoPlayer={handleRemoveVideoPlayer}
<div className="player-wrapper"> selectedVideoUrl={selectedVideoUrl}
<div className="video-player"> />
<ReactPlayer
controls={true}
width="100%"
height="100%"
light="false"
playing // TODO: Not currently working
playsinline
url={`${process.env.NEXT_PUBLIC_TUBEARCHIVIST_URL}/media/${selectedVideoUrl.media_url}`}
/>
<div className="player-title boxed-content">
<NextImage
className="close-button"
src={IconClose}
width={30}
height={30}
alt="close-icon"
onClick={handleRemoveVideoPlayer}
title="Close player"
/>
{/* ${watchStatusIndicator}
${castButton}
*/}
<div className="thumb-icon player-stats">
<img src="/img/icon-eye.svg" alt="views icon" />
<span>
{formatNumbers(
selectedVideoUrl.stats.view_count.toString()
)}
</span>
<span>|</span>
<img src="/img/icon-thumb.svg" alt="thumbs-up" />
<span>
{formatNumbers(
selectedVideoUrl.stats.like_count.toString()
)}
</span>
</div>
<div className="player-channel-playlist">
<h3>
<a href="/channel/${channelId}/">
{selectedVideoUrl.channel.channel_name}
</a>
</h3>
{/* ${playlist} */}
</div>
<a href="/video/${videoId}/">
<h2 id="video-title">{selectedVideoUrl.title}</h2>
</a>
</div>
</div>
</div>
</>
)}
<div className="boxed-content"> <div className="boxed-content">
<div className="title-bar"> <div className="title-bar">
@ -151,25 +106,24 @@ export const VideoList = () => {
/> />
<img <img
src="/img/icon-gridview.svg" src="/img/icon-gridview.svg"
onClick={() => console.log("grid view")} onClick={() => handleSetViewstyle("grid")}
data-origin="home"
data-value="grid"
alt="grid view" alt="grid view"
/> />
<img <img
src="/img/icon-listview.svg" src="/img/icon-listview.svg"
onClick={() => console.log("list view")} onClick={() => handleSetViewstyle("list")}
data-origin="home"
data-value="list"
alt="list view" alt="list view"
/> />
</div> </div>
</div> </div>
<div className="video-list list"> <div className={`video-list ${viewStyle}`}>
{videos && {data &&
videos?.map((video) => { data.data?.map((video) => {
return ( return (
<div key={video.youtube_id} className="video-item list"> <div
key={video.youtube_id}
className={`video-item ${viewStyle}`}
>
<a <a
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
onClick={() => handleSelectedVideo(video)} onClick={() => handleSelectedVideo(video)}

View File

@ -0,0 +1,61 @@
import NextImage from "next/image";
import ReactPlayer from "react-player";
import IconClose from "../images/icon-close.svg";
import { formatNumbers } from "../lib/utils";
export const VideoPlayer = ({ selectedVideoUrl, handleRemoveVideoPlayer }) => {
if (!selectedVideoUrl) return;
return (
<>
<div className="player-wrapper">
<div className="video-player">
<ReactPlayer
controls={true}
width="100%"
height="100%"
light="false"
playing // TODO: Not currently working
playsinline
url={`${process.env.NEXT_PUBLIC_TUBEARCHIVIST_URL}/media/${selectedVideoUrl.media_url}`}
/>
<div className="player-title boxed-content">
<NextImage
className="close-button"
src={IconClose}
width={30}
height={30}
alt="close-icon"
onClick={handleRemoveVideoPlayer}
title="Close player"
/>
{/* ${watchStatusIndicator}
${castButton}
*/}
<div className="thumb-icon player-stats">
<img src="/img/icon-eye.svg" alt="views icon" />
<span>
{formatNumbers(selectedVideoUrl.stats.view_count.toString())}
</span>
<span>|</span>
<img src="/img/icon-thumb.svg" alt="thumbs-up" />
<span>
{formatNumbers(selectedVideoUrl.stats.like_count.toString())}
</span>
</div>
<div className="player-channel-playlist">
<h3>
<a href="/channel/${channelId}/">
{selectedVideoUrl.channel.channel_name}
</a>
</h3>
{/* ${playlist} */}
</div>
<a href="/video/${videoId}/">
<h2 id="video-title">{selectedVideoUrl.title}</h2>
</a>
</div>
</div>
</div>
</>
);
};

View File

@ -1,15 +1,19 @@
import { Channel } from "../types/channel"; import { Channel } from "../types/channel";
export const getChannels = async (): Promise<Channel> => { export const getChannels = async (token: string): Promise<Channel> => {
return await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_TUBEARCHIVIST_URL}/api/channel/`, `${process.env.NEXT_PUBLIC_TUBEARCHIVIST_URL}/api/channel/`,
{ {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Token b4d4330462c7fc16c51873e45579b29a1a12fc90`, Authorization: `Token ${token}`,
mode: "no-cors", mode: "no-cors",
}, },
} }
).then((res) => res.json()); );
if (!response.ok) {
throw new Error("Error getting channel information");
}
return response.json();
}; };

View File

@ -1,15 +1,26 @@
import { Videos } from "../types/video"; import { Videos } from "../types/video";
export const getVideos = async (): Promise<Videos> => { export const getVideos = async (token: string): Promise<Videos> => {
return await fetch( if (!token) {
throw new Error("Missing API token in request to get videos");
}
const response = await fetch(
`${process.env.NEXT_PUBLIC_TUBEARCHIVIST_URL}/api/video/`, `${process.env.NEXT_PUBLIC_TUBEARCHIVIST_URL}/api/video/`,
{ {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Token b4d4330462c7fc16c51873e45579b29a1a12fc90`, Authorization: `Token ${token}`,
mode: "no-cors", mode: "no-cors",
}, },
} }
).then((res) => res.json()); );
if (!response.ok) {
throw new Error("Failed to fetch videos");
}
return response.json();
}; };
// b4d4330462c7fc16c51873e45579b29a1a12fc90

View File

@ -1,40 +1,16 @@
import type { AppProps } from "next/app";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import Script, { ScriptProps } from "next/script"; import type { AppProps } from "next/app";
import { useState } from "react";
import { Hydrate, QueryClient, QueryClientProvider } from "react-query"; import { Hydrate, QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools"; import { ReactQueryDevtools } from "react-query/devtools";
import "../styles/globals.css";
import "../styles/dark.css"; // TODO: Setup themeing the React way import "../styles/dark.css"; // TODO: Setup themeing the React way
import { useState } from "react"; import "../styles/globals.css";
// TODO: Do these scripts need to be on every page?
type ClientOnlyScriptProps = {
src: string;
} & ScriptProps;
/**
* This wraps next/script and returns early if `window` is not detected
* due to next using SSR
*/
const ClientOnlyScript = ({ src, ...props }: ClientOnlyScriptProps) => {
if (typeof window === "undefined") {
return;
}
return <Script src={src} {...props} />;
};
function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) { function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
const [queryClient] = useState(() => new QueryClient()); const [queryClient] = useState(() => new QueryClient());
return ( return (
<> <>
{/* <Script
strategy="lazyOnload"
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
/> */}
{/** TODO: Detect casting before loading this? */}
{/* <ClientOnlyScript strategy="lazyOnload" src="/js/cast-videos.js" /> */}
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ReactQueryDevtools /> <ReactQueryDevtools />
<Hydrate state={pageProps.dehydratedState}> <Hydrate state={pageProps.dehydratedState}>

View File

@ -1,16 +1,49 @@
import { GetServerSideProps, NextPage } from "next"; import type { GetServerSideProps, NextPage } from "next";
import { getSession, useSession } from "next-auth/react";
import { useState } from "react";
import { dehydrate, QueryClient, useQuery } from "react-query";
import { CustomHead } from "../components/CustomHead"; import { CustomHead } from "../components/CustomHead";
import { Layout } from "../components/Layout"; import { Layout } from "../components/Layout";
import { TA_BASE_URL } from "../lib/constants"; import { TA_BASE_URL } from "../lib/constants";
import { getChannels } from "../lib/getChannels"; import { getChannels } from "../lib/getChannels";
import { Channel } from "../types/channel";
export const getServerSideProps: GetServerSideProps = async () => { type ViewStyle = "grid" | "list";
const channels = await getChannels();
return { props: { channels } }; export const getServerSideProps: GetServerSideProps = async (context) => {
const queryClient = new QueryClient();
const session = await getSession(context);
if (!session) {
return {
redirect: {
destination: "/auth/login",
permanent: false,
},
};
}
await queryClient.prefetchQuery("channels", () =>
getChannels(session.ta_token.token)
);
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}; };
const Channel: NextPage<{ channels: Channel }> = ({ channels }) => { const Channel: NextPage = () => {
const { data: session } = useSession();
const {
data: { data: channels },
} = useQuery("channels", () => getChannels(session.ta_token.token));
const [viewStyle, setViewStyle] = useState<ViewStyle>("grid");
const handleSetViewstyle = (selectedViewStyle: ViewStyle) => {
setViewStyle(selectedViewStyle);
};
return ( return (
<> <>
<CustomHead title="Channels" /> <CustomHead title="Channels" />
@ -46,7 +79,6 @@ const Channel: NextPage<{ channels: Channel }> = ({ channels }) => {
id="show_subed_only" id="show_subed_only"
onClick={() => console.log("toggleCheckbox(this)")} onClick={() => console.log("toggleCheckbox(this)")}
type="checkbox" type="checkbox"
checked
/> />
{/* {% if not show_subed_only %} */} {/* {% if not show_subed_only %} */}
<label htmlFor="" className="ofbtn"> <label htmlFor="" className="ofbtn">
@ -62,29 +94,28 @@ const Channel: NextPage<{ channels: Channel }> = ({ channels }) => {
<div className="view-icons"> <div className="view-icons">
<img <img
src="/img/icon-gridview.svg" src="/img/icon-gridview.svg"
onClick={() => console.log("changeView(this)")} onClick={() => handleSetViewstyle("grid")}
data-origin="channel"
data-value="grid"
alt="grid view" alt="grid view"
/> />
<img <img
src="/img/icon-listview.svg" src="/img/icon-listview.svg"
onClick={() => console.log("changeView(this)")} onClick={() => handleSetViewstyle("list")}
data-origin="channel"
data-value="list"
alt="list view" alt="list view"
/> />
</div> </div>
</div> </div>
<h2>Total matching channels: {channels?.data?.length} </h2> <h2>Total matching channels: {channels?.length} </h2>
<div className="channel-list list"> <div className={`channel-list ${viewStyle}`}>
{/* {% if results %} {!channels ? (
{% for channel in results %} */} <h2>No channels found...</h2>
{channels && ) : (
channels?.data?.map((channel) => { channels?.map((channel) => {
return ( return (
<div key={channel?.channel_id} className="channel-item list"> <div
<div className="channel-banner list"> key={channel?.channel_id}
className={`channel-item ${viewStyle}`}
>
<div className={`channel-banner ${viewStyle}`}>
<a href="{% url 'channel_id' channel.source.channel_id %}"> <a href="{% url 'channel_id' channel.source.channel_id %}">
<img <img
src={`${TA_BASE_URL}${channel?.channel_banner_url}`} src={`${TA_BASE_URL}${channel?.channel_banner_url}`}
@ -92,7 +123,7 @@ const Channel: NextPage<{ channels: Channel }> = ({ channels }) => {
/> />
</a> </a>
</div> </div>
<div className="info-box info-box-2 list"> <div className={`info-box info-box-2 ${viewStyle}`}>
<div className="info-box-item"> <div className="info-box-item">
<div className="round-img"> <div className="round-img">
<a href="{% url 'channel_id' channel.source.channel_id %}"> <a href="{% url 'channel_id' channel.source.channel_id %}">
@ -144,7 +175,8 @@ const Channel: NextPage<{ channels: Channel }> = ({ channels }) => {
</div> </div>
</div> </div>
); );
})} })
)}
{/* {% endfor %} {/* {% endfor %}
{% else %} */} {% else %} */}
{/* <h2>No channels found...</h2> */} {/* <h2>No channels found...</h2> */}

View File

@ -1,38 +1,23 @@
import type { GetServerSideProps, GetStaticProps, NextPage } from "next"; import type { GetServerSideProps, NextPage } from "next";
import { signIn, signOut, useSession } from "next-auth/react"; import { getSession } from "next-auth/react";
import { dehydrate, QueryClient } from "react-query"; import { dehydrate, QueryClient } from "react-query";
import { CustomHead } from "../components/CustomHead"; import { CustomHead } from "../components/CustomHead";
import { Layout } from "../components/Layout"; import { Layout } from "../components/Layout";
import { VideoList } from "../components/VideoList"; import { VideoList } from "../components/VideoList";
import { getVideos } from "../lib/getVideos"; import { getVideos } from "../lib/getVideos";
import { Videos } from "../types/video"; import type { Videos } from "../types/video";
type HomeProps = { type HomeProps = {
videos: Videos; videos: Videos;
imagePlaceholders?: string[];
};
const SignInOutButton = ({ isSignedIn }: { isSignedIn: boolean }) => {
if (isSignedIn) {
return <button onClick={() => signOut()}>Sign Out</button>;
}
return <button onClick={() => signIn()}>Sign in</button>;
}; };
const Home: NextPage<HomeProps> = () => { const Home: NextPage<HomeProps> = () => {
const { data: session, status } = useSession();
const authData = {
session,
status,
};
return ( return (
<> <>
<CustomHead /> <CustomHead />
<Layout> <Layout>
<VideoList /> <VideoList />
<SignInOutButton isSignedIn={!!session?.user} />
</Layout> </Layout>
</> </>
); );
@ -40,18 +25,22 @@ const Home: NextPage<HomeProps> = () => {
export default Home; export default Home;
// http://localhost:8000/cache/videos/3/37Kn-kIsVu8.jpg export const getServerSideProps: GetServerSideProps = async (context) => {
// export const getServerSideProps: GetServerSideProps = async (ctx) => {
// const videos = await getVideos();
// return { props: { videos } };
// };
export const getStaticProps: GetStaticProps = async () => {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const session = await getSession(context);
await queryClient.prefetchQuery("videos", getVideos); if (!session) {
return {
redirect: {
destination: "/auth/login",
permanent: false,
},
};
}
await queryClient.prefetchQuery("videos", () =>
getVideos(session.ta_token.token)
);
return { return {
props: { props: {