Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}