Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useState, useEffect, useCallback } from "react";
2import { Link } from "react-router-dom";
3import { Plus } from "lucide-react";
4import { useAuth } from "../context/AuthContext";
5import {
6 getUserBookmarks,
7 deleteBookmark,
8 createBookmark,
9 getURLMetadata,
10} from "../api/client";
11import { BookmarkIcon } from "../components/Icons";
12import BookmarkCard from "../components/BookmarkCard";
13import CollectionItemCard from "../components/CollectionItemCard";
14import AddToCollectionModal from "../components/AddToCollectionModal";
15
16export default function Bookmarks() {
17 const { user, isAuthenticated, loading } = useAuth();
18 const [bookmarks, setBookmarks] = useState([]);
19 const [loadingBookmarks, setLoadingBookmarks] = useState(true);
20 const [error, setError] = useState(null);
21 const [showAddForm, setShowAddForm] = useState(false);
22 const [newUrl, setNewUrl] = useState("");
23 const [newTitle, setNewTitle] = useState("");
24 const [submitting, setSubmitting] = useState(false);
25 const [fetchingTitle, setFetchingTitle] = useState(false);
26 const [collectionModalState, setCollectionModalState] = useState({
27 isOpen: false,
28 uri: null,
29 });
30
31 const loadBookmarks = useCallback(async () => {
32 if (!user?.did) return;
33
34 try {
35 setLoadingBookmarks(true);
36 const data = await getUserBookmarks(user.did);
37 setBookmarks(data.items || []);
38 } catch (err) {
39 console.error("Failed to load bookmarks:", err);
40 setError(err.message);
41 } finally {
42 setLoadingBookmarks(false);
43 }
44 }, [user]);
45
46 useEffect(() => {
47 if (isAuthenticated && user) {
48 loadBookmarks();
49 }
50 }, [isAuthenticated, user, loadBookmarks]);
51
52 const handleDelete = async (uri) => {
53 if (!confirm("Delete this bookmark?")) return;
54
55 try {
56 const parts = uri.split("/");
57 const rkey = parts[parts.length - 1];
58 await deleteBookmark(rkey);
59 setBookmarks((prev) => prev.filter((b) => (b.id || b.uri) !== uri));
60 } catch (err) {
61 alert("Failed to delete: " + err.message);
62 }
63 };
64
65 const handleUrlBlur = async () => {
66 if (!newUrl.trim() || newTitle.trim()) return;
67 try {
68 new URL(newUrl);
69 } catch {
70 return;
71 }
72 try {
73 setFetchingTitle(true);
74 const data = await getURLMetadata(newUrl.trim());
75 if (data.title && !newTitle) {
76 setNewTitle(data.title);
77 }
78 } catch (err) {
79 console.error("Failed to fetch title:", err);
80 } finally {
81 setFetchingTitle(false);
82 }
83 };
84
85 const handleAddBookmark = async (e) => {
86 e.preventDefault();
87 if (!newUrl.trim()) return;
88
89 try {
90 setSubmitting(true);
91 await createBookmark(newUrl.trim(), newTitle.trim() || undefined);
92 setNewUrl("");
93 setNewTitle("");
94 setShowAddForm(false);
95 await loadBookmarks();
96 } catch (err) {
97 alert("Failed to add bookmark: " + err.message);
98 } finally {
99 setSubmitting(false);
100 }
101 };
102
103 if (loading)
104 return (
105 <div className="page-loading">
106 <div className="spinner"></div>
107 </div>
108 );
109
110 if (!isAuthenticated) {
111 return (
112 <div className="new-page">
113 <div className="card" style={{ textAlign: "center", padding: "48px" }}>
114 <h2>Sign in to view your bookmarks</h2>
115 <p style={{ color: "var(--text-secondary)", marginTop: "8px" }}>
116 You need to be logged in with your Bluesky account
117 </p>
118 <Link
119 to="/login"
120 className="btn btn-primary"
121 style={{ marginTop: "24px" }}
122 >
123 Sign in with Bluesky
124 </Link>
125 </div>
126 </div>
127 );
128 }
129
130 return (
131 <div className="feed-page">
132 <div
133 className="page-header"
134 style={{
135 display: "flex",
136 justifyContent: "space-between",
137 alignItems: "flex-start",
138 }}
139 >
140 <div>
141 <h1 className="page-title">My Bookmarks</h1>
142 <p className="page-description">Pages you've saved for later</p>
143 </div>
144 <button
145 onClick={() => setShowAddForm(!showAddForm)}
146 className="btn btn-primary"
147 >
148 <Plus size={20} />
149 Add Bookmark
150 </button>
151 </div>
152
153 {showAddForm && (
154 <div className="card" style={{ marginBottom: "20px", padding: "24px" }}>
155 <h3
156 style={{
157 marginBottom: "16px",
158 fontSize: "1.1rem",
159 color: "var(--text-primary)",
160 }}
161 >
162 Add a Bookmark
163 </h3>
164 <form onSubmit={handleAddBookmark}>
165 <div
166 style={{ display: "flex", flexDirection: "column", gap: "16px" }}
167 >
168 <div>
169 <label
170 style={{
171 display: "block",
172 marginBottom: "6px",
173 fontSize: "0.85rem",
174 color: "var(--text-secondary)",
175 }}
176 >
177 URL *
178 </label>
179 <input
180 type="url"
181 placeholder="https://example.com/article"
182 value={newUrl}
183 onChange={(e) => setNewUrl(e.target.value)}
184 onBlur={handleUrlBlur}
185 className="input"
186 style={{ width: "100%" }}
187 required
188 autoFocus
189 />
190 </div>
191 <div>
192 <label
193 style={{
194 display: "block",
195 marginBottom: "6px",
196 fontSize: "0.85rem",
197 color: "var(--text-secondary)",
198 }}
199 >
200 Title{" "}
201 {fetchingTitle ? (
202 <span style={{ color: "var(--accent)" }}>Fetching...</span>
203 ) : (
204 <span style={{ color: "var(--text-tertiary)" }}>
205 (auto-fetched)
206 </span>
207 )}
208 </label>
209 <input
210 type="text"
211 placeholder={
212 fetchingTitle
213 ? "Fetching title..."
214 : "Page title will be fetched automatically"
215 }
216 value={newTitle}
217 onChange={(e) => setNewTitle(e.target.value)}
218 className="input"
219 style={{ width: "100%" }}
220 />
221 </div>
222 <div
223 style={{
224 display: "flex",
225 gap: "10px",
226 justifyContent: "flex-end",
227 marginTop: "8px",
228 }}
229 >
230 <button
231 type="button"
232 onClick={() => {
233 setShowAddForm(false);
234 setNewUrl("");
235 setNewTitle("");
236 }}
237 className="btn btn-secondary"
238 >
239 Cancel
240 </button>
241 <button
242 type="submit"
243 className="btn btn-primary"
244 disabled={submitting || !newUrl.trim()}
245 >
246 {submitting ? "Adding..." : "Save Bookmark"}
247 </button>
248 </div>
249 </div>
250 </form>
251 </div>
252 )}
253
254 {loadingBookmarks ? (
255 <div className="feed-container">
256 <div className="feed">
257 {[1, 2, 3].map((i) => (
258 <div key={i} className="card">
259 <div
260 className="skeleton skeleton-text"
261 style={{ width: "40%" }}
262 ></div>
263 <div className="skeleton skeleton-text"></div>
264 <div
265 className="skeleton skeleton-text"
266 style={{ width: "60%" }}
267 ></div>
268 </div>
269 ))}
270 </div>
271 </div>
272 ) : error ? (
273 <div className="empty-state">
274 <div className="empty-state-icon">⚠️</div>
275 <h3 className="empty-state-title">Error loading bookmarks</h3>
276 <p className="empty-state-text">{error}</p>
277 </div>
278 ) : bookmarks.length === 0 ? (
279 <div className="empty-state">
280 <div className="empty-state-icon">
281 <BookmarkIcon size={32} />
282 </div>
283 <h3 className="empty-state-title">No bookmarks yet</h3>
284 <p className="empty-state-text">
285 Click "Add Bookmark" above to save a page, or use the
286 browser extension.
287 </p>
288 </div>
289 ) : (
290 <div className="feed-container">
291 <div className="feed">
292 {bookmarks.map((bookmark) => {
293 if (bookmark.type === "CollectionItem") {
294 return (
295 <CollectionItemCard
296 key={bookmark.id}
297 item={bookmark}
298 onAddToCollection={(uri) =>
299 setCollectionModalState({
300 isOpen: true,
301 uri: uri,
302 })
303 }
304 />
305 );
306 }
307 return (
308 <BookmarkCard
309 key={bookmark.id}
310 bookmark={bookmark}
311 onDelete={handleDelete}
312 onAddToCollection={() =>
313 setCollectionModalState({
314 isOpen: true,
315 uri: bookmark.uri || bookmark.id,
316 })
317 }
318 />
319 );
320 })}
321 </div>
322 </div>
323 )}
324 {collectionModalState.isOpen && (
325 <AddToCollectionModal
326 isOpen={collectionModalState.isOpen}
327 onClose={() => setCollectionModalState({ isOpen: false, uri: null })}
328 annotationUri={collectionModalState.uri}
329 />
330 )}
331 </div>
332 );
333}