This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
// A note's attachments: the list with a remove button on each, and the upload.
|
||||
//
|
||||
// An attachment is two steps, both the API's own: the browser sends the file to the files module
|
||||
// (the kit's `uploadFile`: `files.file.beginUpload`, one `files.file.putPart` per piece, and
|
||||
// `files.file.completeUpload`, which answer before anything that follows an upload has run), and
|
||||
// then links the stored file to the note (`notes.attachment.add`). A file uploaded and never linked
|
||||
// is an ordinary file of the project.
|
||||
|
||||
import { downloadHref, type ProjectSlugs, uploadFile, useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
|
||||
export interface AttachmentRow {
|
||||
readonly attachmentId: string
|
||||
readonly fileId: string
|
||||
readonly filename: string
|
||||
readonly contentType: string
|
||||
/** Already formatted: a size is a message of the module, not arithmetic of the component. */
|
||||
readonly size: string
|
||||
}
|
||||
|
||||
export interface AttachmentLabels {
|
||||
readonly title: string
|
||||
readonly count: string
|
||||
readonly empty: string
|
||||
readonly file: string
|
||||
readonly type: string
|
||||
readonly sizeHeading: string
|
||||
readonly actions: string
|
||||
/** `Remove {filename}`: the button's accessible name, with the placeholder still in it. */
|
||||
readonly remove: string
|
||||
/** The button's visible text, which its accessible name contains. */
|
||||
readonly removeShort: string
|
||||
/** `Download {filename}`: a same-origin, project-scoped file stream. */
|
||||
readonly download: string
|
||||
readonly off: string
|
||||
readonly uploadLabel: string
|
||||
readonly uploadHint: string
|
||||
readonly uploadSubmit: string
|
||||
readonly uploadBusy: string
|
||||
readonly uploadDone: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
type State = { kind: "idle" | "busy" | "done" } | { kind: "failed"; detail: string }
|
||||
|
||||
export function Attachments({
|
||||
slugs,
|
||||
noteId,
|
||||
rows,
|
||||
enabled,
|
||||
accept,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
noteId: string
|
||||
rows: readonly AttachmentRow[]
|
||||
enabled: boolean
|
||||
accept: readonly string[]
|
||||
labels: AttachmentLabels
|
||||
}): ReactElement {
|
||||
const transport = useTransport()
|
||||
const { call, describe } = transport
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const attach = 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" })
|
||||
try {
|
||||
const stored = await uploadFile(transport, slugs, file)
|
||||
await call("notes.attachment.add", { ...slugs, noteId, fileId: stored.fileId })
|
||||
form.reset()
|
||||
setState({ kind: "done" })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (attachmentId: string): Promise<void> => {
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
await call("notes.attachment.remove", { ...slugs, noteId, attachmentId })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${inputId}-heading`}>
|
||||
<h2 id={`${inputId}-heading`}>{labels.title}</h2>
|
||||
<p>{labels.count}</p>
|
||||
{rows.length === 0 ? (
|
||||
<p>{labels.empty}</p>
|
||||
) : (
|
||||
<table>
|
||||
<caption>{labels.title}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{labels.file}</th>
|
||||
<th scope="col">{labels.type}</th>
|
||||
<th scope="col">{labels.sizeHeading}</th>
|
||||
<th scope="col">{labels.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.attachmentId}>
|
||||
<th scope="row">{row.filename}</th>
|
||||
<td>{row.contentType}</td>
|
||||
<td>{row.size}</td>
|
||||
<td>
|
||||
<a
|
||||
href={downloadHref("files.file.download", { ...slugs, fileId: row.fileId })}
|
||||
aria-label={labels.download.replace("{filename}", row.filename)}
|
||||
>
|
||||
{labels.download.replace("{filename}", row.filename)}
|
||||
</a>{" "}
|
||||
<button
|
||||
type="button"
|
||||
disabled={state.kind === "busy"}
|
||||
aria-label={labels.remove.replace("{filename}", row.filename)}
|
||||
onClick={() => void remove(row.attachmentId)}
|
||||
>
|
||||
{labels.removeShort}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{enabled ? (
|
||||
<form onSubmit={attach} aria-busy={state.kind === "busy"}>
|
||||
<div className="kit-field">
|
||||
<label htmlFor={inputId}>{labels.uploadLabel}</label>
|
||||
<input id={inputId} name="file" type="file" accept={accept.join(",")} required aria-describedby={hintId} />
|
||||
<p id={hintId}>{labels.uploadHint}</p>
|
||||
</div>
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.uploadBusy : labels.uploadSubmit}
|
||||
</button>
|
||||
<p role="status">{state.kind === "done" ? labels.uploadDone : ""}</p>
|
||||
</form>
|
||||
) : (
|
||||
<p role="status">{labels.off}</p>
|
||||
)}
|
||||
{state.kind === "failed" ? (
|
||||
<p className="app-error" role="alert">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user