184 lines
6.3 KiB
TypeScript
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>
|
|
)
|
|
}
|