Scrapboard.org client

feat: implement recent boards functionality and enhance SaveButton component

+200 -48
+1 -1
src/app/boards/page.tsx
··· 48 className="h-full" 49 > 50 <motion.div 51 - initial={{ opacity: 0, y: 2, filter: "blur(4px)" }} 52 animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} 53 transition={{ duration: 0.3, ease: "easeOut" }} 54 whileTap={{ scale: 0.95 }}
··· 48 className="h-full" 49 > 50 <motion.div 51 + initial={{ opacity: 0, y: 2, filter: "blur(14px)" }} 52 animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} 53 transition={{ duration: 0.3, ease: "easeOut" }} 54 whileTap={{ scale: 0.95 }}
+13 -5
src/components/Feed.tsx
··· 10 import { SaveButton } from "./SaveButton"; 11 import { UnsaveButton } from "./UnsaveButton"; 12 import { LikeButton } from "./LikeButton"; 13 14 export type FeedItem = { 15 id: string; ··· 62 index: number; 63 }) { 64 const image = getImageFromItem(item, index); 65 66 if (!image) return; 67 ··· 69 const txt = getText(item); 70 71 return ( 72 - <div className="relative group"> 73 {/* Save/Unsave button – top-left */} 74 - <div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 transition-opacity"> 75 - {ActionButton && <ActionButton image={index} post={item} />} 76 </div> 77 78 {/* Like button – top-right */} 79 - <div className="absolute top-3 right-3 z-30 opacity-0 group-hover:opacity-100 transition-opacity"> 80 <LikeButton post={item} /> 81 </div> 82 ··· 117 118 {/* Author info */} 119 {item.author && ( 120 - <div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-between p-3"> 121 <div className="w-fit self-start" /> 122 123 <div className="flex flex-col gap-2">
··· 10 import { SaveButton } from "./SaveButton"; 11 import { UnsaveButton } from "./UnsaveButton"; 12 import { LikeButton } from "./LikeButton"; 13 + import { useState } from "react"; 14 15 export type FeedItem = { 16 id: string; ··· 63 index: number; 64 }) { 65 const image = getImageFromItem(item, index); 66 + const [isDropdownOpen, setDropdownOpen] = useState(false); 67 68 if (!image) return; 69 ··· 71 const txt = getText(item); 72 73 return ( 74 + <div className={`relative group ${isDropdownOpen ? "hover-active" : ""}`}> 75 {/* Save/Unsave button – top-left */} 76 + <div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 77 + {ActionButton && ( 78 + <ActionButton 79 + image={index} 80 + post={item} 81 + onDropdownOpenChange={setDropdownOpen} 82 + /> 83 + )} 84 </div> 85 86 {/* Like button – top-right */} 87 + <div className="absolute top-3 right-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 88 <LikeButton post={item} /> 89 </div> 90 ··· 125 126 {/* Author info */} 127 {item.author && ( 128 + <div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity duration-300 flex flex-col justify-between p-3"> 129 <div className="w-fit self-start" /> 130 131 <div className="flex flex-col gap-2">
+151 -42
src/components/SaveButton.tsx
··· 8 DialogTrigger, 9 } from "@/components/ui/dialog"; 10 import { useAuth } from "@/lib/hooks/useAuth"; 11 - import { useState } from "react"; 12 import { Button } from "./ui/button"; 13 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 14 - import { LoaderCircle } from "lucide-react"; 15 import { useBoardsStore } from "@/lib/stores/boards"; 16 import { BoardsPicker } from "./BoardPicker"; 17 import { toast } from "sonner"; ··· 19 import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 20 import { FeedItem } from "./Feed"; 21 import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems"; 22 23 - export function SaveButton({ post, image }: { post: PostView; image: number }) { 24 const { agent } = useAuth(); 25 const [isLoading, setLoading] = useState(false); 26 const [isOpen, setOpen] = useState(false); 27 const [selectedBoard, setSelectedBoard] = useState(""); 28 const boardsStore = useBoardsStore(); 29 const { setBoardItem } = useBoardItemsStore(); 30 31 if (agent == null) return <div>not logged in :(</div>; 32 return ( 33 <Dialog open={isOpen} onOpenChange={setOpen}> 34 - <DialogTrigger asChild> 35 - <span 36 onClick={(e) => { 37 e.stopPropagation(); 38 }} 39 - className="cursor-pointer" 40 > 41 - <Button size="sm" className="cursor-pointer"> 42 - Save 43 - </Button> 44 - </span> 45 - </DialogTrigger> 46 47 <DialogContent> 48 <DialogHeader> ··· 81 <DialogFooter> 82 <Button 83 onClick={async (e) => { 84 - e.stopPropagation(); // Optional, but safe 85 - 86 - setLoading(true); 87 - try { 88 - const record: BoardItem = { 89 - url: post.uri + `?image=${image}`, 90 - list: AtUri.make( 91 - agent?.assertDid, 92 - LIST_COLLECTION, 93 - selectedBoard 94 - ).toString(), 95 - $type: LIST_ITEM_COLLECTION, 96 - createdAt: new Date().toISOString(), 97 - }; 98 - const result = await agent.com.atproto.repo.createRecord({ 99 - collection: LIST_ITEM_COLLECTION, 100 - record, 101 - repo: agent.assertDid, 102 - }); 103 - 104 - if (result?.success) { 105 - const rkey = new AtUri(result.data.uri).rkey; 106 - setBoardItem(rkey, record); 107 - toast("Image saved"); 108 - setOpen(false); 109 - } else { 110 - toast("Failed to save image"); 111 - } 112 - } finally { 113 - setLoading(false); 114 - } 115 }} 116 disabled={selectedBoard.trim().length <= 0} 117 className="cursor-pointer"
··· 8 DialogTrigger, 9 } from "@/components/ui/dialog"; 10 import { useAuth } from "@/lib/hooks/useAuth"; 11 + import { useState, useRef, useEffect } from "react"; 12 import { Button } from "./ui/button"; 13 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 14 + import { ChevronDown, LoaderCircle } from "lucide-react"; 15 import { useBoardsStore } from "@/lib/stores/boards"; 16 import { BoardsPicker } from "./BoardPicker"; 17 import { toast } from "sonner"; ··· 19 import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 20 import { FeedItem } from "./Feed"; 21 import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems"; 22 + import { useRecentBoardsStore } from "@/lib/stores/recentBoards"; 23 + import { 24 + DropdownMenu, 25 + DropdownMenuContent, 26 + DropdownMenuItem, 27 + DropdownMenuLabel, 28 + DropdownMenuSeparator, 29 + DropdownMenuTrigger, 30 + } from "@/components/ui/dropdown-menu"; 31 32 + export 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> ··· 218 <DialogFooter> 219 <Button 220 onClick={async (e) => { 221 + e.stopPropagation(); 222 + addRecentBoard(selectedBoard); 223 + await saveToBoard(selectedBoard); 224 }} 225 disabled={selectedBoard.trim().length <= 0} 226 className="cursor-pointer"
+35
src/lib/stores/recentBoards.ts
···
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + 4 + interface RecentBoardsState { 5 + recentBoards: string[]; // Array of board IDs 6 + addRecentBoard: (boardId: string) => void; 7 + removeRecentBoard: (boardId: string) => void; 8 + clearRecentBoards: () => void; 9 + } 10 + 11 + export const useRecentBoardsStore = create<RecentBoardsState>()( 12 + persist( 13 + (set) => ({ 14 + recentBoards: [], 15 + 16 + addRecentBoard: (boardId: string) => 17 + set((state) => { 18 + // Remove board if it already exists (to reorder) 19 + const filtered = state.recentBoards.filter((id) => id !== boardId); 20 + // Add board to the beginning of the array (most recent first) 21 + return { recentBoards: [boardId, ...filtered] }; 22 + }), 23 + 24 + removeRecentBoard: (boardId: string) => 25 + set((state) => ({ 26 + recentBoards: state.recentBoards.filter((id) => id !== boardId), 27 + })), 28 + 29 + clearRecentBoards: () => set({ recentBoards: [] }), 30 + }), 31 + { 32 + name: "recent-boards-storage", // name for localStorage 33 + } 34 + ) 35 + );