@anchrd/intel-ui 0.45.0 → 0.47.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 +2 -2
- package/src/app/user-footer/user-footer.tsx +9 -0
- package/src/archive/archive.tsx +28 -2
- package/src/board/board-assignee/board-assignee.ts +21 -0
- package/src/board/board-assignee/board-assignee.tsx +24 -8
- package/src/board/board-chip/board-chip.tsx +16 -1
- package/src/board/board-crumbs/board-crumbs.tsx +5 -2
- package/src/board/board-dates/board-dates.ts +40 -0
- package/src/board/board-dates/board-dates.tsx +105 -0
- package/src/board/board-kanban/board-kanban.tsx +13 -2
- package/src/board/board-task/board-task.ts +0 -7
- package/src/board/board-task/board-task.tsx +32 -39
- package/src/data/intel-data-provider/intel-data-provider.ts +9 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +8 -0
- package/src/feed/feed.tsx +312 -0
- package/src/i18n/de.json +23 -1
- package/src/i18n/en.json +23 -1
- package/src/i18n/es.json +23 -1
- package/src/router/router.tsx +7 -0
- package/src/title-row/title-row.tsx +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@anchrd/intel-contract": "^0.
|
|
36
|
+
"@anchrd/intel-contract": "^0.26.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -3,6 +3,7 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
|
3
3
|
import {
|
|
4
4
|
Archive as ArchiveIcon,
|
|
5
5
|
ChevronsUpDown,
|
|
6
|
+
List,
|
|
6
7
|
LogOut,
|
|
7
8
|
RefreshCw,
|
|
8
9
|
Settings,
|
|
@@ -113,6 +114,14 @@ export function UserFooter() {
|
|
|
113
114
|
<Wrench aria-hidden="true" />
|
|
114
115
|
{i18n.t("nav.tools")}
|
|
115
116
|
</DropdownMenuItem>
|
|
117
|
+
{/* ⚠️ The feed lives here and NOT in the tree, and the placement is the decision
|
|
118
|
+
(#742): it is not a place things are in, and it is not something anybody needs
|
|
119
|
+
twice a day. No counter and no dot on the icon either — a badge is a request for
|
|
120
|
+
attention, and this surface is deliberately not making one. */}
|
|
121
|
+
<DropdownMenuItem onSelect={() => void navigate({ to: "/feed" })}>
|
|
122
|
+
<List aria-hidden="true" />
|
|
123
|
+
{i18n.t("feed.title")}
|
|
124
|
+
</DropdownMenuItem>
|
|
116
125
|
{/* The archive lives here rather than in the tree: it is not a place things are IN,
|
|
117
126
|
it is where they went when they left the tree (#113). */}
|
|
118
127
|
<DropdownMenuItem onSelect={() => void navigate({ to: "/archive" })}>
|
package/src/archive/archive.tsx
CHANGED
|
@@ -179,6 +179,8 @@ export function Archive() {
|
|
|
179
179
|
// that it does not come back. The state lives here because the row underneath disappears the
|
|
180
180
|
// moment the purge goes through.
|
|
181
181
|
const [confirming, setConfirming] = useState<ArchivedEntry | null>(null);
|
|
182
|
+
// Whether anything has scrolled away under the header. See the header markup for why.
|
|
183
|
+
const [scrolled, setScrolled] = useState(false);
|
|
182
184
|
const preview = useQuery({
|
|
183
185
|
queryKey: ["purge-preview", confirming?.id],
|
|
184
186
|
queryFn: async () => await confirming?.previewPurge?.(),
|
|
@@ -202,13 +204,37 @@ export function Archive() {
|
|
|
202
204
|
|
|
203
205
|
return (
|
|
204
206
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
205
|
-
|
|
207
|
+
{/* ⚠️ The rule below the header belongs to the SCROLL STATE, not to the header. While nothing
|
|
208
|
+
has scrolled away, it separates a heading from the content that heading is about, which is
|
|
209
|
+
a line drawn for no reason. It earns its place the moment content slides underneath.
|
|
210
|
+
|
|
211
|
+
The width is set from the start and only the COLOUR changes. Adding the border on scroll
|
|
212
|
+
would grow the header by one pixel at that moment and shove the whole list down — visible,
|
|
213
|
+
and exactly at the point where the eye is already following movement (#741).
|
|
214
|
+
|
|
215
|
+
⚠️ `motion-reduce:transition-none` is not decoration. Nothing in this repository disarms a
|
|
216
|
+
colour transition for a reader who asked for less movement — there is no global
|
|
217
|
+
`prefers-reduced-motion` rule in the stylesheets — so a fade added here would run for them
|
|
218
|
+
too unless it says otherwise. The line still appears either way; only the fade is dropped. */}
|
|
219
|
+
<div
|
|
220
|
+
data-slot="pane-header"
|
|
221
|
+
className={cn(
|
|
222
|
+
"border-b border-transparent px-6 py-4 transition-colors motion-reduce:transition-none",
|
|
223
|
+
scrolled && "border-border",
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
206
226
|
<h1 className="text-lg font-medium text-foreground">{i18n.t("archive.title")}</h1>
|
|
207
227
|
<p className="mt-1 max-w-prose text-sm text-muted-foreground">
|
|
208
228
|
{i18n.t("archive.description")}
|
|
209
229
|
</p>
|
|
210
230
|
</div>
|
|
211
|
-
<div
|
|
231
|
+
<div
|
|
232
|
+
data-slot="pane-body"
|
|
233
|
+
// Reading the position off the event rather than holding a ref: React skips the re-render
|
|
234
|
+
// when the boolean has not changed, so a scroll of 400 pixels still costs one render.
|
|
235
|
+
onScroll={(event) => setScrolled(event.currentTarget.scrollTop > 0)}
|
|
236
|
+
className="min-h-0 flex-1 overflow-y-auto px-6 py-4"
|
|
237
|
+
>
|
|
212
238
|
{archived.isError ? (
|
|
213
239
|
<p role="alert" className="text-sm text-destructive">
|
|
214
240
|
{i18n.t("archive.failed")}
|
|
@@ -25,3 +25,24 @@ export function useAssigneeLabel(id: string | null): string {
|
|
|
25
25
|
// replacement for the rule underneath it.
|
|
26
26
|
return own ?? resolved ?? i18n.t("node.someUser");
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The three tones the access summary already uses for its circles, so an assignee looks like the
|
|
31
|
+
* same kind of thing as a person on the share screen (#724).
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ **Chosen by the ID, not by a position.** The access summary can count places in a row; a card
|
|
34
|
+
* stands alone, and there is no row to count in. Hashing the id means the same person keeps the same
|
|
35
|
+
* colour on every card and every board, which is the only property that makes a colour worth having
|
|
36
|
+
* here — a colour that moves is noise.
|
|
37
|
+
*/
|
|
38
|
+
const PERSON_TONES = [
|
|
39
|
+
"bg-muted text-muted-foreground",
|
|
40
|
+
"bg-accent text-accent-foreground",
|
|
41
|
+
"bg-secondary text-secondary-foreground",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export function personTone(id: string): string {
|
|
45
|
+
let sum = 0;
|
|
46
|
+
for (const character of id) sum = (sum + character.charCodeAt(0)) % 4096;
|
|
47
|
+
return PERSON_TONES[sum % PERSON_TONES.length] ?? PERSON_TONES[0];
|
|
48
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { UserRound } from "lucide-react";
|
|
1
2
|
import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
|
|
2
3
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
3
5
|
import { initials } from "@/user-name/user-name.ts";
|
|
4
|
-
import { useAssigneeLabel } from "./board-assignee.ts";
|
|
6
|
+
import { personTone, useAssigneeLabel } from "./board-assignee.ts";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
* The circle for whoever a card is for, and the way to
|
|
9
|
+
* The circle for whoever a card is for, and the way to give it to somebody else.
|
|
8
10
|
*
|
|
9
11
|
* ⚠️ **One truth for two surfaces.** The opened card and the table both draw it; written twice they
|
|
10
12
|
* would drift, and the drift would be an accessibility bug on exactly one of them.
|
|
@@ -12,8 +14,10 @@ import { useAssigneeLabel } from "./board-assignee.ts";
|
|
|
12
14
|
* ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed from the access
|
|
13
15
|
* summary, the field stays `assigneeId`. A row of circles would imply a second data model.
|
|
14
16
|
*
|
|
15
|
-
* ⚠️ **
|
|
16
|
-
* an absence
|
|
17
|
+
* ⚠️ **Drawn even for nobody, since #724** — and that reverses #692 on purpose rather than by
|
|
18
|
+
* accident. A text chip saying "nobody yet" was a placeholder for an absence and earned no room. An
|
|
19
|
+
* empty circle is not that: it is the one control on the row that says the card COULD be given to
|
|
20
|
+
* somebody, and the card nobody owns is exactly the one that needs it.
|
|
17
21
|
*
|
|
18
22
|
* ⚠️ **The circle OPENS the picker; it no longer clears on click** (#723). A control that
|
|
19
23
|
* removes an assignment on the same click somebody uses to change one is a control that
|
|
@@ -29,7 +33,12 @@ export function AssigneeChip({
|
|
|
29
33
|
boardId,
|
|
30
34
|
onPick,
|
|
31
35
|
}: {
|
|
32
|
-
|
|
36
|
+
/**
|
|
37
|
+
* ⚠️ `null` DRAWS, it does not skip (#724). An empty circle with a person in it is the one place
|
|
38
|
+
* on the row that says "this could be given to somebody" — and without it there is nothing to
|
|
39
|
+
* press on a card nobody owns, which is exactly the card that needs an owner.
|
|
40
|
+
*/
|
|
41
|
+
id: string | null;
|
|
33
42
|
boardId: string;
|
|
34
43
|
onPick(assigneeId: string | null): void;
|
|
35
44
|
}) {
|
|
@@ -39,7 +48,11 @@ export function AssigneeChip({
|
|
|
39
48
|
<AssigneePicker boardId={boardId} current={id} onPick={onPick}>
|
|
40
49
|
<button
|
|
41
50
|
type="button"
|
|
42
|
-
aria-label={
|
|
51
|
+
aria-label={
|
|
52
|
+
id === null
|
|
53
|
+
? i18n.t("board.assignSomebody")
|
|
54
|
+
: i18n.t("board.changeAssignee", { name: label })
|
|
55
|
+
}
|
|
43
56
|
title={label}
|
|
44
57
|
className="shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
45
58
|
>
|
|
@@ -49,9 +62,12 @@ export function AssigneeChip({
|
|
|
49
62
|
// label back would not add a second announcement, it would add none, while making the code
|
|
50
63
|
// look as though the name were covered here rather than on the button.
|
|
51
64
|
aria-hidden="true"
|
|
52
|
-
className=
|
|
65
|
+
className={cn(
|
|
66
|
+
"flex size-7 items-center justify-center rounded-full text-xs font-medium ring-2 ring-background",
|
|
67
|
+
id === null ? "border border-dashed text-muted-foreground" : personTone(id),
|
|
68
|
+
)}
|
|
53
69
|
>
|
|
54
|
-
{initials(label)}
|
|
70
|
+
{id === null ? <UserRound aria-hidden="true" className="size-4" /> : initials(label)}
|
|
55
71
|
</span>
|
|
56
72
|
</button>
|
|
57
73
|
</AssigneePicker>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
1
2
|
/**
|
|
2
3
|
* A value on a card, and the way to take it off again.
|
|
3
4
|
*
|
|
@@ -13,17 +14,31 @@ export function BoardChip({
|
|
|
13
14
|
label,
|
|
14
15
|
action,
|
|
15
16
|
onClick,
|
|
17
|
+
tone = "outline",
|
|
16
18
|
}: {
|
|
17
19
|
label: string;
|
|
18
20
|
action: string;
|
|
19
21
|
onClick(): void;
|
|
22
|
+
/**
|
|
23
|
+
* ⚠️ **`inverted` is for labels and nothing else** (#724). Status, dates and the assignee say what
|
|
24
|
+
* a card IS; a label is what somebody chose to call it, and the row needs to tell the two apart at
|
|
25
|
+
* a glance. Filling every chip would take that difference away again.
|
|
26
|
+
*/
|
|
27
|
+
tone?: "outline" | "inverted";
|
|
20
28
|
}) {
|
|
21
29
|
return (
|
|
22
30
|
<button
|
|
23
31
|
type="button"
|
|
24
32
|
aria-label={`${label}, ${action}`}
|
|
25
33
|
onClick={onClick}
|
|
26
|
-
className=
|
|
34
|
+
className={cn(
|
|
35
|
+
"flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
36
|
+
tone === "outline" && "border hover:bg-muted",
|
|
37
|
+
// ⚠️ `foreground`/`background`, not literal black and white. The two swap with the theme on
|
|
38
|
+
// their own, so the label stays inverted in dark mode instead of turning into a black chip
|
|
39
|
+
// on a black ground.
|
|
40
|
+
tone === "inverted" && "bg-foreground text-background hover:bg-foreground/85",
|
|
41
|
+
)}
|
|
27
42
|
>
|
|
28
43
|
{label}
|
|
29
44
|
</button>
|
|
@@ -31,14 +31,17 @@ export function BoardCrumbs({
|
|
|
31
31
|
const rest = shown.slice(1);
|
|
32
32
|
|
|
33
33
|
return (
|
|
34
|
-
<nav
|
|
34
|
+
<nav
|
|
35
|
+
aria-label={i18n.t("board.crumbs")}
|
|
36
|
+
className="flex min-w-0 items-center gap-1.5 text-lg font-semibold"
|
|
37
|
+
>
|
|
35
38
|
<button
|
|
36
39
|
type="button"
|
|
37
40
|
aria-label={i18n.t("board.backTo", { board: board?.title ?? "" })}
|
|
38
41
|
onClick={() => onOpen(null)}
|
|
39
42
|
className="-ml-1 rounded-md p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
40
43
|
>
|
|
41
|
-
<ChevronLeft aria-hidden="true" className="size-
|
|
44
|
+
<ChevronLeft aria-hidden="true" className="size-5" />
|
|
42
45
|
</button>
|
|
43
46
|
|
|
44
47
|
<Step crumb={board} onOpen={onOpen} />
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a card's two dates become the one thing a reader sees (#724).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **Pure, and separate from the chip that draws it.** The rules here — which of the four
|
|
5
|
+
* combinations produces which text — are the part worth holding in a test without a DOM, and the
|
|
6
|
+
* part a second surface would otherwise re-derive slightly differently.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `TT.MM.JJJJ`, from the ISO timestamp the contract carries.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ Built from the date PART of the string, never from a `Date`. `new Date("2026-08-15T00:00:00Z")`
|
|
13
|
+
* is the 14th in every timezone west of London, and a card would show the day before the one
|
|
14
|
+
* somebody picked. The stored value is a calendar day; it is read as one.
|
|
15
|
+
*/
|
|
16
|
+
export function germanDate(iso: string): string {
|
|
17
|
+
const [year, month, day] = iso.slice(0, 10).split("-");
|
|
18
|
+
return `${day}.${month}.${year}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the `<input type="date">` in the popover needs: the same day, the other way round. */
|
|
22
|
+
export function isoDay(iso: string): string {
|
|
23
|
+
return iso.slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The label of the single date chip, or `null` when there is nothing to say.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ **A range only when there IS one.** With one date set the chip carries that date alone: a
|
|
30
|
+
* range with an empty half ("15.08.2026 - ") reads as a value somebody forgot to finish, and a
|
|
31
|
+
* placeholder for the missing end is a placeholder for an absence (#692).
|
|
32
|
+
*/
|
|
33
|
+
export function dateLabel(startDate: string | null, dueDate: string | null): string | null {
|
|
34
|
+
if (startDate !== null && dueDate !== null) {
|
|
35
|
+
return `${germanDate(startDate)} - ${germanDate(dueDate)}`;
|
|
36
|
+
}
|
|
37
|
+
if (startDate !== null) return germanDate(startDate);
|
|
38
|
+
if (dueDate !== null) return germanDate(dueDate);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { CalendarDays } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenu,
|
|
4
|
+
DropdownMenuContent,
|
|
5
|
+
DropdownMenuTrigger,
|
|
6
|
+
} from "@/components/ui/dropdown-menu.tsx";
|
|
7
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
8
|
+
import { dateLabel, isoDay } from "./board-dates.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* When a card runs, as one chip (#724).
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **One chip, not two.** It used to be `from …` and `due …` side by side, and that was two facts
|
|
14
|
+
* where a reader sees one: a card runs from a day to a day. The two are still edited and removed
|
|
15
|
+
* separately, but inside, where the popover can say which half it is changing.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **The chip is the trigger.** A menu with no anchor never appears — that is `#723`, and it cost
|
|
18
|
+
* a whole release. `DropdownMenuContent` without a `DropdownMenuTrigger` in the same `DropdownMenu`
|
|
19
|
+
* is a finding in this repository.
|
|
20
|
+
*/
|
|
21
|
+
export function DatesChip({
|
|
22
|
+
startDate,
|
|
23
|
+
dueDate,
|
|
24
|
+
write,
|
|
25
|
+
}: {
|
|
26
|
+
startDate: string | null;
|
|
27
|
+
dueDate: string | null;
|
|
28
|
+
write(input: { startDate?: string | null; dueDate?: string | null }): void;
|
|
29
|
+
}) {
|
|
30
|
+
const i18n = useI18n();
|
|
31
|
+
const label = dateLabel(startDate, dueDate);
|
|
32
|
+
if (label === null) return null;
|
|
33
|
+
|
|
34
|
+
// ⚠️ Midnight UTC, the same shape the plus menu writes. A card's dates are calendar days here, and
|
|
35
|
+
// a second format would make two cards written by two paths sort against each other.
|
|
36
|
+
const asStored = (day: string) => (day === "" ? null : `${day}T00:00:00.000Z`);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<DropdownMenu>
|
|
40
|
+
<DropdownMenuTrigger
|
|
41
|
+
aria-label={`${label}, ${i18n.t("board.dates.edit")}`}
|
|
42
|
+
className="flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
43
|
+
>
|
|
44
|
+
<CalendarDays aria-hidden="true" className="size-3.5" />
|
|
45
|
+
{label}
|
|
46
|
+
</DropdownMenuTrigger>
|
|
47
|
+
<DropdownMenuContent align="start" className="w-60 p-2">
|
|
48
|
+
<div className="flex flex-col gap-2">
|
|
49
|
+
<Field
|
|
50
|
+
label={i18n.t("board.add.start")}
|
|
51
|
+
value={startDate}
|
|
52
|
+
clear={i18n.t("board.clearStart")}
|
|
53
|
+
onChange={(day) => write({ startDate: asStored(day) })}
|
|
54
|
+
/>
|
|
55
|
+
<Field
|
|
56
|
+
label={i18n.t("board.add.due")}
|
|
57
|
+
value={dueDate}
|
|
58
|
+
clear={i18n.t("board.clearDue")}
|
|
59
|
+
onChange={(day) => write({ dueDate: asStored(day) })}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
</DropdownMenuContent>
|
|
63
|
+
</DropdownMenu>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One half of the range.
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ **The remove button only exists while there is something to remove.** A permanently visible
|
|
71
|
+
* one is a control that does nothing on most cards, and a reader learns to ignore it.
|
|
72
|
+
*/
|
|
73
|
+
function Field({
|
|
74
|
+
label,
|
|
75
|
+
value,
|
|
76
|
+
clear,
|
|
77
|
+
onChange,
|
|
78
|
+
}: {
|
|
79
|
+
label: string;
|
|
80
|
+
value: string | null;
|
|
81
|
+
clear: string;
|
|
82
|
+
onChange(day: string): void;
|
|
83
|
+
}) {
|
|
84
|
+
return (
|
|
85
|
+
<label className="flex items-center gap-2 text-xs">
|
|
86
|
+
<span className="w-14 shrink-0 text-muted-foreground">{label}</span>
|
|
87
|
+
<input
|
|
88
|
+
type="date"
|
|
89
|
+
value={value === null ? "" : isoDay(value)}
|
|
90
|
+
onChange={(event) => onChange(event.target.value)}
|
|
91
|
+
className="min-w-0 flex-1 rounded-md border bg-background px-2 py-1 tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
92
|
+
/>
|
|
93
|
+
{value === null ? null : (
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
aria-label={`${label}, ${clear}`}
|
|
97
|
+
onClick={() => onChange("")}
|
|
98
|
+
className="shrink-0 rounded-md px-1 text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
99
|
+
>
|
|
100
|
+
×
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
</label>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -77,7 +77,14 @@ function Card({
|
|
|
77
77
|
onClick={() => onOpen(card.id)}
|
|
78
78
|
// `relative` and `overflow-hidden` are for the depth pattern: it is positioned against this
|
|
79
79
|
// card, and nothing of it may spill past the rounded edge.
|
|
80
|
-
|
|
80
|
+
/* ⚠️ A quiet grey FIELD, not a box (#748). It carried `bg-card` plus `border-border` plus
|
|
81
|
+
`shadow-xs` — three means saying the same thing ("this is a card"), and on a white board
|
|
82
|
+
the three together read as a container rather than a surface. `bg-muted` is 0.97 against
|
|
83
|
+
a 1.0 ground in light and 0.269 against 0.145 in dark, so the card stands off its column
|
|
84
|
+
in both without a line around it.
|
|
85
|
+
⚠️ The hover is what replaces the border: a surface still has to answer the pointer, and
|
|
86
|
+
without a line there is nothing else left to do it. */
|
|
87
|
+
className="relative w-full cursor-grab overflow-hidden rounded-md bg-muted p-2 text-left text-sm transition-colors hover:bg-muted/70 focus-visible:outline-2 focus-visible:outline-ring"
|
|
81
88
|
onKeyDown={(event) => {
|
|
82
89
|
// ⚠️ Not while a write is in flight. Two moves in quick succession both read the same
|
|
83
90
|
// not-yet-updated `board.columns`, compute a position independently and both write — the
|
|
@@ -585,7 +592,11 @@ export function BoardKanban({
|
|
|
585
592
|
cursor moves, and there is nothing on screen saying the gesture was picked up at all. */}
|
|
586
593
|
<DragOverlay>
|
|
587
594
|
{carriedCard === undefined ? null : (
|
|
588
|
-
<div
|
|
595
|
+
<div /* ⚠️ The same surface as the card it stands for (#748), plus the shadow that is the ONE
|
|
596
|
+
thing this element says beyond it: this one is lifted. A card that changed colour on
|
|
597
|
+
being picked up would read as a different card. */
|
|
598
|
+
className="w-64 rounded-md bg-muted p-2 text-left text-sm shadow-lg"
|
|
599
|
+
>
|
|
589
600
|
{carriedCard.title}
|
|
590
601
|
</div>
|
|
591
602
|
)}
|
|
@@ -96,10 +96,3 @@ export function linksOf(task: BoardTask, columns: ColumnWithCards[]): TaskLink[]
|
|
|
96
96
|
(a.title ?? "").localeCompare(b.title ?? ""),
|
|
97
97
|
);
|
|
98
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
|
-
}
|
|
@@ -5,6 +5,7 @@ import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
|
|
|
5
5
|
import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
|
|
6
6
|
import { BoardChip } from "@/board/board-chip/board-chip.tsx";
|
|
7
7
|
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
8
|
+
import { DatesChip } from "@/board/board-dates/board-dates.tsx";
|
|
8
9
|
import { StatusChip } from "@/board/board-status/board-status.tsx";
|
|
9
10
|
import {
|
|
10
11
|
DropdownMenu,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
DropdownMenuTrigger,
|
|
14
15
|
} from "@/components/ui/dropdown-menu.tsx";
|
|
15
16
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
16
|
-
import { type LinkKind, linksOf,
|
|
17
|
+
import { type LinkKind, linksOf, type TaskLink } from "./board-task.ts";
|
|
17
18
|
|
|
18
19
|
const linkIcons: Record<LinkKind, typeof Lock> = {
|
|
19
20
|
subtask: Square,
|
|
@@ -66,7 +67,6 @@ export function BoardTaskDocument({
|
|
|
66
67
|
const i18n = useI18n();
|
|
67
68
|
|
|
68
69
|
const links = linksOf(task, board.columns);
|
|
69
|
-
const progress = progressOf(links);
|
|
70
70
|
const columns = board.columns.filter((entry) => entry.unknown !== true).map((e) => e.column);
|
|
71
71
|
const column = columns.find((entry) => entry.id === task.status);
|
|
72
72
|
|
|
@@ -87,7 +87,11 @@ export function BoardTaskDocument({
|
|
|
87
87
|
|
|
88
88
|
return (
|
|
89
89
|
<div
|
|
90
|
-
|
|
90
|
+
/* ⚠️ `pt-0`, and the 16px above the chips is the TITLE ROW's own `py-4` (#724). Adding padding
|
|
91
|
+
here as well would stack two gaps into 40px, which is what it was: the chips looked like a
|
|
92
|
+
section of their own instead of the second line of one head. `gap-6` below sets the 24px
|
|
93
|
+
that separates the head from the link list, which IS a section of its own. */
|
|
94
|
+
className="flex min-h-0 flex-1 flex-col gap-6 overflow-auto px-6 pt-0 pb-6"
|
|
91
95
|
aria-busy={board.isWriting}
|
|
92
96
|
>
|
|
93
97
|
{/* ⚠️ No heading and no way back HERE. Both live in the title row above since #692: the path
|
|
@@ -103,43 +107,32 @@ export function BoardTaskDocument({
|
|
|
103
107
|
write={write}
|
|
104
108
|
/>
|
|
105
109
|
|
|
106
|
-
{/* ⚠️
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
|
|
111
|
-
action={i18n.t("board.clearStart")}
|
|
112
|
-
onClick={() => write({ startDate: null })}
|
|
113
|
-
/>
|
|
114
|
-
)}
|
|
115
|
-
{task.dueDate === null ? null : (
|
|
116
|
-
<BoardChip
|
|
117
|
-
label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
|
|
118
|
-
action={i18n.t("board.clearDue")}
|
|
119
|
-
onClick={() => write({ dueDate: null })}
|
|
120
|
-
/>
|
|
121
|
-
)}
|
|
110
|
+
{/* ⚠️ ONE chip, since #724. It was two, `from …` and `due …` — two facts where a reader
|
|
111
|
+
sees one: a card runs from a day to a day. Each half is still edited and removed on its
|
|
112
|
+
own, but inside the popover, where the control can say which half it means. */}
|
|
113
|
+
<DatesChip startDate={task.startDate} dueDate={task.dueDate} write={write} />
|
|
122
114
|
|
|
123
115
|
{task.labels.map((entry) => (
|
|
124
116
|
<BoardChip
|
|
125
117
|
key={entry}
|
|
126
118
|
label={entry}
|
|
119
|
+
tone="inverted"
|
|
127
120
|
action={i18n.t("board.removeLabel")}
|
|
128
121
|
onClick={() => write({ labels: task.labels.filter((keep) => keep !== entry) })}
|
|
129
122
|
/>
|
|
130
123
|
))}
|
|
131
124
|
|
|
132
|
-
{/* ⚠️ **Last, always.** It is the only round element in the row; standing between the chips
|
|
133
|
-
it breaks the line, and a row without one simply ends earlier. */}
|
|
134
|
-
{task.assigneeId === null ? null : (
|
|
135
|
-
<AssigneeChip
|
|
136
|
-
id={task.assigneeId}
|
|
137
|
-
boardId={board.boardId}
|
|
138
|
-
onPick={(assigneeId) => write({ assigneeId })}
|
|
139
|
-
/>
|
|
140
|
-
)}
|
|
141
|
-
|
|
142
125
|
<Adder task={task} board={board} write={write} />
|
|
126
|
+
|
|
127
|
+
{/* ⚠️ **Last, always, and after the plus** (#724). It is the only round element in the row;
|
|
128
|
+
standing between the chips it breaks the line. And it is drawn even when nobody has the
|
|
129
|
+
card: an empty circle is the one control on a row that says the card COULD be given to
|
|
130
|
+
somebody, which is precisely what an unowned card needs. */}
|
|
131
|
+
<AssigneeChip
|
|
132
|
+
id={task.assigneeId}
|
|
133
|
+
boardId={board.boardId}
|
|
134
|
+
onPick={(assigneeId) => write({ assigneeId })}
|
|
135
|
+
/>
|
|
143
136
|
</div>
|
|
144
137
|
|
|
145
138
|
{/* ⚠️ ONE list, not three blocks: the icon carries the kind. And it exists only when there is
|
|
@@ -147,14 +140,11 @@ export function BoardTaskDocument({
|
|
|
147
140
|
is missing; nothing at all tells them there is nothing, which is the truth (#692). */}
|
|
148
141
|
{links.length === 0 ? null : (
|
|
149
142
|
<section aria-label={i18n.t("board.links")} className="flex flex-col gap-1">
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
</span>
|
|
156
|
-
)}
|
|
157
|
-
</h2>
|
|
143
|
+
{/* ⚠️ Secondary, and the counter is gone (#724). The heading is a label for the list
|
|
144
|
+
under it, not a section title competing with the card's name; and "0 of 2 done" was a
|
|
145
|
+
number nobody acts on — the rows themselves say which are done, and they say it
|
|
146
|
+
without arithmetic. */}
|
|
147
|
+
<h2 className="text-xs text-muted-foreground">{i18n.t("board.links")}</h2>
|
|
158
148
|
<ul className="flex flex-col">
|
|
159
149
|
{links.map((link) => (
|
|
160
150
|
<LinkRow key={`${link.kind}:${link.id}`} link={link} onOpen={onOpen} />
|
|
@@ -258,11 +248,14 @@ function Adder({
|
|
|
258
248
|
return (
|
|
259
249
|
<>
|
|
260
250
|
<DropdownMenu>
|
|
251
|
+
{/* ⚠️ No border, square, and the icon at full strength (#724). A bordered pill here read
|
|
252
|
+
as one more chip, so the row looked like it carried a value called "+". Without the
|
|
253
|
+
border it is what it is: a control, quiet until the pointer is on it. */}
|
|
261
254
|
<DropdownMenuTrigger
|
|
262
255
|
aria-label={i18n.t("board.add")}
|
|
263
|
-
className="
|
|
256
|
+
className="flex size-6 shrink-0 items-center justify-center rounded-md text-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
264
257
|
>
|
|
265
|
-
<Plus aria-hidden="true" className="size-
|
|
258
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
266
259
|
</DropdownMenuTrigger>
|
|
267
260
|
<DropdownMenuContent align="start">
|
|
268
261
|
{addable.map((entry) => (
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
BoardView,
|
|
9
9
|
} from "@anchrd/intel-contract/board";
|
|
10
10
|
import { BundleImportResult } from "@anchrd/intel-contract/bundle";
|
|
11
|
+
import { FeedListResponse } from "@anchrd/intel-contract/feed";
|
|
11
12
|
import {
|
|
12
13
|
ArchiveFlowInput,
|
|
13
14
|
CreateFlowInput,
|
|
@@ -580,6 +581,14 @@ export function createIntelDataProvider(
|
|
|
580
581
|
body: JSON.stringify(parsed),
|
|
581
582
|
});
|
|
582
583
|
},
|
|
584
|
+
async listFeed(input) {
|
|
585
|
+
// Only what was asked for travels: an empty `actor` or `before` in the query string is not
|
|
586
|
+
// the same as leaving it out, and the contract refuses the empty string.
|
|
587
|
+
const params = new URLSearchParams({ limit: String(input.limit) });
|
|
588
|
+
if (input.actor) params.set("actor", input.actor);
|
|
589
|
+
if (input.before) params.set("before", input.before);
|
|
590
|
+
return await request(`/feed?${params}`, FeedListResponse);
|
|
591
|
+
},
|
|
583
592
|
async listFlowRuns(input) {
|
|
584
593
|
const parsed = ListFlowRunsInput.parse(input);
|
|
585
594
|
// Only what was actually asked for travels. `failedOnly` is the parameter's presence rather
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
BoardView,
|
|
9
9
|
} from "@anchrd/intel-contract/board";
|
|
10
10
|
import type { BundleImportResult } from "@anchrd/intel-contract/bundle";
|
|
11
|
+
import type { FeedListResponse } from "@anchrd/intel-contract/feed";
|
|
11
12
|
import type {
|
|
12
13
|
ArchiveFlowInput,
|
|
13
14
|
CreateFlowInput,
|
|
@@ -235,6 +236,13 @@ export interface IntelDataProvider {
|
|
|
235
236
|
// What this flow has done, newest first, one page at a time. ⚠️ It carries what a run did and
|
|
236
237
|
// never what it produced: a run reads nodes with the rights of whoever started it, so its
|
|
237
238
|
// result is not automatically readable for everyone who may read the flow.
|
|
239
|
+
// The activity feed, newest first (#742). `before` is the previous answer's `nextCursor`,
|
|
240
|
+
// handed back unchanged; `actor` narrows it to one person or one agent.
|
|
241
|
+
listFeed(input: {
|
|
242
|
+
limit: number;
|
|
243
|
+
before?: string | undefined;
|
|
244
|
+
actor?: string | undefined;
|
|
245
|
+
}): Promise<FeedListResponse>;
|
|
238
246
|
listFlowRuns(input: ListFlowRunsInput): Promise<FlowRunList>;
|
|
239
247
|
getFlowRun(runId: string): Promise<FlowRunStep>;
|
|
240
248
|
// The drill-down behind one run: every step it took, and the call chain it belongs to.
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import type { FeedEvent } from "@anchrd/intel-contract/feed";
|
|
2
|
+
import { useInfiniteQuery, useQueries } from "@tanstack/react-query";
|
|
3
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
4
|
+
import { Check, Filter } from "lucide-react";
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuLabel,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
} from "@/components/ui/dropdown-menu";
|
|
13
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
14
|
+
import { cn } from "@/lib/utils.ts";
|
|
15
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
16
|
+
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
17
|
+
import { initials, useSessionUser } from "@/user-name/user-name.ts";
|
|
18
|
+
|
|
19
|
+
const PageSize = 30;
|
|
20
|
+
|
|
21
|
+
// Who to show. Only the first two reach the server; `people` is applied here because whether an
|
|
22
|
+
// actor is a machine is Gate's answer and arrives with the names, not with the events.
|
|
23
|
+
type Audience = "all" | "mine" | "people";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What happened, newest first, one card per event.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ **The shape is the archive's** — a header with a rule that appears on scroll, then the
|
|
29
|
+
* scrolling area — because this is a page of the same kind and a second layout for it would be a
|
|
30
|
+
* second set of answers to "where does the title go". What differs is inside: the list is capped
|
|
31
|
+
* and centred rather than full width.
|
|
32
|
+
*/
|
|
33
|
+
export function Feed() {
|
|
34
|
+
const { data } = useIntelRouterContext();
|
|
35
|
+
const i18n = useI18n();
|
|
36
|
+
const me = useSessionUser();
|
|
37
|
+
const navigate = useNavigate();
|
|
38
|
+
const [audience, setAudience] = useState<Audience>("all");
|
|
39
|
+
const [scrolled, setScrolled] = useState(false);
|
|
40
|
+
|
|
41
|
+
// ⚠️ The audience belongs in the key. "Only mine" is a different question with a different
|
|
42
|
+
// answer, and sharing one cache entry would show the wrong list for a frame after the switch.
|
|
43
|
+
//
|
|
44
|
+
// ⚠️ `mine` reaches the server, `people` does not: the server has no idea which actor is a
|
|
45
|
+
// machine. Putting `people` in the key anyway would split the cache for a filter that changes
|
|
46
|
+
// nothing about the request — so it is deliberately absent from what travels.
|
|
47
|
+
const actor = audience === "mine" ? (me?.id ?? null) : null;
|
|
48
|
+
const feed = useInfiniteQuery({
|
|
49
|
+
queryKey: ["feed", actor],
|
|
50
|
+
queryFn: ({ pageParam }) =>
|
|
51
|
+
data.listFeed({
|
|
52
|
+
limit: PageSize,
|
|
53
|
+
...(pageParam ? { before: pageParam } : {}),
|
|
54
|
+
...(actor ? { actor } : {}),
|
|
55
|
+
}),
|
|
56
|
+
initialPageParam: null as string | null,
|
|
57
|
+
getNextPageParam: (last) => last.nextCursor,
|
|
58
|
+
// Asking with `actor: null` for "only mine" would be the whole feed, which is the opposite of
|
|
59
|
+
// what was asked for. Better to render nothing for the instant before the session arrives.
|
|
60
|
+
enabled: audience !== "mine" || me !== null,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const events = useMemo(() => feed.data?.pages.flatMap((page) => page.events) ?? [], [feed.data]);
|
|
64
|
+
|
|
65
|
+
// ⚠️ One resolve per PAGE, and PER page rather than over everything loaded so far. A page is
|
|
66
|
+
// thirty cards and often three people, so resolving a single id per card would open thirty
|
|
67
|
+
// requests for three names — that much is obvious. The half that is not: asking again for the
|
|
68
|
+
// GROWING union on every page walks straight into the contract's `max(100)` and takes the endless
|
|
69
|
+
// scroll down with a Zod error, on exactly the busy folder the feed is for.
|
|
70
|
+
//
|
|
71
|
+
// One query per page keeps every ask at most a page wide, and TanStack keeps the earlier answers
|
|
72
|
+
// rather than re-fetching them.
|
|
73
|
+
const nameQueries = useQueries({
|
|
74
|
+
queries: (feed.data?.pages ?? []).map((page) => {
|
|
75
|
+
const ids = [...new Set(page.events.map((entry) => entry.actorId))].sort();
|
|
76
|
+
return {
|
|
77
|
+
queryKey: ["feed-actor-names", ids],
|
|
78
|
+
// ⚠️ Never reached with an empty list: the contract refuses it (`min(1)`), and the refusal
|
|
79
|
+
// would surface as a broken page rather than as the empty answer it means.
|
|
80
|
+
enabled: ids.length > 0,
|
|
81
|
+
queryFn: () => data.resolveBoardAssignees(ids),
|
|
82
|
+
staleTime: 5 * 60 * 1000,
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
// ⚠️ Built on every render rather than memoised, and the first attempt at this is why it says
|
|
87
|
+
// so: keyed on `feed.data` the map was never rebuilt when the NAMES arrived, because the pages
|
|
88
|
+
// had not changed — every card showed "a user account" for good. A few dozen entries cost
|
|
89
|
+
// nothing to rebuild, and a dependency that has to be exactly right to be correct is a worse
|
|
90
|
+
// trade than the work it saves.
|
|
91
|
+
const people = new Map<string, { name: string; isMachine: boolean }>();
|
|
92
|
+
for (const query of nameQueries) {
|
|
93
|
+
for (const person of query.data?.items ?? []) {
|
|
94
|
+
people.set(person.id, { name: person.name, isMachine: person.isMachine });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ⚠️ Filtering people out here can empty a whole page while older ones still hold cards, so the
|
|
99
|
+
// sentinel below has to keep asking rather than stopping at the first empty result.
|
|
100
|
+
const shown =
|
|
101
|
+
audience === "people"
|
|
102
|
+
? events.filter((event) => people.get(event.actorId)?.isMachine !== true)
|
|
103
|
+
: events;
|
|
104
|
+
|
|
105
|
+
const sentinel = useRef<HTMLDivElement | null>(null);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const element = sentinel.current;
|
|
108
|
+
if (!element) return;
|
|
109
|
+
const observer = new IntersectionObserver((entries) => {
|
|
110
|
+
// `hasNextPage` and `isFetchingNextPage` are read inside the callback rather than closed over
|
|
111
|
+
// at setup: the observer outlives several renders, and a stale `false` would end the scroll
|
|
112
|
+
// silently at whatever page happened to be loaded when it was created.
|
|
113
|
+
if (entries.some((entry) => entry.isIntersecting)) void feed.fetchNextPage();
|
|
114
|
+
});
|
|
115
|
+
observer.observe(element);
|
|
116
|
+
return () => observer.disconnect();
|
|
117
|
+
}, [feed.fetchNextPage]);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
121
|
+
{/* The rule belongs to the scroll state, and the width is set from the start so that showing
|
|
122
|
+
it moves nothing (#741). */}
|
|
123
|
+
<div
|
|
124
|
+
data-slot="pane-header"
|
|
125
|
+
className={cn(
|
|
126
|
+
"flex items-start justify-between gap-4 border-b border-transparent px-6 py-4 transition-colors motion-reduce:transition-none",
|
|
127
|
+
scrolled && "border-border",
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
<div>
|
|
131
|
+
<h1 className="text-lg font-medium text-foreground">{i18n.t("feed.title")}</h1>
|
|
132
|
+
<p className="mt-1 max-w-prose text-sm text-muted-foreground">
|
|
133
|
+
{i18n.t("feed.description")}
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
<DropdownMenu>
|
|
137
|
+
<DropdownMenuTrigger
|
|
138
|
+
aria-label={i18n.t("feed.filter")}
|
|
139
|
+
className="rounded-md p-1 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
140
|
+
>
|
|
141
|
+
<Filter aria-hidden="true" className="size-4" />
|
|
142
|
+
</DropdownMenuTrigger>
|
|
143
|
+
<DropdownMenuContent align="end">
|
|
144
|
+
<DropdownMenuLabel>{i18n.t("feed.filter")}</DropdownMenuLabel>
|
|
145
|
+
{(["all", "mine", "people"] as const).map((option) => (
|
|
146
|
+
<DropdownMenuItem
|
|
147
|
+
key={option}
|
|
148
|
+
onSelect={() => setAudience(option)}
|
|
149
|
+
className="gap-2"
|
|
150
|
+
data-slot={`feed-audience-${option}`}
|
|
151
|
+
aria-current={audience === option}
|
|
152
|
+
>
|
|
153
|
+
<Check
|
|
154
|
+
aria-hidden="true"
|
|
155
|
+
className={cn("size-4", audience !== option && "invisible")}
|
|
156
|
+
/>
|
|
157
|
+
{i18n.t(`feed.audience.${option}`)}
|
|
158
|
+
</DropdownMenuItem>
|
|
159
|
+
))}
|
|
160
|
+
</DropdownMenuContent>
|
|
161
|
+
</DropdownMenu>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div
|
|
165
|
+
data-slot="pane-body"
|
|
166
|
+
onScroll={(event) => setScrolled(event.currentTarget.scrollTop > 0)}
|
|
167
|
+
className="min-h-0 flex-1 overflow-y-auto px-6 py-4"
|
|
168
|
+
>
|
|
169
|
+
<div className="mx-auto max-w-3xl">
|
|
170
|
+
{feed.isPending && (
|
|
171
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
172
|
+
)}
|
|
173
|
+
{feed.isError && (
|
|
174
|
+
<div role="alert" className="space-y-3 text-sm">
|
|
175
|
+
<p className="text-destructive">{i18n.t("feed.failed")}</p>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
onClick={() => void feed.refetch()}
|
|
179
|
+
className="rounded-md border px-3 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
180
|
+
>
|
|
181
|
+
{i18n.t("common.retry")}
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
{feed.data && shown.length === 0 && (
|
|
186
|
+
<div className="space-y-2 text-sm text-muted-foreground">
|
|
187
|
+
<p>{i18n.t("feed.empty")}</p>
|
|
188
|
+
{/* ⚠️ Two states look identical and are not, so the second one is said out loud. A
|
|
189
|
+
feed showing only yourself is what sharing nothing with anybody looks like, and a
|
|
190
|
+
reader who takes it for a fault will go looking for one that is not there. */}
|
|
191
|
+
<p>{i18n.t("feed.emptyWhy")}</p>
|
|
192
|
+
{/* ⚠️ The SECOND state the ticket names, and it is not an empty list: it is a gap
|
|
193
|
+
inside a list that looks complete. Somebody whose read access to a folder was
|
|
194
|
+
withdrawn loses their OWN older entries from it, and without this sentence the
|
|
195
|
+
next reader goes looking for a fault that is not there — or loosens the filter
|
|
196
|
+
and opens the authorization while "repairing" it. */}
|
|
197
|
+
<p>{i18n.t("feed.gapWhy")}</p>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
{shown.length > 0 && (
|
|
201
|
+
<ul className="flex flex-col gap-2">
|
|
202
|
+
{shown.map((event) => (
|
|
203
|
+
<FeedCard
|
|
204
|
+
key={event.id}
|
|
205
|
+
event={event}
|
|
206
|
+
person={people.get(event.actorId) ?? null}
|
|
207
|
+
isMe={event.actorId === me?.id}
|
|
208
|
+
open={(nodeId) => void navigate({ to: "/nodes", search: { select: nodeId } })}
|
|
209
|
+
/>
|
|
210
|
+
))}
|
|
211
|
+
</ul>
|
|
212
|
+
)}
|
|
213
|
+
{/* The end of the list, and what keeps it going. There is no button: the answer to "is
|
|
214
|
+
there more" is the cursor, and the observer above asks for it. */}
|
|
215
|
+
<div ref={sentinel} aria-hidden="true" className="h-8" />
|
|
216
|
+
{feed.isFetchingNextPage && (
|
|
217
|
+
<p className="py-2 text-center text-xs text-muted-foreground">
|
|
218
|
+
{i18n.t("common.loading")}
|
|
219
|
+
</p>
|
|
220
|
+
)}
|
|
221
|
+
{feed.data && !feed.hasNextPage && shown.length > 0 && (
|
|
222
|
+
<div className="space-y-1 py-2 text-center text-xs text-muted-foreground">
|
|
223
|
+
<p>{i18n.t("feed.end")}</p>
|
|
224
|
+
{/* The same sentence at the other end: this is where somebody notices that an older
|
|
225
|
+
entry of their own is missing, and where the reason belongs. */}
|
|
226
|
+
<p>{i18n.t("feed.gapWhy")}</p>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function FeedCard({
|
|
236
|
+
event,
|
|
237
|
+
person,
|
|
238
|
+
isMe,
|
|
239
|
+
open,
|
|
240
|
+
}: {
|
|
241
|
+
event: FeedEvent;
|
|
242
|
+
person: { name: string; isMachine: boolean } | null;
|
|
243
|
+
isMe: boolean;
|
|
244
|
+
open(nodeId: string): void;
|
|
245
|
+
}) {
|
|
246
|
+
const i18n = useI18n();
|
|
247
|
+
// ⚠️ Never the raw id. A person we cannot name is "a user account", which is the wording every
|
|
248
|
+
// other surface uses for the same gap (#258) — an id under a name is the question again in
|
|
249
|
+
// smaller type.
|
|
250
|
+
const who = isMe ? i18n.t("feed.you") : (person?.name ?? i18n.t("node.someUser"));
|
|
251
|
+
// What the button on this card says, and it is not always a version. A `node.save` records
|
|
252
|
+
// `{versionId, sequence}`, a `node.share` records `{principalType, verb}` — so a sharing card
|
|
253
|
+
// names the RIGHT that was granted. Both lead to the node: there is no route to a single version
|
|
254
|
+
// in this interface, and the access summary lives on the node itself (#742).
|
|
255
|
+
const sequence = typeof event.metadata.sequence === "number" ? event.metadata.sequence : null;
|
|
256
|
+
const verb = typeof event.metadata.verb === "string" ? event.metadata.verb : null;
|
|
257
|
+
const mark =
|
|
258
|
+
sequence !== null
|
|
259
|
+
? i18n.t("feed.version", { sequence: String(sequence) })
|
|
260
|
+
: verb !== null
|
|
261
|
+
? i18n.t(`node.verb.${verb}`)
|
|
262
|
+
: null;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<li className="flex items-start gap-3 rounded-lg bg-muted/40 px-4 py-3">
|
|
266
|
+
<span
|
|
267
|
+
aria-hidden="true"
|
|
268
|
+
className={cn(
|
|
269
|
+
"grid size-8 shrink-0 place-items-center text-xs font-medium",
|
|
270
|
+
// A square for an agent, a circle for a person: the two are told apart before either
|
|
271
|
+
// name is read.
|
|
272
|
+
person?.isMachine
|
|
273
|
+
? "rounded-md bg-accent text-accent-foreground"
|
|
274
|
+
: "rounded-full bg-secondary text-secondary-foreground",
|
|
275
|
+
)}
|
|
276
|
+
>
|
|
277
|
+
{initials(person?.name ?? who)}
|
|
278
|
+
</span>
|
|
279
|
+
<span className="grid min-w-0 flex-1 gap-1">
|
|
280
|
+
<span className="text-sm leading-snug">
|
|
281
|
+
{i18n.t(`feed.action.${event.action}`, { who, title: event.nodeTitle })}
|
|
282
|
+
</span>
|
|
283
|
+
<span className="flex items-center justify-between gap-3">
|
|
284
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
285
|
+
{event.path.map((step) => step.title).join(" / ")}
|
|
286
|
+
</span>
|
|
287
|
+
{/* ⚠️ A button, not a label. The whole point of the mark is getting there, and a span
|
|
288
|
+
carries no focus — so the keyboard requirement could not be met by a surface that had
|
|
289
|
+
nothing to focus at all.
|
|
290
|
+
|
|
291
|
+
⚠️ It opens the NODE in both cases. There is no route to a single version here, and
|
|
292
|
+
the access summary lives on the node; the mark still says WHICH version or WHICH
|
|
293
|
+
right the card was about, which is the part the reader came for. */}
|
|
294
|
+
{mark !== null && (
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
onClick={() => open(event.nodeId)}
|
|
298
|
+
aria-label={i18n.t("feed.open", { title: event.nodeTitle, mark })}
|
|
299
|
+
className="shrink-0 rounded bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
300
|
+
>
|
|
301
|
+
{mark}
|
|
302
|
+
</button>
|
|
303
|
+
)}
|
|
304
|
+
</span>
|
|
305
|
+
{/* The shared component, not a second way of saying the same thing: it carries the exact
|
|
306
|
+
moment in a tooltip that is reachable by keyboard, and "19 hours ago" is not something
|
|
307
|
+
anybody translates back into a time of day. */}
|
|
308
|
+
<RelativeTime value={event.occurredAt} className="text-xs text-muted-foreground" />
|
|
309
|
+
</span>
|
|
310
|
+
</li>
|
|
311
|
+
);
|
|
312
|
+
}
|
package/src/i18n/de.json
CHANGED
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"board.addCardIn": "Eine Aufgabe zu {column} hinzufügen",
|
|
85
85
|
"board.addLabel": "Label hinzufügen",
|
|
86
86
|
"board.addLink": "Verlinkung",
|
|
87
|
+
"board.assignSomebody": "Jemandem zuweisen",
|
|
87
88
|
"board.assignee.clear": "Zuständigkeit entfernen",
|
|
88
89
|
"board.assignee.keepTyping": "Mindestens zwei Zeichen tippen",
|
|
89
90
|
"board.assignee.nobody": "Niemand mit Zugriff auf dieses Board passt dazu",
|
|
@@ -110,6 +111,7 @@
|
|
|
110
111
|
"board.createFailed": "Die Aufgabe konnte nicht angelegt werden.",
|
|
111
112
|
"board.crumbs": "Wo diese Karte sitzt",
|
|
112
113
|
"board.crumbsFolded": "Die Ebenen dazwischen zeigen",
|
|
114
|
+
"board.dates.edit": "Zeitraum bearbeiten",
|
|
113
115
|
"board.drag.lifted": "{card} aufgenommen",
|
|
114
116
|
"board.drag.moved": "{card} nach {column} verschoben",
|
|
115
117
|
"board.drag.putBack": "{card} zurückgelegt",
|
|
@@ -127,7 +129,6 @@
|
|
|
127
129
|
"board.link.subtask": "Unteraufgabe",
|
|
128
130
|
"board.links": "Verlinkt",
|
|
129
131
|
"board.moveTo": "in eine andere Spalte verschieben",
|
|
130
|
-
"board.progress": "{done} von {total} erledigt",
|
|
131
132
|
"board.removeLabel": "dieses Label entfernen",
|
|
132
133
|
"board.settings.addColumn": "+ Spalte",
|
|
133
134
|
"board.settings.columnName": "Name der Spalte {column}",
|
|
@@ -179,6 +180,27 @@
|
|
|
179
180
|
"common.unsavedLeave": "Ohne Speichern verlassen",
|
|
180
181
|
"common.unsavedStay": "Bleiben und speichern",
|
|
181
182
|
"common.unsavedTitle": "Ungespeicherte Änderungen",
|
|
183
|
+
"feed.action.node.append": "{who} hat etwas an {title} angehängt",
|
|
184
|
+
"feed.action.node.archive": "{who} hat {title} archiviert",
|
|
185
|
+
"feed.action.node.create": "{who} hat {title} angelegt",
|
|
186
|
+
"feed.action.node.revoke": "{who} hat eine Freigabe für {title} entzogen",
|
|
187
|
+
"feed.action.node.save": "{who} hat {title} geändert",
|
|
188
|
+
"feed.action.node.share": "{who} hat {title} freigegeben",
|
|
189
|
+
"feed.action.node.update": "{who} hat {title} umbenannt",
|
|
190
|
+
"feed.audience.all": "Alle",
|
|
191
|
+
"feed.audience.mine": "Nur ich",
|
|
192
|
+
"feed.audience.people": "Ohne Agenten",
|
|
193
|
+
"feed.description": "Was zuletzt passiert ist, im Umfang deiner Freigaben.",
|
|
194
|
+
"feed.empty": "Hier ist noch nichts.",
|
|
195
|
+
"feed.emptyWhy": "Der Feed zeigt nur, was an Knoten passiert, die du lesen darfst. Teilst du mit niemandem eine Freigabe, stehst nur du selbst darin.",
|
|
196
|
+
"feed.end": "Das war alles.",
|
|
197
|
+
"feed.failed": "Der Feed konnte nicht geladen werden.",
|
|
198
|
+
"feed.filter": "Wen zeigen",
|
|
199
|
+
"feed.gapWhy": "Wurde dir das Leserecht auf einen Ordner entzogen, fehlen von dort auch deine eigenen älteren Einträge.",
|
|
200
|
+
"feed.open": "{title} öffnen ({mark})",
|
|
201
|
+
"feed.title": "Feed",
|
|
202
|
+
"feed.version": "v{sequence}",
|
|
203
|
+
"feed.you": "Du",
|
|
182
204
|
"flow.shareUnreadable": "Diese Freigabe deckt nicht ab, was dieser Flow liest: {titles}.",
|
|
183
205
|
"flow.shareUnreadableHidden": "Diese Freigabe deckt nicht alles ab, was dieser Flow liest: {count} davon kannst du nicht sehen.",
|
|
184
206
|
"flow.shareUnreadableHint": "Nichts ist blockiert. Ein Lauf hält für sie schlicht an dieser Stelle an.",
|
package/src/i18n/en.json
CHANGED
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"board.addCardIn": "Add a task to {column}",
|
|
85
85
|
"board.addLabel": "Add a label",
|
|
86
86
|
"board.addLink": "Link",
|
|
87
|
+
"board.assignSomebody": "Assign to somebody",
|
|
87
88
|
"board.assignee.clear": "Remove the assignee",
|
|
88
89
|
"board.assignee.keepTyping": "Type at least two characters",
|
|
89
90
|
"board.assignee.nobody": "Nobody with access to this board matches",
|
|
@@ -110,6 +111,7 @@
|
|
|
110
111
|
"board.createFailed": "The task could not be created.",
|
|
111
112
|
"board.crumbs": "Where this card sits",
|
|
112
113
|
"board.crumbsFolded": "Show the levels in between",
|
|
114
|
+
"board.dates.edit": "Edit the dates",
|
|
113
115
|
"board.drag.lifted": "{card} picked up",
|
|
114
116
|
"board.drag.moved": "{card} moved to {column}",
|
|
115
117
|
"board.drag.putBack": "{card} put back",
|
|
@@ -127,7 +129,6 @@
|
|
|
127
129
|
"board.link.subtask": "Subtask",
|
|
128
130
|
"board.links": "Linked",
|
|
129
131
|
"board.moveTo": "move it to another column",
|
|
130
|
-
"board.progress": "{done} of {total} done",
|
|
131
132
|
"board.removeLabel": "remove this label",
|
|
132
133
|
"board.settings.addColumn": "+ Column",
|
|
133
134
|
"board.settings.columnName": "Name of the column {column}",
|
|
@@ -179,6 +180,27 @@
|
|
|
179
180
|
"common.unsavedLeave": "Leave without saving",
|
|
180
181
|
"common.unsavedStay": "Stay and save",
|
|
181
182
|
"common.unsavedTitle": "Unsaved changes",
|
|
183
|
+
"feed.action.node.append": "{who} appended to {title}",
|
|
184
|
+
"feed.action.node.archive": "{who} archived {title}",
|
|
185
|
+
"feed.action.node.create": "{who} created {title}",
|
|
186
|
+
"feed.action.node.revoke": "{who} withdrew access to {title}",
|
|
187
|
+
"feed.action.node.save": "{who} changed {title}",
|
|
188
|
+
"feed.action.node.share": "{who} shared {title}",
|
|
189
|
+
"feed.action.node.update": "{who} renamed {title}",
|
|
190
|
+
"feed.audience.all": "Everyone",
|
|
191
|
+
"feed.audience.mine": "Only me",
|
|
192
|
+
"feed.audience.people": "Without agents",
|
|
193
|
+
"feed.description": "What happened recently, as far as your access reaches.",
|
|
194
|
+
"feed.empty": "Nothing here yet.",
|
|
195
|
+
"feed.emptyWhy": "The feed only shows what happens to nodes you may read. If you share access with nobody, only your own work appears.",
|
|
196
|
+
"feed.end": "That is everything.",
|
|
197
|
+
"feed.failed": "The feed could not be loaded.",
|
|
198
|
+
"feed.filter": "Who to show",
|
|
199
|
+
"feed.gapWhy": "If your read access to a folder was withdrawn, your own older entries from it are missing too.",
|
|
200
|
+
"feed.open": "Open {title} ({mark})",
|
|
201
|
+
"feed.title": "Feed",
|
|
202
|
+
"feed.version": "v{sequence}",
|
|
203
|
+
"feed.you": "You",
|
|
182
204
|
"flow.shareUnreadable": "This grant does not cover what this flow reads: {titles}.",
|
|
183
205
|
"flow.shareUnreadableHidden": "This grant does not cover everything this flow reads: {count} you cannot see.",
|
|
184
206
|
"flow.shareUnreadableHint": "Nothing is blocked. A run will simply stop at that step for them.",
|
package/src/i18n/es.json
CHANGED
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"board.addCardIn": "Añadir una tarea a {column}",
|
|
85
85
|
"board.addLabel": "Añadir etiqueta",
|
|
86
86
|
"board.addLink": "Vínculo",
|
|
87
|
+
"board.assignSomebody": "Asignar a alguien",
|
|
87
88
|
"board.assignee.clear": "Quitar el responsable",
|
|
88
89
|
"board.assignee.keepTyping": "Escribe al menos dos caracteres",
|
|
89
90
|
"board.assignee.nobody": "Nadie con acceso a este tablero coincide",
|
|
@@ -110,6 +111,7 @@
|
|
|
110
111
|
"board.createFailed": "No se pudo crear la tarea.",
|
|
111
112
|
"board.crumbs": "Dónde está esta tarjeta",
|
|
112
113
|
"board.crumbsFolded": "Mostrar los niveles intermedios",
|
|
114
|
+
"board.dates.edit": "Editar las fechas",
|
|
113
115
|
"board.drag.lifted": "{card} recogida",
|
|
114
116
|
"board.drag.moved": "{card} movida a {column}",
|
|
115
117
|
"board.drag.putBack": "{card} devuelta",
|
|
@@ -127,7 +129,6 @@
|
|
|
127
129
|
"board.link.subtask": "Subtarea",
|
|
128
130
|
"board.links": "Vinculado",
|
|
129
131
|
"board.moveTo": "moverla a otra columna",
|
|
130
|
-
"board.progress": "{done} de {total} hechas",
|
|
131
132
|
"board.removeLabel": "quitar esta etiqueta",
|
|
132
133
|
"board.settings.addColumn": "+ Columna",
|
|
133
134
|
"board.settings.columnName": "Nombre de la columna {column}",
|
|
@@ -179,6 +180,27 @@
|
|
|
179
180
|
"common.unsavedLeave": "Salir sin guardar",
|
|
180
181
|
"common.unsavedStay": "Quedarse y guardar",
|
|
181
182
|
"common.unsavedTitle": "Cambios sin guardar",
|
|
183
|
+
"feed.action.node.append": "{who} añadió algo a {title}",
|
|
184
|
+
"feed.action.node.archive": "{who} archivó {title}",
|
|
185
|
+
"feed.action.node.create": "{who} creó {title}",
|
|
186
|
+
"feed.action.node.revoke": "{who} retiró el acceso a {title}",
|
|
187
|
+
"feed.action.node.save": "{who} cambió {title}",
|
|
188
|
+
"feed.action.node.share": "{who} compartió {title}",
|
|
189
|
+
"feed.action.node.update": "{who} renombró {title}",
|
|
190
|
+
"feed.audience.all": "Todos",
|
|
191
|
+
"feed.audience.mine": "Solo yo",
|
|
192
|
+
"feed.audience.people": "Sin agentes",
|
|
193
|
+
"feed.description": "Lo que ha pasado, hasta donde llega tu acceso.",
|
|
194
|
+
"feed.empty": "Aquí todavía no hay nada.",
|
|
195
|
+
"feed.emptyWhy": "La actividad solo muestra lo que ocurre en nodos que puedes leer. Si no compartes acceso con nadie, solo aparece tu propio trabajo.",
|
|
196
|
+
"feed.end": "Eso es todo.",
|
|
197
|
+
"feed.failed": "No se pudo cargar la actividad.",
|
|
198
|
+
"feed.filter": "A quién mostrar",
|
|
199
|
+
"feed.gapWhy": "Si te retiraron el acceso de lectura a una carpeta, tus propias entradas antiguas de allí también faltan.",
|
|
200
|
+
"feed.open": "Abrir {title} ({mark})",
|
|
201
|
+
"feed.title": "Actividad",
|
|
202
|
+
"feed.version": "v{sequence}",
|
|
203
|
+
"feed.you": "Tú",
|
|
182
204
|
"flow.shareUnreadable": "Este acceso no cubre lo que lee este flujo: {titles}.",
|
|
183
205
|
"flow.shareUnreadableHidden": "Este acceso no cubre todo lo que lee este flujo: {count} que no puedes ver.",
|
|
184
206
|
"flow.shareUnreadableHint": "No se bloquea nada. Una ejecución simplemente se detendrá en ese paso para esa persona.",
|
package/src/router/router.tsx
CHANGED
|
@@ -27,6 +27,12 @@ const flowsRoute = createRoute({
|
|
|
27
27
|
validateSearch: selectionSearch,
|
|
28
28
|
component: lazyRouteComponent(() => import("@/flows/flows.tsx"), "Flows"),
|
|
29
29
|
});
|
|
30
|
+
const feedRoute = createRoute({
|
|
31
|
+
getParentRoute: () => rootRoute,
|
|
32
|
+
path: "/feed",
|
|
33
|
+
component: lazyRouteComponent(() => import("@/feed/feed.tsx"), "Feed"),
|
|
34
|
+
});
|
|
35
|
+
|
|
30
36
|
const archiveRoute = createRoute({
|
|
31
37
|
getParentRoute: () => rootRoute,
|
|
32
38
|
path: "/archive",
|
|
@@ -44,6 +50,7 @@ const routeTree = rootRoute.addChildren([
|
|
|
44
50
|
nodesRoute,
|
|
45
51
|
flowsRoute,
|
|
46
52
|
toolsRoute,
|
|
53
|
+
feedRoute,
|
|
47
54
|
archiveRoute,
|
|
48
55
|
]);
|
|
49
56
|
|
|
@@ -121,7 +121,12 @@ export function TitleRow({
|
|
|
121
121
|
<div
|
|
122
122
|
data-scrolled={scroll?.scrolled || undefined}
|
|
123
123
|
className={cn(
|
|
124
|
-
"relative z-10 flex shrink-0
|
|
124
|
+
"relative z-10 flex shrink-0 justify-between gap-5 px-6 py-4 transition-shadow",
|
|
125
|
+
// ⚠️ Two alignments for two shapes (#724). A document's name can carry a description under
|
|
126
|
+
// it, so its block starts at the top and the menu lines up with the FIRST line. A path is
|
|
127
|
+
// always one line, and starting it at the top leaves it sitting a hair above the icons it
|
|
128
|
+
// shares the row with. Centred, the two read as one line, which is what they are.
|
|
129
|
+
crumbs === undefined ? "items-start" : "items-center",
|
|
125
130
|
(separated || scroll?.scrolled) && "shadow-[inset_0_-1px_0_var(--border)]",
|
|
126
131
|
)}
|
|
127
132
|
>
|