@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.5.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 (73) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
  4. package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
  5. package/dist/esm/api/infra/trpc.d.ts +42 -21
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +19 -0
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
  10. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.d.ts +14 -0
  12. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
  13. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
  15. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
  17. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
  19. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
  20. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
  21. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
  23. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
  25. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
  26. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +90 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +18 -1
  34. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  35. package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
  36. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
  37. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
  38. package/dist/index.css +269 -5
  39. package/package.json +4 -4
  40. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
  41. package/src/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.tsx +113 -0
  42. package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
  43. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
  44. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +193 -0
  45. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
  46. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
  47. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
  48. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
  49. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
  50. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
  51. package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
  52. package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
  53. package/src/index.css +269 -5
  54. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +24 -0
  55. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
  56. package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
  57. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
  58. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
  59. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
  60. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
  61. package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
  62. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +195 -0
  63. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
  64. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +20 -1
  65. package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
  66. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
  67. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
  68. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
  69. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
  70. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
  71. package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
  72. /package/dist/esm/__tests__/modules/appCatalog/ui/{detail/CommentsSection.emptyState.test.d.ts → catalog/RequestAppDialog.test.d.ts} +0 -0
  73. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
@@ -0,0 +1,433 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query";
3
+ import { X } from "lucide-react";
4
+ import { useState, useEffect } from "react";
5
+ import { useTRPC } from "../../../../api/infra/trpc.js";
6
+ import { Button } from "../../../../ui/button.js";
7
+ import { cn } from "../../../../lib/utils.js";
8
+ import { formatRelativeTime } from "../../utils/formatRelativeTime.js";
9
+ const FIELD_CLASSES = "placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50";
10
+ function errorMessage(error) {
11
+ return error instanceof Error ? error.message : "Something went wrong";
12
+ }
13
+ const STATUS_LABELS = {
14
+ applied: {
15
+ text: "Applied",
16
+ className: "comment-chip-applied",
17
+ title: "This improved the entry — the catalog now carries what it said. Thank you."
18
+ },
19
+ acknowledged: {
20
+ text: "Seen",
21
+ className: "comment-chip-seen",
22
+ title: "A maintainer read this and left the entry as it was."
23
+ }
24
+ };
25
+ function StatusChip({ status }) {
26
+ const label = STATUS_LABELS[status];
27
+ return /* @__PURE__ */ jsx("span", { className: cn("comment-chip", label.className), title: label.title, children: label.text });
28
+ }
29
+ function ReviewerReply({ reply }) {
30
+ return /* @__PURE__ */ jsxs("div", { className: "border-primary/45 bg-muted/60 mt-1.5 rounded-r-lg border-l-2 px-2.5 py-1.5", children: [
31
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground font-medium", children: "Catalog team" }),
32
+ /* @__PURE__ */ jsx("p", { className: "mt-0.5 whitespace-pre-wrap", children: reply })
33
+ ] });
34
+ }
35
+ function isSubmitChord(event) {
36
+ return event.key === "Enter" && (event.metaKey || event.ctrlKey);
37
+ }
38
+ function draftKey(slug) {
39
+ return `ac.feedback.draft.${slug}`;
40
+ }
41
+ function readDraft(slug) {
42
+ try {
43
+ return sessionStorage.getItem(draftKey(slug)) ?? "";
44
+ } catch {
45
+ return "";
46
+ }
47
+ }
48
+ function writeDraft(slug, value) {
49
+ try {
50
+ if (value) sessionStorage.setItem(draftKey(slug), value);
51
+ else sessionStorage.removeItem(draftKey(slug));
52
+ } catch {
53
+ }
54
+ }
55
+ const MAX_ATTACHMENTS = 3;
56
+ const UPLOAD_URL = "/api/feedback-attachments/upload";
57
+ function attachmentsKey(slug) {
58
+ return `ac.feedback.images.${slug}`;
59
+ }
60
+ function readAttachments(slug) {
61
+ try {
62
+ const raw = sessionStorage.getItem(attachmentsKey(slug));
63
+ if (!raw) return [];
64
+ const parsed = JSON.parse(raw);
65
+ if (!Array.isArray(parsed)) return [];
66
+ return parsed.filter(
67
+ (entry) => typeof entry === "object" && entry !== null && typeof entry.id === "string" && typeof entry.name === "string"
68
+ );
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+ function writeAttachments(slug, images) {
74
+ try {
75
+ if (images.length > 0) {
76
+ sessionStorage.setItem(attachmentsKey(slug), JSON.stringify(images));
77
+ } else {
78
+ sessionStorage.removeItem(attachmentsKey(slug));
79
+ }
80
+ } catch {
81
+ }
82
+ }
83
+ function attachmentUrl(id) {
84
+ return `/api/feedback-attachments/${id}`;
85
+ }
86
+ function AttachmentStrip({ ids }) {
87
+ if (ids.length === 0) return null;
88
+ return /* @__PURE__ */ jsx("ul", { className: "mt-1.5 flex flex-wrap gap-1.5", children: ids.map((id) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
89
+ "a",
90
+ {
91
+ href: attachmentUrl(id),
92
+ target: "_blank",
93
+ rel: "noreferrer",
94
+ className: "border-border hover:border-ring block overflow-hidden rounded border transition-colors",
95
+ children: /* @__PURE__ */ jsx(
96
+ "img",
97
+ {
98
+ src: attachmentUrl(id),
99
+ alt: "Attached screenshot",
100
+ loading: "lazy",
101
+ className: "h-16 w-24 object-cover"
102
+ }
103
+ )
104
+ }
105
+ ) }, id)) });
106
+ }
107
+ function FeedbackSection({
108
+ appSlug,
109
+ /**
110
+ * Asked to open by the card's band, which is where the affordance now lives —
111
+ * at the foot of this panel it was below the fold of a tab nobody opens unless
112
+ * they already know what is in it.
113
+ *
114
+ * A count of how many times it has asked, so a second ask re-opens the composer
115
+ * the first one closed on send. A boolean could not say "again".
116
+ */
117
+ openComposer = 0
118
+ }) {
119
+ const trpc = useTRPC();
120
+ const queryClient = useQueryClient();
121
+ const listOptions = trpc.feedback.list.queryOptions({ resourceSlug: appSlug });
122
+ const { data, isLoading, error } = useQuery(listOptions);
123
+ const [draft, setDraft] = useState(() => readDraft(appSlug));
124
+ const [images, setImages] = useState(
125
+ () => readAttachments(appSlug)
126
+ );
127
+ const [uploading, setUploading] = useState(false);
128
+ const [uploadError, setUploadError] = useState(null);
129
+ const [editingId, setEditingId] = useState(null);
130
+ const [editDraft, setEditDraft] = useState("");
131
+ const [composerOpen, setComposerOpen] = useState(
132
+ () => openComposer > 0 || !!readDraft(appSlug) || readAttachments(appSlug).length > 0
133
+ );
134
+ useEffect(() => {
135
+ if (openComposer > 0) setComposerOpen(true);
136
+ }, [openComposer]);
137
+ const updateDraft = (value) => {
138
+ setDraft(value);
139
+ writeDraft(appSlug, value);
140
+ };
141
+ const updateImages = (next) => {
142
+ setImages(next);
143
+ writeAttachments(appSlug, next);
144
+ };
145
+ const attach = async (picked) => {
146
+ const files = [...picked ?? []];
147
+ if (files.length === 0) return;
148
+ const room = MAX_ATTACHMENTS - images.length;
149
+ if (room <= 0) {
150
+ setUploadError(`Up to ${MAX_ATTACHMENTS} images`);
151
+ return;
152
+ }
153
+ setUploadError(null);
154
+ setUploading(true);
155
+ try {
156
+ const form = new FormData();
157
+ for (const file of files.slice(0, room)) form.append("image", file);
158
+ const response = await fetch(UPLOAD_URL, {
159
+ method: "POST",
160
+ body: form,
161
+ // The visitor cookie is the identity the upload is recorded against, and it
162
+ // is httpOnly — so it has to ride along explicitly.
163
+ credentials: "same-origin"
164
+ });
165
+ const payload = await response.json().catch(() => null);
166
+ const field = (key) => typeof payload === "object" && payload !== null ? payload[key] : void 0;
167
+ if (!response.ok) {
168
+ const reported = field("error");
169
+ setUploadError(
170
+ typeof reported === "string" ? reported : "Could not attach that image"
171
+ );
172
+ return;
173
+ }
174
+ const ids = field("ids");
175
+ if (!Array.isArray(ids)) {
176
+ setUploadError("Could not attach that image");
177
+ return;
178
+ }
179
+ updateImages([
180
+ ...images,
181
+ ...ids.map((id, index) => {
182
+ var _a;
183
+ return {
184
+ id: String(id),
185
+ name: ((_a = files[index]) == null ? void 0 : _a.name) ?? "image"
186
+ };
187
+ })
188
+ ]);
189
+ if (files.length > room) {
190
+ setUploadError(`Only the first ${room} were attached`);
191
+ }
192
+ } catch {
193
+ setUploadError("Could not attach that image");
194
+ } finally {
195
+ setUploading(false);
196
+ }
197
+ };
198
+ const refresh = () => queryClient.invalidateQueries({ queryKey: listOptions.queryKey });
199
+ const add = useMutation(
200
+ trpc.feedback.add.mutationOptions({
201
+ onSuccess: () => {
202
+ updateDraft("");
203
+ updateImages([]);
204
+ setUploadError(null);
205
+ setComposerOpen(false);
206
+ void refresh();
207
+ }
208
+ })
209
+ );
210
+ const edit = useMutation(
211
+ trpc.feedback.edit.mutationOptions({
212
+ onSuccess: () => {
213
+ setEditingId(null);
214
+ void refresh();
215
+ }
216
+ })
217
+ );
218
+ const dismiss = useMutation(
219
+ trpc.feedback.dismiss.mutationOptions({ onSuccess: () => void refresh() })
220
+ );
221
+ const send = () => add.mutate({
222
+ resourceSlug: appSlug,
223
+ body: draft.trim(),
224
+ ...images.length > 0 ? { attachmentIds: images.map((image) => image.id) } : {}
225
+ });
226
+ const saveEdit = (id) => {
227
+ const body = editDraft.trim();
228
+ if (body) edit.mutate({ id, body });
229
+ };
230
+ const failure = error ?? add.error ?? edit.error ?? dismiss.error;
231
+ const items = (data == null ? void 0 : data.items) ?? [];
232
+ const openCount = (data == null ? void 0 : data.openCount) ?? 0;
233
+ return /* @__PURE__ */ jsxs("div", { className: "mt-7", children: [
234
+ /* @__PURE__ */ jsxs("h3", { className: "section-label mb-1.5 flex items-center gap-2", children: [
235
+ "Notes & requests",
236
+ items.length > 0 && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground font-normal tracking-normal normal-case", children: [
237
+ "(",
238
+ items.length,
239
+ ")"
240
+ ] }),
241
+ openCount > 0 && /* @__PURE__ */ jsxs(
242
+ "span",
243
+ {
244
+ className: "feedback-open-count",
245
+ "aria-label": `${openCount} awaiting review`,
246
+ children: [
247
+ openCount,
248
+ " open"
249
+ ]
250
+ }
251
+ )
252
+ ] }),
253
+ isLoading ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: "Loading…" }) : items.length > 0 ? /* @__PURE__ */ jsx("ul", { className: "space-y-3", children: items.map((item) => {
254
+ const canEdit = item.isMine && !!item.canEditUntil && Date.parse(item.canEditUntil) > Date.now();
255
+ const canDismiss = item.isMine && item.status === null;
256
+ return /* @__PURE__ */ jsxs("li", { className: "text-xs", children: [
257
+ /* @__PURE__ */ jsxs("div", { className: "text-muted-foreground flex items-center gap-2", children: [
258
+ /* @__PURE__ */ jsx("span", { className: "text-foreground font-medium", children: item.authorName }),
259
+ /* @__PURE__ */ jsx("span", { children: formatRelativeTime(item.createdAt) }),
260
+ item.editedAt && /* @__PURE__ */ jsx("span", { children: "(edited)" }),
261
+ item.status && /* @__PURE__ */ jsx(StatusChip, { status: item.status }),
262
+ canEdit && editingId !== item.id && /* @__PURE__ */ jsx(
263
+ "button",
264
+ {
265
+ type: "button",
266
+ className: "hover:text-foreground underline",
267
+ onClick: () => {
268
+ setEditingId(item.id);
269
+ setEditDraft(item.body ?? "");
270
+ },
271
+ children: "Edit"
272
+ }
273
+ ),
274
+ canDismiss && editingId !== item.id && /* @__PURE__ */ jsx(
275
+ "button",
276
+ {
277
+ type: "button",
278
+ className: "hover:text-destructive underline",
279
+ onClick: () => dismiss.mutate({ id: item.id }),
280
+ children: "Dismiss"
281
+ }
282
+ )
283
+ ] }),
284
+ editingId === item.id ? /* @__PURE__ */ jsxs("div", { className: "mt-1 space-y-2", children: [
285
+ /* @__PURE__ */ jsx(
286
+ "textarea",
287
+ {
288
+ autoFocus: true,
289
+ rows: 3,
290
+ className: cn(FIELD_CLASSES),
291
+ value: editDraft,
292
+ onChange: (event) => setEditDraft(event.target.value),
293
+ onKeyDown: (event) => {
294
+ if (isSubmitChord(event)) {
295
+ event.preventDefault();
296
+ saveEdit(item.id);
297
+ }
298
+ }
299
+ }
300
+ ),
301
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
302
+ /* @__PURE__ */ jsx(
303
+ Button,
304
+ {
305
+ type: "button",
306
+ size: "sm",
307
+ disabled: !editDraft.trim() || edit.isPending,
308
+ onClick: () => saveEdit(item.id),
309
+ children: "Save"
310
+ }
311
+ ),
312
+ /* @__PURE__ */ jsx(
313
+ Button,
314
+ {
315
+ type: "button",
316
+ size: "sm",
317
+ variant: "ghost",
318
+ onClick: () => setEditingId(null),
319
+ children: "Cancel"
320
+ }
321
+ )
322
+ ] })
323
+ ] }) : item.body ? /* @__PURE__ */ jsx(
324
+ "p",
325
+ {
326
+ "data-feedback-body": "",
327
+ className: "mt-0.5 whitespace-pre-wrap",
328
+ children: item.body
329
+ }
330
+ ) : item.attachmentIds.length > 0 ? (
331
+ // The screenshot IS the detail — saying "no detail given" over a
332
+ // picture of the problem would read as the catalog ignoring it.
333
+ /* @__PURE__ */ jsx(
334
+ "p",
335
+ {
336
+ "data-feedback-body": "",
337
+ className: "text-muted-foreground mt-0.5 italic",
338
+ children: "Screenshot only — no words added."
339
+ }
340
+ )
341
+ ) : (
342
+ // A bare flag: no words, still a signal. Say so rather than
343
+ // rendering an empty row someone has to interpret.
344
+ /* @__PURE__ */ jsx(
345
+ "p",
346
+ {
347
+ "data-feedback-body": "",
348
+ className: "text-muted-foreground mt-0.5 italic",
349
+ children: "Flagged as out of date — no detail given."
350
+ }
351
+ )
352
+ ),
353
+ /* @__PURE__ */ jsx(AttachmentStrip, { ids: item.attachmentIds }),
354
+ item.reviewerReply && /* @__PURE__ */ jsx(ReviewerReply, { reply: item.reviewerReply })
355
+ ] }, item.id);
356
+ }) }) : /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: "Nothing here yet." }),
357
+ composerOpen ? /* @__PURE__ */ jsxs("div", { className: "animate-in fade-in slide-in-from-top-2 mt-3 space-y-2 duration-200", children: [
358
+ /* @__PURE__ */ jsx(
359
+ "textarea",
360
+ {
361
+ autoFocus: true,
362
+ rows: 3,
363
+ className: cn(FIELD_CLASSES),
364
+ placeholder: "What should change?",
365
+ value: draft,
366
+ onChange: (event) => updateDraft(event.target.value),
367
+ onKeyDown: (event) => {
368
+ if (isSubmitChord(event)) {
369
+ event.preventDefault();
370
+ send();
371
+ }
372
+ }
373
+ }
374
+ ),
375
+ images.length > 0 && /* @__PURE__ */ jsx("ul", { className: "flex flex-wrap gap-2", children: images.map((image) => /* @__PURE__ */ jsxs("li", { className: "relative", children: [
376
+ /* @__PURE__ */ jsx(
377
+ "img",
378
+ {
379
+ src: attachmentUrl(image.id),
380
+ alt: image.name,
381
+ className: "border-border h-16 w-24 rounded border object-cover"
382
+ }
383
+ ),
384
+ /* @__PURE__ */ jsx(
385
+ "button",
386
+ {
387
+ type: "button",
388
+ "aria-label": `Remove ${image.name}`,
389
+ onClick: () => updateImages(images.filter((i) => i.id !== image.id)),
390
+ className: "bg-background/90 text-muted-foreground hover:text-foreground absolute -top-1.5 -right-1.5 rounded-full border p-0.5 leading-none",
391
+ children: /* @__PURE__ */ jsx(X, { className: "size-3" })
392
+ }
393
+ )
394
+ ] }, image.id)) }),
395
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [
396
+ /* @__PURE__ */ jsx(
397
+ Button,
398
+ {
399
+ type: "button",
400
+ size: "sm",
401
+ disabled: add.isPending || uploading,
402
+ onClick: send,
403
+ children: add.isPending ? "Sending…" : "Send"
404
+ }
405
+ ),
406
+ images.length < MAX_ATTACHMENTS && /* @__PURE__ */ jsxs("label", { className: "text-muted-foreground hover:text-foreground cursor-pointer text-xs underline decoration-dotted underline-offset-2", children: [
407
+ /* @__PURE__ */ jsx(
408
+ "input",
409
+ {
410
+ type: "file",
411
+ accept: "image/*",
412
+ multiple: true,
413
+ className: "sr-only",
414
+ disabled: uploading,
415
+ onChange: (event) => {
416
+ void attach(event.target.files);
417
+ event.target.value = "";
418
+ }
419
+ }
420
+ ),
421
+ uploading ? "Attaching…" : "Attach a screenshot"
422
+ ] }),
423
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: "Posted under a nickname. You can edit it for an hour, or withdraw it until someone answers." })
424
+ ] }),
425
+ uploadError && /* @__PURE__ */ jsx("p", { className: "text-destructive text-xs", children: uploadError })
426
+ ] }) : null,
427
+ failure && /* @__PURE__ */ jsx("p", { className: "text-destructive mt-2 text-xs", children: errorMessage(failure) })
428
+ ] });
429
+ }
430
+ export {
431
+ FeedbackSection
432
+ };
433
+ //# sourceMappingURL=FeedbackSection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FeedbackSection.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/FeedbackSection.tsx"],"sourcesContent":["import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { X } from 'lucide-react'\nimport { useEffect, useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { Button } from '~/ui/button'\nimport { cn } from '~/lib/utils'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\n\n/**\n * Feedback on one app, from anyone browsing the catalog.\n *\n * Nobody has to log in, so the server attributes each item to a pseudonym derived from\n * an httpOnly cookie (\"Curious Ferret\"). That cookie is also the only thing that\n * authorises editing or withdrawing: editing closes after an hour, withdrawing stays\n * open for as long as nobody has reviewed it. `canEditUntil` arrives as an absolute\n * instant so a skewed client clock cannot show a control the server would refuse.\n */\n\n/** Same shape the input primitive uses, minus the fixed height. */\nconst FIELD_CLASSES =\n 'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : 'Something went wrong'\n}\n\n/**\n * What a maintainer did with an item, as the reader sees it.\n *\n * `applied` is the point of the whole thing: feedback that improved the entry gets\n * public credit, so the next person can see that reporting changes the catalog. Nobody\n * sets these from the UI — the review tooling writes them.\n *\n * There is no chip for unreviewed. Open is the modal state, so a pill on every\n * unreviewed row would pay the most expensive encoding for the least informative fact;\n * absence carries it, and that leaves a chip meaning \"a maintainer acted here\". The\n * label is also just `Applied` — the gratitude belongs in the reply prose below it,\n * where it reads as a person rather than a system shouting thanks.\n */\nconst STATUS_LABELS = {\n applied: {\n text: 'Applied',\n className: 'comment-chip-applied',\n title:\n 'This improved the entry — the catalog now carries what it said. Thank you.',\n },\n acknowledged: {\n text: 'Seen',\n className: 'comment-chip-seen',\n title: 'A maintainer read this and left the entry as it was.',\n },\n} as const\n\nfunction StatusChip({ status }: { status: 'applied' | 'acknowledged' }) {\n const label = STATUS_LABELS[status]\n return (\n <span className={cn('comment-chip', label.className)} title={label.title}>\n {label.text}\n </span>\n )\n}\n\n/** The maintainers' answer, attributed to the team rather than a pseudonym. */\nfunction ReviewerReply({ reply }: { reply: string }) {\n return (\n <div className=\"border-primary/45 bg-muted/60 mt-1.5 rounded-r-lg border-l-2 px-2.5 py-1.5\">\n <span className=\"text-muted-foreground font-medium\">Catalog team</span>\n <p className=\"mt-0.5 whitespace-pre-wrap\">{reply}</p>\n </div>\n )\n}\n\n/** Cmd/Ctrl+Enter submits; plain Enter keeps making paragraphs. */\nfunction isSubmitChord(event: React.KeyboardEvent): boolean {\n return event.key === 'Enter' && (event.metaKey || event.ctrlKey)\n}\n\n/**\n * Where a half-typed draft lives while it is not submitted.\n *\n * The detail card mounts this section inside a tab panel, and a panel is unmounted\n * while its tab is not the active one — so component state alone loses whatever\n * someone had typed the moment they look at another tab, and again if they close the\n * card. Losing someone's words is the one failure this feature cannot afford: the\n * whole premise is that reporting costs almost nothing, and \"I typed it and it\n * vanished\" is how a person learns not to bother.\n *\n * Session rather than local storage: a draft is in-flight work, not a preference,\n * and it should not still be waiting weeks later in a browser nobody remembers using.\n * Keyed by slug so two entries do not share one draft.\n */\nfunction draftKey(slug: string): string {\n return `ac.feedback.draft.${slug}`\n}\n\nfunction readDraft(slug: string): string {\n try {\n return sessionStorage.getItem(draftKey(slug)) ?? ''\n } catch {\n // Storage can throw outright in a locked-down context; a lost draft is not\n // worth taking the section down over.\n return ''\n }\n}\n\nfunction writeDraft(slug: string, value: string): void {\n try {\n if (value) sessionStorage.setItem(draftKey(slug), value)\n else sessionStorage.removeItem(draftKey(slug))\n } catch {\n /* see readDraft */\n }\n}\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/** One already-uploaded image waiting to be submitted with the words. */\ninterface PendingImage {\n id: string\n name: string\n}\n\n/**\n * Images are uploaded the moment they are picked, so the draft holds ids rather than\n * files — which is also why they have to persist alongside the text. A tab panel\n * unmounts whenever its tab is not the active one, and someone who attached three\n * screenshots and glanced at Overview should not come back to none.\n *\n * Dropping an id here does NOT delete the image; the server sweeps whatever is never\n * submitted. That keeps \"remove\" instant and offline-safe.\n */\nfunction attachmentsKey(slug: string): string {\n return `ac.feedback.images.${slug}`\n}\n\nfunction readAttachments(slug: string): PendingImage[] {\n try {\n const raw = sessionStorage.getItem(attachmentsKey(slug))\n if (!raw) return []\n const parsed: unknown = JSON.parse(raw)\n if (!Array.isArray(parsed)) return []\n return parsed.filter(\n (entry): entry is PendingImage =>\n typeof entry === 'object' &&\n entry !== null &&\n typeof (entry as PendingImage).id === 'string' &&\n typeof (entry as PendingImage).name === 'string',\n )\n } catch {\n // Unparseable or unreadable storage is the same as no attachments — see readDraft.\n return []\n }\n}\n\nfunction writeAttachments(slug: string, images: PendingImage[]): void {\n try {\n if (images.length > 0) {\n sessionStorage.setItem(attachmentsKey(slug), JSON.stringify(images))\n } else {\n sessionStorage.removeItem(attachmentsKey(slug))\n }\n } catch {\n /* see readDraft */\n }\n}\n\n/** The stored image, at whatever size the browser lays it out. */\nfunction attachmentUrl(id: string): string {\n return `/api/feedback-attachments/${id}`\n}\n\n/**\n * Images already submitted with a request. A thumbnail strip rather than inline\n * full-width: the words are the substance and a screenshot is the evidence, so it\n * should be glanceable and openable, not dominate the row.\n */\nfunction AttachmentStrip({ ids }: { ids: string[] }) {\n if (ids.length === 0) return null\n return (\n <ul className=\"mt-1.5 flex flex-wrap gap-1.5\">\n {ids.map((id) => (\n <li key={id}>\n <a\n href={attachmentUrl(id)}\n target=\"_blank\"\n rel=\"noreferrer\"\n className=\"border-border hover:border-ring block overflow-hidden rounded border transition-colors\"\n >\n <img\n src={attachmentUrl(id)}\n alt=\"Attached screenshot\"\n loading=\"lazy\"\n className=\"h-16 w-24 object-cover\"\n />\n </a>\n </li>\n ))}\n </ul>\n )\n}\n\nexport function FeedbackSection({\n appSlug,\n /**\n * Asked to open by the card's band, which is where the affordance now lives —\n * at the foot of this panel it was below the fold of a tab nobody opens unless\n * they already know what is in it.\n *\n * A count of how many times it has asked, so a second ask re-opens the composer\n * the first one closed on send. A boolean could not say \"again\".\n */\n openComposer = 0,\n}: {\n appSlug: string\n openComposer?: number\n}) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const listOptions = trpc.feedback.list.queryOptions({ resourceSlug: appSlug })\n const { data, isLoading, error } = useQuery(listOptions)\n\n // Seeded from storage so a draft survives the tab panel unmounting under it.\n const [draft, setDraft] = useState(() => readDraft(appSlug))\n const [images, setImages] = useState<PendingImage[]>(() =>\n readAttachments(appSlug),\n )\n const [uploading, setUploading] = useState(false)\n const [uploadError, setUploadError] = useState<string | null>(null)\n const [editingId, setEditingId] = useState<string | null>(null)\n const [editDraft, setEditDraft] = useState('')\n /**\n * The composer stays behind the invitation until someone accepts it — which is also\n * what earns the caret: focus follows the click, never the mount. A recovered draft\n * reopens it, otherwise the text would be held but invisible.\n */\n const [composerOpen, setComposerOpen] = useState(\n () =>\n openComposer > 0 ||\n !!readDraft(appSlug) ||\n readAttachments(appSlug).length > 0,\n )\n\n // The band asked for the composer while this panel was unmounted, so honour it on\n // arrival. The request is NOT cleared from here: doing so removed the tab from the\n // strip (it only exists when there is something in it), which bounced the active\n // tab back to the first one — the composer was deleting the tab it had opened.\n useEffect(() => {\n if (openComposer > 0) setComposerOpen(true)\n }, [openComposer])\n\n const updateDraft = (value: string) => {\n setDraft(value)\n writeDraft(appSlug, value)\n }\n\n const updateImages = (next: PendingImage[]) => {\n setImages(next)\n writeAttachments(appSlug, next)\n }\n\n /**\n * Upload on pick, not on send.\n *\n * It costs a round trip before anyone has committed to posting, and it buys the two\n * things that matter: the person sees whether their screenshot actually arrived\n * while they can still do something about it, and Send stays a single fast request\n * that cannot half-fail with three images in flight.\n */\n const attach = async (picked: FileList | null) => {\n const files = [...(picked ?? [])]\n if (files.length === 0) return\n\n const room = MAX_ATTACHMENTS - images.length\n if (room <= 0) {\n setUploadError(`Up to ${MAX_ATTACHMENTS} images`)\n return\n }\n\n setUploadError(null)\n setUploading(true)\n try {\n const form = new FormData()\n for (const file of files.slice(0, room)) form.append('image', file)\n\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 it\n // is httpOnly — so it has to ride along explicitly.\n credentials: 'same-origin',\n })\n // Unknown rather than a declared shape: this is a JSON body off the wire, and\n // the error path exists precisely for responses that are not what we expect.\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\n if (!response.ok) {\n const reported = field('error')\n setUploadError(\n typeof reported === 'string'\n ? reported\n : 'Could not attach that image',\n )\n return\n }\n\n const ids = field('ids')\n if (!Array.isArray(ids)) {\n setUploadError('Could not attach that image')\n return\n }\n updateImages([\n ...images,\n ...ids.map((id, index) => ({\n id: String(id),\n name: files[index]?.name ?? 'image',\n })),\n ])\n if (files.length > room) {\n setUploadError(`Only the first ${room} were attached`)\n }\n } catch {\n setUploadError('Could not attach that image')\n } finally {\n setUploading(false)\n }\n }\n\n const refresh = () =>\n queryClient.invalidateQueries({ queryKey: listOptions.queryKey })\n\n const add = useMutation(\n trpc.feedback.add.mutationOptions({\n onSuccess: () => {\n // Sent: the draft is no longer in flight, so it should not come back.\n updateDraft('')\n updateImages([])\n setUploadError(null)\n setComposerOpen(false)\n void refresh()\n },\n }),\n )\n const edit = useMutation(\n trpc.feedback.edit.mutationOptions({\n onSuccess: () => {\n setEditingId(null)\n void refresh()\n },\n }),\n )\n const dismiss = useMutation(\n trpc.feedback.dismiss.mutationOptions({ onSuccess: () => void refresh() }),\n )\n\n /** Every field is optional, so this submits whatever there is — including nothing. */\n const send = () =>\n add.mutate({\n resourceSlug: appSlug,\n body: draft.trim(),\n ...(images.length > 0\n ? { attachmentIds: images.map((image) => image.id) }\n : {}),\n })\n\n const saveEdit = (id: string) => {\n const body = editDraft.trim()\n if (body) edit.mutate({ id, body })\n }\n\n // One line for every failed action: three mutations and one query, one place to look.\n const failure = error ?? add.error ?? edit.error ?? dismiss.error\n const items = data?.items ?? []\n const openCount = data?.openCount ?? 0\n\n return (\n <div className=\"mt-7\">\n <h3 className=\"section-label mb-1.5 flex items-center gap-2\">\n Notes &amp; requests\n {items.length > 0 && (\n <span className=\"text-muted-foreground font-normal tracking-normal normal-case\">\n ({items.length})\n </span>\n )}\n {openCount > 0 && (\n <span\n className=\"feedback-open-count\"\n aria-label={`${openCount} awaiting review`}\n >\n {openCount} open\n </span>\n )}\n </h3>\n\n {isLoading ? (\n <p className=\"text-muted-foreground text-xs\">Loading…</p>\n ) : items.length > 0 ? (\n <ul className=\"space-y-3\">\n {items.map((item) => {\n const canEdit =\n item.isMine &&\n !!item.canEditUntil &&\n Date.parse(item.canEditUntil) > Date.now()\n // Withdrawing stays available for as long as nobody has reviewed it.\n const canDismiss = item.isMine && item.status === null\n\n return (\n <li key={item.id} className=\"text-xs\">\n <div className=\"text-muted-foreground flex items-center gap-2\">\n <span className=\"text-foreground font-medium\">\n {item.authorName}\n </span>\n <span>{formatRelativeTime(item.createdAt)}</span>\n {item.editedAt && <span>(edited)</span>}\n {item.status && <StatusChip status={item.status} />}\n {canEdit && editingId !== item.id && (\n <button\n type=\"button\"\n className=\"hover:text-foreground underline\"\n onClick={() => {\n setEditingId(item.id)\n setEditDraft(item.body ?? '')\n }}\n >\n Edit\n </button>\n )}\n {canDismiss && editingId !== item.id && (\n <button\n type=\"button\"\n className=\"hover:text-destructive underline\"\n onClick={() => dismiss.mutate({ id: item.id })}\n >\n Dismiss\n </button>\n )}\n </div>\n\n {editingId === item.id ? (\n <div className=\"mt-1 space-y-2\">\n <textarea\n autoFocus\n rows={3}\n className={cn(FIELD_CLASSES)}\n value={editDraft}\n onChange={(event) => setEditDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n saveEdit(item.id)\n }\n }}\n />\n <div className=\"flex gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!editDraft.trim() || edit.isPending}\n onClick={() => saveEdit(item.id)}\n >\n Save\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setEditingId(null)}\n >\n Cancel\n </Button>\n </div>\n </div>\n ) : item.body ? (\n <p\n data-feedback-body=\"\"\n className=\"mt-0.5 whitespace-pre-wrap\"\n >\n {item.body}\n </p>\n ) : item.attachmentIds.length > 0 ? (\n // The screenshot IS the detail — saying \"no detail given\" over a\n // picture of the problem would read as the catalog ignoring it.\n <p\n data-feedback-body=\"\"\n className=\"text-muted-foreground mt-0.5 italic\"\n >\n Screenshot only — no words added.\n </p>\n ) : (\n // A bare flag: no words, still a signal. Say so rather than\n // rendering an empty row someone has to interpret.\n <p\n data-feedback-body=\"\"\n className=\"text-muted-foreground mt-0.5 italic\"\n >\n Flagged as out of date — no detail given.\n </p>\n )}\n\n <AttachmentStrip ids={item.attachmentIds} />\n\n {item.reviewerReply && (\n <ReviewerReply reply={item.reviewerReply} />\n )}\n </li>\n )\n })}\n </ul>\n ) : (\n <p className=\"text-muted-foreground text-xs\">Nothing here yet.</p>\n )}\n\n {composerOpen ? (\n <div className=\"animate-in fade-in slide-in-from-top-2 mt-3 space-y-2 duration-200\">\n <textarea\n autoFocus\n rows={3}\n className={cn(FIELD_CLASSES)}\n placeholder=\"What should change?\"\n value={draft}\n onChange={(event) => updateDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n send()\n }\n }}\n />\n {images.length > 0 && (\n <ul className=\"flex flex-wrap gap-2\">\n {images.map((image) => (\n <li key={image.id} className=\"relative\">\n <img\n src={attachmentUrl(image.id)}\n alt={image.name}\n className=\"border-border h-16 w-24 rounded border object-cover\"\n />\n <button\n type=\"button\"\n aria-label={`Remove ${image.name}`}\n onClick={() =>\n updateImages(images.filter((i) => i.id !== image.id))\n }\n className=\"bg-background/90 text-muted-foreground hover:text-foreground absolute -top-1.5 -right-1.5 rounded-full border p-0.5 leading-none\"\n >\n <X className=\"size-3\" />\n </button>\n </li>\n ))}\n </ul>\n )}\n\n <div className=\"flex flex-wrap items-center gap-3\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={add.isPending || uploading}\n onClick={send}\n >\n {add.isPending ? 'Sending…' : 'Send'}\n </Button>\n\n {images.length < MAX_ATTACHMENTS && (\n <label className=\"text-muted-foreground hover:text-foreground cursor-pointer text-xs underline decoration-dotted underline-offset-2\">\n <input\n type=\"file\"\n accept=\"image/*\"\n multiple\n className=\"sr-only\"\n disabled={uploading}\n onChange={(event) => {\n void attach(event.target.files)\n // Cleared so picking the SAME file again still fires a change.\n event.target.value = ''\n }}\n />\n {uploading ? 'Attaching…' : 'Attach a screenshot'}\n </label>\n )}\n\n <span className=\"text-muted-foreground text-xs\">\n Posted under a nickname. You can edit it for an hour, or withdraw\n it until someone answers.\n </span>\n </div>\n\n {uploadError && (\n <p className=\"text-destructive text-xs\">{uploadError}</p>\n )}\n </div>\n ) : null}\n\n {/* Outside the composer: a failed list query leaves nothing to collapse into. */}\n {failure && (\n <p className=\"text-destructive mt-2 text-xs\">{errorMessage(failure)}</p>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -114,7 +114,24 @@ function AppCatalogPage({
114
114
  {
115
115
  app: selectedApp,
116
116
  subResource: selectedSub,
117
- onClose: () => void navigate({ to: "/" }),
117
+ onClose: () => void navigate({
118
+ to: "/",
119
+ /**
120
+ * Drop the card's own params, keep the catalog's.
121
+ *
122
+ * `navigate({ to: '/' })` alone preserves the whole search object, so
123
+ * closing a card left `?tab=notes` (and `?sub=`, `?qj=`) behind on the
124
+ * catalog, where they mean nothing — and a link copied afterwards
125
+ * carried them. Clearing the lot instead would throw away the
126
+ * visitor's filters, which belong to the catalog and outlive any card.
127
+ */
128
+ search: (prev) => ({
129
+ ...prev,
130
+ tab: void 0,
131
+ sub: void 0,
132
+ qj: void 0
133
+ })
134
+ }),
118
135
  onAppClick: handleAppClick,
119
136
  onBackToParent: handleBackToParent
120
137
  }
