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