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 8 "dependencies": { 9 9 "@atproto/api": "^0.16.6", 10 10 "@atproto/oauth-client-browser": "^0.3.33", 11 + "@radix-ui/react-dialog": "^1.1.15", 11 12 "@radix-ui/react-dropdown-menu": "^2.1.16", 12 13 "@radix-ui/react-slider": "^1.3.6", 13 14 "@tailwindcss/vite": "^4.0.6",
+1
package.json
··· 12 12 "dependencies": { 13 13 "@atproto/api": "^0.16.6", 14 14 "@atproto/oauth-client-browser": "^0.3.33", 15 + "@radix-ui/react-dialog": "^1.1.15", 15 16 "@radix-ui/react-dropdown-menu": "^2.1.16", 16 17 "@radix-ui/react-slider": "^1.3.6", 17 18 "@tailwindcss/vite": "^4.0.6",
+137 -106
src/components/Composer.tsx
··· 1 1 import { RichText } from "@atproto/api"; 2 2 import { useAtom } from "jotai"; 3 + import { Dialog } from "radix-ui"; 3 4 import { useEffect, useRef, useState } from "react"; 4 5 5 6 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 9 10 import { ProfileThing } from "./Login"; 10 11 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 11 12 12 - const MAX_POST_LENGTH = 300 13 + const MAX_POST_LENGTH = 300; 13 14 14 15 export function Composer() { 15 16 const [composerState, setComposerState] = useAtom(composerAtom); ··· 31 32 composerState.kind === "reply" 32 33 ? composerState.parent 33 34 : composerState.kind === "quote" 34 - ? composerState.subject 35 - : undefined; 35 + ? composerState.subject 36 + : undefined; 36 37 37 - const { data: parentPost, isLoading: isParentLoading } = useQueryPost(parentUri); 38 + const { data: parentPost, isLoading: isParentLoading } = 39 + useQueryPost(parentUri); 38 40 39 41 async function handlePost() { 40 42 if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return; ··· 95 97 setPosting(false); 96 98 } 97 99 } 98 - 99 - if (composerState.kind === "closed") { 100 - return null; 101 - } 100 + // if (composerState.kind === "closed") { 101 + // return null; 102 + // } 102 103 103 104 const getPlaceholder = () => { 104 105 switch (composerState.kind) { ··· 111 112 return "What's happening?!"; 112 113 } 113 114 }; 114 - 115 + 115 116 const charsLeft = MAX_POST_LENGTH - postText.length; 116 117 const isPostButtonDisabled = 117 - posting || 118 - !postText.trim() || 119 - isParentLoading || 120 - charsLeft < 0; 118 + posting || !postText.trim() || isParentLoading || charsLeft < 0; 121 119 122 120 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> 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" /> 162 129 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)} 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" 195 136 disabled={posting} 196 - autoFocus 197 - /> 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> 198 170 </div> 199 171 </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... 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 + /> 205 214 </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. 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} 211 239 </div> 212 240 )} 213 241 </div> 214 242 )} 215 - 216 - {postError && ( 217 - <div className="text-red-500 text-sm my-2 text-center">{postError}</div> 218 - )} 219 - 220 243 </div> 221 - )} 222 - </div> 223 - </div> 244 + </Dialog.Content> 245 + </Dialog.Portal> 246 + </Dialog.Root> 224 247 ); 225 248 } 226 249 227 - function AutoGrowTextarea({ value, className, onChange, ...props }: React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>) { 250 + function AutoGrowTextarea({ 251 + value, 252 + className, 253 + onChange, 254 + ...props 255 + }: React.DetailedHTMLProps< 256 + React.TextareaHTMLAttributes<HTMLTextAreaElement>, 257 + HTMLTextAreaElement 258 + >) { 228 259 const ref = useRef<HTMLTextAreaElement>(null); 229 260 230 261 useEffect(() => { ··· 243 274 {...props} 244 275 /> 245 276 ); 246 - } 277 + }
+3 -4
src/components/Login.tsx
··· 311 311 312 312 // --- Profile Component (now supports a `large` prop for styling) --- 313 313 export const ProfileThing = ({ 314 - agent: _unused, 314 + agent, 315 315 large = false, 316 316 }: { 317 - agent?: Agent | null; 317 + agent: Agent | null; 318 318 large?: boolean; 319 319 }) => { 320 - const { agent } = useAuth(); 321 - const did = ((agent as AtpAgent).session?.did ?? 320 + const did = ((agent as AtpAgent)?.session?.did ?? 322 321 (agent as AtpAgent)?.assertDid ?? 323 322 agent?.did) as string | undefined; 324 323 const { data: identity } = useQueryIdentity(did);
+5
src/styles/app.css
··· 222 222 /* placeholder trick */ 223 223 .m3input-field.m3input-label.m3input-border input::placeholder { 224 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; 225 230 }