@anchrd/intel-ui 0.39.0 → 0.41.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 +13 -2
- package/src/access-summary/access-summary.tsx +1 -11
- package/src/app/app-tree/app-tree.tsx +58 -0
- package/src/attachment-viewer/attachment-viewer.tsx +304 -0
- package/src/board/board-assignee/board-assignee.ts +18 -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-data/board-data.ts +31 -5
- package/src/board/board-data/board-data.types.ts +10 -1
- package/src/board/board-kanban/board-kanban.ts +63 -9
- package/src/board/board-kanban/board-kanban.tsx +409 -66
- package/src/board/board-open-task/board-open-task.ts +32 -0
- package/src/board/board-panel/board-panel.tsx +202 -31
- package/src/board/board-settings/board-settings.tsx +209 -0
- package/src/board/board-stripes/board-stripes.ts +128 -0
- package/src/board/board-table/board-table.ts +23 -105
- package/src/board/board-table/board-table.tsx +436 -135
- package/src/board/board-task/board-task.ts +105 -0
- package/src/board/board-task/board-task.tsx +396 -0
- package/src/board/board-title-row/board-title-row.tsx +68 -0
- package/src/file-preview/file-preview-view.tsx +28 -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 +81 -7
- package/src/i18n/en.json +81 -7
- package/src/i18n/es.json +81 -7
- package/src/nodes/nodes.tsx +26 -52
- package/src/resource-menu/resource-menu.tsx +18 -12
- package/src/router/selection-search.ts +17 -2
- package/src/styles.css +29 -25
- package/src/title-row/title-row.tsx +33 -6
- package/src/user-name/user-name.ts +17 -0
- 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.41.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -33,14 +33,24 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@anchrd/intel-contract": "^0.
|
|
36
|
+
"@anchrd/intel-contract": "^0.24.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
40
|
+
"@dnd-kit/core": "^6.3.1",
|
|
41
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
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",
|
|
40
49
|
"@react-sigma/core": "^5.0.6",
|
|
41
50
|
"@tailwindcss/vite": "^4.3.3",
|
|
42
51
|
"@tanstack/react-query": "^5.101.4",
|
|
43
52
|
"@tanstack/react-router": "^1.170.18",
|
|
53
|
+
"@tanstack/react-table": "^9.1.2",
|
|
44
54
|
"@vitejs/plugin-react": "^6.0.4",
|
|
45
55
|
"@xyflow/react": "^12.11.2",
|
|
46
56
|
"class-variance-authority": "^0.7.1",
|
|
@@ -54,6 +64,7 @@
|
|
|
54
64
|
"react": "^19.2.0",
|
|
55
65
|
"react-aria-components": "^1.19.0",
|
|
56
66
|
"react-dom": "^19.2.0",
|
|
67
|
+
"readable-stream": "2.3.8",
|
|
57
68
|
"sigma": "^3.0.3",
|
|
58
69
|
"tailwind-merge": "^3.6.0",
|
|
59
70
|
"tailwindcss": "^4.3.3",
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "@/components/ui/tooltip.tsx";
|
|
12
12
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
13
13
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
14
|
-
import { useUserName } from "@/user-name/user-name.ts";
|
|
14
|
+
import { initials, useUserName } from "@/user-name/user-name.ts";
|
|
15
15
|
|
|
16
16
|
type Principal = ResourceGrant["principal"];
|
|
17
17
|
|
|
@@ -47,16 +47,6 @@ function groupGrants(grants: ResourceGrant[], ownerIds: string[] = []): GrantGro
|
|
|
47
47
|
return [...grouped.values()];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function initials(value: string): string {
|
|
51
|
-
const parts = value
|
|
52
|
-
.replace(/@.*$/, "")
|
|
53
|
-
.split(/[\s._+-]+/)
|
|
54
|
-
.filter(Boolean);
|
|
55
|
-
if (parts.length === 0) return "?";
|
|
56
|
-
if (parts.length === 1) return (parts[0]?.slice(0, 2) ?? "?").toUpperCase();
|
|
57
|
-
return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
50
|
function usePrincipalLabel(principal: Principal): string {
|
|
61
51
|
const i18n = useI18n();
|
|
62
52
|
const userName = useUserName(principal.type === "user" ? principal.id : null);
|
|
@@ -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.
|
|
@@ -288,6 +299,43 @@ export function AppTree() {
|
|
|
288
299
|
idempotencyKey: crypto.randomUUID(),
|
|
289
300
|
});
|
|
290
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* ⚠️ **A board's columns are written at creation, in the language of whoever made it** (#675).
|
|
304
|
+
* The server has a fallback for a board nobody configured, but it cannot know the reader's
|
|
305
|
+
* language — and columns are SHARED data: two people on one board must read the same names,
|
|
306
|
+
* and the settings dialog edits one list. Translating per reader would give them different
|
|
307
|
+
* boards. Written once, they are data anybody can rename.
|
|
308
|
+
*
|
|
309
|
+
* ⚠️ It is a second call and it may fail on its own. That is survivable and deliberately not
|
|
310
|
+
* repaired here: the board then answers with the server's fallback and is usable, which is
|
|
311
|
+
* the same state a board made over MCP is in. A failure that made the whole creation fail
|
|
312
|
+
* would trade a usable board for none.
|
|
313
|
+
*/
|
|
314
|
+
if (kind === "board") {
|
|
315
|
+
try {
|
|
316
|
+
await data.updateBoard({
|
|
317
|
+
boardId: node.id,
|
|
318
|
+
columns: [
|
|
319
|
+
{ id: "backlog", title: i18n.t("board.column.default.backlog"), terminal: false },
|
|
320
|
+
{ id: "todo", title: i18n.t("board.column.default.todo"), terminal: false },
|
|
321
|
+
{ id: "doing", title: i18n.t("board.column.default.doing"), terminal: false },
|
|
322
|
+
{ id: "done", title: i18n.t("board.column.default.done"), terminal: true },
|
|
323
|
+
],
|
|
324
|
+
idempotencyKey: crypto.randomUUID(),
|
|
325
|
+
});
|
|
326
|
+
} catch {
|
|
327
|
+
/**
|
|
328
|
+
* ⚠️ **Swallowed on purpose, and the `catch` is what makes that true.** Without it the
|
|
329
|
+
* failure travels out of the mutation: the node exists, but `onSuccess` never runs, the
|
|
330
|
+
* screen says "creating failed", and the obvious response — try again — makes a SECOND
|
|
331
|
+
* board, because nothing tells the reader the first one is already there.
|
|
332
|
+
*
|
|
333
|
+
* What is lost by swallowing is one thing and it is repairable: the board answers with
|
|
334
|
+
* the server's fallback columns instead of the ones in this reader's language. That is
|
|
335
|
+
* exactly the state a board made over MCP is in, and the settings dialog renames them.
|
|
336
|
+
*/
|
|
337
|
+
}
|
|
338
|
+
}
|
|
291
339
|
return {
|
|
292
340
|
area: "/nodes" as const,
|
|
293
341
|
id: node.id,
|
|
@@ -315,6 +363,16 @@ export function AppTree() {
|
|
|
315
363
|
|
|
316
364
|
const upload = useMutation({
|
|
317
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
|
+
}
|
|
318
376
|
const contentBase64 = await fileBase64(file);
|
|
319
377
|
const node = await data.createNodes({
|
|
320
378
|
parentId,
|
|
@@ -0,0 +1,304 @@
|
|
|
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
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
|
|
111
|
+
<ActionSlot name="title-actions">
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
aria-label={i18n.t(detailsOpen ? "attachment.detailsHide" : "attachment.detailsShow")}
|
|
115
|
+
aria-expanded={detailsOpen}
|
|
116
|
+
onClick={() => setDetailsOpen((current) => !current)}
|
|
117
|
+
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"
|
|
118
|
+
>
|
|
119
|
+
<Info aria-hidden="true" className="size-4" />
|
|
120
|
+
</button>
|
|
121
|
+
</ActionSlot>
|
|
122
|
+
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border bg-background">
|
|
123
|
+
<div className="relative min-h-0 min-w-0 flex-1 bg-muted/40">
|
|
124
|
+
{document.version === null ? (
|
|
125
|
+
<PreviewMessage message={i18n.t("attachment.empty")} />
|
|
126
|
+
) : attachment.isPending ? (
|
|
127
|
+
<PreviewMessage message={i18n.t("common.loading")} />
|
|
128
|
+
) : attachment.isError || !file ? (
|
|
129
|
+
<PreviewMessage error message={i18n.t("attachment.loadFailed")} />
|
|
130
|
+
) : kind === "unsupported" ? (
|
|
131
|
+
<PreviewMessage message={i18n.t("attachment.unsupported")} />
|
|
132
|
+
) : kind === "image" ? (
|
|
133
|
+
<ImagePreview file={file} title={document.node.title} />
|
|
134
|
+
) : (
|
|
135
|
+
<ViewerErrorBoundary
|
|
136
|
+
key={`${document.node.id}:${document.version.id}`}
|
|
137
|
+
fallback={<PreviewMessage error message={i18n.t("attachment.loadFailed")} />}
|
|
138
|
+
>
|
|
139
|
+
<Suspense fallback={<PreviewMessage message={i18n.t("common.loading")} />}>
|
|
140
|
+
<FilePreview file={file} kind={kind} />
|
|
141
|
+
</Suspense>
|
|
142
|
+
</ViewerErrorBoundary>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
{detailsOpen ? (
|
|
146
|
+
<AttachmentDetails
|
|
147
|
+
document={document}
|
|
148
|
+
file={file}
|
|
149
|
+
footerInset={kind === "pdf"}
|
|
150
|
+
kind={kind}
|
|
151
|
+
close={() => setDetailsOpen(false)}
|
|
152
|
+
/>
|
|
153
|
+
) : null}
|
|
154
|
+
{detailsOpen && kind === "pdf" ? (
|
|
155
|
+
<div
|
|
156
|
+
aria-hidden="true"
|
|
157
|
+
className="pointer-events-none absolute inset-x-0 bottom-12 z-10 hidden border-t md:block"
|
|
158
|
+
/>
|
|
159
|
+
) : null}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class ViewerErrorBoundary extends Component<
|
|
166
|
+
{ children: ReactNode; fallback: ReactNode },
|
|
167
|
+
{ failed: boolean }
|
|
168
|
+
> {
|
|
169
|
+
override state = { failed: false };
|
|
170
|
+
|
|
171
|
+
static getDerivedStateFromError() {
|
|
172
|
+
return { failed: true };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
override render() {
|
|
176
|
+
return this.state.failed ? this.props.fallback : this.props.children;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function PreviewMessage({ message, error = false }: { message: string; error?: boolean }) {
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
role={error ? "alert" : undefined}
|
|
184
|
+
className={cn(
|
|
185
|
+
"grid h-full min-h-64 place-items-center p-8 text-center text-sm text-muted-foreground",
|
|
186
|
+
error && "text-destructive",
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
<div className="max-w-sm space-y-3">
|
|
190
|
+
<FileQuestion aria-hidden="true" className="mx-auto size-9" />
|
|
191
|
+
<p>{message}</p>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function ImagePreview({ file, title }: { file: File; title: string }) {
|
|
198
|
+
const [url, setUrl] = useState("");
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const next = URL.createObjectURL(file);
|
|
201
|
+
setUrl(next);
|
|
202
|
+
return () => URL.revokeObjectURL(next);
|
|
203
|
+
}, [file]);
|
|
204
|
+
return url ? (
|
|
205
|
+
<div className="grid h-full min-h-64 place-items-center overflow-auto p-4">
|
|
206
|
+
<img src={url} alt={title} className="max-h-full max-w-full object-contain" />
|
|
207
|
+
</div>
|
|
208
|
+
) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function AttachmentDetails({
|
|
212
|
+
document,
|
|
213
|
+
file,
|
|
214
|
+
footerInset,
|
|
215
|
+
kind,
|
|
216
|
+
close,
|
|
217
|
+
}: {
|
|
218
|
+
document: NodeDocument;
|
|
219
|
+
file: File | null;
|
|
220
|
+
footerInset: boolean;
|
|
221
|
+
kind: AttachmentPreviewKind;
|
|
222
|
+
close(): void;
|
|
223
|
+
}) {
|
|
224
|
+
const i18n = useI18n();
|
|
225
|
+
const dateTime = useDateTime();
|
|
226
|
+
const version = document.version;
|
|
227
|
+
const formatDetail = useQuery({
|
|
228
|
+
queryKey: ["attachment-format-detail", document.node.id, version?.id],
|
|
229
|
+
queryFn: async () => (file ? await attachmentFormatDetail(file, kind) : null),
|
|
230
|
+
enabled: file !== null && kind !== "unsupported",
|
|
231
|
+
retry: false,
|
|
232
|
+
});
|
|
233
|
+
const rows = version
|
|
234
|
+
? [
|
|
235
|
+
[i18n.t("attachment.details.name"), document.node.title],
|
|
236
|
+
[
|
|
237
|
+
i18n.t("attachment.details.type"),
|
|
238
|
+
i18n.t(
|
|
239
|
+
`attachment.kind.${attachmentPreviewKind(document.node.title, version.mediaType)}`,
|
|
240
|
+
),
|
|
241
|
+
],
|
|
242
|
+
[i18n.t("attachment.details.mimeType"), version.mediaType],
|
|
243
|
+
[
|
|
244
|
+
i18n.t("attachment.details.format"),
|
|
245
|
+
document.node.title.split(".").at(-1)?.toUpperCase() ?? "—",
|
|
246
|
+
],
|
|
247
|
+
[i18n.t("attachment.details.size"), formatFileSize(version.size)],
|
|
248
|
+
[i18n.t("attachment.details.uploaded"), dateTime.at(version.createdAt)],
|
|
249
|
+
[i18n.t("attachment.details.uploadedBy"), version.createdBy],
|
|
250
|
+
[i18n.t("attachment.details.version"), String(version.sequence)],
|
|
251
|
+
]
|
|
252
|
+
: [[i18n.t("attachment.details.name"), document.node.title]];
|
|
253
|
+
if (formatDetail.data) {
|
|
254
|
+
rows.push([i18n.t(`attachment.details.${formatDetail.data.label}`), formatDetail.data.value]);
|
|
255
|
+
}
|
|
256
|
+
return (
|
|
257
|
+
<aside
|
|
258
|
+
aria-label={i18n.t("attachment.details.title")}
|
|
259
|
+
className={cn(
|
|
260
|
+
"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",
|
|
261
|
+
// The PDF renderer alone owns a bottom toolbar; keep the desktop panel beside its content.
|
|
262
|
+
footerInset && "md:mb-12",
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
<div className="flex items-center justify-between border-b px-5 py-4">
|
|
266
|
+
<h3 className="font-semibold">{i18n.t("attachment.details.title")}</h3>
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
aria-label={i18n.t("common.close")}
|
|
270
|
+
onClick={close}
|
|
271
|
+
className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
272
|
+
>
|
|
273
|
+
<X aria-hidden="true" className="size-4" />
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
<dl className="divide-y px-5">
|
|
277
|
+
{rows.map(([label, value]) => (
|
|
278
|
+
<div
|
|
279
|
+
key={label}
|
|
280
|
+
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm"
|
|
281
|
+
>
|
|
282
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
283
|
+
<dd className="break-words">{value}</dd>
|
|
284
|
+
</div>
|
|
285
|
+
))}
|
|
286
|
+
</dl>
|
|
287
|
+
{version ? (
|
|
288
|
+
<div className="border-t px-5 py-4">
|
|
289
|
+
<h4 className="mb-2 font-medium">{i18n.t("attachment.details.technical")}</h4>
|
|
290
|
+
<dl className="divide-y">
|
|
291
|
+
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm">
|
|
292
|
+
<dt className="text-muted-foreground">{i18n.t("attachment.details.storage")}</dt>
|
|
293
|
+
<dd>{i18n.t("attachment.details.private")}</dd>
|
|
294
|
+
</div>
|
|
295
|
+
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm">
|
|
296
|
+
<dt className="text-muted-foreground">{i18n.t("attachment.details.checksum")}</dt>
|
|
297
|
+
<dd className="font-mono text-xs">{`${version.contentHash.slice(0, 8)}…${version.contentHash.slice(-4)}`}</dd>
|
|
298
|
+
</div>
|
|
299
|
+
</dl>
|
|
300
|
+
</div>
|
|
301
|
+
) : null}
|
|
302
|
+
</aside>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
2
|
+
import { useUserName } from "@/user-name/user-name.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What to call whoever a card is for.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **One rule, two surfaces.** The table draws the name in a cell, the open card draws it in a
|
|
8
|
+
* circle, and both must answer the same way — `useUserName` resolves only the reader themselves, so
|
|
9
|
+
* for anybody else the honest answer is "a user account" and never the principal id the row carries
|
|
10
|
+
* (`user-name.ts`, #258). Written twice, the two would drift, and the drift would put an id on one
|
|
11
|
+
* of the two screens.
|
|
12
|
+
*/
|
|
13
|
+
export function useAssigneeLabel(id: string | null): string {
|
|
14
|
+
const i18n = useI18n();
|
|
15
|
+
const name = useUserName(id);
|
|
16
|
+
if (id === null) return i18n.t("board.unassigned");
|
|
17
|
+
return name ?? i18n.t("node.someUser");
|
|
18
|
+
}
|
|
@@ -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
|
+
}
|