@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
  4. package/dist/esm/api/infra/trpc.d.ts +42 -21
  5. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
  9. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
  10. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
  11. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  12. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
  13. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
  15. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
  17. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
  21. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
  22. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +88 -0
  23. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
  24. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
  25. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
  26. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
  28. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
  29. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
  30. package/dist/index.css +269 -5
  31. package/package.json +4 -4
  32. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
  33. package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
  34. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
  35. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +169 -0
  36. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
  37. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
  38. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
  39. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
  40. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
  41. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
  42. package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
  43. package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
  44. package/src/index.css +269 -5
  45. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
  46. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
  47. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
  48. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
  49. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
  50. package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
  51. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +188 -0
  52. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
  53. package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
  54. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
  55. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
  56. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
  57. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
  58. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
  59. package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
  60. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.emptyState.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
  61. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.subResourceTab.test.d.ts} +0 -0
@@ -2,17 +2,10 @@ import type {
2
2
  Resource,
3
3
  SourceReference,
4
4
  } from '@igstack/app-catalog-backend-core'
5
- import {
6
- AppWindow,
7
- CalendarPlus,
8
- Clock,
9
- ExternalLink,
10
- Plus,
11
- Trash2,
12
- } from 'lucide-react'
5
+ import { useSearch } from '@tanstack/react-router'
6
+ import { CalendarPlus, Clock, ExternalLink, Plus, Trash2 } from 'lucide-react'
13
7
  import React from 'react'
14
8
  import { useHotkeys } from 'react-hotkeys-hook'
15
- import { cn } from '~/lib/utils'
16
9
  import { Badge } from '~/ui/badge'
17
10
  import { Button } from '~/ui/button'
