@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,626 @@
1
+ import type {
2
+ CommunityTool,
3
+ FeedSort,
4
+ MatchField,
5
+ SortDir,
6
+ } from '@igstack/app-catalog-shared-core'
7
+ import { rankTools, searchTools } from '@igstack/app-catalog-shared-core'
8
+ import { useLocation, useNavigate } from '@tanstack/react-router'
9
+ import { useVirtualizer } from '@tanstack/react-virtual'
10
+ import { AlertTriangle, Plus, RefreshCw, Search, Star } from 'lucide-react'
11
+ import {
12
+ useCallback,
13
+ useEffect,
14
+ useLayoutEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ } from 'react'
19
+ import { useAuthModal } from '~/modules/auth/AuthModalContext'
20
+ import { Skeleton } from '~/ui/skeleton'
21
+ import { useCommunityTools } from '../useCommunityTools'
22
+ import { FeedRow, ROW_THUMB } from './FeedRow'
23
+ import { FeedSortControl, feedHeading } from './FeedSortControl'
24
+ import { MarginPreview } from './MarginPreview'
25
+ import { MyToolsShelf } from './MyToolsShelf'
26
+ import { TagRow } from './TagRow'
27
+ import { Toast } from './Toast'
28
+ import type { ToastMessage } from './Toast'
29
+ import { ToolCard } from './ToolCard'
30
+ import { ToolFormDialog } from './ToolFormDialog'
31
+ import { ToolSearch } from './ToolSearch'
32
+
33
+ const KBD =
34
+ 'rounded-[5px] border border-b-2 border-border bg-card px-1.5 py-px text-[11px] font-semibold text-muted-foreground'
35
+
36
+ interface ListItem {
37
+ tool: CommunityTool
38
+ matched?: MatchField[]
39
+ }
40
+
41
+ export interface CommunityPageProps {
42
+ /** `?tag=`: `<kind>:<value>`, or none for the whole feed. */
43
+ tag: string | undefined
44
+ sort: FeedSort
45
+ dir: SortDir
46
+ onTagChange: (tag: string | undefined) => void
47
+ onSortChange: (sort: FeedSort, dir: SortDir) => void
48
+ /** The tool whose card is open over the page (`/community/<slug>`). */
49
+ cardSlug?: string
50
+ }
51
+
52
+ const RESULTS_ID = 'community-results-grid'
53
+
54
+ /** Stable per tool, so the active descendant survives re-renders and re-ranking. */
55
+ function optionIdOf(slug: string): string {
56
+ return `community-option-${slug}`
57
+ }
58
+
59
+ function openSite(url: string) {
60
+ window.open(url, '_blank', 'noopener,noreferrer')
61
+ }
62
+
63
+ export function CommunityPage({
64
+ tag,
65
+ sort,
66
+ dir,
67
+ onTagChange,
68
+ onSortChange,
69
+ cardSlug,
70
+ }: CommunityPageProps) {
71
+ const {
72
+ list,
73
+ pins,
74
+ appNames,
75
+ isAuthenticated,
76
+ isAuthLoading,
77
+ pin,
78
+ unpin,
79
+ pinBusySlug,
80
+ reorder,
81
+ like,
82
+ unlike,
83
+ withMyLike,
84
+ } = useCommunityTools()
85
+ const { open: openSignIn } = useAuthModal()
86
+ const navigate = useNavigate()
87
+ // Signing in comes back here: this page, or the card open over it. The
88
+ // router's own location, so it holds under any history (and in tests).
89
+ const here = useLocation({ select: (l) => l.href })
90
+ const signInHere = () => openSignIn(here)
91
+
92
+ const [query, setQuery] = useState('')
93
+ const [selected, setSelected] = useState(0)
94
+ const tokens = useMemo(
95
+ () => query.trim().toLowerCase().split(/\s+/).filter(Boolean),
96
+ [query],
97
+ )
98
+ const searching = tokens.length > 0
99
+
100
+ const [toast, setToast] = useState<ToastMessage | null>(null)
101
+ const toastIdRef = useRef(0)
102
+ const showToast = useCallback((message: Omit<ToastMessage, 'id'>) => {
103
+ toastIdRef.current += 1
104
+ setToast({ ...message, id: toastIdRef.current })
105
+ }, [])
106
+ const dismissToast = useCallback(() => setToast(null), [])
107
+
108
+ const tools = useMemo(() => list.data ?? [], [list.data])
109
+ const withTag = useCallback(
110
+ (all: CommunityTool[]) =>
111
+ tag
112
+ ? all.filter((t) => t.tags.some((g) => `${g.kind}:${g.value}` === tag))
113
+ : all,
114
+ [tag],
115
+ )
116
+
117
+ const feed = useMemo<ListItem[]>(
118
+ () => withTag(rankTools(tools, sort, dir)).map((tool) => ({ tool })),
119
+ [tools, sort, dir, withTag],
120
+ )
121
+
122
+ // Strongest match first; archived tools still found, after the rest.
123
+ const results = useMemo<ListItem[]>(
124
+ () =>
125
+ searching
126
+ ? searchTools(withTag(tools), query, appNames, sort, dir).map(
127
+ ({ tool, fields }) => ({ tool, matched: fields }),
128
+ )
129
+ : [],
130
+ [searching, tools, query, appNames, sort, dir, withTag],
131
+ )
132
+
133
+ const items = searching ? results : feed
134
+ const selectedIndex = Math.min(selected, results.length - 1)
135
+ const selectedTool = results[selectedIndex]?.tool
136
+ const activeOptionId =
137
+ searching && selectedTool ? optionIdOf(selectedTool.slug) : undefined
138
+
139
+ const pinnedSlugs = useMemo(
140
+ () => new Set((pins.data ?? []).map((t) => t.slug)),
141
+ [pins.data],
142
+ )
143
+
144
+ const pinError = (retry: () => void) => () =>
145
+ showToast({
146
+ tone: 'error',
147
+ text: 'Could not save the pin. Check your connection.',
148
+ action: { label: 'Try again', run: retry },
149
+ })
150
+
151
+ const pinTool = (slug: string) =>
152
+ pin.mutate(
153
+ { slug },
154
+ {
155
+ onSuccess: () =>
156
+ showToast({
157
+ tone: 'info',
158
+ text: 'Pinned to My tools',
159
+ action: { label: 'Undo', run: () => unpinTool(slug, false) },
160
+ }),
161
+ onError: pinError(() => pinTool(slug)),
162
+ },
163
+ )
164
+ const unpinTool = (slug: string, offerUndo = true) =>
165
+ unpin.mutate(
166
+ { slug },
167
+ {
168
+ onSuccess: () => {
169
+ if (offerUndo) {
170
+ showToast({
171
+ tone: 'info',
172
+ text: 'Removed from My tools',
173
+ action: { label: 'Undo', run: () => pinTool(slug) },
174
+ })
175
+ }
176
+ },
177
+ onError: pinError(() => unpinTool(slug, offerUndo)),
178
+ },
179
+ )
180
+ const togglePin = (tool: CommunityTool) => {
181
+ if (!isAuthenticated) {
182
+ signInHere()
183
+ return
184
+ }
185
+ if (pinnedSlugs.has(tool.slug)) unpinTool(tool.slug)
186
+ else pinTool(tool.slug)
187
+ }
188
+
189
+ const toggleLike = (tool: CommunityTool) => {
190
+ if (!isAuthenticated) {
191
+ signInHere()
192
+ return
193
+ }
194
+ const save = tool.likedByMe ? unlike : like
195
+ save.mutate(
196
+ { slug: tool.slug },
197
+ {
198
+ onError: () =>
199
+ showToast({
200
+ tone: 'error',
201
+ text: 'Could not save the like. Check your connection.',
202
+ action: { label: 'Try again', run: () => toggleLike(tool) },
203
+ }),
204
+ },
205
+ )
206
+ }
207
+
208
+ // A failed save puts the tiles back (the pins refetch); say why they moved.
209
+ const reorderPins = (slugs: string[]) =>
210
+ reorder.mutate(
211
+ { slugs },
212
+ {
213
+ onError: () =>
214
+ showToast({
215
+ tone: 'error',
216
+ text: "Couldn't save the new order",
217
+ action: { label: 'Try again', run: () => reorderPins(slugs) },
218
+ }),
219
+ },
220
+ )
221
+
222
+ // Open: `{}` from Add a tool, the search text from "Add it".
223
+ const [adding, setAdding] = useState<{ prefillName?: string } | null>(null)
224
+ const startAdd = (prefillName?: string) => {
225
+ // Adding needs an identity: sign in, then come back to this page.
226
+ if (!isAuthenticated) signInHere()
227
+ else setAdding({ prefillName })
228
+ }
229
+
230
+ const openCard = (slug: string, replace = false) =>
231
+ navigate({
232
+ to: '/community/$slug',
233
+ params: { slug },
234
+ search: (prev) => prev,
235
+ replace,
236
+ })
237
+ const closeCard = () =>
238
+ void navigate({ to: '/community', search: (prev) => prev })
239
+
240
+ // The page column scrolls (the layout pins the header), not the list: the
241
+ // virtualizer needs where the list starts inside that column. Everything
242
+ // above it can change height — the shelf loads, hides while searching, goes
243
+ // from 2 to 4 columns at `md` — and all of that resizes the page, so one
244
+ // observer on it catches each case.
245
+ const scrollRef = useRef<HTMLDivElement>(null)
246
+ const pageRef = useRef<HTMLDivElement>(null)
247
+ const [listEl, setListEl] = useState<HTMLDivElement | null>(null)
248
+ const [scrollMargin, setScrollMargin] = useState(0)
249
+ useEffect(() => {
250
+ const scroller = scrollRef.current
251
+ const page = pageRef.current
252
+ if (!scroller || !page || !listEl) return
253
+ const measure = () =>
254
+ setScrollMargin(
255
+ listEl.getBoundingClientRect().top -
256
+ scroller.getBoundingClientRect().top +
257
+ scroller.scrollTop,
258
+ )
259
+ const observer = new ResizeObserver(measure)
260
+ observer.observe(page)
261
+ measure()
262
+ return () => observer.disconnect()
263
+ }, [listEl])
264
+ // No layout, no window (jsdom, a print preview): the column measures 0 tall,
265
+ // a virtualizer mounts nothing, so every row is rendered instead.
266
+ const [hasLayout, setHasLayout] = useState(true)
267
+ useLayoutEffect(() => {
268
+ setHasLayout((scrollRef.current?.clientHeight ?? 0) > 0)
269
+ }, [])
270
+ // Stable on purpose: the virtualizer keys its measurement cache on this
271
+ // function's identity, so a new arrow every render re-renders forever.
272
+ const getItemKey = useCallback(
273
+ (i: number) => items[i]?.tool.slug ?? i,
274
+ [items],
275
+ )
276
+ // TanStack Virtual hands back functions the React Compiler cannot memoize
277
+ // safely, so it skips this hook; registered in the root eslint.config.js.
278
+ // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual
279
+ const virtualizer = useVirtualizer({
280
+ getScrollElement: () => scrollRef.current,
281
+ // The column's height before it is measured, so the first paint already
282
+ // holds the rows that will be visible.
283
+ initialRect: { width: 0, height: 800 },
284
+ count: items.length,
285
+ // The thumbnail sets the row: 150 px, 12 px padding either side, borders.
286
+ estimateSize: () => 176,
287
+ overscan: 6,
288
+ gap: 8,
289
+ scrollMargin,
290
+ getItemKey,
291
+ })
292
+
293
+ // Keep the selected result on screen. The next row is almost always already
294
+ // mounted (overscan), and the browser scrolls it exactly; the virtualizer's
295
+ // own estimate-based scroll is only for a row that is not there yet.
296
+ useEffect(() => {
297
+ if (!searching || selectedIndex < 0) return
298
+ const row = listEl?.querySelector(`[data-index="${selectedIndex}"]`)
299
+ if (row) row.scrollIntoView({ block: 'nearest' })
300
+ else virtualizer.scrollToIndex(selectedIndex, { align: 'auto' })
301
+ }, [searching, selectedIndex, virtualizer, listEl])
302
+
303
+ const onSearchKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
304
+ const current = results[selectedIndex]
305
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
306
+ if (results.length === 0) return
307
+ e.preventDefault()
308
+ const step = e.key === 'ArrowDown' ? 1 : -1
309
+ setSelected(
310
+ Math.min(results.length - 1, Math.max(0, selectedIndex + step)),
311
+ )
312
+ } else if (e.key === 'Enter' && current) {
313
+ e.preventDefault()
314
+ openSite(current.tool.url)
315
+ } else if (
316
+ e.key === 'ArrowRight' &&
317
+ current &&
318
+ e.currentTarget.selectionStart === e.currentTarget.value.length
319
+ ) {
320
+ // Only at the end of the text, so → still moves the caret inside it.
321
+ e.preventDefault()
322
+ void openCard(current.tool.slug)
323
+ }
324
+ }
325
+
326
+ // Without layout every row is "virtual", at its natural place in the flow.
327
+ const virtualItems = hasLayout
328
+ ? virtualizer.getVirtualItems()
329
+ : items.map((_, index) => ({
330
+ key: items[index]!.tool.slug,
331
+ index,
332
+ start: virtualizer.options.scrollMargin,
333
+ }))
334
+
335
+ return (
336
+ <div
337
+ ref={scrollRef}
338
+ data-preview-scroller
339
+ className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]"
340
+ >
341
+ <div
342
+ ref={pageRef}
343
+ data-preview-column
344
+ className="mx-auto w-full max-w-[1000px] pt-1.5 pb-10"
345
+ >
346
+ <div className="mb-[22px] flex items-center gap-2.5">
347
+ <ToolSearch
348
+ value={query}
349
+ onChange={(value) => {
350
+ setQuery(value)
351
+ setSelected(0)
352
+ }}
353
+ resultCount={results.length}
354
+ resultsId={searching && results.length > 0 ? RESULTS_ID : undefined}
355
+ activeDescendant={activeOptionId}
356
+ onKey={onSearchKey}
357
+ />
358
+ <button
359
+ type="button"
360
+ onClick={() => startAdd()}
361
+ className="inline-flex h-10 shrink-0 items-center gap-[7px] rounded-[10px] border border-border bg-card px-3.5 text-[13.5px] font-bold whitespace-nowrap hover:border-primary/40 focus-visible:outline-2 focus-visible:outline-ring"
362
+ >
363
+ <Plus aria-hidden="true" className="size-4" />
364
+ Add a tool
365
+ </button>
366
+ </div>
367
+
368
+ {searching ? (
369
+ <div className="-mt-2 mb-3.5 ml-1 flex items-center gap-2 text-[13px] font-semibold text-muted-foreground">
370
+ <Star
371
+ aria-hidden="true"
372
+ className="size-4 fill-amber-400 text-amber-600"
373
+ />
374
+ My tools is hidden while you search
375
+ </div>
376
+ ) : (
377
+ <div className="mb-[26px]">
378
+ <MyToolsShelf
379
+ pins={pins.data ?? []}
380
+ isLoading={isAuthLoading || (isAuthenticated && pins.isPending)}
381
+ onUnpin={(tool) => togglePin(tool)}
382
+ onReorder={reorderPins}
383
+ />
384
+ </div>
385
+ )}
386
+
387
+ <section
388
+ aria-label={searching ? 'Search results' : feedHeading(sort, dir)}
389
+ data-testid={searching ? 'community-results' : 'community-feed'}
390
+ >
391
+ {!searching && (
392
+ <>
393
+ <div className="mb-3 flex items-center justify-between">
394
+ <h2 className="font-serif text-[19px] font-semibold">
395
+ {feedHeading(sort, dir)}
396
+ </h2>
397
+ <FeedSortControl
398
+ sort={sort}
399
+ dir={dir}
400
+ onChange={onSortChange}
401
+ />
402
+ </div>
403
+ <TagRow
404
+ tools={rankTools(tools, sort, dir)}
405
+ activeTag={tag}
406
+ appNames={appNames}
407
+ onChoose={onTagChange}
408
+ />
409
+ </>
410
+ )}
411
+
412
+ {list.isPending ? (
413
+ <FeedSkeleton />
414
+ ) : list.isError ? (
415
+ <FeedError onRetry={() => void list.refetch()} />
416
+ ) : searching && results.length === 0 ? (
417
+ <NoResults
418
+ query={query.trim()}
419
+ onAdd={() => startAdd(query.trim())}
420
+ />
421
+ ) : (
422
+ <div
423
+ ref={setListEl}
424
+ id={searching ? RESULTS_ID : undefined}
425
+ role={searching ? 'grid' : undefined}
426
+ aria-label={searching ? 'Search results' : undefined}
427
+ aria-activedescendant={activeOptionId}
428
+ className={hasLayout ? 'relative' : 'flex flex-col gap-2'}
429
+ style={
430
+ hasLayout ? { height: virtualizer.getTotalSize() } : undefined
431
+ }
432
+ >
433
+ {virtualItems.map((row) => {
434
+ const item = items[row.index]
435
+ if (!item) return null
436
+ return (
437
+ <div
438
+ key={row.key}
439
+ data-index={row.index}
440
+ ref={virtualizer.measureElement}
441
+ className={
442
+ hasLayout ? 'absolute inset-x-0 top-0' : undefined
443
+ }
444
+ style={
445
+ hasLayout
446
+ ? {
447
+ transform: `translateY(${row.start - virtualizer.options.scrollMargin}px)`,
448
+ }
449
+ : undefined
450
+ }
451
+ >
452
+ <FeedRow
453
+ tool={withMyLike(item.tool)}
454
+ rank={searching ? undefined : row.index + 1}
455
+ sort={sort}
456
+ tokens={tokens}
457
+ matched={item.matched}
458
+ selected={searching && row.index === selectedIndex}
459
+ optionId={
460
+ searching ? optionIdOf(item.tool.slug) : undefined
461
+ }
462
+ pinned={pinnedSlugs.has(item.tool.slug)}
463
+ pinBusy={pinBusySlug === item.tool.slug}
464
+ onTogglePin={() => togglePin(item.tool)}
465
+ onToggleLike={() => toggleLike(withMyLike(item.tool))}
466
+ />
467
+ </div>
468
+ )
469
+ })}
470
+ </div>
471
+ )}
472
+
473
+ {searching && results.length > 0 && (
474
+ <div className="mt-3.5 flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground">
475
+ <kbd className={KBD}>↑</kbd>
476
+ <kbd className={KBD}>↓</kbd>
477
+ <span className="mr-2.5">move</span>
478
+ <kbd className={KBD}>Enter</kbd>
479
+ <span className="mr-2.5">open site</span>
480
+ <kbd className={KBD}>→</kbd>
481
+ <span className="mr-2.5">open card</span>
482
+ <kbd className={KBD}>Esc</kbd>
483
+ <span>clear</span>
484
+ </div>
485
+ )}
486
+ </section>
487
+
488
+ <MarginPreview
489
+ list={listEl}
490
+ tools={tools}
491
+ disabled={!!cardSlug || !!adding}
492
+ />
493
+ {cardSlug && (
494
+ <ToolCard
495
+ slug={cardSlug}
496
+ siblings={items.map((item) => item.tool.slug)}
497
+ onClose={closeCard}
498
+ // Stepping swaps the card in place; Back still leaves the card.
499
+ onNavigate={(slug) => void openCard(slug, true)}
500
+ pinned={pinnedSlugs.has(cardSlug)}
501
+ pinBusy={pinBusySlug === cardSlug}
502
+ onTogglePin={togglePin}
503
+ appNames={appNames}
504
+ // One step: the card closes onto the feed already filtered.
505
+ onChooseTag={(chosen) =>
506
+ void navigate({
507
+ to: '/community',
508
+ search: (prev) => ({ ...prev, tag: chosen }),
509
+ })
510
+ }
511
+ />
512
+ )}
513
+ {adding && (
514
+ <ToolFormDialog
515
+ mode="add"
516
+ prefillName={adding.prefillName}
517
+ onClose={() => setAdding(null)}
518
+ onOpenCard={(slug) => {
519
+ setAdding(null)
520
+ void openCard(slug)
521
+ }}
522
+ onDone={(slug) => {
523
+ setAdding(null)
524
+ showToast({
525
+ tone: 'ok',
526
+ text: (
527
+ <>
528
+ <b>Live now</b>, in the review list
529
+ </>
530
+ ),
531
+ action: { label: 'Open card', run: () => void openCard(slug) },
532
+ })
533
+ }}
534
+ />
535
+ )}
536
+ {/* After the card, so a toast raised from it shows above its scrim. */}
537
+ <Toast toast={toast} onDismiss={dismissToast} />
538
+ </div>
539
+ </div>
540
+ )
541
+ }
542
+
543
+ /** Three placeholder rows at the real row height. */
544
+ function FeedSkeleton() {
545
+ return (
546
+ <div
547
+ data-testid="feed-skeleton"
548
+ aria-busy="true"
549
+ className="flex flex-col gap-2"
550
+ >
551
+ {[0, 1, 2].map((i) => (
552
+ <div
553
+ key={i}
554
+ className="flex min-h-[176px] items-center gap-4 rounded-[14px] border border-border bg-card py-3 pl-3"
555
+ >
556
+ <Skeleton className="h-[22px] w-[26px]" />
557
+ <Skeleton className={ROW_THUMB} />
558
+ <div className="flex flex-1 flex-col gap-2">
559
+ <Skeleton className="h-2.5 w-2/5" />
560
+ <Skeleton className="h-2.5 w-4/5" />
561
+ <Skeleton className="h-2.5 w-1/4" />
562
+ </div>
563
+ {/* The Details panel's reserved width. */}
564
+ <div className="w-[120px] shrink-0" />
565
+ </div>
566
+ ))}
567
+ </div>
568
+ )
569
+ }
570
+
571
+ function FeedError({ onRetry }: { onRetry: () => void }) {
572
+ return (
573
+ <div
574
+ data-testid="feed-error"
575
+ role="alert"
576
+ className="flex items-center gap-3.5 rounded-[14px] border border-destructive/30 bg-destructive/5 px-5 py-[18px]"
577
+ >
578
+ <AlertTriangle
579
+ aria-hidden="true"
580
+ className="size-[22px] shrink-0 text-destructive"
581
+ />
582
+ <div>
583
+ <b className="font-bold">Could not load community tools.</b>
584
+ <p className="mt-0.5 text-muted-foreground">
585
+ The server did not answer. Your pinned tools above still work.
586
+ </p>
587
+ </div>
588
+ <button
589
+ type="button"
590
+ onClick={onRetry}
591
+ className="ml-auto inline-flex h-10 shrink-0 items-center gap-[7px] rounded-[10px] border border-border bg-card px-3.5 text-[13.5px] font-bold hover:border-primary/40 focus-visible:outline-2 focus-visible:outline-ring"
592
+ >
593
+ <RefreshCw aria-hidden="true" className="size-4" />
594
+ Try again
595
+ </button>
596
+ </div>
597
+ )
598
+ }
599
+
600
+ function NoResults({ query, onAdd }: { query: string; onAdd: () => void }) {
601
+ return (
602
+ <div
603
+ data-testid="no-results"
604
+ className="flex gap-3.5 rounded-[14px] border border-border bg-card px-5 py-[18px]"
605
+ >
606
+ <Search
607
+ aria-hidden="true"
608
+ className="mt-0.5 size-5 shrink-0 text-muted-foreground"
609
+ />
610
+ <div>
611
+ <b className="font-bold">Nothing matches “{query}”.</b>
612
+ <p className="mt-0.5 text-muted-foreground">
613
+ Try a word from the page itself, or the name of the person who shared
614
+ it. Know the tool?{' '}
615
+ <button
616
+ type="button"
617
+ onClick={onAdd}
618
+ className="font-bold text-primary hover:underline focus-visible:outline-2 focus-visible:outline-ring"
619
+ >
620
+ Add it
621
+ </button>
622
+ </p>
623
+ </div>
624
+ </div>
625
+ )
626
+ }