@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,519 @@
1
+ import type { Claim } from '@igstack/app-catalog-backend-core'
2
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import {
5
+ Archive,
6
+ ArrowLeft,
7
+ ArrowRight,
8
+ Check,
9
+ Eye,
10
+ Pencil,
11
+ } from 'lucide-react'
12
+ import { useEffect, useId, useRef, useState } from 'react'
13
+ import { useTRPC } from '~/api/infra/trpc'
14
+ import {
15
+ DIALOG_ROUND_BUTTON,
16
+ DetailDialogShell,
17
+ } from '~/modules/appCatalog/ui/catalog/DetailDialogShell'
18
+ import { MarkdownText } from '~/modules/appCatalog/ui/components/MarkdownText'
19
+ import { CommentsFeed } from '~/modules/appCatalog/ui/detail/CommentsFeed'
20
+ import { CommentsHelp } from '~/modules/appCatalog/ui/detail/CommentsHelp'
21
+ import { CommunityInput } from '~/modules/appCatalog/ui/detail/CommunityInput'
22
+ import type { CommunityInputHandle } from '~/modules/appCatalog/ui/detail/CommunityInput'
23
+ import { cn } from '~/lib/utils'
24
+ import { useAuth } from '~/modules/auth'
25
+ import { Skeleton } from '~/ui/skeleton'
26
+ import { CardScreenshot, formatCardDate } from './CardScreenshot'
27
+ import { CardTitleBlock } from './CardTitleBlock'
28
+ import { PersonAvatar } from './FeedRow'
29
+ import { useTimeUp } from '../useTimeUp'
30
+ import { PinButton } from './PinButton'
31
+ import { TagChip, tagKey, tagLabel } from './TagRow'
32
+ import { ArchiveConfirm, GHOST_BUTTON, ToolFormDialog } from './ToolFormDialog'
33
+
34
+ const KBD =
35
+ 'rounded-[5px] border border-b-2 border-border bg-card px-1.5 py-px text-[11px] font-semibold text-muted-foreground'
36
+
37
+ const SECTION = 'mt-6 border-t border-border pt-[22px]'
38
+
39
+ const LABEL =
40
+ 'text-[11.5px] font-bold tracking-wider text-muted-foreground uppercase'
41
+
42
+ const NO_CLAIMS: Claim[] = []
43
+
44
+ const DAY_MS = 24 * 60 * 60 * 1000
45
+
46
+ /** How often a card whose first screenshot is on its way asks again. */
47
+ const CAPTURE_POLL_MS = 5000
48
+
49
+ /** How long one open card waits for a first screenshot before it stops asking. */
50
+ const CAPTURE_WAIT_MS = 2 * 60 * 1000
51
+
52
+ export interface ToolCardProps {
53
+ slug: string
54
+ /** The list the card was opened from, in its order: ←/→ step through it. */
55
+ siblings: string[]
56
+ onClose: () => void
57
+ onNavigate: (slug: string) => void
58
+ pinned: boolean
59
+ /** Its pin is being saved. */
60
+ pinBusy?: boolean
61
+ onTogglePin: (tool: CommunityTool) => void
62
+ /** Display names of official apps by slug, for app tags. */
63
+ appNames: Record<string, string>
64
+ /** A tag chip on the card: close it and filter the feed to that tag. */
65
+ onChooseTag: (tag: string) => void
66
+ }
67
+
68
+ /** Typing belongs to the field: the card's keys stay out of it. */
69
+ function isTyping(target: HTMLElement): boolean {
70
+ return (
71
+ target.isContentEditable ||
72
+ target.closest('input, textarea, select') !== null
73
+ )
74
+ }
75
+
76
+ /**
77
+ * A community tool over the Community page, in the official card's shell: no
78
+ * tabs, one scroll. Title and screenshot to recognise it, how to use it, who
79
+ * to ask, and the comments.
80
+ */
81
+ export function ToolCard({
82
+ slug,
83
+ siblings,
84
+ onClose,
85
+ onNavigate,
86
+ pinned,
87
+ pinBusy,
88
+ onTogglePin,
89
+ appNames,
90
+ onChooseTag,
91
+ }: ToolCardProps) {
92
+ const { user } = useAuth()
93
+ const isAdmin = user?.isAdmin === true
94
+ const trpc = useTRPC()
95
+ const queryClient = useQueryClient()
96
+ // The feed already holds every tool, so the card paints at once from it
97
+ // and the tool's own query only refreshes it.
98
+ const list = useQuery(trpc.communityTools.list.queryOptions())
99
+ const fromList = list.data?.find((t) => t.slug === slug)
100
+ // Whatever the server says, an open card stops waiting after a while: a
101
+ // capture that never reports back must not keep it asking forever.
102
+ const gaveUpOnCapture = useTimeUp(slug, CAPTURE_WAIT_MS)
103
+ const get = useQuery({
104
+ ...trpc.communityTools.get.queryOptions({ slug }),
105
+ placeholderData: fromList,
106
+ // Until the first visit lands, keep asking, so the screenshot replaces
107
+ // the placeholder without a reload.
108
+ refetchInterval: (query) =>
109
+ query.state.data?.capturePending && !gaveUpOnCapture
110
+ ? CAPTURE_POLL_MS
111
+ : false,
112
+ })
113
+ const tool = get.data
114
+
115
+ // One view per open. The ref keeps StrictMode's second effect from counting
116
+ // twice; stepping to another tool is another open.
117
+ const [counts, setCounts] = useState<{
118
+ slug: string
119
+ views30d: number
120
+ viewsAll: number
121
+ } | null>(null)
122
+ // The hook's own onSuccess, not one passed to `mutate`: StrictMode's
123
+ // remount detaches the observer a `mutate` callback hangs on, so that one
124
+ // never fires and the count stayed one short.
125
+ const { mutate: recordView } = useMutation(
126
+ trpc.communityTools.recordView.mutationOptions({
127
+ onSuccess: (c, { slug: viewed }) => {
128
+ setCounts({ slug: viewed, ...c })
129
+ },
130
+ }),
131
+ )
132
+ const recordedForRef = useRef<string | null>(null)
133
+ useEffect(() => {
134
+ if (recordedForRef.current === slug) return
135
+ recordedForRef.current = slug
136
+ recordView({ slug })
137
+ }, [slug, recordView])
138
+ const views = counts?.slug === slug ? counts : tool
139
+
140
+ const [editing, setEditing] = useState(false)
141
+ const [archiving, setArchiving] = useState(false)
142
+ const refreshTools = () =>
143
+ queryClient.invalidateQueries({
144
+ queryKey: trpc.communityTools.pathKey(),
145
+ })
146
+ const approve = useMutation(
147
+ trpc.communityTools.approve.mutationOptions({ onSuccess: refreshTools }),
148
+ )
149
+ const archive = useMutation(
150
+ trpc.communityTools.archive.mutationOptions({
151
+ onSuccess: () => {
152
+ setArchiving(false)
153
+ return refreshTools()
154
+ },
155
+ }),
156
+ )
157
+ const adminError = approve.error ?? archive.error
158
+
159
+ const commentsRef = useRef<CommunityInputHandle>(null)
160
+ const commentsHeadingId = useId()
161
+ const commentTarget = { communityToolSlug: slug }
162
+ const comments = useQuery(trpc.feedback.list.queryOptions(commentTarget))
163
+ const commentCount = comments.data?.items.length ?? 0
164
+
165
+ const index = siblings.indexOf(slug)
166
+ const prev = index > 0 ? siblings[index - 1] : undefined
167
+ const next = index >= 0 ? siblings[index + 1] : undefined
168
+ const nameOf = (s: string) => list.data?.find((t) => t.slug === s)?.name ?? s
169
+
170
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
171
+ // React bubbles through portals: keys in the screenshot viewer are not ours.
172
+ const target = e.target as HTMLElement
173
+ if (!e.currentTarget.contains(target)) return
174
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
175
+ if (isTyping(target)) return
176
+ if (e.key === 'ArrowLeft' && prev) {
177
+ e.preventDefault()
178
+ onNavigate(prev)
179
+ } else if (e.key === 'ArrowRight' && next) {
180
+ e.preventDefault()
181
+ onNavigate(next)
182
+ } else if (e.key === 'Enter' && tool) {
183
+ // A link or button answers Enter itself; the title block is the link
184
+ // that opens the site.
185
+ if (target.closest('a, button')) return
186
+ e.preventDefault()
187
+ window.open(tool.url, '_blank', 'noopener,noreferrer')
188
+ }
189
+ }
190
+
191
+ const controls = index >= 0 && siblings.length > 1 && (
192
+ <>
193
+ <span className="flex items-center gap-1 text-[13px] font-semibold text-muted-foreground">
194
+ <button
195
+ type="button"
196
+ aria-label={prev ? `Previous tool: ${nameOf(prev)}` : 'Previous tool'}
197
+ disabled={!prev}
198
+ onClick={() => prev && onNavigate(prev)}
199
+ className={cn(DIALOG_ROUND_BUTTON, 'disabled:opacity-40')}
200
+ >
201
+ <ArrowLeft className="size-4" />
202
+ </button>
203
+ <span data-testid="card-position">
204
+ {index + 1} of {siblings.length}
205
+ </span>
206
+ <button
207
+ type="button"
208
+ aria-label={next ? `Next tool: ${nameOf(next)}` : 'Next tool'}
209
+ disabled={!next}
210
+ onClick={() => next && onNavigate(next)}
211
+ className={cn(DIALOG_ROUND_BUTTON, 'disabled:opacity-40')}
212
+ >
213
+ <ArrowRight className="size-4" />
214
+ </button>
215
+ </span>
216
+ <i aria-hidden="true" className="mx-1 h-5 w-px bg-border" />
217
+ </>
218
+ )
219
+
220
+ return (
221
+ <DetailDialogShell
222
+ label={`${tool?.name ?? 'Community tool'} details`}
223
+ shownKey={slug}
224
+ maxWidthClass="max-w-[min(920px,94vw)]"
225
+ focusSelector='[data-testid="card-title-block"]'
226
+ controls={controls}
227
+ testId="tool-card"
228
+ onClose={onClose}
229
+ onKeyDown={onKeyDown}
230
+ >
231
+ {tool ? (
232
+ <>
233
+ <div className="border-b border-border pb-[22px]">
234
+ <div className="flex items-end justify-between gap-4 pr-44">
235
+ <CardTitleBlock tool={tool} />
236
+ <div className="-mr-40 flex shrink-0 items-center gap-2">
237
+ {tool.canEdit && (
238
+ <button
239
+ type="button"
240
+ data-testid="card-edit-button"
241
+ onClick={() => setEditing(true)}
242
+ className={cn(GHOST_BUTTON, 'h-10 px-3.5 text-[13.5px]')}
243
+ >
244
+ <Pencil aria-hidden="true" className="size-4" />
245
+ Edit
246
+ </button>
247
+ )}
248
+ <PinButton
249
+ labelled
250
+ pinned={pinned}
251
+ busy={pinBusy}
252
+ name={tool.name}
253
+ onToggle={() => onTogglePin(tool)}
254
+ />
255
+ </div>
256
+ </div>
257
+ <div
258
+ className={cn(
259
+ tool.lifecycle.state === 'archived' && 'opacity-60',
260
+ )}
261
+ >
262
+ {tool.summary && (
263
+ <p className="mt-3.5 text-[15px] text-foreground/80">
264
+ {tool.summary}
265
+ </p>
266
+ )}
267
+ {tool.tags.length > 0 && (
268
+ <div
269
+ data-testid="card-tags"
270
+ className="mt-2.5 flex flex-wrap gap-1.5"
271
+ >
272
+ {tool.tags.map((tag) => (
273
+ <TagChip
274
+ key={tagKey(tag)}
275
+ label={tagLabel(tag, appNames)}
276
+ onClick={() => onChooseTag(tagKey(tag))}
277
+ />
278
+ ))}
279
+ </div>
280
+ )}
281
+ {views && (
282
+ <p
283
+ data-testid="card-views"
284
+ className="mt-2.5 flex gap-[18px] text-[13px] font-semibold text-muted-foreground"
285
+ >
286
+ <span className="inline-flex items-center gap-1.5">
287
+ <Eye aria-hidden="true" className="size-[15px]" />
288
+ <b className="text-foreground">
289
+ {views.views30d.toLocaleString('en-US')}
290
+ </b>{' '}
291
+ opens this month
292
+ </span>
293
+ <span>
294
+ <b className="text-foreground">
295
+ {views.viewsAll.toLocaleString('en-US')}
296
+ </b>{' '}
297
+ all time
298
+ </span>
299
+ </p>
300
+ )}
301
+ {/* Who to ask, up here with the facts: below the screenshot
302
+ it fell under the fold. */}
303
+ <p
304
+ data-testid="card-people"
305
+ className="mt-2.5 flex flex-wrap items-center gap-x-[18px] gap-y-1.5 text-[13px] font-semibold text-muted-foreground"
306
+ >
307
+ {tool.owners.length > 0 && (
308
+ <span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1.5">
309
+ <span className={LABEL}>Owners</span>
310
+ {tool.owners.map((owner) => (
311
+ <span
312
+ key={owner.slug ?? `named:${owner.name}`}
313
+ className="inline-flex items-center gap-1.5 text-foreground"
314
+ >
315
+ <PersonAvatar name={owner.name} />
316
+ <span data-testid="card-owner">{owner.name}</span>
317
+ </span>
318
+ ))}
319
+ </span>
320
+ )}
321
+ <span className="inline-flex items-center gap-1.5">
322
+ <span className={LABEL}>Added by</span>
323
+ <span className="text-foreground">{tool.addedBy.name}</span>
324
+ <span className="font-normal">
325
+ {formatCardDate(tool.createdAt)}
326
+ </span>
327
+ </span>
328
+ </p>
329
+ </div>
330
+ <LifecycleNote
331
+ tool={tool}
332
+ onTellOwner={() => commentsRef.current?.focus()}
333
+ />
334
+ {isAdmin && (
335
+ <div
336
+ data-testid="card-admin"
337
+ className="mt-3.5 flex flex-col gap-2.5 rounded-[10px] border border-dashed border-border px-3 py-2.5"
338
+ >
339
+ {archiving ? (
340
+ <ArchiveConfirm
341
+ busy={archive.isPending}
342
+ onCancel={() => setArchiving(false)}
343
+ onArchive={(reason) => archive.mutate({ slug, reason })}
344
+ />
345
+ ) : (
346
+ <div className="flex flex-wrap items-center gap-2.5">
347
+ <span className="mr-auto text-[12.5px] font-bold tracking-wider text-muted-foreground uppercase">
348
+ Admin
349
+ </span>
350
+ {!tool.reviewed && (
351
+ <button
352
+ type="button"
353
+ disabled={approve.isPending}
354
+ aria-busy={approve.isPending || undefined}
355
+ onClick={() => approve.mutate({ slug })}
356
+ className={GHOST_BUTTON}
357
+ >
358
+ <Check aria-hidden="true" className="size-4" />
359
+ {approve.isPending ? 'Approving…' : 'Approve'}
360
+ </button>
361
+ )}
362
+ {tool.lifecycle.state !== 'archived' && (
363
+ <button
364
+ type="button"
365
+ onClick={() => setArchiving(true)}
366
+ className={GHOST_BUTTON}
367
+ >
368
+ <Archive aria-hidden="true" className="size-4" />
369
+ Archive
370
+ </button>
371
+ )}
372
+ </div>
373
+ )}
374
+ {adminError && (
375
+ <p
376
+ role="alert"
377
+ className="text-[13px] font-semibold text-destructive"
378
+ >
379
+ {adminError.message}
380
+ </p>
381
+ )}
382
+ </div>
383
+ )}
384
+ </div>
385
+
386
+ <CardScreenshot
387
+ tool={tool}
388
+ capturing={tool.capturePending && !gaveUpOnCapture}
389
+ />
390
+
391
+ {tool.descriptionMd.trim() && (
392
+ <section
393
+ data-testid="card-how-to-use"
394
+ className={cn(SECTION, 'text-[14.5px]')}
395
+ >
396
+ <h3 className="mb-2 font-serif text-[17px] font-semibold">
397
+ How to use
398
+ </h3>
399
+ <div className="prose prose-sm dark:prose-invert max-w-[68ch]">
400
+ <MarkdownText>{tool.descriptionMd}</MarkdownText>
401
+ </div>
402
+ </section>
403
+ )}
404
+
405
+ {/* The card's own order: what was said first, the input last. The
406
+ official card's CommunitySection puts the input on top. */}
407
+ <section data-testid="card-comments" className={SECTION}>
408
+ <div className="comments-head">
409
+ <h2 id={commentsHeadingId} className="behind-group__title">
410
+ Comments
411
+ </h2>
412
+ {commentCount > 0 && (
413
+ <span
414
+ data-testid="card-comment-count"
415
+ className="rounded-full bg-muted px-2 text-xs leading-5 font-bold text-muted-foreground"
416
+ >
417
+ {commentCount}
418
+ </span>
419
+ )}
420
+ <CommentsHelp appName={tool.name} />
421
+ </div>
422
+ <CommentsFeed
423
+ target={commentTarget}
424
+ claims={NO_CLAIMS}
425
+ labelledBy={commentsHeadingId}
426
+ />
427
+ <div className="mt-3.5">
428
+ <CommunityInput
429
+ ref={commentsRef}
430
+ target={commentTarget}
431
+ appName={tool.name}
432
+ />
433
+ </div>
434
+ </section>
435
+
436
+ <p className="mt-[22px] flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12.5px] font-semibold text-muted-foreground">
437
+ <span className="inline-flex items-center gap-1.5">
438
+ <kbd className={KBD}>Esc</kbd> close
439
+ </span>
440
+ <span className="inline-flex items-center gap-1.5">
441
+ <kbd className={KBD}>←</kbd>
442
+ <kbd className={KBD}>→</kbd> previous / next tool
443
+ </span>
444
+ <span className="inline-flex items-center gap-1.5">
445
+ <kbd className={KBD}>Enter</kbd> open site
446
+ <span className="font-normal">
447
+ (in the comment box, Enter sends)
448
+ </span>
449
+ </span>
450
+ </p>
451
+ {editing && (
452
+ <ToolFormDialog
453
+ mode="edit"
454
+ initial={tool}
455
+ onClose={() => setEditing(false)}
456
+ onDone={() => setEditing(false)}
457
+ />
458
+ )}
459
+ </>
460
+ ) : get.isError ? (
461
+ <p className="py-10 text-center text-muted-foreground">
462
+ This tool is not listed any more.
463
+ </p>
464
+ ) : (
465
+ <div aria-busy="true" className="flex flex-col gap-4">
466
+ <Skeleton className="h-[72px] w-2/3 rounded-[14px]" />
467
+ <Skeleton className="h-4 w-1/2" />
468
+ <Skeleton className="aspect-[16/10] w-full rounded-[14px]" />
469
+ </div>
470
+ )}
471
+ </DetailDialogShell>
472
+ )
473
+ }
474
+
475
+ /** Why a tool that is not active is shown the way it is. */
476
+ function LifecycleNote({
477
+ tool,
478
+ onTellOwner,
479
+ }: {
480
+ tool: CommunityTool
481
+ onTellOwner: () => void
482
+ }) {
483
+ const { lifecycle } = tool
484
+ const [now] = useState(Date.now)
485
+ if (lifecycle.state === 'not-working') {
486
+ const since = new Date(now - lifecycle.days * DAY_MS).toISOString()
487
+ return (
488
+ <p
489
+ data-testid="lifecycle-note"
490
+ className="mt-3 rounded-[10px] bg-amber-50 px-3 py-[9px] text-[13.5px] text-amber-900 dark:bg-amber-950 dark:text-amber-200"
491
+ >
492
+ The daily check has not reached this site since{' '}
493
+ {formatCardDate(since).replace(/ \d{4}$/, '')}. It may be down or moved.{' '}
494
+ <button
495
+ type="button"
496
+ onClick={onTellOwner}
497
+ className="font-bold underline underline-offset-2"
498
+ >
499
+ Tell the owner
500
+ </button>
501
+ </p>
502
+ )
503
+ }
504
+ if (lifecycle.state === 'archived') {
505
+ const on = formatCardDate(lifecycle.at)
506
+ return (
507
+ <p
508
+ data-testid="lifecycle-note"
509
+ className="mt-3 rounded-[10px] bg-muted px-3 py-[9px] text-[13.5px] text-muted-foreground"
510
+ >
511
+ {lifecycle.byName
512
+ ? `Archived by ${lifecycle.byName} on ${on}${lifecycle.reason ? `: ${lifecycle.reason}` : ''}.`
513
+ : `Archived automatically on ${on} after 30 days not working.`}{' '}
514
+ Kept here so old links still land somewhere.
515
+ </p>
516
+ )
517
+ }
518
+ return null
519
+ }