birdclaw 0.4.1 → 0.5.1

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 (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
@@ -10,15 +10,21 @@
10
10
 
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as MentionsRouteImport } from './routes/mentions'
13
+ import { Route as LinksRouteImport } from './routes/links'
13
14
  import { Route as LikesRouteImport } from './routes/likes'
14
15
  import { Route as InboxRouteImport } from './routes/inbox'
15
16
  import { Route as DmsRouteImport } from './routes/dms'
16
17
  import { Route as BookmarksRouteImport } from './routes/bookmarks'
17
18
  import { Route as BlocksRouteImport } from './routes/blocks'
18
19
  import { Route as IndexRouteImport } from './routes/index'
20
+ import { Route as ApiSyncRouteImport } from './routes/api/sync'
19
21
  import { Route as ApiStatusRouteImport } from './routes/api/status'
20
22
  import { Route as ApiQueryRouteImport } from './routes/api/query'
23
+ import { Route as ApiProfileHydrateRouteImport } from './routes/api/profile-hydrate'
24
+ import { Route as ApiLinkPreviewRouteImport } from './routes/api/link-preview'
25
+ import { Route as ApiLinkInsightsRouteImport } from './routes/api/link-insights'
21
26
  import { Route as ApiInboxRouteImport } from './routes/api/inbox'
27
+ import { Route as ApiConversationRouteImport } from './routes/api/conversation'
22
28
  import { Route as ApiBlocksRouteImport } from './routes/api/blocks'
23
29
  import { Route as ApiAvatarRouteImport } from './routes/api/avatar'
24
30
  import { Route as ApiActionRouteImport } from './routes/api/action'
@@ -28,6 +34,11 @@ const MentionsRoute = MentionsRouteImport.update({
28
34
  path: '/mentions',
29
35
  getParentRoute: () => rootRouteImport,
30
36
  } as any)
