@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
@@ -0,0 +1,111 @@
1
+ import { Search, X } from 'lucide-react'
2
+ import { useEffect, useRef } from 'react'
3
+
4
+ const KBD =
5
+ 'shrink-0 rounded-[5px] border border-b-2 border-border bg-card px-1.5 py-px text-[11px] font-semibold text-muted-foreground'
6
+
7
+ function isTypingTarget(target: EventTarget | null): boolean {
8
+ return (
9
+ target instanceof HTMLElement &&
10
+ (target.isContentEditable ||
11
+ target.tagName === 'INPUT' ||
12
+ target.tagName === 'TEXTAREA' ||
13
+ target.tagName === 'SELECT')
14
+ )
15
+ }
16
+
17
+ /**
18
+ * The community search box. It has focus when the page opens and `/` brings it
19
+ * back from anywhere. Esc clears it; a second Esc, on an empty box, leaves it.
20
+ * The arrow keys and Enter belong to the results, so they go to `onKey`.
21
+ */
22
+ export function ToolSearch({
23
+ value,
24
+ onChange,
25
+ resultCount,
26
+ onKey,
27
+ resultsId,
28
+ activeDescendant,
29
+ }: {
30
+ value: string
31
+ onChange: (value: string) => void
32
+ resultCount: number
33
+ onKey: (e: React.KeyboardEvent<HTMLInputElement>) => void
34
+ /** The results grid, while there are results to move through. */
35
+ resultsId?: string
36
+ /** The selected result's id: the option ↑↓ move and Enter opens. */
37
+ activeDescendant?: string
38
+ }) {
39
+ const inputRef = useRef<HTMLInputElement>(null)
40
+ const searching = value.trim() !== ''
41
+
42
+ useEffect(() => {
43
+ const onSlash = (e: KeyboardEvent) => {
44
+ if (e.key !== '/' || isTypingTarget(e.target)) return
45
+ e.preventDefault()
46
+ inputRef.current?.focus()
47
+ }
48
+ document.addEventListener('keydown', onSlash)
49
+ return () => document.removeEventListener('keydown', onSlash)
50
+ }, [])
51
+
52
+ const clear = () => {
53
+ onChange('')
54
+ inputRef.current?.focus()
55
+ }
56
+
57
+ return (
58
+ <div
59
+ data-testid="tool-search"
60
+ className="flex h-[50px] min-w-0 flex-1 items-center gap-[11px] rounded-full border border-border bg-card px-4 text-muted-foreground transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/20"
61
+ >
62
+ <Search aria-hidden="true" className="size-[19px] shrink-0" />
63
+ <input
64
+ ref={inputRef}
65
+ type="text"
66
+ value={value}
67
+ autoFocus
68
+ autoComplete="off"
69
+ spellCheck={false}
70
+ aria-label="Search community tools"
71
+ role="combobox"
72
+ aria-haspopup="grid"
73
+ aria-autocomplete="list"
74
+ aria-expanded={resultsId !== undefined}
75
+ aria-controls={resultsId}
76
+ aria-activedescendant={activeDescendant}
77
+ placeholder="Type anything you remember: a word, the author, part of the address"
78
+ onChange={(e) => onChange(e.target.value)}
79
+ onKeyDown={(e) => {
80
+ if (e.key === 'Escape') {
81
+ e.preventDefault()
82
+ e.stopPropagation()
83
+ if (value) onChange('')
84
+ else e.currentTarget.blur()
85
+ return
86
+ }
87
+ onKey(e)
88
+ }}
89
+ className="min-w-0 flex-1 truncate border-0 bg-transparent text-[15.5px] text-foreground outline-none placeholder:text-muted-foreground"
90
+ />
91
+ {searching ? (
92
+ <>
93
+ <span className="shrink-0 text-[13px] font-semibold">
94
+ {resultCount === 1 ? '1 tool' : `${resultCount} tools`}
95
+ </span>
96
+ <button
97
+ type="button"
98
+ aria-label="Clear search"
99
+ onClick={clear}
100
+ className="shrink-0 rounded-full p-1 hover:bg-accent hover:text-foreground"
101
+ >
102
+ <X aria-hidden="true" className="size-4" />
103
+ </button>
104
+ <kbd className={KBD}>Esc</kbd>
105
+ </>
106
+ ) : (
107
+ <kbd className={KBD}>/</kbd>
108
+ )}
109
+ </div>
110
+ )
111
+ }
@@ -0,0 +1,146 @@
1
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3
+ import { useCallback, useMemo, useState } from 'react'
4
+ import { useTRPC } from '~/api/infra/trpc'
5
+ import { useAppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'
6
+ import { useAuth } from '~/modules/auth'
7
+ import { withLike } from './likedBy'
8
+
9
+ /**
10
+ * Everything the community page reads, and the pin writes. Pins
11
+ * belong to the signed-in user, so they are not asked for until there is one.
12
+ */
13
+ export function useCommunityTools() {
14
+ const trpc = useTRPC()
15
+ const queryClient = useQueryClient()
16
+ const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
17
+ const { resources } = useAppCatalogContext()
18
+
19
+ const list = useQuery(trpc.communityTools.list.queryOptions())
20
+ const pins = useQuery(
21
+ trpc.communityTools.pins.queryOptions(undefined, {
22
+ enabled: isAuthenticated,
23
+ }),
24
+ )
25
+
26
+ /** Display names of official apps by slug, for app tags. */
27
+ const appNames = useMemo(
28
+ () => Object.fromEntries(resources.map((r) => [r.slug, r.displayName])),
29
+ [resources],
30
+ )
31
+
32
+ const pinsKey = trpc.communityTools.pins.queryKey()
33
+ const refreshPins = () => queryClient.invalidateQueries({ queryKey: pinsKey })
34
+
35
+ // The star and the shelf change at once; a failed save puts them back.
36
+ const optimisticPins = (
37
+ change: (old: CommunityTool[], slug: string) => CommunityTool[],
38
+ ) => ({
39
+ onMutate: async ({ slug }: { slug: string }) => {
40
+ await queryClient.cancelQueries({ queryKey: pinsKey })
41
+ const previous = queryClient.getQueryData<CommunityTool[]>(pinsKey)
42
+ queryClient.setQueryData<CommunityTool[]>(pinsKey, (old) =>
43
+ old ? change(old, slug) : old,
44
+ )
45
+ return { previous }
46
+ },
47
+ onError: (
48
+ _error: unknown,
49
+ _input: { slug: string },
50
+ context: { previous?: CommunityTool[] } | undefined,
51
+ ) => {
52
+ queryClient.setQueryData(pinsKey, context?.previous)
53
+ },
54
+ // Not awaited: the pin is saved once the server answers; the refetch
55
+ // behind it only confirms what the star already shows.
56
+ onSettled: () => {
57
+ void refreshPins()
58
+ },
59
+ })
60
+ const pin = useMutation(
61
+ trpc.communityTools.pin.mutationOptions(
62
+ optimisticPins((old, slug) => {
63
+ const tool = list.data?.find((t) => t.slug === slug)
64
+ return tool && !old.some((t) => t.slug === slug) ? [...old, tool] : old
65
+ }),
66
+ ),
67
+ )
68
+ const unpin = useMutation(
69
+ trpc.communityTools.unpin.mutationOptions(
70
+ optimisticPins((old, slug) => old.filter((t) => t.slug !== slug)),
71
+ ),
72
+ )
73
+ /** The tool whose pin is being saved, if any. */
74
+ const pinBusySlug = pin.isPending
75
+ ? pin.variables.slug
76
+ : unpin.isPending
77
+ ? unpin.variables.slug
78
+ : undefined
79
+ const reorder = useMutation(
80
+ trpc.communityTools.reorderPins.mutationOptions({
81
+ onMutate: ({ slugs }) => {
82
+ // The tile moves at once; the server catches up behind it.
83
+ queryClient.setQueryData<CommunityTool[]>(pinsKey, (old) =>
84
+ old ? slugs.flatMap((s) => old.find((t) => t.slug === s) ?? []) : old,
85
+ )
86
+ },
87
+ onSettled: refreshPins,
88
+ }),
89
+ )
90
+
91
+ // My likes show at once, as an overlay on the tools as fetched. The feed
92
+ // ranks from the fetched tools, so a like changes its own count and never
93
+ // moves a row out from under the pointer. Settling marks the list stale
94
+ // without refetching it: it re-ranks on the next refocus or remount.
95
+ const [myLikes, setMyLikes] = useState<ReadonlyMap<string, boolean>>(
96
+ () => new Map(),
97
+ )
98
+ const setMyLike = (slug: string, liked: boolean | undefined) =>
99
+ setMyLikes((prev) => {
100
+ const next = new Map(prev)
101
+ if (liked === undefined) next.delete(slug)
102
+ else next.set(slug, liked)
103
+ return next
104
+ })
105
+ const likeOptions = (liked: boolean) => ({
106
+ onMutate: ({ slug }: { slug: string }) => setMyLike(slug, liked),
107
+ onError: (_error: unknown, { slug }: { slug: string }) =>
108
+ setMyLike(slug, undefined),
109
+ onSettled: () => {
110
+ void queryClient.invalidateQueries({
111
+ queryKey: trpc.communityTools.list.queryKey(),
112
+ refetchType: 'none',
113
+ })
114
+ void refreshPins()
115
+ },
116
+ })
117
+ const like = useMutation(
118
+ trpc.communityTools.like.mutationOptions(likeOptions(true)),
119
+ )
120
+ const unlike = useMutation(
121
+ trpc.communityTools.unlike.mutationOptions(likeOptions(false)),
122
+ )
123
+ /** The tool with my latest like or unlike, saved or still saving. */
124
+ const withMyLike = useCallback(
125
+ (tool: CommunityTool) => {
126
+ const mine = myLikes.get(tool.slug)
127
+ return mine === undefined ? tool : withLike(tool, mine)
128
+ },
129
+ [myLikes],
130
+ )
131
+
132
+ return {
133
+ list,
134
+ pins,
135
+ appNames,
136
+ isAuthenticated,
137
+ isAuthLoading,
138
+ pin,
139
+ unpin,
140
+ pinBusySlug,
141
+ reorder,
142
+ like,
143
+ unlike,
144
+ withMyLike,
145
+ }
146
+ }
@@ -0,0 +1,26 @@
1
+ import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'
4
+ import { useMemo } from 'react'
5
+ import type { AcRouterContext } from '~/types/types'
6
+
7
+ /**
8
+ * The feature switch, read with the route context's clients, for the route
9
+ * above the layout's providers. Same query key as the header's, so it is one
10
+ * request. Here and not in the route file: a deployment compiles the core's
11
+ * route files from its own tree, where these packages are other copies.
12
+ */
13
+ export function useCommunityToolsConfig({
14
+ queryClient,
15
+ trpcClient,
16
+ }: Pick<AcRouterContext, 'queryClient' | 'trpcClient'>) {
17
+ const options = useMemo(
18
+ () =>
19
+ createTRPCOptionsProxy<TRPCRouter>({
20
+ client: trpcClient,
21
+ queryClient,
22
+ }).communityTools.config.queryOptions(),
23
+ [queryClient, trpcClient],
24
+ )
25
+ return useQuery(options, queryClient).data
26
+ }
@@ -0,0 +1,14 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /**
4
+ * True once `ms` have passed since `key` was first shown, false until then. A
5
+ * new key starts the wait again. The card's cap on waiting for a screenshot.
6
+ */
7
+ export function useTimeUp(key: string, ms: number): boolean {
8
+ const [upFor, setUpFor] = useState<string | null>(null)
9
+ useEffect(() => {
10
+ const timer = setTimeout(() => setUpFor(key), ms)
11
+ return () => clearTimeout(timer)
12
+ }, [key, ms])
13
+ return upFor === key
14
+ }
@@ -11,11 +11,13 @@
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as LayoutRouteImport } from './routes/_layout'
13
13
  import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
