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