Scrapboard.org client

feat: boards

TurtlePaw 1549b74b 5d88b046

+675 -105
+33 -1
bun.lock
··· 10 10 "@radix-ui/react-avatar": "^1.1.10", 11 11 "@radix-ui/react-dialog": "^1.1.14", 12 12 "@radix-ui/react-dropdown-menu": "^2.1.15", 13 + "@radix-ui/react-popover": "^1.1.14", 13 14 "@radix-ui/react-scroll-area": "^1.2.9", 14 15 "@radix-ui/react-slot": "^1.2.3", 15 16 "@radix-ui/react-tabs": "^1.1.12", 16 17 "class-variance-authority": "^0.7.1", 17 18 "clsx": "^2.1.1", 19 + "cmdk": "^1.1.1", 18 20 "lucide-react": "^0.526.0", 19 21 "motion": "^12.23.11", 20 22 "next": "15.4.4", ··· 22 24 "react": "19.1.0", 23 25 "react-dom": "19.1.0", 24 26 "react-masonry-css": "^1.0.16", 27 + "sonner": "^2.0.7", 25 28 "tailwind-merge": "^3.3.1", 29 + "zod": "^4.0.14", 26 30 "zustand": "^5.0.7", 27 31 }, 28 32 "devDependencies": { ··· 328 332 329 333 "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="], 330 334 335 + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="], 336 + 331 337 "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], 332 338 333 339 "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], ··· 633 639 "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], 634 640 635 641 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 642 + 643 + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], 636 644 637 645 "code-block-writer": ["code-block-writer@10.1.1", "", {}, "sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw=="], 638 646 ··· 1268 1276 1269 1277 "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], 1270 1278 1279 + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], 1280 + 1271 1281 "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 1272 1282 1273 1283 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], ··· 1452 1462 1453 1463 "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], 1454 1464 1455 - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1465 + "zod": ["zod@4.0.14", "", {}, "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw=="], 1456 1466 1457 1467 "zustand": ["zustand@5.0.7", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg=="], 1458 1468 1469 + "@atproto-labs/did-resolver/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1470 + 1471 + "@atproto-labs/handle-resolver/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1472 + 1473 + "@atproto/api/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1474 + 1475 + "@atproto/common-web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1476 + 1477 + "@atproto/did/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1478 + 1479 + "@atproto/jwk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1480 + 1459 1481 "@atproto/jwk-jose/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 1482 + 1483 + "@atproto/jwk-webcrypto/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1484 + 1485 + "@atproto/lexicon/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1486 + 1487 + "@atproto/oauth-client/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1488 + 1489 + "@atproto/oauth-types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1490 + 1491 + "@atproto/xrpc/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1460 1492 1461 1493 "@cloudflare/next-on-pages/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], 1462 1494
+4
package.json
··· 18 18 "@radix-ui/react-avatar": "^1.1.10", 19 19 "@radix-ui/react-dialog": "^1.1.14", 20 20 "@radix-ui/react-dropdown-menu": "^2.1.15", 21 + "@radix-ui/react-popover": "^1.1.14", 21 22 "@radix-ui/react-scroll-area": "^1.2.9", 22 23 "@radix-ui/react-slot": "^1.2.3", 23 24 "@radix-ui/react-tabs": "^1.1.12", 24 25 "class-variance-authority": "^0.7.1", 25 26 "clsx": "^2.1.1", 27 + "cmdk": "^1.1.1", 26 28 "lucide-react": "^0.526.0", 27 29 "motion": "^12.23.11", 28 30 "next": "15.4.4", ··· 30 32 "react": "19.1.0", 31 33 "react-dom": "19.1.0", 32 34 "react-masonry-css": "^1.0.16", 35 + "sonner": "^2.0.7", 33 36 "tailwind-merge": "^3.3.1", 37 + "zod": "^4.0.14", 34 38 "zustand": "^5.0.7" 35 39 }, 36 40 "devDependencies": {
+20 -15
src/app/[did]/[uri]/page.tsx
··· 1 1 "use client"; 2 2 import LikeCounter from "@/components/LikeCounter"; 3 + import { SaveButton } from "@/components/SaveButton"; 3 4 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 5 import { Button } from "@/components/ui/button"; 5 6 import { useAuth } from "@/lib/useAuth"; ··· 230 231 </div> 231 232 232 233 {/* External Link */} 233 - <Link 234 - href={ 235 - "https://bsky.app/profile/" + 236 - post.author.did + 237 - "/post/" + 238 - post.uri.split("/").pop() 239 - } 240 - target="_blank" 241 - rel="noopener noreferrer" 242 - > 243 - <Button variant="outline" className="cursor-pointer"> 244 - Open in Bluesky 245 - <ExternalLink className="w-4 h-4" /> 246 - </Button> 247 - </Link> 234 + <div> 235 + <SaveButton image={Number(imageIndex)} post={post} /> 236 + <Link 237 + href={ 238 + "https://bsky.app/profile/" + 239 + post.author.did + 240 + "/post/" + 241 + post.uri.split("/").pop() 242 + } 243 + target="_blank" 244 + rel="noopener noreferrer" 245 + className="ml-2" 246 + > 247 + <Button variant="outline" className="cursor-pointer"> 248 + Open in Bluesky 249 + <ExternalLink className="w-4 h-4" /> 250 + </Button> 251 + </Link> 252 + </div> 248 253 </div> 249 254 </div> 250 255 </div>
+2
src/app/layout.tsx
··· 5 5 import { Navbar } from "@/nav/navbar"; 6 6 import { AuthProvider } from "@/lib/useAuth"; 7 7 import { ProfileProvider } from "@/lib/useProfile"; 8 + import { Toaster } from "sonner"; 8 9 9 10 const geistSans = Geist({ 10 11 variable: "--font-geist-sans", ··· 42 43 </ProfileProvider> 43 44 </AuthProvider> 44 45 </ThemeProvider> 46 + <Toaster /> 45 47 </body> 46 48 </html> 47 49 );
+20 -6
src/app/page.tsx
··· 10 10 import { useFeedDefsStore } from "@/lib/stores/feedDefs"; 11 11 import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; 12 12 import { useAuth } from "@/lib/useAuth"; 13 + import { useBoards } from "@/lib/hooks/useBoards"; 13 14 14 15 export default function Home() { 15 16 const { fetchFeed } = useFetchTimeline(); 16 17 const feedStore = useFeedStore(); 17 18 const { isLoading } = useFeeds(); 18 - const { feeds } = useFeedDefsStore(); 19 + const { feeds, defaultFeed, setDefaultFeed } = useFeedDefsStore(); 19 20 const { session } = useAuth(); 21 + useBoards(); 20 22 const sentinelRef = useRef<HTMLDivElement>(null); 21 - const [feed, setFeed] = useState<"timeline" | string>("timeline"); 23 + const [feed, setFeed] = useState<"timeline" | string>( 24 + defaultFeed ?? "timeline" 25 + ); 22 26 23 27 useEffect(() => { 24 28 const observer = new IntersectionObserver( ··· 35 39 return () => { 36 40 if (sentinel) observer.unobserve(sentinel); 37 41 }; 38 - }, [fetchFeed]); 42 + }, [fetchFeed, feed]); 43 + 44 + useEffect(() => { 45 + fetchFeed(feed); 46 + }, [feed]); 39 47 40 48 if (session == null) { 41 49 return ( ··· 57 65 58 66 return ( 59 67 <main className="px-5"> 60 - <Tabs defaultValue="timeline" className="w-full"> 68 + <Tabs defaultValue={defaultFeed} className="w-full"> 61 69 <TabsList 62 70 className="overflow-x-auto w-full justify-start" //"flex w-full overflow-x-auto whitespace-nowrap no-scrollbar pl-10 pr-4 space-x-4" 63 71 style={{ justifyItems: "unset" }} 64 72 > 65 73 <TabsTrigger 66 - onClick={() => setFeed("timeline")} 74 + onClick={() => { 75 + setFeed("timeline"); 76 + setDefaultFeed("timeline"); 77 + }} 67 78 value="timeline" 68 79 className="shrink-0" 69 80 > ··· 71 82 </TabsTrigger> 72 83 {Object.entries(feeds).map(([value, it]) => ( 73 84 <TabsTrigger 74 - onClick={() => setFeed(value)} 85 + onClick={() => { 86 + setFeed(value); 87 + setDefaultFeed(value); 88 + }} 75 89 key={value} 76 90 value={value} 77 91 className="shrink-0"
+103
src/components/BoardPicker.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react"; 5 + 6 + import { cn } from "@/lib/utils"; 7 + import { Button } from "@/components/ui/button"; 8 + import { 9 + Command, 10 + CommandEmpty, 11 + CommandGroup, 12 + CommandInput, 13 + CommandItem, 14 + CommandList, 15 + } from "@/components/ui/command"; 16 + import { 17 + Popover, 18 + PopoverContent, 19 + PopoverTrigger, 20 + } from "@/components/ui/popover"; 21 + import { Board } from "@/lib/stores/boards"; 22 + 23 + export function BoardsPicker({ 24 + boards, 25 + onCreateBoard, 26 + onSelected: setValue, 27 + selected: value, 28 + }: { 29 + selected: string; 30 + onSelected: (value: string) => unknown; 31 + boards: Map<string, Board>; 32 + onCreateBoard: (name: string) => void; // New prop 33 + }) { 34 + const [open, setOpen] = React.useState(false); 35 + const [search, setSearch] = React.useState(""); 36 + 37 + const entries = Array.from(boards.entries()).filter(([_, board]) => 38 + board.name.toLowerCase().includes(search.toLowerCase()) 39 + ); 40 + 41 + const selectedBoard = boards.get(value); 42 + 43 + return ( 44 + <Popover open={open} onOpenChange={setOpen}> 45 + <PopoverTrigger asChild> 46 + <Button 47 + variant="outline" 48 + role="combobox" 49 + aria-expanded={open} 50 + className="w-full justify-between" 51 + > 52 + {selectedBoard ? selectedBoard.name : "Select board..."} 53 + <ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 54 + </Button> 55 + </PopoverTrigger> 56 + <PopoverContent className="w-full p-0"> 57 + <Command shouldFilter={false}> 58 + <CommandInput 59 + placeholder="Search or create a board..." 60 + onValueChange={(val) => setSearch(val)} 61 + /> 62 + <CommandList> 63 + <CommandGroup> 64 + {entries.length > 0 ? ( 65 + entries.map(([key, it]) => ( 66 + <CommandItem 67 + key={key} 68 + value={it.name} 69 + onSelect={() => { 70 + setValue(key === value ? "" : key); 71 + setOpen(false); 72 + }} 73 + > 74 + <CheckIcon 75 + className={cn( 76 + "mr-2 h-4 w-4", 77 + value === key ? "opacity-100" : "opacity-0" 78 + )} 79 + /> 80 + {it.name} 81 + </CommandItem> 82 + )) 83 + ) : ( 84 + <CommandItem 85 + onSelect={() => { 86 + onCreateBoard(search.trim()); 87 + setSearch(""); 88 + setOpen(false); 89 + }} 90 + > 91 + <PlusIcon className="h-4 w-4" /> 92 + <span> 93 + Create board: <b>{search.trim()}</b> 94 + </span> 95 + </CommandItem> 96 + )} 97 + </CommandGroup> 98 + </CommandList> 99 + </Command> 100 + </PopoverContent> 101 + </Popover> 102 + ); 103 + }
+60 -54
src/components/Feed.tsx
··· 14 14 import Link from "next/link"; 15 15 import Masonry from "react-masonry-css"; 16 16 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 17 + import { SaveButton } from "./SaveButton"; 17 18 18 19 function getText(post: PostView) { 19 20 if (!AppBskyFeedPost.isRecord(post.record)) return; ··· 49 50 const t: string = getText(post) || ""; 50 51 const maxLength = 100; 51 52 return images.map((image, index) => ( 52 - <Link 53 - href={`/${post.author.did}/${post.uri 54 - .split("/") 55 - .pop()}?image=${index}`} 56 - key={image.fullsize} 57 - className="block" 58 - > 59 - <motion.div 60 - initial={{ opacity: 0, y: 5 }} 61 - animate={{ opacity: 1, y: 0 }} 62 - transition={{ duration: 0.5, ease: "easeOut" }} 63 - whileTap={{ scale: 0.99 }} 64 - className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900" 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" 65 63 > 66 - {/* Blurred background */} 67 - <Image 68 - src={image.fullsize} 69 - alt="" 70 - fill 71 - placeholder="blur" 72 - blurDataURL={image.thumb} 73 - className="object-cover filter blur-xl scale-110 opacity-30" 74 - /> 75 - 76 - {/* Centered foreground image */} 77 - <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]"> 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 */} 78 72 <Image 79 73 src={image.fullsize} 80 - alt={image.alt} 74 + alt="" 75 + fill 81 76 placeholder="blur" 82 77 blurDataURL={image.thumb} 83 - width={image?.aspectRatio?.width ?? 400} 84 - height={image?.aspectRatio?.height ?? 400} 85 - className="object-contain max-w-full max-h-full rounded-lg" 78 + className="object-cover filter blur-xl scale-110 opacity-30" 86 79 /> 87 - </div> 88 80 89 - {/* Bottom: Avatar, display name, and handle */} 90 - <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"> 91 - <div className="w-fit self-start" /> 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> 92 93 93 - <div className="flex flex-col gap-2"> 94 - <div className="flex items-center gap-2"> 95 - <Avatar> 96 - <AvatarImage src={post.author.avatar} /> 97 - <AvatarFallback> 98 - {post.author.displayName || post.author.handle} 99 - </AvatarFallback> 100 - </Avatar> 101 - <div className="flex flex-col leading-tight"> 102 - <span> 103 - {post.author.displayName || post.author.handle} 104 - </span> 105 - <span className="text-white/70 text-[0.75rem]"> 106 - @{post.author.handle} 107 - </span> 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> 108 114 </div> 109 - </div> 110 115 111 - <div className="text-sm"> 112 - {t.length > maxLength ? t.slice(0, maxLength) + "…" : t} 116 + <div className="text-sm"> 117 + {t.length > maxLength ? t.slice(0, maxLength) + "…" : t} 118 + </div> 113 119 </div> 114 120 </div> 115 - </div> 116 - </motion.div> 117 - </Link> 121 + </motion.div> 122 + </Link> 123 + </div> 118 124 )); 119 125 })} 120 126 </Masonry>
+85 -12
src/components/SaveButton.tsx
··· 12 12 import { Button } from "./ui/button"; 13 13 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 14 14 import { LoaderCircle } from "lucide-react"; 15 + import { 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"; 15 20 16 - function SaveButton(post: PostView) { 17 - const { login } = useAuth(); 18 - const [handle, setHandle] = useState(""); 21 + export function SaveButton({ post, image }: { post: PostView; image: number }) { 22 + const { agent } = useAuth(); 19 23 const [isLoading, setLoading] = useState(false); 24 + const [isOpen, setOpen] = useState(false); 25 + const [selectedBoard, setSelectedBoard] = useState(""); 26 + const boardsStore = useBoardsStore(); 27 + 28 + if (agent == null) return <div>not logged in :(</div>; 20 29 return ( 21 - <Dialog> 22 - <DialogTrigger> 23 - <Button size="sm" className="cursor-pointer"> 24 - Login 25 - </Button> 30 + <Dialog open={isOpen} onOpenChange={setOpen}> 31 + <DialogTrigger asChild> 32 + <span 33 + onClick={(e) => { 34 + e.stopPropagation(); 35 + }} 36 + className="cursor-pointer" 37 + > 38 + <Button size="sm" className="cursor-pointer"> 39 + Save 40 + </Button> 41 + </span> 26 42 </DialogTrigger> 43 + 27 44 <DialogContent> 28 45 <DialogHeader> 29 46 <DialogTitle>Save post to board</DialogTitle> 30 - <DialogDescription className="pt-5"></DialogDescription> 47 + <DialogDescription className="pt-5"> 48 + <BoardsPicker 49 + onSelected={setSelectedBoard} 50 + selected={selectedBoard} 51 + boards={boardsStore.boards} 52 + onCreateBoard={async (name) => { 53 + const record = { 54 + name: name, 55 + $type: LIST_COLLECTION, 56 + createdAt: new Date().toISOString(), 57 + description: "", 58 + }; 59 + const result = await agent?.com.atproto.repo.createRecord({ 60 + collection: LIST_COLLECTION, 61 + record, 62 + repo: agent.assertDid, 63 + }); 64 + 65 + if (result?.success) { 66 + toast("Board created"); 67 + 68 + const rkey = new AtUri(result.data.uri).rkey; 69 + boardsStore.setBoard(rkey, record); 70 + setSelectedBoard(rkey); 71 + } else { 72 + toast("Failed to create board"); 73 + } 74 + }} 75 + /> 76 + </DialogDescription> 31 77 </DialogHeader> 32 78 <DialogFooter> 33 79 <Button 34 - onClick={() => { 80 + onClick={async (e) => { 81 + e.stopPropagation(); // Optional, but safe 82 + 35 83 setLoading(true); 36 - login(handle); 84 + try { 85 + const record = { 86 + url: post.uri + `?image=${image}`, 87 + list: AtUri.make( 88 + agent?.assertDid, 89 + LIST_COLLECTION, 90 + selectedBoard 91 + ).toString(), 92 + $type: LIST_ITEM_COLLECTION, 93 + createdAt: new Date().toISOString(), 94 + }; 95 + const result = await agent.com.atproto.repo.createRecord({ 96 + collection: LIST_ITEM_COLLECTION, 97 + record, 98 + repo: agent.assertDid, 99 + }); 100 + 101 + if (result?.success) { 102 + toast("Image saved"); 103 + setOpen(false); 104 + } else { 105 + toast("Failed to save image"); 106 + } 107 + } finally { 108 + setLoading(false); 109 + } 37 110 }} 38 - disabled={!handle} 111 + disabled={selectedBoard.trim().length <= 0} 39 112 className="cursor-pointer" 40 113 > 41 114 {isLoading && <LoaderCircle className="animate-spin ml-2" />}
+184
src/components/ui/command.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { Command as CommandPrimitive } from "cmdk"; 5 + import { SearchIcon } from "lucide-react"; 6 + 7 + import { cn } from "@/lib/utils"; 8 + import { 9 + Dialog, 10 + DialogContent, 11 + DialogDescription, 12 + DialogHeader, 13 + DialogTitle, 14 + } from "@/components/ui/dialog"; 15 + 16 + function Command({ 17 + className, 18 + ...props 19 + }: React.ComponentProps<typeof CommandPrimitive>) { 20 + return ( 21 + <CommandPrimitive 22 + data-slot="command" 23 + className={cn( 24 + "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", 25 + className 26 + )} 27 + {...props} 28 + /> 29 + ); 30 + } 31 + 32 + function CommandDialog({ 33 + title = "Command Palette", 34 + description = "Search for a command to run...", 35 + children, 36 + className, 37 + showCloseButton = true, 38 + ...props 39 + }: React.ComponentProps<typeof Dialog> & { 40 + title?: string; 41 + description?: string; 42 + className?: string; 43 + showCloseButton?: boolean; 44 + }) { 45 + return ( 46 + <Dialog {...props}> 47 + <DialogHeader className="sr-only"> 48 + <DialogTitle>{title}</DialogTitle> 49 + <DialogDescription>{description}</DialogDescription> 50 + </DialogHeader> 51 + <DialogContent 52 + className={cn("overflow-hidden p-0", className)} 53 + showCloseButton={showCloseButton} 54 + > 55 + <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> 56 + {children} 57 + </Command> 58 + </DialogContent> 59 + </Dialog> 60 + ); 61 + } 62 + 63 + function CommandInput({ 64 + className, 65 + ...props 66 + }: React.ComponentProps<typeof CommandPrimitive.Input>) { 67 + return ( 68 + <div 69 + data-slot="command-input-wrapper" 70 + className="flex h-9 items-center gap-2 border-b px-3" 71 + > 72 + <SearchIcon className="size-4 shrink-0 opacity-50" /> 73 + <CommandPrimitive.Input 74 + data-slot="command-input" 75 + className={cn( 76 + "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", 77 + className 78 + )} 79 + {...props} 80 + /> 81 + </div> 82 + ); 83 + } 84 + 85 + function CommandList({ 86 + className, 87 + ...props 88 + }: React.ComponentProps<typeof CommandPrimitive.List>) { 89 + return ( 90 + <CommandPrimitive.List 91 + data-slot="command-list" 92 + className={cn( 93 + "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", 94 + className 95 + )} 96 + {...props} 97 + /> 98 + ); 99 + } 100 + 101 + function CommandEmpty({ 102 + ...props 103 + }: React.ComponentProps<typeof CommandPrimitive.Empty>) { 104 + return ( 105 + <CommandPrimitive.Empty 106 + data-slot="command-empty" 107 + className="py-6 text-center text-sm" 108 + {...props} 109 + /> 110 + ); 111 + } 112 + 113 + function CommandGroup({ 114 + className, 115 + ...props 116 + }: React.ComponentProps<typeof CommandPrimitive.Group>) { 117 + return ( 118 + <CommandPrimitive.Group 119 + data-slot="command-group" 120 + className={cn( 121 + "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", 122 + className 123 + )} 124 + {...props} 125 + /> 126 + ); 127 + } 128 + 129 + function CommandSeparator({ 130 + className, 131 + ...props 132 + }: React.ComponentProps<typeof CommandPrimitive.Separator>) { 133 + return ( 134 + <CommandPrimitive.Separator 135 + data-slot="command-separator" 136 + className={cn("bg-border -mx-1 h-px", className)} 137 + {...props} 138 + /> 139 + ); 140 + } 141 + 142 + function CommandItem({ 143 + className, 144 + ...props 145 + }: React.ComponentProps<typeof CommandPrimitive.Item>) { 146 + return ( 147 + <CommandPrimitive.Item 148 + data-slot="command-item" 149 + className={cn( 150 + "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 151 + className 152 + )} 153 + {...props} 154 + /> 155 + ); 156 + } 157 + 158 + function CommandShortcut({ 159 + className, 160 + ...props 161 + }: React.ComponentProps<"span">) { 162 + return ( 163 + <span 164 + data-slot="command-shortcut" 165 + className={cn( 166 + "text-muted-foreground ml-auto text-xs tracking-widest", 167 + className 168 + )} 169 + {...props} 170 + /> 171 + ); 172 + } 173 + 174 + export { 175 + Command, 176 + CommandDialog, 177 + CommandInput, 178 + CommandList, 179 + CommandEmpty, 180 + CommandGroup, 181 + CommandItem, 182 + CommandShortcut, 183 + CommandSeparator, 184 + };
+48
src/components/ui/popover.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import * as PopoverPrimitive from "@radix-ui/react-popover" 5 + 6 + import { cn } from "@/lib/utils" 7 + 8 + function Popover({ 9 + ...props 10 + }: React.ComponentProps<typeof PopoverPrimitive.Root>) { 11 + return <PopoverPrimitive.Root data-slot="popover" {...props} /> 12 + } 13 + 14 + function PopoverTrigger({ 15 + ...props 16 + }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { 17 + return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> 18 + } 19 + 20 + function PopoverContent({ 21 + className, 22 + align = "center", 23 + sideOffset = 4, 24 + ...props 25 + }: React.ComponentProps<typeof PopoverPrimitive.Content>) { 26 + return ( 27 + <PopoverPrimitive.Portal> 28 + <PopoverPrimitive.Content 29 + data-slot="popover-content" 30 + align={align} 31 + sideOffset={sideOffset} 32 + className={cn( 33 + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", 34 + className 35 + )} 36 + {...props} 37 + /> 38 + </PopoverPrimitive.Portal> 39 + ) 40 + } 41 + 42 + function PopoverAnchor({ 43 + ...props 44 + }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { 45 + return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> 46 + } 47 + 48 + export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+25
src/components/ui/sonner.tsx
··· 1 + "use client" 2 + 3 + import { useTheme } from "next-themes" 4 + import { Toaster as Sonner, ToasterProps } from "sonner" 5 + 6 + const Toaster = ({ ...props }: ToasterProps) => { 7 + const { theme = "system" } = useTheme() 8 + 9 + return ( 10 + <Sonner 11 + theme={theme as ToasterProps["theme"]} 12 + className="toaster group" 13 + style={ 14 + { 15 + "--normal-bg": "var(--popover)", 16 + "--normal-text": "var(--popover-foreground)", 17 + "--normal-border": "var(--border)", 18 + } as React.CSSProperties 19 + } 20 + {...props} 21 + /> 22 + ) 23 + } 24 + 25 + export { Toaster }
+2
src/constants.ts
··· 1 + export const LIST_COLLECTION = "org.scrapboard.list"; 2 + export const LIST_ITEM_COLLECTION = "org.scrapboard.listitem";
+36
src/lib/hooks/useBoards.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { useAuth } from "@/lib/useAuth"; 3 + import { useFeedDefsStore } from "../stores/feedDefs"; 4 + import { AtUri } from "@atproto/api"; 5 + import { Board, useBoardsStore } from "../stores/boards"; 6 + import { LIST_COLLECTION } from "@/constants"; 7 + 8 + export function useBoards() { 9 + const { agent } = useAuth(); 10 + const store = useBoardsStore(); 11 + const [isLoading, setLoading] = useState(store.boards.size == 0); 12 + 13 + useEffect(() => { 14 + if (agent == null) return; 15 + const loadBoards = async () => { 16 + try { 17 + const boards = await agent.com.atproto.repo.listRecords({ 18 + collection: LIST_COLLECTION, 19 + repo: agent.assertDid, 20 + limit: 100, 21 + }); 22 + 23 + for (const board of boards.data.records) { 24 + const safeBoard = Board.safeParse(board.value); 25 + if (safeBoard.success) 26 + store.setBoard(new AtUri(board.uri).rkey, safeBoard.data); 27 + } 28 + } finally { 29 + setLoading(false); 30 + } 31 + }; 32 + loadBoards(); 33 + }, [agent]); 34 + 35 + return { isLoading }; 36 + }
+11 -16
src/lib/hooks/useTimeline.tsx
··· 5 5 import { useFeedStore } from "../stores/feeds"; 6 6 import { FeedViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 7 7 8 - function filterPosts(posts: FeedViewPost[], seenImageUrls: Set<string>) { 8 + function filterPosts(posts: FeedViewPost[], seenPosts: Set<string>) { 9 9 return posts.filter((it) => { 10 - if (it.reason?.$type === "app.bsky.feed.defs#reasonRepost") return false; 11 10 if ( 12 11 !( 13 12 AppBskyEmbedImages.isMain(it.post.embed) || ··· 16 15 ) 17 16 return false; 18 17 19 - const images = (it.post.embed as AppBskyEmbedImages.View)?.images || []; 20 - const hasNew = images.some((img) => !seenImageUrls.has(img.fullsize)); 21 - if (!hasNew) return false; 18 + if (seenPosts.has(it.post.uri)) return false; 19 + seenPosts.add(it.post.uri); 22 20 23 - images.forEach((img) => seenImageUrls.add(img.fullsize)); 24 21 return true; 25 22 }); 26 23 } ··· 29 26 const { agent } = useAuth(); 30 27 const { 31 28 timeline, 32 - setTimeline, 33 29 appendTimeline, 34 30 setTimelineLoading, 35 - setCustomFeed, 36 31 setCustomFeedLoading, 37 32 customFeeds, 33 + appendCustomFeed, 38 34 } = useFeedStore(); 39 35 const seenImageUrls = useRef<Set<string>>(new Set()); 40 36 ··· 61 57 }); 62 58 63 59 if (!response.success) throw new Error("Failed to fetch timeline"); 64 - setCustomFeed( 65 - feed, 66 - filterPosts(response.data.feed, seenImageUrls.current).map( 67 - (it) => it.post 68 - ), 69 - response.data.cursor 70 - ); 60 + const filtered = filterPosts( 61 + response.data.feed, 62 + seenImageUrls.current 63 + ).map((it) => it.post); 64 + console.log("feed", filtered); 65 + appendCustomFeed(feed, filtered, response.data.cursor); 71 66 } else { 72 67 const response = await agent.getTimeline({ 73 68 cursor: timeline.cursor, ··· 92 87 } else setTimelineLoading(false); 93 88 } 94 89 }, 95 - [agent, timeline] 90 + [agent, timeline, customFeeds] 96 91 ); 97 92 98 93 useEffect(() => {
+33
src/lib/stores/boards.tsx
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + import * as z from "zod"; 4 + 5 + export const Board = z.object({ 6 + name: z.string(), 7 + description: z.string(), 8 + }); 9 + 10 + export type Board = z.infer<typeof Board>; 11 + 12 + type FeedDefsState = { 13 + boards: Map<string, Board>; 14 + setBoard: (rkey: string, board: Board) => void; 15 + }; 16 + 17 + export const useBoardsStore = create<FeedDefsState>()( 18 + persist( 19 + (set) => ({ 20 + boards: new Map(), 21 + setBoard: (rkey, board) => 22 + set((state) => ({ 23 + boards: state.boards.set(rkey, board), 24 + })), 25 + }), 26 + { 27 + name: "boards", 28 + partialize: (state) => ({ 29 + feeds: state.boards, 30 + }), 31 + } 32 + ) 33 + );
+9 -1
src/lib/stores/feedDefs.tsx
··· 8 8 type FeedDefsState = { 9 9 feeds: Record<string, BasicFeedItem>; 10 10 setFeedDef: (id: string, feed: BasicFeedItem) => void; 11 + defaultFeed: string; 12 + setDefaultFeed: (feed: string) => void; 11 13 }; 12 14 13 15 export const useFeedDefsStore = create<FeedDefsState>()( ··· 21 23 [id]: feed, 22 24 }, 23 25 })), 26 + defaultFeed: "timeline", 27 + setDefaultFeed: (feed) => 28 + set(() => ({ 29 + defaultFeed: feed, 30 + })), 24 31 }), 25 32 { 26 33 name: "feed-defs", 27 34 partialize: (state) => ({ 28 - feeds: state.feeds, // Only persist this part 35 + feeds: state.feeds, 36 + defaultFeed: state.defaultFeed, 29 37 }), 30 38 } 31 39 )