diff --git a/components/Upload/Dragzone.tsx b/components/Upload/Dragzone.tsx new file mode 100644 index 0000000..56feb31 --- /dev/null +++ b/components/Upload/Dragzone.tsx @@ -0,0 +1,61 @@ +import { useDropzone } from "react-dropzone"; +import * as React from "react"; +import { Box, CircularProgress, Stack, Typography } from "@mui/material"; + +type DropzoneProps = { + file?: File; + onFile: (file: File) => void; + accept: string; + loading: boolean; +}; + +export function Dropzone(props: DropzoneProps) { + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop: (acceptedFiles) => props.onFile(acceptedFiles[0]), + }); + const ref = React.useRef(null); + + return ( +
{ + ref.current?.click(); + }} + > + + + + {props.file ? props.file.name : "Drag file here or click to upload"} + + + {props.loading && ( + + + + )} + + +
+ ); +} diff --git a/components/Upload/ProgressBar.tsx b/components/Upload/ProgressBar.tsx new file mode 100644 index 0000000..fbdd59e --- /dev/null +++ b/components/Upload/ProgressBar.tsx @@ -0,0 +1,46 @@ +// @flow +import * as React from "react"; +import { styled } from "@mui/material/styles"; +import VideoLibraryIcon from "@mui/icons-material/VideoLibrary"; +import { Box, LinearProgress, Stack, Typography } from "@mui/material"; +import prettyBytes from "pretty-bytes"; + +type Props = { + progress: number; + title: string; + currentUploadBytes: number; + totalUploadBytes: number; +}; + +export const StyledProgressBar = styled(LinearProgress)(({ theme }) => ({ + height: 100, + color: "#1F94FF", + backgroundColor: "#EFF1F3", + borderRadius: 16, +})); + +export function ProgressBar(props: Props) { + return ( + + + + + + + {props.title} + + + {props.progress}% ({prettyBytes(props.currentUploadBytes)} / + {prettyBytes(props.totalUploadBytes)}) + + + + + ); +} diff --git a/package.json b/package.json index 4df9a01..6422cce 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,10 @@ "next": "13.0.6", "next-auth": "^4.18.0", "notistack": "^2.0.8", + "pretty-bytes": "^6.0.0", "react": "18.2.0", "react-dom": "18.2.0", + "react-dropzone": "^14.2.3", "react-qr-code": "^2.0.8", "typescript": "4.9.3", "editor": "workspace:editor" diff --git a/pages/create.tsx b/pages/create.tsx new file mode 100644 index 0000000..bc7d6ca --- /dev/null +++ b/pages/create.tsx @@ -0,0 +1,344 @@ +// @flow +import * as React from "react"; +import { useCallback, useContext } from "react"; +import { Dropzone } from "../components/Upload/Dragzone"; +import { GetServerSideProps } from "next"; +import { requireAuthentication } from "../src/requireAuthentication"; +import { + UploadContext, + UploadContextProvider, +} from "../src/models/UploadModel"; +import { + Box, + Breadcrumbs, + Button, + Card, + CardContent, + Container, + Divider, + FormControl, + FormControlLabel, + FormLabel, + Link, + Paper, + Radio, + RadioGroup, + Stack, + Step, + StepLabel, + Stepper, + TextField, + Typography, +} from "@mui/material"; +import { useRouter } from "next/router"; +import { GetVideoResponse, VideoService } from "../src/services/VideoService"; +import { useSession } from "next-auth/react"; +import { UIContext } from "../src/models/UIModel"; +import { ProgressBar } from "../components/Upload/ProgressBar"; +import { useFormik } from "formik"; +import { LoadingButton } from "@mui/lab"; +import { Editor } from "editor"; +import Image from "next/image"; + +type Props = { + uploadType: "video" | "audio"; + step: number; + video?: GetVideoResponse; +}; + +function UploadStepper({ step }: { step: number }) { + return ( + + + Pick Video + + + Upload Video + + + Finished + + + ); +} + +export default function Create(props: Props) { + return ( + + + + Creation center + + + Home + + Profile + + {props.step === 1 && } + {props.step === 2 && } + {props.step === 3 && } + + + + ); +} + +interface UploadStepProps { + uploadType: "video" | "audio"; +} + +export function UploadStep(props: UploadStepProps) { + const { setFile, file, upload, setPreSignedUrl } = useContext(UploadContext); + const [loading, setLoading] = React.useState(false); + const { notifyError } = useContext(UIContext); + const router = useRouter(); + const session = useSession(); + + const createVideo = useCallback( + async (file: File) => { + if (!session) { + return; + } + setLoading(true); + try { + setFile(file); + const video = await VideoService.createVideo( + (session.data as any).accessToken, + { + fileName: file.name, + title: "", + description: "", + } + ); + setPreSignedUrl(video.preSignedURL); + upload(video.preSignedURL, file); + await router.push(`/create?video=${video.video.id}&step=2`); + } catch (e: any) { + notifyError(e); + } + setLoading(false); + }, + [session] + ); + + return ( + + + + + {["video", "audio"].map((uploadType) => ( + + ))} + + + { + await createVideo(file); + }} + accept={"video/*"} + file={file} + loading={loading} + /> + + + + + + TIPS: + + + You can upload a video by dragging and dropping it into the box + + + + + + ); +} + +interface CreateVideoStep { + video: GetVideoResponse; +} +function CreateVideoStep(props: CreateVideoStep) { + const { uploadProgress, totalUploadBytes, currentUploadBytes } = + useContext(UploadContext); + const [isForSale, setIsForSale] = React.useState(false); + const session = useSession(); + const { notifyError } = useContext(UIContext); + const router = useRouter(); + + const formik = useFormik({ + initialValues: { + title: props.video.title, + description: props.video.description, + SalesInfo: props.video.SalesInfo, + }, + onSubmit: async (values) => { + try { + console.log("values", values); + await VideoService.updateVideo( + (session.data! as any).accessToken, + props.video.id, + { + ...values, + SalesInfo: isForSale ? values.SalesInfo : undefined, + } + ); + router.push(`/create?video=${props.video.id}&step=3`); + } catch (e: any) { + notifyError(e); + } + }, + }); + + return ( + <> + + + + + + +
e.preventDefault()}> + + + Description + + { + formik.setFieldValue("description", v); + }} + /> + + + + Is this video for sale or is it free to watch? + + { + const forSale = value === "true"; + if (!forSale) { + formik.setFieldValue("SalesInfo", undefined); + } + setIsForSale(forSale); + }} + > + } + label={"Yes"} + value={"true"} + /> + } + label={"No"} + value={"false"} + /> + + + {isForSale && ( + { + formik.setFieldValue("SalesInfo", { + price: + e.target.value.length > 0 + ? parseFloat(e.target.value) + : 0, + }); + }} + /> + )} + + + + + Publish + + + +
+
+
+
+ + ); +} + +function FinishStep() { + const router = useRouter(); + + return ( + + + + + {"Finish"} + Your video has been published! + + + + + ); +} + +export const getServerSideProps: GetServerSideProps = async (context) => + requireAuthentication(context, async (accessToken, user) => { + const uploadType = context.query.uploadType ?? "video"; + const step = parseInt((context.query.step as any) ?? "1"); + const videoId = (context.query.video as any) ?? null; + let video: GetVideoResponse | null = null; + + if (videoId) { + video = await VideoService.getVideo(videoId); + } + + return { + props: { + uploadType: uploadType, + step: step, + video: video, + }, + }; + }); diff --git a/pages/user/profile.tsx b/pages/user/profile.tsx index 58ceaf0..011708b 100644 --- a/pages/user/profile.tsx +++ b/pages/user/profile.tsx @@ -88,16 +88,15 @@ export default function Index(props: Props) { setIsUploading(true); try { - const { url, key, previewUrl } = - await AvatarService.createPreSignedAvatarUploadUrl( - (session.data as any).accessToken - ); + const signedUrl = await AvatarService.createPreSignedAvatarUploadUrl( + (session.data as any).accessToken + ); const file = e.target.files[0]; - await StorageService.uploadUsingPreSignedUrl(url, file); + await StorageService.uploadUsingPreSignedUrl(signedUrl, file); notify("Avatar uploaded successfully", "success"); - setAvatar(previewUrl); - await formik.setFieldValue("avatar", key); + setAvatar(signedUrl.previewUrl); + await formik.setFieldValue("avatar", signedUrl.key); } catch (e) { notify(`${e}`, "error"); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ada0cc2..cf50610 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,10 @@ importers: notistack: ^2.0.8 postcss: ^8.4.19 prettier: ^2.8.0 + pretty-bytes: ^6.0.0 react: 18.2.0 react-dom: 18.2.0 + react-dropzone: ^14.2.3 react-qr-code: ^2.0.8 tailwindcss: ^3.2.4 typescript: 4.9.3 @@ -55,8 +57,10 @@ importers: next: 13.0.6_672uxklweod7ene3nqtsh262ca next-auth: 4.18.0_6jx7hpii6hgsrmhxgqrmo3277u notistack: 2.0.8_4xnbwop7ylm5pogdusyqfiqcyu + pretty-bytes: 6.0.0 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 + react-dropzone: 14.2.3_react@18.2.0 react-qr-code: 2.0.8_react@18.2.0 typescript: 4.9.3 devDependencies: @@ -1390,6 +1394,11 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false + /attr-accept/2.2.2: + resolution: {integrity: sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==} + engines: {node: '>=4'} + dev: false + /autoprefixer/10.4.13_postcss@8.4.19: resolution: {integrity: sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==} engines: {node: ^10 || ^12 || >=14} @@ -2114,6 +2123,13 @@ packages: flat-cache: 3.0.4 dev: false + /file-selector/0.6.0: + resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} + engines: {node: '>= 12'} + dependencies: + tslib: 2.4.1 + dev: false + /fill-range/7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} @@ -3118,6 +3134,11 @@ packages: hasBin: true dev: true + /pretty-bytes/6.0.0: + resolution: {integrity: sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg==} + engines: {node: ^14.13.1 || >=16.0.0} + dev: false + /pretty-format/3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} dev: false @@ -3166,6 +3187,18 @@ packages: scheduler: 0.23.0 dev: false + /react-dropzone/14.2.3_react@18.2.0: + resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + dependencies: + attr-accept: 2.2.2 + file-selector: 0.6.0 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-error-boundary/3.1.4_react@18.2.0: resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} engines: {node: '>=10', npm: '>=6'} diff --git a/public/images/finish.webp b/public/images/finish.webp new file mode 100644 index 0000000..2145605 Binary files /dev/null and b/public/images/finish.webp differ diff --git a/src/models/UploadModel.tsx b/src/models/UploadModel.tsx new file mode 100644 index 0000000..c179d4a --- /dev/null +++ b/src/models/UploadModel.tsx @@ -0,0 +1,56 @@ +import { createContext, useCallback, useState } from "react"; +import { SignedUrl, StorageService } from "../services/StorageService"; +import { Sign } from "crypto"; +import { GetVideoResponse } from "../services/VideoService"; + +export interface UploadModelInterface { + file: File | undefined; + setFile: (file: File) => void; + preSignedUrl: SignedUrl | undefined; + setPreSignedUrl: (url: SignedUrl) => void; + upload: (url: SignedUrl, file: File) => void; + uploadProgress: number; + currentUploadBytes: number; + totalUploadBytes: number; +} + +export const UploadContext = createContext( + {} as UploadModelInterface +); + +export function UploadContextProvider(props: any) { + const [file, setFile] = useState(); + const [preSignedUrl, setPreSignedUrl] = useState(); + const [uploadProgress, setUploadProgress] = useState(0); + const [currentUploadBytes, setCurrentUploadBytes] = useState(0); + const [totalUploadBytes, setTotalUploadBytes] = useState(0); + + const upload = useCallback(async (url: SignedUrl, file: File) => { + await StorageService.uploadUsingPreSignedUrl( + url, + file, + (progress, current, total) => { + setUploadProgress(progress); + setCurrentUploadBytes(current); + setTotalUploadBytes(total); + } + ); + }, []); + + const value: UploadModelInterface = { + file, + setFile, + upload, + uploadProgress, + currentUploadBytes, + totalUploadBytes, + setPreSignedUrl, + preSignedUrl, + }; + + return ( + + {props.children} + + ); +} diff --git a/src/services/StorageService.ts b/src/services/StorageService.ts index adc29c0..f5e6fac 100644 --- a/src/services/StorageService.ts +++ b/src/services/StorageService.ts @@ -1,17 +1,38 @@ import axios from "axios"; +export interface SignedUrl { + url: string; + key: string; + previewUrl: string; +} + +type OnProgress = (progress: number, current: number, total: number) => void; + export class StorageService { /** * Upload a file to S3 * @param url Pre-signed URL * @param file File + * @param onProgress On progress callback */ - static uploadUsingPreSignedUrl(url: string, file: File) { - console.log(file); - return axios.put(url, file, { + static uploadUsingPreSignedUrl( + url: SignedUrl, + file: File, + onProgress?: OnProgress + ) { + return axios.put(url.url, file, { headers: { "Content-Type": file.type, }, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + onProgress( + progressEvent.loaded / progressEvent.total, + progressEvent.loaded, + progressEvent.total + ); + } + }, }); } } diff --git a/src/services/VideoService.ts b/src/services/VideoService.ts new file mode 100644 index 0000000..116036b --- /dev/null +++ b/src/services/VideoService.ts @@ -0,0 +1,80 @@ +import axios from "axios"; +import { SignedUrl } from "./StorageService"; + +export interface CreateVideoDto { + title: string; + fileName: string; + description: string; +} + +export interface CreateVideoResponse { + video: { + id: string; + title: string; + fileName: string; + }; + preSignedURL: SignedUrl; +} +export interface SalesInfo { + id: string; + price: number; + tokenId?: string; +} + +export interface GetVideoResponse { + id: string; + createdAt: string; + updatedAt: string; + title: string; + fileName: string; + description: string; + thumbnail: string; + views: number; + likes: number; + dislikes: number; + userId: string; + playlistId: string; + status: string; + SalesInfo: SalesInfo; +} + +export interface UpdateVideoDto { + title?: string; + description?: string; + SalesInfo?: SalesInfo; +} + +export class VideoService { + static async createVideo( + accessToken: string, + video: CreateVideoDto + ): Promise { + const url = process.env.NEXT_PUBLIC_API_ENDPOINT + "/video"; + const newVideo = await axios.post(url, video, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return newVideo.data; + } + + static async getVideo(videoId: string): Promise { + const url = process.env.NEXT_PUBLIC_API_ENDPOINT + `/video/${videoId}`; + const video = await axios.get(url, {}); + return video.data; + } + + static async updateVideo( + accessToken: string, + videoId: string, + data: UpdateVideoDto + ): Promise { + const url = process.env.NEXT_PUBLIC_API_ENDPOINT + `/video/${videoId}`; + const video = await axios.patch(url, data, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return video.data; + } +} diff --git a/styles/globals.css b/styles/globals.css index c08603c..cce1753 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -11,3 +11,10 @@ a { a:visited { color: inherit; } + +.leftBlueRectangle { + width: 6px; + height: 26px; + background: #1F94FF; + border-radius: 4px; +}