Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 158 lines 4.1 kB view raw
1import { Clock, Loader2 } from "lucide-react"; 2import { useCallback, useEffect, useState } from "react"; 3import { type GetFeedParams, getFeed } from "../../api/client"; 4import Card from "../../components/common/Card"; 5import { EmptyState } from "../../components/ui"; 6import type { AnnotationItem } from "../../types"; 7 8const LIMIT = 50; 9 10export interface FeedItemsProps extends Omit< 11 GetFeedParams, 12 "limit" | "offset" 13> { 14 layout: "list" | "mosaic"; 15 emptyMessage: string; 16} 17 18export default function FeedItems({ 19 creator, 20 source, 21 tag, 22 type, 23 motivation, 24 emptyMessage, 25 layout, 26}: FeedItemsProps) { 27 const [items, setItems] = useState<AnnotationItem[]>([]); 28 const [loading, setLoading] = useState(true); 29 const [loadingMore, setLoadingMore] = useState(false); 30 const [hasMore, setHasMore] = useState(false); 31 const [offset, setOffset] = useState(0); 32 33 useEffect(() => { 34 let cancelled = false; 35 36 getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 }) 37 .then((data) => { 38 if (cancelled) return; 39 const fetched = data.items; 40 setItems(fetched); 41 setHasMore(data.hasMore); 42 setOffset(data.fetchedCount); 43 setLoading(false); 44 }) 45 .catch((e) => { 46 if (cancelled) return; 47 console.error(e); 48 setItems([]); 49 setHasMore(false); 50 setLoading(false); 51 }); 52 53 return () => { 54 cancelled = true; 55 }; 56 }, [type, motivation, tag, creator, source]); 57 58 const loadMore = useCallback(async () => { 59 setLoadingMore(true); 60 try { 61 const data = await getFeed({ 62 type, 63 motivation, 64 tag, 65 creator, 66 source, 67 limit: LIMIT, 68 offset, 69 }); 70 const fetched = data?.items || []; 71 setItems((prev) => [...prev, ...fetched]); 72 setHasMore(data.hasMore); 73 setOffset((prev) => prev + data.fetchedCount); 74 } catch (e) { 75 console.error(e); 76 } finally { 77 setLoadingMore(false); 78 } 79 }, [type, motivation, tag, creator, source, offset]); 80 81 const handleDelete = (uri: string) => { 82 setItems((prev) => prev.filter((i) => i.uri !== uri)); 83 }; 84 85 if (loading) { 86 return ( 87 <div className="flex flex-col items-center justify-center py-20 gap-3"> 88 <Loader2 89 className="animate-spin text-primary-600 dark:text-primary-400" 90 size={32} 91 /> 92 <p className="text-sm text-surface-400 dark:text-surface-500"> 93 Loading... 94 </p> 95 </div> 96 ); 97 } 98 99 if (items.length === 0) { 100 return ( 101 <EmptyState 102 icon={<Clock size={48} />} 103 title="Nothing here yet" 104 message={emptyMessage} 105 /> 106 ); 107 } 108 109 const loadMoreButton = hasMore && ( 110 <div className="flex justify-center py-6"> 111 <button 112 type="button" 113 onClick={loadMore} 114 disabled={loadingMore} 115 className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors disabled:opacity-50" 116 > 117 {loadingMore ? ( 118 <> 119 <Loader2 size={16} className="animate-spin" /> 120 Loading... 121 </> 122 ) : ( 123 "Load more" 124 )} 125 </button> 126 </div> 127 ); 128 129 if (layout === "mosaic") { 130 return ( 131 <> 132 <div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in"> 133 {items.map((item) => ( 134 <div key={item.uri || item.cid} className="break-inside-avoid mb-4"> 135 <Card item={item} onDelete={handleDelete} layout="mosaic" /> 136 </div> 137 ))} 138 </div> 139 {loadMoreButton} 140 </> 141 ); 142 } 143 144 return ( 145 <> 146 <div className="space-y-3 animate-fade-in"> 147 {items.map((item) => ( 148 <Card 149 key={item.uri || item.cid} 150 item={item} 151 onDelete={handleDelete} 152 /> 153 ))} 154 </div> 155 {loadMoreButton} 156 </> 157 ); 158}