@anchrd/intel-ui 0.38.0 → 0.40.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 +6 -2
- package/src/access-summary/access-summary.tsx +1 -11
- package/src/app/app-tree/app-tree.tsx +37 -0
- package/src/board/board-assignee/board-assignee.ts +18 -0
- package/src/board/board-data/board-data.ts +31 -5
- package/src/board/board-data/board-data.types.ts +10 -1
- package/src/board/board-kanban/board-kanban.ts +63 -9
- package/src/board/board-kanban/board-kanban.tsx +371 -63
- package/src/board/board-panel/board-panel.tsx +222 -31
- package/src/board/board-settings/board-settings.tsx +209 -0
- package/src/board/board-stripes/board-stripes.ts +128 -0
- package/src/board/board-table/board-table.ts +23 -105
- package/src/board/board-table/board-table.tsx +436 -135
- package/src/board/board-task/board-task.ts +105 -0
- package/src/board/board-task/board-task.tsx +364 -0
- package/src/frontmatter/frontmatter.tsx +11 -2
- package/src/i18n/de.json +44 -3
- package/src/i18n/en.json +44 -3
- package/src/i18n/es.json +44 -3
- package/src/router/selection-search.ts +17 -2
- package/src/styles.css +29 -25
- package/src/user-name/user-name.ts +17 -0
|
@@ -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,364 @@
|
|
|
1
|
+
import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
2
|
+
import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
|
|
3
|
+
import { ChevronLeft, 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
|
+
* Who the task is for, as the circle the access summary uses.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed, the field stays
|
|
29
|
+
* `assigneeId`. A row of circles here would imply a second data model that does not exist.
|
|
30
|
+
*/
|
|
31
|
+
function Assignee({ id }: { id: string | null }) {
|
|
32
|
+
const i18n = useI18n();
|
|
33
|
+
const label = useAssigneeLabel(id);
|
|
34
|
+
if (id === null) {
|
|
35
|
+
return <span className="text-xs text-muted-foreground">{label}</span>;
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<span
|
|
39
|
+
// ⚠️ `role="img"` and not a bare span: two initials are a picture of a name, and a plain
|
|
40
|
+
// `span` carries no `aria-label` at all — the attribute is dropped, and the circle is then
|
|
41
|
+
// read out as the two letters it happens to contain.
|
|
42
|
+
role="img"
|
|
43
|
+
aria-label={label}
|
|
44
|
+
title={label}
|
|
45
|
+
className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
|
|
46
|
+
>
|
|
47
|
+
{initials(label)}
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One entry of the link list. The icon carries the kind; nothing writes the word out. */
|
|
53
|
+
function LinkRow({ link, onOpen }: { link: TaskLink; onOpen(id: string): void }) {
|
|
54
|
+
const i18n = useI18n();
|
|
55
|
+
const Icon = link.kind === "subtask" && link.done ? SquareCheck : linkIcons[link.kind];
|
|
56
|
+
return (
|
|
57
|
+
<li className="flex items-center gap-2 border-b py-2 last:border-0">
|
|
58
|
+
<Icon
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
className={`size-4 shrink-0 ${link.kind === "blocker" ? "text-destructive" : "text-muted-foreground"}`}
|
|
61
|
+
/>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={() => onOpen(link.id)}
|
|
65
|
+
className="flex-1 truncate text-left text-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
66
|
+
>
|
|
67
|
+
{/* ⚠️ A name or a sentence, never the id. This answer holds no title for a node outside the
|
|
68
|
+
board, and a ulid on screen is a label nobody can read (`user-name.ts`, #258). */}
|
|
69
|
+
{link.title ?? i18n.t("board.link.outside")}
|
|
70
|
+
</button>
|
|
71
|
+
{/* ⚠️ The column of the LINKED task, so its state is readable without opening it — the whole
|
|
72
|
+
reason the list carries a right-hand column at all. */}
|
|
73
|
+
{/* ⚠️ Empty rather than a word, and no template over the kind. Only a BLOCKER can be without
|
|
74
|
+
a column — it may be any node, not a card of this board — and the title beside it already
|
|
75
|
+
says "outside this board". A `board.link.${kind}` lookup could reach exactly one of the
|
|
76
|
+
three keys, and kept the other two alive in all three catalogs for nobody. */}
|
|
77
|
+
<span className="shrink-0 text-xs text-muted-foreground">{link.columnTitle ?? ""}</span>
|
|
78
|
+
</li>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function BoardTaskDocument({
|
|
83
|
+
task,
|
|
84
|
+
board,
|
|
85
|
+
onOpen,
|
|
86
|
+
onClose,
|
|
87
|
+
body,
|
|
88
|
+
}: {
|
|
89
|
+
task: BoardTask;
|
|
90
|
+
board: BoardHandle;
|
|
91
|
+
onOpen(taskId: string): void;
|
|
92
|
+
onClose(): void;
|
|
93
|
+
/** The markdown surface, handed in so this component stays free of the editor's own loading. */
|
|
94
|
+
body: React.ReactNode;
|
|
95
|
+
}) {
|
|
96
|
+
const i18n = useI18n();
|
|
97
|
+
const [label, setLabel] = useState("");
|
|
98
|
+
const [adding, setAdding] = useState(false);
|
|
99
|
+
|
|
100
|
+
const links = linksOf(task, board.columns);
|
|
101
|
+
const progress = progressOf(links);
|
|
102
|
+
const columns = board.columns.filter((entry) => entry.unknown !== true).map((e) => e.column);
|
|
103
|
+
const column = columns.find((entry) => entry.id === task.status);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* ⚠️ **Refused while a write is in flight, like every other surface of this board.** Labels make
|
|
107
|
+
* it worst: the whole list is REPLACED, so two quick clicks each send the list minus their own
|
|
108
|
+
* entry, the second answer wins, and one of the two removals is silently undone — both calls
|
|
109
|
+
* succeeded, so nothing anywhere reports a problem (#651).
|
|
110
|
+
*/
|
|
111
|
+
const write = (input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>) => {
|
|
112
|
+
if (board.isWriting) return;
|
|
113
|
+
void board.updateTask({
|
|
114
|
+
taskId: task.id,
|
|
115
|
+
...input,
|
|
116
|
+
idempotencyKey: crypto.randomUUID(),
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
|
|
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
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-6"
|
|
134
|
+
aria-busy={board.isWriting}
|
|
135
|
+
>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={onClose}
|
|
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
|
+
|
|
147
|
+
{/* ⚠️ ONE line of chips, and no field labels. "Status:" in front of a chip that says "Backlog"
|
|
148
|
+
says nothing the chip does not — and it turns a quiet head into a form. */}
|
|
149
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
150
|
+
<DropdownMenu>
|
|
151
|
+
<DropdownMenuTrigger
|
|
152
|
+
aria-label={i18n.t("board.moveTo")}
|
|
153
|
+
className="rounded-full border px-3 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
154
|
+
>
|
|
155
|
+
{column?.title ?? task.status}
|
|
156
|
+
</DropdownMenuTrigger>
|
|
157
|
+
<DropdownMenuContent align="start">
|
|
158
|
+
<DropdownMenuRadioGroup
|
|
159
|
+
value={task.status}
|
|
160
|
+
// ⚠️ Where a task SITS is a move, not a field edit: the server computes a position and
|
|
161
|
+
// checks the cycles for it. Everything else on this screen is a plain change.
|
|
162
|
+
onValueChange={(next) => write({ status: next })}
|
|
163
|
+
>
|
|
164
|
+
{columns.map((entry) => (
|
|
165
|
+
<DropdownMenuRadioItem key={entry.id} value={entry.id}>
|
|
166
|
+
{entry.title}
|
|
167
|
+
</DropdownMenuRadioItem>
|
|
168
|
+
))}
|
|
169
|
+
<DropdownMenuRadioItem value={ARCHIVE_COLUMN_ID}>
|
|
170
|
+
{i18n.t("board.column.archive")}
|
|
171
|
+
</DropdownMenuRadioItem>
|
|
172
|
+
</DropdownMenuRadioGroup>
|
|
173
|
+
</DropdownMenuContent>
|
|
174
|
+
</DropdownMenu>
|
|
175
|
+
|
|
176
|
+
<Assignee id={task.assigneeId} />
|
|
177
|
+
|
|
178
|
+
{dates === null ? null : (
|
|
179
|
+
<span className="rounded-full border px-3 py-1 text-xs tabular-nums">{dates}</span>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{task.labels.map((entry) => (
|
|
183
|
+
<button
|
|
184
|
+
key={entry}
|
|
185
|
+
type="button"
|
|
186
|
+
aria-label={i18n.t("board.removeLabel", { label: entry })}
|
|
187
|
+
onClick={() => write({ labels: task.labels.filter((keep) => keep !== entry) })}
|
|
188
|
+
className="rounded-full bg-muted px-3 py-1 text-xs outline-none hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring"
|
|
189
|
+
>
|
|
190
|
+
{entry}
|
|
191
|
+
</button>
|
|
192
|
+
))}
|
|
193
|
+
|
|
194
|
+
{adding ? (
|
|
195
|
+
<input
|
|
196
|
+
// biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
|
|
197
|
+
autoFocus
|
|
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>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* ⚠️ ONE list, not three blocks. The icon carries the kind — box, lock, chain — and the order
|
|
232
|
+
is the decision documented on `linksOf`. */}
|
|
233
|
+
<section aria-label={i18n.t("board.links")} className="flex flex-col gap-1">
|
|
234
|
+
<h2 className="flex items-center gap-2 text-sm font-medium">
|
|
235
|
+
{i18n.t("board.links")}
|
|
236
|
+
{progress === null ? null : (
|
|
237
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
238
|
+
{i18n.t("board.progress", { done: progress.done, total: progress.total })}
|
|
239
|
+
</span>
|
|
240
|
+
)}
|
|
241
|
+
</h2>
|
|
242
|
+
{links.length === 0 ? null : (
|
|
243
|
+
<ul className="flex flex-col">
|
|
244
|
+
{links.map((link) => (
|
|
245
|
+
<LinkRow key={`${link.kind}:${link.id}`} link={link} onOpen={onOpen} />
|
|
246
|
+
))}
|
|
247
|
+
</ul>
|
|
248
|
+
)}
|
|
249
|
+
<LinkAdder task={task} board={board} />
|
|
250
|
+
</section>
|
|
251
|
+
|
|
252
|
+
{/* The markdown text is the largest element of the page — everything above is the quiet part.
|
|
253
|
+
⚠️ The slot is not decoration: a test that asks whether the BODY drew something has to be
|
|
254
|
+
able to say which part of the card it means. Unscoped, the same loading line elsewhere on
|
|
255
|
+
the card answers for it, and the assurance goes hollow without anybody touching it. */}
|
|
256
|
+
<div data-slot="task-body" className="min-h-0 flex-1">
|
|
257
|
+
{body}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* ⚠️ **ONE `+ Verlinkung`, and the kind is chosen afterwards** — not two buttons labelled "subtask"
|
|
265
|
+
* and "waits for". That is `#371`: the reader decides WHAT they are linking once they know which
|
|
266
|
+
* card they mean, not before.
|
|
267
|
+
*/
|
|
268
|
+
function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
|
|
269
|
+
const i18n = useI18n();
|
|
270
|
+
const [kind, setKind] = useState<"subtask" | "blocker" | null>(null);
|
|
271
|
+
const [title, setTitle] = useState("");
|
|
272
|
+
|
|
273
|
+
const candidates = board.columns
|
|
274
|
+
.flatMap((entry) => entry.cards)
|
|
275
|
+
.filter((card) => card.id !== task.id && card.parentTaskId !== task.id);
|
|
276
|
+
|
|
277
|
+
const close = () => {
|
|
278
|
+
setKind(null);
|
|
279
|
+
setTitle("");
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div className="flex items-center gap-2 pt-1">
|
|
284
|
+
<DropdownMenu>
|
|
285
|
+
<DropdownMenuTrigger className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring">
|
|
286
|
+
<Plus aria-hidden="true" className="size-3" />
|
|
287
|
+
{i18n.t("board.addLink")}
|
|
288
|
+
</DropdownMenuTrigger>
|
|
289
|
+
<DropdownMenuContent align="start">
|
|
290
|
+
<DropdownMenuItem onSelect={() => setKind("subtask")}>
|
|
291
|
+
{i18n.t("board.link.subtask")}
|
|
292
|
+
</DropdownMenuItem>
|
|
293
|
+
<DropdownMenuItem onSelect={() => setKind("blocker")}>
|
|
294
|
+
{i18n.t("board.link.blocker")}
|
|
295
|
+
</DropdownMenuItem>
|
|
296
|
+
</DropdownMenuContent>
|
|
297
|
+
</DropdownMenu>
|
|
298
|
+
|
|
299
|
+
{/* ⚠️ **The two kinds are two different writes, and that is why the kind is asked FIRST.** A
|
|
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" ? (
|
|
304
|
+
<input
|
|
305
|
+
// biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
|
|
306
|
+
autoFocus
|
|
307
|
+
value={title}
|
|
308
|
+
aria-label={i18n.t("board.link.subtask")}
|
|
309
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
310
|
+
onKeyDown={(event) => {
|
|
311
|
+
if (event.key === "Escape") close();
|
|
312
|
+
if (event.key !== "Enter") return;
|
|
313
|
+
event.preventDefault();
|
|
314
|
+
const next = title.trim();
|
|
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);
|
|
331
|
+
}}
|
|
332
|
+
className="w-48 rounded-md border bg-background px-2 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
333
|
+
/>
|
|
334
|
+
) : kind === "blocker" ? (
|
|
335
|
+
<DropdownMenu open onOpenChange={(next) => !next && close()}>
|
|
336
|
+
<DropdownMenuTrigger
|
|
337
|
+
aria-label={i18n.t("board.link.blocker")}
|
|
338
|
+
className="rounded-md border px-2 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
339
|
+
>
|
|
340
|
+
{i18n.t("board.link.blocker")}
|
|
341
|
+
</DropdownMenuTrigger>
|
|
342
|
+
<DropdownMenuContent align="start">
|
|
343
|
+
{candidates.map((card) => (
|
|
344
|
+
<DropdownMenuItem
|
|
345
|
+
key={card.id}
|
|
346
|
+
onSelect={() => {
|
|
347
|
+
close();
|
|
348
|
+
if (board.isWriting) return;
|
|
349
|
+
void board.updateTask({
|
|
350
|
+
taskId: task.id,
|
|
351
|
+
dependsOn: card.id,
|
|
352
|
+
idempotencyKey: crypto.randomUUID(),
|
|
353
|
+
});
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
{card.title}
|
|
357
|
+
</DropdownMenuItem>
|
|
358
|
+
))}
|
|
359
|
+
</DropdownMenuContent>
|
|
360
|
+
</DropdownMenu>
|
|
361
|
+
) : null}
|
|
362
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
@@ -110,14 +110,23 @@ export function FrontmatterHead({ raw }: { raw: string }) {
|
|
|
110
110
|
// The editor owns the text around this; the head itself is not typed into.
|
|
111
111
|
contentEditable={false}
|
|
112
112
|
data-frontmatter={open ? "open" : "closed"}
|
|
113
|
-
|
|
113
|
+
// ⚠️ No border and no vertical margin (#667). The head sits directly under the document's
|
|
114
|
+
// title, and a framed box there asks for more attention than metadata deserve. The tinted
|
|
115
|
+
// surface stays: without any ground at all the keys read as the first paragraph of the text,
|
|
116
|
+
// which is the state #657 came from.
|
|
117
|
+
//
|
|
118
|
+
// ⚠️ The margin is what the drag handle and the `+` line up against — BlockNote centres them
|
|
119
|
+
// on the block's own box, not on what is drawn inside it. `my-2` pushed the strip 12px below
|
|
120
|
+
// them; measured in a real editor, removing it leaves 2px, which is the difference between
|
|
121
|
+
// this row and a line of text.
|
|
122
|
+
className="w-full rounded-md bg-muted/40"
|
|
114
123
|
>
|
|
115
124
|
<button
|
|
116
125
|
type="button"
|
|
117
126
|
aria-expanded={open}
|
|
118
127
|
aria-label={labels.expand}
|
|
119
128
|
onClick={() => setOpen(!open)}
|
|
120
|
-
className="flex w-full items-center gap-2 rounded-md px-3 py-1
|
|
129
|
+
className="flex w-full items-center gap-2 rounded-md px-3 py-1 text-left text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
121
130
|
>
|
|
122
131
|
<ChevronRight
|
|
123
132
|
aria-hidden="true"
|
package/src/i18n/de.json
CHANGED
|
@@ -33,8 +33,15 @@
|
|
|
33
33
|
"auth.signOutFailed": "Das Abmelden ist fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut.",
|
|
34
34
|
"board.addCard": "+ Aufgabe",
|
|
35
35
|
"board.addCardIn": "Eine Aufgabe zu {column} hinzufügen",
|
|
36
|
-
"board.
|
|
36
|
+
"board.addLabel": "Label hinzufügen",
|
|
37
|
+
"board.addLink": "Verlinkung",
|
|
38
|
+
"board.cardLabel": "{title} öffnen, in {column}. Alt und eine Pfeiltaste verschiebt sie.",
|
|
39
|
+
"board.column.archive": "Archiv",
|
|
37
40
|
"board.column.assignee": "Zuständig",
|
|
41
|
+
"board.column.default.backlog": "Backlog",
|
|
42
|
+
"board.column.default.doing": "Läuft",
|
|
43
|
+
"board.column.default.done": "Fertig",
|
|
44
|
+
"board.column.default.todo": "Offen",
|
|
38
45
|
"board.column.dependsOn": "Wartet auf",
|
|
39
46
|
"board.column.due": "Fällig",
|
|
40
47
|
"board.column.labels": "Labels",
|
|
@@ -42,17 +49,51 @@
|
|
|
42
49
|
"board.column.title": "Titel",
|
|
43
50
|
"board.columnEmpty": "Hier ist noch nichts.",
|
|
44
51
|
"board.createFailed": "Die Aufgabe konnte nicht angelegt werden.",
|
|
52
|
+
"board.drag.lifted": "{card} aufgenommen",
|
|
53
|
+
"board.drag.moved": "{card} nach {column} verschoben",
|
|
54
|
+
"board.drag.putBack": "{card} zurückgelegt",
|
|
55
|
+
"board.dragInstructions": "Eine Karte wird mit der Maus gezogen. Ohne Zeigegerät: Alt halten und eine Pfeiltaste drücken — links und rechts wechseln die Spalte, hoch und runter bewegen innerhalb einer Spalte.",
|
|
45
56
|
"board.failed": "Dieses Board konnte nicht geladen werden.",
|
|
46
57
|
"board.filter.all": "Alle Spalten",
|
|
47
58
|
"board.filter.status": "Spalte zeigen",
|
|
48
59
|
"board.filterEmpty": "Keine Karte passt zu diesem Filter.",
|
|
49
|
-
"board.
|
|
60
|
+
"board.fromDate": "ab {date}",
|
|
61
|
+
"board.group.empty": "Nichts gesetzt",
|
|
50
62
|
"board.group.none": "Nichts",
|
|
51
|
-
"board.group.status": "Spalte",
|
|
52
63
|
"board.groupBy": "Gruppieren nach",
|
|
64
|
+
"board.link.blocker": "Wartet auf",
|
|
65
|
+
"board.link.outside": "Außerhalb dieses Boards",
|
|
66
|
+
"board.link.subtask": "Unteraufgabe",
|
|
67
|
+
"board.links": "Verlinkt",
|
|
68
|
+
"board.moveTo": "In eine Spalte verschieben",
|
|
69
|
+
"board.progress": "{done} von {total} erledigt",
|
|
70
|
+
"board.removeLabel": "Label {label} entfernen",
|
|
71
|
+
"board.settings.addColumn": "+ Spalte",
|
|
72
|
+
"board.settings.columnName": "Name der Spalte {column}",
|
|
73
|
+
"board.settings.description": "Spalten und was das Board zeigt.",
|
|
74
|
+
"board.settings.duplicateName": "Zwei Spalten heißen {column}. Namen müssen sie unterscheidbar machen.",
|
|
75
|
+
"board.settings.failed": "Die Einstellungen konnten nicht gespeichert werden.",
|
|
76
|
+
"board.settings.moveDown": "{column} nach unten",
|
|
77
|
+
"board.settings.moveUp": "{column} nach oben",
|
|
78
|
+
"board.settings.newColumn": "Neue Spalte",
|
|
79
|
+
"board.settings.open": "Board-Einstellungen",
|
|
80
|
+
"board.settings.remove": "{column} entfernen",
|
|
81
|
+
"board.settings.removeBlocked": "{column} ist nicht leer. Verschiebe die Karten zuerst.",
|
|
82
|
+
"board.settings.showArchive": "Archiv-Spalte zeigen",
|
|
83
|
+
"board.settings.showArchiveHint": "Sie ist auf jedem Board. Ausgeblendet sind ihre Karten überall aus dem Blick.",
|
|
84
|
+
"board.settings.title": "Board-Einstellungen",
|
|
85
|
+
"board.stripes.root": "Ebene {level}, hat Unteraufgaben",
|
|
86
|
+
"board.stripes.under": "Ebene {level} · übergeordnet in {column}",
|
|
87
|
+
"board.table.collapse": "Verbergen, was unter {row} liegt",
|
|
88
|
+
"board.table.columns": "Spalten",
|
|
89
|
+
"board.table.expand": "Zeigen, was unter {row} liegt",
|
|
90
|
+
"board.table.filteredBy": "nur {column}",
|
|
91
|
+
"board.table.groupedBy": "gruppiert nach {column}",
|
|
53
92
|
"board.tableEmpty": "Dieses Board hat noch keine Karten.",
|
|
93
|
+
"board.taskGone": "Diese Karte steht nicht mehr auf diesem Board. Sie ist entweder archiviert oder nicht mehr da.",
|
|
54
94
|
"board.unassigned": "Noch niemand",
|
|
55
95
|
"board.unknownColumn": "nicht auf diesem Board",
|
|
96
|
+
"board.untilDate": "fällig {date}",
|
|
56
97
|
"board.view.kanban": "Kanban",
|
|
57
98
|
"board.view.table": "Tabelle",
|
|
58
99
|
"common.cancel": "Abbrechen",
|
package/src/i18n/en.json
CHANGED
|
@@ -33,8 +33,15 @@
|
|
|
33
33
|
"auth.signOutFailed": "Signing out failed. Check your connection and try again.",
|
|
34
34
|
"board.addCard": "+ Task",
|
|
35
35
|
"board.addCardIn": "Add a task to {column}",
|
|
36
|
-
"board.
|
|
36
|
+
"board.addLabel": "Add a label",
|
|
37
|
+
"board.addLink": "Link",
|
|
38
|
+
"board.cardLabel": "Open {title}, in {column}. Alt and an arrow key moves it.",
|
|
39
|
+
"board.column.archive": "Archive",
|
|
37
40
|
"board.column.assignee": "Assigned to",
|
|
41
|
+
"board.column.default.backlog": "Backlog",
|
|
42
|
+
"board.column.default.doing": "In progress",
|
|
43
|
+
"board.column.default.done": "Done",
|
|
44
|
+
"board.column.default.todo": "To do",
|
|
38
45
|
"board.column.dependsOn": "Waiting for",
|
|
39
46
|
"board.column.due": "Due",
|
|
40
47
|
"board.column.labels": "Labels",
|
|
@@ -42,17 +49,51 @@
|
|
|
42
49
|
"board.column.title": "Title",
|
|
43
50
|
"board.columnEmpty": "Nothing here yet.",
|
|
44
51
|
"board.createFailed": "The task could not be created.",
|
|
52
|
+
"board.drag.lifted": "{card} picked up",
|
|
53
|
+
"board.drag.moved": "{card} moved to {column}",
|
|
54
|
+
"board.drag.putBack": "{card} put back",
|
|
55
|
+
"board.dragInstructions": "Drag a card with the mouse to move it. Without a pointer, hold Alt and press an arrow key: left and right move the card between columns, up and down within one.",
|
|
45
56
|
"board.failed": "This board could not be loaded.",
|
|
46
57
|
"board.filter.all": "Every column",
|
|
47
58
|
"board.filter.status": "Show column",
|
|
48
59
|
"board.filterEmpty": "No card matches this filter.",
|
|
49
|
-
"board.
|
|
60
|
+
"board.fromDate": "from {date}",
|
|
61
|
+
"board.group.empty": "Nothing set",
|
|
50
62
|
"board.group.none": "Nothing",
|
|
51
|
-
"board.group.status": "Column",
|
|
52
63
|
"board.groupBy": "Group by",
|
|
64
|
+
"board.link.blocker": "Waits for",
|
|
65
|
+
"board.link.outside": "Outside this board",
|
|
66
|
+
"board.link.subtask": "Subtask",
|
|
67
|
+
"board.links": "Linked",
|
|
68
|
+
"board.moveTo": "Move to a column",
|
|
69
|
+
"board.progress": "{done} of {total} done",
|
|
70
|
+
"board.removeLabel": "Remove the label {label}",
|
|
71
|
+
"board.settings.addColumn": "+ Column",
|
|
72
|
+
"board.settings.columnName": "Name of the column {column}",
|
|
73
|
+
"board.settings.description": "Columns and what the board shows.",
|
|
74
|
+
"board.settings.duplicateName": "Two columns are called {column}. Names have to tell them apart.",
|
|
75
|
+
"board.settings.failed": "The settings could not be saved.",
|
|
76
|
+
"board.settings.moveDown": "Move {column} down",
|
|
77
|
+
"board.settings.moveUp": "Move {column} up",
|
|
78
|
+
"board.settings.newColumn": "New column",
|
|
79
|
+
"board.settings.open": "Board settings",
|
|
80
|
+
"board.settings.remove": "Remove {column}",
|
|
81
|
+
"board.settings.removeBlocked": "{column} is not empty. Move its cards out first.",
|
|
82
|
+
"board.settings.showArchive": "Show the archive column",
|
|
83
|
+
"board.settings.showArchiveHint": "It is on every board. Hidden, its cards are out of sight everywhere.",
|
|
84
|
+
"board.settings.title": "Board settings",
|
|
85
|
+
"board.stripes.root": "Level {level}, has subtasks",
|
|
86
|
+
"board.stripes.under": "Level {level} · parent in {column}",
|
|
87
|
+
"board.table.collapse": "Hide what is under {row}",
|
|
88
|
+
"board.table.columns": "Columns",
|
|
89
|
+
"board.table.expand": "Show what is under {row}",
|
|
90
|
+
"board.table.filteredBy": "only {column}",
|
|
91
|
+
"board.table.groupedBy": "grouped by {column}",
|
|
53
92
|
"board.tableEmpty": "This board has no cards yet.",
|
|
93
|
+
"board.taskGone": "This card is not on this board any more. It may have been archived, or it is gone.",
|
|
54
94
|
"board.unassigned": "Nobody yet",
|
|
55
95
|
"board.unknownColumn": "not on this board",
|
|
96
|
+
"board.untilDate": "due {date}",
|
|
56
97
|
"board.view.kanban": "Kanban",
|
|
57
98
|
"board.view.table": "Table",
|
|
58
99
|
"common.cancel": "Cancel",
|