@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,112 @@
1
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
2
+ import { useState } from 'react'
3
+ import { Skeleton } from '~/ui/skeleton'
4
+ import { ShelfTile } from './ShelfTile'
5
+
6
+ const GRID = 'grid grid-cols-2 gap-3.5 md:grid-cols-4'
7
+
8
+ function moved<T>(items: T[], from: number, to: number): T[] {
9
+ const next = [...items]
10
+ const [item] = next.splice(from, 1)
11
+ next.splice(to, 0, item!)
12
+ return next
13
+ }
14
+
15
+ /** Same size as the real shelf, so nothing jumps when the pins arrive. */
16
+ function ShelfSkeleton() {
17
+ return (
18
+ <div data-testid="shelf-skeleton" aria-busy="true" className={GRID}>
19
+ {[0, 1, 2, 3].map((i) => (
20
+ <div
21
+ key={i}
22
+ className="overflow-hidden rounded-[14px] border border-border bg-card"
23
+ >
24
+ <Skeleton className="aspect-[16/9.5] rounded-none" />
25
+ <div className="flex flex-col gap-1.5 px-3 py-3">
26
+ <Skeleton className="h-2.5 w-2/3" />
27
+ <Skeleton className="h-2.5 w-2/5" />
28
+ </div>
29
+ </div>
30
+ ))}
31
+ </div>
32
+ )
33
+ }
34
+
35
+ /**
36
+ * The visitor's pinned tools, in the order they arranged them. Native drag and
37
+ * drop moves a tile; `onReorder` gets the whole new order. Nothing at all
38
+ * shows until there is a pin: the star on every row is the way in.
39
+ */
40
+ export function MyToolsShelf({
41
+ pins,
42
+ isLoading,
43
+ onUnpin,
44
+ onReorder,
45
+ }: {
46
+ pins: CommunityTool[]
47
+ isLoading: boolean
48
+ onUnpin: (tool: CommunityTool) => void
49
+ onReorder: (slugs: string[]) => void
50
+ }) {
51
+ const [dragFrom, setDragFrom] = useState<number | null>(null)
52
+
53
+ const move = (from: number, to: number) => {
54
+ if (to < 0 || to >= pins.length || from === to) return
55
+ onReorder(moved(pins, from, to).map((t) => t.slug))
56
+ }
57
+
58
+ if (!isLoading && pins.length === 0) return null
59
+
60
+ let body: React.ReactNode
61
+ if (isLoading) {
62
+ body = <ShelfSkeleton />
63
+ } else {
64
+ body = (
65
+ <div className={GRID}>
66
+ {pins.map((tool, index) => (
67
+ <ShelfTile
68
+ key={tool.slug}
69
+ tool={tool}
70
+ dragging={dragFrom === index}
71
+ onUnpin={() => onUnpin(tool)}
72
+ onMove={(step) => move(index, index + step)}
73
+ dragProps={{
74
+ onDragStart: (e) => {
75
+ e.dataTransfer.effectAllowed = 'move'
76
+ setDragFrom(index)
77
+ },
78
+ onDragOver: (e) => {
79
+ if (dragFrom !== null) e.preventDefault()
80
+ },
81
+ onDrop: (e) => {
82
+ e.preventDefault()
83
+ if (dragFrom !== null) move(dragFrom, index)
84
+ setDragFrom(null)
85
+ },
86
+ onDragEnd: () => setDragFrom(null),
87
+ }}
88
+ />
89
+ ))}
90
+ </div>
91
+ )
92
+ }
93
+
94
+ return (
95
+ <section aria-labelledby="my-tools-heading" data-testid="my-tools">
96
+ <div className="mb-3 flex items-baseline gap-3">
97
+ <h2
98
+ id="my-tools-heading"
99
+ className="font-serif text-[19px] font-semibold"
100
+ >
101
+ My tools
102
+ </h2>
103
+ {pins.length > 1 && (
104
+ <span className="text-[12.5px] font-medium text-muted-foreground">
105
+ Drag to reorder
106
+ </span>
107
+ )}
108
+ </div>
109
+ {body}
110
+ </section>
111
+ )
112
+ }
@@ -0,0 +1,311 @@
1
+ import type { Person } from '@igstack/app-catalog-backend-core'
2
+ import type { PersonRef } from '@igstack/app-catalog-shared-core'
3
+ import { Search, X } from 'lucide-react'
4
+ import { useId, useState } from 'react'
5
+ import { cn } from '~/lib/utils'
6
+ import { highlightMatches } from '~/util/highlightMatches'
7
+ import { PersonAvatar } from './FeedRow'
8
+
9
+ export const FIELD_BOX =
10
+ 'flex min-h-10 w-full items-center gap-2 rounded-[10px] border border-border bg-card px-3 text-sm transition-[box-shadow,border-color] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/30'
11
+
12
+ export const FIELD_BAD =
13
+ 'border-destructive ring-[3px] ring-destructive/15 focus-within:border-destructive focus-within:ring-destructive/15'
14
+
15
+ export interface SuggestChip {
16
+ key: string
17
+ label: string
18
+ icon?: React.ReactNode
19
+ }
20
+
21
+ export interface SuggestOption {
22
+ key: string
23
+ label: string
24
+ sub?: string
25
+ icon?: React.ReactNode
26
+ /** The typed text is exactly this option, so Enter takes it unasked. */
27
+ exact?: boolean
28
+ }
29
+
30
+ /**
31
+ * Chips with a text box that suggests what to add. ↑↓ move through the
32
+ * suggestions, Enter adds one (or hands the typed text to `onEnterText`),
33
+ * Backspace in an empty box takes the last chip off, Esc closes the list.
34
+ * With `textFirst`, nothing is highlighted until an arrow moves onto a
35
+ * suggestion: Enter adds the typed text, or the option it exactly names.
36
+ */
37
+ export function SuggestInput({
38
+ id,
39
+ chips,
40
+ onRemove,
41
+ placeholder,
42
+ suggest,
43
+ onPick,
44
+ onEnterText,
45
+ invalid,
46
+ describedBy,
47
+ disabled,
48
+ atLimit,
49
+ textFirst = false,
50
+ }: {
51
+ id: string
52
+ chips: SuggestChip[]
53
+ onRemove: (key: string) => void
54
+ placeholder: string
55
+ /** What to offer for the text typed so far. */
56
+ suggest: (query: string) => SuggestOption[]
57
+ onPick: (key: string) => void
58
+ /** Enter with no suggestion chosen; return true when the text was used. */
59
+ onEnterText?: (text: string) => boolean
60
+ invalid?: boolean
61
+ describedBy?: string
62
+ disabled?: boolean
63
+ /** Nothing more can be added; chips can still come off. */
64
+ atLimit?: boolean
65
+ textFirst?: boolean
66
+ }) {
67
+ const listId = useId()
68
+ const [query, setQuery] = useState('')
69
+ const [active, setActive] = useState(0)
70
+ const [open, setOpen] = useState(false)
71
+ // An arrow moved the highlight since the text last changed.
72
+ const [moved, setMoved] = useState(false)
73
+ const options = query.trim() ? suggest(query.trim()) : []
74
+ const shown = open && options.length > 0
75
+ const activeIndex =
76
+ textFirst && !moved ? -1 : Math.min(active, options.length - 1)
77
+ const optionId = (i: number) => `${listId}-${i}`
78
+
79
+ const pick = (key: string) => {
80
+ onPick(key)
81
+ setQuery('')
82
+ setActive(0)
83
+ setMoved(false)
84
+ }
85
+
86
+ const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
87
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
88
+ e.preventDefault()
89
+ // After Esc the list is shut; an arrow brings it back.
90
+ if (!shown) {
91
+ setOpen(true)
92
+ return
93
+ }
94
+ const step = e.key === 'ArrowDown' ? 1 : -1
95
+ setActive(Math.min(options.length - 1, Math.max(0, activeIndex + step)))
96
+ setMoved(true)
97
+ } else if (e.key === 'Enter') {
98
+ // Never submits the form: Enter here means "add this".
99
+ e.preventDefault()
100
+ const option = !shown
101
+ ? undefined
102
+ : activeIndex >= 0
103
+ ? options[activeIndex]
104
+ : options.find((o) => o.exact)
105
+ if (option) pick(option.key)
106
+ else if (query.trim() && onEnterText?.(query)) setQuery('')
107
+ } else if (e.key === 'Backspace' && !query && chips.length > 0) {
108
+ onRemove(chips[chips.length - 1]!.key)
109
+ } else if (e.key === 'Escape' && shown) {
110
+ // The dialog leaves an open list's Escape to it (see ToolFormDialog).
111
+ e.preventDefault()
112
+ setOpen(false)
113
+ }
114
+ }
115
+
116
+ return (
117
+ <div className="relative">
118
+ <div
119
+ className={cn(
120
+ FIELD_BOX,
121
+ 'flex-wrap gap-1.5 px-2 py-[5px]',
122
+ invalid && FIELD_BAD,
123
+ )}
124
+ >
125
+ {chips.map((chip) => (
126
+ <span
127
+ key={chip.key}
128
+ data-testid="picker-chip"
129
+ className="inline-flex h-7 items-center gap-1.5 rounded-full bg-secondary pr-1 pl-[3px] text-[13px] font-semibold"
130
+ >
131
+ {chip.icon}
132
+ <span data-testid="picker-chip-label">{chip.label}</span>
133
+ <button
134
+ type="button"
135
+ aria-label={`Remove ${chip.label}`}
136
+ disabled={disabled}
137
+ onClick={() => onRemove(chip.key)}
138
+ className="inline-flex size-5 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
139
+ >
140
+ <X aria-hidden="true" className="size-3" />
141
+ </button>
142
+ </span>
143
+ ))}
144
+ <input
145
+ id={id}
146
+ type="text"
147
+ role="combobox"
148
+ autoComplete="off"
149
+ aria-expanded={shown}
150
+ aria-controls={shown ? listId : undefined}
151
+ aria-autocomplete="list"
152
+ aria-activedescendant={
153
+ shown && activeIndex >= 0 ? optionId(activeIndex) : undefined
154
+ }
155
+ aria-invalid={invalid || undefined}
156
+ aria-describedby={describedBy}
157
+ disabled={disabled || atLimit}
158
+ placeholder={placeholder}
159
+ value={query}
160
+ onChange={(e) => {
161
+ setQuery(e.target.value)
162
+ setActive(0)
163
+ setMoved(false)
164
+ setOpen(true)
165
+ }}
166
+ onFocus={() => setOpen(true)}
167
+ onBlur={() => setOpen(false)}
168
+ onKeyDown={onKeyDown}
169
+ className="h-7 min-w-32 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground"
170
+ />
171
+ <Search
172
+ aria-hidden="true"
173
+ className="size-4 text-muted-foreground/70"
174
+ />
175
+ </div>
176
+ {shown && (
177
+ <ul
178
+ id={listId}
179
+ role="listbox"
180
+ className="absolute inset-x-0 top-full z-10 mt-0.5 flex flex-col rounded-[10px] border border-border bg-card p-1 shadow-lg"
181
+ >
182
+ {options.map((option, i) => (
183
+ <li
184
+ key={option.key}
185
+ id={optionId(i)}
186
+ role="option"
187
+ aria-selected={i === activeIndex}
188
+ // Keeps the caret in the box, so the next one can be typed at once.
189
+ onMouseDown={(e) => e.preventDefault()}
190
+ onClick={() => pick(option.key)}
191
+ onMouseEnter={() => setActive(i)}
192
+ className={cn(
193
+ 'flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-[7px] text-[13.5px] hover:bg-accent',
194
+ i === activeIndex && 'bg-accent',
195
+ )}
196
+ >
197
+ {option.icon}
198
+ <span className="flex flex-col leading-tight">
199
+ <b className="font-semibold">
200
+ {highlightMatches(option.label, query.trim())}
201
+ </b>
202
+ {option.sub && (
203
+ <span className="text-xs text-muted-foreground">
204
+ {option.sub}
205
+ </span>
206
+ )}
207
+ </span>
208
+ </li>
209
+ ))}
210
+ </ul>
211
+ )}
212
+ </div>
213
+ )
214
+ }
215
+
216
+ export function personName(person: Person): string {
217
+ return `${person.firstName} ${person.lastName}`.trim() || person.slug
218
+ }
219
+
220
+ const MAX_OWNERS = 5
221
+ const MAX_SUGGESTIONS = 6
222
+ const OWNER_NAME_MAX = 80
223
+ /** The option that adds the typed text as an owner outside the directory. */
224
+ const NAMED = 'named:'
225
+
226
+ function ownerKey(owner: PersonRef): string {
227
+ return owner.slug ?? `${NAMED}${owner.name}`
228
+ }
229
+
230
+ /**
231
+ * Owners from the person directory, searched by name or email, and people it
232
+ * does not know, by name: chips, remove.
233
+ */
234
+ export function OwnerPicker({
235
+ id,
236
+ persons,
237
+ value,
238
+ onChange,
239
+ invalid,
240
+ describedBy,
241
+ disabled,
242
+ }: {
243
+ id: string
244
+ persons: Person[]
245
+ /** In the order they were added; `slug` is null outside the directory. */
246
+ value: PersonRef[]
247
+ onChange: (owners: PersonRef[]) => void
248
+ invalid?: boolean
249
+ describedBy?: string
250
+ disabled?: boolean
251
+ }) {
252
+ const full = value.length >= MAX_OWNERS
253
+ const taken = new Set(value.map(ownerKey))
254
+
255
+ return (
256
+ <SuggestInput
257
+ id={id}
258
+ chips={value.map((owner) => ({
259
+ key: ownerKey(owner),
260
+ label: owner.name,
261
+ icon: <PersonAvatar name={owner.name} />,
262
+ }))}
263
+ onRemove={(key) => onChange(value.filter((o) => ownerKey(o) !== key))}
264
+ placeholder={full ? 'Five owners at most' : 'Add a person'}
265
+ disabled={disabled}
266
+ atLimit={full}
267
+ invalid={invalid}
268
+ describedBy={describedBy}
269
+ suggest={(query) => {
270
+ const words = query.toLowerCase().split(/\s+/)
271
+ const people = persons
272
+ .filter((p) => !taken.has(p.slug))
273
+ .filter((p) => {
274
+ const text = `${personName(p)} ${p.email ?? p.slug}`.toLowerCase()
275
+ return words.every((w) => text.includes(w))
276
+ })
277
+ .slice(0, MAX_SUGGESTIONS)
278
+ .map((p) => ({
279
+ key: p.slug,
280
+ label: personName(p),
281
+ sub: p.email ?? p.slug,
282
+ icon: <PersonAvatar name={personName(p)} />,
283
+ }))
284
+ // Someone the directory does not know is still someone to ask.
285
+ const name = query.trim().slice(0, OWNER_NAME_MAX)
286
+ const known = people.some(
287
+ (p) => p.label.toLowerCase() === name.toLowerCase(),
288
+ )
289
+ if (known || taken.has(`${NAMED}${name}`)) return people
290
+ return [
291
+ ...people,
292
+ {
293
+ key: `${NAMED}${name}`,
294
+ label: `Add “${name}”`,
295
+ sub: 'Not in the people list',
296
+ icon: <PersonAvatar name={name} />,
297
+ },
298
+ ]
299
+ }}
300
+ onPick={(key) => {
301
+ if (key.startsWith(NAMED)) {
302
+ onChange([...value, { slug: null, name: key.slice(NAMED.length) }])
303
+ return
304
+ }
305
+ const person = persons.find((p) => p.slug === key)
306
+ if (person)
307
+ onChange([...value, { slug: key, name: personName(person) }])
308
+ }}
309
+ />
310
+ )
311
+ }
@@ -0,0 +1,87 @@
1
+ import { Loader2, Star } from 'lucide-react'
2
+ import { useEffect, useState } from 'react'
3
+ import { cn } from '~/lib/utils'
4
+
5
+ /** A save quicker than this shows no spinner at all: the star already moved. */
6
+ const BUSY_AFTER_MS = 400
7
+
8
+ /**
9
+ * The star on rows, tiles and the card: pins the tool to My tools, or takes it
10
+ * off. The card's is `labelled`, a bordered button that says Pin or Pinned;
11
+ * a feed row's is `small`, a 32 px target in its meta line.
12
+ */
13
+ export function PinButton({
14
+ pinned,
15
+ name,
16
+ onToggle,
17
+ labelled = false,
18
+ small = false,
19
+ busy = false,
20
+ }: {
21
+ pinned: boolean
22
+ name: string
23
+ onToggle: () => void
24
+ labelled?: boolean
25
+ small?: boolean
26
+ /** The pin is being saved; the star shows the state it will have. */
27
+ busy?: boolean
28
+ }) {
29
+ const [slow, setSlow] = useState(false)
30
+ useEffect(() => {
31
+ if (!busy) return
32
+ const timer = setTimeout(() => setSlow(true), BUSY_AFTER_MS)
33
+ return () => {
34
+ clearTimeout(timer)
35
+ setSlow(false)
36
+ }
37
+ }, [busy])
38
+ const showBusy = busy && slow
39
+
40
+ return (
41
+ <button
42
+ type="button"
43
+ data-testid="pin-button"
44
+ aria-pressed={pinned}
45
+ aria-busy={showBusy || undefined}
46
+ // One name; aria-pressed says whether it is pinned.
47
+ aria-label={`Pin ${name}`}
48
+ onClick={(e) => {
49
+ e.stopPropagation()
50
+ onToggle()
51
+ }}
52
+ className={cn(
53
+ 'relative z-10 inline-flex shrink-0 items-center justify-center rounded-[9px] transition-colors',
54
+ 'hover:bg-accent hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
55
+ labelled
56
+ ? cn(
57
+ 'h-10 gap-[7px] rounded-[10px] border px-3.5 text-[13.5px] font-bold whitespace-nowrap',
58
+ pinned
59
+ ? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'
60
+ : 'border-border bg-card text-foreground',
61
+ )
62
+ : cn(
63
+ 'text-muted-foreground',
64
+ small
65
+ ? 'size-8 rounded-lg focus-visible:outline-offset-0'
66
+ : 'size-[34px]',
67
+ ),
68
+ )}
69
+ >
70
+ {showBusy ? (
71
+ <Loader2
72
+ aria-hidden="true"
73
+ className={cn('animate-spin', labelled ? 'size-4' : 'size-[18px]')}
74
+ />
75
+ ) : (
76
+ <Star
77
+ aria-hidden="true"
78
+ className={cn(
79
+ labelled ? 'size-4' : small ? 'size-[15px]' : 'size-[18px]',
80
+ pinned && 'fill-amber-400 text-amber-600',
81
+ )}
82
+ />
83
+ )}
84
+ {labelled && (pinned ? 'Pinned' : 'Pin')}
85
+ </button>
86
+ )
87
+ }
@@ -0,0 +1,83 @@
1
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
2
+ import { ExternalLink, GripVertical } from 'lucide-react'
3
+ import { cn } from '~/lib/utils'
4
+ import { PersonAvatar, ToolThumb } from './FeedRow'
5
+ import { PinButton } from './PinButton'
6
+
7
+ /**
8
+ * A pinned tool: screenshot first, then name and author. A click opens the
9
+ * site. Drag the tile, or press Alt+↑/↓ on it, to move it.
10
+ */
11
+ export function ShelfTile({
12
+ tool,
13
+ dragging,
14
+ onUnpin,
15
+ onMove,
16
+ dragProps,
17
+ }: {
18
+ tool: CommunityTool
19
+ dragging: boolean
20
+ onUnpin: () => void
21
+ /** -1 moves it one place earlier, +1 one place later. */
22
+ onMove: (step: -1 | 1) => void
23
+ dragProps: React.HTMLAttributes<HTMLDivElement>
24
+ }) {
25
+ return (
26
+ <div
27
+ data-testid="shelf-tile"
28
+ draggable
29
+ {...dragProps}
30
+ className={cn(
31
+ 'group relative overflow-hidden rounded-[14px] border border-border bg-card transition-[border-color,box-shadow]',
32
+ 'hover:border-primary/40 hover:shadow-sm focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/30',
33
+ dragging && 'opacity-40',
34
+ )}
35
+ >
36
+ <a
37
+ href={tool.url}
38
+ target="_blank"
39
+ rel="noopener noreferrer"
40
+ aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown"
41
+ onKeyDown={(e) => {
42
+ if (!e.altKey) return
43
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
44
+ e.preventDefault()
45
+ onMove(e.key === 'ArrowUp' ? -1 : 1)
46
+ }
47
+ }}
48
+ className="block focus-visible:outline-none"
49
+ >
50
+ <span className="relative block aspect-[16/9.5] border-b border-border">
51
+ <ToolThumb tool={tool} size={512} className="size-full border-0" />
52
+ <span className="absolute right-2.5 bottom-2.5 inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-extrabold text-primary-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
53
+ <ExternalLink aria-hidden="true" className="size-4" />
54
+ Open site
55
+ </span>
56
+ </span>
57
+ <span className="block py-2.5 pr-12 pl-3">
58
+ <span
59
+ data-testid="shelf-tile-name"
60
+ className="block truncate text-sm font-bold"
61
+ >
62
+ {tool.name}
63
+ </span>
64
+ <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[12.5px] font-medium text-muted-foreground">
65
+ {tool.owners[0] && <PersonAvatar name={tool.owners[0].name} />}
66
+ <span className="truncate">
67
+ {tool.owners.map((o) => o.name).join(', ')}
68
+ </span>
69
+ </span>
70
+ </span>
71
+ </a>
72
+ <span
73
+ aria-hidden="true"
74
+ className="absolute top-2 right-2 inline-flex size-7 cursor-grab items-center justify-center rounded-md bg-card/90 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
75
+ >
76
+ <GripVertical className="size-4" />
77
+ </span>
78
+ <div className="absolute right-2 bottom-2">
79
+ <PinButton pinned name={tool.name} onToggle={onUnpin} />
80
+ </div>
81
+ </div>
82
+ )
83
+ }