birdclaw 0.1.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 +32 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/bin/birdclaw.mjs +28 -0
- package/package.json +100 -0
- package/playwright.config.ts +31 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +3 -0
- package/src/cli-moderation.ts +210 -0
- package/src/cli.ts +450 -0
- package/src/components/AppNav.tsx +49 -0
- package/src/components/AvatarChip.tsx +47 -0
- package/src/components/DmWorkspace.tsx +246 -0
- package/src/components/EmbeddedTweetCard.tsx +44 -0
- package/src/components/InboxCard.tsx +136 -0
- package/src/components/ProfilePreview.tsx +55 -0
- package/src/components/ThemeSlider.tsx +124 -0
- package/src/components/TimelineCard.tsx +118 -0
- package/src/components/TweetMediaGrid.tsx +39 -0
- package/src/components/TweetRichText.tsx +85 -0
- package/src/lib/actions-transport.ts +173 -0
- package/src/lib/archive-finder.ts +128 -0
- package/src/lib/archive-import.ts +736 -0
- package/src/lib/avatar-cache.ts +184 -0
- package/src/lib/bird-actions.ts +200 -0
- package/src/lib/bird.ts +183 -0
- package/src/lib/blocklist.ts +119 -0
- package/src/lib/blocks-write.ts +118 -0
- package/src/lib/blocks.ts +326 -0
- package/src/lib/config.ts +152 -0
- package/src/lib/db.ts +326 -0
- package/src/lib/dms-live.ts +279 -0
- package/src/lib/inbox.ts +210 -0
- package/src/lib/mentions-export.ts +147 -0
- package/src/lib/mentions-live.ts +475 -0
- package/src/lib/moderation-target.ts +171 -0
- package/src/lib/moderation-write.ts +72 -0
- package/src/lib/mutes-write.ts +118 -0
- package/src/lib/mutes.ts +77 -0
- package/src/lib/openai.ts +86 -0
- package/src/lib/present.ts +20 -0
- package/src/lib/profile-hydration.ts +144 -0
- package/src/lib/profile-replies.ts +60 -0
- package/src/lib/queries.ts +725 -0
- package/src/lib/seed.ts +486 -0
- package/src/lib/sync-cache.ts +65 -0
- package/src/lib/theme-transition.ts +117 -0
- package/src/lib/theme.tsx +157 -0
- package/src/lib/tweet-render.ts +115 -0
- package/src/lib/types.ts +316 -0
- package/src/lib/ui.ts +270 -0
- package/src/lib/x-profile.ts +203 -0
- package/src/lib/x-web.ts +168 -0
- package/src/lib/xurl.ts +492 -0
- package/src/routeTree.gen.ts +282 -0
- package/src/router.tsx +20 -0
- package/src/routes/__root.tsx +64 -0
- package/src/routes/api/action.tsx +88 -0
- package/src/routes/api/avatar.tsx +41 -0
- package/src/routes/api/blocks.tsx +32 -0
- package/src/routes/api/inbox.tsx +35 -0
- package/src/routes/api/query.tsx +72 -0
- package/src/routes/api/status.tsx +15 -0
- package/src/routes/blocks.tsx +352 -0
- package/src/routes/dms.tsx +262 -0
- package/src/routes/inbox.tsx +201 -0
- package/src/routes/index.tsx +125 -0
- package/src/routes/mentions.tsx +125 -0
- package/src/styles.css +109 -0
- package/tsconfig.json +30 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { InboxCard } from "#/components/InboxCard";
|
|
4
|
+
import type {
|
|
5
|
+
InboxItem,
|
|
6
|
+
InboxKind,
|
|
7
|
+
InboxResponse,
|
|
8
|
+
QueryEnvelope,
|
|
9
|
+
} from "#/lib/types";
|
|
10
|
+
import {
|
|
11
|
+
actionButtonClass,
|
|
12
|
+
cx,
|
|
13
|
+
eyebrowClass,
|
|
14
|
+
feedPageClass,
|
|
15
|
+
heroControlsClass,
|
|
16
|
+
heroCopyClass,
|
|
17
|
+
heroShellClass,
|
|
18
|
+
heroTitleClass,
|
|
19
|
+
inboxLaneClass,
|
|
20
|
+
navLinkActiveClass,
|
|
21
|
+
navLinkClass,
|
|
22
|
+
pageWrapClass,
|
|
23
|
+
segmentActiveClass,
|
|
24
|
+
segmentClass,
|
|
25
|
+
segmentedClass,
|
|
26
|
+
textFieldClass,
|
|
27
|
+
textFieldShortClass,
|
|
28
|
+
timestampClass,
|
|
29
|
+
} from "#/lib/ui";
|
|
30
|
+
|
|
31
|
+
export const Route = createFileRoute("/inbox")({
|
|
32
|
+
component: InboxRoute,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function InboxRoute() {
|
|
36
|
+
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
37
|
+
const [items, setItems] = useState<InboxItem[]>([]);
|
|
38
|
+
const [kind, setKind] = useState<InboxKind>("mixed");
|
|
39
|
+
const [minScore, setMinScore] = useState("40");
|
|
40
|
+
const [hideLowSignal, setHideLowSignal] = useState(true);
|
|
41
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
42
|
+
const [isScoring, setIsScoring] = useState(false);
|
|
43
|
+
const [activeReplyId, setActiveReplyId] = useState<string | null>(null);
|
|
44
|
+
const [replyDraft, setReplyDraft] = useState("");
|
|
45
|
+
const [isSendingReply, setIsSendingReply] = useState(false);
|
|
46
|
+
const [stats, setStats] = useState<InboxResponse["stats"] | null>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
fetch("/api/status")
|
|
50
|
+
.then((response) => response.json())
|
|
51
|
+
.then((data: QueryEnvelope) => setMeta(data));
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const url = new URL("/api/inbox", window.location.origin);
|
|
56
|
+
url.searchParams.set("kind", kind);
|
|
57
|
+
url.searchParams.set("minScore", minScore);
|
|
58
|
+
url.searchParams.set("refresh", String(refreshTick));
|
|
59
|
+
if (hideLowSignal) {
|
|
60
|
+
url.searchParams.set("hideLowSignal", "1");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fetch(url)
|
|
64
|
+
.then((response) => response.json())
|
|
65
|
+
.then((data: InboxResponse) => {
|
|
66
|
+
setItems(data.items);
|
|
67
|
+
setStats(data.stats);
|
|
68
|
+
});
|
|
69
|
+
}, [hideLowSignal, kind, minScore, refreshTick]);
|
|
70
|
+
|
|
71
|
+
const subtitle = useMemo(() => {
|
|
72
|
+
if (!meta || !stats) return "Ranking unreplied mentions and DMs...";
|
|
73
|
+
return `${stats.total} items in queue · ${stats.openai} OpenAI scored · ${meta.transport.statusText}`;
|
|
74
|
+
}, [meta, stats]);
|
|
75
|
+
|
|
76
|
+
async function scoreNow() {
|
|
77
|
+
setIsScoring(true);
|
|
78
|
+
try {
|
|
79
|
+
await fetch("/api/action", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
kind: "scoreInbox",
|
|
84
|
+
scoreKind: kind,
|
|
85
|
+
limit: 8,
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
setRefreshTick((value) => value + 1);
|
|
89
|
+
} finally {
|
|
90
|
+
setIsScoring(false);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function sendReply(item: InboxItem) {
|
|
95
|
+
if (!replyDraft.trim()) return;
|
|
96
|
+
setIsSendingReply(true);
|
|
97
|
+
try {
|
|
98
|
+
await fetch("/api/action", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "content-type": "application/json" },
|
|
101
|
+
body: JSON.stringify(
|
|
102
|
+
item.entityKind === "dm"
|
|
103
|
+
? {
|
|
104
|
+
kind: "replyDm",
|
|
105
|
+
conversationId: item.entityId,
|
|
106
|
+
text: replyDraft,
|
|
107
|
+
}
|
|
108
|
+
: {
|
|
109
|
+
kind: "replyTweet",
|
|
110
|
+
accountId: item.accountId,
|
|
111
|
+
tweetId: item.entityId,
|
|
112
|
+
text: replyDraft,
|
|
113
|
+
},
|
|
114
|
+
),
|
|
115
|
+
});
|
|
116
|
+
setReplyDraft("");
|
|
117
|
+
setActiveReplyId(null);
|
|
118
|
+
setRefreshTick((value) => value + 1);
|
|
119
|
+
} finally {
|
|
120
|
+
setIsSendingReply(false);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<main className={pageWrapClass}>
|
|
126
|
+
<div className={feedPageClass}>
|
|
127
|
+
<section className={heroShellClass}>
|
|
128
|
+
<div>
|
|
129
|
+
<p className={eyebrowClass}>inbox</p>
|
|
130
|
+
<h2 className={heroTitleClass}>AI triage for mentions and DMs.</h2>
|
|
131
|
+
<p className={heroCopyClass}>{subtitle}</p>
|
|
132
|
+
</div>
|
|
133
|
+
<div className={heroControlsClass}>
|
|
134
|
+
<div className={segmentedClass}>
|
|
135
|
+
{(["mixed", "mentions", "dms"] as const).map((value) => (
|
|
136
|
+
<button
|
|
137
|
+
key={value}
|
|
138
|
+
className={cx(
|
|
139
|
+
segmentClass,
|
|
140
|
+
value === kind && segmentActiveClass,
|
|
141
|
+
)}
|
|
142
|
+
onClick={() => setKind(value)}
|
|
143
|
+
type="button"
|
|
144
|
+
>
|
|
145
|
+
{value}
|
|
146
|
+
</button>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
<input
|
|
150
|
+
className={cx(textFieldClass, textFieldShortClass)}
|
|
151
|
+
inputMode="numeric"
|
|
152
|
+
onChange={(event) => setMinScore(event.target.value)}
|
|
153
|
+
placeholder="Min AI score"
|
|
154
|
+
value={minScore}
|
|
155
|
+
/>
|
|
156
|
+
<button
|
|
157
|
+
className={cx(navLinkClass, hideLowSignal && navLinkActiveClass)}
|
|
158
|
+
onClick={() => setHideLowSignal((value) => !value)}
|
|
159
|
+
type="button"
|
|
160
|
+
>
|
|
161
|
+
{hideLowSignal ? "Hide low-signal" : "Show all"}
|
|
162
|
+
</button>
|
|
163
|
+
<button
|
|
164
|
+
className={actionButtonClass}
|
|
165
|
+
disabled={isScoring}
|
|
166
|
+
onClick={() => void scoreNow()}
|
|
167
|
+
type="button"
|
|
168
|
+
>
|
|
169
|
+
{isScoring ? "Scoring..." : "Score with OpenAI"}
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
</section>
|
|
173
|
+
|
|
174
|
+
<section className={inboxLaneClass}>
|
|
175
|
+
{items.map((item) => (
|
|
176
|
+
<InboxCard
|
|
177
|
+
key={item.id}
|
|
178
|
+
isReplying={activeReplyId === item.id}
|
|
179
|
+
item={item}
|
|
180
|
+
onReplyChange={setReplyDraft}
|
|
181
|
+
onReplySend={() => void sendReply(item)}
|
|
182
|
+
onReplyToggle={() => {
|
|
183
|
+
if (activeReplyId === item.id) {
|
|
184
|
+
setActiveReplyId(null);
|
|
185
|
+
setReplyDraft("");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
setActiveReplyId(item.id);
|
|
189
|
+
setReplyDraft("");
|
|
190
|
+
}}
|
|
191
|
+
replyDraft={activeReplyId === item.id ? replyDraft : ""}
|
|
192
|
+
/>
|
|
193
|
+
))}
|
|
194
|
+
</section>
|
|
195
|
+
</div>
|
|
196
|
+
{isSendingReply ? (
|
|
197
|
+
<p className={timestampClass}>Sending reply...</p>
|
|
198
|
+
) : null}
|
|
199
|
+
</main>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { TimelineCard } from "#/components/TimelineCard";
|
|
4
|
+
import type {
|
|
5
|
+
QueryEnvelope,
|
|
6
|
+
QueryResponse,
|
|
7
|
+
ReplyFilter,
|
|
8
|
+
TimelineItem,
|
|
9
|
+
} from "#/lib/types";
|
|
10
|
+
import {
|
|
11
|
+
cx,
|
|
12
|
+
eyebrowClass,
|
|
13
|
+
feedPageClass,
|
|
14
|
+
heroControlsClass,
|
|
15
|
+
heroCopyClass,
|
|
16
|
+
heroShellClass,
|
|
17
|
+
heroTitleClass,
|
|
18
|
+
pageWrapClass,
|
|
19
|
+
segmentActiveClass,
|
|
20
|
+
segmentClass,
|
|
21
|
+
segmentedClass,
|
|
22
|
+
textFieldClass,
|
|
23
|
+
textFieldWideClass,
|
|
24
|
+
timelineLaneClass,
|
|
25
|
+
} from "#/lib/ui";
|
|
26
|
+
|
|
27
|
+
export const Route = createFileRoute("/")({
|
|
28
|
+
component: HomeRoute,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function HomeRoute() {
|
|
32
|
+
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
33
|
+
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
34
|
+
const [replyFilter, setReplyFilter] = useState<ReplyFilter>("all");
|
|
35
|
+
const [search, setSearch] = useState("");
|
|
36
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
fetch("/api/status")
|
|
40
|
+
.then((response) => response.json())
|
|
41
|
+
.then((data: QueryEnvelope) => setMeta(data));
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const url = new URL("/api/query", window.location.origin);
|
|
46
|
+
url.searchParams.set("resource", "home");
|
|
47
|
+
url.searchParams.set("replyFilter", replyFilter);
|
|
48
|
+
url.searchParams.set("refresh", String(refreshTick));
|
|
49
|
+
if (search.trim()) {
|
|
50
|
+
url.searchParams.set("search", search.trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fetch(url)
|
|
54
|
+
.then((response) => response.json())
|
|
55
|
+
.then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
|
|
56
|
+
}, [refreshTick, replyFilter, search]);
|
|
57
|
+
|
|
58
|
+
const subtitle = useMemo(() => {
|
|
59
|
+
if (!meta) return "Loading local context...";
|
|
60
|
+
return `${meta.stats.home} home items · ${meta.stats.needsReply} waiting on action · ${meta.transport.statusText}`;
|
|
61
|
+
}, [meta]);
|
|
62
|
+
|
|
63
|
+
async function replyToTweet(tweetId: string) {
|
|
64
|
+
const text = window.prompt("Reply text");
|
|
65
|
+
if (!text?.trim()) return;
|
|
66
|
+
|
|
67
|
+
await fetch("/api/action", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "content-type": "application/json" },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
kind: "replyTweet",
|
|
72
|
+
accountId: "acct_primary",
|
|
73
|
+
tweetId,
|
|
74
|
+
text,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
setRefreshTick((value) => value + 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<main className={pageWrapClass}>
|
|
83
|
+
<div className={feedPageClass}>
|
|
84
|
+
<section className={heroShellClass}>
|
|
85
|
+
<div>
|
|
86
|
+
<p className={eyebrowClass}>home timeline</p>
|
|
87
|
+
<h2 className={heroTitleClass}>
|
|
88
|
+
Read first. Act only where signal survives.
|
|
89
|
+
</h2>
|
|
90
|
+
<p className={heroCopyClass}>{subtitle}</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div className={heroControlsClass}>
|
|
93
|
+
<input
|
|
94
|
+
className={cx(textFieldClass, textFieldWideClass)}
|
|
95
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
96
|
+
placeholder="Search local timeline"
|
|
97
|
+
value={search}
|
|
98
|
+
/>
|
|
99
|
+
<div className={segmentedClass}>
|
|
100
|
+
{(["all", "replied", "unreplied"] as const).map((value) => (
|
|
101
|
+
<button
|
|
102
|
+
key={value}
|
|
103
|
+
className={cx(
|
|
104
|
+
segmentClass,
|
|
105
|
+
value === replyFilter && segmentActiveClass,
|
|
106
|
+
)}
|
|
107
|
+
onClick={() => setReplyFilter(value)}
|
|
108
|
+
type="button"
|
|
109
|
+
>
|
|
110
|
+
{value}
|
|
111
|
+
</button>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</section>
|
|
116
|
+
|
|
117
|
+
<section className={timelineLaneClass}>
|
|
118
|
+
{items.map((item) => (
|
|
119
|
+
<TimelineCard key={item.id} item={item} onReply={replyToTweet} />
|
|
120
|
+
))}
|
|
121
|
+
</section>
|
|
122
|
+
</div>
|
|
123
|
+
</main>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { TimelineCard } from "#/components/TimelineCard";
|
|
4
|
+
import type {
|
|
5
|
+
QueryEnvelope,
|
|
6
|
+
QueryResponse,
|
|
7
|
+
ReplyFilter,
|
|
8
|
+
TimelineItem,
|
|
9
|
+
} from "#/lib/types";
|
|
10
|
+
import {
|
|
11
|
+
cx,
|
|
12
|
+
eyebrowClass,
|
|
13
|
+
feedPageClass,
|
|
14
|
+
heroControlsClass,
|
|
15
|
+
heroCopyClass,
|
|
16
|
+
heroShellClass,
|
|
17
|
+
heroTitleClass,
|
|
18
|
+
pageWrapClass,
|
|
19
|
+
segmentActiveClass,
|
|
20
|
+
segmentClass,
|
|
21
|
+
segmentedClass,
|
|
22
|
+
textFieldClass,
|
|
23
|
+
textFieldWideClass,
|
|
24
|
+
timelineLaneClass,
|
|
25
|
+
} from "#/lib/ui";
|
|
26
|
+
|
|
27
|
+
export const Route = createFileRoute("/mentions")({
|
|
28
|
+
component: MentionsRoute,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function MentionsRoute() {
|
|
32
|
+
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
33
|
+
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
34
|
+
const [replyFilter, setReplyFilter] = useState<ReplyFilter>("unreplied");
|
|
35
|
+
const [search, setSearch] = useState("");
|
|
36
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
fetch("/api/status")
|
|
40
|
+
.then((response) => response.json())
|
|
41
|
+
.then((data: QueryEnvelope) => setMeta(data));
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const url = new URL("/api/query", window.location.origin);
|
|
46
|
+
url.searchParams.set("resource", "mentions");
|
|
47
|
+
url.searchParams.set("replyFilter", replyFilter);
|
|
48
|
+
url.searchParams.set("refresh", String(refreshTick));
|
|
49
|
+
if (search.trim()) {
|
|
50
|
+
url.searchParams.set("search", search.trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fetch(url)
|
|
54
|
+
.then((response) => response.json())
|
|
55
|
+
.then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
|
|
56
|
+
}, [refreshTick, replyFilter, search]);
|
|
57
|
+
|
|
58
|
+
const subtitle = useMemo(() => {
|
|
59
|
+
if (!meta) return "Loading mentions...";
|
|
60
|
+
return `${meta.stats.mentions} mention/reply items in local store`;
|
|
61
|
+
}, [meta]);
|
|
62
|
+
|
|
63
|
+
async function replyToTweet(tweetId: string) {
|
|
64
|
+
const text = window.prompt("Reply text");
|
|
65
|
+
if (!text?.trim()) return;
|
|
66
|
+
|
|
67
|
+
await fetch("/api/action", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "content-type": "application/json" },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
kind: "replyTweet",
|
|
72
|
+
accountId: "acct_primary",
|
|
73
|
+
tweetId,
|
|
74
|
+
text,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
setRefreshTick((value) => value + 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<main className={pageWrapClass}>
|
|
83
|
+
<div className={feedPageClass}>
|
|
84
|
+
<section className={heroShellClass}>
|
|
85
|
+
<div>
|
|
86
|
+
<p className={eyebrowClass}>mentions and replies</p>
|
|
87
|
+
<h2 className={heroTitleClass}>
|
|
88
|
+
Keep the actionable queue small and visible.
|
|
89
|
+
</h2>
|
|
90
|
+
<p className={heroCopyClass}>{subtitle}</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div className={heroControlsClass}>
|
|
93
|
+
<input
|
|
94
|
+
className={cx(textFieldClass, textFieldWideClass)}
|
|
95
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
96
|
+
placeholder="Search mentions"
|
|
97
|
+
value={search}
|
|
98
|
+
/>
|
|
99
|
+
<div className={segmentedClass}>
|
|
100
|
+
{(["all", "replied", "unreplied"] as const).map((value) => (
|
|
101
|
+
<button
|
|
102
|
+
key={value}
|
|
103
|
+
className={cx(
|
|
104
|
+
segmentClass,
|
|
105
|
+
value === replyFilter && segmentActiveClass,
|
|
106
|
+
)}
|
|
107
|
+
onClick={() => setReplyFilter(value)}
|
|
108
|
+
type="button"
|
|
109
|
+
>
|
|
110
|
+
{value}
|
|
111
|
+
</button>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</section>
|
|
116
|
+
|
|
117
|
+
<section className={timelineLaneClass}>
|
|
118
|
+
{items.map((item) => (
|
|
119
|
+
<TimelineCard key={item.id} item={item} onReply={replyToTweet} />
|
|
120
|
+
))}
|
|
121
|
+
</section>
|
|
122
|
+
</div>
|
|
123
|
+
</main>
|
|
124
|
+
);
|
|
125
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Instrument+Sans:wght@400;500;600;700&display=swap");
|
|
2
|
+
@import "tailwindcss";
|
|
3
|
+
|
|
4
|
+
@theme inline {
|
|
5
|
+
--font-sans: "Instrument Sans", sans-serif;
|
|
6
|
+
--font-display: "Fraunces", serif;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f5f0e8;
|
|
11
|
+
--panel: rgb(255 252 247 / 92%);
|
|
12
|
+
--panel-strong: rgb(255 255 255 / 82%);
|
|
13
|
+
--line: rgb(32 24 17 / 12%);
|
|
14
|
+
--line-strong: rgb(32 24 17 / 24%);
|
|
15
|
+
--ink: #19130e;
|
|
16
|
+
--ink-soft: rgb(25 19 14 / 68%);
|
|
17
|
+
--accent: #1d6f53;
|
|
18
|
+
--accent-soft: rgb(29 111 83 / 12%);
|
|
19
|
+
--alert: #a34a2d;
|
|
20
|
+
--alert-soft: rgb(163 74 45 / 14%);
|
|
21
|
+
--shadow: rgb(32 24 17 / 8%);
|
|
22
|
+
--theme-switch-x: 50%;
|
|
23
|
+
--theme-switch-y: 50%;
|
|
24
|
+
color-scheme: light;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
[data-theme="dark"] {
|
|
28
|
+
--bg: #151311;
|
|
29
|
+
--panel: rgb(29 24 19 / 88%);
|
|
30
|
+
--panel-strong: rgb(39 32 27 / 94%);
|
|
31
|
+
--line: rgb(245 232 214 / 10%);
|
|
32
|
+
--line-strong: rgb(245 232 214 / 18%);
|
|
33
|
+
--ink: #f4ede3;
|
|
34
|
+
--ink-soft: rgb(244 237 227 / 68%);
|
|
35
|
+
--accent: #68c4a0;
|
|
36
|
+
--accent-soft: rgb(104 196 160 / 14%);
|
|
37
|
+
--alert: #d98a69;
|
|
38
|
+
--alert-soft: rgb(217 138 105 / 16%);
|
|
39
|
+
--shadow: rgb(0 0 0 / 34%);
|
|
40
|
+
color-scheme: dark;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
html,
|
|
44
|
+
body {
|
|
45
|
+
min-height: 100%;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
body {
|
|
49
|
+
background:
|
|
50
|
+
radial-gradient(
|
|
51
|
+
circle at top left,
|
|
52
|
+
color-mix(in srgb, var(--accent) 16%, transparent),
|
|
53
|
+
transparent 28%
|
|
54
|
+
),
|
|
55
|
+
radial-gradient(
|
|
56
|
+
circle at right 18%,
|
|
57
|
+
color-mix(in srgb, var(--alert) 12%, transparent),
|
|
58
|
+
transparent 24%
|
|
59
|
+
),
|
|
60
|
+
linear-gradient(
|
|
61
|
+
180deg,
|
|
62
|
+
color-mix(in srgb, var(--bg) 82%, white) 0%,
|
|
63
|
+
var(--bg) 100%
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@keyframes theme-circle-transition {
|
|
68
|
+
0% {
|
|
69
|
+
clip-path: circle(
|
|
70
|
+
0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
100% {
|
|
75
|
+
clip-path: circle(
|
|
76
|
+
150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
html.theme-transition {
|
|
82
|
+
view-transition-name: theme;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* View transition pseudo-element. */
|
|
86
|
+
html.theme-transition::view-transition-old(theme) {
|
|
87
|
+
animation: none;
|
|
88
|
+
mix-blend-mode: normal;
|
|
89
|
+
z-index: 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* View transition pseudo-element. */
|
|
93
|
+
html.theme-transition::view-transition-new(theme) {
|
|
94
|
+
animation: theme-circle-transition 0.45s ease-out forwards;
|
|
95
|
+
mix-blend-mode: normal;
|
|
96
|
+
z-index: 2;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@media (prefers-reduced-motion: reduce) {
|
|
100
|
+
/* View transition pseudo-element. */
|
|
101
|
+
html.theme-transition::view-transition-old(theme) {
|
|
102
|
+
animation: none !important;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* View transition pseudo-element. */
|
|
106
|
+
html.theme-transition::view-transition-new(theme) {
|
|
107
|
+
animation: none !important;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"include": ["**/*.ts", "**/*.tsx"],
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"baseUrl": ".",
|
|
8
|
+
"ignoreDeprecations": "6.0",
|
|
9
|
+
"paths": {
|
|
10
|
+
"#/*": ["./src/*"],
|
|
11
|
+
"@/*": ["./src/*"]
|
|
12
|
+
},
|
|
13
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
14
|
+
"types": ["vite/client"],
|
|
15
|
+
|
|
16
|
+
/* Bundler mode */
|
|
17
|
+
"moduleResolution": "bundler",
|
|
18
|
+
"allowImportingTsExtensions": true,
|
|
19
|
+
"verbatimModuleSyntax": true,
|
|
20
|
+
"noEmit": true,
|
|
21
|
+
|
|
22
|
+
/* Linting */
|
|
23
|
+
"skipLibCheck": true,
|
|
24
|
+
"strict": true,
|
|
25
|
+
"noUnusedLocals": true,
|
|
26
|
+
"noUnusedParameters": true,
|
|
27
|
+
"noFallthroughCasesInSwitch": true,
|
|
28
|
+
"noUncheckedSideEffectImports": true
|
|
29
|
+
}
|
|
30
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
2
|
+
import { devtools } from "@tanstack/devtools-vite";
|
|
3
|
+
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
|
4
|
+
import viteReact from "@vitejs/plugin-react";
|
|
5
|
+
import { defineConfig } from "vite";
|
|
6
|
+
|
|
7
|
+
const config = defineConfig({
|
|
8
|
+
plugins: [
|
|
9
|
+
devtools(),
|
|
10
|
+
tailwindcss(),
|
|
11
|
+
tanstackStart({
|
|
12
|
+
router: {
|
|
13
|
+
routeFileIgnorePattern: "\\.(test|spec)\\.(ts|tsx)$",
|
|
14
|
+
},
|
|
15
|
+
}),
|
|
16
|
+
viteReact(),
|
|
17
|
+
],
|
|
18
|
+
resolve: {
|
|
19
|
+
tsconfigPaths: true,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export default config;
|