Scrapboard.org client
at main 279 lines 8.4 kB view raw
1"use client"; 2import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3import { 4 Agent, 5 AppBskyEmbedImages, 6 AppBskyFeedPost, 7 AtUri, 8 moderatePost, 9 ModerationPrefs, 10} from "@atproto/api"; 11import { LoaderCircle } from "lucide-react"; 12import { motion } from "motion/react"; 13import Image from "next/image"; 14import Link from "next/link"; 15import Masonry from "react-masonry-css"; 16import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 17import { SaveButton } from "./SaveButton"; 18import { UnsaveButton } from "./UnsaveButton"; 19import { LikeButton } from "./LikeButton"; 20import { useState, useEffect } from "react"; 21import { 22 DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, 23 useModerationOpts, 24} from "@/lib/hooks/useModerationOpts"; 25import { useAuth } from "@/lib/hooks/useAuth"; 26import { ContentWarning } from "./ContentWarning"; 27import clsx from "clsx"; 28 29export type FeedItem = { 30 id: string; 31 imageUrl: string; 32 alt?: string; 33 author?: { 34 avatar?: string; 35 displayName?: string; 36 handle: string; 37 did?: string; 38 }; 39 text?: string; 40 uri: string; 41 aspectRatio?: { width: number; height: number }; 42 blurDataURL?: string; 43}; 44 45// Props for the Feed component 46interface FeedProps { 47 /** 48 * Map of the index of the embedded media and post view 49 */ 50 feed?: [number, PostView][]; 51 52 isLoading?: boolean; 53 showUnsaveButton?: boolean; 54} 55 56function getText(post: PostView) { 57 if (!AppBskyFeedPost.isRecord(post.record)) return; 58 return (post.record as AppBskyFeedPost.Record).text; 59} 60 61function getImageFromItem(it: PostView, index: number) { 62 if ( 63 AppBskyEmbedImages.isMain(it.embed) || 64 AppBskyEmbedImages.isView(it.embed) 65 ) { 66 return it.embed.images[index]; 67 } else return null; 68} 69 70// Add this function to prefetch and cache images 71function prefetchAndCacheImages(feed: [number, PostView][] | undefined) { 72 if (!feed || typeof window === "undefined") return; 73 74 feed.forEach(([index, item]) => { 75 const image = getImageFromItem(item, index); 76 if (image && image.fullsize) { 77 const img = new window.Image(); 78 img.src = image.fullsize; 79 80 // If service worker is active, explicitly add to cache 81 if ("serviceWorker" in navigator && navigator.serviceWorker.controller) { 82 fetch(image.fullsize, { mode: "no-cors" }).catch((err) => 83 console.warn("Error prefetching image:", err) 84 ); 85 } 86 } 87 }); 88} 89 90function ImageCard({ 91 item, 92 showUnsaveButton, 93 index, 94}: { 95 item: PostView; 96 showUnsaveButton?: boolean; 97 index: number; 98}) { 99 const image = getImageFromItem(item, index); 100 const [isDropdownOpen, setDropdownOpen] = useState(false); 101 const modOpts = useModerationOpts(); 102 const { session, agent } = useAuth(); 103 104 if (!image) return; 105 106 const ActionButton = showUnsaveButton ? UnsaveButton : SaveButton; 107 const txt = getText(item); 108 const opts: ModerationPrefs = modOpts.moderationPrefs ?? { 109 adultContentEnabled: false, 110 labelers: agent.appLabelers.map((did) => ({ 111 did, 112 labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, 113 })), 114 hiddenPosts: [], 115 mutedWords: [], 116 labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, 117 }; 118 const mod = moderatePost(item, { 119 prefs: opts, 120 labelDefs: modOpts.labelDefs, 121 userDid: session?.did, 122 }); 123 124 // Debug code removed for production 125 126 return ( 127 <ContentWarning mod={mod}> 128 <div className={`relative group ${isDropdownOpen ? "hover-active" : ""}`}> 129 {/* Save/Unsave button – top-left */} 130 <div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 131 {ActionButton && ( 132 <ActionButton 133 image={index} 134 post={item} 135 onDropdownOpenChange={setDropdownOpen} 136 /> 137 )} 138 </div> 139 140 {/* Like button – top-right */} 141 <div className="absolute top-3 right-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 142 <LikeButton post={item} /> 143 </div> 144 145 {/* Link wraps image only */} 146 <Link 147 href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`} 148 className="block" 149 > 150 <motion.div 151 initial={{ opacity: 0, y: 5 }} 152 animate={{ opacity: 1, y: 0 }} 153 transition={{ duration: 0.5, ease: "easeOut" }} 154 whileTap={{ scale: 0.95 }} 155 className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900" 156 > 157 {/* Blurred background */} 158 <Image 159 src={image.fullsize} 160 alt="" 161 fill 162 placeholder={image.thumb ? "blur" : "empty"} 163 blurDataURL={image.thumb} 164 className="object-cover filter blur-xl scale-110 opacity-30" 165 /> 166 167 {/* Foreground image */} 168 <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]"> 169 <Image 170 src={image.fullsize} 171 alt={image.alt || ""} 172 placeholder={image.thumb ? "blur" : "empty"} 173 blurDataURL={image.thumb} 174 width={image.aspectRatio?.width ?? 400} 175 height={image.aspectRatio?.height ?? 400} 176 className="object-contain max-w-full max-h-full rounded-lg" 177 priority 178 /> 179 </div> 180 181 {/* Author info */} 182 {item.author && ( 183 <div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity duration-300 flex flex-col justify-between p-3"> 184 <div className="w-fit self-start" /> 185 186 <div className="flex flex-col gap-2"> 187 <div className="flex items-center gap-2"> 188 <Avatar> 189 <AvatarImage 190 className={clsx( 191 mod.ui("avatar").blur ? "blur-3xl" : "" 192 )} 193 src={item.author.avatar} 194 /> 195 <AvatarFallback> 196 {item.author.displayName || item.author.handle} 197 </AvatarFallback> 198 </Avatar> 199 <div className="flex flex-col leading-tight"> 200 <span> 201 {item.author.displayName || item.author.handle} 202 </span> 203 <span className="text-white/70 text-[0.75rem]"> 204 @{item.author.handle} 205 </span> 206 </div> 207 </div> 208 209 {txt && ( 210 <div className="text-sm"> 211 {txt.length > 100 ? txt.slice(0, 100) + "…" : txt} 212 </div> 213 )} 214 </div> 215 </div> 216 )} 217 </motion.div> 218 </Link> 219 </div> 220 </ContentWarning> 221 ); 222} 223 224export function feedAsMap(feed: PostView[]) { 225 const map: [number, PostView][] = []; 226 for (const it of feed) { 227 if ( 228 AppBskyEmbedImages.isMain(it.embed) || 229 AppBskyEmbedImages.isView(it.embed) 230 ) { 231 it.embed.images.forEach((image, index) => map.push([index, it])); 232 } 233 } 234 return map; 235} 236 237export const breakpointColumnsObj = { 238 default: 5, 239 1536: 4, 240 1280: 3, 241 1024: 2, 242 768: 1, 243}; 244 245export function Feed({ 246 feed, 247 isLoading = false, 248 showUnsaveButton = false, 249}: FeedProps) { 250 // Use effect to prefetch and cache images when feed changes 251 useEffect(() => { 252 prefetchAndCacheImages(feed); 253 }, [feed]); 254 255 return ( 256 <> 257 <Masonry 258 breakpointCols={breakpointColumnsObj} 259 className="flex -mx-2 w-auto" 260 columnClassName="px-2 space-y-4" 261 > 262 {feed?.map(([index, item]) => ( 263 <ImageCard 264 key={`${item.uri}-${index}`} 265 item={item} 266 index={index} 267 showUnsaveButton={showUnsaveButton} 268 /> 269 ))} 270 </Masonry> 271 272 {isLoading && ( 273 <div className="flex justify-center py-6 text-sm text-black/70 dark:text-white/70"> 274 <LoaderCircle className="animate-spin" /> 275 </div> 276 )} 277 </> 278 ); 279}