@igstack/app-catalog-frontend-core 0.10.1 → 0.12.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 (100) hide show
  1. package/dist/esm/__tests__/integration/harness/given.d.ts +1 -0
  2. package/dist/esm/__tests__/integration/skewWarning.integration.test.d.ts +0 -0
  3. package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +5 -0
  4. package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +15 -1
  5. package/dist/esm/__tests__/modules/appCatalog/utils/resolveHelpers.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.d.ts +40 -0
  7. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js +40 -0
  8. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js.map +1 -0
  9. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +17 -0
  10. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +61 -0
  11. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -0
  12. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +9 -2
  13. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js +1 -1
  15. package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.d.ts +6 -0
  17. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +95 -31
  18. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +9 -1
  20. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +12 -3
  21. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +3 -2
  23. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +5 -0
  25. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +73 -18
  26. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.d.ts +9 -0
  28. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js +31 -0
  29. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.d.ts +16 -0
  31. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +40 -0
  32. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.d.ts +19 -0
  34. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +389 -0
  35. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -0
  36. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.d.ts +13 -0
  37. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js +45 -0
  38. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js.map +1 -0
  39. package/dist/esm/modules/appCatalog/ui/launcher/monogram.d.ts +4 -0
  40. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js +41 -0
  41. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js.map +1 -0
  42. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +40 -6
  43. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  44. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
  45. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
  46. package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +10 -0
  47. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +17 -0
  48. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -1
  49. package/dist/esm/routeTree.gen.d.ts +1 -1
  50. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  51. package/dist/esm/routes/_layout/app._slug.js +4 -3
  52. package/dist/esm/routes/_layout/app._slug.js.map +1 -1
  53. package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
  54. package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
  55. package/dist/esm/routes/_layout/index.js +2 -1
  56. package/dist/esm/routes/_layout/index.js.map +1 -1
  57. package/dist/esm/routes/_layout/service-desks.js +2 -1
  58. package/dist/esm/routes/_layout/service-desks.js.map +1 -1
  59. package/dist/esm/types/uiSettings.d.ts +15 -0
  60. package/dist/esm/ui/components/header/Header.js +1 -1
  61. package/dist/esm/ui/components/header/Header.js.map +1 -1
  62. package/dist/index.css +73 -66
  63. package/package.json +4 -4
  64. package/src/__tests__/integration/appCatalog.integration.test.ts +106 -0
  65. package/src/__tests__/integration/appDeepLink.integration.test.ts +50 -24
  66. package/src/__tests__/integration/appFreshness.integration.test.ts +3 -3
  67. package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
  68. package/src/__tests__/integration/harness/given.tsx +9 -1
  69. package/src/__tests__/integration/replacementLink.integration.test.ts +6 -2
  70. package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +21 -16
  71. package/src/__tests__/integration/setup/testSetup.ts +1 -0
  72. package/src/__tests__/integration/skewWarning.integration.test.ts +56 -0
  73. package/src/__tests__/integration/tools/AppDetailTools.ts +13 -0
  74. package/src/__tests__/integration/tools/CatalogTools.ts +62 -11
  75. package/src/__tests__/integration/tools/GalleryTools.ts +7 -1
  76. package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +5 -5
  77. package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +79 -0
  78. package/src/index.css +73 -66
  79. package/src/modules/appCatalog/hooks/useSessionSyncedState.ts +83 -0
  80. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +89 -0
  81. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +31 -2
  82. package/src/modules/appCatalog/ui/components/OnboardingCard.tsx +1 -1
  83. package/src/modules/appCatalog/ui/components/PersonBadge.tsx +103 -30
  84. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +22 -1
  85. package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +11 -5
  86. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +90 -23
  87. package/src/modules/appCatalog/ui/launcher/AttributionFooter.tsx +43 -0
  88. package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +52 -0
  89. package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +547 -0
  90. package/src/modules/appCatalog/ui/launcher/ResourceIcon.tsx +54 -0
  91. package/src/modules/appCatalog/ui/launcher/monogram.ts +41 -0
  92. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +75 -13
  93. package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
  94. package/src/modules/appCatalog/utils/resolveHelpers.ts +31 -0
  95. package/src/routes/_layout/app.$slug.tsx +7 -4
  96. package/src/routes/_layout/catalog.apps.index.tsx +4 -1
  97. package/src/routes/_layout/index.tsx +5 -1
  98. package/src/routes/_layout/service-desks.tsx +4 -1
  99. package/src/types/uiSettings.ts +15 -0
  100. package/src/ui/components/header/Header.tsx +3 -1
