@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.
Files changed (36) hide show
  1. package/package.json +13 -2
  2. package/src/access-summary/access-summary.tsx +1 -11
  3. package/src/app/app-tree/app-tree.tsx +58 -0
  4. package/src/attachment-viewer/attachment-viewer.tsx +304 -0
  5. package/src/board/board-assignee/board-assignee.ts +18 -0
  6. package/src/board/board-crumbs/board-crumbs.ts +56 -0
  7. package/src/board/board-crumbs/board-crumbs.tsx +108 -0
  8. package/src/board/board-data/board-data.ts +31 -5
  9. package/src/board/board-data/board-data.types.ts +10 -1
  10. package/src/board/board-kanban/board-kanban.ts +63 -9
  11. package/src/board/board-kanban/board-kanban.tsx +409 -66
  12. package/src/board/board-open-task/board-open-task.ts +32 -0
  13. package/src/board/board-panel/board-panel.tsx +202 -31
  14. package/src/board/board-settings/board-settings.tsx +209 -0
  15. package/src/board/board-stripes/board-stripes.ts +128 -0
  16. package/src/board/board-table/board-table.ts +23 -105
  17. package/src/board/board-table/board-table.tsx +436 -135
  18. package/src/board/board-task/board-task.ts +105 -0
  19. package/src/board/board-task/board-task.tsx +396 -0
  20. package/src/board/board-title-row/board-title-row.tsx +68 -0
  21. package/src/file-preview/file-preview-view.tsx +28 -0
  22. package/src/file-preview/file-preview.tsx +28 -0
  23. package/src/file-preview/pdf-file-preview.tsx +5 -0
  24. package/src/file-preview/presentation-file-preview.tsx +21 -0
  25. package/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  26. package/src/file-preview/word-file-preview.tsx +5 -0
  27. package/src/i18n/de.json +81 -7
  28. package/src/i18n/en.json +81 -7
  29. package/src/i18n/es.json +81 -7
  30. package/src/nodes/nodes.tsx +26 -52
  31. package/src/resource-menu/resource-menu.tsx +18 -12
  32. package/src/router/selection-search.ts +17 -2
  33. package/src/styles.css +29 -25
  34. package/src/title-row/title-row.tsx +33 -6
  35. package/src/user-name/user-name.ts +17 -0
  36. package/vite.config.ts +111 -3
