@igstack/app-catalog-frontend-core 0.4.0 → 0.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/modules/appCatalog/context/AppCatalogContext.js +0 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +1 -13
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/MarkdownText.d.ts +18 -0
- package/dist/esm/modules/appCatalog/ui/components/MarkdownText.js +26 -0
- package/dist/esm/modules/appCatalog/ui/components/MarkdownText.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +2 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
- package/package.json +3 -3
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +1 -18
- package/src/modules/appCatalog/ui/components/MarkdownText.tsx +42 -0
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +6 -3
- package/dist/esm/__tests__/modules/appCatalog/ScreenshotPreview.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/gallery/EscapeDismissal.integration.test.d.ts +0 -0
- package/dist/esm/modules/appCatalog/ui/components/AppDetailModal.d.ts +0 -7
- package/src/__tests__/modules/appCatalog/ScreenshotPreview.test.tsx +0 -63
- package/src/__tests__/modules/gallery/EscapeDismissal.integration.test.tsx +0 -118
- package/src/modules/appCatalog/ui/components/AppDetailModal.tsx +0 -365
|
@@ -1,365 +0,0 @@
|
|
|
1
|
-
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
-
import { AppWindowIcon, ExternalLinkIcon, XIcon } from 'lucide-react'
|
|
3
|
-
import React, { useEffect, useMemo, useState } from 'react'
|
|
4
|
-
import { Badge } from '~/ui/badge'
|
|
5
|
-
import { Button } from '~/ui/button'
|
|
6
|
-
import { ScrollArea } from '~/ui/scroll-area'
|
|
7
|
-
import { Separator } from '~/ui/separator'
|
|
8
|
-
import { useAppCatalogContext } from '~/modules/appCatalog'
|
|
9
|
-
import { ExternalLink } from '~/ui/link'
|
|
10
|
-
import {
|
|
11
|
-
Table,
|
|
12
|
-
TableBody,
|
|
13
|
-
TableCaption,
|
|
14
|
-
TableCell,
|
|
15
|
-
TableHead,
|
|
16
|
-
TableHeader,
|
|
17
|
-
TableRow,
|
|
18
|
-
} from '~/ui/table'
|
|
19
|
-
import { ScreenshotGallery } from './ScreenshotGallery'
|
|
20
|
-
import { TierVariantsSection } from './TierVariantsSection'
|
|
21
|
-
import { SubResourcesSection } from './SubResourcesSection'
|
|
22
|
-
import { getChildResources } from '~/modules/appCatalog/utils/resolveHelpers'
|
|
23
|
-
|
|
24
|
-
export interface AppDetailModalProps {
|
|
25
|
-
app: Resource
|
|
26
|
-
isOpen: boolean
|
|
27
|
-
onClose: () => void
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function getIconUrl(iconName: string): string {
|
|
31
|
-
return `/api/icons/${iconName}`
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function AppIcon({ app }: { app: Resource }) {
|
|
35
|
-
const [imageError, setImageError] = React.useState(false)
|
|
36
|
-
|
|
37
|
-
if (app.iconName && !imageError) {
|
|
38
|
-
return (
|
|
39
|
-
<img
|
|
40
|
-
src={getIconUrl(app.iconName)}
|
|
41
|
-
alt={`${app.abbreviation || app.displayName} icon`}
|
|
42
|
-
className="size-16 rounded-lg object-contain"
|
|
43
|
-
onError={() => setImageError(true)}
|
|
44
|
-
/>
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return (
|
|
49
|
-
<div className="flex items-center justify-center rounded-lg bg-primary/10 text-primary size-16">
|
|
50
|
-
<AppWindowIcon className="size-8" />
|
|
51
|
-
</div>
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function ScreenshotPreview({ app }: { app: Resource }) {
|
|
56
|
-
const [imageErrors, setImageErrors] = useState<Set<string>>(() => new Set())
|
|
57
|
-
const [galleryOpen, setGalleryOpen] = useState(false)
|
|
58
|
-
const [initialIndex, setInitialIndex] = useState(0)
|
|
59
|
-
|
|
60
|
-
const screenshotIds = app.screenshotIds || []
|
|
61
|
-
|
|
62
|
-
if (screenshotIds.length === 0) {
|
|
63
|
-
return (
|
|
64
|
-
<div className="w-full h-96 bg-muted/30 rounded-lg flex items-center justify-center text-muted-foreground">
|
|
65
|
-
No screenshots available
|
|
66
|
-
</div>
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const handleImageError = (id: string) => {
|
|
71
|
-
setImageErrors((prev) => new Set([...prev, id]))
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const handleScreenshotClick = (index: number) => {
|
|
75
|
-
setInitialIndex(index)
|
|
76
|
-
setGalleryOpen(true)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<>
|
|
81
|
-
<div className="space-y-4">
|
|
82
|
-
{screenshotIds.map((screenshotId, index) => {
|
|
83
|
-
const screenshotUrl = `/api/screenshots/${screenshotId}?size=600`
|
|
84
|
-
const hasError = imageErrors.has(screenshotId)
|
|
85
|
-
|
|
86
|
-
if (hasError) {
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return (
|
|
91
|
-
<div
|
|
92
|
-
key={screenshotId}
|
|
93
|
-
className="w-full rounded-lg overflow-hidden bg-muted/30 cursor-pointer hover:ring-2 hover:ring-primary transition-all"
|
|
94
|
-
onClick={() => handleScreenshotClick(index)}
|
|
95
|
-
role="button"
|
|
96
|
-
tabIndex={0}
|
|
97
|
-
onKeyDown={(e) => {
|
|
98
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
99
|
-
e.preventDefault()
|
|
100
|
-
handleScreenshotClick(index)
|
|
101
|
-
}
|
|
102
|
-
}}
|
|
103
|
-
>
|
|
104
|
-
<img
|
|
105
|
-
src={screenshotUrl}
|
|
106
|
-
alt={`${app.abbreviation || app.displayName} screenshot ${index + 1}`}
|
|
107
|
-
className="h-auto object-contain max-h-[600px]"
|
|
108
|
-
onError={() => handleImageError(screenshotId)}
|
|
109
|
-
/>
|
|
110
|
-
</div>
|
|
111
|
-
)
|
|
112
|
-
})}
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
<ScreenshotGallery
|
|
116
|
-
app={app}
|
|
117
|
-
screenshotIds={screenshotIds}
|
|
118
|
-
initialIndex={initialIndex}
|
|
119
|
-
open={galleryOpen}
|
|
120
|
-
onOpenChange={setGalleryOpen}
|
|
121
|
-
title={`${app.abbreviation || app.displayName} - Screenshots`}
|
|
122
|
-
/>
|
|
123
|
-
</>
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function AccessSection({ app }: { app: Resource }) {
|
|
128
|
-
const { approvalMethods } = useAppCatalogContext()
|
|
129
|
-
const { accessRequest } = app
|
|
130
|
-
if (!accessRequest) {
|
|
131
|
-
return null
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const approvalMethod = approvalMethods.find(
|
|
135
|
-
(m) => accessRequest.approvalMethodSlug === m.slug,
|
|
136
|
-
)
|
|
137
|
-
if (approvalMethod?.type !== 'service') {
|
|
138
|
-
return 'not service'
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return (
|
|
142
|
-
<div className="space-y-2">
|
|
143
|
-
<div className="text-sm font-medium">Access Method</div>
|
|
144
|
-
<div className="text-sm text-muted-foreground">
|
|
145
|
-
<ExternalLink
|
|
146
|
-
href={approvalMethod.config.url}
|
|
147
|
-
target="_blank"
|
|
148
|
-
rel="noopener noreferrer"
|
|
149
|
-
className={'inline-flex gap-1'}
|
|
150
|
-
>
|
|
151
|
-
{approvalMethod.displayName}
|
|
152
|
-
<ExternalLinkIcon className="size-3" />
|
|
153
|
-
</ExternalLink>
|
|
154
|
-
</div>
|
|
155
|
-
{accessRequest.roles && (
|
|
156
|
-
<>
|
|
157
|
-
<div className="text-sm font-medium">Roles</div>
|
|
158
|
-
<div className="text-sm text-muted-foreground">
|
|
159
|
-
<Table>
|
|
160
|
-
<TableCaption>A list of your recent invoices.</TableCaption>
|
|
161
|
-
<TableHeader>
|
|
162
|
-
<TableRow>
|
|
163
|
-
<TableHead className="w-[100px]">Role</TableHead>
|
|
164
|
-
<TableHead>Description</TableHead>
|
|
165
|
-
<TableHead>Notes</TableHead>
|
|
166
|
-
</TableRow>
|
|
167
|
-
</TableHeader>
|
|
168
|
-
<TableBody>
|
|
169
|
-
{accessRequest.roles.map((role) => (
|
|
170
|
-
<TableRow key={role.displayName}>
|
|
171
|
-
<TableCell className="font-medium">
|
|
172
|
-
{role.displayName}
|
|
173
|
-
</TableCell>
|
|
174
|
-
<TableCell>{role.description}</TableCell>
|
|
175
|
-
<TableCell>{role.adminNotes}</TableCell>
|
|
176
|
-
</TableRow>
|
|
177
|
-
))}
|
|
178
|
-
</TableBody>
|
|
179
|
-
</Table>
|
|
180
|
-
</div>
|
|
181
|
-
</>
|
|
182
|
-
)}
|
|
183
|
-
|
|
184
|
-
<pre>{JSON.stringify(accessRequest, null, 2)}</pre>
|
|
185
|
-
</div>
|
|
186
|
-
)
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function TiersAndSubResources({ app }: { app: Resource }) {
|
|
190
|
-
const { resources } = useAppCatalogContext()
|
|
191
|
-
const appSubResources = useMemo(
|
|
192
|
-
() => getChildResources(resources, app.slug),
|
|
193
|
-
[resources, app.slug],
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
return (
|
|
197
|
-
<>
|
|
198
|
-
{app.tiers && app.tiers.length > 0 && (
|
|
199
|
-
<TierVariantsSection tiers={app.tiers} />
|
|
200
|
-
)}
|
|
201
|
-
{appSubResources.length > 0 && (
|
|
202
|
-
<SubResourcesSection subResources={appSubResources} />
|
|
203
|
-
)}
|
|
204
|
-
</>
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export function AppDetailModal({ app, isOpen, onClose }: AppDetailModalProps) {
|
|
209
|
-
// Close on Escape key — but only if no inner layer (gallery) is handling it
|
|
210
|
-
useEffect(() => {
|
|
211
|
-
const handleEscape = (e: KeyboardEvent) => {
|
|
212
|
-
if (e.key === 'Escape' && !e.defaultPrevented) {
|
|
213
|
-
onClose()
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (isOpen) {
|
|
218
|
-
document.addEventListener('keydown', handleEscape)
|
|
219
|
-
// Prevent body scroll when modal is open
|
|
220
|
-
document.body.style.overflow = 'hidden'
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
return () => {
|
|
224
|
-
document.removeEventListener('keydown', handleEscape)
|
|
225
|
-
document.body.style.overflow = ''
|
|
226
|
-
}
|
|
227
|
-
}, [isOpen, onClose])
|
|
228
|
-
|
|
229
|
-
if (!isOpen) {
|
|
230
|
-
return null
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return (
|
|
234
|
-
<div
|
|
235
|
-
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
|
|
236
|
-
onClick={onClose}
|
|
237
|
-
>
|
|
238
|
-
{/* Modal Container with wide borders */}
|
|
239
|
-
<div className="relative h-full w-full flex items-center justify-center p-4 sm:p-8 lg:p-16">
|
|
240
|
-
{/* Scrollable Content with positioned buttons */}
|
|
241
|
-
<ScrollArea
|
|
242
|
-
className="relative h-full w-full max-w-5xl"
|
|
243
|
-
onClick={(e) => e.stopPropagation()}
|
|
244
|
-
>
|
|
245
|
-
{/* Close Button */}
|
|
246
|
-
<Button
|
|
247
|
-
variant="ghost"
|
|
248
|
-
size="icon"
|
|
249
|
-
className="absolute top-6 right-6 z-10 size-10 rounded-full bg-background/80 backdrop-blur hover:bg-background"
|
|
250
|
-
onClick={onClose}
|
|
251
|
-
>
|
|
252
|
-
<XIcon className="size-5" />
|
|
253
|
-
<span className="sr-only">Close</span>
|
|
254
|
-
</Button>
|
|
255
|
-
|
|
256
|
-
<div className="bg-background rounded-lg shadow-2xl border border-border p-8 space-y-8">
|
|
257
|
-
{/* App Details Section */}
|
|
258
|
-
<div className="space-y-6">
|
|
259
|
-
{/* Header with Icon and Title */}
|
|
260
|
-
<div className="flex items-start gap-4">
|
|
261
|
-
<AppIcon app={app} />
|
|
262
|
-
<div className="flex-1 space-y-2">
|
|
263
|
-
<h2 className="text-3xl font-bold">
|
|
264
|
-
{app.abbreviation || app.displayName}
|
|
265
|
-
</h2>
|
|
266
|
-
{app.abbreviation && (
|
|
267
|
-
<p className="text-sm text-muted-foreground">
|
|
268
|
-
{app.displayName}
|
|
269
|
-
</p>
|
|
270
|
-
)}
|
|
271
|
-
{app.appUrl && (
|
|
272
|
-
<a
|
|
273
|
-
href={app.appUrl}
|
|
274
|
-
target="_blank"
|
|
275
|
-
rel="noopener noreferrer"
|
|
276
|
-
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
|
277
|
-
>
|
|
278
|
-
{app.appUrl.replace(/^https?:\/\//g, '')}
|
|
279
|
-
<ExternalLinkIcon className="size-4" />
|
|
280
|
-
</a>
|
|
281
|
-
)}
|
|
282
|
-
</div>
|
|
283
|
-
</div>
|
|
284
|
-
|
|
285
|
-
{/* Description */}
|
|
286
|
-
{app.description && (
|
|
287
|
-
<div className="space-y-2">
|
|
288
|
-
<div className="text-sm font-medium">Description</div>
|
|
289
|
-
<p className="text-muted-foreground">{app.description}</p>
|
|
290
|
-
</div>
|
|
291
|
-
)}
|
|
292
|
-
|
|
293
|
-
{/* Access Section */}
|
|
294
|
-
<AccessSection app={app} />
|
|
295
|
-
|
|
296
|
-
{/* Tier Variants and Sub-Resources */}
|
|
297
|
-
<TiersAndSubResources app={app} />
|
|
298
|
-
|
|
299
|
-
{/* Tags */}
|
|
300
|
-
{app.tags && app.tags.length > 0 && (
|
|
301
|
-
<div className="space-y-2">
|
|
302
|
-
<div className="text-sm font-medium">Tags</div>
|
|
303
|
-
<div className="flex flex-wrap gap-2">
|
|
304
|
-
{app.tags.map((tag) => (
|
|
305
|
-
<Badge key={tag} variant="secondary">
|
|
306
|
-
{tag}
|
|
307
|
-
</Badge>
|
|
308
|
-
))}
|
|
309
|
-
</div>
|
|
310
|
-
</div>
|
|
311
|
-
)}
|
|
312
|
-
|
|
313
|
-
{/* Teams */}
|
|
314
|
-
{app.teams && app.teams.length > 0 && (
|
|
315
|
-
<div className="space-y-2">
|
|
316
|
-
<div className="text-sm font-medium">Teams</div>
|
|
317
|
-
<div className="flex flex-wrap gap-2">
|
|
318
|
-
{app.teams.map((team) => (
|
|
319
|
-
<Badge key={team} variant="outline">
|
|
320
|
-
{team}
|
|
321
|
-
</Badge>
|
|
322
|
-
))}
|
|
323
|
-
</div>
|
|
324
|
-
</div>
|
|
325
|
-
)}
|
|
326
|
-
|
|
327
|
-
{/* Links */}
|
|
328
|
-
{app.links && app.links.length > 0 && (
|
|
329
|
-
<div className="space-y-2">
|
|
330
|
-
<div className="text-sm font-medium">Related Links</div>
|
|
331
|
-
<div className="space-y-1">
|
|
332
|
-
{app.links.map((link) => (
|
|
333
|
-
<a
|
|
334
|
-
key={link.url}
|
|
335
|
-
href={link.url}
|
|
336
|
-
target="_blank"
|
|
337
|
-
rel="noopener noreferrer"
|
|
338
|
-
className="flex text-sm text-primary hover:underline items-center gap-1"
|
|
339
|
-
>
|
|
340
|
-
{link.title || link.url}
|
|
341
|
-
<ExternalLink className="size-3" />
|
|
342
|
-
</a>
|
|
343
|
-
))}
|
|
344
|
-
</div>
|
|
345
|
-
</div>
|
|
346
|
-
)}
|
|
347
|
-
|
|
348
|
-
{/* Notes */}
|
|
349
|
-
{app.notes && (
|
|
350
|
-
<div className="space-y-2">
|
|
351
|
-
<div className="text-sm font-medium">Notes</div>
|
|
352
|
-
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
|
353
|
-
{app.notes}
|
|
354
|
-
</p>
|
|
355
|
-
</div>
|
|
356
|
-
)}
|
|
357
|
-
</div>
|
|
358
|
-
<Separator />
|
|
359
|
-
<ScreenshotPreview app={app} />
|
|
360
|
-
</div>
|
|
361
|
-
</ScrollArea>
|
|
362
|
-
</div>
|
|
363
|
-
</div>
|
|
364
|
-
)
|
|
365
|
-
}
|