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