@anchrd/intel-ui 0.40.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -1
- package/src/app/app-tree/app-tree.tsx +21 -0
- package/src/attachment-viewer/attachment-viewer.tsx +305 -0
- package/src/board/board-assignee/board-assignee.tsx +45 -0
- package/src/board/board-chip/board-chip.tsx +31 -0
- package/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/src/board/board-crumbs/board-crumbs.tsx +108 -0
- package/src/board/board-kanban/board-kanban.tsx +43 -8
- package/src/board/board-open-task/board-open-task.ts +32 -0
- package/src/board/board-panel/board-panel.tsx +17 -37
- package/src/board/board-status/board-status.tsx +60 -0
- package/src/board/board-table/board-table.tsx +207 -101
- package/src/board/board-task/board-task.tsx +144 -195
- package/src/board/board-title-row/board-title-row.tsx +68 -0
- package/src/file-preview/file-preview-view.tsx +342 -0
- package/src/file-preview/file-preview.tsx +28 -0
- package/src/file-preview/pdf-file-preview.tsx +5 -0
- package/src/file-preview/presentation-file-preview.tsx +21 -0
- package/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/src/file-preview/word-file-preview.tsx +5 -0
- package/src/i18n/de.json +62 -6
- package/src/i18n/en.json +62 -6
- package/src/i18n/es.json +62 -6
- package/src/nodes/nodes.tsx +26 -52
- package/src/resource-menu/resource-menu.tsx +18 -12
- package/src/title-row/title-row.tsx +33 -6
- package/vite.config.ts +111 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
"@dnd-kit/core": "^6.3.1",
|
|
41
41
|
"@dnd-kit/sortable": "^10.0.0",
|
|
42
42
|
"@dnd-kit/utilities": "^3.2.2",
|
|
43
|
+
"@file-viewer/react": "2.3.0",
|
|
44
|
+
"@file-viewer/renderer-pdf": "2.3.0",
|
|
45
|
+
"@file-viewer/renderer-presentation": "2.3.0",
|
|
46
|
+
"@file-viewer/renderer-spreadsheet": "2.3.2",
|
|
47
|
+
"@file-viewer/renderer-word": "2.3.1",
|
|
48
|
+
"@file-viewer/vite-plugin": "2.3.1",
|
|
43
49
|
"@react-sigma/core": "^5.0.6",
|
|
44
50
|
"@tailwindcss/vite": "^4.3.3",
|
|
45
51
|
"@tanstack/react-query": "^5.101.4",
|
|
@@ -58,6 +64,7 @@
|
|
|
58
64
|
"react": "^19.2.0",
|
|
59
65
|
"react-aria-components": "^1.19.0",
|
|
60
66
|
"react-dom": "^19.2.0",
|
|
67
|
+
"readable-stream": "2.3.8",
|
|
61
68
|
"sigma": "^3.0.3",
|
|
62
69
|
"tailwind-merge": "^3.6.0",
|
|
63
70
|
"tailwindcss": "^4.3.3",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { NodeKind } from "@anchrd/intel-contract/node";
|
|
2
2
|
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
4
|
+
import { zipSync } from "fflate";
|
|
4
5
|
import {
|
|
5
6
|
ChevronRight,
|
|
6
7
|
CornerLeftUp,
|
|
@@ -80,6 +81,16 @@ async function fileBase64(file: File): Promise<string> {
|
|
|
80
81
|
});
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
function nativeUpload(file: File): boolean {
|
|
85
|
+
const name = file.name.toLowerCase();
|
|
86
|
+
return name.endsWith(".md") || name.endsWith(".csv");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function singleFileBundle(file: File): Promise<Blob> {
|
|
90
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
91
|
+
return new Blob([zipSync({ [file.name]: bytes }).slice().buffer], { type: "application/zip" });
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
// A level is either a folder of the shared tree or the flows one flow calls. The second kind is
|
|
84
95
|
// derived from that flow's graph, not from `parent_id` (ADR-0004 §3), which is why it is its own
|
|
85
96
|
// level rather than another folder.
|
|
@@ -352,6 +363,16 @@ export function AppTree() {
|
|
|
352
363
|
|
|
353
364
|
const upload = useMutation({
|
|
354
365
|
mutationFn: async ({ parentId, file }: { parentId: string | null; file: File }) => {
|
|
366
|
+
if (nativeUpload(file)) {
|
|
367
|
+
const result = await data.importNodeBundle({
|
|
368
|
+
nodeId: parentId,
|
|
369
|
+
zip: await singleFileBundle(file),
|
|
370
|
+
idempotencyKey: crypto.randomUUID(),
|
|
371
|
+
});
|
|
372
|
+
const id = result.rootNodeIds[0];
|
|
373
|
+
if (!id) throw new Error("Native upload created no root node");
|
|
374
|
+
return { id, parentId };
|
|
375
|
+
}
|
|
355
376
|
const contentBase64 = await fileBase64(file);
|
|
356
377
|
const node = await data.createNodes({
|
|
357
378
|
parentId,
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { NodeDocument } from "@anchrd/intel-contract/node";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { unzipSync } from "fflate";
|
|
4
|
+
import { FileQuestion, Info, X } from "lucide-react";
|
|
5
|
+
import { Component, lazy, type ReactNode, Suspense, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
7
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
8
|
+
import { cn } from "@/lib/utils";
|
|
9
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
10
|
+
import { useDateTime } from "@/time/time-context.tsx";
|
|
11
|
+
|
|
12
|
+
const FilePreview = lazy(async () => ({
|
|
13
|
+
default: (await import("@/file-preview/file-preview.tsx")).FilePreview,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
export type AttachmentPreviewKind =
|
|
17
|
+
| "image"
|
|
18
|
+
| "pdf"
|
|
19
|
+
| "word"
|
|
20
|
+
| "spreadsheet"
|
|
21
|
+
| "presentation"
|
|
22
|
+
| "unsupported";
|
|
23
|
+
|
|
24
|
+
const extensions: Record<Exclude<AttachmentPreviewKind, "unsupported">, readonly string[]> = {
|
|
25
|
+
image: ["avif", "bmp", "gif", "jpeg", "jpg", "png", "svg", "webp"],
|
|
26
|
+
pdf: ["pdf"],
|
|
27
|
+
word: ["docx"],
|
|
28
|
+
spreadsheet: ["xlsx"],
|
|
29
|
+
presentation: ["pptx"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function attachmentPreviewKind(title: string, mediaType: string): AttachmentPreviewKind {
|
|
33
|
+
if (mediaType.startsWith("image/")) return "image";
|
|
34
|
+
if (mediaType === "application/pdf") return "pdf";
|
|
35
|
+
const extension = title.toLowerCase().split(".").at(-1) ?? "";
|
|
36
|
+
for (const [kind, supported] of Object.entries(extensions)) {
|
|
37
|
+
if (supported.includes(extension)) return kind as AttachmentPreviewKind;
|
|
38
|
+
}
|
|
39
|
+
return "unsupported";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatFileSize(bytes: number): string {
|
|
43
|
+
if (bytes < 1_000) return `${bytes} B`;
|
|
44
|
+
if (bytes < 1_000_000) return `${(bytes / 1_000).toFixed(bytes < 10_000 ? 1 : 0)} KB`;
|
|
45
|
+
return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type FormatDetail = {
|
|
49
|
+
label: "dimensions" | "pages" | "sections" | "slides" | "worksheets";
|
|
50
|
+
value: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export async function attachmentFormatDetail(
|
|
54
|
+
file: File,
|
|
55
|
+
kind: AttachmentPreviewKind,
|
|
56
|
+
): Promise<FormatDetail | null> {
|
|
57
|
+
if (kind === "image") {
|
|
58
|
+
const bitmap = await createImageBitmap(file);
|
|
59
|
+
const value = `${bitmap.width} × ${bitmap.height} px`;
|
|
60
|
+
bitmap.close();
|
|
61
|
+
return { label: "dimensions", value };
|
|
62
|
+
}
|
|
63
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
64
|
+
if (kind === "pdf") {
|
|
65
|
+
const source = new TextDecoder("latin1").decode(bytes);
|
|
66
|
+
const pages = source.match(/\/Type\s*\/Page\b/g)?.length ?? 0;
|
|
67
|
+
return pages > 0 ? { label: "pages", value: String(pages) } : null;
|
|
68
|
+
}
|
|
69
|
+
if (kind === "unsupported") return null;
|
|
70
|
+
const archive = unzipSync(bytes);
|
|
71
|
+
if (kind === "presentation") {
|
|
72
|
+
const slides = Object.keys(archive).filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name));
|
|
73
|
+
return { label: "slides", value: String(slides.length) };
|
|
74
|
+
}
|
|
75
|
+
const decoder = new TextDecoder();
|
|
76
|
+
if (kind === "spreadsheet") {
|
|
77
|
+
const workbook = archive["xl/workbook.xml"];
|
|
78
|
+
if (!workbook) return null;
|
|
79
|
+
const worksheets = decoder.decode(workbook).match(/<sheet\b/g)?.length ?? 0;
|
|
80
|
+
return { label: "worksheets", value: String(worksheets) };
|
|
81
|
+
}
|
|
82
|
+
const document = archive["word/document.xml"];
|
|
83
|
+
if (!document) return null;
|
|
84
|
+
const sections = Math.max(1, decoder.decode(document).match(/<w:sectPr\b/g)?.length ?? 0);
|
|
85
|
+
return { label: "sections", value: String(sections) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function AttachmentViewer({ document }: { document: NodeDocument }) {
|
|
89
|
+
const { data } = useIntelRouterContext();
|
|
90
|
+
const i18n = useI18n();
|
|
91
|
+
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
92
|
+
const attachment = useQuery({
|
|
93
|
+
queryKey: ["attachment", document.node.id, document.version?.id],
|
|
94
|
+
queryFn: () => data.getNodeAttachment(document.node.id),
|
|
95
|
+
enabled: document.version !== null,
|
|
96
|
+
retry: false,
|
|
97
|
+
});
|
|
98
|
+
const file = useMemo(
|
|
99
|
+
() =>
|
|
100
|
+
attachment.data && document.version
|
|
101
|
+
? new File([attachment.data], document.node.title, { type: document.version.mediaType })
|
|
102
|
+
: null,
|
|
103
|
+
[attachment.data, document.node.title, document.version],
|
|
104
|
+
);
|
|
105
|
+
const kind = document.version
|
|
106
|
+
? attachmentPreviewKind(document.node.title, document.version.mediaType)
|
|
107
|
+
: "unsupported";
|
|
108
|
+
const hasViewerControls = kind !== "image" && kind !== "unsupported";
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
|
|
112
|
+
<ActionSlot name="title-actions">
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
aria-label={i18n.t(detailsOpen ? "attachment.detailsHide" : "attachment.detailsShow")}
|
|
116
|
+
aria-expanded={detailsOpen}
|
|
117
|
+
onClick={() => setDetailsOpen((current) => !current)}
|
|
118
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-muted outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
119
|
+
>
|
|
120
|
+
<Info aria-hidden="true" className="size-4" />
|
|
121
|
+
</button>
|
|
122
|
+
</ActionSlot>
|
|
123
|
+
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border bg-background">
|
|
124
|
+
<div className="relative min-h-0 min-w-0 flex-1 bg-muted/40">
|
|
125
|
+
{document.version === null ? (
|
|
126
|
+
<PreviewMessage message={i18n.t("attachment.empty")} />
|
|
127
|
+
) : attachment.isPending ? (
|
|
128
|
+
<PreviewMessage message={i18n.t("common.loading")} />
|
|
129
|
+
) : attachment.isError || !file ? (
|
|
130
|
+
<PreviewMessage error message={i18n.t("attachment.loadFailed")} />
|
|
131
|
+
) : kind === "unsupported" ? (
|
|
132
|
+
<PreviewMessage message={i18n.t("attachment.unsupported")} />
|
|
133
|
+
) : kind === "image" ? (
|
|
134
|
+
<ImagePreview file={file} title={document.node.title} />
|
|
135
|
+
) : (
|
|
136
|
+
<ViewerErrorBoundary
|
|
137
|
+
key={`${document.node.id}:${document.version.id}`}
|
|
138
|
+
fallback={<PreviewMessage error message={i18n.t("attachment.loadFailed")} />}
|
|
139
|
+
>
|
|
140
|
+
<Suspense fallback={<PreviewMessage message={i18n.t("common.loading")} />}>
|
|
141
|
+
<FilePreview file={file} kind={kind} />
|
|
142
|
+
</Suspense>
|
|
143
|
+
</ViewerErrorBoundary>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
{detailsOpen ? (
|
|
147
|
+
<AttachmentDetails
|
|
148
|
+
document={document}
|
|
149
|
+
file={file}
|
|
150
|
+
footerInset={hasViewerControls}
|
|
151
|
+
kind={kind}
|
|
152
|
+
close={() => setDetailsOpen(false)}
|
|
153
|
+
/>
|
|
154
|
+
) : null}
|
|
155
|
+
{detailsOpen && hasViewerControls ? (
|
|
156
|
+
<div
|
|
157
|
+
aria-hidden="true"
|
|
158
|
+
className="pointer-events-none absolute inset-x-0 bottom-14 z-10 hidden border-t md:block"
|
|
159
|
+
/>
|
|
160
|
+
) : null}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
class ViewerErrorBoundary extends Component<
|
|
167
|
+
{ children: ReactNode; fallback: ReactNode },
|
|
168
|
+
{ failed: boolean }
|
|
169
|
+
> {
|
|
170
|
+
override state = { failed: false };
|
|
171
|
+
|
|
172
|
+
static getDerivedStateFromError() {
|
|
173
|
+
return { failed: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
override render() {
|
|
177
|
+
return this.state.failed ? this.props.fallback : this.props.children;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function PreviewMessage({ message, error = false }: { message: string; error?: boolean }) {
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
role={error ? "alert" : undefined}
|
|
185
|
+
className={cn(
|
|
186
|
+
"grid h-full min-h-64 place-items-center p-8 text-center text-sm text-muted-foreground",
|
|
187
|
+
error && "text-destructive",
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
190
|
+
<div className="max-w-sm space-y-3">
|
|
191
|
+
<FileQuestion aria-hidden="true" className="mx-auto size-9" />
|
|
192
|
+
<p>{message}</p>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function ImagePreview({ file, title }: { file: File; title: string }) {
|
|
199
|
+
const [url, setUrl] = useState("");
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const next = URL.createObjectURL(file);
|
|
202
|
+
setUrl(next);
|
|
203
|
+
return () => URL.revokeObjectURL(next);
|
|
204
|
+
}, [file]);
|
|
205
|
+
return url ? (
|
|
206
|
+
<div className="grid h-full min-h-64 place-items-center overflow-auto p-4">
|
|
207
|
+
<img src={url} alt={title} className="max-h-full max-w-full object-contain" />
|
|
208
|
+
</div>
|
|
209
|
+
) : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function AttachmentDetails({
|
|
213
|
+
document,
|
|
214
|
+
file,
|
|
215
|
+
footerInset,
|
|
216
|
+
kind,
|
|
217
|
+
close,
|
|
218
|
+
}: {
|
|
219
|
+
document: NodeDocument;
|
|
220
|
+
file: File | null;
|
|
221
|
+
footerInset: boolean;
|
|
222
|
+
kind: AttachmentPreviewKind;
|
|
223
|
+
close(): void;
|
|
224
|
+
}) {
|
|
225
|
+
const i18n = useI18n();
|
|
226
|
+
const dateTime = useDateTime();
|
|
227
|
+
const version = document.version;
|
|
228
|
+
const formatDetail = useQuery({
|
|
229
|
+
queryKey: ["attachment-format-detail", document.node.id, version?.id],
|
|
230
|
+
queryFn: async () => (file ? await attachmentFormatDetail(file, kind) : null),
|
|
231
|
+
enabled: file !== null && kind !== "unsupported",
|
|
232
|
+
retry: false,
|
|
233
|
+
});
|
|
234
|
+
const rows = version
|
|
235
|
+
? [
|
|
236
|
+
[i18n.t("attachment.details.name"), document.node.title],
|
|
237
|
+
[
|
|
238
|
+
i18n.t("attachment.details.type"),
|
|
239
|
+
i18n.t(
|
|
240
|
+
`attachment.kind.${attachmentPreviewKind(document.node.title, version.mediaType)}`,
|
|
241
|
+
),
|
|
242
|
+
],
|
|
243
|
+
[i18n.t("attachment.details.mimeType"), version.mediaType],
|
|
244
|
+
[
|
|
245
|
+
i18n.t("attachment.details.format"),
|
|
246
|
+
document.node.title.split(".").at(-1)?.toUpperCase() ?? "—",
|
|
247
|
+
],
|
|
248
|
+
[i18n.t("attachment.details.size"), formatFileSize(version.size)],
|
|
249
|
+
[i18n.t("attachment.details.uploaded"), dateTime.at(version.createdAt)],
|
|
250
|
+
[i18n.t("attachment.details.uploadedBy"), version.createdBy],
|
|
251
|
+
[i18n.t("attachment.details.version"), String(version.sequence)],
|
|
252
|
+
]
|
|
253
|
+
: [[i18n.t("attachment.details.name"), document.node.title]];
|
|
254
|
+
if (formatDetail.data) {
|
|
255
|
+
rows.push([i18n.t(`attachment.details.${formatDetail.data.label}`), formatDetail.data.value]);
|
|
256
|
+
}
|
|
257
|
+
return (
|
|
258
|
+
<aside
|
|
259
|
+
aria-label={i18n.t("attachment.details.title")}
|
|
260
|
+
className={cn(
|
|
261
|
+
"absolute inset-x-0 bottom-0 z-10 max-h-[80%] overflow-auto border-t bg-background shadow-lg md:static md:z-auto md:w-80 md:max-h-none md:shrink-0 md:border-l md:border-t-0 md:shadow-none",
|
|
262
|
+
// File renderers keep their controls in a shared footer; the panel ends beside that footer.
|
|
263
|
+
footerInset && "md:mb-14",
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
266
|
+
<div className="flex items-center justify-between border-b px-5 py-4">
|
|
267
|
+
<h3 className="font-semibold">{i18n.t("attachment.details.title")}</h3>
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
aria-label={i18n.t("common.close")}
|
|
271
|
+
onClick={close}
|
|
272
|
+
className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
273
|
+
>
|
|
274
|
+
<X aria-hidden="true" className="size-4" />
|
|
275
|
+
</button>
|
|
276
|
+
</div>
|
|
277
|
+
<dl className="divide-y px-5">
|
|
278
|
+
{rows.map(([label, value]) => (
|
|
279
|
+
<div
|
|
280
|
+
key={label}
|
|
281
|
+
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm"
|
|
282
|
+
>
|
|
283
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
284
|
+
<dd className="break-words">{value}</dd>
|
|
285
|
+
</div>
|
|
286
|
+
))}
|
|
287
|
+
</dl>
|
|
288
|
+
{version ? (
|
|
289
|
+
<div className="border-t px-5 py-4">
|
|
290
|
+
<h4 className="mb-2 font-medium">{i18n.t("attachment.details.technical")}</h4>
|
|
291
|
+
<dl className="divide-y">
|
|
292
|
+
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm">
|
|
293
|
+
<dt className="text-muted-foreground">{i18n.t("attachment.details.storage")}</dt>
|
|
294
|
+
<dd>{i18n.t("attachment.details.private")}</dd>
|
|
295
|
+
</div>
|
|
296
|
+
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm">
|
|
297
|
+
<dt className="text-muted-foreground">{i18n.t("attachment.details.checksum")}</dt>
|
|
298
|
+
<dd className="font-mono text-xs">{`${version.contentHash.slice(0, 8)}…${version.contentHash.slice(-4)}`}</dd>
|
|
299
|
+
</div>
|
|
300
|
+
</dl>
|
|
301
|
+
</div>
|
|
302
|
+
) : null}
|
|
303
|
+
</aside>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
2
|
+
import { initials } from "@/user-name/user-name.ts";
|
|
3
|
+
import { useAssigneeLabel } from "./board-assignee.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The circle for whoever a card is for, and the way to take the assignment off.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **One truth for two surfaces.** The opened card and the table both draw it; written twice they
|
|
9
|
+
* would drift, and the drift would be an accessibility bug on exactly one of them.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed from the access
|
|
12
|
+
* summary, the field stays `assigneeId`. A row of circles would imply a second data model.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **Only ever drawn for somebody.** "Nobody yet" as a chip is a placeholder for an absence, and
|
|
15
|
+
* an absence needs no place on screen (#692).
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **The NAME lives on the button, and the circle is hidden.** Children of a `<button>` are
|
|
18
|
+
* presentational in ARIA, so a label inside one is never announced and the button's own name wins.
|
|
19
|
+
* Wrapping the circle in a control therefore takes the person out of the accessibility tree unless
|
|
20
|
+
* the control says it too. That is the trap in `packages/ui/CLAUDE.md`.
|
|
21
|
+
*/
|
|
22
|
+
export function AssigneeChip({ id, clear }: { id: string; clear(): void }) {
|
|
23
|
+
const i18n = useI18n();
|
|
24
|
+
const label = useAssigneeLabel(id);
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
aria-label={i18n.t("board.clearAssignee", { name: label })}
|
|
29
|
+
title={label}
|
|
30
|
+
onClick={clear}
|
|
31
|
+
className="shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
32
|
+
>
|
|
33
|
+
<span
|
|
34
|
+
// ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
|
|
35
|
+
// person's name, and that reached nobody: it sits inside a button. Giving it a role and a
|
|
36
|
+
// label back would not add a second announcement, it would add none, while making the code
|
|
37
|
+
// look as though the name were covered here rather than on the button.
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
|
|
40
|
+
>
|
|
41
|
+
{initials(label)}
|
|
42
|
+
</span>
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value on a card, and the way to take it off again.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **One truth for two surfaces.** The opened card and the table both draw these; written twice
|
|
5
|
+
* they drift in size, in shape and — the expensive one — in what they are called.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **The name says the value AND what pressing it does.** A button called `Zebra` that quietly
|
|
8
|
+
* clears a dependency is one nobody can predict; a name that drops the value is the other half of
|
|
9
|
+
* the mistake, because somebody speaking the visible words has to be able to reach the control by
|
|
10
|
+
* them (WCAG 2.5.3). Value first, then the consequence.
|
|
11
|
+
*/
|
|
12
|
+
export function BoardChip({
|
|
13
|
+
label,
|
|
14
|
+
action,
|
|
15
|
+
onClick,
|
|
16
|
+
}: {
|
|
17
|
+
label: string;
|
|
18
|
+
action: string;
|
|
19
|
+
onClick(): void;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
aria-label={`${label}, ${action}`}
|
|
25
|
+
onClick={onClick}
|
|
26
|
+
className="shrink-0 rounded-full border px-2.5 py-0.5 text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
27
|
+
>
|
|
28
|
+
{label}
|
|
29
|
+
</button>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
2
|
+
|
|
3
|
+
/** One step of the path above an open card. The board is a step like any other. */
|
|
4
|
+
export interface Crumb {
|
|
5
|
+
/** The node id to open, or `null` for the board itself. */
|
|
6
|
+
taskId: string | null;
|
|
7
|
+
title: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The path from the board down to the open card, board first.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **An unknown parent ENDS the walk rather than losing the card.** Under a filter a subtask can
|
|
14
|
+
* be in the answer while its parent is not (`board.ts` says so on `parentTaskId`). The path is then
|
|
15
|
+
* shorter than the tree, which is honest: it shows what this answer knows.
|
|
16
|
+
*/
|
|
17
|
+
export function crumbsOf(taskId: string, boardTitle: string, tasks: BoardTask[]): Crumb[] {
|
|
18
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
19
|
+
const trail: Crumb[] = [];
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ⚠️ **`seen` is the whole guard, and a second counter beside it would be decoration.** A loop in
|
|
24
|
+
* the parent chain ends the walk here because every id is visited once; a step limit could only
|
|
25
|
+
* fire where this already has, and a guard that cannot fire is one nobody can test.
|
|
26
|
+
*/
|
|
27
|
+
let current = byId.get(taskId);
|
|
28
|
+
while (current !== undefined) {
|
|
29
|
+
if (seen.has(current.id)) break;
|
|
30
|
+
seen.add(current.id);
|
|
31
|
+
trail.unshift({ taskId: current.id, title: current.title });
|
|
32
|
+
const parentId = current.parentTaskId;
|
|
33
|
+
current = parentId === null ? undefined : byId.get(parentId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [{ taskId: null, title: boardTitle }, ...trail];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The path as it is drawn: board, then an ellipsis standing for what was left out, then the last
|
|
41
|
+
* `keep` steps.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ **The board and the open card are never hidden.** They are the two ends somebody navigates by:
|
|
44
|
+
* where they came from and where they are. Everything between them is what the ellipsis holds.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ **`hidden` is the LIST, not a count.** The three dots open it, so the view needs the entries
|
|
47
|
+
* themselves; handing over a number would force it to recompute what was dropped.
|
|
48
|
+
*/
|
|
49
|
+
export function foldCrumbs(crumbs: Crumb[], keep = 1): { shown: Crumb[]; hidden: Crumb[] } {
|
|
50
|
+
// Board + the kept tail + at least one in between is what makes folding worth anything.
|
|
51
|
+
if (crumbs.length <= keep + 2) return { shown: crumbs, hidden: [] };
|
|
52
|
+
return {
|
|
53
|
+
shown: [...crumbs.slice(0, 1), ...crumbs.slice(crumbs.length - keep)],
|
|
54
|
+
hidden: crumbs.slice(1, crumbs.length - keep),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { ChevronLeft } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenu,
|
|
4
|
+
DropdownMenuContent,
|
|
5
|
+
DropdownMenuItem,
|
|
6
|
+
DropdownMenuTrigger,
|
|
7
|
+
} from "@/components/ui/dropdown-menu.tsx";
|
|
8
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
9
|
+
import { type Crumb, foldCrumbs } from "./board-crumbs.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The path above an open card: back to the board, then every level down to this one.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **The arrow belongs to the BOARD, the path to the cards.** Jack's decision 2026-08-21: one
|
|
15
|
+
* control that always means the same thing, and a path whose steps are each their own way back. An
|
|
16
|
+
* arrow that went one level up would mean something different on every card.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ **This replaces the heading rather than standing beside it.** The last step IS the card's name;
|
|
19
|
+
* drawn next to a heading it would be the same sentence twice.
|
|
20
|
+
*/
|
|
21
|
+
export function BoardCrumbs({
|
|
22
|
+
crumbs,
|
|
23
|
+
onOpen,
|
|
24
|
+
}: {
|
|
25
|
+
crumbs: Crumb[];
|
|
26
|
+
onOpen(taskId: string | null): void;
|
|
27
|
+
}) {
|
|
28
|
+
const i18n = useI18n();
|
|
29
|
+
const { shown, hidden } = foldCrumbs(crumbs);
|
|
30
|
+
const board = shown[0];
|
|
31
|
+
const rest = shown.slice(1);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<nav aria-label={i18n.t("board.crumbs")} className="flex min-w-0 items-center gap-1.5 text-sm">
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
aria-label={i18n.t("board.backTo", { board: board?.title ?? "" })}
|
|
38
|
+
onClick={() => onOpen(null)}
|
|
39
|
+
className="-ml-1 rounded-md p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
40
|
+
>
|
|
41
|
+
<ChevronLeft aria-hidden="true" className="size-4" />
|
|
42
|
+
</button>
|
|
43
|
+
|
|
44
|
+
<Step crumb={board} onOpen={onOpen} />
|
|
45
|
+
|
|
46
|
+
{hidden.length === 0 ? null : (
|
|
47
|
+
<>
|
|
48
|
+
<Separator />
|
|
49
|
+
<DropdownMenu>
|
|
50
|
+
{/* ⚠️ A menu, not a tooltip. On a touch screen there is no hover, and the levels behind
|
|
51
|
+
the dots are the only way to reach them — a hint that needs a pointer would put them
|
|
52
|
+
out of reach on the device where the path folds soonest. */}
|
|
53
|
+
<DropdownMenuTrigger
|
|
54
|
+
aria-label={i18n.t("board.crumbsFolded")}
|
|
55
|
+
className="rounded-md border px-1.5 leading-none text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
56
|
+
>
|
|
57
|
+
…
|
|
58
|
+
</DropdownMenuTrigger>
|
|
59
|
+
<DropdownMenuContent align="start">
|
|
60
|
+
{hidden.map((crumb) => (
|
|
61
|
+
<DropdownMenuItem key={crumb.taskId} onSelect={() => onOpen(crumb.taskId)}>
|
|
62
|
+
{crumb.title}
|
|
63
|
+
</DropdownMenuItem>
|
|
64
|
+
))}
|
|
65
|
+
</DropdownMenuContent>
|
|
66
|
+
</DropdownMenu>
|
|
67
|
+
</>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{rest.map((crumb, step) => (
|
|
71
|
+
<span key={crumb.taskId} className="flex min-w-0 items-center gap-1.5">
|
|
72
|
+
<Separator />
|
|
73
|
+
{/* The last step is where the reader IS: named, not offered as somewhere to go. */}
|
|
74
|
+
{step === rest.length - 1 ? (
|
|
75
|
+
<span className="truncate font-medium">{crumb.title}</span>
|
|
76
|
+
) : (
|
|
77
|
+
<Step crumb={crumb} onOpen={onOpen} />
|
|
78
|
+
)}
|
|
79
|
+
</span>
|
|
80
|
+
))}
|
|
81
|
+
</nav>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const Separator = () => (
|
|
86
|
+
<span aria-hidden="true" className="text-muted-foreground">
|
|
87
|
+
/
|
|
88
|
+
</span>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
function Step({
|
|
92
|
+
crumb,
|
|
93
|
+
onOpen,
|
|
94
|
+
}: {
|
|
95
|
+
crumb: Crumb | undefined;
|
|
96
|
+
onOpen(taskId: string | null): void;
|
|
97
|
+
}) {
|
|
98
|
+
if (crumb === undefined) return null;
|
|
99
|
+
return (
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() => onOpen(crumb.taskId)}
|
|
103
|
+
className="truncate rounded-sm text-muted-foreground outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
104
|
+
>
|
|
105
|
+
{crumb.title}
|
|
106
|
+
</button>
|
|
107
|
+
);
|
|
108
|
+
}
|