@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,605 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import { X } from 'lucide-react'
3
+ import { useEffect, useState } from 'react'
4
+ import { useTRPC } from '~/api/infra/trpc'
5
+ import { Button } from '~/ui/button'
6
+ import { cn } from '~/lib/utils'
7
+ import { formatRelativeTime } from '../../utils/formatRelativeTime'
8
+
9
+ /**
10
+ * Feedback on one app, from anyone browsing the catalog.
11
+ *
12
+ * Nobody has to log in, so the server attributes each item to a pseudonym derived from
13
+ * an httpOnly cookie ("Curious Ferret"). That cookie is also the only thing that
14
+ * authorises editing or withdrawing: editing closes after an hour, withdrawing stays
15
+ * open for as long as nobody has reviewed it. `canEditUntil` arrives as an absolute
16
+ * instant so a skewed client clock cannot show a control the server would refuse.
17
+ */
18
+
19
+ /** Same shape the input primitive uses, minus the fixed height. */
20
+ const FIELD_CLASSES =
21
+ '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'
22
+
23
+ function errorMessage(error: unknown): string {
24
+ return error instanceof Error ? error.message : 'Something went wrong'
25
+ }
26
+
27
+ /**
28
+ * What a maintainer did with an item, as the reader sees it.
29
+ *
30
+ * `applied` is the point of the whole thing: feedback that improved the entry gets
31
+ * public credit, so the next person can see that reporting changes the catalog. Nobody
32
+ * sets these from the UI — the review tooling writes them.
33
+ *
34
+ * There is no chip for unreviewed. Open is the modal state, so a pill on every
35
+ * unreviewed row would pay the most expensive encoding for the least informative fact;
36
+ * absence carries it, and that leaves a chip meaning "a maintainer acted here". The
37
+ * label is also just `Applied` — the gratitude belongs in the reply prose below it,
38
+ * where it reads as a person rather than a system shouting thanks.
39
+ */
40
+ const STATUS_LABELS = {
41
+ applied: {
42
+ text: 'Applied',
43
+ className: 'comment-chip-applied',
44
+ title:
45
+ 'This improved the entry — the catalog now carries what it said. Thank you.',
46
+ },
47
+ acknowledged: {
48
+ text: 'Seen',
49
+ className: 'comment-chip-seen',
50
+ title: 'A maintainer read this and left the entry as it was.',
51
+ },
52
+ } as const
53
+
54
+ function StatusChip({ status }: { status: 'applied' | 'acknowledged' }) {
55
+ const label = STATUS_LABELS[status]
56
+ return (
57
+ <span className={cn('comment-chip', label.className)} title={label.title}>
58
+ {label.text}
59
+ </span>
60
+ )
61
+ }
62
+
63
+ /** The maintainers' answer, attributed to the team rather than a pseudonym. */
64
+ function ReviewerReply({ reply }: { reply: string }) {
65
+ return (
66
+ <div className="border-primary/45 bg-muted/60 mt-1.5 rounded-r-lg border-l-2 px-2.5 py-1.5">
67
+ <span className="text-muted-foreground font-medium">Catalog team</span>
68
+ <p className="mt-0.5 whitespace-pre-wrap">{reply}</p>
69
+ </div>
70
+ )
71
+ }
72
+
73
+ /** Cmd/Ctrl+Enter submits; plain Enter keeps making paragraphs. */
74
+ function isSubmitChord(event: React.KeyboardEvent): boolean {
75
+ return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
76
+ }
77
+
78
+ /**
79
+ * Where a half-typed draft lives while it is not submitted.
80
+ *
81
+ * The detail card mounts this section inside a tab panel, and a panel is unmounted
82
+ * while its tab is not the active one — so component state alone loses whatever
83
+ * someone had typed the moment they look at another tab, and again if they close the
84
+ * card. Losing someone's words is the one failure this feature cannot afford: the
85
+ * whole premise is that reporting costs almost nothing, and "I typed it and it
86
+ * vanished" is how a person learns not to bother.
87
+ *
88
+ * Session rather than local storage: a draft is in-flight work, not a preference,
89
+ * and it should not still be waiting weeks later in a browser nobody remembers using.
90
+ * Keyed by slug so two entries do not share one draft.
91
+ */
92
+ function draftKey(slug: string): string {
93
+ return `ac.feedback.draft.${slug}`
94
+ }
95
+
96
+ function readDraft(slug: string): string {
97
+ try {
98
+ return sessionStorage.getItem(draftKey(slug)) ?? ''
99
+ } catch {
100
+ // Storage can throw outright in a locked-down context; a lost draft is not
101
+ // worth taking the section down over.
102
+ return ''
103
+ }
104
+ }
105
+
106
+ function writeDraft(slug: string, value: string): void {
107
+ try {
108
+ if (value) sessionStorage.setItem(draftKey(slug), value)
109
+ else sessionStorage.removeItem(draftKey(slug))
110
+ } catch {
111
+ /* see readDraft */
112
+ }
113
+ }
114
+
115
+ /** Mirrors the server's cap, which refuses anything above it regardless. */
116
+ const MAX_ATTACHMENTS = 3
117
+
118
+ const UPLOAD_URL = '/api/feedback-attachments/upload'
119
+
120
+ /** One already-uploaded image waiting to be submitted with the words. */
121
+ interface PendingImage {
122
+ id: string
123
+ name: string
124
+ }
125
+
126
+ /**
127
+ * Images are uploaded the moment they are picked, so the draft holds ids rather than
128
+ * files — which is also why they have to persist alongside the text. A tab panel
129
+ * unmounts whenever its tab is not the active one, and someone who attached three
130
+ * screenshots and glanced at Overview should not come back to none.
131
+ *
132
+ * Dropping an id here does NOT delete the image; the server sweeps whatever is never
133
+ * submitted. That keeps "remove" instant and offline-safe.
134
+ */
135
+ function attachmentsKey(slug: string): string {
136
+ return `ac.feedback.images.${slug}`
137
+ }
138
+
139
+ function readAttachments(slug: string): PendingImage[] {
140
+ try {
141
+ const raw = sessionStorage.getItem(attachmentsKey(slug))
142
+ if (!raw) return []
143
+ const parsed: unknown = JSON.parse(raw)
144
+ if (!Array.isArray(parsed)) return []
145
+ return parsed.filter(
146
+ (entry): entry is PendingImage =>
147
+ typeof entry === 'object' &&
148
+ entry !== null &&
149
+ typeof (entry as PendingImage).id === 'string' &&
150
+ typeof (entry as PendingImage).name === 'string',
151
+ )
152
+ } catch {
153
+ // Unparseable or unreadable storage is the same as no attachments — see readDraft.
154
+ return []
155
+ }
156
+ }
157
+
158
+ function writeAttachments(slug: string, images: PendingImage[]): void {
159
+ try {
160
+ if (images.length > 0) {
161
+ sessionStorage.setItem(attachmentsKey(slug), JSON.stringify(images))
162
+ } else {
163
+ sessionStorage.removeItem(attachmentsKey(slug))
164
+ }
165
+ } catch {
166
+ /* see readDraft */
167
+ }
168
+ }
169
+
170
+ /** The stored image, at whatever size the browser lays it out. */
171
+ function attachmentUrl(id: string): string {
172
+ return `/api/feedback-attachments/${id}`
173
+ }
174
+
175
+ /**
176
+ * Images already submitted with a request. A thumbnail strip rather than inline
177
+ * full-width: the words are the substance and a screenshot is the evidence, so it
178
+ * should be glanceable and openable, not dominate the row.
179
+ */
180
+ function AttachmentStrip({ ids }: { ids: string[] }) {
181
+ if (ids.length === 0) return null
182
+ return (
183
+ <ul className="mt-1.5 flex flex-wrap gap-1.5">
184
+ {ids.map((id) => (
185
+ <li key={id}>
186
+ <a
187
+ href={attachmentUrl(id)}
188
+ target="_blank"
189
+ rel="noreferrer"
190
+ className="border-border hover:border-ring block overflow-hidden rounded border transition-colors"
191
+ >
192
+ <img
193
+ src={attachmentUrl(id)}
194
+ alt="Attached screenshot"
195
+ loading="lazy"
196
+ className="h-16 w-24 object-cover"
197
+ />
198
+ </a>
199
+ </li>
200
+ ))}
201
+ </ul>
202
+ )
203
+ }
204
+
205
+ export function FeedbackSection({
206
+ appSlug,
207
+ /**
208
+ * Asked to open by the card's band, which is where the affordance now lives —
209
+ * at the foot of this panel it was below the fold of a tab nobody opens unless
210
+ * they already know what is in it.
211
+ *
212
+ * A count of how many times it has asked, so a second ask re-opens the composer
213
+ * the first one closed on send. A boolean could not say "again".
214
+ */
215
+ openComposer = 0,
216
+ }: {
217
+ appSlug: string
218
+ openComposer?: number
219
+ }) {
220
+ const trpc = useTRPC()
221
+ const queryClient = useQueryClient()
222
+ const listOptions = trpc.feedback.list.queryOptions({ resourceSlug: appSlug })
223
+ const { data, isLoading, error } = useQuery(listOptions)
224
+
225
+ // Seeded from storage so a draft survives the tab panel unmounting under it.
226
+ const [draft, setDraft] = useState(() => readDraft(appSlug))
227
+ const [images, setImages] = useState<PendingImage[]>(() =>
228
+ readAttachments(appSlug),
229
+ )
230
+ const [uploading, setUploading] = useState(false)
231
+ const [uploadError, setUploadError] = useState<string | null>(null)
232
+ const [editingId, setEditingId] = useState<string | null>(null)
233
+ const [editDraft, setEditDraft] = useState('')
234
+ /**
235
+ * The composer stays behind the invitation until someone accepts it — which is also
236
+ * what earns the caret: focus follows the click, never the mount. A recovered draft
237
+ * reopens it, otherwise the text would be held but invisible.
238
+ */
239
+ const [composerOpen, setComposerOpen] = useState(
240
+ () =>
241
+ openComposer > 0 ||
242
+ !!readDraft(appSlug) ||
243
+ readAttachments(appSlug).length > 0,
244
+ )
245
+
246
+ // The band asked for the composer while this panel was unmounted, so honour it on
247
+ // arrival. The request is NOT cleared from here: doing so removed the tab from the
248
+ // strip (it only exists when there is something in it), which bounced the active
249
+ // tab back to the first one — the composer was deleting the tab it had opened.
250
+ useEffect(() => {
251
+ if (openComposer > 0) setComposerOpen(true)
252
+ }, [openComposer])
253
+
254
+ const updateDraft = (value: string) => {
255
+ setDraft(value)
256
+ writeDraft(appSlug, value)
257
+ }
258
+
259
+ const updateImages = (next: PendingImage[]) => {
260
+ setImages(next)
261
+ writeAttachments(appSlug, next)
262
+ }
263
+
264
+ /**
265
+ * Upload on pick, not on send.
266
+ *
267
+ * It costs a round trip before anyone has committed to posting, and it buys the two
268
+ * things that matter: the person sees whether their screenshot actually arrived
269
+ * while they can still do something about it, and Send stays a single fast request
270
+ * that cannot half-fail with three images in flight.
271
+ */
272
+ const attach = async (picked: FileList | null) => {
273
+ const files = [...(picked ?? [])]
274
+ if (files.length === 0) return
275
+
276
+ const room = MAX_ATTACHMENTS - images.length
277
+ if (room <= 0) {
278
+ setUploadError(`Up to ${MAX_ATTACHMENTS} images`)
279
+ return
280
+ }
281
+
282
+ setUploadError(null)
283
+ setUploading(true)
284
+ try {
285
+ const form = new FormData()
286
+ for (const file of files.slice(0, room)) form.append('image', file)
287
+
288
+ const response = await fetch(UPLOAD_URL, {
289
+ method: 'POST',
290
+ body: form,
291
+ // The visitor cookie is the identity the upload is recorded against, and it
292
+ // is httpOnly — so it has to ride along explicitly.
293
+ credentials: 'same-origin',
294
+ })
295
+ // Unknown rather than a declared shape: this is a JSON body off the wire, and
296
+ // the error path exists precisely for responses that are not what we expect.
297
+ const payload: unknown = await response.json().catch(() => null)
298
+ const field = (key: string): unknown =>
299
+ typeof payload === 'object' && payload !== null
300
+ ? (payload as Record<string, unknown>)[key]
301
+ : undefined
302
+
303
+ if (!response.ok) {
304
+ const reported = field('error')
305
+ setUploadError(
306
+ typeof reported === 'string'
307
+ ? reported
308
+ : 'Could not attach that image',
309
+ )
310
+ return
311
+ }
312
+
313
+ const ids = field('ids')
314
+ if (!Array.isArray(ids)) {
315
+ setUploadError('Could not attach that image')
316
+ return
317
+ }
318
+ updateImages([
319
+ ...images,
320
+ ...ids.map((id, index) => ({
321
+ id: String(id),
322
+ name: files[index]?.name ?? 'image',
323
+ })),
324
+ ])
325
+ if (files.length > room) {
326
+ setUploadError(`Only the first ${room} were attached`)
327
+ }
328
+ } catch {
329
+ setUploadError('Could not attach that image')
330
+ } finally {
331
+ setUploading(false)
332
+ }
333
+ }
334
+
335
+ const refresh = () =>
336
+ queryClient.invalidateQueries({ queryKey: listOptions.queryKey })
337
+
338
+ const add = useMutation(
339
+ trpc.feedback.add.mutationOptions({
340
+ onSuccess: () => {
341
+ // Sent: the draft is no longer in flight, so it should not come back.
342
+ updateDraft('')
343
+ updateImages([])
344
+ setUploadError(null)
345
+ setComposerOpen(false)
346
+ void refresh()
347
+ },
348
+ }),
349
+ )
350
+ const edit = useMutation(
351
+ trpc.feedback.edit.mutationOptions({
352
+ onSuccess: () => {
353
+ setEditingId(null)
354
+ void refresh()
355
+ },
356
+ }),
357
+ )
358
+ const dismiss = useMutation(
359
+ trpc.feedback.dismiss.mutationOptions({ onSuccess: () => void refresh() }),
360
+ )
361
+
362
+ /** Every field is optional, so this submits whatever there is — including nothing. */
363
+ const send = () =>
364
+ add.mutate({
365
+ resourceSlug: appSlug,
366
+ body: draft.trim(),
367
+ ...(images.length > 0
368
+ ? { attachmentIds: images.map((image) => image.id) }
369
+ : {}),
370
+ })
371
+
372
+ const saveEdit = (id: string) => {
373
+ const body = editDraft.trim()
374
+ if (body) edit.mutate({ id, body })
375
+ }
376
+
377
+ // One line for every failed action: three mutations and one query, one place to look.
378
+ const failure = error ?? add.error ?? edit.error ?? dismiss.error
379
+ const items = data?.items ?? []
380
+ const openCount = data?.openCount ?? 0
381
+
382
+ return (
383
+ <div className="mt-7">
384
+ <h3 className="section-label mb-1.5 flex items-center gap-2">
385
+ Notes &amp; requests
386
+ {items.length > 0 && (
387
+ <span className="text-muted-foreground font-normal tracking-normal normal-case">
388
+ ({items.length})
389
+ </span>
390
+ )}
391
+ {openCount > 0 && (
392
+ <span
393
+ className="feedback-open-count"
394
+ aria-label={`${openCount} awaiting review`}
395
+ >
396
+ {openCount} open
397
+ </span>
398
+ )}
399
+ </h3>
400
+
401
+ {isLoading ? (
402
+ <p className="text-muted-foreground text-xs">Loading…</p>
403
+ ) : items.length > 0 ? (
404
+ <ul className="space-y-3">
405
+ {items.map((item) => {
406
+ const canEdit =
407
+ item.isMine &&
408
+ !!item.canEditUntil &&
409
+ Date.parse(item.canEditUntil) > Date.now()
410
+ // Withdrawing stays available for as long as nobody has reviewed it.
411
+ const canDismiss = item.isMine && item.status === null
412
+
413
+ return (
414
+ <li key={item.id} className="text-xs">
415
+ <div className="text-muted-foreground flex items-center gap-2">
416
+ <span className="text-foreground font-medium">
417
+ {item.authorName}
418
+ </span>
419
+ <span>{formatRelativeTime(item.createdAt)}</span>
420
+ {item.editedAt && <span>(edited)</span>}
421
+ {item.status && <StatusChip status={item.status} />}
422
+ {canEdit && editingId !== item.id && (
423
+ <button
424
+ type="button"
425
+ className="hover:text-foreground underline"
426
+ onClick={() => {
427
+ setEditingId(item.id)
428
+ setEditDraft(item.body ?? '')
429
+ }}
430
+ >
431
+ Edit
432
+ </button>
433
+ )}
434
+ {canDismiss && editingId !== item.id && (
435
+ <button
436
+ type="button"
437
+ className="hover:text-destructive underline"
438
+ onClick={() => dismiss.mutate({ id: item.id })}
439
+ >
440
+ Dismiss
441
+ </button>
442
+ )}
443
+ </div>
444
+
445
+ {editingId === item.id ? (
446
+ <div className="mt-1 space-y-2">
447
+ <textarea
448
+ autoFocus
449
+ rows={3}
450
+ className={cn(FIELD_CLASSES)}
451
+ value={editDraft}
452
+ onChange={(event) => setEditDraft(event.target.value)}
453
+ onKeyDown={(event) => {
454
+ if (isSubmitChord(event)) {
455
+ event.preventDefault()
456
+ saveEdit(item.id)
457
+ }
458
+ }}
459
+ />
460
+ <div className="flex gap-2">
461
+ <Button
462
+ type="button"
463
+ size="sm"
464
+ disabled={!editDraft.trim() || edit.isPending}
465
+ onClick={() => saveEdit(item.id)}
466
+ >
467
+ Save
468
+ </Button>
469
+ <Button
470
+ type="button"
471
+ size="sm"
472
+ variant="ghost"
473
+ onClick={() => setEditingId(null)}
474
+ >
475
+ Cancel
476
+ </Button>
477
+ </div>
478
+ </div>
479
+ ) : item.body ? (
480
+ <p
481
+ data-feedback-body=""
482
+ className="mt-0.5 whitespace-pre-wrap"
483
+ >
484
+ {item.body}
485
+ </p>
486
+ ) : item.attachmentIds.length > 0 ? (
487
+ // The screenshot IS the detail — saying "no detail given" over a
488
+ // picture of the problem would read as the catalog ignoring it.
489
+ <p
490
+ data-feedback-body=""
491
+ className="text-muted-foreground mt-0.5 italic"
492
+ >
493
+ Screenshot only — no words added.
494
+ </p>
495
+ ) : (
496
+ // A bare flag: no words, still a signal. Say so rather than
497
+ // rendering an empty row someone has to interpret.
498
+ <p
499
+ data-feedback-body=""
500
+ className="text-muted-foreground mt-0.5 italic"
501
+ >
502
+ Flagged as out of date — no detail given.
503
+ </p>
504
+ )}
505
+
506
+ <AttachmentStrip ids={item.attachmentIds} />
507
+
508
+ {item.reviewerReply && (
509
+ <ReviewerReply reply={item.reviewerReply} />
510
+ )}
511
+ </li>
512
+ )
513
+ })}
514
+ </ul>
515
+ ) : (
516
+ <p className="text-muted-foreground text-xs">Nothing here yet.</p>
517
+ )}
518
+
519
+ {composerOpen ? (
520
+ <div className="animate-in fade-in slide-in-from-top-2 mt-3 space-y-2 duration-200">
521
+ <textarea
522
+ autoFocus
523
+ rows={3}
524
+ className={cn(FIELD_CLASSES)}
525
+ placeholder="What should change?"
526
+ value={draft}
527
+ onChange={(event) => updateDraft(event.target.value)}
528
+ onKeyDown={(event) => {
529
+ if (isSubmitChord(event)) {
530
+ event.preventDefault()
531
+ send()
532
+ }
533
+ }}
534
+ />
535
+ {images.length > 0 && (
536
+ <ul className="flex flex-wrap gap-2">
537
+ {images.map((image) => (
538
+ <li key={image.id} className="relative">
539
+ <img
540
+ src={attachmentUrl(image.id)}
541
+ alt={image.name}
542
+ className="border-border h-16 w-24 rounded border object-cover"
543
+ />
544
+ <button
545
+ type="button"
546
+ aria-label={`Remove ${image.name}`}
547
+ onClick={() =>
548
+ updateImages(images.filter((i) => i.id !== image.id))
549
+ }
550
+ 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"
551
+ >
552
+ <X className="size-3" />
553
+ </button>
554
+ </li>
555
+ ))}
556
+ </ul>
557
+ )}
558
+
559
+ <div className="flex flex-wrap items-center gap-3">
560
+ <Button
561
+ type="button"
562
+ size="sm"
563
+ disabled={add.isPending || uploading}
564
+ onClick={send}
565
+ >
566
+ {add.isPending ? 'Sending…' : 'Send'}
567
+ </Button>
568
+
569
+ {images.length < MAX_ATTACHMENTS && (
570
+ <label className="text-muted-foreground hover:text-foreground cursor-pointer text-xs underline decoration-dotted underline-offset-2">
571
+ <input
572
+ type="file"
573
+ accept="image/*"
574
+ multiple
575
+ className="sr-only"
576
+ disabled={uploading}
577
+ onChange={(event) => {
578
+ void attach(event.target.files)
579
+ // Cleared so picking the SAME file again still fires a change.
580
+ event.target.value = ''
581
+ }}
582
+ />
583
+ {uploading ? 'Attaching…' : 'Attach a screenshot'}
584
+ </label>
585
+ )}
586
+
587
+ <span className="text-muted-foreground text-xs">
588
+ Posted under a nickname. You can edit it for an hour, or withdraw
589
+ it until someone answers.
590
+ </span>
591
+ </div>
592
+
593
+ {uploadError && (
594
+ <p className="text-destructive text-xs">{uploadError}</p>
595
+ )}
596
+ </div>
597
+ ) : null}
598
+
599
+ {/* Outside the composer: a failed list query leaves nothing to collapse into. */}
600
+ {failure && (
601
+ <p className="text-destructive mt-2 text-xs">{errorMessage(failure)}</p>
602
+ )}
603
+ </div>
604
+ )
605
+ }
@@ -179,7 +179,26 @@ export function AppCatalogPage({
179
179
  <AppDetailPanel
180
180
  app={selectedApp}
181
181
  subResource={selectedSub}
182
- onClose={() => void navigate({ to: '/' })}
182
+ onClose={() =>
183
+ void navigate({
184
+ to: '/',
185
+ /**
186
+ * Drop the card's own params, keep the catalog's.
187
+ *
188
+ * `navigate({ to: '/' })` alone preserves the whole search object, so
189
+ * closing a card left `?tab=notes` (and `?sub=`, `?qj=`) behind on the
190
+ * catalog, where they mean nothing — and a link copied afterwards
191
+ * carried them. Clearing the lot instead would throw away the
192
+ * visitor's filters, which belong to the catalog and outlive any card.
193
+ */
194
+ search: (prev) => ({
195
+ ...prev,
196
+ tab: undefined,
197
+ sub: undefined,
198
+ qj: undefined,
199
+ }),
200
+ })
201
+ }
183
202
  onAppClick={handleAppClick}
184
203
  onBackToParent={handleBackToParent}
185
204
  />