birdclaw 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -0,0 +1,169 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
+ import { runEffectBackground } from "#/lib/effect-runtime";
5
+ import {
6
+ parseBoundedInteger,
7
+ runRouteEffect,
8
+ sensitiveRequestErrorResponse,
9
+ } from "#/lib/http-effect";
10
+ import {
11
+ streamSearchDiscussionEffect,
12
+ type SearchDiscussionOptions,
13
+ type SearchDiscussionSource,
14
+ type SearchDiscussionStreamEvent,
15
+ } from "#/lib/search-discussion";
16
+ import type { TweetSearchMode } from "#/lib/tweet-search-live";
17
+
18
+ const encoder = new TextEncoder();
19
+ const MAX_DISCUSSION_SEARCH_LIMIT = 20_000;
20
+ const MAX_DISCUSSION_SEARCH_PAGES = 200;
21
+
22
+ function parseBoolean(value: string | null) {
23
+ return value === "true" || value === "1" || value === "yes";
24
+ }
25
+
26
+ function parseSource(value: string | null): SearchDiscussionSource {
27
+ if (
28
+ value === "all" ||
29
+ value === "home" ||
30
+ value === "mentions" ||
31
+ value === "authored" ||
32
+ value === "search" ||
33
+ value === "likes" ||
34
+ value === "bookmarks"
35
+ ) {
36
+ return value;
37
+ }
38
+ return "search";
39
+ }
40
+
41
+ function parseMode(value: string | null): TweetSearchMode {
42
+ if (
43
+ value === "auto" ||
44
+ value === "bird" ||
45
+ value === "xurl" ||
46
+ value === "local"
47
+ ) {
48
+ return value;
49
+ }
50
+ return "auto";
51
+ }
52
+
53
+ function parseOptions(url: URL): SearchDiscussionOptions {
54
+ return {
55
+ query: url.searchParams.get("query") ?? "",
56
+ account: url.searchParams.get("account") ?? undefined,
57
+ source: parseSource(url.searchParams.get("source")),
58
+ mode: parseMode(url.searchParams.get("mode")),
59
+ includeDms: parseBoolean(url.searchParams.get("includeDms")),
60
+ since: url.searchParams.get("since") ?? undefined,
61
+ until: url.searchParams.get("until") ?? undefined,
62
+ question: url.searchParams.get("question") ?? undefined,
63
+ originalsOnly: parseBoolean(url.searchParams.get("originalsOnly")),
64
+ hideLowQuality: parseBoolean(url.searchParams.get("hideLowQuality")),
65
+ refresh: parseBoolean(url.searchParams.get("refresh")),
66
+ model: url.searchParams.get("model") === "gpt-5.5" ? "gpt-5.5" : undefined,
67
+ limit: parseBoundedInteger(url.searchParams.get("limit"), {
68
+ max: MAX_DISCUSSION_SEARCH_LIMIT,
69
+ }),
70
+ maxPages: parseBoundedInteger(url.searchParams.get("maxPages"), {
71
+ max: MAX_DISCUSSION_SEARCH_PAGES,
72
+ }),
73
+ };
74
+ }
75
+
76
+ function encodeEvent(event: SearchDiscussionStreamEvent) {
77
+ return encoder.encode(`${JSON.stringify(event)}\n`);
78
+ }
79
+
80
+ export const Route = createFileRoute("/api/search-discussion")({
81
+ server: {
82
+ handlers: {
83
+ GET: ({ request }) =>
84
+ runRouteEffect(
85
+ Effect.sync(() => {
86
+ const denied = sensitiveRequestErrorResponse(request);
87
+ if (denied) return denied;
88
+
89
+ const url = new URL(request.url);
90
+ const options = parseOptions(url);
91
+ let abortDiscussion: (() => void) | undefined;
92
+
93
+ return new Response(
94
+ new ReadableStream({
95
+ cancel() {
96
+ abortDiscussion?.();
97
+ },
98
+ start(controller) {
99
+ const abortController = new AbortController();
100
+ let closed = false;
101
+ const close = () => {
102
+ closed = true;
103
+ abortController.abort();
104
+ };
105
+ const closeController = () => {
106
+ request.signal.removeEventListener("abort", onAbort);
107
+ if (!closed) {
108
+ closed = true;
109
+ controller.close();
110
+ }
111
+ };
112
+ const onAbort = () => close();
113
+ request.signal.addEventListener("abort", onAbort, {
114
+ once: true,
115
+ });
116
+ abortDiscussion = close;
117
+ const enqueue = (event: SearchDiscussionStreamEvent) => {
118
+ if (closed) return;
119
+ try {
120
+ controller.enqueue(encodeEvent(event));
121
+ } catch {
122
+ close();
123
+ }
124
+ };
125
+
126
+ runEffectBackground(
127
+ maybeAutoUpdateBackupEffect().pipe(
128
+ Effect.flatMap(() => {
129
+ if (closed || abortController.signal.aborted) {
130
+ return Effect.succeed(undefined);
131
+ }
132
+ return streamSearchDiscussionEffect(
133
+ {
134
+ ...options,
135
+ signal: abortController.signal,
136
+ prefetchAvatars: true,
137
+ },
138
+ { onEvent: enqueue },
139
+ );
140
+ }),
141
+ ),
142
+ {
143
+ onSuccess: closeController,
144
+ onFailure: (error) => {
145
+ enqueue({
146
+ type: "error",
147
+ error:
148
+ error instanceof Error
149
+ ? error.message
150
+ : "Discussion failed",
151
+ });
152
+ closeController();
153
+ },
154
+ },
155
+ );
156
+ },
157
+ }),
158
+ {
159
+ headers: {
160
+ "cache-control": "no-store",
161
+ "content-type": "application/x-ndjson; charset=utf-8",
162
+ },
163
+ },
164
+ );
165
+ }),
166
+ ),
167
+ },
168
+ },
169
+ });
@@ -1,18 +1,26 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { maybeAutoUpdateBackup } from "#/lib/backup";
3
- import { getQueryEnvelope } from "#/lib/queries";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
+ import {
5
+ jsonResponse,
6
+ runRouteEffect,
7
+ sensitiveRequestErrorResponse,
8
+ } from "#/lib/http-effect";
9
+ import { getQueryEnvelopeEffect } from "#/lib/queries";
4
10
 
