@eifi1/ui-kit 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +12 -11
  2. package/dist/admin/user-roster.d.ts +6 -2
  3. package/dist/admin/user-roster.js +7 -3
  4. package/dist/admin/user-roster.js.map +1 -1
  5. package/dist/components/data-table-row-actions.d.ts +22 -5
  6. package/dist/components/data-table-row-actions.js +14 -5
  7. package/dist/components/data-table-row-actions.js.map +1 -1
  8. package/dist/components/data-table.d.ts +16 -1
  9. package/dist/components/data-table.js +20 -1
  10. package/dist/components/data-table.js.map +1 -1
  11. package/dist/components/row-actions.d.ts +37 -5
  12. package/dist/components/row-actions.js +22 -5
  13. package/dist/components/row-actions.js.map +1 -1
  14. package/dist/components/tabs.d.ts +24 -16
  15. package/dist/components/tabs.js +37 -31
  16. package/dist/components/tabs.js.map +1 -1
  17. package/dist/components/translation-review.js +3 -1
  18. package/dist/components/translation-review.js.map +1 -1
  19. package/dist/components/ui-labels.js +1 -1
  20. package/dist/components/ui-labels.js.map +1 -1
  21. package/dist/data-table.d.ts +1 -0
  22. package/dist/data-table.js +2 -0
  23. package/dist/data-table.js.map +1 -1
  24. package/dist/eslint/index.js +1 -1
  25. package/dist/feedback/feedback-attachment-download.d.ts +19 -0
  26. package/dist/feedback/feedback-attachment-download.js +59 -0
  27. package/dist/feedback/feedback-attachment-download.js.map +1 -0
  28. package/dist/feedback/feedback-comment-thread.d.ts +17 -6
  29. package/dist/feedback/feedback-comment-thread.js +21 -2
  30. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  31. package/dist/feedback/feedback-entry-file.d.ts +61 -0
  32. package/dist/feedback/feedback-entry-file.js +76 -0
  33. package/dist/feedback/feedback-entry-file.js.map +1 -0
  34. package/dist/feedback/feedback-public-list.d.ts +18 -5
  35. package/dist/feedback/feedback-public-list.js +8 -0
  36. package/dist/feedback/feedback-public-list.js.map +1 -1
  37. package/dist/feedback/feedback-public-types.d.ts +7 -2
  38. package/dist/feedback/feedback-replies.d.ts +26 -9
  39. package/dist/feedback/feedback-replies.js +20 -10
  40. package/dist/feedback/feedback-replies.js.map +1 -1
  41. package/dist/feedback/feedback-row-detail-labels.js +0 -1
  42. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  43. package/dist/feedback/feedback-row-detail.d.ts +23 -10
  44. package/dist/feedback/feedback-row-detail.js +10 -54
  45. package/dist/feedback/feedback-row-detail.js.map +1 -1
  46. package/dist/feedback.d.ts +1 -0
  47. package/dist/feedback.js +1 -0
  48. package/dist/feedback.js.map +1 -1
  49. package/dist/i18n/german.js +1 -6
  50. package/dist/i18n/german.js.map +1 -1
  51. package/dist/i18n/locales/en.js +1 -6
  52. package/dist/i18n/locales/en.js.map +2 -2
  53. package/dist/i18n/locales/es.js +1 -6
  54. package/dist/i18n/locales/es.js.map +1 -1
  55. package/dist/i18n/locales/fr.js +1 -6
  56. package/dist/i18n/locales/fr.js.map +1 -1
  57. package/dist/i18n/locales/hu.js +1 -6
  58. package/dist/i18n/locales/hu.js.map +1 -1
  59. package/dist/i18n/locales/it.js +1 -6
  60. package/dist/i18n/locales/it.js.map +1 -1
  61. package/dist/i18n/locales/zh.js +1 -6
  62. package/dist/i18n/locales/zh.js.map +1 -1
  63. package/dist/i18n/review.js +0 -1
  64. package/dist/i18n/review.js.map +1 -1
  65. package/dist/index.d.ts +5 -7
  66. package/dist/index.js +14 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/notifications/device-zone-report.d.ts +7 -0
  69. package/dist/notifications/device-zone-report.js +8 -1
  70. package/dist/notifications/device-zone-report.js.map +1 -1
  71. package/dist/notifications/feedback-notification-types.d.ts +14 -1
  72. package/dist/notifications/feedback-notification-types.js +5 -1
  73. package/dist/notifications/feedback-notification-types.js.map +1 -1
  74. package/dist/notifications/notification-bell.d.ts +1 -1
  75. package/dist/notifications/notification-bell.js.map +1 -1
  76. package/dist/notifications/notification-inbox.d.ts +12 -3
  77. package/dist/notifications/notification-inbox.js +2 -2
  78. package/dist/notifications/notification-inbox.js.map +1 -1
  79. package/dist/notifications/notification-types.d.ts +0 -3
  80. package/dist/notifications/notifications-labels.d.ts +2 -13
  81. package/dist/notifications/notifications-labels.js +0 -4
  82. package/dist/notifications/notifications-labels.js.map +1 -1
  83. package/dist/notifications/quiet-hours-setting.d.ts +3 -8
  84. package/dist/notifications/quiet-hours-setting.js +8 -3
  85. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  86. package/dist/offline/local-first.d.ts +9 -0
  87. package/dist/offline/local-first.js +14 -0
  88. package/dist/offline/local-first.js.map +1 -1
  89. package/dist/offline.d.ts +1 -1
  90. package/dist/offline.js +2 -1
  91. package/dist/offline.js.map +1 -1
  92. package/dist/sync/db.d.ts +17 -0
  93. package/dist/sync/db.js +10 -1
  94. package/dist/sync/db.js.map +1 -1
  95. package/dist/sync/engine.d.ts +11 -9
  96. package/dist/sync/engine.js.map +1 -1
  97. package/dist/sync/hooks.d.ts +5 -0
  98. package/dist/sync/hooks.js.map +1 -1
  99. package/dist/sync.d.ts +2 -2
  100. package/dist/sync.js +5 -2
  101. package/dist/sync.js.map +1 -1
  102. package/dist/theme/theme-store.d.ts +4 -4
  103. package/dist/theme/theme-store.js +1 -0
  104. package/dist/theme/theme-store.js.map +1 -1
  105. package/package.json +2 -2
  106. package/src/admin/user-roster.tsx +12 -3
  107. package/src/components/data-table-row-actions.tsx +33 -9
  108. package/src/components/data-table.tsx +46 -3
  109. package/src/components/row-actions.tsx +73 -10
  110. package/src/components/tabs.tsx +66 -44
  111. package/src/components/translation-review.tsx +3 -1
  112. package/src/components/ui-labels.ts +1 -1
  113. package/src/data-table.ts +3 -0
  114. package/src/feedback/feedback-attachment-download.tsx +77 -0
  115. package/src/feedback/feedback-comment-thread.tsx +42 -7
  116. package/src/feedback/feedback-entry-file.tsx +150 -0
  117. package/src/feedback/feedback-public-list.tsx +25 -4
  118. package/src/feedback/feedback-public-types.ts +7 -2
  119. package/src/feedback/feedback-replies.tsx +46 -19
  120. package/src/feedback/feedback-row-detail-labels.ts +0 -1
  121. package/src/feedback/feedback-row-detail.tsx +35 -86
  122. package/src/feedback.ts +1 -0
  123. package/src/i18n/german.ts +1 -6
  124. package/src/i18n/locales/es.ts +1 -6
  125. package/src/i18n/locales/fr.ts +1 -6
  126. package/src/i18n/locales/hu.ts +1 -6
  127. package/src/i18n/locales/it.ts +1 -6
  128. package/src/i18n/locales/zh.ts +1 -6
  129. package/src/i18n/review.ts +0 -1
  130. package/src/index.ts +19 -5
  131. package/src/notifications/device-zone-report.ts +14 -1
  132. package/src/notifications/feedback-notification-types.ts +19 -1
  133. package/src/notifications/notification-bell.tsx +1 -1
  134. package/src/notifications/notification-inbox.tsx +15 -5
  135. package/src/notifications/notification-types.ts +0 -3
  136. package/src/notifications/notifications-labels.ts +2 -17
  137. package/src/notifications/quiet-hours-setting.tsx +16 -11
  138. package/src/offline/local-first.ts +24 -0
  139. package/src/offline.ts +1 -1
  140. package/src/sync/db.ts +22 -0
  141. package/src/sync/engine.ts +11 -9
  142. package/src/sync/hooks.ts +5 -0
  143. package/src/sync.ts +2 -2
  144. package/src/theme/theme-store.ts +13 -5
  145. package/dist/notifications/time-zone-select.d.ts +0 -53
  146. package/dist/notifications/time-zone-select.js +0 -76
  147. package/dist/notifications/time-zone-select.js.map +0 -1
  148. package/src/notifications/time-zone-select.tsx +0 -129
