This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
// Upload one document through the files module. A PDF of 10 to 100 MB cannot be a JSON document, so
|
||||
// the browser sends it the way the kit's `uploadFile` does: it asks `files.file.beginUpload` how the
|
||||
// file is to be cut, sends each piece as the body of `files.file.putPart` (so the bar below moves
|
||||
// as the bytes leave), and ends with `files.file.completeUpload`. The answer comes back before any
|
||||
// OCR has run, because reading the document is the worker's job (AGENTS.md: a request enqueues and
|
||||
// answers).
|
||||
|
||||
import { TransferProgress, uploadFile, useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
import type { ProjectSlugs } from "./slugs.js"
|
||||
|
||||
export interface UploadLabels {
|
||||
readonly label: string
|
||||
readonly hint: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly done: string
|
||||
/** `Uploading {filename}: {percent}%`, with both placeholders still in it. */
|
||||
readonly progress: string
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
type State =
|
||||
| { kind: "idle" | "done" }
|
||||
| { kind: "busy"; filename: string; sent: number; total: number }
|
||||
| { kind: "failed"; detail: string }
|
||||
|
||||
export function UploadForm({
|
||||
slugs,
|
||||
accept,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
/** The content types the module reads, for the file picker; the server is what enforces them. */
|
||||
accept: readonly string[]
|
||||
labels: UploadLabels
|
||||
}): ReactElement {
|
||||
const transport = useTransport()
|
||||
const { describe } = transport
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const file = new FormData(form).get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
setState({ kind: "busy", filename: file.name, sent: 0, total: file.size })
|
||||
try {
|
||||
await uploadFile(transport, slugs, file, {
|
||||
onProgress: ({ sent, total }) => setState({ kind: "busy", filename: file.name, sent, total }),
|
||||
})
|
||||
form.reset()
|
||||
setState({ kind: "done" })
|
||||
// The list is rendered on the server: the new row is one reload away, and the worker will
|
||||
// have picked it up by the next one.
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const busy = state.kind === "busy"
|
||||
return (
|
||||
<form onSubmit={submit} aria-busy={busy}>
|
||||
<div className="kit-field">
|
||||
<label htmlFor={inputId}>{labels.label}</label>
|
||||
<input id={inputId} name="file" type="file" accept={accept.join(",")} required aria-describedby={hintId} />
|
||||
<p id={hintId}>{labels.hint}</p>
|
||||
</div>
|
||||
<button type="submit" disabled={busy}>
|
||||
{busy ? labels.busy : labels.submit}
|
||||
</button>
|
||||
{state.kind === "busy" ? (
|
||||
<TransferProgress
|
||||
label={labels.progress
|
||||
.replace("{filename}", state.filename)
|
||||
.replace("{percent}", String(Math.floor((100 * state.sent) / state.total)))}
|
||||
value={state.sent}
|
||||
max={state.total}
|
||||
/>
|
||||
) : null}
|
||||
<p role="status">{state.kind === "done" ? labels.done : ""}</p>
|
||||
{state.kind === "failed" ? (
|
||||
<p className="app-error" role="alert">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user