163 lines
5.5 KiB
TypeScript
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>
|
|
)
|
|
}
|