@anchrd/intel-ui 0.40.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 +8 -1
- package/src/app/app-tree/app-tree.tsx +21 -0
- package/src/attachment-viewer/attachment-viewer.tsx +304 -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-task/board-task.tsx +177 -145
- 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 +37 -4
- package/src/i18n/en.json +37 -4
- package/src/i18n/es.json +37 -4
- 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.41.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,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,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
|
+
}
|
|
@@ -119,7 +119,12 @@ function Card({
|
|
|
119
119
|
that is 76 px of a 240 px card, given away for nothing. */}
|
|
120
120
|
<span
|
|
121
121
|
className="block"
|
|
122
|
-
|
|
122
|
+
// ⚠️ `STRIPE_WIDTH`, not a number written twice. The reserve followed the OLD grid of
|
|
123
|
+
// 3px plus a 2px gap; since the stripes sit flush and 4px wide, `* 5` reserved up to 14px
|
|
124
|
+
// the title never needed, while the comment above claimed the reserve follows them.
|
|
125
|
+
style={{
|
|
126
|
+
paddingRight: `${8 + Math.min(family.level, MAX_STRIPES) * STRIPE_WIDTH}px`,
|
|
127
|
+
}}
|
|
123
128
|
>
|
|
124
129
|
{card.title}
|
|
125
130
|
</span>
|
|
@@ -158,7 +163,10 @@ function Stripes({ family, label }: { family: Family; label: string }) {
|
|
|
158
163
|
// it, and the tooltip would never appear once. The class is not needed either: this span lies
|
|
159
164
|
// inside the button, so a click reaches it anyway.
|
|
160
165
|
title={label}
|
|
161
|
-
|
|
166
|
+
// ⚠️ Flush and gapless, top to bottom. Inset from the edge and spaced apart, the stripes read
|
|
167
|
+
// as decoration somebody added; against the edge and touching, they read as one mark with a
|
|
168
|
+
// countable number of parts, which is what they are.
|
|
169
|
+
className="absolute inset-y-0 right-0 flex"
|
|
162
170
|
>
|
|
163
171
|
{levels(family.level).map((level) => (
|
|
164
172
|
<span
|
|
@@ -171,8 +179,14 @@ function Stripes({ family, label }: { family: Family; label: string }) {
|
|
|
171
179
|
// Since the pattern caps at six the lowest raw value is 0.25, so the clamp no longer
|
|
172
180
|
// fires; it stays because it is the guard, not the arithmetic, that must hold if the cap
|
|
173
181
|
// ever moves.
|
|
174
|
-
|
|
175
|
-
|
|
182
|
+
// ⚠️ No rounding. A rounded stripe at full height leaves a light wedge at each end, and
|
|
183
|
+
// two of them beside one another look like a gap that is not there.
|
|
184
|
+
// ⚠️ The width as an inline STYLE, not a class. `w-[${…}px]` is a name Tailwind never
|
|
185
|
+
// sees — it reads class names statically, so the stripes would have no width in the build
|
|
186
|
+
// and be perfectly fine in the tests. An inline value is also the only way one number can
|
|
187
|
+
// rule both the mark and the room reserved for it.
|
|
188
|
+
className={toneClasses[family.tone - 1]}
|
|
189
|
+
style={{ width: `${STRIPE_WIDTH}px`, opacity: Math.max(0.3, 1 - (level - 1) * 0.15) }}
|
|
176
190
|
/>
|
|
177
191
|
))}
|
|
178
192
|
</span>
|
|
@@ -186,6 +200,15 @@ function Stripes({ family, label }: { family: Family; label: string }) {
|
|
|
186
200
|
*/
|
|
187
201
|
const MAX_STRIPES = 6;
|
|
188
202
|
|
|
203
|
+
/**
|
|
204
|
+
* How wide one stripe is, in pixels.
|
|
205
|
+
*
|
|
206
|
+
* ⚠️ **One number, read by both the mark and the room reserved for it.** Written twice they drift,
|
|
207
|
+
* and the drift is silent: the title keeps its distance from stripes that are no longer that wide,
|
|
208
|
+
* or runs under ones that grew.
|
|
209
|
+
*/
|
|
210
|
+
const STRIPE_WIDTH = 4;
|
|
211
|
+
|
|
189
212
|
/**
|
|
190
213
|
* `[1, 2, … min(depth, MAX_STRIPES)]` — the level each stripe stands for.
|
|
191
214
|
*
|
|
@@ -339,9 +362,11 @@ function Column({
|
|
|
339
362
|
))}
|
|
340
363
|
</ul>
|
|
341
364
|
</SortableContext>
|
|
342
|
-
{
|
|
343
|
-
|
|
344
|
-
|
|
365
|
+
{/* ⚠️ **Nothing at all under an empty column.** The sentence said what the emptiness already
|
|
366
|
+
says, and the surface I first put in its place was an invention with a false reason: the
|
|
367
|
+
column is a drop target through `setDropRef` on the section above, with or without
|
|
368
|
+
anything drawn in it — `dragEndToDrop` answers `{ status, index: 0 }` for a column with no
|
|
369
|
+
cards, and `neighbourDrop` reaches one by index alone. */}
|
|
345
370
|
{addable ? (
|
|
346
371
|
composing ? (
|
|
347
372
|
<input
|
|
@@ -533,7 +558,17 @@ export function BoardKanban({
|
|
|
533
558
|
});
|
|
534
559
|
}}
|
|
535
560
|
>
|
|
536
|
-
|
|
561
|
+
{/* ⚠️ **`min-h-0 flex-1`, or the scrollbar sits in the middle of the screen.** A box with no
|
|
562
|
+
height shrinks to its content, and the horizontal bar clings to the bottom of the tallest
|
|
563
|
+
column: on a board with few cards that looks broken. The panel above already offers the
|
|
564
|
+
full height; this is where it is taken.
|
|
565
|
+
|
|
566
|
+
⚠️ **And NO `items-start` with it.** A stretched column paints nothing — the section
|
|
567
|
+
carries neither background nor border — but the rectangle it stretches to is what
|
|
568
|
+
`useDroppable` measures and `closestCorners` reckons against. Held to its content instead,
|
|
569
|
+
an empty column shrinks to its heading, and the area a card can be dropped into shrinks
|
|
570
|
+
with it. */}
|
|
571
|
+
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4" aria-busy={board.isWriting}>
|
|
537
572
|
{board.columns.map((column) => (
|
|
538
573
|
<Column
|
|
539
574
|
key={column.column.id}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useNavigate, useSearch } from "@tanstack/react-router";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import { openTaskFrom } from "@/router/selection-search.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which card is open, and the one way to change that.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **One truth for two places.** The title row and the panel both open cards, and they are
|
|
9
|
+
* rendered in different subtrees: the row above the content, the panel inside it. Written twice,
|
|
10
|
+
* one of them would eventually push where the other replaces, and the back button would behave
|
|
11
|
+
* differently depending on which control was used.
|
|
12
|
+
*/
|
|
13
|
+
export function useOpenTask(): { openId: string | null; open(taskId: string | null): void } {
|
|
14
|
+
const search = useSearch({ strict: false });
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
|
|
17
|
+
const open = useCallback(
|
|
18
|
+
(taskId: string | null) =>
|
|
19
|
+
void navigate({
|
|
20
|
+
to: ".",
|
|
21
|
+
// ⚠️ **Closing REPLACES, opening pushes.** Opening a card is a step somebody took and the
|
|
22
|
+
// back button must undo it; closing is the undo itself. Pushed as well, the history holds
|
|
23
|
+
// board → card → board, and one press of Back re-opens the card that was just closed.
|
|
24
|
+
replace: taskId === null,
|
|
25
|
+
search: (previous: Record<string, unknown>) =>
|
|
26
|
+
taskId === null ? { ...previous, task: undefined } : { ...previous, task: taskId },
|
|
27
|
+
}),
|
|
28
|
+
[navigate],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return { openId: openTaskFrom(search), open };
|
|
32
|
+
}
|