@igstack/app-catalog-frontend-core 6.9.0 → 6.10.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/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/composeComments.test.d.ts +1 -0
- package/dist/esm/__tests__/ui/detail/CommunityInput.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +254 -232
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +17 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +187 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +9 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +91 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +17 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +328 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +15 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +26 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +8 -19
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +49 -37
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +30 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +50 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityError.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityError.js +8 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityError.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.d.ts +27 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +66 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.d.ts +11 -0
- package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js +15 -0
- package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js.map +1 -0
- package/dist/esm/modules/extensions/features/resourceDetail.d.ts +38 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js +4 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
- package/dist/esm/modules/extensions/index.d.ts +1 -1
- package/dist/esm/modules/extensions/index.js.map +1 -1
- package/dist/index.css +525 -49
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +9 -10
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +402 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +8 -4
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +175 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +281 -0
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +179 -0
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +423 -0
- package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +170 -0
- package/src/__tests__/ui/detail/CommunityInput.test.tsx +39 -0
- package/src/index.css +525 -49
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +310 -295
- package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +254 -0
- package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +102 -0
- package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +419 -0
- package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +41 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +77 -71
- package/src/modules/appCatalog/ui/detail/communityDraft.ts +80 -0
- package/src/modules/appCatalog/ui/detail/communityError.ts +10 -0
- package/src/modules/appCatalog/ui/detail/composeComments.ts +103 -0
- package/src/modules/appCatalog/ui/detail/usePendingCount.ts +26 -0
- package/src/modules/extensions/features/resourceDetail.ts +28 -0
- package/src/modules/extensions/index.ts +2 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +0 -13
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +0 -433
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +0 -1
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +0 -181
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +0 -131
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +0 -605
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.emptyState.test.d.ts → AppDetails.behindThisCard.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.reviewStatus.test.d.ts → AppDetails.knowMore.test.d.ts} +0 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
|
3
|
+
import { X, Paperclip } from "lucide-react";
|
|
4
|
+
import React__default from "react";
|
|
5
|
+
import { useTRPC } from "../../../../api/infra/trpc.js";
|
|
6
|
+
import { cn } from "../../../../lib/utils.js";
|
|
7
|
+
import { Button } from "../../../../ui/button.js";
|
|
8
|
+
import { readDraft, readAttachments, writeDraft, writeAttachments, attachmentUrl } from "./communityDraft.js";
|
|
9
|
+
import { formatCommunityError } from "./communityError.js";
|
|
10
|
+
const MAX_HEIGHT = 200;
|
|
11
|
+
const FLASH_MS = 900;
|
|
12
|
+
const MAX_ATTACHMENTS = 3;
|
|
13
|
+
const UPLOAD_URL = "/api/feedback-attachments/upload";
|
|
14
|
+
const isImage = (file) => file.type.startsWith("image/");
|
|
15
|
+
async function upload(files) {
|
|
16
|
+
const form = new FormData();
|
|
17
|
+
for (const file of files) form.append("image", file);
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(UPLOAD_URL, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
body: form,
|
|
22
|
+
// The visitor cookie is the identity the upload is recorded against, and
|
|
23
|
+
// it is httpOnly — so it has to ride along explicitly.
|
|
24
|
+
credentials: "same-origin"
|
|
25
|
+
});
|
|
26
|
+
const payload = await response.json().catch(() => null);
|
|
27
|
+
const field = (key) => typeof payload === "object" && payload !== null ? payload[key] : void 0;
|
|
28
|
+
const ids = field("ids");
|
|
29
|
+
if (response.ok && Array.isArray(ids)) return ids.map(String);
|
|
30
|
+
const reported = field("error");
|
|
31
|
+
return typeof reported === "string" ? reported : "Could not attach that image";
|
|
32
|
+
} catch {
|
|
33
|
+
return "Could not attach that image";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function CommunityInput({
|
|
37
|
+
appSlug,
|
|
38
|
+
appName,
|
|
39
|
+
ref
|
|
40
|
+
}) {
|
|
41
|
+
const trpc = useTRPC();
|
|
42
|
+
const queryClient = useQueryClient();
|
|
43
|
+
const [text, setText] = React__default.useState(() => readDraft(appSlug));
|
|
44
|
+
const [images, setImages] = React__default.useState(
|
|
45
|
+
() => readAttachments(appSlug)
|
|
46
|
+
);
|
|
47
|
+
const [sent, setSent] = React__default.useState(false);
|
|
48
|
+
const [sends, setSends] = React__default.useState(0);
|
|
49
|
+
const [uploading, setUploading] = React__default.useState(false);
|
|
50
|
+
const [uploadError, setUploadError] = React__default.useState(null);
|
|
51
|
+
const [dragging, setDragging] = React__default.useState(false);
|
|
52
|
+
const inFlightRef = React__default.useRef(false);
|
|
53
|
+
const dragDepthRef = React__default.useRef(0);
|
|
54
|
+
const draftGenRef = React__default.useRef(0);
|
|
55
|
+
const [height, setHeight] = React__default.useState(40);
|
|
56
|
+
const fieldRef = React__default.useRef(null);
|
|
57
|
+
const mirrorRef = React__default.useRef(null);
|
|
58
|
+
const fileRef = React__default.useRef(null);
|
|
59
|
+
const formRef = React__default.useRef(null);
|
|
60
|
+
const flashTimerRef = React__default.useRef(void 0);
|
|
61
|
+
const [isCoarse, setIsCoarse] = React__default.useState(() => {
|
|
62
|
+
if (typeof window === "undefined") return false;
|
|
63
|
+
return window.matchMedia("(pointer: coarse)").matches;
|
|
64
|
+
});
|
|
65
|
+
React__default.useEffect(() => {
|
|
66
|
+
if (typeof window === "undefined") return;
|
|
67
|
+
const mq = window.matchMedia("(pointer: coarse)");
|
|
68
|
+
const handler = () => setIsCoarse(mq.matches);
|
|
69
|
+
mq.addEventListener("change", handler);
|
|
70
|
+
return () => mq.removeEventListener("change", handler);
|
|
71
|
+
}, []);
|
|
72
|
+
React__default.useEffect(() => writeDraft(appSlug, text), [appSlug, text]);
|
|
73
|
+
React__default.useEffect(() => writeAttachments(appSlug, images), [appSlug, images]);
|
|
74
|
+
React__default.useImperativeHandle(ref, () => ({
|
|
75
|
+
focus: () => {
|
|
76
|
+
var _a;
|
|
77
|
+
(_a = fieldRef.current) == null ? void 0 : _a.focus();
|
|
78
|
+
const form = formRef.current;
|
|
79
|
+
if (!form) return;
|
|
80
|
+
form.classList.remove("is-flash");
|
|
81
|
+
void form.offsetWidth;
|
|
82
|
+
form.classList.add("is-flash");
|
|
83
|
+
clearTimeout(flashTimerRef.current);
|
|
84
|
+
flashTimerRef.current = setTimeout(
|
|
85
|
+
() => form.classList.remove("is-flash"),
|
|
86
|
+
FLASH_MS
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}));
|
|
90
|
+
React__default.useEffect(() => () => clearTimeout(flashTimerRef.current), []);
|
|
91
|
+
const add = useMutation(
|
|
92
|
+
trpc.feedback.add.mutationOptions({
|
|
93
|
+
onSuccess: () => {
|
|
94
|
+
setText("");
|
|
95
|
+
setImages([]);
|
|
96
|
+
setUploadError(null);
|
|
97
|
+
setSent(true);
|
|
98
|
+
setSends((n) => n + 1);
|
|
99
|
+
void queryClient.invalidateQueries({
|
|
100
|
+
queryKey: trpc.feedback.list.queryKey({ resourceSlug: appSlug })
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
React__default.useEffect(() => {
|
|
106
|
+
var _a;
|
|
107
|
+
if (sends) (_a = fieldRef.current) == null ? void 0 : _a.focus();
|
|
108
|
+
}, [sends]);
|
|
109
|
+
const typing = text.trim() !== "";
|
|
110
|
+
const canSend = typing || images.length > 0;
|
|
111
|
+
const fit = React__default.useCallback(() => {
|
|
112
|
+
const field = fieldRef.current;
|
|
113
|
+
const mirror = mirrorRef.current;
|
|
114
|
+
if (!field || !mirror) return;
|
|
115
|
+
mirror.style.width = `${field.offsetWidth}px`;
|
|
116
|
+
mirror.value = text || " ";
|
|
117
|
+
const styles = window.getComputedStyle(field);
|
|
118
|
+
const lineHeight = parseFloat(styles.lineHeight);
|
|
119
|
+
const paddingTop = parseFloat(styles.paddingTop);
|
|
120
|
+
const paddingBottom = parseFloat(styles.paddingBottom);
|
|
121
|
+
const borderTop = parseFloat(styles.borderTopWidth);
|
|
122
|
+
const borderBottom = parseFloat(styles.borderBottomWidth);
|
|
123
|
+
const oneLine = lineHeight + paddingTop + paddingBottom + borderTop + borderBottom;
|
|
124
|
+
setHeight(Math.min(Math.max(mirror.scrollHeight + 2, oneLine), MAX_HEIGHT));
|
|
125
|
+
}, [text]);
|
|
126
|
+
React__default.useLayoutEffect(fit, [fit]);
|
|
127
|
+
React__default.useEffect(() => {
|
|
128
|
+
const field = fieldRef.current;
|
|
129
|
+
if (!field || typeof ResizeObserver === "undefined") return;
|
|
130
|
+
const observer = new ResizeObserver(fit);
|
|
131
|
+
observer.observe(field);
|
|
132
|
+
return () => observer.disconnect();
|
|
133
|
+
}, [fit]);
|
|
134
|
+
const attach = async (picked) => {
|
|
135
|
+
const files = picked.filter(isImage);
|
|
136
|
+
if (files.length === 0 || inFlightRef.current) return;
|
|
137
|
+
const room = MAX_ATTACHMENTS - images.length;
|
|
138
|
+
if (room <= 0) {
|
|
139
|
+
setUploadError(`Up to ${MAX_ATTACHMENTS} images`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
setUploadError(null);
|
|
143
|
+
inFlightRef.current = true;
|
|
144
|
+
setUploading(true);
|
|
145
|
+
const startGen = draftGenRef.current;
|
|
146
|
+
const result = await upload(files.slice(0, room));
|
|
147
|
+
inFlightRef.current = false;
|
|
148
|
+
setUploading(false);
|
|
149
|
+
if (draftGenRef.current !== startGen) return;
|
|
150
|
+
if (typeof result === "string") {
|
|
151
|
+
setUploadError(result);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const newImages = result.map((id, i) => {
|
|
155
|
+
var _a;
|
|
156
|
+
return {
|
|
157
|
+
id,
|
|
158
|
+
name: ((_a = files[i]) == null ? void 0 : _a.name) ?? "image"
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
setImages((current) => {
|
|
162
|
+
const updated = [...current, ...newImages].slice(0, MAX_ATTACHMENTS);
|
|
163
|
+
writeAttachments(appSlug, updated);
|
|
164
|
+
return updated;
|
|
165
|
+
});
|
|
166
|
+
if (files.length > room)
|
|
167
|
+
setUploadError(`Only the first ${room} were attached`);
|
|
168
|
+
};
|
|
169
|
+
const carriesFiles = (e) => [...e.dataTransfer.types].includes("Files");
|
|
170
|
+
const submit = () => {
|
|
171
|
+
if (!canSend || add.isPending || uploading) return;
|
|
172
|
+
const body = text.trim();
|
|
173
|
+
add.mutate({
|
|
174
|
+
resourceSlug: appSlug,
|
|
175
|
+
...body ? { body } : {},
|
|
176
|
+
...images.length > 0 ? { attachmentIds: images.map((i) => i.id) } : {}
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
return /* @__PURE__ */ jsxs(
|
|
180
|
+
"form",
|
|
181
|
+
{
|
|
182
|
+
ref: formRef,
|
|
183
|
+
className: "know-more",
|
|
184
|
+
"data-drop": dragging || void 0,
|
|
185
|
+
onSubmit: (e) => {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
submit();
|
|
188
|
+
},
|
|
189
|
+
onDragEnter: (e) => {
|
|
190
|
+
if (!carriesFiles(e)) return;
|
|
191
|
+
dragDepthRef.current += 1;
|
|
192
|
+
setDragging(true);
|
|
193
|
+
},
|
|
194
|
+
onDragOver: (e) => {
|
|
195
|
+
if (carriesFiles(e)) e.preventDefault();
|
|
196
|
+
},
|
|
197
|
+
onDragLeave: (e) => {
|
|
198
|
+
if (!carriesFiles(e)) return;
|
|
199
|
+
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
200
|
+
if (dragDepthRef.current === 0) setDragging(false);
|
|
201
|
+
},
|
|
202
|
+
onDrop: (e) => {
|
|
203
|
+
if (!carriesFiles(e)) return;
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
dragDepthRef.current = 0;
|
|
206
|
+
setDragging(false);
|
|
207
|
+
void attach([...e.dataTransfer.files]);
|
|
208
|
+
},
|
|
209
|
+
children: [
|
|
210
|
+
images.length > 0 && /* @__PURE__ */ jsx("ul", { className: "know-more-thumbs", children: images.map((image) => /* @__PURE__ */ jsxs("li", { className: "know-more-thumb", children: [
|
|
211
|
+
/* @__PURE__ */ jsx("img", { src: attachmentUrl(image.id), alt: image.name }),
|
|
212
|
+
/* @__PURE__ */ jsx(
|
|
213
|
+
"button",
|
|
214
|
+
{
|
|
215
|
+
type: "button",
|
|
216
|
+
"aria-label": `Remove ${image.name}`,
|
|
217
|
+
onClick: () => setImages(
|
|
218
|
+
(current) => current.filter((i) => i.id !== image.id)
|
|
219
|
+
),
|
|
220
|
+
children: /* @__PURE__ */ jsx(X, { className: "size-2.5" })
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
] }, image.id)) }),
|
|
224
|
+
/* @__PURE__ */ jsxs("div", { className: "know-more-field", children: [
|
|
225
|
+
/* @__PURE__ */ jsx(
|
|
226
|
+
"button",
|
|
227
|
+
{
|
|
228
|
+
type: "button",
|
|
229
|
+
className: "know-more-clip",
|
|
230
|
+
"aria-label": "Attach a screenshot",
|
|
231
|
+
title: "Attach a screenshot",
|
|
232
|
+
"aria-busy": uploading || void 0,
|
|
233
|
+
disabled: uploading || images.length >= MAX_ATTACHMENTS,
|
|
234
|
+
onClick: () => {
|
|
235
|
+
var _a;
|
|
236
|
+
return (_a = fileRef.current) == null ? void 0 : _a.click();
|
|
237
|
+
},
|
|
238
|
+
children: /* @__PURE__ */ jsx(Paperclip, { className: "size-4" })
|
|
239
|
+
}
|
|
240
|
+
),
|
|
241
|
+
/* @__PURE__ */ jsx(
|
|
242
|
+
"input",
|
|
243
|
+
{
|
|
244
|
+
ref: fileRef,
|
|
245
|
+
type: "file",
|
|
246
|
+
accept: "image/*",
|
|
247
|
+
multiple: true,
|
|
248
|
+
tabIndex: -1,
|
|
249
|
+
"aria-hidden": true,
|
|
250
|
+
className: "sr-only",
|
|
251
|
+
onChange: (e) => {
|
|
252
|
+
void attach([...e.target.files ?? []]);
|
|
253
|
+
e.target.value = "";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
),
|
|
257
|
+
/* @__PURE__ */ jsx(
|
|
258
|
+
"textarea",
|
|
259
|
+
{
|
|
260
|
+
ref: fieldRef,
|
|
261
|
+
rows: 1,
|
|
262
|
+
value: text,
|
|
263
|
+
"aria-label": `Know more about ${appName}?`,
|
|
264
|
+
placeholder: isCoarse ? "Add a fact, a fix or a question" : "Add a fact, a fix or a question — press Enter",
|
|
265
|
+
style: {
|
|
266
|
+
height,
|
|
267
|
+
overflowY: height >= MAX_HEIGHT ? "auto" : "hidden"
|
|
268
|
+
},
|
|
269
|
+
onChange: (e) => {
|
|
270
|
+
setText(e.target.value);
|
|
271
|
+
setSent(false);
|
|
272
|
+
if (add.isError) add.reset();
|
|
273
|
+
},
|
|
274
|
+
onPaste: (e) => {
|
|
275
|
+
const files = [...e.clipboardData.files].filter(isImage);
|
|
276
|
+
if (files.length === 0) return;
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
void attach(files);
|
|
279
|
+
},
|
|
280
|
+
onKeyDown: (e) => {
|
|
281
|
+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
submit();
|
|
284
|
+
}
|
|
285
|
+
if (e.key === "Escape" && !e.nativeEvent.isComposing && (text !== "" || images.length > 0)) {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
e.stopPropagation();
|
|
288
|
+
draftGenRef.current += 1;
|
|
289
|
+
setText("");
|
|
290
|
+
setImages([]);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
),
|
|
295
|
+
/* @__PURE__ */ jsx(
|
|
296
|
+
Button,
|
|
297
|
+
{
|
|
298
|
+
type: "submit",
|
|
299
|
+
size: "sm",
|
|
300
|
+
className: "know-more-send",
|
|
301
|
+
disabled: !canSend || add.isPending || uploading,
|
|
302
|
+
children: "Send"
|
|
303
|
+
}
|
|
304
|
+
),
|
|
305
|
+
/* @__PURE__ */ jsx(
|
|
306
|
+
"textarea",
|
|
307
|
+
{
|
|
308
|
+
ref: mirrorRef,
|
|
309
|
+
rows: 1,
|
|
310
|
+
readOnly: true,
|
|
311
|
+
tabIndex: -1,
|
|
312
|
+
"aria-hidden": true,
|
|
313
|
+
className: "know-more-mirror"
|
|
314
|
+
}
|
|
315
|
+
)
|
|
316
|
+
] }),
|
|
317
|
+
!isCoarse && /* @__PURE__ */ jsx("p", { className: cn("know-more-hint", typing && "is-on"), children: "Enter sends · Shift+Enter new line · Esc clears · paste or drop a screenshot" }),
|
|
318
|
+
/* @__PURE__ */ jsx("p", { className: "know-more-attaching", "aria-live": "polite", children: uploading ? "Attaching…" : "" }),
|
|
319
|
+
(add.isError || uploadError) && /* @__PURE__ */ jsx("p", { role: "alert", className: "know-more-error", children: add.isError ? `Could not send: ${formatCommunityError(add.error)}. Your note is still here.` : uploadError }),
|
|
320
|
+
/* @__PURE__ */ jsx("p", { role: "status", className: cn("know-more-sent", sent && "is-on"), children: sent ? "Thanks — sent. It shows below." : "" })
|
|
321
|
+
]
|
|
322
|
+
}
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
export {
|
|
326
|
+
CommunityInput
|
|
327
|
+
};
|
|
328
|
+
//# sourceMappingURL=CommunityInput.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CommunityInput.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommunityInput.tsx"],"sourcesContent":["import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { Paperclip, X } from 'lucide-react'\nimport React from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { cn } from '~/lib/utils'\nimport { Button } from '~/ui/button'\nimport {\n attachmentUrl,\n readAttachments,\n readDraft,\n writeAttachments,\n writeDraft,\n} from './communityDraft'\nimport type { PendingImage } from './communityDraft'\nimport { formatCommunityError } from './communityError'\n\nconst MAX_HEIGHT = 200\n\n/** How long the Share highlight stays on the field, ms. */\nconst FLASH_MS = 900\n\n/** Mirrors the server's cap, which refuses anything above it regardless. */\nconst MAX_ATTACHMENTS = 3\n\nconst UPLOAD_URL = '/api/feedback-attachments/upload'\n\n/** What the card holds on to, so \"Share what you know\" can point at the field. */\nexport interface CommunityInputHandle {\n /** Focuses the field and highlights it briefly. Re-triggers on every call. */\n focus: () => void\n}\n\nconst isImage = (file: File) => file.type.startsWith('image/')\n\n/**\n * Upload on pick, not on send: the person sees whether their screenshot\n * arrived while they can still do something about it, and Send stays one fast\n * request that cannot half-fail with images in flight.\n */\nasync function upload(files: File[]): Promise<string[] | string> {\n const form = new FormData()\n for (const file of files) form.append('image', file)\n try {\n const response = await fetch(UPLOAD_URL, {\n method: 'POST',\n body: form,\n // The visitor cookie is the identity the upload is recorded against, and\n // it is httpOnly — so it has to ride along explicitly.\n credentials: 'same-origin',\n })\n const payload: unknown = await response.json().catch(() => null)\n const field = (key: string): unknown =>\n typeof payload === 'object' && payload !== null\n ? (payload as Record<string, unknown>)[key]\n : undefined\n const ids = field('ids')\n if (response.ok && Array.isArray(ids)) return ids.map(String)\n const reported = field('error')\n return typeof reported === 'string'\n ? reported\n : 'Could not attach that image'\n } catch {\n return 'Could not attach that image'\n }\n}\n\n/**\n * The card's one ask for more information, at the top of Behind this card:\n * one line with Send beside it, grey until there is something to send. The\n * field grows as the text wraps. Screenshots come in through the\n * paperclip, paste or drop, and a screenshot alone is a valid note.\n */\nexport function CommunityInput({\n appSlug,\n appName,\n ref,\n}: {\n appSlug: string\n appName: string\n ref?: React.Ref<CommunityInputHandle>\n}) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n // Seeded from storage so a draft survives the tab panel unmounting under it.\n const [text, setText] = React.useState(() => readDraft(appSlug))\n const [images, setImages] = React.useState<PendingImage[]>(() =>\n readAttachments(appSlug),\n )\n const [sent, setSent] = React.useState(false)\n // Counts successful sends, so a click on Send hands focus back every time.\n const [sends, setSends] = React.useState(0)\n const [uploading, setUploading] = React.useState(false)\n const [uploadError, setUploadError] = React.useState<string | null>(null)\n const [dragging, setDragging] = React.useState(false)\n // The guard events read, set synchronously: `uploading` only updates on the\n // next render, so a second paste or drop in the same tick would slip past it.\n const inFlightRef = React.useRef(false)\n const dragDepthRef = React.useRef(0)\n // Cleared draft stays cleared: Esc increments this; the upload checks it\n // on completion so an in-flight upload does not re-add its image.\n const draftGenRef = React.useRef(0)\n const [height, setHeight] = React.useState(40)\n const fieldRef = React.useRef<HTMLTextAreaElement>(null)\n // A hidden twin measures the height the text needs, so the real field can\n // animate to it. Setting `height: auto` on the real one snaps instead.\n const mirrorRef = React.useRef<HTMLTextAreaElement>(null)\n const fileRef = React.useRef<HTMLInputElement>(null)\n const formRef = React.useRef<HTMLFormElement>(null)\n const flashTimerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n\n // Detect coarse pointer (touch) to adjust placeholder and hide keyboard hints.\n const [isCoarse, setIsCoarse] = React.useState(() => {\n if (typeof window === 'undefined') return false\n return window.matchMedia('(pointer: coarse)').matches\n })\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n const mq = window.matchMedia('(pointer: coarse)')\n const handler = () => setIsCoarse(mq.matches)\n mq.addEventListener('change', handler)\n return () => mq.removeEventListener('change', handler)\n }, [])\n\n React.useEffect(() => writeDraft(appSlug, text), [appSlug, text])\n React.useEffect(() => writeAttachments(appSlug, images), [appSlug, images])\n\n // The class goes on the form, whose className React never rewrites, and\n // is removed and re-added with a reflow between so a second call restarts\n // the highlight instead of merely extending it.\n React.useImperativeHandle(ref, () => ({\n focus: () => {\n fieldRef.current?.focus()\n const form = formRef.current\n if (!form) return\n form.classList.remove('is-flash')\n void form.offsetWidth\n form.classList.add('is-flash')\n clearTimeout(flashTimerRef.current)\n flashTimerRef.current = setTimeout(\n () => form.classList.remove('is-flash'),\n FLASH_MS,\n )\n },\n }))\n React.useEffect(() => () => clearTimeout(flashTimerRef.current), [])\n\n const add = useMutation(\n trpc.feedback.add.mutationOptions({\n onSuccess: () => {\n setText('')\n setImages([])\n setUploadError(null)\n setSent(true)\n setSends((n) => n + 1)\n void queryClient.invalidateQueries({\n queryKey: trpc.feedback.list.queryKey({ resourceSlug: appSlug }),\n })\n },\n }),\n )\n\n // Focus stays in the field so the next Tab doesn't walk the grid behind the scrim.\n React.useEffect(() => {\n if (sends) fieldRef.current?.focus()\n }, [sends])\n\n const typing = text.trim() !== ''\n const canSend = typing || images.length > 0\n\n const fit = React.useCallback(() => {\n const field = fieldRef.current\n const mirror = mirrorRef.current\n if (!field || !mirror) return\n mirror.style.width = `${field.offsetWidth}px`\n mirror.value = text || ' '\n // Compute ONE_LINE from the actual field styles (varies by viewport width).\n const styles = window.getComputedStyle(field)\n const lineHeight = parseFloat(styles.lineHeight)\n const paddingTop = parseFloat(styles.paddingTop)\n const paddingBottom = parseFloat(styles.paddingBottom)\n const borderTop = parseFloat(styles.borderTopWidth)\n const borderBottom = parseFloat(styles.borderBottomWidth)\n const oneLine =\n lineHeight + paddingTop + paddingBottom + borderTop + borderBottom\n // +2: scrollHeight excludes the border, the box height includes it.\n setHeight(Math.min(Math.max(mirror.scrollHeight + 2, oneLine), MAX_HEIGHT))\n }, [text])\n React.useLayoutEffect(fit, [fit])\n React.useEffect(() => {\n const field = fieldRef.current\n if (!field || typeof ResizeObserver === 'undefined') return\n const observer = new ResizeObserver(fit)\n observer.observe(field)\n return () => observer.disconnect()\n }, [fit])\n\n /**\n * One upload at a time: a paste or drop while one is in flight is ignored,\n * and the \"Attaching…\" line says why. The updater clamps as well, so the\n * draft can never hold more than the server accepts on send.\n */\n const attach = async (picked: File[]) => {\n const files = picked.filter(isImage)\n if (files.length === 0 || inFlightRef.current) return\n const room = MAX_ATTACHMENTS - images.length\n if (room <= 0) {\n setUploadError(`Up to ${MAX_ATTACHMENTS} images`)\n return\n }\n setUploadError(null)\n inFlightRef.current = true\n setUploading(true)\n const startGen = draftGenRef.current\n const result = await upload(files.slice(0, room))\n inFlightRef.current = false\n setUploading(false)\n // Draft was cleared (Esc) while upload was in flight: stay cleared.\n if (draftGenRef.current !== startGen) return\n if (typeof result === 'string') {\n setUploadError(result)\n return\n }\n const newImages = result.map((id, i) => ({\n id,\n name: files[i]?.name ?? 'image',\n }))\n // Merge into both state and persisted draft so unmount restores it.\n setImages((current) => {\n const updated = [...current, ...newImages].slice(0, MAX_ATTACHMENTS)\n writeAttachments(appSlug, updated)\n return updated\n })\n if (files.length > room)\n setUploadError(`Only the first ${room} were attached`)\n }\n\n /** Only a drag carrying files is ours; dragged text still drops into the field. */\n const carriesFiles = (e: React.DragEvent) =>\n [...e.dataTransfer.types].includes('Files')\n\n const submit = () => {\n if (!canSend || add.isPending || uploading) return\n const body = text.trim()\n add.mutate({\n resourceSlug: appSlug,\n ...(body ? { body } : {}),\n ...(images.length > 0 ? { attachmentIds: images.map((i) => i.id) } : {}),\n })\n }\n\n return (\n <form\n ref={formRef}\n // State rides on data attributes: the className stays static so the\n // imperative `is-flash` below survives a re-render.\n className=\"know-more\"\n data-drop={dragging || undefined}\n onSubmit={(e) => {\n e.preventDefault()\n submit()\n }}\n onDragEnter={(e) => {\n if (!carriesFiles(e)) return\n dragDepthRef.current += 1\n setDragging(true)\n }}\n onDragOver={(e) => {\n // Accepting the drop is what lets it fire.\n if (carriesFiles(e)) e.preventDefault()\n }}\n onDragLeave={(e) => {\n if (!carriesFiles(e)) return\n // Every child the drag crosses fires its own enter and leave, so only\n // the last leave means the drag left the form.\n dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)\n if (dragDepthRef.current === 0) setDragging(false)\n }}\n onDrop={(e) => {\n if (!carriesFiles(e)) return\n e.preventDefault()\n dragDepthRef.current = 0\n setDragging(false)\n void attach([...e.dataTransfer.files])\n }}\n >\n {images.length > 0 && (\n <ul className=\"know-more-thumbs\">\n {images.map((image) => (\n <li key={image.id} className=\"know-more-thumb\">\n <img src={attachmentUrl(image.id)} alt={image.name} />\n <button\n type=\"button\"\n aria-label={`Remove ${image.name}`}\n onClick={() =>\n setImages((current) =>\n current.filter((i) => i.id !== image.id),\n )\n }\n >\n <X className=\"size-2.5\" />\n </button>\n </li>\n ))}\n </ul>\n )}\n <div className=\"know-more-field\">\n <button\n type=\"button\"\n className=\"know-more-clip\"\n aria-label=\"Attach a screenshot\"\n title=\"Attach a screenshot\"\n aria-busy={uploading || undefined}\n disabled={uploading || images.length >= MAX_ATTACHMENTS}\n onClick={() => fileRef.current?.click()}\n >\n <Paperclip className=\"size-4\" />\n </button>\n <input\n ref={fileRef}\n type=\"file\"\n accept=\"image/*\"\n multiple\n tabIndex={-1}\n aria-hidden\n className=\"sr-only\"\n onChange={(e) => {\n void attach([...(e.target.files ?? [])])\n // Cleared so picking the SAME file again still fires a change.\n e.target.value = ''\n }}\n />\n <textarea\n ref={fieldRef}\n rows={1}\n value={text}\n aria-label={`Know more about ${appName}?`}\n placeholder={\n isCoarse\n ? 'Add a fact, a fix or a question'\n : 'Add a fact, a fix or a question — press Enter'\n }\n style={{\n height,\n overflowY: height >= MAX_HEIGHT ? 'auto' : 'hidden',\n }}\n onChange={(e) => {\n setText(e.target.value)\n setSent(false)\n if (add.isError) add.reset()\n }}\n onPaste={(e) => {\n const files = [...e.clipboardData.files].filter(isImage)\n if (files.length === 0) return\n e.preventDefault()\n void attach(files)\n }}\n onKeyDown={(e) => {\n if (\n e.key === 'Enter' &&\n !e.shiftKey &&\n !e.nativeEvent.isComposing\n ) {\n e.preventDefault()\n submit()\n }\n // Esc clears a draft, and only a draft: with nothing typed or\n // attached it must reach the card's layer so the card still closes.\n if (\n e.key === 'Escape' &&\n !e.nativeEvent.isComposing &&\n (text !== '' || images.length > 0)\n ) {\n e.preventDefault()\n e.stopPropagation()\n draftGenRef.current += 1\n setText('')\n setImages([])\n }\n }}\n />\n <Button\n type=\"submit\"\n size=\"sm\"\n className=\"know-more-send\"\n disabled={!canSend || add.isPending || uploading}\n >\n Send\n </Button>\n <textarea\n ref={mirrorRef}\n rows={1}\n readOnly\n tabIndex={-1}\n aria-hidden\n className=\"know-more-mirror\"\n />\n </div>\n {!isCoarse && (\n <p className={cn('know-more-hint', typing && 'is-on')}>\n Enter sends · Shift+Enter new line · Esc clears · paste or drop a\n screenshot\n </p>\n )}\n <p className=\"know-more-attaching\" aria-live=\"polite\">\n {uploading ? 'Attaching…' : ''}\n </p>\n {(add.isError || uploadError) && (\n <p role=\"alert\" className=\"know-more-error\">\n {add.isError\n ? `Could not send: ${formatCommunityError(add.error)}. Your note is still here.`\n : uploadError}\n </p>\n )}\n <p role=\"status\" className={cn('know-more-sent', sent && 'is-on')}>\n {sent ? 'Thanks — sent. It shows below.' : ''}\n </p>\n </form>\n )\n}\n"],"names":["React"],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Claim } from '@igstack/app-catalog-backend-core';
|
|
2
|
+
import { default as React } from 'react';
|
|
3
|
+
import { CommunityInputHandle } from './CommunityInput.js';
|
|
4
|
+
export type CommunitySectionHandle = CommunityInputHandle;
|
|
5
|
+
/**
|
|
6
|
+
* The top of Behind this card: one input that takes words and screenshots,
|
|
7
|
+
* then "Comments" with its help, then everything already said about the entry
|
|
8
|
+
* as one feed.
|
|
9
|
+
*/
|
|
10
|
+
export declare function CommunitySection({ appSlug, appName, claims, ref, }: {
|
|
11
|
+
appSlug: string;
|
|
12
|
+
appName: string;
|
|
13
|
+
claims?: Claim[];
|
|
14
|
+
ref?: React.Ref<CommunitySectionHandle>;
|
|
15
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
+
import React__default from "react";
|
|
3
|
+
import { CommentsFeed } from "./CommentsFeed.js";
|
|
4
|
+
import { CommentsHelp } from "./CommentsHelp.js";
|
|
5
|
+
import { CommunityInput } from "./CommunityInput.js";
|
|
6
|
+
const NO_CLAIMS = [];
|
|
7
|
+
function CommunitySection({
|
|
8
|
+
appSlug,
|
|
9
|
+
appName,
|
|
10
|
+
claims = NO_CLAIMS,
|
|
11
|
+
ref
|
|
12
|
+
}) {
|
|
13
|
+
const headingId = React__default.useId();
|
|
14
|
+
return /* @__PURE__ */ jsxs("section", { className: "behind-group", children: [
|
|
15
|
+
/* @__PURE__ */ jsx(CommunityInput, { ref, appSlug, appName }),
|
|
16
|
+
/* @__PURE__ */ jsxs("div", { className: "comments-head", children: [
|
|
17
|
+
/* @__PURE__ */ jsx("h2", { id: headingId, className: "behind-group__title", children: "Comments" }),
|
|
18
|
+
/* @__PURE__ */ jsx(CommentsHelp, { appSlug, appName })
|
|
19
|
+
] }),
|
|
20
|
+
/* @__PURE__ */ jsx(CommentsFeed, { appSlug, claims, labelledBy: headingId })
|
|
21
|
+
] });
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
CommunitySection
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=CommunitySection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CommunitySection.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommunitySection.tsx"],"sourcesContent":["import type { Claim } from '@igstack/app-catalog-backend-core'\nimport React from 'react'\nimport { CommentsFeed } from './CommentsFeed'\nimport { CommentsHelp } from './CommentsHelp'\nimport { CommunityInput } from './CommunityInput'\nimport type { CommunityInputHandle } from './CommunityInput'\n\nexport type CommunitySectionHandle = CommunityInputHandle\n\nconst NO_CLAIMS: Claim[] = []\n\n/**\n * The top of Behind this card: one input that takes words and screenshots,\n * then \"Comments\" with its help, then everything already said about the entry\n * as one feed.\n */\nexport function CommunitySection({\n appSlug,\n appName,\n claims = NO_CLAIMS,\n ref,\n}: {\n appSlug: string\n appName: string\n claims?: Claim[]\n ref?: React.Ref<CommunitySectionHandle>\n}) {\n const headingId = React.useId()\n return (\n <section className=\"behind-group\">\n <CommunityInput ref={ref} appSlug={appSlug} appName={appName} />\n <div className=\"comments-head\">\n <h2 id={headingId} className=\"behind-group__title\">\n Comments\n </h2>\n <CommentsHelp appSlug={appSlug} appName={appName} />\n </div>\n <CommentsFeed appSlug={appSlug} claims={claims} labelledBy={headingId} />\n </section>\n )\n}\n"],"names":["React"],"mappings":";;;;;AASA,MAAM,YAAqB,CAAA;AAOpB,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AACF,GAKG;AACD,QAAM,YAAYA,eAAM,MAAA;AACxB,SACE,qBAAC,WAAA,EAAQ,WAAU,gBACjB,UAAA;AAAA,IAAA,oBAAC,gBAAA,EAAe,KAAU,SAAkB,QAAA,CAAkB;AAAA,IAC9D,qBAAC,OAAA,EAAI,WAAU,iBACb,UAAA;AAAA,MAAA,oBAAC,MAAA,EAAG,IAAI,WAAW,WAAU,uBAAsB,UAAA,YAEnD;AAAA,MACA,oBAAC,cAAA,EAAa,SAAkB,QAAA,CAAkB;AAAA,IAAA,GACpD;AAAA,IACA,oBAAC,cAAA,EAAa,SAAkB,QAAgB,YAAY,UAAA,CAAW;AAAA,EAAA,GACzE;AAEJ;"}
|
|
@@ -29,28 +29,10 @@ export interface DetailTab {
|
|
|
29
29
|
* Panels mount only while active, which also resets their internal state on
|
|
30
30
|
* the way out — the same choice the card makes when it navigates between apps.
|
|
31
31
|
*/
|
|
32
|
-
export declare function DetailTabs({ tabs, forcedTab,
|
|
32
|
+
export declare function DetailTabs({ tabs, forcedTab, band, bandClassName, trailing, }: {
|
|
33
33
|
tabs: DetailTab[];
|
|
34
34
|
/** Overrides `?tab=` while set. */
|
|
35
35
|
forcedTab?: string;
|
|
36
|
-
/**
|
|
37
|
-
* A one-shot "please show this tab" from outside the strip — the band's
|
|
38
|
-
* "Suggest a change" uses it.
|
|
39
|
-
*
|
|
40
|
-
* It has to come through here rather than the caller writing `?tab=` itself:
|
|
41
|
-
* `useUrlSyncedState` keeps its own `useState` seeded once from the URL and only
|
|
42
|
-
* ever pushes state OUT to the URL, so a second instance writing the same key
|
|
43
|
-
* does not update this one. The two then fight — the strip's next render sees the
|
|
44
|
-
* URL disagreeing with its own state and navigates back over it.
|
|
45
|
-
*/
|
|
46
|
-
requestTab?: string;
|
|
47
|
-
/**
|
|
48
|
-
* Bumped by the caller every time it asks again. Without it a repeat request is
|
|
49
|
-
* indistinguishable from the same standing value, so the effect below does not
|
|
50
|
-
* re-fire and the second ask does nothing — which is what made the band's
|
|
51
|
-
* button work exactly once per card.
|
|
52
|
-
*/
|
|
53
|
-
requestNonce?: number;
|
|
54
36
|
/**
|
|
55
37
|
* Identity and actions, shown ABOVE the strip and inside the same tinted
|
|
56
38
|
* surface. Owned here rather than by the caller because the band and the
|
|
@@ -61,4 +43,11 @@ export declare function DetailTabs({ tabs, forcedTab, requestTab, requestNonce,
|
|
|
61
43
|
band?: React.ReactNode;
|
|
62
44
|
/** Cancels the dialog's own padding so the tint reaches the card's edges. */
|
|
63
45
|
bandClassName?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Shown at the right end of the strip, outside the tablist so it is not
|
|
48
|
+
* announced as a tab. Handed `select` because the strip owns the active tab:
|
|
49
|
+
* a caller writing `?tab=` itself would fight `useUrlSyncedState`, which keeps
|
|
50
|
+
* its own state and only pushes it out to the url.
|
|
51
|
+
*/
|
|
52
|
+
trailing?: (select: (id: string) => void) => React.ReactNode;
|
|
64
53
|
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -5,10 +5,9 @@ import { useUrlSyncedState } from "../../hooks/useUrlSyncedState.js";
|
|
|
5
5
|
function DetailTabs({
|
|
6
6
|
tabs,
|
|
7
7
|
forcedTab,
|
|
8
|
-
requestTab,
|
|
9
|
-
requestNonce,
|
|
10
8
|
band,
|
|
11
|
-
bandClassName
|
|
9
|
+
bandClassName,
|
|
10
|
+
trailing
|
|
12
11
|
}) {
|
|
13
12
|
var _a;
|
|
14
13
|
const defaultTabId = (_a = tabs[0]) == null ? void 0 : _a.id;
|
|
@@ -18,22 +17,36 @@ function DetailTabs({
|
|
|
18
17
|
encode: (value) => value && value !== defaultTabId ? value : void 0
|
|
19
18
|
});
|
|
20
19
|
const ids = tabs.map((t) => t.id);
|
|
21
|
-
const RETIRED = {
|
|
20
|
+
const RETIRED = {
|
|
21
|
+
metadata: "documentation",
|
|
22
|
+
notes: "behind"
|
|
23
|
+
};
|
|
24
|
+
const ABSENT = {
|
|
25
|
+
documentation: "behind"
|
|
26
|
+
};
|
|
22
27
|
const [userChose, setUserChose] = React__default.useState(false);
|
|
23
|
-
React__default.useEffect(() => {
|
|
24
|
-
if (!requestTab) return;
|
|
25
|
-
setUserChose(true);
|
|
26
|
-
setUrlTab(requestTab);
|
|
27
|
-
}, [requestTab, requestNonce, setUrlTab]);
|
|
28
28
|
const [lastForced, setLastForced] = React__default.useState(forcedTab);
|
|
29
29
|
if (forcedTab !== lastForced) {
|
|
30
30
|
setLastForced(forcedTab);
|
|
31
31
|
if (lastForced && !forcedTab && !userChose) setUrlTab(lastForced);
|
|
32
32
|
}
|
|
33
33
|
const asked = userChose || !forcedTab ? urlTab : forcedTab;
|
|
34
|
-
const
|
|
34
|
+
const retired = RETIRED[asked] ?? asked;
|
|
35
|
+
const requested = ids.includes(retired) ? retired : ABSENT[retired] ?? retired;
|
|
35
36
|
const activeId = ids.includes(requested) ? requested : ids[0] ?? "";
|
|
36
37
|
const active = tabs.find((t) => t.id === activeId);
|
|
38
|
+
const select = (id) => {
|
|
39
|
+
setUserChose(true);
|
|
40
|
+
setUrlTab(id);
|
|
41
|
+
requestAnimationFrame(() => {
|
|
42
|
+
const tabElement = document.getElementById(`detail-tab-${id}`);
|
|
43
|
+
tabElement == null ? void 0 : tabElement.scrollIntoView({
|
|
44
|
+
behavior: "smooth",
|
|
45
|
+
block: "nearest",
|
|
46
|
+
inline: "center"
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
};
|
|
37
50
|
const onKeyDown = (event) => {
|
|
38
51
|
var _a2;
|
|
39
52
|
const delta = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
|
@@ -42,39 +55,38 @@ function DetailTabs({
|
|
|
42
55
|
const from = ids.indexOf(activeId);
|
|
43
56
|
const next = ids[(from + delta + ids.length) % ids.length];
|
|
44
57
|
if (!next) return;
|
|
45
|
-
|
|
46
|
-
setUrlTab(next);
|
|
58
|
+
select(next);
|
|
47
59
|
(_a2 = document.getElementById(`detail-tab-${next}`)) == null ? void 0 : _a2.focus();
|
|
48
60
|
};
|
|
49
61
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
50
62
|
/* @__PURE__ */ jsxs("div", { className: cn("detail-band", bandClassName), children: [
|
|
51
63
|
band,
|
|
52
|
-
/* @__PURE__ */
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
/* @__PURE__ */ jsxs("div", { className: "detail-tabs-row mt-5", children: [
|
|
65
|
+
/* @__PURE__ */ jsx("div", { role: "tablist", className: "detail-tabs", onKeyDown, children: tabs.map((tab) => {
|
|
66
|
+
const selected = tab.id === activeId;
|
|
67
|
+
return /* @__PURE__ */ jsxs(
|
|
68
|
+
"button",
|
|
69
|
+
{
|
|
70
|
+
type: "button",
|
|
71
|
+
role: "tab",
|
|
72
|
+
id: `detail-tab-${tab.id}`,
|
|
73
|
+
"aria-selected": selected,
|
|
74
|
+
"aria-controls": `detail-panel-${tab.id}`,
|
|
75
|
+
tabIndex: selected ? 0 : -1,
|
|
76
|
+
"aria-label": tab.ariaLabel,
|
|
77
|
+
className: "detail-tab",
|
|
78
|
+
onClick: () => select(tab.id),
|
|
79
|
+
children: [
|
|
80
|
+
tab.label,
|
|
81
|
+
tab.chip && /* @__PURE__ */ jsx("span", { className: "detail-tab__chip", children: tab.chip }),
|
|
82
|
+
tab.dot && /* @__PURE__ */ jsx("span", { className: "detail-tab__dot", "aria-hidden": "true" })
|
|
83
|
+
]
|
|
68
84
|
},
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
},
|
|
75
|
-
tab.id
|
|
76
|
-
);
|
|
77
|
-
}) })
|
|
85
|
+
tab.id
|
|
86
|
+
);
|
|
87
|
+
}) }),
|
|
88
|
+
trailing && /* @__PURE__ */ jsx("div", { className: "detail-tabs-trailing", children: trailing(select) })
|
|
89
|
+
] })
|
|
78
90
|
] }),
|
|
79
91
|
active && /* @__PURE__ */ jsx(
|
|
80
92
|
"div",
|