@igstack/app-catalog-frontend-core 6.8.0 → 6.8.2

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.
@@ -128,13 +128,16 @@ function AppDetails({
128
128
  const tag = (_a2 = document.activeElement) == null ? void 0 : _a2.tagName;
129
129
  if (tag === "BUTTON" || tag === "A" || tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA")
130
130
  return;
131
- if (app.screenshotIds && app.screenshotIds.length > 0) {
131
+ if (app.appUrl) {
132
+ recordClick(app.slug);
133
+ window.open(app.appUrl, "_blank", "noopener,noreferrer");
134
+ } else if (app.screenshotIds && app.screenshotIds.length > 0) {
132
135
  setGalleryInitialIndex(0);
133
136
  setIsGalleryOpen(true);
134
137
  }
135
138
  },
136
139
  { enabled: !isGalleryOpen },
137
- [app, isGalleryOpen]
140
+ [app, isGalleryOpen, recordClick]
138
141
  );
139
142
  const handleScreenshotClick = (index) => {
140
143
  setGalleryInitialIndex(index);
@@ -1 +1 @@
1
- {"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type {\n Resource,\n SourceReference,\n} from '@igstack/app-catalog-backend-core'\nimport { useSearch } from '@tanstack/react-router'\nimport { CalendarPlus, Clock, ExternalLink, Plus, Trash2 } from 'lucide-react'\nimport React from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'\nimport { QuickJumpBar, hasQuickJumps } from '../components/QuickJumpBar'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { PersonBadge } from '../components/PersonBadge'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { SourcePulse } from '../components/SourcePulse'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { ResourceDetailAccessActions } from '~/modules/extensions'\nimport { FeedbackSection } from './FeedbackSection'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\nimport { accessTabLabel } from '~/modules/appCatalog/utils/accessTabLabel'\nimport { AppDoor } from './AppDoor'\nimport { DetailTabs } from './DetailTabs'\nimport type { DetailTab } from './DetailTabs'\n\n/** Tab ids. Public — they land in `?tab=`, so renaming one breaks links. */\nconst TAB = {\n overview: 'overview',\n access: 'access',\n resources: 'resources',\n documentation: 'documentation',\n notes: 'notes',\n} as const\n\n/** A labelled block inside a tab. One register for all of them. */\nfunction Section({\n title,\n hint,\n first,\n children,\n}: {\n title: string\n hint?: string\n /** Drops the top margin for the first block in a panel. */\n first?: boolean\n children: React.ReactNode\n}) {\n return (\n <div className={first ? '' : 'mt-7'}>\n <h3 className=\"section-label mb-1.5\">{title}</h3>\n {hint && <p className=\"mb-2 text-xs text-muted-foreground\">{hint}</p>}\n {children}\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n /**\n * Set by the band's \"Suggest a change\", which has to do two things the button\n * cannot do alone: bring the notes tab forward, and open the composer inside it.\n * Held here because the band and the panel are siblings — neither can reach the\n * other, and routing it through the URL would put a transient intent in a\n * shareable link.\n *\n * A count, not a flag: the composer closes itself on send, so asking a second\n * time has to be distinguishable from the first ask still being set. As a\n * boolean the second click changed nothing and the button was dead for the rest\n * of the card's life.\n */\n const [composeRequests, setComposeRequests] = React.useState(0)\n\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n const { state: filterState } = useAppCatalogFilters()\n\n // Arriving with `?sub=` means the visitor picked one child out of a search —\n // so the card has to open ON that child's tab. Landing on Overview and\n // leaving them to find the tab is the bug this scenario exists to prevent.\n const search = useSearch({ strict: false })\n const subSlug = search.sub\n\n const displayTags = (app.tags ?? []).filter((tag) => !tag.includes(':'))\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n // The list renders bare URLs (they are editable as text), so the pulse has to\n // be looked up rather than carried alongside. Absent until the autoupdate loop\n // has seen the source — a freshly added one simply gets no mark.\n const pulseByUrl = React.useMemo(() => {\n const byUrl = new Map<string, NonNullable<SourceReference['pulse']>>()\n for (const source of app.sources ?? []) {\n if (typeof source !== 'string' && source.pulse) {\n byUrl.set(source.url, source.pulse)\n }\n }\n return byUrl\n }, [app.sources])\n\n const children = React.useMemo(\n () => getChildResources(allResources, app.slug),\n [allResources, app.slug],\n )\n\n // `app` is whatever resource the card shows, a nested one included, so the\n // parent has to be looked up rather than assumed absent. A plugin uses its\n // presence to tell a sub-resource view from a top-level one.\n const parent = React.useMemo(\n () =>\n app.parentSlug\n ? allResources.find((r) => r.slug === app.parentSlug)\n : undefined,\n [allResources, app.parentSlug],\n )\n\n // What a plugin slot in the band receives. Memoised so a plugin that\n // memoises on its props is not defeated by a new object every render.\n const accessActionsCtx = React.useMemo(\n () => ({\n resource: app,\n parent,\n subResources: parent\n ? getChildResources(allResources, parent.slug)\n : children,\n user: user ? { email: user.email, isAdmin } : null,\n }),\n [app, parent, allResources, children, user, isAdmin],\n )\n\n // Enter: open screenshot gallery\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n const access = accessTabLabel(app, approvalMethods)\n\n const hasResourcesTab = children.length > 0 || (app.tiers?.length ?? 0) > 0\n const resourcesLabel = children.length\n ? (app.childrenLabel ?? 'Resources')\n : 'Environments'\n\n const tabs: DetailTab[] = [\n {\n id: TAB.overview,\n label: 'Overview',\n render: () => (\n <>\n <Section title=\"Description\" first>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </Section>\n\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <Section title={`Screenshots (${app.screenshotIds.length})`}>\n {/* A button, not a clickable div: the gallery was unreachable by\n keyboard, and a role + label gives it a stable handle. */}\n <button\n type=\"button\"\n aria-label={`View screenshots of ${app.displayName}`}\n className=\"block w-full cursor-pointer text-left hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </button>\n </Section>\n )}\n\n {/* Owner — who is responsible for this resource. Distinct from the\n access approver (who decides access requests); see domain model. */}\n {app.ownerPersonSlug && (\n <Section title=\"Owner\" hint=\"Who is responsible for this resource\">\n <PersonBadge slug={app.ownerPersonSlug} />\n </Section>\n )}\n\n {/* Tags, minus the indexing machinery: `namespace:value` tags drive\n grouping, faceting and placement, are about half of all tag\n references, and say nothing about what an app is for. The colon is\n the test. Search still matches them — only the display drops them. */}\n {displayTags.length > 0 && (\n <Section title=\"Tags\">\n <div className=\"flex flex-wrap gap-2\">\n {displayTags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </Section>\n )}\n\n {app.teams && app.teams.length > 0 && (\n <Section title=\"Teams\">\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </Section>\n )}\n </>\n ),\n },\n {\n id: TAB.access,\n label: `Access — ${access.route}`,\n chip: access.chip,\n render: () => (\n <>\n {/* Two-step access: for a nested resource, show the parent-first\n prerequisite chain before this resource's own instructions. */}\n <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n </>\n ),\n },\n ...(hasResourcesTab\n ? [\n {\n id: TAB.resources,\n label: resourcesLabel,\n chip: children.length ? String(children.length) : undefined,\n render: () => (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <TierVariantsSection tiers={app.tiers} />\n )}\n {children.length > 0 && (\n <div className={app.tiers?.length ? 'mt-7' : ''}>\n {/* The active catalog search term seeds the filter, so a\n visitor who got here by searching something that matched\n a child sees that child rather than all of its siblings. */}\n <SubResourcesSection\n subResources={children}\n parentSlug={app.slug}\n parent={app}\n initialSearch={filterState.searchValue}\n childrenLabel={app.childrenLabel}\n />\n </div>\n )}\n </>\n ),\n } satisfies DetailTab,\n ]\n : []),\n {\n id: TAB.documentation,\n label: 'Documentation',\n render: () => (\n <>\n {app.links && app.links.length > 0 && (\n <Section title=\"Documentation\" first>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || displayUrl(link.url)}\n </a>\n ))}\n </div>\n </Section>\n )}\n {/* Background: why the app exists at all. Deliberately quiet — a\n reader who came to get access has already passed everything they\n need, and history should not compete with the description. */}\n {app.background && (\n <Section title=\"Background\">\n <MarkdownText className=\"prose prose-sm text-muted-foreground max-w-none text-sm [&_p]:mt-0 [&_p]:mb-2 [&_p:last-child]:mb-0\">\n {app.background}\n </MarkdownText>\n </Section>\n )}\n\n <Section title=\"Sources\" first={!app.background}>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(val)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n // `group` so hovering anywhere on the row fades that source's\n // track in — the mark sits in a right-hand column, and row\n // hover is what ties the two ends of a wide row together.\n <li\n key={index}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex min-w-0 items-center gap-1 truncate\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n {pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n </li>\n ))}\n </ul>\n )}\n </Section>\n\n {/* These used to sit unlabelled in a 64px gap between two sections,\n which is what made that gap look like a mistake. Labelled, in\n their own block. \"Dates\" rather than \"Added / updated\" on purpose:\n a label that repeats the first word of every line below it adds\n nothing, and it made \"Added\"/\"Updated\" ambiguous to anything\n matching on text — including this card's own tests. */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <Section title=\"Dates\">\n <div className=\"flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check\n ran — entries predating the field have none, so fall\n back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n </Section>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-7\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"section-label mb-1\">AI Prompt</h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"section-label mb-1\">AI Memory</h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n </>\n ),\n },\n {\n id: TAB.notes,\n label: 'Notes & requests',\n render: () => (\n <FeedbackSection appSlug={app.slug} openComposer={composeRequests} />\n ),\n },\n ]\n\n // The hero band: identity, the way in, and the typed-id jumps. It does NOT\n // scroll away with a tab, so \"take me to the app\" is reachable from every one\n // of them.\n //\n // Deliberately holds nothing else. An owner/teams/freshness strip was drawn\n // and dropped: `ownerPersonSlug` is set on a tiny fraction of entries so the\n // owner slot would almost always be empty, teams already has its own block in\n // Overview, and on an entry with quick jumps the band had grown past 250px\n // before any content — on exactly the apps where people most want to type an\n // id and leave.\n const band = (\n <>\n <div className=\"flex flex-wrap items-center gap-x-7 gap-y-2\">\n <AppDoor app={app} onOpen={() => recordClick(app.slug)} />\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n {/* In the band, not at the foot of the notes panel.\n Down there it was invisible twice over: below the fold of a panel, and\n behind a tab nobody opens unless they already know what is in it — so\n the one affordance the feature exists for was the hardest thing on the\n card to find. Up here it is present on every tab, which is the whole\n reason the band does not scroll away.\n `mr-10` keeps it clear of the dialog's own close button at top-right. */}\n {/* Plugin actions sit beside \"Suggest a change\", sharing its reasoning:\n the band is mounted on every tab and never scrolls away. A slot in a\n tab panel would be invisible until a second click, and one inside\n SubResourceDetailPanel's `hasAnyAccess` gate would vanish for exactly\n the resources that need help most.\n Renders nothing when no plugin contributes, so the band keeps its\n current shape in the open-source build. */}\n <div className=\"ml-auto mr-10 flex items-center gap-2\">\n <ResourceDetailAccessActions {...accessActionsCtx} />\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n onClick={() => setComposeRequests((n) => n + 1)}\n >\n ✎ Suggest a change\n </Button>\n </div>\n </div>\n\n {app.nicknames && app.nicknames.length > 0 && (\n <p className=\"mt-2.5 text-xs text-muted-foreground\">\n Also known as {app.nicknames.join(', ')}\n </p>\n )}\n\n {isAdmin && (\n <div className=\"mt-3 flex flex-wrap items-center gap-x-6 gap-y-1\">\n <span className=\"inline-flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">Slug:</span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </span>\n <span className=\"inline-flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">URL:</span>\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n className=\"text-sm\"\n />\n </span>\n </div>\n )}\n\n {hasQuickJumps(app) && (\n <div className=\"mt-4\">\n {/* Named, where the bare control was not: \"or go straight to a\n record\" says what the field is for before you reach it. */}\n <p className=\"mb-1.5 text-xs text-muted-foreground\">\n Or go straight to a record:\n </p>\n <QuickJumpBar app={app} />\n </div>\n )}\n\n {/* Deprecation stays in the band, not in a tab: \"do not use this\"\n must not be something you have to go looking for. */}\n {app.deprecated &&\n (() => {\n const isDiscouraged = app.deprecated.type === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-4 rounded-lg border border-yellow-500/50 bg-yellow-50 p-4 dark:bg-yellow-950/20'\n : 'mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'section-label mb-1.5 text-yellow-700 dark:text-yellow-500'\n : 'section-label mb-1.5 text-destructive'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n </>\n )\n\n return (\n <>\n <div className=\"flex h-full flex-col\">\n <DetailTabs\n tabs={tabs}\n band={band}\n // Cancels the dialog's own padding so the tint reaches the card's\n // edges, then re-applies it inside.\n bandClassName=\"-mx-6 -mt-5 px-6 pt-5 sm:-mx-8 sm:-mt-7 sm:px-8 sm:pt-7\"\n // `?sub=` means a child was picked out of a search, so the card opens\n // on the tab that holds it rather than making them hunt for it.\n forcedTab={subSlug && hasResourcesTab ? TAB.resources : undefined}\n requestTab={composeRequests > 0 ? TAB.notes : undefined}\n requestNonce={composeRequests}\n />\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n"],"names":["React","_a","_b"],"mappings":"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"}
1
+ {"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type {\n Resource,\n SourceReference,\n} from '@igstack/app-catalog-backend-core'\nimport { useSearch } from '@tanstack/react-router'\nimport { CalendarPlus, Clock, ExternalLink, Plus, Trash2 } from 'lucide-react'\nimport React from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'\nimport { QuickJumpBar, hasQuickJumps } from '../components/QuickJumpBar'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { PersonBadge } from '../components/PersonBadge'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { SourcePulse } from '../components/SourcePulse'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { ResourceDetailAccessActions } from '~/modules/extensions'\nimport { FeedbackSection } from './FeedbackSection'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\nimport { accessTabLabel } from '~/modules/appCatalog/utils/accessTabLabel'\nimport { AppDoor } from './AppDoor'\nimport { DetailTabs } from './DetailTabs'\nimport type { DetailTab } from './DetailTabs'\n\n/** Tab ids. Public — they land in `?tab=`, so renaming one breaks links. */\nconst TAB = {\n overview: 'overview',\n access: 'access',\n resources: 'resources',\n documentation: 'documentation',\n notes: 'notes',\n} as const\n\n/** A labelled block inside a tab. One register for all of them. */\nfunction Section({\n title,\n hint,\n first,\n children,\n}: {\n title: string\n hint?: string\n /** Drops the top margin for the first block in a panel. */\n first?: boolean\n children: React.ReactNode\n}) {\n return (\n <div className={first ? '' : 'mt-7'}>\n <h3 className=\"section-label mb-1.5\">{title}</h3>\n {hint && <p className=\"mb-2 text-xs text-muted-foreground\">{hint}</p>}\n {children}\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n /**\n * Set by the band's \"Suggest a change\", which has to do two things the button\n * cannot do alone: bring the notes tab forward, and open the composer inside it.\n * Held here because the band and the panel are siblings — neither can reach the\n * other, and routing it through the URL would put a transient intent in a\n * shareable link.\n *\n * A count, not a flag: the composer closes itself on send, so asking a second\n * time has to be distinguishable from the first ask still being set. As a\n * boolean the second click changed nothing and the button was dead for the rest\n * of the card's life.\n */\n const [composeRequests, setComposeRequests] = React.useState(0)\n\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n const { state: filterState } = useAppCatalogFilters()\n\n // Arriving with `?sub=` means the visitor picked one child out of a search —\n // so the card has to open ON that child's tab. Landing on Overview and\n // leaving them to find the tab is the bug this scenario exists to prevent.\n const search = useSearch({ strict: false })\n const subSlug = search.sub\n\n const displayTags = (app.tags ?? []).filter((tag) => !tag.includes(':'))\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n // The list renders bare URLs (they are editable as text), so the pulse has to\n // be looked up rather than carried alongside. Absent until the autoupdate loop\n // has seen the source — a freshly added one simply gets no mark.\n const pulseByUrl = React.useMemo(() => {\n const byUrl = new Map<string, NonNullable<SourceReference['pulse']>>()\n for (const source of app.sources ?? []) {\n if (typeof source !== 'string' && source.pulse) {\n byUrl.set(source.url, source.pulse)\n }\n }\n return byUrl\n }, [app.sources])\n\n const children = React.useMemo(\n () => getChildResources(allResources, app.slug),\n [allResources, app.slug],\n )\n\n // `app` is whatever resource the card shows, a nested one included, so the\n // parent has to be looked up rather than assumed absent. A plugin uses its\n // presence to tell a sub-resource view from a top-level one.\n const parent = React.useMemo(\n () =>\n app.parentSlug\n ? allResources.find((r) => r.slug === app.parentSlug)\n : undefined,\n [allResources, app.parentSlug],\n )\n\n // What a plugin slot in the band receives. Memoised so a plugin that\n // memoises on its props is not defeated by a new object every render.\n const accessActionsCtx = React.useMemo(\n () => ({\n resource: app,\n parent,\n subResources: parent\n ? getChildResources(allResources, parent.slug)\n : children,\n user: user ? { email: user.email, isAdmin } : null,\n }),\n [app, parent, allResources, children, user, isAdmin],\n )\n\n // Enter: open the app, as the door does — the card opens from search on one\n // Enter, so a second one finishes the trip. No address: the screenshot gallery.\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.appUrl) {\n recordClick(app.slug)\n window.open(app.appUrl, '_blank', 'noopener,noreferrer')\n } else if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen, recordClick],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n const access = accessTabLabel(app, approvalMethods)\n\n const hasResourcesTab = children.length > 0 || (app.tiers?.length ?? 0) > 0\n const resourcesLabel = children.length\n ? (app.childrenLabel ?? 'Resources')\n : 'Environments'\n\n const tabs: DetailTab[] = [\n {\n id: TAB.overview,\n label: 'Overview',\n render: () => (\n <>\n <Section title=\"Description\" first>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </Section>\n\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <Section title={`Screenshots (${app.screenshotIds.length})`}>\n {/* A button, not a clickable div: the gallery was unreachable by\n keyboard, and a role + label gives it a stable handle. */}\n <button\n type=\"button\"\n aria-label={`View screenshots of ${app.displayName}`}\n className=\"block w-full cursor-pointer text-left hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </button>\n </Section>\n )}\n\n {/* Owner — who is responsible for this resource. Distinct from the\n access approver (who decides access requests); see domain model. */}\n {app.ownerPersonSlug && (\n <Section title=\"Owner\" hint=\"Who is responsible for this resource\">\n <PersonBadge slug={app.ownerPersonSlug} />\n </Section>\n )}\n\n {/* Tags, minus the indexing machinery: `namespace:value` tags drive\n grouping, faceting and placement, are about half of all tag\n references, and say nothing about what an app is for. The colon is\n the test. Search still matches them — only the display drops them. */}\n {displayTags.length > 0 && (\n <Section title=\"Tags\">\n <div className=\"flex flex-wrap gap-2\">\n {displayTags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </Section>\n )}\n\n {app.teams && app.teams.length > 0 && (\n <Section title=\"Teams\">\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </Section>\n )}\n </>\n ),\n },\n {\n id: TAB.access,\n label: `Access — ${access.route}`,\n chip: access.chip,\n render: () => (\n <>\n {/* Two-step access: for a nested resource, show the parent-first\n prerequisite chain before this resource's own instructions. */}\n <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n </>\n ),\n },\n ...(hasResourcesTab\n ? [\n {\n id: TAB.resources,\n label: resourcesLabel,\n chip: children.length ? String(children.length) : undefined,\n render: () => (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <TierVariantsSection tiers={app.tiers} />\n )}\n {children.length > 0 && (\n <div className={app.tiers?.length ? 'mt-7' : ''}>\n {/* The active catalog search term seeds the filter, so a\n visitor who got here by searching something that matched\n a child sees that child rather than all of its siblings. */}\n <SubResourcesSection\n subResources={children}\n parentSlug={app.slug}\n parent={app}\n initialSearch={filterState.searchValue}\n childrenLabel={app.childrenLabel}\n />\n </div>\n )}\n </>\n ),\n } satisfies DetailTab,\n ]\n : []),\n {\n id: TAB.documentation,\n label: 'Documentation',\n render: () => (\n <>\n {app.links && app.links.length > 0 && (\n <Section title=\"Documentation\" first>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || displayUrl(link.url)}\n </a>\n ))}\n </div>\n </Section>\n )}\n {/* Background: why the app exists at all. Deliberately quiet — a\n reader who came to get access has already passed everything they\n need, and history should not compete with the description. */}\n {app.background && (\n <Section title=\"Background\">\n <MarkdownText className=\"prose prose-sm text-muted-foreground max-w-none text-sm [&_p]:mt-0 [&_p]:mb-2 [&_p:last-child]:mb-0\">\n {app.background}\n </MarkdownText>\n </Section>\n )}\n\n <Section title=\"Sources\" first={!app.background}>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(val)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n // `group` so hovering anywhere on the row fades that source's\n // track in — the mark sits in a right-hand column, and row\n // hover is what ties the two ends of a wide row together.\n <li\n key={index}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex min-w-0 items-center gap-1 truncate\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n {pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n </li>\n ))}\n </ul>\n )}\n </Section>\n\n {/* These used to sit unlabelled in a 64px gap between two sections,\n which is what made that gap look like a mistake. Labelled, in\n their own block. \"Dates\" rather than \"Added / updated\" on purpose:\n a label that repeats the first word of every line below it adds\n nothing, and it made \"Added\"/\"Updated\" ambiguous to anything\n matching on text — including this card's own tests. */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <Section title=\"Dates\">\n <div className=\"flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check\n ran — entries predating the field have none, so fall\n back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n </Section>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-7\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"section-label mb-1\">AI Prompt</h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"section-label mb-1\">AI Memory</h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n </>\n ),\n },\n {\n id: TAB.notes,\n label: 'Notes & requests',\n render: () => (\n <FeedbackSection appSlug={app.slug} openComposer={composeRequests} />\n ),\n },\n ]\n\n // The hero band: identity, the way in, and the typed-id jumps. It does NOT\n // scroll away with a tab, so \"take me to the app\" is reachable from every one\n // of them.\n //\n // Deliberately holds nothing else. An owner/teams/freshness strip was drawn\n // and dropped: `ownerPersonSlug` is set on a tiny fraction of entries so the\n // owner slot would almost always be empty, teams already has its own block in\n // Overview, and on an entry with quick jumps the band had grown past 250px\n // before any content — on exactly the apps where people most want to type an\n // id and leave.\n const band = (\n <>\n <div className=\"flex flex-wrap items-center gap-x-7 gap-y-2\">\n <AppDoor app={app} onOpen={() => recordClick(app.slug)} />\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n {/* In the band, not at the foot of the notes panel.\n Down there it was invisible twice over: below the fold of a panel, and\n behind a tab nobody opens unless they already know what is in it — so\n the one affordance the feature exists for was the hardest thing on the\n card to find. Up here it is present on every tab, which is the whole\n reason the band does not scroll away.\n `mr-10` keeps it clear of the dialog's own close button at top-right. */}\n {/* Plugin actions sit beside \"Suggest a change\", sharing its reasoning:\n the band is mounted on every tab and never scrolls away. A slot in a\n tab panel would be invisible until a second click, and one inside\n SubResourceDetailPanel's `hasAnyAccess` gate would vanish for exactly\n the resources that need help most.\n Renders nothing when no plugin contributes, so the band keeps its\n current shape in the open-source build. */}\n <div className=\"ml-auto mr-10 flex items-center gap-2\">\n <ResourceDetailAccessActions {...accessActionsCtx} />\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n onClick={() => setComposeRequests((n) => n + 1)}\n >\n ✎ Suggest a change\n </Button>\n </div>\n </div>\n\n {app.nicknames && app.nicknames.length > 0 && (\n <p className=\"mt-2.5 text-xs text-muted-foreground\">\n Also known as {app.nicknames.join(', ')}\n </p>\n )}\n\n {isAdmin && (\n <div className=\"mt-3 flex flex-wrap items-center gap-x-6 gap-y-1\">\n <span className=\"inline-flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">Slug:</span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </span>\n <span className=\"inline-flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">URL:</span>\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n className=\"text-sm\"\n />\n </span>\n </div>\n )}\n\n {hasQuickJumps(app) && (\n <div className=\"mt-4\">\n {/* Named, where the bare control was not: \"or go straight to a\n record\" says what the field is for before you reach it. */}\n <p className=\"mb-1.5 text-xs text-muted-foreground\">\n Or go straight to a record:\n </p>\n <QuickJumpBar app={app} />\n </div>\n )}\n\n {/* Deprecation stays in the band, not in a tab: \"do not use this\"\n must not be something you have to go looking for. */}\n {app.deprecated &&\n (() => {\n const isDiscouraged = app.deprecated.type === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-4 rounded-lg border border-yellow-500/50 bg-yellow-50 p-4 dark:bg-yellow-950/20'\n : 'mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'section-label mb-1.5 text-yellow-700 dark:text-yellow-500'\n : 'section-label mb-1.5 text-destructive'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n </>\n )\n\n return (\n <>\n <div className=\"flex h-full flex-col\">\n <DetailTabs\n tabs={tabs}\n band={band}\n // Cancels the dialog's own padding so the tint reaches the card's\n // edges, then re-applies it inside.\n bandClassName=\"-mx-6 -mt-5 px-6 pt-5 sm:-mx-8 sm:-mt-7 sm:px-8 sm:pt-7\"\n // `?sub=` means a child was picked out of a search, so the card opens\n // on the tab that holds it rather than making them hunt for it.\n forcedTab={subSlug && hasResourcesTab ? TAB.resources : undefined}\n requestTab={composeRequests > 0 ? TAB.notes : undefined}\n requestNonce={composeRequests}\n />\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n"],"names":["React","_a","_b"],"mappings":"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"}
@@ -10,6 +10,7 @@ import { AppCatalogContext } from "../../../modules/appCatalog/context/AppCatalo
10
10
  import { AttributionFooter } from "../../../modules/appCatalog/ui/catalog/AttributionFooter.js";
11
11
  import { useAuth, useIsAuthenticated, useUser } from "../../../modules/auth/AuthContext.js";
12
12
  import { Button } from "../../button.js";
13
+ import { useAuthModal } from "../../../modules/auth/AuthModalContext.js";
13
14
  import { useAuthActions } from "../../../modules/auth/useAuthActions.js";
14
15
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuItem } from "../../dropdown-menu.js";
15
16
  import { Popover, PopoverTrigger, PopoverContent, PopoverClose } from "../../popover.js";
