Add example: with-apivideo-upload (#36050)

* feat(app): Landing

* feat(index): Add button to video view

* Remove React stric mode

* feat(index): Handle navigate with query params

* feat(app): Create common style

* feat(app): Video view

* feat(video view): Add footer

* feat(app): Add .env

* feat(index): Hide upload button on progress

* Update pages type

* Change MyApp props type

* feat(index): Add prod domain for fetching

* update(app): CSS

* update(index): Domain name

* feat(index): Remove getSSProps

* Update README

* Update README

* update(status): CSS

* feat(index): Add link to GitHub repo

* Remove static API key from .env

* Lint

* update(packages): next version

* Lint + Prettier

* Remove name & version from package.json

* update: Rename .env.development -> .env.local.example

* update: Remove esLint + updagrade React & ReactDOM version

* Remove yarn.lock changes

Co-authored-by: José Barcelon-Godfrey <jose.barcelon@gmail.com>
This commit is contained in:
Yohann MARTZOLFF 2022-07-06 16:35:49 +02:00 committed by GitHub
parent a37abc62ee
commit 27bfdec1ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 937 additions and 0 deletions

View file

@ -0,0 +1 @@
API_KEY=

View file

@ -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

View file

@ -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 🎉

View file

@ -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<ICardProps> = ({ content, url, method }): JSX.Element => (
<Container target="_blank" href={url}>
<Method $method={method}>{method.toUpperCase()}</Method>
<Content>{content}</Content>
<ImageContainer>
<Image src="/arrow.png" alt="Sketch arrow" width={20} height={20} />
<p>Try it out with our API!</p>
</ImageContainer>
</Container>
)
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;
}
`

View file

@ -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<ILoaderProps> = ({ done }): JSX.Element =>
done ? <Image src="/check.png" width={30} height={30} /> : <Spinner />
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;
`

View file

