@igstack/app-catalog-frontend-core 6.10.1 → 6.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/esm/__tests__/modules/auth/useAuthActions.logout.test.d.ts +1 -0
  2. package/dist/esm/__tests__/modules/pwa/PwaAutoUpdateProvider.test.d.ts +1 -0
  3. package/dist/esm/internal.d.ts +1 -0
  4. package/dist/esm/internal.js +2 -0
  5. package/dist/esm/internal.js.map +1 -1
  6. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +0 -1
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +1 -1
  9. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +0 -1
  10. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.d.ts +2 -0
  12. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +8 -4
  13. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  14. package/dist/esm/modules/auth/AuthContext.d.ts +1 -0
  15. package/dist/esm/modules/auth/AuthContext.js +11 -7
  16. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  17. package/dist/esm/modules/auth/useAuthActions.js +3 -1
  18. package/dist/esm/modules/auth/useAuthActions.js.map +1 -1
  19. package/dist/esm/modules/pwa/PwaAutoUpdateContext.js +16 -8
  20. package/dist/esm/modules/pwa/PwaAutoUpdateContext.js.map +1 -1
  21. package/dist/esm/ui/layout/Devtools.js.map +1 -1
  22. package/package.json +4 -4
  23. package/src/__tests__/modules/auth/useAuthActions.logout.test.ts +49 -0
  24. package/src/__tests__/modules/dismiss/DismissStack.test.tsx +10 -12
  25. package/src/__tests__/modules/pwa/PwaAutoUpdateProvider.test.tsx +44 -0
  26. package/src/internal.ts +1 -0
  27. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +14 -5
  28. package/src/modules/auth/AuthContext.tsx +9 -6
  29. package/src/modules/auth/useAuthActions.ts +5 -2
  30. package/src/modules/pwa/PwaAutoUpdateContext.tsx +21 -10
  31. package/src/ui/layout/Devtools.tsx +6 -3
@@ -17,3 +17,4 @@ export { App } from './App.js';
17
17
  export type { AppProps } from './App.js';
18
18
  export { AcDb, dbCacheDbKeys } from './userDb/AcDb.js';
19
19
  export { SEARCH_STORAGE_KEY, clearSessionState, seedSessionState, } from './modules/appCatalog/hooks/useSessionSyncedState.js';
20
+ export { preloadAppDetailPanel } from './modules/appCatalog/ui/pages/AppCatalogPage.js';
@@ -1,12 +1,14 @@
1
1
  import { App } from "./App.js";
2
2
  import { AcDb, dbCacheDbKeys } from "./userDb/AcDb.js";
3
3
  import { SEARCH_STORAGE_KEY, clearSessionState, seedSessionState } from "./modules/appCatalog/hooks/useSessionSyncedState.js";
4
+ import { preloadAppDetailPanel } from "./modules/appCatalog/ui/pages/AppCatalogPage.js";
4
5
  export {
5
6
  AcDb,
6
7
  App,
7
8
  SEARCH_STORAGE_KEY,
8
9
  clearSessionState,
9
10
  dbCacheDbKeys,
11
+ preloadAppDetailPanel,
10
12
  seedSessionState
11
13
  };
12
14
  //# sourceMappingURL=internal.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"internal.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;"}
1
+ {"version":3,"file":"internal.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;"}
@@ -5,7 +5,6 @@ import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
5
5
  import { useOptionalUser } from "../../../auth/AuthContext.js";
6
6
  import "@tanstack/react-query";
7
7
  import "../../../../ui/button.js";
8
- import "../../../auth/AuthModalContext.js";
9
8
  import { useDismissStack, useDismissable } from "../../../dismiss/DismissStack.js";
10
9
  import { getChildResources } from "../../utils/resolveHelpers.js";
11
10
  import { SubResourceDetailPanel } from "../components/SubResourceDetailPanel.js";
