S3 File Uploader
A single-file uploader that talks to presigned storage. It validates, uploads with a progress bar, resolves a preview and handles deletion.
'use client'
import * as React from 'react'
import { S3FileUploader } from '@/components/custom/s3-file-uploader'
import { StorageProvider, type StorageAdapter } from '@/lib/kiota-storage'
const STORED_SVG =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="140">
<rect width="240" height="140" fill="#d7ece8"/>
<text x="120" y="76" font-family="sans-serif" font-size="14" fill="#0d5a4e" text-anchor="middle">stored file</text>
</svg>`,
)
// A demo back end: /api/demo-upload accepts the bytes and throws them away,
// and getFile serves a placeholder rather than what you actually picked.
// Swap all three for your own API client.
const demoAdapter: StorageAdapter = {
proxyUploadEndpoint: '/api/demo-upload',
signFile: async ({ fileName, filePath }) => ({
data: { url: '/api/demo-upload', key: `${filePath}/1723459200_${fileName}` },
}),
getFile: async () => ({ data: { url: STORED_SVG } }),
deleteFile: async () => ({ data: { ok: true } }),
}
export default function S3FileUploaderDemo() {
const [key, setKey] = React.useState<string | null>(null)
return (
<StorageProvider adapter={demoAdapter}>
<div className="w-full max-w-sm space-y-3">
<S3FileUploader
fieldId="demo-uploader"
valueKey={key}
filePath="/payout-receipts"
onCompleteAction={setKey}
placeholder="Upload receipt"
maxSize={5}
acceptedFileTypes={['.png', '.jpg', '.jpeg', '.pdf']}
// The demo route is a normal POST endpoint, not a presigned bucket.
uploadMode="proxy"
/>
<p className="text-sm text-muted-foreground">
Stored key: <code className="font-mono">{key ?? 'null'}</code>
</p>
</div>
</StorageProvider>
)
}The demo above runs against a throwaway route that discards the bytes and serves a placeholder back, so you can see the real state machine without a bucket.
Installation
pnpm dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/s3-file-uploader.jsonnpx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/s3-file-uploader.jsonyarn dlx shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/s3-file-uploader.jsonbunx --bun shadcn@latest add https://kiotapay-ui-staging.staging.kiotapay.com/r/s3-file-uploader.jsonInstalls the component and lib/kiota-storage.tsx. Wire the adapter once — see Storage adapter.
Usage
'use client'
import * as React from 'react'
import { S3FileUploader } from '@/components/custom/s3-file-uploader'
export function ReceiptField({ form }) {
const key = form.watch('receiptKey')
return (
<S3FileUploader
fieldId="receipt"
valueKey={key}
filePath="/payout-receipts"
onCompleteAction={(next) => form.setValue('receiptKey', next)}
placeholder="Upload receipt"
maxSize={5}
acceptedFileTypes={['.png', '.jpg', '.pdf']}
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
fieldId | string | — | Required. Id for the hidden file input; must be unique on the page. |
valueKey | string | null | — | Current storage key. null shows the empty drop area. |
filePath | string | '/general-uploads' | Destination prefix inside the bucket. |
onCompleteAction | (key: string | null, url?: string | null) => void | — | Fires with the new key, or null on removal. |
maxSize | number | 2 | Ceiling in megabytes. |
acceptedFileTypes | string[] | — | Extensions ('.png') or MIME types ('image/png'). |
placeholder | string | 'Upload file' | Drop-area caption. |
uploadMode | 'direct' | 'proxy' | 'direct' | Transport. See Storage adapter. |
readOnly | boolean | false | Hides delete and blocks new selections. |
error / setError | string / (error: string) => void | — | Lift the error into a parent form. Omit to let the component own it. |
adapter | StorageAdapter | — | Overrides the provider for this instance. |
Behaviour notes
Uploading starts on selection. A separate upload button creates a state where the form looks complete but the bytes were never sent, and that failure stays invisible until submit.
A failed preview is not a failed upload. If the object stores but the preview URL cannot be resolved, onCompleteAction still fires with the key. The file is there; only the thumbnail is missing.
Removal always reports null. Whether the file was uploaded or only picked locally, onCompleteAction(null) runs so the bound form field clears with the UI.
Clearing valueKey from the parent resets the component, which is what makes it usable inside a form that resets after submit.
In-flight uploads are cancelled on unmount, on delete, and when a new file is chosen — the abort is wired to the request rather than left dangling.