@anchrd/intel-ui 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,17 @@
1
1
  import type { BoardTask } from "@anchrd/intel-contract/board";
2
- import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
3
2
  import { Lock, Plus, Square, SquareCheck } from "lucide-react";
4
3
  import { useState } from "react";
5
- import { useAssigneeLabel } from "@/board/board-assignee/board-assignee.ts";
4
+ import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
5
+ import { BoardChip } from "@/board/board-chip/board-chip.tsx";
6
6
  import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
7
+ import { StatusChip } from "@/board/board-status/board-status.tsx";
7
8
  import {
8
9
  DropdownMenu,
9
10
  DropdownMenuContent,
10
11
  DropdownMenuItem,
11
- DropdownMenuRadioGroup,
12
- DropdownMenuRadioItem,
13
12
  DropdownMenuTrigger,
14
13
  } from "@/components/ui/dropdown-menu.tsx";
15
14
  import { useI18n } from "@/i18n/i18n-context.tsx";
16
- import { initials } from "@/user-name/user-name.ts";
17
15
  import { type LinkKind, linksOf, progressOf, type TaskLink } from "./board-task.ts";
18
16
 
19
17
  const linkIcons: Record<LinkKind, typeof Lock> = {
@@ -22,54 +20,6 @@ const linkIcons: Record<LinkKind, typeof Lock> = {
22
20
  blocked: Lock,
23
21
  };
24
22
 
25
- /**
26
- * The circle for whoever a card is for, and the way to take the assignment off.
27
- *
28
- * ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed from the access
29
- * summary, the field stays `assigneeId`. A row of circles here would imply a second data model that
30
- * does not exist.
31
- *
32
- * ⚠️ **Only ever drawn for somebody.** "Nobody yet" as a chip is a placeholder for an absence, and
33
- * an absence needs no place on screen (#692).
34
- *
35
- * ⚠️ **The NAME lives on the button, and the circle is hidden.** Children of a `<button>` are
36
- * presentational in ARIA, so a label inside one is never announced and the button's own name wins.
37
- * Wrapping the circle in a control therefore takes the person out of the accessibility tree unless
38
- * the control says it too. That is the trap in `packages/ui/CLAUDE.md`, walked into by the very
39
- * change that documented it.
40
- */
41
- function AssigneeChip({ id, clear }: { id: string; clear(): void }) {
42
- const i18n = useI18n();
43
- const label = useAssigneeLabel(id);
44
- return (
45
- <button
46
- type="button"
47
- aria-label={i18n.t("board.clearAssignee", { name: label })}
48
- title={label}
49
- onClick={clear}
50
- className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
51
- >
52
- <AssigneeCircle label={label} />
53
- </button>
54
- );
55
- }
56
-
57
- function AssigneeCircle({ label }: { label: string }) {
58
- return (
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>`, whose children are
62
- // presentational in ARIA. Giving it a role and a label back would not add a second announcement
63
- // — it would add none, while making the code look as though the name were covered here rather
64
- // than on the button. See `AssigneeChip` above and `packages/ui/CLAUDE.md`.
65
- aria-hidden="true"
66
- className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
67
- >
68
- {initials(label)}
69
- </span>
70
- );
71
- }
72
-
73
23
  /** One entry of the link list. The icon carries the kind; nothing writes the word out. */
74
24
  function LinkRow({ link, onOpen }: { link: TaskLink; onOpen(id: string): void }) {
75
25
  const i18n = useI18n();
@@ -145,57 +95,37 @@ export function BoardTaskDocument({
145
95
  {/* ⚠️ ONE line of chips, and no field labels. "Status:" in front of a chip that says "Backlog"
146
96
  says nothing the chip does not — and it turns a quiet head into a form. */}
147
97
  <div className="flex flex-wrap items-center gap-2">
148
- <DropdownMenu>
149
- <DropdownMenuTrigger
150
- aria-label={i18n.t("board.moveTo")}
151
- className="rounded-full border px-3 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
152
- >
153
- {column?.title ?? task.status}
154
- </DropdownMenuTrigger>
155
- <DropdownMenuContent align="start">
156
- <DropdownMenuRadioGroup
157
- value={task.status}
158
- // ⚠️ Where a task SITS is a move, not a field edit: the server computes a position and
159
- // checks the cycles for it. Everything else on this screen is a plain change.
160
- onValueChange={(next) => write({ status: next })}
161
- >
162
- {columns.map((entry) => (
163
- <DropdownMenuRadioItem key={entry.id} value={entry.id}>
164
- {entry.title}
165
- </DropdownMenuRadioItem>
166
- ))}
167
- <DropdownMenuRadioItem value={ARCHIVE_COLUMN_ID}>
168
- {i18n.t("board.column.archive")}
169
- </DropdownMenuRadioItem>
170
- </DropdownMenuRadioGroup>
171
- </DropdownMenuContent>
172
- </DropdownMenu>
98
+ <StatusChip
99
+ status={task.status}
100
+ title={column?.title ?? task.status}
101
+ columns={columns}
102
+ write={write}
103
+ />
173
104
 
174
105
  {/* ⚠️ Two chips when both dates are set, not a range. A range reads as one fact; these
175
106
  are two, and each is removed on its own. */}
176
107
  {task.startDate === null ? null : (
177
- <DateChip
108
+ <BoardChip
178
109
  label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
179
- remove={() => write({ startDate: null })}
110
+ action={i18n.t("board.clearStart")}
111
+ onClick={() => write({ startDate: null })}
180
112
  />
181
113
  )}
182
114
  {task.dueDate === null ? null : (
183
- <DateChip
115
+ <BoardChip
184
116
  label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
185
- remove={() => write({ dueDate: null })}
117
+ action={i18n.t("board.clearDue")}
118
+ onClick={() => write({ dueDate: null })}
186
119
  />
187
120
  )}
188
121
 
189
122
  {task.labels.map((entry) => (
190
- <button
123
+ <BoardChip
191
124
  key={entry}
192
- type="button"
193
- aria-label={i18n.t("board.removeLabel", { label: entry })}
125
+ label={entry}
126
+ action={i18n.t("board.removeLabel")}
194
127
  onClick={() => write({ labels: task.labels.filter((keep) => keep !== entry) })}
195
- className="rounded-full border px-3 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
196
- >
197
- {entry}
198
- </button>
128
+ />
199
129
  ))}
200
130
 
201
131
  {/* ⚠️ **Last, always.** It is the only round element in the row; standing between the chips
@@ -245,19 +175,6 @@ export function BoardTaskDocument({
245
175
  * card they mean, not before.
246
176
  */
247
177
 
248
- /** A date, and the way to take it off again. */
249
- function DateChip({ label, remove }: { label: string; remove(): void }) {
250
- return (
251
- <button
252
- type="button"
253
- onClick={remove}
254
- className="rounded-full border px-3 py-1 text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
255
- >
256
- {label}
257
- </button>
258
- );
259
- }
260
-
261
178
  /**
262
179
  * What the card can still be given. The order matches the chips it produces.
263
180
  *
@@ -332,7 +249,7 @@ function Adder({
332
249
  <DropdownMenu>
333
250
  <DropdownMenuTrigger
334
251
  aria-label={i18n.t("board.add")}
335
- className="rounded-full border px-2 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
252
+ className="rounded-full border px-2 py-0.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
336
253
  >
337
254
  <Plus aria-hidden="true" className="size-3" />
338
255
  </DropdownMenuTrigger>
@@ -364,7 +281,7 @@ function Adder({
364
281
  event.preventDefault();
365
282
  commit();
366
283
  }}
367
- className="w-36 rounded-full border bg-background px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
284
+ className="w-36 rounded-full border bg-background px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
368
285
  />
369
286
  )}
370
287
 
@@ -372,7 +289,7 @@ function Adder({
372
289
  <DropdownMenu open onOpenChange={(next) => !next && close()}>
373
290
  <DropdownMenuTrigger
374
291
  aria-label={i18n.t("board.link.blocker")}
375
- className="rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
292
+ className="rounded-full border px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
376
293
  >
377
294
  {i18n.t("board.link.blocker")}
378
295
  </DropdownMenuTrigger>
@@ -1,9 +1,66 @@
1
- import FileViewer, { type ViewerOptions } from "@file-viewer/react";
2
- import { useMemo } from "react";
1
+ import FileViewer, {
2
+ type FileViewerHandle,
3
+ type ViewerOptions,
4
+ type ViewerState,
5
+ } from "@file-viewer/react";
6
+ import {
7
+ ChevronLeft,
8
+ ChevronRight,
9
+ Minus,
10
+ PanelLeft,
11
+ Plus,
12
+ RotateCcw,
13
+ RotateCw,
14
+ Search,
15
+ X,
16
+ } from "lucide-react";
17
+ import { useEffect, useMemo, useRef, useState } from "react";
18
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
19
+ import { useI18n } from "@/i18n/i18n-context.tsx";
3
20
  import { useResolvedTheme } from "@/theme/theme-context.tsx";
4
21
 
5
- export function FilePreviewView({ file, renderer }: { file: File; renderer: unknown }) {
22
+ // The PDF renderer exposes no stable Shadow Parts for its navigation internals. These selectors
23
+ // intentionally target the pinned @file-viewer/renderer-pdf 2.3.0 DOM; verify them before upgrades.
24
+ const viewerTheme = `
25
+ :host,.file-viewer-web-shell{
26
+ --file-viewer-render-surface-background:var(--muted);
27
+ --file-viewer-bg:var(--background);--file-viewer-content-bg:var(--muted);
28
+ --file-viewer-text:var(--foreground);--file-viewer-muted:var(--muted-foreground);
29
+ --file-viewer-border:var(--border);--file-viewer-toolbar-bg:var(--background);
30
+ --file-viewer-toolbar-border:var(--border);--file-viewer-group-bg:var(--muted);
31
+ --file-viewer-group-border:var(--border);--file-viewer-button-color:var(--foreground);
32
+ --file-viewer-button-hover-bg:var(--accent);--file-viewer-button-hover-color:var(--accent-foreground);
33
+ --file-viewer-button-disabled-color:var(--muted-foreground);--file-viewer-input-bg:var(--background);
34
+ --file-viewer-input-color:var(--foreground);--file-viewer-focus-ring:var(--ring);
35
+ }
36
+ .pdf-shell,.pdf-wrapper{background:var(--muted)!important;color:var(--foreground)!important}
37
+ .pdf-nav-pane,.pdf-nav-tabs,.pdf-nav-head{background:var(--background)!important;border-color:var(--border)!important}
38
+ .pdf-nav-head{display:none!important}.pdf-nav-tabs{display:flex!important;justify-content:flex-end;padding:6px!important}
39
+ .pdf-nav-tabs button{width:30px;min-width:30px;padding:0;font-size:0;color:var(--muted-foreground)!important}
40
+ .pdf-nav-tabs button svg{width:15px;height:15px;margin:auto;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
41
+ .pdf-nav-tabs button:hover,.pdf-nav-tabs button.active{background:var(--accent)!important;border-color:var(--border)!important;color:var(--accent-foreground)!important}
42
+ .pdf-page-list{gap:4px!important;padding:6px!important}.pdf-page-button{display:flex!important;min-height:36px!important;padding:4px!important;border-color:transparent!important;background:transparent!important;color:var(--foreground)!important}
43
+ .pdf-page-button:hover,.pdf-page-button--active{background:var(--accent)!important;border-color:var(--border)!important;box-shadow:none!important}
44
+ .pdf-page-thumb{width:28px!important;height:28px!important;border:0!important;background:transparent!important;color:var(--foreground)!important}
45
+ .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}
46
+ `;
47
+
48
+ export function FilePreviewView({
49
+ file,
50
+ renderer,
51
+ pdf = false,
52
+ }: {
53
+ file: File;
54
+ renderer: unknown;
55
+ pdf?: boolean;
56
+ }) {
6
57
  const theme = useResolvedTheme();
58
+ const i18n = useI18n();
59
+ const viewer = useRef<FileViewerHandle>(null);
60
+ const searchInput = useRef<HTMLInputElement>(null);
61
+ const [state, setState] = useState<ViewerState | null>(null);
62
+ const [searchOpen, setSearchOpen] = useState(false);
63
+ const [query, setQuery] = useState("");
7
64
  const options = useMemo<ViewerOptions>(
8
65
  () => ({
9
66
  renderers: [renderer] as unknown as NonNullable<ViewerOptions["renderers"]>,
@@ -13,16 +70,273 @@ export function FilePreviewView({ file, renderer }: { file: File; renderer: unkn
13
70
  styleIsolation: "shadow",
14
71
  theme,
15
72
  locale: "auto",
16
- toolbar: {
17
- download: false,
18
- print: false,
19
- exportHtml: false,
20
- theme: false,
21
- permissions: { download: false, print: false, exportHtml: false },
22
- },
73
+ toolbar: false,
74
+ ...(pdf
75
+ ? { pdf: { toolbar: false, navigation: true, defaultNavigationVisible: false } }
76
+ : {}),
23
77
  ui: { density: "compact", surfaceBackground: "transparent" },
24
78
  }),
25
- [renderer, theme],
79
+ [pdf, renderer, theme],
80
+ );
81
+ const viewState = state?.viewState;
82
+ const page = viewState?.page ?? 1;
83
+ const pageCount = viewState?.pageCount ?? 0;
84
+ const scale = state?.zoom?.scale ?? viewState?.scale ?? 1;
85
+ const search = state?.search;
86
+
87
+ useEffect(() => {
88
+ if (!state?.ready) return;
89
+ const root = viewer.current?.getController()?.container.shadowRoot;
90
+ if (!root || root.querySelector("style[data-intel-viewer-theme]")) return;
91
+ const style = document.createElement("style");
92
+ style.dataset.intelViewerTheme = "true";
93
+ style.textContent = viewerTheme;
94
+ root.append(style);
95
+ const labelPages = () => {
96
+ for (const button of root.querySelectorAll<HTMLButtonElement>(".pdf-page-button")) {
97
+ const label = button.querySelector(".pdf-page-label")?.textContent?.trim();
98
+ if (label) button.title = label;
99
+ }
100
+ for (const [index, button] of root
101
+ .querySelectorAll<HTMLButtonElement>(".pdf-nav-tabs button")
102
+ .entries()) {
103
+ if (button.querySelector("[data-intel-navigation-icon]")) continue;
104
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
105
+ svg.dataset.intelNavigationIcon = "true";
106
+ svg.setAttribute("viewBox", "0 0 24 24");
107
+ svg.setAttribute("aria-hidden", "true");
108
+ const paths =
109
+ index === 0
110
+ ? ["M8 6h13M8 12h13M8 18h13", "M3 6h.01M3 12h.01M3 18h.01"]
111
+ : ["M4 6h7M9 12h11M13 18h7", "M4 6v12h9M9 6v6M13 12v6"];
112
+ for (const pathData of paths) {
113
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
114
+ path.setAttribute("d", pathData);
115
+ svg.append(path);
116
+ }
117
+ button.prepend(svg);
118
+ }
119
+ };
120
+ labelPages();
121
+ const observer = new MutationObserver(labelPages);
122
+ observer.observe(root, { childList: true, subtree: true });
123
+ return () => observer.disconnect();
124
+ }, [state?.ready]);
125
+
126
+ useEffect(() => {
127
+ if (searchOpen) searchInput.current?.focus();
128
+ }, [searchOpen]);
129
+
130
+ const applyViewState = (next: Record<string, unknown>) =>
131
+ viewer.current?.applyViewState({ ...viewState, ...next });
132
+
133
+ return (
134
+ <div className="relative h-full min-h-64">
135
+ {pdf ? (
136
+ <ActionSlot name="title-actions">
137
+ <button
138
+ type="button"
139
+ aria-label={i18n.t("attachment.viewer.navigation")}
140
+ aria-pressed={viewState?.navigation?.visible ?? false}
141
+ onClick={() =>
142
+ void applyViewState({
143
+ navigation: {
144
+ ...viewState?.navigation,
145
+ visible: !(viewState?.navigation?.visible ?? false),
146
+ },
147
+ })
148
+ }
149
+ className="inline-flex size-8 items-center justify-center rounded-md bg-transparent outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-muted"
150
+ >
151
+ <PanelLeft aria-hidden="true" className="size-4" />
152
+ </button>
153
+ </ActionSlot>
154
+ ) : null}
155
+ <div className="absolute inset-x-0 top-0 bottom-14 min-h-0">
156
+ <FileViewer
157
+ ref={viewer}
158
+ file={file}
159
+ className="h-full min-h-0"
160
+ options={options}
161
+ onStateChange={setState}
162
+ />
163
+ </div>
164
+ {state?.ready ? (
165
+ <div
166
+ role="toolbar"
167
+ aria-label={i18n.t("attachment.viewer.controls")}
168
+ className="absolute inset-x-3 bottom-2 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/95 p-1 shadow-lg backdrop-blur"
169
+ >
170
+ <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
171
+ {search?.query
172
+ ? search.total > 0
173
+ ? i18n.t("attachment.viewer.searchResults", {
174
+ current: search.currentIndex + 1,
175
+ total: search.total,
176
+ })
177
+ : i18n.t("attachment.viewer.searchNoResults")
178
+ : ""}
179
+ </span>
180
+ {pdf && pageCount > 0 && !searchOpen ? (
181
+ <div className="flex items-center rounded-full bg-muted p-0.5">
182
+ <button
183
+ type="button"
184
+ aria-label={i18n.t("attachment.viewer.previousPage")}
185
+ disabled={page <= 1}
186
+ onClick={() => void applyViewState({ page: page - 1 })}
187
+ className="size-7 rounded-full hover:bg-accent disabled:opacity-40"
188
+ >
189
+ <ChevronLeft aria-hidden="true" className="mx-auto size-3.5" />
190
+ </button>
191
+ <span className="min-w-14 px-1 text-center text-xs tabular-nums">
192
+ {page} / {pageCount}
193
+ </span>
194
+ <button
195
+ type="button"
196
+ aria-label={i18n.t("attachment.viewer.nextPage")}
197
+ disabled={page >= pageCount}
198
+ onClick={() => void applyViewState({ page: page + 1 })}
199
+ className="size-7 rounded-full hover:bg-accent disabled:opacity-40"
200
+ >
201
+ <ChevronRight aria-hidden="true" className="mx-auto size-3.5" />
202
+ </button>
203
+ </div>
204
+ ) : null}
205
+ {searchOpen ? (
206
+ <form
207
+ className="flex min-w-0 items-center gap-1"
208
+ onSubmit={(event) => {
209
+ event.preventDefault();
210
+ void viewer.current?.searchDocument(query);
211
+ }}
212
+ >
213
+ <input
214
+ ref={searchInput}
215
+ value={query}
216
+ onChange={(event) => setQuery(event.target.value)}
217
+ placeholder={i18n.t("attachment.viewer.searchPlaceholder")}
218
+ className="h-7 w-28 min-w-0 rounded-full border bg-background px-3 text-xs outline-none focus:ring-2 focus:ring-ring sm:w-40"
219
+ />
220
+ <button
221
+ type="submit"
222
+ aria-label={i18n.t("attachment.viewer.search")}
223
+ className="grid size-7 place-items-center rounded-full hover:bg-accent"
224
+ >
225
+ <Search aria-hidden="true" className="size-3.5" />
226
+ </button>
227
+ {search && search.total > 0 ? (
228
+ <div className="flex items-center rounded-full bg-muted p-0.5">
229
+ <button
230
+ type="button"
231
+ aria-label={i18n.t("attachment.viewer.previousResult")}
232
+ disabled={search.total <= 1}
233
+ onClick={() => void viewer.current?.previousSearchResult()}
234
+ className="grid size-7 place-items-center rounded-full hover:bg-accent disabled:opacity-40"
235
+ >
236
+ <ChevronLeft aria-hidden="true" className="size-3.5" />
237
+ </button>
238
+ <span
239
+ aria-hidden="true"
240
+ className="min-w-10 px-1 text-center text-xs tabular-nums"
241
+ >
242
+ {search.currentIndex + 1} / {search.total}
243
+ </span>
244
+ <button
245
+ type="button"
246
+ aria-label={i18n.t("attachment.viewer.nextResult")}
247
+ disabled={search.total <= 1}
248
+ onClick={() => void viewer.current?.nextSearchResult()}
249
+ className="grid size-7 place-items-center rounded-full hover:bg-accent disabled:opacity-40"
250
+ >
251
+ <ChevronRight aria-hidden="true" className="size-3.5" />
252
+ </button>
253
+ </div>
254
+ ) : null}
255
+ {search?.query && search.total === 0 ? (
256
+ <span aria-hidden="true" className="min-w-10 px-1 text-center text-xs tabular-nums">
257
+ {i18n.t("attachment.viewer.searchNoResults")}
258
+ </span>
259
+ ) : null}
260
+ <button
261
+ type="button"
262
+ aria-label={i18n.t("common.close")}
263
+ onClick={() => {
264
+ setSearchOpen(false);
265
+ setQuery("");
266
+ void viewer.current?.clearDocumentSearch();
267
+ }}
268
+ className="grid size-7 place-items-center rounded-full hover:bg-accent"
269
+ >
270
+ <X aria-hidden="true" className="size-3.5" />
271
+ </button>
272
+ </form>
273
+ ) : (
274
+ <button
275
+ type="button"
276
+ aria-label={i18n.t("attachment.viewer.search")}
277
+ onClick={() => setSearchOpen(true)}
278
+ className="grid size-8 place-items-center rounded-full hover:bg-accent"
279
+ >
280
+ <Search aria-hidden="true" className="size-4" />
281
+ </button>
282
+ )}
283
+ {!searchOpen ? (
284
+ <div className="flex items-center rounded-full bg-muted p-0.5">
285
+ <button
286
+ type="button"
287
+ aria-label={i18n.t("attachment.viewer.zoomOut")}
288
+ disabled={state.zoom ? !state.zoom.canZoomOut : false}
289
+ onClick={() => void viewer.current?.zoomOut()}
290
+ className="grid size-7 place-items-center rounded-full hover:bg-accent disabled:opacity-40"
291
+ >
292
+ <Minus aria-hidden="true" className="size-3.5" />
293
+ </button>
294
+ <button
295
+ type="button"
296
+ aria-label={i18n.t("attachment.viewer.resetZoom")}
297
+ disabled={state.zoom ? !state.zoom.canReset : false}
298
+ onClick={() => void viewer.current?.resetZoom()}
299
+ className="min-w-12 px-1 text-xs tabular-nums disabled:opacity-40"
300
+ >
301
+ {Math.round(scale * 100)}%
302
+ </button>
303
+ <button
304
+ type="button"
305
+ aria-label={i18n.t("attachment.viewer.zoomIn")}
306
+ disabled={state.zoom ? !state.zoom.canZoomIn : false}
307
+ onClick={() => void viewer.current?.zoomIn()}
308
+ className="grid size-7 place-items-center rounded-full hover:bg-accent disabled:opacity-40"
309
+ >
310
+ <Plus aria-hidden="true" className="size-3.5" />
311
+ </button>
312
+ </div>
313
+ ) : null}
314
+ {pdf && !searchOpen ? (
315
+ <div className="hidden items-center sm:flex">
316
+ <button
317
+ type="button"
318
+ aria-label={i18n.t("attachment.viewer.rotateLeft")}
319
+ onClick={() =>
320
+ void applyViewState({ rotation: ((viewState?.rotation ?? 0) - 90 + 360) % 360 })
321
+ }
322
+ className="grid size-8 place-items-center rounded-full hover:bg-accent"
323
+ >
324
+ <RotateCcw aria-hidden="true" className="size-4" />
325
+ </button>
326
+ <button
327
+ type="button"
328
+ aria-label={i18n.t("attachment.viewer.rotateRight")}
329
+ onClick={() =>
330
+ void applyViewState({ rotation: ((viewState?.rotation ?? 0) + 90) % 360 })
331
+ }
332
+ className="grid size-8 place-items-center rounded-full hover:bg-accent"
333
+ >
334
+ <RotateCw aria-hidden="true" className="size-4" />
335
+ </button>
336
+ </div>
337
+ ) : null}
338
+ </div>
339
+ ) : null}
340
+ </div>
26
341
  );
27
- return <FileViewer file={file} className="h-full min-h-64" options={options} />;
28
342
  }
@@ -1,5 +1,5 @@
1
1
  import { pdfRenderer } from "@file-viewer/renderer-pdf";
2
2
  import { FilePreviewView } from "./file-preview-view.tsx";
3
3
  export function PdfFilePreview({ file }: { file: File }) {
4
- return <FilePreviewView file={file} renderer={pdfRenderer} />;
4
+ return <FilePreviewView file={file} renderer={pdfRenderer} pdf />;
5
5
  }
package/src/i18n/de.json CHANGED
@@ -42,6 +42,9 @@
42
42
  "board.backTo": "Zurück zu {board}",
43
43
  "board.cardLabel": "{title} öffnen, in {column}. Alt und eine Pfeiltaste verschiebt sie.",
44
44
  "board.clearAssignee": "Zuständigkeit von {name} entfernen",
45
+ "board.clearDependsOn": "nicht mehr darauf warten",
46
+ "board.clearDue": "entfernen",
47
+ "board.clearStart": "entfernen",
45
48
  "board.column.archive": "Archiv",
46
49
  "board.column.assignee": "Zuständig",
47
50
  "board.column.default.backlog": "Backlog",
@@ -51,6 +54,7 @@
51
54
  "board.column.dependsOn": "Wartet auf",
52
55
  "board.column.due": "Fällig",
53
56
  "board.column.labels": "Labels",
57
+ "board.column.start": "Start",
54
58
  "board.column.status": "Spalte",
55
59
  "board.column.title": "Titel",
56
60
  "board.createFailed": "Die Aufgabe konnte nicht angelegt werden.",
@@ -72,9 +76,9 @@
72
76
  "board.link.outside": "Außerhalb dieses Boards",
73
77
  "board.link.subtask": "Unteraufgabe",
74
78
  "board.links": "Verlinkt",
75
- "board.moveTo": "In eine Spalte verschieben",
79
+ "board.moveTo": "in eine andere Spalte verschieben",
76
80
  "board.progress": "{done} von {total} erledigt",
77
- "board.removeLabel": "Label {label} entfernen",
81
+ "board.removeLabel": "dieses Label entfernen",
78
82
  "board.settings.addColumn": "+ Spalte",
79
83
  "board.settings.columnName": "Name der Spalte {column}",
80
84
  "board.settings.description": "Spalten und was das Board zeigt.",
@@ -89,6 +93,10 @@
89
93
  "board.settings.showArchive": "Archiv-Spalte zeigen",
90
94
  "board.settings.showArchiveHint": "Sie ist auf jedem Board. Ausgeblendet sind ihre Karten überall aus dem Blick.",
91
95
  "board.settings.title": "Board-Einstellungen",
96
+ "board.sort.asc": "{column}, A bis Z",
97
+ "board.sort.desc": "{column}, Z bis A",
98
+ "board.sort.none": "Unsortiert",
99
+ "board.sortBy": "Sortieren",
92
100
  "board.stripes.root": "Ebene {level}, hat Unteraufgaben",
93
101
  "board.stripes.under": "Ebene {level} · übergeordnet in {column}",
94
102
  "board.table.collapse": "Verbergen, was unter {row} liegt",
@@ -249,6 +257,21 @@
249
257
  "attachment.empty": "Es wurde noch keine Datei hochgeladen.",
250
258
  "attachment.loadFailed": "Die Datei konnte nicht geladen werden. Das Original lässt sich weiterhin über das Menü exportieren.",
251
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",
252
275
  "nav.primary": "Hauptnavigation",
253
276
  "nav.tools": "Werkzeuge",
254
277
  "node.access": "Zugriff",