@eifi1/ui-kit 0.37.0 → 0.38.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 (122) hide show
  1. package/README.md +11 -4
  2. package/dist/eslint/index.js +1 -1
  3. package/dist/feedback/feedback-comment-thread.d.ts +80 -0
  4. package/dist/feedback/feedback-comment-thread.js +162 -0
  5. package/dist/feedback/feedback-comment-thread.js.map +1 -0
  6. package/dist/feedback/feedback-dialog-labels.js +2 -1
  7. package/dist/feedback/feedback-dialog-labels.js.map +1 -1
  8. package/dist/feedback/feedback-dialog.d.ts +13 -0
  9. package/dist/feedback/feedback-dialog.js +12 -4
  10. package/dist/feedback/feedback-dialog.js.map +1 -1
  11. package/dist/feedback/feedback-menu-labels.js +2 -1
  12. package/dist/feedback/feedback-menu-labels.js.map +1 -1
  13. package/dist/feedback/feedback-menu.d.ts +17 -3
  14. package/dist/feedback/feedback-menu.js +11 -1
  15. package/dist/feedback/feedback-menu.js.map +1 -1
  16. package/dist/feedback/feedback-public-labels.d.ts +50 -0
  17. package/dist/feedback/feedback-public-labels.js +26 -0
  18. package/dist/feedback/feedback-public-labels.js.map +1 -0
  19. package/dist/feedback/feedback-public-list.d.ts +118 -0
  20. package/dist/feedback/feedback-public-list.js +248 -0
  21. package/dist/feedback/feedback-public-list.js.map +1 -0
  22. package/dist/feedback/feedback-public-switch.d.ts +51 -0
  23. package/dist/feedback/feedback-public-switch.js +50 -0
  24. package/dist/feedback/feedback-public-switch.js.map +1 -0
  25. package/dist/feedback/feedback-public-types.d.ts +75 -0
  26. package/dist/feedback/feedback-public-types.js +2 -0
  27. package/dist/feedback/feedback-public-types.js.map +1 -0
  28. package/dist/feedback/feedback-record.d.ts +32 -1
  29. package/dist/feedback/feedback-record.js +10 -0
  30. package/dist/feedback/feedback-record.js.map +1 -1
  31. package/dist/feedback/feedback-replies.d.ts +97 -0
  32. package/dist/feedback/feedback-replies.js +114 -0
  33. package/dist/feedback/feedback-replies.js.map +1 -0
  34. package/dist/feedback/feedback-row-detail-labels.js +1 -0
  35. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  36. package/dist/feedback/feedback-row-detail.d.ts +38 -10
  37. package/dist/feedback/feedback-row-detail.js +50 -50
  38. package/dist/feedback/feedback-row-detail.js.map +1 -1
  39. package/dist/feedback/feedback-submit-dialog.d.ts +3 -1
  40. package/dist/feedback/feedback-submit-dialog.js +6 -1
  41. package/dist/feedback/feedback-submit-dialog.js.map +1 -1
  42. package/dist/feedback/feedback-submit.d.ts +8 -0
  43. package/dist/feedback/feedback-submit.js +3 -2
  44. package/dist/feedback/feedback-submit.js.map +1 -1
  45. package/dist/feedback/feedback-table.d.ts +15 -5
  46. package/dist/feedback/feedback-table.js +46 -5
  47. package/dist/feedback/feedback-table.js.map +1 -1
  48. package/dist/feedback/feedback-thread-parts-labels.d.ts +6 -0
  49. package/dist/feedback/feedback-thread-parts-labels.js +36 -0
  50. package/dist/feedback/feedback-thread-parts-labels.js.map +1 -0
  51. package/dist/feedback/feedback-thread.d.ts +8 -0
  52. package/dist/feedback/feedback-thread.js +7 -2
  53. package/dist/feedback/feedback-thread.js.map +1 -1
  54. package/dist/feedback/feedback-vote-button.d.ts +48 -0
  55. package/dist/feedback/feedback-vote-button.js +72 -0
  56. package/dist/feedback/feedback-vote-button.js.map +1 -0
  57. package/dist/feedback.d.ts +6 -0
  58. package/dist/feedback.js +6 -0
  59. package/dist/feedback.js.map +1 -1
  60. package/dist/i18n/defaults.js +12 -0
  61. package/dist/i18n/defaults.js.map +1 -1
  62. package/dist/i18n/german.js +64 -2
  63. package/dist/i18n/german.js.map +1 -1
  64. package/dist/i18n/kit-labels.d.ts +16 -0
  65. package/dist/i18n/kit-labels.js.map +1 -1
  66. package/dist/i18n/locales/en.js +79 -2
  67. package/dist/i18n/locales/en.js.map +3 -3
  68. package/dist/i18n/locales/es.js +64 -2
  69. package/dist/i18n/locales/es.js.map +1 -1
  70. package/dist/i18n/locales/fr.js +64 -2
  71. package/dist/i18n/locales/fr.js.map +1 -1
  72. package/dist/i18n/locales/hu.js +64 -2
  73. package/dist/i18n/locales/hu.js.map +1 -1
  74. package/dist/i18n/locales/it.js +64 -2
  75. package/dist/i18n/locales/it.js.map +1 -1
  76. package/dist/i18n/locales/zh.js +64 -2
  77. package/dist/i18n/locales/zh.js.map +1 -1
  78. package/dist/i18n/review.js +4 -0
  79. package/dist/i18n/review.js.map +1 -1
  80. package/dist/index.d.ts +2 -0
  81. package/dist/index.js +6 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/notifications/feedback-notification-types-labels.d.ts +15 -0
  84. package/dist/notifications/feedback-notification-types-labels.js +11 -0
  85. package/dist/notifications/feedback-notification-types-labels.js.map +1 -0
  86. package/dist/notifications/feedback-notification-types.d.ts +28 -0
  87. package/dist/notifications/feedback-notification-types.js +21 -0
  88. package/dist/notifications/feedback-notification-types.js.map +1 -0
  89. package/package.json +1 -1
  90. package/src/feedback/feedback-comment-thread.tsx +255 -0
  91. package/src/feedback/feedback-dialog-labels.ts +1 -0
  92. package/src/feedback/feedback-dialog.tsx +30 -2
  93. package/src/feedback/feedback-menu-labels.ts +1 -0
  94. package/src/feedback/feedback-menu.tsx +29 -3
  95. package/src/feedback/feedback-public-labels.ts +76 -0
  96. package/src/feedback/feedback-public-list.tsx +395 -0
  97. package/src/feedback/feedback-public-switch.tsx +102 -0
  98. package/src/feedback/feedback-public-types.ts +82 -0
  99. package/src/feedback/feedback-record.ts +39 -1
  100. package/src/feedback/feedback-replies.tsx +235 -0
  101. package/src/feedback/feedback-row-detail-labels.ts +1 -0
  102. package/src/feedback/feedback-row-detail.tsx +103 -74
  103. package/src/feedback/feedback-submit-dialog.tsx +9 -0
  104. package/src/feedback/feedback-submit.tsx +10 -1
  105. package/src/feedback/feedback-table.tsx +73 -6
  106. package/src/feedback/feedback-thread-parts-labels.ts +41 -0
  107. package/src/feedback/feedback-thread.tsx +18 -1
  108. package/src/feedback/feedback-vote-button.tsx +122 -0
  109. package/src/feedback.ts +10 -0
  110. package/src/i18n/defaults.ts +12 -0
  111. package/src/i18n/german.ts +62 -0
  112. package/src/i18n/kit-labels.tsx +16 -0
  113. package/src/i18n/locales/en.ts +7 -0
  114. package/src/i18n/locales/es.ts +62 -0
  115. package/src/i18n/locales/fr.ts +62 -0
  116. package/src/i18n/locales/hu.ts +62 -0
  117. package/src/i18n/locales/it.ts +62 -0
  118. package/src/i18n/locales/zh.ts +62 -0
  119. package/src/i18n/review.ts +4 -0
  120. package/src/index.ts +9 -0
  121. package/src/notifications/feedback-notification-types-labels.ts +24 -0
  122. package/src/notifications/feedback-notification-types.ts +46 -0