18
11
  import {
@@ -23,13 +16,7 @@ import {
23
16
  } from '~/ui/accordion'
24
17
  import { AccessRequestSection } from '../components/AccessRequestSection'
25
18
  import { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'
26
- import {
27
- LEAP,
28
- LEAVE_FILL,
29
- QUIET_FILL,
30
- QuickJumpBar,
31
- hasQuickJumps,
32
- } from '../components/QuickJumpBar'
19
+ import { QuickJumpBar, hasQuickJumps } from '../components/QuickJumpBar'
33
20
  import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
34
21
  import { PersonBadge } from '../components/PersonBadge'
35
22
  import { useUser } from '~/modules/auth'
@@ -44,39 +31,40 @@ import { formatRelativeTime } from '../../utils/formatRelativeTime'
44
31
  import { TierVariantsSection } from '../components/TierVariantsSection'
45
32
  import { SubResourcesSection } from '../components/SubResourcesSection'
46
33
  import { getChildResources } from '../../utils/resolveHelpers'
47
- import { CommentsSection } from './CommentsSection'
34
+ import { FeedbackSection } from './FeedbackSection'
48
35
  import { displayUrl } from '~/modules/appCatalog/utils/displayUrl'
49
-
50
- function getIconUrl(iconName: string): string {
51
- return `/api/icons/${iconName}`
52
- }
53
-
54
- function AppIcon({ app, className }: { app: Resource; className?: string }) {
55
- const [imageError, setImageError] = React.useState(false)
56
-
57
- // Use iconName from backend if available
58
- if (app.iconName && !imageError) {
59
- return (
60
- <div className={cn('size-12 shrink-0', className)}>
61
- <img
62
- src={getIconUrl(app.iconName)}
63
- alt={`${app.abbreviation || app.displayName} icon`}
64
- className="size-12 rounded-lg object-contain"
65
- onError={() => setImageError(true)}
66
- />
67
- </div>
68
- )
69
- }
70
-
71
- // Fallback icon
36
+ import { accessTabLabel } from '~/modules/appCatalog/utils/accessTabLabel'
37
+ import { AppDoor } from './AppDoor'
38
+ import { DetailTabs } from './DetailTabs'
39
+ import type { DetailTab } from './DetailTabs'
40
+
41
+ /** Tab ids. Public — they land in `?tab=`, so renaming one breaks links. */
42
+ const TAB = {
43
+ overview: 'overview',
44
+ access: 'access',
45
+ resources: 'resources',
46
+ documentation: 'documentation',
47
+ notes: 'notes',
48
+ } as const
49
+
50
+ /** A labelled block inside a tab. One register for all of them. */
51
+ function Section({
52
+ title,
53
+ hint,
54
+ first,
55
+ children,
56
+ }: {
57
+ title: string
58
+ hint?: string
59
+ /** Drops the top margin for the first block in a panel. */
60
+ first?: boolean
61
+ children: React.ReactNode
62
+ }) {
72
63
  return (
73
- <div
74
- className={cn(
75
- 'flex items-center justify-center rounded-lg bg-primary/10 text-primary size-12 shrink-0',
76
- className,
77
- )}
78
- >
79
- <AppWindow className="size-6" />
64
+ <div className={first ? '' : 'mt-7'}>
65
+ <h3 className="section-label mb-1.5">{title}</h3>
66
+ {hint && <p className="mb-2 text-xs text-muted-foreground">{hint}</p>}
67
+ {children}
80
68
  </div>
81
69
  )
82
70
  }
@@ -85,7 +73,6 @@ function AppScreenshot({ app }: { app: Resource }) {
85
73
  const [imageError, setImageError] = React.useState(false)
86
74
  const [isLoadingImage, setIsLoadingImage] = React.useState(true)
87
75
 
88
- // Check if app has screenshots
89
76
  const screenshotId = app.screenshotIds?.[0]
90
77
  if (!screenshotId) {
91
78
  return (
@@ -126,37 +113,6 @@ function AppScreenshot({ app }: { app: Resource }) {
126
113
  )
127
114
  }
128
115
 
129
- function TiersAndSubResourcesPanel({ app }: { app: Resource }) {
130
- const { resources } = useAppCatalogContext()
131
- // #38 item C: the active catalog search term. When the user reached this app
132
- // by searching something that matched a child, seed the sub-resource filter
133
- // with it so the matched child is revealed.
134
- const { state: filterState } = useAppCatalogFilters()
135
- const appSubResources = React.useMemo(
136
- () => getChildResources(resources, app.slug),
137
- [resources, app.slug],
138
- )
139
-
140
- return (
141
- <>
142
- {app.tiers && app.tiers.length > 0 && (
143
- <div className="mt-6">
144
- <TierVariantsSection tiers={app.tiers} />
145
- </div>
146
- )}
147
- {appSubResources.length > 0 && (
148
- <div className="mt-6">
149
- <SubResourcesSection
150
- subResources={appSubResources}
151
- parentSlug={app.slug}
152
- initialSearch={filterState.searchValue}
153
- />
154
- </div>
155
- )}
156
- </>
157
- )
158
- }
159
-
160
116
  export function AppDetails({
161
117
  app,
162
118
  onAppClick,
@@ -168,12 +124,33 @@ export function AppDetails({
168
124
  }) {
169
125
  const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)
170
126
  const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)
127
+ /**
128
+ * Set by the band's "Suggest a change", which has to do two things the button
129
+ * cannot do alone: bring the notes tab forward, and open the composer inside it.
130
+ * Held here because the band and the panel are siblings — neither can reach the
131
+ * other, and routing it through the URL would put a transient intent in a
132
+ * shareable link.
133
+ *
134
+ * A count, not a flag: the composer closes itself on send, so asking a second
135
+ * time has to be distinguishable from the first ask still being set. As a
136
+ * boolean the second click changed nothing and the button was dead for the rest
137
+ * of the card's life.
138
+ */
139
+ const [composeRequests, setComposeRequests] = React.useState(0)
140
+
171
141
  const { approvalMethods, resources: allResources } = useAppCatalogContext()
172
142
  const { recordClick } = useAppClickHistory()
173
143
  const updateApp = useUpdateApp()
174
144
  const [draftSource, setDraftSource] = React.useState<string | null>(null)
175
145
  const user = useUser()
176
146
  const isAdmin = user?.isAdmin ?? false
147
+ const { state: filterState } = useAppCatalogFilters()
148
+
149
+ // Arriving with `?sub=` means the visitor picked one child out of a search —
150
+ // so the card has to open ON that child's tab. Landing on Overview and
151
+ // leaving them to find the tab is the bug this scenario exists to prevent.
152
+ const search = useSearch({ strict: false })
153
+ const subSlug = search.sub
177
154
 
178
155
  const displayTags = (app.tags ?? []).filter((tag) => !tag.includes(':'))
179
156
  const sourceUrls: string[] =
@@ -193,6 +170,11 @@ export function AppDetails({
193
170
  return byUrl
194
171
  }, [app.sources])
195
172
 
173
+ const children = React.useMemo(
174
+ () => getChildResources(allResources, app.slug),
175
+ [allResources, app.slug],
176
+ )
177
+
196
178
  // Enter: open screenshot gallery
197
179
  useHotkeys(
198
180
  'enter',
@@ -241,507 +223,545 @@ export function AppDetails({
241
223
  ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)
242
224
  : null
243
225
 
244
- return (
245
- <>
246
- <div className="flex h-full flex-col p-6">
247
- {/* Icon and Title — always first: a pinned Quick Jump takes order -1,
248
- so the header claims -2 rather than being pushed below it. */}
249
- <div className="-order-2 border-b pb-6">
250
- <div className="flex items-center gap-3">
251
- <AppIcon app={app} className="size-16" />
252
- <div className="-mx-3 flex-1 min-w-0">
253
- <div className="flex items-center gap-2 px-3">
254
- <div className="font-serif text-2xl font-semibold min-w-0">
255
- {app.abbreviation
256
- ? `${app.displayName} (${app.abbreviation})`
257
- : app.displayName}
258
- </div>
259
- {app.deprecated && (
260
- <Badge
261
- variant={
262
- app.deprecated.type === 'discouraged'
263
- ? 'secondary'
264
- : 'destructive'
265
- }
266
- >
267
- {app.deprecated.type === 'discouraged'
268
- ? 'Discouraged'
269
- : 'Deprecated'}
270
- </Badge>
226
+ const access = accessTabLabel(app, approvalMethods)
227
+
228
+ const hasResourcesTab = children.length > 0 || (app.tiers?.length ?? 0) > 0
229
+ const resourcesLabel = children.length
230
+ ? (app.childrenLabel ?? 'Resources')
231
+ : 'Environments'
232
+
233
+ const tabs: DetailTab[] = [
234
+ {
235
+ id: TAB.overview,
236
+ label: 'Overview',
237
+ render: () => (
238
+ <>
239
+ <Section title="Description" first>
240
+ {isAdmin ? (
241
+ <InlineEditableField
242
+ value={app.description ?? ''}
243
+ onSave={(description) =>
244
+ updateApp.mutate({ id: app.id, data: { description } })
245
+ }
246
+ multiline
247
+ placeholder="Description"
248
+ className="min-h-[4rem] resize-y text-sm text-muted-foreground"
249
+ />
250
+ ) : app.description ? (
251
+ <MarkdownText className="prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0">
252
+ {app.description}
253
+ </MarkdownText>
254
+ ) : (
255
+ <p className="text-sm text-muted-foreground">—</p>
256
+ )}
257
+ </Section>
258
+
259
+ {app.screenshotIds && app.screenshotIds.length > 0 && (
260
+ <Section title={`Screenshots (${app.screenshotIds.length})`}>
261
+ {/* A button, not a clickable div: the gallery was unreachable by
262
+ keyboard, and a role + label gives it a stable handle. */}
263
+ <button
264
+ type="button"
265
+ aria-label={`View screenshots of ${app.displayName}`}
266
+ className="block w-full cursor-pointer text-left hover:opacity-80 transition-opacity"
267
+ onClick={() => handleScreenshotClick(0)}
268
+ >
269
+ <AppScreenshot app={app} />
270
+ {app.screenshotIds.length > 1 && (
271
+ <p className="text-xs text-muted-foreground mt-2 text-center">
272
+ Click to view all {app.screenshotIds.length} screenshots
273
+ </p>
271
274
  )}
275
+ </button>
276
+ </Section>
277
+ )}
278
+
279
+ {/* Owner — who is responsible for this resource. Distinct from the
280
+ access approver (who decides access requests); see domain model. */}
281
+ {app.ownerPersonSlug && (
282
+ <Section title="Owner" hint="Who is responsible for this resource">
283
+ <PersonBadge slug={app.ownerPersonSlug} />
284
+ </Section>
285
+ )}
286
+
287
+ {/* Tags, minus the indexing machinery: `namespace:value` tags drive
288
+ grouping, faceting and placement, are about half of all tag
289
+ references, and say nothing about what an app is for. The colon is
290
+ the test. Search still matches them — only the display drops them. */}
291
+ {displayTags.length > 0 && (
292
+ <Section title="Tags">
293
+ <div className="flex flex-wrap gap-2">
294
+ {displayTags.map((tag) => (
295
+ <Badge key={tag} variant="secondary" className="text-xs">
296
+ {tag}
297
+ </Badge>
298
+ ))}
272
299
  </div>
273
- {isAdmin && (
274
- <div className="mt-1 px-3">
275
- <span className="text-xs text-muted-foreground mr-2">
276
- Slug:
277
- </span>
278
- <InlineEditableField
279
- value={app.slug}
280
- onSave={(slug) =>
281
- updateApp.mutate({ id: app.id, data: { slug } })
282
- }
283
- className="text-sm"
284
- />
285
- </div>
286
- )}
287
- {/* Open button, then Quick Jump: the host you reach by pressing
288
- it, and the deep link you reach by pasting an id. */}
289
- <div className="mt-3 flex flex-wrap items-stretch gap-3 px-3">
290
- {isAdmin ? (
291
- <InlineEditableField
292
- value={app.appUrl ?? ''}
293
- onSave={(appUrl) =>
294
- updateApp.mutate({ id: app.id, data: { appUrl } })
295
- }
296
- placeholder="App URL"
297
- renderView={(url) =>
298
- url ? (
299
- <a
300
- href={url}
301
- target="_blank"
302
- rel="noopener noreferrer"
303
- onClick={() => recordClick(app.slug)}
304
- className="inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all"
305
- >
306
- {displayUrl(url)}
307
- <ExternalLink className="size-3.5 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity" />
308
- </a>
309
- ) : (
310
- <span className="text-muted-foreground">—</span>
311
- )
312
- }
313
- />
314
- ) : app.appUrl ? (
300
+ </Section>
301
+ )}
302
+
303
+ {app.teams && app.teams.length > 0 && (
304
+ <Section title="Teams">
305
+ <div className="flex flex-wrap gap-2">
306
+ {app.teams.map((team) => (
307
+ <Badge key={team} variant="outline" className="text-xs">
308
+ {team}
309
+ </Badge>
310
+ ))}
311
+ </div>
312
+ </Section>
313
+ )}
314
+ </>
315
+ ),
316
+ },
317
+ {
318
+ id: TAB.access,
319
+ label: `Access — ${access.route}`,
320
+ chip: access.chip,
321
+ render: () => (
322
+ <>
323
+ {/* Two-step access: for a nested resource, show the parent-first
324
+ prerequisite chain before this resource's own instructions. */}
325
+ <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />
326
+ <AccessRequestSection app={app} approvalMethods={approvalMethods} />
327
+ </>
328
+ ),
329
+ },
330
+ ...(hasResourcesTab
331
+ ? [
332
+ {
333
+ id: TAB.resources,
334
+ label: resourcesLabel,
335
+ chip: children.length ? String(children.length) : undefined,
336
+ render: () => (
337
+ <>
338
+ {app.tiers && app.tiers.length > 0 && (
339
+ <TierVariantsSection tiers={app.tiers} />
340
+ )}
341
+ {children.length > 0 && (
342
+ <div className={app.tiers?.length ? 'mt-7' : ''}>
343
+ {/* The active catalog search term seeds the filter, so a
344
+ visitor who got here by searching something that matched
345
+ a child sees that child rather than all of its siblings. */}
346
+ <SubResourcesSection
347
+ subResources={children}
348
+ parentSlug={app.slug}
349
+ initialSearch={filterState.searchValue}
350
+ />
351
+ </div>
352
+ )}
353
+ </>
354
+ ),
355
+ } satisfies DetailTab,
356
+ ]
357
+ : []),
358
+ {
359
+ id: TAB.documentation,
360
+ label: 'Documentation',
361
+ render: () => (
362
+ <>
363
+ {app.links && app.links.length > 0 && (
364
+ <Section title="Documentation" first>
365
+ <div className="space-y-0.5">
366
+ {app.links.map((link) => (
315
367
  <a
316
- href={app.appUrl}
368
+ key={link.url}
369
+ href={link.url}
317
370
  target="_blank"
318
371
  rel="noopener noreferrer"
319
- onClick={() => recordClick(app.slug)}
320
- className={cn(
321
- // The row's one inset and icon gap, and its height stated
322
- // rather than left to the taller neighbour.
323
- 'inline-flex min-h-[34px] items-center gap-1.5 rounded-md px-3 text-sm font-medium shadow-sm',
324
- // Only ever ONE solid brand fill in this row: it goes to
325
- // Quick Jump's submit when there is one, because that is
326
- // the control that acts on something the user typed.
327
- hasQuickJumps(app) ? QUIET_FILL : LEAVE_FILL,
328
- LEAP,
329
- )}
330
- title={displayUrl(app.appUrl)}
331
- aria-label={`Open ${app.displayName} in a new tab (${displayUrl(app.appUrl)})`}
372
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate"
332
373
  >
333
- {/* "Go to <host>": the verb makes it a thing you press and
334
- the host says where to. The glyph adds "in a new tab". */}
335
- <span className="max-w-[280px] truncate whitespace-nowrap">
336
- Go to {displayUrl(app.appUrl)}
337
- </span>
338
- <ExternalLink className="size-3.5 shrink-0" />
374
+ <ExternalLink className="size-3 shrink-0" />
375
+ {link.title || displayUrl(link.url)}
339
376
  </a>
340
- ) : (
341
- <span className="text-muted-foreground">—</span>
342
- )}
343
- <QuickJumpBar app={app} />
377
+ ))}
344
378
  </div>
345
- {/* Updated/Added metadata moved to consolidated section before Sources (#55) */}
346
- </div>
347
- </div>
348
- </div>
379
+ </Section>
380
+ )}
381
+ {/* Background: why the app exists at all. Deliberately quiet — a
382
+ reader who came to get access has already passed everything they
383
+ need, and history should not compete with the description. */}
384
+ {app.background && (
385
+ <Section title="Background">
386
+ <MarkdownText className="prose prose-sm text-muted-foreground max-w-none text-sm [&_p]:mt-0 [&_p]:mb-2 [&_p:last-child]:mb-0">
387
+ {app.background}
388
+ </MarkdownText>
389
+ </Section>
390
+ )}
349
391
 
350
- {/* Deprecation/Discouraged Warning */}
351
- {app.deprecated &&
352
- (() => {
353
- const deprecationType = app.deprecated.type || 'deprecated'
354
- const isDiscouraged = deprecationType === 'discouraged'
355
- return (
356
- <div
357
- className={
358
- isDiscouraged
359
- ? 'mt-6 p-4 border border-yellow-500/50 rounded-lg bg-yellow-50 dark:bg-yellow-950/20'
360
- : 'mt-6 p-4 border border-destructive/50 rounded-lg bg-destructive/10'
361
- }
362
- >
363
- <h3
364
- className={
365
- isDiscouraged
366
- ? 'text-sm font-semibold text-yellow-700 dark:text-yellow-500 mb-2'
367
- : 'text-sm font-semibold text-destructive mb-2'
368
- }
392
+ <Section title="Sources" first={!app.background}>
393
+ {isAdmin ? (
394
+ <>
395
+ <ul className="space-y-2">
396
+ {displaySources.map((url, index) => {
397
+ const isDraft =
398
+ draftSource !== null && index === sourceUrls.length
399
+ return (
400
+ <li
401
+ key={isDraft ? 'draft' : `${index}-${url}`}
402
+ className="group flex items-center gap-2 text-xs"
403
+ >
404
+ <span className="text-muted-foreground shrink-0 tabular-nums">
405
+ {index + 1}.
406
+ </span>
407
+ <InlineEditableField
408
+ value={url}
409
+ initialEditMode={isDraft}
410
+ onCancel={
411
+ isDraft ? () => setDraftSource(null) : undefined
412
+ }
413
+ onSave={(newUrl) => {
414
+ if (isDraft) {
415
+ setDraftSource(null)
416
+ if (newUrl) {
417
+ updateApp.mutate({
418
+ id: app.id,
419
+ data: { sources: [...sourceUrls, newUrl] },
420
+ })
421
+ }
422
+ } else {
423
+ const next = [...sourceUrls]
424
+ next[index] = newUrl
425
+ updateApp.mutate({
426
+ id: app.id,
427
+ data: { sources: next.filter(Boolean) },
428
+ })
429
+ }
430
+ }}
431
+ placeholder="https://..."
432
+ viewClassName="flex-1 min-w-0"
433
+ renderView={(val) =>
434
+ val ? (
435
+ <a
436
+ href={val}
437
+ target="_blank"
438
+ rel="noopener noreferrer"
439
+ className="hover:text-primary inline-flex items-center gap-1 truncate"
440
+ >
441
+ {displayUrl(val)}
442
+ <ExternalLink className="size-3 shrink-0" />
443
+ </a>
444
+ ) : (
445
+ <span className="text-muted-foreground">—</span>
446
+ )
447
+ }
448
+ />
449
+ {!isDraft && pulseByUrl.has(url) && (
450
+ <SourcePulse
451
+ pulse={pulseByUrl.get(url)!}
452
+ className="ml-auto"
453
+ />
454
+ )}
455
+ {!isDraft && (
456
+ <Button
457
+ type="button"
458
+ variant="ghost"
459
+ size="icon-sm"
460
+ aria-label="Remove source"
461
+ className="shrink-0 text-muted-foreground hover:text-destructive"
462
+ onClick={() => {
463
+ const next = sourceUrls.filter(
464
+ (_, i) => i !== index,
465
+ )
466
+ updateApp.mutate({
467
+ id: app.id,
468
+ data: { sources: next },
469
+ })
470
+ }}
471
+ >
472
+ <Trash2 className="size-3.5" />
473
+ </Button>
474
+ )}
475
+ </li>
476
+ )
477
+ })}
478
+ </ul>
479
+ <Button
480
+ type="button"
481
+ variant="ghost"
482
+ size="sm"
483
+ className="mt-2 gap-1 text-muted-foreground"
484
+ onClick={() => setDraftSource('')}
369
485
  >
370
- {isDiscouraged
371
- ? 'Usage discouraged'
372
- : 'This application is deprecated'}
373
- </h3>
374
- <p className="text-sm text-muted-foreground mb-3">
375
- {app.deprecated.comment}
376
- </p>
377
- {replacementApp && (
378
- <button
379
- type="button"
380
- onClick={() => onAppClick?.(replacementApp)}
381
- className="text-sm font-medium text-primary hover:underline inline-flex items-center gap-1"
486
+ <Plus className="size-3.5" />
487
+ Add source
488
+ </Button>
489
+ </>
490
+ ) : (
491
+ <ul className="space-y-2">
492
+ {sourceUrls.map((url, index) => (
493
+ // `group` so hovering anywhere on the row fades that source's
494
+ // track in — the mark sits in a right-hand column, and row
495
+ // hover is what ties the two ends of a wide row together.
496
+ <li
497
+ key={index}
498
+ className="group flex items-center gap-2 text-xs"
499
+ >
500
+ <span className="text-muted-foreground shrink-0 tabular-nums">
501
+ {index + 1}.
502
+ </span>
503
+ {url ? (
504
+ <a
505
+ href={url}
506
+ target="_blank"
507
+ rel="noopener noreferrer"
508
+ className="hover:text-primary inline-flex min-w-0 items-center gap-1 truncate"
509
+ >
510
+ {displayUrl(url)}
511
+ <ExternalLink className="size-3 shrink-0" />
512
+ </a>
513
+ ) : (
514
+ <span className="text-muted-foreground">—</span>
515
+ )}
516
+ {pulseByUrl.has(url) && (
517
+ <SourcePulse
518
+ pulse={pulseByUrl.get(url)!}
519
+ className="ml-auto"
520
+ />
521
+ )}
522
+ </li>
523
+ ))}
524
+ </ul>
525
+ )}
526
+ </Section>
527
+
528
+ {/* These used to sit unlabelled in a 64px gap between two sections,
529
+ which is what made that gap look like a mistake. Labelled, in
530
+ their own block. "Dates" rather than "Added / updated" on purpose:
531
+ a label that repeats the first word of every line below it adds
532
+ nothing, and it made "Added"/"Updated" ambiguous to anything
533
+ matching on text — including this card's own tests. */}
534
+ {(app.createdAt || app.freshness?.lastCheckedAt) && (
535
+ <Section title="Dates">
536
+ <div className="flex flex-wrap gap-x-5 gap-y-1">
537
+ {app.createdAt && (
538
+ <span
539
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground"
540
+ title={app.createdAt}
382
541
  >
383
- View replacement: {replacementApp.displayName}
384
- <ExternalLink className="size-3" />
385
- </button>
542
+ <CalendarPlus className="size-3" />
543
+ Added {formatRelativeTime(app.createdAt)}
544
+ </span>
545
+ )}
546
+ {app.freshness?.lastCheckedAt && (
547
+ <span
548
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground"
549
+ title={
550
+ app.freshness.lastContentChangeAt
551
+ ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`
552
+ : `Last checked ${app.freshness.lastCheckedAt}`
553
+ }
554
+ >
555
+ <Clock className="size-3" />
556
+ {/* "Updated" means the content changed, not that a check
557
+ ran — entries predating the field have none, so fall
558
+ back. */}
559
+ Updated{' '}
560
+ {formatRelativeTime(
561
+ app.freshness.lastContentChangeAt ??
562
+ app.freshness.lastCheckedAt,
563
+ )}
564
+ {app.freshness.isStale && (
565
+ <span className="italic opacity-75">
566
+ {' '}
567
+ · may be out of date
568
+ </span>
569
+ )}
570
+ </span>
386
571
  )}
387
572
  </div>
388
- )
389
- })()}
390
-
391
- {/* Description */}
392
- <div className="mt-6">
393
- <h3 className="mb-2 text-sm font-medium">Description</h3>
394
- {isAdmin ? (
395
- <InlineEditableField
396
- value={app.description ?? ''}
397
- onSave={(description) =>
398
- updateApp.mutate({ id: app.id, data: { description } })
399
- }
400
- multiline
401
- placeholder="Description"
402
- className="min-h-[4rem] resize-y text-sm text-muted-foreground"
403
- />
404
- ) : app.description ? (
405
- <MarkdownText className="prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0">
406
- {app.description}
407
- </MarkdownText>
408
- ) : (
409
- <p className="text-sm text-muted-foreground">—</p>
573
+ </Section>
410
574
  )}
411
- </div>
412
575
 
413
- {/* Two-step access (#38 D): for a nested resource, show the parent-first
414
- prerequisite chain before this resource's own access instructions. */}
415
- <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />
416
-
417
- {/* Access Request Section — what the app is comes first, then how to
418
- get into it. */}
419
- <AccessRequestSection app={app} approvalMethods={approvalMethods} />
420
-
421
- {/* Screenshots - Clickable preview */}
422
- {app.screenshotIds && app.screenshotIds.length > 0 && (
423
- <div className="mt-6">
424
- <h3 className="mb-2 text-sm font-medium">
425
- Screenshots ({app.screenshotIds.length})
426
- </h3>
427
- {/* A button, not a clickable div: the gallery was unreachable by
428
- keyboard, and a role + label gives it a stable handle. */}
429
- <button
430
- type="button"
431
- aria-label={`View screenshots of ${app.displayName}`}
432
- className="block w-full cursor-pointer text-left hover:opacity-80 transition-opacity"
433
- onClick={() => handleScreenshotClick(0)}
434
- >
435
- <AppScreenshot app={app} />
436
- {app.screenshotIds.length > 1 && (
437
- <p className="text-xs text-muted-foreground mt-2 text-center">
438
- Click to view all {app.screenshotIds.length} screenshots
439
- </p>
440
- )}
441
- </button>
442
- </div>
443
- )}
444
-
445
- {/* Owner — who is responsible for this resource. Distinct from the
446
- access approver (who decides access requests); see domain model. */}
447
- {app.ownerPersonSlug && (
448
- <div className="mt-6">
449
- <h3 className="mb-1 text-sm font-medium">Owner</h3>
450
- <p className="mb-2 text-xs text-muted-foreground">
451
- Who is responsible for this resource
452
- </p>
453
- <PersonBadge slug={app.ownerPersonSlug} />
454
- </div>
455
- )}
576
+ {/* Technical information: AI-facing fields, de-emphasized/collapsed */}
577
+ {(app.aiPrompt || app.aiMemory) && (
578
+ <Accordion type="single" collapsible className="mt-7">
579
+ <AccordionItem
580
+ value="technical-info"
581
+ className="border rounded-lg px-4"
582
+ >
583
+ <AccordionTrigger className="text-sm hover:no-underline py-3">
584
+ Technical information
585
+ </AccordionTrigger>
586
+ <AccordionContent className="pb-3 space-y-3">
587
+ {app.aiPrompt && (
588
+ <div>
589
+ <h4 className="section-label mb-1">AI Prompt</h4>
590
+ <p className="text-xs whitespace-pre-wrap">
591
+ {app.aiPrompt}
592
+ </p>
593
+ </div>
594
+ )}
595
+ {app.aiMemory && (
596
+ <div>
597
+ <h4 className="section-label mb-1">AI Memory</h4>
598
+ <p className="text-xs whitespace-pre-wrap">
599
+ {app.aiMemory}
600
+ </p>
601
+ </div>
602
+ )}
603
+ </AccordionContent>
604
+ </AccordionItem>
605
+ </Accordion>
606
+ )}
607
+ </>
608
+ ),
609
+ },
610
+ {
611
+ id: TAB.notes,
612
+ label: 'Notes & requests',
613
+ render: () => (
614
+ <FeedbackSection appSlug={app.slug} openComposer={composeRequests} />
615
+ ),
616
+ },
617
+ ]
456
618
 
457
- {/* Tier Variants and Sub-Resources */}
458
- <TiersAndSubResourcesPanel app={app} />
459
-
460
- {/* Links */}
461
- {app.links && app.links.length > 0 && (
462
- <div className="mt-4">
463
- <h3 className="mb-1 text-xs font-medium text-muted-foreground">
464
- Links
465
- </h3>
466
- <div className="space-y-0.5">
467
- {app.links.map((link) => (
468
- <a
469
- key={link.url}
470
- href={link.url}
471
- target="_blank"
472
- rel="noopener noreferrer"
473
- className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate"
474
- >
475
- <ExternalLink className="size-3 shrink-0" />
476
- {link.title || displayUrl(link.url)}
477
- </a>
478
- ))}
479
- </div>
480
- </div>
619
+ // The hero band: identity, the way in, and the typed-id jumps. It does NOT
620
+ // scroll away with a tab, so "take me to the app" is reachable from every one
621
+ // of them.
622
+ //
623
+ // Deliberately holds nothing else. An owner/teams/freshness strip was drawn
624
+ // and dropped: `ownerPersonSlug` is set on a tiny fraction of entries so the
625
+ // owner slot would almost always be empty, teams already has its own block in
626
+ // Overview, and on an entry with quick jumps the band had grown past 250px
627
+ // before any content — on exactly the apps where people most want to type an
628
+ // id and leave.
629
+ const band = (
630
+ <>
631
+ <div className="flex flex-wrap items-center gap-x-7 gap-y-2">
632
+ <AppDoor app={app} onOpen={() => recordClick(app.slug)} />
633
+ {app.deprecated && (
634
+ <Badge
635
+ variant={
636
+ app.deprecated.type === 'discouraged'
637
+ ? 'secondary'
638
+ : 'destructive'
639
+ }
640
+ >
641
+ {app.deprecated.type === 'discouraged'
642
+ ? 'Discouraged'
643
+ : 'Deprecated'}
644
+ </Badge>
481
645
  )}
646
+ {/* In the band, not at the foot of the notes panel.
647
+ Down there it was invisible twice over: below the fold of a panel, and
648
+ behind a tab nobody opens unless they already know what is in it — so
649
+ the one affordance the feature exists for was the hardest thing on the
650
+ card to find. Up here it is present on every tab, which is the whole
651
+ reason the band does not scroll away.
652
+ `mr-10` keeps it clear of the dialog's own close button at top-right. */}
653
+ <Button
654
+ type="button"
655
+ size="sm"
656
+ variant="outline"
657
+ className="ml-auto mr-10"
658
+ onClick={() => setComposeRequests((n) => n + 1)}
659
+ >
660
+ ✎ Suggest a change
661
+ </Button>
662
+ </div>
482
663
 
483
- {/* Tags, minus the indexing machinery: `namespace:value` tags drive
484
- grouping, faceting and placement, are about half of all tag
485
- references, and say nothing about what an app is for. The colon is
486
- the test. Search still matches them — only the display drops them. */}
487
- {displayTags.length > 0 && (
488
- <div className="mt-6">
489
- <h3 className="mb-2 text-sm font-medium">Tags</h3>
490
- <div className="flex flex-wrap gap-2">
491
- {displayTags.map((tag) => (
492
- <Badge key={tag} variant="secondary" className="text-xs">
493
- {tag}
494
- </Badge>
495
- ))}
496
- </div>
497
- </div>
498
- )}
664
+ {app.nicknames && app.nicknames.length > 0 && (
665
+ <p className="mt-2.5 text-xs text-muted-foreground">
666
+ Also known as {app.nicknames.join(', ')}
667
+ </p>
668
+ )}
499
669
 
500
- {/* Teams */}
501
- {app.teams && app.teams.length > 0 && (
502
- <div className="mt-6">
503
- <h3 className="mb-2 text-sm font-medium">Teams</h3>
504
- <div className="flex flex-wrap gap-2">
505
- {app.teams.map((team) => (
506
- <Badge key={team} variant="outline" className="text-xs">
507
- {team}
508
- </Badge>
509
- ))}
510
- </div>
511
- </div>
512
- )}
670
+ {isAdmin && (
671
+ <div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-1">
672
+ <span className="inline-flex items-center gap-2">
673
+ <span className="text-xs text-muted-foreground">Slug:</span>
674
+ <InlineEditableField
675
+ value={app.slug}
676
+ onSave={(slug) =>
677
+ updateApp.mutate({ id: app.id, data: { slug } })
678
+ }
679
+ className="text-sm"
680
+ />
681
+ </span>
682
+ <span className="inline-flex items-center gap-2">
683
+ <span className="text-xs text-muted-foreground">URL:</span>
684
+ <InlineEditableField
685
+ value={app.appUrl ?? ''}
686
+ onSave={(appUrl) =>
687
+ updateApp.mutate({ id: app.id, data: { appUrl } })
688
+ }
689
+ placeholder="App URL"
690
+ className="text-sm"
691
+ />
692
+ </span>
693
+ </div>
694
+ )}
513
695
 
514
- {/* Background: why the app exists at all. Deliberately late and quiet —
515
- a reader who came to get access has already passed everything they
516
- need, and history should not compete with the description. */}
517
- {app.background && (
518
- <div className="mt-6">
519
- <h3 className="text-muted-foreground mb-2 text-xs font-medium">
520
- Background
521
- </h3>
522
- <MarkdownText className="prose prose-sm text-muted-foreground max-w-none text-sm [&_p]:mt-0 [&_p]:mb-2 [&_p:last-child]:mb-0">
523
- {app.background}
524
- </MarkdownText>
525
- </div>
526
- )}
696
+ {hasQuickJumps(app) && (
697
+ <div className="mt-4">
698
+ {/* Named, where the bare control was not: "or go straight to a
699
+ record" says what the field is for before you reach it. */}
700
+ <p className="mb-1.5 text-xs text-muted-foreground">
701
+ Or go straight to a record:
702
+ </p>
703
+ <QuickJumpBar app={app} />
704
+ </div>
705
+ )}
527
706
 
528
- {/* Technical information: AI-facing fields, de-emphasized/collapsed */}
529
- {(app.aiPrompt || app.aiMemory) && (
530
- <Accordion type="single" collapsible className="mt-6">
531
- <AccordionItem
532
- value="technical-info"
533
- className="border rounded-lg px-4"
707
+ {/* Deprecation stays in the band, not in a tab: "do not use this"
708
+ must not be something you have to go looking for. */}
709
+ {app.deprecated &&
710
+ (() => {
711
+ const isDiscouraged = app.deprecated.type === 'discouraged'
712
+ return (
713
+ <div
714
+ className={
715
+ isDiscouraged
716
+ ? 'mt-4 rounded-lg border border-yellow-500/50 bg-yellow-50 p-4 dark:bg-yellow-950/20'
717
+ : 'mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4'
718
+ }
534
719
  >
535
- <AccordionTrigger className="text-sm hover:no-underline py-3">
536
- Technical information
537
- </AccordionTrigger>
538
- <AccordionContent className="pb-3 space-y-3">
539
- {app.aiPrompt && (
540
- <div>
541
- <h4 className="mb-1 text-xs font-medium text-muted-foreground">
542
- AI Prompt
543
- </h4>
544
- <p className="text-xs whitespace-pre-wrap">
545
- {app.aiPrompt}
546
- </p>
547
- </div>
548
- )}
549
- {app.aiMemory && (
550
- <div>
551
- <h4 className="mb-1 text-xs font-medium text-muted-foreground">
552
- AI Memory
553
- </h4>
554
- <p className="text-xs whitespace-pre-wrap">
555
- {app.aiMemory}
556
- </p>
557
- </div>
558
- )}
559
- </AccordionContent>
560
- </AccordionItem>
561
- </Accordion>
562
- )}
563
-
564
- {/* Metadata: Added / Updated */}
565
- {(app.createdAt || app.freshness?.lastCheckedAt) && (
566
- <div className="mt-6 flex flex-wrap gap-x-5 gap-y-1">
567
- {app.createdAt && (
568
- <span
569
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
570
- title={app.createdAt}
571
- >
572
- <CalendarPlus className="size-3" />
573
- Added {formatRelativeTime(app.createdAt)}
574
- </span>
575
- )}
576
- {app.freshness?.lastCheckedAt && (
577
- <span
578
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
579
- title={
580
- app.freshness.lastContentChangeAt
581
- ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`
582
- : `Last checked ${app.freshness.lastCheckedAt}`
720
+ <h3
721
+ className={
722
+ isDiscouraged
723
+ ? 'section-label mb-1.5 text-yellow-700 dark:text-yellow-500'
724
+ : 'section-label mb-1.5 text-destructive'
583
725
  }
584
726
  >
585
- <Clock className="size-3" />
586
- {/* "Updated" means the content changed, not that a check ran -
587
- entries predating the field have none, so fall back. */}
588
- Updated{' '}
589
- {formatRelativeTime(
590
- app.freshness.lastContentChangeAt ??
591
- app.freshness.lastCheckedAt,
592
- )}
593
- {app.freshness.isStale && (
594
- <span className="italic opacity-75">
595
- {' '}
596
- · may be out of date
597
- </span>
598
- )}
599
- </span>
600
- )}
601
- </div>
602
- )}
603
-
604
- {/* Sources */}
605
- <div className="mt-6">
606
- <h3 className="mb-2 text-sm font-medium">Sources</h3>
607
- {isAdmin ? (
608
- <>
609
- <ul className="space-y-2">
610
- {displaySources.map((url, index) => {
611
- const isDraft =
612
- draftSource !== null && index === sourceUrls.length
613
- return (
614
- <li
615
- key={isDraft ? 'draft' : `${index}-${url}`}
616
- className="group flex items-center gap-2 text-xs"
617
- >
618
- <span className="text-muted-foreground shrink-0 tabular-nums">
619
- {index + 1}.
620
- </span>
621
- <InlineEditableField
622
- value={url}
623
- initialEditMode={isDraft}
624
- onCancel={
625
- isDraft ? () => setDraftSource(null) : undefined
626
- }
627
- onSave={(newUrl) => {
628
- if (isDraft) {
629
- setDraftSource(null)
630
- if (newUrl) {
631
- updateApp.mutate({
632
- id: app.id,
633
- data: { sources: [...sourceUrls, newUrl] },
634
- })
635
- }
636
- } else {
637
- const next = [...sourceUrls]
638
- next[index] = newUrl
639
- updateApp.mutate({
640
- id: app.id,
641
- data: { sources: next.filter(Boolean) },
642
- })
643
- }
644
- }}
645
- placeholder="https://..."
646
- viewClassName="flex-1 min-w-0"
647
- renderView={(val) =>
648
- val ? (
649
- <a
650
- href={val}
651
- target="_blank"
652
- rel="noopener noreferrer"
653
- className="hover:text-primary inline-flex items-center gap-1 truncate"
654
- >
655
- {displayUrl(val)}
656
- <ExternalLink className="size-3 shrink-0" />
657
- </a>
658
- ) : (
659
- <span className="text-muted-foreground">—</span>
660
- )
661
- }
662
- />
663
- {!isDraft && pulseByUrl.has(url) && (
664
- <SourcePulse
665
- pulse={pulseByUrl.get(url)!}
666
- className="ml-auto"
667
- />
668
- )}
669
- {!isDraft && (
670
- <Button
671
- type="button"
672
- variant="ghost"
673
- size="icon-sm"
674
- aria-label="Remove source"
675
- className="shrink-0 text-muted-foreground hover:text-destructive"
676
- onClick={() => {
677
- const next = sourceUrls.filter(
678
- (_, i) => i !== index,
679
- )
680
- updateApp.mutate({
681
- id: app.id,
682
- data: { sources: next },
683
- })
684
- }}
685
- >
686
- <Trash2 className="size-3.5" />
687
- </Button>
688
- )}
689
- </li>
690
- )
691
- })}
692
- </ul>
693
- <Button
694
- type="button"
695
- variant="ghost"
696
- size="sm"
697
- className="mt-2 gap-1 text-muted-foreground"
698
- onClick={() => setDraftSource('')}
699
- >
700
- <Plus className="size-3.5" />
701
- Add source
702
- </Button>
703
- </>
704
- ) : (
705
- <ul className="space-y-2">
706
- {sourceUrls.map((url, index) => (
707
- // `group` so hovering anywhere on the row fades that source's
708
- // track in — the mark sits in a right-hand column, and row hover
709
- // is what ties the two ends of a wide row together.
710
- <li
711
- key={index}
712
- className="group flex items-center gap-2 text-xs"
727
+ {isDiscouraged
728
+ ? 'Usage discouraged'
729
+ : 'This application is deprecated'}
730
+ </h3>
731
+ <p className="text-sm text-muted-foreground">
732
+ {app.deprecated.comment}
733
+ </p>
734
+ {replacementApp && (
735
+ <button
736
+ type="button"
737
+ onClick={() => onAppClick?.(replacementApp)}
738
+ className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
713
739
  >
714
- <span className="text-muted-foreground shrink-0 tabular-nums">
715
- {index + 1}.
716
- </span>
717
- {url ? (
718
- <a
719
- href={url}
720
- target="_blank"
721
- rel="noopener noreferrer"
722
- className="hover:text-primary inline-flex min-w-0 items-center gap-1 truncate"
723
- >
724
- {displayUrl(url)}
725
- <ExternalLink className="size-3 shrink-0" />
726
- </a>
727
- ) : (
728
- <span className="text-muted-foreground">—</span>
729
- )}
730
- {pulseByUrl.has(url) && (
731
- <SourcePulse
732
- pulse={pulseByUrl.get(url)!}
733
- className="ml-auto"
734
- />
735
- )}
736
- </li>
737
- ))}
738
- </ul>
739
- )}
740
- </div>
741
-
742
- {/* Freshness now shown near the header (see "Updated …" above). */}
740
+ View replacement: {replacementApp.displayName}
741
+ <ExternalLink className="size-3" />
742
+ </button>
743
+ )}
744
+ </div>
745
+ )
746
+ })()}
747
+ </>
748
+ )
743
749
 
744
- <CommentsSection appSlug={app.slug} />
750
+ return (
751
+ <>
752
+ <div className="flex h-full flex-col">
753
+ <DetailTabs
754
+ tabs={tabs}
755
+ band={band}
756
+ // Cancels the dialog's own padding so the tint reaches the card's
757
+ // edges, then re-applies it inside.
758
+ bandClassName="-mx-6 -mt-5 px-6 pt-5 sm:-mx-8 sm:-mt-7 sm:px-8 sm:pt-7"
759
+ // `?sub=` means a child was picked out of a search, so the card opens
760
+ // on the tab that holds it rather than making them hunt for it.
761
+ forcedTab={subSlug && hasResourcesTab ? TAB.resources : undefined}
762
+ requestTab={composeRequests > 0 ? TAB.notes : undefined}
763
+ requestNonce={composeRequests}
764
+ />
745
765
  </div>
746
766
 
747
767
  {/* Screenshot Gallery Dialog */}