37
+ const LinksRoute = LinksRouteImport.update({
38
+ id: '/links',
39
+ path: '/links',
40
+ getParentRoute: () => rootRouteImport,
41
+ } as any)
31
42
  const LikesRoute = LikesRouteImport.update({
32
43
  id: '/likes',
33
44
  path: '/likes',
@@ -58,6 +69,11 @@ const IndexRoute = IndexRouteImport.update({
58
69
  path: '/',
59
70
  getParentRoute: () => rootRouteImport,
60
71
  } as any)
72
+ const ApiSyncRoute = ApiSyncRouteImport.update({
73
+ id: '/api/sync',
74
+ path: '/api/sync',
75
+ getParentRoute: () => rootRouteImport,
76
+ } as any)
61
77
  const ApiStatusRoute = ApiStatusRouteImport.update({
62
78
  id: '/api/status',
63
79
  path: '/api/status',
@@ -68,11 +84,31 @@ const ApiQueryRoute = ApiQueryRouteImport.update({
68
84
  path: '/api/query',
69
85
  getParentRoute: () => rootRouteImport,
70
86
  } as any)
87
+ const ApiProfileHydrateRoute = ApiProfileHydrateRouteImport.update({
88
+ id: '/api/profile-hydrate',
89
+ path: '/api/profile-hydrate',
90
+ getParentRoute: () => rootRouteImport,
91
+ } as any)
92
+ const ApiLinkPreviewRoute = ApiLinkPreviewRouteImport.update({
93
+ id: '/api/link-preview',
94
+ path: '/api/link-preview',
95
+ getParentRoute: () => rootRouteImport,
96
+ } as any)
97
+ const ApiLinkInsightsRoute = ApiLinkInsightsRouteImport.update({
98
+ id: '/api/link-insights',
99
+ path: '/api/link-insights',
100
+ getParentRoute: () => rootRouteImport,
101
+ } as any)
71
102
  const ApiInboxRoute = ApiInboxRouteImport.update({
72
103
  id: '/api/inbox',
73
104
  path: '/api/inbox',
74
105
  getParentRoute: () => rootRouteImport,
75
106
  } as any)
107
+ const ApiConversationRoute = ApiConversationRouteImport.update({
108
+ id: '/api/conversation',
109
+ path: '/api/conversation',
110
+ getParentRoute: () => rootRouteImport,
111
+ } as any)
76
112
  const ApiBlocksRoute = ApiBlocksRouteImport.update({
77
113
  id: '/api/blocks',
78
114
  path: '/api/blocks',
@@ -96,13 +132,19 @@ export interface FileRoutesByFullPath {
96
132
  '/dms': typeof DmsRoute
97
133
  '/inbox': typeof InboxRoute
98
134
  '/likes': typeof LikesRoute
135
+ '/links': typeof LinksRoute
99
136
  '/mentions': typeof MentionsRoute
100
137
  '/api/action': typeof ApiActionRoute
101
138
  '/api/avatar': typeof ApiAvatarRoute
102
139
  '/api/blocks': typeof ApiBlocksRoute
140
+ '/api/conversation': typeof ApiConversationRoute
103
141
  '/api/inbox': typeof ApiInboxRoute
142
+ '/api/link-insights': typeof ApiLinkInsightsRoute
143
+ '/api/link-preview': typeof ApiLinkPreviewRoute
144
+ '/api/profile-hydrate': typeof ApiProfileHydrateRoute
104
145
  '/api/query': typeof ApiQueryRoute
105
146
  '/api/status': typeof ApiStatusRoute
147
+ '/api/sync': typeof ApiSyncRoute
106
148
  }
107
149
  export interface FileRoutesByTo {
108
150
  '/': typeof IndexRoute
@@ -111,13 +153,19 @@ export interface FileRoutesByTo {
111
153
  '/dms': typeof DmsRoute
112
154
  '/inbox': typeof InboxRoute
113
155
  '/likes': typeof LikesRoute
156
+ '/links': typeof LinksRoute
114
157
  '/mentions': typeof MentionsRoute
115
158
  '/api/action': typeof ApiActionRoute
116
159
  '/api/avatar': typeof ApiAvatarRoute
117
160
  '/api/blocks': typeof ApiBlocksRoute
161
+ '/api/conversation': typeof ApiConversationRoute
118
162
  '/api/inbox': typeof ApiInboxRoute
163
+ '/api/link-insights': typeof ApiLinkInsightsRoute
164
+ '/api/link-preview': typeof ApiLinkPreviewRoute
165
+ '/api/profile-hydrate': typeof ApiProfileHydrateRoute
119
166
  '/api/query': typeof ApiQueryRoute
120
167
  '/api/status': typeof ApiStatusRoute
168
+ '/api/sync': typeof ApiSyncRoute
121
169
  }
122
170
  export interface FileRoutesById {
123
171
  __root__: typeof rootRouteImport
@@ -127,13 +175,19 @@ export interface FileRoutesById {
127
175
  '/dms': typeof DmsRoute
128
176
  '/inbox': typeof InboxRoute
129
177
  '/likes': typeof LikesRoute
178
+ '/links': typeof LinksRoute
130
179
  '/mentions': typeof MentionsRoute
131
180
  '/api/action': typeof ApiActionRoute
132
181
  '/api/avatar': typeof ApiAvatarRoute
133
182
  '/api/blocks': typeof ApiBlocksRoute
183
+ '/api/conversation': typeof ApiConversationRoute
134
184
  '/api/inbox': typeof ApiInboxRoute
185
+ '/api/link-insights': typeof ApiLinkInsightsRoute
186
+ '/api/link-preview': typeof ApiLinkPreviewRoute
187
+ '/api/profile-hydrate': typeof ApiProfileHydrateRoute
135
188
  '/api/query': typeof ApiQueryRoute
136
189
  '/api/status': typeof ApiStatusRoute
190
+ '/api/sync': typeof ApiSyncRoute
137
191
  }
138
192
  export interface FileRouteTypes {
139
193
  fileRoutesByFullPath: FileRoutesByFullPath
@@ -144,13 +198,19 @@ export interface FileRouteTypes {
144
198
  | '/dms'
145
199
  | '/inbox'
146
200
  | '/likes'
201
+ | '/links'
147
202
  | '/mentions'
148
203
  | '/api/action'
149
204
  | '/api/avatar'
150
205
  | '/api/blocks'
206
+ | '/api/conversation'
151
207
  | '/api/inbox'
208
+ | '/api/link-insights'
209
+ | '/api/link-preview'
210
+ | '/api/profile-hydrate'
152
211
  | '/api/query'
153
212
  | '/api/status'
213
+ | '/api/sync'
154
214
  fileRoutesByTo: FileRoutesByTo
155
215
  to:
156
216
  | '/'
@@ -159,13 +219,19 @@ export interface FileRouteTypes {
159
219
  | '/dms'
160
220
  | '/inbox'
161
221
  | '/likes'
222
+ | '/links'
162
223
  | '/mentions'
163
224
  | '/api/action'
164
225
  | '/api/avatar'
165
226
  | '/api/blocks'
227
+ | '/api/conversation'
166
228
  | '/api/inbox'
229
+ | '/api/link-insights'
230
+ | '/api/link-preview'
231
+ | '/api/profile-hydrate'
167
232
  | '/api/query'
168
233
  | '/api/status'
234
+ | '/api/sync'
169
235
  id:
170
236
  | '__root__'
171
237
  | '/'
@@ -174,13 +240,19 @@ export interface FileRouteTypes {
174
240
  | '/dms'
175
241
  | '/inbox'
176
242
  | '/likes'
243
+ | '/links'
177
244
  | '/mentions'
178
245
  | '/api/action'
179
246
  | '/api/avatar'
180
247
  | '/api/blocks'
248
+ | '/api/conversation'
181
249
  | '/api/inbox'
250
+ | '/api/link-insights'
251
+ | '/api/link-preview'
252
+ | '/api/profile-hydrate'
182
253
  | '/api/query'
183
254
  | '/api/status'
255
+ | '/api/sync'
184
256
  fileRoutesById: FileRoutesById
185
257
  }
186
258
  export interface RootRouteChildren {
@@ -190,13 +262,19 @@ export interface RootRouteChildren {
190
262
  DmsRoute: typeof DmsRoute
191
263
  InboxRoute: typeof InboxRoute
192
264
  LikesRoute: typeof LikesRoute
265
+ LinksRoute: typeof LinksRoute
193
266
  MentionsRoute: typeof MentionsRoute
194
267
  ApiActionRoute: typeof ApiActionRoute
195
268
  ApiAvatarRoute: typeof ApiAvatarRoute
196
269
  ApiBlocksRoute: typeof ApiBlocksRoute
270
+ ApiConversationRoute: typeof ApiConversationRoute
197
271
  ApiInboxRoute: typeof ApiInboxRoute
272
+ ApiLinkInsightsRoute: typeof ApiLinkInsightsRoute
273
+ ApiLinkPreviewRoute: typeof ApiLinkPreviewRoute
274
+ ApiProfileHydrateRoute: typeof ApiProfileHydrateRoute
198
275
  ApiQueryRoute: typeof ApiQueryRoute
199
276
  ApiStatusRoute: typeof ApiStatusRoute
277
+ ApiSyncRoute: typeof ApiSyncRoute
200
278
  }
201
279
 
202
280
  declare module '@tanstack/react-router' {
@@ -208,6 +286,13 @@ declare module '@tanstack/react-router' {
208
286
  preLoaderRoute: typeof MentionsRouteImport
209
287
  parentRoute: typeof rootRouteImport
210
288
  }
289
+ '/links': {
290
+ id: '/links'
291
+ path: '/links'
292
+ fullPath: '/links'
293
+ preLoaderRoute: typeof LinksRouteImport
294
+ parentRoute: typeof rootRouteImport
295
+ }
211
296
  '/likes': {
212
297
  id: '/likes'
213
298
  path: '/likes'
@@ -250,6 +335,13 @@ declare module '@tanstack/react-router' {
250
335
  preLoaderRoute: typeof IndexRouteImport
251
336
  parentRoute: typeof rootRouteImport
252
337
  }
338
+ '/api/sync': {
339
+ id: '/api/sync'
340
+ path: '/api/sync'
341
+ fullPath: '/api/sync'
342
+ preLoaderRoute: typeof ApiSyncRouteImport
343
+ parentRoute: typeof rootRouteImport
344
+ }
253
345
  '/api/status': {
254
346
  id: '/api/status'
255
347
  path: '/api/status'
@@ -264,6 +356,27 @@ declare module '@tanstack/react-router' {
264
356
  preLoaderRoute: typeof ApiQueryRouteImport
265
357
  parentRoute: typeof rootRouteImport
266
358
  }
359
+ '/api/profile-hydrate': {
360
+ id: '/api/profile-hydrate'
361
+ path: '/api/profile-hydrate'
362
+ fullPath: '/api/profile-hydrate'
363
+ preLoaderRoute: typeof ApiProfileHydrateRouteImport
364
+ parentRoute: typeof rootRouteImport
365
+ }
366
+ '/api/link-preview': {
367
+ id: '/api/link-preview'
368
+ path: '/api/link-preview'
369
+ fullPath: '/api/link-preview'
370
+ preLoaderRoute: typeof ApiLinkPreviewRouteImport
371
+ parentRoute: typeof rootRouteImport
372
+ }
373
+ '/api/link-insights': {
374
+ id: '/api/link-insights'
375
+ path: '/api/link-insights'
376
+ fullPath: '/api/link-insights'
377
+ preLoaderRoute: typeof ApiLinkInsightsRouteImport
378
+ parentRoute: typeof rootRouteImport
379
+ }
267
380
  '/api/inbox': {
268
381
  id: '/api/inbox'
269
382
  path: '/api/inbox'
@@ -271,6 +384,13 @@ declare module '@tanstack/react-router' {
271
384
  preLoaderRoute: typeof ApiInboxRouteImport
272
385
  parentRoute: typeof rootRouteImport
273
386
  }
387
+ '/api/conversation': {
388
+ id: '/api/conversation'
389
+ path: '/api/conversation'
390
+ fullPath: '/api/conversation'
391
+ preLoaderRoute: typeof ApiConversationRouteImport
392
+ parentRoute: typeof rootRouteImport
393
+ }
274
394
  '/api/blocks': {
275
395
  id: '/api/blocks'
276
396
  path: '/api/blocks'
@@ -302,13 +422,19 @@ const rootRouteChildren: RootRouteChildren = {
302
422
  DmsRoute: DmsRoute,
303
423
  InboxRoute: InboxRoute,
304
424
  LikesRoute: LikesRoute,
425
+ LinksRoute: LinksRoute,
305
426
  MentionsRoute: MentionsRoute,
306
427
  ApiActionRoute: ApiActionRoute,
307
428
  ApiAvatarRoute: ApiAvatarRoute,
308
429
  ApiBlocksRoute: ApiBlocksRoute,
430
+ ApiConversationRoute: ApiConversationRoute,
309
431
  ApiInboxRoute: ApiInboxRoute,
432
+ ApiLinkInsightsRoute: ApiLinkInsightsRoute,
433
+ ApiLinkPreviewRoute: ApiLinkPreviewRoute,
434
+ ApiProfileHydrateRoute: ApiProfileHydrateRoute,
310
435
  ApiQueryRoute: ApiQueryRoute,
311
436
  ApiStatusRoute: ApiStatusRoute,
437
+ ApiSyncRoute: ApiSyncRoute,
312
438
  }
313
439
  export const routeTree = rootRouteImport
314
440
  ._addFileChildren(rootRouteChildren)
@@ -4,7 +4,7 @@ import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
4
4
  import type { ReactNode } from "react";
5
5
  import { AppNav } from "#/components/AppNav";
6
6
  import { ThemeProvider, themeScript } from "#/lib/theme";
7
- import { bodyClass, pageWrapClass, siteShellClass } from "#/lib/ui";
7
+ import { bodyClass, mainColumnClass, siteShellClass } from "#/lib/ui";
8
8
 
9
9
  import appCss from "../styles.css?url";
10
10
 
@@ -34,7 +34,11 @@ export const Route = createRootRoute({
34
34
  });
35
35
 
36
36
  function NotFoundView() {
37
- return <main className={pageWrapClass}>Not Found</main>;
37
+ return (
38
+ <main className={mainColumnClass}>
39
+ <div className="px-4 py-10 text-[var(--ink-soft)]">Not Found</div>
40
+ </main>
41
+ );
38
42
  }
39
43
 
40
44
  function RootDocument({ children }: { children: ReactNode }) {
@@ -48,7 +52,7 @@ function RootDocument({ children }: { children: ReactNode }) {
48
52
  <ThemeProvider>
49
53
  <div className={siteShellClass}>
50
54
  <AppNav />
51
- {children}
55
+ <main className={mainColumnClass}>{children}</main>
52
56
  </div>
53
57
  </ThemeProvider>
54
58
  <TanStackDevtools
@@ -0,0 +1,34 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { maybeAutoUpdateBackup } from "#/lib/backup";
3
+ import { getTweetConversation } from "#/lib/queries";
4
+
5
+ function json(data: unknown, status = 200) {
6
+ return new Response(JSON.stringify(data), {
7
+ status,
8
+ headers: {
9
+ "content-type": "application/json",
10
+ },
11
+ });
12
+ }
13
+
14
+ export const Route = createFileRoute("/api/conversation")({
15
+ server: {
16
+ handlers: {
17
+ GET: async ({ request }) => {
18
+ await maybeAutoUpdateBackup();
19
+ const url = new URL(request.url);
20
+ const tweetId = url.searchParams.get("tweetId")?.trim();
21
+ if (!tweetId) {
22
+ return json({ ok: false, error: "Missing tweetId" }, 400);
23
+ }
24
+
25
+ const conversation = getTweetConversation(tweetId);
26
+ if (!conversation) {
27
+ return json({ ok: false, error: "Tweet not found" }, 404);
28
+ }
29
+
30
+ return json({ ok: true, ...conversation });
31
+ },
32
+ },
33
+ },
34
+ });
@@ -0,0 +1,79 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { maybeAutoUpdateBackup } from "#/lib/backup";
3
+ import { getNativeDb } from "#/lib/db";
4
+ import { getLinkInsights } from "#/lib/link-insights";
5
+ import type {
6
+ LinkInsightKind,
7
+ LinkInsightRange,
8
+ LinkInsightSort,
9
+ LinkInsightSource,
10
+ } from "#/lib/types";
11
+
12
+ function json(data: unknown) {
13
+ return new Response(JSON.stringify(data), {
14
+ headers: {
15
+ "content-type": "application/json",
16
+ },
17
+ });
18
+ }
19
+
20
+ function parseKind(value: string | null): LinkInsightKind {
21
+ return value === "videos" ? "videos" : "links";
22
+ }
23
+
24
+ function parseRange(value: string | null): LinkInsightRange {
25
+ if (
26
+ value === "today" ||
27
+ value === "week" ||
28
+ value === "month" ||
29
+ value === "year" ||
30
+ value === "all"
31
+ ) {
32
+ return value;
33
+ }
34
+ return "week";
35
+ }
36
+
37
+ function parseSource(value: string | null): LinkInsightSource {
38
+ if (value === "tweet" || value === "dm") {
39
+ return value;
40
+ }
41
+ return "all";
42
+ }
43
+
44
+ function parseSort(value: string | null): LinkInsightSort {
45
+ if (value === "recent" || value === "comments") {
46
+ return value;
47
+ }
48
+ return "rank";
49
+ }
50
+
51
+ function parseNumber(value: string | null) {
52
+ if (!value) return undefined;
53
+ const parsed = Number(value);
54
+ return Number.isFinite(parsed) ? parsed : undefined;
55
+ }
56
+
57
+ export const Route = createFileRoute("/api/link-insights")({
58
+ server: {
59
+ handlers: {
60
+ GET: async ({ request }) => {
61
+ await maybeAutoUpdateBackup();
62
+ getNativeDb();
63
+ const url = new URL(request.url);
64
+ return json(
65
+ getLinkInsights({
66
+ kind: parseKind(url.searchParams.get("kind")),
67
+ range: parseRange(url.searchParams.get("range")),
68
+ sort: parseSort(url.searchParams.get("sort")),
69
+ source: parseSource(url.searchParams.get("source")),
70
+ since: url.searchParams.get("since") ?? undefined,
71
+ until: url.searchParams.get("until") ?? undefined,
72
+ limit: parseNumber(url.searchParams.get("limit")),
73
+ commentsLimit: parseNumber(url.searchParams.get("commentsLimit")),
74
+ }),
75
+ );
76
+ },
77
+ },
78
+ },
79
+ });
@@ -0,0 +1,43 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { getOrFetchLinkPreview } from "#/lib/link-preview-metadata";
3
+
4
+ function json(data: unknown, init?: ResponseInit) {
5
+ return new Response(JSON.stringify(data), {
6
+ ...init,
7
+ headers: {
8
+ "content-type": "application/json",
9
+ ...init?.headers,
10
+ },
11
+ });
12
+ }
13
+
14
+ function parseUrl(value: string | null) {
15
+ if (!value) return null;
16
+ try {
17
+ const parsed = new URL(value);
18
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
19
+ return null;
20
+ }
21
+ return parsed.toString();
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export const Route = createFileRoute("/api/link-preview")({
28
+ server: {
29
+ handlers: {
30
+ GET: async ({ request }) => {
31
+ const url = new URL(request.url);
32
+ const previewUrl = parseUrl(url.searchParams.get("url"));
33
+ const shortUrl = parseUrl(url.searchParams.get("shortUrl"));
34
+ if (!previewUrl) {
35
+ return json({ ok: false, message: "Missing url" }, { status: 400 });
36
+ }
37
+
38
+ const preview = await getOrFetchLinkPreview(previewUrl, { shortUrl });
39
+ return json({ ok: true, preview });
40
+ },
41
+ },
42
+ },
43
+ });
@@ -0,0 +1,51 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { resolveProfilesForHandles } from "#/lib/profile-resolver";
3
+
4
+ function json(data: unknown, init?: ResponseInit) {
5
+ return new Response(JSON.stringify(data), {
6
+ ...init,
7
+ headers: {
8
+ "content-type": "application/json",
9
+ ...init?.headers,
10
+ },
11
+ });
12
+ }
13
+
14
+ function parseHandles(url: URL) {
15
+ const rawValues = [
16
+ ...url.searchParams.getAll("handle"),
17
+ ...(url.searchParams.get("handles")?.split(",") ?? []),
18
+ ];
19
+ return Array.from(
20
+ new Set(
21
+ rawValues
22
+ .map((value) => value.trim().replace(/^@/, ""))
23
+ .filter((value) => /^[A-Za-z0-9_]{1,15}$/.test(value)),
24
+ ),
25
+ ).slice(0, 50);
26
+ }
27
+
28
+ export const Route = createFileRoute("/api/profile-hydrate")({
29
+ server: {
30
+ handlers: {
31
+ GET: async ({ request }) => {
32
+ const url = new URL(request.url);
33
+ const handles = parseHandles(url);
34
+ if (handles.length === 0) {
35
+ return json(
36
+ { ok: false, message: "Missing handles" },
37
+ { status: 400 },
38
+ );
39
+ }
40
+
41
+ const results = await resolveProfilesForHandles(handles);
42
+ return json({
43
+ ok: true,
44
+ results,
45
+ hydratedProfiles: results.filter((result) => result.status === "hit")
46
+ .length,
47
+ });
48
+ },
49
+ },
50
+ },
51
+ });
@@ -0,0 +1,59 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { getWebSyncJob, parseWebSyncKind, startWebSync } from "#/lib/web-sync";
3
+
4
+ function json(data: unknown, init?: ResponseInit) {
5
+ return new Response(JSON.stringify(data), {
6
+ ...init,
7
+ headers: {
8
+ "content-type": "application/json",
9
+ ...init?.headers,
10
+ },
11
+ });
12
+ }
13
+
14
+ function parseAccountId(value: unknown) {
15
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
16
+ }
17
+
18
+ export const Route = createFileRoute("/api/sync")({
19
+ server: {
20
+ handlers: {
21
+ GET: ({ request }) => {
22
+ const url = new URL(request.url);
23
+ const id = url.searchParams.get("id");
24
+ if (!id) {
25
+ return json(
26
+ { ok: false, message: "Missing sync job id" },
27
+ { status: 400 },
28
+ );
29
+ }
30
+
31
+ const job = getWebSyncJob(id);
32
+ if (!job) {
33
+ return json(
34
+ { ok: false, message: "Sync job not found" },
35
+ { status: 404 },
36
+ );
37
+ }
38
+
39
+ return json(job);
40
+ },
41
+ POST: async ({ request }) => {
42
+ const body = (await request.json().catch(() => ({}))) as Record<
43
+ string,
44
+ unknown
45
+ >;
46
+ const kind = parseWebSyncKind(body.kind);
47
+ if (!kind) {
48
+ return json(
49
+ { ok: false, message: "Unknown sync kind" },
50
+ { status: 400 },
51
+ );
52
+ }
53
+
54
+ const job = startWebSync(kind, parseAccountId(body.accountId));
55
+ return json(job, { status: job.inProgress ? 202 : 200 });
56
+ },
57
+ },
58
+ },
59
+ });