@@ -0,0 +1,183 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user