Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 9.4 kB view raw
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&apos;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 &quot;Add Bookmark&quot; 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}