@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
@@ -6,12 +6,13 @@ import { cn } from '~/lib/utils'
6
6
  import { Button } from '~/ui/button'
7
7
  import {
8
8
  attachmentUrl,
9
+ draftSlugOf,
9
10
  readAttachments,
10
11
  readDraft,
11
12
  writeAttachments,
12
13
  writeDraft,
13
14
  } from './communityDraft'
14
- import type { PendingImage } from './communityDraft'
15
+ import type { CommentTarget, PendingImage } from './communityDraft'
15
16
  import { formatCommunityError } from './communityError'
16
17
 
17
18
  const MAX_HEIGHT = 200
@@ -71,20 +72,21 @@ async function upload(files: File[]): Promise<string[] | string> {
71
72
  * paperclip, paste or drop, and a screenshot alone is a valid note.
72
73
  */
73
74
  export function CommunityInput({
74
- appSlug,
75
+ target,
75
76
  appName,
76
77
  ref,
77
78
  }: {
78
- appSlug: string
79
+ target: CommentTarget
79
80
  appName: string
80
81
  ref?: React.Ref<CommunityInputHandle>
81
82
  }) {
82
83
  const trpc = useTRPC()
83
84
  const queryClient = useQueryClient()
85
+ const draftSlug = draftSlugOf(target)
84
86
  // Seeded from storage so a draft survives the tab panel unmounting under it.
85
- const [text, setText] = React.useState(() => readDraft(appSlug))
87
+ const [text, setText] = React.useState(() => readDraft(draftSlug))
86
88
  const [images, setImages] = React.useState<PendingImage[]>(() =>
87
- readAttachments(appSlug),
89
+ readAttachments(draftSlug),
88
90
  )
89
91
  const [sent, setSent] = React.useState(false)
90
92
  // Counts successful sends, so a click on Send hands focus back every time.
@@ -121,8 +123,11 @@ export function CommunityInput({
121
123
  return () => mq.removeEventListener('change', handler)
122
124
  }, [])
123
125
 
124
- React.useEffect(() => writeDraft(appSlug, text), [appSlug, text])
125
- React.useEffect(() => writeAttachments(appSlug, images), [appSlug, images])
126
+ React.useEffect(() => writeDraft(draftSlug, text), [draftSlug, text])
127
+ React.useEffect(
128
+ () => writeAttachments(draftSlug, images),
129
+ [draftSlug, images],
130
+ )
126
131
 
127
132
  // The class goes on the form, whose className React never rewrites, and
128
133
  // is removed and re-added with a reflow between so a second call restarts
@@ -153,7 +158,7 @@ export function CommunityInput({
153
158
  setSent(true)
154
159
  setSends((n) => n + 1)
155
160
  void queryClient.invalidateQueries({
156
- queryKey: trpc.feedback.list.queryKey({ resourceSlug: appSlug }),
161
+ queryKey: trpc.feedback.list.queryKey(target),
157
162
  })
158
163
  },
159
164
  }),
@@ -227,7 +232,7 @@ export function CommunityInput({
227
232
  // Merge into both state and persisted draft so unmount restores it.
228
233
  setImages((current) => {
229
234
  const updated = [...current, ...newImages].slice(0, MAX_ATTACHMENTS)
230
- writeAttachments(appSlug, updated)
235
+ writeAttachments(draftSlug, updated)
231
236
  return updated
232
237
  })
233
238
  if (files.length > room)
@@ -242,7 +247,7 @@ export function CommunityInput({
242
247
  if (!canSend || add.isPending || uploading) return
243
248
  const body = text.trim()
244
249
  add.mutate({
245
- resourceSlug: appSlug,
250
+ ...target,
246
251
  ...(body ? { body } : {}),
247
252
  ...(images.length > 0 ? { attachmentIds: images.map((i) => i.id) } : {}),
248
253
  })
@@ -3,6 +3,7 @@ import React from 'react'
3
3
  import { CommentsFeed } from './CommentsFeed'
4
4
  import { CommentsHelp } from './CommentsHelp'
5
5
  import { CommunityInput } from './CommunityInput'
6
+ import type { CommentTarget } from './communityDraft'
6
7
  import type { CommunityInputHandle } from './CommunityInput'
7
8
 
8
9
  export type CommunitySectionHandle = CommunityInputHandle
@@ -15,12 +16,13 @@ const NO_CLAIMS: Claim[] = []
15
16
  * as one feed.
16
17
  */
17
18
  export function CommunitySection({
18
- appSlug,
19
+ target,
19
20
  appName,
20
21
  claims = NO_CLAIMS,
21
22
  ref,
22
23
  }: {
23
- appSlug: string
24
+ target: CommentTarget
25
+ /** The entry's or the tool's name, for the input's label. */
24
26
  appName: string
25
27
  claims?: Claim[]
26
28
  ref?: React.Ref<CommunitySectionHandle>
@@ -28,14 +30,17 @@ export function CommunitySection({
28
30
  const headingId = React.useId()
29
31
  return (
30
32
  <section className="behind-group">
31
- <CommunityInput ref={ref} appSlug={appSlug} appName={appName} />
33
+ <CommunityInput ref={ref} target={target} appName={appName} />
32
34
  <div className="comments-head">
33
35
  <h2 id={headingId} className="behind-group__title">
34
36
  Comments
35
37
  </h2>
36
- <CommentsHelp appSlug={appSlug} appName={appName} />
38
+ <CommentsHelp
39
+ appSlug={'resourceSlug' in target ? target.resourceSlug : undefined}
40
+ appName={appName}
41
+ />
37
42
  </div>
38
- <CommentsFeed appSlug={appSlug} claims={claims} labelledBy={headingId} />
43
+ <CommentsFeed target={target} claims={claims} labelledBy={headingId} />
39
44
  </section>
40
45
  )
41
46
  }
@@ -78,3 +78,19 @@ export function writeAttachments(slug: string, images: PendingImage[]): void {
78
78
  export function attachmentUrl(id: string): string {
79
79
  return `/api/feedback-attachments/${id}`
80
80
  }
81
+
82
+ /** What a comment is about: an official catalog entry, or a community tool. */
83
+ export type CommentTarget =
84
+ | { resourceSlug: string }
85
+ | { communityToolSlug: string }
86
+
87
+ /**
88
+ * The draft's storage slug. An entry keeps its bare slug, so drafts written
89
+ * before tools had comments still load; a tool's is prefixed, so a tool and an
90
+ * entry that share a slug never share a draft.
91
+ */
92
+ export function draftSlugOf(target: CommentTarget): string {
93
+ return 'resourceSlug' in target
94
+ ? target.resourceSlug
95
+ : `tool:${target.communityToolSlug}`
96
+ }
@@ -23,6 +23,7 @@ export interface CommentEntry {
23
23
  const FEEDBACK_ORIGIN = 'feedback:'
24
24
 
25
25
  function noteBadge(note: Note, linked: Claim[]): CommentBadge {
26
+ if (note.isSystem) return null
26
27
  if (note.status === null) return 'pending'
27
28
  if (linked.some((c) => c.status === 'review')) return 'pending'
28
29
  if (note.status === 'acknowledged') return null
@@ -0,0 +1,18 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { useTRPC } from '~/api/infra/trpc'
3
+
4
+ /**
5
+ * How many live community tools are tagged with this app. One query for the
6
+ * whole catalog, so switching cards asks nothing new. Zero while loading, and
7
+ * when the deployment has community tools turned off.
8
+ */
9
+ export function useCommunityToolCount(appSlug: string): number {
10
+ const trpc = useTRPC()
11
+ const { data: config } = useQuery(trpc.communityTools.config.queryOptions())
12
+ const { data: counts } = useQuery(
13
+ trpc.communityTools.countByApp.queryOptions(undefined, {
14
+ enabled: config?.enabled === true,
15
+ }),
16
+ )
17
+ return counts?.[appSlug] ?? 0
18
+ }
@@ -3,7 +3,7 @@ import type { QueryClient } from '@tanstack/react-query'
3
3
  import type { TRPCClient } from '@trpc/client'
4
4
  import { AppCatalogProvider } from '~/modules/appCatalog/context/AppCatalogContext'
5
5
  import { AppCatalogFiltersProvider } from '~/modules/appCatalog/ui/context/AppCatalogFiltersContext'
6
- import { ViewToggle } from '~/modules/appCatalog/ui/components/ViewToggle'
6
+ import { SectionTabs } from '~/modules/appCatalog/ui/components/SectionTabs'
7
7
  import { MainLayout } from '~/ui/layout/MainLayout'
8
8
  import { TopLevelProviders } from '~/ui/layout/TopLevelProviders'
9
9
 
@@ -24,7 +24,7 @@ export function AppCatalogLayout({
24
24
  <TopLevelProviders queryClient={queryClient} trpcClient={trpcClient}>
25
25
  <AppCatalogProvider>
26
26
  <AppCatalogFiltersProvider>
27
- <MainLayout headerMiddle={headerMiddle ?? <ViewToggle />}>
27
+ <MainLayout headerMiddle={headerMiddle ?? <SectionTabs />}>
28
28
  {/* <Breadcrumb className="pb-4">*/}
29
29
  {/* <BreadcrumbList>*/}
30
30
  {/* <BreadcrumbItem>*/}
@@ -56,11 +56,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
56
56
  if (response.ok) {
57
57
  const data = await response.json()
58
58
  setSession(data)
59
- setUser(data?.user || null)
60
- if (data?.user) {
59
+ const userWithAdmin = data?.user
60
+ ? {
61
+ ...data.user,
62
+ isAdmin: data.isAdmin ?? data.user.isAdmin ?? false,
63
+ }
64
+ : null
65
+ setUser(userWithAdmin)
66
+ if (userWithAdmin) {
61
67
  localStorage.setItem(
62
68
  AUTH_USER_STORAGE_KEY,
63
- JSON.stringify(data.user),
69
+ JSON.stringify(userWithAdmin),
64
70
  )
65
71
  } else {
66
72
  localStorage.removeItem(AUTH_USER_STORAGE_KEY)
@@ -14,7 +14,7 @@ import { LoginPage } from './LoginPage'
14
14
  * Can be opened from anywhere using useAuthModal hook
15
15
  */
16
16
  export function LoginModal() {
17
- const { isOpen, close } = useAuthModal()
17
+ const { isOpen, close, redirectUrl } = useAuthModal()
18
18
 
19
19
  return (
20
20
  <Dialog
@@ -25,7 +25,12 @@ export function LoginModal() {
25
25
  }
26
26
  }}
27
27
  >
28
- <DialogContent className="w-full max-w-md">
28
+ <DialogContent
29
+ className="w-full max-w-md"
30
+ data-testid="login-modal"
31
+ // Where the provider returns the visitor; readable in tests and devtools.
32
+ data-return-to={redirectUrl}
33
+ >
29
34
  <DialogHeader>
30
35
  <DialogTitle>Sign In</DialogTitle>
31
36
  <DialogDescription>
@@ -0,0 +1,32 @@
1
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
2
+
3
+ /** How many likers the tooltip names; the rest are a count. */
4
+ const NAMED = 8
5
+
6
+ /**
7
+ * "Liked by you, A, B and 3 others": you first, then up to eight colleagues,
8
+ * newest first, then how many more.
9
+ */
10
+ export function likedByText(
11
+ tool: Pick<CommunityTool, 'likes' | 'likedByMe' | 'likedBy'>,
12
+ ): string {
13
+ if (tool.likes === 0) return 'No likes yet'
14
+ const names = [
15
+ ...(tool.likedByMe ? ['you'] : []),
16
+ ...tool.likedBy.slice(0, NAMED),
17
+ ]
18
+ const others = tool.likes - names.length
19
+ if (others > 0) {
20
+ return `Liked by ${names.join(', ')} and ${others} ${others === 1 ? 'other' : 'others'}`
21
+ }
22
+ const last = names.pop()
23
+ return names.length > 0
24
+ ? `Liked by ${names.join(', ')} and ${last}`
25
+ : `Liked by ${last}`
26
+ }
27
+
28
+ /** The tool as it reads once I liked it, or took the like back. */
29
+ export function withLike(tool: CommunityTool, liked: boolean): CommunityTool {
30
+ if (tool.likedByMe === liked) return tool
31
+ return { ...tool, likedByMe: liked, likes: tool.likes + (liked ? 1 : -1) }
32
+ }
@@ -0,0 +1,16 @@
1
+ const DAY_MS = 24 * 60 * 60 * 1000
2
+
3
+ /** How long ago, in calendar days and up: "today", "3 days ago", "2 months ago". */
4
+ export function timeAgo(iso: string, now: Date = new Date()): string {
5
+ const day = (d: Date) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
6
+ const days = Math.max(0, Math.round((day(now) - day(new Date(iso))) / DAY_MS))
7
+ if (days === 0) return 'today'
8
+ if (days === 1) return 'yesterday'
9
+ if (days < 31) return `${days} days ago`
10
+ if (days < 365) {
11
+ const months = Math.floor(days / 30)
12
+ return months === 1 ? '1 month ago' : `${months} months ago`
13
+ }
14
+ const years = Math.floor(days / 365)
15
+ return years === 1 ? '1 year ago' : `${years} years ago`
16
+ }
@@ -0,0 +1,124 @@
1
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
2
+ import { Camera, ExternalLink, ImageOff, Maximize2 } from 'lucide-react'
3
+ import { useState } from 'react'
4
+ import { ScreenshotGallery } from '~/modules/appCatalog/ui/components/ScreenshotGallery'
5
+ import { cn } from '~/lib/utils'
6
+ import { screenshotUrl } from './FeedRow'
7
+
8
+ /**
9
+ * The 16:10 box every state shares, so swapping one for another moves nothing.
10
+ * Capped in height, so how to use it and who to ask stay above the fold; the
11
+ * top of the page, where its title is, is what shows.
12
+ */
13
+ const BOX =
14
+ 'relative flex aspect-[16/10] max-h-[320px] w-full items-center justify-center overflow-hidden rounded-[14px] border border-border bg-muted shadow-sm'
15
+
16
+ export function formatCardDate(iso: string): string {
17
+ return new Date(iso).toLocaleDateString('en-GB', {
18
+ day: 'numeric',
19
+ month: 'short',
20
+ year: 'numeric',
21
+ })
22
+ }
23
+
24
+ /**
25
+ * The tool's screenshot, full width: a click opens the site, like the title
26
+ * block, and the corner button enlarges it in the screenshot viewer. A tool
27
+ * with none yet shows a placeholder in the same box, not a link: "still
28
+ * capturing" while its first visit is to come, otherwise "no screenshot".
29
+ */
30
+ export function CardScreenshot({
31
+ tool,
32
+ capturing,
33
+ }: {
34
+ tool: CommunityTool
35
+ /** A first screenshot is on its way and still worth waiting for. */
36
+ capturing: boolean
37
+ }) {
38
+ const [open, setOpen] = useState(false)
39
+ const { screenshotId, capturedAt } = tool
40
+
41
+ if (!screenshotId) {
42
+ return (
43
+ <figure data-testid="card-screenshot" className="mt-[22px]">
44
+ <div
45
+ data-testid="card-screenshot-box"
46
+ data-state={capturing ? 'capturing' : 'missing'}
47
+ aria-busy={capturing || undefined}
48
+ className={cn(
49
+ BOX,
50
+ 'flex-col gap-2.5 px-6 text-center text-sm font-semibold text-muted-foreground',
51
+ )}
52
+ >
53
+ {capturing ? (
54
+ <>
55
+ <Camera aria-hidden="true" className="size-7 animate-pulse" />
56
+ Taking a screenshot of the site…
57
+ </>
58
+ ) : (
59
+ <>
60
+ <ImageOff aria-hidden="true" className="size-7" />
61
+ No screenshot. The site may need a login, or it did not load.
62
+ </>
63
+ )}
64
+ </div>
65
+ <figcaption className="mt-2 text-[12.5px] font-semibold text-muted-foreground">
66
+ {capturing ? 'Usually under a minute' : 'Open the site to see it'}
67
+ </figcaption>
68
+ </figure>
69
+ )
70
+ }
71
+
72
+ return (
73
+ <figure data-testid="card-screenshot" className="mt-[22px]">
74
+ <div
75
+ data-testid="card-screenshot-box"
76
+ data-state="captured"
77
+ className={BOX}
78
+ >
79
+ <a
80
+ href={tool.url}
81
+ target="_blank"
82
+ rel="noopener noreferrer"
83
+ aria-label={`Open ${tool.name} in a new tab`}
84
+ data-testid="card-screenshot-link"
85
+ className="group/shot block size-full rounded-[14px] focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring"
86
+ >
87
+ <img
88
+ src={screenshotUrl(screenshotId, 1280)}
89
+ alt={`Screenshot of ${tool.name}`}
90
+ className="size-full object-cover object-left-top"
91
+ />
92
+ <span className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-focus-visible/shot:bg-black/20 group-hover/shot:bg-black/20">
93
+ <span className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3.5 py-2 text-sm font-extrabold text-primary-foreground opacity-0 shadow-sm transition-opacity group-focus-visible/shot:opacity-100 group-hover/shot:opacity-100">
94
+ Open site
95
+ <ExternalLink aria-hidden="true" className="size-4" />
96
+ </span>
97
+ </span>
98
+ </a>
99
+ {/* Beside the link, not in it: enlarging never opens the site. */}
100
+ <button
101
+ type="button"
102
+ aria-label={`Enlarge screenshot of ${tool.name}`}
103
+ title="Enlarge"
104
+ onClick={() => setOpen(true)}
105
+ className="absolute top-3 right-3 inline-flex size-8 items-center justify-center rounded-lg bg-black/65 text-white hover:bg-black/80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
106
+ >
107
+ <Maximize2 aria-hidden="true" className="size-4" />
108
+ </button>
109
+ </div>
110
+ {capturedAt && (
111
+ <figcaption className="mt-2 text-[12.5px] font-semibold text-muted-foreground">
112
+ Screenshot captured {formatCardDate(capturedAt)}
113
+ </figcaption>
114
+ )}
115
+ <ScreenshotGallery
116
+ app={{ displayName: tool.name }}
117
+ screenshotIds={[screenshotId]}
118
+ open={open}
119
+ onOpenChange={setOpen}
120
+ title={`${tool.name} screenshot`}
121
+ />
122
+ </figure>
123
+ )
124
+ }
@@ -0,0 +1,58 @@
1
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
2
+ import { ExternalLink, Globe } from 'lucide-react'
3
+ import { cn } from '~/lib/utils'
4
+ import { screenshotUrl } from './FeedRow'
5
+ import { LifecycleBadge, UnreviewedBadge } from './LifecycleBadge'
6
+
7
+ /**
8
+ * The card's door: favicon (a globe when the site has none), name, badges and
9
+ * address, all one link that opens the site in a new tab.
10
+ */
11
+ export function CardTitleBlock({ tool }: { tool: CommunityTool }) {
12
+ const archived = tool.lifecycle.state === 'archived'
13
+ return (
14
+ <a
15
+ href={tool.url}
16
+ target="_blank"
17
+ rel="noopener noreferrer"
18
+ data-testid="card-title-block"
19
+ className={cn(
20
+ 'group/door inline-flex min-w-0 items-center gap-[15px] rounded-[14px] border border-border bg-primary/[0.025] px-[17px] py-[11px] transition-colors',
21
+ 'hover:border-primary/40 hover:bg-primary/[0.07] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
22
+ archived && 'opacity-70 grayscale',
23
+ )}
24
+ >
25
+ <span className="inline-flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-card text-muted-foreground">
26
+ {tool.faviconId ? (
27
+ <img
28
+ src={screenshotUrl(tool.faviconId, 64)}
29
+ alt=""
30
+ className="size-7"
31
+ />
32
+ ) : (
33
+ <Globe aria-hidden="true" className="size-6" strokeWidth={1.75} />
34
+ )}
35
+ </span>
36
+ <span className="flex min-w-0 flex-col gap-0.5">
37
+ <span className="flex flex-wrap items-center gap-x-3 gap-y-1">
38
+ <span
39
+ data-testid="card-tool-name"
40
+ className="font-serif text-[26px] leading-tight font-semibold"
41
+ >
42
+ {tool.name}
43
+ </span>
44
+ <LifecycleBadge lifecycle={tool.lifecycle} />
45
+ {!tool.reviewed && <UnreviewedBadge />}
46
+ </span>
47
+ <span className="truncate font-mono text-[13px] text-muted-foreground">
48
+ {tool.host}
49
+ </span>
50
+ <span className="sr-only">(opens in a new tab)</span>
51
+ </span>
52
+ <ExternalLink
53
+ aria-hidden="true"
54
+ className="size-[18px] shrink-0 self-start text-muted-foreground group-hover/door:text-primary"
55
+ />
56
+ </a>
57
+ )
58
+ }