(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { Clock, Loader2 } from "lucide-react";
2import { useCallback, useEffect, useRef, useState } from "react";
3import { useTranslation } from "react-i18next";
4import { type GetFeedParams, getFeed } from "../../api/client";
5import Card from "../../components/common/Card";
6import { EmptyState } from "../../components/ui";
7import type { AnnotationItem } from "../../types";
8
9const LIMIT = 50;
10
11const feedCache = new Map<
12 string,
13 {
14 items: AnnotationItem[];
15 hasMore: boolean;
16 offset: number;
17 timestamp: number;
18 }
19>();
20
21export interface FeedItemsProps extends Omit<
22 GetFeedParams,
23 "limit" | "offset"
24> {
25 layout: "list" | "mosaic";
26 emptyMessage: string;
27 initialItems?: AnnotationItem[];
28 initialHasMore?: boolean;
29}
30
31export default function FeedItems({
32 creator,
33 source,
34 tag,
35 type,
36 motivation,
37 emptyMessage,
38 layout,
39 initialItems,
40 initialHasMore,
41}: FeedItemsProps) {
42 const { t } = useTranslation();
43 const [items, setItems] = useState<AnnotationItem[]>(initialItems || []);
44 const [loading, setLoading] = useState(!initialItems);
45 const [loadingMore, setLoadingMore] = useState(false);
46 const [hasMore, setHasMore] = useState(initialHasMore ?? false);
47 const [offset, setOffset] = useState(initialItems?.length ?? 0);
48 const skipInitialFetch = useRef(!!initialItems);
49
50 useEffect(() => {
51 if (skipInitialFetch.current) {
52 skipInitialFetch.current = false;
53 return;
54 }
55
56 let cancelled = false;
57 const cacheKey = JSON.stringify({ type, motivation, tag, creator, source });
58 const cached = feedCache.get(cacheKey);
59
60 if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
61 setItems(cached.items);
62 setHasMore(cached.hasMore);
63 setOffset(cached.offset);
64 setLoading(false);
65
66 getFeed({
67 type,
68 motivation,
69 tag,
70 creator,
71 source,
72 limit: LIMIT,
73 offset: 0,
74 })
75 .then((data) => {
76 if (cancelled) return;
77 const fetched = data.items;
78 setItems(fetched);
79 setHasMore(data.hasMore);
80 setOffset(data.fetchedCount);
81 feedCache.set(cacheKey, {
82 items: fetched,
83 hasMore: data.hasMore,
84 offset: data.fetchedCount,
85 timestamp: Date.now(),
86 });
87 })
88 .catch(console.error);
89
90 return () => {
91 cancelled = true;
92 };
93 }
94
95 setLoading(true);
96 getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 })
97 .then((data) => {
98 if (cancelled) return;
99 const fetched = data.items;
100 setItems(fetched);
101 setHasMore(data.hasMore);
102 setOffset(data.fetchedCount);
103 setLoading(false);
104 feedCache.set(cacheKey, {
105 items: fetched,
106 hasMore: data.hasMore,
107 offset: data.fetchedCount,
108 timestamp: Date.now(),
109 });
110 })
111 .catch((e) => {
112 if (cancelled) return;
113 console.error(e);
114 setItems([]);
115 setHasMore(false);
116 setLoading(false);
117 });
118
119 return () => {
120 cancelled = true;
121 };
122 }, [type, motivation, tag, creator, source]);
123
124 const loadMore = useCallback(async () => {
125 setLoadingMore(true);
126 try {
127 const cacheKey = JSON.stringify({
128 type,
129 motivation,
130 tag,
131 creator,
132 source,
133 });
134 const data = await getFeed({
135 type,
136 motivation,
137 tag,
138 creator,
139 source,
140 limit: LIMIT,
141 offset,
142 });
143 const fetched = data?.items || [];
144 const newItems = [...items, ...fetched];
145 setItems(newItems);
146 setHasMore(data.hasMore);
147 const newOffset = offset + data.fetchedCount;
148 setOffset(newOffset);
149 feedCache.set(cacheKey, {
150 items: newItems,
151 hasMore: data.hasMore,
152 offset: newOffset,
153 timestamp: Date.now(),
154 });
155 } catch (e) {
156 console.error(e);
157 } finally {
158 setLoadingMore(false);
159 }
160 }, [type, motivation, tag, creator, source, offset, items]);
161
162 const handleDelete = (uri: string) => {
163 setItems((prev) => prev.filter((i) => i.uri !== uri));
164 };
165
166 if (loading) {
167 return (
168 <div className="flex flex-col items-center justify-center py-20 gap-3">
169 <Loader2
170 className="animate-spin text-primary-600 dark:text-primary-400"
171 size={32}
172 />
173 <p className="text-sm text-surface-400 dark:text-surface-500">
174 {t("feed.loading")}
175 </p>
176 </div>
177 );
178 }
179
180 if (items.length === 0) {
181 return (
182 <EmptyState
183 icon={<Clock size={48} />}
184 title={t("feed.nothingHereYet")}
185 message={emptyMessage}
186 />
187 );
188 }
189
190 const loadMoreButton = hasMore && (
191 <div className="flex justify-center py-6">
192 <button
193 type="button"
194 onClick={loadMore}
195 disabled={loadingMore}
196 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"
197 >
198 {loadingMore ? (
199 <>
200 <Loader2 size={16} className="animate-spin" />
201 {t("common.loading")}
202 </>
203 ) : (
204 t("common.loadMore")
205 )}
206 </button>
207 </div>
208 );
209
210 if (layout === "mosaic") {
211 return (
212 <>
213 <div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in">
214 {items.map((item) => (
215 <div key={item.uri || item.cid} className="break-inside-avoid mb-4">
216 <Card item={item} onDelete={handleDelete} layout="mosaic" />
217 </div>
218 ))}
219 </div>
220 {loadMoreButton}
221 </>
222 );
223 }
224
225 return (
226 <>
227 <div className="space-y-3 animate-fade-in">
228 {items.map((item) => (
229 <Card
230 key={item.uri || item.cid}
231 item={item}
232 onDelete={handleDelete}
233 />
234 ))}
235 </div>
236 {loadMoreButton}
237 </>
238 );
239}