ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+183
View File
@@ -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>
)
}
+46
View File
@@ -0,0 +1,46 @@
"use client"
// Deleting a note, in two steps. The attached files are not deleted with the note (they are the
// project's); the confirmation says so.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { useWrite } from "@conusai-kit/ui/data"
import { ConfirmDialog, usePageNavigation } from "@conusai-kit/ui/patterns"
import { Button } from "@conusai-kit/ui/primitives"
import type { ReactElement } from "react"
export interface DeleteNoteLabels {
readonly button: string
readonly confirm: string
readonly yes: string
readonly no: 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 navigation = usePageNavigation()
const deletion = useWrite<Record<string, unknown>, unknown>("notes.note.delete", { org: slugs.org })
return (
<ConfirmDialog
trigger={<Button variant="outline">{labels.button}</Button>}
title={labels.button}
description={labels.confirm}
confirmLabel={labels.yes}
cancelLabel={labels.no}
onConfirm={async () => {
await deletion.mutate({ ...slugs, noteId })
navigation.navigate(listHref)
}}
/>
)
}
+22
View File
@@ -0,0 +1,22 @@
// 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, kilobytes } from "./attachments.js"
export { DeleteNote, type DeleteNoteLabels } from "./delete-note.js"
export {
attachmentLabels,
deleteLabels,
type NotesResourceLabels,
resourceLabels,
type Translate,
} from "./labels.js"
export { useLiveNotes } from "./live-notes.js"
export { type NotesProps, NotesResource } from "./notes-resource.js"
export { type NotesPage, notesPage } from "./pages.js"
export { ProjectPicker, type ProjectPickerProps } from "./project-picker.js"
export {
type NotesWorkspaceData,
type NotesWorkspacePage,
notesWorkspacePage,
WORKSPACE_LABEL_KEYS,
} from "./workspace-page.js"
+61
View File
@@ -0,0 +1,61 @@
// 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)`.
import type { AttachmentLabels } from "./attachments.js"
import type { DeleteNoteLabels } from "./delete-note.js"
export type Translate = (key: string, values?: Record<string, string | number>) => string
export function resourceLabels(t: Translate) {
return {
title: t("title"),
/** A note: the table's name. */
one: t("list.one"),
/** The create action, a whole message: a language agrees the adjective with the noun. */
new: t("list.new"),
empty: t("list.empty"),
note: t("list.note"),
titleField: t("list.title"),
body: t("list.body"),
attachments: t("list.attachments"),
updated: t("list.updated"),
}
}
export type NotesResourceLabels = ReturnType<typeof resourceLabels>
/** `count` and `sizeKb` keep their placeholders: the list is live, so the browser fills them in. */
export function attachmentLabels(t: Translate): AttachmentLabels {
return {
title: t("attachments.title"),
count: t("attachments.count"),
empty: t("attachments.empty"),
file: t("attachments.file"),
type: t("attachments.type"),
sizeHeading: t("attachments.size"),
sizeKb: t("attachments.sizeKb"),
actions: t("attachments.actions"),
download: t("attachments.download", { filename: "{filename}" }),
remove: t("attachments.remove", { filename: "{filename}" }),
removeShort: t("attachments.removeShort"),
removeConfirm: t("attachments.removeConfirm"),
off: t("attachments.off"),
uploadLabel: t("upload.label"),
uploadHint: t("upload.hint"),
uploadSubmit: t("upload.submit"),
uploadBusy: t("upload.busy"),
}
}
export function deleteLabels(t: Translate): DeleteNoteLabels {
return {
button: t("delete.button"),
confirm: t("delete.confirm"),
yes: t("delete.yes"),
no: t("delete.no"),
}
}
+46
View File
@@ -0,0 +1,46 @@
"use client"
// The project's notes, live (umbrella TS6.4 and TS6.6): the page's view renders the first page and hands it
// over as the seed, so the page arrives complete; this keeps it current through the organization's live stream
// -- a note another member adds, renames or deletes appears without a reload. The rows come from
// `notes.note.getMany`, under the same permission as the list, so the stream itself never carries a note's
// contents.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { liveCollection, useDataClient, useLive } from "@conusai-kit/ui/data"
// Its live sync stops when no live query reads it any more (TanStack DB's collection GC).
import { useState } from "react"
import { NOTE_COLLECTION, type NoteView } from "../contract/index.js"
export function useLiveNotes(slugs: ProjectSlugs, seed: readonly NoteView[]): readonly NoteView[] {
const client = useDataClient()
// One collection for the life of the page: a page of another project mounts its own, and the seed is the
// first render's.
const [notes] = useState(() =>
liveCollection<NoteView>({
client,
org: slugs.org,
topic: { ws: slugs.workspace, project: slugs.project },
entity: NOTE_COLLECTION.entity,
key: NOTE_COLLECTION.key,
version: "version",
seed,
list: {
procedure: NOTE_COLLECTION.list,
input: { ...slugs },
rows: (out) => (out as { notes: NoteView[] }).notes,
},
getMany: {
procedure: NOTE_COLLECTION.getMany,
input: { ...slugs },
rows: (out) => (out as { items: NoteView[] }).items,
},
}),
)
// Newest first, as the list procedure orders them, through the query operators (umbrella §7.2).
const { data, isReady } = useLive(notes, (q, rows) =>
q.from({ note: rows }).orderBy(({ note }: { note: NoteView }) => note.createdAt, "desc"),
)
// Until the collection is ready the server's rows are the answer, so the first paint matches it.
return isReady ? data : seed
}
+70
View File
@@ -0,0 +1,70 @@
"use client"
// The project's notes as one record type: the list, a new note and an edit in the right panel, all from this
// declaration. Attachments need a page, so the panel offers it ("Open in full"). The rows are live (live-notes.ts);
// a write resolves once the stream has seen it, so the list shows it the moment the panel closes.
import { emitAppEvent, type ProjectSlugs } from "@conusai-kit/ui"
import { useDataClient, useWrite } from "@conusai-kit/ui/data"
import { defineResource, ResourcePage } from "@conusai-kit/ui/patterns"
import { type ReactElement, type ReactNode, useRef } from "react"
import { z } from "zod"
import type { NoteView } from "../contract/index.js"
import type { NotesResourceLabels } from "./labels.js"
import { useLiveNotes } from "./live-notes.js"
const schema = z.object({ title: z.string().min(1).max(200), body: z.string().max(20_000) })
export interface NotesProps {
readonly slugs: ProjectSlugs
/** The first page, rendered on the server. */
readonly seed: readonly NoteView[]
readonly labels: NotesResourceLabels
readonly intro: ReactNode
/** The locale the server formatted dates in, so the rows read as they were rendered. */
readonly locale: string
}
export function NotesResource({ slugs, seed, labels, intro, locale }: NotesProps): ReactElement {
const client = useDataClient()
const rows = useLiveNotes(slugs, seed)
const create = useWrite<Record<string, unknown>, NoteView>("notes.note.create", { org: slugs.org })
const update = useWrite<Record<string, unknown>, NoteView>("notes.note.update", { org: slugs.org })
const remove = useWrite<Record<string, unknown>, unknown>("notes.note.delete", { org: slugs.org })
// A save carries the version the panel read, so a second editor's is a 409 and never a silent overwrite.
const read = useRef(new Map<string, number>())
for (const { noteId, version } of rows) if (!read.current.has(noteId)) read.current.set(noteId, version)
const day = new Intl.DateTimeFormat(locale).format
const resource = defineResource<NoteView, z.output<typeof schema>>({
id: "notes",
label: labels.one,
newLabel: labels.new,
columns: [
{ id: "title", header: labels.note, wrap: true, minWidth: "10rem", allowsSorting: true },
{ id: "attachmentCount", header: labels.attachments, align: "end" },
{ id: "updatedAt", header: labels.updated, allowsSorting: true, cell: (n) => day(new Date(n.updatedAt)) },
],
rowId: (note) => note.noteId,
source: { rows },
async get(noteId) {
const { value } = await client.call<{ note: NoteView }>("notes.note.get", { ...slugs, noteId })
read.current.set(noteId, value.note.version)
return value.note
},
schema,
fields: [
{ name: "title", label: labels.titleField, kind: "text" },
{ name: "body", label: labels.body, kind: "textarea" },
],
async create(input) {
const note = await create.mutate({ ...slugs, ...input })
await emitAppEvent("note_created") // what an in-app guide's checklist task listens for
return note
},
update: (id, input) => update.mutate({ ...slugs, noteId: id, ...input, expectedVersion: read.current.get(id) }),
remove: (noteId) => remove.mutate({ ...slugs, noteId }).then(() => undefined),
href: (n) => `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes/${n.noteId}`,
permissions: { write: "notes.note.write", remove: "notes.note.delete" },
})
return <ResourcePage resource={resource} title={labels.title} description={intro} empty={{ title: labels.empty }} />
}
+136
View File
@@ -0,0 +1,136 @@
// The notes module's page, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web
// shell runs on the server under `/<org>/<workspace>/<project>/notes`, and the view of what it returned.
//
// /notes the list, on `ResourcePage`: a note opens, and a new one starts, in the right panel
// /notes/<noteId> one note in full: its text, its attachments, deleting it
//
// Every read is a procedure through the same caller the API uses (spec §6.2: one API path), and every
// label is translated here, on the server: the view receives strings and rows, never a caller or a
// catalogue. Every write is a client component that calls a procedure through the shell's transport.
// This is the worked example the other modules' pages follow (apps/web/ROUTES.md).
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
import { Page, PageHeader, PageSection } from "@conusai-kit/ui/patterns"
import { Button } from "@conusai-kit/ui/primitives"
import type { ReactElement } from "react"
import { ATTACHMENT_CONTENT_TYPES, type AttachmentView, type NoteView, noteGet, noteList } from "../contract/index.js"
import { FeedbackButton } from "../guides/feedback-button.js" // kit:guides
import { type FeedbackButtonLabels, feedbackLabels } from "../guides/feedback-labels.js" // kit:guides
import { type AttachmentLabels, Attachments } from "./attachments.js"
import { DeleteNote, type DeleteNoteLabels } from "./delete-note.js"
import { attachmentLabels, deleteLabels, type NotesResourceLabels, resourceLabels } from "./labels.js"
import { NotesResource } from "./notes-resource.js"
/** The list: its labels and the first page of notes, the resource's seed. */
export interface NotesListData {
readonly kind: "list"
readonly intro: string
readonly feedback: FeedbackButtonLabels // kit:guides
readonly labels: NotesResourceLabels
readonly notes: readonly NoteView[]
}
/** One note, with its attachments as the live list's seed. */
export interface NoteDetailData {
readonly kind: "note"
readonly back: string
readonly changed: string
readonly note: { readonly noteId: string; readonly title: string; readonly body: string }
/** What the link to the note's panel on the list says. */
readonly edit: string
readonly attachments: readonly AttachmentView[]
readonly attachmentsEnabled: boolean
readonly attachmentLabels: AttachmentLabels
readonly remove: DeleteNoteLabels
}
export type NotesPageData = NotesListData | NoteDetailData
export type NotesPage = ProjectPage<TransportCaller, I18n, Container, NotesPageData>
const listHrefOf = ({ org, workspace, project }: ProjectPageViewProps<unknown>["slugs"]): string =>
`/${org}/${workspace}/${project}/notes`
export const notesPage: NotesPage = projectPage({
async load({ slugs, segments, caller, i18n }): Promise<NotesPageData> {
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`notes.${key}`, values)
if (segments.length === 0) {
const { notes } = await caller.call(noteList, slugs)
return {
kind: "list",
intro: t("intro"),
feedback: feedbackLabels(t), // kit:guides
labels: resourceLabels(t),
notes,
}
}
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 {
kind: "note",
back: t("detail.back"),
changed: t("detail.changed", { date: i18n.date(new Date(note.updatedAt)) }),
note: { noteId: note.noteId, title: note.title, body: note.body },
edit: t("detail.edit"),
attachments,
attachmentsEnabled,
attachmentLabels: attachmentLabels(t),
remove: deleteLabels(t),
}
},
View({ slugs, locale, data }: ProjectPageViewProps<NotesPageData>): ReactElement {
const listHref = listHrefOf(slugs)
if (data.kind === "list") {
return (
<NotesResource
slugs={slugs}
seed={data.notes}
labels={data.labels}
locale={locale}
intro={
<>
{data.intro}
<FeedbackButton labels={data.feedback} /* kit:guides */ />
</>
}
/>
)
}
const { note } = data
return (
<Page>
<PageHeader
title={note.title}
description={data.changed}
breadcrumb={<a href={listHref}>{data.back}</a>}
actions={
<>
<Button asChild variant="outline">
<a href={`${listHref}?record=${note.noteId}`}>{data.edit}</a>
</Button>
<DeleteNote slugs={slugs} noteId={note.noteId} listHref={listHref} labels={data.remove} />
</>
}
/>
<PageSection>
<p>{note.body}</p>
</PageSection>
<Attachments
slugs={slugs}
noteId={note.noteId}
seed={data.attachments}
enabled={data.attachmentsEnabled}
accept={ATTACHMENT_CONTENT_TYPES}
labels={data.attachmentLabels}
locale={locale}
/>
</Page>
)
},
})
+42
View File
@@ -0,0 +1,42 @@
"use client"
// The workspace page's projects, with a count of those ticked. The count exists only in the browser, so the
// loader cannot translate it: the page hands over the message with its placeholder left in, and this island
// fills it with `formatMessage`, which brings the language's plural rules and no catalogue (F10, decision 2).
import { formatMessage } from "@conusai-kit/i18n/format"
import { ResourceList } from "@conusai-kit/ui/patterns"
import { useUiLabel } from "@conusai-kit/ui/primitives"
import { type ReactElement, useState } from "react"
export interface ProjectPickerProps {
readonly label: string
readonly column: string
readonly empty: string
readonly projects: readonly { readonly slug: string; readonly name: string; readonly href: string }[]
/** `{count, plural, ...}`, in the person's language, not yet filled in. */
readonly selected: string
readonly locale: string
}
export function ProjectPicker({ projects, selected, locale, label, column, empty }: ProjectPickerProps): ReactElement {
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
const selectAll = useUiLabel("selectAll")
return (
<ResourceList
label={label}
columns={[{ id: "name", header: column, wrap: true }]}
data={projects}
rowId={(project) => project.slug}
rowHref={(project) => project.href}
rowLabel={(project) => project.name}
selectionMode="multiple"
selectionLabel={selectAll}
rowSelectionLabel={(project) => project.name}
selectedKeys={picked}
onSelectionChange={setPicked}
count={formatMessage(selected, { count: picked.size }, locale)}
empty={{ title: empty }}
/>
)
}
+108
View File
@@ -0,0 +1,108 @@
// A page above any project, on the shell's F10 contract: `/<org>/<workspace>/modules/notes`. It lists the
// workspace's projects, each linked to its notes, and `?q=` filters them on the server, so the first byte
// already holds the result and a filter is a link anyone can copy.
//
// The shell answers 403 for `permission` before `load` runs; the rest is a `ProjectPage` with two slugs. This is
// the worked example for a catalogue, a list or a setting that belongs to a workspace, not to a project, and for
// a page's strings as one record, with a count only the browser knows formatted there (project-picker.tsx).
import type { Container, TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { projectList } from "@conusai-kit/tenancy"
import { type WorkspacePage, workspacePage } from "@conusai-kit/ui"
import { FilterBar, Page, PageHeader, PageState, TextField } from "@conusai-kit/ui/patterns"
import { Button } from "@conusai-kit/ui/primitives"
import { ProjectPicker } from "./project-picker.js"
/** Every string of the page, as one record. `selected` keeps its placeholder: only the browser knows the count. */
export const WORKSPACE_LABEL_KEYS = [
"title",
"intro",
"filter",
"apply",
"clear",
"empty",
"noProjects",
"selected",
] as const
export interface NotesWorkspaceData {
readonly labels: Readonly<Record<(typeof WORKSPACE_LABEL_KEYS)[number], string>>
readonly query: string
readonly projects: readonly { readonly slug: string; readonly name: string }[]
}
export type NotesWorkspacePage = WorkspacePage<TransportCaller, I18n, Container, NotesWorkspaceData>
export const notesWorkspacePage: NotesWorkspacePage = workspacePage({
permission: "notes.note.read",
async load({ slugs, search, caller, i18n }): Promise<NotesWorkspaceData> {
const query = search.q ?? ""
const wanted = query.trim().toLowerCase()
const { projects } = await caller.call(projectList, slugs)
return {
labels: Object.fromEntries(
WORKSPACE_LABEL_KEYS.map((key) => [key, i18n.message(`notes.workspace.${key}`)]),
) as NotesWorkspaceData["labels"],
query,
projects: projects
.filter((project) => project.name.toLowerCase().includes(wanted))
.map(({ slug, name }) => ({ slug, name })),
}
},
View({ slugs, locale, data }) {
const { labels } = data
return (
<Page>
<PageHeader title={labels.title} description={labels.intro} />
<FilterBar
method="get"
actions={
<>
<Button type="submit" variant="secondary">
{labels.apply}
</Button>
{data.query.trim() && data.projects.length > 0 && (
<Button asChild variant="link">
<a href={`/${slugs.org}/${slugs.workspace}/modules/notes`}>{labels.clear}</a>
</Button>
)}
</>
}
>
<TextField
name="q"
label={labels.filter}
placeholder={labels.filter}
type="search"
defaultValue={data.query}
/>
</FilterBar>
{data.projects.length === 0 ? (
<PageState
title={data.query.trim() ? labels.empty : labels.noProjects}
action={
data.query.trim() ? (
<Button asChild variant="outline">
<a href={`/${slugs.org}/${slugs.workspace}/modules/notes`}>{labels.clear}</a>
</Button>
) : undefined
}
/>
) : (
<ProjectPicker
label={labels.title}
column={labels.filter}
empty={labels.noProjects}
projects={data.projects.map((project) => ({
...project,
href: `/${slugs.org}/${slugs.workspace}/${project.slug}/notes`,
}))}
selected={labels.selected}
locale={locale}
/>
)}
</Page>
)
},
})