birdclaw 0.4.1 → 0.5.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.
- package/CHANGELOG.md +33 -1
- package/README.md +108 -7
- package/package.json +24 -23
- package/playwright.config.ts +1 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/src/cli.ts +450 -18
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/ConversationThread.tsx +125 -0
- package/src/components/DmWorkspace.tsx +96 -98
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/InboxCard.tsx +92 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +48 -38
- package/src/components/TimelineCard.tsx +228 -84
- package/src/components/TweetRichText.tsx +6 -2
- package/src/lib/archive-import.ts +1565 -65
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/db.ts +191 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +233 -7
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +185 -2
- package/src/lib/ui.ts +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +105 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +90 -78
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +63 -50
- package/src/routes/links.tsx +883 -0
- package/src/routes/mentions.tsx +63 -50
- package/src/styles.css +106 -43
package/src/routeTree.gen.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
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'
|
|
@@ -18,7 +19,11 @@ import { Route as BlocksRouteImport } from './routes/blocks'
|
|
|
18
19
|
import { Route as IndexRouteImport } from './routes/index'
|
|
19
20
|
import { Route as ApiStatusRouteImport } from './routes/api/status'
|
|
20
21
|
import { Route as ApiQueryRouteImport } from './routes/api/query'
|
|
22
|
+
import { Route as ApiProfileHydrateRouteImport } from './routes/api/profile-hydrate'
|
|
23
|
+
import { Route as ApiLinkPreviewRouteImport } from './routes/api/link-preview'
|
|
24
|
+
import { Route as ApiLinkInsightsRouteImport } from './routes/api/link-insights'
|
|
21
25
|
import { Route as ApiInboxRouteImport } from './routes/api/inbox'
|
|
26
|
+
import { Route as ApiConversationRouteImport } from './routes/api/conversation'
|
|
22
27
|
import { Route as ApiBlocksRouteImport } from './routes/api/blocks'
|
|
23
28
|
import { Route as ApiAvatarRouteImport } from './routes/api/avatar'
|
|
24
29
|
import { Route as ApiActionRouteImport } from './routes/api/action'
|
|
@@ -28,6 +33,11 @@ const MentionsRoute = MentionsRouteImport.update({
|
|
|
28
33
|
path: '/mentions',
|
|
29
34
|
getParentRoute: () => rootRouteImport,
|
|
30
35
|
} as any)
|
|
36
|
+
const LinksRoute = LinksRouteImport.update({
|
|
37
|
+
id: '/links',
|
|
38
|
+
path: '/links',
|
|
39
|
+
getParentRoute: () => rootRouteImport,
|
|
40
|
+
} as any)
|
|
31
41
|
const LikesRoute = LikesRouteImport.update({
|
|
32
42
|
id: '/likes',
|
|
33
43
|
path: '/likes',
|
|
@@ -68,11 +78,31 @@ const ApiQueryRoute = ApiQueryRouteImport.update({
|
|
|
68
78
|
path: '/api/query',
|
|
69
79
|
getParentRoute: () => rootRouteImport,
|
|
70
80
|
} as any)
|
|
81
|
+
const ApiProfileHydrateRoute = ApiProfileHydrateRouteImport.update({
|
|
82
|
+
id: '/api/profile-hydrate',
|
|
83
|
+
path: '/api/profile-hydrate',
|
|
84
|
+
getParentRoute: () => rootRouteImport,
|
|
85
|
+
} as any)
|
|
86
|
+
const ApiLinkPreviewRoute = ApiLinkPreviewRouteImport.update({
|
|
87
|
+
id: '/api/link-preview',
|
|
88
|
+
path: '/api/link-preview',
|
|
89
|
+
getParentRoute: () => rootRouteImport,
|
|
90
|
+
} as any)
|
|
91
|
+
const ApiLinkInsightsRoute = ApiLinkInsightsRouteImport.update({
|
|
92
|
+
id: '/api/link-insights',
|
|
93
|
+
path: '/api/link-insights',
|
|
94
|
+
getParentRoute: () => rootRouteImport,
|
|
95
|
+
} as any)
|
|
71
96
|
const ApiInboxRoute = ApiInboxRouteImport.update({
|
|
72
97
|
id: '/api/inbox',
|
|
73
98
|
path: '/api/inbox',
|
|
74
99
|
getParentRoute: () => rootRouteImport,
|
|
75
100
|
} as any)
|
|
101
|
+
const ApiConversationRoute = ApiConversationRouteImport.update({
|
|
102
|
+
id: '/api/conversation',
|
|
103
|
+
path: '/api/conversation',
|
|
104
|
+
getParentRoute: () => rootRouteImport,
|
|
105
|
+
} as any)
|
|
76
106
|
const ApiBlocksRoute = ApiBlocksRouteImport.update({
|
|
77
107
|
id: '/api/blocks',
|
|
78
108
|
path: '/api/blocks',
|
|
@@ -96,11 +126,16 @@ export interface FileRoutesByFullPath {
|
|
|
96
126
|
'/dms': typeof DmsRoute
|
|
97
127
|
'/inbox': typeof InboxRoute
|
|
98
128
|
'/likes': typeof LikesRoute
|
|
129
|
+
'/links': typeof LinksRoute
|
|
99
130
|
'/mentions': typeof MentionsRoute
|
|
100
131
|
'/api/action': typeof ApiActionRoute
|
|
101
132
|
'/api/avatar': typeof ApiAvatarRoute
|
|
102
133
|
'/api/blocks': typeof ApiBlocksRoute
|
|
134
|
+
'/api/conversation': typeof ApiConversationRoute
|
|
103
135
|
'/api/inbox': typeof ApiInboxRoute
|
|
136
|
+
'/api/link-insights': typeof ApiLinkInsightsRoute
|
|
137
|
+
'/api/link-preview': typeof ApiLinkPreviewRoute
|
|
138
|
+
'/api/profile-hydrate': typeof ApiProfileHydrateRoute
|
|
104
139
|
'/api/query': typeof ApiQueryRoute
|
|
105
140
|
'/api/status': typeof ApiStatusRoute
|
|
106
141
|
}
|
|
@@ -111,11 +146,16 @@ export interface FileRoutesByTo {
|
|
|
111
146
|
'/dms': typeof DmsRoute
|
|
112
147
|
'/inbox': typeof InboxRoute
|
|
113
148
|
'/likes': typeof LikesRoute
|
|
149
|
+
'/links': typeof LinksRoute
|
|
114
150
|
'/mentions': typeof MentionsRoute
|
|
115
151
|
'/api/action': typeof ApiActionRoute
|
|
116
152
|
'/api/avatar': typeof ApiAvatarRoute
|
|
117
153
|
'/api/blocks': typeof ApiBlocksRoute
|
|
154
|
+
'/api/conversation': typeof ApiConversationRoute
|
|
118
155
|
'/api/inbox': typeof ApiInboxRoute
|
|
156
|
+
'/api/link-insights': typeof ApiLinkInsightsRoute
|
|
157
|
+
'/api/link-preview': typeof ApiLinkPreviewRoute
|
|
158
|
+
'/api/profile-hydrate': typeof ApiProfileHydrateRoute
|
|
119
159
|
'/api/query': typeof ApiQueryRoute
|
|
120
160
|
'/api/status': typeof ApiStatusRoute
|
|
121
161
|
}
|
|
@@ -127,11 +167,16 @@ export interface FileRoutesById {
|
|
|
127
167
|
'/dms': typeof DmsRoute
|
|
128
168
|
'/inbox': typeof InboxRoute
|
|
129
169
|
'/likes': typeof LikesRoute
|
|
170
|
+
'/links': typeof LinksRoute
|
|
130
171
|
'/mentions': typeof MentionsRoute
|
|
131
172
|
'/api/action': typeof ApiActionRoute
|
|
132
173
|
'/api/avatar': typeof ApiAvatarRoute
|
|
133
174
|
'/api/blocks': typeof ApiBlocksRoute
|
|
175
|
+
'/api/conversation': typeof ApiConversationRoute
|
|
134
176
|
'/api/inbox': typeof ApiInboxRoute
|
|
177
|
+
'/api/link-insights': typeof ApiLinkInsightsRoute
|
|
178
|
+
'/api/link-preview': typeof ApiLinkPreviewRoute
|
|
179
|
+
'/api/profile-hydrate': typeof ApiProfileHydrateRoute
|
|
135
180
|
'/api/query': typeof ApiQueryRoute
|
|
136
181
|
'/api/status': typeof ApiStatusRoute
|
|
137
182
|
}
|
|
@@ -144,11 +189,16 @@ export interface FileRouteTypes {
|
|
|
144
189
|
| '/dms'
|
|
145
190
|
| '/inbox'
|
|
146
191
|
| '/likes'
|
|
192
|
+
| '/links'
|
|
147
193
|
| '/mentions'
|
|
148
194
|
| '/api/action'
|
|
149
195
|
| '/api/avatar'
|
|
150
196
|
| '/api/blocks'
|
|
197
|
+
| '/api/conversation'
|
|
151
198
|
| '/api/inbox'
|
|
199
|
+
| '/api/link-insights'
|
|
200
|
+
| '/api/link-preview'
|
|
201
|
+
| '/api/profile-hydrate'
|
|
152
202
|
| '/api/query'
|
|
153
203
|
| '/api/status'
|
|
154
204
|
fileRoutesByTo: FileRoutesByTo
|
|
@@ -159,11 +209,16 @@ export interface FileRouteTypes {
|
|
|
159
209
|
| '/dms'
|
|
160
210
|
| '/inbox'
|
|
161
211
|
| '/likes'
|
|
212
|
+
| '/links'
|
|
162
213
|
| '/mentions'
|
|
163
214
|
| '/api/action'
|
|
164
215
|
| '/api/avatar'
|
|
165
216
|
| '/api/blocks'
|
|
217
|
+
| '/api/conversation'
|
|
166
218
|
| '/api/inbox'
|
|
219
|
+
| '/api/link-insights'
|
|
220
|
+
| '/api/link-preview'
|
|
221
|
+
| '/api/profile-hydrate'
|
|
167
222
|
| '/api/query'
|
|
168
223
|
| '/api/status'
|
|
169
224
|
id:
|
|
@@ -174,11 +229,16 @@ export interface FileRouteTypes {
|
|
|
174
229
|
| '/dms'
|
|
175
230
|
| '/inbox'
|
|
176
231
|
| '/likes'
|
|
232
|
+
| '/links'
|
|
177
233
|
| '/mentions'
|
|
178
234
|
| '/api/action'
|
|
179
235
|
| '/api/avatar'
|
|
180
236
|
| '/api/blocks'
|
|
237
|
+
| '/api/conversation'
|
|
181
238
|
| '/api/inbox'
|
|
239
|
+
| '/api/link-insights'
|
|
240
|
+
| '/api/link-preview'
|
|
241
|
+
| '/api/profile-hydrate'
|
|
182
242
|
| '/api/query'
|
|
183
243
|
| '/api/status'
|
|
184
244
|
fileRoutesById: FileRoutesById
|
|
@@ -190,11 +250,16 @@ export interface RootRouteChildren {
|
|
|
190
250
|
DmsRoute: typeof DmsRoute
|
|
191
251
|
InboxRoute: typeof InboxRoute
|
|
192
252
|
LikesRoute: typeof LikesRoute
|
|
253
|
+
LinksRoute: typeof LinksRoute
|
|
193
254
|
MentionsRoute: typeof MentionsRoute
|
|
194
255
|
ApiActionRoute: typeof ApiActionRoute
|
|
195
256
|
ApiAvatarRoute: typeof ApiAvatarRoute
|
|
196
257
|
ApiBlocksRoute: typeof ApiBlocksRoute
|
|
258
|
+
ApiConversationRoute: typeof ApiConversationRoute
|
|
197
259
|
ApiInboxRoute: typeof ApiInboxRoute
|
|
260
|
+
ApiLinkInsightsRoute: typeof ApiLinkInsightsRoute
|
|
261
|
+
ApiLinkPreviewRoute: typeof ApiLinkPreviewRoute
|
|
262
|
+
ApiProfileHydrateRoute: typeof ApiProfileHydrateRoute
|
|
198
263
|
ApiQueryRoute: typeof ApiQueryRoute
|
|
199
264
|
ApiStatusRoute: typeof ApiStatusRoute
|
|
200
265
|
}
|
|
@@ -208,6 +273,13 @@ declare module '@tanstack/react-router' {
|
|
|
208
273
|
preLoaderRoute: typeof MentionsRouteImport
|
|
209
274
|
parentRoute: typeof rootRouteImport
|
|
210
275
|
}
|
|
276
|
+
'/links': {
|
|
277
|
+
id: '/links'
|
|
278
|
+
path: '/links'
|
|
279
|
+
fullPath: '/links'
|
|
280
|
+
preLoaderRoute: typeof LinksRouteImport
|
|
281
|
+
parentRoute: typeof rootRouteImport
|
|
282
|
+
}
|
|
211
283
|
'/likes': {
|
|
212
284
|
id: '/likes'
|
|
213
285
|
path: '/likes'
|
|
@@ -264,6 +336,27 @@ declare module '@tanstack/react-router' {
|
|
|
264
336
|
preLoaderRoute: typeof ApiQueryRouteImport
|
|
265
337
|
parentRoute: typeof rootRouteImport
|
|
266
338
|
}
|
|
339
|
+
'/api/profile-hydrate': {
|
|
340
|
+
id: '/api/profile-hydrate'
|
|
341
|
+
path: '/api/profile-hydrate'
|
|
342
|
+
fullPath: '/api/profile-hydrate'
|
|
343
|
+
preLoaderRoute: typeof ApiProfileHydrateRouteImport
|
|
344
|
+
parentRoute: typeof rootRouteImport
|
|
345
|
+
}
|
|
346
|
+
'/api/link-preview': {
|
|
347
|
+
id: '/api/link-preview'
|
|
348
|
+
path: '/api/link-preview'
|
|
349
|
+
fullPath: '/api/link-preview'
|
|
350
|
+
preLoaderRoute: typeof ApiLinkPreviewRouteImport
|
|
351
|
+
parentRoute: typeof rootRouteImport
|
|
352
|
+
}
|
|
353
|
+
'/api/link-insights': {
|
|
354
|
+
id: '/api/link-insights'
|
|
355
|
+
path: '/api/link-insights'
|
|
356
|
+
fullPath: '/api/link-insights'
|
|
357
|
+
preLoaderRoute: typeof ApiLinkInsightsRouteImport
|
|
358
|
+
parentRoute: typeof rootRouteImport
|
|
359
|
+
}
|
|
267
360
|
'/api/inbox': {
|
|
268
361
|
id: '/api/inbox'
|
|
269
362
|
path: '/api/inbox'
|
|
@@ -271,6 +364,13 @@ declare module '@tanstack/react-router' {
|
|
|
271
364
|
preLoaderRoute: typeof ApiInboxRouteImport
|
|
272
365
|
parentRoute: typeof rootRouteImport
|
|
273
366
|
}
|
|
367
|
+
'/api/conversation': {
|
|
368
|
+
id: '/api/conversation'
|
|
369
|
+
path: '/api/conversation'
|
|
370
|
+
fullPath: '/api/conversation'
|
|
371
|
+
preLoaderRoute: typeof ApiConversationRouteImport
|
|
372
|
+
parentRoute: typeof rootRouteImport
|
|
373
|
+
}
|
|
274
374
|
'/api/blocks': {
|
|
275
375
|
id: '/api/blocks'
|
|
276
376
|
path: '/api/blocks'
|
|
@@ -302,11 +402,16 @@ const rootRouteChildren: RootRouteChildren = {
|
|
|
302
402
|
DmsRoute: DmsRoute,
|
|
303
403
|
InboxRoute: InboxRoute,
|
|
304
404
|
LikesRoute: LikesRoute,
|
|
405
|
+
LinksRoute: LinksRoute,
|
|
305
406
|
MentionsRoute: MentionsRoute,
|
|
306
407
|
ApiActionRoute: ApiActionRoute,
|
|
307
408
|
ApiAvatarRoute: ApiAvatarRoute,
|
|
308
409
|
ApiBlocksRoute: ApiBlocksRoute,
|
|
410
|
+
ApiConversationRoute: ApiConversationRoute,
|
|
309
411
|
ApiInboxRoute: ApiInboxRoute,
|
|
412
|
+
ApiLinkInsightsRoute: ApiLinkInsightsRoute,
|
|
413
|
+
ApiLinkPreviewRoute: ApiLinkPreviewRoute,
|
|
414
|
+
ApiProfileHydrateRoute: ApiProfileHydrateRoute,
|
|
310
415
|
ApiQueryRoute: ApiQueryRoute,
|
|
311
416
|
ApiStatusRoute: ApiStatusRoute,
|
|
312
417
|
}
|
package/src/routes/__root.tsx
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
+
});
|