"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("notes.attachment.add", { org: slugs.org, }) const removal = useWrite( "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 => { 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[] = [ { 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 ( <> {labels.download.replace("{filename}", row.filename)} {" "} {labels.removeShort} } title={name} description={labels.removeConfirm} confirmLabel={labels.removeShort} onConfirm={async () => { await removal.mutate({ ...slugs, noteId, attachmentId: row.attachmentId }) }} /> ) }, }, ] return ( row.attachmentId} count={formatMessage(labels.count, { count: rows.length }, locale)} rowLabel={(row) => row.filename} empty={{ title: labels.empty }} /> {enabled ? ( ) : (

{labels.off}

)}
) }