an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

add url import bar in lieu of full-text search

rimar1337 08b6118f 96fdf44f

Changed files
+230 -30
src
+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
··· 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
··· 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
··· 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 }