@anchrd/intel-ui 0.44.0 → 0.46.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 +1 -1
- package/src/app/tree-move/tree-move.tsx +10 -2
- package/src/attachment-viewer/attachment-viewer.tsx +2 -2
- package/src/board/board-assignee/board-assignee-picker.tsx +21 -7
- package/src/board/board-assignee/board-assignee.ts +21 -0
- package/src/board/board-assignee/board-assignee.tsx +52 -22
- 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-table/board-table.tsx +5 -1
- package/src/board/board-task/board-task.ts +0 -7
- package/src/board/board-task/board-task.tsx +45 -37
- package/src/file-preview/file-preview-view.tsx +118 -18
- package/src/i18n/de.json +4 -1
- package/src/i18n/en.json +4 -1
- package/src/i18n/es.json +4 -1
- package/src/title-row/title-row.tsx +6 -1
package/package.json
CHANGED
|
@@ -131,10 +131,16 @@ export function moveVerdict(input: {
|
|
|
131
131
|
return "ok";
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
// ⚠️
|
|
134
|
+
// ⚠️ Five refusals, five sentences. The service tells them apart by `code`
|
|
135
135
|
// (`nodes.ts:331/335/338/361`, `flows.ts:205–217/752`), and so does this: one message for all
|
|
136
136
|
// of them would leave the reader guessing which of "you may not write there", "that is not a
|
|
137
|
-
// folder", "that would be a loop" and "somebody else was faster"
|
|
137
|
+
// folder", "that would be a loop", "that destination is archived" and "somebody else was faster"
|
|
138
|
+
// they have just hit.
|
|
139
|
+
//
|
|
140
|
+
// ⚠️ `parent_archived` reaches this since #679: before that the service raised it for tasks only,
|
|
141
|
+
// which never travel through the tree's move. The board is not a drop target for an archived node,
|
|
142
|
+
// so a person meets it rarely — but a move over MCP or HTTP hits it, and the tree reloads from the
|
|
143
|
+
// same error.
|
|
138
144
|
export function moveErrorKey(error: unknown): string {
|
|
139
145
|
const code =
|
|
140
146
|
typeof error === "object" && error !== null && "code" in error
|
|
@@ -146,6 +152,8 @@ export function moveErrorKey(error: unknown): string {
|
|
|
146
152
|
return "tree.move.failed.forbidden";
|
|
147
153
|
case "parent_not_folder":
|
|
148
154
|
return "tree.move.failed.notFolder";
|
|
155
|
+
case "parent_archived":
|
|
156
|
+
return "tree.move.failed.archived";
|
|
149
157
|
case "move_cycle":
|
|
150
158
|
return "tree.move.failed.cycle";
|
|
151
159
|
case "update_conflict":
|
|
@@ -203,8 +203,8 @@ function ImagePreview({ file, title }: { file: File; title: string }) {
|
|
|
203
203
|
return () => URL.revokeObjectURL(next);
|
|
204
204
|
}, [file]);
|
|
205
205
|
return url ? (
|
|
206
|
-
<div className="grid h-full min-h-64 place-items-center overflow-auto
|
|
207
|
-
<img src={url} alt={title} className="max-h-full max-w-full object-contain" />
|
|
206
|
+
<div className="grid h-full min-h-64 place-items-center overflow-auto">
|
|
207
|
+
<img src={url} alt={title} className="h-auto w-auto max-h-full max-w-full object-contain" />
|
|
208
208
|
</div>
|
|
209
209
|
) : null;
|
|
210
210
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
DropdownMenu,
|
|
5
5
|
DropdownMenuContent,
|
|
6
6
|
DropdownMenuItem,
|
|
7
7
|
DropdownMenuSeparator,
|
|
8
|
+
DropdownMenuTrigger,
|
|
8
9
|
} from "@/components/ui/dropdown-menu.tsx";
|
|
9
10
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
10
11
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -24,12 +25,21 @@ export function AssigneePicker({
|
|
|
24
25
|
boardId,
|
|
25
26
|
current,
|
|
26
27
|
onPick,
|
|
27
|
-
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
children,
|
|
28
31
|
}: {
|
|
29
32
|
boardId: string;
|
|
30
33
|
current: string | null;
|
|
31
34
|
onPick(assigneeId: string | null): void;
|
|
32
|
-
|
|
35
|
+
// Controlled only where somebody else decides when it opens — the plus menu does, the chip does
|
|
36
|
+
// not. Left out, the trigger below governs it on its own.
|
|
37
|
+
open?: boolean;
|
|
38
|
+
onOpenChange?(next: boolean): void;
|
|
39
|
+
// ⚠️ THE ANCHOR, and it is not optional decoration (#723). Radix positions a menu against its
|
|
40
|
+
// trigger; the first version of this component had none, and the menu never appeared. Every test
|
|
41
|
+
// stayed green, because jsdom has no layout and Testing Library finds a portal wherever it sits.
|
|
42
|
+
children: ReactNode;
|
|
33
43
|
}) {
|
|
34
44
|
const i18n = useI18n();
|
|
35
45
|
const { data } = useIntelRouterContext();
|
|
@@ -43,7 +53,11 @@ export function AssigneePicker({
|
|
|
43
53
|
const found = search.data?.items ?? [];
|
|
44
54
|
|
|
45
55
|
return (
|
|
46
|
-
<DropdownMenu
|
|
56
|
+
<DropdownMenu
|
|
57
|
+
{...(open === undefined ? {} : { open })}
|
|
58
|
+
{...(onOpenChange === undefined ? {} : { onOpenChange })}
|
|
59
|
+
>
|
|
60
|
+
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
|
47
61
|
<DropdownMenuContent align="start" className="w-64">
|
|
48
62
|
<div className="p-1">
|
|
49
63
|
<input
|
|
@@ -53,7 +67,7 @@ export function AssigneePicker({
|
|
|
53
67
|
aria-label={i18n.t("board.assignee.search")}
|
|
54
68
|
placeholder={i18n.t("board.assignee.search")}
|
|
55
69
|
onChange={(event) => setQuery(event.target.value)}
|
|
56
|
-
onKeyDown={(event) => event.key === "Escape" &&
|
|
70
|
+
onKeyDown={(event) => event.key === "Escape" && onOpenChange?.(false)}
|
|
57
71
|
className="w-full rounded-md border bg-background px-2 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
58
72
|
/>
|
|
59
73
|
</div>
|
|
@@ -61,7 +75,7 @@ export function AssigneePicker({
|
|
|
61
75
|
<>
|
|
62
76
|
<DropdownMenuItem
|
|
63
77
|
onSelect={() => {
|
|
64
|
-
|
|
78
|
+
onOpenChange?.(false);
|
|
65
79
|
onPick(null);
|
|
66
80
|
}}
|
|
67
81
|
>
|
|
@@ -84,7 +98,7 @@ export function AssigneePicker({
|
|
|
84
98
|
<DropdownMenuItem
|
|
85
99
|
key={person.id}
|
|
86
100
|
onSelect={() => {
|
|
87
|
-
|
|
101
|
+
onOpenChange?.(false);
|
|
88
102
|
onPick(person.id);
|
|
89
103
|
}}
|
|
90
104
|
>
|
|
@@ -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,9 +1,12 @@
|
|
|
1
|
+
import { UserRound } from "lucide-react";
|
|
2
|
+
import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
|
|
1
3
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
2
5
|
import { initials } from "@/user-name/user-name.ts";
|
|
3
|
-
import { useAssigneeLabel } from "./board-assignee.ts";
|
|
6
|
+
import { personTone, useAssigneeLabel } from "./board-assignee.ts";
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
|
-
* 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.
|
|
7
10
|
*
|
|
8
11
|
* ⚠️ **One truth for two surfaces.** The opened card and the table both draw it; written twice they
|
|
9
12
|
* would drift, and the drift would be an accessibility bug on exactly one of them.
|
|
@@ -11,35 +14,62 @@ import { useAssigneeLabel } from "./board-assignee.ts";
|
|
|
11
14
|
* ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed from the access
|
|
12
15
|
* summary, the field stays `assigneeId`. A row of circles would imply a second data model.
|
|
13
16
|
*
|
|
14
|
-
* ⚠️ **
|
|
15
|
-
* 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.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **The circle OPENS the picker; it no longer clears on click** (#723). A control that
|
|
23
|
+
* removes an assignment on the same click somebody uses to change one is a control that
|
|
24
|
+
* destroys work on a misclick. Removing lives inside the menu, where it says what it does.
|
|
16
25
|
*
|
|
17
26
|
* ⚠️ **The NAME lives on the button, and the circle is hidden.** Children of a `<button>` are
|
|
18
27
|
* presentational in ARIA, so a label inside one is never announced and the button's own name wins.
|
|
19
28
|
* Wrapping the circle in a control therefore takes the person out of the accessibility tree unless
|
|
20
29
|
* the control says it too. That is the trap in `packages/ui/CLAUDE.md`.
|
|
21
30
|
*/
|
|
22
|
-
export function AssigneeChip({
|
|
31
|
+
export function AssigneeChip({
|
|
32
|
+
id,
|
|
33
|
+
boardId,
|
|
34
|
+
onPick,
|
|
35
|
+
}: {
|
|
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;
|
|
42
|
+
boardId: string;
|
|
43
|
+
onPick(assigneeId: string | null): void;
|
|
44
|
+
}) {
|
|
23
45
|
const i18n = useI18n();
|
|
24
46
|
const label = useAssigneeLabel(id);
|
|
25
47
|
return (
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// label back would not add a second announcement, it would add none, while making the code
|
|
37
|
-
// look as though the name were covered here rather than on the button.
|
|
38
|
-
aria-hidden="true"
|
|
39
|
-
className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
|
|
48
|
+
<AssigneePicker boardId={boardId} current={id} onPick={onPick}>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
aria-label={
|
|
52
|
+
id === null
|
|
53
|
+
? i18n.t("board.assignSomebody")
|
|
54
|
+
: i18n.t("board.changeAssignee", { name: label })
|
|
55
|
+
}
|
|
56
|
+
title={label}
|
|
57
|
+
className="shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
40
58
|
>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
<span
|
|
60
|
+
// ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
|
|
61
|
+
// person's name, and that reached nobody: it sits inside a button. Giving it a role and a
|
|
62
|
+
// label back would not add a second announcement, it would add none, while making the code
|
|
63
|
+
// look as though the name were covered here rather than on the button.
|
|
64
|
+
aria-hidden="true"
|
|
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
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
{id === null ? <UserRound aria-hidden="true" className="size-4" /> : initials(label)}
|
|
71
|
+
</span>
|
|
72
|
+
</button>
|
|
73
|
+
</AssigneePicker>
|
|
44
74
|
);
|
|
45
75
|
}
|
|
@@ -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
|
+
}
|
|
@@ -599,7 +599,11 @@ function RowContent({
|
|
|
599
599
|
) : null}
|
|
600
600
|
|
|
601
601
|
{visible.has("assignee") && task.assigneeId !== null ? (
|
|
602
|
-
<AssigneeChip
|
|
602
|
+
<AssigneeChip
|
|
603
|
+
id={task.assigneeId}
|
|
604
|
+
boardId={board.boardId}
|
|
605
|
+
onPick={(assigneeId) => write({ assigneeId })}
|
|
606
|
+
/>
|
|
603
607
|
) : null}
|
|
604
608
|
</span>
|
|
605
609
|
);
|
|
@@ -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,39 +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 id={task.assigneeId} clear={() => write({ assigneeId: null })} />
|
|
136
|
-
)}
|
|
137
|
-
|
|
138
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
|
+
/>
|
|
139
136
|
</div>
|
|
140
137
|
|
|
141
138
|
{/* ⚠️ ONE list, not three blocks: the icon carries the kind. And it exists only when there is
|
|
@@ -143,14 +140,11 @@ export function BoardTaskDocument({
|
|
|
143
140
|
is missing; nothing at all tells them there is nothing, which is the truth (#692). */}
|
|
144
141
|
{links.length === 0 ? null : (
|
|
145
142
|
<section aria-label={i18n.t("board.links")} className="flex flex-col gap-1">
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
</span>
|
|
152
|
-
)}
|
|
153
|
-
</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>
|
|
154
148
|
<ul className="flex flex-col">
|
|
155
149
|
{links.map((link) => (
|
|
156
150
|
<LinkRow key={`${link.kind}:${link.id}`} link={link} onOpen={onOpen} />
|
|
@@ -254,11 +248,14 @@ function Adder({
|
|
|
254
248
|
return (
|
|
255
249
|
<>
|
|
256
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. */}
|
|
257
254
|
<DropdownMenuTrigger
|
|
258
255
|
aria-label={i18n.t("board.add")}
|
|
259
|
-
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"
|
|
260
257
|
>
|
|
261
|
-
<Plus aria-hidden="true" className="size-
|
|
258
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
262
259
|
</DropdownMenuTrigger>
|
|
263
260
|
<DropdownMenuContent align="start">
|
|
264
261
|
{addable.map((entry) => (
|
|
@@ -279,8 +276,19 @@ function Adder({
|
|
|
279
276
|
boardId={board.boardId}
|
|
280
277
|
current={task.assigneeId}
|
|
281
278
|
onPick={(assigneeId) => write({ assigneeId })}
|
|
282
|
-
|
|
283
|
-
|
|
279
|
+
open
|
|
280
|
+
onOpenChange={(next) => !next && close()}
|
|
281
|
+
>
|
|
282
|
+
{/* ⚠️ The anchor. Opened from the plus menu there is nothing on screen to hang the menu
|
|
283
|
+
on, so the picker brings its own pill — the same shape the blocker branch below uses,
|
|
284
|
+
and for the same reason. */}
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
className="rounded-full border px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
288
|
+
>
|
|
289
|
+
{i18n.t("board.add.assignee")}
|
|
290
|
+
</button>
|
|
291
|
+
</AssigneePicker>
|
|
284
292
|
)}
|
|
285
293
|
|
|
286
294
|
{kind === null || kind === "blocker" || kind === "assignee" ? null : (
|
|
@@ -2,6 +2,7 @@ import FileViewer, {
|
|
|
2
2
|
type FileViewerHandle,
|
|
3
3
|
type ViewerOptions,
|
|
4
4
|
type ViewerState,
|
|
5
|
+
type ViewerViewState,
|
|
5
6
|
} from "@file-viewer/react";
|
|
6
7
|
import {
|
|
7
8
|
ChevronLeft,
|
|
@@ -35,15 +36,16 @@ const viewerTheme = `
|
|
|
35
36
|
}
|
|
36
37
|
.pdf-shell,.pdf-wrapper{background:var(--muted)!important;color:var(--foreground)!important}
|
|
37
38
|
.pdf-nav-pane,.pdf-nav-tabs,.pdf-nav-head{background:var(--background)!important;border-color:var(--border)!important}
|
|
38
|
-
@media (min-width:721px){.pdf-shell:not(.pdf-shell--nav-hidden) .pdf-content{grid-template-columns:
|
|
39
|
-
.
|
|
39
|
+
@media (min-width:721px){.pdf-shell:not(.pdf-shell--nav-hidden) .pdf-content{grid-template-columns:4rem minmax(0,1fr)!important}}
|
|
40
|
+
.pdfViewer{padding-inline:0!important}.pdfViewer .page{border-inline:0!important}
|
|
41
|
+
.pdf-nav-head{display:none!important}.pdf-nav-tabs{display:flex!important;justify-content:center;padding:4px 0!important}
|
|
40
42
|
.pdf-nav-tabs:not([data-outline-available="true"]){display:none!important}.pdf-nav-tabs button.active{display:none!important}
|
|
41
43
|
.pdf-nav-tabs button{width:30px;min-width:30px;padding:0;border:0!important;box-shadow:none!important;font-size:0;color:var(--muted-foreground)!important}
|
|
42
44
|
.pdf-nav-tabs button svg{width:15px;height:15px;margin:auto;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
43
45
|
.pdf-nav-tabs button:hover,.pdf-nav-tabs button.active{background:var(--accent)!important;border:0!important;color:var(--accent-foreground)!important}
|
|
44
|
-
.pdf-page-list{gap:
|
|
46
|
+
.pdf-page-list{gap:2px!important;padding:4px 0!important}.pdf-page-button{display:flex!important;width:28px!important;min-width:28px!important;height:28px!important;min-height:28px!important;margin-inline:auto!important;padding:0!important;border:0!important;box-shadow:none!important;background:transparent!important;color:var(--foreground)!important;justify-content:center!important}
|
|
45
47
|
.pdf-page-button:hover,.pdf-page-button--active{background:var(--accent)!important;border:0!important;box-shadow:none!important}
|
|
46
|
-
.pdf-page-thumb{width:
|
|
48
|
+
.pdf-page-thumb{width:24px!important;height:24px!important;border:0!important;background:transparent!important;color:var(--foreground)!important}
|
|
47
49
|
.pdf-page-label{display:none!important}.pdf-outline-button{color:var(--foreground)!important}.pdf-outline-button:hover{background:var(--accent)!important;border-color:var(--border)!important}
|
|
48
50
|
`;
|
|
49
51
|
|
|
@@ -60,6 +62,8 @@ export function FilePreviewView({
|
|
|
60
62
|
const i18n = useI18n();
|
|
61
63
|
const viewer = useRef<FileViewerHandle>(null);
|
|
62
64
|
const searchInput = useRef<HTMLInputElement>(null);
|
|
65
|
+
const pdfFit = useRef({ enabled: true, page: 0, pageWidthAtScaleOne: 0, rotation: 0 });
|
|
66
|
+
const schedulePdfFit = useRef<(() => void) | null>(null);
|
|
63
67
|
const [state, setState] = useState<ViewerState | null>(null);
|
|
64
68
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
65
69
|
const [query, setQuery] = useState("");
|
|
@@ -74,7 +78,10 @@ export function FilePreviewView({
|
|
|
74
78
|
locale: "auto",
|
|
75
79
|
toolbar: false,
|
|
76
80
|
...(pdf
|
|
77
|
-
? {
|
|
81
|
+
? {
|
|
82
|
+
fit: { mode: "width", resize: "until-interaction", padding: 0 },
|
|
83
|
+
pdf: { toolbar: false, navigation: true, defaultNavigationVisible: false },
|
|
84
|
+
}
|
|
78
85
|
: {}),
|
|
79
86
|
ui: { density: "compact", surfaceBackground: "transparent" },
|
|
80
87
|
}),
|
|
@@ -85,15 +92,28 @@ export function FilePreviewView({
|
|
|
85
92
|
const pageCount = viewState?.pageCount ?? 0;
|
|
86
93
|
const scale = state?.zoom?.scale ?? viewState?.scale ?? 1;
|
|
87
94
|
const search = state?.search;
|
|
95
|
+
const latestView = useRef({ page, rotation: viewState?.rotation ?? 0, scale });
|
|
96
|
+
const pdfPageRequest = useRef({ page, pending: false });
|
|
97
|
+
latestView.current = { page, rotation: viewState?.rotation ?? 0, scale };
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const request = pdfPageRequest.current;
|
|
101
|
+
if (!request.pending || request.page === page) {
|
|
102
|
+
request.page = page;
|
|
103
|
+
request.pending = false;
|
|
104
|
+
}
|
|
105
|
+
}, [page]);
|
|
88
106
|
|
|
89
107
|
useEffect(() => {
|
|
90
108
|
if (!state?.ready) return;
|
|
91
109
|
const root = viewer.current?.getController()?.container.shadowRoot;
|
|
92
|
-
if (!root
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
if (!root) return;
|
|
111
|
+
if (!root.querySelector("style[data-intel-viewer-theme]")) {
|
|
112
|
+
const style = document.createElement("style");
|
|
113
|
+
style.dataset.intelViewerTheme = "true";
|
|
114
|
+
style.textContent = viewerTheme;
|
|
115
|
+
root.append(style);
|
|
116
|
+
}
|
|
97
117
|
const adaptNavigation = () => {
|
|
98
118
|
for (const button of root.querySelectorAll<HTMLButtonElement>(".pdf-page-button")) {
|
|
99
119
|
const label = button.querySelector(".pdf-page-label")?.textContent?.trim();
|
|
@@ -173,12 +193,79 @@ export function FilePreviewView({
|
|
|
173
193
|
};
|
|
174
194
|
}, [state?.ready]);
|
|
175
195
|
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!pdf || !state?.ready) return;
|
|
198
|
+
const root = viewer.current?.getController()?.container.shadowRoot;
|
|
199
|
+
if (!root) return;
|
|
200
|
+
let frame = 0;
|
|
201
|
+
const fitPdfToWidth = () => {
|
|
202
|
+
cancelAnimationFrame(frame);
|
|
203
|
+
frame = requestAnimationFrame(() => {
|
|
204
|
+
const fitState = pdfFit.current;
|
|
205
|
+
if (!fitState.enabled) return;
|
|
206
|
+
const wrapper = root.querySelector<HTMLElement>(".pdf-wrapper");
|
|
207
|
+
const current = latestView.current;
|
|
208
|
+
const currentPage =
|
|
209
|
+
root.querySelector<HTMLElement>(`.pdfViewer .page[data-page-number="${current.page}"]`) ??
|
|
210
|
+
root.querySelector<HTMLElement>(".pdfViewer .page");
|
|
211
|
+
const renderedWidth = currentPage?.getBoundingClientRect().width ?? 0;
|
|
212
|
+
const availableWidth = wrapper?.clientWidth ?? 0;
|
|
213
|
+
if (renderedWidth <= 0 || availableWidth <= 0 || current.scale <= 0) return;
|
|
214
|
+
if (fitState.page !== current.page || fitState.rotation !== current.rotation) {
|
|
215
|
+
fitState.page = current.page;
|
|
216
|
+
fitState.rotation = current.rotation;
|
|
217
|
+
fitState.pageWidthAtScaleOne = 0;
|
|
218
|
+
}
|
|
219
|
+
if (fitState.pageWidthAtScaleOne <= 0) {
|
|
220
|
+
fitState.pageWidthAtScaleOne = renderedWidth / current.scale;
|
|
221
|
+
}
|
|
222
|
+
// The pinned PDF renderer clamps scale to two decimals. Floor to the same precision so the
|
|
223
|
+
// page never overflows and sub-pixel differences do not schedule identical updates.
|
|
224
|
+
const targetScale = Math.floor((availableWidth / fitState.pageWidthAtScaleOne) * 100) / 100;
|
|
225
|
+
if (Math.abs(targetScale - current.scale) <= 0.001) return;
|
|
226
|
+
void viewer.current?.applyViewState({ scale: targetScale });
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
schedulePdfFit.current = fitPdfToWidth;
|
|
230
|
+
const wrapper = root.querySelector<HTMLElement>(".pdf-wrapper");
|
|
231
|
+
const resizeObserver =
|
|
232
|
+
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(fitPdfToWidth);
|
|
233
|
+
if (wrapper) resizeObserver?.observe(wrapper);
|
|
234
|
+
const mutationObserver = new MutationObserver(fitPdfToWidth);
|
|
235
|
+
mutationObserver.observe(root, { childList: true, subtree: true });
|
|
236
|
+
fitPdfToWidth();
|
|
237
|
+
return () => {
|
|
238
|
+
cancelAnimationFrame(frame);
|
|
239
|
+
resizeObserver?.disconnect();
|
|
240
|
+
mutationObserver.disconnect();
|
|
241
|
+
if (schedulePdfFit.current === fitPdfToWidth) schedulePdfFit.current = null;
|
|
242
|
+
};
|
|
243
|
+
}, [pdf, state?.ready]);
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (page > 0 && viewState?.rotation !== undefined) schedulePdfFit.current?.();
|
|
247
|
+
}, [page, viewState?.rotation]);
|
|
248
|
+
|
|
176
249
|
useEffect(() => {
|
|
177
250
|
if (searchOpen) searchInput.current?.focus();
|
|
178
251
|
}, [searchOpen]);
|
|
179
252
|
|
|
180
|
-
const applyViewState = (next:
|
|
181
|
-
|
|
253
|
+
const applyViewState = (next: ViewerViewState) => viewer.current?.applyViewState(next);
|
|
254
|
+
const goToPdfPage = (direction: -1 | 1) => {
|
|
255
|
+
const request = pdfPageRequest.current;
|
|
256
|
+
const nextPage = Math.max(1, Math.min(pageCount, request.page + direction));
|
|
257
|
+
request.page = nextPage;
|
|
258
|
+
request.pending = nextPage !== page;
|
|
259
|
+
const root = viewer.current?.getController()?.container.shadowRoot;
|
|
260
|
+
const pageElement = root?.querySelector<HTMLElement>(
|
|
261
|
+
`.pdfViewer .page[data-page-number="${nextPage}"]`,
|
|
262
|
+
);
|
|
263
|
+
if (pageElement) {
|
|
264
|
+
pageElement.scrollIntoView({ block: "start", inline: "nearest" });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
void applyViewState({ page: nextPage });
|
|
268
|
+
};
|
|
182
269
|
|
|
183
270
|
return (
|
|
184
271
|
<div className="relative h-full min-h-64">
|
|
@@ -217,7 +304,7 @@ export function FilePreviewView({
|
|
|
217
304
|
aria-label={i18n.t("attachment.viewer.controls")}
|
|
218
305
|
className={`absolute right-3 bottom-2 left-3 z-[5] mx-auto flex min-h-10 w-fit max-w-[calc(100%-1.5rem)] items-center gap-1 rounded-full border bg-background/80 p-1 shadow-lg backdrop-blur-md ${
|
|
219
306
|
viewState?.navigation?.visible
|
|
220
|
-
? "[@media(max-width:720px)]:hidden min-[721px]:left-[calc(
|
|
307
|
+
? "[@media(max-width:720px)]:hidden min-[721px]:left-[calc(4rem+0.75rem)]"
|
|
221
308
|
: ""
|
|
222
309
|
}`}
|
|
223
310
|
>
|
|
@@ -237,7 +324,7 @@ export function FilePreviewView({
|
|
|
237
324
|
type="button"
|
|
238
325
|
aria-label={i18n.t("attachment.viewer.previousPage")}
|
|
239
326
|
disabled={page <= 1}
|
|
240
|
-
onClick={() =>
|
|
327
|
+
onClick={() => goToPdfPage(-1)}
|
|
241
328
|
className="size-7 rounded-full hover:bg-accent disabled:opacity-40"
|
|
242
329
|
>
|
|
243
330
|
<ChevronLeft aria-hidden="true" className="mx-auto size-3.5" />
|
|
@@ -249,7 +336,7 @@ export function FilePreviewView({
|
|
|
249
336
|
type="button"
|
|
250
337
|
aria-label={i18n.t("attachment.viewer.nextPage")}
|
|
251
338
|
disabled={page >= pageCount}
|
|
252
|
-
onClick={() =>
|
|
339
|
+
onClick={() => goToPdfPage(1)}
|
|
253
340
|
className="size-7 rounded-full hover:bg-accent disabled:opacity-40"
|
|
254
341
|
>
|
|
255
342
|
<ChevronRight aria-hidden="true" className="mx-auto size-3.5" />
|
|
@@ -340,7 +427,10 @@ export function FilePreviewView({
|
|
|
340
427
|
type="button"
|
|
341
428
|
aria-label={i18n.t("attachment.viewer.zoomOut")}
|
|
342
429
|
disabled={state.zoom ? !state.zoom.canZoomOut : false}
|
|
343
|
-
onClick={() =>
|
|
430
|
+
onClick={() => {
|
|
431
|
+
pdfFit.current.enabled = false;
|
|
432
|
+
void viewer.current?.zoomOut();
|
|
433
|
+
}}
|
|
344
434
|
className="grid size-7 place-items-center rounded-full hover:bg-accent disabled:opacity-40"
|
|
345
435
|
>
|
|
346
436
|
<Minus aria-hidden="true" className="size-3.5" />
|
|
@@ -349,7 +439,14 @@ export function FilePreviewView({
|
|
|
349
439
|
type="button"
|
|
350
440
|
aria-label={i18n.t("attachment.viewer.resetZoom")}
|
|
351
441
|
disabled={state.zoom ? !state.zoom.canReset : false}
|
|
352
|
-
onClick={() =>
|
|
442
|
+
onClick={() => {
|
|
443
|
+
if (pdf) {
|
|
444
|
+
pdfFit.current.enabled = true;
|
|
445
|
+
schedulePdfFit.current?.();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
void viewer.current?.resetZoom();
|
|
449
|
+
}}
|
|
353
450
|
className="min-w-12 px-1 text-xs tabular-nums disabled:opacity-40"
|
|
354
451
|
>
|
|
355
452
|
{Math.round(scale * 100)}%
|
|
@@ -358,7 +455,10 @@ export function FilePreviewView({
|
|
|
358
455
|
type="button"
|
|
359
456
|
aria-label={i18n.t("attachment.viewer.zoomIn")}
|
|
360
457
|
disabled={state.zoom ? !state.zoom.canZoomIn : false}
|
|
361
|
-
onClick={() =>
|
|
458
|
+
onClick={() => {
|
|
459
|
+
pdfFit.current.enabled = false;
|
|
460
|
+
void viewer.current?.zoomIn();
|
|
461
|
+
}}
|
|
362
462
|
className="grid size-7 place-items-center rounded-full hover:bg-accent disabled:opacity-40"
|
|
363
463
|
>
|
|
364
464
|
<Plus aria-hidden="true" className="size-3.5" />
|
package/src/i18n/de.json
CHANGED
|
@@ -84,12 +84,14 @@
|
|
|
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",
|
|
90
91
|
"board.assignee.search": "Person suchen",
|
|
91
92
|
"board.backTo": "Zurück zu {board}",
|
|
92
93
|
"board.cardLabel": "{title} öffnen, in {column}. Alt und eine Pfeiltaste verschiebt sie.",
|
|
94
|
+
"board.changeAssignee": "Zuständig: {name}, ändern",
|
|
93
95
|
"board.clearAssignee": "Zuständigkeit von {name} entfernen",
|
|
94
96
|
"board.clearDependsOn": "nicht mehr darauf warten",
|
|
95
97
|
"board.clearDue": "entfernen",
|
|
@@ -109,6 +111,7 @@
|
|
|
109
111
|
"board.createFailed": "Die Aufgabe konnte nicht angelegt werden.",
|
|
110
112
|
"board.crumbs": "Wo diese Karte sitzt",
|
|
111
113
|
"board.crumbsFolded": "Die Ebenen dazwischen zeigen",
|
|
114
|
+
"board.dates.edit": "Zeitraum bearbeiten",
|
|
112
115
|
"board.drag.lifted": "{card} aufgenommen",
|
|
113
116
|
"board.drag.moved": "{card} nach {column} verschoben",
|
|
114
117
|
"board.drag.putBack": "{card} zurückgelegt",
|
|
@@ -126,7 +129,6 @@
|
|
|
126
129
|
"board.link.subtask": "Unteraufgabe",
|
|
127
130
|
"board.links": "Verlinkt",
|
|
128
131
|
"board.moveTo": "in eine andere Spalte verschieben",
|
|
129
|
-
"board.progress": "{done} von {total} erledigt",
|
|
130
132
|
"board.removeLabel": "dieses Label entfernen",
|
|
131
133
|
"board.settings.addColumn": "+ Spalte",
|
|
132
134
|
"board.settings.columnName": "Name der Spalte {column}",
|
|
@@ -504,6 +506,7 @@
|
|
|
504
506
|
"tree.move.destination": "Neuer Ort: {title}",
|
|
505
507
|
"tree.move.dropRoot": "Hier ablegen, um auf die oberste Ebene zu verschieben",
|
|
506
508
|
"tree.move.elsewhere": "Anderen Ordner wählen",
|
|
509
|
+
"tree.move.failed.archived": "Nicht verschoben: Dieses Ziel ist archiviert. Stell es zuerst wieder her.",
|
|
507
510
|
"tree.move.failed.conflict": "Nicht verschoben: Jemand anderes hat diesen Eintrag zuerst geändert. Der Baum wurde neu geladen — sieh noch einmal nach und verschiebe dann.",
|
|
508
511
|
"tree.move.failed.cycle": "Nicht verschoben: Ein Ordner kann weder in sich selbst noch in etwas, das er enthält.",
|
|
509
512
|
"tree.move.failed.forbidden": "Nicht verschoben: Du darfst in diesen Ordner nicht schreiben. Bitte dort um Schreibzugriff.",
|
package/src/i18n/en.json
CHANGED
|
@@ -84,12 +84,14 @@
|
|
|
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",
|
|
90
91
|
"board.assignee.search": "Find a person",
|
|
91
92
|
"board.backTo": "Back to {board}",
|
|
92
93
|
"board.cardLabel": "Open {title}, in {column}. Alt and an arrow key moves it.",
|
|
94
|
+
"board.changeAssignee": "Assigned to {name}, change",
|
|
93
95
|
"board.clearAssignee": "Take the assignment off {name}",
|
|
94
96
|
"board.clearDependsOn": "stop waiting for it",
|
|
95
97
|
"board.clearDue": "clear it",
|
|
@@ -109,6 +111,7 @@
|
|
|
109
111
|
"board.createFailed": "The task could not be created.",
|
|
110
112
|
"board.crumbs": "Where this card sits",
|
|
111
113
|
"board.crumbsFolded": "Show the levels in between",
|
|
114
|
+
"board.dates.edit": "Edit the dates",
|
|
112
115
|
"board.drag.lifted": "{card} picked up",
|
|
113
116
|
"board.drag.moved": "{card} moved to {column}",
|
|
114
117
|
"board.drag.putBack": "{card} put back",
|
|
@@ -126,7 +129,6 @@
|
|
|
126
129
|
"board.link.subtask": "Subtask",
|
|
127
130
|
"board.links": "Linked",
|
|
128
131
|
"board.moveTo": "move it to another column",
|
|
129
|
-
"board.progress": "{done} of {total} done",
|
|
130
132
|
"board.removeLabel": "remove this label",
|
|
131
133
|
"board.settings.addColumn": "+ Column",
|
|
132
134
|
"board.settings.columnName": "Name of the column {column}",
|
|
@@ -504,6 +506,7 @@
|
|
|
504
506
|
"tree.move.destination": "New place: {title}",
|
|
505
507
|
"tree.move.dropRoot": "Drop here to move to the top level",
|
|
506
508
|
"tree.move.elsewhere": "Choose another folder",
|
|
509
|
+
"tree.move.failed.archived": "It was not moved: that destination is archived. Restore it first.",
|
|
507
510
|
"tree.move.failed.conflict": "It was not moved: somebody else changed this entry first. The tree has been reloaded — look again, then move it.",
|
|
508
511
|
"tree.move.failed.cycle": "It was not moved: a folder cannot be put inside itself or inside anything it contains.",
|
|
509
512
|
"tree.move.failed.forbidden": "It was not moved: you may not write into that folder. Ask for write access there.",
|
package/src/i18n/es.json
CHANGED
|
@@ -84,12 +84,14 @@
|
|
|
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",
|
|
90
91
|
"board.assignee.search": "Buscar una persona",
|
|
91
92
|
"board.backTo": "Volver a {board}",
|
|
92
93
|
"board.cardLabel": "Abrir {title}, en {column}. Alt y una flecha la mueve.",
|
|
94
|
+
"board.changeAssignee": "Responsable: {name}, cambiar",
|
|
93
95
|
"board.clearAssignee": "Quitar la responsabilidad de {name}",
|
|
94
96
|
"board.clearDependsOn": "dejar de esperarlo",
|
|
95
97
|
"board.clearDue": "quitar",
|
|
@@ -109,6 +111,7 @@
|
|
|
109
111
|
"board.createFailed": "No se pudo crear la tarea.",
|
|
110
112
|
"board.crumbs": "Dónde está esta tarjeta",
|
|
111
113
|
"board.crumbsFolded": "Mostrar los niveles intermedios",
|
|
114
|
+
"board.dates.edit": "Editar las fechas",
|
|
112
115
|
"board.drag.lifted": "{card} recogida",
|
|
113
116
|
"board.drag.moved": "{card} movida a {column}",
|
|
114
117
|
"board.drag.putBack": "{card} devuelta",
|
|
@@ -126,7 +129,6 @@
|
|
|
126
129
|
"board.link.subtask": "Subtarea",
|
|
127
130
|
"board.links": "Vinculado",
|
|
128
131
|
"board.moveTo": "moverla a otra columna",
|
|
129
|
-
"board.progress": "{done} de {total} hechas",
|
|
130
132
|
"board.removeLabel": "quitar esta etiqueta",
|
|
131
133
|
"board.settings.addColumn": "+ Columna",
|
|
132
134
|
"board.settings.columnName": "Nombre de la columna {column}",
|
|
@@ -504,6 +506,7 @@
|
|
|
504
506
|
"tree.move.destination": "Sitio nuevo: {title}",
|
|
505
507
|
"tree.move.dropRoot": "Suelta aquí para mover al nivel superior",
|
|
506
508
|
"tree.move.elsewhere": "Elegir otra carpeta",
|
|
509
|
+
"tree.move.failed.archived": "No se ha movido: ese destino está archivado. Restáuralo primero.",
|
|
507
510
|
"tree.move.failed.conflict": "No se ha movido: otra persona ha cambiado esta entrada antes. El árbol se ha recargado — míralo otra vez y muévelo entonces.",
|
|
508
511
|
"tree.move.failed.cycle": "No se ha movido: una carpeta no puede ir dentro de sí misma ni dentro de nada que contenga.",
|
|
509
512
|
"tree.move.failed.forbidden": "No se ha movido: no puedes escribir en esa carpeta. Pide acceso de escritura allí.",
|
|
@@ -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
|
>
|