5
11
  export const Route = createFileRoute("/api/status")({
6
12
  server: {
7
13
  handlers: {
8
- GET: async () => {
9
- await maybeAutoUpdateBackup();
10
- return new Response(JSON.stringify(await getQueryEnvelope()), {
11
- headers: {
12
- "content-type": "application/json",
13
- },
14
- });
15
- },
14
+ GET: ({ request }) =>
15
+ runRouteEffect(
16
+ Effect.gen(function* () {
17
+ const denied = sensitiveRequestErrorResponse(request);
18
+ if (denied) return denied;
19
+
20
+ yield* maybeAutoUpdateBackupEffect();
21
+ return jsonResponse(yield* getQueryEnvelopeEffect());
22
+ }),
23
+ ),
16
24
  },
17
25
  },
18
26
  });
@@ -1,28 +1,64 @@
1
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
- }
2
+ import { Effect } from "effect";
3
+ import {
4
+ jsonResponse,
5
+ requestJsonEffect,
6
+ runRouteEffect,
7
+ sensitiveRequestErrorResponse,
8
+ } from "#/lib/http-effect";
9
+ import {
10
+ getWebSyncJob,
11
+ parseWebSyncKind,
12
+ startWebSync,
13
+ type WebSyncDmInbox,
14
+ type WebSyncOptions,
15
+ } from "#/lib/web-sync";
13
16
 
14
17
  function parseAccountId(value: unknown) {
15
18
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
16
19
  }
17
20
 