@@ -0,0 +1,395 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { MessageSquare, ThumbsUp } from "lucide-react";
4
+ import { DataTable, type DataTableColumn } from "../components/data-table";
5
+ import { SearchField } from "../components/search-field";
6
+ import { ToggleGroup } from "../components/toggle-group";
7
+ import type { CommitScope } from "../components/write-lock";
8
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
9
+ import { cn } from "../lib/cn";
10
+ import { EMPTY_FORMATTED_VALUE } from "../lib/format";
11
+ import { FeedbackCategoryBadge, FeedbackDetail, FeedbackDetailSection, FeedbackProse, FeedbackStatusBadge } from "./feedback-inbox";
12
+ import { useFeedbackCategoryLabels } from "./feedback-labels";
13
+ import { FeedbackCommentThread, type FeedbackCommentsLabels } from "./feedback-comment-thread";
14
+ import { DEFAULT_FEEDBACK_PUBLIC_LABELS, type FeedbackPublicLabels } from "./feedback-public-labels";
15
+ import type {
16
+ FeedbackComment,
17
+ FeedbackPublicListItem,
18
+ FeedbackPublicRecord,
19
+ FeedbackPublicSort,
20
+ FeedbackVoteState,
21
+ } from "./feedback-public-types";
22
+ import {
23
+ useFeedbackPageLabels,
24
+ useFeedbackRenderDate,
25
+ type FeedbackPageLabels,
26
+ type FeedbackRenderDate,
27
+ } from "./feedback-table";
28
+ import { FeedbackVoteButton } from "./feedback-vote-button";
29
+
30
+ export { DEFAULT_FEEDBACK_PUBLIC_LABELS };
31
+ export type { FeedbackPublicLabels };
32
+
33
+ /** `feedbackPublic` resolved: English, then `<UiKitProvider labels>`, then `labels`. */
34
+ export function useFeedbackPublicLabels(labels?: LabelOverride<FeedbackPublicLabels>): FeedbackPublicLabels {
35
+ return useKitLabels("feedbackPublic", DEFAULT_FEEDBACK_PUBLIC_LABELS, labels);
36
+ }
37
+
38
+ /** The sort toggle's order: the server's default first (§9.6). */
39
+ const SORTS: readonly FeedbackPublicSort[] = ["newest", "votes", "updated"];
40
+
41
+ /** A date in a cell, as the inbox writes it: `<time>`, nowrap, tabular figures. */
42
+ function PublicDate({ value, renderDate }: { value: string | null | undefined; renderDate: FeedbackRenderDate }) {
43
+ if (!value) return <span className="text-placeholder">{EMPTY_FORMATTED_VALUE}</span>;
44
+ return (
45
+ <time dateTime={value} className="whitespace-nowrap tabular-nums text-muted">
46
+ {renderDate(value)}
47
+ </time>
48
+ );
49
+ }
50
+
51
+ /** The visible comments as a small chip beside the title — the count in figures, the
52
+ * words for a screen reader. Nothing at 0. */
53
+ function CommentCount({ count, labels }: { count: number; labels: FeedbackPublicLabels }) {
54
+ if (!count) return null;
55
+ return (
56
+ <span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted" data-feedback-comment-count={count}>
57
+ <MessageSquare className="size-3.5" aria-hidden />
58
+ <span aria-hidden className="tabular-nums">
59
+ {count}
60
+ </span>
61
+ <span className="sr-only">{labels.comments(count)}</span>
62
+ </span>
63
+ );
64
+ }
65
+
66
+ /** The votes on a phone card: the thumb and the count, in the brand colour when the
67
+ * viewer's own. */
68
+ function VoteCount({ count, voted, labels }: { count: number; voted: boolean; labels: FeedbackPublicLabels }) {
69
+ return (
70
+ <span
71
+ className={cn("ms-auto inline-flex shrink-0 items-center gap-1 text-xs", voted ? "text-brand" : "text-muted")}
72
+ data-feedback-vote-count={count}
73
+ >
74
+ <ThumbsUp className="size-3.5" aria-hidden />
75
+ <span aria-hidden className="tabular-nums">
76
+ {count}
77
+ </span>
78
+ <span className="sr-only">{labels.votes(count)}</span>
79
+ </span>
80
+ );
81
+ }
82
+
83
+ /* ── The list ─────────────────────────────────────────────────────────────── */
84
+
85
+ export interface FeedbackPublicListProps<T extends FeedbackPublicListItem = FeedbackPublicListItem> {
86
+ /** `GET /feedback/public?sort=&q=` — the server's page, in the server's order. */
87
+ rows: T[];
88
+ /** How many the query has in all — the line beside the sort. Left out, no line. */
89
+ total?: number;
90
+ /** The server sorts (§9.6): the toggle only says which way. */
91
+ sort: FeedbackPublicSort;
92
+ onSortChange: (sort: FeedbackPublicSort) => void;
93
+ /** The search box's text (`q`, titles only). */
94
+ search?: string;
95
+ /**
96
+ * The search box is drawn ONLY with this handler: where the app's titles are sealed
97
+ * (Kurvenschmiede, `FeedbackDeps.public_search=False`, §9.9) there is nothing to search
98
+ * on and the server answers `q` with a 422 — so it passes none.
99
+ */
100
+ onSearchChange?: (search: string) => void;
101
+ /** The opened row: the app's {@link FeedbackPublicDetail} with the row's
102
+ * `GET /feedback/public/{id}` and its comments. */
103
+ renderDetail: (row: T) => ReactNode;
104
+ /** `PUT` / `DELETE /feedback/{id}/vote` for a row's button (`next`: the new state);
105
+ * resolve with the answer, reject to roll back. */
106
+ onVote: (row: T, next: boolean) => Promise<FeedbackVoteState>;
107
+ /** The page is loading: the table says so where its rows would be. */
108
+ loading?: boolean;
109
+ /** The opened row's id, when the app keeps it (a `?row=` param). Left out, the list
110
+ * keeps it itself. */
111
+ openId?: number | null;
112
+ onOpenChange?: (id: number | null) => void;
113
+ /** A vote COMMITS — see Button's `commit`. */
114
+ commit?: CommitScope;
115
+ /** See {@link FeedbackRenderDate}. Default: the kit's, with the weekday. */
116
+ renderDate?: FeedbackRenderDate;
117
+ /** Over the `feedbackPublic` namespace. The shared column names (#, Date, Category,
118
+ * Subject, Status) are `feedbackPage`'s: pass `pageLabels` for those. */
119
+ labels?: LabelOverride<FeedbackPublicLabels>;
120
+ pageLabels?: LabelOverride<FeedbackPageLabels>;
121
+ className?: string;
122
+ }
123
+
124
+ /**
125
+ * **All feedback** — the reports every user the app lets in may read (round 0.38,
126
+ * docs/feedback-harmonization.md §9.3, §9.9; keksdose's `/all-feedback`).
127
+ *
128
+ * - **Titles only** (decision 23): #, Date, Category, Subject (with the visible
129
+ * comments), Votes, Status. No body, no submitter, no file — the list item carries
130
+ * none of them (§9.10).
131
+ * - **The server sorts**: the toggle **Newest / Most wanted / Recently updated** sets
132
+ * `sort`, and the columns themselves do not sort (a page of 50 sorted in the browser
133
+ * would not be the 50 the sort asks for).
134
+ * - **The search box only with `onSearchChange`**: an app whose titles are sealed
135
+ * passes none (§9.9).
136
+ * - **Votes** are `FeedbackVoteButton`s, optimistic, the viewer's own report pressed
137
+ * and locked ("Your report", §9.6).
138
+ * - A row opens in place with `renderDetail` — on the phone, in a full-screen dialog.
139
+ * The phone cards carry the title, the status, the category, the date and the two
140
+ * counts; the vote itself is in the opened report (a button inside the card, which is
141
+ * the row's own button, would be two controls in one).
142
+ *
143
+ * No paging of its own: the server's `limit` is the page, and `total` says how many
144
+ * there are (an app with more than one page adds its pager below). Needs a router, as
145
+ * every `DataTable` does.
146
+ */
147
+ export function FeedbackPublicList<T extends FeedbackPublicListItem = FeedbackPublicListItem>({
148
+ rows,
149
+ total,
150
+ sort,
151
+ onSortChange,
152
+ search = "",
153
+ onSearchChange,
154
+ renderDetail,
155
+ onVote,
156
+ loading = false,
157
+ openId: openIdProp,
158
+ onOpenChange,
159
+ commit,
160
+ renderDate: renderDateProp,
161
+ labels: labelsProp,
162
+ pageLabels: pageLabelsProp,
163
+ className,
164
+ }: FeedbackPublicListProps<T>) {
165
+ const labels = useFeedbackPublicLabels(labelsProp);
166
+ const page = useFeedbackPageLabels(pageLabelsProp);
167
+ const categoryLabels = useFeedbackCategoryLabels();
168
+ const renderDate = useFeedbackRenderDate(renderDateProp);
169
+ const [ownOpenId, setOwnOpenId] = useState<number | null>(null);
170
+ const openId = openIdProp !== undefined ? openIdProp : ownOpenId;
171
+ const setOpen = (id: number | null) => {
172
+ if (openIdProp === undefined) setOwnOpenId(id);
173
+ onOpenChange?.(id);
174
+ };
175
+
176
+ const columns = useMemo<DataTableColumn<T>[]>(
177
+ () => [
178
+ {
179
+ key: "id",
180
+ header: page.columnId,
181
+ cell: (row) => <span className="font-mono text-xs text-muted">{row.id}</span>,
182
+ mobileHidden: true,
183
+ },
184
+ {
185
+ key: "date",
186
+ header: page.columnDate,
187
+ cell: (row) => <PublicDate value={row.created_at} renderDate={renderDate} />,
188
+ },
189
+ {
190
+ key: "category",
191
+ header: page.columnCategory,
192
+ cell: (row) => <FeedbackCategoryBadge category={row.category} label={categoryLabels[row.category]} />,
193
+ },
194
+ {
195
+ key: "title",
196
+ header: page.columnSubject,
197
+ cell: (row) => (
198
+ <span className="inline-flex min-w-0 max-w-full items-center gap-2 large:items-start">
199
+ <span className="min-w-0 truncate-until-large font-medium">{row.title}</span>
200
+ <CommentCount count={row.comment_count} labels={labels} />
201
+ </span>
202
+ ),
203
+ mobilePrimary: true,
204
+ },
205
+ {
206
+ key: "votes",
207
+ header: labels.columnVotes,
208
+ className: "whitespace-nowrap",
209
+ // The cell is a button: the row's own link must never wrap it.
210
+ noRowLink: true,
211
+ cell: (row) => (
212
+ <FeedbackVoteButton
213
+ votes={row.votes}
214
+ voted={row.voted}
215
+ own={row.mine}
216
+ onVote={(next) => onVote(row, next)}
217
+ commit={commit}
218
+ size="sm"
219
+ />
220
+ ),
221
+ },
222
+ {
223
+ key: "status",
224
+ header: page.columnStatus,
225
+ className: "whitespace-nowrap",
226
+ cell: (row) => <FeedbackStatusBadge status={row.status} />,
227
+ },
228
+ ],
229
+ [page, labels, categoryLabels, renderDate, onVote, commit],
230
+ );
231
+
232
+ return (
233
+ <div className={cn("flex flex-col gap-3", className)} data-feedback-public-list="" aria-busy={loading || undefined}>
234
+ <div className="flex flex-wrap items-center gap-2">
235
+ <ToggleGroup
236
+ aria-label={labels.sort}
237
+ value={sort}
238
+ onChange={(value) => onSortChange(value)}
239
+ options={SORTS.map((value) => ({
240
+ value,
241
+ label: value === "newest" ? labels.sortNewest : value === "votes" ? labels.sortVotes : labels.sortUpdated,
242
+ }))}
243
+ />
244
+ {onSearchChange && (
245
+ <SearchField
246
+ aria-label={labels.search}
247
+ placeholder={labels.search}
248
+ value={search}
249
+ onChange={onSearchChange}
250
+ className="min-w-[12rem] flex-1 md:max-w-xs"
251
+ />
252
+ )}
253
+ {total !== undefined && (
254
+ <span className="ms-auto text-sm text-muted" data-feedback-public-total={total}>
255
+ {labels.total(total)}
256
+ </span>
257
+ )}
258
+ </div>
259
+ <DataTable
260
+ rows={rows}
261
+ columns={columns}
262
+ rowKey={(row) => row.id}
263
+ rowName={(row) => row.title}
264
+ onRowClick={(row) => setOpen(openId === row.id ? null : row.id)}
265
+ isExpanded={(row) => row.id === openId}
266
+ expandedRow={(row) => renderDetail(row)}
267
+ mobileExpandAsDialog
268
+ paginated={false}
269
+ empty={loading ? labels.loading : labels.empty}
270
+ mobileCard={(row) => (
271
+ <div className="min-w-0 space-y-1">
272
+ <div className="flex items-start justify-between gap-2">
273
+ <span className="min-w-0 flex-1 font-medium [overflow-wrap:anywhere]">{row.title}</span>
274
+ <FeedbackStatusBadge status={row.status} className="shrink-0" />
275
+ </div>
276
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
277
+ <FeedbackCategoryBadge category={row.category} compact label={categoryLabels[row.category]} />
278
+ <PublicDate value={row.created_at} renderDate={renderDate} />
279
+ <CommentCount count={row.comment_count} labels={labels} />
280
+ {/* A count, not the button: the whole card is the row's button, and a button
281
+ inside it is two controls in one. The vote is in the opened report. */}
282
+ <VoteCount count={row.votes} voted={row.voted || row.mine} labels={labels} />
283
+ </div>
284
+ </div>
285
+ )}
286
+ />
287
+ </div>
288
+ );
289
+ }
290
+
291
+ /* ── The detail ───────────────────────────────────────────────────────────── */
292
+
293
+ export interface FeedbackPublicDetailProps {
294
+ /** `GET /feedback/public/{id}`: the public body (attachment URLs already stripped by
295
+ * the server, §9.4) and the replies. */
296
+ row: FeedbackPublicRecord;
297
+ /** `GET /feedback/{id}/comments`, oldest first; `undefined` while it loads. */
298
+ comments: readonly FeedbackComment[] | undefined;
299
+ /** `PUT` / `DELETE /feedback/{id}/vote`; resolve with the answer, reject to roll back. */
300
+ onVote: (next: boolean) => Promise<FeedbackVoteState>;
301
+ /** The viewer is the app's admin: Hide / Unhide on the comments. */
302
+ isAdmin?: boolean;
303
+ /** `POST /feedback/{id}/comments`. Left out — a demo, a reader who may not write — no
304
+ * composer. */
305
+ onComment?: (body: string) => void | Promise<unknown>;
306
+ /** The admin's hide (`PATCH …/comments/{id} {hidden}`). */
307
+ onHide?: (comment: FeedbackComment, hidden: boolean) => void | Promise<unknown>;
308
+ /** The writer's delete (`DELETE …/comments/{id}`), after a confirm. */
309
+ onDelete?: (comment: FeedbackComment) => void | Promise<unknown>;
310
+ /** A comment is being sent. */
311
+ pending?: boolean;
312
+ /** The vote and the thread's buttons COMMIT — see Button's `commit`. */
313
+ commit?: CommitScope;
314
+ /** Voting is off for this viewer (a demo): `true`, or the reason. */
315
+ voteDisabled?: boolean | string;
316
+ /** See {@link FeedbackRenderDate}. */
317
+ renderDate?: FeedbackRenderDate;
318
+ /** "Now" for the thread's relative times — pin it in tests and stories. */
319
+ now?: Date | number;
320
+ /** Over the `feedbackPublic` namespace. */
321
+ labels?: LabelOverride<FeedbackPublicLabels>;
322
+ /** Over the `feedbackComments` namespace, for the thread. */
323
+ commentLabels?: LabelOverride<FeedbackCommentsLabels>;
324
+ className?: string;
325
+ }
326
+
327
+ /**
328
+ * One public report opened (round 0.38, docs/feedback-harmonization.md §9.3, §9.10): who
329
+ * filed it — the first name the server sent, else **"A user"** (§9.5) — and when; the
330
+ * **vote**; the **public body**; and the **conversation**, the admin's replies and other
331
+ * people's comments merged oldest first (§9.8, `FeedbackCommentThread`), with the box to
332
+ * add one.
333
+ *
334
+ * What it never draws, because the public record never carries it (§9.10): an address,
335
+ * a last name, a screenshot, a file, the page the report was filed on. The body is the
336
+ * server's `public_body` — the client strips nothing and adds nothing.
337
+ */
338
+ export function FeedbackPublicDetail({
339
+ row,
340
+ comments,
341
+ onVote,
342
+ isAdmin = false,
343
+ onComment,
344
+ onHide,
345
+ onDelete,
346
+ pending,
347
+ commit,
348
+ voteDisabled,
349
+ renderDate: renderDateProp,
350
+ now,
351
+ labels: labelsProp,
352
+ commentLabels,
353
+ className,
354
+ }: FeedbackPublicDetailProps) {
355
+ const labels = useFeedbackPublicLabels(labelsProp);
356
+ const renderDate = useFeedbackRenderDate(renderDateProp);
357
+ return (
358
+ <FeedbackDetail className={className}>
359
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2" data-feedback-public-detail={row.id}>
360
+ <span className="text-sm text-secondary" data-feedback-author="">
361
+ {labels.filedBy(row.author_name ?? labels.anonymous)}
362
+ </span>
363
+ <PublicDate value={row.created_at} renderDate={renderDate} />
364
+ <FeedbackVoteButton
365
+ votes={row.votes}
366
+ voted={row.voted}
367
+ own={row.mine}
368
+ onVote={onVote}
369
+ disabled={voteDisabled}
370
+ commit={commit}
371
+ className="ms-auto"
372
+ />
373
+ </div>
374
+ <FeedbackDetailSection title={labels.body}>
375
+ {/* Rework rounds stay in it — the author's own words (§9.4). */}
376
+ <FeedbackProse>{row.body}</FeedbackProse>
377
+ </FeedbackDetailSection>
378
+ <FeedbackDetailSection title={labels.conversation}>
379
+ <FeedbackCommentThread
380
+ replies={row.replies}
381
+ comments={comments ?? []}
382
+ loading={comments === undefined}
383
+ isAdmin={isAdmin}
384
+ onComment={onComment}
385
+ onHide={onHide}
386
+ onDelete={onDelete}
387
+ pending={pending}
388
+ commit={commit}
389
+ now={now}
390
+ labels={commentLabels}
391
+ />
392
+ </FeedbackDetailSection>
393
+ </FeedbackDetail>
394
+ );
395
+ }
@@ -0,0 +1,102 @@
1
+ import { Switch } from "../components/switch";
2
+ import type { CommitScope } from "../components/write-lock";
3
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
4
+ import { FEEDBACK_AUTHOR_EDITABLE_STATUSES, type FeedbackRecord } from "./feedback-record";
5
+ import { DEFAULT_FEEDBACK_PUBLIC_LABELS, type FeedbackPublicLabels } from "./feedback-public-labels";
6
+
7
+ /** What the switch reads off a report: {@link FeedbackRecord}'s fields (§9.3). */
8
+ export type FeedbackPublicSwitchRow = Pick<
9
+ FeedbackRecord,
10
+ "user_id" | "category" | "status" | "public" | "filed_private" | "mine"
11
+ >;
12
+
13
+ /** Who is looking: the app's admin check, and the signed-in user's id. */
14
+ export interface FeedbackPublicSwitchViewer {
15
+ isAdmin: boolean;
16
+ viewerId?: number | null;
17
+ }
18
+
19
+ /** The viewer filed the row — `mine` when the server sent it, else the ids (never for an
20
+ * erased author, `user_id: null`). */
21
+ function isAuthorOf(row: FeedbackPublicSwitchRow, viewerId: number | null | undefined): boolean {
22
+ if (row.mine !== undefined) return row.mine;
23
+ return row.user_id !== null && viewerId !== undefined && viewerId !== null && row.user_id === viewerId;
24
+ }
25
+
26
+ /**
27
+ * Whether the viewer may flip a report's `public` flag — the server's rule (§9.3), so the
28
+ * switch never offers what the `PATCH {public}` would refuse with a 403:
29
+ *
30
+ * - never on a CRASH (decision 9: a crash is private whatever its flag);
31
+ * - an admin, at any status;
32
+ * - the author while the status is OPEN, READY or IN_PROGRESS
33
+ * (`FEEDBACK_AUTHOR_EDITABLE_STATUSES`), and on a `filed_private` report — one the
34
+ * 0.38 migration made public — at any status: that is the opt-out the notice offers.
35
+ */
36
+ export function feedbackPublicSwitchAllowed(
37
+ row: FeedbackPublicSwitchRow,
38
+ { isAdmin, viewerId }: FeedbackPublicSwitchViewer,
39
+ ): boolean {
40
+ if (row.category === "CRASH") return false;
41
+ if (isAdmin) return true;
42
+ if (!isAuthorOf(row, viewerId)) return false;
43
+ return row.filed_private === true || FEEDBACK_AUTHOR_EDITABLE_STATUSES.includes(row.status);
44
+ }
45
+
46
+ export interface FeedbackPublicSwitchProps extends FeedbackPublicSwitchViewer {
47
+ row: FeedbackPublicSwitchRow;
48
+ /** `PATCH /feedback/{id} {"public": next}` — the app's mutation. */
49
+ onChange: (next: boolean) => void;
50
+ /** The PATCH is in flight: the switch holds still. */
51
+ pending?: boolean;
52
+ /** Flipping it SAVES: pass `true` to put it under the page's `WriteLockProvider`
53
+ * (Switch's `commit`). Off by default, as on the inbox's status buttons. */
54
+ commit?: CommitScope;
55
+ /** Over the `feedbackPublic` namespace (`visibleToOthers`, `visibleHint`,
56
+ * `visibleLocked`). */
57
+ labels?: LabelOverride<FeedbackPublicLabels>;
58
+ className?: string;
59
+ }
60
+
61
+ /**
62
+ * **Visible to others** — a report's `public` flag (round 0.38, docs/feedback-harmonization.md
63
+ * §9.3, decision 6), for the row detail (`FeedbackRowDetail`'s `onPublicChange`) or an
64
+ * app's own page.
65
+ *
66
+ * - Drawn for the admin and the author only: nobody else is sent `public` (§9.10). Not
67
+ * drawn on a CRASH, and not when the row carries no `public` at all (a server before
68
+ * 0.38, or an app without public parts — kastlan).
69
+ * - The author past the open window sees it **locked with the reason** ("Only while the
70
+ * report is open"), focusable, so the reason reaches a keyboard; a `filed_private`
71
+ * report is never locked for its author ({@link feedbackPublicSwitchAllowed}).
72
+ * - The line under it says what "visible" means: the title and the text, never the
73
+ * screenshots or files (decision 1).
74
+ */
75
+ export function FeedbackPublicSwitch({
76
+ row,
77
+ isAdmin,
78
+ viewerId,
79
+ onChange,
80
+ pending = false,
81
+ commit,
82
+ labels: labelsProp,
83
+ className,
84
+ }: FeedbackPublicSwitchProps) {
85
+ const labels = useKitLabels("feedbackPublic", DEFAULT_FEEDBACK_PUBLIC_LABELS, labelsProp);
86
+ if (row.category === "CRASH" || row.public === undefined) return null;
87
+ if (!isAdmin && !isAuthorOf(row, viewerId)) return null;
88
+ const allowed = feedbackPublicSwitchAllowed(row, { isAdmin, viewerId });
89
+ return (
90
+ <Switch
91
+ className={className}
92
+ label={labels.visibleToOthers}
93
+ description={labels.visibleHint}
94
+ checked={row.public}
95
+ disabled={pending}
96
+ disabledReason={allowed ? undefined : labels.visibleLocked}
97
+ commit={commit}
98
+ onCheckedChange={(next) => onChange(next)}
99
+ data-feedback-public={row.public ? "on" : "off"}
100
+ />
101
+ );
102
+ }
@@ -0,0 +1,82 @@
1
+ import type { FeedbackCategory, FeedbackStatus } from "./feedback-vocabulary";
2
+
3
+ /**
4
+ * The shapes of round 0.38's public feedback (docs/feedback-harmonization.md §9): the
5
+ * admin's replies as a list (§9.7), other people's comments as rows (§9.8), the upvote
6
+ * (§9.6) and the two public views of a report (§9.3). Types only: what the server
7
+ * answers, as the three apps' clients receive it.
8
+ *
9
+ * A name is always the server's (§9.5): `author_name` is a first name for others, the
10
+ * full name for an admin, and `null` when there is none to show (an erased or incomplete
11
+ * account, a loop's reply). The client never builds one from an id.
12
+ */
13
+
14
+ /** One answer of the admin's — `FeedbackReplyResponse`, §9.7. Oldest first. */
15
+ export interface FeedbackReply {
16
+ id: number;
17
+ /** `null`: a loop's reply, a migrated `outcome`, or an erased admin — drawn as the
18
+ * app's word for its admin ("Admin"). */
19
+ author_name: string | null;
20
+ body: string;
21
+ /** The status this reply moved the report to; `null` = no status change. */
22
+ status_to: FeedbackStatus | null;
23
+ /** Where it moved from — the admin view only. */
24
+ status_from?: FeedbackStatus | null;
25
+ /** ISO 8601. */
26
+ created_at: string;
27
+ }
28
+
29
+ /** One comment by someone who reads the report — `FeedbackCommentResponse`, §9.8. */
30
+ export interface FeedbackComment {
31
+ id: number;
32
+ /** First name for others, the full name for an admin; `null` → "A user". */
33
+ author_name: string | null;
34
+ /** The viewer wrote it (they may delete it). */
35
+ mine: boolean;
36
+ /** keksdose's migrated rows whose writer was an admin (K2): drawn as a staff post. */
37
+ staff: boolean;
38
+ /** `null` when hidden, but for its writer and an admin. */
39
+ body: string | null;
40
+ hidden: boolean;
41
+ /** ISO 8601. */
42
+ created_at: string;
43
+ }
44
+
45
+ /** What `PUT` / `DELETE /feedback/{id}/vote` answer — `FeedbackVoteResponse`, §9.6. */
46
+ export interface FeedbackVoteState {
47
+ votes: number;
48
+ voted: boolean;
49
+ }
50
+
51
+ /** The public list's server-side sort (§9.6): newest first (default), most votes (then
52
+ * newest), or most recently updated. */
53
+ export type FeedbackPublicSort = "newest" | "votes" | "updated";
54
+
55
+ /** One row of `GET /feedback/public` — `FeedbackPublicListItem`, §9.3, §9.9: titles
56
+ * only, no body, no files, no account. */
57
+ export interface FeedbackPublicListItem extends FeedbackVoteState {
58
+ id: number;
59
+ title: string;
60
+ category: FeedbackCategory;
61
+ status: FeedbackStatus;
62
+ /** First name, or `null` → "A user". */
63
+ author_name: string | null;
64
+ /** The viewer filed it. */
65
+ mine: boolean;
66
+ /** Visible comments. */
67
+ comment_count: number;
68
+ /** ISO 8601. */
69
+ created_at: string;
70
+ /** ISO 8601. */
71
+ updated_at: string;
72
+ /** ISO 8601, or `null`. */
73
+ resolved_at: string | null;
74
+ }
75
+
76
+ /** `GET /feedback/public/{id}` — `FeedbackPublicResponse`, §9.3: the list row plus the
77
+ * public body (every attachment URL stripped by the server, §9.4) and the replies. The
78
+ * comments are fetched beside it (`GET /feedback/{id}/comments`). */
79
+ export interface FeedbackPublicRecord extends FeedbackPublicListItem {
80
+ body: string;
81
+ replies: FeedbackReply[];
82
+ }
@@ -1,5 +1,6 @@
1
1
  import { FEEDBACK_CATEGORY_ORDER } from "./feedback-vocabulary";
