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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/dist/esm/__tests__/modules/auth/AuthContext.test.d.ts +1 -0
  2. package/dist/esm/__tests__/modules/communityTools/likedBy.test.d.ts +1 -0
  3. package/dist/esm/__tests__/modules/communityTools/timeAgo.test.d.ts +1 -0
  4. package/dist/esm/__tests__/modules/communityTools/useTimeUp.test.d.ts +1 -0
  5. package/dist/esm/api/infra/trpc.d.ts +419 -0
  6. package/dist/esm/api/infra/trpc.js.map +1 -1
  7. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -1
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.d.ts +2 -4
  9. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +29 -101
  10. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/catalog/DetailDialogShell.d.ts +26 -0
  12. package/dist/esm/modules/appCatalog/ui/catalog/DetailDialogShell.js +108 -0
  13. package/dist/esm/modules/appCatalog/ui/catalog/DetailDialogShell.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.d.ts +2 -1
  15. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/components/SectionTabs.d.ts +8 -0
  17. package/dist/esm/modules/appCatalog/ui/components/SectionTabs.js +73 -0
  18. package/dist/esm/modules/appCatalog/ui/components/SectionTabs.js.map +1 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +19 -2
  20. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  21. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +3 -2
  22. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +4 -4
  23. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +2 -1
  25. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +2 -2
  26. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +3 -2
  28. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +13 -9
  29. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -1
  30. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +4 -2
  31. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +10 -4
  32. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -1
  33. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +12 -0
  34. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +4 -0
  35. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -1
  36. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +1 -0
  37. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -1
  38. package/dist/esm/modules/appCatalog/ui/detail/useCommunityToolCount.d.ts +6 -0
  39. package/dist/esm/modules/appCatalog/ui/detail/useCommunityToolCount.js +16 -0
  40. package/dist/esm/modules/appCatalog/ui/detail/useCommunityToolCount.js.map +1 -0
  41. package/dist/esm/modules/appCatalog/ui/layout/AppCatalogLayout.js +2 -2
  42. package/dist/esm/modules/appCatalog/ui/layout/AppCatalogLayout.js.map +1 -1
  43. package/dist/esm/modules/auth/AuthContext.js +7 -3
  44. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  45. package/dist/esm/modules/auth/ui/LoginModal.js +16 -8
  46. package/dist/esm/modules/auth/ui/LoginModal.js.map +1 -1
  47. package/dist/esm/modules/communityTools/likedBy.d.ts +8 -0
  48. package/dist/esm/modules/communityTools/likedBy.js +23 -0
  49. package/dist/esm/modules/communityTools/likedBy.js.map +1 -0
  50. package/dist/esm/modules/communityTools/timeAgo.d.ts +2 -0
  51. package/dist/esm/modules/communityTools/timeAgo.js +18 -0
  52. package/dist/esm/modules/communityTools/timeAgo.js.map +1 -0
  53. package/dist/esm/modules/communityTools/ui/CardScreenshot.d.ts +13 -0
  54. package/dist/esm/modules/communityTools/ui/CardScreenshot.js +112 -0
  55. package/dist/esm/modules/communityTools/ui/CardScreenshot.js.map +1 -0
  56. package/dist/esm/modules/communityTools/ui/CardTitleBlock.d.ts +8 -0
  57. package/dist/esm/modules/communityTools/ui/CardTitleBlock.js +59 -0
  58. package/dist/esm/modules/communityTools/ui/CardTitleBlock.js.map +1 -0
  59. package/dist/esm/modules/communityTools/ui/CommunityPage.d.ts +12 -0
  60. package/dist/esm/modules/communityTools/ui/CommunityPage.js +547 -0
  61. package/dist/esm/modules/communityTools/ui/CommunityPage.js.map +1 -0
  62. package/dist/esm/modules/communityTools/ui/FeedRow.d.ts +42 -0
  63. package/dist/esm/modules/communityTools/ui/FeedRow.js +260 -0
  64. package/dist/esm/modules/communityTools/ui/FeedRow.js.map +1 -0
  65. package/dist/esm/modules/communityTools/ui/FeedSortControl.d.ts +17 -0
  66. package/dist/esm/modules/communityTools/ui/FeedSortControl.js +84 -0
  67. package/dist/esm/modules/communityTools/ui/FeedSortControl.js.map +1 -0
  68. package/dist/esm/modules/communityTools/ui/LifecycleBadge.d.ts +7 -0
  69. package/dist/esm/modules/communityTools/ui/LifecycleBadge.js +50 -0
  70. package/dist/esm/modules/communityTools/ui/LifecycleBadge.js.map +1 -0
  71. package/dist/esm/modules/communityTools/ui/LikeButton.d.ts +9 -0
  72. package/dist/esm/modules/communityTools/ui/LikeButton.js +56 -0
  73. package/dist/esm/modules/communityTools/ui/LikeButton.js.map +1 -0
  74. package/dist/esm/modules/communityTools/ui/MarginPreview.d.ts +18 -0
  75. package/dist/esm/modules/communityTools/ui/MarginPreview.js +115 -0
  76. package/dist/esm/modules/communityTools/ui/MarginPreview.js.map +1 -0
  77. package/dist/esm/modules/communityTools/ui/MyToolsShelf.d.ts +12 -0
  78. package/dist/esm/modules/communityTools/ui/MyToolsShelf.js +88 -0
  79. package/dist/esm/modules/communityTools/ui/MyToolsShelf.js.map +1 -0
  80. package/dist/esm/modules/communityTools/ui/OwnerPicker.d.ts +56 -0
  81. package/dist/esm/modules/communityTools/ui/OwnerPicker.js +245 -0
  82. package/dist/esm/modules/communityTools/ui/OwnerPicker.js.map +1 -0
  83. package/dist/esm/modules/communityTools/ui/PinButton.d.ts +14 -0
  84. package/dist/esm/modules/communityTools/ui/PinButton.js +72 -0
  85. package/dist/esm/modules/communityTools/ui/PinButton.js.map +1 -0
  86. package/dist/esm/modules/communityTools/ui/ShelfTile.d.ts +13 -0
  87. package/dist/esm/modules/communityTools/ui/ShelfTile.js +81 -0
  88. package/dist/esm/modules/communityTools/ui/ShelfTile.js.map +1 -0
  89. package/dist/esm/modules/communityTools/ui/TagPicker.d.ts +21 -0
  90. package/dist/esm/modules/communityTools/ui/TagPicker.js +87 -0
  91. package/dist/esm/modules/communityTools/ui/TagPicker.js.map +1 -0
  92. package/dist/esm/modules/communityTools/ui/TagRow.d.ts +22 -0
  93. package/dist/esm/modules/communityTools/ui/TagRow.js +92 -0
  94. package/dist/esm/modules/communityTools/ui/TagRow.js.map +1 -0
  95. package/dist/esm/modules/communityTools/ui/Toast.d.ts +19 -0
  96. package/dist/esm/modules/communityTools/ui/Toast.js +87 -0
  97. package/dist/esm/modules/communityTools/ui/Toast.js.map +1 -0
  98. package/dist/esm/modules/communityTools/ui/ToolCard.d.ts +22 -0
  99. package/dist/esm/modules/communityTools/ui/ToolCard.js +467 -0
  100. package/dist/esm/modules/communityTools/ui/ToolCard.js.map +1 -0
  101. package/dist/esm/modules/communityTools/ui/ToolFormDialog.d.ts +32 -0
  102. package/dist/esm/modules/communityTools/ui/ToolFormDialog.js +765 -0
  103. package/dist/esm/modules/communityTools/ui/ToolFormDialog.js.map +1 -0
  104. package/dist/esm/modules/communityTools/ui/ToolSearch.d.ts +15 -0
  105. package/dist/esm/modules/communityTools/ui/ToolSearch.js +92 -0
  106. package/dist/esm/modules/communityTools/ui/ToolSearch.js.map +1 -0
  107. package/dist/esm/modules/communityTools/useCommunityTools.d.ts +157 -0
  108. package/dist/esm/modules/communityTools/useCommunityTools.js +122 -0
  109. package/dist/esm/modules/communityTools/useCommunityTools.js.map +1 -0
  110. package/dist/esm/modules/communityTools/useCommunityToolsConfig.d.ts +10 -0
  111. package/dist/esm/modules/communityTools/useCommunityToolsConfig.js +20 -0
  112. package/dist/esm/modules/communityTools/useCommunityToolsConfig.js.map +1 -0
  113. package/dist/esm/modules/communityTools/useTimeUp.d.ts +5 -0
  114. package/dist/esm/modules/communityTools/useTimeUp.js +13 -0
  115. package/dist/esm/modules/communityTools/useTimeUp.js.map +1 -0
  116. package/dist/esm/node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/classic/schemas.js +2 -2
  117. package/dist/esm/node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/core/schemas.js +1 -1
  118. package/dist/esm/routeTree.gen.d.ts +96 -3
  119. package/dist/esm/routeTree.gen.js +19 -0
  120. package/dist/esm/routeTree.gen.js.map +1 -1
  121. package/dist/esm/routes/_layout/community.$slug.d.ts +22 -0
  122. package/dist/esm/routes/_layout/community._slug.js +10 -0
  123. package/dist/esm/routes/_layout/community._slug.js.map +1 -0
  124. package/dist/esm/routes/_layout/community.d.ts +23 -0
  125. package/dist/esm/routes/_layout/community.js +71 -0
  126. package/dist/esm/routes/_layout/community.js.map +1 -0
  127. package/dist/esm/ui/components/header/Header.js +7 -3
  128. package/dist/esm/ui/components/header/Header.js.map +1 -1
  129. package/dist/esm/util/highlightMatches.d.ts +2 -0
  130. package/dist/esm/util/highlightMatches.js +32 -0
  131. package/dist/esm/util/highlightMatches.js.map +1 -0
  132. package/dist/index.css +6 -0
  133. package/package.json +5 -5
  134. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +3 -0
  135. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +3 -0
  136. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -0
  137. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +4 -0
  138. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +3 -0
  139. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +3 -0
  140. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +3 -0
  141. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -0
  142. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +3 -0
  143. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +3 -0
  144. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +10 -3
  145. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +4 -1
  146. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +12 -2
  147. package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +1 -0
  148. package/src/__tests__/modules/auth/AuthContext.test.tsx +55 -0
  149. package/src/__tests__/modules/communityTools/likedBy.test.ts +45 -0
  150. package/src/__tests__/modules/communityTools/timeAgo.test.ts +28 -0
  151. package/src/__tests__/modules/communityTools/useTimeUp.test.ts +31 -0
  152. package/src/__tests__/ui/components/header/Header.login.test.tsx +4 -4
  153. package/src/api/infra/trpc.ts +2 -0
  154. package/src/index.css +6 -0
  155. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +33 -176
  156. package/src/modules/appCatalog/ui/catalog/DetailDialogShell.tsx +199 -0
  157. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +2 -1
  158. package/src/modules/appCatalog/ui/components/SectionTabs.tsx +81 -0
  159. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +17 -2
  160. package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +9 -4
  161. package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +6 -3
  162. package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +15 -10
  163. package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +10 -5
  164. package/src/modules/appCatalog/ui/detail/communityDraft.ts +16 -0
  165. package/src/modules/appCatalog/ui/detail/composeComments.ts +1 -0
  166. package/src/modules/appCatalog/ui/detail/useCommunityToolCount.ts +18 -0
  167. package/src/modules/appCatalog/ui/layout/AppCatalogLayout.tsx +2 -2
  168. package/src/modules/auth/AuthContext.tsx +9 -3
  169. package/src/modules/auth/ui/LoginModal.tsx +7 -2
  170. package/src/modules/communityTools/likedBy.ts +32 -0
  171. package/src/modules/communityTools/timeAgo.ts +16 -0
  172. package/src/modules/communityTools/ui/CardScreenshot.tsx +124 -0
  173. package/src/modules/communityTools/ui/CardTitleBlock.tsx +58 -0
  174. package/src/modules/communityTools/ui/CommunityPage.tsx +626 -0
  175. package/src/modules/communityTools/ui/FeedRow.tsx +310 -0
  176. package/src/modules/communityTools/ui/FeedSortControl.tsx +97 -0
  177. package/src/modules/communityTools/ui/LifecycleBadge.tsx +44 -0
  178. package/src/modules/communityTools/ui/LikeButton.tsx +56 -0
  179. package/src/modules/communityTools/ui/MarginPreview.tsx +143 -0
  180. package/src/modules/communityTools/ui/MyToolsShelf.tsx +112 -0
  181. package/src/modules/communityTools/ui/OwnerPicker.tsx +311 -0
  182. package/src/modules/communityTools/ui/PinButton.tsx +87 -0
  183. package/src/modules/communityTools/ui/ShelfTile.tsx +83 -0
  184. package/src/modules/communityTools/ui/TagPicker.tsx +118 -0
  185. package/src/modules/communityTools/ui/TagRow.tsx +118 -0
  186. package/src/modules/communityTools/ui/Toast.tsx +105 -0
  187. package/src/modules/communityTools/ui/ToolCard.tsx +519 -0
  188. package/src/modules/communityTools/ui/ToolFormDialog.tsx +922 -0
  189. package/src/modules/communityTools/ui/ToolSearch.tsx +111 -0
  190. package/src/modules/communityTools/useCommunityTools.ts +146 -0
  191. package/src/modules/communityTools/useCommunityToolsConfig.ts +26 -0
  192. package/src/modules/communityTools/useTimeUp.ts +14 -0
  193. package/src/routeTree.gen.ts +52 -0
  194. package/src/routes/_layout/community.$slug.tsx +7 -0
  195. package/src/routes/_layout/community.tsx +97 -0
  196. package/src/ui/components/header/Header.tsx +9 -3
  197. package/src/util/highlightMatches.tsx +34 -0
  198. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.d.ts +0 -12
  199. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js +0 -56
  200. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js.map +0 -1
  201. package/src/modules/appCatalog/ui/components/ViewToggle.tsx +0 -59
