birdclaw 0.5.0 → 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.
- package/CHANGELOG.md +20 -0
- package/README.md +5 -0
- package/bin/birdclaw.mjs +50 -11
- package/package.json +7 -6
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +399 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +29 -0
- package/src/cli.ts +46 -1
- package/src/components/AppNav.tsx +3 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +24 -9
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +157 -61
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +13 -2
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +18 -3
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +2 -0
- package/src/lib/queries.ts +93 -28
- package/src/lib/ui.ts +11 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/dms.tsx +100 -27
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CheckCircle2, Circle } from "lucide-react";
|
|
1
2
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
2
3
|
import type { DmConversationItem, DmMessageItem } from "#/lib/types";
|
|
3
4
|
import {
|
|
@@ -29,7 +30,6 @@ import {
|
|
|
29
30
|
dmThreadNameClass,
|
|
30
31
|
dmThreadSubtitleClass,
|
|
31
32
|
dmThreadTitleClass,
|
|
32
|
-
emptyStateClass,
|
|
33
33
|
pillAlertClass,
|
|
34
34
|
pillClass,
|
|
35
35
|
pillSoftClass,
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
timestampClass,
|
|
38
38
|
} from "#/lib/ui";
|
|
39
39
|
import { AvatarChip } from "./AvatarChip";
|
|
40
|
+
import { BirdclawEmpty } from "./BrandMark";
|
|
40
41
|
|
|
41
42
|
function MessageBubble({ message }: { message: DmMessageItem }) {
|
|
42
43
|
const outbound = message.direction === "outbound";
|
|
@@ -80,14 +81,17 @@ export function DmWorkspace({
|
|
|
80
81
|
}) {
|
|
81
82
|
const participant = selectedConversation?.participant ?? null;
|
|
82
83
|
const subtitle = selectedConversation
|
|
83
|
-
? `${selectedConversation.needsReply ? "Reply
|
|
84
|
+
? `${selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
|
|
84
85
|
: "No conversation selected";
|
|
85
86
|
|
|
86
87
|
return (
|
|
87
88
|
<section className={dmShellClass}>
|
|
88
89
|
<aside className={dmListClass}>
|
|
89
90
|
{conversations.length === 0 ? (
|
|
90
|
-
<
|
|
91
|
+
<BirdclawEmpty
|
|
92
|
+
detail="Sync DMs to populate this lane."
|
|
93
|
+
label="No conversations"
|
|
94
|
+
/>
|
|
91
95
|
) : null}
|
|
92
96
|
{conversations.map((conversation) => {
|
|
93
97
|
const active = conversation.id === selectedConversation?.id;
|
|
@@ -127,8 +131,16 @@ export function DmWorkspace({
|
|
|
127
131
|
pillClass,
|
|
128
132
|
conversation.needsReply ? pillAlertClass : pillSoftClass,
|
|
129
133
|
)}
|
|
134
|
+
aria-label={
|
|
135
|
+
conversation.needsReply ? "Reply open" : "We replied"
|
|
136
|
+
}
|
|
130
137
|
>
|
|
131
|
-
{conversation.needsReply ?
|
|
138
|
+
{conversation.needsReply ? (
|
|
139
|
+
<Circle className="size-3" strokeWidth={2.2} />
|
|
140
|
+
) : (
|
|
141
|
+
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
142
|
+
)}
|
|
143
|
+
{conversation.needsReply ? "open" : "replied"}
|
|
132
144
|
</span>
|
|
133
145
|
<span className={cx(pillClass, pillSoftClass)}>
|
|
134
146
|
{conversation.influenceScore} ·{" "}
|
|
@@ -192,8 +204,8 @@ export function DmWorkspace({
|
|
|
192
204
|
<dt className={contextStatTermClass}>Reply state</dt>
|
|
193
205
|
<dd className={contextStatValueClass}>
|
|
194
206
|
{selectedConversation.needsReply
|
|
195
|
-
? "
|
|
196
|
-
: "
|
|
207
|
+
? "Reply open"
|
|
208
|
+
: "We replied"}
|
|
197
209
|
</dd>
|
|
198
210
|
</div>
|
|
199
211
|
<div className={contextStatRowClass}>
|
|
@@ -221,8 +233,8 @@ export function DmWorkspace({
|
|
|
221
233
|
<div className={composerBarClass}>
|
|
222
234
|
<span className={timestampClass}>
|
|
223
235
|
{selectedConversation.needsReply
|
|
224
|
-
? "Reply
|
|
225
|
-
: "
|
|
236
|
+
? "Reply open"
|
|
237
|
+
: "We replied"}
|
|
226
238
|
</span>
|
|
227
239
|
<button
|
|
228
240
|
className={primaryButtonClass}
|
|
@@ -236,7 +248,10 @@ export function DmWorkspace({
|
|
|
236
248
|
</div>
|
|
237
249
|
</>
|
|
238
250
|
) : (
|
|
239
|
-
<
|
|
251
|
+
<BirdclawEmpty
|
|
252
|
+
detail="Pick a conversation to read the thread."
|
|
253
|
+
label="No DM selected"
|
|
254
|
+
/>
|
|
240
255
|
)}
|
|
241
256
|
</div>
|
|
242
257
|
</section>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
|
|
3
|
+
|
|
4
|
+
function SkeletonBlock({
|
|
5
|
+
className,
|
|
6
|
+
muted = false,
|
|
7
|
+
}: {
|
|
8
|
+
className: string;
|
|
9
|
+
muted?: boolean;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<span
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
className={`block animate-pulse rounded-full ${muted ? "bg-[color:color-mix(in_srgb,var(--ink-soft)_16%,transparent)]" : "bg-[color:color-mix(in_srgb,var(--ink-soft)_24%,transparent)]"} ${className}`}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TweetSkeletonRows({ count = 4 }: { count?: number }) {
|
|
20
|
+
return (
|
|
21
|
+
<div aria-hidden="true" className="divide-y divide-[var(--line)]">
|
|
22
|
+
{Array.from({ length: count }, (_unused, index) => {
|
|
23
|
+
const hasMedia = index % 3 === 0;
|
|
24
|
+
const hasQuote = index % 4 === 2;
|
|
25
|
+
return (
|
|
26
|
+
<article
|
|
27
|
+
className="flex gap-3 px-4 py-3"
|
|
28
|
+
data-perf="tweet-skeleton-row"
|
|
29
|
+
key={`tweet-skeleton-${index}`}
|
|
30
|
+
>
|
|
31
|
+
<SkeletonBlock className="size-10 shrink-0 rounded-full" />
|
|
32
|
+
<div className="min-w-0 flex-1 space-y-3">
|
|
33
|
+
<div className="flex items-center gap-2">
|
|
34
|
+
<SkeletonBlock className="h-3.5 w-32" />
|
|
35
|
+
<SkeletonBlock className="h-3 w-20" muted />
|
|
36
|
+
<SkeletonBlock className="ml-auto h-5 w-16" muted />
|
|
37
|
+
</div>
|
|
38
|
+
<div className="space-y-2">
|
|
39
|
+
<SkeletonBlock className="h-3 w-full" />
|
|
40
|
+
<SkeletonBlock className="h-3 w-11/12" muted />
|
|
41
|
+
{index % 2 === 0 ? (
|
|
42
|
+
<SkeletonBlock className="h-3 w-7/12" muted />
|
|
43
|
+
) : null}
|
|
44
|
+
</div>
|
|
45
|
+
{hasMedia ? (
|
|
46
|
+
<SkeletonBlock className="h-32 w-full rounded-2xl" muted />
|
|
47
|
+
) : null}
|
|
48
|
+
{hasQuote ? (
|
|
49
|
+
<div className="space-y-2 rounded-2xl border border-[var(--line)] px-3 py-2">
|
|
50
|
+
<SkeletonBlock className="h-3 w-28" muted />
|
|
51
|
+
<SkeletonBlock className="h-3 w-full" muted />
|
|
52
|
+
</div>
|
|
53
|
+
) : null}
|
|
54
|
+
<div className="flex max-w-md items-center justify-between">
|
|
55
|
+
{["thread", "reply", "repost", "like"].map((key) => (
|
|
56
|
+
<SkeletonBlock className="h-3 w-11" key={key} muted />
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</article>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function LinkSkeletonRows({ count = 4 }: { count?: number }) {
|
|
68
|
+
return (
|
|
69
|
+
<div aria-hidden="true" className="divide-y divide-[var(--line)]">
|
|
70
|
+
{Array.from({ length: count }, (_unused, index) => (
|
|
71
|
+
<article
|
|
72
|
+
className="flex gap-3 px-4 py-3"
|
|
73
|
+
data-perf="link-skeleton-row"
|
|
74
|
+
key={`link-skeleton-${index}`}
|
|
75
|
+
>
|
|
76
|
+
<SkeletonBlock className="size-9 shrink-0 rounded-full" muted />
|
|
77
|
+
<div className="min-w-0 flex-1 space-y-3">
|
|
78
|
+
<SkeletonBlock className="h-3.5 w-8/12" />
|
|
79
|
+
<SkeletonBlock className="h-3 w-6/12" muted />
|
|
80
|
+
<div className="flex flex-wrap gap-2">
|
|
81
|
+
<SkeletonBlock className="h-3 w-20" muted />
|
|
82
|
+
<SkeletonBlock className="h-3 w-14" muted />
|
|
83
|
+
<SkeletonBlock className="h-3 w-24" muted />
|
|
84
|
+
</div>
|
|
85
|
+
{index % 2 === 0 ? (
|
|
86
|
+
<SkeletonBlock
|
|
87
|
+
className="h-20 w-72 max-w-full rounded-2xl"
|
|
88
|
+
muted
|
|
89
|
+
/>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
</article>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function FeedLoading({
|
|
99
|
+
children,
|
|
100
|
+
detail,
|
|
101
|
+
label,
|
|
102
|
+
}: {
|
|
103
|
+
children?: ReactNode;
|
|
104
|
+
detail?: string;
|
|
105
|
+
label: string;
|
|
106
|
+
}) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="border-b border-[var(--line)]">
|
|
109
|
+
<BirdclawLoading detail={detail} label={label} />
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function FeedError({
|
|
116
|
+
action,
|
|
117
|
+
message,
|
|
118
|
+
title = "Could not load this view",
|
|
119
|
+
}: {
|
|
120
|
+
action?: ReactNode;
|
|
121
|
+
message: string;
|
|
122
|
+
title?: string;
|
|
123
|
+
}) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="border-b border-[var(--line)] px-6 py-10 text-center">
|
|
126
|
+
<div className="mx-auto max-w-sm">
|
|
127
|
+
<div className="text-[14px] font-bold text-[var(--alert)]">{title}</div>
|
|
128
|
+
<p className="mt-2 text-[13px] leading-[1.45] text-[var(--ink-soft)]">
|
|
129
|
+
{message}
|
|
130
|
+
</p>
|
|
131
|
+
{action ? (
|
|
132
|
+
<div className="mt-4 flex justify-center">{action}</div>
|
|
133
|
+
) : null}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function FeedEmpty({
|
|
140
|
+
detail,
|
|
141
|
+
label,
|
|
142
|
+
}: {
|
|
143
|
+
detail?: string;
|
|
144
|
+
label: string;
|
|
145
|
+
}) {
|
|
146
|
+
return <BirdclawEmpty detail={detail} label={label} />;
|
|
147
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { Link } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
Circle,
|
|
5
|
+
ExternalLink,
|
|
6
|
+
MessageCircle,
|
|
7
|
+
} from "lucide-react";
|
|
3
8
|
import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
|
|
4
9
|
import type { InboxItem } from "#/lib/types";
|
|
5
10
|
import {
|
|
@@ -86,7 +91,14 @@ export function InboxCard({
|
|
|
86
91
|
<span className={mutedDotClass} />
|
|
87
92
|
<span>influence {formatCompactNumber(item.influenceScore)}</span>
|
|
88
93
|
<span className={mutedDotClass} />
|
|
89
|
-
<span
|
|
94
|
+
<span className="inline-flex items-center gap-1">
|
|
95
|
+
{item.needsReply ? (
|
|
96
|
+
<Circle className="size-3" strokeWidth={2.2} />
|
|
97
|
+
) : (
|
|
98
|
+
<CheckCircle2 className="size-3.5" strokeWidth={2} />
|
|
99
|
+
)}
|
|
100
|
+
{item.needsReply ? "open" : "replied"}
|
|
101
|
+
</span>
|
|
90
102
|
</div>
|
|
91
103
|
<div className="flex items-center gap-2">
|
|
92
104
|
<button
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { Search } from "lucide-react";
|
|
2
|
-
import {
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
FeedEmpty,
|
|
5
|
+
FeedError,
|
|
6
|
+
FeedLoading,
|
|
7
|
+
TweetSkeletonRows,
|
|
8
|
+
} from "#/components/FeedState";
|
|
9
|
+
import { SyncNowButton } from "#/components/SyncNowButton";
|
|
3
10
|
import { TimelineCard } from "#/components/TimelineCard";
|
|
4
|
-
import
|
|
11
|
+
import { useTimelineRouteData } from "#/components/useTimelineRouteData";
|
|
12
|
+
import { ConversationSurfaceScope } from "#/lib/conversation-surface";
|
|
5
13
|
import {
|
|
6
|
-
emptyStateClass,
|
|
7
14
|
feedClass,
|
|
8
15
|
pageHeaderClass,
|
|
9
16
|
pageHeaderRowClass,
|
|
@@ -33,30 +40,15 @@ export function SavedTimelineView({
|
|
|
33
40
|
loadingLabel,
|
|
34
41
|
searchPlaceholder,
|
|
35
42
|
}: SavedTimelineViewProps) {
|
|
36
|
-
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
37
|
-
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
38
43
|
const [search, setSearch] = useState("");
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
useEffect(() => {
|
|
48
|
-
const url = new URL("/api/query", window.location.origin);
|
|
49
|
-
url.searchParams.set("resource", "home");
|
|
50
|
-
url.searchParams.set(filter, "true");
|
|
51
|
-
url.searchParams.set("refresh", String(refreshTick));
|
|
52
|
-
if (search.trim()) {
|
|
53
|
-
url.searchParams.set("search", search.trim());
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
fetch(url)
|
|
57
|
-
.then((response) => response.json())
|
|
58
|
-
.then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
|
|
59
|
-
}, [filter, refreshTick, search]);
|
|
44
|
+
const { meta, items, loading, error, retry, refreshLocalView, replyToTweet } =
|
|
45
|
+
useTimelineRouteData({
|
|
46
|
+
resource: "home",
|
|
47
|
+
search,
|
|
48
|
+
errorFallback: `${TITLES[filter]} unavailable`,
|
|
49
|
+
likedOnly: filter === "liked",
|
|
50
|
+
bookmarkedOnly: filter === "bookmarked",
|
|
51
|
+
});
|
|
60
52
|
|
|
61
53
|
const subtitle = useMemo(() => {
|
|
62
54
|
if (!meta) {
|
|
@@ -67,23 +59,7 @@ export function SavedTimelineView({
|
|
|
67
59
|
return `${String(items.length)} visible · ${meta.transport.statusText}`;
|
|
68
60
|
}, [items.length, loadingLabel, meta]);
|
|
69
61
|
|
|
70
|
-
|
|
71
|
-
const text = window.prompt("Reply text");
|
|
72
|
-
if (!text?.trim()) return;
|
|
73
|
-
|
|
74
|
-
await fetch("/api/action", {
|
|
75
|
-
method: "POST",
|
|
76
|
-
headers: { "content-type": "application/json" },
|
|
77
|
-
body: JSON.stringify({
|
|
78
|
-
kind: "replyTweet",
|
|
79
|
-
accountId: "acct_primary",
|
|
80
|
-
tweetId,
|
|
81
|
-
text,
|
|
82
|
-
}),
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
setRefreshTick((value) => value + 1);
|
|
86
|
-
}
|
|
62
|
+
const syncKind = filter === "liked" ? "likes" : "bookmarks";
|
|
87
63
|
|
|
88
64
|
return (
|
|
89
65
|
<>
|
|
@@ -94,6 +70,12 @@ export function SavedTimelineView({
|
|
|
94
70
|
<p className={pageSubtitleClass}>{title}</p>
|
|
95
71
|
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
96
72
|
</div>
|
|
73
|
+
<SyncNowButton
|
|
74
|
+
accounts={meta?.accounts}
|
|
75
|
+
kind={syncKind}
|
|
76
|
+
label={filter === "liked" ? "Sync likes" : "Sync bookmarks"}
|
|
77
|
+
onSynced={refreshLocalView}
|
|
78
|
+
/>
|
|
97
79
|
</div>
|
|
98
80
|
<div className="px-4 pb-3">
|
|
99
81
|
<label className={searchFieldShellClass}>
|
|
@@ -107,19 +89,45 @@ export function SavedTimelineView({
|
|
|
107
89
|
</label>
|
|
108
90
|
</div>
|
|
109
91
|
</header>
|
|
110
|
-
<
|
|
111
|
-
{
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
92
|
+
<ConversationSurfaceScope>
|
|
93
|
+
<section className={feedClass}>
|
|
94
|
+
{loading ? (
|
|
95
|
+
<FeedLoading
|
|
96
|
+
detail={`Reading local ${TITLES[filter].toLowerCase()}`}
|
|
97
|
+
label={loadingLabel}
|
|
98
|
+
>
|
|
99
|
+
<TweetSkeletonRows />
|
|
100
|
+
</FeedLoading>
|
|
101
|
+
) : error ? (
|
|
102
|
+
<FeedError
|
|
103
|
+
action={
|
|
104
|
+
<button
|
|
105
|
+
className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
|
|
106
|
+
onClick={retry}
|
|
107
|
+
type="button"
|
|
108
|
+
>
|
|
109
|
+
Retry
|
|
110
|
+
</button>
|
|
111
|
+
}
|
|
112
|
+
message={error}
|
|
113
|
+
title={`Could not load ${TITLES[filter].toLowerCase()}`}
|
|
114
|
+
/>
|
|
115
|
+
) : items.length === 0 ? (
|
|
116
|
+
<FeedEmpty
|
|
117
|
+
detail="Sync this collection or broaden the search."
|
|
118
|
+
label="Nothing saved here yet"
|
|
119
|
+
/>
|
|
120
|
+
) : null}
|
|
121
|
+
{items.map((item) => (
|
|
122
|
+
<TimelineCard
|
|
123
|
+
key={item.id}
|
|
124
|
+
item={item}
|
|
125
|
+
onReply={replyToTweet}
|
|
126
|
+
showReplyControls={false}
|
|
127
|
+
/>
|
|
128
|
+
))}
|
|
129
|
+
</section>
|
|
130
|
+
</ConversationSurfaceScope>
|
|
123
131
|
</>
|
|
124
132
|
);
|
|
125
133
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { RefreshCw } from "lucide-react";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { postSync } from "#/lib/api-client";
|
|
4
|
+
import type { AccountRecord } from "#/lib/types";
|
|
5
|
+
import { cx, selectFieldClass } from "#/lib/ui";
|
|
6
|
+
import type { WebSyncKind, WebSyncResponse } from "#/lib/web-sync";
|
|
7
|
+
|
|
8
|
+
interface SyncNowButtonProps {
|
|
9
|
+
kind: WebSyncKind;
|
|
10
|
+
label: string;
|
|
11
|
+
accounts?: AccountRecord[];
|
|
12
|
+
onSynced: (result: WebSyncResponse) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SyncNowButton({
|
|
16
|
+
kind,
|
|
17
|
+
label,
|
|
18
|
+
accounts = [],
|
|
19
|
+
onSynced,
|
|
20
|
+
}: SyncNowButtonProps) {
|
|
21
|
+
const [syncing, setSyncing] = useState(false);
|
|
22
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const [selectedAccountId, setSelectedAccountId] = useState<
|
|
25
|
+
string | undefined
|
|
26
|
+
>();
|
|
27
|
+
const defaultAccountId = useMemo(
|
|
28
|
+
() => accounts.find((account) => account.isDefault)?.id ?? accounts[0]?.id,
|
|
29
|
+
[accounts],
|
|
30
|
+
);
|
|
31
|
+
const accountId = selectedAccountId ?? defaultAccountId;
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!accounts.length) {
|
|
35
|
+
setSelectedAccountId(undefined);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!accountId || !accounts.some((account) => account.id === accountId)) {
|
|
39
|
+
setSelectedAccountId(defaultAccountId);
|
|
40
|
+
}
|
|
41
|
+
}, [accountId, accounts, defaultAccountId]);
|
|
42
|
+
|
|
43
|
+
async function syncNow() {
|
|
44
|
+
setSyncing(true);
|
|
45
|
+
setError(null);
|
|
46
|
+
setMessage(null);
|
|
47
|
+
try {
|
|
48
|
+
const data = await postSync(kind, accountId);
|
|
49
|
+
if (!data.ok) throw new Error(data.summary);
|
|
50
|
+
setMessage(data.summary);
|
|
51
|
+
onSynced(data);
|
|
52
|
+
} catch (syncError) {
|
|
53
|
+
setError(syncError instanceof Error ? syncError.message : "Sync failed");
|
|
54
|
+
} finally {
|
|
55
|
+
setSyncing(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
61
|
+
{accounts.length > 1 ? (
|
|
62
|
+
<select
|
|
63
|
+
aria-label="Sync account"
|
|
64
|
+
className={cx(selectFieldClass, "h-9 w-[132px]")}
|
|
65
|
+
disabled={syncing}
|
|
66
|
+
onChange={(event) => setSelectedAccountId(event.target.value)}
|
|
67
|
+
value={accountId ?? ""}
|
|
68
|
+
>
|
|
69
|
+
{accounts.map((account) => (
|
|
70
|
+
<option key={account.id} value={account.id}>
|
|
71
|
+
{account.handle}
|
|
72
|
+
</option>
|
|
73
|
+
))}
|
|
74
|
+
</select>
|
|
75
|
+
) : null}
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
className={cx(
|
|
79
|
+
"inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-semibold text-[var(--ink)] transition-[background,border-color,color,transform] duration-150 hover:border-[color:color-mix(in_srgb,var(--accent)_45%,var(--line))] hover:bg-[var(--accent-soft)] hover:text-[var(--accent)] active:scale-[0.98] disabled:cursor-wait disabled:opacity-65",
|
|
80
|
+
syncing && "text-[var(--ink-soft)]",
|
|
81
|
+
)}
|
|
82
|
+
aria-label={syncing ? `${label}: syncing` : label}
|
|
83
|
+
disabled={syncing}
|
|
84
|
+
onClick={syncNow}
|
|
85
|
+
>
|
|
86
|
+
<RefreshCw
|
|
87
|
+
className={cx("size-4", syncing && "animate-spin")}
|
|
88
|
+
strokeWidth={2}
|
|
89
|
+
/>
|
|
90
|
+
<span className="hidden sm:inline">
|
|
91
|
+
{syncing ? "Syncing..." : label}
|
|
92
|
+
</span>
|
|
93
|
+
</button>
|
|
94
|
+
<span
|
|
95
|
+
className={cx(
|
|
96
|
+
"hidden max-w-[190px] truncate text-[12px] sm:inline",
|
|
97
|
+
error ? "text-[var(--alert)]" : "text-[var(--ink-soft)]",
|
|
98
|
+
)}
|
|
99
|
+
role="status"
|
|
100
|
+
>
|
|
101
|
+
{error ?? message ?? ""}
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Monitor, Moon, Sun } from "lucide-react";
|
|
2
|
-
import type {
|
|
3
|
-
import { useMemo } from "react";
|
|
2
|
+
import type { MouseEvent } from "react";
|
|
4
3
|
import { type ThemeValue, useTheme } from "#/lib/theme";
|
|
5
4
|
import {
|
|
6
5
|
startThemeTransition,
|
|
@@ -18,67 +17,18 @@ const THEME_OPTIONS = [
|
|
|
18
17
|
label: string;
|
|
19
18
|
}>;
|
|
20
19
|
|
|
21
|
-
const ACTIVE_ITEM_WIDTH_PX = 30;
|
|
22
|
-
const GAP_PX = 6;
|
|
23
|
-
const CONTAINER_PADDING_PX = 6;
|
|
24
|
-
const INDICATOR_SIZE_PX = 30;
|
|
25
|
-
const INDICATOR_OVERHANG_PX = (INDICATOR_SIZE_PX - ACTIVE_ITEM_WIDTH_PX) / 2;
|
|
26
|
-
const INDICATOR_BASE_OFFSET_PX = CONTAINER_PADDING_PX - INDICATOR_OVERHANG_PX;
|
|
27
|
-
|
|
28
|
-
function toPx(value: number) {
|
|
29
|
-
return `${String(value)}px`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
20
|
export function ThemeSlider() {
|
|
33
|
-
const { isReady, theme,
|
|
34
|
-
|
|
35
|
-
const activeIndex = useMemo(() => {
|
|
36
|
-
const index = THEME_OPTIONS.findIndex((option) => option.key === theme);
|
|
37
|
-
return index === -1 ? 0 : index;
|
|
38
|
-
}, [theme]);
|
|
39
|
-
|
|
40
|
-
const indicatorOffset = activeIndex * (ACTIVE_ITEM_WIDTH_PX + GAP_PX);
|
|
41
|
-
const indicatorStyle = useMemo<CSSProperties>(
|
|
42
|
-
() => ({
|
|
43
|
-
left: toPx(INDICATOR_BASE_OFFSET_PX),
|
|
44
|
-
transform: `translate(${toPx(indicatorOffset)}, -50%)`,
|
|
45
|
-
}),
|
|
46
|
-
[indicatorOffset],
|
|
47
|
-
);
|
|
48
|
-
const sliderStyle = useMemo<CSSProperties>(
|
|
49
|
-
() => ({
|
|
50
|
-
gridTemplateColumns: `repeat(${String(THEME_OPTIONS.length)}, ${toPx(ACTIVE_ITEM_WIDTH_PX)})`,
|
|
51
|
-
columnGap: toPx(GAP_PX),
|
|
52
|
-
padding: `0 ${toPx(CONTAINER_PADDING_PX)}`,
|
|
53
|
-
width: toPx(
|
|
54
|
-
THEME_OPTIONS.length * ACTIVE_ITEM_WIDTH_PX +
|
|
55
|
-
(THEME_OPTIONS.length - 1) * GAP_PX +
|
|
56
|
-
CONTAINER_PADDING_PX * 2,
|
|
57
|
-
),
|
|
58
|
-
}),
|
|
59
|
-
[],
|
|
60
|
-
);
|
|
21
|
+
const { isReady, theme, setTheme } = useTheme();
|
|
61
22
|
|
|
62
23
|
return (
|
|
63
24
|
<fieldset
|
|
64
|
-
className="theme-slider-shell m-0 border-0
|
|
25
|
+
className="theme-slider-shell m-0 flex justify-center border-0 px-2 py-1 min-[1100px]:justify-start min-[1100px]:px-3"
|
|
65
26
|
aria-label="Theme selector"
|
|
66
27
|
>
|
|
67
|
-
<div
|
|
68
|
-
className="theme-slider relative grid h-[42px] place-items-center overflow-hidden rounded-full border border-[var(--line)] bg-[color:color-mix(in_srgb,var(--panel-strong)_86%,transparent)] shadow-[inset_0_1px_0_color-mix(in_srgb,white_40%,transparent)] transition-[background,border-color] duration-180"
|
|
69
|
-
style={sliderStyle}
|
|
70
|
-
>
|
|
71
|
-
<div
|
|
72
|
-
className={cx(
|
|
73
|
-
"theme-slider-indicator pointer-events-none absolute top-1/2 z-0 size-[30px] rounded-full border border-[color:color-mix(in_srgb,var(--line-strong)_85%,transparent)] bg-[radial-gradient(circle_at_30%_30%,rgba(255,255,255,0.6),transparent_58%),var(--panel-strong)] shadow-[0_10px_24px_var(--shadow),inset_0_1px_0_rgba(255,255,255,0.35)] transition-[transform,background,border-color,box-shadow] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
|
74
|
-
resolvedTheme === "dark" &&
|
|
75
|
-
"theme-slider-indicator-dark shadow-[0_10px_24px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)]",
|
|
76
|
-
)}
|
|
77
|
-
style={indicatorStyle}
|
|
78
|
-
/>
|
|
28
|
+
<div className="theme-slider flex flex-col items-center gap-1.5 min-[1100px]:flex-row">
|
|
79
29
|
{THEME_OPTIONS.map((option, index) => {
|
|
80
30
|
const Icon = option.icon;
|
|
81
|
-
const isActive = index ===
|
|
31
|
+
const isActive = option.key === theme || (index === 0 && !theme);
|
|
82
32
|
|
|
83
33
|
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
|
84
34
|
if (isActive) return;
|
|
@@ -102,8 +52,9 @@ export function ThemeSlider() {
|
|
|
102
52
|
key={option.key}
|
|
103
53
|
type="button"
|
|
104
54
|
className={cx(
|
|
105
|
-
"theme-slider-button
|
|
106
|
-
isActive &&
|
|
55
|
+
"theme-slider-button inline-flex size-9 items-center justify-center rounded-full border-0 bg-transparent text-[var(--ink-soft)] transition-[background,color,transform] duration-150 hover:bg-[var(--bg-hover)] hover:text-[var(--ink)] active:scale-95 disabled:cursor-default disabled:opacity-55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:color-mix(in_srgb,var(--accent)_54%,transparent)]",
|
|
56
|
+
isActive &&
|
|
57
|
+
"theme-slider-button-active bg-[var(--bg-active)] text-[var(--ink)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--line)_72%,transparent)]",
|
|
107
58
|
)}
|
|
108
59
|
onClick={handleClick}
|
|
109
60
|
aria-label={option.label}
|
|
@@ -112,8 +63,8 @@ export function ThemeSlider() {
|
|
|
112
63
|
disabled={!isReady}
|
|
113
64
|
>
|
|
114
65
|
<Icon
|
|
115
|
-
className="theme-slider-icon size-[
|
|
116
|
-
strokeWidth={1.8}
|
|
66
|
+
className="theme-slider-icon size-[17px]"
|
|
67
|
+
strokeWidth={isActive ? 2.1 : 1.8}
|
|
117
68
|
/>
|
|
118
69
|
</button>
|
|
119
70
|
);
|