@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,310 @@
1
+ import type {
2
+ CommunityTool,
3
+ FeedSort,
4
+ MatchField,
5
+ } from '@igstack/app-catalog-shared-core'
6
+ import { Link } from '@tanstack/react-router'
7
+ import {
8
+ CornerDownLeft,
9
+ ExternalLink,
10
+ Eye,
11
+ Globe,
12
+ PanelRight,
13
+ } from 'lucide-react'
14
+ import { cn } from '~/lib/utils'
15
+ import { highlightTokens } from '~/util/highlightMatches'
16
+ import { timeAgo } from '../timeAgo'
17
+ import { LifecycleBadge, UnreviewedBadge } from './LifecycleBadge'
18
+ import { LikeButton } from './LikeButton'
19
+ import { PinButton } from './PinButton'
20
+
21
+ export function screenshotUrl(id: string, size: number): string {
22
+ return `/api/screenshots/${id}?size=${size}`
23
+ }
24
+
25
+ /** The tool's screenshot, cropped to its top-left, or a globe when there is none. */
26
+ export function ToolThumb({
27
+ tool,
28
+ size,
29
+ className,
30
+ }: {
31
+ tool: CommunityTool
32
+ size: number
33
+ className?: string
34
+ }) {
35
+ return (
36
+ <div
37
+ className={cn(
38
+ 'flex shrink-0 items-center justify-center overflow-hidden border border-border bg-muted text-muted-foreground',
39
+ className,
40
+ )}
41
+ >
42
+ {tool.screenshotId ? (
43
+ <img
44
+ src={screenshotUrl(tool.screenshotId, size)}
45
+ alt=""
46
+ loading="lazy"
47
+ className="size-full object-cover object-left-top"
48
+ />
49
+ ) : (
50
+ <Globe aria-hidden="true" className="size-1/4 min-w-4" />
51
+ )}
52
+ </div>
53
+ )
54
+ }
55
+
56
+ const AVATAR_TONES = [
57
+ 'bg-orange-200',
58
+ 'bg-sky-200',
59
+ 'bg-lime-200',
60
+ 'bg-amber-200',
61
+ 'bg-rose-200',
62
+ 'bg-violet-200',
63
+ ]
64
+
65
+ export function PersonAvatar({ name }: { name: string }) {
66
+ const initials = name
67
+ .split(/\s+/)
68
+ .slice(0, 2)
69
+ .map((w) => w.charAt(0).toUpperCase())
70
+ .join('')
71
+ let hash = 0
72
+ for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) | 0
73
+ return (
74
+ <span
75
+ aria-hidden="true"
76
+ className={cn(
77
+ 'inline-flex size-[22px] shrink-0 items-center justify-center rounded-full text-[9.5px] font-extrabold text-stone-700',
78
+ AVATAR_TONES[Math.abs(hash) % AVATAR_TONES.length],
79
+ )}
80
+ >
81
+ {initials}
82
+ </span>
83
+ )
84
+ }
85
+
86
+ const FIELD_LABELS: Record<MatchField, string> = {
87
+ name: 'name',
88
+ summary: 'summary',
89
+ description: 'how to use',
90
+ owner: 'author',
91
+ address: 'address',
92
+ tag: 'tag',
93
+ }
94
+
95
+ export interface FeedRowProps {
96
+ tool: CommunityTool
97
+ /** Shown in the ranked feed; search results have no rank. */
98
+ rank?: number
99
+ /** A date sort shows its date on the row. */
100
+ sort: FeedSort
101
+ tokens: string[]
102
+ /** Set while searching: which fields the query hit. */
103
+ matched?: MatchField[]
104
+ selected?: boolean
105
+ /**
106
+ * Set while searching: the row is a row of the results grid, and this is the
107
+ * id of its main cell, the one the search box points at.
108
+ */
109
+ optionId?: string
110
+ pinned: boolean
111
+ /** Its pin is being saved. */
112
+ pinBusy?: boolean
113
+ onTogglePin: () => void
114
+ onToggleLike: () => void
115
+ }
116
+
117
+ /** The feed row's thumbnail: big enough to read the page title in its top left. */
118
+ export const ROW_THUMB = 'h-[150px] w-[240px] rounded-[10px]'
119
+
120
+ /** The Details panel's width, reserved at rest so nothing moves when it shows. */
121
+ const PANEL = 'w-[120px]'
122
+
123
+ /**
124
+ * One tool in the feed or the search results. Everything left of the end
125
+ * panel (rank, picture, name, summary, meta) is one link that opens the site
126
+ * in a new tab; the 120 px panel opens the card. The star sits in the meta
127
+ * line as its own target, above that link. The bottom strip shows the address
128
+ * the big click goes to, on hover or focus only.
129
+ */
130
+ export function FeedRow({
131
+ tool,
132
+ rank,
133
+ sort,
134
+ tokens,
135
+ matched,
136
+ selected = false,
137
+ optionId,
138
+ pinned,
139
+ pinBusy,
140
+ onTogglePin,
141
+ onToggleLike,
142
+ }: FeedRowProps) {
143
+ const archived = tool.lifecycle.state === 'archived'
144
+ const cell = optionId ? 'gridcell' : undefined
145
+ const owners = tool.owners.map((o) => o.name).join(', ')
146
+ const dated =
147
+ sort === 'newest'
148
+ ? `Added ${timeAgo(tool.createdAt)}`
149
+ : sort === 'updated' && tool.siteUpdatedAt
150
+ ? `Updated ${timeAgo(tool.siteUpdatedAt)}`
151
+ : null
152
+ const address = tool.url.replace(/^https?:\/\//, '').replace(/\/$/, '')
153
+
154
+ return (
155
+ <div
156
+ data-testid="feed-row"
157
+ data-slug={tool.slug}
158
+ data-selected={selected || undefined}
159
+ role={optionId ? 'row' : undefined}
160
+ className={cn(
161
+ 'group/row relative flex min-h-[176px] items-center overflow-hidden rounded-[14px] border bg-card transition-[border-color,box-shadow]',
162
+ selected
163
+ ? 'border-primary ring-[3px] ring-primary/20'
164
+ : 'border-border hover:border-primary/35 hover:shadow-sm has-[:focus-visible]:border-primary/35',
165
+ archived && 'opacity-55',
166
+ )}
167
+ >
168
+ {/* Two cells while searching: the site, and the card. A grid rather
169
+ than a listbox, because an option's content is presentational and
170
+ would hide the link and the star. */}
171
+ <div
172
+ id={optionId}
173
+ role={cell}
174
+ aria-selected={optionId ? selected : undefined}
175
+ className="flex min-w-0 flex-1 items-center gap-4 self-stretch py-3 pl-3"
176
+ >
177
+ {rank !== undefined && (
178
+ <span className="w-[26px] shrink-0 text-center font-serif text-[22px] font-semibold text-muted-foreground/70">
179
+ {rank}
180
+ </span>
181
+ )}
182
+ <ToolThumb tool={tool} size={512} className={ROW_THUMB} />
183
+ <div className="min-w-0 flex-1 self-start pt-5 pr-6 pb-[52px]">
184
+ <div className="flex items-center gap-2.5 text-[15.5px] font-extrabold">
185
+ <a
186
+ href={tool.url}
187
+ target="_blank"
188
+ rel="noopener noreferrer"
189
+ data-site
190
+ data-testid="feed-row-name"
191
+ aria-label={`${tool.name}, opens ${tool.host} in a new tab`}
192
+ className={cn(
193
+ 'truncate decoration-1 underline-offset-[3px] after:absolute after:inset-y-0 after:left-0 after:right-[120px] after:z-[1] after:rounded-l-[13px] focus-visible:outline-none focus-visible:after:outline-2 focus-visible:after:-outline-offset-[3px] focus-visible:after:outline-ring',
194
+ 'group-hover/row:underline group-has-[[data-details]:hover]/row:no-underline',
195
+ archived && 'text-muted-foreground',
196
+ )}
197
+ >
198
+ {highlightTokens(tool.name, tokens)}
199
+ </a>
200
+ {selected && (
201
+ <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11.5px] font-bold text-primary">
202
+ <CornerDownLeft aria-hidden="true" className="size-3" />
203
+ Enter opens site
204
+ </span>
205
+ )}
206
+ <LifecycleBadge lifecycle={tool.lifecycle} />
207
+ {!tool.reviewed && <UnreviewedBadge />}
208
+ </div>
209
+ {tool.summary && (
210
+ <div className="mt-1 line-clamp-2 text-foreground/75">
211
+ {highlightTokens(tool.summary, tokens)}
212
+ </div>
213
+ )}
214
+ {matched && matched.length > 0 && (
215
+ <div
216
+ data-testid="match-reason"
217
+ className="mt-1 text-xs font-semibold text-muted-foreground"
218
+ >
219
+ Matched: {matched.map((f) => FIELD_LABELS[f]).join(', ')}
220
+ </div>
221
+ )}
222
+ </div>
223
+ {/* Meta on the left, the address on the right, flush to the panel.
224
+ The address keeps 150 px; a long author list gives way first.
225
+ Clicks fall through to the row link, except on star and like. */}
226
+ <div
227
+ className={cn(
228
+ 'pointer-events-none absolute right-[120px] bottom-0 z-[2] flex h-12 items-center gap-4',
229
+ rank !== undefined ? 'left-[310px]' : 'left-[268px]',
230
+ )}
231
+ >
232
+ <div className="flex min-w-0 items-center gap-1.5 text-[13px] font-semibold whitespace-nowrap text-foreground/75 [&>*]:shrink-0">
233
+ {tool.owners[0] && <PersonAvatar name={tool.owners[0].name} />}
234
+ <span
235
+ data-testid="feed-row-owner"
236
+ className="mr-1 min-w-0 !shrink truncate"
237
+ >
238
+ {highlightTokens(owners, tokens)}
239
+ </span>
240
+ <span className="pointer-events-auto -mr-1 flex items-center">
241
+ <PinButton
242
+ small
243
+ pinned={pinned}
244
+ busy={pinBusy}
245
+ name={tool.name}
246
+ onToggle={onTogglePin}
247
+ />
248
+ <LikeButton tool={tool} onToggle={onToggleLike} />
249
+ </span>
250
+ <span
251
+ data-testid="feed-row-views"
252
+ className="inline-flex items-center gap-1 text-muted-foreground"
253
+ >
254
+ <Eye aria-hidden="true" className="size-[15px]" />
255
+ {tool.views30d}
256
+ <span className="sr-only"> views this month</span>
257
+ </span>
258
+ {dated && (
259
+ <>
260
+ <span aria-hidden="true" className="text-muted-foreground">
261
+ ·
262
+ </span>
263
+ <span
264
+ data-testid="feed-row-date"
265
+ className="text-muted-foreground"
266
+ >
267
+ {dated}
268
+ </span>
269
+ </>
270
+ )}
271
+ </div>
272
+ <div
273
+ aria-hidden="true"
274
+ data-testid="feed-row-address"
275
+ className={cn(
276
+ 'flex h-full min-w-[150px] flex-1 items-center justify-end gap-1.5 px-4 font-mono text-[12.5px] text-muted-foreground opacity-0 transition-opacity',
277
+ 'group-hover/row:text-foreground group-hover/row:opacity-100 group-has-[[data-site]:focus-visible]/row:opacity-100 group-has-[[data-details]:hover]/row:opacity-0 [@media(hover:none)]:opacity-100',
278
+ selected && 'opacity-100',
279
+ )}
280
+ >
281
+ <span className="min-w-0 truncate">{address}</span>
282
+ <ExternalLink aria-hidden="true" className="size-3.5 shrink-0" />
283
+ </div>
284
+ </div>
285
+ </div>
286
+ <div role={cell} className="flex self-stretch">
287
+ <Link
288
+ to="/community/$slug"
289
+ params={{ slug: tool.slug }}
290
+ search={(prev) => prev}
291
+ data-details
292
+ aria-label={`Details for ${tool.name}`}
293
+ className={cn(
294
+ PANEL,
295
+ 'relative z-[2] flex flex-none flex-col items-center justify-center gap-1.5 border-l border-border/55 text-[13.5px] font-extrabold text-muted-foreground transition-colors',
296
+ 'group-hover/row:border-border group-hover/row:bg-primary/[0.025] group-has-[:focus-visible]/row:bg-primary/[0.025]',
297
+ 'hover:!border-primary/30 hover:!bg-primary/12 hover:text-foreground',
298
+ 'focus-visible:rounded-r-[13px] focus-visible:!bg-primary/12 focus-visible:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-[3px] focus-visible:outline-ring',
299
+ )}
300
+ >
301
+ {/* Shown on hover or focus; always where nothing hovers. */}
302
+ <span className="flex flex-col items-center gap-1.5 opacity-0 transition-opacity group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 [@media(hover:none)]:opacity-100">
303
+ <PanelRight aria-hidden="true" className="size-[18px]" />
304
+ <span>Details</span>
305
+ </span>
306
+ </Link>
307
+ </div>
308
+ </div>
309
+ )
310
+ }
@@ -0,0 +1,97 @@
1
+ import type { FeedSort, SortDir } from '@igstack/app-catalog-shared-core'
2
+ import { ArrowDown, ArrowUp } from 'lucide-react'
3
+ import { useRef } from 'react'
4
+ import { cn } from '~/lib/utils'
5
+
6
+ export const SORT_OPTIONS: { value: FeedSort; label: string }[] = [
7
+ { value: 'liked', label: 'Most liked' },
8
+ { value: 'popular', label: 'Popular' },
9
+ { value: 'newest', label: 'Newest' },
10
+ { value: 'updated', label: 'Recently updated' },
11
+ ]
12
+
13
+ const HEADINGS: Record<FeedSort, Record<SortDir, string>> = {
14
+ liked: { desc: 'Most liked', asc: 'Least liked' },
15
+ popular: { desc: 'Most opened', asc: 'Least opened' },
16
+ newest: { desc: 'Newest', asc: 'Oldest' },
17
+ updated: { desc: 'Recently updated', asc: 'Longest unchanged' },
18
+ }
19
+
20
+ /** The feed's heading, saying the order it is in. */
21
+ export function feedHeading(sort: FeedSort, dir: SortDir): string {
22
+ return HEADINGS[sort][dir]
23
+ }
24
+
25
+ const DIR_LABEL: Record<SortDir, string> = {
26
+ desc: 'descending',
27
+ asc: 'ascending',
28
+ }
29
+
30
+ /**
31
+ * Segmented control above the feed. Choosing a sort starts it descending;
32
+ * choosing the active one again flips the direction. Arrow keys move between
33
+ * segments.
34
+ */
35
+ export function FeedSortControl({
36
+ sort,
37
+ dir,
38
+ onChange,
39
+ }: {
40
+ sort: FeedSort
41
+ dir: SortDir
42
+ onChange: (sort: FeedSort, dir: SortDir) => void
43
+ }) {
44
+ const buttonsRef = useRef<(HTMLButtonElement | null)[]>([])
45
+ const label = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? sort
46
+
47
+ const onKeyDown = (e: React.KeyboardEvent, index: number) => {
48
+ const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0
49
+ if (step === 0) return
50
+ e.preventDefault()
51
+ const next = (index + step + SORT_OPTIONS.length) % SORT_OPTIONS.length
52
+ onChange(SORT_OPTIONS[next]!.value, 'desc')
53
+ buttonsRef.current[next]?.focus()
54
+ }
55
+
56
+ return (
57
+ <div
58
+ role="radiogroup"
59
+ aria-label={`Sort by ${label}, ${DIR_LABEL[dir]}`}
60
+ data-testid="feed-sort"
61
+ className="inline-flex gap-0.5 rounded-[10px] bg-muted p-[3px] text-[13px] font-semibold"
62
+ >
63
+ {SORT_OPTIONS.map((option, index) => {
64
+ const on = option.value === sort
65
+ const Arrow = dir === 'desc' ? ArrowDown : ArrowUp
66
+ return (
67
+ <button
68
+ key={option.value}
69
+ ref={(el) => {
70
+ buttonsRef.current[index] = el
71
+ }}
72
+ type="button"
73
+ role="radio"
74
+ aria-checked={on}
75
+ aria-label={
76
+ on ? `${option.label}, ${DIR_LABEL[dir]}` : option.label
77
+ }
78
+ tabIndex={on ? 0 : -1}
79
+ onClick={() =>
80
+ onChange(option.value, on && dir === 'desc' ? 'asc' : 'desc')
81
+ }
82
+ onKeyDown={(e) => onKeyDown(e, index)}
83
+ className={cn(
84
+ 'inline-flex items-center gap-1 rounded-lg px-3 py-[5px] transition-colors focus-visible:outline-2 focus-visible:outline-ring',
85
+ on
86
+ ? 'bg-card text-foreground shadow-sm'
87
+ : 'text-muted-foreground hover:text-foreground',
88
+ )}
89
+ >
90
+ {option.label}
91
+ {on && <Arrow aria-hidden="true" className="size-3.5" />}
92
+ </button>
93
+ )
94
+ })}
95
+ </div>
96
+ )
97
+ }
@@ -0,0 +1,44 @@
1
+ import type { Lifecycle } from '@igstack/app-catalog-shared-core'
2
+ import { AlertTriangle, Archive } from 'lucide-react'
3
+
4
+ const PILL =
5
+ 'inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-px text-[11.5px] font-bold'
6
+
7
+ /** Next to the name on rows, tiles and the card. An active tool shows nothing. */
8
+ export function LifecycleBadge({ lifecycle }: { lifecycle: Lifecycle }) {
9
+ if (lifecycle.state === 'not-working') {
10
+ return (
11
+ <span
12
+ data-testid="lifecycle-badge"
13
+ className={`${PILL} border-amber-300 bg-amber-100 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300`}
14
+ >
15
+ <AlertTriangle aria-hidden="true" className="size-3" />
16
+ Not working · {lifecycle.days} {lifecycle.days === 1 ? 'day' : 'days'}
17
+ </span>
18
+ )
19
+ }
20
+ if (lifecycle.state === 'archived') {
21
+ return (
22
+ <span
23
+ data-testid="lifecycle-badge"
24
+ className={`${PILL} border-border bg-muted text-muted-foreground`}
25
+ >
26
+ <Archive aria-hidden="true" className="size-3" />
27
+ Archived
28
+ </span>
29
+ )
30
+ }
31
+ return null
32
+ }
33
+
34
+ /** Live since it was published, but no admin has looked at it yet. */
35
+ export function UnreviewedBadge() {
36
+ return (
37
+ <span
38
+ data-testid="unreviewed-badge"
39
+ className={`${PILL} border-primary/25 bg-primary/10 text-primary`}
40
+ >
41
+ New · not reviewed
42
+ </span>
43
+ )
44
+ }
@@ -0,0 +1,56 @@
1
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
2
+ import { ThumbsUp } from 'lucide-react'
3
+ import { cn } from '~/lib/utils'
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from '~/ui/tooltip'
5
+ import { likedByText } from '../likedBy'
6
+
7
+ /**
8
+ * A thumb and the count, filled in the brand colour once I liked it; hover or
9
+ * focus says who liked it.
10
+ */
11
+ export function LikeButton({
12
+ tool,
13
+ onToggle,
14
+ }: {
15
+ tool: Pick<CommunityTool, 'name' | 'likes' | 'likedByMe' | 'likedBy'>
16
+ onToggle: () => void
17
+ }) {
18
+ const { name, likes, likedByMe } = tool
19
+ return (
20
+ <Tooltip>
21
+ <TooltipTrigger asChild>
22
+ <button
23
+ type="button"
24
+ data-testid="like-button"
25
+ aria-pressed={likedByMe}
26
+ // One name; aria-pressed says whether it is liked.
27
+ aria-label={`Like ${name}`}
28
+ onClick={(e) => {
29
+ e.stopPropagation()
30
+ onToggle()
31
+ }}
32
+ className={cn(
33
+ 'relative z-10 inline-flex h-8 min-w-8 items-center justify-center gap-1 rounded-lg px-1.5 transition-colors hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring',
34
+ likedByMe ? 'text-foreground' : 'text-muted-foreground',
35
+ )}
36
+ >
37
+ <ThumbsUp
38
+ aria-hidden="true"
39
+ className={cn(
40
+ 'size-[15px]',
41
+ likedByMe && 'fill-primary/85 stroke-foreground',
42
+ )}
43
+ />
44
+ <span data-testid="like-count">{likes}</span>
45
+ </button>
46
+ </TooltipTrigger>
47
+ <TooltipContent
48
+ data-testid="liked-by"
49
+ sideOffset={8}
50
+ className="max-w-[300px] rounded-[10px] px-2.5 py-[7px] text-[12.5px] leading-[1.4] font-semibold"
51
+ >
52
+ {likedByText(tool)}
53
+ </TooltipContent>
54
+ </Tooltip>
55
+ )
56
+ }
@@ -0,0 +1,143 @@
1
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
2
+ import { useEffect, useRef, useState } from 'react'
3
+ import { screenshotUrl } from './FeedRow'
4
+
5
+ /** Less free margin than this beside the column and there is no preview. */
6
+ const MIN_MARGIN = 420
7
+ /** Hover or focus this long before the first preview shows. */
8
+ const FIRST_SHOW_MS = 300
9
+ const GAP = 12
10
+
11
+ interface Box {
12
+ left: number
13
+ top: number
14
+ width: number
15
+ height: number
16
+ }
17
+
18
+ /**
19
+ * The hovered or focused row's screenshot, big, in the empty margin left of
20
+ * the feed: one reused panel whose top lines up with the row's, moved up only
21
+ * as far as it takes to stay in the window. The first one waits a moment;
22
+ * moving to another row swaps it at once. Nothing on a narrow window, on a
23
+ * touch screen, or while a dialog is open.
24
+ */
25
+ export function MarginPreview({
26
+ list,
27
+ tools,
28
+ disabled,
29
+ }: {
30
+ /**
31
+ * The element the rows are in; hover and focus are read off it. Inside it
32
+ * sits `[data-preview-column]`, the centred column whose left margin holds
33
+ * the preview, inside `[data-preview-scroller]`.
34
+ */
35
+ list: HTMLElement | null
36
+ tools: CommunityTool[]
37
+ disabled: boolean
38
+ }) {
39
+ const [slug, setSlug] = useState<string | null>(null)
40
+ const [box, setBox] = useState<Box | null>(null)
41
+ const rowRef = useRef<HTMLElement | null>(null)
42
+ const [canHover] = useState(
43
+ () =>
44
+ typeof window !== 'undefined' &&
45
+ window.matchMedia('(hover: hover) and (pointer: fine)').matches,
46
+ )
47
+
48
+ useEffect(() => {
49
+ const scroller = list?.closest<HTMLElement>('[data-preview-scroller]')
50
+ const column = list?.closest<HTMLElement>('[data-preview-column]')
51
+ if (!list || !scroller || !column || !canHover || disabled) return
52
+ let timer: ReturnType<typeof setTimeout> | undefined
53
+ let shown = false
54
+
55
+ const place = (): Box | null => {
56
+ const row = rowRef.current
57
+ if (!row?.isConnected) return null
58
+ const area = scroller.getBoundingClientRect()
59
+ const margin = column.getBoundingClientRect().left - area.left
60
+ if (margin < MIN_MARGIN) return null
61
+ const width = margin - 2 * GAP
62
+ const height = Math.round((width * 10) / 16)
63
+ const top = Math.max(
64
+ area.top,
65
+ Math.min(
66
+ row.getBoundingClientRect().top,
67
+ window.innerHeight - height - GAP,
68
+ ),
69
+ )
70
+ return { left: area.left + GAP, top, width, height }
71
+ }
72
+ const show = () => {
73
+ shown = true
74
+ setSlug(rowRef.current?.dataset['slug'] ?? null)
75
+ setBox(place())
76
+ }
77
+ const hide = () => {
78
+ clearTimeout(timer)
79
+ shown = false
80
+ rowRef.current = null
81
+ setSlug(null)
82
+ setBox(null)
83
+ }
84
+ const enter = (target: EventTarget | null) => {
85
+ const row =
86
+ target instanceof Element
87
+ ? target.closest<HTMLElement>('[data-testid="feed-row"]')
88
+ : null
89
+ if (!row || row === rowRef.current) return
90
+ rowRef.current = row
91
+ clearTimeout(timer)
92
+ if (shown) show()
93
+ else timer = setTimeout(show, FIRST_SHOW_MS)
94
+ }
95
+ const onPointerOver = (e: PointerEvent) => enter(e.target)
96
+ const onFocusIn = (e: FocusEvent) => enter(e.target)
97
+ const onFocusOut = (e: FocusEvent) => {
98
+ if (!list.contains(e.relatedTarget as Node | null)) hide()
99
+ }
100
+ // Scrolling under a still-hovered row keeps the panel on it.
101
+ const follow = () => {
102
+ if (shown) setBox(place())
103
+ }
104
+
105
+ list.addEventListener('pointerover', onPointerOver)
106
+ list.addEventListener('pointerleave', hide)
107
+ list.addEventListener('focusin', onFocusIn)
108
+ list.addEventListener('focusout', onFocusOut)
109
+ scroller.addEventListener('scroll', follow, { passive: true })
110
+ window.addEventListener('resize', follow)
111
+ return () => {
112
+ hide()
113
+ list.removeEventListener('pointerover', onPointerOver)
114
+ list.removeEventListener('pointerleave', hide)
115
+ list.removeEventListener('focusin', onFocusIn)
116
+ list.removeEventListener('focusout', onFocusOut)
117
+ scroller.removeEventListener('scroll', follow)
118
+ window.removeEventListener('resize', follow)
119
+ }
120
+ }, [list, canHover, disabled])
121
+
122
+ const shot = tools.find((t) => t.slug === slug)?.screenshotId
123
+ if (disabled || !box || !shot) return null
124
+ return (
125
+ <div
126
+ aria-hidden="true"
127
+ data-testid="margin-preview"
128
+ className="pointer-events-none fixed top-0 z-20 overflow-hidden rounded-[14px] border border-border bg-muted shadow-lg transition-transform duration-[120ms] ease-out"
129
+ style={{
130
+ left: box.left,
131
+ width: box.width,
132
+ height: box.height,
133
+ transform: `translateY(${box.top}px)`,
134
+ }}
135
+ >
136
+ <img
137
+ src={screenshotUrl(shot, 1280)}
138
+ alt=""
139
+ className="size-full object-cover object-left-top"
140
+ />
141
+ </div>
142
+ )
143
+ }