2
2
  import type { FeedbackCategory, FeedbackStatus } from "./feedback-vocabulary";
3
+ import type { FeedbackReply } from "./feedback-public-types";
3
4
 
4
5
  /**
5
6
  * One feedback report as the three apps send it, and the facts the kit reads off it.
@@ -102,7 +103,9 @@ export interface FeedbackRecord {
102
103
  /** Up to 5 other files; `null` = none. Optional because a server older than the
103
104
  * column sends no key at all (keksdose dev #578 met exactly that). */
104
105
  attachment_urls?: string[] | null;
105
- /** The team's answer; `null` = none written. */
106
+ /** The team's answer; `null` = none written. **Frozen since 0.38** (§9.7): answered
107
+ * as stored for one release, written by nothing, then dropped — the answer is
108
+ * {@link FeedbackRecord.replies} now. */
106
109
  outcome: string | null;
107
110
  /** Set on ENTERING `DONE` / `WONT_DO`, cleared on leaving them. ISO 8601. */
108
111
  resolved_at: string | null;
@@ -110,6 +113,24 @@ export interface FeedbackRecord {
110
113
  created_at: string;
111
114
  /** ISO 8601. */
112
115
  updated_at: string;
116
+ // ── Round 0.38 (§9.3): all optional, so an app without public parts sends none. ──
117
+ /** The admin: the full name; the author: their own; `null`: none to show. */
118
+ author_name?: string | null;
119
+ /** The viewer filed it. */
120
+ mine?: boolean;
121
+ /** Visible to the app's other users (§9.3); never on a CRASH. */
122
+ public?: boolean;
123
+ /** Made public by the 0.38 migration: the author may switch it off at any status. */
124
+ filed_private?: boolean;
125
+ /** People who want this (the author's own vote included, §9.6). */
126
+ votes?: number;
127
+ /** The viewer's vote. */
128
+ voted?: boolean;
129
+ /** Comments (an admin's count includes hidden ones). */
130
+ comment_count?: number;
131
+ /** The admin's answers, oldest first (§9.7). Absent: a server before 0.38 — read
132
+ * `outcome` instead. */
133
+ replies?: FeedbackReply[];
113
134
  }
