Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState, useEffect, useCallback, useRef } from "react";
2import { Loader2, ExternalLink, Compass, Tag } from "lucide-react";
3import { useStore } from "@nanostores/react";
4import { clsx } from "clsx";
5import { getDocuments, getRecommendations } from "../../api/client";
6import type { DocumentItem } from "../../api/client";
7import { Tabs, EmptyState } from "../../components/ui";
8import LayoutToggle from "../../components/ui/LayoutToggle";
9import { $user } from "../../store/auth";
10import { $feedLayout } from "../../store/feedLayout";
11import { formatDistanceToNow } from "date-fns";
12
13interface DiscoverProps {
14 initialDocuments?: DocumentItem[];
15 initialHasMore?: boolean;
16}
17
18export default function Discover({
19 initialDocuments,
20 initialHasMore,
21}: DiscoverProps) {
22 const user = useStore($user);
23 const layout = useStore($feedLayout);
24 const [activeTab, setActiveTab] = useState("new");
25 const [items, setItems] = useState<DocumentItem[]>(initialDocuments || []);
26 const [loading, setLoading] = useState(!initialDocuments);
27 const [hasMore, setHasMore] = useState(initialHasMore ?? false);
28 const [offset, setOffset] = useState(initialDocuments?.length ?? 0);
29 const [recommendationsUnavailable, setRecommendationsUnavailable] =
30 useState(false);
31 const fetchIdRef = useRef(0);
32 const limit = 30;
33
34 const tabs = [
35 { id: "new", label: "New" },
36 { id: "popular", label: "Popular" },
37 ...(user ? [{ id: "recommended", label: "For You" }] : []),
38 ];
39
40 const fetchItems = useCallback(
41 async (tab: string, newOffset = 0, append = false) => {
42 const id = ++fetchIdRef.current;
43 setLoading(true);
44
45 let data: { items: DocumentItem[]; totalItems: number };
46 if (tab === "recommended") {
47 const res = await getRecommendations(limit);
48 if ("unavailable" in res && res.unavailable) {
49 setRecommendationsUnavailable(true);
50 setLoading(false);
51 return;
52 }
53 setRecommendationsUnavailable(false);
54 data = res;
55 } else {
56 data = await getDocuments({ sort: tab, limit, offset: newOffset });
57 }
58
59 if (id !== fetchIdRef.current) return;
60
61 setItems((prev) => (append ? [...prev, ...data.items] : data.items));
62 setHasMore(
63 tab !== "recommended" &&
64 newOffset + data.items.length < data.totalItems,
65 );
66 setOffset(newOffset + data.items.length);
67 setLoading(false);
68 },
69 [limit],
70 );
71
72 const skipInitialFetch = useRef(!!initialDocuments);
73 useEffect(() => {
74 if (skipInitialFetch.current) {
75 skipInitialFetch.current = false;
76 return;
77 }
78 queueMicrotask(() => fetchItems(activeTab, 0));
79 }, [activeTab, fetchItems]);
80
81 const handleTabChange = (id: string) => {
82 if (id === activeTab) return;
83 setActiveTab(id);
84 window.scrollTo({ top: 0, behavior: "smooth" });
85 };
86
87 const loadMore = () => {
88 fetchItems(activeTab, offset, true);
89 };
90
91 return (
92 <div className="mx-auto max-w-2xl xl:max-w-none">
93 <div className="sticky top-0 z-10 bg-white/90 dark:bg-surface-800/90 backdrop-blur-md pb-3 mb-2 -mx-1 px-1 pt-2 space-y-2">
94 <div className="flex items-center gap-2">
95 <Tabs tabs={tabs} activeTab={activeTab} onChange={handleTabChange} />
96 <LayoutToggle className="hidden sm:inline-flex ml-auto" />
97 </div>
98 </div>
99
100 {loading && items.length === 0 ? (
101 <div className="flex justify-center py-20">
102 <Loader2 className="w-6 h-6 animate-spin text-surface-400" />
103 </div>
104 ) : activeTab === "recommended" && recommendationsUnavailable ? (
105 <EmptyState
106 icon={<Compass size={40} />}
107 title="Coming soon"
108 message="Personalized recommendations aren't available on this server yet."
109 />
110 ) : items.length === 0 ? (
111 <EmptyState
112 icon={<Compass size={40} />}
113 title="Nothing here yet"
114 message={
115 activeTab === "recommended"
116 ? "Start annotating and highlighting to get personalized recommendations."
117 : "No documents have been discovered yet. Check back soon!"
118 }
119 />
120 ) : (
121 <div
122 className={clsx(
123 layout === "mosaic"
124 ? "columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4"
125 : "space-y-3",
126 "animate-fade-in",
127 )}
128 >
129 {items.map((doc) => (
130 <div
131 key={doc.uri}
132 className={
133 layout === "mosaic" ? "break-inside-avoid mb-4" : undefined
134 }
135 >
136 <DocumentCard doc={doc} />
137 </div>
138 ))}
139
140 {loading && (
141 <div className="flex justify-center py-6">
142 <Loader2 className="w-5 h-5 animate-spin text-surface-400" />
143 </div>
144 )}
145
146 {hasMore && !loading && (
147 <button
148 onClick={loadMore}
149 className="w-full py-3 text-sm font-medium text-surface-500 hover:text-surface-700 dark:text-surface-400 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors"
150 >
151 Load more
152 </button>
153 )}
154 </div>
155 )}
156 </div>
157 );
158}
159
160function DocumentCard({ doc }: { doc: DocumentItem }) {
161 const [ogData, setOgData] = useState<{
162 title?: string;
163 description?: string;
164 image?: string;
165 icon?: string;
166 } | null>(() => {
167 try {
168 const cached = sessionStorage.getItem(`og:${doc.canonicalUrl}`);
169 return cached ? JSON.parse(cached) : null;
170 } catch {
171 return null;
172 }
173 });
174
175 useEffect(() => {
176 if (!doc.canonicalUrl || ogData) return;
177 fetch(`/api/url-metadata?url=${encodeURIComponent(doc.canonicalUrl)}`)
178 .then((res) => (res.ok ? res.json() : null))
179 .then((data) => {
180 if (data) {
181 setOgData(data);
182 try {
183 sessionStorage.setItem(
184 `og:${doc.canonicalUrl}`,
185 JSON.stringify(data),
186 );
187 } catch {
188 /* quota exceeded */
189 }
190 }
191 })
192 .catch(() => {});
193 }, [doc.canonicalUrl, ogData]);
194
195 const displayUrl = doc.canonicalUrl
196 .replace(/^https?:\/\//, "")
197 .replace(/\/$/, "");
198
199 const hostname = (() => {
200 try {
201 return new URL(doc.canonicalUrl).hostname;
202 } catch {
203 return null;
204 }
205 })();
206
207 return (
208 <a
209 href={doc.canonicalUrl}
210 target="_blank"
211 rel="noopener noreferrer"
212 className="card block hover:ring-1 hover:ring-black/10 dark:hover:ring-white/10 transition-all group overflow-hidden"
213 >
214 {ogData?.image && (
215 <div className="w-full h-40 bg-surface-100 dark:bg-surface-800 overflow-hidden">
216 <img
217 src={ogData.image}
218 alt=""
219 className="w-full h-full object-cover"
220 onError={(e) => (e.currentTarget.style.display = "none")}
221 />
222 </div>
223 )}
224 <div className="p-4">
225 <div className="flex items-start justify-between gap-3">
226 <div className="min-w-0 flex-1">
227 <h3 className="font-display font-semibold text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors line-clamp-2">
228 {doc.title || displayUrl}
229 </h3>
230 {doc.description && (
231 <p className="mt-1 text-sm text-surface-500 dark:text-surface-400 line-clamp-2">
232 {doc.description}
233 </p>
234 )}
235 <div className="mt-2 flex items-center gap-3 text-xs text-surface-400 dark:text-surface-500">
236 <span className="flex items-center gap-1 truncate">
237 {ogData?.icon ? (
238 <img
239 src={ogData.icon}
240 alt=""
241 className="w-3 h-3 rounded-sm"
242 onError={(e) => (e.currentTarget.style.display = "none")}
243 />
244 ) : (
245 <ExternalLink size={12} />
246 )}
247 {hostname || displayUrl}
248 </span>
249 {doc.publishedAt && (
250 <span>
251 {formatDistanceToNow(new Date(doc.publishedAt), {
252 addSuffix: true,
253 })}
254 </span>
255 )}
256 </div>
257 {doc.tags && doc.tags.length > 0 && (
258 <div className="mt-2 flex flex-wrap gap-1.5">
259 {doc.tags.slice(0, 5).map((tag) => (
260 <span
261 key={tag}
262 className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400"
263 >
264 <Tag size={10} />
265 {tag}
266 </span>
267 ))}
268 </div>
269 )}
270 </div>
271 </div>
272 </div>
273 </a>
274 );
275}