birdclaw 0.8.2 → 0.8.4

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 (247) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +10 -8
  3. package/bin/birdclaw.mjs +4 -66
  4. package/dist/cli/birdclaw.js +28165 -0
  5. package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
  6. package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
  7. package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
  8. package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
  9. package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
  10. package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
  11. package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
  12. package/dist/client/assets/chunk-C0phanGV.js +1 -0
  13. package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
  14. package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
  15. package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
  16. package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
  17. package/dist/client/assets/discuss-DTjaOugw.js +1 -0
  18. package/dist/client/assets/dms-CGd6Eogq.js +1 -0
  19. package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
  20. package/dist/client/assets/index-CEIG7S6s.js +31 -0
  21. package/dist/client/assets/index-bpFrQ5yG.css +1 -0
  22. package/dist/client/assets/likes-cLrcAQem.js +1 -0
  23. package/dist/client/assets/links-CM9QO1LC.js +1 -0
  24. package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
  25. package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
  26. package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
  27. package/dist/client/assets/network-map-B5a3whqj.js +2 -0
  28. package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
  29. package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
  30. package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
  31. package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
  32. package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
  33. package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
  34. package/dist/client/assets/react-nBylq1U5.js +1 -0
  35. package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
  36. package/dist/client/assets/routes-q7ZaW4En.js +1 -0
  37. package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
  38. package/dist/client/assets/search-Dq5kYsxA.js +1 -0
  39. package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
  40. package/dist/client/assets/styles-B8s3tt3r.css +2 -0
  41. package/dist/client/assets/today-CeG1O7Py.js +1 -0
  42. package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
  43. package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
  44. package/dist/client/assets/users-CHbKlqUr.js +1 -0
  45. package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
  46. package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
  47. package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
  48. package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
  49. package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
  50. package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
  51. package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
  52. package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
  53. package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
  54. package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
  55. package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
  56. package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
  57. package/dist/server/assets/blocks-DSgQghqP.js +17 -0
  58. package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
  59. package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
  60. package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
  61. package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
  62. package/dist/server/assets/discuss-QTo08NCN.js +338 -0
  63. package/dist/server/assets/dms-BuLj4bp8.js +368 -0
  64. package/dist/server/assets/dms-DAyQrVM2.js +240 -0
  65. package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
  66. package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
  67. package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
  68. package/dist/server/assets/likes-DBOAtsJU.js +14 -0
  69. package/dist/server/assets/links-DAhSjynu.js +240 -0
  70. package/dist/server/assets/links-DmH5lxHO.js +468 -0
  71. package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
  72. package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
  73. package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
  74. package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
  75. package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
  76. package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
  77. package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
  78. package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
  79. package/dist/server/assets/query-client-BdauHBgd.js +923 -0
  80. package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
  81. package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
  82. package/dist/server/assets/router-DqkwSPiM.js +17026 -0
  83. package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
  84. package/dist/server/assets/start-FaDOTmto.js +4 -0
  85. package/dist/server/assets/today-B5hVM57Z.js +17 -0
  86. package/dist/server/assets/today-fvGiZ7ud.js +303 -0
  87. package/dist/server/assets/ui-qbmcMSSX.js +137 -0
  88. package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
  89. package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
  90. package/dist/server/server.js +5520 -0
  91. package/package.json +27 -34
  92. package/playwright.config.ts +0 -35
  93. package/scripts/browser-perf.mjs +0 -427
  94. package/scripts/build-docs-site.mjs +0 -967
  95. package/scripts/docs-site-assets.mjs +0 -311
  96. package/scripts/run-vitest.mjs +0 -21
  97. package/scripts/sanitize-node-options.mjs +0 -23
  98. package/scripts/start-test-server.mjs +0 -42
  99. package/src/cli-moderation.ts +0 -210
  100. package/src/cli.ts +0 -2336
  101. package/src/components/AccountSwitcher.tsx +0 -186
  102. package/src/components/AppNav.tsx +0 -118
  103. package/src/components/AvatarChip.tsx +0 -58
  104. package/src/components/BrandMark.tsx +0 -67
  105. package/src/components/ConversationThread.tsx +0 -131
  106. package/src/components/DmWorkspace.tsx +0 -286
  107. package/src/components/EmbeddedTweetCard.tsx +0 -55
  108. package/src/components/FeedState.tsx +0 -147
  109. package/src/components/FloatingPreview.tsx +0 -382
  110. package/src/components/InboxCard.tsx +0 -152
  111. package/src/components/LinkPreviewCard.tsx +0 -292
  112. package/src/components/MarkdownViewer.tsx +0 -777
  113. package/src/components/ProfileAnalysisStream.tsx +0 -428
  114. package/src/components/ProfilePreview.tsx +0 -129
  115. package/src/components/SavedTimelineView.tsx +0 -155
  116. package/src/components/SmartTimestamp.tsx +0 -72
  117. package/src/components/SyncNowButton.tsx +0 -140
  118. package/src/components/ThemeSlider.tsx +0 -80
  119. package/src/components/TimelineCard.tsx +0 -533
  120. package/src/components/TimelineRouteFrame.tsx +0 -186
  121. package/src/components/TweetArticleCard.tsx +0 -66
  122. package/src/components/TweetMediaGrid.tsx +0 -185
  123. package/src/components/TweetRichText.tsx +0 -159
  124. package/src/components/account-selection.ts +0 -64
  125. package/src/components/useTimelineRouteData.ts +0 -323
  126. package/src/lib/account-sync-job.ts +0 -666
  127. package/src/lib/actions-transport.ts +0 -300
  128. package/src/lib/api-client.ts +0 -419
  129. package/src/lib/archive-finder.ts +0 -185
  130. package/src/lib/archive-import.ts +0 -2764
  131. package/src/lib/authored-live.ts +0 -1072
  132. package/src/lib/avatar-cache.ts +0 -349
  133. package/src/lib/backup.ts +0 -2741
  134. package/src/lib/bird-actions.ts +0 -294
  135. package/src/lib/bird-command.ts +0 -130
  136. package/src/lib/bird.ts +0 -1140
  137. package/src/lib/blocklist.ts +0 -136
  138. package/src/lib/blocks-write.ts +0 -167
  139. package/src/lib/blocks.ts +0 -389
  140. package/src/lib/bookmark-sync-job.ts +0 -536
  141. package/src/lib/client-cache.ts +0 -109
  142. package/src/lib/config.ts +0 -224
  143. package/src/lib/conversation-surface.ts +0 -205
  144. package/src/lib/data-sources.ts +0 -219
  145. package/src/lib/db.ts +0 -1135
  146. package/src/lib/dms-live.ts +0 -933
  147. package/src/lib/effect-runtime.ts +0 -45
  148. package/src/lib/follow-graph.ts +0 -1097
  149. package/src/lib/geocoding.ts +0 -296
  150. package/src/lib/http-effect.ts +0 -222
  151. package/src/lib/identity-search-index.ts +0 -369
  152. package/src/lib/inbox.ts +0 -241
  153. package/src/lib/link-index.ts +0 -641
  154. package/src/lib/link-insights.ts +0 -860
  155. package/src/lib/link-preview-metadata.ts +0 -754
  156. package/src/lib/location.ts +0 -137
  157. package/src/lib/media-fetch.ts +0 -860
  158. package/src/lib/media-includes.ts +0 -165
  159. package/src/lib/mention-threads-live.ts +0 -917
  160. package/src/lib/mentions-export.ts +0 -147
  161. package/src/lib/mentions-live.ts +0 -1238
  162. package/src/lib/moderation-target.ts +0 -216
  163. package/src/lib/moderation-write.ts +0 -131
  164. package/src/lib/mutes-write.ts +0 -167
  165. package/src/lib/mutes.ts +0 -84
  166. package/src/lib/ndjson-stream.ts +0 -106
  167. package/src/lib/network-map.ts +0 -382
  168. package/src/lib/openai.ts +0 -117
  169. package/src/lib/period-digest.ts +0 -1552
  170. package/src/lib/present.ts +0 -118
  171. package/src/lib/profile-affiliation-hydration.ts +0 -228
  172. package/src/lib/profile-affiliations.ts +0 -262
  173. package/src/lib/profile-analysis.ts +0 -1272
  174. package/src/lib/profile-bio-entities.ts +0 -318
  175. package/src/lib/profile-history.ts +0 -164
  176. package/src/lib/profile-hydration.ts +0 -180
  177. package/src/lib/profile-replies.ts +0 -77
  178. package/src/lib/profile-resolver.ts +0 -650
  179. package/src/lib/queries.ts +0 -2381
  180. package/src/lib/research.ts +0 -652
  181. package/src/lib/search-discussion.ts +0 -1016
  182. package/src/lib/seed.ts +0 -613
  183. package/src/lib/sqlite.ts +0 -147
  184. package/src/lib/sync-cache.ts +0 -65
  185. package/src/lib/theme-transition.ts +0 -113
  186. package/src/lib/theme.tsx +0 -165
  187. package/src/lib/timeline-collections-live.ts +0 -581
  188. package/src/lib/timeline-live.ts +0 -452
  189. package/src/lib/tweet-account-edges.ts +0 -48
  190. package/src/lib/tweet-lookup.ts +0 -46
  191. package/src/lib/tweet-render.ts +0 -430
  192. package/src/lib/tweet-search-live.ts +0 -566
  193. package/src/lib/types.ts +0 -723
  194. package/src/lib/ui.ts +0 -529
  195. package/src/lib/url-expansion-store.ts +0 -110
  196. package/src/lib/url-expansion.ts +0 -343
  197. package/src/lib/url-safety.ts +0 -220
  198. package/src/lib/web-sync.ts +0 -516
  199. package/src/lib/whois.ts +0 -1079
  200. package/src/lib/x-profile.ts +0 -365
  201. package/src/lib/x-web.ts +0 -199
  202. package/src/lib/xurl-rate-limits.ts +0 -267
  203. package/src/lib/xurl.ts +0 -1677
  204. package/src/routeTree.gen.ts +0 -723
  205. package/src/router.tsx +0 -20
  206. package/src/routes/__root.tsx +0 -79
  207. package/src/routes/api/action.tsx +0 -147
  208. package/src/routes/api/avatar.tsx +0 -50
  209. package/src/routes/api/blocks.tsx +0 -35
  210. package/src/routes/api/conversation.tsx +0 -45
  211. package/src/routes/api/data-sources.tsx +0 -24
  212. package/src/routes/api/inbox.tsx +0 -43
  213. package/src/routes/api/link-insights.tsx +0 -85
  214. package/src/routes/api/link-preview.tsx +0 -47
  215. package/src/routes/api/network-map.tsx +0 -55
  216. package/src/routes/api/period-digest.tsx +0 -101
  217. package/src/routes/api/profile-analysis.tsx +0 -97
  218. package/src/routes/api/profile-hydrate.tsx +0 -54
  219. package/src/routes/api/query.tsx +0 -115
  220. package/src/routes/api/search-discussion.tsx +0 -113
  221. package/src/routes/api/status.tsx +0 -28
  222. package/src/routes/api/sync.tsx +0 -105
  223. package/src/routes/api/xurl-rate-limits.tsx +0 -24
  224. package/src/routes/blocks.tsx +0 -377
  225. package/src/routes/bookmarks.tsx +0 -18
  226. package/src/routes/data-sources.tsx +0 -257
  227. package/src/routes/discuss.tsx +0 -419
  228. package/src/routes/dms.tsx +0 -432
  229. package/src/routes/inbox.tsx +0 -226
  230. package/src/routes/index.tsx +0 -32
  231. package/src/routes/likes.tsx +0 -18
  232. package/src/routes/links.tsx +0 -994
  233. package/src/routes/mentions.tsx +0 -32
  234. package/src/routes/network-map.tsx +0 -1035
  235. package/src/routes/profile-analyze.tsx +0 -112
  236. package/src/routes/profiles.$handle.tsx +0 -228
  237. package/src/routes/rate-limits.tsx +0 -309
  238. package/src/routes/today.tsx +0 -511
  239. package/src/styles.css +0 -257
  240. package/tsconfig.json +0 -29
  241. package/vite.config.ts +0 -29
  242. /package/{public → dist/client}/birdclaw-mark.png +0 -0
  243. /package/{public → dist/client}/favicon.ico +0 -0
  244. /package/{public → dist/client}/logo192.png +0 -0
  245. /package/{public → dist/client}/logo512.png +0 -0
  246. /package/{public → dist/client}/manifest.json +0 -0
  247. /package/{public → dist/client}/robots.txt +0 -0
