(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}