diff --git a/examples/with-apivideo-upload/.env.local.example b/examples/with-apivideo-upload/.env.local.example new file mode 100644 index 0000000000..2387a6f9e2 --- /dev/null +++ b/examples/with-apivideo-upload/.env.local.example @@ -0,0 +1 @@ +API_KEY= \ No newline at end of file diff --git a/examples/with-apivideo-upload/.gitignore b/examples/with-apivideo-upload/.gitignore new file mode 100644 index 0000000000..737d872109 --- /dev/null +++ b/examples/with-apivideo-upload/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo diff --git a/examples/with-apivideo-upload/README.md b/examples/with-apivideo-upload/README.md new file mode 100644 index 0000000000..ce6f6b723e --- /dev/null +++ b/examples/with-apivideo-upload/README.md @@ -0,0 +1,47 @@ +# api.video video uploader + +This video uploader and playback app is built with Next.js and api.video, the video first API. + +## Demo + +[https://apivideo-uploader.vercel.app/](https://apivideo-uploader.vercel.app/) + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-apivideo-upload&project-name=with-apivideo-upload&repository-name=with-apivideo-upload) + +## How to use + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npx create-next-app --example with-apivideo-upload with-apivideo-upload-app +# or +yarn create next-app --example with-apivideo-upload with-apivideo-upload-app +# or +pnpm create next-app -- --example with-apivideo-upload with-apivideo-upload-app +``` + +Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). + +## Getting started + +### 1. Create an api.video free account + +Go to [dashboard.api.video](https://dashboard.api.video/), log in or create a free account. +You can choose to stay in sandbox and have watermark over your videos, or enter in [production mode](https://api.video/pricing) and take advantage of all the features without limitations. + +### 2. Get you API key + +Once in the dashboard, find your API keys directly in the `/overview` or navigate to `/apikeys` with the "API Keys" button in the side navigation. +Copy your API key and paste it in `.env.development` as value for `API_KEY`. +You can now try the application locally by running `npm run dev` from the root directory. + +### 3. Deployment + +First, push your app to GitHub/GitLab or Bitbucket +The, go to [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) and import your new repository. +Add an environment variable with name `API_KEY` and your API key for value. +Click on deploy 🎉 diff --git a/examples/with-apivideo-upload/components/Card/index.tsx b/examples/with-apivideo-upload/components/Card/index.tsx new file mode 100644 index 0000000000..287da46ff8 --- /dev/null +++ b/examples/with-apivideo-upload/components/Card/index.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import styled from 'styled-components' +import Image from 'next/image' + +interface ICardProps { + content: string + url: string + method: 'get' | 'post' +} + +const Card: React.FC = ({ content, url, method }): JSX.Element => ( + + {method.toUpperCase()} + {content} + + Sketch arrow +

Try it out with our API!

