Files
template-app/modules/notes/ui/attachments.tsx
T
2026-11-14 09:56:45 +00:00

184 lines
6.3 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.
//
// The list is live: the page's view hands over the attachments it rendered as the seed, and this reads
// them again through `notes.note.get` whenever the live stream announces a change of the note -- a link
// or an unlink moves the note's version (core/notes.ts `touchNote`), whoever made it. A write resolves
// once the stream has seen it, so the list already shows it; nothing reloads the page.
import { formatMessage } from "@conusai-kit/i18n/format"
import { downloadHref, type ProjectSlugs } from "@conusai-kit/ui"
import { useRead, useUpload, useWrite } from "@conusai-kit/ui/data"
import {
ConfirmDialog,
FormLayout,
PageSection,
type ResourceColumn,
ResourceList,
TextField,
} from "@conusai-kit/ui/patterns"
import { Button } from "@conusai-kit/ui/primitives"
import type { ReactElement } from "react"
import { type AttachmentView, NOTE_COLLECTION } from "../contract/index.js"
export interface AttachmentRow {
readonly attachmentId: string
readonly fileId: string
readonly filename: string
readonly contentType: string
/** Already formatted from the `sizeKb` message. */
readonly size: string
}
/** A size in whole kilobytes, at least one: the number the `sizeKb` message is filled with. */
export const kilobytes = (bytes: number): number => Math.max(1, Math.ceil(bytes / 1024))
export interface AttachmentLabels {
readonly title: string
/** `{count, plural, ...}`, with its placeholder left in: only the browser knows the live count. */
readonly count: string
readonly empty: string
readonly file: string
readonly type: string
readonly sizeHeading: string
/** `{size} KB`, with its placeholder left in. */
readonly sizeKb: 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
/** What removing it does to the file. */
readonly removeConfirm: 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
}
export function Attachments({
slugs,
noteId,
seed,
enabled,
accept,
labels,
locale,
}: {
slugs: ProjectSlugs
noteId: string
/** The attachments the server rendered: shown at once, then kept current. */
seed: readonly AttachmentView[]
enabled: boolean
accept: readonly string[]
labels: AttachmentLabels
/** The locale the page was rendered in, for the count's plural and the sizes. */
locale: string
}): ReactElement {
const upload = useUpload()
const add = useWrite<ProjectSlugs & { noteId: string; fileId: string }, unknown>("notes.attachment.add", {
org: slugs.org,
})
const removal = useWrite<ProjectSlugs & { noteId: string; attachmentId: string }, unknown>(
"notes.attachment.remove",
{ org: slugs.org },
)
const { data } = useRead<{ attachments: readonly AttachmentView[] }>(
"notes.note.get",
{ ...slugs, noteId },
{
live: {
org: slugs.org,
topic: { ws: slugs.workspace, project: slugs.project },
entities: [NOTE_COLLECTION.entity],
},
initial: { attachments: seed },
},
)
const rows: AttachmentRow[] = (data?.attachments ?? seed).map((attachment) => ({
attachmentId: attachment.attachmentId,
fileId: attachment.fileId,
filename: attachment.filename,
contentType: attachment.contentType,
size: formatMessage(labels.sizeKb, { size: kilobytes(attachment.size) }, locale),
}))
const attach = async (_: unknown, form: FormData): Promise<void> => {
const file = form.get("file")
if (!(file instanceof File) || file.size === 0) return
const stored = await upload(slugs, file)
await add.mutate({ ...slugs, noteId, fileId: stored.fileId })
}
const columns: ResourceColumn<AttachmentRow>[] = [
{ id: "filename", header: labels.file, wrap: true },
{ id: "contentType", header: labels.type },
{ id: "size", header: labels.sizeHeading, align: "end" },
{
id: "actions",
header: labels.actions,
cell: (row) => {
const name = labels.remove.replace("{filename}", row.filename)
return (
<>
<a href={downloadHref("files.file.download", { ...slugs, fileId: row.fileId })}>
{labels.download.replace("{filename}", row.filename)}
</a>{" "}
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" aria-label={name}>
{labels.removeShort}
</Button>
}
title={name}
description={labels.removeConfirm}
confirmLabel={labels.removeShort}
onConfirm={async () => {
await removal.mutate({ ...slugs, noteId, attachmentId: row.attachmentId })
}}
/>
</>
)
},
},
]
return (
<PageSection title={labels.title}>
<ResourceList
label={labels.title}
columns={columns}
data={rows}
rowId={(row) => row.attachmentId}
count={formatMessage(labels.count, { count: rows.length }, locale)}
rowLabel={(row) => row.filename}
empty={{ title: labels.empty }}
/>
{enabled ? (
<FormLayout submitLabel={labels.uploadSubmit} busyLabel={labels.uploadBusy} onSubmit={attach}>
<TextField
name="file"
type="file"
label={labels.uploadLabel}
description={labels.uploadHint}
accept={accept.join(",")}
required
/>
</FormLayout>
) : (
<p role="status">{labels.off}</p>
)}
</PageSection>
)
}