+149
src/components/Import.tsx
+149
src/components/Import.tsx
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { useNavigate, type UseNavigateResult } from "@tanstack/react-router";
3
+
import { useState } from "react";
4
+
5
+
/**
6
+
* Basically the best equivalent to Search that i can do
7
+
*/
8
+
export function Import() {
9
+
const [textInput, setTextInput] = useState<string | undefined>();
10
+
const navigate = useNavigate();
11
+
12
+
const handleEnter = () => {
13
+
if (!textInput) return;
14
+
handleImport({
15
+
text: textInput,
16
+
navigate,
17
+
});
18
+
};
19
+
20
+
return (
21
+
<div className="w-full relative">
22
+
<IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
23
+
24
+
<input
25
+
type="text"
26
+
placeholder="Import..."
27
+
value={textInput}
28
+
onChange={(e) => setTextInput(e.target.value)}
29
+
onKeyDown={(e) => {
30
+
if (e.key === "Enter") handleEnter();
31
+
}}
32
+
className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition"
33
+
/>
34
+
</div>
35
+
);
36
+
}
37
+
38
+
function handleImport({
39
+
text,
40
+
navigate,
41
+
}: {
42
+
text: string;
43
+
navigate: UseNavigateResult<string>;
44
+
}) {
45
+
const trimmed = text.trim();
46
+
// parse text
47
+
/**
48
+
* text might be
49
+
* 1. bsky dot app url (deer.social, reddwarf.whey.party, main.bsky.dev, catskys.social) (reddwarf link segments might be uri encoded,)
50
+
* 2. aturi
51
+
* 3. plain handle
52
+
* 4. plain did
53
+
*/
54
+
55
+
// 1. Check if it’s a URL
56
+
try {
57
+
const url = new URL(text);
58
+
const knownHosts = [
59
+
"bsky.app",
60
+
"social.daniela.lol",
61
+
"deer.social",
62
+
"reddwarf.whey.party",
63
+
"main.bsky.dev",
64
+
"catsky.social",
65
+
"blacksky.community",
66
+
"red-dwarf-social-app.whey.party",
67
+
"zeppelin.social",
68
+
];
69
+
if (knownHosts.includes(url.hostname)) {
70
+
// parse path to get URI or handle
71
+
const path = decodeURIComponent(url.pathname.slice(1)); // remove leading /
72
+
console.log("BSky URL path:", path);
73
+
navigate({
74
+
to: `/${path}`,
75
+
});
76
+
return;
77
+
}
78
+
} catch {
79
+
// not a URL, continue
80
+
}
81
+
82
+
// 2. Check if text looks like an at-uri
83
+
try {
84
+
if (text.startsWith("at://")) {
85
+
console.log("AT URI detected:", text);
86
+
const aturi = new AtUri(text);
87
+
switch (aturi.collection) {
88
+
case "app.bsky.feed.post": {
89
+
navigate({
90
+
to: "/profile/$did/post/$rkey",
91
+
params: {
92
+
did: aturi.host,
93
+
rkey: aturi.rkey,
94
+
},
95
+
});
96
+
return;
97
+
}
98
+
case "app.bsky.actor.profile": {
99
+
navigate({
100
+
to: "/profile/$did",
101
+
params: {
102
+
did: aturi.host,
103
+
},
104
+
});
105
+
return;
106
+
}
107
+
// todo add more handlers as more routes are added. like feeds, lists, etc etc thanks!
108
+
default: {
109
+
// continue
110
+
}
111
+
}
112
+
}
113
+
} catch {
114
+
// continue
115
+
}
116
+
117
+
// 3. Plain handle (starts with @)
118
+
try {
119
+
if (text.startsWith("@")) {
120
+
const handle = text.slice(1);
121
+
console.log("Handle detected:", handle);
122
+
navigate({ to: "/profile/$did", params: { did: handle } });
123
+
return;
124
+
}
125
+
} catch {
126
+
// continue
127
+
}
128
+
129
+
// 4. Plain DID (starts with did:)
130
+
try {
131
+
if (text.startsWith("did:")) {
132
+
console.log("did detected:", text);
133
+
navigate({ to: "/profile/$did", params: { did: text } });
134
+
return;
135
+
}
136
+
} catch {
137
+
// continue
138
+
}
139
+
140
+
// if all else fails
141
+
142
+
// try {
143
+
// // probably a user?
144
+
// navigate({ to: "/profile/$did", params: { did: text } });
145
+
// return;
146
+
// } catch {
147
+
// // continue
148
+
// }
149
+
}
+3
-3
src/components/Login.tsx
+3
-3
src/components/Login.tsx
···
24
24
className={
25
25
compact
26
26
? "flex items-center justify-center p-1"
27
-
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]"
27
+
: "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]"
28
28
}
29
29
>
30
30
<span
···
43
43
// Large view
44
44
if (!compact) {
45
45
return (
46
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
46
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
47
47
<div className="flex flex-col items-center justify-center text-center">
48
48
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
49
49
You are logged in!
···
77
77
if (!compact) {
78
78
// Large view renders the form directly in the card
79
79
return (
80
-
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4">
80
+
<div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4">
81
81
<UnifiedLoginForm />
82
82
</div>
83
83
);
+28
-26
src/routes/__root.tsx
+28
-26
src/routes/__root.tsx
···
18
18
19
19
import { Composer } from "~/components/Composer";
20
20
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
21
+
import { Import } from "~/components/Import";
21
22
import Login from "~/components/Login";
22
23
import { NotFound } from "~/components/NotFound";
23
24
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
···
154
155
/>
155
156
156
157
<MaterialNavItem
158
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
159
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
160
+
active={locationEnum === "search"}
161
+
onClickCallbback={() =>
162
+
navigate({
163
+
to: "/search",
164
+
//params: { did: agent.assertDid },
165
+
})
166
+
}
167
+
text="Explore"
168
+
/>
169
+
<MaterialNavItem
157
170
InactiveIcon={
158
171
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
159
172
}
···
180
193
})
181
194
}
182
195
text="Feeds"
183
-
/>
184
-
<MaterialNavItem
185
-
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
186
-
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
187
-
active={locationEnum === "search"}
188
-
onClickCallbback={() =>
189
-
navigate({
190
-
to: "/search",
191
-
//params: { did: agent.assertDid },
192
-
})
193
-
}
194
-
text="Search"
195
196
/>
196
197
<MaterialNavItem
197
198
InactiveIcon={
···
389
390
390
391
<MaterialNavItem
391
392
small
393
+
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
394
+
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
395
+
active={locationEnum === "search"}
396
+
onClickCallbback={() =>
397
+
navigate({
398
+
to: "/search",
399
+
//params: { did: agent.assertDid },
400
+
})
401
+
}
402
+
text="Explore"
403
+
/>
404
+
<MaterialNavItem
405
+
small
392
406
InactiveIcon={
393
407
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
394
408
}
···
419
433
/>
420
434
<MaterialNavItem
421
435
small
422
-
InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
423
-
ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />}
424
-
active={locationEnum === "search"}
425
-
onClickCallbback={() =>
426
-
navigate({
427
-
to: "/search",
428
-
//params: { did: agent.assertDid },
429
-
})
430
-
}
431
-
text="Search"
432
-
/>
433
-
<MaterialNavItem
434
-
small
435
436
InactiveIcon={
436
437
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
437
438
}
···
498
499
</main>
499
500
500
501
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
502
+
<div className="px-4 pt-4"><Import /></div>
501
503
<Login />
502
504
503
505
<div className="flex-1"></div>
···
551
553
//params: { did: agent.assertDid },
552
554
})
553
555
}
554
-
text="Search"
556
+
text="Explore"
555
557
/>
556
558
{/* <Link
557
559
to="/search"
+50
-1
src/routes/search.tsx
+50
-1
src/routes/search.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
3
+
import { Header } from "~/components/Header";
4
+
import { Import } from "~/components/Import";
5
+
3
6
export const Route = createFileRoute("/search")({
4
7
component: Search,
5
8
});
6
9
7
10
export function Search() {
8
-
return <div className="p-6">Search page (coming soon)</div>;
11
+
return (
12
+
<>
13
+
<Header
14
+
title="Explore"
15
+
backButtonCallback={() => {
16
+
if (window.history.length > 1) {
17
+
window.history.back();
18
+
} else {
19
+
window.location.assign("/");
20
+
}
21
+
}}
22
+
/>
23
+
<div className=" flex flex-col items-center mt-4 mx-4 gap-4">
24
+
<Import />
25
+
<div className="flex flex-col">
26
+
<p className="text-gray-600 dark:text-gray-400">
27
+
Sorry we dont have search. But instead, you can load some of these
28
+
types of content into Red Dwarf:
29
+
</p>
30
+
<ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400">
31
+
<li>
32
+
Bluesky URLs from supported clients (like{" "}
33
+
<code className="text-sm">bsky.app</code> or{" "}
34
+
<code className="text-sm">deer.social</code>).
35
+
</li>
36
+
<li>
37
+
AT-URIs (e.g.,{" "}
38
+
<code className="text-sm">at://did:example/collection/item</code>
39
+
).
40
+
</li>
41
+
<li>
42
+
Plain handles (like{" "}
43
+
<code className="text-sm">@username.bsky.social</code>).
44
+
</li>
45
+
<li>
46
+
Direct DIDs (Decentralized Identifiers, starting with{" "}
47
+
<code className="text-sm">did:</code>).
48
+
</li>
49
+
</ul>
50
+
<p className="mt-2 text-gray-600 dark:text-gray-400">
51
+
Simply paste one of these into the import field above and press
52
+
Enter to load the content.
53
+
</p>
54
+
</div>
55
+
</div>
56
+
</>
57
+
);
9
58
}