@@ -0,0 +1,105 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract/board";
2
+ import type { ColumnWithCards } from "@/board/board-data/board-data.types.ts";
3
+
4
+ /** What one entry of the task's link list is. The icon is drawn from this, nothing else. */
5
+ /**
6
+ * ⚠️ **No `reference` kind yet, on purpose.** A plain reference is a link in the node graph and
7
+ * needs `node_link_list` — a second read this screen does not make. Carrying the kind here with no
8
+ * caller would be a path that looks built and is not; it is `#687` instead.
9
+ */
10
+ export type LinkKind = "subtask" | "blocker" | "blocked";
11
+
12
+ export interface TaskLink {
13
+ kind: LinkKind;
14
+ /** The linked node's id — its address, and the key of the row. */
15
+ id: string;
16
+ /** ⚠️ `null` when this answer holds no name for it. The VIEW decides what to say then; putting
17
+ * the id here would put it on screen. */
18
+ title: string | null;
19
+ /** The column the linked task sits in, so its state is readable without opening it. */
20
+ columnTitle: string | null;
21
+ /** Set on a subtask only: whether it sits in a column the board calls terminal. */
22
+ done: boolean;
23
+ }
24
+
25
+ /**
26
+ * ⚠️ **The order is a DECISION, not an accident.** Subtasks, blockers and references stand mixed in
27
+ * one list, and a list whose order nobody can name reorders itself on every load. The key is, in
28
+ * this order:
29
+ *
30
+ * 1. **by kind** — subtasks, then what this task waits for, then what waits for it. Subtasks first
31
+ * because they carry the progress; what waits on this one last, because it says nothing about
32
+ * whether this task itself can move.
33
+ * 2. **by the board's own position** within a kind, so the list reads in the same order as the
34
+ * column it came from.
35
+ * 3. **by title**, only to break a tie — two cards may share a position after a filtered read.
36
+ */
37
+ const kindOrder: Record<LinkKind, number> = {
38
+ subtask: 0,
39
+ blocker: 1,
40
+ blocked: 2,
41
+ };
42
+
43
+ export function linksOf(task: BoardTask, columns: ColumnWithCards[]): TaskLink[] {
44
+ const cards = columns.flatMap((entry) =>
45
+ entry.cards.map((card) => ({ card, column: entry.column })),
46
+ );
47
+ const at = (id: string | null) =>
48
+ id === null ? undefined : cards.find((entry) => entry.card.id === id);
49
+ const position = (id: string) => at(id)?.card.position ?? Number.POSITIVE_INFINITY;
50
+
51
+ const links: TaskLink[] = [];
52
+
53
+ for (const { card, column } of cards) {
54
+ if (card.parentTaskId === task.id) {
55
+ links.push({
56
+ kind: "subtask",
57
+ id: card.id,
58
+ title: card.title,
59
+ columnTitle: column.title,
60
+ done: column.terminal,
61
+ });
62
+ }
63
+ // ⚠️ The other direction too. `dependsOn` is stored on the waiting card, so a task only learns
64
+ // what waits for IT by looking at every card — and that is exactly what somebody opening a
65
+ // blocker wants to know before they move it.
66
+ if (card.dependsOn === task.id && card.id !== task.id) {
67
+ links.push({
68
+ kind: "blocked",
69
+ id: card.id,
70
+ title: card.title,
71
+ columnTitle: column.title,
72
+ done: false,
73
+ });
74
+ }
75
+ }
76
+
77
+ if (task.dependsOn !== null) {
78
+ const blocking = at(task.dependsOn);
79
+ links.push({
80
+ kind: "blocker",
81
+ id: task.dependsOn,
82
+ // ⚠️ **`null`, never the id.** A blocker may be any node, not only a card of this board, so
83
+ // this answer often has no title for it — and an id put on screen in place of a name is the
84
+ // rule in `user-name.ts` (#258) broken with a node id instead of a principal id. The view
85
+ // says "something outside this board"; the id says nothing at all.
86
+ title: blocking?.card.title ?? null,
87
+ columnTitle: blocking?.column.title ?? null,
88
+ done: false,
89
+ });
90
+ }
91
+
92
+ return links.sort(
93
+ (a, b) =>
94
+ kindOrder[a.kind] - kindOrder[b.kind] ||
95
+ position(a.id) - position(b.id) ||
96
+ (a.title ?? "").localeCompare(b.title ?? ""),
97
+ );
98
+ }
99
+
100
+ /** How far the subtasks have got — `null` when there are none, so the view draws nothing. */
101
+ export function progressOf(links: TaskLink[]): { done: number; total: number } | null {
102
+ const subtasks = links.filter((link) => link.kind === "subtask");
103
+ if (subtasks.length === 0) return null;
104
+ return { done: subtasks.filter((link) => link.done).length, total: subtasks.length };
105
+ }
@@ -0,0 +1,396 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract/board";
2
+ import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
3
+ import { Lock, Plus, Square, SquareCheck } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { useAssigneeLabel } from "@/board/board-assignee/board-assignee.ts";
6
+ import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuRadioGroup,
12
+ DropdownMenuRadioItem,
13
+ DropdownMenuTrigger,
14
+ } from "@/components/ui/dropdown-menu.tsx";
15
+ import { useI18n } from "@/i18n/i18n-context.tsx";
16
+ import { initials } from "@/user-name/user-name.ts";
17
+ import { type LinkKind, linksOf, progressOf, type TaskLink } from "./board-task.ts";
18
+
19
+ const linkIcons: Record<LinkKind, typeof Lock> = {
20
+ subtask: Square,
21
+ blocker: Lock,
22
+ blocked: Lock,
23
+ };
24
+
25
+ /**
26
+ * The circle for whoever a card is for, and the way to take the assignment off.
27
+ *
28
+ * ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed from the access
29
+ * summary, the field stays `assigneeId`. A row of circles here would imply a second data model that
30
+ * does not exist.
31
+ *
32
+ * ⚠️ **Only ever drawn for somebody.** "Nobody yet" as a chip is a placeholder for an absence, and
33
+ * an absence needs no place on screen (#692).
34
+ *
35
+ * ⚠️ **The NAME lives on the button, and the circle is hidden.** Children of a `<button>` are
36
+ * presentational in ARIA, so a label inside one is never announced and the button's own name wins.
37
+ * Wrapping the circle in a control therefore takes the person out of the accessibility tree unless
38
+ * the control says it too. That is the trap in `packages/ui/CLAUDE.md`, walked into by the very
39
+ * change that documented it.
40
+ */
41
+ function AssigneeChip({ id, clear }: { id: string; clear(): void }) {
42
+ const i18n = useI18n();
43
+ const label = useAssigneeLabel(id);
44
+ return (
45
+ <button
46
+ type="button"
47
+ aria-label={i18n.t("board.clearAssignee", { name: label })}
48
+ title={label}
49
+ onClick={clear}
50
+ className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
51
+ >
52
+ <AssigneeCircle label={label} />
53
+ </button>
54
+ );
55
+ }
56
+
57
+ function AssigneeCircle({ label }: { label: string }) {
58
+ return (
59
+ <span
60
+ // ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
61
+ // person's name, and that reached nobody: it sits inside a `<button>`, whose children are
62
+ // presentational in ARIA. Giving it a role and a label back would not add a second announcement
63
+ // — it would add none, while making the code look as though the name were covered here rather
64
+ // than on the button. See `AssigneeChip` above and `packages/ui/CLAUDE.md`.
65
+ aria-hidden="true"
66
+ className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
67
+ >
68
+ {initials(label)}
69
+ </span>
70
+ );
71
+ }
72
+
73
+ /** One entry of the link list. The icon carries the kind; nothing writes the word out. */
74
+ function LinkRow({ link, onOpen }: { link: TaskLink; onOpen(id: string): void }) {
75
+ const i18n = useI18n();
76
+ const Icon = link.kind === "subtask" && link.done ? SquareCheck : linkIcons[link.kind];
77
+ return (
78
+ <li className="flex items-center gap-2 border-b py-2 last:border-0">
79
+ <Icon
80
+ aria-hidden="true"
81
+ className={`size-4 shrink-0 ${link.kind === "blocker" ? "text-destructive" : "text-muted-foreground"}`}
82
+ />
83
+ <button
84
+ type="button"
85
+ onClick={() => onOpen(link.id)}
86
+ className="flex-1 truncate text-left text-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
87
+ >
88
+ {/* ⚠️ A name or a sentence, never the id. This answer holds no title for a node outside the
89
+ board, and a ulid on screen is a label nobody can read (`user-name.ts`, #258). */}
90
+ {link.title ?? i18n.t("board.link.outside")}
91
+ </button>
92
+ {/* ⚠️ The column of the LINKED task, so its state is readable without opening it — the whole
93
+ reason the list carries a right-hand column at all. */}
94
+ {/* ⚠️ Empty rather than a word, and no template over the kind. Only a BLOCKER can be without
95
+ a column — it may be any node, not a card of this board — and the title beside it already
96
+ says "outside this board". A `board.link.${kind}` lookup could reach exactly one of the
97
+ three keys, and kept the other two alive in all three catalogs for nobody. */}
98
+ <span className="shrink-0 text-xs text-muted-foreground">{link.columnTitle ?? ""}</span>
99
+ </li>
100
+ );
101
+ }
102
+
103
+ export function BoardTaskDocument({
104
+ task,
105
+ board,
106
+ onOpen,
107
+ body,
108
+ }: {
109
+ task: BoardTask;
110
+ board: BoardHandle;
111
+ onOpen(taskId: string): void;
112
+ /** The markdown surface, handed in so this component stays free of the editor's own loading. */
113
+ body: React.ReactNode;
114
+ }) {
115
+ const i18n = useI18n();
116
+
117
+ const links = linksOf(task, board.columns);
118
+ const progress = progressOf(links);
119
+ const columns = board.columns.filter((entry) => entry.unknown !== true).map((e) => e.column);
120
+ const column = columns.find((entry) => entry.id === task.status);
121
+
122
+ /**
123
+ * ⚠️ **Refused while a write is in flight, like every other surface of this board.** Labels make
124
+ * it worst: the whole list is REPLACED, so two quick clicks each send the list minus their own
125
+ * entry, the second answer wins, and one of the two removals is silently undone — both calls
126
+ * succeeded, so nothing anywhere reports a problem (#651).
127
+ */
128
+ const write = (input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>) => {
129
+ if (board.isWriting) return;
130
+ void board.updateTask({
131
+ taskId: task.id,
132
+ ...input,
133
+ idempotencyKey: crypto.randomUUID(),
134
+ });
135
+ };
136
+
137
+ return (
138
+ <div
139
+ className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-6"
140
+ aria-busy={board.isWriting}
141
+ >
142
+ {/* ⚠️ No heading and no way back HERE. Both live in the title row above since #692: the path
143
+ there ends in this card's name, so a heading would print it a second time, and an arrow
144
+ here would be a second control meaning what the one up there already means. */}
145
+ {/* ⚠️ ONE line of chips, and no field labels. "Status:" in front of a chip that says "Backlog"
146
+ says nothing the chip does not — and it turns a quiet head into a form. */}
147
+ <div className="flex flex-wrap items-center gap-2">
148
+ <DropdownMenu>
149
+ <DropdownMenuTrigger
150
+ aria-label={i18n.t("board.moveTo")}
151
+ className="rounded-full border px-3 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
152
+ >
153
+ {column?.title ?? task.status}
154
+ </DropdownMenuTrigger>
155
+ <DropdownMenuContent align="start">
156
+ <DropdownMenuRadioGroup
157
+ value={task.status}
158
+ // ⚠️ Where a task SITS is a move, not a field edit: the server computes a position and
159
+ // checks the cycles for it. Everything else on this screen is a plain change.
160
+ onValueChange={(next) => write({ status: next })}
161
+ >
162
+ {columns.map((entry) => (
163
+ <DropdownMenuRadioItem key={entry.id} value={entry.id}>
164
+ {entry.title}
165
+ </DropdownMenuRadioItem>
166
+ ))}
167
+ <DropdownMenuRadioItem value={ARCHIVE_COLUMN_ID}>
168
+ {i18n.t("board.column.archive")}
169
+ </DropdownMenuRadioItem>
170
+ </DropdownMenuRadioGroup>
171
+ </DropdownMenuContent>
172
+ </DropdownMenu>
173
+
174
+ {/* ⚠️ Two chips when both dates are set, not a range. A range reads as one fact; these
175
+ are two, and each is removed on its own. */}
176
+ {task.startDate === null ? null : (
177
+ <DateChip
178
+ label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
179
+ remove={() => write({ startDate: null })}
180
+ />
181
+ )}
182
+ {task.dueDate === null ? null : (
183
+ <DateChip
184
+ label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
185
+ remove={() => write({ dueDate: null })}
186
+ />
187
+ )}
188
+
189
+ {task.labels.map((entry) => (
190
+ <button
191
+ key={entry}
192
+ type="button"
193
+ aria-label={i18n.t("board.removeLabel", { label: entry })}
194
+ onClick={() => write({ labels: task.labels.filter((keep) => keep !== entry) })}
195
+ className="rounded-full border px-3 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
196
+ >
197
+ {entry}
198
+ </button>
199
+ ))}
200
+
201
+ {/* ⚠️ **Last, always.** It is the only round element in the row; standing between the chips
202
+ it breaks the line, and a row without one simply ends earlier. */}
203
+ {task.assigneeId === null ? null : (
204
+ <AssigneeChip id={task.assigneeId} clear={() => write({ assigneeId: null })} />
205
+ )}
206
+
207
+ <Adder task={task} board={board} write={write} />
208
+ </div>
209
+
210
+ {/* ⚠️ ONE list, not three blocks: the icon carries the kind. And it exists only when there is
211
+ something in it. An empty section with a heading and a hint tells the reader that something
212
+ is missing; nothing at all tells them there is nothing, which is the truth (#692). */}
213
+ {links.length === 0 ? null : (
214
+ <section aria-label={i18n.t("board.links")} className="flex flex-col gap-1">
215
+ <h2 className="flex items-center gap-2 text-sm font-medium">
216
+ {i18n.t("board.links")}
217
+ {progress === null ? null : (
218
+ <span className="text-xs text-muted-foreground tabular-nums">
219
+ {i18n.t("board.progress", { done: progress.done, total: progress.total })}
220
+ </span>
221
+ )}
222
+ </h2>
223
+ <ul className="flex flex-col">
224
+ {links.map((link) => (
225
+ <LinkRow key={`${link.kind}:${link.id}`} link={link} onOpen={onOpen} />
226
+ ))}
227
+ </ul>
228
+ </section>
229
+ )}
230
+
231
+ {/* The markdown text is the largest element of the page — everything above is the quiet part.
232
+ ⚠️ The slot is not decoration: a test that asks whether the BODY drew something has to be
233
+ able to say which part of the card it means. Unscoped, the same loading line elsewhere on
234
+ the card answers for it, and the assurance goes hollow without anybody touching it. */}
235
+ <div data-slot="task-body" className="min-h-0 flex-1">
236
+ {body}
237
+ </div>
238
+ </div>
239
+ );
240
+ }
241
+
242
+ /**
243
+ * ⚠️ **ONE `+ Verlinkung`, and the kind is chosen afterwards** — not two buttons labelled "subtask"
244
+ * and "waits for". That is `#371`: the reader decides WHAT they are linking once they know which
245
+ * card they mean, not before.
246
+ */
247
+
248
+ /** A date, and the way to take it off again. */
249
+ function DateChip({ label, remove }: { label: string; remove(): void }) {
250
+ return (
251
+ <button
252
+ type="button"
253
+ onClick={remove}
254
+ className="rounded-full border px-3 py-1 text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
255
+ >
256
+ {label}
257
+ </button>
258
+ );
259
+ }
260
+
261
+ /**
262
+ * What the card can still be given. The order matches the chips it produces.
263
+ *
264
+ * ⚠️ **No `assignee` here, and that is a gap rather than a decision.** Setting one needs a person to
265
+ * pick from, and this application can only ever DISPLAY principals it already sees in a grant: there
266
+ * is no list to choose from anywhere in the data provider. An entry that opens nothing is worse than
267
+ * a missing one, because it looks like the feature is there (`#700`).
268
+ */
269
+ const addable = ["label", "start", "due"] as const;
270
+ const linkable = ["subtask", "blocker"] as const;
271
+ type Addable = (typeof addable)[number] | (typeof linkable)[number];
272
+
273
+ /**
274
+ * ⚠️ **ONE plus for everything the card can carry**, and the kind is chosen afterwards. Two
275
+ * controls, one for fields and one for links, asked the reader to know which of the two a thing was
276
+ * before they could add it, and that is a question about our data model, not about their work.
277
+ */
278
+ function Adder({
279
+ task,
280
+ board,
281
+ write,
282
+ }: {
283
+ task: BoardTask;
284
+ board: BoardHandle;
285
+ write(input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>): void;
286
+ }) {
287
+ const i18n = useI18n();
288
+ const [kind, setKind] = useState<Addable | null>(null);
289
+ const [draft, setDraft] = useState("");
290
+ const close = () => {
291
+ setKind(null);
292
+ setDraft("");
293
+ };
294
+
295
+ const candidates = board.columns
296
+ .flatMap((entry) => entry.cards)
297
+ .filter((card) => card.id !== task.id && card.parentTaskId !== task.id);
298
+
299
+ const commit = () => {
300
+ const value = draft.trim();
301
+ if (value.length === 0 || board.isWriting) return close();
302
+ if (kind === "label") {
303
+ // ⚠️ Tags are REPLACED, not merged, so the whole list travels, and a duplicate would be
304
+ // written as one more entry rather than refused.
305
+ if (!task.labels.includes(value)) write({ labels: [...task.labels, value] });
306
+ }
307
+ if (kind === "start") write({ startDate: `${value}T00:00:00.000Z` });
308
+ if (kind === "due") write({ dueDate: `${value}T00:00:00.000Z` });
309
+ if (kind === "subtask") {
310
+ void board
311
+ .createTask({
312
+ title: value,
313
+ // A subtask starts in the same column as the task it belongs to: the first column would
314
+ // claim work was reset that never started.
315
+ status: task.status,
316
+ parentTaskId: task.id,
317
+ assigneeId: null,
318
+ labels: [],
319
+ startDate: null,
320
+ dueDate: null,
321
+ dependsOn: null,
322
+ idempotencyKey: crypto.randomUUID(),
323
+ })
324
+ .then(close);
325
+ return;
326
+ }
327
+ close();
328
+ };
329
+
330
+ return (
331
+ <>
332
+ <DropdownMenu>
333
+ <DropdownMenuTrigger
334
+ aria-label={i18n.t("board.add")}
335
+ className="rounded-full border px-2 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
336
+ >
337
+ <Plus aria-hidden="true" className="size-3" />
338
+ </DropdownMenuTrigger>
339
+ <DropdownMenuContent align="start">
340
+ {addable.map((entry) => (
341
+ <DropdownMenuItem key={entry} onSelect={() => setKind(entry)}>
342
+ {i18n.t(`board.add.${entry}`)}
343
+ </DropdownMenuItem>
344
+ ))}
345
+ {linkable.map((entry) => (
346
+ <DropdownMenuItem key={entry} onSelect={() => setKind(entry)}>
347
+ {i18n.t(`board.link.${entry}`)}
348
+ </DropdownMenuItem>
349
+ ))}
350
+ </DropdownMenuContent>
351
+ </DropdownMenu>
352
+
353
+ {kind === null || kind === "blocker" ? null : (
354
+ <input
355
+ // biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
356
+ autoFocus
357
+ type={kind === "start" || kind === "due" ? "date" : "text"}
358
+ value={draft}
359
+ aria-label={i18n.t(kind === "subtask" ? "board.link.subtask" : `board.add.${kind}`)}
360
+ onChange={(event) => setDraft(event.target.value)}
361
+ onKeyDown={(event) => {
362
+ if (event.key === "Escape") close();
363
+ if (event.key !== "Enter") return;
364
+ event.preventDefault();
365
+ commit();
366
+ }}
367
+ className="w-36 rounded-full border bg-background px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
368
+ />
369
+ )}
370
+
371
+ {kind !== "blocker" ? null : (
372
+ <DropdownMenu open onOpenChange={(next) => !next && close()}>
373
+ <DropdownMenuTrigger
374
+ aria-label={i18n.t("board.link.blocker")}
375
+ className="rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
376
+ >
377
+ {i18n.t("board.link.blocker")}
378
+ </DropdownMenuTrigger>
379
+ <DropdownMenuContent align="start">
380
+ {candidates.map((card) => (
381
+ <DropdownMenuItem
382
+ key={card.id}
383
+ onSelect={() => {
384
+ close();
385
+ write({ dependsOn: card.id });
386
+ }}
387
+ >
388
+ {card.title}
389
+ </DropdownMenuItem>
390
+ ))}
391
+ </DropdownMenuContent>
392
+ </DropdownMenu>
393
+ )}
394
+ </>
395
+ );
396
+ }
@@ -0,0 +1,68 @@
1
+ import type { Node } from "@anchrd/intel-contract/node";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { crumbsOf } from "@/board/board-crumbs/board-crumbs.ts";
4
+ import { BoardCrumbs } from "@/board/board-crumbs/board-crumbs.tsx";
5
+ import { useBoard } from "@/board/board-data/board-data.ts";
6
+ import { useOpenTask } from "@/board/board-open-task/board-open-task.ts";
7
+ import { useIntelRouterContext } from "@/router/router-context.ts";
8
+ import { TitleRow } from "@/title-row/title-row.tsx";
9
+
10
+ /**
11
+ * The one line above a board, and above a card opened on it.
12
+ *
13
+ * ⚠️ **The SAME `TitleRow`, with the path in place of the name.** A second header component for the
14
+ * open card would look identical today and drift tomorrow: the order of the right-hand group, the
15
+ * shadow on scroll and the slots are decided in exactly one place, and this keeps it that way (#53).
16
+ *
17
+ * ⚠️ **The menu then belongs to the CARD, not to the board.** Rename, move and archive act on what
18
+ * the reader is looking at. That is also where renaming a card happens, since the path carries its
19
+ * name and there is no heading to edit (#692).
20
+ */
21
+ export function BoardTitleRow({ node }: { node: Node }) {
22
+ const { openId, open } = useOpenTask();
23
+ const board = useBoard(node.id);
24
+ const { data } = useIntelRouterContext();
25
+
26
+ // ⚠️ The same key the open card's body already uses, so this costs no second request.
27
+ const task = useQuery({
28
+ queryKey: ["node", openId],
29
+ queryFn: () => data.getNode(openId ?? ""),
30
+ enabled: openId !== null,
31
+ });
32
+
33
+ const tasks = board.columns.flatMap((entry) => entry.cards);
34
+ const openTask = tasks.find((card) => card.id === openId);
35
+
36
+ // ⚠️ Until the card is in the ANSWER, the board's own row stands. Drawing a path around a name
37
+ // nobody knows yet would flicker a wrong one into place for a moment.
38
+ //
39
+ // ⚠️ The card's NODE is a different wait, and it must not gate the path: it is only needed for the
40
+ // menu, and holding the whole row for it would show the board's name first and swap it a moment
41
+ // later — two different headers in sequence for one navigation.
42
+ if (openId === null || openTask === undefined) {
43
+ return (
44
+ <TitleRow title={node.title} description={node.description} target={{ type: "node", node }} />
45
+ );
46
+ }
47
+
48
+ const crumbs = <BoardCrumbs crumbs={crumbsOf(openId, board.title, tasks)} onOpen={open} />;
49
+
50
+ /**
51
+ * ⚠️ **No menu at all while the card's node is missing**, rather than the board's. Rename, move
52
+ * and archive would otherwise act on the BOARD, and `archive` fires without asking: a control
53
+ * that is not there yet costs a moment, one that acts on the wrong thing costs the thing.
54
+ *
55
+ * ⚠️ **Missing covers two states, and deliberately treats them the same**: the read is still on
56
+ * its way, or it was refused. Neither gives this row a card to act on, and telling them apart
57
+ * here would only decide WHICH wrong menu to draw.
58
+ */
59
+ if (task.data === undefined) return <TitleRow title={openTask.title} crumbs={crumbs} />;
60
+
61
+ return (
62
+ <TitleRow
63
+ title={openTask.title}
64
+ crumbs={crumbs}
65
+ target={{ type: "node", node: task.data.node }}
66
+ />
67
+ );
68
+ }
@@ -0,0 +1,28 @@
1
+ import FileViewer, { type ViewerOptions } from "@file-viewer/react";
2
+ import { useMemo } from "react";
3
+ import { useResolvedTheme } from "@/theme/theme-context.tsx";
4
+
5
+ export function FilePreviewView({ file, renderer }: { file: File; renderer: unknown }) {
6
+ const theme = useResolvedTheme();
7
+ const options = useMemo<ViewerOptions>(
8
+ () => ({
9
+ renderers: [renderer] as unknown as NonNullable<ViewerOptions["renderers"]>,
10
+ rendererMode: "replace",
11
+ builtinRenderers: "none",
12
+ autoRenderers: false,
13
+ styleIsolation: "shadow",
14
+ theme,
15
+ locale: "auto",
16
+ toolbar: {
17
+ download: false,
18
+ print: false,
19
+ exportHtml: false,
20
+ theme: false,
21
+ permissions: { download: false, print: false, exportHtml: false },
22
+ },
23
+ ui: { density: "compact", surfaceBackground: "transparent" },
24
+ }),
25
+ [renderer, theme],
26
+ );
27
+ return <FileViewer file={file} className="h-full min-h-64" options={options} />;
28
+ }
@@ -0,0 +1,28 @@
1
+ import { lazy, Suspense } from "react";
2
+ import type { AttachmentPreviewKind } from "@/attachment-viewer/attachment-viewer.tsx";
3
+
4
+ const previews = {
5
+ pdf: lazy(async () => ({ default: (await import("./pdf-file-preview.tsx")).PdfFilePreview })),
6
+ presentation: lazy(async () => ({
7
+ default: (await import("./presentation-file-preview.tsx")).PresentationFilePreview,
8
+ })),
9
+ spreadsheet: lazy(async () => ({
10
+ default: (await import("./spreadsheet-file-preview.tsx")).SpreadsheetFilePreview,
11
+ })),
12
+ word: lazy(async () => ({ default: (await import("./word-file-preview.tsx")).WordFilePreview })),
13
+ };
14
+
15
+ export function FilePreview({
16
+ file,
17
+ kind,
18
+ }: {
19
+ file: File;
20
+ kind: Exclude<AttachmentPreviewKind, "image" | "unsupported">;
21
+ }) {
22
+ const Preview = previews[kind];
23
+ return (
24
+ <Suspense fallback={null}>
25
+ <Preview file={file} />
26
+ </Suspense>
27
+ );
28
+ }
@@ -0,0 +1,5 @@
1
+ import { pdfRenderer } from "@file-viewer/renderer-pdf";
2
+ import { FilePreviewView } from "./file-preview-view.tsx";
3
+ export function PdfFilePreview({ file }: { file: File }) {
4
+ return <FilePreviewView file={file} renderer={pdfRenderer} />;
5
+ }
@@ -0,0 +1,21 @@
1
+ import {
2
+ presentationRendererDefinition,
3
+ renderFileViewerPresentation,
4
+ } from "@file-viewer/renderer-presentation";
5
+ import { FilePreviewView } from "./file-preview-view.tsx";
6
+
7
+ const presentationRenderer = {
8
+ id: "intel-file-viewer-renderer-presentation",
9
+ label: "Intel PPTX renderer",
10
+ definitions: [presentationRendererDefinition],
11
+ handlers: [
12
+ {
13
+ rendererId: presentationRendererDefinition.id,
14
+ handler: renderFileViewerPresentation,
15
+ },
16
+ ],
17
+ };
18
+
19
+ export function PresentationFilePreview({ file }: { file: File }) {
20
+ return <FilePreviewView file={file} renderer={presentationRenderer} />;
21
+ }
@@ -0,0 +1,5 @@
1
+ import { spreadsheetRenderer } from "@file-viewer/renderer-spreadsheet";
2
+ import { FilePreviewView } from "./file-preview-view.tsx";
3
+ export function SpreadsheetFilePreview({ file }: { file: File }) {
4
+ return <FilePreviewView file={file} renderer={spreadsheetRenderer} />;
5
+ }
@@ -0,0 +1,5 @@
1
+ import { wordRenderer } from "@file-viewer/renderer-word";
2
+ import { FilePreviewView } from "./file-preview-view.tsx";
3
+ export function WordFilePreview({ file }: { file: File }) {
4
+ return <FilePreviewView file={file} renderer={wordRenderer} />;
5
+ }