@@ -1 +1 @@
1
- {"version":3,"file":"AppDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Maximize2, Minimize2, X } from 'lucide-react'\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport {\n ResourceDetailAccessActions,\n ResourceDetailProvider,\n} from '~/modules/extensions'\nimport { useOptionalUser } from '~/modules/auth'\nimport { useDismissStack, useDismissable } from '~/modules/dismiss/DismissStack'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'\nimport { AppDetails } from '../detail/AppDetails'\n\n/**\n * Centered detail card over the catalog (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the catalog backdrop.\n *\n * When a sub-resource is selected (`?sub=<slug>` — e.g. the user clicked a\n * matched sub-resource row in the search results) the card shows that\n * sub-resource's own detail, with its two-step access chain and a back link to\n * the parent.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function AppDetailPanel({\n app,\n subResource,\n onClose,\n onAppClick,\n onBackToParent,\n}: {\n app: Resource\n /** Selected child of `app`, when the URL carries `?sub=<slug>`. */\n subResource?: Resource | null\n onClose: () => void\n onAppClick?: (app: Resource) => void\n /** Clears `?sub=` and returns to the parent's detail. */\n onBackToParent?: () => void\n}) {\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const user = useOptionalUser()\n\n // The sub-resource branch has no band of its own, so its slot payload is\n // assembled here, where the catalog context already is, and handed down.\n const subResourceActionsCtx = useMemo(\n () => ({\n resource: subResource ?? app,\n parent: subResource ? app : undefined,\n subResources: getChildResources(allResources, app.slug),\n user: user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null,\n }),\n [subResource, app, allResources, user],\n )\n const dialogRef = useRef<HTMLDivElement>(null)\n // Not persisted: every open starts at the normal width. The toggle is for the\n // one card being read, and the default stays readable for the next one.\n const [isWide, setIsWide] = useState(false)\n\n /**\n * Centre the card ONCE when it opens, then hold that position.\n *\n * `my-auto` centred it on every render, so each panel height change pushed both\n * edges and half the delta became upward movement of everything above it —\n * including the tab strip, which moved 113px and took the control the user had\n * just clicked with it (CLS 0.19, \"needs improvement\").\n *\n * Top-anchoring fixed the shift but looked wrong: a short card sat high on the\n * page. So pin the centred offset instead. The card still opens centred, and\n * anything that grows it afterwards extends downward only — which is what a\n * reader expects when they open a tab with more in it.\n *\n * Only on open and on resize. Deliberately NOT on content change: reacting to\n * content is precisely the bug.\n */\n useLayoutEffect(() => {\n const card = dialogRef.current\n const scroller = card?.parentElement\n if (!card || !scroller) return\n\n const pinCentre = () => {\n card.style.marginTop = '0px'\n const style = getComputedStyle(scroller)\n const usable =\n scroller.clientHeight -\n parseFloat(style.paddingTop) -\n parseFloat(style.paddingBottom)\n const slack = usable - card.offsetHeight\n // Negative slack means the card is already taller than the viewport; leave it\n // at the top and let the overlay scroll, rather than pushing its head off.\n card.style.marginTop = `${Math.max(0, Math.round(slack / 2))}px`\n }\n\n pinCentre()\n window.addEventListener('resize', pinCentre)\n return () => window.removeEventListener('resize', pinCentre)\n // Keyed on the opened entry: a different card is a new open, and should\n // re-centre for its own height.\n }, [app.slug, subResource?.slug])\n\n // Focus the card on mount so Esc hotkeys fire immediately, even when\n // the card was opened by a mouse click (which leaves focus on the grid row).\n //\n // The door link rather than the dialog itself when the app has one: Enter on\n // a search result opens the card, so a second Enter is the browser's own link\n // activation and takes the reader into the app. No door: the dialog.\n //\n // Unless something inside already took the caret — the Quick Jump field does,\n // and it has the better claim: a child's effect runs before its parent's, so\n // without this guard the card would take focus straight back off it.\n //\n // Runs again whenever the card navigates to another resource, keyed the same\n // way the body below is: that navigation destroys whatever was focused (the\n // \"View replacement\" button, a prerequisite's parent), and without this the\n // caret would land on <body> — where the next Tab walks the catalog grid\n // behind the scrim, since the card has no focus trap.\n const shownSlug = subResource?.slug ?? app.slug\n const stack = useDismissStack()\n useEffect(() => {\n const dialog = dialogRef.current\n if (!dialog || dialog.contains(document.activeElement)) return\n // Only while the card is on top. A layer opened OVER it — a deployment's\n // dialog on a nested route — owns the caret, and on a cold deep link that\n // layer mounts before the card does, so without this check the card took\n // focus back off the dialog's first field the moment the catalog arrived.\n const top = stack.topElement()\n if (top && top !== dialog) return\n const target = dialog.querySelector<HTMLElement>('.app-door') ?? dialog\n target.focus()\n }, [shownSlug, stack])\n\n // Escape closes the card. Registered here rather than in AppDetails because\n // this is the layer's element, and because the sub-resource view renders a\n // different body in place of AppDetails — which left that view with no\n // Escape at all while the registration lived in the body.\n useDismissable(dialogRef, onClose)\n\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card — wide enough for data tables (sub-resources have 5\n columns incl. \"Access Contacts\"/\"AWS Account\" that wrapped at 760px);\n prose inside AppDetails is width-capped separately so it stays\n readable. Caps at 94vw so it never touches the edges. */}\n <div\n ref={dialogRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${subResource?.displayName ?? app.displayName} details`}\n tabIndex={-1}\n // Centred on open, then held — see the pin effect above. `my-auto` is\n // deliberately absent: it re-centres on EVERY height change, which is the\n // layout-shift bug.\n // `overflow-hidden` belongs on the rounded box: the scrolling child\n // below carries the band's tint to its own square corners and paints\n // them over this radius, so the card's top corners read as cut off.\n // Widening drops the cap only. The pinned top margin is left alone, so\n // the card's top edge stays put and restoring gives back the same layout.\n className={`relative w-full ${isWide ? 'max-w-none' : 'max-w-[min(1120px,94vw)]'} overflow-hidden rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none`}\n >\n <div className=\"absolute top-3 right-3 z-10 flex gap-1\">\n <button\n type=\"button\"\n aria-label={isWide ? 'Restore width' : 'Widen card'}\n title={isWide ? 'Restore width' : 'Widen card'}\n onClick={() => setIsWide((wide) => !wide)}\n className=\"rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n {isWide ? (\n <Minimize2 className=\"size-4\" />\n ) : (\n <Maximize2 className=\"size-4\" />\n )}\n </button>\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={onClose}\n className=\"rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n </div>\n {/* Keyed by the resource on screen, so the body remounts when the card\n navigates from one app to another (\"View replacement\", an access\n prerequisite's parent) instead of updating in place. Everything\n per-app inside it is component state with no reset of its own — the\n roles table's expansion, the icon/screenshot error flags, a draft\n source edit — and it used to arrive at the next app still carrying\n the previous one's, plus the previous scroll offset.\n\n The key is here and not on the outer dialog on purpose: that one\n would restart the open animation and re-run the mount-focus effect\n on every in-card navigation. */}\n <div\n key={shownSlug}\n className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\"\n >\n {/* Inside the key, so anything a plugin provides here is scoped to\n the resource on screen and is discarded when the card navigates.\n Renders its children untouched when no plugin contributes. */}\n <ResourceDetailProvider\n resource={subResource ?? app}\n parent={subResource ? app : undefined}\n >\n {subResource ? (\n <SubResourceDetailPanel\n subResource={subResource}\n parent={app}\n approvalMethods={approvalMethods}\n onBack={onBackToParent ?? onClose}\n actions={\n <ResourceDetailAccessActions {...subResourceActionsCtx} />\n }\n />\n ) : (\n <AppDetails app={app} onAppClick={onAppClick} />\n )}\n </ResourceDetailProvider>\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AppDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Maximize2, Minimize2, X } from 'lucide-react'\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport {\n ResourceDetailAccessActions,\n ResourceDetailProvider,\n} from '~/modules/extensions'\nimport { useOptionalUser } from '~/modules/auth'\nimport { useDismissStack, useDismissable } from '~/modules/dismiss/DismissStack'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'\nimport { AppDetails } from '../detail/AppDetails'\n\n/**\n * Centered detail card over the catalog (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the catalog backdrop.\n *\n * When a sub-resource is selected (`?sub=<slug>` — e.g. the user clicked a\n * matched sub-resource row in the search results) the card shows that\n * sub-resource's own detail, with its two-step access chain and a back link to\n * the parent.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function AppDetailPanel({\n app,\n subResource,\n onClose,\n onAppClick,\n onBackToParent,\n}: {\n app: Resource\n /** Selected child of `app`, when the URL carries `?sub=<slug>`. */\n subResource?: Resource | null\n onClose: () => void\n onAppClick?: (app: Resource) => void\n /** Clears `?sub=` and returns to the parent's detail. */\n onBackToParent?: () => void\n}) {\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const user = useOptionalUser()\n\n // The sub-resource branch has no band of its own, so its slot payload is\n // assembled here, where the catalog context already is, and handed down.\n const subResourceActionsCtx = useMemo(\n () => ({\n resource: subResource ?? app,\n parent: subResource ? app : undefined,\n subResources: getChildResources(allResources, app.slug),\n user: user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null,\n }),\n [subResource, app, allResources, user],\n )\n const dialogRef = useRef<HTMLDivElement>(null)\n // Not persisted: every open starts at the normal width. The toggle is for the\n // one card being read, and the default stays readable for the next one.\n const [isWide, setIsWide] = useState(false)\n\n /**\n * Centre the card ONCE when it opens, then hold that position.\n *\n * `my-auto` centred it on every render, so each panel height change pushed both\n * edges and half the delta became upward movement of everything above it —\n * including the tab strip, which moved 113px and took the control the user had\n * just clicked with it (CLS 0.19, \"needs improvement\").\n *\n * Top-anchoring fixed the shift but looked wrong: a short card sat high on the\n * page. So pin the centred offset instead. The card still opens centred, and\n * anything that grows it afterwards extends downward only — which is what a\n * reader expects when they open a tab with more in it.\n *\n * Only on open and on resize. Deliberately NOT on content change: reacting to\n * content is precisely the bug.\n */\n useLayoutEffect(() => {\n const card = dialogRef.current\n const scroller = card?.parentElement\n if (!card || !scroller) return\n\n const pinCentre = () => {\n card.style.marginTop = '0px'\n const style = getComputedStyle(scroller)\n const usable =\n scroller.clientHeight -\n parseFloat(style.paddingTop) -\n parseFloat(style.paddingBottom)\n const slack = usable - card.offsetHeight\n // Negative slack means the card is already taller than the viewport; leave it\n // at the top and let the overlay scroll, rather than pushing its head off.\n card.style.marginTop = `${Math.max(0, Math.round(slack / 2))}px`\n }\n\n pinCentre()\n window.addEventListener('resize', pinCentre)\n return () => window.removeEventListener('resize', pinCentre)\n // Keyed on the opened entry: a different card is a new open, and should\n // re-centre for its own height.\n }, [app.slug, subResource?.slug])\n\n // Focus the card on mount so Esc hotkeys fire immediately, even when\n // the card was opened by a mouse click (which leaves focus on the grid row).\n //\n // The door link rather than the dialog itself when the app has one: Enter on\n // a search result opens the card, so a second Enter is the browser's own link\n // activation and takes the reader into the app. No door: the dialog.\n //\n // Unless something inside already took the caret — the Quick Jump field does,\n // and it has the better claim: a child's effect runs before its parent's, so\n // without this guard the card would take focus straight back off it.\n //\n // Runs again whenever the card navigates to another resource, keyed the same\n // way the body below is: that navigation destroys whatever was focused (the\n // \"View replacement\" button, a prerequisite's parent), and without this the\n // caret would land on <body> — where the next Tab walks the catalog grid\n // behind the scrim, since the card has no focus trap.\n const shownSlug = subResource?.slug ?? app.slug\n const stack = useDismissStack()\n useEffect(() => {\n const dialog = dialogRef.current\n if (!dialog || dialog.contains(document.activeElement)) return\n // Only while the card is on top. A layer opened OVER it — a deployment's\n // dialog on a nested route — owns the caret, and on a cold deep link that\n // layer mounts before the card does, so without this check the card took\n // focus back off the dialog's first field the moment the catalog arrived.\n const top = stack.topElement()\n if (top && top !== dialog) return\n const target = dialog.querySelector<HTMLElement>('.app-door') ?? dialog\n target.focus()\n }, [shownSlug, stack])\n\n // Escape closes the card. Registered here rather than in AppDetails because\n // this is the layer's element, and because the sub-resource view renders a\n // different body in place of AppDetails — which left that view with no\n // Escape at all while the registration lived in the body.\n useDismissable(dialogRef, onClose)\n\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card — wide enough for data tables (sub-resources have 5\n columns incl. \"Access Contacts\"/\"AWS Account\" that wrapped at 760px);\n prose inside AppDetails is width-capped separately so it stays\n readable. Caps at 94vw so it never touches the edges. */}\n <div\n ref={dialogRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${subResource?.displayName ?? app.displayName} details`}\n tabIndex={-1}\n // Centred on open, then held — see the pin effect above. `my-auto` is\n // deliberately absent: it re-centres on EVERY height change, which is the\n // layout-shift bug.\n // `overflow-hidden` belongs on the rounded box: the scrolling child\n // below carries the band's tint to its own square corners and paints\n // them over this radius, so the card's top corners read as cut off.\n // Widening drops the cap only. The pinned top margin is left alone, so\n // the card's top edge stays put and restoring gives back the same layout.\n className={`relative w-full ${isWide ? 'max-w-none' : 'max-w-[min(1120px,94vw)]'} overflow-hidden rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none`}\n >\n <div className=\"absolute top-3 right-3 z-10 flex gap-1\">\n <button\n type=\"button\"\n aria-label={isWide ? 'Restore width' : 'Widen card'}\n title={isWide ? 'Restore width' : 'Widen card'}\n onClick={() => setIsWide((wide) => !wide)}\n className=\"rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n {isWide ? (\n <Minimize2 className=\"size-4\" />\n ) : (\n <Maximize2 className=\"size-4\" />\n )}\n </button>\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={onClose}\n className=\"rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n </div>\n {/* Keyed by the resource on screen, so the body remounts when the card\n navigates from one app to another (\"View replacement\", an access\n prerequisite's parent) instead of updating in place. Everything\n per-app inside it is component state with no reset of its own — the\n roles table's expansion, the icon/screenshot error flags, a draft\n source edit — and it used to arrive at the next app still carrying\n the previous one's, plus the previous scroll offset.\n\n The key is here and not on the outer dialog on purpose: that one\n would restart the open animation and re-run the mount-focus effect\n on every in-card navigation. */}\n <div\n key={shownSlug}\n className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\"\n >\n {/* Inside the key, so anything a plugin provides here is scoped to\n the resource on screen and is discarded when the card navigates.\n Renders its children untouched when no plugin contributes. */}\n <ResourceDetailProvider\n resource={subResource ?? app}\n parent={subResource ? app : undefined}\n >\n {subResource ? (\n <SubResourceDetailPanel\n subResource={subResource}\n parent={app}\n approvalMethods={approvalMethods}\n onBack={onBackToParent ?? onClose}\n actions={\n <ResourceDetailAccessActions {...subResourceActionsCtx} />\n }\n />\n ) : (\n <AppDetails app={app} onAppClick={onAppClick} />\n )}\n </ResourceDetailProvider>\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
- import { useMemo, createContext, use } from "react";
2
+ import { use, createContext, useMemo } from "react";
3
3
  import { useUrlSyncedState } from "../../hooks/useUrlSyncedState.js";
4
4
  import { useSessionSyncedState, SEARCH_STORAGE_KEY } from "../../hooks/useSessionSyncedState.js";
5
5
  const AppCatalogFiltersContext = createContext(void 0);
@@ -12,7 +12,6 @@ import { useAppCatalogFilters } from "../context/AppCatalogFiltersContext.js";
12
12
  import { PersonBadge } from "../components/PersonBadge.js";
13
13
  import { useUser } from "../../../auth/AuthContext.js";
14
14
  import "@tanstack/react-query";
15
- import "../../../auth/AuthModalContext.js";
16
15
  import { InlineEditableField } from "../components/InlineEditableField.js";
17
16
  import { MarkdownText } from "../components/MarkdownText.js";
18
17
  import { ScreenshotGallery } from "../components/ScreenshotGallery.js";
@@ -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 { 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 { 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 {\n ResourceDetailAccessActions,\n ResourceDetailCardSource,\n} from '~/modules/extensions'\nimport { CommunitySection } from './CommunitySection'\nimport type { CommunitySectionHandle } from './CommunitySection'\nimport { usePendingCount } from './usePendingCount'\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 behind: 'behind',\n} as const\n\n/**\n * One of Behind this card's top-level groups. A level above `Section`: its\n * title is a plain heading, and a hairline separates it from the group before.\n */\nfunction Group({\n title,\n children,\n}: {\n title: string\n children: React.ReactNode\n}) {\n return (\n <section className=\"behind-group\">\n <h2 className=\"behind-group__title\">{title}</h2>\n {children}\n </section>\n )\n}\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 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 // Agrees with the Pending badges in the feed: composed by the same rules.\n const behindCount = usePendingCount(app.slug, app.claims)\n\n // \"Last checked 10 hours ago · added 7 months ago\" under the sources.\n const lastChecked = app.freshness?.lastCheckedAt\n const dateParts = []\n if (lastChecked) {\n const suffix = app.freshness?.isStale ? ' (may be out of date)' : ''\n dateParts.push(`Last checked ${formatRelativeTime(lastChecked)}${suffix}`)\n }\n if (app.createdAt) {\n const addedLabel = dateParts.length > 0 ? 'added' : 'Added'\n dateParts.push(`${addedLabel} ${formatRelativeTime(app.createdAt)}`)\n }\n const datesLine = dateParts.join(' · ')\n const datesTitle = [\n app.freshness?.lastContentChangeAt &&\n `Content last changed ${app.freshness.lastContentChangeAt}`,\n lastChecked && `Last checked ${lastChecked}`,\n app.createdAt && `Added ${app.createdAt}`,\n ]\n .filter(Boolean)\n .join(' · ')\n\n // \"Share what you know\" asks for the input by bumping this. A counter, not a\n // flag, so a second press while already on the tab still re-focuses. The\n // effect runs after the click's render has mounted the tab's panel, so the\n // ref is attached by then; opening the tab by hand bumps nothing.\n const knowMoreRef = React.useRef<CommunitySectionHandle>(null)\n const [shareAsk, setShareAsk] = React.useState(0)\n React.useEffect(() => {\n if (shareAsk) knowMoreRef.current?.focus()\n }, [shareAsk])\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. Only reached on a card with no door — where\n // there is one it holds focus, and Enter there is the link's own.\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 hasDocumentationTab = (app.links?.length ?? 0) > 0 || !!app.background\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 ...(hasDocumentationTab\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\" first={!app.links?.length}>\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 ),\n } satisfies DetailTab,\n ]\n : []),\n {\n id: TAB.behind,\n label: 'Behind this card',\n chip: behindCount ? String(behindCount) : undefined,\n ariaLabel: behindCount\n ? `Behind this card, ${behindCount} waiting for review`\n : undefined,\n render: () => (\n <div className=\"behind-panel\">\n <CommunitySection\n ref={knowMoreRef}\n appSlug={app.slug}\n appName={app.displayName}\n claims={app.claims}\n />\n\n {(isAdmin ||\n sourceUrls.length > 0 ||\n app.createdAt ||\n app.freshness?.lastCheckedAt) && (\n <Group title=\"Kept up to date from\">\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 {datesLine && (\n <p className=\"behind-dates\" title={datesTitle}>\n {datesLine}\n </p>\n )}\n </Group>\n )}\n\n {(app.aiPrompt || app.aiMemory) && (\n <Group title=\"How the AI maintains this card\">\n {app.aiPrompt && (\n <>\n <p className=\"behind-ai__label\">Prompt</p>\n <p className=\"behind-ai\">{app.aiPrompt}</p>\n </>\n )}\n {app.aiMemory && (\n <>\n <p className=\"behind-ai__label\">Memory</p>\n <p className=\"behind-ai\">{app.aiMemory}</p>\n </>\n )}\n </Group>\n )}\n\n <div className=\"behind-card-source\">\n <ResourceDetailCardSource\n appSlug={app.slug}\n appName={app.displayName}\n />\n </div>\n </div>\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 {/* Plugin actions live in the band because it is mounted on every tab and\n never scrolls away; a slot in a tab panel would be invisible until a\n second click. Renders nothing when no plugin contributes.\n `mr-10` keeps them clear of the dialog's close button at top-right. */}\n <div className=\"ml-auto mr-10 flex items-center gap-2\">\n <ResourceDetailAccessActions {...accessActionsCtx} />\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 trailing={(select) => (\n <button\n type=\"button\"\n className=\"detail-share\"\n aria-label=\"Share what you know\"\n onClick={() => {\n select(TAB.behind)\n setShareAsk((n) => n + 1)\n }}\n >\n <span className=\"max-sm:sr-only\">Share what you know</span>\n <Plus className=\"size-4 sm:hidden\" aria-hidden=\"true\" />\n </button>\n )}\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"],"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 { 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 { 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 {\n ResourceDetailAccessActions,\n ResourceDetailCardSource,\n} from '~/modules/extensions'\nimport { CommunitySection } from './CommunitySection'\nimport type { CommunitySectionHandle } from './CommunitySection'\nimport { usePendingCount } from './usePendingCount'\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 behind: 'behind',\n} as const\n\n/**\n * One of Behind this card's top-level groups. A level above `Section`: its\n * title is a plain heading, and a hairline separates it from the group before.\n */\nfunction Group({\n title,\n children,\n}: {\n title: string\n children: React.ReactNode\n}) {\n return (\n <section className=\"behind-group\">\n <h2 className=\"behind-group__title\">{title}</h2>\n {children}\n </section>\n )\n}\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 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 // Agrees with the Pending badges in the feed: composed by the same rules.\n const behindCount = usePendingCount(app.slug, app.claims)\n\n // \"Last checked 10 hours ago · added 7 months ago\" under the sources.\n const lastChecked = app.freshness?.lastCheckedAt\n const dateParts = []\n if (lastChecked) {\n const suffix = app.freshness?.isStale ? ' (may be out of date)' : ''\n dateParts.push(`Last checked ${formatRelativeTime(lastChecked)}${suffix}`)\n }\n if (app.createdAt) {\n const addedLabel = dateParts.length > 0 ? 'added' : 'Added'\n dateParts.push(`${addedLabel} ${formatRelativeTime(app.createdAt)}`)\n }\n const datesLine = dateParts.join(' · ')\n const datesTitle = [\n app.freshness?.lastContentChangeAt &&\n `Content last changed ${app.freshness.lastContentChangeAt}`,\n lastChecked && `Last checked ${lastChecked}`,\n app.createdAt && `Added ${app.createdAt}`,\n ]\n .filter(Boolean)\n .join(' · ')\n\n // \"Share what you know\" asks for the input by bumping this. A counter, not a\n // flag, so a second press while already on the tab still re-focuses. The\n // effect runs after the click's render has mounted the tab's panel, so the\n // ref is attached by then; opening the tab by hand bumps nothing.\n const knowMoreRef = React.useRef<CommunitySectionHandle>(null)\n const [shareAsk, setShareAsk] = React.useState(0)\n React.useEffect(() => {\n if (shareAsk) knowMoreRef.current?.focus()\n }, [shareAsk])\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. Only reached on a card with no door — where\n // there is one it holds focus, and Enter there is the link's own.\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 hasDocumentationTab = (app.links?.length ?? 0) > 0 || !!app.background\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 ...(hasDocumentationTab\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\" first={!app.links?.length}>\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 ),\n } satisfies DetailTab,\n ]\n : []),\n {\n id: TAB.behind,\n label: 'Behind this card',\n chip: behindCount ? String(behindCount) : undefined,\n ariaLabel: behindCount\n ? `Behind this card, ${behindCount} waiting for review`\n : undefined,\n render: () => (\n <div className=\"behind-panel\">\n <CommunitySection\n ref={knowMoreRef}\n appSlug={app.slug}\n appName={app.displayName}\n claims={app.claims}\n />\n\n {(isAdmin ||\n sourceUrls.length > 0 ||\n app.createdAt ||\n app.freshness?.lastCheckedAt) && (\n <Group title=\"Kept up to date from\">\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 {datesLine && (\n <p className=\"behind-dates\" title={datesTitle}>\n {datesLine}\n </p>\n )}\n </Group>\n )}\n\n {(app.aiPrompt || app.aiMemory) && (\n <Group title=\"How the AI maintains this card\">\n {app.aiPrompt && (\n <>\n <p className=\"behind-ai__label\">Prompt</p>\n <p className=\"behind-ai\">{app.aiPrompt}</p>\n </>\n )}\n {app.aiMemory && (\n <>\n <p className=\"behind-ai__label\">Memory</p>\n <p className=\"behind-ai\">{app.aiMemory}</p>\n </>\n )}\n </Group>\n )}\n\n <div className=\"behind-card-source\">\n <ResourceDetailCardSource\n appSlug={app.slug}\n appName={app.displayName}\n />\n </div>\n </div>\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 {/* Plugin actions live in the band because it is mounted on every tab and\n never scrolls away; a slot in a tab panel would be invisible until a\n second click. Renders nothing when no plugin contributes.\n `mr-10` keeps them clear of the dialog's close button at top-right. */}\n <div className=\"ml-auto mr-10 flex items-center gap-2\">\n <ResourceDetailAccessActions {...accessActionsCtx} />\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 trailing={(select) => (\n <button\n type=\"button\"\n className=\"detail-share\"\n aria-label=\"Share what you know\"\n onClick={() => {\n select(TAB.behind)\n setShareAsk((n) => n + 1)\n }}\n >\n <span className=\"max-sm:sr-only\">Share what you know</span>\n <Plus className=\"size-4 sm:hidden\" aria-hidden=\"true\" />\n </button>\n )}\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"],"mappings":"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"}
@@ -1,3 +1,5 @@
1
+ /** Resolves once the detail card's chunk is loaded. */
2
+ export declare const preloadAppDetailPanel: () => Promise<void>;
1
3
  export declare function AppCatalogPage({ selectedSlug, selectedSubSlug: subPageSlug, }?: {
2
4
  selectedSlug?: string;
3
5
  selectedSubSlug?: string;
@@ -5,10 +5,10 @@ import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
5
5
  import { useAppCatalogFilters } from "../context/AppCatalogFiltersContext.js";
6
6
  import { AppCatalogGrid } from "../catalog/AppCatalogGrid.js";
7
7
  import { AttributionFooter } from "../catalog/AttributionFooter.js";
8
+ const loadAppDetailPanel = () => import("../catalog/AppDetailPanel.js");
9
+ const preloadAppDetailPanel = () => loadAppDetailPanel().then(() => void 0);
8
10
  const AppDetailPanel = lazy(
9
- () => import("../catalog/AppDetailPanel.js").then((m) => ({
10
- default: m.AppDetailPanel
11
- }))
11
+ () => loadAppDetailPanel().then((m) => ({ default: m.AppDetailPanel }))
12
12
  );
13
13
  function AppCatalogPage({
14
14
  selectedSlug,
@@ -49,6 +49,9 @@ function AppCatalogPage({
49
49
  () => rootResources.filter((a) => filterState.showDeprecated || !a.deprecated),
50
50
  [rootResources, filterState.showDeprecated]
51
51
  );
52
+ useEffect(() => {
53
+ void preloadAppDetailPanel();
54
+ }, []);
52
55
  useEffect(() => {
53
56
  }, [isLoadingApps, resources.length, rootResources.length]);
54
57
  useEffect(() => {
@@ -141,6 +144,7 @@ function AppCatalogPage({
141
144
  );
142
145
  }
143
146
  export {
144
- AppCatalogPage
147
+ AppCatalogPage,
148
+ preloadAppDetailPanel
145
149
  };
146
150
  //# sourceMappingURL=AppCatalogPage.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate, useSearch } from '@tanstack/react-router'\nimport { Suspense, lazy, useCallback, useEffect, useMemo } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { AppCatalogGrid } from '../catalog/AppCatalogGrid'\nimport { AttributionFooter } from '../catalog/AttributionFooter'\n\n// The detail card (and the markdown stack it renders) loads on first open, not\n// with the catalog grid every visitor sees first (#120).\nconst AppDetailPanel = lazy(() =>\n import('../catalog/AppDetailPanel').then((m) => ({\n default: m.AppDetailPanel,\n })),\n)\n\nexport function AppCatalogPage({\n selectedSlug,\n selectedSubSlug: subPageSlug,\n}: { selectedSlug?: string; selectedSubSlug?: string } = {}) {\n const { resources, isLoadingApps } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (module-scoped, see AppCatalogFiltersContext)\n const search = useSearch({ strict: false })\n\n // Two distinct states, deliberately on different parts of the URL:\n // - `?sub=<slug>` the parent's detail, singled out to one child\n // - `/app/<slug>/sub/<child>` that child's OWN page, with its access chain\n // Clicking a matched sub-resource in the search results reaches the first;\n // clicking its name in the parent's table reaches the second.\n const highlightSubSlug = search.sub\n\n const handleSubClick = useCallback(\n (parentSlug: string, subSlug: string) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: parentSlug },\n search: (prev) => ({ ...prev, sub: subSlug }),\n })\n },\n [navigate],\n )\n\n const handleBackToParent = useCallback(() => {\n // Leave the sub-resource's page for its parent's detail, keeping `?sub=` so\n // the row the user came from stays singled out — going \"back\" shouldn't\n // dump them into all forty-seven siblings.\n if (selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: selectedAppSlug },\n search: (prev) => ({ ...prev, sub: subPageSlug }),\n })\n }\n }, [navigate, selectedAppSlug, subPageSlug])\n\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Get root resources for display (children surface through search)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n /**\n * What the grid actually shows, memoized because its IDENTITY matters.\n *\n * Filtering inline in the JSX produced a new array on every render, and the\n * grid keys four `useMemo`s off this prop (the slug index, \"new this week\",\n * the browse list and its area grouping). A fresh identity invalidated all\n * four on every keystroke — recomputed even while searching, when the\n * discovery spine they feed is not on screen. The search box lives in a\n * context above this component, so every keystroke re-renders here.\n */\n const visibleRootResources = useMemo(\n () =>\n rootResources.filter((a) => filterState.showDeprecated || !a.deprecated),\n [rootResources, filterState.showDeprecated],\n )\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n // #22: alias → canonical redirect. When an app is renamed its old slug is\n // kept in `aliases[]`. If the URL slug matches no canonical slug but does\n // match some app's alias, redirect (replace) to that app's canonical slug so\n // old bookmarks resolve instead of showing a blank catalog. Guard on a real\n // canonical miss so we never fight the normal detail-open path. Note: this is\n // a client-side SPA redirect (replace), not an HTTP 301 — see #22.\n useEffect(() => {\n if (!selectedAppSlug || resources.length === 0) return\n const canonical = resources.some((r) => r.slug === selectedAppSlug)\n if (canonical) return\n const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))\n if (aliased) {\n void navigate({\n to: '/app/$slug',\n params: { slug: aliased.slug },\n search: (prev) => prev,\n replace: true,\n })\n }\n }, [selectedAppSlug, resources, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleLaunch = (app: Resource) => {\n if (app.appUrl) window.open(app.appUrl, '_blank', 'noopener,noreferrer')\n }\n\n // The app whose detail card is open over the catalog backdrop.\n /**\n * Strip the card's own params once no card is open.\n *\n * Clearing them inside the close handler does not hold: `useUrlSyncedState` only\n * ever pushes its state OUT to the url, so the tab strip — still mounted for one\n * more render — sees the url disagreeing with its own state and writes `tab` straight\n * back. Two owners, and the one on its way out gets the last word. Measured on a\n * live host: `?tab=notes` survived the close every time.\n *\n * Doing it here instead means one owner decides, and it decides AFTER the strip has\n * gone, so there is nothing left to argue with. Only the card's params go; the\n * catalog's own state (filters, recents, the deprecated toggle) outlives any card.\n */\n useEffect(() => {\n if (selectedSlug || subPageSlug) return\n const stale = ['tab', 'sub', 'qj'].filter(\n (key) => (search as Record<string, unknown>)[key] !== undefined,\n )\n if (stale.length === 0) return\n void navigate({\n to: '/',\n replace: true,\n search: (prev) => {\n const next = { ...(prev as Record<string, unknown>) }\n for (const key of stale) delete next[key]\n return next\n },\n })\n }, [selectedSlug, subPageSlug, search, navigate])\n\n const selectedApp = useMemo(\n () =>\n selectedAppSlug\n ? (resources.find((r) => r.slug === selectedAppSlug) ?? null)\n : null,\n [selectedAppSlug, resources],\n )\n\n // …and the sub-resource whose OWN page is open (`/app/<slug>/sub/<child>`).\n // `?sub=` deliberately does NOT land here: that state stays on the parent's\n // detail with the child singled out in its table.\n const selectedSub = useMemo(() => {\n if (!subPageSlug || !selectedApp) return null\n return (\n resources.find(\n (r) => r.slug === subPageSlug && r.parentSlug === selectedApp.slug,\n ) ?? null\n )\n }, [subPageSlug, selectedApp, resources])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // The catalog is one persistent shell (#38): the discovery spine or, while\n // typing, the in-place results list — both handled inside AppCatalogGrid, so\n // searching never swaps containers and the hero never moves. It owns vertical\n // scroll (MainLayout is h-screen/overflow-hidden), and `scrollbar-gutter:\n // stable` reserves the track so a shorter results list doesn't shift the hero\n // sideways when the scrollbar disappears.\n return (\n // `pb-24` lives here, on whatever ends up last — the footer when the app\n // configures one, the final card row otherwise — so nothing sits flush\n // against the bottom edge or under the floating support bubble.\n <div className=\"flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24\">\n <AppCatalogGrid\n apps={visibleRootResources}\n // All resources incl. children — so search matches sub-resources\n // (e.g. a cloud account) and surfaces their parent.\n allResources={resources}\n searchValue={searchValue}\n onSearchChange={setSearchValue}\n onAppClick={handleAppClick}\n onSubClick={handleSubClick}\n onLaunch={handleLaunch}\n detailOpen={selectedApp !== null}\n selectedSubSlug={highlightSubSlug}\n />\n {/* Outside the grid's 1000px column so its rule spans the full width. */}\n <AttributionFooter />\n {/* #38 item B: the detail as a card over the catalog backdrop. */}\n {selectedApp && (\n <Suspense fallback={null}>\n <AppDetailPanel\n app={selectedApp}\n subResource={selectedSub}\n onClose={() => void navigate({ to: '/' })}\n onAppClick={handleAppClick}\n onBackToParent={handleBackToParent}\n />\n </Suspense>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { useNavigate, useSearch } from '@tanstack/react-router'\nimport { Suspense, lazy, useCallback, useEffect, useMemo } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { AppCatalogGrid } from '../catalog/AppCatalogGrid'\nimport { AttributionFooter } from '../catalog/AttributionFooter'\n\n// The detail card (and the markdown stack it renders) stays out of the bundle\n// every visitor loads first (#120). The page fetches it after its first paint,\n// so the first click on a row does not wait for the chunk.\nconst loadAppDetailPanel = () => import('../catalog/AppDetailPanel')\n\n/** Resolves once the detail card's chunk is loaded. */\nexport const preloadAppDetailPanel = (): Promise<void> =>\n loadAppDetailPanel().then(() => undefined)\n\nconst AppDetailPanel = lazy(() =>\n loadAppDetailPanel().then((m) => ({ default: m.AppDetailPanel })),\n)\n\nexport function AppCatalogPage({\n selectedSlug,\n selectedSubSlug: subPageSlug,\n}: { selectedSlug?: string; selectedSubSlug?: string } = {}) {\n const { resources, isLoadingApps } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\n\n // Search value from context (module-scoped, see AppCatalogFiltersContext)\n const search = useSearch({ strict: false })\n\n // Two distinct states, deliberately on different parts of the URL:\n // - `?sub=<slug>` the parent's detail, singled out to one child\n // - `/app/<slug>/sub/<child>` that child's OWN page, with its access chain\n // Clicking a matched sub-resource in the search results reaches the first;\n // clicking its name in the parent's table reaches the second.\n const highlightSubSlug = search.sub\n\n const handleSubClick = useCallback(\n (parentSlug: string, subSlug: string) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: parentSlug },\n search: (prev) => ({ ...prev, sub: subSlug }),\n })\n },\n [navigate],\n )\n\n const handleBackToParent = useCallback(() => {\n // Leave the sub-resource's page for its parent's detail, keeping `?sub=` so\n // the row the user came from stays singled out — going \"back\" shouldn't\n // dump them into all forty-seven siblings.\n if (selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: selectedAppSlug },\n search: (prev) => ({ ...prev, sub: subPageSlug }),\n })\n }\n }, [navigate, selectedAppSlug, subPageSlug])\n\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Get root resources for display (children surface through search)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n /**\n * What the grid actually shows, memoized because its IDENTITY matters.\n *\n * Filtering inline in the JSX produced a new array on every render, and the\n * grid keys four `useMemo`s off this prop (the slug index, \"new this week\",\n * the browse list and its area grouping). A fresh identity invalidated all\n * four on every keystroke — recomputed even while searching, when the\n * discovery spine they feed is not on screen. The search box lives in a\n * context above this component, so every keystroke re-renders here.\n */\n const visibleRootResources = useMemo(\n () =>\n rootResources.filter((a) => filterState.showDeprecated || !a.deprecated),\n [rootResources, filterState.showDeprecated],\n )\n\n useEffect(() => {\n void preloadAppDetailPanel()\n }, [])\n\n // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n // #22: alias → canonical redirect. When an app is renamed its old slug is\n // kept in `aliases[]`. If the URL slug matches no canonical slug but does\n // match some app's alias, redirect (replace) to that app's canonical slug so\n // old bookmarks resolve instead of showing a blank catalog. Guard on a real\n // canonical miss so we never fight the normal detail-open path. Note: this is\n // a client-side SPA redirect (replace), not an HTTP 301 — see #22.\n useEffect(() => {\n if (!selectedAppSlug || resources.length === 0) return\n const canonical = resources.some((r) => r.slug === selectedAppSlug)\n if (canonical) return\n const aliased = resources.find((r) => r.aliases?.includes(selectedAppSlug))\n if (aliased) {\n void navigate({\n to: '/app/$slug',\n params: { slug: aliased.slug },\n search: (prev) => prev,\n replace: true,\n })\n }\n }, [selectedAppSlug, resources, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleLaunch = (app: Resource) => {\n if (app.appUrl) window.open(app.appUrl, '_blank', 'noopener,noreferrer')\n }\n\n // The app whose detail card is open over the catalog backdrop.\n /**\n * Strip the card's own params once no card is open.\n *\n * Clearing them inside the close handler does not hold: `useUrlSyncedState` only\n * ever pushes its state OUT to the url, so the tab strip — still mounted for one\n * more render — sees the url disagreeing with its own state and writes `tab` straight\n * back. Two owners, and the one on its way out gets the last word. Measured on a\n * live host: `?tab=notes` survived the close every time.\n *\n * Doing it here instead means one owner decides, and it decides AFTER the strip has\n * gone, so there is nothing left to argue with. Only the card's params go; the\n * catalog's own state (filters, recents, the deprecated toggle) outlives any card.\n */\n useEffect(() => {\n if (selectedSlug || subPageSlug) return\n const stale = ['tab', 'sub', 'qj'].filter(\n (key) => (search as Record<string, unknown>)[key] !== undefined,\n )\n if (stale.length === 0) return\n void navigate({\n to: '/',\n replace: true,\n search: (prev) => {\n const next = { ...(prev as Record<string, unknown>) }\n for (const key of stale) delete next[key]\n return next\n },\n })\n }, [selectedSlug, subPageSlug, search, navigate])\n\n const selectedApp = useMemo(\n () =>\n selectedAppSlug\n ? (resources.find((r) => r.slug === selectedAppSlug) ?? null)\n : null,\n [selectedAppSlug, resources],\n )\n\n // …and the sub-resource whose OWN page is open (`/app/<slug>/sub/<child>`).\n // `?sub=` deliberately does NOT land here: that state stays on the parent's\n // detail with the child singled out in its table.\n const selectedSub = useMemo(() => {\n if (!subPageSlug || !selectedApp) return null\n return (\n resources.find(\n (r) => r.slug === subPageSlug && r.parentSlug === selectedApp.slug,\n ) ?? null\n )\n }, [subPageSlug, selectedApp, resources])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // The catalog is one persistent shell (#38): the discovery spine or, while\n // typing, the in-place results list — both handled inside AppCatalogGrid, so\n // searching never swaps containers and the hero never moves. It owns vertical\n // scroll (MainLayout is h-screen/overflow-hidden), and `scrollbar-gutter:\n // stable` reserves the track so a shorter results list doesn't shift the hero\n // sideways when the scrollbar disappears.\n return (\n // `pb-24` lives here, on whatever ends up last — the footer when the app\n // configures one, the final card row otherwise — so nothing sits flush\n // against the bottom edge or under the floating support bubble.\n <div className=\"flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] pb-24\">\n <AppCatalogGrid\n apps={visibleRootResources}\n // All resources incl. children — so search matches sub-resources\n // (e.g. a cloud account) and surfaces their parent.\n allResources={resources}\n searchValue={searchValue}\n onSearchChange={setSearchValue}\n onAppClick={handleAppClick}\n onSubClick={handleSubClick}\n onLaunch={handleLaunch}\n detailOpen={selectedApp !== null}\n selectedSubSlug={highlightSubSlug}\n />\n {/* Outside the grid's 1000px column so its rule spans the full width. */}\n <AttributionFooter />\n {/* #38 item B: the detail as a card over the catalog backdrop. */}\n {selectedApp && (\n <Suspense fallback={null}>\n <AppDetailPanel\n app={selectedApp}\n subResource={selectedSub}\n onClose={() => void navigate({ to: '/' })}\n onAppClick={handleAppClick}\n onBackToParent={handleBackToParent}\n />\n </Suspense>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -13,6 +13,7 @@ interface AuthContextType {
13
13
  isLoading: boolean;
14
14
  isAuthenticated: boolean;
15
15
  }
16
+ export declare const AUTH_USER_STORAGE_KEY = "ac_auth_user";
16
17
  /**
17
18
  * Provider component for authentication state
18
19
  * Fetches and manages session from the backend
@@ -1,13 +1,13 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
- import { useState, useEffect, useMemo, createContext, use } from "react";
2
+ import { use, createContext, useState, useEffect, useMemo } from "react";
3
3
  const AuthContext = createContext(void 0);
4
- const STORAGE_KEY = "ac_auth_user";
4
+ const AUTH_USER_STORAGE_KEY = "ac_auth_user";
5
5
  function AuthProvider({ children }) {
6
6
  const [session, setSession] = useState(null);
7
7
  const [user, setUser] = useState(() => {
8
8
  if (typeof window === "undefined") return null;
9
9
  try {
10
- const stored = localStorage.getItem(STORAGE_KEY);
10
+ const stored = localStorage.getItem(AUTH_USER_STORAGE_KEY);
11
11
  return stored ? JSON.parse(stored) : null;
12
12
  } catch {
13
13
  return null;
@@ -28,14 +28,17 @@ function AuthProvider({ children }) {
28
28
  setSession(data);
29
29
  setUser((data == null ? void 0 : data.user) || null);
30
30
  if (data == null ? void 0 : data.user) {
31
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data.user));
31
+ localStorage.setItem(
32
+ AUTH_USER_STORAGE_KEY,
33
+ JSON.stringify(data.user)
34
+ );
32
35
  } else {
33
- localStorage.removeItem(STORAGE_KEY);
36
+ localStorage.removeItem(AUTH_USER_STORAGE_KEY);
34
37
  }
35
38
  } else {
36
39
  setSession(null);
37
40
  setUser(null);
38
- localStorage.removeItem(STORAGE_KEY);
41
+ localStorage.removeItem(AUTH_USER_STORAGE_KEY);
39
42
  }
40
43
  } catch (error) {
41
44
  if (error instanceof Error && error.name === "AbortError") {
@@ -44,7 +47,7 @@ function AuthProvider({ children }) {
44
47
  console.error("Failed to fetch session:", error);
45
48
  setSession(null);
46
49
  setUser(null);
47
- localStorage.removeItem(STORAGE_KEY);
50
+ localStorage.removeItem(AUTH_USER_STORAGE_KEY);
48
51
  } finally {
49
52
  setIsLoading(false);
50
53
  }
@@ -85,6 +88,7 @@ function useOptionalUser() {
85
88
  return ((_a = use(AuthContext)) == null ? void 0 : _a.user) ?? null;
86
89
  }
87
90
  export {
91
+ AUTH_USER_STORAGE_KEY,
88
92
  AuthProvider,
89
93
  useAuth,
90
94
  useIsAuthenticated,
@@ -1 +1 @@
1
- {"version":3,"file":"AuthContext.js","sources":["../../../../src/modules/auth/AuthContext.tsx"],"sourcesContent":["import type { Session } from 'better-auth/types'\nimport { createContext, use, useEffect, useMemo, useState } from 'react'\n\ninterface User {\n id: string\n email?: string | null\n name?: string | null\n image?: string | null\n emailVerified?: boolean\n isAdmin?: boolean\n}\n\ninterface AuthContextType {\n session: Session | null\n user: User | null\n isLoading: boolean\n isAuthenticated: boolean\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined)\n\nconst STORAGE_KEY = 'ac_auth_user'\n\n/**\n * Provider component for authentication state\n * Fetches and manages session from the backend\n * Uses localStorage for optimistic rendering to avoid flickering\n */\nexport function AuthProvider({ children }: { children: React.ReactNode }) {\n const [session, setSession] = useState<Session | null>(null)\n const [user, setUser] = useState<User | null>(() => {\n // Hydrate from localStorage on mount to avoid flickering\n if (typeof window === 'undefined') return null\n try {\n const stored = localStorage.getItem(STORAGE_KEY)\n return stored ? JSON.parse(stored) : null\n } catch {\n return null\n }\n })\n const [isLoading, setIsLoading] = useState(true)\n\n useEffect(() => {\n // Use AbortController to cancel fetch on unmount (e.g., React StrictMode double-mount)\n const abortController = new AbortController()\n\n // Fetch session from backend\n async function fetchSession() {\n try {\n const baseUrl =\n typeof window !== 'undefined' ? window.location.origin : ''\n const response = await fetch(`${baseUrl}/api/auth/session`, {\n credentials: 'include',\n signal: abortController.signal,\n })\n if (response.ok) {\n const data = await response.json()\n setSession(data)\n setUser(data?.user || null)\n if (data?.user) {\n localStorage.setItem(STORAGE_KEY, JSON.stringify(data.user))\n } else {\n localStorage.removeItem(STORAGE_KEY)\n }\n } else {\n setSession(null)\n setUser(null)\n localStorage.removeItem(STORAGE_KEY)\n }\n } catch (error) {\n // Ignore abort errors (happens on cleanup)\n if (error instanceof Error && error.name === 'AbortError') {\n return\n }\n console.error('Failed to fetch session:', error)\n setSession(null)\n setUser(null)\n localStorage.removeItem(STORAGE_KEY)\n } finally {\n setIsLoading(false)\n }\n }\n\n fetchSession()\n\n // Cleanup: abort fetch on unmount\n return () => {\n abortController.abort()\n }\n }, [])\n\n const value: AuthContextType = useMemo(\n () => ({\n session,\n user,\n isLoading,\n isAuthenticated: !!user,\n }),\n [session, user, isLoading],\n )\n\n return <AuthContext value={value}>{children}</AuthContext>\n}\n\n/**\n * Hook to access authentication state\n * @returns Authentication context containing session, user, and auth state\n */\nexport function useAuth() {\n const context = use(AuthContext)\n if (!context) {\n throw new Error('useAuth must be used within AuthProvider')\n }\n return context\n}\n\n/**\n * Hook to check if user is authenticated\n * @returns Boolean indicating if user is authenticated\n */\nexport function useIsAuthenticated() {\n const { isAuthenticated } = useAuth()\n return isAuthenticated\n}\n\n/**\n * Hook to get the current user\n * @returns Current user or null\n */\nexport function useUser() {\n const { user } = useAuth()\n return user\n}\n\n/**\n * The current user, or null when there is no `AuthProvider` above.\n *\n * `useUser()` throws without a provider, which is correct for UI that only ever\n * renders inside the app but wrong for anything a test may mount bare. Several\n * components are rendered bare on purpose (`AppDetailPanel`, `AppCatalogGrid`),\n * so assembling a plugin slot payload with `useUser()` there turns every one of\n * those tests red — \"useAuth must be used within AuthProvider\".\n *\n * Signed-out and no-provider are the same answer for a slot payload: null.\n */\nexport function useOptionalUser() {\n return use(AuthContext)?.user ?? null\n}\n"],"names":[],"mappings":";;AAmBA,MAAM,cAAc,cAA2C,MAAS;AAExE,MAAM,cAAc;AAOb,SAAS,aAAa,EAAE,YAA2C;AACxE,QAAM,CAAC,SAAS,UAAU,IAAI,SAAyB,IAAI;AAC3D,QAAM,CAAC,MAAM,OAAO,IAAI,SAAsB,MAAM;AAElD,QAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,QAAI;AACF,YAAM,SAAS,aAAa,QAAQ,WAAW;AAC/C,aAAO,SAAS,KAAK,MAAM,MAAM,IAAI;AAAA,IACvC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF,CAAC;AACD,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,IAAI;AAE/C,YAAU,MAAM;AAEd,UAAM,kBAAkB,IAAI,gBAAA;AAG5B,mBAAe,eAAe;AAC5B,UAAI;AACF,cAAM,UACJ,OAAO,WAAW,cAAc,OAAO,SAAS,SAAS;AAC3D,cAAM,WAAW,MAAM,MAAM,GAAG,OAAO,qBAAqB;AAAA,UAC1D,aAAa;AAAA,UACb,QAAQ,gBAAgB;AAAA,QAAA,CACzB;AACD,YAAI,SAAS,IAAI;AACf,gBAAM,OAAO,MAAM,SAAS,KAAA;AAC5B,qBAAW,IAAI;AACf,mBAAQ,6BAAM,SAAQ,IAAI;AAC1B,cAAI,6BAAM,MAAM;AACd,yBAAa,QAAQ,aAAa,KAAK,UAAU,KAAK,IAAI,CAAC;AAAA,UAC7D,OAAO;AACL,yBAAa,WAAW,WAAW;AAAA,UACrC;AAAA,QACF,OAAO;AACL,qBAAW,IAAI;AACf,kBAAQ,IAAI;AACZ,uBAAa,WAAW,WAAW;AAAA,QACrC;AAAA,MACF,SAAS,OAAO;AAEd,YAAI,iBAAiB,SAAS,MAAM,SAAS,cAAc;AACzD;AAAA,QACF;AACA,gBAAQ,MAAM,4BAA4B,KAAK;AAC/C,mBAAW,IAAI;AACf,gBAAQ,IAAI;AACZ,qBAAa,WAAW,WAAW;AAAA,MACrC,UAAA;AACE,qBAAa,KAAK;AAAA,MACpB;AAAA,IACF;AAEA,iBAAA;AAGA,WAAO,MAAM;AACX,sBAAgB,MAAA;AAAA,IAClB;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,QAAM,QAAyB;AAAA,IAC7B,OAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA,iBAAiB,CAAC,CAAC;AAAA,IAAA;AAAA,IAErB,CAAC,SAAS,MAAM,SAAS;AAAA,EAAA;AAG3B,SAAO,oBAAC,aAAA,EAAY,OAAe,SAAA,CAAS;AAC9C;AAMO,SAAS,UAAU;AACxB,QAAM,UAAU,IAAI,WAAW;AAC/B,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,SAAO;AACT;AAMO,SAAS,qBAAqB;AACnC,QAAM,EAAE,gBAAA,IAAoB,QAAA;AAC5B,SAAO;AACT;AAMO,SAAS,UAAU;AACxB,QAAM,EAAE,KAAA,IAAS,QAAA;AACjB,SAAO;AACT;AAaO,SAAS,kBAAkB;;AAChC,WAAO,SAAI,WAAW,MAAf,mBAAkB,SAAQ;AACnC;"}
1
+ {"version":3,"file":"AuthContext.js","sources":["../../../../src/modules/auth/AuthContext.tsx"],"sourcesContent":["import type { Session } from 'better-auth/types'\nimport { createContext, use, useEffect, useMemo, useState } from 'react'\n\ninterface User {\n id: string\n email?: string | null\n name?: string | null\n image?: string | null\n emailVerified?: boolean\n isAdmin?: boolean\n}\n\ninterface AuthContextType {\n session: Session | null\n user: User | null\n isLoading: boolean\n isAuthenticated: boolean\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined)\n\nexport const AUTH_USER_STORAGE_KEY = 'ac_auth_user'\n\n/**\n * Provider component for authentication state\n * Fetches and manages session from the backend\n * Uses localStorage for optimistic rendering to avoid flickering\n */\nexport function AuthProvider({ children }: { children: React.ReactNode }) {\n const [session, setSession] = useState<Session | null>(null)\n const [user, setUser] = useState<User | null>(() => {\n // Hydrate from localStorage on mount to avoid flickering\n if (typeof window === 'undefined') return null\n try {\n const stored = localStorage.getItem(AUTH_USER_STORAGE_KEY)\n return stored ? JSON.parse(stored) : null\n } catch {\n return null\n }\n })\n const [isLoading, setIsLoading] = useState(true)\n\n useEffect(() => {\n // Use AbortController to cancel fetch on unmount (e.g., React StrictMode double-mount)\n const abortController = new AbortController()\n\n // Fetch session from backend\n async function fetchSession() {\n try {\n const baseUrl =\n typeof window !== 'undefined' ? window.location.origin : ''\n const response = await fetch(`${baseUrl}/api/auth/session`, {\n credentials: 'include',\n signal: abortController.signal,\n })\n if (response.ok) {\n const data = await response.json()\n setSession(data)\n setUser(data?.user || null)\n if (data?.user) {\n localStorage.setItem(\n AUTH_USER_STORAGE_KEY,\n JSON.stringify(data.user),\n )\n } else {\n localStorage.removeItem(AUTH_USER_STORAGE_KEY)\n }\n } else {\n setSession(null)\n setUser(null)\n localStorage.removeItem(AUTH_USER_STORAGE_KEY)\n }\n } catch (error) {\n // Ignore abort errors (happens on cleanup)\n if (error instanceof Error && error.name === 'AbortError') {\n return\n }\n console.error('Failed to fetch session:', error)\n setSession(null)\n setUser(null)\n localStorage.removeItem(AUTH_USER_STORAGE_KEY)\n } finally {\n setIsLoading(false)\n }\n }\n\n fetchSession()\n\n // Cleanup: abort fetch on unmount\n return () => {\n abortController.abort()\n }\n }, [])\n\n const value: AuthContextType = useMemo(\n () => ({\n session,\n user,\n isLoading,\n isAuthenticated: !!user,\n }),\n [session, user, isLoading],\n )\n\n return <AuthContext value={value}>{children}</AuthContext>\n}\n\n/**\n * Hook to access authentication state\n * @returns Authentication context containing session, user, and auth state\n */\nexport function useAuth() {\n const context = use(AuthContext)\n if (!context) {\n throw new Error('useAuth must be used within AuthProvider')\n }\n return context\n}\n\n/**\n * Hook to check if user is authenticated\n * @returns Boolean indicating if user is authenticated\n */\nexport function useIsAuthenticated() {\n const { isAuthenticated } = useAuth()\n return isAuthenticated\n}\n\n/**\n * Hook to get the current user\n * @returns Current user or null\n */\nexport function useUser() {\n const { user } = useAuth()\n return user\n}\n\n/**\n * The current user, or null when there is no `AuthProvider` above.\n *\n * `useUser()` throws without a provider, which is correct for UI that only ever\n * renders inside the app but wrong for anything a test may mount bare. Several\n * components are rendered bare on purpose (`AppDetailPanel`, `AppCatalogGrid`),\n * so assembling a plugin slot payload with `useUser()` there turns every one of\n * those tests red — \"useAuth must be used within AuthProvider\".\n *\n * Signed-out and no-provider are the same answer for a slot payload: null.\n */\nexport function useOptionalUser() {\n return use(AuthContext)?.user ?? null\n}\n"],"names":[],"mappings":";;AAmBA,MAAM,cAAc,cAA2C,MAAS;AAEjE,MAAM,wBAAwB;AAO9B,SAAS,aAAa,EAAE,YAA2C;AACxE,QAAM,CAAC,SAAS,UAAU,IAAI,SAAyB,IAAI;AAC3D,QAAM,CAAC,MAAM,OAAO,IAAI,SAAsB,MAAM;AAElD,QAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,QAAI;AACF,YAAM,SAAS,aAAa,QAAQ,qBAAqB;AACzD,aAAO,SAAS,KAAK,MAAM,MAAM,IAAI;AAAA,IACvC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF,CAAC;AACD,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,IAAI;AAE/C,YAAU,MAAM;AAEd,UAAM,kBAAkB,IAAI,gBAAA;AAG5B,mBAAe,eAAe;AAC5B,UAAI;AACF,cAAM,UACJ,OAAO,WAAW,cAAc,OAAO,SAAS,SAAS;AAC3D,cAAM,WAAW,MAAM,MAAM,GAAG,OAAO,qBAAqB;AAAA,UAC1D,aAAa;AAAA,UACb,QAAQ,gBAAgB;AAAA,QAAA,CACzB;AACD,YAAI,SAAS,IAAI;AACf,gBAAM,OAAO,MAAM,SAAS,KAAA;AAC5B,qBAAW,IAAI;AACf,mBAAQ,6BAAM,SAAQ,IAAI;AAC1B,cAAI,6BAAM,MAAM;AACd,yBAAa;AAAA,cACX;AAAA,cACA,KAAK,UAAU,KAAK,IAAI;AAAA,YAAA;AAAA,UAE5B,OAAO;AACL,yBAAa,WAAW,qBAAqB;AAAA,UAC/C;AAAA,QACF,OAAO;AACL,qBAAW,IAAI;AACf,kBAAQ,IAAI;AACZ,uBAAa,WAAW,qBAAqB;AAAA,QAC/C;AAAA,MACF,SAAS,OAAO;AAEd,YAAI,iBAAiB,SAAS,MAAM,SAAS,cAAc;AACzD;AAAA,QACF;AACA,gBAAQ,MAAM,4BAA4B,KAAK;AAC/C,mBAAW,IAAI;AACf,gBAAQ,IAAI;AACZ,qBAAa,WAAW,qBAAqB;AAAA,MAC/C,UAAA;AACE,qBAAa,KAAK;AAAA,MACpB;AAAA,IACF;AAEA,iBAAA;AAGA,WAAO,MAAM;AACX,sBAAgB,MAAA;AAAA,IAClB;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,QAAM,QAAyB;AAAA,IAC7B,OAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA,iBAAiB,CAAC,CAAC;AAAA,IAAA;AAAA,IAErB,CAAC,SAAS,MAAM,SAAS;AAAA,EAAA;AAG3B,SAAO,oBAAC,aAAA,EAAY,OAAe,SAAA,CAAS;AAC9C;AAMO,SAAS,UAAU;AACxB,QAAM,UAAU,IAAI,WAAW;AAC/B,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,SAAO;AACT;AAMO,SAAS,qBAAqB;AACnC,QAAM,EAAE,gBAAA,IAAoB,QAAA;AAC5B,SAAO;AACT;AAMO,SAAS,UAAU;AACxB,QAAM,EAAE,KAAA,IAAS,QAAA;AACjB,SAAO;AACT;AAaO,SAAS,kBAAkB;;AAChC,WAAO,SAAI,WAAW,MAAf,mBAAkB,SAAQ;AACnC;"}
@@ -1,4 +1,5 @@
1
1
  import { useCallback } from "react";
2
+ import { AUTH_USER_STORAGE_KEY } from "./AuthContext.js";
2
3
  function useAuthActions() {
3
4
  const login = useCallback(async (credentials) => {
4
5
  const response = await fetch("/api/auth/sign-in/email", {
@@ -37,7 +38,8 @@ function useAuthActions() {
37
38
  credentials: "include"
38
39
  });
39
40
  if (!response.ok) ;
40
- window.location.href = "/";
41
+ localStorage.removeItem(AUTH_USER_STORAGE_KEY);
42
+ window.location.reload();
41
43
  }, []);
42
44
  const devLogin = useCallback(async () => {
43
45
  const response = await fetch("/api/auth/dev-login", {
@@ -1 +1 @@
1
- {"version":3,"file":"useAuthActions.js","sources":["../../../../src/modules/auth/useAuthActions.ts"],"sourcesContent":["import { useCallback } from 'react'\n\ninterface LoginCredentials {\n email: string\n password: string\n}\n\ninterface SignupCredentials extends LoginCredentials {\n name: string\n}\n\n/**\n * Hook for authentication actions (login, signup, logout)\n */\nexport function useAuthActions() {\n const login = useCallback(async (credentials: LoginCredentials) => {\n const response = await fetch('/api/auth/sign-in/email', {\n method: 'POST',\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(credentials),\n })\n\n if (!response.ok) {\n const error = await response.json()\n throw new Error(error.message || 'Login failed')\n }\n\n return response.json()\n }, [])\n\n const signup = useCallback(async (credentials: SignupCredentials) => {\n const response = await fetch('/api/auth/sign-up/email', {\n method: 'POST',\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(credentials),\n })\n\n if (!response.ok) {\n const error = await response.json()\n throw new Error(error.message || 'Signup failed')\n }\n\n return response.json()\n }, [])\n\n const logout = useCallback(async () => {\n // Clear dev session cookie if present\n await fetch('/api/auth/dev-logout', {\n method: 'POST',\n credentials: 'include',\n }).catch(() => {})\n\n const response = await fetch('/api/auth/sign-out', {\n method: 'POST',\n credentials: 'include',\n })\n\n if (!response.ok) {\n // Sign-out may fail if there's no real session (dev-only login)\n // Still reload to clear UI state\n }\n\n // Clear session and reload to update UI\n window.location.href = '/'\n }, [])\n\n const devLogin = useCallback(async () => {\n const response = await fetch('/api/auth/dev-login', {\n method: 'POST',\n credentials: 'include',\n })\n if (!response.ok) {\n throw new Error('Dev login failed')\n }\n window.location.reload()\n }, [])\n\n const socialLogin = useCallback(async (provider: 'github' | 'google') => {\n const response = await fetch(`/api/auth/signin/${provider}`, {\n method: 'POST',\n credentials: 'include',\n })\n\n if (!response.ok) {\n throw new Error(`${provider} login failed`)\n }\n\n const data = await response.json()\n // Redirect to provider's OAuth flow\n if (data.redirectUrl) {\n window.location.href = data.redirectUrl\n }\n }, [])\n\n return {\n login,\n signup,\n logout,\n socialLogin,\n devLogin,\n }\n}\n"],"names":[],"mappings":";AAcO,SAAS,iBAAiB;AAC/B,QAAM,QAAQ,YAAY,OAAO,gBAAkC;AACjE,UAAM,WAAW,MAAM,MAAM,2BAA2B;AAAA,MACtD,QAAQ;AAAA,MACR,aAAa;AAAA,MACb,SAAS,EAAE,gBAAgB,mBAAA;AAAA,MAC3B,MAAM,KAAK,UAAU,WAAW;AAAA,IAAA,CACjC;AAED,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,QAAQ,MAAM,SAAS,KAAA;AAC7B,YAAM,IAAI,MAAM,MAAM,WAAW,cAAc;AAAA,IACjD;AAEA,WAAO,SAAS,KAAA;AAAA,EAClB,GAAG,CAAA,CAAE;AAEL,QAAM,SAAS,YAAY,OAAO,gBAAmC;AACnE,UAAM,WAAW,MAAM,MAAM,2BAA2B;AAAA,MACtD,QAAQ;AAAA,MACR,aAAa;AAAA,MACb,SAAS,EAAE,gBAAgB,mBAAA;AAAA,MAC3B,MAAM,KAAK,UAAU,WAAW;AAAA,IAAA,CACjC;AAED,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,QAAQ,MAAM,SAAS,KAAA;AAC7B,YAAM,IAAI,MAAM,MAAM,WAAW,eAAe;AAAA,IAClD;AAEA,WAAO,SAAS,KAAA;AAAA,EAClB,GAAG,CAAA,CAAE;AAEL,QAAM,SAAS,YAAY,YAAY;AAErC,UAAM,MAAM,wBAAwB;AAAA,MAClC,QAAQ;AAAA,MACR,aAAa;AAAA,IAAA,CACd,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAEjB,UAAM,WAAW,MAAM,MAAM,sBAAsB;AAAA,MACjD,QAAQ;AAAA,MACR,aAAa;AAAA,IAAA,CACd;AAED,QAAI,CAAC,SAAS,GAAI;AAMlB,WAAO,SAAS,OAAO;AAAA,EACzB,GAAG,CAAA,CAAE;AAEL,QAAM,WAAW,YAAY,YAAY;AACvC,UAAM,WAAW,MAAM,MAAM,uBAAuB;AAAA,MAClD,QAAQ;AAAA,MACR,aAAa;AAAA,IAAA,CACd;AACD,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,IAAI,MAAM,kBAAkB;AAAA,IACpC;AACA,WAAO,SAAS,OAAA;AAAA,EAClB,GAAG,CAAA,CAAE;AAEL,QAAM,cAAc,YAAY,OAAO,aAAkC;AACvE,UAAM,WAAW,MAAM,MAAM,oBAAoB,QAAQ,IAAI;AAAA,MAC3D,QAAQ;AAAA,MACR,aAAa;AAAA,IAAA,CACd;AAED,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,IAAI,MAAM,GAAG,QAAQ,eAAe;AAAA,IAC5C;AAEA,UAAM,OAAO,MAAM,SAAS,KAAA;AAE5B,QAAI,KAAK,aAAa;AACpB,aAAO,SAAS,OAAO,KAAK;AAAA,IAC9B;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EAAA;AAEJ;"}
1
+ {"version":3,"file":"useAuthActions.js","sources":["../../../../src/modules/auth/useAuthActions.ts"],"sourcesContent":["import { useCallback } from 'react'\nimport { AUTH_USER_STORAGE_KEY } from './AuthContext'\n\ninterface LoginCredentials {\n email: string\n password: string\n}\n\ninterface SignupCredentials extends LoginCredentials {\n name: string\n}\n\n/**\n * Hook for authentication actions (login, signup, logout)\n */\nexport function useAuthActions() {\n const login = useCallback(async (credentials: LoginCredentials) => {\n const response = await fetch('/api/auth/sign-in/email', {\n method: 'POST',\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(credentials),\n })\n\n if (!response.ok) {\n const error = await response.json()\n throw new Error(error.message || 'Login failed')\n }\n\n return response.json()\n }, [])\n\n const signup = useCallback(async (credentials: SignupCredentials) => {\n const response = await fetch('/api/auth/sign-up/email', {\n method: 'POST',\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(credentials),\n })\n\n if (!response.ok) {\n const error = await response.json()\n throw new Error(error.message || 'Signup failed')\n }\n\n return response.json()\n }, [])\n\n const logout = useCallback(async () => {\n // Clear dev session cookie if present\n await fetch('/api/auth/dev-logout', {\n method: 'POST',\n credentials: 'include',\n }).catch(() => {})\n\n const response = await fetch('/api/auth/sign-out', {\n method: 'POST',\n credentials: 'include',\n })\n\n if (!response.ok) {\n // Sign-out may fail if there's no real session (dev-only login)\n // Still reload to clear UI state\n }\n\n // Stay on the current page: drop the optimistic user the provider hydrates\n // from, so the reload renders signed out without flashing the old user.\n localStorage.removeItem(AUTH_USER_STORAGE_KEY)\n window.location.reload()\n }, [])\n\n const devLogin = useCallback(async () => {\n const response = await fetch('/api/auth/dev-login', {\n method: 'POST',\n credentials: 'include',\n })\n if (!response.ok) {\n throw new Error('Dev login failed')\n }\n window.location.reload()\n }, [])\n\n const socialLogin = useCallback(async (provider: 'github' | 'google') => {\n const response = await fetch(`/api/auth/signin/${provider}`, {\n method: 'POST',\n credentials: 'include',\n })\n\n if (!response.ok) {\n throw new Error(`${provider} login failed`)\n }\n\n const data = await response.json()\n // Redirect to provider's OAuth flow\n if (data.redirectUrl) {\n window.location.href = data.redirectUrl\n }\n }, [])\n\n return {\n login,\n signup,\n logout,\n socialLogin,\n devLogin,\n }\n}\n"],"names":[],"mappings":";;AAeO,SAAS,iBAAiB;AAC/B,QAAM,QAAQ,YAAY,OAAO,gBAAkC;AACjE,UAAM,WAAW,MAAM,MAAM,2BAA2B;AAAA,MACtD,QAAQ;AAAA,MACR,aAAa;AAAA,MACb,SAAS,EAAE,gBAAgB,mBAAA;AAAA,MAC3B,MAAM,KAAK,UAAU,WAAW;AAAA,IAAA,CACjC;AAED,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,QAAQ,MAAM,SAAS,KAAA;AAC7B,YAAM,IAAI,MAAM,MAAM,WAAW,cAAc;AAAA,IACjD;AAEA,WAAO,SAAS,KAAA;AAAA,EAClB,GAAG,CAAA,CAAE;AAEL,QAAM,SAAS,YAAY,OAAO,gBAAmC;AACnE,UAAM,WAAW,MAAM,MAAM,2BAA2B;AAAA,MACtD,QAAQ;AAAA,MACR,aAAa;AAAA,MACb,SAAS,EAAE,gBAAgB,mBAAA;AAAA,MAC3B,MAAM,KAAK,UAAU,WAAW;AAAA,IAAA,CACjC;AAED,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,QAAQ,MAAM,SAAS,KAAA;AAC7B,YAAM,IAAI,MAAM,MAAM,WAAW,eAAe;AAAA,IAClD;AAEA,WAAO,SAAS,KAAA;AAAA,EAClB,GAAG,CAAA,CAAE;AAEL,QAAM,SAAS,YAAY,YAAY;AAErC,UAAM,MAAM,wBAAwB;AAAA,MAClC,QAAQ;AAAA,MACR,aAAa;AAAA,IAAA,CACd,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAEjB,UAAM,WAAW,MAAM,MAAM,sBAAsB;AAAA,MACjD,QAAQ;AAAA,MACR,aAAa;AAAA,IAAA,CACd;AAED,QAAI,CAAC,SAAS,GAAI;AAOlB,iBAAa,WAAW,qBAAqB;AAC7C,WAAO,SAAS,OAAA;AAAA,EAClB,GAAG,CAAA,CAAE;AAEL,QAAM,WAAW,YAAY,YAAY;AACvC,UAAM,WAAW,MAAM,MAAM,uBAAuB;AAAA,MAClD,QAAQ;AAAA,MACR,aAAa;AAAA,IAAA,CACd;AACD,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,IAAI,MAAM,kBAAkB;AAAA,IACpC;AACA,WAAO,SAAS,OAAA;AAAA,EAClB,GAAG,CAAA,CAAE;AAEL,QAAM,cAAc,YAAY,OAAO,aAAkC;AACvE,UAAM,WAAW,MAAM,MAAM,oBAAoB,QAAQ,IAAI;AAAA,MAC3D,QAAQ;AAAA,MACR,aAAa;AAAA,IAAA,CACd;AAED,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,IAAI,MAAM,GAAG,QAAQ,eAAe;AAAA,IAC5C;AAEA,UAAM,OAAO,MAAM,SAAS,KAAA;AAE5B,QAAI,KAAK,aAAa;AACpB,aAAO,SAAS,OAAO,KAAK;AAAA,IAC9B;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EAAA;AAEJ;"}
@@ -10,21 +10,29 @@ function PwaAutoUpdateProvider({
10
10
  children
11
11
  }) {
12
12
  const controllerRef = useRef(void 0);
13
+ const handleRef = useRef(handle);
13
14
  useEffect(() => {
14
- if (!handle) return;
15
- const controller = new PwaAutoUpdateController(handle, options);
15
+ var _a;
16
+ handleRef.current = handle;
17
+ if (handle) (_a = controllerRef.current) == null ? void 0 : _a.updateHandle(handle);
18
+ }, [handle]);
19
+ const hasHandle = handle !== void 0;
20
+ const { idleTimeoutMs, minCheckIntervalMs, debug } = options ?? {};
21
+ useEffect(() => {
22
+ const current = handleRef.current;
23
+ if (!current) return;
24
+ const controller = new PwaAutoUpdateController(current, {
25
+ idleTimeoutMs,
26
+ minCheckIntervalMs,
27
+ debug
28
+ });
16
29
  controllerRef.current = controller;
17
30
  controller.start();
18
31
  return () => {
19
32
  controller.destroy();
20
33
  controllerRef.current = void 0;
21
34
  };
22
- }, [handle, options]);
23
- useEffect(() => {
24
- if (handle && controllerRef.current) {
25
- controllerRef.current.updateHandle(handle);
26
- }
27
- }, [handle]);
35
+ }, [hasHandle, idleTimeoutMs, minCheckIntervalMs, debug]);
28
36
  const contextValue = useMemo(
29
37
  () => ({
30
38
  triggerUpdateOnError: () => {
@@ -1 +1 @@
1
- {"version":3,"file":"PwaAutoUpdateContext.js","sources":["../../../../src/modules/pwa/PwaAutoUpdateContext.tsx"],"sourcesContent":["import type { ReactNode } from 'react'\nimport { createContext, use, useEffect, useMemo, useRef } from 'react'\nimport { PwaAutoUpdateController } from './PwaAutoUpdateController'\nimport type { PwaAutoUpdateOptions, PwaUpdateHandle } from './types'\n\nexport interface PwaAutoUpdateContextValue {\n triggerUpdateOnError: () => void\n}\n\nconst PwaAutoUpdateContext = createContext<PwaAutoUpdateContextValue | null>(\n null,\n)\n\ninterface PwaAutoUpdateProviderProps {\n handle: PwaUpdateHandle | undefined\n options?: PwaAutoUpdateOptions\n children: ReactNode\n}\n\nexport function PwaAutoUpdateProvider({\n handle,\n options,\n children,\n}: PwaAutoUpdateProviderProps) {\n const controllerRef = useRef<PwaAutoUpdateController | undefined>(undefined)\n\n useEffect(() => {\n if (!handle) return\n\n const controller = new PwaAutoUpdateController(handle, options)\n controllerRef.current = controller\n controller.start()\n\n return () => {\n controller.destroy()\n controllerRef.current = undefined\n }\n }, [handle, options])\n\n // Update handle when registration arrives async\n useEffect(() => {\n if (handle && controllerRef.current) {\n controllerRef.current.updateHandle(handle)\n }\n }, [handle])\n\n const contextValue = useMemo<PwaAutoUpdateContextValue>(\n () => ({\n triggerUpdateOnError: () => {\n controllerRef.current?.triggerUpdateOnError()\n },\n }),\n [],\n )\n\n return (\n <PwaAutoUpdateContext value={contextValue}>{children}</PwaAutoUpdateContext>\n )\n}\n\n/**\n * Returns the PWA auto-update context, or null if outside the provider.\n * Null return allows graceful degradation when PWA is not configured.\n */\nexport function usePwaAutoUpdate(): PwaAutoUpdateContextValue | null {\n return use(PwaAutoUpdateContext)\n}\n"],"names":[],"mappings":";;;AASA,MAAM,uBAAuB;AAAA,EAC3B;AACF;AAQO,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AACF,GAA+B;AAC7B,QAAM,gBAAgB,OAA4C,MAAS;AAE3E,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AAEb,UAAM,aAAa,IAAI,wBAAwB,QAAQ,OAAO;AAC9D,kBAAc,UAAU;AACxB,eAAW,MAAA;AAEX,WAAO,MAAM;AACX,iBAAW,QAAA;AACX,oBAAc,UAAU;AAAA,IAC1B;AAAA,EACF,GAAG,CAAC,QAAQ,OAAO,CAAC;AAGpB,YAAU,MAAM;AACd,QAAI,UAAU,cAAc,SAAS;AACnC,oBAAc,QAAQ,aAAa,MAAM;AAAA,IAC3C;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,eAAe;AAAA,IACnB,OAAO;AAAA,MACL,sBAAsB,MAAM;;AAC1B,4BAAc,YAAd,mBAAuB;AAAA,MACzB;AAAA,IAAA;AAAA,IAEF,CAAA;AAAA,EAAC;AAGH,SACE,oBAAC,sBAAA,EAAqB,OAAO,cAAe,SAAA,CAAS;AAEzD;AAMO,SAAS,mBAAqD;AACnE,SAAO,IAAI,oBAAoB;AACjC;"}
1
+ {"version":3,"file":"PwaAutoUpdateContext.js","sources":["../../../../src/modules/pwa/PwaAutoUpdateContext.tsx"],"sourcesContent":["import type { ReactNode } from 'react'\nimport { createContext, use, useEffect, useMemo, useRef } from 'react'\nimport { PwaAutoUpdateController } from './PwaAutoUpdateController'\nimport type { PwaAutoUpdateOptions, PwaUpdateHandle } from './types'\n\nexport interface PwaAutoUpdateContextValue {\n triggerUpdateOnError: () => void\n}\n\nconst PwaAutoUpdateContext = createContext<PwaAutoUpdateContextValue | null>(\n null,\n)\n\ninterface PwaAutoUpdateProviderProps {\n handle: PwaUpdateHandle | undefined\n options?: PwaAutoUpdateOptions\n children: ReactNode\n}\n\nexport function PwaAutoUpdateProvider({\n handle,\n options,\n children,\n}: PwaAutoUpdateProviderProps) {\n const controllerRef = useRef<PwaAutoUpdateController | undefined>(undefined)\n const handleRef = useRef(handle)\n\n // A new handle (the registration arriving) goes to the running controller.\n // Declared first, so the effect below reads the current handle.\n useEffect(() => {\n handleRef.current = handle\n if (handle) controllerRef.current?.updateHandle(handle)\n }, [handle])\n\n // One controller for as long as there is a handle and the option values\n // hold. Keyed on values, not identities: rebuilding it drops its pending\n // visibility check and its idle tracking.\n const hasHandle = handle !== undefined\n const { idleTimeoutMs, minCheckIntervalMs, debug } = options ?? {}\n useEffect(() => {\n const current = handleRef.current\n if (!current) return\n\n const controller = new PwaAutoUpdateController(current, {\n idleTimeoutMs,\n minCheckIntervalMs,\n debug,\n })\n controllerRef.current = controller\n controller.start()\n\n return () => {\n controller.destroy()\n controllerRef.current = undefined\n }\n }, [hasHandle, idleTimeoutMs, minCheckIntervalMs, debug])\n\n const contextValue = useMemo<PwaAutoUpdateContextValue>(\n () => ({\n triggerUpdateOnError: () => {\n controllerRef.current?.triggerUpdateOnError()\n },\n }),\n [],\n )\n\n return (\n <PwaAutoUpdateContext value={contextValue}>{children}</PwaAutoUpdateContext>\n )\n}\n\n/**\n * Returns the PWA auto-update context, or null if outside the provider.\n * Null return allows graceful degradation when PWA is not configured.\n */\nexport function usePwaAutoUpdate(): PwaAutoUpdateContextValue | null {\n return use(PwaAutoUpdateContext)\n}\n"],"names":[],"mappings":";;;AASA,MAAM,uBAAuB;AAAA,EAC3B;AACF;AAQO,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AACF,GAA+B;AAC7B,QAAM,gBAAgB,OAA4C,MAAS;AAC3E,QAAM,YAAY,OAAO,MAAM;AAI/B,YAAU,MAAM;;AACd,cAAU,UAAU;AACpB,QAAI,OAAQ,qBAAc,YAAd,mBAAuB,aAAa;AAAA,EAClD,GAAG,CAAC,MAAM,CAAC;AAKX,QAAM,YAAY,WAAW;AAC7B,QAAM,EAAE,eAAe,oBAAoB,MAAA,IAAU,WAAW,CAAA;AAChE,YAAU,MAAM;AACd,UAAM,UAAU,UAAU;AAC1B,QAAI,CAAC,QAAS;AAEd,UAAM,aAAa,IAAI,wBAAwB,SAAS;AAAA,MACtD;AAAA,MACA;AAAA,MACA;AAAA,IAAA,CACD;AACD,kBAAc,UAAU;AACxB,eAAW,MAAA;AAEX,WAAO,MAAM;AACX,iBAAW,QAAA;AACX,oBAAc,UAAU;AAAA,IAC1B;AAAA,EACF,GAAG,CAAC,WAAW,eAAe,oBAAoB,KAAK,CAAC;AAExD,QAAM,eAAe;AAAA,IACnB,OAAO;AAAA,MACL,sBAAsB,MAAM;;AAC1B,4BAAc,YAAd,mBAAuB;AAAA,MACzB;AAAA,IAAA;AAAA,IAEF,CAAA;AAAA,EAAC;AAGH,SACE,oBAAC,sBAAA,EAAqB,OAAO,cAAe,SAAA,CAAS;AAEzD;AAMO,SAAS,mBAAqD;AACnE,SAAO,IAAI,oBAAoB;AACjC;"}
@@ -1 +1 @@
1
- {"version":3,"file":"Devtools.js","sources":["../../../../src/ui/layout/Devtools.tsx"],"sourcesContent":["import { Suspense, lazy } from 'react'\nimport type { QueryClient } from '@tanstack/react-query'\n\n// Dev-only: `import.meta.env.DEV` is false in production builds, so Rollup drops\n// the import() and neither devtools package reaches the shipped bundle (#120).\nconst DevtoolsPanels = import.meta.env.DEV\n ? lazy(() => import('./DevtoolsPanels'))\n : null\n\nexport function Devtools({ client }: { client: QueryClient }) {\n if (!DevtoolsPanels) return null\n return (\n <Suspense fallback={null}>\n <DevtoolsPanels client={client} />\n </Suspense>\n )\n}\n"],"names":[],"mappings":";;AASO,SAAS,SAAS,EAAE,UAAmC;AACvC,SAAO;AAM9B;"}
1
+ {"version":3,"file":"Devtools.js","sources":["../../../../src/ui/layout/Devtools.tsx"],"sourcesContent":["import { Suspense, lazy } from 'react'\nimport type { QueryClient } from '@tanstack/react-query'\n\n// Dev-only: `import.meta.env.DEV` is false in production builds, so Rollup drops\n// the import() and neither devtools package reaches the shipped bundle (#120).\n// Off under vitest too: nobody looks at them there, and every test file would\n// otherwise transform both packages on its first mount.\nconst DevtoolsPanels =\n import.meta.env.DEV && import.meta.env.MODE !== 'test'\n ? lazy(() => import('./DevtoolsPanels'))\n : null\n\nexport function Devtools({ client }: { client: QueryClient }) {\n if (!DevtoolsPanels) return null\n return (\n <Suspense fallback={null}>\n <DevtoolsPanels client={client} />\n </Suspense>\n )\n}\n"],"names":[],"mappings":";;AAYO,SAAS,SAAS,EAAE,UAAmC;AACvC,SAAO;AAM9B;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "6.10.1",
3
+ "version": "6.10.3",
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.10.1"
121
+ "@igstack/app-catalog-shared-core": "6.10.3"
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.10.1"
149
+ "@igstack/app-catalog-backend-core": "6.10.3"
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": "4c83cecec5cf3d8a70d28faad5a7e773adadaea5",
159
+ "gitHead": "980bbf4c4901132d351291df3522fa6b6eaaa123",
160
160
  "scripts": {
161
161
  "build": "vite build",
162
162
  "build:lenient": "vite build --mode lenient",
@@ -0,0 +1,49 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { AUTH_USER_STORAGE_KEY } from '~/modules/auth/AuthContext'
4
+ import { useAuthActions } from '~/modules/auth/useAuthActions'
5
+
6
+ const originalLocation = window.location
7
+ const reload = vi.fn()
8
+
9
+ beforeEach(() => {
10
+ vi.stubGlobal(
11
+ 'fetch',
12
+ vi.fn(async () => new Response('{}', { status: 200 })),
13
+ )
14
+ // jsdom's location cannot be spied on; swap in one whose reload is observable.
15
+ Object.defineProperty(window, 'location', {
16
+ configurable: true,
17
+ value: { ...originalLocation, href: originalLocation.href, reload },
18
+ })
19
+ })
20
+
21
+ afterEach(() => {
22
+ Object.defineProperty(window, 'location', {
23
+ configurable: true,
24
+ value: originalLocation,
25
+ })
26
+ vi.unstubAllGlobals()
27
+ reload.mockClear()
28
+ localStorage.clear()
29
+ })
30
+
31
+ describe('useAuthActions.logout', () => {
32
+ it('reloads the current page signed out instead of going home', async () => {
33
+ const page = 'http://localhost:3000/app/sample-review-app?tab=access'
34
+ window.location.href = page
35
+ localStorage.setItem(
36
+ AUTH_USER_STORAGE_KEY,
37
+ JSON.stringify({ id: 'u1', name: 'Signed In' }),
38
+ )
39
+ const { result } = renderHook(() => useAuthActions())
40
+
41
+ await result.current.logout()
42
+
43
+ expect(reload).toHaveBeenCalledOnce()
44
+ expect(window.location.href).toBe(page)
45
+ // The provider hydrates from this before the session fetch returns, so a
46
+ // leftover entry would flash the old user on the reloaded page.
47
+ expect(localStorage.getItem(AUTH_USER_STORAGE_KEY)).toBeNull()
48
+ })
49
+ })
@@ -1,5 +1,5 @@
1
1
  import '@testing-library/jest-dom/vitest'
2
- import { render, screen } from '@testing-library/react'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
3
  import userEvent from '@testing-library/user-event'
4
4
  import { describe, expect, it, vi } from 'vitest'
5
5
  import { useEffect, useRef, useState } from 'react'
@@ -322,10 +322,8 @@ describe('the dismiss stack', () => {
322
322
  screen.getByTestId('layer-dialog').focus()
323
323
 
324
324
  await esc()
325
- // The handback is queued after the unmount, so let it run.
326
- await new Promise((r) => setTimeout(r, 0))
327
-
328
- expect(document.activeElement).toBe(screen.getByTestId('layer-card'))
325
+ // The handback is queued after the unmount.
326
+ await waitFor(() => expect(screen.getByTestId('layer-card')).toHaveFocus())
329
327
  })
330
328
 
331
329
  /** A layer that takes the caret on mount, as the detail card does. */
@@ -374,9 +372,8 @@ describe('the dismiss stack', () => {
374
372
  expect(screen.getByTestId('layer-card')).toHaveFocus()
375
373
 
376
374
  await esc()
377
- await new Promise((r) => setTimeout(r, 0))
378
375
 
379
- expect(input).toHaveFocus()
376
+ await waitFor(() => expect(input).toHaveFocus())
380
377
  expect([input.selectionStart, input.selectionEnd]).toEqual([13, 13])
381
378
  })
382
379
 
@@ -405,16 +402,17 @@ describe('the dismiss stack', () => {
405
402
  )
406
403
  }
407
404
  render(<Host />)
408
- const tick = () => new Promise((r) => setTimeout(r, 0))
409
405
 
410
406
  await userEvent.click(screen.getByRole('button', { name: 'open card' }))
411
407
  await userEvent.click(screen.getByRole('button', { name: 'open dialog' }))
412
408
  await esc()
413
- await tick()
414
- expect(screen.getByRole('button', { name: 'open dialog' })).toHaveFocus()
409
+ await waitFor(() =>
410
+ expect(screen.getByRole('button', { name: 'open dialog' })).toHaveFocus(),
411
+ )
415
412
 
416
413
  await esc()
417
- await tick()
418
- expect(screen.getByRole('button', { name: 'open card' })).toHaveFocus()
414
+ await waitFor(() =>
415
+ expect(screen.getByRole('button', { name: 'open card' })).toHaveFocus(),
416
+ )
419
417
  })
420
418
  })
@@ -0,0 +1,44 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { render } from '@testing-library/react'
3
+ import { PwaAutoUpdateController } from '~/modules/pwa/PwaAutoUpdateController'
4
+ import { PwaAutoUpdateProvider } from '~/modules/pwa/PwaAutoUpdateContext'
5
+ import type { PwaUpdateHandle } from '~/modules/pwa/types'
6
+
7
+ const handle = (registration?: ServiceWorkerRegistration): PwaUpdateHandle => ({
8
+ updateSW: vi.fn(),
9
+ registration,
10
+ })
11
+
12
+ describe('PwaAutoUpdateProvider', () => {
13
+ afterEach(() => {
14
+ vi.restoreAllMocks()
15
+ })
16
+
17
+ it('keeps one controller while the registration arrives and the options are re-created', () => {
18
+ // The host app passes a handle without a registration first, then a new
19
+ // one once the worker registers, and an inline options object every
20
+ // render. A rebuilt controller drops its pending visibility check.
21
+ const start = vi.spyOn(PwaAutoUpdateController.prototype, 'start')
22
+ const destroy = vi.spyOn(PwaAutoUpdateController.prototype, 'destroy')
23
+ const updateHandle = vi.spyOn(
24
+ PwaAutoUpdateController.prototype,
25
+ 'updateHandle',
26
+ )
27
+ const registered = handle({} as ServiceWorkerRegistration)
28
+
29
+ const { rerender } = render(
30
+ <PwaAutoUpdateProvider handle={handle()} options={{ debug: false }}>
31
+ <div />
32
+ </PwaAutoUpdateProvider>,
33
+ )
34
+ rerender(
35
+ <PwaAutoUpdateProvider handle={registered} options={{ debug: false }}>
36
+ <div />
37
+ </PwaAutoUpdateProvider>,
38
+ )
39
+
40
+ expect(start).toHaveBeenCalledTimes(1)
41
+ expect(destroy).not.toHaveBeenCalled()
42
+ expect(updateHandle).toHaveBeenLastCalledWith(registered)
43
+ })
44
+ })
package/src/internal.ts CHANGED
@@ -21,3 +21,4 @@ export {
21
21
  clearSessionState,
22
22
  seedSessionState,
23
23
  } from './modules/appCatalog/hooks/useSessionSyncedState'
24
+ export { preloadAppDetailPanel } from './modules/appCatalog/ui/pages/AppCatalogPage'
@@ -6,12 +6,17 @@ import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
6
6
  import { AppCatalogGrid } from '../catalog/AppCatalogGrid'
7
7
  import { AttributionFooter } from '../catalog/AttributionFooter'
8
8
 
9
- // The detail card (and the markdown stack it renders) loads on first open, not
10
- // with the catalog grid every visitor sees first (#120).
9
+ // The detail card (and the markdown stack it renders) stays out of the bundle
10
+ // every visitor loads first (#120). The page fetches it after its first paint,
11
+ // so the first click on a row does not wait for the chunk.
12
+ const loadAppDetailPanel = () => import('../catalog/AppDetailPanel')
13
+
14
+ /** Resolves once the detail card's chunk is loaded. */
15
+ export const preloadAppDetailPanel = (): Promise<void> =>
16
+ loadAppDetailPanel().then(() => undefined)
17
+
11
18
  const AppDetailPanel = lazy(() =>
12
- import('../catalog/AppDetailPanel').then((m) => ({
13
- default: m.AppDetailPanel,
14
- })),
19
+ loadAppDetailPanel().then((m) => ({ default: m.AppDetailPanel })),
15
20
  )
16
21
 
17
22
  export function AppCatalogPage({
@@ -84,6 +89,10 @@ export function AppCatalogPage({
84
89
  [rootResources, filterState.showDeprecated],
85
90
  )
86
91
 
92
+ useEffect(() => {
93
+ void preloadAppDetailPanel()
94
+ }, [])
95
+
87
96
  // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level
88
97
  // (rootResources === 0). That fingerprints a frontend/backend-core version skew
89
98
  // or a stale service worker — NOT a normal empty search/filter (those still have
@@ -19,7 +19,7 @@ interface AuthContextType {
19
19
 
20
20
  const AuthContext = createContext<AuthContextType | undefined>(undefined)
21
21
 
22
- const STORAGE_KEY = 'ac_auth_user'
22
+ export const AUTH_USER_STORAGE_KEY = 'ac_auth_user'
23
23
 
24
24
  /**
25
25
  * Provider component for authentication state
@@ -32,7 +32,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
32
32
  // Hydrate from localStorage on mount to avoid flickering
33
33
  if (typeof window === 'undefined') return null
34
34
  try {
35
- const stored = localStorage.getItem(STORAGE_KEY)
35
+ const stored = localStorage.getItem(AUTH_USER_STORAGE_KEY)
36
36
  return stored ? JSON.parse(stored) : null
37
37
  } catch {
38
38
  return null
@@ -58,14 +58,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
58
58
  setSession(data)
59
59
  setUser(data?.user || null)
60
60
  if (data?.user) {
61
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data.user))
61
+ localStorage.setItem(
62
+ AUTH_USER_STORAGE_KEY,
63
+ JSON.stringify(data.user),
64
+ )
62
65
  } else {
63
- localStorage.removeItem(STORAGE_KEY)
66
+ localStorage.removeItem(AUTH_USER_STORAGE_KEY)
64
67
  }
65
68
  } else {
66
69
  setSession(null)
67
70
  setUser(null)
68
- localStorage.removeItem(STORAGE_KEY)
71
+ localStorage.removeItem(AUTH_USER_STORAGE_KEY)
69
72
  }
70
73
  } catch (error) {
71
74
  // Ignore abort errors (happens on cleanup)
@@ -75,7 +78,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
75
78
  console.error('Failed to fetch session:', error)
76
79
  setSession(null)
77
80
  setUser(null)
78
- localStorage.removeItem(STORAGE_KEY)
81
+ localStorage.removeItem(AUTH_USER_STORAGE_KEY)
79
82
  } finally {
80
83
  setIsLoading(false)
81
84
  }
@@ -1,4 +1,5 @@
1
1
  import { useCallback } from 'react'
2
+ import { AUTH_USER_STORAGE_KEY } from './AuthContext'
2
3
 
3
4
  interface LoginCredentials {
4
5
  email: string
@@ -62,8 +63,10 @@ export function useAuthActions() {
62
63
  // Still reload to clear UI state
63
64
  }
64
65
 
65
- // Clear session and reload to update UI
66
- window.location.href = '/'
66
+ // Stay on the current page: drop the optimistic user the provider hydrates
67
+ // from, so the reload renders signed out without flashing the old user.
68
+ localStorage.removeItem(AUTH_USER_STORAGE_KEY)
69
+ window.location.reload()
67
70
  }, [])
68
71
 
69
72
  const devLogin = useCallback(async () => {
@@ -23,11 +23,29 @@ export function PwaAutoUpdateProvider({
23
23
  children,
24
24
  }: PwaAutoUpdateProviderProps) {
25
25
  const controllerRef = useRef<PwaAutoUpdateController | undefined>(undefined)
26
+ const handleRef = useRef(handle)
26
27
 
28
+ // A new handle (the registration arriving) goes to the running controller.
29
+ // Declared first, so the effect below reads the current handle.
27
30
  useEffect(() => {
28
- if (!handle) return
31
+ handleRef.current = handle
32
+ if (handle) controllerRef.current?.updateHandle(handle)
33
+ }, [handle])
34
+
35
+ // One controller for as long as there is a handle and the option values
36
+ // hold. Keyed on values, not identities: rebuilding it drops its pending
37
+ // visibility check and its idle tracking.
38
+ const hasHandle = handle !== undefined
39
+ const { idleTimeoutMs, minCheckIntervalMs, debug } = options ?? {}
40
+ useEffect(() => {
41
+ const current = handleRef.current
42
+ if (!current) return
29
43
 
30
- const controller = new PwaAutoUpdateController(handle, options)
44
+ const controller = new PwaAutoUpdateController(current, {
45
+ idleTimeoutMs,
46
+ minCheckIntervalMs,
47
+ debug,
48
+ })
31
49
  controllerRef.current = controller
32
50
  controller.start()
33
51
 
@@ -35,14 +53,7 @@ export function PwaAutoUpdateProvider({
35
53
  controller.destroy()
36
54
  controllerRef.current = undefined
37
55
  }
38
- }, [handle, options])
39
-
40
- // Update handle when registration arrives async
41
- useEffect(() => {
42
- if (handle && controllerRef.current) {
43
- controllerRef.current.updateHandle(handle)
44
- }
45
- }, [handle])
56
+ }, [hasHandle, idleTimeoutMs, minCheckIntervalMs, debug])
46
57
 
47
58
  const contextValue = useMemo<PwaAutoUpdateContextValue>(
48
59
  () => ({
@@ -3,9 +3,12 @@ import type { QueryClient } from '@tanstack/react-query'
3
3
 
4
4
  // Dev-only: `import.meta.env.DEV` is false in production builds, so Rollup drops
5
5
  // the import() and neither devtools package reaches the shipped bundle (#120).
6
- const DevtoolsPanels = import.meta.env.DEV
7
- ? lazy(() => import('./DevtoolsPanels'))
8
- : null
6
+ // Off under vitest too: nobody looks at them there, and every test file would
7
+ // otherwise transform both packages on its first mount.
8
+ const DevtoolsPanels =
9
+ import.meta.env.DEV && import.meta.env.MODE !== 'test'
10
+ ? lazy(() => import('./DevtoolsPanels'))
11
+ : null
9
12
 
10
13
  export function Devtools({ client }: { client: QueryClient }) {
11
14
  if (!DevtoolsPanels) return null