Scrapboard.org client

feat: boards!!

TurtlePaw 7f54ba24 b9bababf

+552 -172
+90 -78
src/app/board/[did]/[rkey]/page.tsx
··· 1 1 "use client"; 2 2 import { paramAsString } from "@/app/[did]/[uri]/page"; 3 - import LikeCounter from "@/components/LikeCounter"; 4 - import { SaveButton } from "@/components/SaveButton"; 5 - import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 6 - import { Button } from "@/components/ui/button"; 7 - import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 3 + import { Feed } from "@/components/Feed"; 4 + import { LoaderCircle } from "lucide-react"; 8 5 import { useBoardItemsStore } from "@/lib/stores/boardItems"; 9 - import { Board, useBoardsStore } from "@/lib/stores/boards"; 6 + import { useBoardsStore } from "@/lib/stores/boards"; 7 + import { useCurrentBoard } from "@/lib/stores/useCurrentBoard"; 10 8 import { useAuth } from "@/lib/useAuth"; 11 - import { $Typed, AtUri } from "@atproto/api"; 12 - import { AppBskyEmbedImages, AppBskyFeedPost } from "@atproto/api/dist/client"; 9 + import { AtUri } from "@atproto/api"; 13 10 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 14 - import clsx from "clsx"; 15 - import { 16 - ExternalLink, 17 - Heart, 18 - LoaderCircle, 19 - MessagesSquare, 20 - Repeat, 21 - Repeat2, 22 - } from "lucide-react"; 23 - import { AnimatePresence, motion } from "motion/react"; 24 - import Image, { ImageProps } from "next/image"; 25 - import Link from "next/link"; 26 - import { useParams, useSearchParams } from "next/navigation"; 27 - import { use, useEffect, useMemo, useRef, useState } from "react"; 28 - import z from "zod"; 11 + import { useParams } from "next/navigation"; 12 + import { useEffect, useMemo, useState } from "react"; 13 + import { EditButton } from "@/components/EditButton"; 29 14 30 15 export const runtime = "edge"; 31 16 32 - export default function PostPage({ 33 - params, 34 - }: { 35 - params: Promise<{ slug: string }>; 36 - }) { 17 + export default function BoardPage() { 37 18 const { did, rkey } = useParams(); 19 + const { agent } = useAuth(); 38 20 21 + useCurrentBoard.getState().setCurrentBoard(rkey?.toString() ?? null); 39 22 const { boards, isLoading: isBoardsLoading } = useBoardsStore(); 40 23 const { boardItems: items, isLoading: isItemsLoading } = useBoardItemsStore(); 41 24 42 - if (!rkey || !did) 25 + const [posts, setPosts] = useState<[number, PostView][]>([]); 26 + const [loading, setLoading] = useState(true); 27 + 28 + const itemsInBoard = useMemo(() => { 29 + if (!rkey) return []; 30 + return Array.from(items.entries()).filter( 31 + ([, item]) => new AtUri(item.list).rkey === paramAsString(rkey) 32 + ); 33 + }, [items, rkey]); 34 + 35 + const board = useMemo( 36 + () => (rkey ? boards.get(paramAsString(rkey)) : null), 37 + [boards, rkey] 38 + ); 39 + 40 + // Initial fetch 41 + useEffect(() => { 42 + if (!agent || !rkey || !did || itemsInBoard.length === 0) { 43 + setLoading(false); 44 + return; 45 + } 46 + 47 + let cancelled = false; 48 + 49 + const fetchPosts = async () => { 50 + try { 51 + const uris = itemsInBoard.map(([, item]) => 52 + AtUri.make(item.url.split("?")[0]).toString() 53 + ); 54 + 55 + const response = await agent.getPosts({ uris }); 56 + const skeets = response?.data.posts || []; 57 + 58 + if (!cancelled) { 59 + const newPosts: [number, PostView][] = skeets.map((skeet) => { 60 + const uri = new AtUri(skeet.uri); 61 + const index = Number(uri.searchParams.get("image")) || 0; 62 + return [index, skeet] as [number, PostView]; 63 + }); 64 + 65 + setPosts(newPosts); 66 + setLoading(false); 67 + } 68 + } catch (error) { 69 + console.error("Error fetching posts:", error); 70 + if (!cancelled) setLoading(false); 71 + } 72 + }; 73 + 74 + fetchPosts(); 75 + return () => { 76 + cancelled = true; 77 + }; 78 + }, [agent, did, rkey, itemsInBoard]); 79 + 80 + if (!rkey || !did) { 43 81 return ( 44 82 <div className="min-h-screen flex items-center justify-center px-4"> 45 - <div className="text-center"> 46 - <p className="text-red-500 dark:text-red-400">No rkey or did</p> 47 - </div> 83 + <p className="text-red-500 dark:text-red-400">No rkey or did</p> 48 84 </div> 49 85 ); 50 - if (isItemsLoading || isBoardsLoading) 86 + } 87 + 88 + if (isItemsLoading || isBoardsLoading || loading) { 51 89 return ( 52 90 <div className="min-h-screen flex items-center justify-center px-4"> 53 91 <LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" /> 54 92 </div> 55 93 ); 94 + } 56 95 57 - const board = boards.get(paramAsString(rkey)); 58 - const itemsInBoard = items 59 - .entries() 60 - .filter((it) => new AtUri(it[1].list).rkey == paramAsString(rkey)) 61 - .toArray(); 62 - if (itemsInBoard.length <= 0) 96 + if (itemsInBoard.length === 0) { 63 97 return ( 64 98 <div className="min-h-screen flex items-center justify-center px-4"> 65 99 <p className="text-black/70 dark:text-white/70">No items found</p> 66 100 </div> 67 101 ); 68 - if (!board) 102 + } 103 + 104 + if (!board) { 69 105 return ( 70 106 <div className="min-h-screen flex items-center justify-center px-4"> 71 107 <p className="text-black/70 dark:text-white/70">No board found</p> 72 108 </div> 73 109 ); 110 + } 74 111 75 112 return ( 76 - <div className="py-4 px-4 sm:py-8 sm:px-6 lg:px-8 flex items-center justify-center"> 77 - {/* Container that adapts to image width */} 78 - <div className="w-full max-w-4xl flex justify-center"> 79 - <div className="inline-block"> 80 - <div> 81 - <h2>{board?.name}</h2> 82 - <p>{board.description}</p> 83 - </div> 113 + <div className="px-5"> 114 + <div className="flex flex-row"> 115 + <div className="mb-5 ml-2"> 116 + <h2 className="font-bold text-xl">{board.name}</h2> 117 + <p className="text-black/80 dark:text-white/80"> 118 + {board.description} 119 + </p> 84 120 </div> 121 + <EditButton board={board} rkey={paramAsString(rkey)} className="ml-3" /> 85 122 </div> 123 + <Feed 124 + feed={posts} 125 + showUnsaveButton={true} 126 + onUnsave={(imageUrl, index) => console.log("Unsave", imageUrl)} 127 + /> 86 128 </div> 87 129 ); 88 130 } 89 - 90 - type BskyImageProps = { 91 - embed: 92 - | $Typed<AppBskyEmbedImages.View> 93 - | { 94 - $type: string; 95 - } 96 - | undefined; 97 - imageIndex?: number; 98 - className?: string; 99 - width?: number; 100 - height?: number; 101 - } & Omit<ImageProps, "src" | "alt">; 102 - 103 - function BskyImage({ embed, imageIndex = 0, ...props }: BskyImageProps) { 104 - if (!AppBskyEmbedImages.isView(embed)) return null; 105 - 106 - const image = embed.images?.[imageIndex]; 107 - if (!image) return null; 108 - 109 - return ( 110 - <Image 111 - src={image.fullsize} 112 - alt={image.alt || "Post Image"} 113 - placeholder="blur" 114 - blurDataURL={image.thumb} 115 - {...props} 116 - /> 117 - ); 118 - }
+11 -6
src/app/boards/page.tsx
··· 28 28 29 29 export const runtime = "edge"; 30 30 31 + function truncateString(str: string, num: number) { 32 + return str.length > num ? str.slice(0, num) + "..." : str; 33 + } 34 + 31 35 export default function BoardsPage() { 32 36 const { boards, isLoading } = useBoardsStore(); 33 37 const { agent } = useAuth(); ··· 51 55 <div className="w-full max-w-4xl flex justify-center"> 52 56 <div className="inline-block"> 53 57 {Array.from(boards.entries()).map(([key, it]) => ( 54 - <Link 55 - href={`/board/${agent?.did ?? "unknown"}/${key}`} 56 - key={key} 57 - className="bg-accent/80 p-4 rounded-lg m-2 hover:bg-accent" 58 - > 59 - {it.name} 58 + <Link href={`/board/${agent?.did ?? "unknown"}/${key}`} key={key}> 59 + <div className="bg-black/10 dark:bg-white/40 p-4 rounded-lg m-2 hover:bg-black/15 dark:hover:bg-white/60 min-w-lg min-h-2/5 transition-colors"> 60 + <h2 className="font-medium text-lg">{it.name}</h2> 61 + <p className="text-sm text-black/80 dark:text-white/80"> 62 + {truncateString(it.description, 50)} 63 + </p> 64 + </div> 60 65 </Link> 61 66 ))} 62 67 </div>
+3 -3
src/app/page.tsx
··· 1 1 "use client"; 2 2 3 - import { Feed } from "@/components/Feed"; 3 + import { Feed, feedAsMap } from "@/components/Feed"; 4 4 import { useFetchTimeline } from "@/lib/hooks/useTimeline"; 5 5 import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; 6 6 import { useRef, useEffect, useState } from "react"; ··· 96 96 97 97 <TabsContent value="timeline"> 98 98 <Feed 99 - feed={feedStore.timeline.posts.map((it) => it.post)} 99 + feed={feedAsMap(feedStore.timeline.posts.map((it) => it.post))} 100 100 isLoading={feedStore.timeline.isLoading} 101 101 /> 102 102 </TabsContent> ··· 106 106 .map(([value]) => ( 107 107 <TabsContent key={value} value={value}> 108 108 <Feed 109 - feed={feedStore.customFeeds[value].posts} 109 + feed={feedAsMap(feedStore.customFeeds[value].posts)} 110 110 isLoading={feedStore.customFeeds[value].isLoading} 111 111 /> 112 112 </TabsContent>
+136
src/components/EditButton.tsx
··· 1 + import { 2 + Dialog, 3 + DialogContent, 4 + DialogDescription, 5 + DialogFooter, 6 + DialogHeader, 7 + DialogTitle, 8 + DialogTrigger, 9 + } from "@/components/ui/dialog"; 10 + import { useAuth } from "@/lib/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 { EditIcon, LoaderCircle } from "lucide-react"; 15 + import { Board, useBoardsStore } from "@/lib/stores/boards"; 16 + import { BoardsPicker } from "./BoardPicker"; 17 + import { toast } from "sonner"; 18 + import { AtUri } from "@atproto/api"; 19 + import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 20 + import { FeedItem } from "./Feed"; 21 + import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems"; 22 + import clsx from "clsx"; 23 + import { Input } from "./ui/input"; 24 + import { Textarea } from "./ui/textarea"; 25 + 26 + export function EditButton({ 27 + board, 28 + rkey, 29 + className, 30 + }: { 31 + board: Board; 32 + rkey: string; 33 + className: string; 34 + }) { 35 + const { agent } = useAuth(); 36 + const [isLoading, setLoading] = useState(false); 37 + const [isOpen, setOpen] = useState(false); 38 + const [name, setName] = useState(board.name); 39 + const [description, setDescription] = useState(board.description); 40 + const { setBoard } = useBoardsStore(); 41 + 42 + if (agent == null) return <div>not logged in :(</div>; 43 + return ( 44 + <Dialog open={isOpen} onOpenChange={setOpen}> 45 + <DialogTrigger asChild> 46 + <span 47 + onClick={(e) => { 48 + e.stopPropagation(); 49 + }} 50 + className={clsx("cursor-pointer", className)} 51 + > 52 + <Button 53 + size="sm" 54 + className={clsx("cursor-pointer")} 55 + variant={"ghost"} 56 + > 57 + <EditIcon /> 58 + </Button> 59 + </span> 60 + </DialogTrigger> 61 + 62 + <DialogContent> 63 + <DialogHeader> 64 + <DialogTitle>Update board</DialogTitle> 65 + <DialogDescription className="pt-5"> 66 + <Input 67 + onChange={(e) => setName(e.target.value)} 68 + value={name} 69 + className="dark:text-white text-black" 70 + /> 71 + <Textarea 72 + className="mt-2 dark:text-white text-black" 73 + onChange={(e) => setDescription(e.target.value)} 74 + value={description} 75 + placeholder="Enter a description of your board..." 76 + /> 77 + </DialogDescription> 78 + </DialogHeader> 79 + <DialogFooter> 80 + <Button 81 + onClick={async (e) => { 82 + e.stopPropagation(); // Optional, but safe 83 + 84 + setLoading(true); 85 + try { 86 + const record: Board = { 87 + name, 88 + description, 89 + }; 90 + 91 + const result = await agent.com.atproto.repo.applyWrites({ 92 + repo: agent.assertDid, 93 + writes: [ 94 + { 95 + $type: "com.atproto.repo.applyWrites#update", 96 + collection: LIST_COLLECTION, 97 + value: record, 98 + rkey: rkey, 99 + }, 100 + ], 101 + }); 102 + 103 + const newRecord = await agent.com.atproto.repo.getRecord({ 104 + repo: agent.assertDid, 105 + collection: LIST_COLLECTION, 106 + rkey: rkey, 107 + }); 108 + 109 + const newRecordData = Board.safeParse(newRecord.data.value); 110 + 111 + if ( 112 + result?.success && 113 + newRecord.success && 114 + newRecordData.success 115 + ) { 116 + setBoard(rkey, newRecordData.data); 117 + toast("Board updated"); 118 + setOpen(false); 119 + } else { 120 + toast("Failed to update board"); 121 + } 122 + } finally { 123 + setLoading(false); 124 + } 125 + }} 126 + disabled={name.length <= 0} 127 + className="cursor-pointer" 128 + > 129 + {isLoading && <LoaderCircle className="animate-spin ml-2" />} 130 + Update 131 + </Button> 132 + </DialogFooter> 133 + </DialogContent> 134 + </Dialog> 135 + ); 136 + }
+158 -84
src/components/Feed.tsx
··· 5 5 AppBskyEmbedImages, 6 6 AppBskyFeedDefs, 7 7 AppBskyFeedPost, 8 + AtUri, 8 9 } from "@atproto/api"; 9 10 import { LoaderCircle } from "lucide-react"; 10 11 import { useEffect, useRef, useState, useCallback } from "react"; ··· 15 16 import Masonry from "react-masonry-css"; 16 17 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 17 18 import { SaveButton } from "./SaveButton"; 19 + import { UnsaveButton } from "./UnsaveButton"; 20 + 21 + export type FeedItem = { 22 + id: string; 23 + imageUrl: string; 24 + alt?: string; 25 + author?: { 26 + avatar?: string; 27 + displayName?: string; 28 + handle: string; 29 + did?: string; 30 + }; 31 + text?: string; 32 + uri: string; 33 + aspectRatio?: { width: number; height: number }; 34 + blurDataURL?: string; 35 + }; 36 + 37 + // Props for the Feed component 38 + interface FeedProps { 39 + /** 40 + * Map of the index of the embedded media and post view 41 + */ 42 + feed?: [number, PostView][]; 43 + 44 + isLoading?: boolean; 45 + showUnsaveButton?: boolean; 46 + onUnsave?: (imageUrl: string, index: number) => void; 47 + } 18 48 19 49 function getText(post: PostView) { 20 50 if (!AppBskyFeedPost.isRecord(post.record)) return; 21 51 return (post.record as AppBskyFeedPost.Record).text; 22 52 } 23 53 54 + function getImageFromItem(it: PostView, index: number) { 55 + if ( 56 + AppBskyEmbedImages.isMain(it.embed) || 57 + AppBskyEmbedImages.isView(it.embed) 58 + ) { 59 + return it.embed.images[index]; 60 + } else return null; 61 + } 62 + 63 + function ImageCard({ 64 + item, 65 + showUnsaveButton, 66 + onUnsave, 67 + index, 68 + }: { 69 + item: PostView; 70 + showUnsaveButton?: boolean; 71 + onUnsave?: (imageUrl: string, index: number) => void; 72 + index: number; 73 + }) { 74 + const image = getImageFromItem(item, index); 75 + 76 + if (!image) return; 77 + 78 + const ActionButton = showUnsaveButton ? UnsaveButton : SaveButton; 79 + const txt = getText(item); 80 + 81 + return ( 82 + <div key={item.uri} className="relative group"> 83 + {ActionButton && ( 84 + <div className="absolute z-30 top-3 left-3 opacity-0 group-hover:opacity-100 transition-opacity"> 85 + <ActionButton image={index} post={item} /> 86 + </div> 87 + )} 88 + <Link 89 + href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`} 90 + className="block" 91 + > 92 + <motion.div 93 + initial={{ opacity: 0, y: 5 }} 94 + animate={{ opacity: 1, y: 0 }} 95 + transition={{ duration: 0.5, ease: "easeOut" }} 96 + whileTap={{ scale: 0.95 }} 97 + className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900" 98 + > 99 + {/* Blurred background */} 100 + <Image 101 + src={image.fullsize} 102 + alt="" 103 + fill 104 + placeholder={image.thumb ? "blur" : "empty"} 105 + blurDataURL={image.thumb} 106 + className="object-cover filter blur-xl scale-110 opacity-30" 107 + /> 108 + 109 + {/* Centered foreground image */} 110 + <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]"> 111 + <Image 112 + src={image.fullsize} 113 + alt={image.alt || ""} 114 + placeholder={image.thumb ? "blur" : "empty"} 115 + blurDataURL={image.thumb} 116 + width={image.aspectRatio?.width ?? 400} 117 + height={image.aspectRatio?.height ?? 400} 118 + className="object-contain max-w-full max-h-full rounded-lg" 119 + /> 120 + </div> 121 + 122 + {/* Author info and text overlay (only if author exists) */} 123 + {item.author && ( 124 + <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"> 125 + <div className="w-fit self-start" /> 126 + 127 + <div className="flex flex-col gap-2"> 128 + <div className="flex items-center gap-2"> 129 + <Avatar> 130 + <AvatarImage src={item.author.avatar} /> 131 + <AvatarFallback> 132 + {item.author.displayName || item.author.handle} 133 + </AvatarFallback> 134 + </Avatar> 135 + <div className="flex flex-col leading-tight"> 136 + <span>{item.author.displayName || item.author.handle}</span> 137 + <span className="text-white/70 text-[0.75rem]"> 138 + @{item.author.handle} 139 + </span> 140 + </div> 141 + </div> 142 + 143 + {txt && ( 144 + <div className="text-sm"> 145 + {txt.length > 100 ? txt.slice(0, 100) + "…" : txt} 146 + </div> 147 + )} 148 + </div> 149 + </div> 150 + )} 151 + </motion.div> 152 + </Link> 153 + </div> 154 + ); 155 + } 156 + 157 + export function feedAsMap(feed: PostView[]) { 158 + const map: [number, PostView][] = []; 159 + for (const it of feed) { 160 + if ( 161 + AppBskyEmbedImages.isMain(it.embed) || 162 + AppBskyEmbedImages.isView(it.embed) 163 + ) { 164 + it.embed.images.forEach((image, index) => map.push([index, it])); 165 + } 166 + } 167 + return map; 168 + } 169 + 24 170 export function Feed({ 25 171 feed, 26 172 isLoading = false, 27 - }: { 28 - feed: PostView[]; 29 - isLoading?: boolean; 30 - }) { 173 + showUnsaveButton = false, 174 + onUnsave, 175 + }: FeedProps) { 31 176 const breakpointColumnsObj = { 32 177 default: 5, 33 178 1536: 4, ··· 43 188 className="flex -mx-2 w-auto" 44 189 columnClassName="px-2 space-y-4" 45 190 > 46 - {feed.flatMap((post) => { 47 - if (!AppBskyEmbedImages.isView(post.embed)) return []; 48 - const images = post.embed.images || []; 49 - if (images.length === 0) return []; 50 - const t: string = getText(post) || ""; 51 - const maxLength = 100; 52 - return images.map((image, index) => ( 53 - <div key={image.fullsize} className="relative group"> 54 - <div className="absolute z-30 top-3 left-3 opacity-0 group-hover:opacity-100 transition-opacity"> 55 - <SaveButton post={post} image={index} /> 56 - </div> 57 - <Link 58 - href={`/${post.author.did}/${post.uri 59 - .split("/") 60 - .pop()}?image=${index}`} 61 - key={image.fullsize} 62 - className="block" 63 - > 64 - <motion.div 65 - initial={{ opacity: 0, y: 5 }} 66 - animate={{ opacity: 1, y: 0 }} 67 - transition={{ duration: 0.5, ease: "easeOut" }} 68 - whileTap={{ scale: 0.99 }} 69 - className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900" 70 - > 71 - {/* Blurred background */} 72 - <Image 73 - src={image.fullsize} 74 - alt="" 75 - fill 76 - placeholder="blur" 77 - blurDataURL={image.thumb} 78 - className="object-cover filter blur-xl scale-110 opacity-30" 79 - /> 80 - 81 - {/* Centered foreground image */} 82 - <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]"> 83 - <Image 84 - src={image.fullsize} 85 - alt={image.alt} 86 - placeholder="blur" 87 - blurDataURL={image.thumb} 88 - width={image?.aspectRatio?.width ?? 400} 89 - height={image?.aspectRatio?.height ?? 400} 90 - className="object-contain max-w-full max-h-full rounded-lg" 91 - /> 92 - </div> 93 - 94 - {/* Bottom: Avatar, display name, and handle */} 95 - <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"> 96 - <div className="w-fit self-start" /> 97 - 98 - <div className="flex flex-col gap-2"> 99 - <div className="flex items-center gap-2"> 100 - <Avatar> 101 - <AvatarImage src={post.author.avatar} /> 102 - <AvatarFallback> 103 - {post.author.displayName || post.author.handle} 104 - </AvatarFallback> 105 - </Avatar> 106 - <div className="flex flex-col leading-tight"> 107 - <span> 108 - {post.author.displayName || post.author.handle} 109 - </span> 110 - <span className="text-white/70 text-[0.75rem]"> 111 - @{post.author.handle} 112 - </span> 113 - </div> 114 - </div> 115 - 116 - <div className="text-sm"> 117 - {t.length > maxLength ? t.slice(0, maxLength) + "…" : t} 118 - </div> 119 - </div> 120 - </div> 121 - </motion.div> 122 - </Link> 123 - </div> 124 - )); 125 - })} 191 + {feed?.map(([index, item]) => ( 192 + <ImageCard 193 + key={item.uri} 194 + item={item} 195 + index={index} 196 + showUnsaveButton={showUnsaveButton} 197 + onUnsave={onUnsave} 198 + /> 199 + ))} 126 200 </Masonry> 127 201 128 202 {isLoading && (
+6 -1
src/components/SaveButton.tsx
··· 17 17 import { toast } from "sonner"; 18 18 import { AtUri } from "@atproto/api"; 19 19 import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 20 + import { FeedItem } from "./Feed"; 21 + import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems"; 20 22 21 23 export function SaveButton({ post, image }: { post: PostView; image: number }) { 22 24 const { agent } = useAuth(); ··· 24 26 const [isOpen, setOpen] = useState(false); 25 27 const [selectedBoard, setSelectedBoard] = useState(""); 26 28 const boardsStore = useBoardsStore(); 29 + const { setBoardItem } = useBoardItemsStore(); 27 30 28 31 if (agent == null) return <div>not logged in :(</div>; 29 32 return ( ··· 82 85 83 86 setLoading(true); 84 87 try { 85 - const record = { 88 + const record: BoardItem = { 86 89 url: post.uri + `?image=${image}`, 87 90 list: AtUri.make( 88 91 agent?.assertDid, ··· 99 102 }); 100 103 101 104 if (result?.success) { 105 + const rkey = new AtUri(result.data.uri).rkey; 106 + setBoardItem(rkey, record); 102 107 toast("Image saved"); 103 108 setOpen(false); 104 109 } else {
+108
src/components/UnsaveButton.tsx
··· 1 + import { 2 + Dialog, 3 + DialogClose, 4 + DialogContent, 5 + DialogDescription, 6 + DialogFooter, 7 + DialogHeader, 8 + DialogTitle, 9 + DialogTrigger, 10 + } from "@/components/ui/dialog"; 11 + import { useAuth } from "@/lib/useAuth"; 12 + import { useState } from "react"; 13 + import { Button } from "./ui/button"; 14 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 15 + import { LoaderCircle } from "lucide-react"; 16 + import { useBoardsStore } from "@/lib/stores/boards"; 17 + import { BoardsPicker } from "./BoardPicker"; 18 + import { toast } from "sonner"; 19 + import { AtUri } from "@atproto/api"; 20 + import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 21 + import { FeedItem } from "./Feed"; 22 + import { useCurrentBoard } from "@/lib/stores/useCurrentBoard"; 23 + import { useBoardItemsStore } from "@/lib/stores/boardItems"; 24 + 25 + export function UnsaveButton({ 26 + post, 27 + image, 28 + }: { 29 + post: PostView; 30 + image: number; 31 + }) { 32 + const { agent } = useAuth(); 33 + const [isLoading, setLoading] = useState(false); 34 + const [isOpen, setOpen] = useState(false); 35 + const { removeBoardItem, boardItems } = useBoardItemsStore(); 36 + 37 + if (agent == null) return <div>not logged in :(</div>; 38 + return ( 39 + <Dialog open={isOpen} onOpenChange={setOpen}> 40 + <DialogTrigger asChild> 41 + <span 42 + onClick={(e) => { 43 + e.stopPropagation(); 44 + }} 45 + className="cursor-pointer" 46 + > 47 + <Button size="sm" className="cursor-pointer"> 48 + Remove 49 + </Button> 50 + </span> 51 + </DialogTrigger> 52 + 53 + <DialogContent> 54 + <DialogHeader> 55 + <DialogTitle>Remove from board?</DialogTitle> 56 + <DialogDescription> 57 + Are you sure you want to remove this from your board? 58 + </DialogDescription> 59 + </DialogHeader> 60 + <DialogFooter> 61 + <DialogClose> 62 + <Button className="cursor-pointer">Cancel</Button> 63 + </DialogClose> 64 + <Button 65 + onClick={async (e) => { 66 + e.stopPropagation(); // Optional, but safe 67 + 68 + setLoading(true); 69 + try { 70 + const postRkey = AtUri.make(post.uri).rkey; 71 + const record = boardItems 72 + .entries() 73 + .find((e) => AtUri.make(e[1].url).rkey == postRkey); 74 + if (!record) 75 + return toast( 76 + "Couldn't find post. You might be viewing stale data." 77 + ); 78 + 79 + const rkey = record[0]; 80 + console.log("using rkey", rkey, "and record", record); 81 + const result = await agent.com.atproto.repo.deleteRecord({ 82 + collection: LIST_ITEM_COLLECTION, 83 + rkey, 84 + repo: agent.assertDid, 85 + }); 86 + 87 + if (result?.success) { 88 + removeBoardItem(rkey); 89 + toast("Removed from board"); 90 + setOpen(false); 91 + } else { 92 + toast("Failed to remove"); 93 + } 94 + } finally { 95 + setLoading(false); 96 + } 97 + }} 98 + className="cursor-pointer" 99 + variant="destructive" 100 + > 101 + {isLoading && <LoaderCircle className="animate-spin ml-2" />} 102 + Remove 103 + </Button> 104 + </DialogFooter> 105 + </DialogContent> 106 + </Dialog> 107 + ); 108 + }
+18
src/components/ui/textarea.tsx
··· 1 + import * as React from "react" 2 + 3 + import { cn } from "@/lib/utils" 4 + 5 + function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { 6 + return ( 7 + <textarea 8 + data-slot="textarea" 9 + className={cn( 10 + "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 11 + className 12 + )} 13 + {...props} 14 + /> 15 + ) 16 + } 17 + 18 + export { Textarea }
+1
src/lib/hooks/useBoardItems.tsx
··· 28 28 const safeItem = BoardItem.safeParse(item.value); 29 29 if (safeItem.success) 30 30 store.setBoardItem(new AtUri(item.uri).rkey, safeItem.data); 31 + else console.warn(`${item.uri} could not be parsed safely`); 31 32 } 32 33 } finally { 33 34 setLoading(false);
+9
src/lib/stores/boardItems.tsx
··· 15 15 type BoardItemsState = { 16 16 boardItems: Map<string, BoardItem>; 17 17 setBoardItem: (rkey: string, board: BoardItem) => void; 18 + removeBoardItem: (rkey: string) => void; 18 19 isLoading: boolean; 19 20 setLoading: (value: boolean) => void; 20 21 }; ··· 27 28 set((state) => ({ 28 29 boardItems: new Map(state.boardItems).set(rkey, board), 29 30 })), 31 + removeBoardItem: (rkey) => 32 + set((state) => { 33 + const newMap = new Map(state.boardItems); 34 + newMap.delete(rkey); 35 + return { 36 + boardItems: newMap, 37 + }; 38 + }), 30 39 isLoading: true, 31 40 setLoading(value) { 32 41 set(() => ({
+12
src/lib/stores/useCurrentBoard.ts
··· 1 + // lib/stores/useCurrentBoard.ts 2 + import { create } from "zustand"; 3 + 4 + interface CurrentBoardState { 5 + currentBoard: string | null; 6 + setCurrentBoard: (board: string | null) => void; 7 + } 8 + 9 + export const useCurrentBoard = create<CurrentBoardState>((set) => ({ 10 + currentBoard: null, 11 + setCurrentBoard: (board) => set({ currentBoard: board }), 12 + }));