diff --git a/docs/content/docs/components/file-field.mdx b/docs/content/docs/components/file-field.mdx index e1f527ad..8cbd38de 100644 --- a/docs/content/docs/components/file-field.mdx +++ b/docs/content/docs/components/file-field.mdx @@ -57,7 +57,7 @@ First you will need to follow the [manual install setup](./manual-install) guide - [uploader-provider](./uploader-provider) - [dropzone](./dropzone) -- [multi-file](./multi-file) +- [file-list](./file-list) diff --git a/docs/content/docs/components/file-list.mdx b/docs/content/docs/components/file-list.mdx new file mode 100644 index 00000000..4ad48a0b --- /dev/null +++ b/docs/content/docs/components/file-list.mdx @@ -0,0 +1,103 @@ +--- +title: File List +description: Lists the files of an UploaderProvider with their status, progress and actions. +--- + +import { LimitedCode } from '@/components/ui/limited-code'; +import { + OpenTabs, + OpenTabsContent, + OpenTabsList, + OpenTabsTrigger, +} from '@/components/ui/open-tabs'; +import { Callout } from 'fumadocs-ui/components/callout'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; + +`file-list.tsx` exports: + +- `FileList`: lists the files of the nearest `UploaderProvider`. +- `FileListItem`: renders one file with its status, progress and actions (retry, cancel, remove). +- `FileBadge`: renders the colored file-type tile. + +Use it next to an [Upload Button](./upload-button), or anywhere you want the files of an `UploaderProvider` shown as a list. + + + The [multi-file](./multi-file) and [file field](./file-field) components + install it automatically via the CLI. + + +## Installation + + + + + CLI + Manual + + + + +Use the shadcn CLI to add the component to your project. + +```package-install +npx shadcn@latest add https://edgestore.dev/r/file-list.json +``` + + + + + + + + + +### Setup for manual installation + +First you will need to follow the [manual install setup](./manual-install) guide. + + + + + +### Install required components + +- [uploader-provider](./uploader-provider) +- [progress-bar](./progress-bar) + + + + + +### Copy this component + + + +../../../src/components/upload/file-list.tsx + +../../../src/components/upload/upload-utils.ts + + + + + + + + + + + +## Usage + +```tsx + + {/* anything that adds files, e.g. */} + + +``` + +## Props + +| Prop | Type | Default | Description | +| ---------- | --------- | ------- | ------------------------------------------- | +| `disabled` | `boolean` | `false` | Hides the retry, cancel and remove actions. | +| `...` | | | Any other `\n );\n}\n", + "type": "registry:component", + "target": "components/upload/file-list.tsx" + } + ], + "type": "registry:component" +} \ No newline at end of file diff --git a/docs/public/r/file-uploader-block.json b/docs/public/r/file-uploader-block.json index fab6dd14..6cabbfec 100644 --- a/docs/public/r/file-uploader-block.json +++ b/docs/public/r/file-uploader-block.json @@ -11,8 +11,8 @@ { "path": "examples/components/src/components/upload/blocks/multi-file-block.tsx", "content": "'use client';\n\nimport { FileUploader } from '@/components/upload/multi-file';\nimport {\n UploaderProvider,\n useUploader,\n type CompletedFileState,\n type UploadFn,\n} from '@/components/upload/uploader-provider';\nimport * as React from 'react';\n\nexport default function FileUploaderBlock() {\n const { edgestore } = useMockEdgeStore(); // Mock edgestore for easy v0 integration\n\n const uploadFn: UploadFn = React.useCallback(\n async ({ file, signal, onProgressChange }) => {\n const res = await edgestore.myPublicFiles.upload({\n file,\n signal,\n onProgressChange,\n });\n // you can run some server action or api here\n // to add the necessary data to your database\n return { url: res.url };\n },\n [edgestore],\n );\n\n return (\n
\n
\n \n \n \n \n
\n
\n );\n}\n\nfunction CompletedFiles() {\n const { fileStates } = useUploader();\n\n const completedFiles = fileStates.filter(\n (fs): fs is CompletedFileState => fs.status === 'COMPLETE',\n );\n\n if (completedFiles.length === 0) {\n return null;\n }\n\n return (\n
\n

Uploaded files

\n \n
\n );\n}\n\n// Mock implementation of EdgeStore\nfunction useMockEdgeStore() {\n return {\n edgestore: {\n myPublicFiles: {\n upload: async ({\n signal,\n onProgressChange,\n }: {\n file: File;\n signal?: AbortSignal;\n onProgressChange?: (progress: number) => void | Promise;\n }) => {\n // Simulate upload progress. Rejects like EdgeStore when the upload is aborted.\n await new Promise((resolve, reject) => {\n let progress = 0;\n const interval = setInterval(() => {\n progress = Math.min(\n progress + Math.floor(Math.random() * 21) + 10,\n 100,\n );\n void onProgressChange?.(progress);\n if (progress >= 100) {\n clearInterval(interval);\n resolve();\n }\n }, 300);\n signal?.addEventListener('abort', () => {\n clearInterval(interval);\n reject(new DOMException('Upload aborted', 'AbortError'));\n });\n });\n\n return {\n url: 'https://edgestore.dev/img/upload-demo.webp',\n };\n },\n },\n },\n };\n}\n", - "type": "registry:block", - "target": "components/upload/blocks/multi-file-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ], "type": "registry:block" diff --git a/docs/public/r/file-uploader.json b/docs/public/r/file-uploader.json index 8db1bb9a..80944ff7 100644 --- a/docs/public/r/file-uploader.json +++ b/docs/public/r/file-uploader.json @@ -10,14 +10,14 @@ "registryDependencies": [ "button", "https://edgestore.dev/r/dropzone.json", - "https://edgestore.dev/r/progress-bar.json", + "https://edgestore.dev/r/file-list.json", "https://edgestore.dev/r/uploader-provider.json", "https://edgestore.dev/r/upload-utils.json" ], "files": [ { "path": "examples/components/src/components/upload/multi-file.tsx", - "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport { cn } from '@/lib/utils';\nimport { CheckIcon, RotateCwIcon, XIcon } from 'lucide-react';\nimport * as React from 'react';\nimport { type Accept } from 'react-dropzone';\nimport { Dropzone } from './dropzone';\nimport { ProgressBar } from './progress-bar';\nimport {\n fileExtension,\n fileKind,\n formatFileSize,\n type FileKind,\n} from './upload-utils';\nimport { useUploader, type FileState } from './uploader-provider';\n\nconst KIND_CLASSNAMES: Record = {\n image: 'text-purple-600 dark:text-purple-400',\n video: 'text-pink-600 dark:text-pink-400',\n audio: 'text-cyan-600 dark:text-cyan-400',\n pdf: 'text-red-600 dark:text-red-400',\n doc: 'text-blue-600 dark:text-blue-400',\n sheet: 'text-green-600 dark:text-green-400',\n slides: 'text-orange-600 dark:text-orange-400',\n archive: 'text-yellow-600 dark:text-yellow-400',\n code: 'text-teal-600 dark:text-teal-400',\n other: 'text-muted-foreground',\n};\n\n/**\n * A colored tile with the file's extension.\n */\nexport function FileBadge({\n file,\n className,\n}: {\n file: File;\n className?: string;\n}) {\n return (\n \n {fileExtension(file.name).slice(0, 4) || 'file'}\n \n );\n}\n\nfunction IconButton({\n label,\n className,\n ...props\n}: React.ComponentProps<'button'> & { label: string }) {\n return (\n \n );\n}\n\n/**\n * One file with its status, progress and actions (retry, cancel, remove).\n */\nexport function FileListItem({\n fileState,\n disabled,\n className,\n ...props\n}: React.ComponentProps<'li'> & {\n fileState: FileState;\n /** Hides the retry, cancel and remove actions. */\n disabled?: boolean;\n}) {\n const { removeFile, cancelUpload, uploadFiles } = useUploader();\n const { file, key, status, progress, error } = fileState;\n\n return (\n \n \n
\n
\n \n {file.name}\n \n {status === 'COMPLETE' && (\n \n \n Uploaded\n \n )}\n
\n \n {status === 'ERROR'\n ? (error ?? 'Upload failed')\n : status === 'UPLOADING'\n ? `${formatFileSize((file.size * progress) / 100)} of ${formatFileSize(file.size)} · ${Math.round(progress)}%`\n : formatFileSize(file.size)}\n