114
135
 
115
136
  /* ── Constants (§3.4, §3.5, §4.3) ─────────────────────────────────────────── */
@@ -195,6 +216,23 @@ export const FEEDBACK_AWAITING_STATUSES: readonly FeedbackStatus[] = Object.free
195
216
  "NEEDS_LIVE_TEST",
196
217
  ] as const);
197
218
 
219
+ /**
220
+ * The **decided** statuses (§9.7, decision 13): `DONE`, `WONT_DO`, `POSTPONED`. A
221
+ * status-only move into one tells the author nothing (a status alone sends no
222
+ * notification), so the row detail nudges the admin toward a reply until one with that
223
+ * `status_to` exists.
224
+ */
225
+ export const FEEDBACK_DECIDED_STATUSES: readonly FeedbackStatus[] = Object.freeze([
226
+ "DONE",
227
+ "WONT_DO",
228
+ "POSTPONED",
229
+ ] as const);
230
+
231
+ /** A comment's longest body after trimming (§9.8). */
232
+ export const FEEDBACK_COMMENT_MAX = 4000;
233
+ /** A reply's longest body after trimming (§9.7). */
234
+ export const FEEDBACK_REPLY_MAX = 8000;
235
+
198
236
  /* ── The body (§3.4) ──────────────────────────────────────────────────────── */
199
237
 
200
238
  /**