@@ -1,16 +1,15 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
- import { Maximize2, Minimize2, X } from 'lucide-react'
3
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
+ import { useMemo } from 'react'
4
3
  import { useAppCatalogContext } from '../../context/AppCatalogContext'
5
4
  import {
6
5
  ResourceDetailAccessActions,
7
6
  ResourceDetailProvider,
8
7
  } from '~/modules/extensions'
9
8
  import { useOptionalUser } from '~/modules/auth'
10
- import { useDismissStack, useDismissable } from '~/modules/dismiss/DismissStack'
11
9
  import { getChildResources } from '../../utils/resolveHelpers'
12
10
  import { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'
13
11
  import { AppDetails } from '../detail/AppDetails'
12
+ import { DetailDialogShell } from './DetailDialogShell'
14
13
 
15
14
  /**
16
15
  * Centered detail card over the catalog (#38, item B). Renders the rich
@@ -22,10 +21,8 @@ import { AppDetails } from '../detail/AppDetails'
22
21
  * sub-resource's own detail, with its two-step access chain and a back link to
23
22
  * the parent.
24
23
  *
25
- * Escape handling: this component does NOT globally bind Escape. Escape-to-close
26
- * is owned by AppDetails' own key handling (which first closes an open
27
- * screenshot gallery, then closes the card), so pressing Esc inside the gallery
28
- * returns to the card — not all the way to the home page (#38 Esc-stacking bug).
24
+ * The overlay itself — scrim, centring, Widen, Close, Escape — is
25
+ * `DetailDialogShell`, shared with the community tool card.
29
26
  */
30
27
  export function AppDetailPanel({
31
28
  app,
@@ -56,179 +53,39 @@ export function AppDetailPanel({
56
53
  }),
57
54
  [subResource, app, allResources, user],
58
55
  )
59
- const dialogRef = useRef<HTMLDivElement>(null)
60
- // Not persisted: every open starts at the normal width. The toggle is for the
61
- // one card being read, and the default stays readable for the next one.
62
- const [isWide, setIsWide] = useState(false)
63
-
64
- /**
65
- * Centre the card ONCE when it opens, then hold that position.
66
- *
67
- * `my-auto` centred it on every render, so each panel height change pushed both
68
- * edges and half the delta became upward movement of everything above it —
69
- * including the tab strip, which moved 113px and took the control the user had
70
- * just clicked with it (CLS 0.19, "needs improvement").
71
- *
72
- * Top-anchoring fixed the shift but looked wrong: a short card sat high on the
73
- * page. So pin the centred offset instead. The card still opens centred, and
74
- * anything that grows it afterwards extends downward only — which is what a
75
- * reader expects when they open a tab with more in it.
76
- *
77
- * Only on open and on resize. Deliberately NOT on content change: reacting to
78
- * content is precisely the bug.
79
- */
80
- useLayoutEffect(() => {
81
- const card = dialogRef.current
82
- const scroller = card?.parentElement
83
- if (!card || !scroller) return
84
-
85
- const pinCentre = () => {
86
- card.style.marginTop = '0px'
87
- const style = getComputedStyle(scroller)
88
- const usable =
89
- scroller.clientHeight -
90
- parseFloat(style.paddingTop) -
91
- parseFloat(style.paddingBottom)
92
- const slack = usable - card.offsetHeight
93
- // Negative slack means the card is already taller than the viewport; leave it
94
- // at the top and let the overlay scroll, rather than pushing its head off.
95
- card.style.marginTop = `${Math.max(0, Math.round(slack / 2))}px`
96
- }
97
-
98
- pinCentre()
99
- window.addEventListener('resize', pinCentre)
100
- return () => window.removeEventListener('resize', pinCentre)
101
- // Keyed on the opened entry: a different card is a new open, and should
102
- // re-centre for its own height.
103
- }, [app.slug, subResource?.slug])
104
-
105
- // Focus the card on mount so Esc hotkeys fire immediately, even when
106
- // the card was opened by a mouse click (which leaves focus on the grid row).
107
- //
108
- // The door link rather than the dialog itself when the app has one: Enter on
109
- // a search result opens the card, so a second Enter is the browser's own link
110
- // activation and takes the reader into the app. No door: the dialog.
111
- //
112
- // Unless something inside already took the caret — the Quick Jump field does,
113
- // and it has the better claim: a child's effect runs before its parent's, so
114
- // without this guard the card would take focus straight back off it.
115
- //
116
- // Runs again whenever the card navigates to another resource, keyed the same
117
- // way the body below is: that navigation destroys whatever was focused (the
118
- // "View replacement" button, a prerequisite's parent), and without this the
119
- // caret would land on <body> — where the next Tab walks the catalog grid
120
- // behind the scrim, since the card has no focus trap.
121
56
  const shownSlug = subResource?.slug ?? app.slug
122
- const stack = useDismissStack()
123
- useEffect(() => {
124
- const dialog = dialogRef.current
125
- if (!dialog || dialog.contains(document.activeElement)) return
126
- // Only while the card is on top. A layer opened OVER it — a deployment's
127
- // dialog on a nested route — owns the caret, and on a cold deep link that
128
- // layer mounts before the card does, so without this check the card took
129
- // focus back off the dialog's first field the moment the catalog arrived.
130
- const top = stack.topElement()
131
- if (top && top !== dialog) return
132
- const target = dialog.querySelector<HTMLElement>('.app-door') ?? dialog
133
- target.focus()
134
- }, [shownSlug, stack])
135
-
136
- // Escape closes the card. Registered here rather than in AppDetails because
137
- // this is the layer's element, and because the sub-resource view renders a
138
- // different body in place of AppDetails — which left that view with no
139
- // Escape at all while the registration lived in the body.
140
- useDismissable(dialogRef, onClose)
141
57
 
142
58
  return (
143
- <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
144
- {/* scrim */}
145
- <button
146
- type="button"
147
- aria-label="Close details panel"
148
- onClick={onClose}
149
- className="fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in"
150
- />
151
- {/* centered card — wide enough for data tables (sub-resources have 5
152
- columns incl. "Access Contacts"/"AWS Account" that wrapped at 760px);
153
- prose inside AppDetails is width-capped separately so it stays
154
- readable. Caps at 94vw so it never touches the edges. */}
155
- <div
156
- ref={dialogRef}
157
- role="dialog"
158
- aria-modal="true"
159
- aria-label={`${subResource?.displayName ?? app.displayName} details`}
160
- tabIndex={-1}
161
- // Centred on open, then held — see the pin effect above. `my-auto` is
162
- // deliberately absent: it re-centres on EVERY height change, which is the
163
- // layout-shift bug.
164
- // `overflow-hidden` belongs on the rounded box: the scrolling child
165
- // below carries the band's tint to its own square corners and paints
166
- // them over this radius, so the card's top corners read as cut off.
167
- // Widening drops the cap only. The pinned top margin is left alone, so
168
- // the card's top edge stays put and restoring gives back the same layout.
169
- 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`}
170
- >
171
- <div className="absolute top-3 right-3 z-10 flex gap-1">
172
- <button
173
- type="button"
174
- aria-label={isWide ? 'Restore width' : 'Widen card'}
175
- title={isWide ? 'Restore width' : 'Widen card'}
176
- onClick={() => setIsWide((wide) => !wide)}
177
- className="rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
178
- >
179
- {isWide ? (
180
- <Minimize2 className="size-4" />
181
- ) : (
182
- <Maximize2 className="size-4" />
183
- )}
184
- </button>
185
- <button
186
- type="button"
187
- aria-label="Close"
188
- onClick={onClose}
189
- className="rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
190
- >
191
- <X className="size-4" />
192
- </button>
193
- </div>
194
- {/* Keyed by the resource on screen, so the body remounts when the card
195
- navigates from one app to another ("View replacement", an access
196
- prerequisite's parent) instead of updating in place. Everything
197
- per-app inside it is component state with no reset of its own — the
198
- roles table's expansion, the icon/screenshot error flags, a draft
199
- source edit — and it used to arrive at the next app still carrying
200
- the previous one's, plus the previous scroll offset.
201
-
202
- The key is here and not on the outer dialog on purpose: that one
203
- would restart the open animation and re-run the mount-focus effect
204
- on every in-card navigation. */}
205
- <div
206
- key={shownSlug}
207
- className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7"
208
- >
209
- {/* Inside the key, so anything a plugin provides here is scoped to
59
+ <DetailDialogShell
60
+ label={`${subResource?.displayName ?? app.displayName} details`}
61
+ shownKey={shownSlug}
62
+ // Wide enough for data tables (sub-resources have 5 columns incl.
63
+ // "Access Contacts"/"AWS Account" that wrapped at 760px); prose inside
64
+ // AppDetails is width-capped separately so it stays readable. Caps at
65
+ // 94vw so it never touches the edges.
66
+ maxWidthClass="max-w-[min(1120px,94vw)]"
67
+ focusSelector=".app-door"
68
+ onClose={onClose}
69
+ >
70
+ {/* Inside the key, so anything a plugin provides here is scoped to
210
71
  the resource on screen and is discarded when the card navigates.
211
72
  Renders its children untouched when no plugin contributes. */}
212
- <ResourceDetailProvider
213
- resource={subResource ?? app}
214
- parent={subResource ? app : undefined}
215
- >
216
- {subResource ? (
217
- <SubResourceDetailPanel
218
- subResource={subResource}
219
- parent={app}
220
- approvalMethods={approvalMethods}
221
- onBack={onBackToParent ?? onClose}
222
- actions={
223
- <ResourceDetailAccessActions {...subResourceActionsCtx} />
224
- }
225
- />
226
- ) : (
227
- <AppDetails app={app} onAppClick={onAppClick} />
228
- )}
229
- </ResourceDetailProvider>
230
- </div>
231
- </div>
232
- </div>
73
+ <ResourceDetailProvider
74
+ resource={subResource ?? app}
75
+ parent={subResource ? app : undefined}
76
+ >
77
+ {subResource ? (
78
+ <SubResourceDetailPanel
79
+ subResource={subResource}
80
+ parent={app}
81
+ approvalMethods={approvalMethods}
82
+ onBack={onBackToParent ?? onClose}
83
+ actions={<ResourceDetailAccessActions {...subResourceActionsCtx} />}
84
+ />
85
+ ) : (
86
+ <AppDetails app={app} onAppClick={onAppClick} />
87
+ )}
88
+ </ResourceDetailProvider>
89
+ </DetailDialogShell>
233
90
  )
234
91
  }
@@ -0,0 +1,199 @@
1
+ import { Maximize2, Minimize2, X } from 'lucide-react'
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
3
+ import { useDismissStack, useDismissable } from '~/modules/dismiss/DismissStack'
4
+
5
+ const ROUND_BUTTON =
6
+ 'rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors'
7
+
8
+ /**
9
+ * The overlay a detail card lives in: dimmed page, a card centred once on
10
+ * open, Widen and Close at its top right, and a body that scrolls inside it.
11
+ * The official app card and the community tool card both sit in it.
12
+ */
13
+ export function DetailDialogShell({
14
+ label,
15
+ shownKey,
16
+ maxWidthClass,
17
+ focusSelector,
18
+ controls,
19
+ testId,
20
+ onClose,
21
+ onKeyDown,
22
+ children,
23
+ }: {
24
+ /** The dialog's accessible name. */
25
+ label: string
26
+ /**
27
+ * What the card shows. A new value is a new open: the card re-centres for
28
+ * its own height, takes the caret back, and the body remounts.
29
+ */
30
+ shownKey: string
31
+ /** The width cap while not widened, e.g. `max-w-[min(1120px,94vw)]`. */
32
+ maxWidthClass: string
33
+ /** What takes the caret on open, when it exists; otherwise the dialog. */
34
+ focusSelector?: string
35
+ /** Extra header buttons, placed before Widen. */
36
+ controls?: React.ReactNode
37
+ testId?: string
38
+ onClose: () => void
39
+ onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>
40
+ children: React.ReactNode
41
+ }) {
42
+ const dialogRef = useRef<HTMLDivElement>(null)
43
+ // Not persisted: every open starts at the normal width. The toggle is for the
44
+ // one card being read, and the default stays readable for the next one.
45
+ const [isWide, setIsWide] = useState(false)
46
+
47
+ /**
48
+ * Centre the card ONCE when it opens, then hold that position.
49
+ *
50
+ * `my-auto` centred it on every render, so each panel height change pushed both
51
+ * edges and half the delta became upward movement of everything above it —
52
+ * including the tab strip, which moved 113px and took the control the user had
53
+ * just clicked with it (CLS 0.19, "needs improvement").
54
+ *
55
+ * Top-anchoring fixed the shift but looked wrong: a short card sat high on the
56
+ * page. So pin the centred offset instead. The card still opens centred, and
57
+ * anything that grows it afterwards extends downward only — which is what a
58
+ * reader expects when they open a tab with more in it.
59
+ *
60
+ * Only on open and on resize. Deliberately NOT on content change: reacting to
61
+ * content is precisely the bug.
62
+ */
63
+ useLayoutEffect(() => {
64
+ const card = dialogRef.current
65
+ const scroller = card?.parentElement
66
+ if (!card || !scroller) return
67
+
68
+ const pinCentre = () => {
69
+ card.style.marginTop = '0px'
70
+ const style = getComputedStyle(scroller)
71
+ const usable =
72
+ scroller.clientHeight -
73
+ parseFloat(style.paddingTop) -
74
+ parseFloat(style.paddingBottom)
75
+ const slack = usable - card.offsetHeight
76
+ // Negative slack means the card is already taller than the viewport; leave it
77
+ // at the top and let the overlay scroll, rather than pushing its head off.
78
+ card.style.marginTop = `${Math.max(0, Math.round(slack / 2))}px`
79
+ }
80
+
81
+ pinCentre()
82
+ window.addEventListener('resize', pinCentre)
83
+ return () => window.removeEventListener('resize', pinCentre)
84
+ // Keyed on the opened entry: a different card is a new open, and should
85
+ // re-centre for its own height.
86
+ }, [shownKey])
87
+
88
+ // Focus the card on mount so Esc hotkeys fire immediately, even when
89
+ // the card was opened by a mouse click (which leaves focus on the grid row).
90
+ //
91
+ // The door link rather than the dialog itself when the card has one: Enter on
92
+ // a search result opens the card, so a second Enter is the browser's own link
93
+ // activation and takes the reader into the app. No door: the dialog.
94
+ //
95
+ // Unless something inside already took the caret — the Quick Jump field does,
96
+ // and it has the better claim: a child's effect runs before its parent's, so
97
+ // without this guard the card would take focus straight back off it.
98
+ //
99
+ // Runs again whenever the card navigates to another entry, keyed the same
100
+ // way the body below is: that navigation destroys whatever was focused (the
101
+ // "View replacement" button, a prerequisite's parent), and without this the
102
+ // caret would land on <body> — where the next Tab walks the page behind the
103
+ // scrim, since the card has no focus trap.
104
+ const stack = useDismissStack()
105
+ useEffect(() => {
106
+ const dialog = dialogRef.current
107
+ if (!dialog || dialog.contains(document.activeElement)) return
108
+ // Only while the card is on top. A layer opened OVER it — a deployment's
109
+ // dialog on a nested route — owns the caret, and on a cold deep link that
110
+ // layer mounts before the card does, so without this check the card took
111
+ // focus back off the dialog's first field the moment the catalog arrived.
112
+ const top = stack.topElement()
113
+ if (top && top !== dialog) return
114
+ const target =
115
+ (focusSelector && dialog.querySelector<HTMLElement>(focusSelector)) ||
116
+ dialog
117
+ target.focus()
118
+ }, [shownKey, stack, focusSelector])
119
+
120
+ // Escape closes the card. Registered on the layer's own element, so a layer
121
+ // opened over it (the screenshot viewer) gets Escape first.
122
+ useDismissable(dialogRef, onClose)
123
+
124
+ return (
125
+ <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
126
+ {/* scrim */}
127
+ <button
128
+ type="button"
129
+ aria-label="Close details panel"
130
+ onClick={onClose}
131
+ className="fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in"
132
+ />
133
+ <div
134
+ ref={dialogRef}
135
+ role="dialog"
136
+ aria-modal="true"
137
+ aria-label={label}
138
+ tabIndex={-1}
139
+ data-testid={testId}
140
+ onKeyDown={onKeyDown}
141
+ // Centred on open, then held — see the pin effect above. `my-auto` is
142
+ // deliberately absent: it re-centres on EVERY height change, which is the
143
+ // layout-shift bug.
144
+ // `overflow-hidden` belongs on the rounded box: the scrolling child
145
+ // below carries the band's tint to its own square corners and paints
146
+ // them over this radius, so the card's top corners read as cut off.
147
+ // Widening drops the cap only. The pinned top margin is left alone, so
148
+ // the card's top edge stays put and restoring gives back the same layout.
149
+ className={`relative w-full ${isWide ? 'max-w-none' : maxWidthClass} overflow-hidden rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200 outline-none`}
150
+ >
151
+ {/* On a backdrop of its own: the body scrolls under these buttons. */}
152
+ <div className="absolute top-3 right-3 z-10 flex items-center gap-1 rounded-full bg-background/90 p-0.5 backdrop-blur-sm">
153
+ {controls}
154
+ <button
155
+ type="button"
156
+ aria-label={isWide ? 'Restore width' : 'Widen card'}
157
+ title={isWide ? 'Restore width' : 'Widen card'}
158
+ onClick={() => setIsWide((wide) => !wide)}
159
+ className={ROUND_BUTTON}
160
+ >
161
+ {isWide ? (
162
+ <Minimize2 className="size-4" />
163
+ ) : (
164
+ <Maximize2 className="size-4" />
165
+ )}
166
+ </button>
167
+ <button
168
+ type="button"
169
+ aria-label="Close"
170
+ onClick={onClose}
171
+ className={ROUND_BUTTON}
172
+ >
173
+ <X className="size-4" />
174
+ </button>
175
+ </div>
176
+ {/* Keyed by the entry on screen, so the body remounts when the card
177
+ navigates from one entry to another instead of updating in place.
178
+ Everything per-entry inside it is component state with no reset of
179
+ its own — the roles table's expansion, the icon/screenshot error
180
+ flags, a draft source edit — and it used to arrive at the next
181
+ entry still carrying the previous one's, plus the previous scroll
182
+ offset.
183
+
184
+ The key is here and not on the outer dialog on purpose: that one
185
+ would restart the open animation and re-run the mount-focus effect
186
+ on every in-card navigation. */}
187
+ <div
188
+ key={shownKey}
189
+ className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7"
190
+ >
191
+ {children}
192
+ </div>
193
+ </div>
194
+ </div>
195
+ )
196
+ }
197
+
198
+ /** The round header button style, for a card's own `controls`. */
199
+ export const DIALOG_ROUND_BUTTON = ROUND_BUTTON
@@ -9,7 +9,8 @@ import { Dialog, DialogContent, DialogTitle } from '~/ui/dialog'
9
9
  import { VisuallyHidden } from '~/ui/visually-hidden'
10
10
 
11
11
  export interface ScreenshotGalleryProps {
12
- app: Resource
12
+ /** Names the images; an entry, or anything else with a display name. */
13
+ app: Pick<Resource, 'displayName' | 'abbreviation'>
13
14
  screenshotIds: string[]
14
15
  initialIndex?: number
15
16
  open: boolean
@@ -0,0 +1,81 @@
1
+ import { Link, useRouterState } from '@tanstack/react-router'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { useTRPC } from '~/api/infra/trpc'
4
+ import { cn } from '~/lib/utils'
5
+
6
+ /**
7
+ * The header's section tabs: one captioned link per section, so each is
8
+ * deep-linkable. The active tab is derived from the pathname rather than an
9
+ * exact-match Link, because Catalog must stay active on the app-detail routes
10
+ * (`/app/<slug>`, #23) and on `/catalog/apps`, which renders the same page.
11
+ * Community tools is hidden until the server says the feature is on.
12
+ */
13
+ export function SectionTabs() {
14
+ const pathname = useRouterState({ select: (s) => s.location.pathname })
15
+ const trpc = useTRPC()
16
+ const { data: communityConfig } = useQuery(
17
+ trpc.communityTools.config.queryOptions(),
18
+ )
19
+
20
+ const tabs = [
21
+ {
22
+ to: '/',
23
+ title: 'Catalog',
24
+ caption: 'Official company apps',
25
+ active:
26
+ pathname === '/' ||
27
+ pathname.startsWith('/app/') ||
28
+ pathname.startsWith('/catalog/'),
29
+ },
30
+ ...(communityConfig?.enabled
31
+ ? [
32
+ {
33
+ to: '/community',
34
+ title: 'Community tools',
35
+ caption: 'Built by colleagues',
36
+ active: pathname.startsWith('/community'),
37
+ } as const,
38
+ ]
39
+ : []),
40
+ {
41
+ to: '/service-desks',
42
+ title: 'Service Desks',
43
+ caption: 'Where to file a ticket',
44
+ active: pathname.startsWith('/service-desks'),
45
+ },
46
+ ] as const
47
+
48
+ return (
49
+ <div
50
+ role="tablist"
51
+ aria-label="Sections"
52
+ data-testid="section-tabs"
53
+ className="flex items-stretch gap-2"
54
+ >
55
+ {tabs.map((tab) => (
56
+ <Link
57
+ key={tab.to}
58
+ to={tab.to}
59
+ aria-current={tab.active ? 'page' : undefined}
60
+ className={cn(
61
+ 'flex flex-col items-start rounded-sm border-b-2 px-3.5 py-1.5 transition-colors',
62
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
63
+ tab.active
64
+ ? 'border-primary text-foreground'
65
+ : 'border-transparent text-foreground/90 hover:text-primary',
66
+ )}
67
+ >
68
+ <span className="text-base font-semibold">{tab.title}</span>
69
+ <span
70
+ className={cn(
71
+ 'text-xs',
72
+ tab.active ? 'text-muted-foreground' : 'text-foreground/80',
73
+ )}
74
+ >
75
+ {tab.caption}
76
+ </span>
77
+ </Link>
78
+ ))}
79
+ </div>
80
+ )
81
+ }
@@ -2,7 +2,7 @@ import type {
2
2
  Resource,
3
3
  SourceReference,
4
4
  } from '@igstack/app-catalog-backend-core'
5
- import { useSearch } from '@tanstack/react-router'
5
+ import { Link, useSearch } from '@tanstack/react-router'
6
6
  import { ExternalLink, Plus, Trash2 } from 'lucide-react'
7
7
  import React from 'react'
8
8
  import { useHotkeys } from 'react-hotkeys-hook'
@@ -32,6 +32,7 @@ import {
32
32
  import { CommunitySection } from './CommunitySection'
33
33
  import type { CommunitySectionHandle } from './CommunitySection'
34
34
  import { usePendingCount } from './usePendingCount'
35
+ import { useCommunityToolCount } from './useCommunityToolCount'
35
36
  import { displayUrl } from '~/modules/appCatalog/utils/displayUrl'
36
37
  import { accessTabLabel } from '~/modules/appCatalog/utils/accessTabLabel'
37
38
  import { AppDoor } from './AppDoor'
@@ -151,6 +152,7 @@ export function AppDetails({
151
152
  const { state: filterState } = useAppCatalogFilters()
152
153
  // Agrees with the Pending badges in the feed: composed by the same rules.
153
154
  const behindCount = usePendingCount(app.slug, app.claims)
155
+ const communityCount = useCommunityToolCount(app.slug)
154
156
 
155
157
  // "Last checked 10 hours ago · added 7 months ago" under the sources.
156
158
  const lastChecked = app.freshness?.lastCheckedAt
@@ -458,7 +460,7 @@ export function AppDetails({
458
460
  <div className="behind-panel">
459
461
  <CommunitySection
460
462
  ref={knowMoreRef}
461
- appSlug={app.slug}
463
+ target={{ resourceSlug: app.slug }}
462
464
  appName={app.displayName}
463
465
  claims={app.claims}
464
466
  />
@@ -679,6 +681,19 @@ export function AppDetails({
679
681
  </p>
680
682
  )}
681
683
 
684
+ {communityCount > 0 && (
685
+ <p className="mt-2.5 text-xs">
686
+ <Link
687
+ to="/community"
688
+ search={{ tag: `app:${app.slug}` }}
689
+ data-testid="app-community-tools"
690
+ className="text-muted-foreground hover:text-primary hover:underline"
691
+ >
692
+ Community tools for {app.displayName} · {communityCount}
693
+ </Link>
694
+ </p>
695
+ )}
696
+
682
697
  {isAdmin && (
683
698
  <div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-1">
684
699
  <span className="inline-flex items-center gap-2">
@@ -9,6 +9,7 @@ import { cn } from '~/lib/utils'
9
9
  import { Button } from '~/ui/button'
10
10
  import { formatRelativeTime } from '../../utils/formatRelativeTime'
11
11
  import { attachmentUrl } from './communityDraft'
12
+ import type { CommentTarget } from './communityDraft'
12
13
  import { formatCommunityError } from './communityError'
13
14
  import { composeComments } from './composeComments'
14
15
  import type { CommentEntry } from './composeComments'
@@ -84,18 +85,18 @@ function Meta({ entry, edited }: { entry: CommentEntry; edited: boolean }) {
84
85
  * instant so a skewed client clock cannot show a control the server would refuse.
85
86
  */
86
87
  export function CommentsFeed({
87
- appSlug,
88
+ target,
88
89
  claims,
89
90
  labelledBy,
90
91
  }: {
91
- appSlug: string
92
+ target: CommentTarget
92
93
  claims: Claim[]
93
94
  /** Id of the heading that names the list. */
94
95
  labelledBy: string
95
96
  }) {
96
97
  const trpc = useTRPC()
97
98
  const queryClient = useQueryClient()
98
- const listOptions = trpc.feedback.list.queryOptions({ resourceSlug: appSlug })
99
+ const listOptions = trpc.feedback.list.queryOptions(target)
99
100
  const { data, error, isPending } = useQuery(listOptions)
100
101
  const [editingId, setEditingId] = React.useState<string | null>(null)
101
102
  const [editDraft, setEditDraft] = React.useState('')
@@ -233,7 +234,11 @@ export function CommentsFeed({
233
234
  {entries.map((entry) => (
234
235
  <li key={entry.key} className="comment">
235
236
  <Meta entry={entry} edited={!!entry.note?.editedAt} />
236
- {entry.note ? (
237
+ {entry.note?.isSystem ? (
238
+ <p className="comment__text is-system" data-system-line="">
239
+ {entry.text}
240
+ </p>
241
+ ) : entry.note ? (
237
242
  noteBody(entry.note)
238
243
  ) : (
239
244
  <p className="comment__text">{entry.text}</p>
@@ -11,7 +11,8 @@ export function CommentsHelp({
11
11
  appSlug,
12
12
  appName,
13
13
  }: {
14
- appSlug: string
14
+ /** Set for an official entry; a deployment's note speaks to entries only. */
15
+ appSlug?: string
15
16
  appName: string
16
17
  }) {
17
18
  const [open, setOpen] = React.useState(false)
@@ -88,8 +89,10 @@ export function CommentsHelp({
88
89
  <li>Attach a screenshot — paste it, drop it or use the paperclip.</li>
89
90
  </ul>
90
91
  <p className="comments-help__note">
91
- <ResourceDetailFeedbackNote appSlug={appSlug} appName={appName} />
92
- {!hasPluginNote && (
92
+ {appSlug && (
93
+ <ResourceDetailFeedbackNote appSlug={appSlug} appName={appName} />
94
+ )}
95
+ {!(appSlug && hasPluginNote) && (
93
96
  <>
94
97
  Every note is reviewed. Facts that check out update the card and
95
98
  are marked Used as source.