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

scrollable post composer + radix dialog

rimar1337 fa673e49 b9c7d025

Changed files
+147 -110
src
components
styles
+1
package-lock.json
··· 8 "dependencies": { 9 "@atproto/api": "^0.16.6", 10 "@atproto/oauth-client-browser": "^0.3.33", 11 "@radix-ui/react-dropdown-menu": "^2.1.16", 12 "@radix-ui/react-slider": "^1.3.6", 13 "@tailwindcss/vite": "^4.0.6",
··· 8 "dependencies": { 9 "@atproto/api": "^0.16.6", 10 "@atproto/oauth-client-browser": "^0.3.33", 11 + "@radix-ui/react-dialog": "^1.1.15", 12 "@radix-ui/react-dropdown-menu": "^2.1.16", 13 "@radix-ui/react-slider": "^1.3.6", 14 "@tailwindcss/vite": "^4.0.6",
+1
package.json
··· 12 "dependencies": { 13 "@atproto/api": "^0.16.6", 14 "@atproto/oauth-client-browser": "^0.3.33", 15 "@radix-ui/react-dropdown-menu": "^2.1.16", 16 "@radix-ui/react-slider": "^1.3.6", 17 "@tailwindcss/vite": "^4.0.6",
··· 12 "dependencies": { 13 "@atproto/api": "^0.16.6", 14 "@atproto/oauth-client-browser": "^0.3.33", 15 + "@radix-ui/react-dialog": "^1.1.15", 16 "@radix-ui/react-dropdown-menu": "^2.1.16", 17 "@radix-ui/react-slider": "^1.3.6", 18 "@tailwindcss/vite": "^4.0.6",
+137 -106
src/components/Composer.tsx
··· 1 import { RichText } from "@atproto/api"; 2 import { useAtom } from "jotai"; 3 import { useEffect, useRef, useState } from "react"; 4 5 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 9 import { ProfileThing } from "./Login"; 10 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 11 12 - const MAX_POST_LENGTH = 300 13 14 export function Composer() { 15 const [composerState, setComposerState] = useAtom(composerAtom); ··· 31 composerState.kind === "reply" 32 ? composerState.parent 33 : composerState.kind === "quote" 34 - ? composerState.subject 35 - : undefined; 36 37 - const { data: parentPost, isLoading: isParentLoading } = useQueryPost(parentUri); 38 39 async function handlePost() { 40 if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return; ··· 95 setPosting(false); 96 } 97 } 98 - 99 - if (composerState.kind === "closed") { 100 - return null; 101 - } 102 103 const getPlaceholder = () => { 104 switch (composerState.kind) { ··· 111 return "What's happening?!"; 112 } 113 }; 114 - 115 const charsLeft = MAX_POST_LENGTH - postText.length; 116 const isPostButtonDisabled = 117 - posting || 118 - !postText.trim() || 119 - isParentLoading || 120 - charsLeft < 0; 121 122 return ( 123 - <div className="fixed inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 bg-black/40 dark:bg-black/50"> 124 - <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4"> 125 - <div className="flex flex-row justify-between p-2"> 126 - <button 127 - className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" 128 - onClick={() => !posting && setComposerState({ kind: "closed" })} 129 - disabled={posting} 130 - aria-label="Close" 131 - > 132 - <svg 133 - xmlns="http://www.w3.org/2000/svg" 134 - width="20" 135 - height="20" 136 - viewBox="0 0 24 24" 137 - fill="none" 138 - stroke="currentColor" 139 - strokeWidth="2.5" 140 - strokeLinecap="round" 141 - strokeLinejoin="round" 142 - > 143 - <line x1="18" y1="6" x2="6" y2="18"></line> 144 - <line x1="6" y1="6" x2="18" y2="18"></line> 145 - </svg> 146 - </button> 147 - <div className="flex-1" /> 148 - <div className="flex items-center gap-4"> 149 - <span className={`text-sm ${charsLeft < 0 ? 'text-red-500' : 'text-gray-500'}`}> 150 - {charsLeft} 151 - </span> 152 - 153 - <button 154 - className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 155 - onClick={handlePost} 156 - disabled={isPostButtonDisabled} 157 - > 158 - {posting ? "Posting..." : "Post"} 159 - </button> 160 - </div> 161 - </div> 162 163 - {postSuccess ? ( 164 - <div className="flex flex-col items-center justify-center py-16"> 165 - <span className="text-gray-500 text-6xl mb-4">✓</span> 166 - <span className="text-xl font-bold text-black dark:text-white">Posted!</span> 167 - </div> 168 - ) : ( 169 - <div className="px-4"> 170 - {(composerState.kind === "reply") && ( 171 - <div className="mb-1 -mx-4"> 172 - {isParentLoading ? ( 173 - <div className="text-sm text-gray-500 animate-pulse"> 174 - Loading parent post... 175 - </div> 176 - ) : parentUri ? ( 177 - <UniversalPostRendererATURILoader atUri={parentUri} bottomReplyLine bottomBorder={false} /> 178 - ) : ( 179 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 180 - Could not load parent post. 181 - </div> 182 - )} 183 - </div> 184 - )} 185 - 186 - <div className="flex w-full gap-1 flex-col"> 187 - <ProfileThing agent={agent} large/> 188 - <div className="flex pl-[50px]"> 189 - <AutoGrowTextarea 190 - className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 191 - rows={5} 192 - placeholder={getPlaceholder()} 193 - value={postText} 194 - onChange={(e) => setPostText(e.target.value)} 195 disabled={posting} 196 - autoFocus 197 - /> 198 </div> 199 </div> 200 - {(composerState.kind === "quote") && ( 201 - <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 202 - {isParentLoading ? ( 203 - <div className="text-sm text-gray-500 animate-pulse"> 204 - Loading parent post... 205 </div> 206 - ) : parentUri ? ( 207 - <UniversalPostRendererATURILoader atUri={parentUri} isQuote /> 208 - ) : ( 209 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 210 - Could not load parent post. 211 </div> 212 )} 213 </div> 214 )} 215 - 216 - {postError && ( 217 - <div className="text-red-500 text-sm my-2 text-center">{postError}</div> 218 - )} 219 - 220 </div> 221 - )} 222 - </div> 223 - </div> 224 ); 225 } 226 227 - function AutoGrowTextarea({ value, className, onChange, ...props }: React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>) { 228 const ref = useRef<HTMLTextAreaElement>(null); 229 230 useEffect(() => { ··· 243 {...props} 244 /> 245 ); 246 - }
··· 1 import { RichText } from "@atproto/api"; 2 import { useAtom } from "jotai"; 3 + import { Dialog } from "radix-ui"; 4 import { useEffect, useRef, useState } from "react"; 5 6 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 10 import { ProfileThing } from "./Login"; 11 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 12 13 + const MAX_POST_LENGTH = 300; 14 15 export function Composer() { 16 const [composerState, setComposerState] = useAtom(composerAtom); ··· 32 composerState.kind === "reply" 33 ? composerState.parent 34 : composerState.kind === "quote" 35 + ? composerState.subject 36 + : undefined; 37 38 + const { data: parentPost, isLoading: isParentLoading } = 39 + useQueryPost(parentUri); 40 41 async function handlePost() { 42 if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return; ··· 97 setPosting(false); 98 } 99 } 100 + // if (composerState.kind === "closed") { 101 + // return null; 102 + // } 103 104 const getPlaceholder = () => { 105 switch (composerState.kind) { ··· 112 return "What's happening?!"; 113 } 114 }; 115 + 116 const charsLeft = MAX_POST_LENGTH - postText.length; 117 const isPostButtonDisabled = 118 + posting || !postText.trim() || isParentLoading || charsLeft < 0; 119 120 return ( 121 + <Dialog.Root 122 + open={composerState.kind !== "closed"} 123 + onOpenChange={(open) => { 124 + if (!open) setComposerState({ kind: "closed" }); 125 + }} 126 + > 127 + <Dialog.Portal> 128 + <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 129 130 + <Dialog.Content className="fixed overflow-y-scroll inset-0 z-50 flex items-start justify-center py-10 sm:py-20"> 131 + <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4"> 132 + <div className="flex flex-row justify-between p-2"> 133 + <Dialog.Close asChild> 134 + <button 135 + className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" 136 disabled={posting} 137 + aria-label="Close" 138 + > 139 + <svg 140 + xmlns="http://www.w3.org/2000/svg" 141 + width="20" 142 + height="20" 143 + viewBox="0 0 24 24" 144 + fill="none" 145 + stroke="currentColor" 146 + strokeWidth="2.5" 147 + strokeLinecap="round" 148 + strokeLinejoin="round" 149 + > 150 + <line x1="18" y1="6" x2="6" y2="18"></line> 151 + <line x1="6" y1="6" x2="18" y2="18"></line> 152 + </svg> 153 + </button> 154 + </Dialog.Close> 155 + 156 + <div className="flex-1" /> 157 + <div className="flex items-center gap-4"> 158 + <span 159 + className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 160 + > 161 + {charsLeft} 162 + </span> 163 + <button 164 + className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 165 + onClick={handlePost} 166 + disabled={isPostButtonDisabled} 167 + > 168 + {posting ? "Posting..." : "Post"} 169 + </button> 170 </div> 171 </div> 172 + 173 + {postSuccess ? ( 174 + <div className="flex flex-col items-center justify-center py-16"> 175 + <span className="text-gray-500 text-6xl mb-4">✓</span> 176 + <span className="text-xl font-bold text-black dark:text-white"> 177 + Posted! 178 + </span> 179 + </div> 180 + ) : ( 181 + <div className="px-4"> 182 + {composerState.kind === "reply" && ( 183 + <div className="mb-1 -mx-4"> 184 + {isParentLoading ? ( 185 + <div className="text-sm text-gray-500 animate-pulse"> 186 + Loading parent post... 187 + </div> 188 + ) : parentUri ? ( 189 + <UniversalPostRendererATURILoader 190 + atUri={parentUri} 191 + bottomReplyLine 192 + bottomBorder={false} 193 + /> 194 + ) : ( 195 + <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 196 + Could not load parent post. 197 + </div> 198 + )} 199 + </div> 200 + )} 201 + 202 + <div className="flex w-full gap-1 flex-col"> 203 + <ProfileThing agent={agent} large /> 204 + <div className="flex pl-[50px]"> 205 + <AutoGrowTextarea 206 + className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 207 + rows={5} 208 + placeholder={getPlaceholder()} 209 + value={postText} 210 + onChange={(e) => setPostText(e.target.value)} 211 + disabled={posting} 212 + autoFocus 213 + /> 214 </div> 215 + </div> 216 + 217 + {composerState.kind === "quote" && ( 218 + <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 219 + {isParentLoading ? ( 220 + <div className="text-sm text-gray-500 animate-pulse"> 221 + Loading parent post... 222 + </div> 223 + ) : parentUri ? ( 224 + <UniversalPostRendererATURILoader 225 + atUri={parentUri} 226 + isQuote 227 + /> 228 + ) : ( 229 + <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 230 + Could not load parent post. 231 + </div> 232 + )} 233 + </div> 234 + )} 235 + 236 + {postError && ( 237 + <div className="text-red-500 text-sm my-2 text-center"> 238 + {postError} 239 </div> 240 )} 241 </div> 242 )} 243 </div> 244 + </Dialog.Content> 245 + </Dialog.Portal> 246 + </Dialog.Root> 247 ); 248 } 249 250 + function AutoGrowTextarea({ 251 + value, 252 + className, 253 + onChange, 254 + ...props 255 + }: React.DetailedHTMLProps< 256 + React.TextareaHTMLAttributes<HTMLTextAreaElement>, 257 + HTMLTextAreaElement 258 + >) { 259 const ref = useRef<HTMLTextAreaElement>(null); 260 261 useEffect(() => { ··· 274 {...props} 275 /> 276 ); 277 + }
+3 -4
src/components/Login.tsx
··· 311 312 // --- Profile Component (now supports a `large` prop for styling) --- 313 export const ProfileThing = ({ 314 - agent: _unused, 315 large = false, 316 }: { 317 - agent?: Agent | null; 318 large?: boolean; 319 }) => { 320 - const { agent } = useAuth(); 321 - const did = ((agent as AtpAgent).session?.did ?? 322 (agent as AtpAgent)?.assertDid ?? 323 agent?.did) as string | undefined; 324 const { data: identity } = useQueryIdentity(did);
··· 311 312 // --- Profile Component (now supports a `large` prop for styling) --- 313 export const ProfileThing = ({ 314 + agent, 315 large = false, 316 }: { 317 + agent: Agent | null; 318 large?: boolean; 319 }) => { 320 + const did = ((agent as AtpAgent)?.session?.did ?? 321 (agent as AtpAgent)?.assertDid ?? 322 agent?.did) as string | undefined; 323 const { data: identity } = useQueryIdentity(did);
+5
src/styles/app.css
··· 222 /* placeholder trick */ 223 .m3input-field.m3input-label.m3input-border input::placeholder { 224 color: transparent; 225 }
··· 222 /* placeholder trick */ 223 .m3input-field.m3input-label.m3input-border input::placeholder { 224 color: transparent; 225 + } 226 + 227 + /* radix i love you but like cmon man */ 228 + body[data-scroll-locked]{ 229 + margin-left: var(--removed-body-scroll-bar-size) !important; 230 }