@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,118 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import type { CommunityTag } from '@igstack/app-catalog-shared-core'
3
+ import { TAG_VALUE_MAX, normalizeTopic } from '@igstack/app-catalog-shared-core'
4
+ import { AppWindow, Hash } from 'lucide-react'
5
+ import { SuggestInput } from './OwnerPicker'
6
+ import { tagKey } from './TagRow'
7
+
8
+ const MAX_TAGS = 8
9
+ const MAX_SUGGESTIONS = 6
10
+
11
+ /**
12
+ * App tags, picked from the catalog's own apps so they always resolve, and
13
+ * topic words, suggested from the tags already in use so they converge. One
14
+ * box offers both. Enter adds the typed text as a topic, unless it is exactly
15
+ * an app's name or abbreviation, or an arrow chose a suggestion.
16
+ */
17
+ export function TagPicker({
18
+ id,
19
+ apps,
20
+ knownTopics,
21
+ value,
22
+ onChange,
23
+ onTooLong,
24
+ describedBy,
25
+ disabled,
26
+ }: {
27
+ id: string
28
+ /** The official apps (no sub-resources). */
29
+ apps: Resource[]
30
+ /** Every topic some tool already carries. */
31
+ knownTopics: string[]
32
+ value: CommunityTag[]
33
+ onChange: (tags: CommunityTag[]) => void
34
+ /** A typed topic over the length cap; the form says so next to the field. */
35
+ onTooLong: () => void
36
+ describedBy?: string
37
+ disabled?: boolean
38
+ }) {
39
+ const appName = (slug: string) =>
40
+ apps.find((a) => a.slug === slug)?.displayName ?? slug
41
+ const has = (tag: CommunityTag) =>
42
+ value.some((t) => tagKey(t) === tagKey(tag))
43
+ const add = (tag: CommunityTag) => {
44
+ if (!has(tag)) onChange([...value, tag])
45
+ }
46
+
47
+ return (
48
+ <SuggestInput
49
+ id={id}
50
+ chips={value.map((tag) => ({
51
+ key: tagKey(tag),
52
+ label: tag.kind === 'app' ? appName(tag.value) : tag.value,
53
+ icon:
54
+ tag.kind === 'app' ? (
55
+ <AppWindow aria-hidden="true" className="ml-1 size-3.5" />
56
+ ) : (
57
+ <Hash aria-hidden="true" className="ml-1 size-3.5" />
58
+ ),
59
+ }))}
60
+ onRemove={(key) => onChange(value.filter((t) => tagKey(t) !== key))}
61
+ placeholder={
62
+ value.length >= MAX_TAGS
63
+ ? 'Eight tags at most'
64
+ : 'An app it is for, or a topic word'
65
+ }
66
+ atLimit={value.length >= MAX_TAGS}
67
+ disabled={disabled}
68
+ describedBy={describedBy}
69
+ textFirst
70
+ suggest={(query) => {
71
+ const q = query.toLowerCase()
72
+ const topic = normalizeTopic(query)
73
+ const appOptions = apps
74
+ .filter(
75
+ (a) =>
76
+ a.displayName.toLowerCase().includes(q) ||
77
+ a.abbreviation?.toLowerCase().includes(q),
78
+ )
79
+ .filter((a) => !has({ kind: 'app', value: a.slug }))
80
+ .slice(0, MAX_SUGGESTIONS)
81
+ .map((a) => ({
82
+ key: `app:${a.slug}`,
83
+ label: a.displayName,
84
+ sub: a.abbreviation ? `App · ${a.abbreviation}` : 'App',
85
+ icon: <AppWindow aria-hidden="true" className="size-4" />,
86
+ exact:
87
+ a.displayName.toLowerCase() === q ||
88
+ a.abbreviation?.toLowerCase() === q,
89
+ }))
90
+ const topicOptions = knownTopics
91
+ .filter((t) => t.includes(topic))
92
+ .filter((t) => !has({ kind: 'topic', value: t }))
93
+ .slice(0, MAX_SUGGESTIONS)
94
+ .map((t) => ({
95
+ key: `topic:${t}`,
96
+ label: t,
97
+ sub: 'Topic in use',
98
+ icon: <Hash aria-hidden="true" className="size-4" />,
99
+ }))
100
+ return [...appOptions, ...topicOptions]
101
+ }}
102
+ onPick={(key) => {
103
+ const [kind, ...rest] = key.split(':')
104
+ add({ kind: kind as CommunityTag['kind'], value: rest.join(':') })
105
+ }}
106
+ onEnterText={(text) => {
107
+ const topic = normalizeTopic(text)
108
+ if (!topic) return false
109
+ if (topic.length > TAG_VALUE_MAX) {
110
+ onTooLong()
111
+ return false
112
+ }
113
+ add({ kind: 'topic', value: topic })
114
+ return true
115
+ }}
116
+ />
117
+ )
118
+ }
@@ -0,0 +1,118 @@
1
+ import type {
2
+ CommunityTag,
3
+ CommunityTool,
4
+ } from '@igstack/app-catalog-shared-core'
5
+ import { X } from 'lucide-react'
6
+ import { cn } from '~/lib/utils'
7
+
8
+ /** A tag as it travels in `?tag=`: `topic:sandbox`, `app:order-service`. */
9
+ export function tagKey(tag: CommunityTag): string {
10
+ return `${tag.kind}:${tag.value}`
11
+ }
12
+
13
+ /** An app tag reads as the app's name; a topic tag as its word. */
14
+ export function tagLabel(
15
+ tag: CommunityTag,
16
+ appNames: Record<string, string>,
17
+ ): string {
18
+ return tag.kind === 'app' ? (appNames[tag.value] ?? tag.value) : tag.value
19
+ }
20
+
21
+ const TOP_TAGS = 8
22
+
23
+ export function TagChip({
24
+ label,
25
+ active = false,
26
+ count,
27
+ onClick,
28
+ }: {
29
+ label: string
30
+ active?: boolean
31
+ count?: number
32
+ onClick: () => void
33
+ }) {
34
+ return (
35
+ <button
36
+ type="button"
37
+ data-testid="tool-tag"
38
+ aria-pressed={active}
39
+ onClick={(e) => {
40
+ e.stopPropagation()
41
+ onClick()
42
+ }}
43
+ className={cn(
44
+ 'relative z-10 inline-flex h-6 items-center gap-1 rounded-full border px-2.5 text-xs font-semibold transition-colors',
45
+ 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring',
46
+ active
47
+ ? 'border-primary/40 bg-accent text-accent-foreground'
48
+ : 'border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground',
49
+ )}
50
+ >
51
+ {label}
52
+ {count !== undefined && (
53
+ <span className="font-bold text-muted-foreground/70">{count}</span>
54
+ )}
55
+ {active && <X aria-hidden="true" className="size-3" />}
56
+ </button>
57
+ )
58
+ }
59
+
60
+ /**
61
+ * The most-used tags above the feed, as a way in for someone who does not know
62
+ * what to search for. The chosen tag stays in the row even when it is not a top
63
+ * one, so the filter can always be cleared where it is shown.
64
+ */
65
+ export function TagRow({
66
+ tools,
67
+ activeTag,
68
+ appNames,
69
+ onChoose,
70
+ }: {
71
+ tools: CommunityTool[]
72
+ activeTag: string | undefined
73
+ appNames: Record<string, string>
74
+ onChoose: (tag: string | undefined) => void
75
+ }) {
76
+ const counts = new Map<string, { tag: CommunityTag; count: number }>()
77
+ for (const tool of tools) {
78
+ for (const tag of tool.tags) {
79
+ const key = tagKey(tag)
80
+ const entry = counts.get(key) ?? { tag, count: 0 }
81
+ entry.count += 1
82
+ counts.set(key, entry)
83
+ }
84
+ }
85
+ const ranked = [...counts.values()].sort(
86
+ (a, b) =>
87
+ b.count - a.count ||
88
+ tagLabel(a.tag, appNames).localeCompare(tagLabel(b.tag, appNames)),
89
+ )
90
+ const shown = ranked.slice(0, TOP_TAGS)
91
+ const active = activeTag ? counts.get(activeTag) : undefined
92
+ if (active && !shown.includes(active)) shown.push(active)
93
+
94
+ if (shown.length === 0) return null
95
+
96
+ return (
97
+ <div
98
+ data-testid="tag-row"
99
+ aria-label="Popular tags"
100
+ role="group"
101
+ className="mb-3 flex flex-wrap items-center gap-1.5"
102
+ >
103
+ {shown.map(({ tag, count }) => {
104
+ const key = tagKey(tag)
105
+ const on = key === activeTag
106
+ return (
107
+ <TagChip
108
+ key={key}
109
+ label={tagLabel(tag, appNames)}
110
+ count={count}
111
+ active={on}
112
+ onClick={() => onChoose(on ? undefined : key)}
113
+ />
114
+ )
115
+ })}
116
+ </div>
117
+ )
118
+ }
@@ -0,0 +1,105 @@
1
+ import { AlertTriangle, Check, Star, X } from 'lucide-react'
2
+ import { useEffect, useState } from 'react'
3
+ import { cn } from '~/lib/utils'
4
+
5
+ export interface ToastMessage {
6
+ /** Changes for every new toast, so the same text twice still restarts the timer. */
7
+ id: number
8
+ tone: 'info' | 'ok' | 'error'
9
+ text: React.ReactNode
10
+ action?: { label: string; run: () => void }
11
+ }
12
+
13
+ const SHOW_MS = 5000
14
+
15
+ const ICONS = { info: Star, ok: Check, error: AlertTriangle } as const
16
+
17
+ /**
18
+ * One toast at a time, bottom-centre. It leaves after 5 s unless the pointer or
19
+ * focus is on it. The live region is always mounted, so the first toast is
20
+ * announced too; an error is an alert.
21
+ */
22
+ export function Toast({
23
+ toast,
24
+ onDismiss,
25
+ }: {
26
+ toast: ToastMessage | null
27
+ onDismiss: () => void
28
+ }) {
29
+ return (
30
+ <div
31
+ aria-live="polite"
32
+ className="pointer-events-none fixed inset-x-0 bottom-6 z-50 flex justify-center px-4"
33
+ >
34
+ {toast && (
35
+ <ToastBody key={toast.id} toast={toast} onDismiss={onDismiss} />
36
+ )}
37
+ </div>
38
+ )
39
+ }
40
+
41
+ /** Keyed by toast id, so a new toast starts un-held with a fresh timer. */
42
+ function ToastBody({
43
+ toast,
44
+ onDismiss,
45
+ }: {
46
+ toast: ToastMessage
47
+ onDismiss: () => void
48
+ }) {
49
+ const [held, setHeld] = useState(false)
50
+
51
+ useEffect(() => {
52
+ if (held) return
53
+ const timer = setTimeout(onDismiss, SHOW_MS)
54
+ return () => clearTimeout(timer)
55
+ }, [held, onDismiss])
56
+
57
+ const Icon = ICONS[toast.tone]
58
+
59
+ return (
60
+ <div
61
+ data-testid="toast"
62
+ role={toast.tone === 'error' ? 'alert' : 'status'}
63
+ onMouseEnter={() => setHeld(true)}
64
+ onMouseLeave={() => setHeld(false)}
65
+ onFocus={() => setHeld(true)}
66
+ onBlur={() => setHeld(false)}
67
+ className={cn(
68
+ 'pointer-events-auto inline-flex max-w-full items-center gap-2.5 rounded-xl py-2.5 pr-2.5 pl-3.5 text-sm font-semibold shadow-lg',
69
+ toast.tone === 'error'
70
+ ? 'bg-destructive text-white'
71
+ : 'bg-foreground text-background',
72
+ )}
73
+ >
74
+ <Icon
75
+ aria-hidden="true"
76
+ className={cn(
77
+ 'size-4 shrink-0',
78
+ toast.tone === 'info' && 'fill-amber-400 text-amber-400',
79
+ toast.tone === 'ok' && 'text-green-400',
80
+ )}
81
+ />
82
+ <span>{toast.text}</span>
83
+ {toast.action && (
84
+ <button
85
+ type="button"
86
+ onClick={() => {
87
+ toast.action?.run()
88
+ onDismiss()
89
+ }}
90
+ className="rounded-md px-2 py-1 font-bold underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-current"
91
+ >
92
+ {toast.action.label}
93
+ </button>
94
+ )}
95
+ <button
96
+ type="button"
97
+ aria-label="Dismiss"
98
+ onClick={onDismiss}
99
+ className="rounded-md p-1 opacity-70 hover:opacity-100 focus-visible:outline-2 focus-visible:outline-current"
100
+ >
101
+ <X aria-hidden="true" className="size-4" />
102
+ </button>
103
+ </div>
104
+ )
105
+ }