@igstack/app-catalog-frontend-core 6.9.0 → 6.10.1

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 (80) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.d.ts +0 -0
  4. package/dist/esm/__tests__/modules/appCatalog/ui/detail/composeComments.test.d.ts +1 -0
  5. package/dist/esm/__tests__/ui/detail/CommunityInput.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +1 -0
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +237 -218
  10. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +17 -0
  12. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +187 -0
  13. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +9 -0
  15. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +91 -0
  16. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -0
  17. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +17 -0
  18. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +328 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +15 -0
  21. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +26 -0
  22. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -0
  23. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +8 -19
  24. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +49 -37
  25. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  26. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +30 -0
  27. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +50 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/communityError.d.ts +6 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/communityError.js +8 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/communityError.js.map +1 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/composeComments.d.ts +27 -0
  33. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +66 -0
  34. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -0
  35. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.d.ts +11 -0
  36. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js +15 -0
  37. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js.map +1 -0
  38. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +38 -0
  39. package/dist/esm/modules/extensions/features/resourceDetail.js +4 -0
  40. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
  41. package/dist/esm/modules/extensions/index.d.ts +1 -1
  42. package/dist/esm/modules/extensions/index.js.map +1 -1
  43. package/dist/index.css +501 -49
  44. package/package.json +4 -4
  45. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +5 -2
  46. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +5 -2
  47. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +9 -10
  48. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +402 -0
  49. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +8 -4
  50. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +5 -2
  51. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +175 -0
  52. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +5 -2
  53. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +5 -2
  54. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +5 -2
  55. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +281 -0
  56. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +179 -0
  57. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +423 -0
  58. package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +170 -0
  59. package/src/__tests__/ui/detail/CommunityInput.test.tsx +39 -0
  60. package/src/index.css +501 -49
  61. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +292 -289
  62. package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +254 -0
  63. package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +102 -0
  64. package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +419 -0
  65. package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +41 -0
  66. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +77 -71
  67. package/src/modules/appCatalog/ui/detail/communityDraft.ts +80 -0
  68. package/src/modules/appCatalog/ui/detail/communityError.ts +10 -0
  69. package/src/modules/appCatalog/ui/detail/composeComments.ts +103 -0
  70. package/src/modules/appCatalog/ui/detail/usePendingCount.ts +26 -0
  71. package/src/modules/extensions/features/resourceDetail.ts +28 -0
  72. package/src/modules/extensions/index.ts +2 -0
  73. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +0 -13
  74. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +0 -433
  75. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +0 -1
  76. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +0 -181
  77. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +0 -131
  78. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +0 -605
  79. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.emptyState.test.d.ts → AppDetails.behindThisCard.test.d.ts} +0 -0
  80. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.reviewStatus.test.d.ts → AppDetails.knowMore.test.d.ts} +0 -0
