Files
template-app/modules/notes/ui/attachments.tsx
T

163 lines
5.5 KiB
TypeScript

"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>
)
}