@anchrd/intel-ui 0.43.0 → 0.45.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/tree-move/tree-move.tsx +10 -2
- package/src/attachment-viewer/attachment-viewer.tsx +2 -2
- package/src/board/board-assignee/board-assignee-names.tsx +61 -0
- package/src/board/board-assignee/board-assignee-picker.tsx +115 -0
- package/src/board/board-assignee/board-assignee.ts +11 -2
- package/src/board/board-assignee/board-assignee.tsx +32 -18
- package/src/board/board-panel/board-panel.tsx +64 -49
- package/src/board/board-table/board-table.tsx +5 -1
- package/src/board/board-task/board-task.tsx +34 -3
- package/src/data/intel-data-provider/intel-data-provider.ts +16 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +16 -0
- package/src/file-preview/file-preview-view.tsx +118 -18
- package/src/i18n/de.json +51 -44
- package/src/i18n/en.json +51 -44
- package/src/i18n/es.json +51 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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.25.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
|
3
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The names behind the assignee ids a board is currently showing (#700).
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **One request for the whole board, not one per card.** A hook that resolved a single id would
|
|
9
|
+
* be called once per row and once per card, and a board with forty cards would open forty requests
|
|
10
|
+
* for four distinct people. The ids are collected where they are already all in one place — the
|
|
11
|
+
* board view — and asked for together.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **Absent is not an error.** An id that cannot be named is missing from the answer, and the
|
|
14
|
+
* map simply has no entry for it. Callers fall back to the same wording they used before names
|
|
15
|
+
* existed at all; nobody ever draws the raw id (#258).
|
|
16
|
+
*/
|
|
17
|
+
const AssigneeNames = createContext<ReadonlyMap<string, string> | null>(null);
|
|
18
|
+
|
|
19
|
+
export function AssigneeNamesProvider({
|
|
20
|
+
ids,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
ids: readonly (string | null)[];
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}) {
|
|
26
|
+
const { data } = useIntelRouterContext();
|
|
27
|
+
// Sorted and de-duplicated so that the same board produces the same key twice running: an
|
|
28
|
+
// unstable key would refetch on every render that reorders cards, which a drag does constantly.
|
|
29
|
+
const wanted = useMemo(
|
|
30
|
+
() => [...new Set(ids.filter((id): id is string => id !== null))].sort(),
|
|
31
|
+
[ids],
|
|
32
|
+
);
|
|
33
|
+
const query = useQuery({
|
|
34
|
+
queryKey: ["board-assignee-names", wanted],
|
|
35
|
+
// ⚠️ Not `enabled: wanted.length > 0` alone — the queryFn must never be reached with an empty
|
|
36
|
+
// list, because the contract refuses it (`min(1)`) and the refusal would surface as a broken
|
|
37
|
+
// board rather than as the empty answer it means.
|
|
38
|
+
enabled: wanted.length > 0,
|
|
39
|
+
queryFn: async () => await data.resolveBoardAssignees(wanted),
|
|
40
|
+
staleTime: 5 * 60 * 1000,
|
|
41
|
+
});
|
|
42
|
+
const names = useMemo(() => {
|
|
43
|
+
const map = new Map<string, string>();
|
|
44
|
+
for (const person of query.data?.items ?? []) map.set(person.id, person.name);
|
|
45
|
+
return map;
|
|
46
|
+
}, [query.data]);
|
|
47
|
+
return <AssigneeNames.Provider value={names}>{children}</AssigneeNames.Provider>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The name behind one id, or `null` where this browser cannot say.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ Outside a provider this answers `null` for everybody, which is exactly the behaviour every
|
|
54
|
+
* caller already handles. A screen that forgets the provider shows what it showed before this
|
|
55
|
+
* existed; it does not break, and it does not leak an id.
|
|
56
|
+
*/
|
|
57
|
+
export function useResolvedName(id: string | null): string | null {
|
|
58
|
+
const names = useContext(AssigneeNames);
|
|
59
|
+
if (id === null) return null;
|
|
60
|
+
return names?.get(id) ?? null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuSeparator,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "@/components/ui/dropdown-menu.tsx";
|
|
10
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
11
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Who to give this card to (#700, D70).
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ **The list is the board's, not the installation's.** Only people who can actually open this
|
|
17
|
+
* board are offered, so handing somebody a card never also hands them a reason to ask why they
|
|
18
|
+
* cannot see it.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **Under two characters the answer is empty on purpose**, and the wording says so rather than
|
|
21
|
+
* showing "nothing found": a picker that says "no matches" for one typed letter teaches the reader
|
|
22
|
+
* that the person is not there, which is the opposite of the truth.
|
|
23
|
+
*/
|
|
24
|
+
export function AssigneePicker({
|
|
25
|
+
boardId,
|
|
26
|
+
current,
|
|
27
|
+
onPick,
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
children,
|
|
31
|
+
}: {
|
|
32
|
+
boardId: string;
|
|
33
|
+
current: string | null;
|
|
34
|
+
onPick(assigneeId: string | null): void;
|
|
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;
|
|
43
|
+
}) {
|
|
44
|
+
const i18n = useI18n();
|
|
45
|
+
const { data } = useIntelRouterContext();
|
|
46
|
+
const [query, setQuery] = useState("");
|
|
47
|
+
const trimmed = query.trim();
|
|
48
|
+
const search = useQuery({
|
|
49
|
+
queryKey: ["board-assignee-search", boardId, trimmed],
|
|
50
|
+
enabled: trimmed.length >= 2,
|
|
51
|
+
queryFn: async () => await data.searchBoardAssignees(boardId, trimmed),
|
|
52
|
+
});
|
|
53
|
+
const found = search.data?.items ?? [];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<DropdownMenu
|
|
57
|
+
{...(open === undefined ? {} : { open })}
|
|
58
|
+
{...(onOpenChange === undefined ? {} : { onOpenChange })}
|
|
59
|
+
>
|
|
60
|
+
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
|
61
|
+
<DropdownMenuContent align="start" className="w-64">
|
|
62
|
+
<div className="p-1">
|
|
63
|
+
<input
|
|
64
|
+
// biome-ignore lint/a11y/noAutofocus: the menu opens on a deliberate click, never on load
|
|
65
|
+
autoFocus
|
|
66
|
+
value={query}
|
|
67
|
+
aria-label={i18n.t("board.assignee.search")}
|
|
68
|
+
placeholder={i18n.t("board.assignee.search")}
|
|
69
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
70
|
+
onKeyDown={(event) => event.key === "Escape" && onOpenChange?.(false)}
|
|
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"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
{current === null ? null : (
|
|
75
|
+
<>
|
|
76
|
+
<DropdownMenuItem
|
|
77
|
+
onSelect={() => {
|
|
78
|
+
onOpenChange?.(false);
|
|
79
|
+
onPick(null);
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{i18n.t("board.assignee.clear")}
|
|
83
|
+
</DropdownMenuItem>
|
|
84
|
+
<DropdownMenuSeparator />
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
{trimmed.length < 2 ? (
|
|
88
|
+
<p className="px-2 py-1.5 text-muted-foreground text-xs">
|
|
89
|
+
{i18n.t("board.assignee.keepTyping")}
|
|
90
|
+
</p>
|
|
91
|
+
) : null}
|
|
92
|
+
{trimmed.length >= 2 && !search.isPending && found.length === 0 ? (
|
|
93
|
+
<p className="px-2 py-1.5 text-muted-foreground text-xs">
|
|
94
|
+
{i18n.t("board.assignee.nobody")}
|
|
95
|
+
</p>
|
|
96
|
+
) : null}
|
|
97
|
+
{found.map((person) => (
|
|
98
|
+
<DropdownMenuItem
|
|
99
|
+
key={person.id}
|
|
100
|
+
onSelect={() => {
|
|
101
|
+
onOpenChange?.(false);
|
|
102
|
+
onPick(person.id);
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<span className="truncate">{person.name}</span>
|
|
106
|
+
{/* ⚠️ The address disambiguates two colleagues who share a first name. It is the
|
|
107
|
+
second line, not the first: a picker reads by NAME, and an address in the same
|
|
108
|
+
weight would make every row look like a form field. */}
|
|
109
|
+
<span className="ml-auto truncate text-muted-foreground text-xs">{person.email}</span>
|
|
110
|
+
</DropdownMenuItem>
|
|
111
|
+
))}
|
|
112
|
+
</DropdownMenuContent>
|
|
113
|
+
</DropdownMenu>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
2
2
|
import { useUserName } from "@/user-name/user-name.ts";
|
|
3
|
+
import { useResolvedName } from "./board-assignee-names.tsx";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* What to call whoever a card is for.
|
|
@@ -12,7 +13,15 @@ import { useUserName } from "@/user-name/user-name.ts";
|
|
|
12
13
|
*/
|
|
13
14
|
export function useAssigneeLabel(id: string | null): string {
|
|
14
15
|
const i18n = useI18n();
|
|
15
|
-
|
|
16
|
+
// ⚠️ TWO sources, and the order matters. `useUserName` answers for the reader themselves out of
|
|
17
|
+
// the session the shell already holds, without a request; the board's resolved map answers for
|
|
18
|
+
// everybody else. Asking the map first would make the reader's own name depend on a round trip
|
|
19
|
+
// that has no reason to exist, and it would leave them nameless on any screen without a provider.
|
|
20
|
+
const own = useUserName(id);
|
|
21
|
+
const resolved = useResolvedName(id);
|
|
16
22
|
if (id === null) return i18n.t("board.unassigned");
|
|
17
|
-
|
|
23
|
+
// ⚠️ The fallback stays what it was: a person we cannot name is "a user account", never the
|
|
24
|
+
// principal id the row carries (#258). Names arriving is an improvement on this line, not a
|
|
25
|
+
// replacement for the rule underneath it.
|
|
26
|
+
return own ?? resolved ?? i18n.t("node.someUser");
|
|
18
27
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
|
|
1
2
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
2
3
|
import { initials } from "@/user-name/user-name.ts";
|
|
3
4
|
import { useAssigneeLabel } from "./board-assignee.ts";
|
|
@@ -14,32 +15,45 @@ import { useAssigneeLabel } from "./board-assignee.ts";
|
|
|
14
15
|
* ⚠️ **Only ever drawn for somebody.** "Nobody yet" as a chip is a placeholder for an absence, and
|
|
15
16
|
* an absence needs no place on screen (#692).
|
|
16
17
|
*
|
|
18
|
+
* ⚠️ **The circle OPENS the picker; it no longer clears on click** (#723). A control that
|
|
19
|
+
* removes an assignment on the same click somebody uses to change one is a control that
|
|
20
|
+
* destroys work on a misclick. Removing lives inside the menu, where it says what it does.
|
|
21
|
+
*
|
|
17
22
|
* ⚠️ **The NAME lives on the button, and the circle is hidden.** Children of a `<button>` are
|
|
18
23
|
* presentational in ARIA, so a label inside one is never announced and the button's own name wins.
|
|
19
24
|
* Wrapping the circle in a control therefore takes the person out of the accessibility tree unless
|
|
20
25
|
* the control says it too. That is the trap in `packages/ui/CLAUDE.md`.
|
|
21
26
|
*/
|
|
22
|
-
export function AssigneeChip({
|
|
27
|
+
export function AssigneeChip({
|
|
28
|
+
id,
|
|
29
|
+
boardId,
|
|
30
|
+
onPick,
|
|
31
|
+
}: {
|
|
32
|
+
id: string;
|
|
33
|
+
boardId: string;
|
|
34
|
+
onPick(assigneeId: string | null): void;
|
|
35
|
+
}) {
|
|
23
36
|
const i18n = useI18n();
|
|
24
37
|
const label = useAssigneeLabel(id);
|
|
25
38
|
return (
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
>
|
|
33
|
-
<span
|
|
34
|
-
// ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
|
|
35
|
-
// person's name, and that reached nobody: it sits inside a button. Giving it a role and a
|
|
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"
|
|
39
|
+
<AssigneePicker boardId={boardId} current={id} onPick={onPick}>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
aria-label={i18n.t("board.changeAssignee", { name: label })}
|
|
43
|
+
title={label}
|
|
44
|
+
className="shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
40
45
|
>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
<span
|
|
47
|
+
// ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
|
|
48
|
+
// person's name, and that reached nobody: it sits inside a button. Giving it a role and a
|
|
49
|
+
// label back would not add a second announcement, it would add none, while making the code
|
|
50
|
+
// look as though the name were covered here rather than on the button.
|
|
51
|
+
aria-hidden="true"
|
|
52
|
+
className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
|
|
53
|
+
>
|
|
54
|
+
{initials(label)}
|
|
55
|
+
</span>
|
|
56
|
+
</button>
|
|
57
|
+
</AssigneePicker>
|
|
44
58
|
);
|
|
45
59
|
}
|
|
@@ -5,6 +5,7 @@ import type { ColumnVisibilityState } from "@tanstack/react-table";
|
|
|
5
5
|
import { KanbanSquare, Settings, Table2 } from "lucide-react";
|
|
6
6
|
import { Suspense, useState } from "react";
|
|
7
7
|
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
8
|
+
import { AssigneeNamesProvider } from "@/board/board-assignee/board-assignee-names.tsx";
|
|
8
9
|
import { useBoard } from "@/board/board-data/board-data.ts";
|
|
9
10
|
import { BoardKanban } from "@/board/board-kanban/board-kanban.tsx";
|
|
10
11
|
import { useOpenTask } from "@/board/board-open-task/board-open-task.ts";
|
|
@@ -48,64 +49,78 @@ export function BoardPanel({ node }: { node: Node }) {
|
|
|
48
49
|
const board = useBoard(node.id);
|
|
49
50
|
const { openId, open } = useOpenTask();
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* ⚠️ Collected HERE and nowhere lower (#700). This component holds the one unfiltered board
|
|
54
|
+
* answer that both views and the open card read, so the ids are already all in one place. A hook
|
|
55
|
+
* per card would open one request per row for a handful of distinct people, and a hook per view
|
|
56
|
+
* would ask twice for the same names when somebody switches between them.
|
|
57
|
+
*/
|
|
58
|
+
const assigneeIds = (board.columns ?? []).flatMap((column) =>
|
|
59
|
+
column.cards.map((card) => card.assigneeId),
|
|
60
|
+
);
|
|
61
|
+
|
|
51
62
|
return (
|
|
52
|
-
<
|
|
53
|
-
<
|
|
54
|
-
|
|
63
|
+
<AssigneeNamesProvider ids={assigneeIds}>
|
|
64
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
65
|
+
<ActionSlot name="title-actions">
|
|
66
|
+
{/* ⚠️ **Gone while a card is open.** The switch chooses between two views OF THE BOARD, and
|
|
55
67
|
an open card is neither — left standing it reports `aria-pressed` for a view nobody is
|
|
56
68
|
looking at and does nothing when pressed, which is a control that lies twice. */}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
{(openId === null ? views : []).map(({ name, Icon }) => (
|
|
70
|
+
<button
|
|
71
|
+
key={name}
|
|
72
|
+
type="button"
|
|
73
|
+
aria-pressed={view === name}
|
|
74
|
+
aria-label={i18n.t(`board.view.${name}`)}
|
|
75
|
+
onClick={() => setView(name)}
|
|
76
|
+
className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-accent"
|
|
77
|
+
>
|
|
78
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
79
|
+
</button>
|
|
80
|
+
))}
|
|
81
|
+
{/* ⚠️ Gone while a card is open, like the view switch above it. It configures the BOARD,
|
|
70
82
|
and the line it sits in belongs to the card then — a control that acts on something the
|
|
71
83
|
reader is not looking at is worse than a missing one, because it looks like it acts on
|
|
72
84
|
what they see. */}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
{openId === null ? (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
aria-label={i18n.t("board.settings.open")}
|
|
89
|
+
onClick={() => setSettingsOpen(true)}
|
|
90
|
+
className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
91
|
+
>
|
|
92
|
+
<Settings aria-hidden="true" className="size-4" />
|
|
93
|
+
</button>
|
|
94
|
+
) : null}
|
|
95
|
+
</ActionSlot>
|
|
96
|
+
{/* ⚠️ Mounted only while open, so its draft is seeded once per opening. Handed an `open` flag
|
|
85
97
|
instead, a draft re-seeded from the board would lose what somebody typed the moment a
|
|
86
98
|
write from elsewhere answers. */}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
{settingsOpen ? (
|
|
100
|
+
<BoardSettings board={board} onClose={() => setSettingsOpen(false)} />
|
|
101
|
+
) : null}
|
|
102
|
+
{openId === null ? (
|
|
103
|
+
view === "kanban" ? (
|
|
104
|
+
<KanbanView board={board} onOpen={open} />
|
|
105
|
+
) : (
|
|
106
|
+
<TableView
|
|
107
|
+
nodeId={node.id}
|
|
108
|
+
hidden={hidden}
|
|
109
|
+
onHidden={setHidden}
|
|
110
|
+
onOpen={open}
|
|
111
|
+
filter={filter}
|
|
112
|
+
onFilter={setFilter}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
91
115
|
) : (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
101
|
-
) : (
|
|
102
|
-
// ⚠️ Drawn from the panel's own UNFILTERED board answer, which is also what the kanban
|
|
103
|
-
// reads — the open task is not a second read of the card. ⚠️ It is NOT the table's answer:
|
|
104
|
-
// the table keeps its own filtered query, so a card filtered out of the table still opens
|
|
105
|
-
// from a link. What this cannot find is a card that is gone, or one on another board.
|
|
106
|
-
<OpenTask board={board} taskId={openId} onOpen={open} />
|
|
107
|
-
)}
|
|
108
|
-
</div>
|
|
116
|
+
// ⚠️ Drawn from the panel's own UNFILTERED board answer, which is also what the kanban
|
|
117
|
+
// reads — the open task is not a second read of the card. ⚠️ It is NOT the table's answer:
|
|
118
|
+
// the table keeps its own filtered query, so a card filtered out of the table still opens
|
|
119
|
+
// from a link. What this cannot find is a card that is gone, or one on another board.
|
|
120
|
+
<OpenTask board={board} taskId={openId} onOpen={open} />
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</AssigneeNamesProvider>
|
|
109
124
|
);
|
|
110
125
|
}
|
|
111
126
|
|
|
@@ -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
|
);
|
|
@@ -2,6 +2,7 @@ import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
|
2
2
|
import { Lock, Plus, Square, SquareCheck } from "lucide-react";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
|
|
5
|
+
import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
|
|
5
6
|
import { BoardChip } from "@/board/board-chip/board-chip.tsx";
|
|
6
7
|
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
7
8
|
import { StatusChip } from "@/board/board-status/board-status.tsx";
|
|
@@ -131,7 +132,11 @@ export function BoardTaskDocument({
|
|
|
131
132
|
{/* ⚠️ **Last, always.** It is the only round element in the row; standing between the chips
|
|
132
133
|
it breaks the line, and a row without one simply ends earlier. */}
|
|
133
134
|
{task.assigneeId === null ? null : (
|
|
134
|
-
<AssigneeChip
|
|
135
|
+
<AssigneeChip
|
|
136
|
+
id={task.assigneeId}
|
|
137
|
+
boardId={board.boardId}
|
|
138
|
+
onPick={(assigneeId) => write({ assigneeId })}
|
|
139
|
+
/>
|
|
135
140
|
)}
|
|
136
141
|
|
|
137
142
|
<Adder task={task} board={board} write={write} />
|
|
@@ -183,7 +188,10 @@ export function BoardTaskDocument({
|
|
|
183
188
|
* is no list to choose from anywhere in the data provider. An entry that opens nothing is worse than
|
|
184
189
|
* a missing one, because it looks like the feature is there (`#700`).
|
|
185
190
|
*/
|
|
186
|
-
|
|
191
|
+
// ⚠️ `assignee` is first, and that is Jacks Vorgabe 2026-08-21 read back into the menu: on a card
|
|
192
|
+
// the assignee chip stands LAST so the round element does not break the row, while in the menu the
|
|
193
|
+
// most-used entry stands first. The two orders answer different questions and are not a mismatch.
|
|
194
|
+
const addable = ["assignee", "label", "start", "due"] as const;
|
|
187
195
|
const linkable = ["subtask", "blocker"] as const;
|
|
188
196
|
type Addable = (typeof addable)[number] | (typeof linkable)[number];
|
|
189
197
|
|
|
@@ -213,6 +221,9 @@ function Adder({
|
|
|
213
221
|
.flatMap((entry) => entry.cards)
|
|
214
222
|
.filter((card) => card.id !== task.id && card.parentTaskId !== task.id);
|
|
215
223
|
|
|
224
|
+
// ⚠️ The picker is not an `<input>`, so it cannot ride the `draft` path below. It gets its own
|
|
225
|
+
// branch rather than a special case inside `commit`, because a "value" that is picked from a
|
|
226
|
+
// remote list has nothing in common with one that is typed.
|
|
216
227
|
const commit = () => {
|
|
217
228
|
const value = draft.trim();
|
|
218
229
|
if (value.length === 0 || board.isWriting) return close();
|
|
@@ -267,7 +278,27 @@ function Adder({
|
|
|
267
278
|
</DropdownMenuContent>
|
|
268
279
|
</DropdownMenu>
|
|
269
280
|
|
|
270
|
-
{kind
|
|
281
|
+
{kind !== "assignee" ? null : (
|
|
282
|
+
<AssigneePicker
|
|
283
|
+
boardId={board.boardId}
|
|
284
|
+
current={task.assigneeId}
|
|
285
|
+
onPick={(assigneeId) => write({ assigneeId })}
|
|
286
|
+
open
|
|
287
|
+
onOpenChange={(next) => !next && close()}
|
|
288
|
+
>
|
|
289
|
+
{/* ⚠️ The anchor. Opened from the plus menu there is nothing on screen to hang the menu
|
|
290
|
+
on, so the picker brings its own pill — the same shape the blocker branch below uses,
|
|
291
|
+
and for the same reason. */}
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
className="rounded-full border px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
295
|
+
>
|
|
296
|
+
{i18n.t("board.add.assignee")}
|
|
297
|
+
</button>
|
|
298
|
+
</AssigneePicker>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{kind === null || kind === "blocker" || kind === "assignee" ? null : (
|
|
271
302
|
<input
|
|
272
303
|
// biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
|
|
273
304
|
autoFocus
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type NamedOrCounted, ProblemDetails, SessionUser } from "@anchrd/intel-contract";
|
|
2
2
|
import {
|
|
3
|
+
BoardAssigneeList,
|
|
3
4
|
BoardGetInput,
|
|
4
5
|
BoardTaskCreateInput,
|
|
5
6
|
BoardTaskUpdateInput,
|
|
@@ -394,6 +395,21 @@ export function createIntelDataProvider(
|
|
|
394
395
|
{ method: "PATCH", body: JSON.stringify(parsed) },
|
|
395
396
|
);
|
|
396
397
|
},
|
|
398
|
+
async searchBoardAssignees(boardId, query) {
|
|
399
|
+
return await request(
|
|
400
|
+
`/boards/${encodeURIComponent(boardId)}/assignees?q=${encodeURIComponent(query)}`,
|
|
401
|
+
BoardAssigneeList,
|
|
402
|
+
);
|
|
403
|
+
},
|
|
404
|
+
// ⚠️ A POST although it reads, mirroring the route: a hundred identifiers in a query string
|
|
405
|
+
// land in proxy logs and in browser history, and they would hit the length limit long before
|
|
406
|
+
// the hundredth.
|
|
407
|
+
async resolveBoardAssignees(ids) {
|
|
408
|
+
return await request("/boards/assignees/resolve", BoardAssigneeList, {
|
|
409
|
+
method: "POST",
|
|
410
|
+
body: JSON.stringify({ ids }),
|
|
411
|
+
});
|
|
412
|
+
},
|
|
397
413
|
async updateNode(input) {
|
|
398
414
|
const parsed = UpdateNodeInput.parse(input);
|
|
399
415
|
return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}`, Node, {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SessionUser } from "@anchrd/intel-contract";
|
|
2
2
|
import type {
|
|
3
|
+
BoardAssigneeList,
|
|
3
4
|
BoardGetInput,
|
|
4
5
|
BoardTaskCreateInput,
|
|
5
6
|
BoardTaskUpdateInput,
|
|
@@ -117,6 +118,21 @@ export interface IntelDataProvider {
|
|
|
117
118
|
// and shifts nothing else, but a column's contents are what the screen draws, and re-deriving
|
|
118
119
|
// them from a single row is where a second, quietly different truth would start.
|
|
119
120
|
updateBoardTask(boardId: string, input: BoardTaskUpdateInput): Promise<BoardView>;
|
|
121
|
+
/**
|
|
122
|
+
* Who a card on THIS board may be given to (#700, D70).
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ Board-scoped, and the same query against two boards can give two different answers. Only
|
|
125
|
+
* people who can actually open this one are offered.
|
|
126
|
+
*/
|
|
127
|
+
searchBoardAssignees(boardId: string, query: string): Promise<BoardAssigneeList>;
|
|
128
|
+
/**
|
|
129
|
+
* What the people already on cards are called (#258, #700).
|
|
130
|
+
*
|
|
131
|
+
* ⚠️ NOT board-scoped, unlike the search above: somebody whose access was withdrawn is still who
|
|
132
|
+
* the card belongs to, and a card that read as unassigned would be a worse answer than the truth.
|
|
133
|
+
* Ids that cannot be named are absent from the answer rather than reported.
|
|
134
|
+
*/
|
|
135
|
+
resolveBoardAssignees(ids: string[]): Promise<BoardAssigneeList>;
|
|
120
136
|
listNodes(input?: Partial<ListNodesInput>): Promise<NodeList>;
|
|
121
137
|
// One level of the shared tree: the documents and the flows filed in the same folder, in one
|
|
122
138
|
// sorted list. Per level rather than recursive, so opening a folder is what costs a request.
|
|
@@ -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
|
@@ -29,9 +29,54 @@
|
|
|
29
29
|
"archive.restoring": "{title} wird wiederhergestellt",
|
|
30
30
|
"archive.restoringAction": "Wird wiederhergestellt",
|
|
31
31
|
"archive.title": "Archiv",
|
|
32
|
+
"attachment.details.checksum": "Prüfsumme",
|
|
33
|
+
"attachment.details.dimensions": "Abmessungen",
|
|
34
|
+
"attachment.details.format": "Format",
|
|
35
|
+
"attachment.details.mimeType": "MIME-Typ",
|
|
36
|
+
"attachment.details.name": "Name",
|
|
37
|
+
"attachment.details.pages": "Seiten",
|
|
38
|
+
"attachment.details.private": "Privat",
|
|
39
|
+
"attachment.details.sections": "Abschnitte",
|
|
40
|
+
"attachment.details.size": "Größe",
|
|
41
|
+
"attachment.details.slides": "Folien",
|
|
42
|
+
"attachment.details.storage": "Speicher",
|
|
43
|
+
"attachment.details.technical": "Technisch",
|
|
44
|
+
"attachment.details.title": "Dateidetails",
|
|
45
|
+
"attachment.details.type": "Typ",
|
|
46
|
+
"attachment.details.uploaded": "Hochgeladen",
|
|
47
|
+
"attachment.details.uploadedBy": "Hochgeladen von",
|
|
48
|
+
"attachment.details.version": "Version",
|
|
49
|
+
"attachment.details.worksheets": "Tabellenblätter",
|
|
50
|
+
"attachment.detailsHide": "Dateidetails ausblenden",
|
|
51
|
+
"attachment.detailsShow": "Dateidetails anzeigen",
|
|
52
|
+
"attachment.empty": "Es wurde noch keine Datei hochgeladen.",
|
|
53
|
+
"attachment.kind.image": "Bild",
|
|
54
|
+
"attachment.kind.pdf": "PDF-Dokument",
|
|
55
|
+
"attachment.kind.presentation": "Präsentation",
|
|
56
|
+
"attachment.kind.spreadsheet": "Tabelle",
|
|
57
|
+
"attachment.kind.unsupported": "Datei",
|
|
58
|
+
"attachment.kind.word": "Word-Dokument",
|
|
59
|
+
"attachment.loadFailed": "Die Datei konnte nicht geladen werden. Das Original lässt sich weiterhin über das Menü exportieren.",
|
|
60
|
+
"attachment.unsupported": "Dieser Dateityp kann nicht angezeigt werden. Das Original lässt sich weiterhin über das Menü exportieren.",
|
|
61
|
+
"attachment.viewer.controls": "Dateisteuerung",
|
|
62
|
+
"attachment.viewer.navigation": "Seitennavigation anzeigen",
|
|
63
|
+
"attachment.viewer.nextPage": "Nächste Seite",
|
|
64
|
+
"attachment.viewer.nextResult": "Nächster Suchtreffer",
|
|
65
|
+
"attachment.viewer.previousPage": "Vorherige Seite",
|
|
66
|
+
"attachment.viewer.previousResult": "Vorheriger Suchtreffer",
|
|
67
|
+
"attachment.viewer.resetZoom": "Zoom zurücksetzen",
|
|
68
|
+
"attachment.viewer.rotateLeft": "Nach links drehen",
|
|
69
|
+
"attachment.viewer.rotateRight": "Nach rechts drehen",
|
|
70
|
+
"attachment.viewer.search": "Dokument durchsuchen",
|
|
71
|
+
"attachment.viewer.searchNoResults": "Keine Treffer",
|
|
72
|
+
"attachment.viewer.searchPlaceholder": "Dokument durchsuchen",
|
|
73
|
+
"attachment.viewer.searchResults": "Suchtreffer {current} von {total}",
|
|
74
|
+
"attachment.viewer.zoomIn": "Vergrößern",
|
|
75
|
+
"attachment.viewer.zoomOut": "Verkleinern",
|
|
32
76
|
"auth.signOut": "Abmelden",
|
|
33
77
|
"auth.signOutFailed": "Das Abmelden ist fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut.",
|
|
34
78
|
"board.add": "Etwas hinzufügen",
|
|
79
|
+
"board.add.assignee": "Zuständigkeit",
|
|
35
80
|
"board.add.due": "Fällig",
|
|
36
81
|
"board.add.label": "Label",
|
|
37
82
|
"board.add.start": "Start",
|
|
@@ -39,8 +84,13 @@
|
|
|
39
84
|
"board.addCardIn": "Eine Aufgabe zu {column} hinzufügen",
|
|
40
85
|
"board.addLabel": "Label hinzufügen",
|
|
41
86
|
"board.addLink": "Verlinkung",
|
|
87
|
+
"board.assignee.clear": "Zuständigkeit entfernen",
|
|
88
|
+
"board.assignee.keepTyping": "Mindestens zwei Zeichen tippen",
|
|
89
|
+
"board.assignee.nobody": "Niemand mit Zugriff auf dieses Board passt dazu",
|
|
90
|
+
"board.assignee.search": "Person suchen",
|
|
42
91
|
"board.backTo": "Zurück zu {board}",
|
|
43
92
|
"board.cardLabel": "{title} öffnen, in {column}. Alt und eine Pfeiltaste verschiebt sie.",
|
|
93
|
+
"board.changeAssignee": "Zuständig: {name}, ändern",
|
|
44
94
|
"board.clearAssignee": "Zuständigkeit von {name} entfernen",
|
|
45
95
|
"board.clearDependsOn": "nicht mehr darauf warten",
|
|
46
96
|
"board.clearDue": "entfernen",
|
|
@@ -228,50 +278,6 @@
|
|
|
228
278
|
"flows.validate": "Prüfen, ob er laufen würde",
|
|
229
279
|
"flows.validateReady": "Dieser Flow würde jetzt starten.",
|
|
230
280
|
"flows.validateWhen": "Geprüft {when}. Der Werkzeug-Zugriff wird jedes Mal mit deinem eigenen Token erfragt, diese Antwort ist also eine Momentaufnahme.",
|
|
231
|
-
"attachment.details.checksum": "Prüfsumme",
|
|
232
|
-
"attachment.details.dimensions": "Abmessungen",
|
|
233
|
-
"attachment.details.format": "Format",
|
|
234
|
-
"attachment.details.mimeType": "MIME-Typ",
|
|
235
|
-
"attachment.details.name": "Name",
|
|
236
|
-
"attachment.details.pages": "Seiten",
|
|
237
|
-
"attachment.details.private": "Privat",
|
|
238
|
-
"attachment.details.size": "Größe",
|
|
239
|
-
"attachment.details.sections": "Abschnitte",
|
|
240
|
-
"attachment.details.slides": "Folien",
|
|
241
|
-
"attachment.details.storage": "Speicher",
|
|
242
|
-
"attachment.details.technical": "Technisch",
|
|
243
|
-
"attachment.details.title": "Dateidetails",
|
|
244
|
-
"attachment.details.type": "Typ",
|
|
245
|
-
"attachment.details.uploaded": "Hochgeladen",
|
|
246
|
-
"attachment.details.uploadedBy": "Hochgeladen von",
|
|
247
|
-
"attachment.details.version": "Version",
|
|
248
|
-
"attachment.details.worksheets": "Tabellenblätter",
|
|
249
|
-
"attachment.kind.image": "Bild",
|
|
250
|
-
"attachment.kind.pdf": "PDF-Dokument",
|
|
251
|
-
"attachment.kind.presentation": "Präsentation",
|
|
252
|
-
"attachment.kind.spreadsheet": "Tabelle",
|
|
253
|
-
"attachment.kind.unsupported": "Datei",
|
|
254
|
-
"attachment.kind.word": "Word-Dokument",
|
|
255
|
-
"attachment.detailsHide": "Dateidetails ausblenden",
|
|
256
|
-
"attachment.detailsShow": "Dateidetails anzeigen",
|
|
257
|
-
"attachment.empty": "Es wurde noch keine Datei hochgeladen.",
|
|
258
|
-
"attachment.loadFailed": "Die Datei konnte nicht geladen werden. Das Original lässt sich weiterhin über das Menü exportieren.",
|
|
259
|
-
"attachment.unsupported": "Dieser Dateityp kann nicht angezeigt werden. Das Original lässt sich weiterhin über das Menü exportieren.",
|
|
260
|
-
"attachment.viewer.controls": "Dateisteuerung",
|
|
261
|
-
"attachment.viewer.navigation": "Seitennavigation anzeigen",
|
|
262
|
-
"attachment.viewer.nextPage": "Nächste Seite",
|
|
263
|
-
"attachment.viewer.nextResult": "Nächster Suchtreffer",
|
|
264
|
-
"attachment.viewer.previousPage": "Vorherige Seite",
|
|
265
|
-
"attachment.viewer.previousResult": "Vorheriger Suchtreffer",
|
|
266
|
-
"attachment.viewer.resetZoom": "Zoom zurücksetzen",
|
|
267
|
-
"attachment.viewer.rotateLeft": "Nach links drehen",
|
|
268
|
-
"attachment.viewer.rotateRight": "Nach rechts drehen",
|
|
269
|
-
"attachment.viewer.search": "Dokument durchsuchen",
|
|
270
|
-
"attachment.viewer.searchNoResults": "Keine Treffer",
|
|
271
|
-
"attachment.viewer.searchPlaceholder": "Dokument durchsuchen",
|
|
272
|
-
"attachment.viewer.searchResults": "Suchtreffer {current} von {total}",
|
|
273
|
-
"attachment.viewer.zoomIn": "Vergrößern",
|
|
274
|
-
"attachment.viewer.zoomOut": "Verkleinern",
|
|
275
281
|
"nav.primary": "Hauptnavigation",
|
|
276
282
|
"nav.tools": "Werkzeuge",
|
|
277
283
|
"node.access": "Zugriff",
|
|
@@ -499,6 +505,7 @@
|
|
|
499
505
|
"tree.move.destination": "Neuer Ort: {title}",
|
|
500
506
|
"tree.move.dropRoot": "Hier ablegen, um auf die oberste Ebene zu verschieben",
|
|
501
507
|
"tree.move.elsewhere": "Anderen Ordner wählen",
|
|
508
|
+
"tree.move.failed.archived": "Nicht verschoben: Dieses Ziel ist archiviert. Stell es zuerst wieder her.",
|
|
502
509
|
"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.",
|
|
503
510
|
"tree.move.failed.cycle": "Nicht verschoben: Ein Ordner kann weder in sich selbst noch in etwas, das er enthält.",
|
|
504
511
|
"tree.move.failed.forbidden": "Nicht verschoben: Du darfst in diesen Ordner nicht schreiben. Bitte dort um Schreibzugriff.",
|
package/src/i18n/en.json
CHANGED
|
@@ -29,9 +29,54 @@
|
|
|
29
29
|
"archive.restoring": "Restoring {title}",
|
|
30
30
|
"archive.restoringAction": "Restoring",
|
|
31
31
|
"archive.title": "Archive",
|
|
32
|
+
"attachment.details.checksum": "Checksum",
|
|
33
|
+
"attachment.details.dimensions": "Dimensions",
|
|
34
|
+
"attachment.details.format": "Format",
|
|
35
|
+
"attachment.details.mimeType": "MIME type",
|
|
36
|
+
"attachment.details.name": "Name",
|
|
37
|
+
"attachment.details.pages": "Pages",
|
|
38
|
+
"attachment.details.private": "Private",
|
|
39
|
+
"attachment.details.sections": "Sections",
|
|
40
|
+
"attachment.details.size": "Size",
|
|
41
|
+
"attachment.details.slides": "Slides",
|
|
42
|
+
"attachment.details.storage": "Storage",
|
|
43
|
+
"attachment.details.technical": "Technical",
|
|
44
|
+
"attachment.details.title": "File details",
|
|
45
|
+
"attachment.details.type": "Type",
|
|
46
|
+
"attachment.details.uploaded": "Uploaded",
|
|
47
|
+
"attachment.details.uploadedBy": "Uploaded by",
|
|
48
|
+
"attachment.details.version": "Version",
|
|
49
|
+
"attachment.details.worksheets": "Worksheets",
|
|
50
|
+
"attachment.detailsHide": "Hide file details",
|
|
51
|
+
"attachment.detailsShow": "Show file details",
|
|
52
|
+
"attachment.empty": "No file has been uploaded yet.",
|
|
53
|
+
"attachment.kind.image": "Image",
|
|
54
|
+
"attachment.kind.pdf": "PDF document",
|
|
55
|
+
"attachment.kind.presentation": "Presentation",
|
|
56
|
+
"attachment.kind.spreadsheet": "Spreadsheet",
|
|
57
|
+
"attachment.kind.unsupported": "File",
|
|
58
|
+
"attachment.kind.word": "Word document",
|
|
59
|
+
"attachment.loadFailed": "The file could not be loaded. You can still export the original from the menu.",
|
|
60
|
+
"attachment.unsupported": "This file type cannot be previewed. You can still export the original from the menu.",
|
|
61
|
+
"attachment.viewer.controls": "File controls",
|
|
62
|
+
"attachment.viewer.navigation": "Show page navigation",
|
|
63
|
+
"attachment.viewer.nextPage": "Next page",
|
|
64
|
+
"attachment.viewer.nextResult": "Next search result",
|
|
65
|
+
"attachment.viewer.previousPage": "Previous page",
|
|
66
|
+
"attachment.viewer.previousResult": "Previous search result",
|
|
67
|
+
"attachment.viewer.resetZoom": "Reset zoom",
|
|
68
|
+
"attachment.viewer.rotateLeft": "Rotate left",
|
|
69
|
+
"attachment.viewer.rotateRight": "Rotate right",
|
|
70
|
+
"attachment.viewer.search": "Search document",
|
|
71
|
+
"attachment.viewer.searchNoResults": "No results",
|
|
72
|
+
"attachment.viewer.searchPlaceholder": "Search document",
|
|
73
|
+
"attachment.viewer.searchResults": "Search result {current} of {total}",
|
|
74
|
+
"attachment.viewer.zoomIn": "Zoom in",
|
|
75
|
+
"attachment.viewer.zoomOut": "Zoom out",
|
|
32
76
|
"auth.signOut": "Sign out",
|
|
33
77
|
"auth.signOutFailed": "Signing out failed. Check your connection and try again.",
|
|
34
78
|
"board.add": "Add something",
|
|
79
|
+
"board.add.assignee": "Assignee",
|
|
35
80
|
"board.add.due": "Due",
|
|
36
81
|
"board.add.label": "Label",
|
|
37
82
|
"board.add.start": "Start",
|
|
@@ -39,8 +84,13 @@
|
|
|
39
84
|
"board.addCardIn": "Add a task to {column}",
|
|
40
85
|
"board.addLabel": "Add a label",
|
|
41
86
|
"board.addLink": "Link",
|
|
87
|
+
"board.assignee.clear": "Remove the assignee",
|
|
88
|
+
"board.assignee.keepTyping": "Type at least two characters",
|
|
89
|
+
"board.assignee.nobody": "Nobody with access to this board matches",
|
|
90
|
+
"board.assignee.search": "Find a person",
|
|
42
91
|
"board.backTo": "Back to {board}",
|
|
43
92
|
"board.cardLabel": "Open {title}, in {column}. Alt and an arrow key moves it.",
|
|
93
|
+
"board.changeAssignee": "Assigned to {name}, change",
|
|
44
94
|
"board.clearAssignee": "Take the assignment off {name}",
|
|
45
95
|
"board.clearDependsOn": "stop waiting for it",
|
|
46
96
|
"board.clearDue": "clear it",
|
|
@@ -228,50 +278,6 @@
|
|
|
228
278
|
"flows.validate": "Check whether it would run",
|
|
229
279
|
"flows.validateReady": "This flow would start now.",
|
|
230
280
|
"flows.validateWhen": "Checked {when}. Tool access is asked with your own token each time, so this answer is a snapshot.",
|
|
231
|
-
"attachment.details.checksum": "Checksum",
|
|
232
|
-
"attachment.details.dimensions": "Dimensions",
|
|
233
|
-
"attachment.details.format": "Format",
|
|
234
|
-
"attachment.details.mimeType": "MIME type",
|
|
235
|
-
"attachment.details.name": "Name",
|
|
236
|
-
"attachment.details.pages": "Pages",
|
|
237
|
-
"attachment.details.private": "Private",
|
|
238
|
-
"attachment.details.size": "Size",
|
|
239
|
-
"attachment.details.sections": "Sections",
|
|
240
|
-
"attachment.details.slides": "Slides",
|
|
241
|
-
"attachment.details.storage": "Storage",
|
|
242
|
-
"attachment.details.technical": "Technical",
|
|
243
|
-
"attachment.details.title": "File details",
|
|
244
|
-
"attachment.details.type": "Type",
|
|
245
|
-
"attachment.details.uploaded": "Uploaded",
|
|
246
|
-
"attachment.details.uploadedBy": "Uploaded by",
|
|
247
|
-
"attachment.details.version": "Version",
|
|
248
|
-
"attachment.details.worksheets": "Worksheets",
|
|
249
|
-
"attachment.kind.image": "Image",
|
|
250
|
-
"attachment.kind.pdf": "PDF document",
|
|
251
|
-
"attachment.kind.presentation": "Presentation",
|
|
252
|
-
"attachment.kind.spreadsheet": "Spreadsheet",
|
|
253
|
-
"attachment.kind.unsupported": "File",
|
|
254
|
-
"attachment.kind.word": "Word document",
|
|
255
|
-
"attachment.detailsHide": "Hide file details",
|
|
256
|
-
"attachment.detailsShow": "Show file details",
|
|
257
|
-
"attachment.empty": "No file has been uploaded yet.",
|
|
258
|
-
"attachment.loadFailed": "The file could not be loaded. You can still export the original from the menu.",
|
|
259
|
-
"attachment.unsupported": "This file type cannot be previewed. You can still export the original from the menu.",
|
|
260
|
-
"attachment.viewer.controls": "File controls",
|
|
261
|
-
"attachment.viewer.navigation": "Show page navigation",
|
|
262
|
-
"attachment.viewer.nextPage": "Next page",
|
|
263
|
-
"attachment.viewer.nextResult": "Next search result",
|
|
264
|
-
"attachment.viewer.previousPage": "Previous page",
|
|
265
|
-
"attachment.viewer.previousResult": "Previous search result",
|
|
266
|
-
"attachment.viewer.resetZoom": "Reset zoom",
|
|
267
|
-
"attachment.viewer.rotateLeft": "Rotate left",
|
|
268
|
-
"attachment.viewer.rotateRight": "Rotate right",
|
|
269
|
-
"attachment.viewer.search": "Search document",
|
|
270
|
-
"attachment.viewer.searchNoResults": "No results",
|
|
271
|
-
"attachment.viewer.searchPlaceholder": "Search document",
|
|
272
|
-
"attachment.viewer.searchResults": "Search result {current} of {total}",
|
|
273
|
-
"attachment.viewer.zoomIn": "Zoom in",
|
|
274
|
-
"attachment.viewer.zoomOut": "Zoom out",
|
|
275
281
|
"nav.primary": "Primary navigation",
|
|
276
282
|
"nav.tools": "Tools",
|
|
277
283
|
"node.access": "Access",
|
|
@@ -499,6 +505,7 @@
|
|
|
499
505
|
"tree.move.destination": "New place: {title}",
|
|
500
506
|
"tree.move.dropRoot": "Drop here to move to the top level",
|
|
501
507
|
"tree.move.elsewhere": "Choose another folder",
|
|
508
|
+
"tree.move.failed.archived": "It was not moved: that destination is archived. Restore it first.",
|
|
502
509
|
"tree.move.failed.conflict": "It was not moved: somebody else changed this entry first. The tree has been reloaded — look again, then move it.",
|
|
503
510
|
"tree.move.failed.cycle": "It was not moved: a folder cannot be put inside itself or inside anything it contains.",
|
|
504
511
|
"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
|
@@ -29,9 +29,54 @@
|
|
|
29
29
|
"archive.restoring": "Restaurando {title}",
|
|
30
30
|
"archive.restoringAction": "Restaurando",
|
|
31
31
|
"archive.title": "Archivo",
|
|
32
|
+
"attachment.details.checksum": "Suma de comprobación",
|
|
33
|
+
"attachment.details.dimensions": "Dimensiones",
|
|
34
|
+
"attachment.details.format": "Formato",
|
|
35
|
+
"attachment.details.mimeType": "Tipo MIME",
|
|
36
|
+
"attachment.details.name": "Nombre",
|
|
37
|
+
"attachment.details.pages": "Páginas",
|
|
38
|
+
"attachment.details.private": "Privado",
|
|
39
|
+
"attachment.details.sections": "Secciones",
|
|
40
|
+
"attachment.details.size": "Tamaño",
|
|
41
|
+
"attachment.details.slides": "Diapositivas",
|
|
42
|
+
"attachment.details.storage": "Almacenamiento",
|
|
43
|
+
"attachment.details.technical": "Técnico",
|
|
44
|
+
"attachment.details.title": "Detalles del archivo",
|
|
45
|
+
"attachment.details.type": "Tipo",
|
|
46
|
+
"attachment.details.uploaded": "Subido",
|
|
47
|
+
"attachment.details.uploadedBy": "Subido por",
|
|
48
|
+
"attachment.details.version": "Versión",
|
|
49
|
+
"attachment.details.worksheets": "Hojas",
|
|
50
|
+
"attachment.detailsHide": "Ocultar detalles del archivo",
|
|
51
|
+
"attachment.detailsShow": "Mostrar detalles del archivo",
|
|
52
|
+
"attachment.empty": "Todavía no se ha subido ningún archivo.",
|
|
53
|
+
"attachment.kind.image": "Imagen",
|
|
54
|
+
"attachment.kind.pdf": "Documento PDF",
|
|
55
|
+
"attachment.kind.presentation": "Presentación",
|
|
56
|
+
"attachment.kind.spreadsheet": "Hoja de cálculo",
|
|
57
|
+
"attachment.kind.unsupported": "Archivo",
|
|
58
|
+
"attachment.kind.word": "Documento de Word",
|
|
59
|
+
"attachment.loadFailed": "No se pudo cargar el archivo. Aún puedes exportar el original desde el menú.",
|
|
60
|
+
"attachment.unsupported": "No se puede previsualizar este tipo de archivo. Aún puedes exportar el original desde el menú.",
|
|
61
|
+
"attachment.viewer.controls": "Controles del archivo",
|
|
62
|
+
"attachment.viewer.navigation": "Mostrar navegación de páginas",
|
|
63
|
+
"attachment.viewer.nextPage": "Página siguiente",
|
|
64
|
+
"attachment.viewer.nextResult": "Siguiente resultado de búsqueda",
|
|
65
|
+
"attachment.viewer.previousPage": "Página anterior",
|
|
66
|
+
"attachment.viewer.previousResult": "Resultado de búsqueda anterior",
|
|
67
|
+
"attachment.viewer.resetZoom": "Restablecer zoom",
|
|
68
|
+
"attachment.viewer.rotateLeft": "Girar a la izquierda",
|
|
69
|
+
"attachment.viewer.rotateRight": "Girar a la derecha",
|
|
70
|
+
"attachment.viewer.search": "Buscar en el documento",
|
|
71
|
+
"attachment.viewer.searchNoResults": "Sin resultados",
|
|
72
|
+
"attachment.viewer.searchPlaceholder": "Buscar en el documento",
|
|
73
|
+
"attachment.viewer.searchResults": "Resultado {current} de {total}",
|
|
74
|
+
"attachment.viewer.zoomIn": "Ampliar",
|
|
75
|
+
"attachment.viewer.zoomOut": "Reducir",
|
|
32
76
|
"auth.signOut": "Cerrar sesión",
|
|
33
77
|
"auth.signOutFailed": "El cierre de sesión ha fallado. Comprueba tu conexión e inténtalo de nuevo.",
|
|
34
78
|
"board.add": "Añadir algo",
|
|
79
|
+
"board.add.assignee": "Responsable",
|
|
35
80
|
"board.add.due": "Vencimiento",
|
|
36
81
|
"board.add.label": "Etiqueta",
|
|
37
82
|
"board.add.start": "Inicio",
|
|
@@ -39,8 +84,13 @@
|
|
|
39
84
|
"board.addCardIn": "Añadir una tarea a {column}",
|
|
40
85
|
"board.addLabel": "Añadir etiqueta",
|
|
41
86
|
"board.addLink": "Vínculo",
|
|
87
|
+
"board.assignee.clear": "Quitar el responsable",
|
|
88
|
+
"board.assignee.keepTyping": "Escribe al menos dos caracteres",
|
|
89
|
+
"board.assignee.nobody": "Nadie con acceso a este tablero coincide",
|
|
90
|
+
"board.assignee.search": "Buscar una persona",
|
|
42
91
|
"board.backTo": "Volver a {board}",
|
|
43
92
|
"board.cardLabel": "Abrir {title}, en {column}. Alt y una flecha la mueve.",
|
|
93
|
+
"board.changeAssignee": "Responsable: {name}, cambiar",
|
|
44
94
|
"board.clearAssignee": "Quitar la responsabilidad de {name}",
|
|
45
95
|
"board.clearDependsOn": "dejar de esperarlo",
|
|
46
96
|
"board.clearDue": "quitar",
|
|
@@ -228,50 +278,6 @@
|
|
|
228
278
|
"flows.validate": "Comprobar si se ejecutaría",
|
|
229
279
|
"flows.validateReady": "Este flujo empezaría ahora mismo.",
|
|
230
280
|
"flows.validateWhen": "Comprobado {when}. El acceso a las herramientas se pide cada vez con tu propio token, así que esta respuesta es una instantánea.",
|
|
231
|
-
"attachment.details.checksum": "Suma de comprobación",
|
|
232
|
-
"attachment.details.dimensions": "Dimensiones",
|
|
233
|
-
"attachment.details.format": "Formato",
|
|
234
|
-
"attachment.details.mimeType": "Tipo MIME",
|
|
235
|
-
"attachment.details.name": "Nombre",
|
|
236
|
-
"attachment.details.pages": "Páginas",
|
|
237
|
-
"attachment.details.private": "Privado",
|
|
238
|
-
"attachment.details.size": "Tamaño",
|
|
239
|
-
"attachment.details.sections": "Secciones",
|
|
240
|
-
"attachment.details.slides": "Diapositivas",
|
|
241
|
-
"attachment.details.storage": "Almacenamiento",
|
|
242
|
-
"attachment.details.technical": "Técnico",
|
|
243
|
-
"attachment.details.title": "Detalles del archivo",
|
|
244
|
-
"attachment.details.type": "Tipo",
|
|
245
|
-
"attachment.details.uploaded": "Subido",
|
|
246
|
-
"attachment.details.uploadedBy": "Subido por",
|
|
247
|
-
"attachment.details.version": "Versión",
|
|
248
|
-
"attachment.details.worksheets": "Hojas",
|
|
249
|
-
"attachment.kind.image": "Imagen",
|
|
250
|
-
"attachment.kind.pdf": "Documento PDF",
|
|
251
|
-
"attachment.kind.presentation": "Presentación",
|
|
252
|
-
"attachment.kind.spreadsheet": "Hoja de cálculo",
|
|
253
|
-
"attachment.kind.unsupported": "Archivo",
|
|
254
|
-
"attachment.kind.word": "Documento de Word",
|
|
255
|
-
"attachment.detailsHide": "Ocultar detalles del archivo",
|
|
256
|
-
"attachment.detailsShow": "Mostrar detalles del archivo",
|
|
257
|
-
"attachment.empty": "Todavía no se ha subido ningún archivo.",
|
|
258
|
-
"attachment.loadFailed": "No se pudo cargar el archivo. Aún puedes exportar el original desde el menú.",
|
|
259
|
-
"attachment.unsupported": "No se puede previsualizar este tipo de archivo. Aún puedes exportar el original desde el menú.",
|
|
260
|
-
"attachment.viewer.controls": "Controles del archivo",
|
|
261
|
-
"attachment.viewer.navigation": "Mostrar navegación de páginas",
|
|
262
|
-
"attachment.viewer.nextPage": "Página siguiente",
|
|
263
|
-
"attachment.viewer.nextResult": "Siguiente resultado de búsqueda",
|
|
264
|
-
"attachment.viewer.previousPage": "Página anterior",
|
|
265
|
-
"attachment.viewer.previousResult": "Resultado de búsqueda anterior",
|
|
266
|
-
"attachment.viewer.resetZoom": "Restablecer zoom",
|
|
267
|
-
"attachment.viewer.rotateLeft": "Girar a la izquierda",
|
|
268
|
-
"attachment.viewer.rotateRight": "Girar a la derecha",
|
|
269
|
-
"attachment.viewer.search": "Buscar en el documento",
|
|
270
|
-
"attachment.viewer.searchNoResults": "Sin resultados",
|
|
271
|
-
"attachment.viewer.searchPlaceholder": "Buscar en el documento",
|
|
272
|
-
"attachment.viewer.searchResults": "Resultado {current} de {total}",
|
|
273
|
-
"attachment.viewer.zoomIn": "Ampliar",
|
|
274
|
-
"attachment.viewer.zoomOut": "Reducir",
|
|
275
281
|
"nav.primary": "Navegación principal",
|
|
276
282
|
"nav.tools": "Herramientas",
|
|
277
283
|
"node.access": "Acceso",
|
|
@@ -499,6 +505,7 @@
|
|
|
499
505
|
"tree.move.destination": "Sitio nuevo: {title}",
|
|
500
506
|
"tree.move.dropRoot": "Suelta aquí para mover al nivel superior",
|
|
501
507
|
"tree.move.elsewhere": "Elegir otra carpeta",
|
|
508
|
+
"tree.move.failed.archived": "No se ha movido: ese destino está archivado. Restáuralo primero.",
|
|
502
509
|
"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.",
|
|
503
510
|
"tree.move.failed.cycle": "No se ha movido: una carpeta no puede ir dentro de sí misma ni dentro de nada que contenga.",
|
|
504
511
|
"tree.move.failed.forbidden": "No se ha movido: no puedes escribir en esa carpeta. Pide acceso de escritura allí.",
|