This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
// Deleting a note, in two steps: the button asks, and a second press does it. The attached files are
|
||||
// not deleted with the note (they are the project's); the confirmation says so.
|
||||
|
||||
import { type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useState } from "react"
|
||||
|
||||
export interface DeleteNoteLabels {
|
||||
readonly button: string
|
||||
readonly confirm: string
|
||||
readonly yes: string
|
||||
readonly no: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
export function DeleteNote({
|
||||
slugs,
|
||||
noteId,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
noteId: string
|
||||
/** Where to go once the note is gone. */
|
||||
listHref: string
|
||||
labels: DeleteNoteLabels
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const [state, setState] = useState<{ kind: "idle" | "asking" | "busy" } | { kind: "failed"; detail: string }>({
|
||||
kind: "idle",
|
||||
})
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
await call("notes.note.delete", { ...slugs, noteId })
|
||||
window.location.assign(listHref)
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.kind === "asking" || state.kind === "busy" ? (
|
||||
<fieldset>
|
||||
<legend>{labels.button}</legend>
|
||||
<p>{labels.confirm}</p>
|
||||
<button type="button" disabled={state.kind === "busy"} onClick={() => void remove()}>
|
||||
{labels.yes}
|
||||
</button>
|
||||
<button type="button" disabled={state.kind === "busy"} onClick={() => setState({ kind: "idle" })}>
|
||||
{labels.no}
|
||||
</button>
|
||||
</fieldset>
|
||||
) : (
|
||||
<button type="button" onClick={() => setState({ kind: "asking" })}>
|
||||
{labels.button}
|
||||
</button>
|
||||
)}
|
||||
{state.kind === "failed" ? (
|
||||
<p className="app-error" role="alert">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// The notes module's pages and components. `notesPage` is what the web shell mounts; the rest is
|
||||
// exported for an app that composes its own page from the parts.
|
||||
|
||||
export { type AttachmentLabels, type AttachmentRow, Attachments } from "./attachments.js"
|
||||
export { DeleteNote, type DeleteNoteLabels } from "./delete-note.js"
|
||||
export {
|
||||
attachmentLabels,
|
||||
createFormLabels,
|
||||
deleteLabels,
|
||||
editFormLabels,
|
||||
listLabels,
|
||||
sizeLabel,
|
||||
type Translate,
|
||||
} from "./labels.js"
|
||||
export { NoteForm, type NoteFormLabels, type NoteFormTarget } from "./note-form.js"
|
||||
export { NoteList, type NoteListLabels } from "./note-list.js"
|
||||
export { type NotesPage, notesPage } from "./pages.js"
|
||||
@@ -0,0 +1,86 @@
|
||||
// The labels each component takes, built from a message lookup. Every key this module's pages use is
|
||||
// named here and nowhere else, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks -- the page itself holds no string.
|
||||
//
|
||||
// `t` takes a key relative to the module (`form.create`); the page binds it to
|
||||
// `i18n.message("notes." + key)`. The one message that is not this module's own, the app's
|
||||
// `app.failed`, is passed in already looked up.
|
||||
|
||||
import type { AttachmentLabels } from "./attachments.js"
|
||||
import type { DeleteNoteLabels } from "./delete-note.js"
|
||||
import type { NoteFormLabels } from "./note-form.js"
|
||||
import type { NoteListLabels } from "./note-list.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
export function createFormLabels(t: Translate, failed: string): NoteFormLabels {
|
||||
return {
|
||||
heading: t("form.createHeading"),
|
||||
title: t("form.titleLabel"),
|
||||
body: t("form.bodyLabel"),
|
||||
submit: t("form.create"),
|
||||
busy: t("form.creating"),
|
||||
saved: t("form.saved"),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
|
||||
export function editFormLabels(t: Translate, failed: string): NoteFormLabels {
|
||||
return {
|
||||
heading: t("form.editHeading"),
|
||||
title: t("form.titleLabel"),
|
||||
body: t("form.bodyLabel"),
|
||||
submit: t("form.save"),
|
||||
busy: t("form.saving"),
|
||||
saved: t("form.saved"),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
|
||||
export function listLabels(t: Translate): NoteListLabels {
|
||||
return {
|
||||
caption: t("list.caption"),
|
||||
empty: t("list.empty"),
|
||||
note: t("list.note"),
|
||||
attachments: t("list.attachments"),
|
||||
updated: t("list.updated"),
|
||||
// The `{title}` placeholder is the component's to fill, so it is passed through untouched.
|
||||
open: t("list.open", { title: "{title}" }),
|
||||
}
|
||||
}
|
||||
|
||||
export function attachmentLabels(t: Translate, failed: string, count: number): AttachmentLabels {
|
||||
return {
|
||||
title: t("attachments.title"),
|
||||
count: t("attachments.count", { count }),
|
||||
empty: t("attachments.empty"),
|
||||
file: t("attachments.file"),
|
||||
type: t("attachments.type"),
|
||||
sizeHeading: t("attachments.size"),
|
||||
actions: t("attachments.actions"),
|
||||
download: t("attachments.download", { filename: "{filename}" }),
|
||||
remove: t("attachments.remove", { filename: "{filename}" }),
|
||||
removeShort: t("attachments.removeShort"),
|
||||
off: t("attachments.off"),
|
||||
uploadLabel: t("upload.label"),
|
||||
uploadHint: t("upload.hint"),
|
||||
uploadSubmit: t("upload.submit"),
|
||||
uploadBusy: t("upload.busy"),
|
||||
uploadDone: t("upload.done"),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteLabels(t: Translate, failed: string): DeleteNoteLabels {
|
||||
return {
|
||||
button: t("delete.button"),
|
||||
confirm: t("delete.confirm"),
|
||||
yes: t("delete.yes"),
|
||||
no: t("delete.no"),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
|
||||
/** A size as the message of the module: whole kilobytes, at least one. */
|
||||
export const sizeLabel = (t: Translate, bytes: number): string =>
|
||||
t("attachments.sizeKb", { size: Math.max(1, Math.ceil(bytes / 1024)) })
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
// Creates a note, or saves a change to one. The same form for both: a note being edited carries the
|
||||
// version it was read at, so a second editor's save is a 409 shown here and never a silent overwrite.
|
||||
|
||||
import { emitAppEvent, type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
|
||||
export interface NoteFormLabels {
|
||||
readonly heading: string
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly saved: string
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
export type NoteFormTarget =
|
||||
| { readonly mode: "create"; readonly listHref: string }
|
||||
| {
|
||||
readonly mode: "edit"
|
||||
readonly noteId: string
|
||||
readonly version: number
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
export function NoteForm({
|
||||
slugs,
|
||||
target,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
target: NoteFormTarget
|
||||
labels: NoteFormLabels
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const titleId = useId()
|
||||
const bodyId = useId()
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" | "saved" } | { kind: "failed"; detail: string }>({
|
||||
kind: "idle",
|
||||
})
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
const title = String(form.get("title") ?? "").trim()
|
||||
const body = String(form.get("body") ?? "")
|
||||
if (title.length === 0) return
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
if (target.mode === "create") {
|
||||
const created = (await call("notes.note.create", { ...slugs, title, body })) as { noteId: string }
|
||||
// For whatever listens in the browser (an in-app guide's checklist task); it waits for a
|
||||
// listener that asked to store the event first, because the next line leaves the page.
|
||||
await emitAppEvent("note_created")
|
||||
window.location.assign(`${target.listHref}/${created.noteId}`)
|
||||
return
|
||||
}
|
||||
await call("notes.note.update", {
|
||||
...slugs,
|
||||
noteId: target.noteId,
|
||||
title,
|
||||
body,
|
||||
expectedVersion: target.version,
|
||||
})
|
||||
setState({ kind: "saved" })
|
||||
// The page is rendered on the server: a reload shows the new version, which the next save needs.
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<h2>{labels.heading}</h2>
|
||||
<div className="kit-field" data-guide="notes.note-title">
|
||||
<label htmlFor={titleId}>{labels.title}</label>
|
||||
<input
|
||||
id={titleId}
|
||||
name="title"
|
||||
type="text"
|
||||
required
|
||||
maxLength={200}
|
||||
defaultValue={target.mode === "edit" ? target.title : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="kit-field">
|
||||
<label htmlFor={bodyId}>{labels.body}</label>
|
||||
<textarea
|
||||
id={bodyId}
|
||||
name="body"
|
||||
rows={6}
|
||||
maxLength={20000}
|
||||
defaultValue={target.mode === "edit" ? target.body : ""}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
<p role="status">{state.kind === "saved" ? labels.saved : ""}</p>
|
||||
{state.kind === "failed" ? (
|
||||
<p className="app-error" role="alert">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The project's notes, one row each. A server component: it holds no state, and every string is passed in.
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import type { NoteView } from "../contract/index.js"
|
||||
|
||||
export interface NoteListLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly note: string
|
||||
readonly attachments: string
|
||||
readonly updated: string
|
||||
/** `Open {title}`: the link's accessible name, so a list of links is not a list of the same word. */
|
||||
readonly open: string
|
||||
}
|
||||
|
||||
export function NoteList({
|
||||
notes,
|
||||
hrefFor,
|
||||
labels,
|
||||
formatDate,
|
||||
}: {
|
||||
notes: readonly NoteView[]
|
||||
hrefFor: (noteId: string) => string
|
||||
labels: NoteListLabels
|
||||
formatDate: (iso: string) => string
|
||||
}): ReactElement {
|
||||
if (notes.length === 0) {
|
||||
return <p data-guide="notes.note-list">{labels.empty}</p>
|
||||
}
|
||||
return (
|
||||
// guide: The notes of this project
|
||||
<table data-guide="notes.note-list">
|
||||
<caption>{labels.caption}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{labels.note}</th>
|
||||
<th scope="col">{labels.attachments}</th>
|
||||
<th scope="col">{labels.updated}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notes.map((note) => (
|
||||
<tr key={note.noteId}>
|
||||
<th scope="row">
|
||||
<a href={hrefFor(note.noteId)} aria-label={labels.open.replace("{title}", note.title)}>
|
||||
{note.title}
|
||||
</a>
|
||||
</th>
|
||||
<td>{note.attachmentCount}</td>
|
||||
<td>{formatDate(note.updatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// The notes module's pages, as the one server component the web shell mounts under
|
||||
// `/<org>/<workspace>/<project>/notes` (apps/web's module mount reads it from `modules/index.ts`):
|
||||
//
|
||||
// /notes the list and the form that adds a note
|
||||
// /notes/<noteId> one note: edit it, attach files, delete it
|
||||
//
|
||||
// Every read is a procedure through the same caller the API uses (spec §6.2: one API path); every
|
||||
// write is a client component that calls a procedure through the shell's transport.
|
||||
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import type { ProjectPage } from "@conusai-kit/ui"
|
||||
import type { ReactElement } from "react"
|
||||
import { ATTACHMENT_CONTENT_TYPES, noteGet, noteList } from "../contract/index.js"
|
||||
import { FeedbackButton } from "../guides/feedback-button.js" // kit:guides
|
||||
import { feedbackLabels } from "../guides/feedback-labels.js" // kit:guides
|
||||
import { Attachments } from "./attachments.js"
|
||||
import { DeleteNote } from "./delete-note.js"
|
||||
import { attachmentLabels, createFormLabels, deleteLabels, editFormLabels, listLabels, sizeLabel } from "./labels.js"
|
||||
import { NoteForm } from "./note-form.js"
|
||||
import { NoteList } from "./note-list.js"
|
||||
|
||||
export type NotesPage = ProjectPage<TransportCaller, I18n, Container>
|
||||
|
||||
export const notesPage: NotesPage = async ({ slugs, segments, caller, i18n }): Promise<ReactElement> => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`notes.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes`
|
||||
|
||||
if (segments.length === 0) {
|
||||
const { notes } = await caller.call(noteList, slugs)
|
||||
return (
|
||||
<>
|
||||
<h1>{t("title")}</h1>
|
||||
<p>{t("intro")}</p>
|
||||
<FeedbackButton labels={feedbackLabels(t)} /* kit:guides */ />
|
||||
{/* guide: The form that adds a note */}
|
||||
<div data-guide="notes.new-note">
|
||||
<NoteForm slugs={slugs} target={{ mode: "create", listHref }} labels={createFormLabels(t, failed)} />
|
||||
</div>
|
||||
<NoteList
|
||||
notes={notes}
|
||||
hrefFor={(noteId) => `${listHref}/${noteId}`}
|
||||
labels={listLabels(t)}
|
||||
formatDate={(iso) => i18n.date(new Date(iso))}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const [noteId, ...rest] = segments
|
||||
if (noteId === undefined || rest.length > 0) throw notFound("no such page")
|
||||
const { note, attachments, attachmentsEnabled } = await caller.call(noteGet, { ...slugs, noteId })
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<a href={listHref}>{t("detail.back")}</a>
|
||||
</p>
|
||||
<h1>{note.title}</h1>
|
||||
<p>{t("detail.changed", { date: i18n.date(new Date(note.updatedAt)) })}</p>
|
||||
<NoteForm
|
||||
slugs={slugs}
|
||||
target={{ mode: "edit", noteId: note.noteId, version: note.version, title: note.title, body: note.body }}
|
||||
labels={editFormLabels(t, failed)}
|
||||
/>
|
||||
<Attachments
|
||||
slugs={slugs}
|
||||
noteId={note.noteId}
|
||||
rows={attachments.map((attachment) => ({
|
||||
attachmentId: attachment.attachmentId,
|
||||
fileId: attachment.fileId,
|
||||
filename: attachment.filename,
|
||||
contentType: attachment.contentType,
|
||||
size: sizeLabel(t, attachment.size),
|
||||
}))}
|
||||
enabled={attachmentsEnabled}
|
||||
accept={ATTACHMENT_CONTENT_TYPES}
|
||||
labels={attachmentLabels(t, failed, attachments.length)}
|
||||
/>
|
||||
<DeleteNote slugs={slugs} noteId={note.noteId} listHref={listHref} labels={deleteLabels(t, failed)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user