@ -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<IStatusProps> = ({ done, title }): JSX.Element => (
<Container>
<p>{title}</p>
<Loader done={done} />
</Container>
)
export default Status
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
`

View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
compiler: {
styledComponents: true,
},
}
module.exports = nextConfig

View file

@ -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"
}
}

View file

@ -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<IVideoViewProps> = ({
videoId,
width,
height,
}): JSX.Element => {
const [player, setPlayer] = useState<PlayerSdk | undefined>(undefined)
const [playerSettings, setPlayerSettings] = useState<PlayerTheme>({
link: 'rgb(235, 137, 82)',
linkHover: 'rgb(240, 95, 12)',
})
const [hideControls, setHideControls] = useState<boolean>(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<HTMLInputElement>,
prop: string
) => {
const newSettings = { ...playerSettings, [prop]: e.currentTarget.value }
setPlayerSettings(newSettings)
player?.setTheme(newSettings)
}
return (
<GlobalContainer>
<Head>
<title>Video view</title>
<meta
name="description"
content="Generated by create next app & created by api.video"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<Header>
<span>Already there</span> 🎉
</Header>
<main>
<TextsContainer>
<Text>
This player is generated by the{' '}
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/apivideo/api.video-player-sdk"
>
api.video's Player SDK
</a>
.<br />
It provides multiple properties to customize your video player.
</Text>
<Text>Try 3 of them just bellow 👇</Text>
</TextsContainer>
<InputsContainer>
<div>
<label>Play button color</label>
<input
type="text"
placeholder="Play button color"
value={playerSettings.link}
onChange={(e) => handleChangeSetting(e, 'link')}
/>
</div>
<div>
<label>Buttons hover color</label>
<input
type="text"
placeholder="Buttons hover color"
value={playerSettings.linkHover}
onChange={(e) => handleChangeSetting(e, 'linkHover')}
/>
</div>
<div>
<input
type="checkbox"
checked={hideControls}
onChange={(e) => {
setHideControls(e.currentTarget.checked)
}}
/>
<label>Hide controls</label>
</div>
</InputsContainer>
<PlayerSdkContainer
id="player"
$width={parseInt(width)}
$height={parseInt(height)}
/>
<Button onClick={() => router.push('/')}>Another video?</Button>
</main>
<Footer>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<span>
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
</span>
</a>
<span>and</span>
<a href="https://api.video" target="_blank" rel="noopener noreferrer">
api.video
</a>
</Footer>
</GlobalContainer>
)
}
export default VideoView
export const getServerSideProps: GetServerSideProps = async (context) => {
const { videoId, w, h } = context.query
return { props: { videoId, width: w ?? null, height: h ?? null } }
}

View file

@ -0,0 +1,7 @@
import '../style/index.css'
function MyApp({ Component, pageProps }: any) {
return <Component {...pageProps} />
}
export default MyApp

View file

@ -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

View file

@ -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

View file

@ -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<any> => {
return fetch(url).then((res) => res.json())
}
const Home: NextPage = () => {
const [uploadProgress, setUploadProgress] = useState<number | undefined>(
undefined
)
const [video, setVideo] = useState<VideoUploadResponse | undefined>(undefined)
const [ready, setReady] = useState<boolean>(false)
const [status, setStatus] = useState<{ ingested: boolean; encoded: boolean }>(
{ ingested: false, encoded: false }
)
const [interId, setInterId] = useState<number | undefined>(undefined)
const [size, setSize] = useState<
{ width: number; height: number } | undefined
>(undefined)
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>
): Promise<void> => {
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<void> => {
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 (
<GlobalContainer>
<Head>
<title>Video Uploader</title>
<meta
name="description"
content="Generated by create next app & created by api.video"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<Header>
<span>api.video uploader</span> 🚀
</Header>
<main>
<TextsContainer>
<Text>
Hey fellow dev! 👋 <br />
Welcome to this basic example of video uploader provided by{' '}
<a
href="https://api.video"
target="_blank"
rel="noopener noreferrer"
>
api.video
</a>{' '}
and powered by{' '}
<a
href="https://nextjs.org/"
target="_blank"
rel="noopener noreferrer"
>
Vercel & Next.js
</a>
.
</Text>
<Text>
api.video provides APIs and clients to handle all your video needs.
<br />
This app is built with the{' '}
<a
href="https://github.com/apivideo/api.video-nodejs-client"
target="_blank"
rel="noopener noreferrer"
>
api.video Node.js client
</a>{' '}
and the{' '}
<a
href="https://github.com/apivideo/api.video-typescript-uploader"
target="_blank"
rel="noopener noreferrer"
>
Typescript uploader
</a>
.
</Text>
<Text>
You can{' '}
<a
href="https://github.com/vercel/next.js/tree/canary/examples/with-apivideo-upload"
target="_blank"
rel="noopener noreferrer"
>
check the source code on GitHub
</a>
.
</Text>
<Text>
Please add a video to upload and let the power of the API do the
rest 🎩
</Text>
</TextsContainer>
{!uploadProgress ? (
<>
<Button $upload onClick={() => inputRef.current?.click()}>
Select a file
</Button>
<input
ref={inputRef}
hidden
type="file"
accept="mp4"
onChange={handleSelectFile}
/>
</>
) : (
<>
<StatusContainer>
<Status title="Uploaded" done={uploadProgress >= 100} />
<span />
<Status title="Ingested" done={status.ingested} />
<span />
<Status title="Playable" done={status.encoded} />
</StatusContainer>
<Card
content="https://ws.api.video/videos/{videoId}/source"
url="https://docs.api.video/reference/post_videos-videoid-source"
method="post"
/>
</>
)}
{ready && video && (
<Button onClick={handleNavigate}>Watch it 🍿</Button>
)}
</main>
<Footer>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<span>
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
</span>
</a>
<span>and</span>
<a href="https://api.video" target="_blank" rel="noopener noreferrer">
api.video
</a>
</Footer>
</GlobalContainer>
)
}
export default Home

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,4 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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;
}
`

View file

@ -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;
}

View file

@ -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"]
}