@@ -0,0 +1,150 @@
1
+ import { useState } from "react";
2
+ import { AuthedImage } from "../components/authed-image";
3
+ import { toast } from "../components/toast";
4
+ import type { AuthedFetcher } from "../hooks/use-authed-src";
5
+ import { useKitLabels } from "../i18n/kit-labels";
6
+ import { cn } from "../lib/cn";
7
+ import { DEFAULT_FEEDBACK_ATTACHMENT_LABELS, DEFAULT_MAX_ATTACHMENT_BYTES } from "./feedback-attachment";
8
+ import type { FeedbackNoteAttachment } from "./feedback-inbox";
9
+ import { useFeedbackToastLabels } from "./feedback-labels";
10
+ import { FeedbackAttachmentDownload, plainFeedbackFetch } from "./feedback-attachment-download";
11
+ import { FEEDBACK_ATTACHMENT_ACCEPT, isImageAttachment } from "./feedback-record";
12
+ import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
13
+ import type { FeedbackComposerAttachment } from "./feedback-thread";
14
+
15
+ /**
16
+ * One file of a conversation entry, and the upload behind an entry's composer
17
+ * (docs/feedback-harmonization.md §12, round 0.47; keksdose run 82 R1, live #447).
18
+ *
19
+ * - {@link FeedbackEntryFile}: a picture drawn INLINE — a thumbnail fetched through the
20
+ * app's authenticated GET, a tap opens it full size in a tab — and any other file a
21
+ * download button, as the row detail's Attachment section draws them. The reply
22
+ * timeline, the rework entries and the comment thread all draw their file with it.
23
+ * - {@link useFeedbackEntryUpload}: the rework note's one-file field (paste from the
24
+ * clipboard, capture, pick; the contract's types and 10 MB, §3.5) for a
25
+ * {@link ChatComposer}, and the upload that runs FIRST on send, so an entry never names
26
+ * a file that did not arrive.
27
+ */
28
+
29
+ export interface FeedbackEntryFileProps {
30
+ /** The entry's `attachment_url` — `/api/v1/feedback/attachments/<key>`. */
31
+ url: string;
32
+ /** The app's authenticated GET (`AuthedImage`'s): the attachment route wants the
33
+ * bearer token (§3.5). Left out, the browser's own fetch with same-origin cookies. */
34
+ fetcher?: AuthedFetcher | null;
35
+ className?: string;
36
+ }
37
+
38
+ /**
39
+ * An entry's one file (§12): a picture (`png`, `jpg`, `webp`, `gif` — by the key's
40
+ * extension, which the server minted from the sniffed bytes) as an inline thumbnail that
41
+ * opens full size in a new tab (`AuthedImage`'s `link`: the `blob:` URL, never the API
42
+ * path); anything else a download button. A picture that will not load falls back to the
43
+ * download button, so a broken file still says it is there. `alt`:
44
+ * `feedbackDetail.attachment`.
45
+ */
46
+ export function FeedbackEntryFile({ url, fetcher, className }: FeedbackEntryFileProps) {
47
+ const labels = useKitLabels("feedbackDetail", DEFAULT_FEEDBACK_DETAIL_LABELS);
48
+ const fetchFile = fetcher ?? plainFeedbackFetch;
49
+ return (
50
+ <span className={cn("inline-block min-w-0 max-w-full", className)} data-feedback-entry-file={url}>
51
+ {isImageAttachment(url) ? (
52
+ <AuthedImage
53
+ src={url}
54
+ alt={labels.attachment}
55
+ fetcher={fetchFile}
56
+ link
57
+ stopPropagation
58
+ errorFallback={<FeedbackAttachmentDownload url={url} fetcher={fetchFile} />}
59
+ wrapperClassName="inline-block max-w-full"
60
+ className="max-h-40 max-w-full rounded border border-subtle"
61
+ />
62
+ ) : (
63
+ <FeedbackAttachmentDownload url={url} fetcher={fetchFile} />
64
+ )}
65
+ </span>
66
+ );
67
+ }
68
+
69
+ /* ── The composer's one file ──────────────────────────────────────────────── */
70
+
71
+ /** `POST /feedback/attachments` — the file, answered with its URL. */
72
+ export type FeedbackEntryUpload = (file: File) => Promise<string>;
73
+
74
+ export interface FeedbackEntryUploadOptions {
75
+ /** The upload. Left out, the composer is text only. */
76
+ onUpload?: FeedbackEntryUpload;
77
+ /** The "Capture screenshot" button — the kit's `captureAppScreenshot` or the app's
78
+ * own. A rejection toasts `feedbackToast.captureFailed` and adds nothing. */
79
+ onCaptureScreenshot?: () => Promise<File | null>;
80
+ /** Over the field's defaults: the contract's types and 10 MB (§3.5), the
81
+ * `feedbackToast` refusals. The same object the rework section takes. */
82
+ attachment?: Partial<FeedbackNoteAttachment>;
83
+ }
84
+
85
+ export interface FeedbackEntryUploadState {
86
+ /** For {@link ChatComposer}'s `attachment`; `undefined` without `onUpload`. */
87
+ composerAttachment: FeedbackComposerAttachment | undefined;
88
+ /** Upload the picked file, if any: its URL, `undefined` for none. Rejects after a
89
+ * toast (`feedbackAttachment.attachmentUploadFailed`) when the upload fails — the
90
+ * send then stops and the composer keeps the draft and the file. */
91
+ upload: (file: File | null) => Promise<string | undefined>;
92
+ /** An upload is running — add it to the composer's `pending`. */
93
+ uploading: boolean;
94
+ }
95
+
96
+ /**
97
+ * The rework note's file field for a reply or a comment composer (§12): one file, added,
98
+ * captured or pasted from the clipboard into the box, the contract's types and 10 MB; and
99
+ * the upload, awaited before the entry is sent.
100
+ */
101
+ export function useFeedbackEntryUpload({
102
+ onUpload,
103
+ onCaptureScreenshot,
104
+ attachment,
105
+ }: FeedbackEntryUploadOptions): FeedbackEntryUploadState {
106
+ const toasts = useFeedbackToastLabels();
107
+ const fieldLabels = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS);
108
+ const [uploading, setUploading] = useState(false);
109
+
110
+ const capture = onCaptureScreenshot
111
+ ? async () => {
112
+ try {
113
+ return await onCaptureScreenshot();
114
+ } catch {
115
+ toast.error(toasts.captureFailed);
116
+ return null;
117
+ }
118
+ }
119
+ : undefined;
120
+
121
+ const composerAttachment: FeedbackComposerAttachment | undefined = onUpload
122
+ ? {
123
+ accept: FEEDBACK_ATTACHMENT_ACCEPT,
124
+ maxBytes: DEFAULT_MAX_ATTACHMENT_BYTES,
125
+ onError: (kind) => {
126
+ toast.error(kind === "type" ? toasts.attachmentUnsupported : toasts.attachmentTooLarge);
127
+ },
128
+ onCaptureScreenshot: capture,
129
+ buttonVariant: "ghost",
130
+ buttonSize: "sm",
131
+ ...attachment,
132
+ }
133
+ : undefined;
134
+
135
+ const upload = async (file: File | null) => {
136
+ if (!file || !onUpload) return undefined;
137
+ setUploading(true);
138
+ try {
139
+ return await onUpload(file);
140
+ } catch (error) {
141
+ const failed = fieldLabels.attachmentUploadFailed ?? DEFAULT_FEEDBACK_ATTACHMENT_LABELS.attachmentUploadFailed!;
142
+ toast.error(failed(file.name));
143
+ throw error;
144
+ } finally {
145
+ setUploading(false);
146
+ }
147
+ };
148
+
149
+ return { composerAttachment, upload, uploading };
150
+ }
@@ -7,6 +7,7 @@ import { IconButton } from "../components/icon-button";
7
7
  import { SearchField } from "../components/search-field";
