migrate off chakra entirely to tailwind

pull/134/head
Alex Kern 1 year ago
parent e73acc3eda
commit dd25e6f7d8
No known key found for this signature in database
GPG Key ID: EF051FACCACBEE25

5
next-env.d.ts vendored

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

@ -20,50 +20,48 @@
"url": "https://github.com/kern/filepizza/issues" "url": "https://github.com/kern/filepizza/issues"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/react": "^1.3.2", "autoprefixer": "^10.4.20",
"@emotion/react": "^11.1.5", "debug": "^4.3.6",
"@emotion/styled": "^11.1.5", "express": "^4.19.2",
"debug": "^4.2.0", "fp-ts": "^2.16.9",
"express": "^4.12.0", "framer-motion": "^3.10.6",
"fp-ts": "^2.9.3", "immer": "^8.0.4",
"framer-motion": "^3.3.0", "io-ts": "^2.2.21",
"immer": "^8.0.0", "ioredis": "^4.28.5",
"io-ts": "^2.2.13", "next": "^14.2.7",
"ioredis": "^4.17.3", "nodemon": "^1.19.4",
"next": "^9.5.3",
"nodemon": "^1.4.1",
"peer": "^0.5.3", "peer": "^0.5.3",
"peerjs": "^1.3.1", "peerjs": "^1.5.4",
"react": "^16.13.1", "postcss": "^8.4.44",
"react-device-detect": "^1.15.0", "react": "^18.3.1",
"react-dom": "^16.13.1", "react-device-detect": "^1.17.0",
"react-dom": "^18.3.1",
"react-qr": "0.0.2", "react-qr": "0.0.2",
"react-qr-code": "^1.0.5", "react-qr-code": "^1.1.1",
"streamsaver": "^2.0.5", "streamsaver": "^2.0.6",
"styled-components": "^5.2.0", "tailwindcss": "^3.4.10",
"twilio": "^2.9.1", "twilio": "^2.11.1",
"use-http": "^1.0.16", "use-http": "^1.0.28",
"web-streams-polyfill": "^3.0.1", "web-streams-polyfill": "^3.3.3",
"webrtcsupport": "^2.2.0", "webrtcsupport": "^2.2.0",
"xkcd-password": "^1.2.0" "xkcd-password": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/debug": "^4.1.5", "@types/debug": "^4.1.12",
"@types/ioredis": "^4.17.4", "@types/ioredis": "^4.28.10",
"@types/node": "^14.11.1", "@types/node": "^14.18.63",
"@types/react": "^16.9.49", "@types/react": "^16.14.60",
"@types/styled-components": "^5.1.3", "@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/eslint-plugin": "^4.1.1", "@typescript-eslint/parser": "^4.33.0",
"@typescript-eslint/parser": "^4.1.1", "eslint": "^7.32.0",
"eslint": "^7.9.0", "eslint-config-prettier": "^6.15.0",
"eslint-config-prettier": "^6.11.0", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-import": "^2.22.0", "eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-prettier": "^3.1.4", "eslint-plugin-react": "^7.35.1",
"eslint-plugin-react": "^7.20.6", "husky": "^4.3.8",
"husky": "^4.3.0", "lint-staged": "^10.5.4",
"lint-staged": "^10.4.0", "prettier": "^2.8.8",
"prettier": "^2.1.2", "typescript": "^4.9.5"
"typescript": "^4.0.3"
}, },
"husky": { "husky": {
"hooks": { "hooks": {

File diff suppressed because it is too large Load Diff

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import { Button } from '@chakra-ui/react'
type Props = { type Props = {
onClick: React.MouseEventHandler onClick: React.MouseEventHandler
@ -7,9 +6,12 @@ type Props = {
const CancelButton: React.FC<Props> = ({ onClick }: Props) => { const CancelButton: React.FC<Props> = ({ onClick }: Props) => {
return ( return (
<Button onClick={onClick} variant="outline"> <button
onClick={onClick}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Cancel Cancel
</Button> </button>
) )
} }

@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import { Button } from '@chakra-ui/react'
type Props = { type Props = {
onClick?: React.MouseEventHandler onClick?: React.MouseEventHandler
@ -7,9 +6,12 @@ type Props = {
const DownloadButton: React.FC<Props> = ({ onClick }: Props) => { const DownloadButton: React.FC<Props> = ({ onClick }: Props) => {
return ( return (
<Button onClick={onClick} colorScheme="green"> <button
onClick={onClick}
className="px-4 py-2 bg-green-500 text-white font-semibold rounded-md hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
>
Download Download
</Button> </button>
) )
} }

@ -14,7 +14,6 @@ import { createZipStream } from '../zip-stream'
import { DataConnection } from 'peerjs' import { DataConnection } from 'peerjs'
import PasswordField from './PasswordField' import PasswordField from './PasswordField'
import UnlockButton from './UnlockButton' import UnlockButton from './UnlockButton'
import { chakra, Box, Text, VStack } from '@chakra-ui/react'
import Loading from './Loading' import Loading from './Loading'
import UploadFileList from './UploadFileList' import UploadFileList from './UploadFileList'
import DownloadButton from './DownloadButton' import DownloadButton from './DownloadButton'
@ -313,42 +312,42 @@ export default function Downloader({
if (done && filesInfo) { if (done && filesInfo) {
return ( return (
<VStack spacing="20px" w="100%"> <div className="flex flex-col space-y-5 w-full">
<Text textStyle="description"> <p className="text-description">
You downloaded {filesInfo.length} files. You downloaded {filesInfo.length} files.
</Text> </p>
<UploadFileList files={filesInfo} /> <UploadFileList files={filesInfo} />
<Box w="100%"> <div className="w-full">
<ProgressBar value={bytesDownloaded} max={totalSize} /> <ProgressBar value={bytesDownloaded} max={totalSize} />
</Box> </div>
</VStack> </div>
) )
} }
if (downloading && filesInfo) { if (downloading && filesInfo) {
return ( return (
<VStack spacing="20px" w="100%"> <div className="flex flex-col space-y-5 w-full">
<Text textStyle="description"> <p className="text-description">
You are about to start downloading {filesInfo.length} files. You are about to start downloading {filesInfo.length} files.
</Text> </p>
<UploadFileList files={filesInfo} /> <UploadFileList files={filesInfo} />
<Box w="100%"> <div className="w-full">
<ProgressBar value={bytesDownloaded} max={totalSize} /> <ProgressBar value={bytesDownloaded} max={totalSize} />
</Box> </div>
<StopButton onClick={handleStopDownload} isDownloading /> <StopButton onClick={handleStopDownload} isDownloading />
</VStack> </div>
) )
} }
if (open && filesInfo) { if (open && filesInfo) {
return ( return (
<VStack spacing="20px" w="100%"> <div className="flex flex-col space-y-5 w-full">
<Text textStyle="description"> <p className="text-description">
You are about to start downloading {filesInfo.length} files. You are about to start downloading {filesInfo.length} files.
</Text> </p>
<UploadFileList files={filesInfo} /> <UploadFileList files={filesInfo} />
<DownloadButton onClick={handleStartDownload} /> <DownloadButton onClick={handleStartDownload} />
</VStack> </div>
) )
} }
@ -362,19 +361,17 @@ export default function Downloader({
} }
return ( return (
<chakra.form <form
action="#" action="#"
method="post" method="post"
onSubmit={handleSubmitPassword} onSubmit={handleSubmitPassword}
w="100%" className="w-full"
> >
<VStack spacing="20px" w="100%"> <div className="flex flex-col space-y-5 w-full">
{errorMessage ? ( {errorMessage ? (
<Text textStyle="descriptionError">{errorMessage}</Text> <p className="text-description-error">{errorMessage}</p>
) : ( ) : (
<Text textStyle="description"> <p className="text-description">This download requires a password.</p>
This download requires a password.
</Text>
)} )}
<PasswordField <PasswordField
value={password} value={password}
@ -383,7 +380,7 @@ export default function Downloader({
isInvalid={Boolean(errorMessage)} isInvalid={Boolean(errorMessage)}
/> />
<UnlockButton onClick={handleSubmitPassword} /> <UnlockButton onClick={handleSubmitPassword} />
</VStack> </div>
</chakra.form> </form>
) )
} }

@ -1,65 +1,109 @@
import React, { useState, useRef, useCallback } from 'react' import React, { useState, useCallback, useEffect, useRef } from 'react'
import styled from 'styled-components'
import { extractFileList } from '../fs' import { extractFileList } from '../fs'
const Wrapper = styled.div`
display: block;
`
const Overlay = styled.div`
display: block;
`
export default function DropZone({ export default function DropZone({
children,
onDrop, onDrop,
}: { }: {
onDrop: (files: File[]) => void onDrop: (files: File[]) => void
children?: React.ReactNode
}): JSX.Element { }): JSX.Element {
const overlay = useRef() const [isDragging, setIsDragging] = useState(false)
const [focus, setFocus] = useState(false) const [fileCount, setFileCount] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleDragEnter = useCallback(() => { const handleDragEnter = useCallback((e: DragEvent) => {
setFocus(true) e.preventDefault()
setIsDragging(true)
setFileCount(e.dataTransfer?.items.length || 0)
}, []) }, [])
const handleDragLeave = useCallback( const handleDragLeave = useCallback((e: DragEvent) => {
(e: React.DragEvent) => { e.preventDefault()
if (e.target !== overlay.current) {
const currentTarget =
e.currentTarget === window ? window.document : e.currentTarget
if (
e.relatedTarget &&
currentTarget instanceof Node &&
currentTarget.contains(e.relatedTarget as Node)
) {
return return
} }
setFocus(false) setIsDragging(false)
}, }, [])
[overlay.current],
)
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault() e.preventDefault()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy' e.dataTransfer.dropEffect = 'copy'
}
}, []) }, [])
const handleDrop = useCallback( const handleDrop = useCallback(
async (e: React.DragEvent) => { async (e: DragEvent) => {
e.preventDefault() e.preventDefault()
setFocus(false) setIsDragging(false)
if (e.dataTransfer) {
const files = await extractFileList(e) const files = await extractFileList(e)
onDrop(files) onDrop(files)
}
},
[onDrop],
)
const handleClick = useCallback(() => {
fileInputRef.current?.click()
}, [])
const handleFileInputChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files)
onDrop(files)
}
}, },
[onDrop], [onDrop],
) )
useEffect(() => {
window.addEventListener('dragenter', handleDragEnter)
window.addEventListener('dragleave', handleDragLeave)
window.addEventListener('dragover', handleDragOver)
window.addEventListener('drop', handleDrop)
return () => {
window.removeEventListener('dragenter', handleDragEnter)
window.removeEventListener('dragleave', handleDragLeave)
window.removeEventListener('dragover', handleDragOver)
window.removeEventListener('drop', handleDrop)
}
}, [handleDragEnter, handleDragLeave, handleDragOver, handleDrop])
return ( return (
<Wrapper <>
onDragEnter={handleDragEnter} <div
onDragLeave={handleDragLeave} className={`fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center text-2xl text-white transition-opacity duration-300 backdrop-blur-sm z-50 ${
onDragOver={handleDragOver} isDragging ? 'opacity-100 visible' : 'opacity-0 invisible'
onDrop={handleDrop} }`}
>
Drop to select {fileCount} file{fileCount !== 1 ? 's' : ''}
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileInputChange}
multiple
/>
<button
className="inline-block cursor-pointer relative py-3 px-6 text-base font-bold text-gray-700 bg-white border-2 border-gray-700 rounded-lg transition-all duration-300 ease-in-out outline-none hover:shadow-md active:shadow-inner focus:shadow-outline"
onClick={handleClick}
> >
<Overlay ref={overlay} hidden={!focus} /> <span className="text-center text-stone-700">
{children} Drop a file to get started
</Wrapper> </span>
</button>
</>
) )
} }

@ -1,54 +1,56 @@
import { chakra, Text, Link, Button, VStack, HStack } from '@chakra-ui/react'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
const DONATE_HREF = const DONATE_HREF =
'https://commerce.coinbase.com/checkout/247b6ffe-fb4e-47a8-9a76-e6b7ef83ea22' 'https://commerce.coinbase.com/checkout/247b6ffe-fb4e-47a8-9a76-e6b7ef83ea22'
export const Footer: React.FC = () => { const FooterLink: React.FC<{ href: string; children: React.ReactNode }> = ({
href,
children,
}) => (
<a
className="text-stone-600 underline hover:text-stone-800"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
)
export function Footer(): JSX.Element {
const handleDonate = useCallback(() => { const handleDonate = useCallback(() => {
window.location.href = DONATE_HREF window.location.href = DONATE_HREF
}, []) }, [])
return ( return (
<chakra.footer textAlign="center" textStyle="footer" paddingY="10px"> <footer className="text-center py-2.5 pb-4 text-xs border-t border-stone-200 shadow-[0_-1px_2px_rgba(0,0,0,0.04)]">
<VStack spacing="4px"> <div className="flex flex-col items-center space-y-1 px-4 sm:px-6 md:px-8">
<HStack> <div className="flex items-center space-x-2">
<Text> <p className="text-stone-600">
<strong>Like FilePizza?</strong> Support its development!{' '} <strong>Like FilePizza?</strong> Support its development!{' '}
</Text> </p>
<Button size="xs" onClick={handleDonate}> <button
donate className="px-1.5 py-0.5 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors duration-200 font-medium text-[10px]"
</Button> onClick={handleDonate}
</HStack> >
Donate
</button>
</div>
<Text> <p className="text-stone-600">
Cooked up by{' '} Cooked up by <FooterLink href="http://kern.io">Alex Kern</FooterLink>{' '}
<Link textStyle="footerLink" href="http://kern.io" isExternal> &amp; <FooterLink href="http://neeraj.io">Neeraj Baid</FooterLink>{' '}
Alex Kern
</Link>{' '}
&amp;{' '}
<Link textStyle="footerLink" href="http://neeraj.io" isExternal>
Neeraj Baid
</Link>{' '}
while eating <strong>Sliver</strong> @ UC Berkeley &middot;{' '} while eating <strong>Sliver</strong> @ UC Berkeley &middot;{' '}
<Link <FooterLink href="https://github.com/kern/filepizza#faq">
textStyle="footerLink"
href="https://github.com/kern/filepizza#faq"
isExternal
>
FAQ FAQ
</Link>{' '} </FooterLink>{' '}
&middot;{' '} &middot;{' '}
<Link <FooterLink href="https://github.com/kern/filepizza">
textStyle="footerLink"
href="https://github.com/kern/filepizza"
isExternal
>
Fork us Fork us
</Link> </FooterLink>
</Text> </p>
</VStack> </div>
</chakra.footer> </footer>
) )
} }

@ -1,11 +1,10 @@
import React from 'react' import React from 'react'
import { Spinner, Text, VStack } from '@chakra-ui/react'
export default function Loading({ text }: { text: string }): JSX.Element { export default function Loading({ text }: { text: string }): JSX.Element {
return ( return (
<VStack> <div className="flex flex-col items-center">
<Spinner color="blue.500" /> <div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<Text textStyle="descriptionSmall">{text}</Text> <p className="text-sm text-gray-600 mt-2">{text}</p>
</VStack> </div>
) )
} }

@ -1,33 +1,35 @@
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { Input } from '@chakra-ui/react'
interface Props { export function PasswordField({
value: string
onChange: (value: string) => void
isRequired?: boolean
isInvalid?: boolean
}
export const PasswordField: React.FC<Props> = ({
value, value,
onChange, onChange,
isRequired, isRequired,
isInvalid, isInvalid,
}: Props) => { }: {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { value: string
onChange: (v: string) => void
isRequired?: boolean
isInvalid?: boolean
}): JSX.Element {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value) onChange(e.target.value)
}, []) },
[onChange],
)
return ( return (
<Input <input
autoFocus autoFocus
type="password" type="password"
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
isInvalid ? 'border-red-500' : 'border-gray-300'
}`}
placeholder={ placeholder={
isRequired ? 'Enter password...' : 'Add password (optional)...' isRequired ? 'Enter password...' : 'Add password (optional)...'
} }
value={value} value={value}
onChange={handleChange} onChange={handleChange}
isInvalid={isInvalid}
/> />
) )
} }

@ -1,4 +1,4 @@
import { Progress } from '@chakra-ui/react' import React from 'react'
export default function ProgressBar({ export default function ProgressBar({
value, value,
@ -7,13 +7,17 @@ export default function ProgressBar({
value: number value: number
max: number max: number
}): JSX.Element { }): JSX.Element {
const percentage = (value / max) * 100
const isComplete = value === max
return ( return (
<Progress <div className="w-full h-12 bg-gray-200 rounded-md overflow-hidden">
height="48px" <div
colorScheme={value === max ? 'green' : 'blue'} className={`h-full ${
value={value} isComplete ? 'bg-green-500' : 'bg-blue-500'
max={max} } transition-all duration-300 ease-in-out`}
borderRadius="md" style={{ width: `${percentage}%` }}
/> ></div>
</div>
) )
} }

@ -1,11 +1,5 @@
import React from 'react' import React from 'react'
import { Box, Center, Img } from '@chakra-ui/react' import Image from 'next/image'
import { keyframes } from '@emotion/react'
const rotate = keyframes`
from { transform: rotate(0deg) }
to { transform: rotate(360deg) }
`
export default function Spinner({ export default function Spinner({
direction, direction,
@ -16,14 +10,22 @@ export default function Spinner({
}): JSX.Element { }): JSX.Element {
const src = `/images/${direction}.png` const src = `/images/${direction}.png`
return ( return (
<Box pos="relative" w="300px" h="300px"> <div className="relative w-[300px] h-[300px]">
<Img <Image
src="/images/pizza.png" src="/images/pizza.png"
animation={isRotating ? `${rotate} 5s infinite linear` : 'none'} alt="Pizza"
width={300}
height={300}
className={isRotating ? 'animate-spin-slow' : ''}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Image
src={src}
alt={`Arrow pointing ${direction}`}
width={120}
height={120}
/> />
<Center pos="absolute" top="0" left="0" w="100%" h="100%"> </div>
<Img src={src} w="120px" /> </div>
</Center>
</Box>
) )
} }

@ -1,15 +1,17 @@
import React from 'react' import React from 'react'
import { Button } from '@chakra-ui/react'
type Props = { export function StartButton({
onClick,
}: {
onClick: React.MouseEventHandler onClick: React.MouseEventHandler
} }): JSX.Element {
const StartButton: React.FC<Props> = ({ onClick }: Props) => {
return ( return (
<Button onClick={onClick} colorScheme="green"> <button
onClick={onClick}
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors duration-200"
>
Start Start
</Button> </button>
) )
} }

@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import { Button } from '@chakra-ui/react'
type Props = { type Props = {
onClick: React.MouseEventHandler onClick: React.MouseEventHandler
@ -8,9 +7,12 @@ type Props = {
const StopButton: React.FC<Props> = ({ isDownloading, onClick }: Props) => { const StopButton: React.FC<Props> = ({ isDownloading, onClick }: Props) => {
return ( return (
<Button size="xs" colorScheme="orange" variant="ghost" onClick={onClick}> <button
className="px-2 py-1 text-xs text-orange-500 bg-transparent hover:bg-orange-100 rounded transition-colors duration-200"
onClick={onClick}
>
{isDownloading ? 'Stop Download' : 'Stop Upload'} {isDownloading ? 'Stop Download' : 'Stop Upload'}
</Button> </button>
) )
} }

@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import { Button } from '@chakra-ui/react'
type Props = { type Props = {
onClick?: React.MouseEventHandler onClick?: React.MouseEventHandler
@ -7,9 +6,12 @@ type Props = {
const UnlockButton: React.FC<Props> = ({ onClick }: Props) => { const UnlockButton: React.FC<Props> = ({ onClick }: Props) => {
return ( return (
<Button onClick={onClick} colorScheme="green"> <button
onClick={onClick}
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors duration-200"
>
Unlock Unlock
</Button> </button>
) )
} }

@ -1,38 +1,74 @@
import React from 'react' import React from 'react'
import { Box, Text, Badge, HStack, VStack } from '@chakra-ui/react'
type UploadedFileLike = { type UploadedFileLike = {
fullPath: string fullPath?: string
name?: string
type: string type: string
} }
interface Props { interface Props {
files: UploadedFileLike[] files: UploadedFileLike[]
onChange?: (updatedFiles: UploadedFileLike[]) => void
} }
const UploadFileList: React.FC<Props> = ({ files }: Props) => { const getFileName = (file: UploadedFileLike): string => {
if (file.fullPath) {
return file.fullPath.slice(1)
}
return file.name || 'Unknown'
}
export function TypeBadge({ type }: { type: string }): JSX.Element {
const getTypeColor = (fileType: string): string => {
if (fileType.startsWith('image/')) return 'bg-blue-100 text-blue-800'
if (fileType.startsWith('text/')) return 'bg-green-100 text-green-800'
if (fileType.startsWith('audio/')) return 'bg-purple-100 text-purple-800'
if (fileType.startsWith('video/')) return 'bg-red-100 text-red-800'
return 'bg-gray-100 text-gray-800'
}
return (
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${getTypeColor(
type,
)} transition-all duration-300 mr-2`}
>
{type}
</span>
)
}
export function UploadFileList({ files, onChange }: Props): JSX.Element {
const handleRemove = (index: number) => {
if (onChange) {
const updatedFiles = files.filter((_, i) => i !== index)
onChange(updatedFiles)
}
}
const items = files.map((f: UploadedFileLike, i: number) => ( const items = files.map((f: UploadedFileLike, i: number) => (
<Box key={f.fullPath} w="100%"> <div
<HStack key={getFileName(f)}
justify="space-between" className="w-full border border-stone-300 rounded-md mb-2 group"
paddingY="8px"
paddingX="10px"
borderTopColor="gray.100"
borderTopWidth={i === 0 ? '0' : '1px'}
> >
<Text textStyle="fileName" isTruncated> <div className="flex justify-between items-center py-2 px-2.5">
{f.fullPath.slice(1)} <p className="truncate text-sm font-medium">{getFileName(f)}</p>
</Text> <div className="flex items-center">
<Badge size="sm">{f.type}</Badge> <TypeBadge type={f.type} />
</HStack> {onChange && (
</Box> <button
onClick={() => handleRemove(i)}
className="text-stone-500 hover:text-stone-700 focus:outline-none"
>
</button>
)}
</div>
</div>
</div>
)) ))
return ( return <div className="w-full">{items}</div>
<VStack w="100%" spacing="0" borderRadius="md" boxShadow="base">
{items}
</VStack>
)
} }
export default UploadFileList export default UploadFileList