\n
\n {/* Pinned to the bottom edge so the row keeps its height when the upload ends. */}\n {status === 'UPLOADING' && (\n \n )}\n {!disabled && (\n
\n {status === 'ERROR' && (\n void uploadFiles([key])}\n >\n \n \n )}\n {status === 'UPLOADING' ? (\n cancelUpload(key)}\n >\n \n \n ) : (\n removeFile(key)}\n >\n \n \n )}\n
\n )}\n \n );\n}\n\n/**\n * Lists the files of the nearest `UploaderProvider`.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport function FileList({\n disabled,\n className,\n ...props\n}: React.ComponentProps<'ul'> & {\n /** Hides the retry, cancel and remove actions. */\n disabled?: boolean;\n}) {\n const { fileStates } = useUploader();\n if (fileStates.length === 0) return null;\n\n return (\n \n {fileStates.map((fileState) => (\n \n ))}\n \n );\n}\n\n/**\n * Props for the FileUploader component.\n */\nexport type FileUploaderProps = React.ComponentProps<'div'> & {\n /**\n * Maximum number of files allowed in total.\n */\n maxFiles?: number;\n\n /**\n * Maximum file size in bytes.\n */\n maxSize?: number;\n\n /**\n * Accepted file types.\n *\n * @example\n * ```tsx\n * accept={{ 'application/pdf': ['.pdf'] }}\n * ```\n */\n accept?: Accept;\n\n /**\n * Human-readable list of accepted types, shown in the hint and messages.\n *\n * @example \"PDF or TXT\"\n */\n typesLabel?: string;\n\n /**\n * Whether the uploader is disabled.\n */\n disabled?: boolean;\n\n /**\n * Additional className for the dropzone.\n */\n dropzoneClassName?: string;\n\n /**\n * Additional className for the file list.\n */\n fileListClassName?: string;\n};\n\n/**\n * A dropzone with a list of files, their progress and a summary.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport function FileUploader({\n maxFiles,\n maxSize,\n accept,\n typesLabel,\n disabled,\n className,\n dropzoneClassName,\n fileListClassName,\n ...props\n}: FileUploaderProps) {\n const { fileStates, resetFiles } = useUploader();\n const completed = fileStates.filter((fs) => fs.status === 'COMPLETE').length;\n const totalSize = fileStates.reduce((sum, fs) => sum + fs.file.size, 0);\n\n return (\n
\n \n \n {fileStates.length > 0 && (\n
\n \n {completed} of {fileStates.length} uploaded ·{' '}\n {formatFileSize(totalSize)}\n \n \n Clear all\n \n
\n )}\n
\n );\n}\n", + "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport { cn } from '@/lib/utils';\nimport * as React from 'react';\nimport { type Accept } from 'react-dropzone';\nimport { Dropzone } from './dropzone';\nimport { FileList } from './file-list';\nimport { formatFileSize } from './upload-utils';\nimport { useUploader } from './uploader-provider';\n\n/**\n * Props for the FileUploader component.\n */\nexport type FileUploaderProps = React.ComponentProps<'div'> & {\n /**\n * Maximum number of files allowed in total.\n */\n maxFiles?: number;\n\n /**\n * Maximum file size in bytes.\n */\n maxSize?: number;\n\n /**\n * Accepted file types.\n *\n * @example\n * ```tsx\n * accept={{ 'application/pdf': ['.pdf'] }}\n * ```\n */\n accept?: Accept;\n\n /**\n * Human-readable list of accepted types, shown in the hint and messages.\n *\n * @example \"PDF or TXT\"\n */\n typesLabel?: string;\n\n /**\n * Whether the uploader is disabled.\n */\n disabled?: boolean;\n\n /**\n * Additional className for the dropzone.\n */\n dropzoneClassName?: string;\n\n /**\n * Additional className for the file list.\n */\n fileListClassName?: string;\n};\n\n/**\n * A dropzone with a list of files, their progress and a summary.\n *\n * @example\n * ```tsx\n * \n * ```\n */\nexport function FileUploader({\n maxFiles,\n maxSize,\n accept,\n typesLabel,\n disabled,\n className,\n dropzoneClassName,\n fileListClassName,\n ...props\n}: FileUploaderProps) {\n const { fileStates, resetFiles } = useUploader();\n const completed = fileStates.filter((fs) => fs.status === 'COMPLETE').length;\n const totalSize = fileStates.reduce((sum, fs) => sum + fs.file.size, 0);\n\n return (\n
\n \n \n {fileStates.length > 0 && (\n
\n \n {completed} of {fileStates.length} uploaded ·{' '}\n {formatFileSize(totalSize)}\n \n \n Clear all\n \n
\n )}\n
\n );\n}\n", "type": "registry:component", "target": "components/upload/multi-file.tsx" } diff --git a/docs/public/r/multi-image-uploader-block.json b/docs/public/r/multi-image-uploader-block.json index 505f5299..d3e0c6cd 100644 --- a/docs/public/r/multi-image-uploader-block.json +++ b/docs/public/r/multi-image-uploader-block.json @@ -11,8 +11,8 @@ { "path": "examples/components/src/components/upload/blocks/multi-image-block.tsx", "content": "'use client';\n\nimport { ImageUploader } from '@/components/upload/multi-image';\nimport {\n UploaderProvider,\n useUploader,\n type CompletedFileState,\n type UploadFn,\n} from '@/components/upload/uploader-provider';\nimport * as React from 'react';\n\nexport default function MultiImageUploaderBlock() {\n const { edgestore } = useMockEdgeStore(); // Mock edgestore for easy v0 integration\n\n const uploadFn: UploadFn = React.useCallback(\n async ({ file, signal, onProgressChange }) => {\n const res = await edgestore.myPublicImages.upload({\n file,\n signal,\n onProgressChange,\n });\n // you can run some server action or api here\n // to add the necessary data to your database\n return { url: res.url };\n },\n [edgestore],\n );\n\n return (\n
\n
\n \n \n \n \n
\n
\n );\n}\n\nfunction CompletedFiles() {\n const { fileStates } = useUploader();\n\n const completedFiles = fileStates.filter(\n (fs): fs is CompletedFileState => fs.status === 'COMPLETE',\n );\n\n if (completedFiles.length === 0) {\n return null;\n }\n\n return (\n
\n

Uploaded files

\n
    \n {completedFiles.map((fs) => (\n
  • \n \n {fs.file.name}\n \n
  • \n ))}\n
\n
\n );\n}\n\n// Mock implementation of EdgeStore\nfunction useMockEdgeStore() {\n return {\n edgestore: {\n myPublicImages: {\n upload: async ({\n signal,\n onProgressChange,\n }: {\n file: File;\n signal?: AbortSignal;\n onProgressChange?: (progress: number) => void | Promise;\n }) => {\n // Simulate upload progress. Rejects like EdgeStore when the upload is aborted.\n await new Promise((resolve, reject) => {\n let progress = 0;\n const interval = setInterval(() => {\n progress = Math.min(\n progress + Math.floor(Math.random() * 21) + 10,\n 100,\n );\n void onProgressChange?.(progress);\n if (progress >= 100) {\n clearInterval(interval);\n resolve();\n }\n }, 300);\n signal?.addEventListener('abort', () => {\n clearInterval(interval);\n reject(new DOMException('Upload aborted', 'AbortError'));\n });\n });\n\n return {\n url: 'https://edgestore.dev/img/upload-demo.webp',\n };\n },\n },\n },\n };\n}\n", - "type": "registry:block", - "target": "components/upload/blocks/multi-image-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ], "type": "registry:block" diff --git a/docs/public/r/progress-bar-block.json b/docs/public/r/progress-bar-block.json index d0235832..f232bb53 100644 --- a/docs/public/r/progress-bar-block.json +++ b/docs/public/r/progress-bar-block.json @@ -9,9 +9,9 @@ "files": [ { "path": "examples/components/src/components/upload/blocks/progress-bar-block.tsx", - "content": "'use client';\n\nimport * as React from 'react';\nimport { ProgressBar } from '../progress-bar';\n\nexport default function ProgressBarUsage() {\n const [progress, setProgress] = React.useState(0);\n\n React.useEffect(() => {\n const startAnimation = () => {\n const interval = setInterval(() => {\n setProgress((prevProgress) => {\n if (prevProgress >= 100) {\n clearInterval(interval);\n // Reset after 1 second when reaching 100%\n setTimeout(() => {\n setProgress(0);\n startAnimation(); // Restart the animation cycle\n }, 1000);\n return 100;\n }\n // Add a random value between 10-20 percent\n return Math.min(\n 100,\n prevProgress + Math.floor(Math.random() * 11) + 10,\n );\n });\n }, 300);\n\n return interval;\n };\n\n const interval = startAnimation();\n\n return () => {\n clearInterval(interval);\n };\n }, []);\n\n return (\n
\n \n
\n );\n}\n", - "type": "registry:block", - "target": "components/upload/blocks/progress-bar-block.tsx" + "content": "'use client';\n\nimport { ProgressBar } from '@/components/upload/progress-bar';\nimport * as React from 'react';\n\nexport default function ProgressBarUsage() {\n const [progress, setProgress] = React.useState(0);\n\n React.useEffect(() => {\n const startAnimation = () => {\n const interval = setInterval(() => {\n setProgress((prevProgress) => {\n if (prevProgress >= 100) {\n clearInterval(interval);\n // Reset after 1 second when reaching 100%\n setTimeout(() => {\n setProgress(0);\n startAnimation(); // Restart the animation cycle\n }, 1000);\n return 100;\n }\n // Add a random value between 10-20 percent\n return Math.min(\n 100,\n prevProgress + Math.floor(Math.random() * 11) + 10,\n );\n });\n }, 300);\n\n return interval;\n };\n\n const interval = startAnimation();\n\n return () => {\n clearInterval(interval);\n };\n }, []);\n\n return (\n
\n \n
\n );\n}\n", + "type": "registry:page", + "target": "app/page.tsx" } ], "type": "registry:block" diff --git a/docs/public/r/progress-circle-block.json b/docs/public/r/progress-circle-block.json index 86d11fd4..0ec5241c 100644 --- a/docs/public/r/progress-circle-block.json +++ b/docs/public/r/progress-circle-block.json @@ -9,9 +9,9 @@ "files": [ { "path": "examples/components/src/components/upload/blocks/progress-circle-block.tsx", - "content": "'use client';\n\nimport * as React from 'react';\nimport { ProgressCircle } from '../progress-circle';\n\nexport default function ProgressCircleUsage() {\n const [progress, setProgress] = React.useState(0);\n\n React.useEffect(() => {\n const startAnimation = () => {\n const interval = setInterval(() => {\n setProgress((prevProgress) => {\n if (prevProgress >= 100) {\n clearInterval(interval);\n // Reset after 1 second when reaching 100%\n setTimeout(() => {\n setProgress(0);\n startAnimation(); // Restart the animation cycle\n }, 1000);\n return 100;\n }\n // Add a random value between 10-20 percent\n return Math.min(\n 100,\n prevProgress + Math.floor(Math.random() * 11) + 10,\n );\n });\n }, 300);\n\n return interval;\n };\n\n const interval = startAnimation();\n\n return () => {\n clearInterval(interval);\n };\n }, []);\n\n return (\n
\n
\n \n
\n
\n );\n}\n", - "type": "registry:block", - "target": "components/upload/blocks/progress-circle-block.tsx" + "content": "'use client';\n\nimport { ProgressCircle } from '@/components/upload/progress-circle';\nimport * as React from 'react';\n\nexport default function ProgressCircleUsage() {\n const [progress, setProgress] = React.useState(0);\n\n React.useEffect(() => {\n const startAnimation = () => {\n const interval = setInterval(() => {\n setProgress((prevProgress) => {\n if (prevProgress >= 100) {\n clearInterval(interval);\n // Reset after 1 second when reaching 100%\n setTimeout(() => {\n setProgress(0);\n startAnimation(); // Restart the animation cycle\n }, 1000);\n return 100;\n }\n // Add a random value between 10-20 percent\n return Math.min(\n 100,\n prevProgress + Math.floor(Math.random() * 11) + 10,\n );\n });\n }, 300);\n\n return interval;\n };\n\n const interval = startAnimation();\n\n return () => {\n clearInterval(interval);\n };\n }, []);\n\n return (\n
\n
\n \n
\n
\n );\n}\n", + "type": "registry:page", + "target": "app/page.tsx" } ], "type": "registry:block" diff --git a/docs/public/r/registry.json b/docs/public/r/registry.json index 9a5189c4..18f57379 100644 --- a/docs/public/r/registry.json +++ b/docs/public/r/registry.json @@ -48,6 +48,25 @@ } ] }, + { + "name": "file-list", + "type": "registry:component", + "title": "File List", + "description": "Lists the files of an UploaderProvider with their status, progress, retry, cancel and remove.", + "dependencies": ["lucide-react"], + "registryDependencies": [ + "https://edgestore.dev/r/progress-bar.json", + "https://edgestore.dev/r/uploader-provider.json", + "https://edgestore.dev/r/upload-utils.json" + ], + "files": [ + { + "path": "examples/components/src/components/upload/file-list.tsx", + "type": "registry:component", + "target": "components/upload/file-list.tsx" + } + ] + }, { "name": "file-uploader", "type": "registry:component", @@ -57,7 +76,7 @@ "registryDependencies": [ "button", "https://edgestore.dev/r/dropzone.json", - "https://edgestore.dev/r/progress-bar.json", + "https://edgestore.dev/r/file-list.json", "https://edgestore.dev/r/uploader-provider.json", "https://edgestore.dev/r/upload-utils.json" ], @@ -139,7 +158,7 @@ "dependencies": ["react-dropzone", "lucide-react"], "registryDependencies": [ "https://edgestore.dev/r/dropzone.json", - "https://edgestore.dev/r/file-uploader.json", + "https://edgestore.dev/r/file-list.json", "https://edgestore.dev/r/uploader-provider.json", "https://edgestore.dev/r/upload-utils.json" ], @@ -208,8 +227,8 @@ "files": [ { "path": "examples/components/src/components/upload/blocks/multi-file-block.tsx", - "type": "registry:block", - "target": "components/upload/blocks/multi-file-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ] }, @@ -225,8 +244,8 @@ "files": [ { "path": "examples/components/src/components/upload/blocks/single-image-block.tsx", - "type": "registry:block", - "target": "components/upload/blocks/single-image-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ] }, @@ -242,8 +261,8 @@ "files": [ { "path": "examples/components/src/components/upload/blocks/multi-image-block.tsx", - "type": "registry:block", - "target": "components/upload/blocks/multi-image-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ] }, @@ -259,8 +278,8 @@ "files": [ { "path": "examples/components/src/components/upload/blocks/avatar-block.tsx", - "type": "registry:block", - "target": "components/upload/blocks/avatar-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ] }, @@ -277,8 +296,8 @@ "files": [ { "path": "examples/components/src/components/upload/blocks/file-field-block.tsx", - "type": "registry:block", - "target": "components/upload/blocks/file-field-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ] }, @@ -290,15 +309,15 @@ "dependencies": ["lucide-react"], "registryDependencies": [ "https://edgestore.dev/r/upload-button.json", - "https://edgestore.dev/r/file-uploader.json", + "https://edgestore.dev/r/file-list.json", "https://edgestore.dev/r/dropzone.json", "https://edgestore.dev/r/uploader-provider.json" ], "files": [ { "path": "examples/components/src/components/upload/blocks/upload-button-block.tsx", - "type": "registry:block", - "target": "components/upload/blocks/upload-button-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ] }, @@ -311,8 +330,8 @@ "files": [ { "path": "examples/components/src/components/upload/blocks/progress-bar-block.tsx", - "type": "registry:block", - "target": "components/upload/blocks/progress-bar-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ] }, @@ -325,8 +344,8 @@ "files": [ { "path": "examples/components/src/components/upload/blocks/progress-circle-block.tsx", - "type": "registry:block", - "target": "components/upload/blocks/progress-circle-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ] } diff --git a/docs/public/r/single-image-dropzone-block.json b/docs/public/r/single-image-dropzone-block.json index b8f7e699..30bca9d4 100644 --- a/docs/public/r/single-image-dropzone-block.json +++ b/docs/public/r/single-image-dropzone-block.json @@ -11,8 +11,8 @@ { "path": "examples/components/src/components/upload/blocks/single-image-block.tsx", "content": "'use client';\n\nimport { SingleImageDropzone } from '@/components/upload/single-image';\nimport {\n UploaderProvider,\n useUploader,\n type CompletedFileState,\n type UploadFn,\n} from '@/components/upload/uploader-provider';\nimport * as React from 'react';\n\nexport default function SingleImageUploaderBlock() {\n const { edgestore } = useMockEdgeStore(); // Mock edgestore for easy v0 integration\n\n const uploadFn: UploadFn = React.useCallback(\n async ({ file, signal, onProgressChange }) => {\n const res = await edgestore.myPublicImages.upload({\n file,\n signal,\n onProgressChange,\n });\n // you can run some server action or api here\n // to add the necessary data to your database\n return { url: res.url };\n },\n [edgestore],\n );\n\n return (\n
\n \n \n \n \n
\n );\n}\n\nfunction CompletedFiles() {\n const { fileStates } = useUploader();\n\n const completedFiles = fileStates.filter(\n (fs): fs is CompletedFileState => fs.status === 'COMPLETE',\n );\n\n if (completedFiles.length === 0) {\n return null;\n }\n\n return (\n
\n

Uploaded files

\n
    \n {completedFiles.map((fs) => (\n
  • \n \n {fs.file.name}\n \n
  • \n ))}\n
\n
\n );\n}\n\n// Mock implementation of EdgeStore\nfunction useMockEdgeStore() {\n return {\n edgestore: {\n myPublicImages: {\n upload: async ({\n signal,\n onProgressChange,\n }: {\n file: File;\n signal?: AbortSignal;\n onProgressChange?: (progress: number) => void | Promise;\n }) => {\n // Simulate upload progress. Rejects like EdgeStore when the upload is aborted.\n await new Promise((resolve, reject) => {\n let progress = 0;\n const interval = setInterval(() => {\n progress = Math.min(\n progress + Math.floor(Math.random() * 21) + 10,\n 100,\n );\n void onProgressChange?.(progress);\n if (progress >= 100) {\n clearInterval(interval);\n resolve();\n }\n }, 300);\n signal?.addEventListener('abort', () => {\n clearInterval(interval);\n reject(new DOMException('Upload aborted', 'AbortError'));\n });\n });\n\n return {\n url: 'https://edgestore.dev/img/upload-demo.webp',\n };\n },\n },\n },\n };\n}\n", - "type": "registry:block", - "target": "components/upload/blocks/single-image-block.tsx" + "type": "registry:page", + "target": "app/page.tsx" } ], "type": "registry:block" diff --git a/docs/public/r/upload-button-block.json b/docs/public/r/upload-button-block.json index 9cda7a90..03fc977a 100644 --- a/docs/public/r/upload-button-block.json +++ b/docs/public/r/upload-button-block.json @@ -8,16 +8,16 @@ ], "registryDependencies": [ "https://edgestore.dev/r/upload-button.json", - "https://edgestore.dev/r/file-uploader.json", + "https://edgestore.dev/r/file-list.json", "https://edgestore.dev/r/dropzone.json", "https://edgestore.dev/r/uploader-provider.json" ], "files": [ { "path": "examples/components/src/components/upload/blocks/upload-button-block.tsx", - "content": "'use client';\n\nimport { UploadErrors } from '@/components/upload/dropzone';\nimport { FileList } from '@/components/upload/multi-file';\nimport { UploadButton } from '@/components/upload/upload-button';\nimport {\n UploaderProvider,\n type UploadFn,\n} from '@/components/upload/uploader-provider';\nimport { PaperclipIcon, UploadIcon } from 'lucide-react';\nimport * as React from 'react';\n\nexport default function UploadButtonBlock() {\n const [errors, setErrors] = React.useState([]);\n const { edgestore } = useMockEdgeStore(); // Mock edgestore for easy v0 integration\n\n const uploadFn: UploadFn = React.useCallback(\n async ({ file, signal, onProgressChange }) => {\n const res = await edgestore.myPublicFiles.upload({\n file,\n signal,\n onProgressChange,\n });\n // you can run some server action or api here\n // to add the necessary data to your database\n return { url: res.url };\n },\n [edgestore],\n );\n\n return (\n
\n
\n \n
\n \n Upload files\n \n \n \n \n
\n setErrors([])} />\n \n
\n
\n
\n );\n}\n\n// Mock implementation of EdgeStore\nfunction useMockEdgeStore() {\n return {\n edgestore: {\n myPublicFiles: {\n upload: async ({\n signal,\n onProgressChange,\n }: {\n file: File;\n signal?: AbortSignal;\n onProgressChange?: (progress: number) => void | Promise;\n }) => {\n // Simulate upload progress. Rejects like EdgeStore when the upload is aborted.\n await new Promise((resolve, reject) => {\n let progress = 0;\n const interval = setInterval(() => {\n progress = Math.min(\n progress + Math.floor(Math.random() * 21) + 10,\n 100,\n );\n void onProgressChange?.(progress);\n if (progress >= 100) {\n clearInterval(interval);\n resolve();\n }\n }, 300);\n signal?.addEventListener('abort', () => {\n clearInterval(interval);\n reject(new DOMException('Upload aborted', 'AbortError'));\n });\n });\n\n return {\n url: 'https://edgestore.dev/img/upload-demo.webp',\n };\n },\n },\n },\n };\n}\n", - "type": "registry:block", - "target": "components/upload/blocks/upload-button-block.tsx" + "content": "'use client';\n\nimport { UploadErrors } from '@/components/upload/dropzone';\nimport { FileList } from '@/components/upload/file-list';\nimport { UploadButton } from '@/components/upload/upload-button';\nimport {\n UploaderProvider,\n type UploadFn,\n} from '@/components/upload/uploader-provider';\nimport { PaperclipIcon, UploadIcon } from 'lucide-react';\nimport * as React from 'react';\n\nexport default function UploadButtonBlock() {\n const [errors, setErrors] = React.useState([]);\n const { edgestore } = useMockEdgeStore(); // Mock edgestore for easy v0 integration\n\n const uploadFn: UploadFn = React.useCallback(\n async ({ file, signal, onProgressChange }) => {\n const res = await edgestore.myPublicFiles.upload({\n file,\n signal,\n onProgressChange,\n });\n // you can run some server action or api here\n // to add the necessary data to your database\n return { url: res.url };\n },\n [edgestore],\n );\n\n return (\n
\n
\n \n
\n \n Upload files\n \n \n \n \n
\n setErrors([])} />\n \n
\n
\n
\n );\n}\n\n// Mock implementation of EdgeStore\nfunction useMockEdgeStore() {\n return {\n edgestore: {\n myPublicFiles: {\n upload: async ({\n signal,\n onProgressChange,\n }: {\n file: File;\n signal?: AbortSignal;\n onProgressChange?: (progress: number) => void | Promise;\n }) => {\n // Simulate upload progress. Rejects like EdgeStore when the upload is aborted.\n await new Promise((resolve, reject) => {\n let progress = 0;\n const interval = setInterval(() => {\n progress = Math.min(\n progress + Math.floor(Math.random() * 21) + 10,\n 100,\n );\n void onProgressChange?.(progress);\n if (progress >= 100) {\n clearInterval(interval);\n resolve();\n }\n }, 300);\n signal?.addEventListener('abort', () => {\n clearInterval(interval);\n reject(new DOMException('Upload aborted', 'AbortError'));\n });\n });\n\n return {\n url: 'https://edgestore.dev/img/upload-demo.webp',\n };\n },\n },\n },\n };\n}\n", + "type": "registry:page", + "target": "app/page.tsx" } ], "type": "registry:block" diff --git a/docs/src/app/(main)/pricing/_components/pricing.tsx b/docs/src/app/(main)/pricing/_components/pricing.tsx index 3b10a080..8de41116 100644 --- a/docs/src/app/(main)/pricing/_components/pricing.tsx +++ b/docs/src/app/(main)/pricing/_components/pricing.tsx @@ -1,5 +1,6 @@ import { ActionButton } from '@/components/action-button'; import { env } from '@/env'; +import { SPONSOR_URL } from '@/lib/constants'; import { formatFileSize, formatNumber } from '@/lib/formatter'; import { cn } from '@/lib/utils'; import { CheckCircleIcon, HeartIcon } from 'lucide-react'; @@ -127,7 +128,7 @@ function PricingBlock(props: { function SponsorButton() { return ( - + Sponsor ); @@ -224,7 +225,7 @@ function buildPricingBlocks( id: 'STARTER', title: 'Starter', description: - 'Best for start-ups and businesses who build commercial products with Edge Store.', + 'Best for start-ups and businesses who build commercial products with EdgeStore.', price: toDollars(defaultPricing.STARTER.basePrice, 5), upcoming: toUpcoming(upcomingPricing?.STARTER), features: [ @@ -286,7 +287,7 @@ function buildPricingBlocks( id: 'PRO', title: 'Pro', description: - 'Best for businesses who build larger commercial products with Edge Store.', + 'Best for businesses who build larger commercial products with EdgeStore.', price: toDollars(defaultPricing.PRO.basePrice, 35), upcoming: toUpcoming(upcomingPricing?.PRO), features: [ diff --git a/docs/src/components/setup-guide.tsx b/docs/src/components/setup-guide.tsx index 65c66efe..c210a344 100644 --- a/docs/src/components/setup-guide.tsx +++ b/docs/src/components/setup-guide.tsx @@ -1,67 +1,14 @@ 'use client'; +import { useRevealAnchor } from '@/hooks/use-reveal-anchor'; import { ChevronRight } from 'lucide-react'; -import { useEffect, useRef, type ReactNode } from 'react'; +import { useRef, type ReactNode } from 'react'; export function SetupGuide({ children }: { children: ReactNode }) { const details = useRef(null); - - useEffect(() => { - let frame: number | undefined; - - function reveal(hash: string) { - let id: string; - try { - id = decodeURIComponent(hash.slice(1)); - } catch { - return; - } - const target = document.getElementById(id); - if (!target || !details.current?.contains(target)) return; - details.current.open = true; - if (frame !== undefined) cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => - target.scrollIntoView({ block: 'start' }), - ); - } - - function onHashChange() { - reveal(window.location.hash); - } - - // Also handle clicks on the current hash after the reader closes the guide. - function onClick(event: MouseEvent) { - if ( - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) - return; - const link = - event.target instanceof Element ? event.target.closest('a') : null; - if (!link || link.target === '_blank' || link.hasAttribute('download')) - return; - const url = new URL(link.href, window.location.href); - if ( - url.origin === window.location.origin && - url.pathname === window.location.pathname && - url.search === window.location.search - ) { - reveal(url.hash); - } - } - - onHashChange(); - window.addEventListener('hashchange', onHashChange); - document.addEventListener('click', onClick, true); - return () => { - window.removeEventListener('hashchange', onHashChange); - document.removeEventListener('click', onClick, true); - if (frame !== undefined) cancelAnimationFrame(frame); - }; - }, []); + useRevealAnchor(details, () => { + if (details.current) details.current.open = true; + }); return (
diff --git a/docs/src/components/ui/open-tabs.tsx b/docs/src/components/ui/open-tabs.tsx index 9cafdea3..4a7b660f 100644 --- a/docs/src/components/ui/open-tabs.tsx +++ b/docs/src/components/ui/open-tabs.tsx @@ -1,10 +1,28 @@ 'use client'; +import { useRevealAnchor } from '@/hooks/use-reveal-anchor'; import { cn } from '@/lib/utils'; import * as TabsPrimitive from '@radix-ui/react-tabs'; import * as React from 'react'; -const OpenTabs = TabsPrimitive.Root; +const SelectTabContext = React.createContext<((value: string) => void) | null>( + null, +); + +function OpenTabs({ + defaultValue, + ...props +}: Omit< + React.ComponentProps, + 'value' | 'onValueChange' +>) { + const [value, setValue] = React.useState(defaultValue); + return ( + + + + ); +} const OpenTabsList = React.forwardRef< React.ComponentRef, @@ -36,19 +54,28 @@ const OpenTabsTrigger = React.forwardRef< )); OpenTabsTrigger.displayName = TabsPrimitive.Trigger.displayName; -const OpenTabsContent = React.forwardRef< - React.ComponentRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -OpenTabsContent.displayName = TabsPrimitive.Content.displayName; +// Inactive panels stay mounted so links to headings inside them can open them. +function OpenTabsContent({ + className, + value, + ...props +}: Omit, 'forceMount'>) { + const selectTab = React.use(SelectTabContext); + const content = React.useRef(null); + useRevealAnchor(content, () => selectTab?.(value)); + + return ( + + ); +} export { OpenTabs, OpenTabsList, OpenTabsTrigger, OpenTabsContent }; diff --git a/docs/src/components/upload/blocks/upload-button-block.tsx b/docs/src/components/upload/blocks/upload-button-block.tsx index 2a4406a6..5f2df2f2 100644 --- a/docs/src/components/upload/blocks/upload-button-block.tsx +++ b/docs/src/components/upload/blocks/upload-button-block.tsx @@ -1,7 +1,7 @@ 'use client'; import { UploadErrors } from '@/components/upload/dropzone'; -import { FileList } from '@/components/upload/multi-file'; +import { FileList } from '@/components/upload/file-list'; import { UploadButton } from '@/components/upload/upload-button'; import { UploaderProvider, diff --git a/docs/src/components/upload/file-field.tsx b/docs/src/components/upload/file-field.tsx index a4393bc7..a46adfb5 100644 --- a/docs/src/components/upload/file-field.tsx +++ b/docs/src/components/upload/file-field.tsx @@ -5,7 +5,7 @@ import { UploadIcon } from 'lucide-react'; import * as React from 'react'; import { type Accept } from 'react-dropzone'; import { dropzoneState, dropzoneVariants, useUploadDropzone } from './dropzone'; -import { FileListItem } from './multi-file'; +import { FileListItem } from './file-list'; import { formatFileSize } from './upload-utils'; import { useUploader } from './uploader-provider'; diff --git a/docs/src/components/upload/file-list.tsx b/docs/src/components/upload/file-list.tsx new file mode 100644 index 00000000..6e980be1 --- /dev/null +++ b/docs/src/components/upload/file-list.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { CheckIcon, RotateCwIcon, XIcon } from 'lucide-react'; +import * as React from 'react'; +import { ProgressBar } from './progress-bar'; +import { + fileExtension, + fileKind, + formatFileSize, + type FileKind, +} from './upload-utils'; +import { useUploader, type FileState } from './uploader-provider'; + +const KIND_CLASSNAMES: Record = { + image: 'text-purple-600 dark:text-purple-400', + video: 'text-pink-600 dark:text-pink-400', + audio: 'text-cyan-600 dark:text-cyan-400', + pdf: 'text-red-600 dark:text-red-400', + doc: 'text-blue-600 dark:text-blue-400', + sheet: 'text-green-600 dark:text-green-400', + slides: 'text-orange-600 dark:text-orange-400', + archive: 'text-yellow-600 dark:text-yellow-400', + code: 'text-teal-600 dark:text-teal-400', + other: 'text-muted-foreground', +}; + +/** + * A colored tile with the file's extension. + */ +export function FileBadge({ + file, + className, +}: { + file: File; + className?: string; +}) { + return ( + + ); +} + +function IconButton({ + label, + className, + ...props +}: React.ComponentProps<'button'> & { label: string }) { + return ( +