refactor code

TurtlePaw 2559e853 c226fd6e

+8
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-scroll-area": "^1.2.9", 13 14 "@radix-ui/react-slot": "^1.2.3", 14 15 "@radix-ui/react-tabs": "^1.1.12", 15 16 "class-variance-authority": "^0.7.1", ··· 22 23 "react-dom": "19.1.0", 23 24 "react-masonry-css": "^1.0.16", 24 25 "tailwind-merge": "^3.3.1", 26 + "zustand": "^5.0.7", 25 27 }, 26 28 "devDependencies": { 27 29 "@eslint/eslintrc": "^3", ··· 296 298 297 299 "@poppinss/exception": ["@poppinss/exception@1.2.2", "", {}, "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg=="], 298 300 301 + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], 302 + 299 303 "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], 300 304 301 305 "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], ··· 333 337 "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], 334 338 335 339 "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "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-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], 340 + 341 + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@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-presence": "1.1.4", "@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" }, "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-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="], 336 342 337 343 "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 338 344 ··· 1447 1453 "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], 1448 1454 1449 1455 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1456 + 1457 + "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=="], 1450 1458 1451 1459 "@atproto/jwk-jose/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 1452 1460
+4 -4
next.config.ts
··· 1 1 import type { NextConfig } from "next"; 2 - import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev'; 2 + import { setupDevPlatform } from "@cloudflare/next-on-pages/next-dev"; 3 3 4 4 const nextConfig: NextConfig = { 5 5 /* config options here */ ··· 8 8 }, 9 9 }; 10 10 11 - if (process.env.NODE_ENV === 'development') { 12 - await setupDevPlatform(); 13 - } 11 + // if (process.env.NODE_ENV === "development") { 12 + // await setupDevPlatform(); 13 + // } 14 14 15 15 export default nextConfig;
+3 -1
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-scroll-area": "^1.2.9", 21 22 "@radix-ui/react-slot": "^1.2.3", 22 23 "@radix-ui/react-tabs": "^1.1.12", 23 24 "class-variance-authority": "^0.7.1", ··· 29 30 "react": "19.1.0", 30 31 "react-dom": "19.1.0", 31 32 "react-masonry-css": "^1.0.16", 32 - "tailwind-merge": "^3.3.1" 33 + "tailwind-merge": "^3.3.1", 34 + "zustand": "^5.0.7" 33 35 }, 34 36 "devDependencies": { 35 37 "@eslint/eslintrc": "^3",
+1 -1
src/app/[did]/[uri]/page.tsx
··· 114 114 <BskyImage 115 115 embed={post.embed} 116 116 fill 117 - className="absolute inset-0 object-cover blur-3xl scale-110 z-0 opacity-10" 117 + className="absolute inset-0 object-cover blur-3xl scale-110 z-0 opacity-30" 118 118 /> 119 119 120 120 {/* Foreground Content */}
+53 -200
src/app/page.tsx
··· 1 1 "use client"; 2 - import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 - import { useAuth } from "@/lib/useAuth"; 4 - import { 5 - AppBskyEmbedImages, 6 - AppBskyFeedDefs, 7 - AppBskyFeedPost, 8 - } from "@atproto/api"; 9 - import { LoaderCircle } from "lucide-react"; 10 - import Image from "next/image"; 11 - import { useEffect, useRef, useState, useCallback } from "react"; 12 - import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 13 - import Link from "next/link"; 14 - import Masonry from "react-masonry-css"; 15 - import { motion } from "motion/react"; 16 2 17 - export const runtime = "edge"; 3 + import { Feed } from "@/components/Feed"; 4 + import { useFetchTimeline } from "@/lib/hooks/useTimeline"; 5 + import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; 6 + import { useRef, useEffect } from "react"; 7 + import { useFeedStore } from "@/lib/stores/feeds"; 8 + import { useFeeds } from "@/lib/hooks/useFeeds"; 9 + import { LoaderCircle } from "lucide-react"; 10 + import { useFeedDefsStore } from "@/lib/stores/feedDefs"; 11 + import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; 12 + import { useAuth } from "@/lib/useAuth"; 18 13 19 14 export default function Home() { 20 - const [timeline, setTimeline] = useState<AppBskyFeedDefs.FeedViewPost[]>([]); 21 - const [cursor, setCursor] = useState<string | null>(null); 22 - const [loading, setLoading] = useState(false); 23 - const { agent, session } = useAuth(); 15 + const { fetchFeed } = useFetchTimeline(); 16 + const feedStore = useFeedStore(); 17 + const { isLoading } = useFeeds(); 18 + const { feeds } = useFeedDefsStore(); 19 + const { session } = useAuth(); 24 20 const sentinelRef = useRef<HTMLDivElement>(null); 25 21 26 - const seenImageUrls = new Set<string>(); 27 - 28 - const fetchFeed = useCallback(async () => { 29 - if (!agent || loading) return []; 30 - setLoading(true); 31 - try { 32 - const response = await agent.getTimeline({ 33 - cursor: cursor ?? undefined, 34 - limit: 100, 35 - }); 36 - if (!response.success) throw new Error("Failed to fetch timeline"); 37 - 38 - const nextCursor = response.data.cursor || null; 39 - setCursor(nextCursor); 40 - 41 - const filtered = response.data.feed.filter((it) => { 42 - // Filter out reposts 43 - if (it.reason?.$type === "app.bsky.feed.defs#reasonRepost") 44 - return false; 45 - 46 - // Must be an image embed 47 - if ( 48 - !( 49 - AppBskyEmbedImages.isMain(it.post.embed) || 50 - AppBskyEmbedImages.isView(it.post.embed) 51 - ) 52 - ) { 53 - return false; 54 - } 55 - 56 - // Check for new image URLs (to avoid repeats) 57 - const images = (it.post.embed as AppBskyEmbedImages.View)?.images || []; 58 - const hasNewImage = images.some( 59 - (img) => !seenImageUrls.has(img.fullsize) 60 - ); 61 - if (!hasNewImage) return false; 62 - 63 - // Add seen image URLs 64 - images.forEach((img) => seenImageUrls.add(img.fullsize)); 65 - return true; 66 - }); 67 - 68 - return filtered; 69 - } catch (err) { 70 - console.error("Error fetching timeline", err); 71 - return []; 72 - } finally { 73 - setLoading(false); 74 - } 75 - }, [agent, cursor, loading]); 76 - 77 - // Initial + fill up to 15 images 78 - useEffect(() => { 79 - if (!agent) return; 80 - 81 - const loadMinimumPosts = async () => { 82 - let accumulated: AppBskyFeedDefs.FeedViewPost[] = []; 83 - let newCursor = cursor; 84 - 85 - while ( 86 - accumulated.flatMap( 87 - (p) => (p.post.embed as AppBskyEmbedImages.View)?.images || [] 88 - ).length < 15 89 - ) { 90 - const batch = await fetchFeed(); 91 - if (batch.length === 0) break; 92 - accumulated = [...accumulated, ...batch]; 93 - newCursor = cursor; 94 - } 95 - 96 - setTimeline(accumulated); 97 - }; 98 - 99 - loadMinimumPosts(); 100 - }, [agent]); 101 - 102 - // Load more on scroll to sentinel 103 22 useEffect(() => { 104 23 const observer = new IntersectionObserver( 105 - async (entries) => { 106 - const entry = entries[0]; 107 - if (entry.isIntersecting && !loading && cursor) { 108 - const more = await fetchFeed(); 109 - setTimeline((prev) => [...prev, ...more]); 24 + (entries) => { 25 + if (entries[0].isIntersecting) { 26 + fetchFeed(); 110 27 } 111 28 }, 112 - { 113 - rootMargin: "200px", 114 - } 29 + { rootMargin: "200px" } 115 30 ); 116 31 117 32 const sentinel = sentinelRef.current; ··· 119 34 return () => { 120 35 if (sentinel) observer.unobserve(sentinel); 121 36 }; 122 - }, [fetchFeed, cursor, loading]); 123 - 124 - const breakpointColumnsObj = { 125 - default: 5, 126 - 1536: 4, 127 - 1280: 3, 128 - 1024: 2, 129 - 768: 1, 130 - }; 131 - 132 - console.log(session); 37 + }, [fetchFeed]); 133 38 134 39 if (session == null) { 135 40 return ( ··· 142 47 ); 143 48 } 144 49 50 + if (isLoading) 51 + return ( 52 + <div className="flex justify-center py-6 text-sm text-black/70 dark:text-white/70"> 53 + <LoaderCircle className="animate-spin" /> 54 + </div> 55 + ); 56 + 145 57 return ( 146 - <div className="items-center justify-items-center"> 147 - <div className="h-5" /> 148 - <main className="px-5"> 149 - <Masonry 150 - breakpointCols={breakpointColumnsObj} 151 - className="flex -mx-2 w-auto" 152 - columnClassName="px-2 space-y-4" 58 + <main className="px-5"> 59 + <Tabs defaultValue="timeline" className="w-full"> 60 + <TabsList 61 + className="flex w-full overflow-x-auto whitespace-nowrap no-scrollbar px-4 space-x-4" 62 + style={{ justifyItems: "unset" }} 153 63 > 154 - {timeline.flatMap((post) => { 155 - if (!AppBskyEmbedImages.isView(post.post.embed)) return; 156 - const images = post.post.embed.images || []; 157 - if (images.length === 0) return []; 158 - const t: string = (post.post.record.text as string) || ""; 159 - const maxLength = 100; 160 - return images.map((image, index) => ( 161 - <Link 162 - href={`/${post.post.author.did}/${post.post.uri 163 - .split("/") 164 - .pop()}?image=${index}`} 165 - key={image.fullsize} 166 - className="block" 167 - > 168 - <motion.div 169 - initial={{ opacity: 0, y: 5 }} 170 - animate={{ opacity: 1, y: 0 }} 171 - transition={{ duration: 0.5, ease: "easeOut" }} 172 - whileTap={{ scale: 0.99 }} 173 - className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900" 174 - > 175 - {/* Blurred background */} 176 - <Image 177 - src={image.fullsize} 178 - alt="" 179 - fill 180 - placeholder="blur" 181 - blurDataURL={image.thumb} 182 - className="object-cover filter blur-xl scale-110 opacity-30" 183 - /> 64 + <TabsTrigger value="timeline" className="shrink-0 ml-10"> 65 + Timeline 66 + </TabsTrigger> 67 + {Object.entries(feeds).map(([value, it]) => ( 68 + <TabsTrigger key={value} value={value} className="shrink-0"> 69 + {it?.displayName} 70 + </TabsTrigger> 71 + ))} 72 + </TabsList> 184 73 185 - {/* Centered foreground image */} 186 - <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]"> 187 - <Image 188 - src={image.fullsize} 189 - alt={image.alt} 190 - placeholder="blur" 191 - blurDataURL={image.thumb} 192 - width={image?.aspectRatio?.width ?? 400} 193 - height={image?.aspectRatio?.height ?? 400} 194 - className="object-contain max-w-full max-h-full rounded-lg" 195 - /> 196 - </div> 74 + <TabsContent value="timeline"> 75 + <Feed 76 + feed={feedStore.timeline.posts.map((it) => it.post)} 77 + isLoading={feedStore.timeline.isLoading} 78 + /> 79 + </TabsContent> 197 80 198 - {/* Hover overlay */} 199 - <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-end p-3"> 200 - <div className="text-sm mb-1"> 201 - {AppBskyFeedPost.isRecord(post.post.record) && ( 202 - <> 203 - {t.length > maxLength 204 - ? t.slice(0, maxLength) + "…" 205 - : t} 206 - </> 207 - )} 208 - </div> 209 - <div className="text-xs flex gap-2"> 210 - <Avatar> 211 - <AvatarImage src={post.post.author.avatar} /> 212 - <AvatarFallback> 213 - {post.post.author.displayName || 214 - post.post.author.handle} 215 - </AvatarFallback> 216 - </Avatar> 217 - {post.post.author.displayName || post.post.author.handle} 218 - </div> 219 - </div> 220 - </motion.div> 221 - </Link> 222 - )); 223 - })} 224 - </Masonry> 225 - <div ref={sentinelRef} className="h-1 col-span-full" /> 226 - {loading && ( 227 - <div className="col-span-full flex justify-center text-sm text-black/70 dark:text-white/70"> 228 - <LoaderCircle className="animate-spin" /> 229 - </div> 230 - )} 231 - </main> 81 + {Object.entries(feeds).map(([value]) => ( 82 + <TabsContent key={value} value={value}></TabsContent> 83 + ))} 84 + </Tabs> 232 85 233 - {/* <footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center"></footer> */} 234 - </div> 86 + <div ref={sentinelRef} className="h-1" /> 87 + </main> 235 88 ); 236 89 }
+129
src/components/Feed.tsx
··· 1 + "use client"; 2 + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 + import { useAuth } from "@/lib/useAuth"; 4 + import { 5 + AppBskyEmbedImages, 6 + AppBskyFeedDefs, 7 + AppBskyFeedPost, 8 + } from "@atproto/api"; 9 + import { LoaderCircle } from "lucide-react"; 10 + import { useEffect, useRef, useState, useCallback } from "react"; 11 + import { motion } from "motion/react"; 12 + import { Button } from "@/components/ui/button"; 13 + import Image from "next/image"; 14 + import Link from "next/link"; 15 + import Masonry from "react-masonry-css"; 16 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 17 + 18 + function getText(post: PostView) { 19 + if (!AppBskyFeedPost.isRecord(post.record)) return; 20 + return (post.record as AppBskyFeedPost.Record).text; 21 + } 22 + 23 + export function Feed({ 24 + feed, 25 + isLoading = false, 26 + }: { 27 + feed: PostView[]; 28 + isLoading?: boolean; 29 + }) { 30 + const breakpointColumnsObj = { 31 + default: 5, 32 + 1536: 4, 33 + 1280: 3, 34 + 1024: 2, 35 + 768: 1, 36 + }; 37 + 38 + return ( 39 + <> 40 + <Masonry 41 + breakpointCols={breakpointColumnsObj} 42 + className="flex -mx-2 w-auto" 43 + columnClassName="px-2 space-y-4" 44 + > 45 + {feed.flatMap((post) => { 46 + if (!AppBskyEmbedImages.isView(post.embed)) return []; 47 + const images = post.embed.images || []; 48 + if (images.length === 0) return []; 49 + const t: string = getText(post) || ""; 50 + const maxLength = 100; 51 + 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" 65 + > 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]"> 78 + <Image 79 + src={image.fullsize} 80 + alt={image.alt} 81 + placeholder="blur" 82 + 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" 86 + /> 87 + </div> 88 + 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" /> 92 + 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> 108 + </div> 109 + </div> 110 + 111 + <div className="text-sm"> 112 + {t.length > maxLength ? t.slice(0, maxLength) + "…" : t} 113 + </div> 114 + </div> 115 + </div> 116 + </motion.div> 117 + </Link> 118 + )); 119 + })} 120 + </Masonry> 121 + 122 + {isLoading && ( 123 + <div className="flex justify-center py-6 text-sm text-black/70 dark:text-white/70"> 124 + <LoaderCircle className="animate-spin" /> 125 + </div> 126 + )} 127 + </> 128 + ); 129 + }
+48
src/components/SaveButton.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 { LoaderCircle } from "lucide-react"; 15 + 16 + function SaveButton(post: PostView) { 17 + const { login } = useAuth(); 18 + const [handle, setHandle] = useState(""); 19 + const [isLoading, setLoading] = useState(false); 20 + return ( 21 + <Dialog> 22 + <DialogTrigger> 23 + <Button size="sm" className="cursor-pointer"> 24 + Login 25 + </Button> 26 + </DialogTrigger> 27 + <DialogContent> 28 + <DialogHeader> 29 + <DialogTitle>Save post to board</DialogTitle> 30 + <DialogDescription className="pt-5"></DialogDescription> 31 + </DialogHeader> 32 + <DialogFooter> 33 + <Button 34 + onClick={() => { 35 + setLoading(true); 36 + login(handle); 37 + }} 38 + disabled={!handle} 39 + className="cursor-pointer" 40 + > 41 + {isLoading && <LoaderCircle className="animate-spin ml-2" />} 42 + Save 43 + </Button> 44 + </DialogFooter> 45 + </DialogContent> 46 + </Dialog> 47 + ); 48 + }
+58
src/components/ui/scroll-area.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 + 6 + import { cn } from "@/lib/utils" 7 + 8 + function ScrollArea({ 9 + className, 10 + children, 11 + ...props 12 + }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) { 13 + return ( 14 + <ScrollAreaPrimitive.Root 15 + data-slot="scroll-area" 16 + className={cn("relative", className)} 17 + {...props} 18 + > 19 + <ScrollAreaPrimitive.Viewport 20 + data-slot="scroll-area-viewport" 21 + className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1" 22 + > 23 + {children} 24 + </ScrollAreaPrimitive.Viewport> 25 + <ScrollBar /> 26 + <ScrollAreaPrimitive.Corner /> 27 + </ScrollAreaPrimitive.Root> 28 + ) 29 + } 30 + 31 + function ScrollBar({ 32 + className, 33 + orientation = "vertical", 34 + ...props 35 + }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) { 36 + return ( 37 + <ScrollAreaPrimitive.ScrollAreaScrollbar 38 + data-slot="scroll-area-scrollbar" 39 + orientation={orientation} 40 + className={cn( 41 + "flex touch-none p-px transition-colors select-none", 42 + orientation === "vertical" && 43 + "h-full w-2.5 border-l border-l-transparent", 44 + orientation === "horizontal" && 45 + "h-2.5 flex-col border-t border-t-transparent", 46 + className 47 + )} 48 + {...props} 49 + > 50 + <ScrollAreaPrimitive.ScrollAreaThumb 51 + data-slot="scroll-area-thumb" 52 + className="bg-border relative flex-1 rounded-full" 53 + /> 54 + </ScrollAreaPrimitive.ScrollAreaScrollbar> 55 + ) 56 + } 57 + 58 + export { ScrollArea, ScrollBar }
+53
src/lib/hooks/useFeeds.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 + 6 + export function useFeeds() { 7 + const { agent } = useAuth(); 8 + const store = useFeedDefsStore(); 9 + const [isLoading, setLoading] = useState(store.feeds == null); 10 + 11 + useEffect(() => { 12 + if (agent == null) return; 13 + const loadFeeds = async () => { 14 + try { 15 + const prefs = await agent.getPreferences(); 16 + if (prefs?.savedFeeds == null) return; 17 + 18 + for (const feed of prefs.savedFeeds) { 19 + if (!feed.value.startsWith("at")) continue; 20 + const urip = AtUri.make(feed.value); 21 + 22 + console.log("host", urip.host); 23 + if (!urip.host.startsWith("did:")) { 24 + const res = await agent.resolveHandle({ handle: urip.host }); 25 + urip.host = res.data.did; 26 + } 27 + 28 + console.log("Fetching feed defs", feed); 29 + if (feed.type == "feed") { 30 + const feedDef = await agent.app.bsky.feed.getFeedGenerators({ 31 + feeds: [urip.toString()], 32 + }); 33 + 34 + store.setFeedDef(feed.value, feedDef.data.feeds[0]); 35 + } else if (feed.type == "list") { 36 + const listDef = await agent.app.bsky.graph.getList({ 37 + list: urip.toString(), 38 + }); 39 + 40 + store.setFeedDef(feed.value, { 41 + displayName: listDef.data.list.name, 42 + }); 43 + } 44 + } 45 + } finally { 46 + setLoading(false); 47 + } 48 + }; 49 + loadFeeds(); 50 + }, [agent]); 51 + 52 + return { isLoading }; 53 + }
+69
src/lib/hooks/useTimeline.tsx
··· 1 + // lib/hooks/useFetchTimeline.ts 2 + import { useEffect, useRef, useCallback } from "react"; 3 + import { AppBskyEmbedImages } from "@atproto/api"; 4 + import { useAuth } from "@/lib/useAuth"; 5 + import { useFeedStore } from "../stores/feeds"; 6 + 7 + export function useFetchTimeline() { 8 + const { agent } = useAuth(); 9 + const { timeline, setTimeline, appendTimeline, setTimelineLoading } = 10 + useFeedStore(); 11 + const seenImageUrls = useRef<Set<string>>(new Set()); 12 + 13 + const fetchFeed = useCallback(async () => { 14 + if (!agent || timeline.isLoading) return; 15 + setTimelineLoading(true); 16 + try { 17 + const response = await agent.getTimeline({ 18 + cursor: timeline.cursor, 19 + limit: 100, 20 + }); 21 + if (!response.success) throw new Error("Failed to fetch timeline"); 22 + 23 + const newCursor = response.data.cursor; 24 + 25 + const filtered = response.data.feed.filter((it) => { 26 + if (it.reason?.$type === "app.bsky.feed.defs#reasonRepost") 27 + return false; 28 + if ( 29 + !( 30 + AppBskyEmbedImages.isMain(it.post.embed) || 31 + AppBskyEmbedImages.isView(it.post.embed) 32 + ) 33 + ) 34 + return false; 35 + 36 + const images = (it.post.embed as AppBskyEmbedImages.View)?.images || []; 37 + const hasNew = images.some( 38 + (img) => !seenImageUrls.current.has(img.fullsize) 39 + ); 40 + if (!hasNew) return false; 41 + 42 + images.forEach((img) => seenImageUrls.current.add(img.fullsize)); 43 + return true; 44 + }); 45 + 46 + appendTimeline(filtered, newCursor); 47 + } catch (err) { 48 + console.error("Fetch failed", err); 49 + } finally { 50 + setTimelineLoading(false); 51 + } 52 + }, [agent, timeline]); 53 + 54 + useEffect(() => { 55 + const loadMinimum = async () => { 56 + while ( 57 + timeline.posts.flatMap( 58 + (p) => (p.post.embed as AppBskyEmbedImages.View)?.images || [] 59 + ).length < 30 60 + ) { 61 + await fetchFeed(); 62 + if (!timeline.cursor) break; 63 + } 64 + }; 65 + loadMinimum(); 66 + }, [agent]); 67 + 68 + return { fetchFeed }; 69 + }
+32
src/lib/stores/feedDefs.tsx
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + 4 + export interface BasicFeedItem { 5 + displayName: string; 6 + } 7 + 8 + type FeedDefsState = { 9 + feeds: Record<string, BasicFeedItem>; 10 + setFeedDef: (id: string, feed: BasicFeedItem) => void; 11 + }; 12 + 13 + export const useFeedDefsStore = create<FeedDefsState>()( 14 + persist( 15 + (set) => ({ 16 + feeds: {}, 17 + setFeedDef: (id, feed) => 18 + set((state) => ({ 19 + feeds: { 20 + ...state.feeds, 21 + [id]: feed, 22 + }, 23 + })), 24 + }), 25 + { 26 + name: "feed-defs", 27 + partialize: (state) => ({ 28 + feeds: state.feeds, // Only persist this part 29 + }), 30 + } 31 + ) 32 + );
+122
src/lib/stores/feeds.tsx
··· 1 + import { AppBskyFeedDefs } from "@atproto/api"; 2 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 3 + import { create } from "zustand"; 4 + import { combine } from "zustand/middleware"; 5 + 6 + type Post = AppBskyFeedDefs.FeedViewPost; 7 + 8 + type FeedState = { 9 + posts: PostView[]; 10 + isLoading: boolean; 11 + cursor?: string; 12 + }; 13 + 14 + type FeedMap = Record<string, FeedState>; 15 + 16 + export const useFeedStore = create( 17 + combine( 18 + { 19 + timeline: { 20 + posts: [] as Post[], 21 + isLoading: false, 22 + cursor: undefined as string | undefined, 23 + }, 24 + customFeeds: {} as FeedMap, 25 + }, 26 + (set, get) => ({ 27 + // TIMELINE METHODS 28 + setTimeline: (posts: Post[], cursor?: string) => 29 + set({ 30 + timeline: { 31 + posts, 32 + isLoading: false, 33 + cursor, 34 + }, 35 + }), 36 + 37 + appendTimeline: (newPosts: Post[], cursor?: string) => 38 + set((state) => { 39 + const existing = state.timeline.posts; 40 + const deduped = [ 41 + ...existing, 42 + ...newPosts.filter( 43 + (p) => !existing.some((ep) => ep.post.uri === p.post.uri) 44 + ), 45 + ]; 46 + return { 47 + timeline: { 48 + posts: deduped, 49 + isLoading: false, 50 + cursor, 51 + }, 52 + }; 53 + }), 54 + 55 + setTimelineLoading: (isLoading: boolean) => 56 + set((state) => ({ 57 + timeline: { 58 + ...state.timeline, 59 + isLoading, 60 + }, 61 + })), 62 + 63 + // CUSTOM FEED METHODS 64 + setCustomFeed: (feedId: string, posts: PostView[], cursor?: string) => 65 + set((state) => ({ 66 + customFeeds: { 67 + ...state.customFeeds, 68 + [feedId]: { 69 + posts, 70 + isLoading: false, 71 + cursor, 72 + }, 73 + }, 74 + })), 75 + 76 + appendCustomFeed: ( 77 + feedId: string, 78 + newPosts: PostView[], 79 + cursor?: string 80 + ) => { 81 + const current = get().customFeeds[feedId] || { 82 + posts: [], 83 + isLoading: false, 84 + cursor: undefined, 85 + }; 86 + const deduped = [ 87 + ...current.posts, 88 + ...newPosts.filter( 89 + (p) => !current.posts.some((ep) => ep.uri === p.uri) 90 + ), 91 + ]; 92 + set((state) => ({ 93 + customFeeds: { 94 + ...state.customFeeds, 95 + [feedId]: { 96 + posts: deduped, 97 + isLoading: false, 98 + cursor, 99 + }, 100 + }, 101 + })); 102 + }, 103 + 104 + setCustomFeedLoading: (feedId: string, isLoading: boolean) => { 105 + const current = get().customFeeds[feedId] || { 106 + posts: [], 107 + isLoading: false, 108 + cursor: undefined, 109 + }; 110 + set((state) => ({ 111 + customFeeds: { 112 + ...state.customFeeds, 113 + [feedId]: { 114 + ...current, 115 + isLoading, 116 + }, 117 + }, 118 + })); 119 + }, 120 + }) 121 + ) 122 + );
+1 -1
src/nav/navbar.tsx
··· 134 134 value={handle} 135 135 onChange={(e) => setHandle(e.target.value)} 136 136 placeholder="example.bsky.social" 137 - className="text-white" 137 + className="dark:text-white text-black" 138 138 /> 139 139 </DialogDescription> 140 140 </DialogHeader>