14
+ import { Route as LayoutCommunityRouteImport } from './routes/_layout/community'
14
15
  import { Route as LayoutLoginRouteImport } from './routes/_layout/login'
15
16
  import { Route as LayoutMcpRouteImport } from './routes/_layout/mcp'
16
17
  import { Route as LayoutServiceDesksRouteImport } from './routes/_layout/service-desks'
17
18
  import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
18
19
  import { Route as LayoutAppSlugRouteImport } from './routes/_layout/app.$slug'
20
+ import { Route as LayoutCommunitySlugRouteImport } from './routes/_layout/community.$slug'
19
21
  import { Route as LayoutCatalogAppsIndexRouteImport } from './routes/_layout/catalog.apps.index'
20
22
  import { Route as LayoutAppSlugSubSubSlugRouteImport } from './routes/_layout/app.$slug_.sub.$subSlug'
21
23
 
@@ -28,6 +30,11 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
28
30
  path: '/',
29
31
  getParentRoute: () => LayoutRoute,
30
32
  } as any)
33
+ const LayoutCommunityRoute = LayoutCommunityRouteImport.update({
34
+ id: '/community',
35
+ path: '/community',
36
+ getParentRoute: () => LayoutRoute,
37
+ } as any)
31
38
  const LayoutLoginRoute = LayoutLoginRouteImport.update({
32
39
  id: '/login',
33
40
  path: '/login',
@@ -53,6 +60,11 @@ const LayoutAppSlugRoute = LayoutAppSlugRouteImport.update({
53
60
  path: '/app/$slug',
54
61
  getParentRoute: () => LayoutRoute,
55
62
  } as any)
63
+ const LayoutCommunitySlugRoute = LayoutCommunitySlugRouteImport.update({
64
+ id: '/$slug',
65
+ path: '/$slug',
66
+ getParentRoute: () => LayoutCommunityRoute,
67
+ } as any)
56
68
  const LayoutCatalogAppsIndexRoute = LayoutCatalogAppsIndexRouteImport.update({
57
69
  id: '/catalog/apps/',
58
70
  path: '/catalog/apps/',
@@ -66,33 +78,39 @@ const LayoutAppSlugSubSubSlugRoute = LayoutAppSlugSubSubSlugRouteImport.update({
66
78
 
67
79
  export interface FileRoutesByFullPath {
68
80
  '/': typeof LayoutIndexRoute
81
+ '/community': typeof LayoutCommunityRouteWithChildren
69
82
  '/login': typeof LayoutLoginRoute
70
83
  '/mcp': typeof LayoutMcpRoute
71
84
  '/service-desks': typeof LayoutServiceDesksRoute
72
85
  '/auth/callback': typeof AuthCallbackRoute
73
86
  '/app/$slug': typeof LayoutAppSlugRoute
87
+ '/community/$slug': typeof LayoutCommunitySlugRoute
74
88
  '/catalog/apps/': typeof LayoutCatalogAppsIndexRoute
75
89
  '/app/$slug/sub/$subSlug': typeof LayoutAppSlugSubSubSlugRoute
76
90
  }
77
91
  export interface FileRoutesByTo {
92
+ '/community': typeof LayoutCommunityRouteWithChildren
78
93
  '/login': typeof LayoutLoginRoute
79
94
  '/mcp': typeof LayoutMcpRoute
80
95
  '/service-desks': typeof LayoutServiceDesksRoute
81
96
  '/auth/callback': typeof AuthCallbackRoute
82
97
  '/': typeof LayoutIndexRoute
83
98
  '/app/$slug': typeof LayoutAppSlugRoute
99
+ '/community/$slug': typeof LayoutCommunitySlugRoute
84
100
  '/catalog/apps': typeof LayoutCatalogAppsIndexRoute
85
101
  '/app/$slug/sub/$subSlug': typeof LayoutAppSlugSubSubSlugRoute
86
102
  }
87
103
  export interface FileRoutesById {
88
104
  __root__: typeof rootRouteImport
89
105
  '/_layout': typeof LayoutRouteWithChildren
106
+ '/_layout/community': typeof LayoutCommunityRouteWithChildren
90
107
  '/_layout/login': typeof LayoutLoginRoute
91
108
  '/_layout/mcp': typeof LayoutMcpRoute
92
109
  '/_layout/service-desks': typeof LayoutServiceDesksRoute
93
110
  '/auth/callback': typeof AuthCallbackRoute
94
111
  '/_layout/': typeof LayoutIndexRoute
95
112
  '/_layout/app/$slug': typeof LayoutAppSlugRoute
113
+ '/_layout/community/$slug': typeof LayoutCommunitySlugRoute
96
114
  '/_layout/catalog/apps/': typeof LayoutCatalogAppsIndexRoute
97
115
  '/_layout/app/$slug_/sub/$subSlug': typeof LayoutAppSlugSubSubSlugRoute
98
116
  }
@@ -100,32 +118,38 @@ export interface FileRouteTypes {
100
118
  fileRoutesByFullPath: FileRoutesByFullPath
101
119
  fullPaths:
102
120
  | '/'
121
+ | '/community'
103
122
  | '/login'
104
123
  | '/mcp'
105
124
  | '/service-desks'
106
125
  | '/auth/callback'
107
126
  | '/app/$slug'
127
+ | '/community/$slug'
108
128
  | '/catalog/apps/'
109
129
  | '/app/$slug/sub/$subSlug'
110
130
  fileRoutesByTo: FileRoutesByTo
111
131
  to:
132
+ | '/community'
112
133
  | '/login'
113
134
  | '/mcp'
114
135
  | '/service-desks'
115
136
  | '/auth/callback'
116
137
  | '/'
117
138
  | '/app/$slug'
139
+ | '/community/$slug'
118
140
  | '/catalog/apps'
119
141
  | '/app/$slug/sub/$subSlug'
120
142
  id:
121
143
  | '__root__'
122
144
  | '/_layout'
145
+ | '/_layout/community'
123
146
  | '/_layout/login'
124
147
  | '/_layout/mcp'
125
148
  | '/_layout/service-desks'
126
149
  | '/auth/callback'
127
150
  | '/_layout/'
128
151
  | '/_layout/app/$slug'
152
+ | '/_layout/community/$slug'
129
153
  | '/_layout/catalog/apps/'
130
154
  | '/_layout/app/$slug_/sub/$subSlug'
131
155
  fileRoutesById: FileRoutesById
@@ -151,6 +175,13 @@ declare module '@tanstack/react-router' {
151
175
  preLoaderRoute: typeof LayoutIndexRouteImport
152
176
  parentRoute: typeof LayoutRoute
153
177
  }
178
+ '/_layout/community': {
179
+ id: '/_layout/community'
180
+ path: '/community'
181
+ fullPath: '/community'
182
+ preLoaderRoute: typeof LayoutCommunityRouteImport
183
+ parentRoute: typeof LayoutRoute
184
+ }
154
185
  '/_layout/login': {
155
186
  id: '/_layout/login'
156
187
  path: '/login'
@@ -186,6 +217,13 @@ declare module '@tanstack/react-router' {
186
217
  preLoaderRoute: typeof LayoutAppSlugRouteImport
187
218
  parentRoute: typeof LayoutRoute
188
219
  }
220
+ '/_layout/community/$slug': {
221
+ id: '/_layout/community/$slug'
222
+ path: '/$slug'
223
+ fullPath: '/community/$slug'
224
+ preLoaderRoute: typeof LayoutCommunitySlugRouteImport
225
+ parentRoute: typeof LayoutCommunityRoute
226
+ }
189
227
  '/_layout/catalog/apps/': {
190
228
  id: '/_layout/catalog/apps/'
191
229
  path: '/catalog/apps'
@@ -203,7 +241,20 @@ declare module '@tanstack/react-router' {
203
241
  }
204
242
  }
205
243
 
244
+ interface LayoutCommunityRouteChildren {
245
+ LayoutCommunitySlugRoute: typeof LayoutCommunitySlugRoute
246
+ }
247
+
248
+ const LayoutCommunityRouteChildren: LayoutCommunityRouteChildren = {
249
+ LayoutCommunitySlugRoute: LayoutCommunitySlugRoute,
250
+ }
251
+
252
+ const LayoutCommunityRouteWithChildren = LayoutCommunityRoute._addFileChildren(
253
+ LayoutCommunityRouteChildren,
254
+ )
255
+
206
256
  interface LayoutRouteChildren {
257
+ LayoutCommunityRoute: typeof LayoutCommunityRouteWithChildren
207
258
  LayoutLoginRoute: typeof LayoutLoginRoute
208
259
  LayoutMcpRoute: typeof LayoutMcpRoute
209
260
  LayoutServiceDesksRoute: typeof LayoutServiceDesksRoute
@@ -214,6 +265,7 @@ interface LayoutRouteChildren {
214
265
  }
215
266
 
216
267
  const LayoutRouteChildren: LayoutRouteChildren = {
268
+ LayoutCommunityRoute: LayoutCommunityRouteWithChildren,
217
269
  LayoutLoginRoute: LayoutLoginRoute,
218
270
  LayoutMcpRoute: LayoutMcpRoute,
219
271
  LayoutServiceDesksRoute: LayoutServiceDesksRoute,
@@ -0,0 +1,7 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+
3
+ export const Route = createFileRoute('/_layout/community/$slug')({
4
+ // Renders nothing: the parent `/community` route owns the page, so the list
5
+ // stays mounted (and keeps its scroll) under the card for this slug.
6
+ component: () => null,
7
+ })
@@ -0,0 +1,97 @@
1
+ import {
2
+ Outlet,
3
+ createFileRoute,
4
+ stripSearchParams,
5
+ useParams,
6
+ } from '@tanstack/react-router'
7
+ import { z } from 'zod'
8
+ import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'
9
+ import { CommunityPage } from '~/modules/communityTools/ui/CommunityPage'
10
+ import { useCommunityToolsConfig } from '~/modules/communityTools/useCommunityToolsConfig'
11
+ import { NotFoundError } from '~/ui/error/NotFoundError'
12
+ import { TopLevelProvidersForErrors } from '~/ui/layout/TopLevelProvidersForErrors'
13
+
14
+ // Each defaults to absent, so a plain /community link stays plain. A value that
15
+ // does not parse reads as absent rather than failing the route.
16
+ /** The old window toggle: Newest kept its name; both view counts are Popular now. */
17
+ const LEGACY_WINDOW: Record<string, 'popular' | 'newest' | undefined> = {
18
+ newest: 'newest',
19
+ month: 'popular',
20
+ all: 'popular',
21
+ }
22
+
23
+ const searchSchema = z
24
+ .object({
25
+ /** `<kind>:<value>`, e.g. `topic:sandbox` or `app:order-service`. */
26
+ tag: z.string().optional().catch(undefined),
27
+ /** Most liked when absent. */
28
+ sort: z.enum(['popular', 'newest', 'updated']).optional().catch(undefined),
29
+ /** Descending when absent. */
30
+ dir: z.enum(['asc']).optional().catch(undefined),
31
+ /** The old window toggle: `newest` still means Newest; the param is dropped. */
32
+ window: z.string().optional().catch(undefined),
33
+ })
34
+ .transform(({ window, ...search }) => ({
35
+ ...search,
36
+ sort: search.sort ?? LEGACY_WINDOW[window ?? ''],
37
+ window: undefined,
38
+ }))
39
+
40
+ export const Route = createFileRoute('/_layout/community')({
41
+ component: RouteComponent,
42
+ validateSearch: searchSchema,
43
+ search: { middlewares: [stripSearchParams(['window'])] },
44
+ })
45
+
46
+ function RouteComponent() {
47
+ const { queryClient, trpcClient } = Route.useRouteContext()
48
+ const search = Route.useSearch()
49
+ const navigate = Route.useNavigate()
50
+ // Set while a card is open: the child route's slug.
51
+ const { slug } = useParams({ strict: false })
52
+ // Read here, above the layout's providers, so a deployment with the feature
53
+ // off answers a typed-in /community with the usual not-found page.
54
+ const config = useCommunityToolsConfig({ queryClient, trpcClient })
55
+
56
+ // A tag or sort change keeps an open card open: it navigates from the
57
+ // current match, not from this parent route, which would drop the slug.
58
+ const setSearch = (patch: Partial<typeof search>) =>
59
+ void (slug
60
+ ? navigate({
61
+ to: '/community/$slug',
62
+ params: { slug },
63
+ search: (prev) => ({ ...prev, ...patch }),
64
+ })
65
+ : navigate({
66
+ to: '/community',
67
+ search: (prev) => ({ ...prev, ...patch }),
68
+ }))
69
+
70
+ if (config?.enabled === false) {
71
+ return (
72
+ <TopLevelProvidersForErrors>
73
+ <NotFoundError />
74
+ </TopLevelProvidersForErrors>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <AppCatalogLayout queryClient={queryClient} trpcClient={trpcClient}>
80
+ <CommunityPage
81
+ tag={search.tag}
82
+ sort={search.sort ?? 'liked'}
83
+ dir={search.dir ?? 'desc'}
84
+ cardSlug={slug}
85
+ onTagChange={(tag) => setSearch({ tag })}
86
+ onSortChange={(sort, dir) =>
87
+ setSearch({
88
+ sort: sort === 'liked' ? undefined : sort,
89
+ dir: dir === 'asc' ? 'asc' : undefined,
90
+ })
91
+ }
92
+ />
93
+ {/* The card route renders nothing; the page above reads its slug. */}
94
+ <Outlet />
95
+ </AppCatalogLayout>
96
+ )
97
+ }
@@ -4,7 +4,7 @@ import type {
4
4
  } from '@igstack/app-catalog-backend-core'
5
5
  import { Link } from '@tanstack/react-router'
6
6
  import { useQuery } from '@tanstack/react-query'
7
- import { LogOut, X } from 'lucide-react'
7
+ import { LogOut, Plug, X } from 'lucide-react'
8
8
  import React, { use } from 'react'
9
9
  import AppCatalogLogo from '~/assets/app-catalog.svg?react'
10
10
  import { useTRPC } from '~/api/infra/trpc'
@@ -211,11 +211,17 @@ export function Header({ middle }: HeaderProps) {
211
211
  </div>
212
212
  {middle && <div className="flex-1">{middle}</div>}
213
213
  <div className="flex items-center gap-3 ml-auto">
214
+ <Button variant="outline" size="sm" asChild>
215
+ <Link to="/mcp">
216
+ <Plug />
217
+ MCP
218
+ </Link>
219
+ </Button>
214
220
  <ThemeSwitcher />
215
221
  {isLoading ? (
216
222
  <Skeleton className="w-8 h-8 rounded-full" />
217
223
  ) : !isAuthenticated ? (
218
- // The catalog is browsable without signing in; Login is for the parts
224
+ // The catalog is browsable without signing in; Sign in is for the parts
219
225
  // that need an identity (comments, admin). DEV Login is gated behind
220
226
  // `devLoginEnabled`, so it never reaches a real deployment.
221
227
  <div className="flex items-center gap-2">
@@ -233,7 +239,7 @@ export function Header({ middle }: HeaderProps) {
233
239
  onClick={handleLoginClick}
234
240
  className="text-sm font-medium text-muted-foreground hover:text-foreground cursor-pointer"
235
241
  >
236
- Login
242
+ Sign in
237
243
  </button>
238
244
  </div>
239
245
  ) : (
@@ -27,3 +27,37 @@ export function highlightMatches(text: string, query: string): React.ReactNode {
27
27
  </>
28
28
  )
29
29
  }
30
+
31
+ /** Like `highlightMatches`, for several search words at once, in any order. */
32
+ export function highlightTokens(
33
+ text: string,
34
+ tokens: string[],
35
+ ): React.ReactNode {
36
+ const words = tokens.filter(Boolean)
37
+ if (words.length === 0) return text
38
+
39
+ // Longest first, so "runbook" wins over its own prefix "run".
40
+ const alternation = [...words]
41
+ .sort((a, b) => b.length - a.length)
42
+ .map(escapeRegex)
43
+ .join('|')
44
+ const parts = text.split(new RegExp(`(${alternation})`, 'gi'))
45
+
46
+ let offset = 0
47
+ return (
48
+ <>
49
+ {parts.map((part, idx) => {
50
+ // An empty part shares its offset with the match after it; parity splits them.
51
+ const key = `${offset}-${idx % 2}`
52
+ offset += part.length
53
+ return idx % 2 === 1 ? (
54
+ <span key={key} className="rounded-[3px] bg-highlight">
55
+ {part}
56
+ </span>
57
+ ) : (
58
+ <React.Fragment key={key}>{part}</React.Fragment>
59
+ )
60
+ })}
61
+ </>
62
+ )
63
+ }
@@ -1,12 +0,0 @@
1
- /**
2
- * Compact segmented toggle for the header (Apps | Service Desks | MCP). Rendered in
3
- * the header's `middle` slot so it adds no header height. Uses router Links so
4
- * each segment is deep-linkable.
5
- *
6
- * The active segment is derived from the current pathname rather than a plain
7
- * exact-match on the Link: the "Apps" tab must stay active on the app-detail
8
- * routes (`/app/<slug>`) too, since the detail panel is part of the Apps view
9
- * (#23). A plain `activeOptions={{ exact: true }}` on `to="/"` left both tabs
10
- * inactive on `/app/<slug>`.
11
- */
12
- export declare function ViewToggle(): import("react/jsx-runtime").JSX.Element;