@eifi1/ui-kit 0.46.1 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/admin/admin-sections.d.ts +8 -0
- package/dist/admin/admin-sections.js +2 -1
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/auth/verify-email.d.ts +9 -1
- package/dist/auth/verify-email.js +22 -13
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/components/alert-banner.d.ts +29 -1
- package/dist/components/alert-banner.js +84 -7
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/button-look.d.ts +4 -0
- package/dist/components/button-look.js +2 -0
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/combobox-core.js +5 -1
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/data-table-column-settings.d.ts +6 -1
- package/dist/components/data-table-column-settings.js +12 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-fit.d.ts +44 -0
- package/dist/components/data-table-fit.js +103 -0
- package/dist/components/data-table-fit.js.map +1 -0
- package/dist/components/data-table-groups.d.ts +95 -0
- package/dist/components/data-table-groups.js +165 -0
- package/dist/components/data-table-groups.js.map +1 -0
- package/dist/components/data-table-head.js +3 -1
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +7 -0
- package/dist/components/data-table-labels.js +3 -0
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-leading-row.d.ts +17 -0
- package/dist/components/data-table-leading-row.js +41 -0
- package/dist/components/data-table-leading-row.js.map +1 -0
- package/dist/components/data-table-resize.js +1 -1
- package/dist/components/data-table-resize.js.map +1 -1
- package/dist/components/data-table.d.ts +78 -2
- package/dist/components/data-table.js +355 -247
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/disclosure.d.ts +3 -1
- package/dist/components/disclosure.js +10 -4
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/dropdown.js +8 -1
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/floating-panel.d.ts +14 -1
- package/dist/components/floating-panel.js +7 -2
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/hover-menu.js +5 -1
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/icon-button.js +2 -1
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/popover.js +5 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/row-actions.d.ts +25 -0
- package/dist/components/row-actions.js +13 -3
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/table-skeleton.d.ts +35 -0
- package/dist/components/table-skeleton.js +75 -0
- package/dist/components/table-skeleton.js.map +1 -0
- package/dist/components/tabs.d.ts +22 -16
- package/dist/components/tabs.js +47 -31
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/ui-labels.js +1 -1
- package/dist/components/ui-labels.js.map +1 -1
- package/dist/components/use-table-state.d.ts +8 -7
- package/dist/components/use-table-state.js +37 -3
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/data-table.d.ts +1 -0
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-attachment-download.d.ts +19 -0
- package/dist/feedback/feedback-attachment-download.js +59 -0
- package/dist/feedback/feedback-attachment-download.js.map +1 -0
- package/dist/feedback/feedback-comment-thread.d.ts +17 -6
- package/dist/feedback/feedback-comment-thread.js +21 -2
- package/dist/feedback/feedback-comment-thread.js.map +1 -1
- package/dist/feedback/feedback-entry-file.d.ts +61 -0
- package/dist/feedback/feedback-entry-file.js +76 -0
- package/dist/feedback/feedback-entry-file.js.map +1 -0
- package/dist/feedback/feedback-public-list.d.ts +18 -5
- package/dist/feedback/feedback-public-list.js +8 -0
- package/dist/feedback/feedback-public-list.js.map +1 -1
- package/dist/feedback/feedback-public-types.d.ts +7 -2
- package/dist/feedback/feedback-replies.d.ts +26 -9
- package/dist/feedback/feedback-replies.js +20 -10
- package/dist/feedback/feedback-replies.js.map +1 -1
- package/dist/feedback/feedback-row-detail-labels.js +0 -1
- package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +23 -10
- package/dist/feedback/feedback-row-detail.js +10 -54
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-table.d.ts +5 -0
- package/dist/feedback/feedback-table.js +30 -7
- package/dist/feedback/feedback-table.js.map +1 -1
- package/dist/feedback.d.ts +1 -0
- package/dist/feedback.js +1 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-dismiss.d.ts +30 -1
- package/dist/hooks/use-dismiss.js +30 -2
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/i18n/german.js +7 -7
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +4 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +7 -7
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +7 -7
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +7 -7
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +7 -7
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +7 -7
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +7 -7
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +0 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/shared-labels.js +3 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +13 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/float-clearance.d.ts +8 -0
- package/dist/lib/float-clearance.js +29 -0
- package/dist/lib/float-clearance.js.map +1 -0
- package/dist/notifications/device-zone-report.d.ts +1 -1
- package/dist/notifications/device-zone-report.js.map +1 -1
- package/dist/notifications/notification-types.d.ts +0 -3
- package/dist/notifications/notifications-labels.d.ts +2 -15
- package/dist/notifications/notifications-labels.js +0 -4
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/quiet-hours-setting.d.ts +3 -8
- package/dist/notifications/quiet-hours-setting.js +8 -3
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/settings/settings-catalogue.d.ts +10 -0
- package/dist/settings/settings-catalogue.js.map +1 -1
- package/dist/settings/settings-layout.js +10 -3
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/shell/app-shell.d.ts +5 -0
- package/dist/shell/app-shell.js +5 -3
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.d.ts +2 -0
- package/dist/shell/option-switcher-menu.js +6 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +2 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.js +5 -3
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/theme/theme-store.d.ts +4 -4
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour.js +1 -0
- package/dist/tour/tour.js.map +1 -1
- package/package.json +2 -2
- package/src/admin/admin-sections.tsx +8 -0
- package/src/auth/verify-email.tsx +34 -9
- package/src/components/alert-banner.tsx +131 -7
- package/src/components/button-look.ts +6 -0
- package/src/components/combobox-core.tsx +7 -1
- package/src/components/data-table-column-settings.tsx +23 -2
- package/src/components/data-table-fit.ts +187 -0
- package/src/components/data-table-groups.tsx +275 -0
- package/src/components/data-table-head.tsx +4 -1
- package/src/components/data-table-labels.ts +13 -0
- package/src/components/data-table-leading-row.tsx +55 -0
- package/src/components/data-table-resize.ts +2 -1
- package/src/components/data-table.tsx +274 -45
- package/src/components/disclosure.tsx +22 -5
- package/src/components/dropdown.tsx +11 -1
- package/src/components/floating-panel.tsx +24 -1
- package/src/components/hover-menu.tsx +6 -1
- package/src/components/icon-button.tsx +3 -3
- package/src/components/popover.tsx +7 -1
- package/src/components/row-actions.tsx +42 -3
- package/src/components/table-skeleton.tsx +133 -0
- package/src/components/tabs.tsx +102 -50
- package/src/components/ui-labels.ts +1 -1
- package/src/components/use-table-state.ts +54 -3
- package/src/data-table.ts +2 -0
- package/src/feedback/feedback-attachment-download.tsx +77 -0
- package/src/feedback/feedback-comment-thread.tsx +42 -7
- package/src/feedback/feedback-entry-file.tsx +150 -0
- package/src/feedback/feedback-public-list.tsx +25 -4
- package/src/feedback/feedback-public-types.ts +7 -2
- package/src/feedback/feedback-replies.tsx +46 -19
- package/src/feedback/feedback-row-detail-labels.ts +0 -1
- package/src/feedback/feedback-row-detail.tsx +35 -86
- package/src/feedback/feedback-table.tsx +33 -1
- package/src/feedback.ts +1 -0
- package/src/hooks/use-dismiss.ts +65 -3
- package/src/i18n/german.ts +6 -6
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/es.ts +6 -6
- package/src/i18n/locales/fr.ts +6 -6
- package/src/i18n/locales/hu.ts +6 -6
- package/src/i18n/locales/it.ts +6 -6
- package/src/i18n/locales/zh.ts +6 -6
- package/src/i18n/review.ts +0 -1
- package/src/i18n/shared-labels.ts +2 -0
- package/src/index.ts +13 -3
- package/src/lib/float-clearance.ts +54 -0
- package/src/notifications/device-zone-report.ts +1 -1
- package/src/notifications/notification-types.ts +0 -3
- package/src/notifications/notifications-labels.ts +2 -19
- package/src/notifications/quiet-hours-setting.tsx +16 -11
- package/src/settings/settings-catalogue.ts +10 -0
- package/src/settings/settings-layout.tsx +18 -4
- package/src/shell/app-shell.tsx +17 -3
- package/src/shell/option-switcher-menu.tsx +16 -4
- package/src/shell/topbar-action-menu.tsx +22 -10
- package/src/shell/topbar-controls.tsx +18 -12
- package/src/theme/theme-store.ts +13 -5
- package/src/tour/tour.tsx +5 -0
- package/dist/notifications/time-zone-select.d.ts +0 -54
- package/dist/notifications/time-zone-select.js +0 -71
- package/dist/notifications/time-zone-select.js.map +0 -1
- package/src/notifications/time-zone-select.tsx +0 -125
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { SetStateAction } from "react";
|
|
2
3
|
import { useLocation, useSearchParams } from "react-router";
|
|
3
4
|
import { useKitTableStorageKeyPrefix } from "../i18n/kit-labels";
|
|
4
5
|
import { readStored, writeStored } from "../lib/safe-storage";
|
|
@@ -24,6 +25,17 @@ interface PersistedState {
|
|
|
24
25
|
pageSize: number | "all";
|
|
25
26
|
widths?: Record<string, number>;
|
|
26
27
|
hidden?: string[];
|
|
28
|
+
/** 0.48: the `defaultHidden` columns the user ticked back on — an entry that beats the
|
|
29
|
+
* default. Written only when non-empty, so a table without such columns stores what
|
|
30
|
+
* it always did. */
|
|
31
|
+
shown?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The hidden set as drawn: the stored one plus every `defaultHidden` column the view
|
|
35
|
+
* holds no entry for (neither hidden nor `shown`). */
|
|
36
|
+
function withDefaultHidden(view: { hidden: Set<string>; shown: Set<string> }, defaults: string[]): Set<string> {
|
|
37
|
+
const extra = defaults.filter((k) => !view.hidden.has(k) && !view.shown.has(k));
|
|
38
|
+
return extra.length === 0 ? view.hidden : new Set([...view.hidden, ...extra]);
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
export const DEFAULT_PERSIST_PREFIX = "hbui-table:";
|
|
@@ -338,7 +350,45 @@ export function useTableState<T>({
|
|
|
338
350
|
);
|
|
339
351
|
const pageSize = pageSizeProp ?? internalPageSize;
|
|
340
352
|
const [widths, setWidths] = useState<Record<string, number>>(() => initial.widths ?? {});
|
|
341
|
-
|
|
353
|
+
// Hidden columns (0.48, keksdose #617): what the user chose — `hidden`, and `shown`
|
|
354
|
+
// for a `defaultHidden` column ticked back on — kept apart from the defaults, which
|
|
355
|
+
// are applied on read and never stored. Keyed on the default keys as a string: every
|
|
356
|
+
// call site builds `columns` inline, so the array is new on every render.
|
|
357
|
+
const [view, setView] = useState(() => ({
|
|
358
|
+
hidden: new Set<string>(initial.hidden ?? []),
|
|
359
|
+
shown: new Set<string>(initial.shown ?? []),
|
|
360
|
+
}));
|
|
361
|
+
const defaultHiddenSig = columns
|
|
362
|
+
.filter((c) => c.defaultHidden)
|
|
363
|
+
.map((c) => c.key)
|
|
364
|
+
.join("\u0000");
|
|
365
|
+
const hiddenCols = useMemo(
|
|
366
|
+
() => withDefaultHidden(view, defaultHiddenSig ? defaultHiddenSig.split("\u0000") : []),
|
|
367
|
+
[view, defaultHiddenSig],
|
|
368
|
+
);
|
|
369
|
+
// The panel hands over the next DRAWN set; what changed is recorded as a choice.
|
|
370
|
+
const setHiddenCols = useCallback(
|
|
371
|
+
(action: SetStateAction<Set<string>>) =>
|
|
372
|
+
setView((v) => {
|
|
373
|
+
const defaults = defaultHiddenSig ? defaultHiddenSig.split("\u0000") : [];
|
|
374
|
+
const prev = withDefaultHidden(v, defaults);
|
|
375
|
+
const next = typeof action === "function" ? action(prev) : action;
|
|
376
|
+
const hidden = new Set(v.hidden);
|
|
377
|
+
const shown = new Set(v.shown);
|
|
378
|
+
for (const k of next) {
|
|
379
|
+
if (prev.has(k)) continue;
|
|
380
|
+
hidden.add(k);
|
|
381
|
+
shown.delete(k);
|
|
382
|
+
}
|
|
383
|
+
for (const k of prev) {
|
|
384
|
+
if (next.has(k)) continue;
|
|
385
|
+
hidden.delete(k);
|
|
386
|
+
if (defaults.includes(k)) shown.add(k);
|
|
387
|
+
}
|
|
388
|
+
return { hidden, shown };
|
|
389
|
+
}),
|
|
390
|
+
[defaultHiddenSig],
|
|
391
|
+
);
|
|
342
392
|
const [showSettings, setShowSettings] = useState(false);
|
|
343
393
|
const headRefs = useMemo(() => new Map<string, HTMLTableCellElement>(), []);
|
|
344
394
|
|
|
@@ -360,7 +410,8 @@ export function useTableState<T>({
|
|
|
360
410
|
filters,
|
|
361
411
|
pageSize: pageSize === Infinity ? "all" : pageSize,
|
|
362
412
|
widths,
|
|
363
|
-
hidden: Array.from(
|
|
413
|
+
hidden: Array.from(view.hidden),
|
|
414
|
+
...(view.shown.size > 0 ? { shown: Array.from(view.shown) } : {}),
|
|
364
415
|
});
|
|
365
416
|
useEffect(() => {
|
|
366
417
|
if (!storageKey) return;
|
package/src/data-table.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type {
|
|
|
30
30
|
// 0.46.1 (keksdose N3): a swipe plan as `RowActions`' actions — the desktop column's own
|
|
31
31
|
// derivation, for an app's hand-built actions column.
|
|
32
32
|
export { swipePlanRowActions } from "./components/data-table-row-actions";
|
|
33
|
+
// 0.48 (keksdose dev #605): a `groupBy` level (also `mobileGroupBy`'s) and the `loadMore` footer.
|
|
34
|
+
export type { DataTableGroupBy, DataTableLoadMore } from "./components/data-table-groups";
|
|
33
35
|
export * from "./components/data-table-cells";
|
|
34
36
|
export { useTableUrlState, readTableUrlState } from "./components/use-table-state";
|
|
35
37
|
export type {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { MouseEvent } from "react";
|
|
3
|
+
import { Download, FileText } from "lucide-react";
|
|
4
|
+
import { Button } from "../components/button";
|
|
5
|
+
import { toast } from "../components/toast";
|
|
6
|
+
import type { AuthedFetcher } from "../hooks/use-authed-src";
|
|
7
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
8
|
+
import { filenameFromDisposition, saveBlob } from "../lib/download";
|
|
9
|
+
import { attachmentName } from "./feedback-record";
|
|
10
|
+
import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
|
|
11
|
+
|
|
12
|
+
// Internal (not in the `./feedback` entry): the row detail's download button and its
|
|
13
|
+
// plain fetch, shared since 0.47 with ./feedback-entry-file's inline entry files.
|
|
14
|
+
|
|
15
|
+
/** The bytes behind a fetcher's answer — a `Blob` as it is, a `Response` checked. */
|
|
16
|
+
async function toBlob(answer: Blob | Response): Promise<Blob> {
|
|
17
|
+
if (typeof Response !== "undefined" && answer instanceof Response) {
|
|
18
|
+
if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
|
|
19
|
+
return answer.blob();
|
|
20
|
+
}
|
|
21
|
+
return answer as Blob;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** No fetcher: the browser's own `fetch`, with the cookies a same-origin app uses. */
|
|
25
|
+
export const plainFeedbackFetch: AuthedFetcher = (url, { signal }) =>
|
|
26
|
+
fetch(url, { signal, credentials: "same-origin" });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A PDF or a text log (keksdose `FeedbackFileDownload`, dev #578): the bytes sit behind
|
|
30
|
+
* an authenticated route that serves them as `attachment`, never inline (§3.5), so a
|
|
31
|
+
* plain link would be a 401 the moment it was opened. Fetched through the app's
|
|
32
|
+
* fetcher and saved with {@link saveBlob}; a failure says so in a toast.
|
|
33
|
+
*
|
|
34
|
+
* Saved under the name the server gives it when the fetcher answers with a `Response`
|
|
35
|
+
* (server-kit's `content_disposition` puts the uploader's own name in `filename*`), and
|
|
36
|
+
* under the URL's last segment — the name on the button — when it answers with a bare
|
|
37
|
+
* `Blob`, which has no headers to ask. Words: `feedbackDetail.download` /
|
|
38
|
+
* `downloadFailed`.
|
|
39
|
+
*/
|
|
40
|
+
export function FeedbackAttachmentDownload({ url, fetcher }: { url: string; fetcher: AuthedFetcher }) {
|
|
41
|
+
const labels = useKitLabels("feedbackDetail", DEFAULT_FEEDBACK_DETAIL_LABELS);
|
|
42
|
+
const [busy, setBusy] = useState(false);
|
|
43
|
+
const name = attachmentName(url);
|
|
44
|
+
const download = async (event: MouseEvent) => {
|
|
45
|
+
// The row around the detail may toggle on a click.
|
|
46
|
+
event.stopPropagation();
|
|
47
|
+
setBusy(true);
|
|
48
|
+
try {
|
|
49
|
+
const answer = await fetcher(url, { signal: new AbortController().signal });
|
|
50
|
+
const blob = await toBlob(answer);
|
|
51
|
+
const saveAs =
|
|
52
|
+
typeof Response !== "undefined" && answer instanceof Response
|
|
53
|
+
? filenameFromDisposition(answer.headers.get("Content-Disposition"), name)
|
|
54
|
+
: name;
|
|
55
|
+
saveBlob(blob, saveAs);
|
|
56
|
+
} catch {
|
|
57
|
+
toast.error(labels.downloadFailed);
|
|
58
|
+
} finally {
|
|
59
|
+
setBusy(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return (
|
|
63
|
+
<Button
|
|
64
|
+
type="button"
|
|
65
|
+
variant="secondary"
|
|
66
|
+
size="sm"
|
|
67
|
+
pending={busy}
|
|
68
|
+
onClick={(event) => void download(event)}
|
|
69
|
+
aria-label={labels.download(name)}
|
|
70
|
+
className="max-w-full"
|
|
71
|
+
>
|
|
72
|
+
<FileText className="size-3.5 shrink-0" aria-hidden />
|
|
73
|
+
<span className="min-w-0 max-w-[14rem] truncate-until-large">{name}</span>
|
|
74
|
+
<Download className="size-3.5 shrink-0" aria-hidden />
|
|
75
|
+
</Button>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -4,11 +4,13 @@ import { EyeOff } from "lucide-react";
|
|
|
4
4
|
import { Button } from "../components/button";
|
|
5
5
|
import { useConfirm } from "../components/confirm-dialog";
|
|
6
6
|
import type { CommitScope } from "../components/write-lock";
|
|
7
|
+
import type { AuthedFetcher } from "../hooks/use-authed-src";
|
|
7
8
|
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
8
9
|
import { FeedbackStatusBadge } from "./feedback-inbox";
|
|
9
10
|
import type { FeedbackComment, FeedbackReply } from "./feedback-public-types";
|
|
10
11
|
import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
|
|
11
12
|
import { FEEDBACK_COMMENT_MAX, attachmentName } from "./feedback-record";
|
|
13
|
+
import { FeedbackEntryFile, useFeedbackEntryUpload, type FeedbackEntryUploadOptions } from "./feedback-entry-file";
|
|
12
14
|
import { useFeedbackRepliesLabels } from "./feedback-replies";
|
|
13
15
|
import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
|
|
14
16
|
import { FeedbackReworkEntryChip } from "./feedback-rework-entry-chip";
|
|
@@ -34,8 +36,13 @@ import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels
|
|
|
34
36
|
* sees their text with that line; the admin sees it struck through, with **Unhide**.
|
|
35
37
|
* An admin has **Hide** on every visible comment; the writer **Delete** on their own,
|
|
36
38
|
* after a confirm (it is a hard delete). Nobody edits a comment.
|
|
37
|
-
* - **The composer** (1–4 000 characters
|
|
38
|
-
*
|
|
39
|
+
* - **The composer** (1–4 000 characters) only with `onComment`: left out for a reader
|
|
40
|
+
* who may not write (a demo, a CRASH report — the app knows). Since 0.47 (§12) with
|
|
41
|
+
* `onUpload` it takes one file as the rework note does — pasted into the box,
|
|
42
|
+
* captured or picked — uploaded first, its URL `onComment`'s second argument.
|
|
43
|
+
* - **Files** (0.47, §12): a comment's or an entry's file under its text, a picture
|
|
44
|
+
* INLINE — a thumbnail through `fetcher` that opens full size — any other file a
|
|
45
|
+
* download ({@link FeedbackEntryFile}). A veiled comment shows none.
|
|
39
46
|
*
|
|
40
47
|
* Delete asks through `useConfirm`, so a thread with `onDelete` and a comment of the
|
|
41
48
|
* viewer's needs the app's `ConfirmProvider` (each app mounts one already).
|
|
@@ -75,7 +82,7 @@ export { DEFAULT_FEEDBACK_COMMENTS_LABELS };
|
|
|
75
82
|
|
|
76
83
|
/* ── The thread ───────────────────────────────────────────────────────────── */
|
|
77
84
|
|
|
78
|
-
export interface FeedbackCommentThreadProps {
|
|
85
|
+
export interface FeedbackCommentThreadProps extends FeedbackEntryUploadOptions {
|
|
79
86
|
/** The report's replies (§9.7), oldest first. Left out or `[]`: none.
|
|
80
87
|
*
|
|
81
88
|
* Inside `FeedbackRowDetail`'s `comments` they are dropped (0.38.1): the detail's
|
|
@@ -92,8 +99,12 @@ export interface FeedbackCommentThreadProps {
|
|
|
92
99
|
/** `POST /feedback/{id}/comments`. Return the promise: the box clears when it
|
|
93
100
|
* resolves, and keeps the draft when it rejects. A failure is the app's to toast —
|
|
94
101
|
* the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no
|
|
95
|
-
* composer.
|
|
96
|
-
|
|
102
|
+
* composer. 0.47 (§12): `attachmentUrl` is the one file uploaded with it (with
|
|
103
|
+
* `onUpload`), `undefined` for none — `attachment_url` in the body (server-kit 0.18). */
|
|
104
|
+
onComment?: (body: string, attachmentUrl?: string) => void | Promise<unknown>;
|
|
105
|
+
/** 0.47: the app's authenticated GET (`AuthedImage`'s) for the files in the thread.
|
|
106
|
+
* Left out, the browser's own fetch with same-origin cookies. */
|
|
107
|
+
fetcher?: AuthedFetcher | null;
|
|
97
108
|
/** `PATCH /feedback/{id}/comments/{comment_id} {hidden}` — the admin's. A rejection
|
|
98
109
|
* is caught (0.38.1) and is the app's to toast (`feedbackToast.updateFailed`). Left
|
|
99
110
|
* out, no Hide / Unhide. */
|
|
@@ -141,9 +152,18 @@ export function FeedbackCommentThread({
|
|
|
141
152
|
loading = false,
|
|
142
153
|
commit,
|
|
143
154
|
now,
|
|
155
|
+
fetcher,
|
|
156
|
+
onUpload,
|
|
157
|
+
onCaptureScreenshot,
|
|
158
|
+
attachment,
|
|
144
159
|
labels: labelsProp,
|
|
145
160
|
className,
|
|
146
161
|
}: FeedbackCommentThreadProps) {
|
|
162
|
+
const { composerAttachment, upload, uploading } = useFeedbackEntryUpload({
|
|
163
|
+
onUpload,
|
|
164
|
+
onCaptureScreenshot,
|
|
165
|
+
attachment,
|
|
166
|
+
});
|
|
147
167
|
const labels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS, labelsProp);
|
|
148
168
|
const anonymous = useKitLabels("feedbackPublic", DEFAULT_FEEDBACK_PUBLIC_LABELS).anonymous;
|
|
149
169
|
const replyLabels = useFeedbackRepliesLabels();
|
|
@@ -189,6 +209,10 @@ export function FeedbackCommentThread({
|
|
|
189
209
|
author: reply.author_name ?? replyLabels.admin,
|
|
190
210
|
createdAt: reply.created_at,
|
|
191
211
|
body: reply.body,
|
|
212
|
+
// 0.47 (§12): an admin reply's file, where the view carries it.
|
|
213
|
+
attachments: reply.attachment_url
|
|
214
|
+
? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }]
|
|
215
|
+
: undefined,
|
|
192
216
|
staff: true,
|
|
193
217
|
badge: reply.status_to ? (
|
|
194
218
|
<span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-reply-status={reply.status_to}>
|
|
@@ -244,6 +268,11 @@ export function FeedbackCommentThread({
|
|
|
244
268
|
author: veiled ? undefined : comment.mine ? undefined : (comment.author_name ?? anonymous),
|
|
245
269
|
createdAt: comment.created_at,
|
|
246
270
|
body: veiled ? "" : (comment.body ?? ""),
|
|
271
|
+
// 0.47 (§12): its one file; none when veiled (the server sends none either).
|
|
272
|
+
attachments:
|
|
273
|
+
!veiled && comment.attachment_url
|
|
274
|
+
? [{ name: attachmentName(comment.attachment_url), url: comment.attachment_url }]
|
|
275
|
+
: undefined,
|
|
247
276
|
staff: !veiled && comment.staff,
|
|
248
277
|
struck: comment.hidden && isAdmin && !veiled,
|
|
249
278
|
footer: actions.length > 0 ? actions : undefined,
|
|
@@ -258,13 +287,19 @@ export function FeedbackCommentThread({
|
|
|
258
287
|
messages={entries.map((entry) => entry.message)}
|
|
259
288
|
loading={loading}
|
|
260
289
|
now={now}
|
|
290
|
+
renderAttachment={(file) => (file.url ? <FeedbackEntryFile url={file.url} fetcher={fetcher} /> : file.name)}
|
|
261
291
|
labels={{ thread: labels.comments, empty: labels.empty }}
|
|
262
292
|
/>
|
|
263
293
|
{onComment && (
|
|
264
294
|
<ChatComposer
|
|
265
295
|
className="mt-3"
|
|
266
|
-
onSend={(body) =>
|
|
267
|
-
|
|
296
|
+
onSend={async (body, file) => {
|
|
297
|
+
const url = await upload(file);
|
|
298
|
+
// The second argument only with a file, so a text comment calls as before 0.47.
|
|
299
|
+
return url === undefined ? onComment(body) : onComment(body, url);
|
|
300
|
+
}}
|
|
301
|
+
pending={pending || uploading}
|
|
302
|
+
attachment={composerAttachment}
|
|
268
303
|
commit={commit}
|
|
269
304
|
maxLength={FEEDBACK_COMMENT_MAX}
|
|
270
305
|
showCount
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
32
|
-
*
|
|
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
|
}
|