@@ -1,994 +0,0 @@
1
- import { createFileRoute } from "@tanstack/react-router";
2
- import {
3
- ChevronDown,
4
- ChevronUp,
5
- ExternalLink,
6
- MessageCircle,
7
- Play,
8
- Repeat2,
9
- Search,
10
- Users,
11
- } from "lucide-react";
12
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
- import { AvatarChip } from "#/components/AvatarChip";
14
- import {
15
- FeedEmpty,
16
- FeedError,
17
- FeedLoading,
18
- LinkSkeletonRows,
19
- } from "#/components/FeedState";
20
- import { ProfilePreview } from "#/components/ProfilePreview";
21
- import { SmartTimestamp } from "#/components/SmartTimestamp";
22
- import {
23
- loadClientCache,
24
- readClientCache,
25
- writeClientCache,
26
- } from "#/lib/client-cache";
27
- import { formatCompactNumber } from "#/lib/present";
28
- import type {
29
- LinkInsightItem,
30
- LinkInsightKind,
31
- LinkInsightMention,
32
- LinkInsightRange,
33
- LinkInsightResponse,
34
- LinkInsightSort,
35
- LinkInsightSource,
36
- ProfileRecord,
37
- TweetMediaItem,
38
- } from "#/lib/types";
39
- import {
40
- cx,
41
- pageHeaderClass,
42
- pageHeaderRowClass,
43
- pageSubtitleClass,
44
- pageTitleClass,
45
- searchFieldIconClass,
46
- searchFieldInputClass,
47
- searchFieldShellClass,
48
- secondaryButtonClass,
49
- segmentActiveClass,
50
- segmentClass,
51
- segmentedClass,
52
- } from "#/lib/ui";
53
-
54
- export const Route = createFileRoute("/links")({
55
- component: LinksRoute,
56
- });
57
-
58
- const INITIAL_VISIBLE_COMMENTS = 3;
59
- const MORE_COMMENTS_BATCH = 6;
60
- const LINK_INSIGHTS_LIMIT = 30;
61
- const LINK_INSIGHTS_COMMENTS_LIMIT = 30;
62
- const PROFILE_HYDRATION_LIMIT = 30;
63
- const PROFILE_HYDRATION_DELAY_MS = 1200;
64
- const LINK_INSIGHTS_CACHE_PREFIX = "link-insights:";
65
- const LINK_INSIGHTS_CACHE_MAX_AGE_MS = 5 * 60_000;
66
- const LINK_PROFILE_HYDRATED_CACHE_PREFIX = "link-profile-hydrated:";
67
- const hydratingLinkProfileHandles = new Set<string>();
68
-
69
- const ranges: Array<{ value: LinkInsightRange; label: string }> = [
70
- { value: "today", label: "Today" },
71
- { value: "week", label: "Week" },
72
- { value: "month", label: "Month" },
73
- { value: "year", label: "Year" },
74
- { value: "all", label: "All" },
75
- ];
76
-
77
- function itemTitle(item: LinkInsightItem) {
78
- return item.title?.trim() || item.displayUrl;
79
- }
80
-
81
- function itemSubtitle(item: LinkInsightItem) {
82
- const description = item.description?.trim();
83
- if (description) {
84
- return description;
85
- }
86
- return item.displayUrl.split("?")[0] || item.displayUrl;
87
- }
88
-
89
- function insightCacheKey(
90
- kind: LinkInsightKind,
91
- range: LinkInsightRange,
92
- sort: LinkInsightSort,
93
- source: LinkInsightSource,
94
- refreshTick: number,
95
- ) {
96
- return `${kind}:${range}:${sort}:${source}:${refreshTick}`;
97
- }
98
-
99
- function sharedInsightCacheKey(
100
- kind: LinkInsightKind,
101
- range: LinkInsightRange,
102
- sort: LinkInsightSort,
103
- source: LinkInsightSource,
104
- ) {
105
- return `${LINK_INSIGHTS_CACHE_PREFIX}${kind}:${range}:${sort}:${source}`;
106
- }
107
-
108
- function hydratedProfileCacheKey(handle: string) {
109
- return `${LINK_PROFILE_HYDRATED_CACHE_PREFIX}${handle.toLowerCase()}`;
110
- }
111
-
112
- function linkInsightsUrl(
113
- kind: LinkInsightKind,
114
- range: LinkInsightRange,
115
- sort: LinkInsightSort,
116
- source: LinkInsightSource,
117
- refreshTick: number,
118
- ) {
119
- const url = new URL("/api/link-insights", window.location.origin);
120
- url.searchParams.set("kind", kind);
121
- url.searchParams.set("range", range);
122
- url.searchParams.set("sort", sort);
123
- url.searchParams.set("source", source);
124
- url.searchParams.set("limit", String(LINK_INSIGHTS_LIMIT));
125
- url.searchParams.set("commentsLimit", String(LINK_INSIGHTS_COMMENTS_LIMIT));
126
- url.searchParams.set("refresh", String(refreshTick));
127
- return url;
128
- }
129
-
130
- function mentionHref(mention: LinkInsightMention, item: LinkInsightItem) {
131
- return mention.sourceUrl || mention.contentTweetUrl || item.url;
132
- }
133
-
134
- function mentionCopy(mention: LinkInsightMention) {
135
- return (
136
- mention.commentText ||
137
- mention.sharedContentText ||
138
- mention.rawText ||
139
- "Shared without comment"
140
- );
141
- }
142
-
143
- function isSameProfile(
144
- left: ProfileRecord | null | undefined,
145
- right: ProfileRecord | null | undefined,
146
- ) {
147
- return Boolean(left && right && left.id === right.id);
148
- }
149
-
150
- function mediaImage(media: TweetMediaItem[]) {
151
- return media.find((item) => item.thumbnailUrl || item.url) ?? null;
152
- }
153
-
154
- function youtubeVideoId(rawUrl: string) {
155
- try {
156
- const url = new URL(rawUrl);
157
- const host = url.hostname.toLowerCase().replace(/^www\./, "");
158
- if (host === "youtu.be") {
159
- return url.pathname.split("/").filter(Boolean)[0] ?? null;
160
- }
161
- if (!host.endsWith("youtube.com")) {
162
- return null;
163
- }
164
- if (url.pathname === "/watch") {
165
- return url.searchParams.get("v");
166
- }
167
- const parts = url.pathname.split("/").filter(Boolean);
168
- if (parts[0] === "embed" || parts[0] === "shorts" || parts[0] === "live") {
169
- return parts[1] ?? null;
170
- }
171
- return null;
172
- } catch {
173
- return null;
174
- }
175
- }
176
-
177
- function youtubeThumbnailUrl(rawUrl: string) {
178
- const id = youtubeVideoId(rawUrl);
179
- if (!id || !/^[\w-]{6,}$/.test(id)) {
180
- return null;
181
- }
182
- return `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`;
183
- }
184
-
185
- function commentCount(item: LinkInsightItem) {
186
- return (
187
- item.commentCount ??
188
- item.mentions.filter((mention) => mention.hasComment).length
189
- );
190
- }
191
-
192
- function pureShareCount(item: LinkInsightItem) {
193
- return (
194
- item.pureShareCount ??
195
- item.mentions.filter(
196
- (mention) => mention.isPureShare || !mention.hasComment,
197
- ).length
198
- );
199
- }
200
-
201
- function isArchivePlaceholderProfile(profile: ProfileRecord) {
202
- return (
203
- /^id\d+$/i.test(profile.handle) &&
204
- profile.displayName === profile.handle &&
205
- profile.id === `profile_user_${profile.handle.slice(2)}`
206
- );
207
- }
208
-
209
- function profileNeedsHydration(profile: ProfileRecord | null | undefined) {
210
- if (!profile?.handle || isArchivePlaceholderProfile(profile)) {
211
- return false;
212
- }
213
- return !profile.avatarUrl || profile.followersCount === 0;
214
- }
215
-
216
- function collectProfilesForHydration(data: LinkInsightResponse | null) {
217
- const handles = new Set<string>();
218
- for (const item of data?.items ?? []) {
219
- for (const profile of [
220
- item.topSharer,
221
- ...item.sharers,
222
- ...item.mentions.flatMap((mention) => [
223
- mention.sharedBy,
224
- mention.contentAuthor,
225
- mention.participant,
226
- ]),
227
- ]) {
228
- if (!profile || !profileNeedsHydration(profile)) {
229
- continue;
230
- }
231
- handles.add(profile.handle.replace(/^@/, ""));
232
- if (handles.size >= PROFILE_HYDRATION_LIMIT) {
233
- return [...handles];
234
- }
235
- }
236
- }
237
- return [...handles];
238
- }
239
-
240
- function ProfilePill({
241
- profile,
242
- className,
243
- }: {
244
- profile: ProfileRecord | null | undefined;
245
- className?: string;
246
- }) {
247
- if (!profile) {
248
- return (
249
- <span className={cx("text-[var(--ink-soft)]", className)}>unknown</span>
250
- );
251
- }
252
- return (
253
- <ProfilePreview profile={profile}>
254
- <span
255
- className={cx("font-bold text-[var(--ink)] hover:underline", className)}
256
- >
257
- @{profile.handle}
258
- </span>
259
- </ProfilePreview>
260
- );
261
- }
262
-
263
- function SharerStrip({ item }: { item: LinkInsightItem }) {
264
- const itemSharers = item.sharers ?? [];
265
- const sharers =
266
- itemSharers.length > 0
267
- ? itemSharers
268
- : item.topSharer
269
- ? [item.topSharer]
270
- : [];
271
- const visible = sharers.slice(0, 5);
272
- const hidden = Math.max(0, item.uniqueSharers - visible.length);
273
-
274
- if (visible.length === 0) {
275
- return <span>top unknown</span>;
276
- }
277
-
278
- return (
279
- <div className="group/sharers relative z-10 inline-flex items-center gap-1 hover:z-50 focus-within:z-50">
280
- <div className="flex -space-x-2">
281
- {visible.slice(0, 4).map((profile) => (
282
- <a
283
- aria-label={`Open @${profile.handle}`}
284
- className="inline-flex rounded-full bg-[var(--bg)] ring-2 ring-[var(--bg)]"
285
- href={`https://x.com/${profile.handle}`}
286
- key={profile.id}
287
- rel="noreferrer"
288
- target="_blank"
289
- >
290
- <AvatarChip
291
- avatarUrl={profile.avatarUrl}
292
- hue={profile.avatarHue}
293
- name={profile.displayName}
294
- profileId={profile.id}
295
- size="small"
296
- />
297
- </a>
298
- ))}
299
- </div>
300
- <span className="inline-flex items-center gap-1 text-[13px] text-[var(--ink-soft)]">
301
- <span>top</span>
302
- <a
303
- className="font-medium text-[var(--ink-soft)] hover:underline"
304
- href={`https://x.com/${visible[0]?.handle ?? ""}`}
305
- rel="noreferrer"
306
- target="_blank"
307
- >
308
- @{visible[0]?.handle}
309
- </a>
310
- </span>
311
- {hidden > 0 ? (
312
- <span className="text-[13px] text-[var(--ink-soft)]">
313
- +{formatCompactNumber(hidden)}
314
- </span>
315
- ) : null}
316
- <div className="pointer-events-none absolute left-0 top-[calc(100%+8px)] z-[80] grid w-[280px] translate-y-1 gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg-elevated)] p-3 opacity-0 shadow-[0_8px_28px_var(--shadow-strong)] transition-all duration-150 group-hover/sharers:pointer-events-auto group-hover/sharers:translate-y-0 group-hover/sharers:opacity-100 group-focus-within/sharers:pointer-events-auto group-focus-within/sharers:translate-y-0 group-focus-within/sharers:opacity-100">
317
- <div className="flex items-center gap-2 text-[13px] font-bold text-[var(--ink)]">
318
- <Users className="size-4" />
319
- <span>{formatCompactNumber(item.uniqueSharers)} sharers</span>
320
- </div>
321
- <div className="grid gap-2">
322
- {visible.map((profile) => (
323
- <a
324
- className="flex min-w-0 items-center gap-2 rounded-xl p-1 transition-colors hover:bg-[var(--bg-hover)]"
325
- href={`https://x.com/${profile.handle}`}
326
- key={profile.id}
327
- rel="noreferrer"
328
- target="_blank"
329
- >
330
- <AvatarChip
331
- avatarUrl={profile.avatarUrl}
332
- hue={profile.avatarHue}
333
- name={profile.displayName}
334
- profileId={profile.id}
335
- size="small"
336
- />
337
- <span className="min-w-0">
338
- <span className="block truncate text-[14px] font-bold text-[var(--ink)]">
339
- {profile.displayName}
340
- </span>
341
- <span className="block truncate text-[13px] text-[var(--ink-soft)]">
342
- @{profile.handle} /{" "}
343
- {formatCompactNumber(profile.followersCount)} followers
344
- </span>
345
- </span>
346
- </a>
347
- ))}
348
- </div>
349
- </div>
350
- </div>
351
- );
352
- }
353
-
354
- function SourceActions({
355
- item,
356
- mention,
357
- }: {
358
- item: LinkInsightItem;
359
- mention: LinkInsightMention;
360
- }) {
361
- const links = [
362
- mention.sourceUrl
363
- ? {
364
- href: mention.sourceUrl,
365
- label: mention.sourceKind === "dm" ? "Open DM" : "Open tweet",
366
- }
367
- : null,
368
- mention.contentTweetUrl
369
- ? { href: mention.contentTweetUrl, label: "Open source tweet" }
370
- : null,
371
- { href: item.url, label: "Open link" },
372
- ].filter(Boolean) as Array<{ href: string; label: string }>;
373
-
374
- return (
375
- <div className="flex flex-wrap items-center gap-3 text-[13px]">
376
- {links.map((link) => (
377
- <a
378
- key={`${link.label}:${link.href}`}
379
- className="inline-flex items-center gap-1 font-semibold text-[var(--accent)] hover:underline"
380
- href={link.href}
381
- rel="noreferrer"
382
- target="_blank"
383
- >
384
- <span>{link.label}</span>
385
- <ExternalLink className="size-3.5" />
386
- </a>
387
- ))}
388
- </div>
389
- );
390
- }
391
-
392
- function MentionCard({
393
- item,
394
- mention,
395
- }: {
396
- item: LinkInsightItem;
397
- mention: LinkInsightMention;
398
- }) {
399
- const image = mediaImage(mention.media);
400
- const href = mentionHref(mention, item);
401
- const contentAuthor =
402
- mention.contentAuthor &&
403
- !isSameProfile(mention.sharedBy, mention.contentAuthor)
404
- ? mention.contentAuthor
405
- : null;
406
-
407
- return (
408
- <article className="grid gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg)] px-3 py-2.5">
409
- <div className="flex min-w-0 items-center gap-2 text-[13px] text-[var(--ink-soft)]">
410
- <ProfilePill profile={mention.sharedBy} />
411
- <span>{mention.sourceLabel ?? mention.sourceKind}</span>
412
- <span>/</span>
413
- <a
414
- className="hover:underline"
415
- href={href}
416
- rel="noreferrer"
417
- target="_blank"
418
- >
419
- <SmartTimestamp value={mention.createdAt} />
420
- </a>
421
- </div>
422
- <p className="m-0 whitespace-pre-wrap text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]">
423
- {mentionCopy(mention)}
424
- </p>
425
- {contentAuthor ? (
426
- <div className="text-[13px] text-[var(--ink-soft)]">
427
- source by <ProfilePill profile={contentAuthor} />
428
- </div>
429
- ) : null}
430
- {image ? (
431
- <a
432
- className="block max-w-[220px] overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-active)]"
433
- href={href}
434
- rel="noreferrer"
435
- target="_blank"
436
- >
437
- <img
438
- alt={image.altText || "Mention media"}
439
- className="aspect-video h-auto w-full object-cover"
440
- src={image.thumbnailUrl || image.url}
441
- />
442
- </a>
443
- ) : null}
444
- <SourceActions item={item} mention={mention} />
445
- </article>
446
- );
447
- }
448
-
449
- function PureShareCluster({
450
- item,
451
- mentions,
452
- }: {
453
- item: LinkInsightItem;
454
- mentions: LinkInsightMention[];
455
- }) {
456
- const profiles = mentions
457
- .map((mention) => mention.sharedBy)
458
- .filter(Boolean) as ProfileRecord[];
459
- const visible = profiles.slice(0, 6);
460
- const firstSource = mentions.find((mention) => mention.sourceUrl)?.sourceUrl;
461
-
462
- return (
463
- <div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--line)] bg-[var(--bg-hover)] px-3 py-2.5">
464
- <div className="flex min-w-0 items-center gap-2">
465
- <div className="grid size-8 shrink-0 place-items-center rounded-full bg-[var(--bg-active)] text-[var(--ink-soft)]">
466
- <Repeat2 className="size-4" />
467
- </div>
468
- <div className="min-w-0">
469
- <div className="text-[14px] font-bold text-[var(--ink)]">
470
- {formatCompactNumber(pureShareCount(item))} shares without comment
471
- </div>
472
- <div className="flex min-w-0 flex-wrap items-center gap-1 text-[13px] text-[var(--ink-soft)]">
473
- {visible.map((profile) => (
474
- <ProfilePill key={profile.id} profile={profile} />
475
- ))}
476
- {pureShareCount(item) > visible.length ? (
477
- <span>
478
- +{formatCompactNumber(pureShareCount(item) - visible.length)}{" "}
479
- more
480
- </span>
481
- ) : null}
482
- </div>
483
- </div>
484
- </div>
485
- {firstSource ? (
486
- <a
487
- className="inline-flex items-center gap-1 text-[13px] font-semibold text-[var(--accent)] hover:underline"
488
- href={firstSource}
489
- rel="noreferrer"
490
- target="_blank"
491
- >
492
- <span>Open first source</span>
493
- <ExternalLink className="size-3.5" />
494
- </a>
495
- ) : null}
496
- </div>
497
- );
498
- }
499
-
500
- function CommentsSection({ item }: { item: LinkInsightItem }) {
501
- const [expanded, setExpanded] = useState(false);
502
- const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
503
- const comments = item.mentions.filter((mention) => mention.hasComment);
504
- const pureShares = item.mentions.filter(
505
- (mention) => mention.isPureShare || !mention.hasComment,
506
- );
507
- const itemCommentCount = commentCount(item);
508
- const itemPureShareCount = pureShareCount(item);
509
- const totalDiscussion = itemCommentCount + itemPureShareCount;
510
- const visibleComments = comments.slice(0, visibleCount);
511
- const remainingLocalComments = Math.max(0, comments.length - visibleCount);
512
- const remainingComments = Math.max(
513
- 0,
514
- itemCommentCount - visibleComments.length,
515
- );
516
-
517
- if (totalDiscussion === 0) {
518
- return null;
519
- }
520
-
521
- return (
522
- <div className="mt-2 grid gap-2 border-t border-[var(--line)] pt-2">
523
- <button
524
- className="inline-flex w-fit items-center gap-1 rounded-full border-0 bg-transparent px-2 py-1 text-[13px] font-bold text-[var(--accent)] transition-colors hover:bg-[var(--accent-soft)]"
525
- onClick={() => setExpanded((value) => !value)}
526
- type="button"
527
- >
528
- <MessageCircle className="size-4" />
529
- <span>
530
- {expanded ? "Hide" : "Show"} {formatCompactNumber(itemCommentCount)}{" "}
531
- comments
532
- {itemPureShareCount > 0
533
- ? ` / ${formatCompactNumber(itemPureShareCount)} shares`
534
- : ""}
535
- </span>
536
- {expanded ? (
537
- <ChevronUp className="size-4" />
538
- ) : (
539
- <ChevronDown className="size-4" />
540
- )}
541
- </button>
542
- {expanded ? (
543
- <div className="grid gap-2">
544
- {visibleComments.map((mention) => (
545
- <MentionCard key={mention.id} item={item} mention={mention} />
546
- ))}
547
- {remainingLocalComments > 0 ? (
548
- <button
549
- className={cx(secondaryButtonClass, "w-fit")}
550
- onClick={() =>
551
- setVisibleCount((value) => value + MORE_COMMENTS_BATCH)
552
- }
553
- type="button"
554
- >
555
- Show{" "}
556
- {formatCompactNumber(
557
- Math.min(MORE_COMMENTS_BATCH, remainingLocalComments),
558
- )}{" "}
559
- more
560
- </button>
561
- ) : null}
562
- {remainingComments > 0 && remainingLocalComments === 0 ? (
563
- <div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-hover)] px-3 py-2 text-[13px] text-[var(--ink-soft)]">
564
- {formatCompactNumber(remainingComments)} more mentions in archive.
565
- Narrow the range or raise the API limit to inspect them.
566
- </div>
567
- ) : null}
568
- {itemPureShareCount > 0 ? (
569
- <PureShareCluster item={item} mentions={pureShares} />
570
- ) : null}
571
- </div>
572
- ) : null}
573
- </div>
574
- );
575
- }
576
-
577
- function VideoPreview({ item }: { item: LinkInsightItem }) {
578
- if (item.kind !== "videos") {
579
- return null;
580
- }
581
- const image = mediaImage(item.mentions.flatMap((mention) => mention.media));
582
- const thumbnailUrl =
583
- image?.thumbnailUrl || image?.url || youtubeThumbnailUrl(item.url);
584
-
585
- return (
586
- <a
587
- className="mt-2 flex max-w-md items-center gap-3 overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-hover)] transition-colors hover:bg-[var(--bg-active)]"
588
- href={item.url}
589
- rel="noreferrer"
590
- target="_blank"
591
- >
592
- <div className="relative grid aspect-video w-36 shrink-0 place-items-center overflow-hidden bg-[var(--bg-active)] text-[var(--ink-soft)]">
593
- {thumbnailUrl ? (
594
- <img
595
- alt={image?.altText || itemTitle(item)}
596
- className="h-full w-full object-cover transition-transform duration-150 hover:scale-105"
597
- src={thumbnailUrl}
598
- />
599
- ) : (
600
- <Play className="size-6" />
601
- )}
602
- <div className="absolute inset-0 grid place-items-center bg-black/20">
603
- <span className="grid size-9 place-items-center rounded-full bg-black/70 text-white shadow-[0_4px_18px_rgba(0,0,0,0.35)]">
604
- <Play className="ml-0.5 size-4 fill-current" />
605
- </span>
606
- </div>
607
- </div>
608
- <div className="min-w-0 py-2 pr-3">
609
- <div className="truncate text-[13px] font-bold text-[var(--ink)]">
610
- {itemTitle(item)}
611
- </div>
612
- <div className="truncate text-[13px] text-[var(--ink-soft)]">
613
- {item.displayUrl}
614
- </div>
615
- </div>
616
- </a>
617
- );
618
- }
619
-
620
- function LinkInsightRow({
621
- index,
622
- item,
623
- }: {
624
- index: number;
625
- item: LinkInsightItem;
626
- }) {
627
- return (
628
- <article
629
- className="flex flex-col gap-2 border-b border-[var(--line)] px-4 py-3 transition-colors duration-150 hover:bg-[var(--bg-hover)]"
630
- data-perf="link-insight-row"
631
- >
632
- <div className="flex items-start gap-3">
633
- <div className="grid size-9 shrink-0 place-items-center rounded-full bg-[var(--bg-active)] text-[14px] font-bold text-[var(--ink-soft)]">
634
- {index + 1}
635
- </div>
636
- <div className="min-w-0 flex-1">
637
- <a
638
- className="block break-words text-[15px] font-bold text-[var(--ink)] [overflow-wrap:anywhere] hover:underline"
639
- href={item.url}
640
- rel="noreferrer"
641
- target="_blank"
642
- >
643
- {itemTitle(item)}
644
- </a>
645
- <p className="mt-0.5 truncate text-[13px] text-[var(--ink-soft)]">
646
- {itemSubtitle(item)}
647
- </p>
648
- <div className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
649
- <span>{item.host}</span>
650
- <span>/</span>
651
- <span>{formatCompactNumber(item.shareCount)} shares</span>
652
- <span>/</span>
653
- <span>{formatCompactNumber(item.uniqueSharers)} sharers</span>
654
- <span>/</span>
655
- <span>{formatCompactNumber(commentCount(item))} comments</span>
656
- <span>/</span>
657
- <SharerStrip item={item} />
658
- <span>/</span>
659
- <SmartTimestamp value={item.lastSeenAt} />
660
- </div>
661
- <VideoPreview item={item} />
662
- </div>
663
- </div>
664
- <CommentsSection item={item} />
665
- </article>
666
- );
667
- }
668
-
669
- function LinksRoute() {
670
- const [kind, setKind] = useState<LinkInsightKind>("links");
671
- const [range, setRange] = useState<LinkInsightRange>("week");
672
- const [source, setSource] = useState<LinkInsightSource>("all");
673
- const [sort, setSort] = useState<LinkInsightSort>("rank");
674
- const [search, setSearch] = useState("");
675
- const [refreshTick, setRefreshTick] = useState(0);
676
- const initialCacheKey = insightCacheKey(kind, range, sort, source, 0);
677
- const initialSharedData = readClientCache<LinkInsightResponse>(
678
- sharedInsightCacheKey(kind, range, sort, source),
679
- LINK_INSIGHTS_CACHE_MAX_AGE_MS,
680
- );
681
- const cacheRef = useRef(
682
- new Map<string, LinkInsightResponse>(
683
- initialSharedData ? [[initialCacheKey, initialSharedData]] : [],
684
- ),
685
- );
686
- const inFlightRef = useRef(new Set<string>());
687
- const mountedRef = useRef(true);
688
- const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
689
- const [, bumpCacheVersion] = useState(0);
690
- const currentCacheKey = insightCacheKey(
691
- kind,
692
- range,
693
- sort,
694
- source,
695
- refreshTick,
696
- );
697
- const data = cacheRef.current.get(currentCacheKey) ?? null;
698
- const error = errorByKey[currentCacheKey] ?? null;
699
- const loading = !data && !error;
700
-
701
- useEffect(() => {
702
- return () => {
703
- mountedRef.current = false;
704
- };
705
- }, []);
706
-
707
- const fetchInsights = useCallback(
708
- (fetchKind: LinkInsightKind) => {
709
- const key = insightCacheKey(fetchKind, range, sort, source, refreshTick);
710
- if (cacheRef.current.has(key) || inFlightRef.current.has(key)) {
711
- return;
712
- }
713
- const sharedKey = sharedInsightCacheKey(fetchKind, range, sort, source);
714
- if (refreshTick === 0) {
715
- const cached = readClientCache<LinkInsightResponse>(
716
- sharedKey,
717
- LINK_INSIGHTS_CACHE_MAX_AGE_MS,
718
- );
719
- if (cached) {
720
- cacheRef.current.set(key, cached);
721
- bumpCacheVersion((value) => value + 1);
722
- return;
723
- }
724
- }
725
- inFlightRef.current.add(key);
726
- setErrorByKey((current) => {
727
- const rest = { ...current };
728
- delete rest[key];
729
- return rest;
730
- });
731
- loadClientCache(
732
- sharedKey,
733
- () =>
734
- fetch(
735
- linkInsightsUrl(fetchKind, range, sort, source, refreshTick),
736
- ).then((response) => response.json() as Promise<LinkInsightResponse>),
737
- {
738
- force: refreshTick > 0,
739
- maxAgeMs: LINK_INSIGHTS_CACHE_MAX_AGE_MS,
740
- },
741
- )
742
- .then((response: LinkInsightResponse) => {
743
- cacheRef.current.set(key, response);
744
- if (mountedRef.current) {
745
- bumpCacheVersion((value) => value + 1);
746
- }
747
- })
748
- .catch((error: unknown) => {
749
- if (error instanceof DOMException && error.name === "AbortError") {
750
- return;
751
- }
752
- if (!mountedRef.current) {
753
- return;
754
- }
755
- console.warn("Link insights failed", error);
756
- setErrorByKey((current) => ({
757
- ...current,
758
- [key]:
759
- error instanceof Error
760
- ? error.message
761
- : "Link insights unavailable",
762
- }));
763
- })
764
- .finally(() => {
765
- inFlightRef.current.delete(key);
766
- });
767
- },
768
- [range, refreshTick, sort, source],
769
- );
770
-
771
- useEffect(() => {
772
- fetchInsights(kind);
773
- }, [fetchInsights, kind]);
774
-
775
- useEffect(() => {
776
- if (!data) {
777
- return;
778
- }
779
- const prefetchKind = kind === "links" ? "videos" : "links";
780
- const timer = window.setTimeout(() => fetchInsights(prefetchKind), 250);
781
- return () => window.clearTimeout(timer);
782
- }, [data, fetchInsights, kind]);
783
-
784
- useEffect(() => {
785
- const handles = collectProfilesForHydration(data).filter(
786
- (handle) =>
787
- !readClientCache<boolean>(hydratedProfileCacheKey(handle)) &&
788
- !hydratingLinkProfileHandles.has(handle.toLowerCase()),
789
- );
790
- if (handles.length === 0) {
791
- return;
792
- }
793
-
794
- const controller = new AbortController();
795
- const url = new URL("/api/profile-hydrate", window.location.origin);
796
- url.searchParams.set("handles", handles.join(","));
797
- for (const handle of handles) {
798
- hydratingLinkProfileHandles.add(handle.toLowerCase());
799
- }
800
- const finishHydration = (succeeded: boolean) => {
801
- for (const handle of handles) {
802
- const normalized = handle.toLowerCase();
803
- hydratingLinkProfileHandles.delete(normalized);
804
- if (succeeded) {
805
- writeClientCache(hydratedProfileCacheKey(normalized), true);
806
- }
807
- }
808
- };
809
-
810
- let idleId: number | null = null;
811
- const runHydration = () => {
812
- fetch(url, { signal: controller.signal })
813
- .then((response) => response.json())
814
- .then((response: { hydratedProfiles?: number }) => {
815
- finishHydration(true);
816
- if ((response.hydratedProfiles ?? 0) > 0) {
817
- setRefreshTick((value) => value + 1);
818
- }
819
- })
820
- .catch((error: unknown) => {
821
- finishHydration(false);
822
- if (error instanceof DOMException && error.name === "AbortError") {
823
- return;
824
- }
825
- console.warn("Profile hydration failed", error);
826
- });
827
- };
828
- const timer = window.setTimeout(() => {
829
- if ("requestIdleCallback" in window) {
830
- idleId = window.requestIdleCallback(runHydration, { timeout: 2500 });
831
- } else {
832
- runHydration();
833
- }
834
- }, PROFILE_HYDRATION_DELAY_MS);
835
-
836
- return () => {
837
- controller.abort();
838
- finishHydration(false);
839
- window.clearTimeout(timer);
840
- if (idleId !== null && "cancelIdleCallback" in window) {
841
- window.cancelIdleCallback(idleId);
842
- }
843
- };
844
- }, [data]);
845
-
846
- const items = useMemo(() => {
847
- const query = search.trim().toLowerCase();
848
- const filtered = (data?.items ?? []).filter((item) => {
849
- if (!query) {
850
- return true;
851
- }
852
- return [
853
- item.title,
854
- item.description,
855
- item.displayUrl,
856
- item.host,
857
- item.topSharer?.handle,
858
- ...item.mentions.map((mention) => mention.commentText),
859
- ]
860
- .filter(Boolean)
861
- .some((value) => String(value).toLowerCase().includes(query));
862
- });
863
- return filtered;
864
- }, [data?.items, search]);
865
-
866
- const subtitle = useMemo(() => {
867
- if (!data) {
868
- return "Loading link memory...";
869
- }
870
- const label = kind === "videos" ? "video URLs" : "URLs";
871
- return `${formatCompactNumber(data.stats.occurrences)} ${label} across ${formatCompactNumber(data.stats.groups)} groups`;
872
- }, [data, kind]);
873
-
874
- return (
875
- <>
876
- <header className={pageHeaderClass}>
877
- <div className={pageHeaderRowClass}>
878
- <div className="flex min-w-0 flex-col">
879
- <h1 className={pageTitleClass}>Links</h1>
880
- <p className={pageSubtitleClass}>{subtitle}</p>
881
- </div>
882
- </div>
883
- <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
884
- <label className={cx(searchFieldShellClass, "min-w-[180px] flex-1")}>
885
- <Search className={searchFieldIconClass} strokeWidth={2} />
886
- <input
887
- className={searchFieldInputClass}
888
- onChange={(event) => setSearch(event.target.value)}
889
- placeholder="Search links"
890
- value={search}
891
- />
892
- </label>
893
- <div className={segmentedClass}>
894
- {(["links", "videos"] as const).map((value) => (
895
- <button
896
- key={value}
897
- className={cx(
898
- segmentClass,
899
- kind === value && segmentActiveClass,
900
- )}
901
- onClick={() => setKind(value)}
902
- type="button"
903
- >
904
- {value}
905
- </button>
906
- ))}
907
- </div>
908
- <div className={segmentedClass}>
909
- {ranges.map((entry) => (
910
- <button
911
- key={entry.value}
912
- className={cx(
913
- segmentClass,
914
- range === entry.value && segmentActiveClass,
915
- )}
916
- onClick={() => setRange(entry.value)}
917
- type="button"
918
- >
919
- {entry.label}
920
- </button>
921
- ))}
922
- </div>
923
- <div className={segmentedClass}>
924
- {(["all", "tweet", "dm"] as const).map((value) => (
925
- <button
926
- key={value}
927
- className={cx(
928
- segmentClass,
929
- source === value && segmentActiveClass,
930
- )}
931
- onClick={() => setSource(value)}
932
- type="button"
933
- >
934
- {value}
935
- </button>
936
- ))}
937
- </div>
938
- <div className={segmentedClass}>
939
- {(["rank", "recent", "comments"] as const).map((value) => (
940
- <button
941
- key={value}
942
- className={cx(
943
- segmentClass,
944
- sort === value && segmentActiveClass,
945
- )}
946
- onClick={() => setSort(value)}
947
- type="button"
948
- >
949
- {value}
950
- </button>
951
- ))}
952
- </div>
953
- </div>
954
- </header>
955
-
956
- <section className="flex flex-col">
957
- {loading ? (
958
- <FeedLoading
959
- detail="Ranking URLs and collecting local discussion"
960
- label="Loading links"
961
- >
962
- <LinkSkeletonRows />
963
- </FeedLoading>
964
- ) : error ? (
965
- <FeedError
966
- action={
967
- <button
968
- className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
969
- onClick={() => setRefreshTick((value) => value + 1)}
970
- type="button"
971
- >
972
- Retry
973
- </button>
974
- }
975
- message={error}
976
- title="Could not load links"
977
- />
978
- ) : items.length === 0 ? (
979
- <FeedEmpty
980
- detail="Try a different range, source, or search term."
981
- label="No links in this window"
982
- />
983
- ) : null}
984
- {items.map((item, index) => (
985
- <LinkInsightRow
986
- index={index}
987
- item={item}
988
- key={`${kind}:${range}:${source}:${item.id}`}
989
- />
990
- ))}
991
- </section>
992
- </>
993
- );
994
- }