21
+ function parseDmInbox(value: unknown): WebSyncDmInbox | undefined {
22
+ return value === "all" || value === "accepted" || value === "requests"
23
+ ? value
24
+ : undefined;
25
+ }
26
+
27
+ function parsePositiveInteger(value: unknown, max: number) {
28
+ const parsed =
29
+ typeof value === "number"
30
+ ? value
31
+ : typeof value === "string"
32
+ ? Number(value)
33
+ : Number.NaN;
34
+ if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
35
+ return Math.min(parsed, max);
36
+ }
37
+
38
+ function parseSyncOptions(kind: string, body: Record<string, unknown>) {
39
+ if (kind !== "dms") return {};
40
+ const options: WebSyncOptions = {};
41
+ const inbox = parseDmInbox(body.inbox);
42
+ const limit = parsePositiveInteger(body.limit, 1000);
43
+ const maxPages = parsePositiveInteger(body.maxPages, 250);
44
+ if (inbox) options.inbox = inbox;
45
+ if (limit) options.limit = limit;
46
+ if (maxPages) options.maxPages = maxPages;
47
+ if (typeof body.allPages === "boolean") options.allPages = body.allPages;
48
+ return options;
49
+ }
50
+
18
51
  export const Route = createFileRoute("/api/sync")({
19
52
  server: {
20
53
  handlers: {
21
54
  GET: ({ request }) => {
55
+ const denied = sensitiveRequestErrorResponse(request);
56
+ if (denied) return denied;
57
+
22
58
  const url = new URL(request.url);
23
59
  const id = url.searchParams.get("id");
24
60
  if (!id) {
25
- return json(
61
+ return jsonResponse(
26
62
  { ok: false, message: "Missing sync job id" },
27
63
  { status: 400 },
28
64
  );
@@ -30,30 +66,40 @@ export const Route = createFileRoute("/api/sync")({
30
66
 
31
67
  const job = getWebSyncJob(id);
32
68
  if (!job) {
33
- return json(
69
+ return jsonResponse(
34
70
  { ok: false, message: "Sync job not found" },
35
71
  { status: 404 },
36
72
  );
37
73
  }
38
74
 
39
- return json(job);
75
+ return jsonResponse(job);
40
76
  },
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
- }
77
+ POST: ({ request }) =>
78
+ runRouteEffect(
79
+ Effect.gen(function* () {
80
+ const denied = sensitiveRequestErrorResponse(request);
81
+ if (denied) return denied;
53
82
 
54
- const job = startWebSync(kind, parseAccountId(body.accountId));
55
- return json(job, { status: job.inProgress ? 202 : 200 });
56
- },
83
+ const body = yield* requestJsonEffect<Record<string, unknown>>(
84
+ request,
85
+ {},
86
+ );
87
+ const kind = parseWebSyncKind(body.kind);
88
+ if (!kind) {
89
+ return jsonResponse(
90
+ { ok: false, message: "Unknown sync kind" },
91
+ { status: 400 },
92
+ );
93
+ }
94
+
95
+ const job = startWebSync(
96
+ kind,
97
+ parseAccountId(body.accountId),
98
+ parseSyncOptions(kind, body),
99
+ );
100
+ return jsonResponse(job, { status: job.inProgress ? 202 : 200 });
101
+ }),
102
+ ),
57
103
  },
58
104
  },
59
105
  });
@@ -0,0 +1,24 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
3
+ import {
4
+ jsonResponse,
5
+ runRouteEffect,
6
+ sensitiveRequestErrorResponse,
7
+ } from "#/lib/http-effect";
8
+ import { getXurlRateLimitSnapshot } from "#/lib/xurl-rate-limits";
9
+
10
+ export const Route = createFileRoute("/api/xurl-rate-limits")({
11
+ server: {
12
+ handlers: {
13
+ GET: ({ request }) =>
14
+ runRouteEffect(
15
+ Effect.sync(() => {
16
+ const denied = sensitiveRequestErrorResponse(request);
17
+ if (denied) return denied;
18
+
19
+ return jsonResponse(getXurlRateLimitSnapshot());
20
+ }),
21
+ ),
22
+ },
23
+ },
24
+ });
@@ -0,0 +1,255 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import {
3
+ AlertTriangle,
4
+ CheckCircle2,
5
+ Database,
6
+ RefreshCw,
7
+ Route as RouteIcon,
8
+ TerminalSquare,
9
+ XCircle,
10
+ } from "lucide-react";
11
+ import { useCallback, useEffect, useMemo, useState } from "react";
12
+ import type {
13
+ LiveDataSourceCapability,
14
+ LiveDataSourceKind,
15
+ LiveDataSourcesResponse,
16
+ LiveDataSourceStatus,
17
+ } from "#/lib/types";
18
+ import {
19
+ cx,
20
+ errorCopyClass,
21
+ pageHeaderActionsClass,
22
+ pageHeaderClass,
23
+ pageHeaderRowClass,
24
+ pageSubtitleClass,
25
+ pageTitleClass,
26
+ secondaryButtonClass,
27
+ statusCopyClass,
28
+ } from "#/lib/ui";
29
+
30
+ export const Route = createFileRoute("/data-sources")({
31
+ component: DataSourcesRoute,
32
+ });
33
+
34
+ async function fetchDataSources() {
35
+ const response = await fetch("/api/data-sources");
36
+ if (!response.ok) {
37
+ throw new Error(`Data source status failed (${String(response.status)})`);
38
+ }
39
+ return (await response.json()) as LiveDataSourcesResponse;
40
+ }
41
+
42
+ function sourceIcon(source: LiveDataSourceKind) {
43
+ if (source === "birdclaw") return Database;
44
+ if (source === "bird") return TerminalSquare;
45
+ return RouteIcon;
46
+ }
47
+
48
+ function statusTone(status: LiveDataSourceStatus["status"]) {
49
+ if (status === "ok") {
50
+ return "border-[color:color-mix(in_srgb,#22c55e_45%,var(--line))] bg-[color:color-mix(in_srgb,#22c55e_10%,var(--bg))] text-[color:color-mix(in_srgb,#22c55e_82%,var(--ink))]";
51
+ }
52
+ if (status === "warning") {
53
+ return "border-[color:color-mix(in_srgb,#f59e0b_50%,var(--line))] bg-[color:color-mix(in_srgb,#f59e0b_12%,var(--bg))] text-[color:color-mix(in_srgb,#f59e0b_82%,var(--ink))]";
54
+ }
55
+ return "border-[color:color-mix(in_srgb,var(--alert)_55%,var(--line))] bg-[var(--alert-soft)] text-[var(--alert)]";
56
+ }
57
+
58
+ function statusIcon(status: LiveDataSourceStatus["status"]) {
59
+ if (status === "ok") return CheckCircle2;
60
+ if (status === "warning") return AlertTriangle;
61
+ return XCircle;
62
+ }
63
+
64
+ function sourceLabel(source: LiveDataSourceKind) {
65
+ if (source === "birdclaw") return "local";
66
+ return source;
67
+ }
68
+
69
+ function SourceCard({ source }: { source: LiveDataSourceStatus }) {
70
+ const Icon = sourceIcon(source.source);
71
+ const StatusIcon = statusIcon(source.status);
72
+ return (
73
+ <section className="min-w-0 border-b border-[var(--line)] px-4 py-4">
74
+ <div className="flex items-start justify-between gap-3">
75
+ <div className="flex min-w-0 items-center gap-3">
76
+ <div className="grid size-10 shrink-0 place-items-center rounded-md border border-[var(--line)] bg-[var(--bg-active)] text-[var(--ink)]">
77
+ <Icon className="size-5" strokeWidth={1.9} />
78
+ </div>
79
+ <div className="min-w-0">
80
+ <h2 className="truncate text-[16px] font-bold text-[var(--ink)]">
81
+ {source.label}
82
+ </h2>
83
+ <p className="truncate text-[13px] text-[var(--ink-soft)]">
84
+ {source.installed === false ? "not installed" : source.detail}
85
+ </p>
86
+ </div>
87
+ </div>
88
+ <span
89
+ className={cx(
90
+ "inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[12px] font-bold",
91
+ statusTone(source.status),
92
+ )}
93
+ >
94
+ <StatusIcon className="size-3.5" strokeWidth={2} />
95
+ {source.works ? "works" : source.status}
96
+ </span>
97
+ </div>
98
+ <div className="mt-4 flex flex-wrap gap-2">
99
+ {source.accounts.length > 0 ? (
100
+ source.accounts.map((account, index) => (
101
+ <span
102
+ key={`${account.app ?? ""}:${account.username ?? account.handle ?? account.id ?? String(index)}`}
103
+ className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--line)] bg-[var(--bg)] px-2.5 py-1 text-[12px] font-semibold text-[var(--ink-soft)]"
104
+ >
105
+ {account.app ? (
106
+ <span className="text-[var(--ink-faint)]">{account.app}</span>
107
+ ) : null}
108
+ <span className="truncate">
109
+ {account.handle ??
110
+ (account.username ? `@${account.username}` : account.id)}
111
+ </span>
112
+ {account.isDefault ? (
113
+ <span className="text-[var(--accent)]">default</span>
114
+ ) : null}
115
+ </span>
116
+ ))
117
+ ) : (
118
+ <span className="text-[13px] text-[var(--ink-soft)]">
119
+ no authenticated account detected
120
+ </span>
121
+ )}
122
+ </div>
123
+ </section>
124
+ );
125
+ }
126
+
127
+ function CapabilityRow({
128
+ capability,
129
+ sourcesByKind,
130
+ }: {
131
+ capability: LiveDataSourceCapability;
132
+ sourcesByKind: Map<LiveDataSourceKind, LiveDataSourceStatus>;
133
+ }) {
134
+ const chain = [capability.primary, ...capability.fallbacks];
135
+ return (
136
+ <div className="grid gap-3 border-b border-[var(--line)] px-4 py-4 min-[840px]:grid-cols-[220px_minmax(0,1fr)]">
137
+ <div className="min-w-0">
138
+ <div className="font-bold text-[var(--ink)]">{capability.label}</div>
139
+ {capability.notes ? (
140
+ <div className="mt-1 text-[12px] text-[var(--ink-soft)]">
141
+ {capability.notes}
142
+ </div>
143
+ ) : null}
144
+ </div>
145
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
146
+ {chain.map((source, index) => {
147
+ const status = sourcesByKind.get(source);
148
+ const ok = Boolean(status?.works);
149
+ return (
150
+ <div
151
+ key={`${capability.key}:${source}:${String(index)}`}
152
+ className={cx(
153
+ "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[13px] font-semibold",
154
+ ok
155
+ ? "border-[color:color-mix(in_srgb,#22c55e_38%,var(--line))] text-[var(--ink)]"
156
+ : "border-[var(--line)] text-[var(--ink-soft)]",
157
+ )}
158
+ >
159
+ <span className="text-[12px] text-[var(--ink-faint)]">
160
+ {index === 0 ? "primary" : `fallback ${String(index)}`}
161
+ </span>
162
+ <span>{sourceLabel(source)}</span>
163
+ </div>
164
+ );
165
+ })}
166
+ </div>
167
+ </div>
168
+ );
169
+ }
170
+
171
+ function DataSourcesRoute() {
172
+ const [snapshot, setSnapshot] = useState<LiveDataSourcesResponse | null>(null);
173
+ const [error, setError] = useState<string | null>(null);
174
+ const [loading, setLoading] = useState(true);
175
+ const sourcesByKind = useMemo(
176
+ () =>
177
+ new Map(
178
+ (snapshot?.sources ?? []).map((source) => [source.source, source]),
179
+ ),
180
+ [snapshot],
181
+ );
182
+
183
+ const load = useCallback(() => {
184
+ setLoading(true);
185
+ setError(null);
186
+ fetchDataSources()
187
+ .then(setSnapshot)
188
+ .catch((cause: unknown) => {
189
+ setError(
190
+ cause instanceof Error ? cause.message : "Data sources unavailable",
191
+ );
192
+ })
193
+ .finally(() => setLoading(false));
194
+ }, []);
195
+
196
+ useEffect(() => {
197
+ load();
198
+ }, [load]);
199
+
200
+ return (
201
+ <section className="flex min-h-screen flex-col">
202
+ <header className={pageHeaderClass}>
203
+ <div className={pageHeaderRowClass}>
204
+ <div>
205
+ <h1 className={pageTitleClass}>Data Sources</h1>
206
+ <p className={pageSubtitleClass}>
207
+ Live auth, local archive, and automatic fallback order
208
+ </p>
209
+ </div>
210
+ <div className={pageHeaderActionsClass}>
211
+ <button
212
+ className={secondaryButtonClass}
213
+ type="button"
214
+ onClick={load}
215
+ disabled={loading}
216
+ >
217
+ <RefreshCw className={cx("size-4", loading && "animate-spin")} />
218
+ Refresh
219
+ </button>
220
+ </div>
221
+ </div>
222
+ </header>
223
+ {error ? <div className={errorCopyClass}>{error}</div> : null}
224
+ {snapshot ? (
225
+ <>
226
+ <div className="grid border-t border-[var(--line)]">
227
+ {snapshot.sources.map((source) => (
228
+ <SourceCard key={source.source} source={source} />
229
+ ))}
230
+ </div>
231
+ <section className="min-h-0 flex-1">
232
+ <header className="border-b border-[var(--line)] px-4 py-3">
233
+ <h2 className="text-[16px] font-bold text-[var(--ink)]">
234
+ Fallbacks
235
+ </h2>
236
+ <p className="text-[13px] text-[var(--ink-soft)]">
237
+ Calls try the next source when the current source cannot serve
238
+ the request.
239
+ </p>
240
+ </header>
241
+ {snapshot.capabilities.map((capability) => (
242
+ <CapabilityRow
243
+ key={capability.key}
244
+ capability={capability}
245
+ sourcesByKind={sourcesByKind}
246
+ />
247
+ ))}
248
+ </section>
249
+ </>
250
+ ) : loading ? (
251
+ <div className={statusCopyClass}>Checking data sources...</div>
252
+ ) : null}
253
+ </section>
254
+ );
255
+ }