@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
- package/dist/esm/api/infra/trpc.d.ts +42 -21
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +19 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.d.ts +14 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +90 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +18 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
- package/dist/index.css +269 -5
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
- package/src/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.tsx +113 -0
- package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +193 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
- package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
- package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
- package/src/index.css +269 -5
- package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +24 -0
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
- package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
- package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +195 -0
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +20 -1
- package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
- package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
- /package/dist/esm/__tests__/modules/appCatalog/ui/{detail/CommentsSection.emptyState.test.d.ts → catalog/RequestAppDialog.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.doorAndTabs.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
|
-
|
|
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 {
|
|
34
|
+
import { FeedbackSection } from './FeedbackSection'
|
|
48
35
|
import { displayUrl } from '~/modules/appCatalog/utils/displayUrl'
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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={
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
368
|
+
key={link.url}
|
|
369
|
+
href={link.url}
|
|
317
370
|
target="_blank"
|
|
318
371
|
rel="noopener noreferrer"
|
|
319
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
</
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
<
|
|
503
|
-
<
|
|
504
|
-
<
|
|
505
|
-
{app.
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
</
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
<
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
<
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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 */}
|