Skip to content

Commit

Permalink
feat: add basic upload
Browse files Browse the repository at this point in the history
  • Loading branch information
sadmann7 committed Apr 7, 2024
1 parent e2ffc59 commit dee1a3a
Show file tree
Hide file tree
Showing 11 changed files with 239 additions and 67 deletions.
9 changes: 9 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
/** @type {import("next").NextConfig} */
const config = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "utfs.io",
},
],
unoptimized: true,
},
experimental: {
ppr: true,
},
Expand Down
81 changes: 50 additions & 31 deletions src/app/_components/basic-uploader-demo.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,60 @@
"use client"

import * as React from "react"
import type { UploadedFile } from "@/types"
import { generateReactHelpers } from "@uploadthing/react/hooks"
import Image from "next/image"

import { useFileUpload } from "@/hooks/use-file-upload"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { FileUploader } from "@/components/file-uploader"
import { type OurFileRouter } from "@/app/api/uploadthing/core"

const { useUploadThing } = generateReactHelpers<OurFileRouter>()

export function BasicUploaderDemo() {
const [uploadedFiles, setUploadedFiles] = React.useState<
UploadedFile[] | null
>([])
const [progress, setProgress] = React.useState(0)
const { startUpload, isUploading } = useUploadThing("imageUploader", {
onUploadProgress: setProgress,
})

console.log({ uploadedFiles, progress })
const { uploadFiles, progresses, uploadedFiles, isUploading } = useFileUpload(
"imageUploader",
{ defaultUploadedFiles: [] }
)

return (
<FileUploader
maxFiles={4}
maxSize={1 * 1024 * 1024}
onUpload={async (files) => {
const uploadedFiles = await startUpload(files).then((res) => {
const formattedImages = res?.map((image) => ({
id: image.key,
name: image.key.split("_")[1] ?? image.key,
url: image.url,
}))
return formattedImages ?? null
})
setUploadedFiles(uploadedFiles)
}}
disabled={isUploading}
/>
<div className="flex flex-col space-y-6">
<FileUploader
maxFiles={4}
maxSize={4 * 1024 * 1024}
progresses={progresses}
onUpload={uploadFiles}
disabled={isUploading}
/>
<Card>
<CardHeader>
<CardTitle>Uploaded files</CardTitle>
<CardDescription>View the uploaded files here</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="py-4">
<div className="flex w-max space-x-2.5">
{Array.from({ length: 11 }).map(() =>
uploadedFiles?.slice(0, 1).map((file) => (
<div key={file.key} className="relative aspect-video w-64">
<Image
src={file.url}
alt={file.name}
fill
sizes="(min-width: 640px) 640px, 100vw"
loading="lazy"
className="rounded-md object-cover"
/>
</div>
))
)}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</CardContent>
</Card>
</div>
)
}
12 changes: 7 additions & 5 deletions src/app/_components/variant-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,23 @@ import { HookForm } from "./hook-form"

