mirror of https://github.com/kern/filepizza
migrate off chakra entirely to tailwind
parent
e73acc3eda
commit
dd25e6f7d8
@ -1,4 +1,5 @@
|
||||
/// <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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@ -1,65 +1,109 @@
|
||||
import React, { useState, useRef, useCallback } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { extractFileList } from '../fs'
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: block;
|
||||
`
|
||||
|
||||
const Overlay = styled.div`
|
||||
display: block;
|
||||
`
|
||||
|
||||
export default function DropZone({
|
||||
children,
|
||||
onDrop,
|
||||
}: {
|
||||
onDrop: (files: File[]) => void
|
||||
children?: React.ReactNode
|
||||
}): JSX.Element {
|
||||
const overlay = useRef()
|
||||
const [focus, setFocus] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [fileCount, setFileCount] = useState(0)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleDragEnter = useCallback(() => {
|
||||
setFocus(true)
|
||||
const handleDragEnter = useCallback((e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
setFileCount(e.dataTransfer?.items.length || 0)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (e.target !== overlay.current) {
|
||||
return
|
||||
}
|
||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
setFocus(false)
|
||||
},
|
||||
[overlay.current],
|
||||
)
|
||||
const currentTarget =
|
||||
e.currentTarget === window ? window.document : e.currentTarget
|
||||
if (
|
||||
e.relatedTarget &&
|
||||
currentTarget instanceof Node &&
|
||||
currentTarget.contains(e.relatedTarget as Node)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setFocus(false)
|
||||
setIsDragging(false)
|
||||
|
||||
const files = await extractFileList(e)
|
||||
onDrop(files)
|
||||
if (e.dataTransfer) {
|
||||
const files = await extractFileList(e)
|
||||
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],
|
||||
)
|
||||
|
||||
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 (
|
||||
<Wrapper
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Overlay ref={overlay} hidden={!focus} />
|
||||
{children}
|
||||
</Wrapper>
|
||||
<>
|
||||
<div
|
||||
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 ${
|
||||
isDragging ? 'opacity-100 visible' : 'opacity-0 invisible'
|
||||
}`}
|
||||
>
|
||||
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}
|
||||
>
|
||||
<span className="text-center text-stone-700">
|
||||
Drop a file to get started
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import React from 'react'
|
||||
import { Spinner, Text, VStack } from '@chakra-ui/react'
|
||||
|
||||
export default function Loading({ text }: { text: string }): JSX.Element {
|
||||
return (
|
||||
<VStack>
|
||||
<Spinner color="blue.500" />
|
||||
<Text textStyle="descriptionSmall">{text}</Text>
|
||||
</VStack>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-sm text-gray-600 mt-2">{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,38 +1,74 @@
|
||||
import React from 'react'
|
||||
import { Box, Text, Badge, HStack, VStack } from '@chakra-ui/react'
|
||||
|
||||
type UploadedFileLike = {
|
||||
fullPath: string
|
||||
fullPath?: string
|
||||
name?: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
files: UploadedFileLike[]
|
||||
onChange?: (updatedFiles: UploadedFileLike[]) => void
|
||||
}
|
||||
|
||||
const UploadFileList: React.FC<Props> = ({ files }: Props) => {
|
||||
const items = files.map((f: UploadedFileLike, i: number) => (
|
||||
<Box key={f.fullPath} w="100%">
|
||||
<HStack
|
||||
justify="space-between"
|
||||
paddingY="8px"
|
||||
paddingX="10px"
|
||||
borderTopColor="gray.100"
|
||||
borderTopWidth={i === 0 ? '0' : '1px'}
|
||||
>
|
||||
<Text textStyle="fileName" isTruncated>
|
||||
{f.fullPath.slice(1)}
|
||||
</Text>
|
||||
<Badge size="sm">{f.type}</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
))
|
||||
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 (
|
||||
<VStack w="100%" spacing="0" borderRadius="md" boxShadow="base">
|
||||
{items}
|
||||
</VStack>
|
||||
<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) => (
|
||||
<div
|
||||
key={getFileName(f)}
|
||||
className="w-full border border-stone-300 rounded-md mb-2 group"
|
||||
>
|
||||
<div className="flex justify-between items-center py-2 px-2.5">
|
||||
<p className="truncate text-sm font-medium">{getFileName(f)}</p>
|
||||
<div className="flex items-center">
|
||||
<TypeBadge type={f.type} />
|
||||
{onChange && (
|
||||
<button
|
||||
onClick={() => handleRemove(i)}
|
||||
className="text-stone-500 hover:text-stone-700 focus:outline-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
return <div className="w-full">{items}</div>
|
||||
}
|
||||
|
||||
export default UploadFileList
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Img } from '@chakra-ui/react'
|
||||
import Image from 'next/image'
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -1,16 +1,17 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
height: -webkit-fill-available;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
|
||||
/* mobile viewport bug fix */
|
||||
min-height: -webkit-fill-available;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
#__next {
|
||||
display: flex;
|
||||
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: [],
|
||||
}
|
||||
Loading…
Reference in New Issue