@igstack/app-catalog-frontend-core 6.10.2 → 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 (207) hide show
  1. package/dist/esm/__tests__/modules/auth/AuthContext.test.d.ts +1 -0
  2. package/dist/esm/__tests__/modules/auth/useAuthActions.logout.test.d.ts +1 -0
  3. package/dist/esm/__tests__/modules/communityTools/likedBy.test.d.ts +1 -0
  4. package/dist/esm/__tests__/modules/communityTools/timeAgo.test.d.ts +1 -0
  5. package/dist/esm/__tests__/modules/communityTools/useTimeUp.test.d.ts +1 -0
  6. package/dist/esm/api/infra/trpc.d.ts +419 -0
  7. package/dist/esm/api/infra/trpc.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -1
  9. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.d.ts +2 -4
  10. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +29 -101
  11. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  12. package/dist/esm/modules/appCatalog/ui/catalog/DetailDialogShell.d.ts +26 -0
  13. package/dist/esm/modules/appCatalog/ui/catalog/DetailDialogShell.js +108 -0
  14. package/dist/esm/modules/appCatalog/ui/catalog/DetailDialogShell.js.map +1 -0
  15. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.d.ts +2 -1
  16. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  17. package/dist/esm/modules/appCatalog/ui/components/SectionTabs.d.ts +8 -0
  18. package/dist/esm/modules/appCatalog/ui/components/SectionTabs.js +73 -0
  19. package/dist/esm/modules/appCatalog/ui/components/SectionTabs.js.map +1 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +19 -2
  21. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +3 -2
  23. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +4 -4
  24. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -1
  25. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +2 -1
  26. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +2 -2
  27. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -1
  28. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +3 -2
  29. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +13 -9
  30. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -1
  31. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +4 -2
  32. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +10 -4
  33. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -1
  34. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +12 -0
  35. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +4 -0
  36. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -1
  37. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +1 -0
  38. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -1
  39. package/dist/esm/modules/appCatalog/ui/detail/useCommunityToolCount.d.ts +6 -0
  40. package/dist/esm/modules/appCatalog/ui/detail/useCommunityToolCount.js +16 -0
  41. package/dist/esm/modules/appCatalog/ui/detail/useCommunityToolCount.js.map +1 -0
  42. package/dist/esm/modules/appCatalog/ui/layout/AppCatalogLayout.js +2 -2
  43. package/dist/esm/modules/appCatalog/ui/layout/AppCatalogLayout.js.map +1 -1
  44. package/dist/esm/modules/auth/AuthContext.d.ts +1 -0
  45. package/dist/esm/modules/auth/AuthContext.js +16 -8
  46. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  47. package/dist/esm/modules/auth/ui/LoginModal.js +16 -8
  48. package/dist/esm/modules/auth/ui/LoginModal.js.map +1 -1
  49. package/dist/esm/modules/auth/useAuthActions.js +3 -1
  50. package/dist/esm/modules/auth/useAuthActions.js.map +1 -1
  51. package/dist/esm/modules/communityTools/likedBy.d.ts +8 -0
  52. package/dist/esm/modules/communityTools/likedBy.js +23 -0
  53. package/dist/esm/modules/communityTools/likedBy.js.map +1 -0
  54. package/dist/esm/modules/communityTools/timeAgo.d.ts +2 -0
  55. package/dist/esm/modules/communityTools/timeAgo.js +18 -0
  56. package/dist/esm/modules/communityTools/timeAgo.js.map +1 -0
  57. package/dist/esm/modules/communityTools/ui/CardScreenshot.d.ts +13 -0
  58. package/dist/esm/modules/communityTools/ui/CardScreenshot.js +112 -0
  59. package/dist/esm/modules/communityTools/ui/CardScreenshot.js.map +1 -0
  60. package/dist/esm/modules/communityTools/ui/CardTitleBlock.d.ts +8 -0
  61. package/dist/esm/modules/communityTools/ui/CardTitleBlock.js +59 -0
  62. package/dist/esm/modules/communityTools/ui/CardTitleBlock.js.map +1 -0
  63. package/dist/esm/modules/communityTools/ui/CommunityPage.d.ts +12 -0
  64. package/dist/esm/modules/communityTools/ui/CommunityPage.js +547 -0
  65. package/dist/esm/modules/communityTools/ui/CommunityPage.js.map +1 -0
  66. package/dist/esm/modules/communityTools/ui/FeedRow.d.ts +42 -0
  67. package/dist/esm/modules/communityTools/ui/FeedRow.js +260 -0
  68. package/dist/esm/modules/communityTools/ui/FeedRow.js.map +1 -0
  69. package/dist/esm/modules/communityTools/ui/FeedSortControl.d.ts +17 -0
  70. package/dist/esm/modules/communityTools/ui/FeedSortControl.js +84 -0
  71. package/dist/esm/modules/communityTools/ui/FeedSortControl.js.map +1 -0
  72. package/dist/esm/modules/communityTools/ui/LifecycleBadge.d.ts +7 -0
  73. package/dist/esm/modules/communityTools/ui/LifecycleBadge.js +50 -0
  74. package/dist/esm/modules/communityTools/ui/LifecycleBadge.js.map +1 -0
  75. package/dist/esm/modules/communityTools/ui/LikeButton.d.ts +9 -0
  76. package/dist/esm/modules/communityTools/ui/LikeButton.js +56 -0
  77. package/dist/esm/modules/communityTools/ui/LikeButton.js.map +1 -0
  78. package/dist/esm/modules/communityTools/ui/MarginPreview.d.ts +18 -0
  79. package/dist/esm/modules/communityTools/ui/MarginPreview.js +115 -0
  80. package/dist/esm/modules/communityTools/ui/MarginPreview.js.map +1 -0
  81. package/dist/esm/modules/communityTools/ui/MyToolsShelf.d.ts +12 -0
  82. package/dist/esm/modules/communityTools/ui/MyToolsShelf.js +88 -0
  83. package/dist/esm/modules/communityTools/ui/MyToolsShelf.js.map +1 -0
  84. package/dist/esm/modules/communityTools/ui/OwnerPicker.d.ts +56 -0
  85. package/dist/esm/modules/communityTools/ui/OwnerPicker.js +245 -0
  86. package/dist/esm/modules/communityTools/ui/OwnerPicker.js.map +1 -0
  87. package/dist/esm/modules/communityTools/ui/PinButton.d.ts +14 -0
  88. package/dist/esm/modules/communityTools/ui/PinButton.js +72 -0
  89. package/dist/esm/modules/communityTools/ui/PinButton.js.map +1 -0
  90. package/dist/esm/modules/communityTools/ui/ShelfTile.d.ts +13 -0
  91. package/dist/esm/modules/communityTools/ui/ShelfTile.js +81 -0
  92. package/dist/esm/modules/communityTools/ui/ShelfTile.js.map +1 -0
  93. package/dist/esm/modules/communityTools/ui/TagPicker.d.ts +21 -0
  94. package/dist/esm/modules/communityTools/ui/TagPicker.js +87 -0
  95. package/dist/esm/modules/communityTools/ui/TagPicker.js.map +1 -0
  96. package/dist/esm/modules/communityTools/ui/TagRow.d.ts +22 -0
  97. package/dist/esm/modules/communityTools/ui/TagRow.js +92 -0
  98. package/dist/esm/modules/communityTools/ui/TagRow.js.map +1 -0
  99. package/dist/esm/modules/communityTools/ui/Toast.d.ts +19 -0
  100. package/dist/esm/modules/communityTools/ui/Toast.js +87 -0
  101. package/dist/esm/modules/communityTools/ui/Toast.js.map +1 -0
  102. package/dist/esm/modules/communityTools/ui/ToolCard.d.ts +22 -0
  103. package/dist/esm/modules/communityTools/ui/ToolCard.js +467 -0
  104. package/dist/esm/modules/communityTools/ui/ToolCard.js.map +1 -0
  105. package/dist/esm/modules/communityTools/ui/ToolFormDialog.d.ts +32 -0
  106. package/dist/esm/modules/communityTools/ui/ToolFormDialog.js +765 -0
  107. package/dist/esm/modules/communityTools/ui/ToolFormDialog.js.map +1 -0
  108. package/dist/esm/modules/communityTools/ui/ToolSearch.d.ts +15 -0
  109. package/dist/esm/modules/communityTools/ui/ToolSearch.js +92 -0
  110. package/dist/esm/modules/communityTools/ui/ToolSearch.js.map +1 -0
  111. package/dist/esm/modules/communityTools/useCommunityTools.d.ts +157 -0
  112. package/dist/esm/modules/communityTools/useCommunityTools.js +122 -0
  113. package/dist/esm/modules/communityTools/useCommunityTools.js.map +1 -0
  114. package/dist/esm/modules/communityTools/useCommunityToolsConfig.d.ts +10 -0
  115. package/dist/esm/modules/communityTools/useCommunityToolsConfig.js +20 -0
  116. package/dist/esm/modules/communityTools/useCommunityToolsConfig.js.map +1 -0
  117. package/dist/esm/modules/communityTools/useTimeUp.d.ts +5 -0
  118. package/dist/esm/modules/communityTools/useTimeUp.js +13 -0
  119. package/dist/esm/modules/communityTools/useTimeUp.js.map +1 -0
  120. package/dist/esm/node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/classic/schemas.js +2 -2
  121. package/dist/esm/node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/core/schemas.js +1 -1
  122. package/dist/esm/routeTree.gen.d.ts +96 -3
  123. package/dist/esm/routeTree.gen.js +19 -0
  124. package/dist/esm/routeTree.gen.js.map +1 -1
  125. package/dist/esm/routes/_layout/community.$slug.d.ts +22 -0
  126. package/dist/esm/routes/_layout/community._slug.js +10 -0
  127. package/dist/esm/routes/_layout/community._slug.js.map +1 -0
  128. package/dist/esm/routes/_layout/community.d.ts +23 -0
  129. package/dist/esm/routes/_layout/community.js +71 -0
  130. package/dist/esm/routes/_layout/community.js.map +1 -0
  131. package/dist/esm/ui/components/header/Header.js +7 -3
  132. package/dist/esm/ui/components/header/Header.js.map +1 -1
  133. package/dist/esm/util/highlightMatches.d.ts +2 -0
  134. package/dist/esm/util/highlightMatches.js +32 -0
  135. package/dist/esm/util/highlightMatches.js.map +1 -0
  136. package/dist/index.css +6 -0
  137. package/package.json +5 -5
  138. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +3 -0
  139. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +3 -0
  140. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -0
  141. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +4 -0
  142. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +3 -0
  143. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +3 -0
  144. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +3 -0
  145. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -0
  146. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +3 -0
  147. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +3 -0
  148. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +10 -3
  149. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +4 -1
  150. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +12 -2
  151. package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +1 -0
  152. package/src/__tests__/modules/auth/AuthContext.test.tsx +55 -0
  153. package/src/__tests__/modules/auth/useAuthActions.logout.test.ts +49 -0
  154. package/src/__tests__/modules/communityTools/likedBy.test.ts +45 -0
  155. package/src/__tests__/modules/communityTools/timeAgo.test.ts +28 -0
  156. package/src/__tests__/modules/communityTools/useTimeUp.test.ts +31 -0
  157. package/src/__tests__/ui/components/header/Header.login.test.tsx +4 -4
  158. package/src/api/infra/trpc.ts +2 -0
  159. package/src/index.css +6 -0
  160. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +33 -176
  161. package/src/modules/appCatalog/ui/catalog/DetailDialogShell.tsx +199 -0
  162. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +2 -1
  163. package/src/modules/appCatalog/ui/components/SectionTabs.tsx +81 -0
  164. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +17 -2
  165. package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +9 -4
  166. package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +6 -3
  167. package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +15 -10
  168. package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +10 -5
  169. package/src/modules/appCatalog/ui/detail/communityDraft.ts +16 -0
  170. package/src/modules/appCatalog/ui/detail/composeComments.ts +1 -0
  171. package/src/modules/appCatalog/ui/detail/useCommunityToolCount.ts +18 -0
  172. package/src/modules/appCatalog/ui/layout/AppCatalogLayout.tsx +2 -2
  173. package/src/modules/auth/AuthContext.tsx +17 -8
  174. package/src/modules/auth/ui/LoginModal.tsx +7 -2
  175. package/src/modules/auth/useAuthActions.ts +5 -2
  176. package/src/modules/communityTools/likedBy.ts +32 -0
  177. package/src/modules/communityTools/timeAgo.ts +16 -0
  178. package/src/modules/communityTools/ui/CardScreenshot.tsx +124 -0
  179. package/src/modules/communityTools/ui/CardTitleBlock.tsx +58 -0
  180. package/src/modules/communityTools/ui/CommunityPage.tsx +626 -0
  181. package/src/modules/communityTools/ui/FeedRow.tsx +310 -0
  182. package/src/modules/communityTools/ui/FeedSortControl.tsx +97 -0
  183. package/src/modules/communityTools/ui/LifecycleBadge.tsx +44 -0
  184. package/src/modules/communityTools/ui/LikeButton.tsx +56 -0
  185. package/src/modules/communityTools/ui/MarginPreview.tsx +143 -0
  186. package/src/modules/communityTools/ui/MyToolsShelf.tsx +112 -0
  187. package/src/modules/communityTools/ui/OwnerPicker.tsx +311 -0
  188. package/src/modules/communityTools/ui/PinButton.tsx +87 -0
  189. package/src/modules/communityTools/ui/ShelfTile.tsx +83 -0
  190. package/src/modules/communityTools/ui/TagPicker.tsx +118 -0
  191. package/src/modules/communityTools/ui/TagRow.tsx +118 -0
  192. package/src/modules/communityTools/ui/Toast.tsx +105 -0
  193. package/src/modules/communityTools/ui/ToolCard.tsx +519 -0
  194. package/src/modules/communityTools/ui/ToolFormDialog.tsx +922 -0
  195. package/src/modules/communityTools/ui/ToolSearch.tsx +111 -0
  196. package/src/modules/communityTools/useCommunityTools.ts +146 -0
  197. package/src/modules/communityTools/useCommunityToolsConfig.ts +26 -0
  198. package/src/modules/communityTools/useTimeUp.ts +14 -0
  199. package/src/routeTree.gen.ts +52 -0
  200. package/src/routes/_layout/community.$slug.tsx +7 -0
  201. package/src/routes/_layout/community.tsx +97 -0
  202. package/src/ui/components/header/Header.tsx +9 -3
  203. package/src/util/highlightMatches.tsx +34 -0
  204. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.d.ts +0 -12
  205. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js +0 -56
  206. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js.map +0 -1
  207. package/src/modules/appCatalog/ui/components/ViewToggle.tsx +0 -59