export function VariantTabs() {
return (
<Tabs defaultValue="basic" className="w-full">
<Tabs defaultValue="basic" className="w-full overflow-hidden">
<TabsList>
<TabsTrigger value="basic">Basic</TabsTrigger>
<TabsTrigger value="hook">React hook form</TabsTrigger>
<TabsTrigger value="hook" disabled>
React hook form
</TabsTrigger>
<TabsTrigger value="action" disabled>
Server action
</TabsTrigger>
</TabsList>
<TabsContent value="basic">
<TabsContent value="basic" className="mt-6">
<BasicUploaderDemo />
</TabsContent>
<TabsContent value="hook">
<TabsContent value="hook" className="mt-6">
<HookForm />
</TabsContent>
<TabsContent value="action">
<TabsContent value="action" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/uploadthing/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ async function auth(req: NextRequest) {
// FileRouter for your app, can contain multiple FileRoutes
export const ourFileRouter = {
// Define as many FileRoutes as you like, each with a unique routeSlug
imageUploader: f({ image: { maxFileSize: "2MB" } })
imageUploader: f({ image: { maxFileSize: "4MB", maxFileCount: 4 } })
// Set permissions and file types for this FileRoute
.middleware(async ({ req }) => {
// This code runs on your server before upload
Expand Down
60 changes: 35 additions & 25 deletions src/components/file-uploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"

import { Progress } from "./ui/progress"

interface FileUploaderProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "accept"> {
/**
Expand All @@ -33,6 +35,14 @@ interface FileUploaderProps
*/
onUpload?: (files: File[]) => Promise<void>

/**
* Progress of the uploaded files.
* @type Record<string, number> | undefined
* @default undefined
* @example progresses={{ "file1.png": 50 }}
*/
progresses?: Record<string, number>

/**
* Accepted file types for the uploader.
* @type { [key: string]: string[]}
Expand Down Expand Up @@ -69,6 +79,7 @@ interface FileUploaderProps
export function FileUploader({
onValueChange,
onUpload,
progresses,
accept = { "image/*": [] },
multiple,
maxSize = 1024 * 1024 * 2,
Expand Down Expand Up @@ -105,24 +116,18 @@ export function FileUploader({
})
}

console.log({ files })
if (
onUpload &&
updatedFiles.length > 0 &&
updatedFiles.length <= maxFiles
) {
const target = updatedFiles.length > 0 ? "Files" : "File"

if (onUpload) {
if (
!files ||
files.length === 0 ||
files.length + acceptedFiles.length > maxFiles
)
return

const target = files.length > 0 ? "files" : "file"

toast.promise(onUpload(acceptedFiles), {
toast.promise(onUpload(updatedFiles), {
loading: `Uploading ${target}...`,
success: () => {
setFiles(null)
onValueChange?.(null)

return `${target} uploaded`
},
error: `Failed to upload ${target}`,
Expand All @@ -149,6 +154,8 @@ export function FileUploader({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

const isDisabled = disabled || (files?.length ?? 0) >= maxFiles

return (
<Card className="relative flex flex-col gap-6 overflow-hidden p-6">
<Dropzone
Expand All @@ -157,7 +164,7 @@ export function FileUploader({
maxSize={maxSize}
maxFiles={maxFiles}
multiple={maxFiles > 1 || multiple}
disabled={disabled || (files?.length ?? 0) >= maxFiles}
disabled={isDisabled}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
Expand All @@ -166,9 +173,7 @@ export function FileUploader({
"group relative grid h-48 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-5 py-2.5 text-center transition hover:bg-muted/25",
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isDragActive && "border-muted-foreground/50",
disabled ||
((files?.length ?? 0) >= maxFiles &&
"pointer-events-none opacity-60"),
isDisabled && "pointer-events-none opacity-60",
className
)}
>
Expand Down Expand Up @@ -225,6 +230,7 @@ export function FileUploader({
key={file.id}
file={file}
onRemove={() => onRemove(file)}
progress={progresses?.[file.name]}
/>
))}
</div>
Expand All @@ -237,9 +243,10 @@ export function FileUploader({
interface FileCardProps {
file: FileWithPreview
onRemove: () => void
progress?: number
}

function FileCard({ file, onRemove }: FileCardProps) {
function FileCard({ file, progress, onRemove }: FileCardProps) {
return (
<div className="relative flex items-center space-x-4">
<div className="flex flex-1 space-x-4">
Expand All @@ -251,13 +258,16 @@ function FileCard({ file, onRemove }: FileCardProps) {
loading="lazy"
className="size-12 shrink-0 rounded-md object-cover"
/>
<div className="flex flex-col">
<p className="line-clamp-1 text-sm font-medium text-foreground/80">
{file.name.slice(0, 45)}.{file.type.split("/")[1]}
</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)}MB
</p>
<div className="flex w-full flex-col gap-2">
<div className="space-y-px">
<p className="line-clamp-1 text-sm font-medium text-foreground/80">
{file.name.slice(0, 45)}.{file.type.split("/")[1]}
</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)}MB
</p>
</div>
{progress ? <Progress value={progress} /> : null}
</div>
</div>
<div className="flex items-center gap-2">
Expand Down
15 changes: 15 additions & 0 deletions src/hooks/use-debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from "react"

export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value)

React.useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500)

return () => {
clearTimeout(timer)
}
}, [value, delay])

return debouncedValue
}
61 changes: 61 additions & 0 deletions src/hooks/use-file-upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as React from "react"
import type { UploadedFile } from "@/types"
import { toast } from "sonner"
import type { UploadFilesOptions } from "uploadthing/types"

import { getErrorMessage } from "@/lib/handle-error"
import { uploadFiles } from "@/lib/uploadthing"
import { type OurFileRouter } from "@/app/api/uploadthing/core"

interface UseFileUploadProps
extends Pick<
UploadFilesOptions<OurFileRouter, keyof OurFileRouter>,
"headers" | "onUploadBegin" | "onUploadProgress" | "skipPolling"
> {
defaultUploadedFiles?: UploadedFile[]
}

export function useFileUpload(
endpoint: keyof OurFileRouter,
{ defaultUploadedFiles = [], ...props }: UseFileUploadProps = {}
) {
const [uploadedFiles, setUploadedFiles] = React.useState<
UploadedFile[] | null
>(defaultUploadedFiles)
const [progresses, setProgresses] = React.useState<Record<string, number>>({})
const [isUploading, setIsUploading] = React.useState(false)

async function uploadThings(files: File[]) {
setIsUploading(true)
try {
const res = await uploadFiles(endpoint, {
...props,
files,
onUploadProgress: ({ file, progress }) => {
setProgresses((prev) => {
return {
...prev,
[file]: progress,
}
})
},
})

setUploadedFiles((prev) => {
return prev ? [...prev, ...res] : res
})
} catch (err) {
toast.error(getErrorMessage(err))
} finally {
setProgresses({})
setIsUploading(false)
}
}

return {
uploadedFiles,
progresses,
uploadFiles: uploadThings,
isUploading,
}
}
27 changes: 27 additions & 0 deletions src/hooks/use-upload-thing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from "react"
import { type UseUploadthingProps as UseUploadFileProps } from "@uploadthing/react"

import { useUploadThing as useUploadFile } from "@/lib/uploadthing"
import { type OurFileRouter } from "@/app/api/uploadthing/core"

interface UseUploadthingProps
extends UseUploadFileProps<OurFileRouter, keyof OurFileRouter> {}

export function useUploadThing(
endpoint: keyof OurFileRouter,
props: UseUploadthingProps = {}
) {
const [progress, setProgress] = React.useState(0)
const { startUpload, isUploading } = useUploadFile(endpoint, {
onUploadProgress: () => {
setProgress(progress)
},
...props,
})

return {
startUpload,
isUploading,
progress,
}
}
Loading

0 comments on commit dee1a3a

Please sign in to comment.