@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.
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/composeComments.test.d.ts +1 -0
- package/dist/esm/__tests__/ui/detail/CommunityInput.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +1 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +237 -218
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +17 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +187 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +9 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +91 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +17 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +328 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +15 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +26 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +8 -19
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +49 -37
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +30 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +50 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityError.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityError.js +8 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityError.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.d.ts +27 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +66 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.d.ts +11 -0
- package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js +15 -0
- package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js.map +1 -0
- package/dist/esm/modules/extensions/features/resourceDetail.d.ts +38 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js +4 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
- package/dist/esm/modules/extensions/index.d.ts +1 -1
- package/dist/esm/modules/extensions/index.js.map +1 -1
- package/dist/index.css +501 -49
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +9 -10
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +402 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +8 -4
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +175 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +281 -0
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +179 -0
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +423 -0
- package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +170 -0
- package/src/__tests__/ui/detail/CommunityInput.test.tsx +39 -0
- package/src/index.css +501 -49
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +292 -289
- package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +254 -0
- package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +102 -0
- package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +419 -0
- package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +41 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +77 -71
- package/src/modules/appCatalog/ui/detail/communityDraft.ts +80 -0
- package/src/modules/appCatalog/ui/detail/communityError.ts +10 -0
- package/src/modules/appCatalog/ui/detail/composeComments.ts +103 -0
- package/src/modules/appCatalog/ui/detail/usePendingCount.ts +26 -0
- package/src/modules/extensions/features/resourceDetail.ts +28 -0
- package/src/modules/extensions/index.ts +2 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +0 -13
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +0 -433
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +0 -1
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +0 -181
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +0 -131
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +0 -605
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.emptyState.test.d.ts → AppDetails.behindThisCard.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.reviewStatus.test.d.ts → AppDetails.knowMore.test.d.ts} +0 -0
|
@@ -0,0 +1,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> = {
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 && (
|