(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at frontend-rewrite 267 lines 8.0 kB view raw
1import React, { useEffect, useState, useCallback } from "react"; 2import { getFeed } from "../../api/client"; 3import Card from "../../components/common/Card"; 4import { 5 Loader2, 6 Clock, 7 Bookmark, 8 MessageSquare, 9 Highlighter, 10} from "lucide-react"; 11import { useStore } from "@nanostores/react"; 12import { $user } from "../../store/auth"; 13import type { AnnotationItem } from "../../types"; 14import { Tabs, EmptyState, Button } from "../../components/ui"; 15import LayoutToggle from "../../components/ui/LayoutToggle"; 16import { $feedLayout } from "../../store/feedLayout"; 17import { clsx } from "clsx"; 18 19interface FeedProps { 20 initialType?: string; 21 motivation?: string; 22 showTabs?: boolean; 23 emptyMessage?: string; 24} 25 26function FeedContent({ 27 type, 28 motivation, 29 emptyMessage, 30 layout, 31}: { 32 type: string; 33 motivation?: string; 34 emptyMessage: string; 35 layout: "list" | "mosaic"; 36}) { 37 const [items, setItems] = useState<AnnotationItem[]>([]); 38 const [loading, setLoading] = useState(true); 39 const [loadingMore, setLoadingMore] = useState(false); 40 const [hasMore, setHasMore] = useState(false); 41 const [offset, setOffset] = useState(0); 42 43 const LIMIT = 50; 44 45 useEffect(() => { 46 let cancelled = false; 47 48 getFeed({ type, motivation, limit: LIMIT, offset: 0 }) 49 .then((data) => { 50 if (cancelled) return; 51 const fetched = data?.items || []; 52 setItems(fetched); 53 setHasMore(fetched.length >= LIMIT); 54 setOffset(fetched.length); 55 setLoading(false); 56 }) 57 .catch((e) => { 58 if (cancelled) return; 59 console.error(e); 60 setItems([]); 61 setHasMore(false); 62 setLoading(false); 63 }); 64 65 return () => { 66 cancelled = true; 67 }; 68 }, [type, motivation]); 69 70 const loadMore = useCallback(async () => { 71 setLoadingMore(true); 72 try { 73 const data = await getFeed({ type, motivation, limit: LIMIT, offset }); 74 const fetched = data?.items || []; 75 setItems((prev) => [...prev, ...fetched]); 76 setHasMore(fetched.length >= LIMIT); 77 setOffset((prev) => prev + fetched.length); 78 } catch (e) { 79 console.error(e); 80 } finally { 81 setLoadingMore(false); 82 } 83 }, [type, motivation, offset]); 84 85 const handleDelete = (uri: string) => { 86 setItems((prev) => prev.filter((i) => i.uri !== uri)); 87 }; 88 89 if (loading) { 90 return ( 91 <div className="flex flex-col items-center justify-center py-20 gap-3"> 92 <Loader2 93 className="animate-spin text-primary-600 dark:text-primary-400" 94 size={32} 95 /> 96 <p className="text-sm text-surface-400 dark:text-surface-500"> 97 Loading feed... 98 </p> 99 </div> 100 ); 101 } 102 103 if (items.length === 0) { 104 return ( 105 <EmptyState 106 icon={<Clock size={48} />} 107 title="Nothing here yet" 108 message={emptyMessage} 109 /> 110 ); 111 } 112 113 const loadMoreButton = hasMore && ( 114 <div className="flex justify-center py-6"> 115 <button 116 onClick={loadMore} 117 disabled={loadingMore} 118 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" 119 > 120 {loadingMore ? ( 121 <> 122 <Loader2 size={16} className="animate-spin" /> 123 Loading... 124 </> 125 ) : ( 126 "Load more" 127 )} 128 </button> 129 </div> 130 ); 131 132 if (layout === "mosaic") { 133 return ( 134 <> 135 <div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in"> 136 {items.map((item) => ( 137 <div key={item.uri || item.cid} className="break-inside-avoid mb-4"> 138 <Card item={item} onDelete={handleDelete} /> 139 </div> 140 ))} 141 </div> 142 {loadMoreButton} 143 </> 144 ); 145 } 146 147 return ( 148 <> 149 <div className="space-y-3 animate-fade-in"> 150 {items.map((item) => ( 151 <Card 152 key={item.uri || item.cid} 153 item={item} 154 onDelete={handleDelete} 155 /> 156 ))} 157 </div> 158 {loadMoreButton} 159 </> 160 ); 161} 162 163export default function Feed({ 164 initialType = "all", 165 motivation, 166 showTabs = true, 167 emptyMessage = "No items found.", 168}: FeedProps) { 169 const user = useStore($user); 170 const layout = useStore($feedLayout); 171 const [activeTab, setActiveTab] = useState(initialType); 172 const [activeFilter, setActiveFilter] = useState<string | undefined>( 173 motivation, 174 ); 175 176 const handleTabChange = (id: string) => { 177 if (id === activeTab) return; 178 setActiveTab(id); 179 window.scrollTo({ top: 0, behavior: "smooth" }); 180 }; 181 182 const handleFilterChange = (id: string) => { 183 const next = id === "all" ? undefined : id; 184 if (next === activeFilter) return; 185 setActiveFilter(next); 186 window.scrollTo({ top: 0, behavior: "smooth" }); 187 }; 188 189 const tabs = [ 190 { id: "all", label: "Recent" }, 191 { id: "popular", label: "Popular" }, 192 { id: "shelved", label: "Shelved" }, 193 { id: "margin", label: "Margin" }, 194 { id: "semble", label: "Semble" }, 195 ]; 196 197 const filters = [ 198 { id: "all", label: "All", icon: null }, 199 { id: "commenting", label: "Annotations", icon: MessageSquare }, 200 { id: "highlighting", label: "Highlights", icon: Highlighter }, 201 { id: "bookmarking", label: "Bookmarks", icon: Bookmark }, 202 ]; 203 204 return ( 205 <div className="mx-auto max-w-2xl xl:max-w-none"> 206 {!user && ( 207 <div className="text-center py-10 px-6 mb-4 animate-fade-in"> 208 <h1 className="text-2xl font-display font-bold mb-2 tracking-tight text-surface-900 dark:text-white"> 209 Welcome to Margin 210 </h1> 211 <p className="text-surface-500 dark:text-surface-400 mb-4 max-w-md mx-auto"> 212 Annotate, highlight, and bookmark anything on the web. 213 </p> 214 <div className="flex gap-3 justify-center"> 215 <Button onClick={() => (window.location.href = "/login")}> 216 Get Started 217 </Button> 218 <Button 219 variant="secondary" 220 onClick={() => window.open("/about", "_blank")} 221 > 222 Learn More 223 </Button> 224 </div> 225 </div> 226 )} 227 228 {showTabs && ( 229 <div className="sticky top-0 z-10 bg-surface-50/95 dark:bg-surface-950/95 backdrop-blur-sm pb-3 mb-2 -mx-1 px-1 pt-1 space-y-2"> 230 <Tabs tabs={tabs} activeTab={activeTab} onChange={handleTabChange} /> 231 <div className="flex items-center gap-1.5 flex-wrap"> 232 {filters.map((f) => { 233 const isActive = 234 f.id === "all" ? !activeFilter : activeFilter === f.id; 235 return ( 236 <button 237 key={f.id} 238 onClick={() => handleFilterChange(f.id)} 239 className={clsx( 240 "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all", 241 isActive 242 ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 243 : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400", 244 )} 245 > 246 {f.icon && <f.icon size={12} />} 247 {f.label} 248 </button> 249 ); 250 })} 251 <div className="ml-auto"> 252 <LayoutToggle /> 253 </div> 254 </div> 255 </div> 256 )} 257 258 <FeedContent 259 key={`${activeTab}-${activeFilter || "all"}`} 260 type={activeTab} 261 motivation={activeFilter} 262 emptyMessage={emptyMessage} 263 layout={layout} 264 /> 265 </div> 266 ); 267}