diff --git a/examples/with-tailwindcss/src/app/my-upload-button.tsx b/examples/with-tailwindcss/src/app/my-upload-button.tsx new file mode 100644 index 0000000000..62054ae006 --- /dev/null +++ b/examples/with-tailwindcss/src/app/my-upload-button.tsx @@ -0,0 +1,13 @@ +import { useUploadThing } from "~/utils/uploadthing"; + +export const MyUploadButton = (props: {}) => { + const { getInputProps, files, isUploading, progresses } = + useUploadThing("videoAndImage"); + + return ( + + ); +}; diff --git a/examples/with-tailwindcss/src/app/page.tsx b/examples/with-tailwindcss/src/app/page.tsx index 7daca96d4d..029651f432 100644 --- a/examples/with-tailwindcss/src/app/page.tsx +++ b/examples/with-tailwindcss/src/app/page.tsx @@ -5,6 +5,7 @@ import { UploadDropzone, useUploadThing, } from "~/utils/uploadthing"; +import { MyUploadButton } from "./my-upload-button"; export default function Home() { const { startUpload } = useUploadThing("videoAndImage", { @@ -15,6 +16,7 @@ export default function Home() { return (
+ { @@ -24,6 +26,10 @@ export default function Home() { onUploadBegin={() => { console.log("upload begin"); }} + config={{ + appendOnPaste: true, + mode: "manual", + }} /> { + const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig); + + const { maxFiles, maxSize } = Object.values(routeConfig ?? {}).reduce( + (acc, curr) => { + // Don't think it makes sense to have a minFileCount since they can select many times + // acc.minFiles = Math.min(acc.minFiles, curr.minFileCount); + acc.maxFiles = Math.max(acc.maxFiles, curr.maxFileCount); + acc.maxSize = Math.max( + acc.maxSize, + Micro.runSync(fileSizeToBytes(curr.maxFileSize)), + ); + return acc; + }, + { maxFiles: 0, maxSize: 0 }, + ); + + return { + multiple, + accept: acceptPropAsAcceptAttr(generateClientDropzoneAccept(fileTypes)), + maxFiles, + maxSize: maxSize, + }; +}; + /** * ================================================ * Reducer @@ -191,7 +227,7 @@ export const initialState = { isDragActive: false, isDragAccept: false, isDragReject: false, - acceptedFiles: [] as File[], + acceptedFiles: [] as FileWithState[], }; export function reducer( diff --git a/packages/dropzone/src/react.tsx b/packages/dropzone/src/react.tsx index 8485749d17..30fd2e346f 100644 --- a/packages/dropzone/src/react.tsx +++ b/packages/dropzone/src/react.tsx @@ -15,8 +15,9 @@ import type { import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; import { fromEvent } from "file-selector"; +import type { FileWithState } from "@uploadthing/shared"; + import { - acceptPropAsAcceptAttr, allFilesAccepted, initialState, isEnterOrSpace, @@ -27,6 +28,7 @@ import { isValidSize, noop, reducer, + routeConfigToDropzoneProps, } from "./core"; import type { DropzoneOptions } from "./types"; @@ -60,15 +62,15 @@ export type DropEvent = * ``` */ export function useDropzone({ - accept, + routeConfig, disabled = false, - maxSize = Number.POSITIVE_INFINITY, minSize = 0, - multiple = true, - maxFiles = 0, onDrop, }: DropzoneOptions) { - const acceptAttr = useMemo(() => acceptPropAsAcceptAttr(accept), [accept]); + const { accept, multiple, maxFiles, maxSize } = useMemo( + () => routeConfigToDropzoneProps(routeConfig), + [routeConfig], + ); const rootRef = useRef(null); const inputRef = useRef(null); @@ -136,7 +138,7 @@ export function useDropzone({ fileCount > 0 && allFilesAccepted({ files: files as File[], - accept: acceptAttr!, + accept, minSize, maxSize, multiple, @@ -156,7 +158,7 @@ export function useDropzone({ .catch(noop); } }, - [acceptAttr, maxFiles, maxSize, minSize, multiple], + [accept, maxFiles, maxSize, minSize, multiple], ); const onDragOver = useCallback((event: DragEvent) => { @@ -203,14 +205,18 @@ export function useDropzone({ const setFiles = useCallback( (files: File[]) => { - const acceptedFiles: File[] = []; + const acceptedFiles: FileWithState[] = []; files.forEach((file) => { - const accepted = isFileAccepted(file, acceptAttr!); + const accepted = isFileAccepted(file, accept); const sizeMatch = isValidSize(file, minSize, maxSize); if (accepted && sizeMatch) { - acceptedFiles.push(file); + const fileWithState: FileWithState = Object.assign(file, { + status: "pending" as const, + key: null, + }); + acceptedFiles.push(fileWithState); } }); @@ -227,7 +233,7 @@ export function useDropzone({ onDrop(acceptedFiles); }, - [acceptAttr, maxFiles, maxSize, minSize, multiple, onDrop], + [accept, maxFiles, maxSize, minSize, multiple, onDrop], ); const onDropCb = useCallback( @@ -241,7 +247,16 @@ export function useDropzone({ Promise.resolve(fromEvent(event)) .then((files) => { if (event.isPropagationStopped()) return; - setFiles(files as File[]); + + console.log("files in onDrop", files); + + const filesWithState = (files as File[]).map((file) => + Object.assign(file, { + status: "pending" as const, + key: null, + }), + ); + setFiles(filesWithState); }) .catch(noop); } @@ -323,7 +338,7 @@ export function useDropzone({ ref: inputRef, type: "file", style: { display: "none" }, - accept: acceptAttr, + accept: accept, multiple, tabIndex: -1, ...(!disabled @@ -334,7 +349,7 @@ export function useDropzone({ } : {}), }), - [acceptAttr, multiple, onDropCb, onInputElementClick, disabled], + [accept, multiple, onDropCb, onInputElementClick, disabled], ); return { diff --git a/packages/dropzone/src/solid.tsx b/packages/dropzone/src/solid.tsx index a5407df6cd..2ec9182e2a 100644 --- a/packages/dropzone/src/solid.tsx +++ b/packages/dropzone/src/solid.tsx @@ -15,8 +15,9 @@ import { } from "solid-js"; import { createStore } from "solid-js/store"; +import type { FileWithState } from "@uploadthing/shared"; + import { - acceptPropAsAcceptAttr, allFilesAccepted, initialState, isEnterOrSpace, @@ -27,6 +28,7 @@ import { isValidQuantity, isValidSize, noop, + routeConfigToDropzoneProps, } from "./core"; import type { DropzoneOptions } from "./types"; @@ -38,15 +40,14 @@ export function createDropzone(_props: DropzoneOptions) { const props = mergeProps( { disabled: false, - maxSize: Number.POSITIVE_INFINITY, minSize: 0, - multiple: true, - maxFiles: 0, }, _props, ); - const acceptAttr = createMemo(() => acceptPropAsAcceptAttr(props.accept)); + const routeProps = createMemo(() => + routeConfigToDropzoneProps(props.routeConfig), + ); const [rootRef, setRootRef] = createSignal(); const [inputRef, setInputRef] = createSignal(); @@ -114,11 +115,8 @@ export function createDropzone(_props: DropzoneOptions) { fileCount > 0 && allFilesAccepted({ files: files as File[], - accept: acceptAttr()!, minSize: props.minSize, - maxSize: props.maxSize, - multiple: props.multiple, - maxFiles: props.maxFiles, + ...routeProps(), }); const isDragReject = fileCount > 0 && !isDragAccept; @@ -174,25 +172,34 @@ export function createDropzone(_props: DropzoneOptions) { }; const setFiles = (files: File[]) => { - const acceptedFiles: File[] = []; + const acceptedFiles: FileWithState[] = []; files.forEach((file) => { - const accepted = isFileAccepted(file, acceptAttr()!); - const sizeMatch = isValidSize(file, props.minSize, props.maxSize); + const accepted = isFileAccepted(file, routeProps().accept); + const sizeMatch = isValidSize(file, props.minSize, routeProps().maxSize); if (accepted && sizeMatch) { - acceptedFiles.push(file); + const fileWithState: FileWithState = Object.assign(file, { + status: "pending" as const, + key: null, + }); + acceptedFiles.push(fileWithState); } }); - if (!isValidQuantity(acceptedFiles, props.multiple, props.maxFiles)) { + if ( + !isValidQuantity( + acceptedFiles, + routeProps().multiple, + routeProps().maxFiles, + ) + ) { acceptedFiles.splice(0); } setState({ acceptedFiles, }); - props.onDrop?.(acceptedFiles); }; @@ -271,8 +278,8 @@ export function createDropzone(_props: DropzoneOptions) { ref: setInputRef, type: "file", style: { display: "none" }, - accept: acceptAttr(), - multiple: props.multiple, + accept: routeProps().accept, + multiple: routeProps().multiple, tabIndex: -1, ...(!props.disabled ? { diff --git a/packages/dropzone/src/svelte.ts b/packages/dropzone/src/svelte.ts index aeb2d6ed70..65fe20e0e7 100644 --- a/packages/dropzone/src/svelte.ts +++ b/packages/dropzone/src/svelte.ts @@ -3,8 +3,9 @@ import { onMount } from "svelte"; import type { Action } from "svelte/action"; import { derived, get, writable } from "svelte/store"; +import type { FileWithState } from "@uploadthing/shared"; + import { - acceptPropAsAcceptAttr, allFilesAccepted, initialState, isEnterOrSpace, @@ -16,6 +17,7 @@ import { isValidSize, noop, reducer, + routeConfigToDropzoneProps, } from "./core"; import type { DropzoneOptions } from "./types"; @@ -36,15 +38,12 @@ function reducible( export function createDropzone(_props: DropzoneOptions) { const props = writable({ disabled: false, - maxSize: Number.POSITIVE_INFINITY, minSize: 0, - multiple: true, - maxFiles: 0, ..._props, }); - const acceptAttr = derived(props, ($props) => - acceptPropAsAcceptAttr($props.accept), + const routeProps = derived(props, ($props) => + routeConfigToDropzoneProps($props.routeConfig), ); const rootRef = writable(); @@ -113,11 +112,11 @@ export function createDropzone(_props: DropzoneOptions) { fileCount > 0 && allFilesAccepted({ files: files as File[], - accept: get(acceptAttr)!, + accept: get(routeProps).accept, minSize: get(props).minSize, - maxSize: get(props).maxSize, - multiple: get(props).multiple, - maxFiles: get(props).maxFiles, + maxSize: get(routeProps).maxSize, + multiple: get(routeProps).multiple, + maxFiles: get(routeProps).maxFiles, }); const isDragReject = fileCount > 0 && !isDragAccept; @@ -181,23 +180,31 @@ export function createDropzone(_props: DropzoneOptions) { }; const setFiles = (files: File[]) => { - const acceptedFiles: File[] = []; + const acceptedFiles: FileWithState[] = []; files.forEach((file) => { - const accepted = isFileAccepted(file, get(acceptAttr)!); + const accepted = isFileAccepted(file, get(routeProps).accept); const sizeMatch = isValidSize( file, get(props).minSize, - get(props).maxSize, + get(routeProps).maxSize, ); if (accepted && sizeMatch) { - acceptedFiles.push(file); + const fileWithState: FileWithState = Object.assign(file, { + status: "pending" as const, + key: null, + }); + acceptedFiles.push(fileWithState); } }); if ( - !isValidQuantity(acceptedFiles, get(props).multiple, get(props).maxFiles) + !isValidQuantity( + acceptedFiles, + get(routeProps).multiple, + get(routeProps).maxFiles, + ) ) { acceptedFiles.splice(0); } @@ -208,7 +215,6 @@ export function createDropzone(_props: DropzoneOptions) { acceptedFiles, }, }); - get(props).onDrop(acceptedFiles); }; @@ -306,10 +312,10 @@ export function createDropzone(_props: DropzoneOptions) { inputRef.set(node); node.setAttribute("type", "file"); node.style.display = "none"; - node.setAttribute("multiple", String(options.multiple)); node.setAttribute("tabIndex", "-1"); - const acceptAttrUnsub = acceptAttr.subscribe((accept) => { - node.setAttribute("accept", accept!); + const unsub = routeProps.subscribe(({ accept, multiple }) => { + node.setAttribute("multiple", String(multiple)); + if (accept) node.setAttribute("accept", accept); }); if (!options.disabled) { node.addEventListener("change", onDropCb); @@ -318,11 +324,10 @@ export function createDropzone(_props: DropzoneOptions) { return { update(options: DropzoneOptions) { props.update(($props) => ({ ...$props, ...options })); - node.setAttribute("multiple", String(options.multiple)); }, destroy() { inputRef.set(null); - acceptAttrUnsub(); + unsub(); node.removeEventListener("change", onDropCb); node.removeEventListener("click", onInputElementClick); }, diff --git a/packages/dropzone/src/types.ts b/packages/dropzone/src/types.ts index 2b2228b8ef..ffd6acf2a4 100644 --- a/packages/dropzone/src/types.ts +++ b/packages/dropzone/src/types.ts @@ -1,13 +1,12 @@ +import type { ExpandedRouteConfig, FileWithState } from "@uploadthing/shared"; + export type AcceptProp = Record; export type DropzoneOptions = { - multiple?: boolean; - accept?: AcceptProp | undefined; + routeConfig: ExpandedRouteConfig | undefined; minSize?: number; - maxSize?: number; - maxFiles?: number; disabled?: boolean | undefined; - onDrop: (acceptedFiles: T[]) => void; + onDrop: (acceptedFiles: FileWithState[]) => void; }; export type DropzoneState = { @@ -16,5 +15,5 @@ export type DropzoneState = { isDragAccept: boolean; isDragReject: boolean; isFileDialogActive: boolean; - acceptedFiles: File[]; + acceptedFiles: FileWithState[]; }; diff --git a/packages/dropzone/src/vue.ts b/packages/dropzone/src/vue.ts index cfd6ed48f6..dea7a87be1 100644 --- a/packages/dropzone/src/vue.ts +++ b/packages/dropzone/src/vue.ts @@ -10,8 +10,9 @@ import { watch, } from "vue"; +import type { FileWithState } from "@uploadthing/shared"; + import { - acceptPropAsAcceptAttr, allFilesAccepted, initialState, isEnterOrSpace, @@ -22,6 +23,7 @@ import { isValidQuantity, isValidSize, noop, + routeConfigToDropzoneProps, } from "./core"; import type { DropzoneOptions } from "./types"; @@ -45,8 +47,8 @@ export function useDropzone(options: DropzoneOptions) { }, ); - const acceptAttr = computed(() => - acceptPropAsAcceptAttr(optionsRef.value.accept), + const routeProps = computed(() => + routeConfigToDropzoneProps(optionsRef.value.routeConfig), ); const rootRef = ref(); @@ -108,7 +110,7 @@ export function useDropzone(options: DropzoneOptions) { fileCount > 0 && allFilesAccepted({ files: files as File[], - accept: acceptAttr.value!, + accept: routeProps.value.accept, minSize: optionsRef.value.minSize, maxSize: optionsRef.value.maxSize, multiple: optionsRef.value.multiple, @@ -166,10 +168,10 @@ export function useDropzone(options: DropzoneOptions) { }; const setFiles = (files: File[]) => { - const acceptedFiles: File[] = []; + const acceptedFiles: FileWithState[] = []; files.forEach((file) => { - const accepted = isFileAccepted(file, acceptAttr.value!); + const accepted = isFileAccepted(file, routeProps.value.accept); const sizeMatch = isValidSize( file, optionsRef.value.minSize, @@ -177,7 +179,11 @@ export function useDropzone(options: DropzoneOptions) { ); if (accepted && sizeMatch) { - acceptedFiles.push(file); + const fileWithState: FileWithState = Object.assign(file, { + status: "pending" as const, + key: null, + }); + acceptedFiles.push(fileWithState); } }); @@ -269,7 +275,7 @@ export function useDropzone(options: DropzoneOptions) { ref: inputRef, type: "file", style: "display: none", - accept: acceptAttr.value ?? "", // exactOptionalPropertyTypes: true + accept: routeProps.value.accept!, multiple: optionsRef.value.multiple, tabindex: -1, ...(!optionsRef.value.disabled diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx index 6c25e05e22..612ac174fc 100644 --- a/packages/react/src/components/button.tsx +++ b/packages/react/src/components/button.tsx @@ -21,9 +21,9 @@ import type { } from "@uploadthing/shared"; import type { FileRouter } from "uploadthing/types"; +import { usePaste } from "../hooks/use-paste"; +import { INTERNAL_uploadthingHookGen } from "../hooks/use-uploadthing"; import type { UploadthingComponentProps } from "../types"; -import { INTERNAL_uploadthingHookGen } from "../useUploadThing"; -import { usePaste } from "../utils/usePaste"; import { Cancel, progressWidths, Spinner } from "./shared"; type ButtonStyleFieldCallbackArgs = { @@ -92,8 +92,7 @@ export function UploadButton< ? ErrorMessage<"You forgot to pass the generic"> : UploadButtonProps, ) { - // Cast back to UploadthingComponentProps to get the correct type - // since the ErrorMessage messes it up otherwise + // Cast back to UploadthingComponentProps to get the correct type. ErrorMessage is unreachable const $props = props as unknown as UploadButtonProps< TRouter, TEndpoint, @@ -132,7 +131,7 @@ export function UploadButton< }, onUploadProgress: (p) => { setUploadProgress(p); - $props.onUploadProgress?.(p); + $props.onUploadProgress?.(p, undefined); }, onUploadError: $props.onUploadError, onUploadBegin: $props.onUploadBegin, @@ -195,7 +194,7 @@ export function UploadButton< const pastedFiles = getFilesFromClipboardEvent(event); if (!pastedFiles) return; - let filesToUpload = pastedFiles; + let filesToUpload = pastedFiles as File[]; setFiles((prev) => { filesToUpload = [...prev, ...pastedFiles]; return filesToUpload; diff --git a/packages/react/src/components/dropzone.tsx b/packages/react/src/components/dropzone.tsx index 831ab85385..b8bdd2097e 100644 --- a/packages/react/src/components/dropzone.tsx +++ b/packages/react/src/components/dropzone.tsx @@ -1,13 +1,12 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { twMerge } from "tailwind-merge"; import { useDropzone } from "@uploadthing/dropzone/react"; import { allowedContentTextLabelGenerator, contentFieldToContent, - generateClientDropzoneAccept, generatePermittedFileTypes, getFilesFromClipboardEvent, resolveMaybeUrlArg, @@ -18,12 +17,14 @@ import { import type { ContentField, ErrorMessage, + FileWithState, StyleField, } from "@uploadthing/shared"; import type { FileRouter } from "uploadthing/types"; +import { usePaste } from "../hooks/use-paste"; +import { INTERNAL_uploadthingHookGen } from "../hooks/use-uploadthing"; import type { UploadthingComponentProps } from "../types"; -import { INTERNAL_uploadthingHookGen } from "../useUploadThing"; import { Cancel, progressWidths, Spinner } from "./shared"; type DropzoneStyleFieldCallbackArgs = { @@ -117,16 +118,16 @@ export function UploadDropzone< url: resolveMaybeUrlArg($props.url), }); - const [files, setFiles] = useState([]); - const [uploadProgressState, setUploadProgress] = useState( $props.__internal_upload_progress ?? 0, ); const uploadProgress = $props.__internal_upload_progress ?? uploadProgressState; - const { startUpload, isUploading, permittedFileInfo } = useUploadThing( - $props.endpoint, - { + + const { files, setFiles, startUpload, isUploading, routeConfig } = + useUploadThing($props.endpoint, { + files: $props.files, + onFilesChange: $props.onFilesChange, signal: acRef.current.signal, headers: $props.headers, skipPolling: !$props?.onClientUploadComplete ? true : $props?.skipPolling, @@ -135,15 +136,16 @@ export function UploadDropzone< void $props.onClientUploadComplete?.(res); setUploadProgress(0); }, - onUploadProgress: (p) => { + onUploadProgress: (p, e) => { setUploadProgress(p); - $props.onUploadProgress?.(p); + $props.onUploadProgress?.(p, e); }, onUploadError: $props.onUploadError, onUploadBegin: $props.onUploadBegin, onBeforeUploadBegin: $props.onBeforeUploadBegin, - }, - ); + }); + + const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig); const uploadFiles = useCallback( (files: File[]) => { @@ -158,12 +160,8 @@ export function UploadDropzone< [$props, startUpload, fileRouteInput], ); - const { fileTypes, multiple } = generatePermittedFileTypes( - permittedFileInfo?.config, - ); - const onDrop = useCallback( - (acceptedFiles: File[]) => { + (acceptedFiles: FileWithState[]) => { $props.onDrop?.(acceptedFiles); setFiles(acceptedFiles); @@ -171,7 +169,7 @@ export function UploadDropzone< // If mode is auto, start upload immediately if (mode === "auto") uploadFiles(acceptedFiles); }, - [$props, mode, uploadFiles], + [$props, mode, setFiles, uploadFiles], ); const isDisabled = (() => { @@ -183,8 +181,7 @@ export function UploadDropzone< const { getRootProps, getInputProps, isDragActive, rootRef } = useDropzone({ onDrop, - multiple, - accept: fileTypes ? generateClientDropzoneAccept(fileTypes) : undefined, + routeConfig, disabled: isDisabled, }); @@ -211,28 +208,24 @@ export function UploadDropzone< } }; - useEffect(() => { - const handlePaste = (event: ClipboardEvent) => { - if (!appendOnPaste) return; - if (document.activeElement !== rootRef.current) return; - - const pastedFiles = getFilesFromClipboardEvent(event); - if (!pastedFiles?.length) return; + usePaste((event) => { + if (!appendOnPaste) return; + if (document.activeElement !== rootRef.current) return; - let filesToUpload = pastedFiles; - setFiles((prev) => { - filesToUpload = [...prev, ...pastedFiles]; - return filesToUpload; - }); + const pastedFiles = getFilesFromClipboardEvent(event); + if (!pastedFiles?.length) return; - if (mode === "auto") uploadFiles(filesToUpload); - }; + let filesToUpload = pastedFiles; + setFiles((prev) => { + filesToUpload = [...prev, ...pastedFiles]; + return filesToUpload; + }); - window.addEventListener("paste", handlePaste); - return () => { - window.removeEventListener("paste", handlePaste); - }; - }, [uploadFiles, $props, appendOnPaste, mode, fileTypes, rootRef, files]); + if (mode === "auto") { + const input = "input" in $props ? $props.input : undefined; + void startUpload(filesToUpload, input); + } + }); const getUploadButtonText = (fileTypes: string[]) => { if (files.length > 0) @@ -338,7 +331,7 @@ export function UploadDropzone< data-state={state} > {contentFieldToContent($props.content?.allowedContent, styleFieldArg) ?? - allowedContentTextLabelGenerator(permittedFileInfo?.config)} + allowedContentTextLabelGenerator(routeConfig)} + + + ); +} diff --git a/playground/src/app/globals.css b/playground/src/app/globals.css new file mode 100644 index 0000000000..60ada06638 --- /dev/null +++ b/playground/src/app/globals.css @@ -0,0 +1,178 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +@media (max-width: 640px) { + .container { + @apply px-4; + } +} + +/** + * Spinner from Geist: https://vercel.com/geist/spinner + */ +.spinner_spinner__fqUfx, +.spinner_wrapper__zbFtL { + height: var(--spinner-size, 20px); + width: var(--spinner-size, 20px); +} + +.spinner_spinner__fqUfx { + position: relative; + top: 50%; + left: 50%; +} + +.spinner_bar__VysK5 { + animation: spinner_spin__7lZMA 1.2s linear infinite; + background: var(--spinner-color, var(--muted-foreground)); + border-radius: var(--radius); + height: 8%; + left: -10%; + position: absolute; + top: -3.9%; + width: 24%; +} + +.spinner_bar__VysK5:first-child { + animation-delay: -1.2s; + transform: rotate(0.0001deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(2) { + animation-delay: -1.1s; + transform: rotate(30deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(3) { + animation-delay: -1s; + transform: rotate(60deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(4) { + animation-delay: -0.9s; + transform: rotate(90deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(5) { + animation-delay: -0.8s; + transform: rotate(120deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(6) { + animation-delay: -0.7s; + transform: rotate(150deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(7) { + animation-delay: -0.6s; + transform: rotate(180deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(8) { + animation-delay: -0.5s; + transform: rotate(210deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(9) { + animation-delay: -0.4s; + transform: rotate(240deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(10) { + animation-delay: -0.3s; + transform: rotate(270deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(11) { + animation-delay: -0.2s; + transform: rotate(300deg) translate(146%); +} + +.spinner_bar__VysK5:nth-child(12) { + animation-delay: -0.1s; + transform: rotate(330deg) translate(146%); +} + +@keyframes spinner_spin__7lZMA { + 0% { + opacity: 1; + } + + to { + opacity: 0.15; + } +} diff --git a/playground/src/app/layout.tsx b/playground/src/app/layout.tsx new file mode 100644 index 0000000000..6318c813c1 --- /dev/null +++ b/playground/src/app/layout.tsx @@ -0,0 +1,48 @@ +import { Inter } from "next/font/google"; + +import "./globals.css"; + +import Link from "next/link"; +import { Toaster } from "sonner"; +import { twMerge } from "tailwind-merge"; + +import { NextSSRPlugin } from "@uploadthing/react/next-ssr-plugin"; +import { extractRouterConfig } from "uploadthing/server"; + +import { buttonVariants } from "~/components/ui/button"; +import { uploadRouter } from "~/uploadthing/server"; + +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + ); +} diff --git a/playground/src/app/page.tsx b/playground/src/app/page.tsx new file mode 100644 index 0000000000..4f6643484f --- /dev/null +++ b/playground/src/app/page.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return

Select a demo

; +} diff --git a/playground/src/app/rhf-builtin/page.tsx b/playground/src/app/rhf-builtin/page.tsx new file mode 100644 index 0000000000..e77cb6b8e9 --- /dev/null +++ b/playground/src/app/rhf-builtin/page.tsx @@ -0,0 +1,5 @@ +import { SimpleRHFDemo } from "./rhf"; + +export default function DemoPage() { + return ; +} diff --git a/playground/src/app/rhf-builtin/rhf.tsx b/playground/src/app/rhf-builtin/rhf.tsx new file mode 100644 index 0000000000..5bfbb587aa --- /dev/null +++ b/playground/src/app/rhf-builtin/rhf.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { toast } from "sonner"; +import { z } from "zod"; + +import { Button } from "~/components/ui/button"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "~/components/ui/form"; +import { UploadDropzone } from "~/uploadthing/client"; +import { fileWithStateValidator } from "~/utils"; + +/** + * A demo using RHF with built-in components from UT + */ +export const SimpleRHFDemo = () => { + const form = useForm({ + schema: z.object({ + images: fileWithStateValidator.array(), + }), + defaultValues: { + images: [], + }, + mode: "onChange", + }); + + const onSubmit = form.handleSubmit((data) => { + toast( +
+        {JSON.stringify(data, null, 4)}
+      
, + ); + form.reset(); + }); + + return ( +
+
+ + ( + + Upload images + { + console.log("files", files); + return field.onChange(files); + }} + config={{ mode: "auto" }} + /> + + )} + /> + + + + +
+        Form: {JSON.stringify(form.watch(), null, 4)}
+      
+
+        Errors: {JSON.stringify(form.formState.errors, null, 4)}
+      
+
+ ); +}; diff --git a/playground/src/app/rhf-custom/page.tsx b/playground/src/app/rhf-custom/page.tsx new file mode 100644 index 0000000000..e77cb6b8e9 --- /dev/null +++ b/playground/src/app/rhf-custom/page.tsx @@ -0,0 +1,5 @@ +import { SimpleRHFDemo } from "./rhf"; + +export default function DemoPage() { + return ; +} diff --git a/playground/src/app/rhf-custom/rhf.tsx b/playground/src/app/rhf-custom/rhf.tsx new file mode 100644 index 0000000000..9d07fe9a92 --- /dev/null +++ b/playground/src/app/rhf-custom/rhf.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { UploadIcon } from "@radix-ui/react-icons"; +import { toast } from "sonner"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { useDropzone } from "@uploadthing/react"; +import { FileWithState } from "uploadthing/types"; + +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + useForm, +} from "~/components/ui/form"; +import { useUploadThing } from "~/uploadthing/client"; +import { fileWithStateValidator } from "~/utils"; + +const MyDropzone = (props: { + files: FileWithState[]; + onFilesChange: (files: FileWithState[]) => void; +}) => { + const { routeConfig, startUpload } = useUploadThing("imageUploader", { + skipPolling: true, + files: props.files, + onFilesChange: props.onFilesChange, + }); + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + routeConfig, + onDrop: (files) => { + const updatedFiles = [...props.files, ...files]; + props.onFilesChange(updatedFiles); + startUpload(updatedFiles); + }, + }); + + return ( +
+ +
+
+
+
+

+ Drag {`'n'`} drop files here, or click to select files +

+

+ You can upload: {Object.keys(routeConfig ?? {}).join(", ")} +

+
+
+
+ ); +}; + +/** + * A demo using RHF with a custom dropzone components + */ +export const SimpleRHFDemo = () => { + const form = useForm({ + schema: z.object({ + images: fileWithStateValidator.array(), + }), + defaultValues: { + images: [], + }, + mode: "onChange", + }); + + const onSubmit = form.handleSubmit((data) => { + toast( +
+        {JSON.stringify(data, null, 4)}
+      
, + ); + form.reset(); + }); + + return ( +
+
+ + ( + + Images + + + + + )} + /> + + + + +
+        Form: {JSON.stringify(form.watch(), null, 4)}
+      
+
+        formstate: {JSON.stringify(form.formState, null, 4)}
+      
+
+ ); +}; diff --git a/playground/src/components/file-preview.tsx b/playground/src/components/file-preview.tsx new file mode 100644 index 0000000000..989921df2f --- /dev/null +++ b/playground/src/components/file-preview.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import { ImageIcon } from "@radix-ui/react-icons"; +import { toast } from "sonner"; +import { twMerge } from "tailwind-merge"; + +import { Card, CardDescription, CardTitle } from "./ui/card"; + +interface EmptyCardProps extends React.ComponentPropsWithoutRef { + title: string; + description?: string; + Icon?: React.ComponentType<{ className?: string }>; +} + +export const EmptyCard = ({ + title, + description, + Icon = ImageIcon, + className, + ...props +}: EmptyCardProps) => { + return ( + +
+
+
+ {title} + {description ? {description} : null} +
+
+ ); +}; + +export const FilePreview = ({ + file, +}: { + file: { key: string; name: string }; +}) => { + const [errored, setErrored] = React.useState(false); + + const ext = file.name.split(".")!.pop()!; + if (errored || !["png", "jpg", "jpeg", "gif"].includes(ext)) { + const Icon = ImageIcon; // TODO: Dynamic icon + + return ( +
+
+ ); + } + + return ( +
+ {file.name} { + setErrored(true); + toast.error(`Failed to load file ${file.name}`); + }} + /> +
+ ); +}; diff --git a/playground/src/components/file-uploader.tsx b/playground/src/components/file-uploader.tsx new file mode 100644 index 0000000000..1a446f65c2 --- /dev/null +++ b/playground/src/components/file-uploader.tsx @@ -0,0 +1,194 @@ +import "client-only"; + +import * as React from "react"; +import Image from "next/image"; +import { Cross2Icon, UploadIcon } from "@radix-ui/react-icons"; +import { twMerge } from "tailwind-merge"; + +import { useDropzone } from "@uploadthing/react"; +import { bytesToFileSize } from "uploadthing/client"; +import { FileWithState } from "uploadthing/types"; + +import { Button } from "~/components/ui/button"; +import { Progress } from "~/components/ui/progress"; +import { useUploadThing } from "~/uploadthing/client"; +import { LoadingSpinner } from "./ui/loading"; + +interface FileUploaderProps extends React.HTMLAttributes { + files: FileWithState[]; + onFilesChange: (files: FileWithState[]) => void; +} + +export function FileUploader({ + files, + onFilesChange, + className, + ...dropzoneProps +}: FileUploaderProps) { + const [progresses, setProgresses] = React.useState(new Map()); + const { routeConfig, startUpload } = useUploadThing("imageUploader", { + files, + onFilesChange, + skipPolling: true, + onUploadError: (e) => console.error(e), + onUploadProgress: (_, e) => { + if (!e) return; + setProgresses((p) => new Map(p).set(e?.file, e?.progress)); + }, + }); + + const onDrop = React.useCallback( + (acceptedFiles: FileWithState[]) => { + const newFiles = acceptedFiles.map((file) => + Object.assign(file, { + preview: URL.createObjectURL(file), + }), + ); + + const updatedFiles = files ? [...files, ...newFiles] : newFiles; + onFilesChange(updatedFiles); + void startUpload(updatedFiles); + }, + [files], + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + routeConfig, + }); + + // Revoke preview url when component unmounts + React.useEffect(() => { + return () => { + if (!files) return; + files.forEach((file) => { + if (isFileWithPreview(file)) { + URL.revokeObjectURL(file.preview); + } + }); + }; + }, []); + + return ( +
+
+ + {isDragActive ? ( +
+
+
+

+ Drop the files here +

+
+ ) : ( +
+
+
+
+

+ Drag {`'n'`} drop files here, or click to select files +

+

You can upload

+
+
+ )} +
+ + {files?.length ? ( +
+ {files?.map((file, index) => ( + { + if (!files) return; + const newFiles = files.filter((_, i) => i !== index); + onFilesChange?.(newFiles); + }} + progress={progresses.get(file.name)} + /> + ))} +
+ ) : null} +
+ ); +} + +interface FileCardProps { + file: FileWithState; + onRemove: () => void; + progress?: number; +} + +function FileCard({ file, progress = 0, onRemove }: FileCardProps) { + return ( +
+
+ {isFileWithPreview(file) ? ( + {file.name} + ) : null} +
+
+
+

+ {file.name} +

+

+ {bytesToFileSize(file.size)} +

+
+ {file.status === "uploading" && ( +
+ {progress < 100 ? : null} + {progress}% +
+ )} +
+ +
+
+ {file.status === "pending" && ( + + )} +
+ ); +} + +function isFileWithPreview(file: File): file is File & { preview: string } { + return "preview" in file && typeof file.preview === "string"; +} diff --git a/playground/src/components/load-more.tsx b/playground/src/components/load-more.tsx new file mode 100644 index 0000000000..5f8c7434b9 --- /dev/null +++ b/playground/src/components/load-more.tsx @@ -0,0 +1,93 @@ +"use client"; + +/** + * Shamlessly stolen from Gabriel + * @see https://github.com/gabrielelpidio/next-infinite-scroll-server-actions/blob/main/src/components/loadMore.tsx + */ +import * as React from "react"; +import { twMerge } from "tailwind-merge"; + +import { LoadingDots } from "./ui/loading"; + +type LoadMoreAction = ( + offset: number, +) => Promise; + +const LoadMore = ({ + children, + initialOffset, + loadMoreAction, +}: React.PropsWithChildren<{ + initialOffset: number; + loadMoreAction: LoadMoreAction; +}>) => { + const ref = React.useRef(null); + const [elements, setElements] = React.useState([] as React.ReactNode[]); + + const currentOffset = React.useRef(initialOffset); + const [loading, setLoading] = React.useState(false); + + const loadMore = React.useCallback( + async (abortController?: AbortController) => { + if (currentOffset.current === null) return; + + setLoading(true); + loadMoreAction(currentOffset.current) + .then(([node, next]) => { + if (abortController?.signal.aborted) return; + + setElements((prev) => [...prev, node]); + currentOffset.current = next; + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, + [loadMoreAction], + ); + + React.useEffect(() => { + const signal = new AbortController(); + + const element = ref.current; + + const observer = new IntersectionObserver(([entry]) => { + if (entry?.isIntersecting && !loading) { + loadMore(signal); + } + }); + + if (element) { + observer.observe(element); + } + + return () => { + signal.abort(); + if (element) { + observer.unobserve(element); + } + }; + }, [loadMore]); + + return ( + <> +
+ {children} + {elements} +
+
+ {currentOffset.current === null && ( + No more files + )} + {loading && } +
+ + ); +}; + +export { LoadMore }; diff --git a/playground/src/components/ui/button.tsx b/playground/src/components/ui/button.tsx new file mode 100644 index 0000000000..3865f6830b --- /dev/null +++ b/playground/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { twMerge } from "tailwind-merge"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/playground/src/components/ui/card.tsx b/playground/src/components/ui/card.tsx new file mode 100644 index 0000000000..03de6dc9f1 --- /dev/null +++ b/playground/src/components/ui/card.tsx @@ -0,0 +1,82 @@ +import * as React from "react"; +import { twMerge } from "tailwind-merge"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/playground/src/components/ui/form.tsx b/playground/src/components/ui/form.tsx new file mode 100644 index 0000000000..ed68e4c3bc --- /dev/null +++ b/playground/src/components/ui/form.tsx @@ -0,0 +1,197 @@ +import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + useForm as useFormHook, + UseFormProps, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { twMerge } from "tailwind-merge"; +import { ZodType } from "zod"; + +import { Label } from "~/components/ui/label"; + +export const useForm = ( + props: Omit, "resolver"> & { + schema: TSchema; + }, +) => { + const form = useFormHook({ + ...props, + resolver: zodResolver(props.schema, undefined), + }); + + return form; +}; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +