Scrapboard.org client
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'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}