@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
|
@@ -1,605 +0,0 @@
|
|
|
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 & 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
|
-
}
|
|
File without changes
|