birdclaw 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +113 -7
- package/bin/birdclaw.mjs +50 -11
- package/package.json +30 -28
- package/playwright.config.ts +1 -0
- package/public/birdclaw-mark.png +0 -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/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 +496 -19
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +126 -0
- package/src/components/DmWorkspace.tsx +118 -105
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +104 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +89 -71
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +326 -86
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +19 -4
- 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 +1582 -67
- 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/conversation-surface.ts +174 -0
- package/src/lib/db.ts +193 -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 +326 -35
- 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 +383 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +126 -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/api/sync.tsx +59 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +172 -87
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +22 -115
- package/src/routes/links.tsx +928 -0
- package/src/routes/mentions.tsx +22 -115
- package/src/styles.css +169 -43
- package/vite.config.ts +8 -0
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
profilePreviewHandleClass,
|
|
10
10
|
profilePreviewHeaderClass,
|
|
11
11
|
profilePreviewMetaClass,
|
|
12
|
+
profilePreviewNameClass,
|
|
12
13
|
profilePreviewTriggerClass,
|
|
13
14
|
} from "#/lib/ui";
|
|
14
15
|
import { AvatarChip } from "./AvatarChip";
|
|
@@ -40,12 +41,16 @@ export function ProfilePreview({
|
|
|
40
41
|
name={profile.displayName}
|
|
41
42
|
profileId={profile.id}
|
|
42
43
|
/>
|
|
43
|
-
<span>
|
|
44
|
-
<
|
|
44
|
+
<span className="flex min-w-0 flex-col">
|
|
45
|
+
<span className={profilePreviewNameClass}>
|
|
46
|
+
{profile.displayName}
|
|
47
|
+
</span>
|
|
45
48
|
<span className={profilePreviewHandleClass}>@{profile.handle}</span>
|
|
46
49
|
</span>
|
|
47
50
|
</span>
|
|
48
|
-
|
|
51
|
+
{profile.bio ? (
|
|
52
|
+
<span className={profilePreviewBioClass}>{profile.bio}</span>
|
|
53
|
+
) : null}
|
|
49
54
|
<span className={profilePreviewMetaClass}>
|
|
50
55
|
{formatCompactNumber(profile.followersCount)} followers
|
|
51
56
|
</span>
|
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Search } from "lucide-react";
|
|
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";
|
|
2
10
|
import { TimelineCard } from "#/components/TimelineCard";
|
|
3
|
-
import
|
|
11
|
+
import { useTimelineRouteData } from "#/components/useTimelineRouteData";
|
|
12
|
+
import { ConversationSurfaceScope } from "#/lib/conversation-surface";
|
|
4
13
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
textFieldClass,
|
|
14
|
-
textFieldWideClass,
|
|
15
|
-
timelineLaneClass,
|
|
14
|
+
feedClass,
|
|
15
|
+
pageHeaderClass,
|
|
16
|
+
pageHeaderRowClass,
|
|
17
|
+
pageSubtitleClass,
|
|
18
|
+
pageTitleClass,
|
|
19
|
+
searchFieldIconClass,
|
|
20
|
+
searchFieldInputClass,
|
|
21
|
+
searchFieldShellClass,
|
|
16
22
|
} from "#/lib/ui";
|
|
17
23
|
|
|
18
24
|
interface SavedTimelineViewProps {
|
|
@@ -23,83 +29,95 @@ interface SavedTimelineViewProps {
|
|
|
23
29
|
searchPlaceholder: string;
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
const TITLES: Record<SavedTimelineViewProps["filter"], string> = {
|
|
33
|
+
liked: "Likes",
|
|
34
|
+
bookmarked: "Bookmarks",
|
|
35
|
+
};
|
|
36
|
+
|
|
26
37
|
export function SavedTimelineView({
|
|
27
38
|
filter,
|
|
28
|
-
eyebrow,
|
|
29
39
|
title,
|
|
30
40
|
loadingLabel,
|
|
31
41
|
searchPlaceholder,
|
|
32
42
|
}: SavedTimelineViewProps) {
|
|
33
|
-
const [meta, setMeta] = useState<QueryEnvelope | null>(null);
|
|
34
|
-
const [items, setItems] = useState<TimelineItem[]>([]);
|
|
35
43
|
const [search, setSearch] = useState("");
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
const url = new URL("/api/query", window.location.origin);
|
|
46
|
-
url.searchParams.set("resource", "home");
|
|
47
|
-
url.searchParams.set(filter, "true");
|
|
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
|
-
}, [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
|
+
});
|
|
57
52
|
|
|
58
53
|
const subtitle = useMemo(() => {
|
|
59
54
|
if (!meta) {
|
|
60
|
-
return items.length > 0
|
|
55
|
+
return items.length > 0
|
|
56
|
+
? `${String(items.length)} visible`
|
|
57
|
+
: loadingLabel;
|
|
61
58
|
}
|
|
62
|
-
return `${items.length} visible · ${meta.transport.statusText}`;
|
|
59
|
+
return `${String(items.length)} visible · ${meta.transport.statusText}`;
|
|
63
60
|
}, [items.length, loadingLabel, meta]);
|
|
64
61
|
|
|
65
|
-
|
|
66
|
-
const text = window.prompt("Reply text");
|
|
67
|
-
if (!text?.trim()) return;
|
|
68
|
-
|
|
69
|
-
await fetch("/api/action", {
|
|
70
|
-
method: "POST",
|
|
71
|
-
headers: { "content-type": "application/json" },
|
|
72
|
-
body: JSON.stringify({
|
|
73
|
-
kind: "replyTweet",
|
|
74
|
-
accountId: "acct_primary",
|
|
75
|
-
tweetId,
|
|
76
|
-
text,
|
|
77
|
-
}),
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
setRefreshTick((value) => value + 1);
|
|
81
|
-
}
|
|
62
|
+
const syncKind = filter === "liked" ? "likes" : "bookmarks";
|
|
82
63
|
|
|
83
64
|
return (
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
<
|
|
87
|
-
<div>
|
|
88
|
-
<
|
|
89
|
-
<
|
|
90
|
-
<p className={
|
|
65
|
+
<>
|
|
66
|
+
<header className={pageHeaderClass}>
|
|
67
|
+
<div className={pageHeaderRowClass}>
|
|
68
|
+
<div className="flex min-w-0 flex-col">
|
|
69
|
+
<h1 className={pageTitleClass}>{TITLES[filter]}</h1>
|
|
70
|
+
<p className={pageSubtitleClass}>{title}</p>
|
|
71
|
+
<p className={pageSubtitleClass}>{subtitle}</p>
|
|
91
72
|
</div>
|
|
92
|
-
<
|
|
73
|
+
<SyncNowButton
|
|
74
|
+
accounts={meta?.accounts}
|
|
75
|
+
kind={syncKind}
|
|
76
|
+
label={filter === "liked" ? "Sync likes" : "Sync bookmarks"}
|
|
77
|
+
onSynced={refreshLocalView}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="px-4 pb-3">
|
|
81
|
+
<label className={searchFieldShellClass}>
|
|
82
|
+
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
93
83
|
<input
|
|
94
|
-
className={
|
|
84
|
+
className={searchFieldInputClass}
|
|
95
85
|
onChange={(event) => setSearch(event.target.value)}
|
|
96
86
|
placeholder={searchPlaceholder}
|
|
97
87
|
value={search}
|
|
98
88
|
/>
|
|
99
|
-
</
|
|
100
|
-
</
|
|
101
|
-
|
|
102
|
-
|
|
89
|
+
</label>
|
|
90
|
+
</div>
|
|
91
|
+
</header>
|
|
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}
|
|
103
121
|
{items.map((item) => (
|
|
104
122
|
<TimelineCard
|
|
105
123
|
key={item.id}
|
|
@@ -109,7 +127,7 @@ export function SavedTimelineView({
|
|
|
109
127
|
/>
|
|
110
128
|
))}
|
|
111
129
|
</section>
|
|
112
|
-
</
|
|
113
|
-
|
|
130
|
+
</ConversationSurfaceScope>
|
|
131
|
+
</>
|
|
114
132
|
);
|
|
115
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
|
);
|