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

Compare changes

Choose any two refs to compare.

Changed files
+242 -135
src
components
routes
profile.$did
+168 -121
src/components/Composer.tsx
··· 8 8 import { useQueryPost } from "~/utils/useQuery"; 9 9 10 10 import { ProfileThing } from "./Login"; 11 + import { Button } from "./radix-m3-rd/Button"; 11 12 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 12 13 13 14 const MAX_POST_LENGTH = 300; 14 15 15 16 export function Composer() { 16 17 const [composerState, setComposerState] = useAtom(composerAtom); 18 + const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false); 17 19 const { agent } = useAuth(); 18 20 19 21 const [postText, setPostText] = useState(""); ··· 112 114 setPosting(false); 113 115 } 114 116 } 115 - // if (composerState.kind === "closed") { 116 - // return null; 117 - // } 118 117 119 118 const getPlaceholder = () => { 120 119 switch (composerState.kind) { ··· 132 131 const isPostButtonDisabled = 133 132 posting || !postText.trim() || isParentLoading || charsLeft < 0; 134 133 134 + function handleAttemptClose() { 135 + if (postText.trim() && !posting) { 136 + setCloseConfirmState(true); 137 + } else { 138 + setComposerState({ kind: "closed" }); 139 + } 140 + } 141 + 142 + function handleConfirmClose() { 143 + setComposerState({ kind: "closed" }); 144 + setCloseConfirmState(false); 145 + setPostText(""); 146 + } 147 + 135 148 return ( 136 - <Dialog.Root 137 - open={composerState.kind !== "closed"} 138 - onOpenChange={(open) => { 139 - if (!open) setComposerState({ kind: "closed" }); 140 - }} 141 - > 142 - <Dialog.Portal> 143 - <Dialog.Overlay className="fixed disablegutter inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 144 - 145 - <Dialog.Content className="fixed gutter overflow-y-scroll inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]"> 146 - <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"> 147 - <div className="flex flex-row justify-between p-2"> 148 - <Dialog.Close asChild> 149 - <button 150 - 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" 151 - disabled={posting} 152 - aria-label="Close" 153 - > 154 - <svg 155 - xmlns="http://www.w3.org/2000/svg" 156 - width="20" 157 - height="20" 158 - viewBox="0 0 24 24" 159 - fill="none" 160 - stroke="currentColor" 161 - strokeWidth="2.5" 162 - strokeLinecap="round" 163 - strokeLinejoin="round" 149 + <> 150 + <Dialog.Root 151 + open={composerState.kind !== "closed"} 152 + onOpenChange={(open) => { 153 + if (!open) handleAttemptClose(); 154 + }} 155 + > 156 + <Dialog.Portal> 157 + <Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 158 + 159 + <Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]"> 160 + <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"> 161 + <div className="flex flex-row justify-between p-2"> 162 + <Dialog.Close asChild> 163 + <button 164 + 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" 165 + disabled={posting} 166 + aria-label="Close" 167 + onClick={handleAttemptClose} 164 168 > 165 - <line x1="18" y1="6" x2="6" y2="18"></line> 166 - <line x1="6" y1="6" x2="18" y2="18"></line> 167 - </svg> 168 - </button> 169 - </Dialog.Close> 170 - 171 - <div className="flex-1" /> 172 - <div className="flex items-center gap-4"> 173 - <span 174 - className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 175 - > 176 - {charsLeft} 177 - </span> 178 - <button 179 - 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" 180 - onClick={handlePost} 181 - disabled={isPostButtonDisabled} 182 - > 183 - {posting ? "Posting..." : "Post"} 184 - </button> 169 + <svg 170 + xmlns="http://www.w3.org/2000/svg" 171 + width="20" 172 + height="20" 173 + viewBox="0 0 24 24" 174 + fill="none" 175 + stroke="currentColor" 176 + strokeWidth="2.5" 177 + strokeLinecap="round" 178 + strokeLinejoin="round" 179 + > 180 + <line x1="18" y1="6" x2="6" y2="18"></line> 181 + <line x1="6" y1="6" x2="18" y2="18"></line> 182 + </svg> 183 + </button> 184 + </Dialog.Close> 185 + 186 + <div className="flex-1" /> 187 + <div className="flex items-center gap-4"> 188 + <span 189 + className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 190 + > 191 + {charsLeft} 192 + </span> 193 + <Button 194 + onClick={handlePost} 195 + disabled={isPostButtonDisabled} 196 + > 197 + {posting ? "Posting..." : "Post"} 198 + </Button> 199 + </div> 185 200 </div> 186 - </div> 187 201 188 - {postSuccess ? ( 189 - <div className="flex flex-col items-center justify-center py-16"> 190 - <span className="text-gray-500 text-6xl mb-4">โœ“</span> 191 - <span className="text-xl font-bold text-black dark:text-white"> 192 - Posted! 193 - </span> 194 - </div> 195 - ) : ( 196 - <div className="px-4"> 197 - {composerState.kind === "reply" && ( 198 - <div className="mb-1 -mx-4"> 199 - {isParentLoading ? ( 200 - <div className="text-sm text-gray-500 animate-pulse"> 201 - Loading parent post... 202 - </div> 203 - ) : parentUri ? ( 204 - <UniversalPostRendererATURILoader 205 - atUri={parentUri} 206 - bottomReplyLine 207 - bottomBorder={false} 208 - /> 209 - ) : ( 210 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 211 - Could not load parent post. 212 - </div> 213 - )} 214 - </div> 215 - )} 216 - 217 - <div className="flex w-full gap-1 flex-col"> 218 - <ProfileThing agent={agent} large /> 219 - <div className="flex pl-[50px]"> 220 - <AutoGrowTextarea 221 - className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 222 - rows={5} 223 - placeholder={getPlaceholder()} 224 - value={postText} 225 - onChange={(e) => setPostText(e.target.value)} 226 - disabled={posting} 227 - autoFocus 228 - /> 229 - </div> 202 + {postSuccess ? ( 203 + <div className="flex flex-col items-center justify-center py-16"> 204 + <span className="text-gray-500 text-6xl mb-4">โœ“</span> 205 + <span className="text-xl font-bold text-black dark:text-white"> 206 + Posted! 207 + </span> 230 208 </div> 231 - 232 - {composerState.kind === "quote" && ( 233 - <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 234 - {isParentLoading ? ( 235 - <div className="text-sm text-gray-500 animate-pulse"> 236 - Loading parent post... 237 - </div> 238 - ) : parentUri ? ( 239 - <UniversalPostRendererATURILoader 240 - atUri={parentUri} 241 - isQuote 209 + ) : ( 210 + <div className="px-4"> 211 + {composerState.kind === "reply" && ( 212 + <div className="mb-1 -mx-4"> 213 + {isParentLoading ? ( 214 + <div className="text-sm text-gray-500 animate-pulse"> 215 + Loading parent post... 216 + </div> 217 + ) : parentUri ? ( 218 + <UniversalPostRendererATURILoader 219 + atUri={parentUri} 220 + bottomReplyLine 221 + bottomBorder={false} 222 + /> 223 + ) : ( 224 + <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 225 + Could not load parent post. 226 + </div> 227 + )} 228 + </div> 229 + )} 230 + 231 + <div className="flex w-full gap-1 flex-col"> 232 + <ProfileThing agent={agent} large /> 233 + <div className="flex pl-[50px]"> 234 + <AutoGrowTextarea 235 + className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 236 + rows={5} 237 + placeholder={getPlaceholder()} 238 + value={postText} 239 + onChange={(e) => setPostText(e.target.value)} 240 + disabled={posting} 241 + autoFocus 242 242 /> 243 - ) : ( 244 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 245 - Could not load parent post. 246 - </div> 247 - )} 243 + </div> 248 244 </div> 249 - )} 250 245 251 - {postError && ( 252 - <div className="text-red-500 text-sm my-2 text-center"> 253 - {postError} 254 - </div> 255 - )} 246 + {composerState.kind === "quote" && ( 247 + <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 248 + {isParentLoading ? ( 249 + <div className="text-sm text-gray-500 animate-pulse"> 250 + Loading parent post... 251 + </div> 252 + ) : parentUri ? ( 253 + <UniversalPostRendererATURILoader 254 + atUri={parentUri} 255 + isQuote 256 + /> 257 + ) : ( 258 + <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 259 + Could not load parent post. 260 + </div> 261 + )} 262 + </div> 263 + )} 264 + 265 + {postError && ( 266 + <div className="text-red-500 text-sm my-2 text-center"> 267 + {postError} 268 + </div> 269 + )} 270 + </div> 271 + )} 272 + </div> 273 + </Dialog.Content> 274 + </Dialog.Portal> 275 + </Dialog.Root> 276 + 277 + {/* Close confirmation dialog */} 278 + <Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}> 279 + <Dialog.Portal> 280 + 281 + <Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 282 + 283 + <Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40"> 284 + <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-md relative mx-4 py-6"> 285 + <div className="text-xl mb-4 text-center"> 286 + Discard your post? 287 + </div> 288 + <div className="text-md mb-4 text-center"> 289 + You will lose your draft 290 + </div> 291 + <div className="flex justify-end gap-2 px-6"> 292 + <Button 293 + onClick={handleConfirmClose} 294 + > 295 + Discard 296 + </Button> 297 + <Button 298 + variant={"outlined"} 299 + onClick={() => setCloseConfirmState(false)} 300 + > 301 + Cancel 302 + </Button> 256 303 </div> 257 - )} 258 - </div> 259 - </Dialog.Content> 260 - </Dialog.Portal> 261 - </Dialog.Root> 304 + </div> 305 + </Dialog.Content> 306 + </Dialog.Portal> 307 + </Dialog.Root> 308 + </> 262 309 ); 263 310 } 264 311
+5 -3
src/components/Login.tsx
··· 7 7 import { imgCDNAtom } from "~/utils/atoms"; 8 8 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 9 9 10 + import { Button } from "./radix-m3-rd/Button"; 11 + 10 12 // --- 1. The Main Component (Orchestrator with `compact` prop) --- 11 13 export default function Login({ 12 14 compact = false, ··· 49 51 You are logged in! 50 52 </p> 51 53 <ProfileThing agent={agent} large /> 52 - <button 54 + <Button 53 55 onClick={logout} 54 - className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors" 56 + className="mt-4" 55 57 > 56 58 Log out 57 - </button> 59 + </Button> 58 60 </div> 59 61 </div> 60 62 );
+2
src/components/UniversalPostRenderer.tsx
··· 1557 1557 className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1558 1558 /> 1559 1559 <div className=" flex-1 flex flex-row align-middle justify-end"> 1560 + <div className=" flex flex-col justify-start"> 1560 1561 <FollowButton targetdidorhandle={post.author.did} /> 1561 1562 </div> 1563 + </div> 1562 1564 </div> 1563 1565 <div className="flex flex-col gap-3"> 1564 1566 <div>
+59
src/components/radix-m3-rd/Button.tsx
··· 1 + import { Slot } from "@radix-ui/react-slot"; 2 + import clsx from "clsx"; 3 + import * as React from "react"; 4 + 5 + export type ButtonVariant = "filled" | "outlined" | "text" | "secondary"; 6 + export type ButtonSize = "sm" | "md" | "lg"; 7 + 8 + const variantClasses: Record<ButtonVariant, string> = { 9 + filled: 10 + "bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500", 11 + secondary: 12 + "bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500", 13 + outlined: 14 + "border border-gray-800 text-gray-800 hover:bg-gray-100 dark:border-gray-200 dark:text-gray-200 dark:hover:bg-gray-800/10", 15 + text: "bg-transparent text-gray-800 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800/10", 16 + }; 17 + 18 + const sizeClasses: Record<ButtonSize, string> = { 19 + sm: "px-3 py-1.5 text-sm", 20 + md: "px-4 py-2 text-base", 21 + lg: "px-6 py-3 text-lg", 22 + }; 23 + 24 + export function Button({ 25 + variant = "filled", 26 + size = "md", 27 + asChild = false, 28 + ref, 29 + className, 30 + children, 31 + ...props 32 + }: { 33 + variant?: ButtonVariant; 34 + size?: ButtonSize; 35 + asChild?: boolean; 36 + className?: string; 37 + children?: React.ReactNode; 38 + ref?: React.Ref<HTMLButtonElement>; 39 + } & React.ComponentPropsWithoutRef<"button">) { 40 + const Comp = asChild ? Slot : "button"; 41 + 42 + return ( 43 + <Comp 44 + ref={ref} 45 + className={clsx( 46 + //focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-gray-500 dark:focus:ring-gray-300 47 + "inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed", 48 + variantClasses[variant], 49 + sizeClasses[size], 50 + className 51 + )} 52 + {...props} 53 + > 54 + {children} 55 + </Comp> 56 + ); 57 + } 58 + 59 + Button.displayName = "Button";
+8 -11
src/routes/profile.$did/index.tsx
··· 5 5 import React, { type ReactNode, useEffect, useState } from "react"; 6 6 7 7 import { Header } from "~/components/Header"; 8 + import { Button } from "~/components/radix-m3-rd/Button"; 8 9 import { 9 10 renderTextWithFacets, 10 11 UniversalPostRendererATURILoader, ··· 170 171 also save it persistently 171 172 */} 172 173 <FollowButton targetdidorhandle={did} /> 173 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 174 + <Button className="rounded-full" variant={"secondary"}> 174 175 ... {/* todo: icon */} 175 - </button> 176 + </Button> 176 177 </div> 177 178 178 179 {/* Info Card */} ··· 248 249 {identity?.did !== agent?.did ? ( 249 250 <> 250 251 {!(followRecords?.length && followRecords?.length > 0) ? ( 251 - <button 252 + <Button 252 253 onClick={(e) => { 253 254 e.stopPropagation(); 254 255 toggleFollow({ ··· 258 259 queryClient: queryClient, 259 260 }); 260 261 }} 261 - className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 262 262 > 263 263 Follow 264 - </button> 264 + </Button> 265 265 ) : ( 266 - <button 266 + <Button 267 267 onClick={(e) => { 268 268 e.stopPropagation(); 269 269 toggleFollow({ ··· 273 273 queryClient: queryClient, 274 274 }); 275 275 }} 276 - className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 277 276 > 278 277 Unfollow 279 - </button> 278 + </Button> 280 279 )} 281 280 </> 282 281 ) : ( 283 - <button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"> 284 - Edit Profile 285 - </button> 282 + <Button variant={"secondary"}>Edit Profile</Button> 286 283 )} 287 284 </> 288 285 );