8
8
  import { ToggleGroup } from "../components/toggle-group";
9
9
  import type { CommitScope } from "../components/write-lock";
10
+ import type { AuthedFetcher } from "../hooks/use-authed-src";
10
11
  import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
11
12
  import { cn } from "../lib/cn";
12
13
  import { FILL_HEIGHT_CLASS } from "../shell/fill-height";
@@ -14,6 +15,7 @@ import { EMPTY_FORMATTED_VALUE } from "../lib/format";
14
15
  import { FeedbackCategoryBadge, FeedbackDetail, FeedbackDetailSection, FeedbackProse, FeedbackStatusBadge } from "./feedback-inbox";
15
16
  import { useFeedbackCategoryLabels } from "./feedback-labels";
16
17
  import { FeedbackCommentThread, type FeedbackCommentsLabels } from "./feedback-comment-thread";
18
+ import type { FeedbackEntryUploadOptions } from "./feedback-entry-file";
17
19
  import { DEFAULT_FEEDBACK_PUBLIC_LABELS, type FeedbackPublicLabels } from "./feedback-public-labels";
18
20
  import type {
19
21
  FeedbackComment,
@@ -394,8 +396,18 @@ export interface FeedbackPublicDetailProps {
394
396
  /** The viewer is the app's admin: Hide / Unhide on the comments. */
395
397
  isAdmin?: boolean;
396
398
  /** `POST /feedback/{id}/comments`. Left out — a demo, a reader who may not write — no
397
- * composer. */
398
- onComment?: (body: string) => void | Promise<unknown>;
399
+ * composer. 0.47 (§12): `attachmentUrl` is the one file uploaded with it (with
400
+ * `onUpload`). */
401
+ onComment?: (body: string, attachmentUrl?: string) => void | Promise<unknown>;
402
+ /** 0.47 (§12): `POST /feedback/attachments` — the comment box then takes one file,
403
+ * pasted, captured or picked, as the rework note does. Left out, text only. */
404
+ onUpload?: FeedbackEntryUploadOptions["onUpload"];
405
+ /** 0.47: the comment box's "Capture screenshot". */
406
+ onCaptureScreenshot?: FeedbackEntryUploadOptions["onCaptureScreenshot"];
407
+ /** 0.47: the comment box's file field, over the contract's defaults. */
408
+ attachment?: FeedbackEntryUploadOptions["attachment"];
409
+ /** 0.47: the app's authenticated GET for the comments' pictures, drawn inline. */
410
+ fetcher?: AuthedFetcher | null;
399
411
  /** The admin's hide (`PATCH …/comments/{id} {hidden}`). */
400
412
  onHide?: (comment: FeedbackComment, hidden: boolean) => void | Promise<unknown>;
401
413
  /** The writer's delete (`DELETE …/comments/{id}`), after a confirm. */
@@ -425,8 +437,9 @@ export interface FeedbackPublicDetailProps {
425
437
  * add one.
426
438
  *
427
439
  * What it never draws, because the public record never carries it (§9.10): an address,
428
- * a last name, a screenshot, a file, the page the report was filed on. The body is the
429
- * server's `public_body` — the client strips nothing and adds nothing.
440
+ * a last name, the report's screenshot or files, the page the report was filed on. The
441
+ * body is the server's `public_body` — the client strips nothing and adds nothing. The
442
+ * one file it shows (0.47, §12) is a comment's own, posted with it to be seen.
430
443
  */
431
444
  export function FeedbackPublicDetail({
432
445
  row,
@@ -436,6 +449,10 @@ export function FeedbackPublicDetail({
436
449
  onComment,
437
450
  onHide,
438
451
  onDelete,
452
+ onUpload,
453
+ onCaptureScreenshot,
454
+ attachment,
455
+ fetcher,
439
456
  pending,
440
457
  commit,
441
458
  voteDisabled,
@@ -480,6 +497,10 @@ export function FeedbackPublicDetail({
480
497
  onComment={onComment}
481
498
  onHide={onHide}
482
499
  onDelete={onDelete}
500
+ onUpload={onUpload}
501
+ onCaptureScreenshot={onCaptureScreenshot}
502
+ attachment={attachment}
503
+ fetcher={fetcher}
483
504
  pending={pending}
484
505
  commit={commit}
485
506
  now={now}
@@ -28,8 +28,9 @@ export interface FeedbackReply {
28
28
  /** 0.40 (§10.3): `"reply"` (absent: a server before server-kit 0.13, all replies) or
29
29
  * `"rework"`, the author's entry. */
30
30
  kind?: FeedbackEntryKind;
31
- /** 0.40: a rework entry's one file (`/api/v1/feedback/attachments/<key>`) — the admin
32
- * and author views only; `null` or absent otherwise (never in the public view, §9.10). */
31
+ /** The entry's one file (`/api/v1/feedback/attachments/<key>`): a rework's (0.40) or,
32
+ * since 0.47 (§12, server-kit 0.18), an admin reply's — the admin and author views
33
+ * only; `null` or absent otherwise (never in the public view, §9.10). */
33
34
  attachment_url?: string | null;
34
35
  /** The status this reply moved the report to; `null` = no status change — and on a
35
36
  * rework migrated out of the body (§10.4), whose move was never recorded. */
@@ -52,6 +53,10 @@ export interface FeedbackComment {
52
53
  /** `null` when hidden, but for its writer and an admin. */
53
54
  body: string | null;
54
55
  hidden: boolean;
56
+ /** 0.47 (§12, server-kit 0.18): the comment's one file, shown with it to whoever reads
57
+ * its body; `null` when it has none or is hidden from this viewer, absent from an
58
+ * older server. */
59
+ attachment_url?: string | null;
55
60
  /** ISO 8601. */
56
61
  created_at: string;
57
62
  }
@@ -1,15 +1,17 @@
1
1
  import { useState } from "react";
2
2
  import type { ReactNode } from "react";
3
- import { BellOff, Paperclip } from "lucide-react";
3
+ import { BellOff } from "lucide-react";
4
4
  import { Select } from "../components/select";
5
5
  import type { CommitScope } from "../components/write-lock";
6
+ import type { AuthedFetcher } from "../hooks/use-authed-src";
6
7
  import { useKitLabels, useKitLocale, type LabelOverride } from "../i18n/kit-labels";
7
8
  import { cn } from "../lib/cn";
8
9
  import { formatDate } from "../lib/format";
9
10
  import { FeedbackStatusBadge, selectableFeedbackStatuses, type FeedbackStatus } from "./feedback-inbox";
10
11
  import { useFeedbackStatusLabels } from "./feedback-labels";
11
12
  import type { FeedbackReply } from "./feedback-public-types";
12
- import { FEEDBACK_DECIDED_STATUSES, FEEDBACK_REPLY_MAX, attachmentName } from "./feedback-record";
13
+ import { FEEDBACK_DECIDED_STATUSES, FEEDBACK_REPLY_MAX } from "./feedback-record";
14
+ import { FeedbackEntryFile, useFeedbackEntryUpload, type FeedbackEntryUploadOptions } from "./feedback-entry-file";
13
15
  import { ChatComposer } from "./feedback-thread";
14
16
  import { DEFAULT_FEEDBACK_REPLIES_LABELS } from "./feedback-thread-parts-labels";
15
17
  import { FeedbackReworkEntryChip } from "./feedback-rework-entry-chip";
@@ -82,10 +84,14 @@ export interface FeedbackReplyTimelineProps {
82
84
  renderDate?: (iso: string) => string;
83
85
  /** Tag bodies and names `data-private` (a host's screen-share blur). Default true. */
84
86
  redact?: boolean;
85
- /** 0.40: how a rework entry's file (`attachment_url`) is drawn under its text. Default:
86
- * its name beside a paperclip — the file itself is in the row detail's Attachment
87
- * section, fetched with the app's credentials. */
87
+ /** How an entry's file (`attachment_url` — a rework's since 0.40, an admin reply's
88
+ * since 0.47) is drawn under its text. Default (0.47, §12): {@link FeedbackEntryFile} —
89
+ * a picture inline as a thumbnail that opens full size, any other file a download —
90
+ * through `fetcher`. */
88
91
  renderAttachment?: (url: string) => ReactNode;
92
+ /** 0.47: the app's authenticated GET (`AuthedImage`'s) for the entries' files. Left
93
+ * out, the browser's own fetch with same-origin cookies. */
94
+ fetcher?: AuthedFetcher | null;
89
95
  labels?: LabelOverride<FeedbackRepliesLabels>;
90
96
  className?: string;
91
97
  }
@@ -118,12 +124,16 @@ function EntryStatus({ reply, statusChanged }: { reply: FeedbackReply; statusCha
118
124
  * 0.40 (§10.3): an author's **rework** entry (`kind: "rework"`) stands among them as the
119
125
  * author's — their name (`null` → "A user", never "Admin"), the **Rework** chip, the
120
126
  * status it moved the report to (none on a migrated one) and its file.
127
+ *
128
+ * 0.47 (§12): any entry's file — a rework's, an admin reply's — is drawn under its text,
129
+ * a picture INLINE ({@link FeedbackEntryFile}).
121
130
  */
122
131
  export function FeedbackReplyTimeline({
123
132
  replies,
124
133
  renderDate,
125
134
  redact = true,
126
135
  renderAttachment,
136
+ fetcher,
127
137
  labels: labelsProp,
128
138
  className,
129
139
  }: FeedbackReplyTimelineProps) {
@@ -160,15 +170,12 @@ export function FeedbackReplyTimeline({
160
170
  <p data-private={priv} dir="auto" className="mt-0.5 whitespace-pre-wrap break-words text-start text-primary">
161
171
  {reply.body}
162
172
  </p>
163
- {rework && reply.attachment_url && (
164
- <div className="mt-1 min-w-0" data-private={priv} data-feedback-entry-file="">
173
+ {reply.attachment_url && (
174
+ <div className="mt-1 min-w-0" data-private={priv} data-feedback-entry-attachment="">
165
175
  {renderAttachment ? (
166
176
  renderAttachment(reply.attachment_url)
167
177
  ) : (
168
- <span className="inline-flex min-w-0 max-w-full items-center gap-1 text-xs text-secondary">
169
- <Paperclip className="size-3.5 shrink-0" aria-hidden />
170
- <span className="min-w-0 break-all">{attachmentName(reply.attachment_url)}</span>
171
- </span>
178
+ <FeedbackEntryFile url={reply.attachment_url} fetcher={fetcher} />
172
179
  )}
173
180
  </div>
174
181
  )}
@@ -204,15 +211,16 @@ const REPLY_ROW_ALIGN = cn(
204
211
  "pointer-coarse:[&_[data-chat-composer-hint]]:sr-only",
205
212
  );
206
213
 
207
- export interface FeedbackReplyComposerProps {
214
+ export interface FeedbackReplyComposerProps extends FeedbackEntryUploadOptions {
208
215
  /** The report's status now — what "No change" keeps. */
209
216
  status: FeedbackStatus;
210
217
  /**
211
218
  * `POST /feedback/{id}/replies` — the body (1–{@link FEEDBACK_REPLY_MAX} after
212
- * trimming) and the status picked, `undefined` for "No change". Return the promise:
213
- * the box clears when it resolves, and keeps everything on a rejection.
219
+ * trimming), the status picked, `undefined` for "No change", and (0.47, §12) the URL
220
+ * of the one file uploaded with it, `undefined` for none. Return the promise: the box
221
+ * clears when it resolves, and keeps everything on a rejection.
214
222
  */
215
- onReply: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
223
+ onReply: (body: string, status?: FeedbackStatus, attachmentUrl?: string) => void | Promise<unknown>;
216
224
  /** The reply is in flight. */
217
225
  pending?: boolean;
218
226
  /** Preselect this status and say the author has not been told (§9.7, the nudge) —
@@ -235,6 +243,13 @@ export interface FeedbackReplyComposerProps {
235
243
  * With `suggestedStatus` the line "The author has not been told. Add a reply." stands
236
244
  * above it and that status is picked, so the reply that answers it records the status
237
245
  * and the nudge goes away.
246
+ *
247
+ * 0.47 (§12, keksdose live #447): with `onUpload`, one file as the rework note takes it —
248
+ * a screenshot pasted into the box, a capture (`onCaptureScreenshot`), or a pick; the
249
+ * contract's types and 10 MB. It is uploaded FIRST; a failed upload toasts and sends
250
+ * nothing, and the draft and the file stay. `onUpload` here is the composer's own opt-in
251
+ * (new in 0.47, so no page passed it before); `FeedbackRowDetail` hands it on only with
252
+ * `replyAttachments`, since its `onUpload` is the rework note's too.
238
253
  */
239
254
  export function FeedbackReplyComposer({
240
255
  status,
@@ -242,10 +257,18 @@ export function FeedbackReplyComposer({
242
257
  pending = false,
243
258
  suggestedStatus,
244
259
  commit,
260
+ onUpload,
261
+ onCaptureScreenshot,
262
+ attachment,
245
263
  labels: labelsProp,
246
264
  className,
247
265
  }: FeedbackReplyComposerProps) {
248
266
  const labels = useFeedbackRepliesLabels(labelsProp);
267
+ const { composerAttachment, upload, uploading } = useFeedbackEntryUpload({
268
+ onUpload,
269
+ onCaptureScreenshot,
270
+ attachment,
271
+ });
249
272
  const statusNames = useFeedbackStatusLabels();
250
273
  // The pick, valid for the suggestion it was made under: a new suggestion (another
251
274
  // status set from the table) picks itself again.
@@ -255,9 +278,12 @@ export function FeedbackReplyComposer({
255
278
  });
256
279
  const picked = pick.under === suggestedStatus ? pick.value : (suggestedStatus ?? "");
257
280
 
258
- const send = (body: string) => {
281
+ const send = async (body: string, file: File | null) => {
259
282
  const sent = picked;
260
- return Promise.resolve(onReply(body, sent || undefined)).then(() => {
283
+ const url = await upload(file);
284
+ // The third argument only with a file, so a text reply calls as before 0.47.
285
+ const replied = url === undefined ? onReply(body, sent || undefined) : onReply(body, sent || undefined, url);
286
+ return Promise.resolve(replied).then(() => {
261
287
  setPick((current) => (current.value === sent ? { under: suggestedStatus, value: "" } : current));
262
288
  });
263
289
  };
@@ -272,7 +298,8 @@ export function FeedbackReplyComposer({
272
298
  )}
273
299
  <ChatComposer
274
300
  onSend={send}
275
- pending={pending}
301
+ pending={pending || uploading}
302
+ attachment={composerAttachment}
276
303
  commit={commit}
277
304
  maxLength={FEEDBACK_REPLY_MAX}
278
305
  showCount
@@ -284,7 +311,7 @@ export function FeedbackReplyComposer({
284
311
  <Select
285
312
  aria-label={labels.status}
286
313
  value={picked}
287
- disabled={pending}
314
+ disabled={pending || uploading}
288
315
  onChange={(event) =>
289
316
  setPick({ under: suggestedStatus, value: event.target.value as FeedbackStatus | "" })
290
317
  }
@@ -24,7 +24,6 @@ export const DEFAULT_FEEDBACK_DETAIL_LABELS: FeedbackDetailLabels = {
24
24
  outcomeUpdate: "Update",
25
25
  outcomePlaceholder: "What was done, decided, or why this won't be addressed.",
26
26
  rework: "Rework",
27
- openPage: "Open page",
28
27
  reworkTitle: "Send for rework",
29
28
  reworkSend: "Send rework",
30
29
  reworkPlaceholder: "What still needs refinement? Any new constraints or change of direction.",
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import type { MouseEvent, ReactNode } from "react";
3
- import { Download, FileText, RotateCcw } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { RotateCcw } from "lucide-react";
4
4
  import { AuthedImage } from "../components/authed-image";
5
5
  import { CopyButton } from "../components/copy-button";
6
6
  import { TextLink } from "../components/text-link";
@@ -8,7 +8,6 @@ import { toast } from "../components/toast";
8
8
  import { Button } from "../components/button";
9
9
  import type { AuthedFetcher } from "../hooks/use-authed-src";
10
10
  import { useKitLabels, useKitLocale, type LabelOverride } from "../i18n/kit-labels";
11
- import { filenameFromDisposition, saveBlob } from "../lib/download";
12
11
  import { formatDate } from "../lib/format";
13
12
  import { DEFAULT_MAX_ATTACHMENT_BYTES } from "./feedback-attachment";
14
13
  import { DEFAULT_FEEDBACK_DIALOG_LABELS } from "./feedback-dialog";
@@ -29,7 +28,6 @@ import {
29
28
  FEEDBACK_AUTHOR_EDITABLE_STATUSES,
30
29
  FEEDBACK_REWORKABLE_STATUSES,
31
30
  appendRework,
32
- attachmentName,
33
31
  isImageAttachment,
34
32
  replaceDescription,
35
33
  splitBodyAttachments,
@@ -41,6 +39,7 @@ import type { FeedbackStatusChange } from "./feedback-status-undo";
41
39
  import type { CommitScope } from "../components/write-lock";
42
40
  import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
43
41
  import { FeedbackReplyComposer, FeedbackReplyTimeline, feedbackReplyNudge } from "./feedback-replies";
42
+ import { FeedbackAttachmentDownload, plainFeedbackFetch } from "./feedback-attachment-download";
44
43
  import { FeedbackPublicSwitch } from "./feedback-public-switch";
45
44
  import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
46
45
  import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels";
@@ -143,10 +142,6 @@ export interface FeedbackDetailLabels {
143
142
  outcomePlaceholder: string;
144
143
  /** The button that opens section 5 on an answered row. de-CH: "Nacharbeit" */
145
144
  rework: string;
146
- /** The link to the page the report was filed on. de-CH: "Seite öffnen"
147
- * @deprecated 0.46 — the Replies section's "Open page" button is gone (keksdose live #449:
148
- * it repeated the URL section's link); removed in 0.47. Unused, kept for one release. */
149
- openPage: string;
150
145
  /** Section 5's heading. de-CH: "Zur Nacharbeit senden" */
151
146
  reworkTitle: string;
152
147
  /** Section 5's send button. de-CH: "Nacharbeit senden" */
@@ -257,75 +252,6 @@ export function feedbackAttachmentUrls(
257
252
  return [...new Set(urls)];
258
253
  }
259
254
 
260
- /** The bytes behind a fetcher's answer — a `Blob` as it is, a `Response` checked. */
261
- async function toBlob(answer: Blob | Response): Promise<Blob> {
262
- if (typeof Response !== "undefined" && answer instanceof Response) {
263
- if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
264
- return answer.blob();
265
- }
266
- return answer as Blob;
267
- }
268
-
269
- /** No fetcher: the browser's own `fetch`, with the cookies a same-origin app uses. */
270
- const plainFetch: AuthedFetcher = (url, { signal }) => fetch(url, { signal, credentials: "same-origin" });
271
-
272
- /**
273
- * A PDF or a text log (keksdose `FeedbackFileDownload`, dev #578): the bytes sit behind
274
- * an authenticated route that serves them as `attachment`, never inline (§3.5), so a
275
- * plain link would be a 401 the moment it was opened. Fetched through the app's
276
- * fetcher and saved with {@link saveBlob}; a failure says so in a toast.
277
- *
278
- * Saved under the name the server gives it when the fetcher answers with a `Response`
279
- * (server-kit's `content_disposition` puts the uploader's own name in `filename*`), and
280
- * under the URL's last segment — the name on the button — when it answers with a bare
281
- * `Blob`, which has no headers to ask.
282
- */
283
- function AttachmentDownload({
284
- url,
285
- fetcher,
286
- labels,
287
- }: {
288
- url: string;
289
- fetcher: AuthedFetcher;
290
- labels: FeedbackDetailLabels;
291
- }) {
292
- const [busy, setBusy] = useState(false);
293
- const name = attachmentName(url);
294
- const download = async (event: MouseEvent) => {
295
- // The row around the detail may toggle on a click.
296
- event.stopPropagation();
297
- setBusy(true);
298
- try {
299
- const answer = await fetcher(url, { signal: new AbortController().signal });
300
- const blob = await toBlob(answer);
301
- const saveAs =
302
- typeof Response !== "undefined" && answer instanceof Response
303
- ? filenameFromDisposition(answer.headers.get("Content-Disposition"), name)
304
- : name;
305
- saveBlob(blob, saveAs);
306
- } catch {
307
- toast.error(labels.downloadFailed);
308
- } finally {
309
- setBusy(false);
310
- }
311
- };
312
- return (
313
- <Button
314
- type="button"
315
- variant="secondary"
316
- size="sm"
317
- pending={busy}
318
- onClick={(event) => void download(event)}
319
- aria-label={labels.download(name)}
320
- className="max-w-full"
321
- >
322
- <FileText className="size-3.5 shrink-0" aria-hidden />
323
- <span className="min-w-0 max-w-[14rem] truncate-until-large">{name}</span>
324
- <Download className="size-3.5 shrink-0" aria-hidden />
325
- </Button>
326
- );
327
- }
328
-
329
255
  /* ── Who may do what (§3.4, §4.3, §4.4) ───────────────────────────────────── */
330
256
 
331
257
  /** What the person looking at a row may do with it — {@link feedbackRowAccess}. */
@@ -589,8 +515,8 @@ export interface FeedbackRowDetailProps {
589
515
  onUpdate: FeedbackDetailUpdate;
590
516
  /** `update.isPending`: the editors' save buttons wait for it. */
591
517
  pending?: boolean;
592
- /** `POST /feedback/attachments` for the rework's one file. Left out, a rework is a
593
- * note alone. */
518
+ /** `POST /feedback/attachments` for the rework's one file and, since 0.47 (§12) with
519
+ * `replyAttachments`, the admin reply's. Left out, a rework or a reply is text alone. */
594
520
  onUpload?: FeedbackUpload;
595
521
  /** The app's authenticated GET (`AuthedImage`'s) — the pictures and the downloads go
596
522
  * through it, since the attachment route wants the bearer token (§3.5). Left out,
@@ -603,9 +529,11 @@ export interface FeedbackRowDetailProps {
603
529
  * page's mutation (its observer outlives the row a status filter removes).
604
530
  */
605
531
  statusChange?: FeedbackStatusChange;
606
- /** For the rework's "Capture screenshot" — see {@link FeedbackReworkSectionProps}. */
532
+ /** For the rework's and (0.47, with `replyAttachments`) the reply composer's "Capture screenshot" — see
533
+ * {@link FeedbackReworkSectionProps}. */
607
534
  onCaptureScreenshot?: () => Promise<File | null>;
608
- /** The rework's file field, over the contract's defaults. */
535
+ /** The rework's and (0.47, with `replyAttachments`) the reply composer's file field,
536
+ * over the contract's defaults. */
609
537
  reworkAttachment?: Partial<FeedbackNoteAttachment>;
610
538
  /** How the `resolved_at` date reads — the app's date cell, as a string. Default: the
611
539
  * kit's `formatDate` in the provider's locale, `medium` style. */
@@ -629,8 +557,23 @@ export interface FeedbackRowDetailProps {
629
557
  * reply composer stands under the timeline. Return the promise: the box clears when it
630
558
  * resolves. A failure is the app's to toast — the server's `detail`, else
631
559
  * `feedbackToast.replyFailed` (0.38.1).
560
+ *
561
+ * 0.47 (§12, keksdose live #447): with `replyAttachments` (and `onUpload`) the
562
+ * composer takes one file as the rework note does — pasted into the box, captured or
563
+ * picked — uploaded first, and its URL is the third argument (`attachment_url` in the
564
+ * body; server-kit 0.18).
565
+ */
566
+ onReply?: (body: string, status?: FeedbackStatus, attachmentUrl?: string) => void | Promise<unknown>;
567
+ /**
568
+ * 0.47 (§12): the reply composer takes one file — through `onUpload`,
569
+ * `onCaptureScreenshot` and `reworkAttachment`, the rework note's. Off by default:
570
+ * `onUpload` alone stays the rework's, so a page that passes it and an `onReply` of
571
+ * `(body, status)` does not grow a file field whose file its reply would drop (round
572
+ * 0.47 build reviews, keksdose and kastlan B1). Turn it on in the commit that makes
573
+ * `onReply` send its third argument as `attachment_url`, with server-kit 0.18 (an
574
+ * older server answers the field with a 422).
632
575
  */
633
- onReply?: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
576
+ replyAttachments?: boolean;
634
577
  /** 0.38: the reply is in flight. */
635
578
  replying?: boolean;
636
579
  /**
@@ -695,6 +638,7 @@ export function FeedbackRowDetail({
695
638
  commit,
696
639
  showSubject = true,
697
640
  onReply,
641
+ replyAttachments = false,
698
642
  replying = false,
699
643
  onRework,
700
644
  comments,
@@ -719,8 +663,10 @@ export function FeedbackRowDetail({
719
663
  const { description } = splitDescription(row.body);
720
664
  const pageHref = feedbackPageHref(row.context);
721
665
  const pageUrl = feedbackPageUrl(row.context);
722
- const files = feedbackAttachmentUrls(row);
723
- const fetchFile = fetcher ?? plainFetch;
666
+ // 0.47 (§12): with the entries, each entry's file is drawn inline in the Replies
667
+ // timeline, so the Attachment section holds the report's own files.
668
+ const files = feedbackAttachmentUrls(row.replies ? { ...row, replies: undefined } : row);
669
+ const fetchFile = fetcher ?? plainFeedbackFetch;
724
670
  const dateText = (iso: string) => (renderDate ? renderDate(iso) : formatDate(iso, "medium", { locale }));
725
671
 
726
672
  const saveDescription = (value: string) => {
@@ -823,7 +769,7 @@ export function FeedbackRowDetail({
823
769
  className="max-h-64 max-w-full rounded border border-subtle"
824
770
  />
825
771
  ) : (
826
- <AttachmentDownload key={url} url={url} fetcher={fetchFile} labels={labels} />
772
+ <FeedbackAttachmentDownload key={url} url={url} fetcher={fetchFile} />
827
773
  ),
828
774
  )}
829
775
  </div>
@@ -841,7 +787,7 @@ export function FeedbackRowDetail({
841
787
  >
842
788
  <div className="space-y-3">
843
789
  {replies ? (
844
- <FeedbackReplyTimeline replies={replies} />
790
+ <FeedbackReplyTimeline replies={replies} fetcher={fetchFile} />
845
791
  ) : (
846
792
  <FeedbackProse>{row.outcome || undefined}</FeedbackProse>
847
793
  )}
@@ -851,6 +797,9 @@ export function FeedbackRowDetail({
851
797
  status={row.status}
852
798
  onReply={onReply}
853
799
  pending={replying}
800
+ onUpload={replyAttachments ? onUpload : undefined}
801
+ onCaptureScreenshot={replyAttachments ? onCaptureScreenshot : undefined}
802
+ attachment={replyAttachments ? reworkAttachment : undefined}
854
803
  suggestedStatus={feedbackReplyNudge(row)}
855
804
  commit={commit}
856
805
  />
package/src/feedback.ts CHANGED
@@ -30,3 +30,4 @@ export * from "./feedback/feedback-public-types";
30
30
  export * from "./feedback/feedback-vote-button";
31
31
  export * from "./feedback/feedback-comment-thread";
32
32
  export * from "./feedback/feedback-replies";
33
+ export * from "./feedback/feedback-entry-file";