Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 333 lines 10 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 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&apos;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 &quot;Add Bookmark&quot; 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}