@@ -0,0 +1,922 @@
1
+ import type { ToolPreview } from '@igstack/app-catalog-backend-core'
2
+ import type {
3
+ CommunityTag,
4
+ CommunityTool,
5
+ PersonRef,
6
+ } from '@igstack/app-catalog-shared-core'
7
+ import { TAG_VALUE_MAX, hostOf } from '@igstack/app-catalog-shared-core'
8
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
9
+ import { Link } from '@tanstack/react-router'
10
+ import {
11
+ AlertCircle,
12
+ AlertTriangle,
13
+ Archive,
14
+ Camera,
15
+ CheckCircle2,
16
+ ImageOff,
17
+ Loader2,
18
+ X,
19
+ } from 'lucide-react'
20
+ import { useEffect, useId, useRef, useState } from 'react'
21
+ import { useTRPC } from '~/api/infra/trpc'
22
+ import { cn } from '~/lib/utils'
23
+ import { useAppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'
24
+ import { MarkdownText } from '~/modules/appCatalog/ui/components/MarkdownText'
25
+ import { useAuth } from '~/modules/auth'
26
+ import { useDismissable } from '~/modules/dismiss/DismissStack'
27
+ import { Dialog, DialogContent, DialogTitle } from '~/ui/dialog'
28
+ import { formatCardDate } from './CardScreenshot'
29
+ import { screenshotUrl } from './FeedRow'
30
+ import { FIELD_BAD, FIELD_BOX, OwnerPicker, personName } from './OwnerPicker'
31
+ import { TagPicker } from './TagPicker'
32
+
33
+ /** How long the address must sit still before the site is read. */
34
+ const PREVIEW_DELAY_MS = 400
35
+
36
+ const NAME_MAX = 80
37
+ const SUMMARY_MAX = 160
38
+ const DESCRIPTION_MAX = 5000
39
+
40
+ export const GHOST_BUTTON =
41
+ 'inline-flex h-[34px] items-center gap-[7px] rounded-[10px] border border-border bg-card px-[11px] text-[13px] font-bold whitespace-nowrap hover:border-primary/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:opacity-50'
42
+
43
+ export const PRIMARY_BUTTON =
44
+ 'inline-flex h-[34px] items-center gap-2 rounded-[10px] bg-primary px-4 text-[13.5px] font-extrabold whitespace-nowrap text-primary-foreground shadow-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-50'
45
+
46
+ const LABEL = 'text-[13px] font-bold'
47
+ const HINT = 'text-[12.5px] text-muted-foreground'
48
+
49
+ type Field = 'name' | 'owners' | 'tags'
50
+
51
+ export interface ToolFormDialogProps {
52
+ mode: 'add' | 'edit'
53
+ /** The tool being edited. */
54
+ initial?: CommunityTool
55
+ /** What the visitor searched for: a first guess at the name. */
56
+ prefillName?: string
57
+ /** Published, saved or archived. */
58
+ onDone: (slug: string) => void
59
+ onClose: () => void
60
+ /** The address is already listed, and they chose to look at that card. */
61
+ onOpenCard?: (slug: string) => void
62
+ }
63
+
64
+ /** A field's error, next to it; the form points at the first one on submit. */
65
+ function FieldError({ id, children }: { id: string; children: string }) {
66
+ return (
67
+ <p
68
+ id={id}
69
+ role="alert"
70
+ className="flex items-center gap-1.5 text-[12.5px] font-bold text-destructive"
71
+ >
72
+ <AlertCircle aria-hidden="true" className="size-3.5 shrink-0" />
73
+ {children}
74
+ </p>
75
+ )
76
+ }
77
+
78
+ /**
79
+ * Add a tool, or edit one: the same form. The address comes first; once it
80
+ * sits still the backend reads the page, the title fills the name and a
81
+ * screenshot preview shows. A host already listed is caught before anything
82
+ * else is asked. Publish makes the tool live at once and puts it in the
83
+ * review list.
84
+ */
85
+ export function ToolFormDialog({
86
+ mode,
87
+ initial,
88
+ prefillName,
89
+ onDone,
90
+ onClose,
91
+ onOpenCard,
92
+ }: ToolFormDialogProps) {
93
+ const trpc = useTRPC()
94
+ const queryClient = useQueryClient()
95
+ const { user } = useAuth()
96
+ const { persons, resources } = useAppCatalogContext()
97
+ const list = useQuery(trpc.communityTools.list.queryOptions())
98
+ const formId = useId()
99
+ const ids = {
100
+ url: `${formId}-url`,
101
+ name: `${formId}-name`,
102
+ summary: `${formId}-summary`,
103
+ owners: `${formId}-owners`,
104
+ tags: `${formId}-tags`,
105
+ description: `${formId}-description`,
106
+ }
107
+ const errorId = (field: string) => `${field}-error`
108
+
109
+ const [url, setUrl] = useState(initial?.url ?? '')
110
+ const [name, setName] = useState(initial?.name ?? prefillName ?? '')
111
+ // The page title fills the name only until the visitor writes their own.
112
+ const [nameTouched, setNameTouched] = useState(mode === 'edit')
113
+ // The same, for the debounced read below, without restarting its timer.
114
+ const nameTouchedRef = useRef(nameTouched)
115
+ const [summary, setSummary] = useState(initial?.summary ?? '')
116
+ const [descriptionMd, setDescriptionMd] = useState(
117
+ initial?.descriptionMd ?? '',
118
+ )
119
+ const [owners, setOwners] = useState<PersonRef[]>(() => {
120
+ if (initial) return initial.owners
121
+ // The person adding it is the first owner, when the directory knows them.
122
+ const email = user?.email?.toLowerCase()
123
+ const me = email
124
+ ? persons.find(
125
+ (p) =>
126
+ p.email?.toLowerCase() === email || p.slug.toLowerCase() === email,
127
+ )
128
+ : undefined
129
+ return me ? [{ slug: me.slug, name: personName(me) }] : []
130
+ })
131
+ const [tags, setTags] = useState<CommunityTag[]>(initial?.tags ?? [])
132
+ const [tab, setTab] = useState<'write' | 'preview'>('write')
133
+ const [errors, setErrors] = useState<Partial<Record<Field, string>>>({})
134
+ const [archiving, setArchiving] = useState(false)
135
+
136
+ // The address as it was when the visitor stopped typing, and what reading it gave.
137
+ const [checked, setChecked] = useState(initial?.url ?? '')
138
+ const [preview, setPreview] = useState<{
139
+ url: string
140
+ data: ToolPreview | null
141
+ } | null>(null)
142
+ const [addAnywayFor, setAddAnywayFor] = useState<string | null>(null)
143
+
144
+ const previewMutation = useMutation(
145
+ trpc.communityTools.preview.mutationOptions(),
146
+ )
147
+ const { mutateAsync: readSite } = previewMutation
148
+ useEffect(() => {
149
+ const address = url.trim()
150
+ // Set once the address changes again: a slower answer for this one must
151
+ // not overwrite the newer address's preview or name.
152
+ let stale = false
153
+ const timer = setTimeout(() => {
154
+ setChecked(address)
155
+ if (!needsPreviewFor(address, mode, initial?.url)) return
156
+ readSite({ url: address }).then(
157
+ (data) => {
158
+ if (stale) return
159
+ setPreview({ url: address, data })
160
+ if (data.title && !nameTouchedRef.current) setName(data.title)
161
+ },
162
+ () => {
163
+ if (!stale) setPreview({ url: address, data: null })
164
+ },
165
+ )
166
+ }, PREVIEW_DELAY_MS)
167
+ return () => {
168
+ stale = true
169
+ clearTimeout(timer)
170
+ }
171
+ }, [url, mode, initial?.url, readSite])
172
+
173
+ const address = url.trim()
174
+ const validAddress = hostOf(address) !== null
175
+ const showBadAddress = checked !== '' && checked === address && !validAddress
176
+ const reading =
177
+ needsPreviewFor(address, mode, initial?.url) &&
178
+ (checked !== address || preview?.url !== address)
179
+ const current = preview?.url === address ? preview.data : null
180
+ const duplicate =
181
+ current?.duplicate && current.duplicate.slug !== initial?.slug
182
+ ? current.duplicate
183
+ : null
184
+ // An official catalog app on the same host outranks a community duplicate.
185
+ const officialApp = current?.officialApp ?? null
186
+ const blockedByDuplicate =
187
+ (officialApp !== null || duplicate !== null) && addAnywayFor !== address
188
+ const showDetails =
189
+ mode === 'edit' ||
190
+ (validAddress && !reading && !blockedByDuplicate) ||
191
+ (!validAddress && name.trim() !== '' && !showBadAddress)
192
+
193
+ const invalidate = () =>
194
+ queryClient.invalidateQueries({
195
+ queryKey: trpc.communityTools.pathKey(),
196
+ })
197
+ const add = useMutation(
198
+ trpc.communityTools.add.mutationOptions({ onSuccess: invalidate }),
199
+ )
200
+ const edit = useMutation(
201
+ trpc.communityTools.edit.mutationOptions({ onSuccess: invalidate }),
202
+ )
203
+ const archive = useMutation(
204
+ trpc.communityTools.archive.mutationOptions({ onSuccess: invalidate }),
205
+ )
206
+ const save = mode === 'add' ? add : edit
207
+ const busy = save.isPending || archive.isPending
208
+
209
+ const urlRef = useRef<HTMLInputElement>(null)
210
+
211
+ const submit = (e: React.FormEvent) => {
212
+ e.preventDefault()
213
+ // Publish stays disabled until the address is one; Enter in a field
214
+ // cannot submit past a disabled button either.
215
+ if (busy || !validAddress) return
216
+ const found: Partial<Record<Field, string>> = {}
217
+ if (!name.trim()) found.name = 'Give it the name colleagues call it.'
218
+ if (owners.length === 0) {
219
+ found.owners = 'Add at least one person to ask about it.'
220
+ }
221
+ setErrors(found)
222
+ const first = (['name', 'owners'] as const).find((f) => found[f])
223
+ if (first) {
224
+ document.getElementById(ids[first])?.focus()
225
+ return
226
+ }
227
+ const input = {
228
+ url: address,
229
+ name: name.trim(),
230
+ summary: summary.trim(),
231
+ descriptionMd,
232
+ ownerSlugs: owners.flatMap((o) => (o.slug ? [o.slug] : [])),
233
+ ownerNames: owners.flatMap((o) => (o.slug ? [] : [o.name])),
234
+ tags,
235
+ }
236
+ if (mode === 'add') {
237
+ add.mutate(input, { onSuccess: (tool) => onDone(tool.slug) })
238
+ } else if (initial) {
239
+ edit.mutate(
240
+ { ...input, slug: initial.slug },
241
+ { onSuccess: (tool) => onDone(tool.slug) },
242
+ )
243
+ }
244
+ }
245
+
246
+ const contentRef = useRef<HTMLDivElement>(null)
247
+ // On the dismiss stack, so Escape closes this form and not the card under
248
+ // it; while it saves, Escape does nothing at all.
249
+ useDismissable(contentRef, () => {
250
+ if (!busy) onClose()
251
+ })
252
+
253
+ const officialApps = resources.filter((r) => !r.parentSlug)
254
+ const knownTopics = [
255
+ ...new Set(
256
+ (list.data ?? []).flatMap((t) =>
257
+ t.tags.filter((g) => g.kind === 'topic').map((g) => g.value),
258
+ ),
259
+ ),
260
+ ].sort()
261
+
262
+ const title = mode === 'add' ? 'Add a tool' : 'Edit tool'
263
+ const serverError = save.error ?? archive.error
264
+
265
+ return (
266
+ <Dialog
267
+ open
268
+ onOpenChange={(open) => {
269
+ if (!open && !busy) onClose()
270
+ }}
271
+ >
272
+ <DialogContent
273
+ ref={contentRef}
274
+ data-testid={mode === 'add' ? 'add-tool-dialog' : 'edit-tool-dialog'}
275
+ aria-describedby={undefined}
276
+ showCloseButton={false}
277
+ // Top-anchored: the form grows downward as the site is read, so
278
+ // nothing above the caret moves.
279
+ className="top-[6vh] max-h-[88vh] translate-y-0 gap-0 overflow-y-auto p-0 sm:max-w-[680px]"
280
+ onOpenAutoFocus={(e) => {
281
+ e.preventDefault()
282
+ urlRef.current?.focus()
283
+ }}
284
+ // Typing is lost on a stray click; only Cancel, Close or Esc leave.
285
+ onInteractOutside={(e) => e.preventDefault()}
286
+ onEscapeKeyDown={(e) => {
287
+ // An open suggestion list takes Escape for itself.
288
+ const target = e.target as Element | null
289
+ if (target?.closest('[aria-expanded="true"]')) e.preventDefault()
290
+ }}
291
+ >
292
+ <form onSubmit={submit} noValidate aria-busy={busy || undefined}>
293
+ <div className="flex items-center justify-between px-5 pt-4">
294
+ <DialogTitle className="font-serif text-[19px] leading-tight font-semibold">
295
+ {title}
296
+ </DialogTitle>
297
+ <button
298
+ type="button"
299
+ aria-label="Close"
300
+ disabled={busy}
301
+ onClick={onClose}
302
+ className="rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
303
+ >
304
+ <X aria-hidden="true" className="size-4" />
305
+ </button>
306
+ </div>
307
+
308
+ <div className="flex flex-col gap-3.5 px-5 pt-4 pb-1.5">
309
+ <div className="flex flex-col gap-1.5">
310
+ <label htmlFor={ids.url} className={LABEL}>
311
+ Site address
312
+ </label>
313
+ <div className={cn(FIELD_BOX, showBadAddress && FIELD_BAD)}>
314
+ <input
315
+ ref={urlRef}
316
+ id={ids.url}
317
+ type="url"
318
+ inputMode="url"
319
+ autoComplete="off"
320
+ placeholder="Paste the link to the tool"
321
+ value={url}
322
+ disabled={busy}
323
+ aria-invalid={showBadAddress || undefined}
324
+ aria-describedby={
325
+ showBadAddress ? errorId(ids.url) : `${ids.url}-hint`
326
+ }
327
+ onChange={(e) => setUrl(e.target.value)}
328
+ className="h-10 min-w-0 flex-1 bg-transparent font-mono text-[13px] outline-none placeholder:font-sans placeholder:text-muted-foreground"
329
+ />
330
+ {validAddress && reading && (
331
+ <span
332
+ data-testid="reading-site"
333
+ className="inline-flex shrink-0 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground"
334
+ >
335
+ <Loader2
336
+ aria-hidden="true"
337
+ className="size-3.5 animate-spin"
338
+ />
339
+ Reading the site…
340
+ </span>
341
+ )}
342
+ {validAddress &&
343
+ !reading &&
344
+ (current?.title || current?.screenshotDataUrl) && (
345
+ <span className="inline-flex shrink-0 items-center gap-1.5 text-[12.5px] font-semibold text-green-700 dark:text-green-400">
346
+ <CheckCircle2 aria-hidden="true" className="size-3.5" />
347
+ Found
348
+ </span>
349
+ )}
350
+ </div>
351
+ {showBadAddress ? (
352
+ <FieldError id={errorId(ids.url)}>
353
+ That is not a web address. Paste the full link, starting with
354
+ https://
355
+ </FieldError>
356
+ ) : (
357
+ !validAddress &&
358
+ !showDetails &&
359
+ !blockedByDuplicate && (
360
+ <p id={`${ids.url}-hint`} className={HINT}>
361
+ Paste it first. The name and screenshot fill in from the
362
+ site.
363
+ </p>
364
+ )
365
+ )}
366
+ </div>
367
+
368
+ {validAddress && reading && <ReadingSkeleton />}
369
+
370
+ {blockedByDuplicate && officialApp && (
371
+ <div
372
+ data-testid="official-app-warning"
373
+ role="alert"
374
+ className="flex gap-2.5 rounded-[10px] bg-amber-50 px-3.5 py-3 text-[13.5px] text-amber-900 dark:bg-amber-950 dark:text-amber-200"
375
+ >
376
+ <AlertTriangle
377
+ aria-hidden="true"
378
+ className="mt-px size-[18px] shrink-0"
379
+ />
380
+ <div>
381
+ <b>This is already in the catalog as {officialApp.name}.</b>
382
+ <div className="mt-2.5 flex items-center gap-3.5">
383
+ <Link
384
+ to="/app/$slug"
385
+ params={{ slug: officialApp.slug }}
386
+ className={GHOST_BUTTON}
387
+ >
388
+ Open {officialApp.name}
389
+ </Link>
390
+ <button
391
+ type="button"
392
+ className="font-bold underline underline-offset-2"
393
+ onClick={() => setAddAnywayFor(address)}
394
+ >
395
+ Add anyway
396
+ </button>
397
+ </div>
398
+ </div>
399
+ </div>
400
+ )}
401
+
402
+ {blockedByDuplicate && !officialApp && duplicate && (
403
+ <div
404
+ data-testid="duplicate-warning"
405
+ role="alert"
406
+ className="flex gap-2.5 rounded-[10px] bg-amber-50 px-3.5 py-3 text-[13.5px] text-amber-900 dark:bg-amber-950 dark:text-amber-200"
407
+ >
408
+ <AlertTriangle
409
+ aria-hidden="true"
410
+ className="mt-px size-[18px] shrink-0"
411
+ />
412
+ <div>
413
+ <b>This site is already here.</b> “{duplicate.name}” uses the
414
+ same address.
415
+ <div className="mt-2.5 flex items-center gap-3.5">
416
+ <button
417
+ type="button"
418
+ className={GHOST_BUTTON}
419
+ onClick={() => onOpenCard?.(duplicate.slug)}
420
+ >
421
+ Open that card
422
+ </button>
423
+ <button
424
+ type="button"
425
+ className="font-bold underline underline-offset-2"
426
+ onClick={() => setAddAnywayFor(address)}
427
+ >
428
+ Add anyway
429
+ </button>
430
+ </div>
431
+ </div>
432
+ </div>
433
+ )}
434
+
435
+ {showDetails && (
436
+ <>
437
+ <div className="grid grid-cols-[minmax(0,1fr)_190px] items-start gap-4">
438
+ <div className="flex min-w-0 flex-col gap-3.5">
439
+ <div className="flex flex-col gap-1.5">
440
+ <label htmlFor={ids.name} className={LABEL}>
441
+ Name
442
+ </label>
443
+ <div
444
+ className={cn(
445
+ FIELD_BOX,
446
+ !nameTouched &&
447
+ current?.title &&
448
+ name === current.title &&
449
+ 'bg-amber-50/70 dark:bg-amber-950/40',
450
+ errors.name && FIELD_BAD,
451
+ )}
452
+ >
453
+ <input
454
+ id={ids.name}
455
+ type="text"
456
+ maxLength={NAME_MAX}
457
+ value={name}
458
+ disabled={busy}
459
+ aria-invalid={!!errors.name || undefined}
460
+ aria-describedby={
461
+ errors.name ? errorId(ids.name) : `${ids.name}-hint`
462
+ }
463
+ onChange={(e) => {
464
+ setName(e.target.value)
465
+ setNameTouched(true)
466
+ nameTouchedRef.current = true
467
+ setErrors((prev) => ({ ...prev, name: undefined }))
468
+ }}
469
+ className="h-10 min-w-0 flex-1 bg-transparent outline-none"
470
+ />
471
+ </div>
472
+ {errors.name ? (
473
+ <FieldError id={errorId(ids.name)}>
474
+ {errors.name}
475
+ </FieldError>
476
+ ) : (
477
+ current?.title &&
478
+ !nameTouched && (
479
+ <p id={`${ids.name}-hint`} className={HINT}>
480
+ From the page title. Change it if colleagues call it
481
+ something else.
482
+ </p>
483
+ )
484
+ )}
485
+ </div>
486
+
487
+ <div className="flex flex-col gap-1.5">
488
+ <label htmlFor={ids.owners} className={LABEL}>
489
+ Owners
490
+ </label>
491
+ <OwnerPicker
492
+ id={ids.owners}
493
+ persons={persons}
494
+ value={owners}
495
+ disabled={busy}
496
+ invalid={!!errors.owners}
497
+ describedBy={
498
+ errors.owners
499
+ ? errorId(ids.owners)
500
+ : `${ids.owners}-hint`
501
+ }
502
+ onChange={(next) => {
503
+ setOwners(next)
504
+ setErrors((prev) => ({ ...prev, owners: undefined }))
505
+ }}
506
+ />
507
+ {errors.owners ? (
508
+ <FieldError id={errorId(ids.owners)}>
509
+ {errors.owners}
510
+ </FieldError>
511
+ ) : (
512
+ <p id={`${ids.owners}-hint`} className={HINT}>
513
+ Who to ask about it. You are added by default.
514
+ </p>
515
+ )}
516
+ </div>
517
+ </div>
518
+ <ShotPreview
519
+ preview={current}
520
+ initial={address === initial?.url ? initial : undefined}
521
+ validAddress={validAddress}
522
+ />
523
+ </div>
524
+
525
+ <div className="flex flex-col gap-1.5">
526
+ <label htmlFor={ids.summary} className={LABEL}>
527
+ Summary{' '}
528
+ <span className="font-normal text-muted-foreground">
529
+ (optional)
530
+ </span>
531
+ </label>
532
+ <div className={FIELD_BOX}>
533
+ <input
534
+ id={ids.summary}
535
+ type="text"
536
+ maxLength={SUMMARY_MAX}
537
+ value={summary}
538
+ disabled={busy}
539
+ aria-describedby={`${ids.summary}-hint`}
540
+ onChange={(e) => setSummary(e.target.value)}
541
+ className="h-10 min-w-0 flex-1 bg-transparent outline-none"
542
+ />
543
+ </div>
544
+ <p id={`${ids.summary}-hint`} className={HINT}>
545
+ One line under the name, in the list and on the card.
546
+ </p>
547
+ </div>
548
+
549
+ <div className="flex flex-col gap-1.5">
550
+ <label htmlFor={ids.tags} className={LABEL}>
551
+ Tags{' '}
552
+ <span className="font-normal text-muted-foreground">
553
+ (optional)
554
+ </span>
555
+ </label>
556
+ <TagPicker
557
+ id={ids.tags}
558
+ apps={officialApps}
559
+ knownTopics={knownTopics}
560
+ value={tags}
561
+ disabled={busy}
562
+ describedBy={
563
+ errors.tags ? errorId(ids.tags) : `${ids.tags}-hint`
564
+ }
565
+ onChange={(next) => {
566
+ setTags(next)
567
+ setErrors((prev) => ({ ...prev, tags: undefined }))
568
+ }}
569
+ onTooLong={() =>
570
+ setErrors((prev) => ({
571
+ ...prev,
572
+ tags: `A topic is at most ${TAG_VALUE_MAX} characters.`,
573
+ }))
574
+ }
575
+ />
576
+ {errors.tags ? (
577
+ <FieldError id={errorId(ids.tags)}>
578
+ {errors.tags}
579
+ </FieldError>
580
+ ) : (
581
+ <p id={`${ids.tags}-hint`} className={HINT}>
582
+ The app it is for, or a topic word. Press Enter to add a
583
+ new topic.
584
+ </p>
585
+ )}
586
+ </div>
587
+
588
+ <div className="flex flex-col gap-1.5">
589
+ <label htmlFor={ids.description} className={LABEL}>
590
+ How to use
591
+ </label>
592
+ <div className="overflow-hidden rounded-[10px] border border-border bg-card">
593
+ <div
594
+ role="tablist"
595
+ aria-label="Write or preview"
596
+ className="flex items-center gap-0.5 border-b border-border bg-muted p-1"
597
+ >
598
+ {(['write', 'preview'] as const).map((t) => (
599
+ <button
600
+ key={t}
601
+ type="button"
602
+ role="tab"
603
+ aria-selected={tab === t}
604
+ onClick={() => setTab(t)}
605
+ className={cn(
606
+ 'rounded-[7px] px-3 py-1 text-[12.5px] font-bold text-muted-foreground',
607
+ tab === t && 'bg-card text-foreground shadow-sm',
608
+ )}
609
+ >
610
+ {t === 'write' ? 'Write' : 'Preview'}
611
+ </button>
612
+ ))}
613
+ <span className="ml-auto pr-2 text-[11.5px] text-muted-foreground">
614
+ Markdown
615
+ </span>
616
+ </div>
617
+ {/* Kept mounted while previewing, so the label and its draft stay. */}
618
+ <textarea
619
+ id={ids.description}
620
+ rows={6}
621
+ maxLength={DESCRIPTION_MAX}
622
+ value={descriptionMd}
623
+ disabled={busy}
624
+ hidden={tab !== 'write'}
625
+ aria-describedby={`${ids.description}-hint`}
626
+ onChange={(e) => setDescriptionMd(e.target.value)}
627
+ className="block w-full resize-y bg-transparent px-3 py-2.5 font-mono text-[12.5px] leading-relaxed outline-none"
628
+ />
629
+ {tab === 'preview' && (
630
+ <div
631
+ data-testid="description-preview"
632
+ role="tabpanel"
633
+ className="prose prose-sm dark:prose-invert min-h-24 max-w-none px-3.5 py-2.5"
634
+ >
635
+ {descriptionMd.trim() ? (
636
+ <MarkdownText>{descriptionMd}</MarkdownText>
637
+ ) : (
638
+ <p className="text-muted-foreground">
639
+ Nothing written yet.
640
+ </p>
641
+ )}
642
+ </div>
643
+ )}
644
+ </div>
645
+ <p id={`${ids.description}-hint`} className={HINT}>
646
+ Short steps work best. This is the text people read on the
647
+ card.
648
+ </p>
649
+ </div>
650
+ </>
651
+ )}
652
+ </div>
653
+
654
+ {serverError && (
655
+ <p
656
+ role="alert"
657
+ className="mx-5 mt-2 rounded-[10px] bg-destructive/10 px-3 py-2 text-[13px] font-semibold text-destructive"
658
+ >
659
+ {archive.error ? 'Could not archive it: ' : 'Could not save it: '}
660
+ {serverError.message}
661
+ </p>
662
+ )}
663
+
664
+ {archiving && initial ? (
665
+ <div className="mt-2 border-t border-border px-5 pt-3 pb-4">
666
+ <ArchiveConfirm
667
+ busy={archive.isPending}
668
+ onCancel={() => setArchiving(false)}
669
+ onArchive={(reason) =>
670
+ archive.mutate(
671
+ { slug: initial.slug, reason },
672
+ { onSuccess: () => onDone(initial.slug) },
673
+ )
674
+ }
675
+ />
676
+ </div>
677
+ ) : (
678
+ <div className="mt-2 flex items-center justify-end gap-2.5 border-t border-border px-5 pt-3 pb-4">
679
+ {/* Already archived: nothing to archive, as on the card. */}
680
+ {mode === 'edit' && initial?.lifecycle.state !== 'archived' && (
681
+ <button
682
+ type="button"
683
+ disabled={busy}
684
+ onClick={() => setArchiving(true)}
685
+ className="inline-flex items-center gap-1.5 text-[13px] font-bold text-destructive hover:underline disabled:opacity-50"
686
+ >
687
+ <Archive aria-hidden="true" className="size-3.5" />
688
+ Archive this tool
689
+ </button>
690
+ )}
691
+ <span
692
+ className={cn(
693
+ 'text-[12.5px] text-muted-foreground',
694
+ mode === 'add' ? 'mr-auto' : 'ml-auto',
695
+ )}
696
+ >
697
+ {mode === 'add'
698
+ ? 'Goes live now and joins the review list.'
699
+ : 'Changes show on the card right away.'}
700
+ </span>
701
+ <button
702
+ type="button"
703
+ disabled={busy}
704
+ onClick={onClose}
705
+ className={GHOST_BUTTON}
706
+ >
707
+ Cancel
708
+ </button>
709
+ <button
710
+ type="submit"
711
+ disabled={!validAddress || reading || busy}
712
+ aria-busy={save.isPending || undefined}
713
+ className={PRIMARY_BUTTON}
714
+ >
715
+ {save.isPending && (
716
+ <Loader2 aria-hidden="true" className="size-4 animate-spin" />
717
+ )}
718
+ {mode === 'add'
719
+ ? save.isPending
720
+ ? 'Publishing…'
721
+ : 'Publish'
722
+ : save.isPending
723
+ ? 'Saving…'
724
+ : 'Save changes'}
725
+ </button>
726
+ </div>
727
+ )}
728
+ </form>
729
+ </DialogContent>
730
+ </Dialog>
731
+ )
732
+ }
733
+
734
+ function needsPreviewFor(
735
+ address: string,
736
+ mode: 'add' | 'edit',
737
+ initialUrl: string | undefined,
738
+ ): boolean {
739
+ return hostOf(address) !== null && (mode === 'add' || address !== initialUrl)
740
+ }
741
+
742
+ /** While the site is read: where the name, owners and picture will be. */
743
+ function ReadingSkeleton() {
744
+ return (
745
+ <div
746
+ aria-hidden="true"
747
+ className="grid grid-cols-[minmax(0,1fr)_190px] items-start gap-4"
748
+ >
749
+ <div className="flex flex-col gap-3.5">
750
+ {['55%', '40%'].map((w, i) => (
751
+ <div key={w} className="flex flex-col gap-1.5">
752
+ <span className={LABEL}>{i === 0 ? 'Name' : 'Owners'}</span>
753
+ <span className={cn(FIELD_BOX, 'pointer-events-none')}>
754
+ <i
755
+ className="block h-3 animate-pulse rounded-md bg-muted"
756
+ style={{ width: w }}
757
+ />
758
+ </span>
759
+ </div>
760
+ ))}
761
+ </div>
762
+ <div className="mt-[22px] flex aspect-[16/11] flex-col items-center justify-center gap-1.5 rounded-[10px] border border-border bg-muted text-xs font-semibold text-muted-foreground">
763
+ <Camera aria-hidden="true" className="size-5 animate-pulse" />
764
+ Taking a screenshot…
765
+ </div>
766
+ </div>
767
+ )
768
+ }
769
+
770
+ /** The picture the card will lead with, or why there will be none. */
771
+ function ShotPreview({
772
+ preview,
773
+ initial,
774
+ validAddress,
775
+ }: {
776
+ preview: ToolPreview | null
777
+ /** The tool being edited, while its address is unchanged. */
778
+ initial?: CommunityTool
779
+ validAddress: boolean
780
+ }) {
781
+ const box =
782
+ 'relative mt-[22px] flex aspect-[16/11] flex-col items-center justify-center gap-1.5 overflow-hidden rounded-[10px] border border-border bg-muted px-3 text-center text-xs font-semibold text-muted-foreground'
783
+ const caption =
784
+ 'absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 rounded-md bg-white/90 px-[7px] py-0.5 text-[11px] font-bold text-muted-foreground dark:bg-black/70'
785
+
786
+ if (preview?.screenshotDataUrl) {
787
+ return (
788
+ <div data-testid="shot-preview" className={box}>
789
+ <img
790
+ src={preview.screenshotDataUrl}
791
+ alt="Screenshot preview of the site"
792
+ className="absolute inset-0 size-full object-cover object-left-top"
793
+ />
794
+ <span className={caption}>
795
+ <Camera aria-hidden="true" className="size-3" />
796
+ Captured just now
797
+ </span>
798
+ </div>
799
+ )
800
+ }
801
+ if (initial?.screenshotId) {
802
+ return (
803
+ <div data-testid="shot-preview" className={box}>
804
+ <img
805
+ src={screenshotUrl(initial.screenshotId, 640)}
806
+ alt={`Screenshot of ${initial.name}`}
807
+ className="absolute inset-0 size-full object-cover object-left-top"
808
+ />
809
+ {initial.capturedAt && (
810
+ <span className={caption}>
811
+ <Camera aria-hidden="true" className="size-3" />
812
+ Captured {formatCardDate(initial.capturedAt)}
813
+ </span>
814
+ )}
815
+ </div>
816
+ )
817
+ }
818
+ return (
819
+ <div data-testid="shot-preview" data-state="none" className={box}>
820
+ <ImageOff aria-hidden="true" className="size-5" />
821
+ {preview && !preview.captureAllowed
822
+ ? "This site's address is outside the hosts we take screenshots of, so it will be listed without a picture."
823
+ : validAddress
824
+ ? 'No preview. The site may need a login, or it did not load.'
825
+ : 'The screenshot shows here once the address is in.'}
826
+ </div>
827
+ )
828
+ }
829
+
830
+ /**
831
+ * Archiving asks why, in one step: the reason is what colleagues read on the
832
+ * card, and what the review list records.
833
+ */
834
+ export function ArchiveConfirm({
835
+ busy,
836
+ onArchive,
837
+ onCancel,
838
+ }: {
839
+ busy: boolean
840
+ onArchive: (reason: string) => void
841
+ onCancel: () => void
842
+ }) {
843
+ const id = useId()
844
+ const inputRef = useRef<HTMLInputElement>(null)
845
+ const [reason, setReason] = useState('')
846
+ const [missing, setMissing] = useState(false)
847
+
848
+ useEffect(() => {
849
+ inputRef.current?.focus()
850
+ }, [])
851
+
852
+ const confirm = () => {
853
+ if (!reason.trim()) {
854
+ setMissing(true)
855
+ inputRef.current?.focus()
856
+ return
857
+ }
858
+ onArchive(reason.trim())
859
+ }
860
+
861
+ return (
862
+ <div data-testid="archive-confirm" className="flex flex-col gap-1.5">
863
+ <label htmlFor={id} className={LABEL}>
864
+ Why archive it?
865
+ </label>
866
+ <div className="flex items-center gap-2.5">
867
+ <div className={cn(FIELD_BOX, 'flex-1', missing && FIELD_BAD)}>
868
+ <input
869
+ ref={inputRef}
870
+ id={id}
871
+ type="text"
872
+ maxLength={500}
873
+ value={reason}
874
+ disabled={busy}
875
+ aria-invalid={missing || undefined}
876
+ aria-describedby={missing ? `${id}-error` : `${id}-hint`}
877
+ onChange={(e) => {
878
+ setReason(e.target.value)
879
+ setMissing(false)
880
+ }}
881
+ onKeyDown={(e) => {
882
+ if (e.key === 'Enter') {
883
+ e.preventDefault()
884
+ confirm()
885
+ }
886
+ }}
887
+ className="h-10 min-w-0 flex-1 bg-transparent outline-none"
888
+ />
889
+ </div>
890
+ <button
891
+ type="button"
892
+ disabled={busy}
893
+ onClick={onCancel}
894
+ className={GHOST_BUTTON}
895
+ >
896
+ Keep it
897
+ </button>
898
+ <button
899
+ type="button"
900
+ disabled={busy}
901
+ aria-busy={busy || undefined}
902
+ onClick={confirm}
903
+ className={cn(PRIMARY_BUTTON, 'bg-destructive text-white')}
904
+ >
905
+ {busy && (
906
+ <Loader2 aria-hidden="true" className="size-4 animate-spin" />
907
+ )}
908
+ {busy ? 'Archiving…' : 'Archive'}
909
+ </button>
910
+ </div>
911
+ {missing ? (
912
+ <FieldError id={`${id}-error`}>
913
+ Say why, so colleagues know what happened to it.
914
+ </FieldError>
915
+ ) : (
916
+ <p id={`${id}-hint`} className={HINT}>
917
+ Colleagues read this on the card. It stays reachable by its link.
918
+ </p>
919
+ )}
920
+ </div>
921
+ )
922
+ }