+
+
+) + +export default Card + +const Container = styled.a` + border: 1px solid rgb(215, 219, 236); + border-radius: 0.25rem; + background-color: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0.5rem; + font-size: 0.5rem; + position: relative; + box-shadow: rgb(0 0 0 / 5%) 0px 2px 4px; +` + +const Method = styled.div<{ $method: 'get' | 'post' }>` + color: #ffffff; + background-color: ${(p) => (p.$method === 'get' ? 'green' : 'blue')}; + padding: 0.3rem; + border-radius: 2px; + font-weight: 500; +` + +const Content = styled.p` + letter-spacing: 0.05rem; +` + +const ImageContainer = styled.div` + position: absolute; + bottom: -25px; + right: -105px; + display: flex; + gap: 5px; + align-items: flex-end; + p { + margin-bottom: -3px; + font-size: 0.5rem; + } +` diff --git a/examples/with-apivideo-upload/components/Loader/index.tsx b/examples/with-apivideo-upload/components/Loader/index.tsx new file mode 100644 index 0000000000..0b0bd14a8f --- /dev/null +++ b/examples/with-apivideo-upload/components/Loader/index.tsx @@ -0,0 +1,28 @@ +import Image from 'next/image' +import React from 'react' +import styled, { keyframes } from 'styled-components' + +interface ILoaderProps { + done: boolean +} +const Loader: React.FC = ({ done }): JSX.Element => + done ? : + +export default Loader + +const spin = keyframes` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +` +const Spinner = styled.div` + border: 3px solid #f3f3f3; + border-top: 3px solid rgb(235, 137, 82); + border-radius: 50%; + width: 25px; + height: 25px; + animation: ${spin} 1s linear infinite; +` diff --git a/examples/with-apivideo-upload/components/Status/index.tsx b/examples/with-apivideo-upload/components/Status/index.tsx new file mode 100644 index 0000000000..90d494863c --- /dev/null +++ b/examples/with-apivideo-upload/components/Status/index.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import styled from 'styled-components' +import Loader from '../Loader' + +interface IStatusProps { + done: boolean + title: string +} +const Status: React.FC = ({ done, title }): JSX.Element => ( + +

{title}

+ +
+) + +export default Status + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; +` diff --git a/examples/with-apivideo-upload/next-env.d.ts b/examples/with-apivideo-upload/next-env.d.ts new file mode 100644 index 0000000000..4f11a03dc6 --- /dev/null +++ b/examples/with-apivideo-upload/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/examples/with-apivideo-upload/next.config.js b/examples/with-apivideo-upload/next.config.js new file mode 100644 index 0000000000..0c0fded4f0 --- /dev/null +++ b/examples/with-apivideo-upload/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: false, + compiler: { + styledComponents: true, + }, +} + +module.exports = nextConfig diff --git a/examples/with-apivideo-upload/package.json b/examples/with-apivideo-upload/package.json new file mode 100644 index 0000000000..7c9be33c36 --- /dev/null +++ b/examples/with-apivideo-upload/package.json @@ -0,0 +1,25 @@ +{ + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@api.video/nodejs-client": "2.2.5", + "@api.video/player-sdk": "1.2.13", + "@api.video/video-uploader": "1.0.4", + "@types/styled-components": "5.1.24", + "next": "latest", + "react": "18.1.0", + "react-dom": "18.1.0", + "styled-components": "5.3.5", + "swr": "1.2.2" + }, + "devDependencies": { + "@types/node": "17.0.23", + "@types/react": "17.0.43", + "@types/react-dom": "17.0.14", + "typescript": "4.6.3" + } +} diff --git a/examples/with-apivideo-upload/pages/[videoId].tsx b/examples/with-apivideo-upload/pages/[videoId].tsx new file mode 100644 index 0000000000..66607b0e3f --- /dev/null +++ b/examples/with-apivideo-upload/pages/[videoId].tsx @@ -0,0 +1,155 @@ +import { PlayerSdk, PlayerTheme } from '@api.video/player-sdk' +import { GetServerSideProps, NextPage } from 'next' +import Head from 'next/head' +import Image from 'next/image' +import { useRouter } from 'next/router' +import React, { ChangeEvent, useEffect, useState } from 'react' +import { + Button, + Footer, + GlobalContainer, + Header, + InputsContainer, + PlayerSdkContainer, + Text, + TextsContainer, +} from '../style/common' + +interface IVideoViewProps { + children: React.ReactNode + videoId: string + width: string + height: string +} +const VideoView: NextPage = ({ + videoId, + width, + height, +}): JSX.Element => { + const [player, setPlayer] = useState(undefined) + const [playerSettings, setPlayerSettings] = useState({ + link: 'rgb(235, 137, 82)', + linkHover: 'rgb(240, 95, 12)', + }) + const [hideControls, setHideControls] = useState(false) + const router = useRouter() + + useEffect(() => { + const player = new PlayerSdk('#player', { + id: videoId, + }) + player.setTheme({ + link: 'rgb(235, 137, 82)', + linkHover: 'rgb(240, 95, 12)', + }) + setPlayer(player) + }, [videoId]) + useEffect(() => { + player && player?.loadConfig({ id: videoId, hideControls: hideControls }) + }, [hideControls, player, videoId]) + + const handleChangeSetting = ( + e: ChangeEvent, + prop: string + ) => { + const newSettings = { ...playerSettings, [prop]: e.currentTarget.value } + setPlayerSettings(newSettings) + player?.setTheme(newSettings) + } + + return ( + + + Video view + + + +
+ Already there 🎉 +
+ +
+ + + This player is generated by the{' '} + + api.video's Player SDK + + .
+ It provides multiple properties to customize your video player. +
+ Try 3 of them just bellow 👇 +
+ + +
+ + handleChangeSetting(e, 'link')} + /> +
+
+ + handleChangeSetting(e, 'linkHover')} + /> +
+
+ { + setHideControls(e.currentTarget.checked) + }} + /> + +
+
+ + + +
+ + +
+ ) +} + +export default VideoView + +export const getServerSideProps: GetServerSideProps = async (context) => { + const { videoId, w, h } = context.query + return { props: { videoId, width: w ?? null, height: h ?? null } } +} diff --git a/examples/with-apivideo-upload/pages/_app.tsx b/examples/with-apivideo-upload/pages/_app.tsx new file mode 100644 index 0000000000..fb4be69877 --- /dev/null +++ b/examples/with-apivideo-upload/pages/_app.tsx @@ -0,0 +1,7 @@ +import '../style/index.css' + +function MyApp({ Component, pageProps }: any) { + return +} + +export default MyApp diff --git a/examples/with-apivideo-upload/pages/api/[videoId].ts b/examples/with-apivideo-upload/pages/api/[videoId].ts new file mode 100644 index 0000000000..683fec00e0 --- /dev/null +++ b/examples/with-apivideo-upload/pages/api/[videoId].ts @@ -0,0 +1,15 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import ApiVideoClient from '@api.video/nodejs-client' + +const getVideoStatus = async (req: NextApiRequest, res: NextApiResponse) => { + try { + const { videoId } = req.query + const client = new ApiVideoClient({ apiKey: process.env.API_KEY }) + const status = await client.videos.getStatus(videoId as string) + res.status(200).json({ status }) + } catch (error) { + res.status(400).end() + } +} + +export default getVideoStatus diff --git a/examples/with-apivideo-upload/pages/api/uploadToken.ts b/examples/with-apivideo-upload/pages/api/uploadToken.ts new file mode 100644 index 0000000000..81cde847d9 --- /dev/null +++ b/examples/with-apivideo-upload/pages/api/uploadToken.ts @@ -0,0 +1,14 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import ApiVideoClient from '@api.video/nodejs-client' + +const getUploadToken = async (req: NextApiRequest, res: NextApiResponse) => { + try { + const client = new ApiVideoClient({ apiKey: process.env.API_KEY }) + const uploadToken = await client.uploadTokens.createToken() + res.status(200).json(uploadToken) + } catch (error) { + res.status(400).end() + } +} + +export default getUploadToken diff --git a/examples/with-apivideo-upload/pages/index.tsx b/examples/with-apivideo-upload/pages/index.tsx new file mode 100644 index 0000000000..24ec5d4edf --- /dev/null +++ b/examples/with-apivideo-upload/pages/index.tsx @@ -0,0 +1,234 @@ +import type { NextPage } from 'next' +import Head from 'next/head' +import Image from 'next/image' +import React, { ChangeEvent, useEffect, useRef, useState } from 'react' +import Card from '../components/Card' +import { VideoUploader, VideoUploadResponse } from '@api.video/video-uploader' +import Status from '../components/Status' +import { useRouter } from 'next/router' +import { + Button, + Footer, + GlobalContainer, + Header, + StatusContainer, + Text, + TextsContainer, +} from '../style/common' +import useSWR from 'swr' + +const fetcher = async (url: string): Promise => { + return fetch(url).then((res) => res.json()) +} + +const Home: NextPage = () => { + const [uploadProgress, setUploadProgress] = useState( + undefined + ) + const [video, setVideo] = useState(undefined) + const [ready, setReady] = useState(false) + const [status, setStatus] = useState<{ ingested: boolean; encoded: boolean }>( + { ingested: false, encoded: false } + ) + const [interId, setInterId] = useState(undefined) + const [size, setSize] = useState< + { width: number; height: number } | undefined + >(undefined) + const inputRef = useRef(null) + const router = useRouter() + + const { data: uploadToken } = useSWR<{ token: string }>( + '/api/uploadToken', + fetcher + ) + + useEffect(() => { + if (video) { + const intervalId = window.setInterval(() => { + fetchVideoStatus(video.videoId) + }, 1000) + setInterId(intervalId) + } + }, [video, ready]) + useEffect(() => { + ready && window.clearInterval(interId) + }, [interId, ready]) + + const handleSelectFile = async ( + e: ChangeEvent + ): Promise => { + e.preventDefault() + clearState() + if (!e.target.files || !uploadToken) return + const file = e.target.files[0] + const uploader = new VideoUploader({ + file, + uploadToken: uploadToken.token, + }) + uploader.onProgress((e) => + setUploadProgress(Math.round((e.uploadedBytes * 100) / e.totalBytes)) + ) + const video = await uploader.upload() + setVideo(video) + } + + const fetchVideoStatus = async (videoId: string): Promise => { + const { status } = await fetcher(`/api/${videoId}`) + const { encoding, ingest } = status + setStatus({ + ingested: ingest.status === 'uploaded', + encoded: encoding.playable, + }) + if (ingest.status === 'uploaded' && encoding.playable) { + setSize({ + width: encoding.metadata.width, + height: encoding.metadata.height, + }) + setReady(true) + } + } + + const handleNavigate = (): void => { + if (!video) return + router.push(`/${video.videoId}?w=${size?.width}&h=${size?.height}`) + } + + const clearState = (): void => { + setReady(false) + setStatus({ ingested: false, encoded: false }) + setVideo(undefined) + setUploadProgress(undefined) + setSize(undefined) + } + + return ( + + + Video Uploader + + + + +
+ api.video uploader 🚀 +
+ +
+ + + Hey fellow dev! 👋
+ Welcome to this basic example of video uploader provided by{' '} + + api.video + {' '} + and powered by{' '} + + Vercel & Next.js + + . +
+ + api.video provides APIs and clients to handle all your video needs. +
+ This app is built with the{' '} + + api.video Node.js client + {' '} + and the{' '} + + Typescript uploader + + . +
+ + You can{' '} + + check the source code on GitHub + + . + + + Please add a video to upload and let the power of the API do the + rest 🎩 + +
+ + {!uploadProgress ? ( + <> + + + + ) : ( + <> + + = 100} /> + + + + + + + + )} + + {ready && video && ( + + )} +
+ + +
+ ) +} + +export default Home diff --git a/examples/with-apivideo-upload/public/arrow.png b/examples/with-apivideo-upload/public/arrow.png new file mode 100644 index 0000000000..6d20faac99 Binary files /dev/null and b/examples/with-apivideo-upload/public/arrow.png differ diff --git a/examples/with-apivideo-upload/public/check.png b/examples/with-apivideo-upload/public/check.png new file mode 100644 index 0000000000..9659410054 Binary files /dev/null and b/examples/with-apivideo-upload/public/check.png differ diff --git a/examples/with-apivideo-upload/public/favicon.ico b/examples/with-apivideo-upload/public/favicon.ico new file mode 100644 index 0000000000..daa1e7d0ff Binary files /dev/null and b/examples/with-apivideo-upload/public/favicon.ico differ diff --git a/examples/with-apivideo-upload/public/vercel.svg b/examples/with-apivideo-upload/public/vercel.svg new file mode 100644 index 0000000000..fbf0e25a65 --- /dev/null +++ b/examples/with-apivideo-upload/public/vercel.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/examples/with-apivideo-upload/style/common.ts b/examples/with-apivideo-upload/style/common.ts new file mode 100644 index 0000000000..17b891ab8f --- /dev/null +++ b/examples/with-apivideo-upload/style/common.ts @@ -0,0 +1,124 @@ +import styled from 'styled-components' + +export const GlobalContainer = styled.div` + box-sizing: border-box; + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + width: 100vw; + gap: 20px; + main { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + } +` + +export const Header = styled.header` + font-size: 2.5rem; + margin-top: 2rem; + span { + font-weight: 700; + background: -webkit-linear-gradient( + 45deg, + rgb(250, 91, 48) 0%, + rgb(128, 54, 255) 26.88%, + rgb(213, 63, 255) 50.44%, + rgb(235, 137, 82) 73.83%, + rgb(247, 181, 0) 100% + ); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + } +` + +export const TextsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 20px; + padding: 3rem 5rem; + box-shadow: rgb(0 0 0 / 10%) 0px 2px 4px; + border-radius: 5px; +` + +export const Text = styled.p` + text-align: center; + font-size: 1.1rem; + letter-spacing: 0.03rem; + a { + font-weight: 700; + } +` + +export const Button = styled.button<{ $upload?: boolean }>` + background: ${(p) => + p.$upload + ? '-webkit-linear-gradient(45deg, rgb(250, 91, 48) 0%, rgb(235, 137, 82) 50%, rgb(247, 181, 0) 100%)' + : '-webkit-linear-gradient(45deg, rgb(247, 181, 0) 0%, rgb(235, 137, 82) 50%, rgb(250, 91, 48) 100%)'}; + border: none; + padding: 0.8rem 1.2rem; + border-radius: 5px; + color: #ffffff; + cursor: pointer; + font-size: 1.2rem; + font-weight: 500; +` + +export const StatusContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 2rem; + span { + width: 35px; + height: 5px; + border-radius: 5px; + background-color: rgb(235, 137, 82); + margin-top: 20px; + } +` + +export const Footer = styled.footer` + margin-top: auto; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 5px; + a:nth-of-type(2) { + font-weight: 600; + font-size: 1.1rem; + } +` + +export const PlayerSdkContainer = styled.div<{ + $width: number + $height: number +}>` + width: ${(p) => (p.$width && p.$width <= 800 ? p.$width : '800')}px; + height: ${(p) => (p.$height && p.$height <= 250 ? p.$height : '250')}px; + iframe { + height: ${(p) => (p.$height <= 250 ? p.$height : '250')}px !important; + } +` + +export const InputsContainer = styled.div` + display: flex; + gap: 20px; + > div { + display: flex; + flex-direction: column; + gap: 5px; + label { + font-size: 0.6rem; + } + } + > div:last-child { + flex-direction: row; + align-items: center; + align-self: flex-end; + } +` diff --git a/examples/with-apivideo-upload/style/index.css b/examples/with-apivideo-upload/style/index.css new file mode 100644 index 0000000000..56826a0da9 --- /dev/null +++ b/examples/with-apivideo-upload/style/index.css @@ -0,0 +1,130 @@ +* { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + vertical-align: baseline; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} +body { + line-height: 1; +} +ol, +ul { + list-style: none; +} +blockquote, +q { + quotes: none; +} +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +a { + text-decoration: none; + color: unset; +} diff --git a/examples/with-apivideo-upload/tsconfig.json b/examples/with-apivideo-upload/tsconfig.json new file mode 100644 index 0000000000..99710e8578 --- /dev/null +++ b/examples/with-apivideo-upload/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}