@@ -0,0 +1,419 @@
1
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
2
+ import { Paperclip, X } from 'lucide-react'
3
+ import React from 'react'
4
+ import { useTRPC } from '~/api/infra/trpc'
5
+ import { cn } from '~/lib/utils'
6
+ import { Button } from '~/ui/button'
7
+ import {
8
+ attachmentUrl,
9
+ readAttachments,
10
+ readDraft,
11
+ writeAttachments,
12
+ writeDraft,
13
+ } from './communityDraft'
14
+ import type { PendingImage } from './communityDraft'
15
+ import { formatCommunityError } from './communityError'
16
+
17
+ const MAX_HEIGHT = 200
18
+
19
+ /** How long the Share highlight stays on the field, ms. */
20
+ const FLASH_MS = 900
21
+
22
+ /** Mirrors the server's cap, which refuses anything above it regardless. */
23
+ const MAX_ATTACHMENTS = 3
24
+
25
+ const UPLOAD_URL = '/api/feedback-attachments/upload'
26
+
27
+ /** What the card holds on to, so "Share what you know" can point at the field. */
28
+ export interface CommunityInputHandle {
29
+ /** Focuses the field and highlights it briefly. Re-triggers on every call. */
30
+ focus: () => void
31
+ }
32
+
33
+ const isImage = (file: File) => file.type.startsWith('image/')
34
+
35
+ /**
36
+ * Upload on pick, not on send: the person sees whether their screenshot
37
+ * arrived while they can still do something about it, and Send stays one fast
38
+ * request that cannot half-fail with images in flight.
39
+ */
40
+ async function upload(files: File[]): Promise<string[] | string> {
41
+ const form = new FormData()
42
+ for (const file of files) form.append('image', file)
43
+ try {
44
+ const response = await fetch(UPLOAD_URL, {
45
+ method: 'POST',
46
+ body: form,
47
+ // The visitor cookie is the identity the upload is recorded against, and
48
+ // it is httpOnly — so it has to ride along explicitly.
49
+ credentials: 'same-origin',
50
+ })
51
+ const payload: unknown = await response.json().catch(() => null)
52
+ const field = (key: string): unknown =>
53
+ typeof payload === 'object' && payload !== null
54
+ ? (payload as Record<string, unknown>)[key]
55
+ : undefined
56
+ const ids = field('ids')
57
+ if (response.ok && Array.isArray(ids)) return ids.map(String)
58
+ const reported = field('error')
59
+ return typeof reported === 'string'
60
+ ? reported
61
+ : 'Could not attach that image'
62
+ } catch {
63
+ return 'Could not attach that image'
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The card's one ask for more information, at the top of Behind this card:
69
+ * one line with Send beside it, grey until there is something to send. The
70
+ * field grows as the text wraps. Screenshots come in through the
71
+ * paperclip, paste or drop, and a screenshot alone is a valid note.
72
+ */
73
+ export function CommunityInput({
74
+ appSlug,
75
+ appName,
76
+ ref,
77
+ }: {
78
+ appSlug: string
79
+ appName: string
80
+ ref?: React.Ref<CommunityInputHandle>
81
+ }) {
82
+ const trpc = useTRPC()
83
+ const queryClient = useQueryClient()
84
+ // Seeded from storage so a draft survives the tab panel unmounting under it.
85
+ const [text, setText] = React.useState(() => readDraft(appSlug))
86
+ const [images, setImages] = React.useState<PendingImage[]>(() =>
87
+ readAttachments(appSlug),
88
+ )
89
+ const [sent, setSent] = React.useState(false)
90
+ // Counts successful sends, so a click on Send hands focus back every time.
91
+ const [sends, setSends] = React.useState(0)
92
+ const [uploading, setUploading] = React.useState(false)
93
+ const [uploadError, setUploadError] = React.useState<string | null>(null)
94
+ const [dragging, setDragging] = React.useState(false)
95
+ // The guard events read, set synchronously: `uploading` only updates on the
96
+ // next render, so a second paste or drop in the same tick would slip past it.
97
+ const inFlightRef = React.useRef(false)
98
+ const dragDepthRef = React.useRef(0)
99
+ // Cleared draft stays cleared: Esc increments this; the upload checks it
100
+ // on completion so an in-flight upload does not re-add its image.
101
+ const draftGenRef = React.useRef(0)
102
+ const [height, setHeight] = React.useState(40)
103
+ const fieldRef = React.useRef<HTMLTextAreaElement>(null)
104
+ // A hidden twin measures the height the text needs, so the real field can
105
+ // animate to it. Setting `height: auto` on the real one snaps instead.
106
+ const mirrorRef = React.useRef<HTMLTextAreaElement>(null)
107
+ const fileRef = React.useRef<HTMLInputElement>(null)
108
+ const formRef = React.useRef<HTMLFormElement>(null)
109
+ const flashTimerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined)
110
+
111
+ // Detect coarse pointer (touch) to adjust placeholder and hide keyboard hints.
112
+ const [isCoarse, setIsCoarse] = React.useState(() => {
113
+ if (typeof window === 'undefined') return false
114
+ return window.matchMedia('(pointer: coarse)').matches
115
+ })
116
+ React.useEffect(() => {
117
+ if (typeof window === 'undefined') return
118
+ const mq = window.matchMedia('(pointer: coarse)')
119
+ const handler = () => setIsCoarse(mq.matches)
120
+ mq.addEventListener('change', handler)
121
+ return () => mq.removeEventListener('change', handler)
122
+ }, [])
123
+
124
+ React.useEffect(() => writeDraft(appSlug, text), [appSlug, text])
125
+ React.useEffect(() => writeAttachments(appSlug, images), [appSlug, images])
126
+
127
+ // The class goes on the form, whose className React never rewrites, and
128
+ // is removed and re-added with a reflow between so a second call restarts
129
+ // the highlight instead of merely extending it.
130
+ React.useImperativeHandle(ref, () => ({
131
+ focus: () => {
132
+ fieldRef.current?.focus()
133
+ const form = formRef.current
134
+ if (!form) return
135
+ form.classList.remove('is-flash')
136
+ void form.offsetWidth
137
+ form.classList.add('is-flash')
138
+ clearTimeout(flashTimerRef.current)
139
+ flashTimerRef.current = setTimeout(
140
+ () => form.classList.remove('is-flash'),
141
+ FLASH_MS,
142
+ )
143
+ },
144
+ }))
145
+ React.useEffect(() => () => clearTimeout(flashTimerRef.current), [])
146
+
147
+ const add = useMutation(
148
+ trpc.feedback.add.mutationOptions({
149
+ onSuccess: () => {
150
+ setText('')
151
+ setImages([])
152
+ setUploadError(null)
153
+ setSent(true)
154
+ setSends((n) => n + 1)
155
+ void queryClient.invalidateQueries({
156
+ queryKey: trpc.feedback.list.queryKey({ resourceSlug: appSlug }),
157
+ })
158
+ },
159
+ }),
160
+ )
161
+
162
+ // Focus stays in the field so the next Tab doesn't walk the grid behind the scrim.
163
+ React.useEffect(() => {
164
+ if (sends) fieldRef.current?.focus()
165
+ }, [sends])
166
+
167
+ const typing = text.trim() !== ''
168
+ const canSend = typing || images.length > 0
169
+
170
+ const fit = React.useCallback(() => {
171
+ const field = fieldRef.current
172
+ const mirror = mirrorRef.current
173
+ if (!field || !mirror) return
174
+ mirror.style.width = `${field.offsetWidth}px`
175
+ mirror.value = text || ' '
176
+ // Compute ONE_LINE from the actual field styles (varies by viewport width).
177
+ const styles = window.getComputedStyle(field)
178
+ const lineHeight = parseFloat(styles.lineHeight)
179
+ const paddingTop = parseFloat(styles.paddingTop)
180
+ const paddingBottom = parseFloat(styles.paddingBottom)
181
+ const borderTop = parseFloat(styles.borderTopWidth)
182
+ const borderBottom = parseFloat(styles.borderBottomWidth)
183
+ const oneLine =
184
+ lineHeight + paddingTop + paddingBottom + borderTop + borderBottom
185
+ // +2: scrollHeight excludes the border, the box height includes it.
186
+ setHeight(Math.min(Math.max(mirror.scrollHeight + 2, oneLine), MAX_HEIGHT))
187
+ }, [text])
188
+ React.useLayoutEffect(fit, [fit])
189
+ React.useEffect(() => {
190
+ const field = fieldRef.current
191
+ if (!field || typeof ResizeObserver === 'undefined') return
192
+ const observer = new ResizeObserver(fit)
193
+ observer.observe(field)
194
+ return () => observer.disconnect()
195
+ }, [fit])
196
+
197
+ /**
198
+ * One upload at a time: a paste or drop while one is in flight is ignored,
199
+ * and the "Attaching…" line says why. The updater clamps as well, so the
200
+ * draft can never hold more than the server accepts on send.
201
+ */
202
+ const attach = async (picked: File[]) => {
203
+ const files = picked.filter(isImage)
204
+ if (files.length === 0 || inFlightRef.current) return
205
+ const room = MAX_ATTACHMENTS - images.length
206
+ if (room <= 0) {
207
+ setUploadError(`Up to ${MAX_ATTACHMENTS} images`)
208
+ return
209
+ }
210
+ setUploadError(null)
211
+ inFlightRef.current = true
212
+ setUploading(true)
213
+ const startGen = draftGenRef.current
214
+ const result = await upload(files.slice(0, room))
215
+ inFlightRef.current = false
216
+ setUploading(false)
217
+ // Draft was cleared (Esc) while upload was in flight: stay cleared.
218
+ if (draftGenRef.current !== startGen) return
219
+ if (typeof result === 'string') {
220
+ setUploadError(result)
221
+ return
222
+ }
223
+ const newImages = result.map((id, i) => ({
224
+ id,
225
+ name: files[i]?.name ?? 'image',
226
+ }))
227
+ // Merge into both state and persisted draft so unmount restores it.
228
+ setImages((current) => {
229
+ const updated = [...current, ...newImages].slice(0, MAX_ATTACHMENTS)
230
+ writeAttachments(appSlug, updated)
231
+ return updated
232
+ })
233
+ if (files.length > room)
234
+ setUploadError(`Only the first ${room} were attached`)
235
+ }
236
+
237
+ /** Only a drag carrying files is ours; dragged text still drops into the field. */
238
+ const carriesFiles = (e: React.DragEvent) =>
239
+ [...e.dataTransfer.types].includes('Files')
240
+
241
+ const submit = () => {
242
+ if (!canSend || add.isPending || uploading) return
243
+ const body = text.trim()
244
+ add.mutate({
245
+ resourceSlug: appSlug,
246
+ ...(body ? { body } : {}),
247
+ ...(images.length > 0 ? { attachmentIds: images.map((i) => i.id) } : {}),
248
+ })
249
+ }
250
+
251
+ return (
252
+ <form
253
+ ref={formRef}
254
+ // State rides on data attributes: the className stays static so the
255
+ // imperative `is-flash` below survives a re-render.
256
+ className="know-more"
257
+ data-drop={dragging || undefined}
258
+ onSubmit={(e) => {
259
+ e.preventDefault()
260
+ submit()
261
+ }}
262
+ onDragEnter={(e) => {
263
+ if (!carriesFiles(e)) return
264
+ dragDepthRef.current += 1
265
+ setDragging(true)
266
+ }}
267
+ onDragOver={(e) => {
268
+ // Accepting the drop is what lets it fire.
269
+ if (carriesFiles(e)) e.preventDefault()
270
+ }}
271
+ onDragLeave={(e) => {
272
+ if (!carriesFiles(e)) return
273
+ // Every child the drag crosses fires its own enter and leave, so only
274
+ // the last leave means the drag left the form.
275
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
276
+ if (dragDepthRef.current === 0) setDragging(false)
277
+ }}
278
+ onDrop={(e) => {
279
+ if (!carriesFiles(e)) return
280
+ e.preventDefault()
281
+ dragDepthRef.current = 0
282
+ setDragging(false)
283
+ void attach([...e.dataTransfer.files])
284
+ }}
285
+ >
286
+ {images.length > 0 && (
287
+ <ul className="know-more-thumbs">
288
+ {images.map((image) => (
289
+ <li key={image.id} className="know-more-thumb">
290
+ <img src={attachmentUrl(image.id)} alt={image.name} />
291
+ <button
292
+ type="button"
293
+ aria-label={`Remove ${image.name}`}
294
+ onClick={() =>
295
+ setImages((current) =>
296
+ current.filter((i) => i.id !== image.id),
297
+ )
298
+ }
299
+ >
300
+ <X className="size-2.5" />
301
+ </button>
302
+ </li>
303
+ ))}
304
+ </ul>
305
+ )}
306
+ <div className="know-more-field">
307
+ <button
308
+ type="button"
309
+ className="know-more-clip"
310
+ aria-label="Attach a screenshot"
311
+ title="Attach a screenshot"
312
+ aria-busy={uploading || undefined}
313
+ disabled={uploading || images.length >= MAX_ATTACHMENTS}
314
+ onClick={() => fileRef.current?.click()}
315
+ >
316
+ <Paperclip className="size-4" />
317
+ </button>
318
+ <input
319
+ ref={fileRef}
320
+ type="file"
321
+ accept="image/*"
322
+ multiple
323
+ tabIndex={-1}
324
+ aria-hidden
325
+ className="sr-only"
326
+ onChange={(e) => {
327
+ void attach([...(e.target.files ?? [])])
328
+ // Cleared so picking the SAME file again still fires a change.
329
+ e.target.value = ''
330
+ }}
331
+ />
332
+ <textarea
333
+ ref={fieldRef}
334
+ rows={1}
335
+ value={text}
336
+ aria-label={`Know more about ${appName}?`}
337
+ placeholder={
338
+ isCoarse
339
+ ? 'Add a fact, a fix or a question'
340
+ : 'Add a fact, a fix or a question — press Enter'
341
+ }
342
+ style={{
343
+ height,
344
+ overflowY: height >= MAX_HEIGHT ? 'auto' : 'hidden',
345
+ }}
346
+ onChange={(e) => {
347
+ setText(e.target.value)
348
+ setSent(false)
349
+ if (add.isError) add.reset()
350
+ }}
351
+ onPaste={(e) => {
352
+ const files = [...e.clipboardData.files].filter(isImage)
353
+ if (files.length === 0) return
354
+ e.preventDefault()
355
+ void attach(files)
356
+ }}
357
+ onKeyDown={(e) => {
358
+ if (
359
+ e.key === 'Enter' &&
360
+ !e.shiftKey &&
361
+ !e.nativeEvent.isComposing
362
+ ) {
363
+ e.preventDefault()
364
+ submit()
365
+ }
366
+ // Esc clears a draft, and only a draft: with nothing typed or
367
+ // attached it must reach the card's layer so the card still closes.
368
+ if (
369
+ e.key === 'Escape' &&
370
+ !e.nativeEvent.isComposing &&
371
+ (text !== '' || images.length > 0)
372
+ ) {
373
+ e.preventDefault()
374
+ e.stopPropagation()
375
+ draftGenRef.current += 1
376
+ setText('')
377
+ setImages([])
378
+ }
379
+ }}
380
+ />
381
+ <Button
382
+ type="submit"
383
+ size="sm"
384
+ className="know-more-send"
385
+ disabled={!canSend || add.isPending || uploading}
386
+ >
387
+ Send
388
+ </Button>
389
+ <textarea
390
+ ref={mirrorRef}
391
+ rows={1}
392
+ readOnly
393
+ tabIndex={-1}
394
+ aria-hidden
395
+ className="know-more-mirror"
396
+ />
397
+ </div>
398
+ {!isCoarse && (
399
+ <p className={cn('know-more-hint', typing && 'is-on')}>
400
+ Enter sends · Shift+Enter new line · Esc clears · paste or drop a
401
+ screenshot
402
+ </p>
403
+ )}
404
+ <p className="know-more-attaching" aria-live="polite">
405
+ {uploading ? 'Attaching…' : ''}
406
+ </p>
407
+ {(add.isError || uploadError) && (
408
+ <p role="alert" className="know-more-error">
409
+ {add.isError
410
+ ? `Could not send: ${formatCommunityError(add.error)}. Your note is still here.`
411
+ : uploadError}
412
+ </p>
413
+ )}
414
+ <p role="status" className={cn('know-more-sent', sent && 'is-on')}>
415
+ {sent ? 'Thanks — sent. It shows below.' : ''}
416
+ </p>
417
+ </form>
418
+ )
419
+ }
@@ -0,0 +1,41 @@
1
+ import type { Claim } from '@igstack/app-catalog-backend-core'
2
+ import React from 'react'
3
+ import { CommentsFeed } from './CommentsFeed'
4
+ import { CommentsHelp } from './CommentsHelp'
5
+ import { CommunityInput } from './CommunityInput'
6
+ import type { CommunityInputHandle } from './CommunityInput'
7
+
8
+ export type CommunitySectionHandle = CommunityInputHandle
9
+
10
+ const NO_CLAIMS: Claim[] = []
11
+
12
+ /**
13
+ * The top of Behind this card: one input that takes words and screenshots,
14
+ * then "Comments" with its help, then everything already said about the entry
15
+ * as one feed.
16
+ */
17
+ export function CommunitySection({
18
+ appSlug,
19
+ appName,
20
+ claims = NO_CLAIMS,
21
+ ref,
22
+ }: {
23
+ appSlug: string
24
+ appName: string
25
+ claims?: Claim[]
26
+ ref?: React.Ref<CommunitySectionHandle>
27
+ }) {
28
+ const headingId = React.useId()
29
+ return (
30
+ <section className="behind-group">
31
+ <CommunityInput ref={ref} appSlug={appSlug} appName={appName} />
32
+ <div className="comments-head">
33
+ <h2 id={headingId} className="behind-group__title">
34
+ Comments
35
+ </h2>
36
+ <CommentsHelp appSlug={appSlug} appName={appName} />
37
+ </div>
38
+ <CommentsFeed appSlug={appSlug} claims={claims} labelledBy={headingId} />
39
+ </section>
40
+ )
41
+ }
@@ -36,32 +36,13 @@ export interface DetailTab {
36
36
  export function DetailTabs({
37
37
  tabs,
38
38
  forcedTab,
39
- requestTab,
40
- requestNonce,
41
39
  band,
42
40
  bandClassName,
41
+ trailing,
43
42
  }: {
44
43
  tabs: DetailTab[]
45
44
  /** Overrides `?tab=` while set. */
46
45
  forcedTab?: string
47
- /**
48
- * A one-shot "please show this tab" from outside the strip — the band's
49
- * "Suggest a change" uses it.
50
- *
51
- * It has to come through here rather than the caller writing `?tab=` itself:
52
- * `useUrlSyncedState` keeps its own `useState` seeded once from the URL and only
53
- * ever pushes state OUT to the URL, so a second instance writing the same key
54
- * does not update this one. The two then fight — the strip's next render sees the
55
- * URL disagreeing with its own state and navigates back over it.
56
- */
57
- requestTab?: string
58
- /**
59
- * Bumped by the caller every time it asks again. Without it a repeat request is
60
- * indistinguishable from the same standing value, so the effect below does not
61
- * re-fire and the second ask does nothing — which is what made the band's
62
- * button work exactly once per card.
63
- */
64
- requestNonce?: number
65
46
  /**
66
47
  * Identity and actions, shown ABOVE the strip and inside the same tinted
67
48
  * surface. Owned here rather than by the caller because the band and the
@@ -72,6 +53,13 @@ export function DetailTabs({
72
53
  band?: React.ReactNode
73
54
  /** Cancels the dialog's own padding so the tint reaches the card's edges. */
74
55
  bandClassName?: string
56
+ /**
57
+ * Shown at the right end of the strip, outside the tablist so it is not
58
+ * announced as a tab. Handed `select` because the strip owns the active tab:
59
+ * a caller writing `?tab=` itself would fight `useUrlSyncedState`, which keeps
60
+ * its own state and only pushes it out to the url.
61
+ */
62
+ trailing?: (select: (id: string) => void) => React.ReactNode
75
63
  }) {
76
64
  /**
77
65
  * The first tab is what the card opens on, so naming it in the URL says nothing.
@@ -93,29 +81,29 @@ export function DetailTabs({
93
81
  * Renaming one would silently drop those onto the first tab, so a retired id
94
82
  * keeps resolving to its replacement instead.
95
83
  */
96
- const RETIRED: Record<string, string> = { metadata: 'documentation' }
84
+ const RETIRED: Record<string, string> = {
85
+ metadata: 'documentation',
86
+ notes: 'behind',
87
+ }
88
+
89
+ /**
90
+ * Where a link to a tab the entry does not have lands instead. A thin entry
91
+ * has no Documentation tab, and links to it were mostly about sources and
92
+ * dates, which Behind this card holds.
93
+ */
94
+ const ABSENT: Record<string, string> = {
95
+ documentation: 'behind',
96
+ }
97
97
 
98
98
  /**
99
99
  * `forcedTab` picks the tab you ARRIVE on, not the tab you are stuck on.
100
100
  *
101
- * It used to win over `?tab=` for as long as it was set, and `?sub=` keeps it set
102
- * for the whole life of the card — so on any entry reached by a sub-resource
103
- * search (667 accounts on one of them) the strip was dead: every click wrote
104
- * nothing and the tab never moved. The same override also silently killed the
105
- * band's "Suggest a change", which navigates by writing `?tab=`.
106
- *
107
- * So it applies until the user expresses a preference, and then gets out of the
108
- * way. `userChose` is what distinguishes "no opinion yet" from "chose the tab
109
- * that happens to be the forced one".
101
+ * `?sub=` stays set for the card's whole life, so forcing it permanently would
102
+ * make the strip unclickable. It applies until the user expresses a preference,
103
+ * and then gets out of the way. `userChose` is what distinguishes "no opinion
104
+ * yet" from "chose the tab that happens to be the forced one".
110
105
  */
111
106
  const [userChose, setUserChose] = React.useState(false)
112
-
113
- // Applied when it changes, through the state that actually owns the tab.
114
- React.useEffect(() => {
115
- if (!requestTab) return
116
- setUserChose(true)
117
- setUrlTab(requestTab)
118
- }, [requestTab, requestNonce, setUrlTab])
119
107
  /**
120
108
  * Releasing the force must not move the view.
121
109
  *
@@ -137,10 +125,27 @@ export function DetailTabs({
137
125
  }
138
126
 
139
127
  const asked = userChose || !forcedTab ? urlTab : forcedTab
140
- const requested = RETIRED[asked] ?? asked
128
+ const retired = RETIRED[asked] ?? asked
129
+ const requested = ids.includes(retired)
130
+ ? retired
131
+ : (ABSENT[retired] ?? retired)
141
132
  const activeId = ids.includes(requested) ? requested : (ids[0] ?? '')
142
133
  const active = tabs.find((t) => t.id === activeId)
143
134
 
135
+ const select = (id: string) => {
136
+ setUserChose(true)
137
+ setUrlTab(id)
138
+ // Scroll the active tab into view on narrow screens where tabs overflow.
139
+ requestAnimationFrame(() => {
140
+ const tabElement = document.getElementById(`detail-tab-${id}`)
141
+ tabElement?.scrollIntoView({
142
+ behavior: 'smooth',
143
+ block: 'nearest',
144
+ inline: 'center',
145
+ })
146
+ })
147
+ }
148
+
144
149
  // Arrow keys move between tabs, per the tablist pattern. Selection follows
145
150
  // focus, which is correct here because every panel is already loaded.
146
151
  const onKeyDown = (event: React.KeyboardEvent) => {
@@ -151,8 +156,7 @@ export function DetailTabs({
151
156
  const from = ids.indexOf(activeId)
152
157
  const next = ids[(from + delta + ids.length) % ids.length]
153
158
  if (!next) return
154
- setUserChose(true)
155
- setUrlTab(next)
159
+ select(next)
156
160
  // Focus follows selection, or the arrow keys would walk the visual
157
161
  // selection away from the keyboard's position. By id rather than by
158
162
  // position in a NodeList: each tab already carries one, and an id lookup
@@ -164,37 +168,39 @@ export function DetailTabs({
164
168
  <>
165
169
  <div className={cn('detail-band', bandClassName)}>
166
170
  {band}
167
- <div role="tablist" className="detail-tabs mt-5" onKeyDown={onKeyDown}>
168
- {tabs.map((tab) => {
169
- const selected = tab.id === activeId
170
- return (
171
- <button
172
- key={tab.id}
173
- type="button"
174
- role="tab"
175
- id={`detail-tab-${tab.id}`}
176
- aria-selected={selected}
177
- aria-controls={`detail-panel-${tab.id}`}
178
- // Only the selected tab stays in the tab order; the rest are
179
- // reached with the arrow keys, as the pattern requires.
180
- tabIndex={selected ? 0 : -1}
181
- aria-label={tab.ariaLabel}
182
- className="detail-tab"
183
- onClick={() => {
184
- setUserChose(true)
185
- setUrlTab(tab.id)
186
- }}
187
- >
188
- {tab.label}
189
- {tab.chip && (
190
- <span className="detail-tab__chip">{tab.chip}</span>
191
- )}
192
- {tab.dot && (
193
- <span className="detail-tab__dot" aria-hidden="true" />
194
- )}
195
- </button>
196
- )
197
- })}
171
+ <div className="detail-tabs-row mt-5">
172
+ <div role="tablist" className="detail-tabs" onKeyDown={onKeyDown}>
173
+ {tabs.map((tab) => {
174
+ const selected = tab.id === activeId
175
+ return (
176
+ <button
177
+ key={tab.id}
178
+ type="button"
179
+ role="tab"
180
+ id={`detail-tab-${tab.id}`}
181
+ aria-selected={selected}
182
+ aria-controls={`detail-panel-${tab.id}`}
183
+ // Only the selected tab stays in the tab order; the rest are
184
+ // reached with the arrow keys, as the pattern requires.
185
+ tabIndex={selected ? 0 : -1}
186
+ aria-label={tab.ariaLabel}
187
+ className="detail-tab"
188
+ onClick={() => select(tab.id)}
189
+ >
190
+ {tab.label}
191
+ {tab.chip && (
192
+ <span className="detail-tab__chip">{tab.chip}</span>
193
+ )}
194
+ {tab.dot && (
195
+ <span className="detail-tab__dot" aria-hidden="true" />
196
+ )}
197
+ </button>
198
+ )
199
+ })}
200
+ </div>
201
+ {trailing && (
202
+ <div className="detail-tabs-trailing">{trailing(select)}</div>
203
+ )}
198
204
  </div>
199
205
  </div>
200
206
  {active && (