Scrapboard.org client
at labels 248 lines 8.1 kB view raw
1import { 2 Dialog, 3 DialogContent, 4 DialogDescription, 5 DialogFooter, 6 DialogHeader, 7 DialogTitle, 8 DialogTrigger, 9} from "@/components/ui/dialog"; 10import { useAuth } from "@/lib/hooks/useAuth"; 11import { useState, useRef, useEffect } from "react"; 12import { Button } from "./ui/button"; 13import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 14import { ChevronDown, ExternalLink, LoaderCircle } from "lucide-react"; 15import { useBoardsStore } from "@/lib/stores/boards"; 16import { BoardsPicker } from "./BoardPicker"; 17import { toast } from "sonner"; 18import { AtUri } from "@atproto/api"; 19import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 20import { FeedItem } from "./Feed"; 21import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems"; 22import { useRecentBoardsStore } from "@/lib/stores/recentBoards"; 23import { 24 DropdownMenu, 25 DropdownMenuContent, 26 DropdownMenuItem, 27 DropdownMenuLabel, 28 DropdownMenuSeparator, 29 DropdownMenuTrigger, 30} from "@/components/ui/dropdown-menu"; 31 32export function SaveButton({ 33 post, 34 image, 35 onDropdownOpenChange, 36}: { 37 post: PostView; 38 image: number; 39 onDropdownOpenChange?: (isOpen: boolean) => void; 40}) { 41 const { agent } = useAuth(); 42 const [isLoading, setLoading] = useState(false); 43 const [isOpen, setOpen] = useState(false); 44 const [selectedBoard, setSelectedBoard] = useState(""); 45 const boardsStore = useBoardsStore(); 46 const { setBoardItem } = useBoardItemsStore(); 47 const [isDropdownOpen, setDropdownOpen] = useState(false); 48 const { recentBoards, addRecentBoard } = useRecentBoardsStore(); 49 const dropdownRef = useRef<HTMLDivElement>(null); 50 51 // Handle closing dropdown when clicking outside 52 useEffect(() => { 53 const handleClickOutside = (event: MouseEvent) => { 54 if ( 55 dropdownRef.current && 56 !dropdownRef.current.contains(event.target as Node) 57 ) { 58 setDropdownOpen(false); 59 } 60 }; 61 62 document.addEventListener("mousedown", handleClickOutside); 63 return () => document.removeEventListener("mousedown", handleClickOutside); 64 }, []); 65 66 // Update parent component when dropdown state changes 67 useEffect(() => { 68 onDropdownOpenChange?.(isDropdownOpen); 69 }, [isDropdownOpen, onDropdownOpenChange]); 70 71 const saveToBoard = async (boardId: string) => { 72 setLoading(true); 73 try { 74 if (!agent || !agent.assertDid) { 75 toast("Unable to save - not logged in properly"); 76 return; 77 } 78 79 const record: BoardItem = { 80 url: post.uri + `?image=${image}`, 81 list: AtUri.make(agent.assertDid, LIST_COLLECTION, boardId).toString(), 82 $type: LIST_ITEM_COLLECTION, 83 createdAt: new Date().toISOString(), 84 }; 85 const result = await agent?.com.atproto.repo.createRecord({ 86 collection: LIST_ITEM_COLLECTION, 87 record, 88 repo: agent?.assertDid || "", 89 }); 90 91 if (result?.success) { 92 const rkey = new AtUri(result.data.uri).rkey; 93 setBoardItem(rkey, record); 94 addRecentBoard(boardId); 95 toast("Image saved"); 96 setOpen(false); 97 setDropdownOpen(false); 98 } else { 99 toast("Failed to save image"); 100 } 101 } finally { 102 setLoading(false); 103 } 104 }; 105 106 if (agent == null) return <div>not logged in :(</div>; 107 108 // Get board names for recent boards with correct board structure 109 const recentBoardsWithNames = recentBoards 110 .map((boardId) => { 111 // Look through all DIDs in the boards store 112 for (const did in boardsStore.boards) { 113 // Check if this DID has the board we're looking for 114 if (boardsStore.boards[did]?.[boardId]) { 115 return { 116 id: boardId, 117 name: boardsStore.boards[did][boardId].name || "Unnamed Board", 118 }; 119 } 120 } 121 // If board not found 122 return { id: boardId, name: "Unnamed Board" }; 123 }) 124 .slice(0, 5); // Show only top 5 recent boards 125 126 return ( 127 <Dialog open={isOpen} onOpenChange={setOpen}> 128 <div className="flex items-center" ref={dropdownRef}> 129 {/* Main Save button - always opens dialog */} 130 <Button 131 size="sm" 132 className="rounded-r-none border-r-0 cursor-pointer" 133 onClick={(e) => { 134 e.stopPropagation(); 135 setOpen(true); 136 }} 137 > 138 Save 139 </Button> 140 141 {/* Dropdown arrow for recents */} 142 <DropdownMenu 143 open={isDropdownOpen} 144 onOpenChange={(open) => { 145 setDropdownOpen(open); 146 onDropdownOpenChange?.(open); 147 }} 148 > 149 <DropdownMenuTrigger asChild> 150 <Button 151 size="sm" 152 variant="default" 153 className="rounded-l-none px-2 cursor-pointer" 154 onClick={(e) => e.stopPropagation()} 155 > 156 <ChevronDown className="h-4 w-4" /> 157 </Button> 158 </DropdownMenuTrigger> 159 <DropdownMenuContent align="end" className="z-50" sideOffset={5}> 160 <DropdownMenuLabel>Recent Boards</DropdownMenuLabel> 161 <DropdownMenuSeparator /> 162 {recentBoardsWithNames.length > 0 ? ( 163 recentBoardsWithNames.map((board) => ( 164 <DropdownMenuItem 165 key={board.id} 166 onClick={(e) => { 167 e.stopPropagation(); 168 saveToBoard(board.id); 169 }} 170 className="cursor-pointer" 171 > 172 {board.name} 173 </DropdownMenuItem> 174 )) 175 ) : ( 176 <DropdownMenuItem disabled className="cursor-not-allowed"> 177 No recent boards 178 </DropdownMenuItem> 179 )} 180 </DropdownMenuContent> 181 </DropdownMenu> 182 </div> 183 184 <DialogContent> 185 <DialogHeader> 186 <DialogTitle>Save post to board</DialogTitle> 187 <DialogDescription className="pt-5"> 188 <BoardsPicker 189 onSelected={setSelectedBoard} 190 selected={selectedBoard} 191 boards={boardsStore.boards} 192 onCreateBoard={async (name) => { 193 const record = { 194 name: name, 195 $type: LIST_COLLECTION, 196 createdAt: new Date().toISOString(), 197 description: "", 198 }; 199 const result = await agent?.com.atproto.repo.createRecord({ 200 collection: LIST_COLLECTION, 201 record, 202 repo: agent.assertDid, 203 }); 204 205 if (result?.success) { 206 toast("Board created"); 207 208 const rkey = new AtUri(result.data.uri).rkey; 209 boardsStore.setBoard(agent.assertDid, rkey, record); 210 setSelectedBoard(rkey); 211 } else { 212 toast("Failed to create board"); 213 } 214 }} 215 /> 216 <p className="mt-3 text-sm text-muted-foreground"> 217 Saved posts use{" "} 218 <a 219 href="https://scrapboard.org" 220 target="_blank" 221 rel="noopener noreferrer" 222 className="inline-flex items-center hover:underline text-blue-400 mr-1" 223 > 224 scrapboard.org&apos;s 225 <ExternalLink className="w-3 h-3 ml-1" /> 226 </a> 227 standard format, making them interoperable. 228 </p> 229 </DialogDescription> 230 </DialogHeader> 231 <DialogFooter> 232 <Button 233 onClick={async (e) => { 234 e.stopPropagation(); 235 addRecentBoard(selectedBoard); 236 await saveToBoard(selectedBoard); 237 }} 238 disabled={selectedBoard.trim().length <= 0} 239 className="cursor-pointer" 240 > 241 {isLoading && <LoaderCircle className="animate-spin ml-2" />} 242 Save 243 </Button> 244 </DialogFooter> 245 </DialogContent> 246 </Dialog> 247 ); 248}