@ -4,19 +4,12 @@ import { useWebRTC } from './WebRTCProvider'
import useFetch from 'use-http' import useFetch from 'use-http'
import Peer, { DataConnection } from 'peerjs' import Peer, { DataConnection } from 'peerjs'
import { decodeMessage, Message, MessageType } from '../messages' import { decodeMessage, Message, MessageType } from '../messages'
import {
Box,
Button,
Input,
HStack,
useClipboard,
VStack,
} from '@chakra-ui/react'
import QRCode from 'react-qr-code' import QRCode from 'react-qr-code'
import produce from 'immer' import produce from 'immer'
import * as t from 'io-ts' import * as t from 'io-ts'
import Loading from './Loading' import Loading from './Loading'
import ProgressBar from './ProgressBar' import ProgressBar from './ProgressBar'
import useClipboard from '../hooks/useClipboard'
enum UploaderConnectionStatus { enum UploaderConnectionStatus {
Pending = 'PENDING', Pending = 'PENDING',
@ -44,9 +37,7 @@ type UploaderConnection = {
const RENEW_INTERVAL = 5000 // 20 minutes const RENEW_INTERVAL = 5000 // 20 minutes
const MAX_CHUNK_SIZE = 10 * 1024 * 1024 // 10 Mi const MAX_CHUNK_SIZE = 10 * 1024 * 1024 // 10 Mi
function useUploaderChannel( function useUploaderChannel(uploaderPeerID: string): {
uploaderPeerID: string,
): {
loading: boolean loading: boolean
error: Error | null error: Error | null
longSlug: string longSlug: string
@ -141,7 +132,7 @@ function useUploaderConnections(
return return
} }
fn(updatedConn) fn(updatedConn as UploaderConnection)
}), }),
) )
} }
@ -240,7 +231,7 @@ function useUploaderConnections(
if (final) { if (final) {
draft.status = UploaderConnectionStatus.Paused draft.status = UploaderConnectionStatus.Paused
} else { } else {
sendChunkTimeout = setTimeout(() => { sendChunkTimeout = window.setTimeout(() => {
sendNextChunk() sendNextChunk()
}, 0) }, 0)
} }
@ -328,12 +319,10 @@ export default function Uploader({
: ':' + window.location.port) : ':' + window.location.port)
const longURL = `${hostPrefix}/download/${longSlug}` const longURL = `${hostPrefix}/download/${longSlug}`
const shortURL = `${hostPrefix}/download/${shortSlug}` const shortURL = `${hostPrefix}/download/${shortSlug}`
const { hasCopied: hasCopiedLongURL, onCopy: onCopyLongURL } = useClipboard( const { hasCopied: hasCopiedLongURL, onCopy: onCopyLongURL } =
longURL, useClipboard(longURL)
) const { hasCopied: hasCopiedShortURL, onCopy: onCopyShortURL } =
const { hasCopied: hasCopiedShortURL, onCopy: onCopyShortURL } = useClipboard( useClipboard(shortURL)
shortURL,
)
if (!longSlug || !shortSlug) { if (!longSlug || !shortSlug) {
return <Loading text="Creating channel" /> return <Loading text="Creating channel" />
@ -341,39 +330,47 @@ export default function Uploader({
return ( return (
<> <>
<HStack w="100%"> <div className="flex w-full">
<Box flex="none"> <div className="flex-none">
<QRCode value={shortURL} size={88} /> <QRCode value={shortURL} size={88} />
</Box> </div>
<VStack flex="auto"> <div className="flex-auto flex flex-col">
<HStack w="100%"> <div className="flex w-full">
<Input value={longURL} isReadOnly fontSize="10px" /> <input
<Button className="flex-grow px-2 py-1 text-xs border rounded-l"
value={longURL}
readOnly
/>
<button
className="px-4 py-1 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-r"
onClick={onCopyLongURL} onClick={onCopyLongURL}
variant="ghost"
colorScheme="blackAlpha"
> >
{hasCopiedLongURL ? 'Copied' : 'Copy'} {hasCopiedLongURL ? 'Copied' : 'Copy'}
</Button> </button>
</HStack> </div>
<HStack w="100%"> <div className="flex w-full mt-2">
<Input value={shortURL} isReadOnly fontSize="10px" /> <input
<Button className="flex-grow px-2 py-1 text-xs border rounded-l"
value={shortURL}
readOnly
/>
<button
className="px-4 py-1 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-r"
onClick={onCopyShortURL} onClick={onCopyShortURL}
variant="ghost"
colorScheme="blackAlpha"
> >
{hasCopiedShortURL ? 'Copied' : 'Copy'} {hasCopiedShortURL ? 'Copied' : 'Copy'}
</Button> </button>
</HStack> </div>
</VStack> </div>
</HStack> </div>
{connections.map((conn, i) => ( {connections.map((conn, i) => (
<Box key={i} w="100%"> <div key={i} className="w-full mt-4">
{/* TODO(@kern): Make this look nicer */} {/* TODO(@kern): Make this look nicer */}
<div className="text-sm">
{conn.status} {conn.browserName} {conn.browserVersion} {conn.status} {conn.browserName} {conn.browserVersion}
</div>
<ProgressBar value={50} max={100} /> <ProgressBar value={50} max={100} />
</Box> </div>
))} ))}
</> </>
) )

@ -3,7 +3,7 @@ import type { default as PeerType } from 'peerjs'
import Loading from './Loading' import Loading from './Loading'
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const Peer = process.browser ? require('peerjs').default : null const Peer = typeof window !== 'undefined' ? require('peerjs').default : null
export type WebRTCValue = PeerType | null export type WebRTCValue = PeerType | null
@ -18,7 +18,12 @@ const ICE_SERVERS: RTCConfiguration = {
const WebRTCContext = React.createContext<WebRTCValue | null>(null) const WebRTCContext = React.createContext<WebRTCValue | null>(null)
export const useWebRTC = (): WebRTCValue => { export const useWebRTC = (): WebRTCValue => {
return useContext(WebRTCContext)! const value = useContext(WebRTCContext)
if (value === null) {
throw new Error('useWebRTC must be used within a WebRTCProvider')
}
return value
} }
export function WebRTCProvider({ export function WebRTCProvider({
@ -33,11 +38,22 @@ export function WebRTCProvider({
useEffect(() => { useEffect(() => {
const effect = async () => { const effect = async () => {
const peerObj = new Peer(undefined, { const peerConfig: {
host: '/', host?: string
port: '9000', port?: string
path?: string
config: RTCConfiguration
} = {
config: servers, config: servers,
}) }
if (process.env.NEXT_PUBLIC_PEERJS_HOST) {
peerConfig.host = process.env.NEXT_PUBLIC_PEERJS_HOST
peerConfig.port = process.env.NEXT_PUBLIC_PEERJS_PORT || '9000'
peerConfig.path = process.env.NEXT_PUBLIC_PEERJS_PATH || '/'
}
const peerObj = new Peer(undefined, peerConfig)
peerObj.on('open', () => { peerObj.on('open', () => {
peer.current = peerObj peer.current = peerObj
@ -46,7 +62,7 @@ export function WebRTCProvider({
} }
effect() effect()
}, []) }, [servers])
if (!loaded || !peer.current) { if (!loaded || !peer.current) {
return <Loading text="Initializing WebRTC" /> return <Loading text="Initializing WebRTC" />

@ -1,6 +1,14 @@
import React from 'react' import React from 'react'
import { Img } from '@chakra-ui/react' import Image from 'next/image'
export default function Wordmark(): JSX.Element { export default function Wordmark(): JSX.Element {
return <Img src="/images/wordmark.png" maxH="48px" /> return (
<Image
src="/images/wordmark.png"
className="max-h-12"
alt="FilePizza Wordmark"
width={200}
height={45}
/>
)
} }

@ -37,28 +37,33 @@ const scanDirectoryEntry = async (entry: any): Promise<File[]> => {
} }
} }
export const extractFileList = async (e: React.DragEvent): Promise<File[]> => { export const extractFileList = async (
if (!e.dataTransfer.items.length) { e: React.DragEvent | DragEvent,
): Promise<File[]> => {
if (!e.dataTransfer || !e.dataTransfer.items.length) {
return [] return []
} }
const items = e.dataTransfer.items const items = e.dataTransfer.items
const scans = [] const scans: Promise<File[]>[] = []
const files = [] const files: Promise<File>[] = []
for (const item of items) { for (let i = 0; i < items.length; i++) {
const item = items[i]
const entry = item.webkitGetAsEntry() const entry = item.webkitGetAsEntry()
if (entry) {
if (entry.isDirectory) { if (entry.isDirectory) {
scans.push(scanDirectoryEntry(entry)) scans.push(scanDirectoryEntry(entry))
} else { } else {
files.push(getAsFile(entry)) files.push(getAsFile(entry))
} }
} }
}
const scanResults = await Promise.all(scans) const scanResults = await Promise.all(scans)
const fileResults = await Promise.all(files) const fileResults = await Promise.all(files)
return [scanResults, fileResults].flat(2) return scanResults.flat().concat(fileResults)
} }
// Borrowed from StackOverflow // Borrowed from StackOverflow

@ -0,0 +1,25 @@
import { useState, useCallback, useEffect } from 'react'
export default function useClipboard(text: string, delay = 1000) {
const [hasCopied, setHasCopied] = useState(false)
const onCopy = useCallback(() => {
navigator.clipboard.writeText(text).then(() => {
setHasCopied(true)
})
}, [text])
useEffect(() => {
let timeoutId: NodeJS.Timeout
if (hasCopied) {
timeoutId = setTimeout(() => {
setHasCopied(false)
}, delay)
}
return () => {
clearTimeout(timeoutId)
}
}, [hasCopied, delay])
return { hasCopied, onCopy }
}

@ -2,55 +2,8 @@ import React from 'react'
import { AppProps } from 'next/app' import { AppProps } from 'next/app'
import Head from 'next/head' import Head from 'next/head'
import Footer from '../components/Footer' import Footer from '../components/Footer'
import { ChakraProvider, extendTheme, Container } from '@chakra-ui/react'
import '../styles.css' import '../styles.css'
const theme = extendTheme({
colors: {
brand: {},
},
textStyles: {
description: {
color: 'gray.500',
fontSize: '18px',
lineHeight: '20px',
letterSpacing: '2%',
},
descriptionSmall: {
color: 'gray.500',
fontSize: '12px',
lineHeight: '20px',
letterSpacing: '2%',
},
descriptionError: {
color: 'red.500',
fontSize: '18px',
lineHeight: '20px',
letterSpacing: '2%',
},
fileName: {
color: 'gray.900',
fontSize: '12px',
lineHeight: '20px',
fontFamily: 'monospace',
},
footer: {
fontSize: '12px',
lineHeight: '20px',
letterSpacing: '2%',
},
footerLink: {
color: 'gray.500',
},
h2: {
fontSize: ['36px', '48px'],
fontWeight: 'semibold',
lineHeight: '110%',
letterSpacing: '-1%',
},
},
})
const App: React.FC<AppProps> = ({ Component, pageProps }: AppProps) => ( const App: React.FC<AppProps> = ({ Component, pageProps }: AppProps) => (
<> <>
<Head> <Head>
@ -68,15 +21,11 @@ const App: React.FC<AppProps> = ({ Component, pageProps }: AppProps) => (
<meta property="og:title" content="FilePizza" key="title" /> <meta property="og:title" content="FilePizza" key="title" />
</Head> </Head>
<ChakraProvider theme={theme}> <main>
<Container flex="auto">
<Component {...pageProps} /> <Component {...pageProps} />
</Container> </main>
<Container flex="none">
<Footer /> <Footer />
</Container>
</ChakraProvider>
</> </>
) )

@ -3,7 +3,6 @@ import WebRTCProvider from '../../components/WebRTCProvider'
import Downloader from '../../components/Downloader' import Downloader from '../../components/Downloader'
import { NextPage, GetServerSideProps } from 'next' import { NextPage, GetServerSideProps } from 'next'
import { channelRepo } from '../../channel' import { channelRepo } from '../../channel'
import { VStack } from '@chakra-ui/react'
import Spinner from '../../components/Spinner' import Spinner from '../../components/Spinner'
import Wordmark from '../../components/Wordmark' import Wordmark from '../../components/Wordmark'
@ -15,13 +14,13 @@ type Props = {
const DownloadPage: NextPage<Props> = ({ uploaderPeerID }) => { const DownloadPage: NextPage<Props> = ({ uploaderPeerID }) => {
return ( return (
<VStack spacing="20px" paddingY="40px" w="100%"> <div className="flex flex-col items-center space-y-5 py-10 w-full">
<Spinner direction="down" /> <Spinner direction="down" />
<Wordmark /> <Wordmark />
<WebRTCProvider> <WebRTCProvider>
<Downloader uploaderPeerID={uploaderPeerID} /> <Downloader uploaderPeerID={uploaderPeerID} />
</WebRTCProvider> </WebRTCProvider>
</VStack> </div>
) )
} }

@ -9,7 +9,6 @@ import StopButton from '../components/StopButton'
import { UploadedFile } from '../types' import { UploadedFile } from '../types'
import { NextPage } from 'next' import { NextPage } from 'next'
import Spinner from '../components/Spinner' import Spinner from '../components/Spinner'
import { ButtonGroup, Text, VStack } from '@chakra-ui/react'
import Wordmark from '../components/Wordmark' import Wordmark from '../components/Wordmark'
import CancelButton from '../components/CancelButton' import CancelButton from '../components/CancelButton'
@ -39,55 +38,59 @@ export const IndexPage: NextPage = () => {
setUploading(false) setUploading(false)
}, []) }, [])
const handleFileListChange = useCallback((updatedFiles: UploadedFile[]) => {
setUploadedFiles(updatedFiles)
}, [])
if (!uploadedFiles.length) { if (!uploadedFiles.length) {
return ( return (
<VStack spacing="20px" paddingY="40px"> <div className="flex flex-col items-center space-y-5 py-10">
<Spinner direction="up" /> <Spinner direction="up" />
<Wordmark /> <Wordmark />
<VStack spacing="4px"> <div className="flex flex-col items-center space-y-1">
<Text textStyle="description"> <p className="text-lg text-center text-stone-800">
Peer-to-peer file transfers in your browser. Peer-to-peer file transfers in your browser.
</Text> </p>
<Text textStyle="descriptionSmall"> <p className="text-sm text-center text-stone-600">
We never store anything. Files only served fresh. We never store anything. Files only served fresh.
</Text> </p>
</VStack> </div>
<DropZone onDrop={handleDrop}>Drop a file to get started.</DropZone> <DropZone onDrop={handleDrop} />
</VStack> </div>
) )
} }
if (!uploading) { if (!uploading) {
return ( return (
<VStack spacing="20px" paddingY="40px"> <div className="flex flex-col items-center space-y-5 py-10">
<Spinner direction="up" /> <Spinner direction="up" />
<Wordmark /> <Wordmark />
<Text textStyle="description"> <p className="text-lg text-center text-stone-800">
You are about to start uploading {uploadedFiles.length} files. You are about to start uploading {uploadedFiles.length} files.
</Text> </p>
<UploadFileList files={uploadedFiles} /> <UploadFileList files={uploadedFiles} onChange={handleFileListChange} />
<PasswordField value={password} onChange={handleChangePassword} /> <PasswordField value={password} onChange={handleChangePassword} />
<ButtonGroup> <div className="flex space-x-4">
<CancelButton onClick={handleCancel} /> <CancelButton onClick={handleCancel} />
<StartButton onClick={handleStart} /> <StartButton onClick={handleStart} />
</ButtonGroup> </div>
</VStack> </div>
) )
} }
return ( return (
<VStack spacing="20px" paddingY="40px"> <div className="flex flex-col items-center space-y-5 py-10">
<Spinner direction="up" isRotating /> <Spinner direction="up" isRotating />
<Wordmark /> <Wordmark />
<Text textStyle="description"> <p className="text-lg text-center text-stone-800">
You are uploading {uploadedFiles.length} files. You are uploading {uploadedFiles.length} files.
</Text> </p>
<UploadFileList files={uploadedFiles} /> <UploadFileList files={uploadedFiles} />
<WebRTCProvider> <WebRTCProvider>
<Uploader files={uploadedFiles} password={password} /> <Uploader files={uploadedFiles} password={password} />
</WebRTCProvider> </WebRTCProvider>
<StopButton onClick={handleStop} /> <StopButton onClick={handleStop} />
</VStack> </div>
) )
} }

@ -1,16 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html { html {
height: -webkit-fill-available; height: 100dvh;
} }
body { body {
min-height: 100vh; min-height: 100dvh;
/* mobile viewport bug fix */
min-height: -webkit-fill-available;
} }
#__next { #__next {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100dvh;
} }

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {},
},
plugins: [],
}

@ -17,9 +17,11 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"downlevelIteration": true, "downlevelIteration": true,
"jsx": "preserve" "jsx": "preserve",
"incremental": true
}, },
"include": [ "include": [
"tailwind.config.js",
"next-env.d.ts", "next-env.d.ts",
"src/**/*.js", "src/**/*.js",
"src/**/*.ts", "src/**/*.ts",

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save