@@ -1 +1 @@
1
- {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate, useSearch } from '@tanstack/react-router'\nimport { Suspense, lazy, useCallback, useEffect, useMemo } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { AppCatalogGrid } from '../catalog/AppCatalogGrid'\nimport { AttributionFooter } from '../catalog/AttributionFooter'\n\n// The detail card (and the markdown stack it renders) loads on first open, not\n// with the catalog grid every visitor sees first (#120).\nconst AppDetailPanel = lazy(() =>\n import('../catalog/AppDetailPanel').then((m) => ({\n default: m.AppDetailPanel,\n })),\n)\n\nexport function AppCatalogPage({\n selectedSlug,\n selectedSubSlug: subPageSlug,\n}: { selectedSlug?: string; selectedSubSlug?: string } = {}) {\n const { resources, isLoadingApps } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (module-scoped, see AppCatalogFiltersContext)\n const search = useSearch({ strict: false })\n\n // Two distinct states, deliberately on different parts of the URL:\n // - `?sub=<slug>` the parent's detail, singled out to one child\n // - `/app/<slug>/sub/<child>` that child's OWN page, with its access chain\n // Clicking a matched sub-resource in the search results reaches the first;\n // clicking its name in the parent's table reaches the second.\n const highlightSubSlug = search.sub\n\n const handleSubClick = useCallback(\n (parentSlug: string, subSlug: string) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: parentSlug },\n search: (prev) => ({ ...prev, sub: subSlug }),\n })\n },\n [navigate],\n )\n\n const handleBackToParent = useCallback(() => {\n // Leave the sub-resource's page for its parent's detail, keeping `?sub=` so\n // the row the user came from stays singled out — going \"back\" shouldn't\n // dump them into all forty-seven siblings.\n if (selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: selectedAppSlug },\n search: (prev) => ({ ...prev, sub: subPageSlug }),\n })\n }\n }, [navigate, selectedAppSlug, subPageSlug])\n\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Get root resources for display (children surface through search)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n // #22: alias → canonical redirect. When an app is renamed its old slug is\n // kept in `aliases[]`. If the URL slug matches no canonical slug but does\n // match some app's alias, redirect (replace) to that app's canonical slug so\n // old bookmarks resolve instead of showing a blank catalog. Guard on a real\n // canonical miss so we never fight the normal detail-open path. Note: this is\n // a client-side SPA redirect (replace), not an HTTP 301 — see #22.\n useEffect(() => {\n if (!selectedAppSlug || resources.length === 0) return\n const canonical = resources.some((r) => r.slug === selectedAppSlug)\n if (canonical) return\n const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))\n if (aliased) {\n void navigate({\n to: '/app/$slug',\n params: { slug: aliased.slug },\n search: (prev) => prev,\n replace: true,\n })\n }\n }, [selectedAppSlug, resources, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleLaunch = (app: Resource) => {\n if (app.appUrl) window.open(app.appUrl, '_blank', 'noopener,noreferrer')\n }\n\n // The app whose detail card is open over the catalog backdrop.\n const selectedApp = useMemo(\n () =>\n selectedAppSlug\n ? (resources.find((r) => r.slug === selectedAppSlug) ?? null)\n : null,\n [selectedAppSlug, resources],\n )\n\n // …and the sub-resource whose OWN page is open (`/app/<slug>/sub/<child>`).\n // `?sub=` deliberately does NOT land here: that state stays on the parent's\n // detail with the child singled out in its table.\n const selectedSub = useMemo(() => {\n if (!subPageSlug || !selectedApp) return null\n return (\n resources.find(\n (r) => r.slug === subPageSlug && r.parentSlug === selectedApp.slug,\n ) ?? null\n )\n }, [subPageSlug, selectedApp, resources])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // The catalog is one persistent shell (#38): the discovery spine or, while\n // typing, the in-place results list — both handled inside AppCatalogGrid, so\n // searching never swaps containers and the hero never moves. It owns vertical\n // scroll (MainLayout is h-screen/overflow-hidden), and `scrollbar-gutter:\n // stable` reserves the track so a shorter results list doesn't shift the hero\n // sideways when the scrollbar disappears.\n return (\n // `pb-24` lives here, on whatever ends up last — the footer when the app\n // configures one, the final card row otherwise — so nothing sits flush\n // against the bottom edge or under the floating support bubble.\n <div className=\"flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24\">\n <AppCatalogGrid\n apps={rootResources.filter(\n (a) => filterState.showDeprecated || !a.deprecated,\n )}\n // All resources incl. children — so search matches sub-resources\n // (e.g. a cloud account) and surfaces their parent.\n allResources={resources}\n searchValue={searchValue}\n onSearchChange={setSearchValue}\n onAppClick={handleAppClick}\n onSubClick={handleSubClick}\n onLaunch={handleLaunch}\n detailOpen={selectedApp !== null}\n selectedSubSlug={highlightSubSlug}\n />\n {/* Outside the grid's 1000px column so its rule spans the full width. */}\n <AttributionFooter />\n {/* #38 item B: the detail as a card over the catalog backdrop. */}\n {selectedApp && (\n <Suspense fallback={null}>\n <AppDetailPanel\n app={selectedApp}\n subResource={selectedSub}\n onClose={() => void navigate({ to: '/' })}\n onAppClick={handleAppClick}\n onBackToParent={handleBackToParent}\n />\n </Suspense>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate, useSearch } from '@tanstack/react-router'\nimport { Suspense, lazy, useCallback, useEffect, useMemo } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { AppCatalogGrid } from '../catalog/AppCatalogGrid'\nimport { AttributionFooter } from '../catalog/AttributionFooter'\n\n// The detail card (and the markdown stack it renders) loads on first open, not\n// with the catalog grid every visitor sees first (#120).\nconst AppDetailPanel = lazy(() =>\n import('../catalog/AppDetailPanel').then((m) => ({\n default: m.AppDetailPanel,\n })),\n)\n\nexport function AppCatalogPage({\n selectedSlug,\n selectedSubSlug: subPageSlug,\n}: { selectedSlug?: string; selectedSubSlug?: string } = {}) {\n const { resources, isLoadingApps } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (module-scoped, see AppCatalogFiltersContext)\n const search = useSearch({ strict: false })\n\n // Two distinct states, deliberately on different parts of the URL:\n // - `?sub=<slug>` the parent's detail, singled out to one child\n // - `/app/<slug>/sub/<child>` that child's OWN page, with its access chain\n // Clicking a matched sub-resource in the search results reaches the first;\n // clicking its name in the parent's table reaches the second.\n const highlightSubSlug = search.sub\n\n const handleSubClick = useCallback(\n (parentSlug: string, subSlug: string) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: parentSlug },\n search: (prev) => ({ ...prev, sub: subSlug }),\n })\n },\n [navigate],\n )\n\n const handleBackToParent = useCallback(() => {\n // Leave the sub-resource's page for its parent's detail, keeping `?sub=` so\n // the row the user came from stays singled out — going \"back\" shouldn't\n // dump them into all forty-seven siblings.\n if (selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: selectedAppSlug },\n search: (prev) => ({ ...prev, sub: subPageSlug }),\n })\n }\n }, [navigate, selectedAppSlug, subPageSlug])\n\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Get root resources for display (children surface through search)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n // #22: alias → canonical redirect. When an app is renamed its old slug is\n // kept in `aliases[]`. If the URL slug matches no canonical slug but does\n // match some app's alias, redirect (replace) to that app's canonical slug so\n // old bookmarks resolve instead of showing a blank catalog. Guard on a real\n // canonical miss so we never fight the normal detail-open path. Note: this is\n // a client-side SPA redirect (replace), not an HTTP 301 — see #22.\n useEffect(() => {\n if (!selectedAppSlug || resources.length === 0) return\n const canonical = resources.some((r) => r.slug === selectedAppSlug)\n if (canonical) return\n const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))\n if (aliased) {\n void navigate({\n to: '/app/$slug',\n params: { slug: aliased.slug },\n search: (prev) => prev,\n replace: true,\n })\n }\n }, [selectedAppSlug, resources, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleLaunch = (app: Resource) => {\n if (app.appUrl) window.open(app.appUrl, '_blank', 'noopener,noreferrer')\n }\n\n // The app whose detail card is open over the catalog backdrop.\n const selectedApp = useMemo(\n () =>\n selectedAppSlug\n ? (resources.find((r) => r.slug === selectedAppSlug) ?? null)\n : null,\n [selectedAppSlug, resources],\n )\n\n // …and the sub-resource whose OWN page is open (`/app/<slug>/sub/<child>`).\n // `?sub=` deliberately does NOT land here: that state stays on the parent's\n // detail with the child singled out in its table.\n const selectedSub = useMemo(() => {\n if (!subPageSlug || !selectedApp) return null\n return (\n resources.find(\n (r) => r.slug === subPageSlug && r.parentSlug === selectedApp.slug,\n ) ?? null\n )\n }, [subPageSlug, selectedApp, resources])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // The catalog is one persistent shell (#38): the discovery spine or, while\n // typing, the in-place results list — both handled inside AppCatalogGrid, so\n // searching never swaps containers and the hero never moves. It owns vertical\n // scroll (MainLayout is h-screen/overflow-hidden), and `scrollbar-gutter:\n // stable` reserves the track so a shorter results list doesn't shift the hero\n // sideways when the scrollbar disappears.\n return (\n // `pb-24` lives here, on whatever ends up last — the footer when the app\n // configures one, the final card row otherwise — so nothing sits flush\n // against the bottom edge or under the floating support bubble.\n <div className=\"flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24\">\n <AppCatalogGrid\n apps={rootResources.filter(\n (a) => filterState.showDeprecated || !a.deprecated,\n )}\n // All resources incl. children — so search matches sub-resources\n // (e.g. a cloud account) and surfaces their parent.\n allResources={resources}\n searchValue={searchValue}\n onSearchChange={setSearchValue}\n onAppClick={handleAppClick}\n onSubClick={handleSubClick}\n onLaunch={handleLaunch}\n detailOpen={selectedApp !== null}\n selectedSubSlug={highlightSubSlug}\n />\n {/* Outside the grid's 1000px column so its rule spans the full width. */}\n <AttributionFooter />\n {/* #38 item B: the detail as a card over the catalog backdrop. */}\n {selectedApp && (\n <Suspense fallback={null}>\n <AppDetailPanel\n app={selectedApp}\n subResource={selectedSub}\n onClose={() =>\n void navigate({\n to: '/',\n /**\n * Drop the card's own params, keep the catalog's.\n *\n * `navigate({ to: '/' })` alone preserves the whole search object, so\n * closing a card left `?tab=notes` (and `?sub=`, `?qj=`) behind on the\n * catalog, where they mean nothing — and a link copied afterwards\n * carried them. Clearing the lot instead would throw away the\n * visitor's filters, which belong to the catalog and outlive any card.\n */\n search: (prev) => ({\n ...prev,\n tab: undefined,\n sub: undefined,\n qj: undefined,\n }),\n })\n }\n onAppClick={handleAppClick}\n onBackToParent={handleBackToParent}\n />\n </Suspense>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ import { ApprovalMethod, Resource } from '@igstack/app-catalog-backend-core';
2
+ /**
3
+ * What the detail card's access tab says about itself.
4
+ *
5
+ * `route` names how you get in — the tab reads `Access — <route>` — and `chip`
6
+ * is the one number worth carrying next to it.
7
+ */
8
+ export interface AccessTabLabel {
9
+ route: string;
10
+ chip?: string;
11
+ }
12
+ /** Shown when nothing in the entry records a way in. */
13
+ export declare const ROUTE_UNDOCUMENTED = "not documented";
14
+ /** Shown when the resource needs no request at all. */
15
+ export declare const ROUTE_FREE = "Free";
16
+ /** A request with no named channel behind it. */
17
+ export declare const ROUTE_REQUEST = "request";
18
+ /**
19
+ * Names the access route for a resource's tab.
20
+ *
21
+ * The point is that the tab answers "can I get in?" BEFORE it is opened. That
22
+ * is what lets every entry carry the same four tabs: an access tab holding one
23
+ * sentence saying the route is unknown is a dead end when you have to click to
24
+ * find out, and merely a fact you already read when the label says it.
25
+ *
26
+ * A named channel beats the step count, because the channel is the thing people
27
+ * recognise — most entries route through one or two of them, and "two steps"
28
+ * describes effort rather than destination. The count rides in `chip` instead.
29
+ */
30
+ export declare function accessTabLabel(resource: Resource, approvalMethods: ApprovalMethod[]): AccessTabLabel;
@@ -0,0 +1,34 @@
1
+ const ROUTE_UNDOCUMENTED = "not documented";
2
+ const ROUTE_FREE = "Free";
3
+ const ROUTE_REQUEST = "request";
4
+ function accessTabLabel(resource, approvalMethods) {
5
+ var _a, _b;
6
+ const request = resource.accessRequest;
7
+ const hasContactsOnly = (((_a = resource.approverSlugs) == null ? void 0 : _a.length) ?? 0) > 0 || Boolean(resource.accessComments);
8
+ if (!request) {
9
+ return hasContactsOnly ? { route: ROUTE_REQUEST, chip: void 0 } : { route: ROUTE_UNDOCUMENTED };
10
+ }
11
+ const method = approvalMethods.find(
12
+ (m) => m.slug === request.approvalMethodSlug
13
+ );
14
+ const hasWrittenSteps = Boolean(request.requestPrompt || request.comments);
15
+ const isTwoStep = Boolean(hasWrittenSteps && request.postApprovalInstructions);
16
+ const roleCount = ((_b = request.roles) == null ? void 0 : _b.length) ?? 0;
17
+ const chip = isTwoStep ? "2 steps" : roleCount > 0 ? `${roleCount} role${roleCount === 1 ? "" : "s"}` : void 0;
18
+ if ((method == null ? void 0 : method.type) === "noAccessRequired") return { route: ROUTE_FREE, chip };
19
+ const cannotNameRoute = !method || method.type === "custom";
20
+ if ((method == null ? void 0 : method.type) === "unknown" || cannotNameRoute && !hasWrittenSteps) {
21
+ return { route: ROUTE_UNDOCUMENTED, chip };
22
+ }
23
+ if (!cannotNameRoute) {
24
+ return { route: method.shortName ?? method.displayName, chip };
25
+ }
26
+ return { route: isTwoStep ? "2 steps" : ROUTE_REQUEST, chip };
27
+ }
28
+ export {
29
+ ROUTE_FREE,
30
+ ROUTE_REQUEST,
31
+ ROUTE_UNDOCUMENTED,
32
+ accessTabLabel
33
+ };
34
+ //# sourceMappingURL=accessTabLabel.js.map