Scrapboard.org client

feat: enhance board page with author profile display and share functionality

+190 -17
+75 -9
src/app/board/[did]/[rkey]/page.tsx
··· 1 1 "use client"; 2 2 3 3 import { Feed } from "@/components/Feed"; 4 - import { GitFork, LoaderCircle } from "lucide-react"; 4 + import { Copy, GitFork, LoaderCircle, Share2 } from "lucide-react"; 5 5 import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems"; 6 6 import { Board, useBoardsStore } from "@/lib/stores/boards"; 7 7 import { useCurrentBoard } from "@/lib/stores/useCurrentBoard"; 8 8 import { useAuth } from "@/lib/hooks/useAuth"; 9 9 import { AtUri } from "@atproto/api"; 10 - import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 11 10 import { useParams, useRouter } from "next/navigation"; 12 11 import { useEffect, useMemo, useCallback, useState } from "react"; 13 12 import { EditButton } from "@/components/EditButton"; ··· 24 23 import { getPdsAgent } from "@/lib/utils/pds"; 25 24 import { de } from "zod/v4/locales"; 26 25 import { useDidStore } from "@/lib/stores/did"; 26 + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 27 + import { useActorProfile } from "@/lib/stores/actorProfiles"; 28 + import { 29 + ProfileView, 30 + ProfileViewDetailed, 31 + } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 27 32 28 33 export const runtime = "edge"; 29 34 ··· 150 155 // Create list record 151 156 const listRes = await agent.com.atproto.repo.createRecord({ 152 157 collection: LIST_COLLECTION, 153 - record: board, 158 + record: { 159 + ...board, 160 + source: boardUri.toString(), 161 + }, 154 162 repo: agent.assertDid, 155 163 }); 156 164 157 165 if (!listRes.success) { 166 + toast.dismiss(); // Dismiss the forking toast 158 167 toast("Failed to create list"); 159 168 return; 160 169 } ··· 209 218 } 210 219 }, [agent, board, decodedDid, rkey, isForkingBoard, router]); 211 220 221 + // Get author profile - now using our store 222 + const { profile: authorProfile, isLoading: isAuthorLoading } = 223 + useActorProfile(decodedDid); 224 + 212 225 // Loading states 213 226 if (!isValidParams) { 214 227 return <ErrorState message="Invalid board parameters" />; ··· 223 236 } 224 237 225 238 const canEdit = agent?.did == decodedDid; 239 + const canFork = !canEdit && agent?.did != null; 226 240 const isLoading = isPostsLoading || isLoadingMore; 227 241 228 242 return ( ··· 230 244 <BoardHeader 231 245 board={board} 232 246 canEdit={canEdit} 247 + canFork={canFork} 233 248 rkey={paramAsString(rkey)} 234 249 onFork={handleForkBoard} 235 250 isForkingBoard={isForkingBoard} 251 + authorProfile={authorProfile} 252 + isAuthorLoading={isAuthorLoading} 236 253 /> 237 254 {error && ( 238 255 <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"> ··· 274 291 canEdit, 275 292 rkey, 276 293 onFork, 294 + canFork, 277 295 isForkingBoard, 296 + authorProfile, 297 + isAuthorLoading, 278 298 }: { 279 299 board: Board; 280 300 canEdit: boolean; 301 + canFork: boolean; 281 302 rkey: string; 282 303 onFork: () => void; 283 304 isForkingBoard: boolean; 305 + authorProfile: ProfileViewDetailed | null; 306 + isAuthorLoading: boolean; 284 307 }) { 285 308 return ( 286 - <div className="flex flex-row mb-5 justify-between"> 309 + <div className="flex flex-col md:flex-row mb-5 md:justify-between gap-4"> 287 310 <div className="flex flex-row"> 288 311 <div className="ml-2"> 289 312 <div className="flex items-center gap-2"> ··· 292 315 <p className="text-black/80 dark:text-white/80"> 293 316 {board.description} 294 317 </p> 318 + 319 + {/* Author with subtle styling */} 320 + <div className="flex items-center text-sm text-muted-foreground mt-2"> 321 + <span className="mr-2">By</span> 322 + {isAuthorLoading ? ( 323 + <div className="flex items-center animate-pulse"> 324 + <div className="w-4 h-4 rounded-full bg-muted mr-1.5"></div> 325 + <div className="w-16 h-3 bg-muted rounded"></div> 326 + </div> 327 + ) : authorProfile ? ( 328 + <a 329 + href={`https://bsky.app/profile/${authorProfile.handle}`} 330 + target="_blank" 331 + rel="noopener noreferrer" 332 + className="flex items-center hover:text-foreground transition-colors" 333 + > 334 + <Avatar className="w-4 h-4 mr-1.5"> 335 + <AvatarImage src={authorProfile.avatar} /> 336 + <AvatarFallback className="text-[8px]"> 337 + {authorProfile.displayName?.[0] || authorProfile.handle[0]} 338 + </AvatarFallback> 339 + </Avatar> 340 + <span>{authorProfile.displayName || authorProfile.handle}</span> 341 + </a> 342 + ) : ( 343 + <span className="italic">unknown</span> 344 + )} 345 + </div> 295 346 </div> 296 347 {canEdit && <EditButton board={board} rkey={rkey} className="ml-3" />} 297 348 </div> 298 - {!canEdit && ( 299 - <div> 349 + 350 + <div className="flex gap-2 mt-2 md:mt-0"> 351 + <Button 352 + className="gap-2 cursor-pointer" 353 + variant={"secondary"} 354 + onClick={() => { 355 + navigator.clipboard.writeText(window.location.href); 356 + toast("Link to board copied to clipboard", { 357 + duration: 2000, 358 + dismissible: true, 359 + }); 360 + }} 361 + > 362 + <Share2 className="w-4 h-4" /> 363 + Share 364 + </Button> 365 + {canFork && ( 300 366 <Button 301 367 className="gap-2 cursor-pointer" 302 368 onClick={onFork} ··· 305 371 {isForkingBoard ? ( 306 372 <LoaderCircle className="animate-spin w-4 h-4" /> 307 373 ) : ( 308 - <GitFork /> 374 + <GitFork className="w-4 h-4" /> 309 375 )} 310 376 {isForkingBoard ? "Forking..." : "Fork"} 311 377 </Button> 312 - </div> 313 - )} 378 + )} 379 + </div> 314 380 </div> 315 381 ); 316 382 }
+4 -7
src/components/DeleteButton.tsx
··· 11 11 import { useAuth } from "@/lib/hooks/useAuth"; 12 12 import { useState } from "react"; 13 13 import { Button } from "./ui/button"; 14 - import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 15 - import { DeleteIcon, EditIcon, LoaderCircle, TrashIcon } from "lucide-react"; 14 + import { LoaderCircle, TrashIcon } from "lucide-react"; 16 15 import { Board, useBoardsStore } from "@/lib/stores/boards"; 17 - import { BoardsPicker } from "./BoardPicker"; 18 16 import { toast } from "sonner"; 19 17 import { AtUri } from "@atproto/api"; 20 18 import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 21 - import { FeedItem } from "./Feed"; 22 - import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems"; 19 + import { useBoardItemsStore } from "@/lib/stores/boardItems"; 23 20 import clsx from "clsx"; 24 - import { Input } from "./ui/input"; 25 - import { Textarea } from "./ui/textarea"; 26 21 import { Progress } from "./ui/progress"; 22 + import { redirect } from "next/navigation"; 27 23 28 24 export function DeleteButton({ board, rkey }: { board: Board; rkey: string }) { 29 25 const { agent } = useAuth(); ··· 135 131 removeBoard(agent.assertDid, rkey); 136 132 toast("Board deleted"); 137 133 setOpen(false); 134 + redirect("/boards"); 138 135 } else { 139 136 toast("Failed to delete board"); 140 137 }
+1 -1
src/components/EditButton.tsx
··· 31 31 }: { 32 32 board: Board; 33 33 rkey: string; 34 - className: string; 34 + className?: string; 35 35 }) { 36 36 const { agent } = useAuth(); 37 37 const [isLoading, setLoading] = useState(false);
+110
src/lib/stores/actorProfiles.ts
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + 4 + interface ActorProfilesState { 5 + profiles: Record<string, ProfileViewDetailed>; 6 + loadingProfiles: Set<string>; 7 + errors: Record<string, string>; 8 + setProfile: (did: string, profile: ProfileViewDetailed) => void; 9 + setLoading: (did: string, isLoading: boolean) => void; 10 + setError: (did: string, error: string | null) => void; 11 + getProfile: (did: string) => ProfileViewDetailed | null; 12 + isLoading: (did: string) => boolean; 13 + getError: (did: string) => string | null; 14 + } 15 + 16 + export const useActorProfilesStore = create<ActorProfilesState>()( 17 + persist( 18 + (set, get) => ({ 19 + profiles: {}, 20 + loadingProfiles: new Set<string>(), 21 + errors: {}, 22 + 23 + setProfile: (did, profile) => 24 + set((state) => ({ 25 + profiles: { ...state.profiles, [did]: profile }, 26 + })), 27 + 28 + setLoading: (did, isLoading) => 29 + set((state) => { 30 + const newLoadingProfiles = new Set(state.loadingProfiles); 31 + if (isLoading) { 32 + newLoadingProfiles.add(did); 33 + } else { 34 + newLoadingProfiles.delete(did); 35 + } 36 + return { loadingProfiles: newLoadingProfiles }; 37 + }), 38 + 39 + setError: (did, error) => 40 + set((state) => { 41 + const newErrors = { ...state.errors }; 42 + if (error) { 43 + newErrors[did] = error; 44 + } else { 45 + delete newErrors[did]; 46 + } 47 + return { errors: newErrors }; 48 + }), 49 + 50 + getProfile: (did) => get().profiles[did] || null, 51 + isLoading: (did) => get().loadingProfiles.has(did), 52 + getError: (did) => get().errors[did] || null, 53 + }), 54 + { 55 + name: "actor-profiles-storage", 56 + partialize: (state) => ({ profiles: state.profiles }), 57 + } 58 + ) 59 + ); 60 + 61 + // Hook to fetch and use actor profiles 62 + import { useEffect } from "react"; 63 + import { useAuth } from "@/lib/hooks/useAuth"; 64 + import { 65 + ProfileView, 66 + ProfileViewDetailed, 67 + } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 68 + 69 + export function useActorProfile(did: string | null) { 70 + const { agent } = useAuth(); 71 + const { getProfile, setProfile, isLoading, setLoading, getError, setError } = 72 + useActorProfilesStore(); 73 + 74 + useEffect(() => { 75 + if (!did || !agent) return; 76 + 77 + // Check if we already have the profile 78 + if (getProfile(did)) return; 79 + 80 + // Check if already loading 81 + if (isLoading(did)) return; 82 + 83 + const fetchProfile = async () => { 84 + setLoading(did, true); 85 + setError(did, null); 86 + 87 + try { 88 + const response = await agent.getProfile({ actor: did }); 89 + if (response.success) { 90 + setProfile(did, response.data); 91 + } else { 92 + throw new Error("Failed to fetch profile"); 93 + } 94 + } catch (err) { 95 + console.error("Error fetching profile:", err); 96 + setError(did, err instanceof Error ? err.message : String(err)); 97 + } finally { 98 + setLoading(did, false); 99 + } 100 + }; 101 + 102 + fetchProfile(); 103 + }, [did, agent, getProfile, setProfile, isLoading, setLoading, setError]); 104 + 105 + return { 106 + profile: did ? getProfile(did) : null, 107 + isLoading: did ? isLoading(did) : false, 108 + error: did ? getError(did) : null, 109 + }; 110 + }