@igstack/app-catalog-frontend-core 0.11.0 → 0.13.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/ui/components/PersonBadge.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +95 -31
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +18 -9
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.d.ts +9 -0
- package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js +31 -0
- package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.d.ts +8 -4
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +6 -14
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +41 -7
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -1
- package/dist/esm/types/uiSettings.d.ts +15 -0
- package/dist/esm/ui/button.d.ts +1 -1
- package/package.json +4 -4
- package/src/__tests__/integration/appFreshness.integration.test.ts +3 -3
- package/src/__tests__/integration/tools/CatalogTools.ts +3 -1
- package/src/__tests__/integration/tools/GalleryTools.ts +7 -1
- package/src/modules/appCatalog/ui/components/PersonBadge.tsx +103 -30
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +22 -13
- package/src/modules/appCatalog/ui/launcher/AttributionFooter.tsx +43 -0
- package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +19 -22
- package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +60 -10
- package/src/types/uiSettings.ts +15 -0
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
getCoreRowModel,
|
|
9
9
|
useReactTable,
|
|
10
10
|
} from '@tanstack/react-table'
|
|
11
|
-
import { AppWindow, ExternalLink, Plus, Trash2, X } from 'lucide-react'
|
|
11
|
+
import { AppWindow, Clock, ExternalLink, Plus, Trash2, X } from 'lucide-react'
|
|
12
12
|
import React, { useState } from 'react'
|
|
13
13
|
import { useHotkeys } from 'react-hotkeys-hook'
|
|
14
14
|
import { cn } from '~/lib/utils'
|
|
@@ -353,6 +353,26 @@ export function AppDetails({
|
|
|
353
353
|
<span className="text-muted-foreground">—</span>
|
|
354
354
|
)}
|
|
355
355
|
</div>
|
|
356
|
+
{/* Freshness date near the header (this is the same signal that
|
|
357
|
+
drives "New this week"), so the date is visible in the card
|
|
358
|
+
itself, not only in the muted footer. */}
|
|
359
|
+
{app.freshness?.lastCheckedAt && (
|
|
360
|
+
<div className="mt-1 px-3">
|
|
361
|
+
<span
|
|
362
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
|
363
|
+
title={app.freshness.lastCheckedAt}
|
|
364
|
+
>
|
|
365
|
+
<Clock className="size-3" />
|
|
366
|
+
Updated {formatRelativeTime(app.freshness.lastCheckedAt)}
|
|
367
|
+
{app.freshness.isStale && (
|
|
368
|
+
<span className="italic opacity-75">
|
|
369
|
+
{' '}
|
|
370
|
+
· may be out of date
|
|
371
|
+
</span>
|
|
372
|
+
)}
|
|
373
|
+
</span>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
356
376
|
</div>
|
|
357
377
|
</div>
|
|
358
378
|
</div>
|
|
@@ -633,18 +653,7 @@ export function AppDetails({
|
|
|
633
653
|
)}
|
|
634
654
|
</div>
|
|
635
655
|
|
|
636
|
-
{/* Freshness
|
|
637
|
-
backend owns the semantic (freshness.isStale); we only render. */}
|
|
638
|
-
{app.freshness?.lastCheckedAt && (
|
|
639
|
-
<p className="mt-4 text-xs text-muted-foreground">
|
|
640
|
-
<span title={app.freshness.lastCheckedAt}>
|
|
641
|
-
Last checked {formatRelativeTime(app.freshness.lastCheckedAt)}
|
|
642
|
-
</span>
|
|
643
|
-
{app.freshness.isStale && (
|
|
644
|
-
<span className="italic opacity-75"> · may be out of date</span>
|
|
645
|
-
)}
|
|
646
|
-
</p>
|
|
647
|
-
)}
|
|
656
|
+
{/* Freshness now shown near the header (see "Updated …" above). */}
|
|
648
657
|
</div>
|
|
649
658
|
|
|
650
659
|
{/* Screenshot Gallery Dialog */}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ExternalLink, Github } from 'lucide-react'
|
|
2
|
+
import { useUiSettings } from '~/context/UiSettingsContext'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subtle attribution line for the home view (author + repo links). Content is
|
|
6
|
+
* supplied by the consuming app via `UiSettings.attribution` — the OSS core
|
|
7
|
+
* hard-codes nothing company-specific. Renders nothing when unset.
|
|
8
|
+
*
|
|
9
|
+
* `kind: 'oss'` links get a GitHub mark; everything else gets a generic
|
|
10
|
+
* external-link icon (proprietary repos, etc.).
|
|
11
|
+
*/
|
|
12
|
+
export function AttributionFooter() {
|
|
13
|
+
const { attribution } = useUiSettings()
|
|
14
|
+
if (!attribution || (!attribution.madeBy && !attribution.links?.length)) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<footer className="mt-16 border-t border-border/60 pt-5 text-center text-[12.5px] text-muted-foreground">
|
|
20
|
+
{attribution.madeBy && <span>{attribution.madeBy}</span>}
|
|
21
|
+
{attribution.links && attribution.links.length > 0 && (
|
|
22
|
+
<div className="mt-1.5 flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
|
23
|
+
{attribution.links.map((link) => (
|
|
24
|
+
<a
|
|
25
|
+
key={link.url}
|
|
26
|
+
href={link.url}
|
|
27
|
+
target="_blank"
|
|
28
|
+
rel="noopener noreferrer"
|
|
29
|
+
className="inline-flex items-center gap-1 hover:text-primary transition-colors"
|
|
30
|
+
>
|
|
31
|
+
{link.kind === 'oss' ? (
|
|
32
|
+
<Github className="size-3.5" />
|
|
33
|
+
) : (
|
|
34
|
+
<ExternalLink className="size-3.5" />
|
|
35
|
+
)}
|
|
36
|
+
{link.label}
|
|
37
|
+
</a>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
</footer>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
-
import { useEffect } from 'react'
|
|
3
2
|
import { AppDetails } from '../grid/AppCatalogGrid'
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
*
|
|
7
|
-
* AppDetails (access-hero, sub-resources, tiers
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Centered detail card for the launcher (#38, item B). Renders the rich
|
|
6
|
+
* AppDetails (access-hero, sub-resources, tiers) as a large centered modal card
|
|
7
|
+
* over the launcher backdrop, instead of the old grid + resizable split-pane.
|
|
8
|
+
*
|
|
9
|
+
* Escape handling: this component does NOT globally bind Escape. Escape-to-close
|
|
10
|
+
* is owned by AppDetails' own key handling (which first closes an open
|
|
11
|
+
* screenshot gallery, then closes the card), so pressing Esc inside the gallery
|
|
12
|
+
* returns to the card — not all the way to the home page (#38 Esc-stacking bug).
|
|
10
13
|
*/
|
|
11
14
|
export function LauncherDetailPanel({
|
|
12
15
|
app,
|
|
@@ -17,39 +20,33 @@ export function LauncherDetailPanel({
|
|
|
17
20
|
onClose: () => void
|
|
18
21
|
onAppClick?: (app: Resource) => void
|
|
19
22
|
}) {
|
|
20
|
-
// Close on Escape.
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
const onKey = (e: KeyboardEvent) => {
|
|
23
|
-
if (e.key === 'Escape') onClose()
|
|
24
|
-
}
|
|
25
|
-
document.addEventListener('keydown', onKey)
|
|
26
|
-
return () => document.removeEventListener('keydown', onKey)
|
|
27
|
-
}, [onClose])
|
|
28
|
-
|
|
29
23
|
return (
|
|
30
|
-
|
|
24
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
|
|
31
25
|
{/* scrim */}
|
|
32
26
|
<button
|
|
33
27
|
type="button"
|
|
34
28
|
aria-label="Close details panel"
|
|
35
29
|
onClick={onClose}
|
|
36
|
-
className="fixed inset-0 z-
|
|
30
|
+
className="fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in"
|
|
37
31
|
/>
|
|
38
|
-
{/*
|
|
39
|
-
|
|
32
|
+
{/* centered card — wide enough for data tables (sub-resources have 5
|
|
33
|
+
columns incl. "Access Contacts"/"AWS Account" that wrapped at 760px);
|
|
34
|
+
prose inside AppDetails is width-capped separately so it stays
|
|
35
|
+
readable. Caps at 94vw so it never touches the edges. */}
|
|
36
|
+
<div
|
|
40
37
|
role="dialog"
|
|
41
38
|
aria-modal="true"
|
|
42
39
|
aria-label={`${app.displayName} details`}
|
|
43
|
-
className="
|
|
40
|
+
className="relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200"
|
|
44
41
|
>
|
|
45
|
-
<div className="overflow-y-auto
|
|
42
|
+
<div className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7">
|
|
46
43
|
<AppDetails
|
|
47
44
|
app={app}
|
|
48
45
|
onAppClick={onAppClick}
|
|
49
46
|
onClosePanel={onClose}
|
|
50
47
|
/>
|
|
51
48
|
</div>
|
|
52
|
-
</
|
|
53
|
-
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
54
51
|
)
|
|
55
52
|
}
|
|
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
4
4
|
import { cn } from '~/lib/utils'
|
|
5
5
|
import { useAppClickHistory } from '../../hooks/useAppClickHistory'
|
|
6
6
|
import { markdownToPlainText } from '../../utils/markdownToPlainText'
|
|
7
|
+
import { AttributionFooter } from './AttributionFooter'
|
|
7
8
|
import { ResourceIcon } from './ResourceIcon'
|
|
8
9
|
import { searchResources } from '../../utils/searchApps'
|
|
9
10
|
|
|
@@ -16,8 +17,11 @@ import { searchResources } from '../../utils/searchApps'
|
|
|
16
17
|
* off to the results view (increment 2 refines the morph).
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// Type pill text. Plain "application" resources are the common case and don't
|
|
21
|
+
// need a badge (the user asked not to show a bare "App" badge); only surface a
|
|
22
|
+
// pill for distinctive types (database, cloud, service, …).
|
|
23
|
+
const typeLabel = (t?: string): string | null => {
|
|
24
|
+
if (!t || t === 'application') return null
|
|
21
25
|
return t.charAt(0).toUpperCase() + t.slice(1)
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -49,13 +53,16 @@ function LaunchButton({
|
|
|
49
53
|
className?: string
|
|
50
54
|
}) {
|
|
51
55
|
if (!app.appUrl) return null
|
|
56
|
+
// Show the destination URL on hover (user ask) — the native title tooltip
|
|
57
|
+
// reveals where the ↗ jumps to, e.g. "Open → console.aws.amazon.com".
|
|
58
|
+
const prettyUrl = app.appUrl.replace(/^https?:\/\//, '')
|
|
52
59
|
return (
|
|
53
60
|
<a
|
|
54
61
|
href={app.appUrl}
|
|
55
62
|
target="_blank"
|
|
56
63
|
rel="noopener noreferrer"
|
|
57
|
-
title={`Open ${
|
|
58
|
-
aria-label={`Open ${app.displayName} in a new tab`}
|
|
64
|
+
title={`Open → ${prettyUrl}`}
|
|
65
|
+
aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}
|
|
59
66
|
onClick={(e) => {
|
|
60
67
|
e.stopPropagation()
|
|
61
68
|
onLaunch(app)
|
|
@@ -134,9 +141,11 @@ function ResourceRow({
|
|
|
134
141
|
New
|
|
135
142
|
</span>
|
|
136
143
|
)}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
144
|
+
{typeLabel(app.type) && (
|
|
145
|
+
<span className="text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap">
|
|
146
|
+
{typeLabel(app.type)}
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
140
149
|
<LaunchButton app={app} onLaunch={onLaunch} className="size-8" />
|
|
141
150
|
</button>
|
|
142
151
|
)
|
|
@@ -167,6 +176,28 @@ function SearchResultsList({
|
|
|
167
176
|
[apps, searchValue],
|
|
168
177
|
)
|
|
169
178
|
|
|
179
|
+
// For each result, the child/sub-resources of it that themselves match the
|
|
180
|
+
// query — so a parent surfaced only via its children (e.g. AWS Console via a
|
|
181
|
+
// "biom" account) tells the user WHICH sub-resources matched, instead of an
|
|
182
|
+
// unexplained parent. Keyed by parent slug.
|
|
183
|
+
const matchedChildrenByParent = useMemo(() => {
|
|
184
|
+
const q = searchValue.trim().toLowerCase()
|
|
185
|
+
const map = new Map<string, Resource[]>()
|
|
186
|
+
if (!q) return map
|
|
187
|
+
const resultSlugs = new Set(results.map((r) => r.slug))
|
|
188
|
+
for (const r of apps) {
|
|
189
|
+
if (!r.parentSlug || !resultSlugs.has(r.parentSlug)) continue
|
|
190
|
+
const hay = `${r.displayName} ${r.abbreviation ?? ''} ${(
|
|
191
|
+
r.aliases ?? []
|
|
192
|
+
).join(' ')} ${r.description ?? ''}`.toLowerCase()
|
|
193
|
+
if (!hay.includes(q)) continue
|
|
194
|
+
const list = map.get(r.parentSlug) ?? []
|
|
195
|
+
list.push(r)
|
|
196
|
+
map.set(r.parentSlug, list)
|
|
197
|
+
}
|
|
198
|
+
return map
|
|
199
|
+
}, [apps, results, searchValue])
|
|
200
|
+
|
|
170
201
|
// #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there
|
|
171
202
|
// are no active matches for the query, only deprecated ones), surface a
|
|
172
203
|
// notice so the user understands why they're seeing archived tools. The
|
|
@@ -262,10 +293,27 @@ function SearchResultsList({
|
|
|
262
293
|
{markdownToPlainText(app.description)}
|
|
263
294
|
</span>
|
|
264
295
|
)}
|
|
296
|
+
{(() => {
|
|
297
|
+
const kids = matchedChildrenByParent.get(app.slug)
|
|
298
|
+
if (!kids || kids.length === 0) return null
|
|
299
|
+
const names = kids.map((k) => k.displayName)
|
|
300
|
+
const shown = names.slice(0, 3).join(', ')
|
|
301
|
+
const extra =
|
|
302
|
+
names.length > 3 ? ` +${names.length - 3} more` : ''
|
|
303
|
+
return (
|
|
304
|
+
<span className="mt-0.5 block text-[12px] text-primary truncate">
|
|
305
|
+
Matched {kids.length} sub-resource
|
|
306
|
+
{kids.length === 1 ? '' : 's'}: {shown}
|
|
307
|
+
{extra}
|
|
308
|
+
</span>
|
|
309
|
+
)
|
|
310
|
+
})()}
|
|
265
311
|
</span>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
312
|
+
{typeLabel(app.type) && (
|
|
313
|
+
<span className="text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0">
|
|
314
|
+
{typeLabel(app.type)}
|
|
315
|
+
</span>
|
|
316
|
+
)}
|
|
269
317
|
<LaunchButton
|
|
270
318
|
app={app}
|
|
271
319
|
onLaunch={onLaunch}
|
|
@@ -490,6 +538,8 @@ export function LauncherHome({
|
|
|
490
538
|
</div>
|
|
491
539
|
</div>
|
|
492
540
|
</section>
|
|
541
|
+
|
|
542
|
+
<AttributionFooter />
|
|
493
543
|
</>
|
|
494
544
|
)}
|
|
495
545
|
</div>
|
package/src/types/uiSettings.ts
CHANGED
|
@@ -13,4 +13,19 @@ export interface UiSettings {
|
|
|
13
13
|
frontendBuildId?: string
|
|
14
14
|
/** PWA auto-update configuration (idle timeout, check interval, debug) */
|
|
15
15
|
pwaAutoUpdate?: PwaAutoUpdateOptions
|
|
16
|
+
/**
|
|
17
|
+
* Optional attribution shown as a subtle footer line on the home view.
|
|
18
|
+
* Kept generic here (no hard-coded names/links) so the OSS core stays
|
|
19
|
+
* vendor-neutral; the consuming app supplies its own author + repo links.
|
|
20
|
+
*/
|
|
21
|
+
attribution?: {
|
|
22
|
+
/** e.g. "Made by <name>" — rendered verbatim. */
|
|
23
|
+
madeBy?: string
|
|
24
|
+
/** Labeled links; `kind` lets the UI hint open-source vs proprietary. */
|
|
25
|
+
links?: {
|
|
26
|
+
label: string
|
|
27
|
+
url: string
|
|
28
|
+
kind?: 'oss' | 'proprietary'
|
|
29
|
+
}[]
|
|
30
|
+
}
|
|
16
31
|
}
|