Scrapboard.org client

feat: board items store

TurtlePaw b9bababf e8552c55

+392 -3
+1 -1
src/app/[did]/[uri]/page.tsx
··· 22 22 import { useParams, useSearchParams } from "next/navigation"; 23 23 import { use, useEffect, useMemo, useRef, useState } from "react"; 24 24 25 - function paramAsString(str: string | string[]): string { 25 + export function paramAsString(str: string | string[]): string { 26 26 if (Array.isArray(str)) { 27 27 return str[0]; 28 28 }
+118
src/app/board/[did]/[rkey]/page.tsx
··· 1 + "use client"; 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"; 8 + import { useBoardItemsStore } from "@/lib/stores/boardItems"; 9 + import { Board, useBoardsStore } from "@/lib/stores/boards"; 10 + import { useAuth } from "@/lib/useAuth"; 11 + import { $Typed, AtUri } from "@atproto/api"; 12 + import { AppBskyEmbedImages, AppBskyFeedPost } from "@atproto/api/dist/client"; 13 + 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"; 29 + 30 + export const runtime = "edge"; 31 + 32 + export default function PostPage({ 33 + params, 34 + }: { 35 + params: Promise<{ slug: string }>; 36 + }) { 37 + const { did, rkey } = useParams(); 38 + 39 + const { boards, isLoading: isBoardsLoading } = useBoardsStore(); 40 + const { boardItems: items, isLoading: isItemsLoading } = useBoardItemsStore(); 41 + 42 + if (!rkey || !did) 43 + return ( 44 + <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> 48 + </div> 49 + ); 50 + if (isItemsLoading || isBoardsLoading) 51 + return ( 52 + <div className="min-h-screen flex items-center justify-center px-4"> 53 + <LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" /> 54 + </div> 55 + ); 56 + 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) 63 + return ( 64 + <div className="min-h-screen flex items-center justify-center px-4"> 65 + <p className="text-black/70 dark:text-white/70">No items found</p> 66 + </div> 67 + ); 68 + if (!board) 69 + return ( 70 + <div className="min-h-screen flex items-center justify-center px-4"> 71 + <p className="text-black/70 dark:text-white/70">No board found</p> 72 + </div> 73 + ); 74 + 75 + 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> 84 + </div> 85 + </div> 86 + </div> 87 + ); 88 + } 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 + }
+96
src/app/boards/page.tsx
··· 1 + "use client"; 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 } from "@/constants"; 8 + import { Board, useBoardsStore } from "@/lib/stores/boards"; 9 + import { useAuth } from "@/lib/useAuth"; 10 + import { $Typed, AtUri } from "@atproto/api"; 11 + import { AppBskyEmbedImages, AppBskyFeedPost } from "@atproto/api/dist/client"; 12 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 13 + import clsx from "clsx"; 14 + import { 15 + ExternalLink, 16 + Heart, 17 + LoaderCircle, 18 + MessagesSquare, 19 + Repeat, 20 + Repeat2, 21 + } from "lucide-react"; 22 + import { AnimatePresence, motion } from "motion/react"; 23 + import Image, { ImageProps } from "next/image"; 24 + import Link from "next/link"; 25 + import { useParams, useSearchParams } from "next/navigation"; 26 + import { use, useEffect, useMemo, useRef, useState } from "react"; 27 + import z from "zod"; 28 + 29 + export const runtime = "edge"; 30 + 31 + export default function BoardsPage() { 32 + const { boards, isLoading } = useBoardsStore(); 33 + const { agent } = useAuth(); 34 + 35 + if (isLoading) 36 + return ( 37 + <div className="min-h-screen flex items-center justify-center px-4"> 38 + <LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" /> 39 + </div> 40 + ); 41 + if (boards.size <= 0) 42 + return ( 43 + <div className="min-h-screen flex items-center justify-center px-4"> 44 + <p className="text-black/70 dark:text-white/70">No boards found</p> 45 + </div> 46 + ); 47 + 48 + return ( 49 + <div className="py-4 px-4 sm:py-8 sm:px-6 lg:px-8 flex items-center justify-center"> 50 + {/* Container that adapts to image width */} 51 + <div className="w-full max-w-4xl flex justify-center"> 52 + <div className="inline-block"> 53 + {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} 60 + </Link> 61 + ))} 62 + </div> 63 + </div> 64 + </div> 65 + ); 66 + } 67 + 68 + type BskyImageProps = { 69 + embed: 70 + | $Typed<AppBskyEmbedImages.View> 71 + | { 72 + $type: string; 73 + } 74 + | undefined; 75 + imageIndex?: number; 76 + className?: string; 77 + width?: number; 78 + height?: number; 79 + } & Omit<ImageProps, "src" | "alt">; 80 + 81 + function BskyImage({ embed, imageIndex = 0, ...props }: BskyImageProps) { 82 + if (!AppBskyEmbedImages.isView(embed)) return null; 83 + 84 + const image = embed.images?.[imageIndex]; 85 + if (!image) return null; 86 + 87 + return ( 88 + <Image 89 + src={image.fullsize} 90 + alt={image.alt || "Post Image"} 91 + placeholder="blur" 92 + blurDataURL={image.thumb} 93 + {...props} 94 + /> 95 + ); 96 + }
+41
src/lib/hooks/useBoardItems.tsx
··· 1 + "use client"; 2 + import { PropsWithChildren, useEffect, useState } from "react"; 3 + import { useAuth } from "@/lib/useAuth"; 4 + import { useFeedDefsStore } from "../stores/feedDefs"; 5 + import { AtUri } from "@atproto/api"; 6 + import { Board, useBoardsStore } from "../stores/boards"; 7 + import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 8 + import { BoardItem, useBoardItemsStore } from "../stores/boardItems"; 9 + import { getAllRecords } from "../records"; 10 + 11 + export function useBoardItems() { 12 + const { agent } = useAuth(); 13 + const store = useBoardItemsStore(); 14 + const [isLoading, setLoading] = useState(store.boardItems.size == 0); 15 + 16 + useEffect(() => { 17 + if (agent == null) return; 18 + const loadItems = async () => { 19 + try { 20 + const boards = await getAllRecords({ 21 + collection: LIST_ITEM_COLLECTION, 22 + repo: agent.assertDid, 23 + limit: 100, 24 + agent, 25 + }); 26 + 27 + for (const item of boards) { 28 + const safeItem = BoardItem.safeParse(item.value); 29 + if (safeItem.success) 30 + store.setBoardItem(new AtUri(item.uri).rkey, safeItem.data); 31 + } 32 + } finally { 33 + setLoading(false); 34 + store.setLoading(false); 35 + } 36 + }; 37 + loadItems(); 38 + }, [agent]); 39 + 40 + return { isLoading }; 41 + }
+3
src/lib/hooks/useBoards.tsx
··· 5 5 import { AtUri } from "@atproto/api"; 6 6 import { Board, useBoardsStore } from "../stores/boards"; 7 7 import { LIST_COLLECTION } from "@/constants"; 8 + import { useBoardItems } from "./useBoardItems"; 8 9 9 10 export function useBoards() { 10 11 const { agent } = useAuth(); ··· 28 29 } 29 30 } finally { 30 31 setLoading(false); 32 + store.setLoading(false); 31 33 } 32 34 }; 33 35 loadBoards(); ··· 38 40 39 41 export function BoardsProvider({ children }: PropsWithChildren) { 40 42 useBoards(); 43 + useBoardItems(); 41 44 return children; 42 45 }
+34
src/lib/records.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { Record } from "@atproto/api/dist/client/types/com/atproto/repo/listRecords"; 3 + 4 + /** 5 + * Fetches all records for a given repo & collection, handling pagination via cursors. 6 + */ 7 + export async function getAllRecords({ 8 + repo, 9 + collection, 10 + limit = 100, 11 + agent, 12 + }: { 13 + repo: string; 14 + collection: string; 15 + limit?: number; 16 + agent: Agent; 17 + }) { 18 + let records: Record[] = []; 19 + let cursor: string | undefined = undefined; 20 + 21 + do { 22 + const res = await agent.com.atproto.repo.listRecords({ 23 + repo, 24 + collection, 25 + limit, 26 + cursor, 27 + }); 28 + 29 + records = records.concat(res.data.records); 30 + cursor = res.data.cursor; 31 + } while (cursor); 32 + 33 + return records; 34 + }
+45
src/lib/stores/boardItems.tsx
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + import * as z from "zod"; 4 + import { createMapStorage } from "../utils/mapStorage"; 5 + 6 + export const BoardItem = z.object({ 7 + url: z.string(), 8 + list: z.string(), 9 + $type: z.string(), 10 + createdAt: z.string(), 11 + }); 12 + 13 + export type BoardItem = z.infer<typeof BoardItem>; 14 + 15 + type BoardItemsState = { 16 + boardItems: Map<string, BoardItem>; 17 + setBoardItem: (rkey: string, board: BoardItem) => void; 18 + isLoading: boolean; 19 + setLoading: (value: boolean) => void; 20 + }; 21 + 22 + export const useBoardItemsStore = create<BoardItemsState>()( 23 + persist( 24 + (set) => ({ 25 + boardItems: new Map(), 26 + setBoardItem: (rkey, board) => 27 + set((state) => ({ 28 + boardItems: new Map(state.boardItems).set(rkey, board), 29 + })), 30 + isLoading: true, 31 + setLoading(value) { 32 + set(() => ({ 33 + isLoading: value, 34 + })); 35 + }, 36 + }), 37 + { 38 + name: "board-items", 39 + partialize: (state) => ({ 40 + items: state.boardItems, 41 + }), 42 + storage: createMapStorage("boardItems"), 43 + } 44 + ) 45 + );
+12 -2
src/lib/stores/boards.tsx
··· 1 1 import { create } from "zustand"; 2 2 import { persist } from "zustand/middleware"; 3 3 import * as z from "zod"; 4 + import { createMapStorage } from "../utils/mapStorage"; 4 5 5 6 export const Board = z.object({ 6 7 name: z.string(), ··· 12 13 type FeedDefsState = { 13 14 boards: Map<string, Board>; 14 15 setBoard: (rkey: string, board: Board) => void; 16 + isLoading: boolean; 17 + setLoading: (value: boolean) => void; 15 18 }; 16 19 17 20 export const useBoardsStore = create<FeedDefsState>()( ··· 20 23 boards: new Map(), 21 24 setBoard: (rkey, board) => 22 25 set((state) => ({ 23 - boards: state.boards.set(rkey, board), 26 + boards: new Map(state.boards).set(rkey, board), 24 27 })), 28 + isLoading: true, 29 + setLoading(value) { 30 + set(() => ({ 31 + isLoading: value, 32 + })); 33 + }, 25 34 }), 26 35 { 27 36 name: "boards", 28 37 partialize: (state) => ({ 29 - feeds: state.boards, 38 + boards: state.boards, 30 39 }), 40 + storage: createMapStorage("boards"), 31 41 } 32 42 ) 33 43 );
+37
src/lib/utils/mapStorage.ts
··· 1 + /* eslint-disable @typescript-eslint/no-explicit-any */ 2 + import type { PersistStorage, StorageValue } from "zustand/middleware"; 3 + 4 + export function createMapStorage<T>( 5 + key: string 6 + ): PersistStorage<any> | undefined { 7 + return { 8 + getItem: (name) => { 9 + const str = localStorage.getItem(name); 10 + if (!str) return null; 11 + const existingValue = JSON.parse(str); 12 + return { 13 + ...existingValue, 14 + state: { 15 + ...existingValue.state, 16 + [key]: new Map(existingValue.state[key]), 17 + }, 18 + }; 19 + }, 20 + setItem: (name, newValue: StorageValue<any>) => { 21 + const mapValue = newValue.state?.[key]; 22 + const serializedMap = 23 + mapValue instanceof Map ? Array.from(mapValue.entries()) : []; 24 + 25 + const str = JSON.stringify({ 26 + ...newValue, 27 + state: { 28 + ...newValue.state, 29 + [key]: serializedMap, 30 + }, 31 + }); 32 + localStorage.setItem(name, str); 33 + }, 34 + 35 + removeItem: (name) => localStorage.removeItem(name), 36 + }; 37 + }
+5
src/nav/navbar.tsx
··· 76 76 <DropdownMenuLabel>My Account</DropdownMenuLabel> 77 77 <DropdownMenuSeparator /> 78 78 {/* <DropdownMenuItem>Profile</DropdownMenuItem> */} 79 + <Link href={"/boards"}> 80 + <DropdownMenuItem className="cursor-pointer"> 81 + My Boards 82 + </DropdownMenuItem> 83 + </Link> 79 84 <DropdownMenuItem className="cursor-pointer" onClick={logout}> 80 85 Logout 81 86 </DropdownMenuItem>