@@ -117,6 +118,7 @@ function Header({ middle }) {
117
118
  const isAuthenticated = useIsAuthenticated();
118
119
  const user = useUser();
119
120
  const { logout, devLogin } = useAuthActions();
121
+ const { open: openLoginModal } = useAuthModal();
120
122
  const appCatalogContextMaybe = use(AppCatalogContext);
121
123
  const trpc = useTRPC();
122
124
  const { data: providersData } = useQuery(
@@ -129,6 +131,10 @@ function Header({ middle }) {
129
131
  console.error("Logout failed:", error);
130
132
  }
131
133
  };
134
+ const handleLoginClick = () => {
135
+ const currentUrl = window.location.pathname + window.location.search;
136
+ openLoginModal(currentUrl);
137
+ };
132
138
  return /* @__PURE__ */ jsxs("div", { className: "flex items-center mb-4 pb-4 gap-4 border-b", children: [
133
139
  /* @__PURE__ */ jsx("div", { className: "flex items-center gap-4", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
134
140
  /* @__PURE__ */ jsx(Link, { to: "/", children: /* @__PURE__ */ jsx(SvgAppCatalog, { className: "h-16 w-16" }) }),
@@ -141,24 +147,29 @@ function Header({ middle }) {
141
147
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 ml-auto", children: [
142
148
  /* @__PURE__ */ jsx(ThemeSwitcher, {}),
143
149
  isLoading ? /* @__PURE__ */ jsx(Skeleton, { className: "w-8 h-8 rounded-full" }) : !isAuthenticated ? (
144
- // No Login button: the catalog is fully browsable without signing in,
145
- // so it mostly advertised a flow most visitors have no reason to
146
- // enter. Auth itself is untouched -- the modal, the login route, the
147
- // client and session handling all still work, so anyone who reaches
148
- // the flow by URL or by calling `useAuthModal().open()` can sign in,
149
- // and this branch flips to the user menu below once they do. `git
150
- // revert` of the commit that removed it restores the button as-is.
151
- // DEV Login stays: it is gated behind `devLoginEnabled`, so it never
152
- // reaches a real deployment.
153
- (providersData == null ? void 0 : providersData.devLoginEnabled) && /* @__PURE__ */ jsx(
154
- "button",
155
- {
156
- type: "button",
157
- onClick: devLogin,
158
- className: "text-xs font-medium px-2 py-1 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 cursor-pointer",
159
- children: "DEV Login"
160
- }
161
- )
150
+ // The catalog is browsable without signing in; Login is for the parts
151
+ // that need an identity (comments, admin). DEV Login is gated behind
152
+ // `devLoginEnabled`, so it never reaches a real deployment.
153
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
154
+ (providersData == null ? void 0 : providersData.devLoginEnabled) && /* @__PURE__ */ jsx(
155
+ "button",
156
+ {
157
+ type: "button",
158
+ onClick: devLogin,
159
+ className: "text-xs font-medium px-2 py-1 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 cursor-pointer",
160
+ children: "DEV Login"
161
+ }
162
+ ),
163
+ /* @__PURE__ */ jsx(
164
+ "button",
165
+ {
166
+ type: "button",
167
+ onClick: handleLoginClick,
168
+ className: "text-sm font-medium text-muted-foreground hover:text-foreground cursor-pointer",
169
+ children: "Login"
170
+ }
171
+ )
172
+ ] })
162
173
  ) : /* @__PURE__ */ jsxs(DropdownMenu, { children: [
163
174
  /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
164
175
  Button,
@@ -1 +1 @@
1
- {"version":3,"file":"Header.js","sources":["../../../../../src/ui/components/header/Header.tsx"],"sourcesContent":["import type {\n AppVersionInfo,\n VersionInfo,\n} from '@igstack/app-catalog-backend-core'\nimport { Link } from '@tanstack/react-router'\nimport { useQuery } from '@tanstack/react-query'\nimport { LogOut, X } from 'lucide-react'\nimport React, { use } from 'react'\nimport AppCatalogLogo from '~/assets/app-catalog.svg?react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { ThemeSwitcher } from '~/components/ThemeSwitcher'\nimport { AppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'\nimport { AttributionFooter } from '~/modules/appCatalog/ui/catalog/AttributionFooter'\nimport {\n useAuth,\n useAuthActions,\n useIsAuthenticated,\n useUser,\n} from '~/modules/auth'\nimport { Button } from '~/ui/button'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\nimport {\n Popover,\n PopoverClose,\n PopoverContent,\n PopoverTrigger,\n} from '~/ui/popover'\nimport { Skeleton } from '~/ui/skeleton'\n\nfunction ShaSuffix({ version }: { version: VersionInfo }) {\n if (!version.sha) return null\n if (version.shaUrl) {\n return (\n <>\n {' '}\n <a\n href={version.shaUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n onClick={(e) => e.stopPropagation()}\n >\n ({version.sha})\n </a>\n </>\n )\n }\n return <span className=\"text-muted-foreground\"> ({version.sha})</span>\n}\n\nfunction VersionItem({\n label,\n version,\n}: {\n label: string\n version: VersionInfo\n}) {\n return (\n <div className=\"flex items-center justify-between gap-4 py-1\">\n <span className=\"text-xs text-muted-foreground\">{label}</span>\n <span className=\"text-sm font-medium text-right\">\n {version.url ? (\n <a\n href={version.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:text-primary/80 transition-colors\"\n onClick={(e) => e.stopPropagation()}\n >\n {version.displayName}\n </a>\n ) : (\n version.displayName\n )}\n <ShaSuffix version={version} />\n </span>\n </div>\n )\n}\n\nexport function VersionDisplay({ versions }: { versions: AppVersionInfo }) {\n const isLocal =\n versions.backend?.displayName === 'local' && !versions.coreVersion\n\n if (isLocal) {\n return (\n <span className=\"text-xs text-muted-foreground\" title=\"Local\">\n local\n </span>\n )\n }\n\n return (\n <div className=\"flex flex-col divide-y\">\n {versions.backend && (\n <VersionItem label=\"Pipeline\" version={versions.backend} />\n )}\n {versions.coreVersion && (\n <VersionItem label=\"Core\" version={versions.coreVersion} />\n )}\n {versions.frontend && (\n <VersionItem label=\"FE\" version={versions.frontend} />\n )}\n </div>\n )\n}\n\n/**\n * Version detail moved behind an on-demand popover (same click-to-reveal\n * pattern as PersonBadge's approver chips) instead of always-visible text —\n * keeps the header compact. Content is unchanged: same Pipeline/Core/FE\n * lines, links, and SHA suffix as before, just revealed on click.\n */\nexport function VersionPopover({ versions }: { versions: AppVersionInfo }) {\n const isLocal =\n versions.backend?.displayName === 'local' && !versions.coreVersion\n\n if (isLocal) {\n return (\n <span className=\"text-xs text-muted-foreground\" title=\"Local\">\n local\n </span>\n )\n }\n\n return (\n <Popover>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-mono leading-none text-muted-foreground hover:bg-primary/15 hover:text-primary transition-colors\"\n aria-label=\"Show version details\"\n title=\"Version details\"\n data-testid=\"version-info-trigger\"\n >\n {versions.backend?.displayName ?? '?'}\n </button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"w-72 p-0 overflow-hidden\">\n <div className=\"flex items-center justify-between px-3 py-2 border-b bg-muted/40\">\n <span className=\"text-sm font-semibold\">Version details</span>\n <PopoverClose\n className=\"rounded p-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors\"\n aria-label=\"Close\"\n >\n <X className=\"size-3.5\" />\n </PopoverClose>\n </div>\n <div className=\"px-3 py-1\">\n <VersionDisplay versions={versions} />\n </div>\n <AttributionFooter variant=\"popover\" />\n </PopoverContent>\n </Popover>\n )\n}\n\nexport interface HeaderProps {\n middle?: React.ReactNode\n}\n\nexport function Header({ middle }: HeaderProps) {\n const { isLoading } = useAuth()\n const isAuthenticated = useIsAuthenticated()\n const user = useUser()\n const { logout, devLogin } = useAuthActions()\n const appCatalogContextMaybe = use(AppCatalogContext)\n const trpc = useTRPC()\n const { data: providersData } = useQuery(\n trpc.auth.getProviders.queryOptions(),\n )\n\n const handleLogout = async () => {\n try {\n await logout()\n } catch (error) {\n console.error('Logout failed:', error)\n }\n }\n\n return (\n <div className=\"flex items-center mb-4 pb-4 gap-4 border-b\">\n <div className=\"flex items-center gap-4\">\n <div className=\"flex items-center gap-2\">\n <Link to=\"/\">\n <AppCatalogLogo className=\"h-16 w-16\" />\n </Link>\n <div className=\"flex flex-col items-start\">\n <Link to=\"/\" className=\"font-serif text-lg font-semibold\">\n App Catalog\n </Link>\n {appCatalogContextMaybe?.versions && (\n <VersionPopover versions={appCatalogContextMaybe.versions} />\n )}\n </div>\n </div>\n </div>\n {middle && <div className=\"flex-1\">{middle}</div>}\n <div className=\"flex items-center gap-3 ml-auto\">\n <ThemeSwitcher />\n {isLoading ? (\n <Skeleton className=\"w-8 h-8 rounded-full\" />\n ) : !isAuthenticated ? (\n // No Login button: the catalog is fully browsable without signing in,\n // so it mostly advertised a flow most visitors have no reason to\n // enter. Auth itself is untouched -- the modal, the login route, the\n // client and session handling all still work, so anyone who reaches\n // the flow by URL or by calling `useAuthModal().open()` can sign in,\n // and this branch flips to the user menu below once they do. `git\n // revert` of the commit that removed it restores the button as-is.\n // DEV Login stays: it is gated behind `devLoginEnabled`, so it never\n // reaches a real deployment.\n providersData?.devLoginEnabled && (\n <button\n type=\"button\"\n onClick={devLogin}\n className=\"text-xs font-medium px-2 py-1 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 cursor-pointer\"\n >\n DEV Login\n </button>\n )\n ) : (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-8 h-8 rounded-full p-0 hover:bg-accent hover:text-accent-foreground cursor-pointer\"\n aria-label={`User menu for ${user?.name || 'User'}`}\n data-testid=\"user-avatar-button\"\n >\n {user?.image ? (\n <img\n src={user.image}\n alt={user.name || 'User'}\n className=\"w-full h-full rounded-full object-cover\"\n />\n ) : (\n <div className=\"flex items-center justify-center w-full h-full rounded-full bg-primary text-primary-foreground text-xs font-semibold\">\n {(user?.name || user?.email || '?').charAt(0).toUpperCase()}\n </div>\n )}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-56\">\n <div className=\"px-2 py-3 flex items-center gap-3\">\n {user?.image ? (\n <img\n src={user.image}\n alt={user.name || 'User'}\n className=\"w-10 h-10 rounded-full object-cover shrink-0\"\n />\n ) : (\n <div className=\"flex items-center justify-center w-10 h-10 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0\">\n {(user?.name || user?.email || '?').charAt(0).toUpperCase()}\n </div>\n )}\n <div className=\"flex flex-col min-w-0\">\n <p className=\"text-xs text-muted-foreground truncate\">\n @{user?.email?.split('@')[0] || 'user'}\n </p>\n <p className=\"text-sm font-medium truncate\">\n {user?.name || 'User'}\n </p>\n </div>\n </div>\n <DropdownMenuSeparator />\n <DropdownMenuItem onClick={handleLogout}>\n <LogOut className=\"h-4 w-4 mr-2\" />\n <span>Sign out</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n )}\n </div>\n </div>\n )\n}\n"],"names":["AppCatalogLogo"],"mappings":"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"}
1
+ {"version":3,"file":"Header.js","sources":["../../../../../src/ui/components/header/Header.tsx"],"sourcesContent":["import type {\n AppVersionInfo,\n VersionInfo,\n} from '@igstack/app-catalog-backend-core'\nimport { Link } from '@tanstack/react-router'\nimport { useQuery } from '@tanstack/react-query'\nimport { LogOut, X } from 'lucide-react'\nimport React, { use } from 'react'\nimport AppCatalogLogo from '~/assets/app-catalog.svg?react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { ThemeSwitcher } from '~/components/ThemeSwitcher'\nimport { AppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'\nimport { AttributionFooter } from '~/modules/appCatalog/ui/catalog/AttributionFooter'\nimport {\n useAuth,\n useAuthActions,\n useIsAuthenticated,\n useUser,\n} from '~/modules/auth'\nimport { useAuthModal } from '~/modules/auth/AuthModalContext'\nimport { Button } from '~/ui/button'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\nimport {\n Popover,\n PopoverClose,\n PopoverContent,\n PopoverTrigger,\n} from '~/ui/popover'\nimport { Skeleton } from '~/ui/skeleton'\n\nfunction ShaSuffix({ version }: { version: VersionInfo }) {\n if (!version.sha) return null\n if (version.shaUrl) {\n return (\n <>\n {' '}\n <a\n href={version.shaUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n onClick={(e) => e.stopPropagation()}\n >\n ({version.sha})\n </a>\n </>\n )\n }\n return <span className=\"text-muted-foreground\"> ({version.sha})</span>\n}\n\nfunction VersionItem({\n label,\n version,\n}: {\n label: string\n version: VersionInfo\n}) {\n return (\n <div className=\"flex items-center justify-between gap-4 py-1\">\n <span className=\"text-xs text-muted-foreground\">{label}</span>\n <span className=\"text-sm font-medium text-right\">\n {version.url ? (\n <a\n href={version.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:text-primary/80 transition-colors\"\n onClick={(e) => e.stopPropagation()}\n >\n {version.displayName}\n </a>\n ) : (\n version.displayName\n )}\n <ShaSuffix version={version} />\n </span>\n </div>\n )\n}\n\nexport function VersionDisplay({ versions }: { versions: AppVersionInfo }) {\n const isLocal =\n versions.backend?.displayName === 'local' && !versions.coreVersion\n\n if (isLocal) {\n return (\n <span className=\"text-xs text-muted-foreground\" title=\"Local\">\n local\n </span>\n )\n }\n\n return (\n <div className=\"flex flex-col divide-y\">\n {versions.backend && (\n <VersionItem label=\"Pipeline\" version={versions.backend} />\n )}\n {versions.coreVersion && (\n <VersionItem label=\"Core\" version={versions.coreVersion} />\n )}\n {versions.frontend && (\n <VersionItem label=\"FE\" version={versions.frontend} />\n )}\n </div>\n )\n}\n\n/**\n * Version detail moved behind an on-demand popover (same click-to-reveal\n * pattern as PersonBadge's approver chips) instead of always-visible text —\n * keeps the header compact. Content is unchanged: same Pipeline/Core/FE\n * lines, links, and SHA suffix as before, just revealed on click.\n */\nexport function VersionPopover({ versions }: { versions: AppVersionInfo }) {\n const isLocal =\n versions.backend?.displayName === 'local' && !versions.coreVersion\n\n if (isLocal) {\n return (\n <span className=\"text-xs text-muted-foreground\" title=\"Local\">\n local\n </span>\n )\n }\n\n return (\n <Popover>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-mono leading-none text-muted-foreground hover:bg-primary/15 hover:text-primary transition-colors\"\n aria-label=\"Show version details\"\n title=\"Version details\"\n data-testid=\"version-info-trigger\"\n >\n {versions.backend?.displayName ?? '?'}\n </button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"w-72 p-0 overflow-hidden\">\n <div className=\"flex items-center justify-between px-3 py-2 border-b bg-muted/40\">\n <span className=\"text-sm font-semibold\">Version details</span>\n <PopoverClose\n className=\"rounded p-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors\"\n aria-label=\"Close\"\n >\n <X className=\"size-3.5\" />\n </PopoverClose>\n </div>\n <div className=\"px-3 py-1\">\n <VersionDisplay versions={versions} />\n </div>\n <AttributionFooter variant=\"popover\" />\n </PopoverContent>\n </Popover>\n )\n}\n\nexport interface HeaderProps {\n middle?: React.ReactNode\n}\n\nexport function Header({ middle }: HeaderProps) {\n const { isLoading } = useAuth()\n const isAuthenticated = useIsAuthenticated()\n const user = useUser()\n const { logout, devLogin } = useAuthActions()\n const { open: openLoginModal } = useAuthModal()\n const appCatalogContextMaybe = use(AppCatalogContext)\n const trpc = useTRPC()\n const { data: providersData } = useQuery(\n trpc.auth.getProviders.queryOptions(),\n )\n\n const handleLogout = async () => {\n try {\n await logout()\n } catch (error) {\n console.error('Logout failed:', error)\n }\n }\n\n const handleLoginClick = () => {\n // Preserve the current URL for redirect after login\n const currentUrl = window.location.pathname + window.location.search\n openLoginModal(currentUrl)\n }\n\n return (\n <div className=\"flex items-center mb-4 pb-4 gap-4 border-b\">\n <div className=\"flex items-center gap-4\">\n <div className=\"flex items-center gap-2\">\n <Link to=\"/\">\n <AppCatalogLogo className=\"h-16 w-16\" />\n </Link>\n <div className=\"flex flex-col items-start\">\n <Link to=\"/\" className=\"font-serif text-lg font-semibold\">\n App Catalog\n </Link>\n {appCatalogContextMaybe?.versions && (\n <VersionPopover versions={appCatalogContextMaybe.versions} />\n )}\n </div>\n </div>\n </div>\n {middle && <div className=\"flex-1\">{middle}</div>}\n <div className=\"flex items-center gap-3 ml-auto\">\n <ThemeSwitcher />\n {isLoading ? (\n <Skeleton className=\"w-8 h-8 rounded-full\" />\n ) : !isAuthenticated ? (\n // The catalog is browsable without signing in; Login is for the parts\n // that need an identity (comments, admin). DEV Login is gated behind\n // `devLoginEnabled`, so it never reaches a real deployment.\n <div className=\"flex items-center gap-2\">\n {providersData?.devLoginEnabled && (\n <button\n type=\"button\"\n onClick={devLogin}\n className=\"text-xs font-medium px-2 py-1 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 cursor-pointer\"\n >\n DEV Login\n </button>\n )}\n <button\n type=\"button\"\n onClick={handleLoginClick}\n className=\"text-sm font-medium text-muted-foreground hover:text-foreground cursor-pointer\"\n >\n Login\n </button>\n </div>\n ) : (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-8 h-8 rounded-full p-0 hover:bg-accent hover:text-accent-foreground cursor-pointer\"\n aria-label={`User menu for ${user?.name || 'User'}`}\n data-testid=\"user-avatar-button\"\n >\n {user?.image ? (\n <img\n src={user.image}\n alt={user.name || 'User'}\n className=\"w-full h-full rounded-full object-cover\"\n />\n ) : (\n <div className=\"flex items-center justify-center w-full h-full rounded-full bg-primary text-primary-foreground text-xs font-semibold\">\n {(user?.name || user?.email || '?').charAt(0).toUpperCase()}\n </div>\n )}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-56\">\n <div className=\"px-2 py-3 flex items-center gap-3\">\n {user?.image ? (\n <img\n src={user.image}\n alt={user.name || 'User'}\n className=\"w-10 h-10 rounded-full object-cover shrink-0\"\n />\n ) : (\n <div className=\"flex items-center justify-center w-10 h-10 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0\">\n {(user?.name || user?.email || '?').charAt(0).toUpperCase()}\n </div>\n )}\n <div className=\"flex flex-col min-w-0\">\n <p className=\"text-xs text-muted-foreground truncate\">\n @{user?.email?.split('@')[0] || 'user'}\n </p>\n <p className=\"text-sm font-medium truncate\">\n {user?.name || 'User'}\n </p>\n </div>\n </div>\n <DropdownMenuSeparator />\n <DropdownMenuItem onClick={handleLogout}>\n <LogOut className=\"h-4 w-4 mr-2\" />\n <span>Sign out</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n )}\n </div>\n </div>\n )\n}\n"],"names":["AppCatalogLogo"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "6.8.0",
3
+ "version": "6.8.2",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -118,7 +118,7 @@
118
118
  "tailwindcss": "^4.1.7",
119
119
  "tiptap-markdown": "^0.8.0",
120
120
  "tslib": "^2.3.0",
121
- "@igstack/app-catalog-shared-core": "6.8.0"
121
+ "@igstack/app-catalog-shared-core": "6.8.2"
122
122
  },
123
123
  "devDependencies": {
124
124
  "@tailwindcss/vite": "^4.0.5",
@@ -146,7 +146,7 @@
146
146
  "vite-plugin-static-copy": "^3.1.4",
147
147
  "vite-plugin-svgr": "^4.2.0",
148
148
  "vitest": "^4.1.2",
149
- "@igstack/app-catalog-backend-core": "6.8.0"
149
+ "@igstack/app-catalog-backend-core": "6.8.2"
150
150
  },
151
151
  "peerDependencies": {
152
152
  "@tanstack/react-router": "1.170.41",
@@ -156,7 +156,7 @@
156
156
  "vite": "^6.3.5",
157
157
  "vite-plugin-svgr": "^4.2.0"
158
158
  },
159
- "gitHead": "23f71eafc97012101c37c2939800b95f7a515851",
159
+ "gitHead": "622217f4de5765e49a41d89c2e87592dccad8a23",
160
160
  "scripts": {
161
161
  "build": "vite build",
162
162
  "build:lenient": "vite build --mode lenient",
@@ -0,0 +1,90 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import '@testing-library/jest-dom/vitest'
5
+
6
+ // Same stubs as the tags test: AppDetails drags the whole panel data layer in
7
+ // and none of it decides what Enter does.
8
+ vi.mock('@tanstack/react-router', () => ({
9
+ useSearch: vi.fn(() => ({})),
10
+ useNavigate: vi.fn(() => vi.fn()),
11
+ useRouter: vi.fn(() => ({
12
+ state: { location: { pathname: '/', search: {} } },
13
+ })),
14
+ Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
15
+ <a {...rest}>{children}</a>
16
+ ),
17
+ }))
18
+ vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
19
+ useAppCatalogContext: vi.fn(() => ({ approvalMethods: [], resources: [] })),
20
+ }))
21
+ vi.mock('~/modules/appCatalog/hooks/useAppClickHistory', () => ({
22
+ useAppClickHistory: vi.fn(() => ({ recordClick: vi.fn() })),
23
+ }))
24
+ vi.mock('~/modules/appCatalog/hooks/useUpdateApp', () => ({
25
+ useUpdateApp: vi.fn(() => ({ mutate: vi.fn() })),
26
+ }))
27
+ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
28
+ useAppCatalogFilters: vi.fn(() => ({ selectedTags: [], toggleTag: vi.fn() })),
29
+ }))
30
+ vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
31
+ vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
32
+ FeedbackSection: () => <div />,
33
+ }))
34
+
35
+ const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
36
+
37
+ /** Enter as the browser delivers it to whatever holds focus. */
38
+ function pressEnter(target: Element) {
39
+ fireEvent.keyDown(target, { key: 'Enter', code: 'Enter' })
40
+ fireEvent.keyUp(target, { key: 'Enter', code: 'Enter' })
41
+ }
42
+
43
+ /** The card as it opens from search: the dialog itself holds focus. */
44
+ function openCard(app: Resource) {
45
+ render(
46
+ <div role="dialog" aria-label="card" tabIndex={-1}>
47
+ <AppDetails app={app} />
48
+ </div>,
49
+ )
50
+ const card = screen.getByRole('dialog', { name: 'card' })
51
+ card.focus()
52
+ return card
53
+ }
54
+
55
+ // Enter on a search result opens the card; a second Enter has to take the
56
+ // reader into the app, or the keyboard path ends one step short of the goal.
57
+ describe('AppDetails — Enter on the open card', () => {
58
+ it('opens the app address in a new tab', () => {
59
+ const open = vi.spyOn(window, 'open').mockReturnValue(null)
60
+ const card = openCard({
61
+ id: 'tracker',
62
+ slug: 'tracker',
63
+ displayName: 'Tracker',
64
+ appUrl: 'https://tracker.example.com',
65
+ })
66
+
67
+ pressEnter(card)
68
+
69
+ expect(open).toHaveBeenCalledWith(
70
+ 'https://tracker.example.com',
71
+ '_blank',
72
+ 'noopener,noreferrer',
73
+ )
74
+ open.mockRestore()
75
+ })
76
+
77
+ it('opens nothing for an app with no address', () => {
78
+ const open = vi.spyOn(window, 'open').mockReturnValue(null)
79
+ const card = openCard({
80
+ id: 'tracker',
81
+ slug: 'tracker',
82
+ displayName: 'Tracker',
83
+ })
84
+
85
+ pressEnter(card)
86
+
87
+ expect(open).not.toHaveBeenCalled()
88
+ open.mockRestore()
89
+ })
90
+ })
@@ -1,10 +1,11 @@
1
1
  import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
