@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
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import type { BoardTaskFilter } from "@anchrd/intel-contract/board";
|
|
2
2
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
3
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import { useNavigate, useSearch } from "@tanstack/react-router";
|
|
5
4
|
import type { ColumnVisibilityState } from "@tanstack/react-table";
|
|
6
5
|
import { KanbanSquare, Settings, Table2 } from "lucide-react";
|
|
7
|
-
import { Suspense,
|
|
6
|
+
import { Suspense, useState } from "react";
|
|
8
7
|
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
9
8
|
import { useBoard } from "@/board/board-data/board-data.ts";
|
|
10
9
|
import { BoardKanban } from "@/board/board-kanban/board-kanban.tsx";
|
|
10
|
+
import { useOpenTask } from "@/board/board-open-task/board-open-task.ts";
|
|
11
11
|
import { BoardSettings } from "@/board/board-settings/board-settings.tsx";
|
|
12
12
|
import { BoardTable } from "@/board/board-table/board-table.tsx";
|
|
13
13
|
import { BoardTaskDocument } from "@/board/board-task/board-task.tsx";
|
|
14
14
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
15
15
|
import { NodeEditor } from "@/node-editor/node-editor.tsx";
|
|
16
16
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
17
|
-
import { openTaskFrom } from "@/router/selection-search.ts";
|
|
18
17
|
|
|
19
18
|
type View = "kanban" | "table";
|
|
20
19
|
|
|
@@ -47,31 +46,7 @@ export function BoardPanel({ node }: { node: Node }) {
|
|
|
47
46
|
*/
|
|
48
47
|
const [filter, setFilter] = useState<Partial<BoardTaskFilter>>({});
|
|
49
48
|
const board = useBoard(node.id);
|
|
50
|
-
const
|
|
51
|
-
const navigate = useNavigate();
|
|
52
|
-
const openId = openTaskFrom(search);
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* ⚠️ **The address, not a piece of state.** A task opened into component state would have no link
|
|
56
|
-
* and no back button — the browser's back would leave the board entirely, which is the one thing
|
|
57
|
-
* somebody pressing it after opening a card does not want.
|
|
58
|
-
*/
|
|
59
|
-
const open = useCallback(
|
|
60
|
-
(taskId: string | null) =>
|
|
61
|
-
void navigate({
|
|
62
|
-
to: ".",
|
|
63
|
-
// ⚠️ **Closing REPLACES, opening pushes.** Opening a card is a step somebody took and the
|
|
64
|
-
// back button must undo it; closing is the undo itself. Pushed as well, the history holds
|
|
65
|
-
// board → card → board, and one press of Back re-opens the card that was just closed —
|
|
66
|
-
// exactly the behaviour the address was chosen to avoid.
|
|
67
|
-
replace: taskId === null,
|
|
68
|
-
search: (previous: Record<string, unknown>) =>
|
|
69
|
-
taskId === null ? { ...previous, task: undefined } : { ...previous, task: taskId },
|
|
70
|
-
}),
|
|
71
|
-
// ⚠️ Stable, or the table's `useMemo` over its columns rebuilds on every render and the memo
|
|
72
|
-
// is decoration.
|
|
73
|
-
[navigate],
|
|
74
|
-
);
|
|
49
|
+
const { openId, open } = useOpenTask();
|
|
75
50
|
|
|
76
51
|
return (
|
|
77
52
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
@@ -91,14 +66,20 @@ export function BoardPanel({ node }: { node: Node }) {
|
|
|
91
66
|
<Icon aria-hidden="true" className="size-4" />
|
|
92
67
|
</button>
|
|
93
68
|
))}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
69
|
+
{/* ⚠️ Gone while a card is open, like the view switch above it. It configures the BOARD,
|
|
70
|
+
and the line it sits in belongs to the card then — a control that acts on something the
|
|
71
|
+
reader is not looking at is worse than a missing one, because it looks like it acts on
|
|
72
|
+
what they see. */}
|
|
73
|
+
{openId === null ? (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
aria-label={i18n.t("board.settings.open")}
|
|
77
|
+
onClick={() => setSettingsOpen(true)}
|
|
78
|
+
className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
79
|
+
>
|
|
80
|
+
<Settings aria-hidden="true" className="size-4" />
|
|
81
|
+
</button>
|
|
82
|
+
) : null}
|
|
102
83
|
</ActionSlot>
|
|
103
84
|
{/* ⚠️ Mounted only while open, so its draft is seeded once per opening. Handed an `open` flag
|
|
104
85
|
instead, a draft re-seeded from the board would lose what somebody typed the moment a
|
|
@@ -183,7 +164,6 @@ function OpenTask({
|
|
|
183
164
|
task={task}
|
|
184
165
|
board={board}
|
|
185
166
|
onOpen={onOpen}
|
|
186
|
-
onClose={() => onOpen(null)}
|
|
187
167
|
body={<TaskBody nodeId={task.id} />}
|
|
188
168
|
/>
|
|
189
169
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
2
2
|
import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
|
|
3
|
-
import {
|
|
3
|
+
import { Lock, Plus, Square, SquareCheck } from "lucide-react";
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import { useAssigneeLabel } from "@/board/board-assignee/board-assignee.ts";
|
|
6
6
|
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
@@ -23,25 +23,46 @@ const linkIcons: Record<LinkKind, typeof Lock> = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
26
|
+
* The circle for whoever a card is for, and the way to take the assignment off.
|
|
27
27
|
*
|
|
28
|
-
* ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed
|
|
29
|
-
* `assigneeId`. A row of circles here would imply a second data model that
|
|
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.
|
|
30
40
|
*/
|
|
31
|
-
function
|
|
41
|
+
function AssigneeChip({ id, clear }: { id: string; clear(): void }) {
|
|
32
42
|
const i18n = useI18n();
|
|
33
43
|
const label = useAssigneeLabel(id);
|
|
34
|
-
if (id === null) {
|
|
35
|
-
return <span className="text-xs text-muted-foreground">{label}</span>;
|
|
36
|
-
}
|
|
37
44
|
return (
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// read out as the two letters it happens to contain.
|
|
42
|
-
role="img"
|
|
43
|
-
aria-label={label}
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
aria-label={i18n.t("board.clearAssignee", { name: label })}
|
|
44
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"
|
|
45
66
|
className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
|
|
46
67
|
>
|
|
47
68
|
{initials(label)}
|
|
@@ -83,19 +104,15 @@ export function BoardTaskDocument({
|
|
|
83
104
|
task,
|
|
84
105
|
board,
|
|
85
106
|
onOpen,
|
|
86
|
-
onClose,
|
|
87
107
|
body,
|
|
88
108
|
}: {
|
|
89
109
|
task: BoardTask;
|
|
90
110
|
board: BoardHandle;
|
|
91
111
|
onOpen(taskId: string): void;
|
|
92
|
-
onClose(): void;
|
|
93
112
|
/** The markdown surface, handed in so this component stays free of the editor's own loading. */
|
|
94
113
|
body: React.ReactNode;
|
|
95
114
|
}) {
|
|
96
115
|
const i18n = useI18n();
|
|
97
|
-
const [label, setLabel] = useState("");
|
|
98
|
-
const [adding, setAdding] = useState(false);
|
|
99
116
|
|
|
100
117
|
const links = linksOf(task, board.columns);
|
|
101
118
|
const progress = progressOf(links);
|
|
@@ -117,33 +134,14 @@ export function BoardTaskDocument({
|
|
|
117
134
|
});
|
|
118
135
|
};
|
|
119
136
|
|
|
120
|
-
// ⚠️ A single date has to say WHICH one it is. Drawn bare, a start date and a due date look
|
|
121
|
-
// identical — and reading a start date as a deadline is the expensive direction of that mistake.
|
|
122
|
-
const dates =
|
|
123
|
-
task.startDate !== null && task.dueDate !== null
|
|
124
|
-
? `${task.startDate.slice(0, 10)} – ${task.dueDate.slice(0, 10)}`
|
|
125
|
-
: task.startDate !== null
|
|
126
|
-
? i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })
|
|
127
|
-
: task.dueDate !== null
|
|
128
|
-
? i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })
|
|
129
|
-
: null;
|
|
130
|
-
|
|
131
137
|
return (
|
|
132
138
|
<div
|
|
133
139
|
className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-6"
|
|
134
140
|
aria-busy={board.isWriting}
|
|
135
141
|
>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
className="flex w-fit items-center gap-1 rounded-md text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
140
|
-
>
|
|
141
|
-
<ChevronLeft aria-hidden="true" className="size-4" />
|
|
142
|
-
{board.title}
|
|
143
|
-
</button>
|
|
144
|
-
|
|
145
|
-
<h1 className="text-2xl font-semibold tracking-tight">{task.title}</h1>
|
|
146
|
-
|
|
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. */}
|
|
147
145
|
{/* ⚠️ ONE line of chips, and no field labels. "Status:" in front of a chip that says "Backlog"
|
|
148
146
|
says nothing the chip does not — and it turns a quiet head into a form. */}
|
|
149
147
|
<div className="flex flex-wrap items-center gap-2">
|
|
@@ -173,10 +171,19 @@ export function BoardTaskDocument({
|
|
|
173
171
|
</DropdownMenuContent>
|
|
174
172
|
</DropdownMenu>
|
|
175
173
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
{
|
|
179
|
-
<
|
|
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
|
+
/>
|
|
180
187
|
)}
|
|
181
188
|
|
|
182
189
|
{task.labels.map((entry) => (
|
|
@@ -185,69 +192,41 @@ export function BoardTaskDocument({
|
|
|
185
192
|
type="button"
|
|
186
193
|
aria-label={i18n.t("board.removeLabel", { label: entry })}
|
|
187
194
|
onClick={() => write({ labels: task.labels.filter((keep) => keep !== entry) })}
|
|
188
|
-
className="rounded-full
|
|
195
|
+
className="rounded-full border px-3 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
189
196
|
>
|
|
190
197
|
{entry}
|
|
191
198
|
</button>
|
|
192
199
|
))}
|
|
193
200
|
|
|
194
|
-
{
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
value={label}
|
|
199
|
-
aria-label={i18n.t("board.addLabel")}
|
|
200
|
-
onChange={(event) => setLabel(event.target.value)}
|
|
201
|
-
onKeyDown={(event) => {
|
|
202
|
-
if (event.key === "Escape") {
|
|
203
|
-
setAdding(false);
|
|
204
|
-
setLabel("");
|
|
205
|
-
}
|
|
206
|
-
if (event.key !== "Enter") return;
|
|
207
|
-
event.preventDefault();
|
|
208
|
-
const next = label.trim();
|
|
209
|
-
// ⚠️ Tags are REPLACED, not merged, so the whole list travels — and a duplicate would
|
|
210
|
-
// be written as one more entry rather than refused.
|
|
211
|
-
if (next.length > 0 && !task.labels.includes(next)) {
|
|
212
|
-
write({ labels: [...task.labels, next] });
|
|
213
|
-
}
|
|
214
|
-
setAdding(false);
|
|
215
|
-
setLabel("");
|
|
216
|
-
}}
|
|
217
|
-
className="w-28 rounded-full border bg-background px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
218
|
-
/>
|
|
219
|
-
) : (
|
|
220
|
-
<button
|
|
221
|
-
type="button"
|
|
222
|
-
aria-label={i18n.t("board.addLabel")}
|
|
223
|
-
onClick={() => setAdding(true)}
|
|
224
|
-
className="rounded-full border px-2 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
225
|
-
>
|
|
226
|
-
<Plus aria-hidden="true" className="size-3" />
|
|
227
|
-
</button>
|
|
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 })} />
|
|
228
205
|
)}
|
|
206
|
+
|
|
207
|
+
<Adder task={task} board={board} write={write} />
|
|
229
208
|
</div>
|
|
230
209
|
|
|
231
|
-
{/* ⚠️ ONE list, not three blocks
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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>
|
|
243
223
|
<ul className="flex flex-col">
|
|
244
224
|
{links.map((link) => (
|
|
245
225
|
<LinkRow key={`${link.kind}:${link.id}`} link={link} onOpen={onOpen} />
|
|
246
226
|
))}
|
|
247
227
|
</ul>
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
</section>
|
|
228
|
+
</section>
|
|
229
|
+
)}
|
|
251
230
|
|
|
252
231
|
{/* The markdown text is the largest element of the page — everything above is the quiet part.
|
|
253
232
|
⚠️ The slot is not decoration: a test that asks whether the BODY drew something has to be
|
|
@@ -265,77 +244,135 @@ export function BoardTaskDocument({
|
|
|
265
244
|
* and "waits for". That is `#371`: the reader decides WHAT they are linking once they know which
|
|
266
245
|
* card they mean, not before.
|
|
267
246
|
*/
|
|
268
|
-
|
|
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
|
+
}) {
|
|
269
287
|
const i18n = useI18n();
|
|
270
|
-
const [kind, setKind] = useState<
|
|
271
|
-
const [
|
|
288
|
+
const [kind, setKind] = useState<Addable | null>(null);
|
|
289
|
+
const [draft, setDraft] = useState("");
|
|
290
|
+
const close = () => {
|
|
291
|
+
setKind(null);
|
|
292
|
+
setDraft("");
|
|
293
|
+
};
|
|
272
294
|
|
|
273
295
|
const candidates = board.columns
|
|
274
296
|
.flatMap((entry) => entry.cards)
|
|
275
297
|
.filter((card) => card.id !== task.id && card.parentTaskId !== task.id);
|
|
276
298
|
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
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();
|
|
280
328
|
};
|
|
281
329
|
|
|
282
330
|
return (
|
|
283
|
-
|
|
331
|
+
<>
|
|
284
332
|
<DropdownMenu>
|
|
285
|
-
<DropdownMenuTrigger
|
|
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
|
+
>
|
|
286
337
|
<Plus aria-hidden="true" className="size-3" />
|
|
287
|
-
{i18n.t("board.addLink")}
|
|
288
338
|
</DropdownMenuTrigger>
|
|
289
339
|
<DropdownMenuContent align="start">
|
|
290
|
-
|
|
291
|
-
{
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
))}
|
|
296
350
|
</DropdownMenuContent>
|
|
297
351
|
</DropdownMenu>
|
|
298
352
|
|
|
299
|
-
{
|
|
300
|
-
subtask is CREATED — where a task sits is the node tree, and moving an existing card under
|
|
301
|
-
another needs `node_update` with the version it was last read at, which this answer does
|
|
302
|
-
not carry. A blocker is a field on this card and only needs the card that is meant. */}
|
|
303
|
-
{kind === "subtask" ? (
|
|
353
|
+
{kind === null || kind === "blocker" ? null : (
|
|
304
354
|
<input
|
|
305
355
|
// biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
|
|
306
356
|
autoFocus
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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)}
|
|
310
361
|
onKeyDown={(event) => {
|
|
311
362
|
if (event.key === "Escape") close();
|
|
312
363
|
if (event.key !== "Enter") return;
|
|
313
364
|
event.preventDefault();
|
|
314
|
-
|
|
315
|
-
if (next.length === 0 || board.isWriting) return;
|
|
316
|
-
void board
|
|
317
|
-
.createTask({
|
|
318
|
-
title: next,
|
|
319
|
-
// A subtask starts in the same column as the task it belongs to — the alternative
|
|
320
|
-
// is the first column, which claims work was reset that never started.
|
|
321
|
-
status: task.status,
|
|
322
|
-
parentTaskId: task.id,
|
|
323
|
-
assigneeId: null,
|
|
324
|
-
labels: [],
|
|
325
|
-
startDate: null,
|
|
326
|
-
dueDate: null,
|
|
327
|
-
dependsOn: null,
|
|
328
|
-
idempotencyKey: crypto.randomUUID(),
|
|
329
|
-
})
|
|
330
|
-
.then(close);
|
|
365
|
+
commit();
|
|
331
366
|
}}
|
|
332
|
-
className="w-
|
|
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"
|
|
333
368
|
/>
|
|
334
|
-
)
|
|
369
|
+
)}
|
|
370
|
+
|
|
371
|
+
{kind !== "blocker" ? null : (
|
|
335
372
|
<DropdownMenu open onOpenChange={(next) => !next && close()}>
|
|
336
373
|
<DropdownMenuTrigger
|
|
337
374
|
aria-label={i18n.t("board.link.blocker")}
|
|
338
|
-
className="rounded-
|
|
375
|
+
className="rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
339
376
|
>
|
|
340
377
|
{i18n.t("board.link.blocker")}
|
|
341
378
|
</DropdownMenuTrigger>
|
|
@@ -345,12 +382,7 @@ function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
|
|
|
345
382
|
key={card.id}
|
|
346
383
|
onSelect={() => {
|
|
347
384
|
close();
|
|
348
|
-
|
|
349
|
-
void board.updateTask({
|
|
350
|
-
taskId: task.id,
|
|
351
|
-
dependsOn: card.id,
|
|
352
|
-
idempotencyKey: crypto.randomUUID(),
|
|
353
|
-
});
|
|
385
|
+
write({ dependsOn: card.id });
|
|
354
386
|
}}
|
|
355
387
|
>
|
|
356
388
|
{card.title}
|
|
@@ -358,7 +390,7 @@ function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
|
|
|
358
390
|
))}
|
|
359
391
|
</DropdownMenuContent>
|
|
360
392
|
</DropdownMenu>
|
|
361
|
-
)
|
|
362
|
-
|
|
393
|
+
)}
|
|
394
|
+
</>
|
|
363
395
|
);
|
|
364
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
|
+
}
|