@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.
Files changed (25) hide show
  1. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.d.ts +6 -0
  2. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +95 -31
  3. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  4. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +18 -9
  5. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  6. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.d.ts +9 -0
  7. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js +31 -0
  8. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js.map +1 -0
  9. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.d.ts +8 -4
  10. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +6 -14
  11. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -1
  12. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +41 -7
  13. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -1
  14. package/dist/esm/types/uiSettings.d.ts +15 -0
  15. package/dist/esm/ui/button.d.ts +1 -1
  16. package/package.json +4 -4
  17. package/src/__tests__/integration/appFreshness.integration.test.ts +3 -3
  18. package/src/__tests__/integration/tools/CatalogTools.ts +3 -1
  19. package/src/__tests__/integration/tools/GalleryTools.ts +7 -1
  20. package/src/modules/appCatalog/ui/components/PersonBadge.tsx +103 -30
  21. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +22 -13
  22. package/src/modules/appCatalog/ui/launcher/AttributionFooter.tsx +43 -0
  23. package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +19 -22
  24. package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +60 -10
  25. 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: muted "last checked" line, rarely-needed metadata. The
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
- * Slide-over detail panel for the launcher (#38, item B). Renders the rich
7
- * AppDetails (access-hero, sub-resources, tiers — increments 3/4) as a right
8
- * slide-over over the launcher backdrop, matching the option-a prototype,
9
- * instead of dropping the user into the old grid + resizable split-pane.
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-40 bg-black/30 backdrop-blur-[2px] animate-in fade-in"
30
+ className="fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in"
37
31
  />
38
- {/* panel */}
39
- <aside
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="fixed right-0 top-0 z-50 h-full w-[min(560px,100%)] bg-background shadow-2xl border-l border-border flex flex-col animate-in slide-in-from-right duration-200"
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 flex-1 px-6 py-5">
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
- </aside>
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
- const typeLabel = (t?: string): string => {
20
- if (!t || t === 'application') return 'App'
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 ${app.displayName} in a new tab`}
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
- <span className="text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap">
138
- {typeLabel(app.type)}
139
- </span>
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
- <span className="text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0">
267
- {typeLabel(app.type)}
268
- </span>
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>
@@ -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
  }