2
3
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
4
  import '@testing-library/jest-dom/vitest'
4
5
 
5
6
  // The header pulls in the router, the theme switcher, an svgr asset and the
6
7
  // whole auth/tRPC stack. None of that is under test here, so it is stubbed down
7
- // to the two things these cases drive: session state and `devLoginEnabled`.
8
+ // to what these cases drive: session state, `devLoginEnabled` and the modal.
8
9
  const state = vi.hoisted(() => ({
9
10
  isLoading: false,
10
11
  isAuthenticated: false,
@@ -37,6 +38,10 @@ vi.mock('~/modules/auth', () => ({
37
38
  useUser: vi.fn(() => state.user),
38
39
  useAuthActions: vi.fn(() => ({ logout: vi.fn(), devLogin: vi.fn() })),
39
40
  }))
41
+ const openLoginModal = vi.hoisted(() => vi.fn())
42
+ vi.mock('~/modules/auth/AuthModalContext', () => ({
43
+ useAuthModal: vi.fn(() => ({ open: openLoginModal })),
44
+ }))
40
45
 
41
46
  const { Header } = await import('~/ui/components/header/Header')
42
47
 
@@ -45,15 +50,20 @@ beforeEach(() => {
45
50
  state.isAuthenticated = false
46
51
  state.devLoginEnabled = false
47
52
  state.user = null
53
+ openLoginModal.mockClear()
48
54
  })
49
55
 
50
- describe('Header login affordance', () => {
51
- it('offers an anonymous visitor no way into the login flow', () => {
56
+ describe('Header login button', () => {
57
+ it('offers an anonymous visitor a Login button that opens the sign-in modal', async () => {
58
+ window.history.pushState({}, '', '/app/sample-review-app?tab=access')
52
59
  render(<Header />)
53
60
 
54
- // Catches a plain "Login" and any other login-ish control alike.
55
- expect(screen.queryByRole('button', { name: /login/i })).toBeNull()
56
- expect(screen.queryByText('Login')).not.toBeInTheDocument()
61
+ await userEvent.click(screen.getByRole('button', { name: 'Login' }))
62
+
63
+ // Comes back to where the visitor was after signing in.
64
+ expect(openLoginModal).toHaveBeenCalledWith(
65
+ '/app/sample-review-app?tab=access',
66
+ )
57
67
  })
58
68
 
59
69
  // Deliberately kept: already gated behind `devLoginEnabled`, so it never
@@ -65,10 +75,10 @@ describe('Header login affordance', () => {
65
75
  expect(
66
76
  screen.getByRole('button', { name: 'DEV Login' }),
67
77
  ).toBeInTheDocument()
68
- expect(screen.queryByText('Login')).not.toBeInTheDocument()
78
+ expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument()
69
79
  })
70
80
 
71
- it('leaves no empty wrapper where the button used to be', () => {
81
+ it('leaves no empty flex child in the header row', () => {
72
82
  const { container } = render(<Header />)
73
83
 
74
84
  // An empty flex child still eats the parent's `gap-3`, which is the
@@ -85,5 +95,6 @@ describe('Header login affordance', () => {
85
95
  render(<Header />)
86
96
 
87
97
  expect(screen.getByTestId('user-avatar-button')).toBeInTheDocument()
98
+ expect(screen.queryByRole('button', { name: 'Login' })).toBeNull()
88
99
  })
89
100
  })
@@ -199,7 +199,8 @@ export function AppDetails({
199
199
  [app, parent, allResources, children, user, isAdmin],
200
200
  )
201
201
 
202
- // Enter: open screenshot gallery
202
+ // Enter: open the app, as the door does — the card opens from search on one
203
+ // Enter, so a second one finishes the trip. No address: the screenshot gallery.
203
204
  useHotkeys(
204
205
  'enter',
205
206
  () => {
@@ -213,13 +214,16 @@ export function AppDetails({
213
214
  )
214
215
  return
215
216
 
216
- if (app.screenshotIds && app.screenshotIds.length > 0) {
217
+ if (app.appUrl) {
218
+ recordClick(app.slug)
219
+ window.open(app.appUrl, '_blank', 'noopener,noreferrer')
220
+ } else if (app.screenshotIds && app.screenshotIds.length > 0) {
217
221
  setGalleryInitialIndex(0)
218
222
  setIsGalleryOpen(true)
219
223
  }
220
224
  },
221
225
  { enabled: !isGalleryOpen },
222
- [app, isGalleryOpen],
226
+ [app, isGalleryOpen, recordClick],
223
227
  )
224
228
 
225
229
  const handleScreenshotClick = (index: number) => {
@@ -17,6 +17,7 @@ import {
17
17
  useIsAuthenticated,
18
18
  useUser,
19
19
  } from '~/modules/auth'
20
+ import { useAuthModal } from '~/modules/auth/AuthModalContext'
20
21
  import { Button } from '~/ui/button'
21
22
  import {
22
23
  DropdownMenu,
@@ -170,6 +171,7 @@ export function Header({ middle }: HeaderProps) {
170
171
  const isAuthenticated = useIsAuthenticated()
171
172
  const user = useUser()
172
173
  const { logout, devLogin } = useAuthActions()
174
+ const { open: openLoginModal } = useAuthModal()
173
175
  const appCatalogContextMaybe = use(AppCatalogContext)
174
176
  const trpc = useTRPC()
175
177
  const { data: providersData } = useQuery(
@@ -184,6 +186,12 @@ export function Header({ middle }: HeaderProps) {
184
186
  }
185
187
  }
186
188
 
189
+ const handleLoginClick = () => {
190
+ // Preserve the current URL for redirect after login
191
+ const currentUrl = window.location.pathname + window.location.search
192
+ openLoginModal(currentUrl)
193
+ }
194
+
187
195
  return (
188
196
  <div className="flex items-center mb-4 pb-4 gap-4 border-b">
189
197
  <div className="flex items-center gap-4">
@@ -207,24 +215,27 @@ export function Header({ middle }: HeaderProps) {
207
215
  {isLoading ? (
208
216
  <Skeleton className="w-8 h-8 rounded-full" />
209
217
  ) : !isAuthenticated ? (
210
- // No Login button: the catalog is fully browsable without signing in,
211
- // so it mostly advertised a flow most visitors have no reason to
212
- // enter. Auth itself is untouched -- the modal, the login route, the
213
- // client and session handling all still work, so anyone who reaches
214
- // the flow by URL or by calling `useAuthModal().open()` can sign in,
215
- // and this branch flips to the user menu below once they do. `git
216
- // revert` of the commit that removed it restores the button as-is.
217
- // DEV Login stays: it is gated behind `devLoginEnabled`, so it never
218
- // reaches a real deployment.
219
- providersData?.devLoginEnabled && (
218
+ // The catalog is browsable without signing in; Login is for the parts
219
+ // that need an identity (comments, admin). DEV Login is gated behind
220
+ // `devLoginEnabled`, so it never reaches a real deployment.
221
+ <div className="flex items-center gap-2">
222
+ {providersData?.devLoginEnabled && (
223
+ <button
224
+ type="button"
225
+ onClick={devLogin}
226
+ className="text-xs font-medium px-2 py-1 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 cursor-pointer"
227
+ >
228
+ DEV Login
229
+ </button>
230
+ )}
220
231
  <button
221
232
  type="button"
222
- onClick={devLogin}
223
- className="text-xs font-medium px-2 py-1 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 cursor-pointer"
233
+ onClick={handleLoginClick}
234
+ className="text-sm font-medium text-muted-foreground hover:text-foreground cursor-pointer"
224
235
  >
225
- DEV Login
236
+ Login
226
237
  </button>
227
- )
238
+ </div>
228
239
  ) : (
229
240
  <DropdownMenu>
230
241
  <DropdownMenuTrigger asChild>