@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.
- package/dist/esm/__tests__/integration/harness/given.d.ts +1 -0
- package/dist/esm/__tests__/integration/skewWarning.integration.test.d.ts +0 -0
- package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +5 -0
- package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +15 -1
- package/dist/esm/__tests__/modules/appCatalog/utils/resolveHelpers.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.d.ts +40 -0
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js +40 -0
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +17 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +61 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +9 -2
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js +1 -1
- package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js.map +1 -1
- 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/components/SubResourcesSection.d.ts +9 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +12 -3
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +3 -2
- package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +5 -0
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +73 -18
- 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 +16 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +40 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.d.ts +19 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +389 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.d.ts +13 -0
- package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js +45 -0
- package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/monogram.d.ts +4 -0
- package/dist/esm/modules/appCatalog/ui/launcher/monogram.js +41 -0
- package/dist/esm/modules/appCatalog/ui/launcher/monogram.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +40 -6
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
- package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +10 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +17 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -1
- package/dist/esm/routeTree.gen.d.ts +1 -1
- package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
- package/dist/esm/routes/_layout/app._slug.js +4 -3
- package/dist/esm/routes/_layout/app._slug.js.map +1 -1
- package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
- package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
- package/dist/esm/routes/_layout/index.js +2 -1
- package/dist/esm/routes/_layout/index.js.map +1 -1
- package/dist/esm/routes/_layout/service-desks.js +2 -1
- package/dist/esm/routes/_layout/service-desks.js.map +1 -1
- package/dist/esm/types/uiSettings.d.ts +15 -0
- package/dist/esm/ui/components/header/Header.js +1 -1
- package/dist/esm/ui/components/header/Header.js.map +1 -1
- package/dist/index.css +73 -66
- package/package.json +4 -4
- package/src/__tests__/integration/appCatalog.integration.test.ts +106 -0
- package/src/__tests__/integration/appDeepLink.integration.test.ts +50 -24
- package/src/__tests__/integration/appFreshness.integration.test.ts +3 -3
- package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
- package/src/__tests__/integration/harness/given.tsx +9 -1
- package/src/__tests__/integration/replacementLink.integration.test.ts +6 -2
- package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +21 -16
- package/src/__tests__/integration/setup/testSetup.ts +1 -0
- package/src/__tests__/integration/skewWarning.integration.test.ts +56 -0
- package/src/__tests__/integration/tools/AppDetailTools.ts +13 -0
- package/src/__tests__/integration/tools/CatalogTools.ts +62 -11
- package/src/__tests__/integration/tools/GalleryTools.ts +7 -1
- package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +5 -5
- package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +79 -0
- package/src/index.css +73 -66
- package/src/modules/appCatalog/hooks/useSessionSyncedState.ts +83 -0
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +89 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +31 -2
- package/src/modules/appCatalog/ui/components/OnboardingCard.tsx +1 -1
- package/src/modules/appCatalog/ui/components/PersonBadge.tsx +103 -30
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +22 -1
- package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +11 -5
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +90 -23
- package/src/modules/appCatalog/ui/launcher/AttributionFooter.tsx +43 -0
- package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +52 -0
- package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +547 -0
- package/src/modules/appCatalog/ui/launcher/ResourceIcon.tsx +54 -0
- package/src/modules/appCatalog/ui/launcher/monogram.ts +41 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +75 -13
- package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
- package/src/modules/appCatalog/utils/resolveHelpers.ts +31 -0
- package/src/routes/_layout/app.$slug.tsx +7 -4
- package/src/routes/_layout/catalog.apps.index.tsx +4 -1
- package/src/routes/_layout/index.tsx +5 -1
- package/src/routes/_layout/service-desks.tsx +4 -1
- package/src/types/uiSettings.ts +15 -0
- package/src/ui/components/header/Header.tsx +3 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Check, Copy, User } from 'lucide-react'
|
|
1
|
+
import { Check, Copy, Mail, User } from 'lucide-react'
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
3
|
import { Badge } from '~/ui/badge'
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger } from '~/ui/popover'
|
|
4
5
|
import { useAppCatalogContext } from '~/modules/appCatalog'
|
|
5
6
|
import { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'
|
|
6
7
|
|
|
@@ -8,6 +9,12 @@ interface PersonBadgeProps {
|
|
|
8
9
|
slug: string
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Person chip. Clicking it opens a small popover exposing BOTH the full name
|
|
14
|
+
* and the email, each with its own copy button — so the user picks what to copy
|
|
15
|
+
* (previously a single button copied only the email). Email also links to
|
|
16
|
+
* mailto:. Falls back to just the name (no popover) when no email is known.
|
|
17
|
+
*/
|
|
11
18
|
export function PersonBadge({ slug }: PersonBadgeProps) {
|
|
12
19
|
const { persons } = useAppCatalogContext()
|
|
13
20
|
const person = getPersonBySlug(persons, slug)
|
|
@@ -15,55 +22,121 @@ export function PersonBadge({ slug }: PersonBadgeProps) {
|
|
|
15
22
|
const displayName = person
|
|
16
23
|
? `${person.firstName} ${person.lastName}`.trim() || slug
|
|
17
24
|
: slug
|
|
18
|
-
|
|
19
25
|
const email = person?.email
|
|
20
26
|
|
|
27
|
+
// No email → nothing to pick between; render a plain, non-interactive chip.
|
|
28
|
+
if (!email) {
|
|
29
|
+
return (
|
|
30
|
+
<Badge
|
|
31
|
+
variant="outline"
|
|
32
|
+
className="font-normal inline-flex items-center gap-1"
|
|
33
|
+
title={displayName}
|
|
34
|
+
>
|
|
35
|
+
<User className="size-3" />
|
|
36
|
+
{displayName}
|
|
37
|
+
</Badge>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
return (
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
<Popover>
|
|
43
|
+
<PopoverTrigger asChild>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
className="inline-flex"
|
|
47
|
+
title={`${displayName} · ${email}`}
|
|
48
|
+
>
|
|
49
|
+
<Badge
|
|
50
|
+
variant="outline"
|
|
51
|
+
className="font-normal inline-flex items-center gap-1 cursor-pointer hover:border-primary transition-colors"
|
|
52
|
+
>
|
|
53
|
+
<User className="size-3" />
|
|
54
|
+
{displayName}
|
|
55
|
+
<Copy className="size-3 opacity-50" />
|
|
56
|
+
</Badge>
|
|
57
|
+
</button>
|
|
58
|
+
</PopoverTrigger>
|
|
59
|
+
<PopoverContent align="start" className="w-64 p-2">
|
|
60
|
+
<CopyRow
|
|
61
|
+
icon={<User className="size-3.5" />}
|
|
62
|
+
label="Name"
|
|
63
|
+
value={displayName}
|
|
64
|
+
/>
|
|
65
|
+
<CopyRow
|
|
66
|
+
icon={<Mail className="size-3.5" />}
|
|
67
|
+
label="Email"
|
|
68
|
+
value={email}
|
|
69
|
+
href={`mailto:${email}`}
|
|
70
|
+
/>
|
|
71
|
+
</PopoverContent>
|
|
72
|
+
</Popover>
|
|
31
73
|
)
|
|
32
74
|
}
|
|
33
75
|
|
|
34
|
-
|
|
76
|
+
/** One copyable field (label + value + copy button), value optionally a link. */
|
|
77
|
+
function CopyRow({
|
|
78
|
+
icon,
|
|
79
|
+
label,
|
|
80
|
+
value,
|
|
81
|
+
href,
|
|
82
|
+
}: {
|
|
83
|
+
icon: React.ReactNode
|
|
84
|
+
label: string
|
|
85
|
+
value: string
|
|
86
|
+
href?: string
|
|
87
|
+
}) {
|
|
35
88
|
const [copied, setCopied] = useState(false)
|
|
36
89
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
37
90
|
|
|
38
|
-
useEffect(
|
|
39
|
-
|
|
91
|
+
useEffect(
|
|
92
|
+
() => () => {
|
|
40
93
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
41
|
-
}
|
|
42
|
-
|
|
94
|
+
},
|
|
95
|
+
[],
|
|
96
|
+
)
|
|
43
97
|
|
|
44
98
|
const handleCopy = useCallback(
|
|
45
99
|
(e: React.MouseEvent) => {
|
|
46
100
|
e.stopPropagation()
|
|
47
|
-
navigator.clipboard.writeText(
|
|
101
|
+
void navigator.clipboard.writeText(value)
|
|
48
102
|
setCopied(true)
|
|
49
103
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
50
104
|
timeoutRef.current = setTimeout(() => setCopied(false), 2000)
|
|
51
105
|
},
|
|
52
|
-
[
|
|
106
|
+
[value],
|
|
53
107
|
)
|
|
54
108
|
|
|
55
109
|
return (
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
className="
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
110
|
+
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted">
|
|
111
|
+
<span className="text-muted-foreground shrink-0">{icon}</span>
|
|
112
|
+
<span className="min-w-0 flex-1">
|
|
113
|
+
<span className="block text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
114
|
+
{label}
|
|
115
|
+
</span>
|
|
116
|
+
{href ? (
|
|
117
|
+
<a
|
|
118
|
+
href={href}
|
|
119
|
+
className="block truncate text-[13px] text-primary hover:underline"
|
|
120
|
+
>
|
|
121
|
+
{value}
|
|
122
|
+
</a>
|
|
123
|
+
) : (
|
|
124
|
+
<span className="block truncate text-[13px]">{value}</span>
|
|
125
|
+
)}
|
|
126
|
+
</span>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onClick={handleCopy}
|
|
130
|
+
className="shrink-0 rounded p-1 text-muted-foreground hover:text-primary hover:bg-background transition-colors"
|
|
131
|
+
title={copied ? 'Copied!' : `Copy ${label.toLowerCase()}`}
|
|
132
|
+
aria-label={`Copy ${label.toLowerCase()}`}
|
|
133
|
+
>
|
|
134
|
+
{copied ? (
|
|
135
|
+
<Check className="size-3.5 text-green-600" />
|
|
136
|
+
) : (
|
|
137
|
+
<Copy className="size-3.5" />
|
|
138
|
+
)}
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
68
141
|
)
|
|
69
142
|
}
|
|
@@ -25,6 +25,14 @@ import { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainT
|
|
|
25
25
|
|
|
26
26
|
interface SubResourcesSectionProps {
|
|
27
27
|
subResources: Resource[]
|
|
28
|
+
/**
|
|
29
|
+
* Initial filter text (#38 item C). When the user reached this app by
|
|
30
|
+
* searching a term that matched a sub-resource, seed the sub-resource filter
|
|
31
|
+
* with that term so the matched child is revealed instead of buried in a
|
|
32
|
+
* long list (e.g. searching "biom" → open AWS Console → the biomarker
|
|
33
|
+
* account is pre-filtered). Only applied when it actually matches a child.
|
|
34
|
+
*/
|
|
35
|
+
initialSearch?: string
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
function getTierBadgeVariant(
|
|
@@ -90,9 +98,22 @@ function CopyAccountIdButton({ accountId }: { accountId: string }) {
|
|
|
90
98
|
|
|
91
99
|
export function SubResourcesSection({
|
|
92
100
|
subResources,
|
|
101
|
+
initialSearch,
|
|
93
102
|
}: SubResourcesSectionProps) {
|
|
94
103
|
const { groups } = useAppCatalogContext()
|
|
95
|
-
|
|
104
|
+
// Seed the filter with the incoming query ONLY if it matches a child, so we
|
|
105
|
+
// reveal the matched sub-resource without hiding everything on a non-match.
|
|
106
|
+
const seededSearch = useMemo(() => {
|
|
107
|
+
const q = initialSearch?.trim().toLowerCase()
|
|
108
|
+
if (!q) return ''
|
|
109
|
+
const hit = subResources.some(
|
|
110
|
+
(sr) =>
|
|
111
|
+
sr.displayName.toLowerCase().includes(q) ||
|
|
112
|
+
(sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)),
|
|
113
|
+
)
|
|
114
|
+
return hit ? initialSearch!.trim() : ''
|
|
115
|
+
}, [initialSearch, subResources])
|
|
116
|
+
const [search, setSearch] = useState(seededSearch)
|
|
96
117
|
const [tierFilter, setTierFilter] = useState<string>('all')
|
|
97
118
|
|
|
98
119
|
const uniqueTiers = useMemo(() => {
|
|
@@ -2,6 +2,10 @@ import type { ReactNode } from 'react'
|
|
|
2
2
|
import { createContext, use, useMemo } from 'react'
|
|
3
3
|
import { useAppCatalogContext } from '../../context/AppCatalogContext'
|
|
4
4
|
import { useUrlSyncedState } from '../../hooks/useUrlSyncedState'
|
|
5
|
+
import {
|
|
6
|
+
SEARCH_STORAGE_KEY,
|
|
7
|
+
useSessionSyncedState,
|
|
8
|
+
} from '../../hooks/useSessionSyncedState'
|
|
5
9
|
import {
|
|
6
10
|
decodeFiltersParam,
|
|
7
11
|
encodeFiltersParam,
|
|
@@ -100,11 +104,13 @@ export function AppCatalogFiltersProvider({
|
|
|
100
104
|
encode: encodeFiltersParam,
|
|
101
105
|
})
|
|
102
106
|
|
|
103
|
-
// Search value
|
|
104
|
-
// e.g. auto-opening an app's detail page
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
// Search value persists in sessionStorage (not the URL) so it survives the
|
|
108
|
+
// per-route remount of this provider — e.g. auto-opening an app's detail page
|
|
109
|
+
// when the query narrows to one match (#10) — WITHOUT leaking `?q=` into every
|
|
110
|
+
// shared/bookmarked app link (#27). The synchronous store read/write also
|
|
111
|
+
// removes the effect-ordering race the URL sync had (#10).
|
|
112
|
+
const [searchValue, setSearchValue] = useSessionSyncedState<string>({
|
|
113
|
+
key: SEARCH_STORAGE_KEY,
|
|
108
114
|
defaultValue: '',
|
|
109
115
|
decode: (value) => value,
|
|
110
116
|
encode: (value) => (value === '' ? undefined : value),
|
|
@@ -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'
|
|
@@ -29,6 +29,9 @@ import {
|
|
|
29
29
|
TableRow,
|
|
30
30
|
} from '~/ui/table'
|
|
31
31
|
import { AccessRequestSection } from '../components/AccessRequestSection'
|
|
32
|
+
import { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'
|
|
33
|
+
import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
|
|
34
|
+
import { PersonBadge } from '../components/PersonBadge'
|
|
32
35
|
import { useUser } from '~/modules/auth'
|
|
33
36
|
import { InlineEditableField } from '../components/InlineEditableField'
|
|
34
37
|
import { MarkdownText } from '../components/MarkdownText'
|
|
@@ -173,6 +176,10 @@ function AppScreenshot({ app }: { app: Resource }) {
|
|
|
173
176
|
|
|
174
177
|
function TiersAndSubResourcesPanel({ app }: { app: Resource }) {
|
|
175
178
|
const { resources } = useAppCatalogContext()
|
|
179
|
+
// #38 item C: the active catalog search term. When the user reached this app
|
|
180
|
+
// by searching something that matched a child, seed the sub-resource filter
|
|
181
|
+
// with it so the matched child is revealed.
|
|
182
|
+
const { state: filterState } = useAppCatalogFilters()
|
|
176
183
|
const appSubResources = React.useMemo(
|
|
177
184
|
() => getChildResources(resources, app.slug),
|
|
178
185
|
[resources, app.slug],
|
|
@@ -187,14 +194,17 @@ function TiersAndSubResourcesPanel({ app }: { app: Resource }) {
|
|
|
187
194
|
)}
|
|
188
195
|
{appSubResources.length > 0 && (
|
|
189
196
|
<div className="mt-6">
|
|
190
|
-
<SubResourcesSection
|
|
197
|
+
<SubResourcesSection
|
|
198
|
+
subResources={appSubResources}
|
|
199
|
+
initialSearch={filterState.searchValue}
|
|
200
|
+
/>
|
|
191
201
|
</div>
|
|
192
202
|
)}
|
|
193
203
|
</>
|
|
194
204
|
)
|
|
195
205
|
}
|
|
196
206
|
|
|
197
|
-
function AppDetails({
|
|
207
|
+
export function AppDetails({
|
|
198
208
|
app,
|
|
199
209
|
onAppClick,
|
|
200
210
|
onClosePanel,
|
|
@@ -269,7 +279,7 @@ function AppDetails({
|
|
|
269
279
|
<AppIcon app={app} className="size-16" />
|
|
270
280
|
<div className="-mx-3 flex-1 min-w-0">
|
|
271
281
|
<div className="flex items-center gap-2 px-3">
|
|
272
|
-
<div className="text-2xl font-semibold min-w-0">
|
|
282
|
+
<div className="font-serif text-2xl font-semibold min-w-0">
|
|
273
283
|
{app.abbreviation
|
|
274
284
|
? `${app.displayName} (${app.abbreviation})`
|
|
275
285
|
: app.displayName}
|
|
@@ -333,15 +343,36 @@ function AppDetails({
|
|
|
333
343
|
target="_blank"
|
|
334
344
|
rel="noopener noreferrer"
|
|
335
345
|
onClick={() => recordClick(app.slug)}
|
|
336
|
-
className="inline-flex items-center gap-1 rounded-md py-1 text-
|
|
346
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs text-muted-foreground hover:border-primary hover:text-primary transition-all"
|
|
347
|
+
title="Open app in new tab (secondary — see access info below)"
|
|
337
348
|
>
|
|
338
349
|
{app.appUrl.replace(/https?:\/\//g, '')}
|
|
339
|
-
<ExternalLink className="size-3
|
|
350
|
+
<ExternalLink className="size-3 shrink-0 opacity-60" />
|
|
340
351
|
</a>
|
|
341
352
|
) : (
|
|
342
353
|
<span className="text-muted-foreground">—</span>
|
|
343
354
|
)}
|
|
344
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
|
+
)}
|
|
345
376
|
</div>
|
|
346
377
|
</div>
|
|
347
378
|
</div>
|
|
@@ -387,6 +418,13 @@ function AppDetails({
|
|
|
387
418
|
)
|
|
388
419
|
})()}
|
|
389
420
|
|
|
421
|
+
{/* Two-step access (#38 D): for a nested resource, show the parent-first
|
|
422
|
+
prerequisite chain before this resource's own access instructions. */}
|
|
423
|
+
<AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />
|
|
424
|
+
|
|
425
|
+
{/* Access Request Section — hero of the detail, shown before description */}
|
|
426
|
+
<AccessRequestSection app={app} approvalMethods={approvalMethods} />
|
|
427
|
+
|
|
390
428
|
{/* Description */}
|
|
391
429
|
<div className="mt-6">
|
|
392
430
|
<h3 className="mb-2 text-sm font-medium">Description</h3>
|
|
@@ -429,8 +467,17 @@ function AppDetails({
|
|
|
429
467
|
</div>
|
|
430
468
|
)}
|
|
431
469
|
|
|
432
|
-
{/*
|
|
433
|
-
|
|
470
|
+
{/* Owner — who is responsible for this resource. Distinct from the
|
|
471
|
+
access approver (who decides access requests); see domain model. */}
|
|
472
|
+
{app.ownerPersonSlug && (
|
|
473
|
+
<div className="mt-6">
|
|
474
|
+
<h3 className="mb-1 text-sm font-medium">Owner</h3>
|
|
475
|
+
<p className="mb-2 text-xs text-muted-foreground">
|
|
476
|
+
Who is responsible for this resource
|
|
477
|
+
</p>
|
|
478
|
+
<PersonBadge slug={app.ownerPersonSlug} />
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
434
481
|
|
|
435
482
|
{/* Tier Variants and Sub-Resources */}
|
|
436
483
|
<TiersAndSubResourcesPanel app={app} />
|
|
@@ -606,18 +653,7 @@ function AppDetails({
|
|
|
606
653
|
)}
|
|
607
654
|
</div>
|
|
608
655
|
|
|
609
|
-
{/* Freshness
|
|
610
|
-
backend owns the semantic (freshness.isStale); we only render. */}
|
|
611
|
-
{app.freshness?.lastCheckedAt && (
|
|
612
|
-
<p className="mt-4 text-xs text-muted-foreground">
|
|
613
|
-
<span title={app.freshness.lastCheckedAt}>
|
|
614
|
-
Last checked {formatRelativeTime(app.freshness.lastCheckedAt)}
|
|
615
|
-
</span>
|
|
616
|
-
{app.freshness.isStale && (
|
|
617
|
-
<span className="italic opacity-75"> · may be out of date</span>
|
|
618
|
-
)}
|
|
619
|
-
</p>
|
|
620
|
-
)}
|
|
656
|
+
{/* Freshness now shown near the header (see "Updated …" above). */}
|
|
621
657
|
</div>
|
|
622
658
|
|
|
623
659
|
{/* Screenshot Gallery Dialog */}
|
|
@@ -742,6 +778,9 @@ export function AppCatalogGrid({
|
|
|
742
778
|
onAppClick,
|
|
743
779
|
})
|
|
744
780
|
|
|
781
|
+
// Launch history for the secondary "open in new tab" action on each row.
|
|
782
|
+
const { recordClick: recordLaunch } = useAppClickHistory()
|
|
783
|
+
|
|
745
784
|
// Build a map of parentSlug -> matched child resource displayName for search annotation
|
|
746
785
|
const { resources: allResources2 } = useAppCatalogContext()
|
|
747
786
|
const matchedSubResourceMap = React.useMemo(() => {
|
|
@@ -847,8 +886,36 @@ export function AppCatalogGrid({
|
|
|
847
886
|
</div>
|
|
848
887
|
),
|
|
849
888
|
},
|
|
889
|
+
{
|
|
890
|
+
// Secondary "launch" action (#31): the primary row click opens the
|
|
891
|
+
// detail panel (how to get access); this quiet ↗ button is the fast
|
|
892
|
+
// "I just want the URL" jump. stopPropagation so it doesn't also open
|
|
893
|
+
// the panel. Only shown when the resource has a URL.
|
|
894
|
+
id: 'launch',
|
|
895
|
+
header: '',
|
|
896
|
+
cell: ({ row }) =>
|
|
897
|
+
row.original.appUrl ? (
|
|
898
|
+
<a
|
|
899
|
+
href={row.original.appUrl}
|
|
900
|
+
target="_blank"
|
|
901
|
+
rel="noopener noreferrer"
|
|
902
|
+
aria-label={`Open ${row.original.displayName} in a new tab`}
|
|
903
|
+
title={`Open ${row.original.displayName}`}
|
|
904
|
+
onClick={(e) => {
|
|
905
|
+
e.stopPropagation()
|
|
906
|
+
recordLaunch(row.original.slug)
|
|
907
|
+
}}
|
|
908
|
+
className="inline-flex size-8 items-center justify-center rounded-full border text-muted-foreground opacity-0 transition-opacity hover:border-primary hover:text-primary group-hover:opacity-100 focus-visible:opacity-100"
|
|
909
|
+
>
|
|
910
|
+
<ExternalLink className="size-4" />
|
|
911
|
+
</a>
|
|
912
|
+
) : null,
|
|
913
|
+
meta: {
|
|
914
|
+
className: 'w-[56px] text-right',
|
|
915
|
+
},
|
|
916
|
+
},
|
|
850
917
|
],
|
|
851
|
-
[searchQuery, matchedSubResourceMap],
|
|
918
|
+
[searchQuery, matchedSubResourceMap, recordLaunch],
|
|
852
919
|
)
|
|
853
920
|
|
|
854
921
|
// Create a single table instance with all apps
|
|
@@ -937,7 +1004,7 @@ export function AppCatalogGrid({
|
|
|
937
1004
|
className="px-4 py-6 sticky top-[49px] bg-muted/90 backdrop-blur z-10"
|
|
938
1005
|
>
|
|
939
1006
|
<div className="flex items-center justify-center">
|
|
940
|
-
<span className="font-
|
|
1007
|
+
<span className="font-serif font-semibold text-lg tracking-widest uppercase leading-loose text-muted-foreground">
|
|
941
1008
|
{group.groupName}
|
|
942
1009
|
</span>
|
|
943
1010
|
</div>
|
|
@@ -963,7 +1030,7 @@ export function AppCatalogGrid({
|
|
|
963
1030
|
}}
|
|
964
1031
|
onClick={() => handleAppClick(row.original)}
|
|
965
1032
|
className={cn(
|
|
966
|
-
'border-b cursor-pointer transition-colors',
|
|
1033
|
+
'group border-b cursor-pointer transition-colors',
|
|
967
1034
|
selectedApp?.id === row.original.id
|
|
968
1035
|
? 'bg-blue-100 dark:bg-blue-950 hover:bg-blue-200 dark:hover:bg-blue-900'
|
|
969
1036
|
: 'hover:bg-muted/30',
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { AppDetails } from '../grid/AppCatalogGrid'
|
|
3
|
+
|
|
4
|
+
/**
|
|
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).
|
|
13
|
+
*/
|
|
14
|
+
export function LauncherDetailPanel({
|
|
15
|
+
app,
|
|
16
|
+
onClose,
|
|
17
|
+
onAppClick,
|
|
18
|
+
}: {
|
|
19
|
+
app: Resource
|
|
20
|
+
onClose: () => void
|
|
21
|
+
onAppClick?: (app: Resource) => void
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
|
|
25
|
+
{/* scrim */}
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
aria-label="Close details panel"
|
|
29
|
+
onClick={onClose}
|
|
30
|
+
className="fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in"
|
|
31
|
+
/>
|
|
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
|
|
37
|
+
role="dialog"
|
|
38
|
+
aria-modal="true"
|
|
39
|
+
aria-label={`${app.displayName} details`}
|
|
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"
|
|
41
|
+
>
|
|
42
|
+
<div className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7">
|
|
43
|
+
<AppDetails
|
|
44
|
+
app={app}
|
|
45
|
+
onAppClick={onAppClick}
|
|
46
|
+
onClosePanel={onClose}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|