@@ -0,0 +1,547 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { ArrowUpRight, Search } from 'lucide-react'
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+ import { cn } from '~/lib/utils'
5
+ import { useAppClickHistory } from '../../hooks/useAppClickHistory'
6
+ import { markdownToPlainText } from '../../utils/markdownToPlainText'
7
+ import { AttributionFooter } from './AttributionFooter'
8
+ import { ResourceIcon } from './ResourceIcon'
9
+ import { searchResources } from '../../utils/searchApps'
10
+
11
+ /**
12
+ * Adaptive-home discovery spine (issue #38, increment 1) — matches the
13
+ * option-a-launcher prototype:
14
+ * Your apps (frequent) → New this week → Browse all
15
+ * Rendered by AppCatalogPage when there is no active search and no open detail.
16
+ * The hero search box drives the same `searchValue` used elsewhere; typing hands
17
+ * off to the results view (increment 2 refines the morph).
18
+ */
19
+
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
25
+ return t.charAt(0).toUpperCase() + t.slice(1)
26
+ }
27
+
28
+ export interface LauncherHomeProps {
29
+ /** All root (non-child) resources, already filtered for deprecated per settings. */
30
+ apps: Resource[]
31
+ /**
32
+ * All resources INCLUDING children/sub-resources. Passed to search so a query
33
+ * matching a sub-resource (e.g. an AWS account, an alias/account ID) surfaces
34
+ * its parent — preserving the existing cross-sub-resource search behavior.
35
+ * Falls back to `apps` when omitted.
36
+ */
37
+ allResources?: Resource[]
38
+ searchValue: string
39
+ onSearchChange: (v: string) => void
40
+ onAppClick: (app: Resource) => void
41
+ onLaunch: (app: Resource) => void
42
+ /** Total resource count for the "Browse all" label. */
43
+ totalCount: number
44
+ }
45
+
46
+ function LaunchButton({
47
+ app,
48
+ onLaunch,
49
+ className,
50
+ }: {
51
+ app: Resource
52
+ onLaunch: (app: Resource) => void
53
+ className?: string
54
+ }) {
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?:\/\//, '')
59
+ return (
60
+ <a
61
+ href={app.appUrl}
62
+ target="_blank"
63
+ rel="noopener noreferrer"
64
+ title={`Open → ${prettyUrl}`}
65
+ aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}
66
+ onClick={(e) => {
67
+ e.stopPropagation()
68
+ onLaunch(app)
69
+ }}
70
+ className={cn(
71
+ 'grid place-items-center rounded-full border border-border bg-card text-muted-foreground',
72
+ 'hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors',
73
+ className,
74
+ )}
75
+ >
76
+ <ArrowUpRight className="size-3.5" />
77
+ </a>
78
+ )
79
+ }
80
+
81
+ function SectionHead({
82
+ title,
83
+ count,
84
+ action,
85
+ }: {
86
+ title: string
87
+ count?: string
88
+ action?: React.ReactNode
89
+ }) {
90
+ return (
91
+ <div className="flex items-center gap-2.5 mb-4">
92
+ <h2 className="font-serif font-semibold text-[15px] tracking-tight m-0">
93
+ {title}
94
+ </h2>
95
+ {count && (
96
+ <span className="text-[13px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5">
97
+ {count}
98
+ </span>
99
+ )}
100
+ {action && <div className="ml-auto">{action}</div>}
101
+ </div>
102
+ )
103
+ }
104
+
105
+ /** Small row used by "New this week" and "Browse all". */
106
+ function ResourceRow({
107
+ app,
108
+ onAppClick,
109
+ onLaunch,
110
+ showNew = false,
111
+ }: {
112
+ app: Resource
113
+ onAppClick: (app: Resource) => void
114
+ onLaunch: (app: Resource) => void
115
+ showNew?: boolean
116
+ }) {
117
+ return (
118
+ <button
119
+ type="button"
120
+ onClick={() => onAppClick(app)}
121
+ title={`View ${app.displayName}`}
122
+ className={cn(
123
+ 'group flex items-center gap-3.5 w-full text-left',
124
+ 'bg-card border border-border rounded-[var(--radius)] px-3.5 py-3',
125
+ 'hover:border-ring hover:shadow-sm transition-all',
126
+ )}
127
+ >
128
+ <ResourceIcon app={app} size={38} />
129
+ <span className="flex-1 min-w-0">
130
+ <span className="block text-[14.5px] font-semibold truncate">
131
+ {app.displayName}
132
+ </span>
133
+ {app.description && (
134
+ <span className="block text-[13px] text-muted-foreground truncate">
135
+ {markdownToPlainText(app.description)}
136
+ </span>
137
+ )}
138
+ </span>
139
+ {showNew && (
140
+ <span className="text-[11px] font-bold text-[#65a443] bg-[#65a443]/12 rounded-full px-2.5 py-0.5">
141
+ New
142
+ </span>
143
+ )}
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
+ )}
149
+ <LaunchButton app={app} onLaunch={onLaunch} className="size-8" />
150
+ </button>
151
+ )
152
+ }
153
+
154
+ /**
155
+ * Keyboard-navigable search results list — increment 2 of the launcher redesign.
156
+ * Appears below the hero search box when the user types, replacing the
157
+ * discovery spine. Pressing ↑↓ navigates, ↵ opens detail, Esc clears.
158
+ */
159
+ function SearchResultsList({
160
+ apps,
161
+ searchValue,
162
+ onAppClick,
163
+ onLaunch,
164
+ onClear,
165
+ }: {
166
+ apps: Resource[]
167
+ searchValue: string
168
+ onAppClick: (app: Resource) => void
169
+ onLaunch: (app: Resource) => void
170
+ onClear: () => void
171
+ }) {
172
+ const [focusedIndex, setFocusedIndex] = useState(-1)
173
+
174
+ const results = useMemo(
175
+ () => searchResources(apps, searchValue),
176
+ [apps, searchValue],
177
+ )
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
+
201
+ // #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there
202
+ // are no active matches for the query, only deprecated ones), surface a
203
+ // notice so the user understands why they're seeing archived tools. The
204
+ // deprecated apps are already in `results` because the launcher feeds search
205
+ // the full resource set; this just labels the situation.
206
+ const didDeprecatedFallback = useMemo(
207
+ () => results.length > 0 && results.every((r) => Boolean(r.deprecated)),
208
+ [results],
209
+ )
210
+
211
+ // Reset focus when results change
212
+ useEffect(() => {
213
+ setFocusedIndex(-1)
214
+ }, [searchValue])
215
+
216
+ useEffect(() => {
217
+ const onKey = (e: KeyboardEvent) => {
218
+ if (results.length === 0) return
219
+ if (e.key === 'ArrowDown') {
220
+ e.preventDefault()
221
+ setFocusedIndex((i) => Math.min(i + 1, results.length - 1))
222
+ } else if (e.key === 'ArrowUp') {
223
+ e.preventDefault()
224
+ setFocusedIndex((i) => Math.max(i - 1, 0))
225
+ } else if (e.key === 'Enter') {
226
+ if (focusedIndex >= 0 && results[focusedIndex]) {
227
+ onAppClick(results[focusedIndex])
228
+ }
229
+ } else if (e.key === 'Escape') {
230
+ onClear()
231
+ }
232
+ }
233
+ document.addEventListener('keydown', onKey)
234
+ return () => document.removeEventListener('keydown', onKey)
235
+ }, [results, focusedIndex, onAppClick, onClear])
236
+
237
+ return (
238
+ <div className="max-w-[620px] mx-auto mt-3">
239
+ {/* Meta line */}
240
+ <div className="text-[13px] text-muted-foreground mb-2 px-1">
241
+ {results.length === 0
242
+ ? `No results for "${searchValue}"`
243
+ : `${results.length} result${results.length === 1 ? '' : 's'}`}
244
+ </div>
245
+
246
+ {/* #11: deprecated-only fallback notice */}
247
+ {didDeprecatedFallback && (
248
+ <div
249
+ role="status"
250
+ className="text-[13px] text-muted-foreground mb-2 px-1"
251
+ >
252
+ No active apps for &quot;{searchValue}&quot; — showing deprecated
253
+ matches.
254
+ </div>
255
+ )}
256
+
257
+ {/* Results */}
258
+ <div
259
+ className="flex flex-col gap-1.5"
260
+ role="listbox"
261
+ aria-label="Search results"
262
+ >
263
+ {results.map((app, i) => (
264
+ <button
265
+ key={app.slug}
266
+ type="button"
267
+ role="option"
268
+ title={`View ${app.displayName}`}
269
+ aria-selected={focusedIndex === i}
270
+ onMouseEnter={() => setFocusedIndex(i)}
271
+ onClick={() => onAppClick(app)}
272
+ className={cn(
273
+ 'group flex items-center gap-3.5 w-full text-left',
274
+ 'bg-card border rounded-[var(--radius)] px-3.5 py-2.5',
275
+ 'transition-all',
276
+ focusedIndex === i
277
+ ? 'border-ring shadow-sm bg-muted/30'
278
+ : 'border-border hover:border-ring hover:shadow-sm',
279
+ )}
280
+ >
281
+ <ResourceIcon app={app} size={40} />
282
+ <span className="flex-1 min-w-0">
283
+ <span className="block text-[14px] font-semibold truncate">
284
+ {app.displayName}
285
+ {app.abbreviation && app.abbreviation !== app.displayName && (
286
+ <span className="ml-1.5 text-[12px] font-normal text-muted-foreground">
287
+ ({app.abbreviation})
288
+ </span>
289
+ )}
290
+ </span>
291
+ {app.description && (
292
+ <span className="block text-[12.5px] text-muted-foreground truncate">
293
+ {markdownToPlainText(app.description)}
294
+ </span>
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
+ })()}
311
+ </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
+ )}
317
+ <LaunchButton
318
+ app={app}
319
+ onLaunch={onLaunch}
320
+ className="size-7 shrink-0 opacity-0 group-hover:opacity-100"
321
+ />
322
+ </button>
323
+ ))}
324
+ </div>
325
+
326
+ {/* Keyboard hint */}
327
+ {results.length > 0 && (
328
+ <p className="text-[11.5px] text-muted-foreground/60 text-center mt-3">
329
+ ↑↓ navigate · ↵ view access · esc clear
330
+ </p>
331
+ )}
332
+ </div>
333
+ )
334
+ }
335
+
336
+ export function LauncherHome({
337
+ apps,
338
+ allResources,
339
+ searchValue,
340
+ onSearchChange,
341
+ onAppClick,
342
+ onLaunch,
343
+ totalCount,
344
+ }: LauncherHomeProps) {
345
+ const { getTopApps } = useAppClickHistory()
346
+ const [topSlugs, setTopSlugs] = useState<string[]>([])
347
+
348
+ useEffect(() => {
349
+ void getTopApps(5).then(setTopSlugs)
350
+ }, [getTopApps])
351
+
352
+ const bySlug = useMemo(() => {
353
+ const m = new Map<string, Resource>()
354
+ for (const a of apps) m.set(a.slug, a)
355
+ return m
356
+ }, [apps])
357
+
358
+ // Your apps: frequently-clicked (personalized). Empty on a cold new machine.
359
+ const frequent = useMemo(
360
+ () =>
361
+ topSlugs
362
+ .map((s) => bySlug.get(s))
363
+ .filter((a): a is Resource => Boolean(a))
364
+ .slice(0, 5),
365
+ [topSlugs, bySlug],
366
+ )
367
+
368
+ // New this week: apps whose sources were checked in the last 7 days, as a
369
+ // proxy for "recently added/updated" (frontend Resource has no createdAt yet;
370
+ // see #38). Falls back to empty (section hidden) when no freshness data.
371
+ const fresh = useMemo(() => {
372
+ const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
373
+ return apps
374
+ .filter((a) => {
375
+ const t = a.freshness?.lastCheckedAt
376
+ return t ? new Date(t).getTime() >= weekAgo : false
377
+ })
378
+ .sort((a, b) => {
379
+ const ta = new Date(a.freshness?.lastCheckedAt ?? 0).getTime()
380
+ const tb = new Date(b.freshness?.lastCheckedAt ?? 0).getTime()
381
+ return tb - ta
382
+ })
383
+ .slice(0, 6)
384
+ }, [apps])
385
+
386
+ // Browse all: everything not already promoted to "Your apps", alpha by name.
387
+ const browse = useMemo(() => {
388
+ const promoted = new Set(frequent.map((a) => a.slug))
389
+ return [...apps]
390
+ .filter((a) => !promoted.has(a.slug))
391
+ .sort((a, b) => a.displayName.localeCompare(b.displayName))
392
+ }, [apps, frequent])
393
+
394
+ const browseLeft = browse.filter((_, i) => i % 2 === 0)
395
+ const browseRight = browse.filter((_, i) => i % 2 === 1)
396
+
397
+ const isSearching = searchValue.trim() !== ''
398
+ const searchRef = useRef<HTMLInputElement>(null)
399
+
400
+ // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.
401
+ useEffect(() => {
402
+ const onKey = (e: KeyboardEvent) => {
403
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
404
+ e.preventDefault()
405
+ searchRef.current?.focus()
406
+ }
407
+ }
408
+ document.addEventListener('keydown', onKey)
409
+ return () => document.removeEventListener('keydown', onKey)
410
+ }, [])
411
+
412
+ return (
413
+ <div className="mx-auto w-full max-w-[1000px] pb-24">
414
+ {/* hero */}
415
+ <div className="text-center pt-10 pb-1.5">
416
+ <h1 className="font-serif font-semibold text-[30px] tracking-tight m-0 mb-1.5">
417
+ What do you need to get into?
418
+ </h1>
419
+ <p className="text-muted-foreground m-0 mb-6 text-[15px]">
420
+ Find any tool, see how to request access, or jump straight in.
421
+ </p>
422
+ <div className="max-w-[620px] mx-auto">
423
+ <div className="flex items-center gap-3 bg-card border-[1.5px] border-input rounded-full px-5 h-14 shadow-sm focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/15 transition-colors">
424
+ <Search className="size-5 shrink-0 text-muted-foreground" />
425
+ <input
426
+ type="text"
427
+ value={searchValue}
428
+ onChange={(e) => onSearchChange(e.target.value)}
429
+ placeholder="Search apps, databases, cloud accounts, account IDs…"
430
+ autoComplete="off"
431
+ autoFocus
432
+ ref={searchRef}
433
+ aria-label="Search apps"
434
+ className="flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground"
435
+ />
436
+ {!isSearching && (
437
+ <kbd className="hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0">
438
+ {typeof navigator !== 'undefined' &&
439
+ navigator.platform.includes('Mac')
440
+ ? '⌘K'
441
+ : 'Ctrl+K'}
442
+ </kbd>
443
+ )}
444
+ </div>
445
+ </div>
446
+ </div>
447
+
448
+ {/* Search morph: when typing, show compact results list instead of the discovery spine */}
449
+ {isSearching && (
450
+ <SearchResultsList
451
+ apps={allResources ?? apps}
452
+ searchValue={searchValue}
453
+ onAppClick={onAppClick}
454
+ onLaunch={onLaunch}
455
+ onClear={() => onSearchChange('')}
456
+ />
457
+ )}
458
+
459
+ {/* Discovery spine: only shown when not searching */}
460
+ {!isSearching && (
461
+ <>
462
+ {/* Your apps */}
463
+ {frequent.length > 0 && (
464
+ <section className="mt-9">
465
+ <SectionHead title="Your apps" count="frequent" />
466
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3.5">
467
+ {frequent.map((app) => (
468
+ <button
469
+ type="button"
470
+ key={app.slug}
471
+ onClick={() => onAppClick(app)}
472
+ title={`View ${app.displayName}`}
473
+ className="group relative bg-card border border-border rounded-[var(--radius)] px-3.5 pt-[18px] pb-4 text-center shadow-sm hover:-translate-y-0.5 hover:shadow-md transition-all"
474
+ >
475
+ <LaunchButton
476
+ app={app}
477
+ onLaunch={onLaunch}
478
+ className="absolute top-2.5 right-2.5 size-[26px] opacity-0 group-hover:opacity-100"
479
+ />
480
+ <ResourceIcon
481
+ app={app}
482
+ size={46}
483
+ className="mx-auto mb-3"
484
+ />
485
+ <span className="block text-[13.5px] font-bold truncate">
486
+ {app.displayName}
487
+ </span>
488
+ </button>
489
+ ))}
490
+ </div>
491
+ </section>
492
+ )}
493
+
494
+ {/* New this week */}
495
+ {fresh.length > 0 && (
496
+ <section className="mt-9">
497
+ <SectionHead
498
+ title="New this week"
499
+ count={`${fresh.length} added`}
500
+ />
501
+ <div className="flex flex-col gap-2">
502
+ {fresh.map((app) => (
503
+ <ResourceRow
504
+ key={app.slug}
505
+ app={app}
506
+ onAppClick={onAppClick}
507
+ onLaunch={onLaunch}
508
+ showNew
509
+ />
510
+ ))}
511
+ </div>
512
+ </section>
513
+ )}
514
+
515
+ {/* Browse all */}
516
+ <section className="mt-9">
517
+ <SectionHead title="Browse all" count={`${totalCount} resources`} />
518
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
519
+ <div className="flex flex-col gap-2">
520
+ {browseLeft.map((app) => (
521
+ <ResourceRow
522
+ key={app.slug}
523
+ app={app}
524
+ onAppClick={onAppClick}
525
+ onLaunch={onLaunch}
526
+ />
527
+ ))}
528
+ </div>
529
+ <div className="flex flex-col gap-2">
530
+ {browseRight.map((app) => (
531
+ <ResourceRow
532
+ key={app.slug}
533
+ app={app}
534
+ onAppClick={onAppClick}
535
+ onLaunch={onLaunch}
536
+ />
537
+ ))}
538
+ </div>
539
+ </div>
540
+ </section>
541
+
542
+ <AttributionFooter />
543
+ </>
544
+ )}
545
+ </div>
546
+ )
547
+ }
@@ -0,0 +1,54 @@
1
+ import { useState } from 'react'
2
+ import type { Resource } from '@igstack/app-catalog-backend-core'
3
+ import { cn } from '~/lib/utils'
4
+ import { monogramColor, monogramInitials } from './monogram'
5
+
6
+ /**
7
+ * Resource icon used across the launcher UI.
8
+ * - If the app has an `iconName`, render the served image (`/api/icons/<name>`).
9
+ * - Otherwise fall back to a flat crayon-colored monogram chip (prototype look),
10
+ * NOT a generic glyph, so every resource still has a recognizable "scent".
11
+ */
12
+ export function ResourceIcon({
13
+ app,
14
+ size = 40,
15
+ className,
16
+ rounded = 'rounded-[12px]',
17
+ }: {
18
+ app: Pick<Resource, 'iconName' | 'displayName' | 'abbreviation' | 'slug'>
19
+ size?: number
20
+ className?: string
21
+ rounded?: string
22
+ }) {
23
+ const [imageError, setImageError] = useState(false)
24
+ const dim = { width: size, height: size }
25
+
26
+ if (app.iconName && !imageError) {
27
+ return (
28
+ <img
29
+ src={`/api/icons/${app.iconName}`}
30
+ alt=""
31
+ aria-hidden="true"
32
+ style={dim}
33
+ className={cn(rounded, 'shrink-0 object-contain', className)}
34
+ onError={() => setImageError(true)}
35
+ />
36
+ )
37
+ }
38
+
39
+ const initials = monogramInitials(app.displayName, app.abbreviation)
40
+ const bg = monogramColor(app.slug || app.displayName)
41
+ return (
42
+ <span
43
+ aria-hidden="true"
44
+ style={{ ...dim, background: bg, fontSize: Math.round(size * 0.36) }}
45
+ className={cn(
46
+ rounded,
47
+ 'shrink-0 grid place-items-center font-semibold text-white font-serif tracking-tight select-none',
48
+ className,
49
+ )}
50
+ >
51
+ {initials}
52
+ </span>
53
+ )
54
+ }
@@ -0,0 +1,41 @@
1
+ // Deterministic crayon-palette monogram color for an app that has no icon.
2
+ // Mirrors the prototype's flat brand-color chips (option-a-launcher.html):
3
+ // a small warm palette drawn from the logo, picked stably by slug so the same
4
+ // app always gets the same color.
5
+
6
+ const CRAYON_PALETTE = [
7
+ '#dc6827', // orange
8
+ '#eca832', // amber
9
+ '#e4724a', // coral
10
+ '#f07a84', // pink
11
+ '#3782c2', // blue
12
+ '#388aca', // blue-2
13
+ '#65a443', // green
14
+ '#2c2622', // ink
15
+ ]
16
+
17
+ /** Stable hash → palette index, so a given slug always maps to one color. */
18
+ export function monogramColor(key: string): string {
19
+ let hash = 0
20
+ for (let i = 0; i < key.length; i++) {
21
+ hash = (hash * 31 + key.charCodeAt(i)) | 0
22
+ }
23
+ const idx = Math.abs(hash) % CRAYON_PALETTE.length
24
+ return CRAYON_PALETTE[idx] as string
25
+ }
26
+
27
+ /** Up-to-2-char initials from a display name / abbreviation. */
28
+ export function monogramInitials(name: string, abbreviation?: string): string {
29
+ const base = (abbreviation || name).trim()
30
+ if (!base) return '?'
31
+ // If an abbreviation is given, use its first 2 chars verbatim (e.g. "K8", "EH").
32
+ if (abbreviation) return abbreviation.slice(0, 2).toUpperCase()
33
+ const words = base
34
+ .replace(/[^A-Za-z0-9 ]/g, '')
35
+ .split(/\s+/)
36
+ .filter(Boolean)
37
+ if (words.length >= 2) {
38
+ return (words[0]![0]! + words[1]![0]!).toUpperCase()
39
+ }
40
+ return base.slice(0, 2).toUpperCase()
41
+ }