this repo has no description
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

oauth

+771 -671
+15
client-metadata.json
··· 1 + { 2 + "client_id": "https://boomarks.netlify.app/client-metadata.json", 3 + "client_name": "boomarks", 4 + "client_uri": "https://boomarks.netlify.app", 5 + "logo_uri": "https://boomarks.netlify.app/favicon.svg", 6 + "tos_uri": "https://boomarks.netlify.app", 7 + "policy_uri": "https://boomarks.netlify.app", 8 + "redirect_uris": ["https://boomarks.netlify.app/"], 9 + "scope": "atproto transition:generic", 10 + "grant_types": ["authorization_code", "refresh_token"], 11 + "response_types": ["code"], 12 + "token_endpoint_auth_method": "none", 13 + "application_type": "web", 14 + "dpop_bound_access_tokens": true 15 + }
+4 -18
index.html
··· 27 27 placeholder="user.bsky.social" 28 28 /> 29 29 </div> 30 - <div class="param-group"> 31 - <label for="passwordInput" class="param-label">Password</label> 32 - <input 33 - type="password" 34 - id="passwordInput" 35 - class="param-input" 36 - /> 37 - </div> 38 - <div class="param-group"> 39 - <label for="guestSearchInput" class="param-label">Or view someone's bookmarks without login</label> 40 - <input 41 - type="text" 42 - id="guestSearchInput" 43 - class="param-input" 44 - placeholder="username.bsky.social" 45 - /> 46 - </div> 47 30 <menu class="param-menu"> 48 31 <button id="loginBtn" type="button" class="param-btn dark">Login</button> 49 32 <button id="guestViewBtn" type="button" class="param-btn">View Bookmarks</button> ··· 144 127 </dialog> 145 128 </body> 146 129 <script type="module"> 147 - import { AtpAgent } from 'https://esm.sh/@atproto/api'; 130 + import { AtpAgent, Agent } from 'https://esm.sh/@atproto/api'; 131 + import { BrowserOAuthClient } from 'https://esm.sh/@atproto/oauth-client-browser'; 148 132 window.AtpAgent = AtpAgent; 133 + window.Agent = Agent; 134 + window.BrowserOAuthClient = BrowserOAuthClient; 149 135 </script> 150 136 <script async src="./script.js"></script> 151 137 </html>
+752 -653
script.js
··· 10 10 const EST_CHAR_WIDTH = 0.6; // em 11 11 const HYPHENATE_THRESHOLD = 12; 12 12 const COLOR_PAIRS = [ 13 - ["#D1F257", "#0D0D0D"], ["#F2BBDF", "#D94E41"], ["#010D00", "#33A63B"], 14 - ["#F2E4E4", "#0D0C00"], ["#2561D9", "#F2FDFE"], ["#734c48", "#F2F2EB"], 15 - ["#8FBFAE", "#127357"], ["#3A8C5D", "#F2BFAC"], ["#8AA3A6", "#F2F0E4"], 16 - ["#F2C438", "#F23E2E"], ["#455919", "#F2D338"], ["#F2D8A7", "#F26363"], 17 - ["#260101", "#D93223"], ["#456EBF", "#F2F1E9"], ["#131E40", "#F2A413"], 18 - ["#F2F2F2", "#131E40"], ["#262626", "#F2EDDC"], ["#40593C", "#F2E6D0"], 19 - ["#F2F1DF", "#262416"], ["#F2CB05", "#0D0D0D"], ["#F2F2F2", "#F2CB05"], 20 - ["#F2E6D0", "#261C10"], ["#F2D7D0", "#262523"], ["#F2F0D8", "#F24535"], 21 - ["#191726", "#D9D9D9"], ["#F2E8D5", "#0C06BF"], ["#F2EFE9", "#45BFB3"], 22 - ["#F2C2C2", "#D93644"], ["#734C48", "#F2C2C2"], 13 + ["#D1F257", "#0D0D0D"], ["#F2BBDF", "#D94E41"], ["#010D00", "#33A63B"], 14 + ["#F2E4E4", "#0D0C00"], ["#2561D9", "#F2FDFE"], ["#734c48", "#F2F2EB"], 15 + ["#8FBFAE", "#127357"], ["#3A8C5D", "#F2BFAC"], ["#8AA3A6", "#F2F0E4"], 16 + ["#F2C438", "#F23E2E"], ["#455919", "#F2D338"], ["#F2D8A7", "#F26363"], 17 + ["#260101", "#D93223"], ["#456EBF", "#F2F1E9"], ["#131E40", "#F2A413"], 18 + ["#F2F2F2", "#131E40"], ["#262626", "#F2EDDC"], ["#40593C", "#F2E6D0"], 19 + ["#F2F1DF", "#262416"], ["#F2CB05", "#0D0D0D"], ["#F2F2F2", "#F2CB05"], 20 + ["#F2E6D0", "#261C10"], ["#F2D7D0", "#262523"], ["#F2F0D8", "#F24535"], 21 + ["#191726", "#D9D9D9"], ["#F2E8D5", "#0C06BF"], ["#F2EFE9", "#45BFB3"], 22 + ["#F2C2C2", "#D93644"], ["#734C48", "#F2C2C2"], 23 23 ]; 24 24 25 25 const FONT_LIST = [ 26 - "Caveat", "Permanent Marker", "Courier", "Doto", "Bree Serif", 27 - "Ultra", "Alfa Slab One", "Sedan SC", "EB Garamond", "Bebas Neue", 26 + "Caveat", "Permanent Marker", "Courier", "Doto", "Bree Serif", 27 + "Ultra", "Alfa Slab One", "Sedan SC", "EB Garamond", "Bebas Neue", 28 28 ]; 29 29 30 30 // State variables 31 31 let atpAgent = null; 32 + let oauthClient = null; 32 33 let userDid = null; 33 34 let bookmarks = []; 34 35 let reversedOrder = false; ··· 41 42 // ====== DOM Elements ====== 42 43 const loginDialog = document.getElementById("loginDialog"); 43 44 const handleInput = document.getElementById("handleInput"); 44 - const passwordInput = document.getElementById("passwordInput"); 45 45 const loginBtn = document.getElementById("loginBtn"); 46 46 const logoutBtn = document.getElementById("logoutBtn"); 47 47 const userAvatar = document.getElementById("userAvatar"); ··· 59 59 const viewToggleBtn = document.getElementById("viewToggleBtn"); 60 60 const userSearchInput = document.getElementById("userSearchInput"); 61 61 const viewingUser = document.getElementById("viewingUser"); 62 - const guestSearchInput = document.getElementById("guestSearchInput"); 62 + // guestSearchInput removed - using handleInput for both login and guest 63 63 const guestViewBtn = document.getElementById("guestViewBtn"); 64 64 65 65 // ====== AT Protocol Functions ====== ··· 68 68 * Resolve handle to DID and PDS 69 69 */ 70 70 async function resolveHandle(handle) { 71 - if (!atpAgent && !window.AtpAgent) return null; 72 - 73 - try { 74 - const agent = atpAgent || new window.AtpAgent({ 75 - service: "https://bsky.social", 76 - }); 77 - 78 - // First resolve handle to DID 79 - const response = await agent.com.atproto.identity.resolveHandle({ 80 - handle: handle.replace('@', '') 81 - }); 82 - 83 - const did = response.data.did; 84 - 85 - // Now resolve DID to get PDS URL 86 - const didDoc = await fetch(`https://plc.directory/${did}`).then(res => res.json()); 87 - 88 - // Find the PDS service endpoint 89 - let pdsUrl = "https://bsky.social"; // fallback 90 - if (didDoc.service) { 91 - const pdsService = didDoc.service.find(s => s.type === "AtprotoPersonalDataServer"); 92 - if (pdsService && pdsService.serviceEndpoint) { 93 - pdsUrl = pdsService.serviceEndpoint; 94 - } 95 - } 96 - 97 - return { did, pdsUrl }; 98 - } catch (error) { 99 - console.error("Failed to resolve handle:", error); 100 - return null; 101 - } 71 + if (!atpAgent && !window.AtpAgent) return null; 72 + 73 + try { 74 + const agent = atpAgent || new window.AtpAgent({ 75 + service: "https://bsky.social", 76 + }); 77 + 78 + // First resolve handle to DID 79 + const response = await agent.com.atproto.identity.resolveHandle({ 80 + handle: handle.replace('@', '') 81 + }); 82 + 83 + const did = response.data.did; 84 + 85 + // Now resolve DID to get PDS URL 86 + const didDoc = await fetch(`https://plc.directory/${did}`).then(res => res.json()); 87 + 88 + // Find the PDS service endpoint 89 + let pdsUrl = "https://bsky.social"; // fallback 90 + if (didDoc.service) { 91 + const pdsService = didDoc.service.find(s => s.type === "AtprotoPersonalDataServer"); 92 + if (pdsService && pdsService.serviceEndpoint) { 93 + pdsUrl = pdsService.serviceEndpoint; 94 + } 95 + } 96 + 97 + return { did, pdsUrl }; 98 + } catch (error) { 99 + console.error("Failed to resolve handle:", error); 100 + return null; 101 + } 102 102 } 103 103 104 104 /** 105 - * Initialize AT Protocol agent with stored session 105 + * Get client ID based on environment 106 106 */ 107 - async function initializeATProto() { 108 - const session = localStorage.getItem("atproto_session"); 109 - if (!session) { 110 - showLoginDialog(); 111 - return false; 112 - } 107 + function getClientId() { 108 + const hostname = window.location.hostname; 109 + if (hostname === 'localhost' || hostname === '127.0.0.1') { 110 + const port = window.location.port || '8080'; 111 + const params = new URLSearchParams({ 112 + scope: 'atproto transition:generic', 113 + redirect_uri: `http://127.0.0.1:${port}/` 114 + }); 115 + return `http://localhost?${params}`; 116 + } 117 + return 'https://boomarks.netlify.app/client-metadata.json'; 118 + } 113 119 114 - try { 115 - atpAgent = new window.AtpAgent({ 116 - service: "https://bsky.social", 117 - }); 118 - 119 - await atpAgent.resumeSession(JSON.parse(session)); 120 - userDid = atpAgent.session.did; 121 - 122 - await updateUIForLoggedInState(); 123 - await loadBookmarks(); 124 - return true; 125 - } catch (error) { 126 - console.error("Failed to resume session:", error); 127 - localStorage.removeItem("atproto_session"); 128 - updateUIForLoggedOutState(); 129 - return false; 130 - } 120 + /** 121 + * Initialize OAuth client and check for existing session 122 + */ 123 + async function initializeOAuth() { 124 + const clientId = getClientId(); 125 + console.log("Initializing OAuth client with ID:", clientId); 126 + 127 + try { 128 + const hostname = window.location.hostname; 129 + oauthClient = await window.BrowserOAuthClient.load({ 130 + clientId: clientId, 131 + handleResolver: 'https://bsky.social', 132 + allowHttp: hostname === 'localhost' || hostname === '127.0.0.1' 133 + }); 134 + console.log("OAuth client loaded successfully:", oauthClient); 135 + } catch (error) { 136 + console.error("Failed to load OAuth client:", error); 137 + showLoginDialog(); 138 + return false; 139 + } 140 + 141 + // Clear any old app password session data that might conflict 142 + localStorage.removeItem("atproto_session"); 143 + 144 + // Use init() to handle both callbacks and session restoration 145 + try { 146 + const result = await oauthClient.init(); 147 + if (result) { 148 + console.log("OAuth init result:", result); 149 + const session = result.session; 150 + atpAgent = new window.Agent(session); 151 + userDid = session.sub; 152 + 153 + // Clear URL parameters if this was a callback 154 + const urlParams = new URLSearchParams(window.location.search); 155 + if (urlParams.has('code') || urlParams.has('error')) { 156 + window.history.replaceState({}, document.title, window.location.pathname); 157 + } 158 + 159 + await updateUIForLoggedInState(); 160 + await loadBookmarks(); 161 + return true; 162 + } 163 + } catch (error) { 164 + console.error("Failed to initialize OAuth:", error); 165 + } 166 + 167 + showLoginDialog(); 168 + return false; 131 169 } 132 170 133 171 /** 134 - * Login to AT Protocol 172 + * Start OAuth login flow 135 173 */ 136 - async function login() { 137 - const handle = handleInput.value.trim(); 138 - const password = passwordInput.value.trim(); 174 + async function startOAuthLogin() { 175 + let handle = handleInput.value.trim(); 176 + if (!handle) return; 139 177 140 - if (!handle || !password) return; 178 + // Strip @ prefix if present 179 + if (handle.startsWith('@')) { 180 + handle = handle.slice(1); 181 + } 141 182 142 - try { 143 - atpAgent = new window.AtpAgent({ 144 - service: "https://bsky.social", 145 - }); 183 + console.log("Starting OAuth login for handle:", handle); 184 + console.log("OAuth client:", oauthClient); 146 185 147 - await atpAgent.login({ 148 - identifier: handle, 149 - password: password, 150 - }); 186 + // If OAuth client is null (e.g., after logout), reinitialize it 187 + if (!oauthClient) { 188 + console.log("OAuth client is null, reinitializing..."); 189 + await initializeOAuth(); 190 + if (!oauthClient) { 191 + throw new Error("Failed to initialize OAuth client"); 192 + } 193 + } 151 194 152 - userDid = atpAgent.session.did; 153 - localStorage.setItem("atproto_session", JSON.stringify(atpAgent.session)); 154 - 155 - loginDialog.close(); 156 - await updateUIForLoggedInState(); 157 - await loadBookmarks(); 158 - } catch (error) { 159 - console.error("Login failed:", error); 160 - alert("Login failed. Please check your credentials."); 161 - } 195 + try { 196 + // Use signIn method like the reference implementation 197 + const session = await oauthClient.signIn(handle, { 198 + scope: 'atproto transition:generic' 199 + }); 200 + 201 + console.log("Login successful:", session); 202 + 203 + // Set up authenticated agent 204 + atpAgent = new window.AtpAgent({ service: session.pds }); 205 + await atpAgent.configure({ 206 + service: session.pds, 207 + accessToken: session.accessToken 208 + }); 209 + 210 + userDid = session.sub; 211 + loginDialog.close(); 212 + await updateUIForLoggedInState(); 213 + await loadBookmarks(); 214 + } catch (error) { 215 + console.error("OAuth login failed:", error); 216 + console.error("Error details:", error.message, error.stack); 217 + alert(`Failed to login: ${error.message}`); 218 + } 219 + } 220 + 221 + /** 222 + * Handle OAuth callback after redirect 223 + */ 224 + async function handleOAuthCallback() { 225 + try { 226 + const result = await oauthClient.callback(window.location.href); 227 + 228 + // Create authenticated AtpAgent 229 + atpAgent = new window.AtpAgent({ service: result.pds }); 230 + await atpAgent.configure({ 231 + service: result.pds, 232 + accessToken: result.accessToken 233 + }); 234 + 235 + userDid = result.sub; 236 + 237 + // Clear URL parameters 238 + window.history.replaceState({}, document.title, window.location.pathname); 239 + 240 + await updateUIForLoggedInState(); 241 + await loadBookmarks(); 242 + return true; 243 + } catch (error) { 244 + console.error("OAuth callback failed:", error); 245 + alert("Login failed. Please try again."); 246 + showLoginDialog(); 247 + return false; 248 + } 162 249 } 163 250 164 251 /** 165 252 * Fetch user profile information 166 253 */ 167 254 async function fetchUserProfile(did) { 168 - // Try to use the logged-in agent first, fallback to public agent 169 - let agent = atpAgent; 170 - if (!agent) { 171 - agent = new window.AtpAgent({ 172 - service: "https://bsky.social", 173 - }); 174 - } 175 - 176 - try { 177 - const response = await agent.getProfile({ actor: did }); 178 - return response.data; 179 - } catch (error) { 180 - console.error("Failed to fetch user profile:", error); 181 - return null; 182 - } 255 + // Try to use the logged-in agent first, fallback to public agent 256 + let agent = atpAgent; 257 + if (!agent) { 258 + agent = new window.AtpAgent({ 259 + service: "https://bsky.social", 260 + }); 261 + } 262 + 263 + try { 264 + const response = await agent.getProfile({ actor: did }); 265 + return response.data; 266 + } catch (error) { 267 + console.error("Failed to fetch user profile:", error); 268 + return null; 269 + } 183 270 } 184 271 185 272 /** 186 - * Logout from AT Protocol 273 + * Logout from OAuth session 187 274 */ 188 275 async function logout() { 189 - if (atpAgent) { 190 - try { 191 - await atpAgent.com.atproto.session.delete(); 192 - } catch (error) { 193 - console.error("Logout error:", error); 194 - } 195 - } 196 - 197 - atpAgent = null; 198 - userDid = null; 199 - bookmarks = []; 200 - localStorage.removeItem("atproto_session"); 201 - updateUIForLoggedOutState(); 276 + if (oauthClient) { 277 + try { 278 + await oauthClient.revoke(); 279 + } catch (error) { 280 + console.error("Logout error:", error); 281 + } 282 + } 283 + 284 + oauthClient = null; 285 + atpAgent = null; 286 + userDid = null; 287 + bookmarks = []; 288 + isViewingOtherUser = false; 289 + viewingUserDid = null; 290 + viewingUserHandle = null; 291 + 292 + updateUIForLoggedOutState(); 293 + showLoginDialog(); 202 294 } 203 295 204 296 /** 205 297 * Load bookmarks from PDS 206 298 */ 207 299 async function loadBookmarks(targetDid = null, targetPdsUrl = null) { 208 - const did = targetDid || userDid; 209 - if (!did) return; 210 - 211 - // Create agent if needed for public access 212 - let agent = atpAgent; 213 - if (!agent || targetPdsUrl) { 214 - const serviceUrl = targetPdsUrl || "https://bsky.social"; 215 - agent = new window.AtpAgent({ 216 - service: serviceUrl, 217 - }); 218 - } 300 + const did = targetDid || userDid; 301 + if (!did) return; 302 + 303 + // Create agent if needed for public access 304 + let agent = atpAgent; 305 + if (!agent || targetPdsUrl) { 306 + const serviceUrl = targetPdsUrl || "https://bsky.social"; 307 + agent = new window.AtpAgent({ 308 + service: serviceUrl, 309 + }); 310 + } 311 + 312 + try { 313 + // First try to describe the repo to see if it exists 314 + try { 315 + await agent.com.atproto.repo.describeRepo({ 316 + repo: did, 317 + }); 318 + } catch (describeError) { 319 + console.error("Repo describe failed:", describeError); 320 + bookmarks = []; 321 + renderBookmarks(); 322 + alert("User has no bookmarks or bookmarks are not accessible"); 323 + return; 324 + } 219 325 220 - try { 221 - // First try to describe the repo to see if it exists 222 - try { 223 - await agent.com.atproto.repo.describeRepo({ 224 - repo: did, 225 - }); 226 - } catch (describeError) { 227 - console.error("Repo describe failed:", describeError); 228 - bookmarks = []; 229 - renderBookmarks(); 230 - alert("User has no bookmarks or bookmarks are not accessible"); 231 - return; 232 - } 233 - 234 - const response = await agent.com.atproto.repo.listRecords({ 235 - repo: did, 236 - collection: BOOKMARK_LEXICON, 237 - }); 326 + const response = await agent.com.atproto.repo.listRecords({ 327 + repo: did, 328 + collection: BOOKMARK_LEXICON, 329 + }); 238 330 239 - bookmarks = response.data.records.map(record => ({ 240 - atUri: record.uri, // AT Protocol record URI 241 - cid: record.cid, 242 - ...record.value // Contains subject, title, tags, etc. 243 - })); 331 + bookmarks = response.data.records.map(record => ({ 332 + atUri: record.uri, // AT Protocol record URI 333 + cid: record.cid, 334 + ...record.value // Contains subject, title, tags, etc. 335 + })); 244 336 245 - renderBookmarks(); 246 - } catch (error) { 247 - console.error("Failed to load bookmarks:", error); 248 - if (error.message?.includes("Could not find repo") || error.message?.includes("not found") || error.message?.includes("RecordNotFound")) { 249 - bookmarks = []; 250 - renderBookmarks(); 251 - alert("User has no bookmarks with this lexicon"); 252 - } 253 - } 337 + renderBookmarks(); 338 + } catch (error) { 339 + console.error("Failed to load bookmarks:", error); 340 + if (error.message?.includes("Could not find repo") || error.message?.includes("not found") || error.message?.includes("RecordNotFound")) { 341 + bookmarks = []; 342 + renderBookmarks(); 343 + alert("User has no bookmarks with this lexicon"); 344 + } 345 + } 254 346 } 255 347 256 348 /** 257 349 * Save a bookmark to PDS 258 350 */ 259 351 async function saveBookmark() { 260 - const title = titleInput.value.trim(); 261 - const url = urlInput.value.trim(); 262 - const rawTags = tagsInput.value.trim(); 352 + const title = titleInput.value.trim(); 353 + const url = urlInput.value.trim(); 354 + const rawTags = tagsInput.value.trim(); 263 355 264 - if (!url || !atpAgent || !userDid) return; 356 + if (!url || !atpAgent || !userDid) return; 265 357 266 - const tags = rawTags.split(",").map(t => t.trim()).filter(Boolean); 267 - 268 - const bookmarkRecord = { 269 - $type: BOOKMARK_LEXICON, 270 - subject: url, 271 - tags, 272 - createdAt: new Date().toISOString(), 273 - }; 274 - 275 - // Add optional title if provided 276 - if (title) { 277 - bookmarkRecord.title = title; 278 - } 358 + const tags = rawTags.split(",").map(t => t.trim()).filter(Boolean); 279 359 280 - try { 281 - const response = await atpAgent.com.atproto.repo.createRecord({ 282 - repo: userDid, 283 - collection: BOOKMARK_LEXICON, 284 - record: bookmarkRecord, 285 - }); 360 + const bookmarkRecord = { 361 + $type: BOOKMARK_LEXICON, 362 + subject: url, 363 + tags, 364 + createdAt: new Date().toISOString(), 365 + }; 286 366 287 - // Add to local array 288 - bookmarks.push({ 289 - atUri: response.data.uri, 290 - cid: response.data.cid, 291 - ...bookmarkRecord 292 - }); 367 + // Add optional title if provided 368 + if (title) { 369 + bookmarkRecord.title = title; 370 + } 293 371 294 - renderBookmarks(); 295 - dialog.close(); 296 - 297 - // Clear URL params and reload to clean state 298 - window.history.replaceState({}, document.title, window.location.pathname); 299 - } catch (error) { 300 - console.error("Failed to save bookmark:", error); 301 - alert("Failed to save bookmark. Please try again."); 302 - } 372 + try { 373 + const response = await atpAgent.com.atproto.repo.createRecord({ 374 + repo: userDid, 375 + collection: BOOKMARK_LEXICON, 376 + record: bookmarkRecord, 377 + }); 378 + 379 + // Add to local array 380 + bookmarks.push({ 381 + atUri: response.data.uri, 382 + cid: response.data.cid, 383 + ...bookmarkRecord 384 + }); 385 + 386 + renderBookmarks(); 387 + dialog.close(); 388 + 389 + // Clear URL params and reload to clean state 390 + window.history.replaceState({}, document.title, window.location.pathname); 391 + } catch (error) { 392 + console.error("Failed to save bookmark:", error); 393 + alert("Failed to save bookmark. Please try again."); 394 + } 303 395 } 304 396 305 397 /** 306 398 * Delete a bookmark from PDS 307 399 */ 308 400 async function deleteBookmark(uri) { 309 - if (!atpAgent || !userDid) return; 401 + if (!atpAgent || !userDid) return; 310 402 311 - try { 312 - console.log("Deleting bookmark with URI:", uri); 313 - const rkey = uri.split("/").pop(); 314 - console.log("Extracted rkey:", rkey); 315 - 316 - const deleteParams = { 317 - repo: userDid, 318 - collection: BOOKMARK_LEXICON, 319 - rkey, 320 - }; 321 - console.log("Delete parameters:", deleteParams); 322 - 323 - const result = await atpAgent.com.atproto.repo.deleteRecord(deleteParams); 324 - console.log("Delete result:", result); 403 + try { 404 + console.log("Deleting bookmark with URI:", uri); 405 + const rkey = uri.split("/").pop(); 406 + console.log("Extracted rkey:", rkey); 407 + 408 + const deleteParams = { 409 + repo: userDid, 410 + collection: BOOKMARK_LEXICON, 411 + rkey, 412 + }; 413 + console.log("Delete parameters:", deleteParams); 414 + 415 + const result = await atpAgent.com.atproto.repo.deleteRecord(deleteParams); 416 + console.log("Delete result:", result); 417 + 418 + console.log("Successfully deleted from PDS"); 419 + 420 + // Remove from local array 421 + const beforeCount = bookmarks.length; 422 + bookmarks = bookmarks.filter(bookmark => bookmark.atUri !== uri); 423 + console.log(`Removed from local array: ${beforeCount} -> ${bookmarks.length}`); 325 424 326 - console.log("Successfully deleted from PDS"); 327 - 328 - // Remove from local array 329 - const beforeCount = bookmarks.length; 330 - bookmarks = bookmarks.filter(bookmark => bookmark.atUri !== uri); 331 - console.log(`Removed from local array: ${beforeCount} -> ${bookmarks.length}`); 332 - 333 - renderBookmarks(); 334 - } catch (error) { 335 - console.error("Failed to delete bookmark:", error); 336 - alert("Failed to delete bookmark: " + error.message); 337 - } 425 + renderBookmarks(); 426 + } catch (error) { 427 + console.error("Failed to delete bookmark:", error); 428 + alert("Failed to delete bookmark: " + error.message); 429 + } 338 430 } 339 431 340 432 // ====== UI Functions ====== 341 433 342 434 async function updateUIForLoggedInState() { 343 - if (!userDid || !atpAgent) return; 344 - 345 - // Fetch and display user avatar 346 - const profile = await fetchUserProfile(userDid); 347 - if (profile && profile.avatar) { 348 - userAvatar.src = profile.avatar; 349 - userAvatar.style.display = "inline-block"; 350 - } else { 351 - userAvatar.style.display = "none"; 352 - } 353 - 354 - // Update button to show logout 355 - logoutBtn.textContent = "Logout"; 356 - logoutBtn.style.display = "inline-block"; 357 - 358 - showMainUI(); 435 + if (!userDid || !atpAgent) return; 436 + 437 + // Fetch and display user avatar 438 + const profile = await fetchUserProfile(userDid); 439 + if (profile && profile.avatar) { 440 + userAvatar.src = profile.avatar; 441 + userAvatar.style.display = "inline-block"; 442 + } else { 443 + userAvatar.style.display = "none"; 444 + } 445 + 446 + // Update button to show logout 447 + logoutBtn.textContent = "Logout"; 448 + logoutBtn.style.display = "inline-block"; 449 + 450 + showMainUI(); 359 451 } 360 452 361 453 function updateUIForLoggedOutState() { 362 - // Hide avatar 363 - userAvatar.style.display = "none"; 364 - 365 - // Update button to show login 366 - logoutBtn.textContent = "Login"; 367 - logoutBtn.style.display = "inline-block"; 368 - 369 - showLoginDialog(); 454 + // Hide avatar 455 + userAvatar.style.display = "none"; 456 + 457 + // Update button to show login 458 + logoutBtn.textContent = "Login"; 459 + logoutBtn.style.display = "inline-block"; 460 + 461 + showLoginDialog(); 370 462 } 371 463 372 464 function showLoginDialog() { 373 - loginDialog.showModal(); 374 - openEmptyDialogBtn.style.display = "none"; 375 - sortToggleBtn.style.display = "none"; 376 - viewToggleBtn.style.display = "none"; 377 - searchInput.style.display = "none"; 465 + loginDialog.showModal(); 466 + openEmptyDialogBtn.style.display = "none"; 467 + sortToggleBtn.style.display = "none"; 468 + viewToggleBtn.style.display = "none"; 469 + searchInput.style.display = "none"; 378 470 } 379 471 380 472 function showMainUI() { 381 - openEmptyDialogBtn.style.display = isViewingOtherUser ? "none" : "inline-block"; 382 - sortToggleBtn.style.display = "inline-block"; 383 - viewToggleBtn.style.display = "inline-block"; 384 - searchInput.style.display = "inline-block"; 385 - userSearchInput.style.display = "inline-block"; 473 + openEmptyDialogBtn.style.display = isViewingOtherUser ? "none" : "inline-block"; 474 + sortToggleBtn.style.display = "inline-block"; 475 + viewToggleBtn.style.display = "inline-block"; 476 + searchInput.style.display = "inline-block"; 477 + userSearchInput.style.display = "inline-block"; 386 478 } 387 479 388 480 function updateViewingUserUI() { 389 - if (isViewingOtherUser) { 390 - // Don't show "Viewing: ..." text anymore 391 - viewingUser.style.display = "none"; 392 - openEmptyDialogBtn.style.display = "none"; 393 - // Show searched user avatar if we have profile data 394 - if (currentSearchedUserProfile && currentSearchedUserProfile.avatar) { 395 - searchedUserAvatar.src = currentSearchedUserProfile.avatar; 396 - searchedUserAvatar.style.display = "inline-block"; 397 - } 398 - } else { 399 - viewingUser.style.display = "none"; 400 - openEmptyDialogBtn.style.display = atpAgent ? "inline-block" : "none"; 401 - searchedUserAvatar.style.display = "none"; // Hide searched user avatar when back to own bookmarks 402 - currentSearchedUserProfile = null; 403 - } 481 + if (isViewingOtherUser) { 482 + // Don't show "Viewing: ..." text anymore 483 + viewingUser.style.display = "none"; 484 + openEmptyDialogBtn.style.display = "none"; 485 + // Show searched user avatar if we have profile data 486 + if (currentSearchedUserProfile && currentSearchedUserProfile.avatar) { 487 + searchedUserAvatar.src = currentSearchedUserProfile.avatar; 488 + searchedUserAvatar.style.display = "inline-block"; 489 + } 490 + } else { 491 + viewingUser.style.display = "none"; 492 + openEmptyDialogBtn.style.display = atpAgent ? "inline-block" : "none"; 493 + searchedUserAvatar.style.display = "none"; // Hide searched user avatar when back to own bookmarks 494 + currentSearchedUserProfile = null; 495 + } 404 496 } 405 497 406 498 // ====== Utility Functions ====== ··· 409 501 * Hashes a string to a non-negative 32-bit integer. 410 502 */ 411 503 function hashString(str) { 412 - let hash = 0; 413 - for (let i = 0; i < str.length; i++) { 414 - hash = (hash << 5) - hash + str.charCodeAt(i); 415 - hash |= 0; 416 - } 417 - return Math.abs(hash); 504 + let hash = 0; 505 + for (let i = 0; i < str.length; i++) { 506 + hash = (hash << 5) - hash + str.charCodeAt(i); 507 + hash |= 0; 508 + } 509 + return Math.abs(hash); 418 510 } 419 511 420 512 /** 421 513 * Get a color pair deterministically by title. 422 514 */ 423 515 function getColorPairByTitle(title, pairs) { 424 - const hash = hashString(title); 425 - const idx = hash % pairs.length; 426 - const [bg, fg] = pairs[idx]; 427 - return (hash % 2 === 0) ? [bg, fg] : [fg, bg]; 516 + const hash = hashString(title); 517 + const idx = hash % pairs.length; 518 + const [bg, fg] = pairs[idx]; 519 + return (hash % 2 === 0) ? [bg, fg] : [fg, bg]; 428 520 } 429 521 430 522 /** 431 523 * Get a font family deterministically by title. 432 524 */ 433 525 function getFontByTitle(title, fonts) { 434 - return fonts[hashString(title) % fonts.length]; 526 + return fonts[hashString(title) % fonts.length]; 435 527 } 436 528 437 529 /** 438 530 * Format date as natural language for recent dates, otherwise as regular date 439 531 */ 440 532 function formatNaturalDate(dateString) { 441 - if (!dateString) return ''; 442 - 443 - const date = new Date(dateString); 444 - const now = new Date(); 445 - const diffTime = now.getTime() - date.getTime(); 446 - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); 447 - 448 - // If it's within the last month (30 days) 449 - if (diffDays < 30) { 450 - if (diffDays === 0) { 451 - return 'today'; 452 - } else if (diffDays === 1) { 453 - return 'yesterday'; 454 - } else { 455 - return `${diffDays} days ago`; 456 - } 457 - } 458 - 459 - // For older dates, show the actual date 460 - return date.toLocaleDateString('en-US', { 461 - year: 'numeric', 462 - month: 'short', 463 - day: 'numeric' 464 - }); 533 + if (!dateString) return ''; 534 + 535 + const date = new Date(dateString); 536 + const now = new Date(); 537 + const diffTime = now.getTime() - date.getTime(); 538 + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); 539 + 540 + // If it's within the last month (30 days) 541 + if (diffDays < 30) { 542 + if (diffDays === 0) { 543 + return 'today'; 544 + } else if (diffDays === 1) { 545 + return 'yesterday'; 546 + } else { 547 + return `${diffDays} days ago`; 548 + } 549 + } 550 + 551 + // For older dates, show the actual date 552 + return date.toLocaleDateString('en-US', { 553 + year: 'numeric', 554 + month: 'short', 555 + day: 'numeric' 556 + }); 465 557 } 466 558 467 559 // ====== Rendering Functions ====== ··· 470 562 * Renders bookmarks in list view 471 563 */ 472 564 function renderListView() { 473 - const containerWrapper = document.querySelector(".containers"); 474 - containerWrapper.innerHTML = ""; 565 + const containerWrapper = document.querySelector(".containers"); 566 + containerWrapper.innerHTML = ""; 475 567 476 - const fragment = document.createDocumentFragment(); 477 - const displayBookmarks = reversedOrder ? bookmarks : [...bookmarks].reverse(); 568 + const fragment = document.createDocumentFragment(); 569 + const displayBookmarks = reversedOrder ? bookmarks : [...bookmarks].reverse(); 478 570 479 - displayBookmarks.forEach(bookmark => { 480 - const title = bookmark.title || bookmark.subject; 481 - const url = bookmark.subject || bookmark.uri; 482 - const tags = bookmark.tags || []; 483 - const createdAt = bookmark.createdAt; 571 + displayBookmarks.forEach(bookmark => { 572 + const title = bookmark.title || bookmark.subject; 573 + const url = bookmark.subject || bookmark.uri; 574 + const tags = bookmark.tags || []; 575 + const createdAt = bookmark.createdAt; 484 576 485 - if (!url) return; 577 + if (!url) return; 486 578 487 - const displayTitle = title.replace(/^https?:\/\/(www\.)?/i, ""); 488 - 489 - // Create list item 490 - const listItem = document.createElement("div"); 491 - listItem.className = "bookmark-item"; 579 + const displayTitle = title.replace(/^https?:\/\/(www\.)?/i, ""); 492 580 493 - // Content container 494 - const content = document.createElement("div"); 495 - content.className = "bookmark-content"; 581 + // Create list item 582 + const listItem = document.createElement("div"); 583 + listItem.className = "bookmark-item"; 496 584 497 - // Link group (title + URL together, but not date) 498 - const linkGroup = document.createElement("div"); 499 - linkGroup.className = "bookmark-link-group"; 585 + // Content container 586 + const content = document.createElement("div"); 587 + content.className = "bookmark-content"; 500 588 501 - // Title link 502 - const titleLink = document.createElement("a"); 503 - titleLink.className = "bookmark-title"; 504 - titleLink.href = url; 505 - titleLink.target = "_blank"; 506 - titleLink.textContent = displayTitle; 507 - linkGroup.appendChild(titleLink); 589 + // Link group (title + URL together, but not date) 590 + const linkGroup = document.createElement("div"); 591 + linkGroup.className = "bookmark-link-group"; 508 592 509 - // URL-only container (without date) 510 - const urlContainer = document.createElement("div"); 511 - urlContainer.className = "bookmark-url-container"; 512 - 513 - const urlLink = document.createElement("a"); 514 - urlLink.className = "bookmark-url"; 515 - urlLink.href = url; 516 - urlLink.target = "_blank"; 517 - urlLink.textContent = url; 518 - urlLink.style.textDecoration = "none"; 519 - urlLink.style.color = "#666"; 520 - urlContainer.appendChild(urlLink); 593 + // Title link 594 + const titleLink = document.createElement("a"); 595 + titleLink.className = "bookmark-title"; 596 + titleLink.href = url; 597 + titleLink.target = "_blank"; 598 + titleLink.textContent = displayTitle; 599 + linkGroup.appendChild(titleLink); 521 600 522 - linkGroup.appendChild(urlContainer); 523 - content.appendChild(linkGroup); 601 + // URL-only container (without date) 602 + const urlContainer = document.createElement("div"); 603 + urlContainer.className = "bookmark-url-container"; 524 604 525 - // Meta row for date and tags (outside hover group) 526 - const metaRow = document.createElement("div"); 527 - metaRow.className = "bookmark-meta-row"; 605 + const urlLink = document.createElement("a"); 606 + urlLink.className = "bookmark-url"; 607 + urlLink.href = url; 608 + urlLink.target = "_blank"; 609 + urlLink.textContent = url; 610 + urlLink.style.textDecoration = "none"; 611 + urlLink.style.color = "#666"; 612 + urlContainer.appendChild(urlLink); 528 613 529 - // Tags on the left 530 - if (tags.length > 0) { 531 - const tagsDiv = document.createElement("div"); 532 - tagsDiv.className = "bookmark-tags"; 614 + linkGroup.appendChild(urlContainer); 615 + content.appendChild(linkGroup); 533 616 534 - tags.forEach(tag => { 535 - const tagSpan = document.createElement("span"); 536 - tagSpan.className = "bookmark-tag"; 537 - tagSpan.textContent = `#${tag}`; 538 - tagSpan.addEventListener("click", () => filterByTag(tag)); 539 - tagsDiv.appendChild(tagSpan); 540 - }); 617 + // Meta row for date and tags (outside hover group) 618 + const metaRow = document.createElement("div"); 619 + metaRow.className = "bookmark-meta-row"; 541 620 542 - metaRow.appendChild(tagsDiv); 543 - } 621 + // Tags on the left 622 + if (tags.length > 0) { 623 + const tagsDiv = document.createElement("div"); 624 + tagsDiv.className = "bookmark-tags"; 544 625 545 - // Date on the right 546 - if (createdAt) { 547 - const dateDiv = document.createElement("div"); 548 - dateDiv.className = "bookmark-date"; 549 - dateDiv.textContent = formatNaturalDate(createdAt); 550 - metaRow.appendChild(dateDiv); 551 - } 626 + tags.forEach(tag => { 627 + const tagSpan = document.createElement("span"); 628 + tagSpan.className = "bookmark-tag"; 629 + tagSpan.textContent = `#${tag}`; 630 + tagSpan.addEventListener("click", () => filterByTag(tag)); 631 + tagsDiv.appendChild(tagSpan); 632 + }); 552 633 553 - content.appendChild(metaRow); 634 + metaRow.appendChild(tagsDiv); 635 + } 554 636 555 - listItem.appendChild(content); 637 + // Date on the right 638 + if (createdAt) { 639 + const dateDiv = document.createElement("div"); 640 + dateDiv.className = "bookmark-date"; 641 + dateDiv.textContent = formatNaturalDate(createdAt); 642 + metaRow.appendChild(dateDiv); 643 + } 556 644 557 - // Actions (delete button) 558 - if (!isViewingOtherUser) { 559 - const actions = document.createElement("div"); 560 - actions.className = "bookmark-actions"; 645 + content.appendChild(metaRow); 561 646 562 - const deleteBtn = document.createElement("button"); 563 - deleteBtn.className = "delete-btn"; 564 - deleteBtn.textContent = "×"; 565 - deleteBtn.title = "Delete this bookmark"; 566 - deleteBtn.addEventListener("click", e => { 567 - e.stopPropagation(); 568 - e.preventDefault(); 569 - if (confirm("Delete this bookmark?")) { 570 - deleteBookmark(bookmark.atUri); 571 - } 572 - }); 647 + listItem.appendChild(content); 648 + 649 + // Actions (delete button) 650 + if (!isViewingOtherUser) { 651 + const actions = document.createElement("div"); 652 + actions.className = "bookmark-actions"; 653 + 654 + const deleteBtn = document.createElement("button"); 655 + deleteBtn.className = "delete-btn"; 656 + deleteBtn.textContent = "×"; 657 + deleteBtn.title = "Delete this bookmark"; 658 + deleteBtn.addEventListener("click", e => { 659 + e.stopPropagation(); 660 + e.preventDefault(); 661 + if (confirm("Delete this bookmark?")) { 662 + deleteBookmark(bookmark.atUri); 663 + } 664 + }); 573 665 574 - actions.appendChild(deleteBtn); 575 - listItem.appendChild(actions); 576 - } 666 + actions.appendChild(deleteBtn); 667 + listItem.appendChild(actions); 668 + } 577 669 578 - fragment.appendChild(listItem); 579 - }); 670 + fragment.appendChild(listItem); 671 + }); 580 672 581 - containerWrapper.appendChild(fragment); 673 + containerWrapper.appendChild(fragment); 582 674 } 583 675 584 676 /** 585 677 * Renders bookmarks in grid view (original) 586 678 */ 587 679 function renderGridView() { 588 - const containerWrapper = document.querySelector(".containers"); 589 - containerWrapper.innerHTML = ""; 680 + const containerWrapper = document.querySelector(".containers"); 681 + containerWrapper.innerHTML = ""; 590 682 591 - const fragment = document.createDocumentFragment(); 592 - const displayBookmarks = reversedOrder ? bookmarks : [...bookmarks].reverse(); 683 + const fragment = document.createDocumentFragment(); 684 + const displayBookmarks = reversedOrder ? bookmarks : [...bookmarks].reverse(); 593 685 594 - displayBookmarks.forEach(bookmark => { 595 - const title = bookmark.title || bookmark.subject; // fallback to subject as title if no title 596 - const url = bookmark.subject || bookmark.uri; // support both old and new schema 597 - const tags = bookmark.tags || []; 686 + displayBookmarks.forEach(bookmark => { 687 + const title = bookmark.title || bookmark.subject; // fallback to subject as title if no title 688 + const url = bookmark.subject || bookmark.uri; // support both old and new schema 689 + const tags = bookmark.tags || []; 598 690 599 - if (!url) return; 691 + if (!url) return; 600 692 601 - const displayTitle = title.replace(/^https?:\/\/(www\.)?/i, ""); 602 - const [bgColor, fontColor] = getColorPairByTitle(title, COLOR_PAIRS); 603 - const fontFamily = getFontByTitle(title, FONT_LIST); 693 + const displayTitle = title.replace(/^https?:\/\/(www\.)?/i, ""); 694 + const [bgColor, fontColor] = getColorPairByTitle(title, COLOR_PAIRS); 695 + const fontFamily = getFontByTitle(title, FONT_LIST); 604 696 605 - const container = document.createElement("div"); 606 - container.className = "container"; 607 - container.style.backgroundColor = bgColor; 608 - container.style.color = fontColor; 609 - container.style.fontFamily = `'${fontFamily}', sans-serif`; 697 + const container = document.createElement("div"); 698 + container.className = "container"; 699 + container.style.backgroundColor = bgColor; 700 + container.style.color = fontColor; 701 + container.style.fontFamily = `'${fontFamily}', sans-serif`; 610 702 611 - // Delete Button (only show for own bookmarks) 612 - if (!isViewingOtherUser) { 613 - const closeBtn = document.createElement("button"); 614 - closeBtn.className = "delete-btn"; 615 - closeBtn.textContent = "x"; 616 - closeBtn.title = "Delete this bookmark"; 617 - closeBtn.addEventListener("click", e => { 618 - e.stopPropagation(); 619 - e.preventDefault(); 620 - if (confirm("Delete this bookmark?")) { 621 - deleteBookmark(bookmark.atUri); 622 - } 623 - }); 624 - container.appendChild(closeBtn); 625 - } 703 + // Delete Button (only show for own bookmarks) 704 + if (!isViewingOtherUser) { 705 + const closeBtn = document.createElement("button"); 706 + closeBtn.className = "delete-btn"; 707 + closeBtn.textContent = "x"; 708 + closeBtn.title = "Delete this bookmark"; 709 + closeBtn.addEventListener("click", e => { 710 + e.stopPropagation(); 711 + e.preventDefault(); 712 + if (confirm("Delete this bookmark?")) { 713 + deleteBookmark(bookmark.atUri); 714 + } 715 + }); 716 + container.appendChild(closeBtn); 717 + } 626 718 627 - // Anchor (bookmark link) 628 - const anchor = document.createElement("a"); 629 - anchor.href = url; 630 - anchor.target = "_blank"; 631 - anchor.innerHTML = `<span style="font-size: 5vw;"><span>${displayTitle}</span></span>`; 632 - container.appendChild(anchor); 719 + // Anchor (bookmark link) 720 + const anchor = document.createElement("a"); 721 + anchor.href = url; 722 + anchor.target = "_blank"; 723 + anchor.innerHTML = `<span style="font-size: 5vw;"><span>${displayTitle}</span></span>`; 724 + container.appendChild(anchor); 633 725 634 - // Tags 635 - if (tags.length > 0) { 636 - const wrapper = document.createElement("div"); 637 - wrapper.className = "tags-wrapper"; 726 + // Tags 727 + if (tags.length > 0) { 728 + const wrapper = document.createElement("div"); 729 + wrapper.className = "tags-wrapper"; 638 730 639 - tags.forEach(tag => { 640 - const tagDiv = document.createElement("div"); 641 - tagDiv.className = "tags tag-style"; 642 - tagDiv.textContent = `#${tag}`; 643 - tagDiv.addEventListener("click", () => filterByTag(tag)); 644 - wrapper.appendChild(tagDiv); 645 - }); 731 + tags.forEach(tag => { 732 + const tagDiv = document.createElement("div"); 733 + tagDiv.className = "tags tag-style"; 734 + tagDiv.textContent = `#${tag}`; 735 + tagDiv.addEventListener("click", () => filterByTag(tag)); 736 + wrapper.appendChild(tagDiv); 737 + }); 646 738 647 - container.appendChild(wrapper); 648 - } 739 + container.appendChild(wrapper); 740 + } 649 741 650 - fragment.appendChild(container); 651 - }); 742 + fragment.appendChild(container); 743 + }); 652 744 653 - containerWrapper.appendChild(fragment); 654 - runTextFormatting(); 745 + containerWrapper.appendChild(fragment); 746 + runTextFormatting(); 655 747 } 656 748 657 749 /** 658 750 * Renders bookmark containers 659 751 */ 660 752 function renderBookmarks() { 661 - // Toggle body class for CSS styling 662 - document.body.classList.toggle('list-view', isListView); 663 - 664 - if (isListView) { 665 - renderListView(); 666 - } else { 667 - renderGridView(); 668 - } 753 + // Toggle body class for CSS styling 754 + document.body.classList.toggle('list-view', isListView); 755 + 756 + if (isListView) { 757 + renderListView(); 758 + } else { 759 + renderGridView(); 760 + } 669 761 } 670 762 671 763 /** 672 764 * Filter bookmarks by tag 673 765 */ 674 766 function filterByTag(tag) { 675 - searchInput.value = `#${tag}`; 676 - searchInput.dispatchEvent(new Event("input")); 767 + searchInput.value = `#${tag}`; 768 + searchInput.dispatchEvent(new Event("input")); 677 769 } 678 770 679 771 /** 680 772 * Formats text inside containers after rendering 681 773 */ 682 774 function runTextFormatting() { 683 - document.querySelectorAll(".container").forEach(container => { 684 - const anchor = container.querySelector("a"); 685 - if (!anchor) return; 775 + document.querySelectorAll(".container").forEach(container => { 776 + const anchor = container.querySelector("a"); 777 + if (!anchor) return; 686 778 687 - const originalText = anchor.innerText.trim(); 688 - const href = anchor.href; 689 - if (!originalText || !href) return; 779 + const originalText = anchor.innerText.trim(); 780 + const href = anchor.href; 781 + if (!originalText || !href) return; 690 782 691 - anchor.innerHTML = ""; 783 + anchor.innerHTML = ""; 692 784 693 - const formattedText = originalText.replace(/(\s\|\s|\s-\s|\s–\s|\/,)/g, "<hr/>"); 694 - const [firstPart, ...restParts] = formattedText.split("<hr/>"); 695 - const secondPart = restParts.join("<hr/>"); 785 + const formattedText = originalText.replace(/(\s\|\s|\s-\s|\s–\s|\/,)/g, "<hr/>"); 786 + const [firstPart, ...restParts] = formattedText.split("<hr/>"); 787 + const secondPart = restParts.join("<hr/>"); 696 788 697 - const span = document.createElement("span"); 789 + const span = document.createElement("span"); 698 790 699 - let fontSizeVW = 3; 700 - if (originalText.length < 9) fontSizeVW = 6; 701 - else if (originalText.length < 20) fontSizeVW = 5; 702 - else if (originalText.length < 35) fontSizeVW = 4; 703 - else if (originalText.length < 100) fontSizeVW = 3; 704 - else fontSizeVW = 2.5; 791 + let fontSizeVW = 3; 792 + if (originalText.length < 9) fontSizeVW = 6; 793 + else if (originalText.length < 20) fontSizeVW = 5; 794 + else if (originalText.length < 35) fontSizeVW = 4; 795 + else if (originalText.length < 100) fontSizeVW = 3; 796 + else fontSizeVW = 2.5; 705 797 706 - span.style.fontSize = `${fontSizeVW}vw`; 798 + span.style.fontSize = `${fontSizeVW}vw`; 707 799 708 - const firstSpan = document.createElement("span"); 709 - firstSpan.innerHTML = firstPart; 710 - span.appendChild(firstSpan); 800 + const firstSpan = document.createElement("span"); 801 + firstSpan.innerHTML = firstPart; 802 + span.appendChild(firstSpan); 711 803 712 - if (restParts.length) { 713 - const hr = document.createElement("hr"); 714 - hr.classList.add("invisible-hr"); 804 + if (restParts.length) { 805 + const hr = document.createElement("hr"); 806 + hr.classList.add("invisible-hr"); 715 807 716 - const secondSpan = document.createElement("span"); 717 - secondSpan.innerHTML = secondPart; 718 - secondSpan.style.fontSize = `${(fontSizeVW * 2) / 3}vw`; 808 + const secondSpan = document.createElement("span"); 809 + secondSpan.innerHTML = secondPart; 810 + secondSpan.style.fontSize = `${(fontSizeVW * 2) / 3}vw`; 719 811 720 - span.appendChild(hr); 721 - span.appendChild(secondSpan); 722 - } 812 + span.appendChild(hr); 813 + span.appendChild(secondSpan); 814 + } 723 815 724 - anchor.appendChild(span); 725 - }); 816 + anchor.appendChild(span); 817 + }); 726 818 } 727 819 728 820 // ====== Search & Event Handlers ====== ··· 731 823 * Debounce utility 732 824 */ 733 825 function debounce(fn, delay) { 734 - let timeout; 735 - return (...args) => { 736 - clearTimeout(timeout); 737 - timeout = setTimeout(() => fn(...args), delay); 738 - }; 826 + let timeout; 827 + return (...args) => { 828 + clearTimeout(timeout); 829 + timeout = setTimeout(() => fn(...args), delay); 830 + }; 739 831 } 740 832 741 833 /** 742 834 * Search functionality for bookmarks 743 835 */ 744 836 function runSearch(term) { 745 - const searchTerm = term.toLowerCase(); 837 + const searchTerm = term.toLowerCase(); 746 838 747 - if (isListView) { 748 - document.querySelectorAll(".bookmark-item").forEach(item => { 749 - if (searchTerm.startsWith("#")) { 750 - const tagToSearch = searchTerm.slice(1); 751 - const tags = Array.from(item.querySelectorAll(".bookmark-tag")) 752 - .map(el => el.textContent.toLowerCase().replace("#", "").trim()); 839 + if (isListView) { 840 + document.querySelectorAll(".bookmark-item").forEach(item => { 841 + if (searchTerm.startsWith("#")) { 842 + const tagToSearch = searchTerm.slice(1); 843 + const tags = Array.from(item.querySelectorAll(".bookmark-tag")) 844 + .map(el => el.textContent.toLowerCase().replace("#", "").trim()); 753 845 754 - item.style.display = tags.some(tag => tag.includes(tagToSearch)) ? "flex" : "none"; 755 - } else { 756 - const title = item.querySelector(".bookmark-title")?.textContent.toLowerCase() || ""; 757 - const url = item.querySelector(".bookmark-url")?.textContent.toLowerCase() || ""; 758 - const matches = title.includes(searchTerm) || url.includes(searchTerm); 759 - item.style.display = matches ? "flex" : "none"; 760 - } 761 - }); 762 - } else { 763 - document.querySelectorAll(".container").forEach(container => { 764 - if (searchTerm.startsWith("#")) { 765 - const tagToSearch = searchTerm.slice(1); 766 - const tags = Array.from(container.querySelectorAll(".tags")) 767 - .map(el => el.textContent.toLowerCase().replace("#", "").trim()); 846 + item.style.display = tags.some(tag => tag.includes(tagToSearch)) ? "flex" : "none"; 847 + } else { 848 + const title = item.querySelector(".bookmark-title")?.textContent.toLowerCase() || ""; 849 + const url = item.querySelector(".bookmark-url")?.textContent.toLowerCase() || ""; 850 + const matches = title.includes(searchTerm) || url.includes(searchTerm); 851 + item.style.display = matches ? "flex" : "none"; 852 + } 853 + }); 854 + } else { 855 + document.querySelectorAll(".container").forEach(container => { 856 + if (searchTerm.startsWith("#")) { 857 + const tagToSearch = searchTerm.slice(1); 858 + const tags = Array.from(container.querySelectorAll(".tags")) 859 + .map(el => el.textContent.toLowerCase().replace("#", "").trim()); 768 860 769 - container.style.display = tags.some(tag => tag.includes(tagToSearch)) ? "block" : "none"; 770 - } else { 771 - const anchor = container.querySelector("a"); 772 - const title = anchor?.innerText.toLowerCase() || ""; 773 - container.style.display = title.includes(searchTerm) ? "block" : "none"; 774 - } 775 - }); 776 - } 861 + container.style.display = tags.some(tag => tag.includes(tagToSearch)) ? "block" : "none"; 862 + } else { 863 + const anchor = container.querySelector("a"); 864 + const title = anchor?.innerText.toLowerCase() || ""; 865 + container.style.display = title.includes(searchTerm) ? "block" : "none"; 866 + } 867 + }); 868 + } 777 869 } 778 870 779 871 /** 780 872 * Show dialog with URL params if present 781 873 */ 782 874 function showParamsIfPresent() { 783 - if (!dialog || !atpAgent) return; 784 - 785 - const params = new URLSearchParams(window.location.search); 786 - const title = params.get("title"); 787 - const url = params.get("url"); 875 + if (!dialog || !atpAgent) return; 788 876 789 - if (title && url) { 790 - titleInput.value = title; 791 - urlInput.value = url; 792 - dialog.showModal(); 793 - } 877 + const params = new URLSearchParams(window.location.search); 878 + const title = params.get("title"); 879 + const url = params.get("url"); 880 + 881 + if (title && url) { 882 + titleInput.value = title; 883 + urlInput.value = url; 884 + dialog.showModal(); 885 + } 794 886 } 795 887 796 888 // ====== Event Listeners ====== 797 889 798 890 // Login/logout 799 - loginBtn.addEventListener("click", login); 891 + loginBtn.addEventListener("click", startOAuthLogin); 892 + 893 + // Submit login on Enter key 894 + handleInput.addEventListener("keypress", (e) => { 895 + if (e.key === "Enter") { 896 + startOAuthLogin(); 897 + } 898 + }); 800 899 logoutBtn.addEventListener("click", () => { 801 - if (atpAgent) { 802 - logout(); 803 - } else { 804 - showLoginDialog(); 805 - } 900 + if (atpAgent) { 901 + logout(); 902 + } else { 903 + showLoginDialog(); 904 + } 806 905 }); 807 906 808 907 // Guest view functionality 809 908 guestViewBtn?.addEventListener("click", async () => { 810 - const handle = guestSearchInput.value.trim(); 811 - if (!handle) return; 812 - 813 - const result = await resolveHandle(handle); 814 - if (result) { 815 - isViewingOtherUser = true; 816 - viewingUserDid = result.did; 817 - viewingUserHandle = handle; 818 - loginDialog.close(); 819 - showMainUI(); 820 - await loadBookmarks(result.did, result.pdsUrl); 821 - updateViewingUserUI(); 822 - } else { 823 - alert("User not found"); 824 - } 909 + const handle = handleInput.value.trim(); 910 + if (!handle) return; 911 + 912 + const result = await resolveHandle(handle); 913 + if (result) { 914 + isViewingOtherUser = true; 915 + viewingUserDid = result.did; 916 + viewingUserHandle = handle; 917 + loginDialog.close(); 918 + showMainUI(); 919 + await loadBookmarks(result.did, result.pdsUrl); 920 + updateViewingUserUI(); 921 + } else { 922 + alert("User not found"); 923 + } 825 924 }); 826 925 827 926 // Dialog 828 927 saveBtn.addEventListener("click", saveBookmark); 829 928 cancelBtn?.addEventListener("click", () => { 830 - dialog.close(); 831 - window.history.replaceState({}, document.title, window.location.pathname); 929 + dialog.close(); 930 + window.history.replaceState({}, document.title, window.location.pathname); 832 931 }); 833 932 834 933 // Main UI 835 934 openEmptyDialogBtn?.addEventListener("click", () => { 836 - if (!atpAgent) return; 837 - 838 - titleInput.value = ""; 839 - urlInput.value = ""; 840 - tagsInput.value = ""; 841 - 842 - const countInfo = document.getElementById("paramDialogCount"); 843 - countInfo.innerHTML = `${bookmarks.length} bookmarks in PDS`; 844 - 845 - dialog.showModal(); 935 + if (!atpAgent) return; 936 + 937 + titleInput.value = ""; 938 + urlInput.value = ""; 939 + tagsInput.value = ""; 940 + 941 + const countInfo = document.getElementById("paramDialogCount"); 942 + countInfo.innerHTML = `${bookmarks.length} bookmarks in PDS`; 943 + 944 + dialog.showModal(); 846 945 }); 847 946 848 947 // Search 849 948 searchInput?.addEventListener( 850 - "input", 851 - debounce(e => { 852 - const searchTerm = e.target.value.trim(); 853 - const params = new URLSearchParams(window.location.search); 854 - if (searchTerm) params.set("search", searchTerm); 855 - else params.delete("search"); 856 - history.replaceState(null, "", `${location.pathname}?${params.toString()}`); 857 - runSearch(searchTerm); 858 - }, 150) 949 + "input", 950 + debounce(e => { 951 + const searchTerm = e.target.value.trim(); 952 + const params = new URLSearchParams(window.location.search); 953 + if (searchTerm) params.set("search", searchTerm); 954 + else params.delete("search"); 955 + history.replaceState(null, "", `${location.pathname}?${params.toString()}`); 956 + runSearch(searchTerm); 957 + }, 150) 859 958 ); 860 959 861 960 // Sort toggle 862 961 sortToggleBtn?.addEventListener("click", () => { 863 - reversedOrder = !reversedOrder; 864 - renderBookmarks(); 962 + reversedOrder = !reversedOrder; 963 + renderBookmarks(); 865 964 866 - if (reversedOrder) { 867 - sortToggleBtn.lastChild.textContent = " ▼"; 868 - } else { 869 - sortToggleBtn.lastChild.textContent = " ▲"; 870 - } 965 + if (reversedOrder) { 966 + sortToggleBtn.lastChild.textContent = " ▼"; 967 + } else { 968 + sortToggleBtn.lastChild.textContent = " ▲"; 969 + } 871 970 }); 872 971 873 972 // View toggle 874 973 viewToggleBtn?.addEventListener("click", () => { 875 - isListView = !isListView; 876 - renderBookmarks(); 974 + isListView = !isListView; 975 + renderBookmarks(); 877 976 878 - if (isListView) { 879 - viewToggleBtn.innerHTML = '<span class="btn-text">Grid</span> ⊞'; 880 - } else { 881 - viewToggleBtn.innerHTML = '<span class="btn-text">List</span> ☰'; 882 - } 977 + if (isListView) { 978 + viewToggleBtn.innerHTML = '<span class="btn-text">Grid</span> ⊞'; 979 + } else { 980 + viewToggleBtn.innerHTML = '<span class="btn-text">List</span> ☰'; 981 + } 883 982 884 - // Re-apply current search 885 - const currentSearch = searchInput.value.trim(); 886 - if (currentSearch) { 887 - runSearch(currentSearch); 888 - } 983 + // Re-apply current search 984 + const currentSearch = searchInput.value.trim(); 985 + if (currentSearch) { 986 + runSearch(currentSearch); 987 + } 889 988 }); 890 989 891 990 // User search 892 991 userSearchInput?.addEventListener("keypress", async (e) => { 893 - if (e.key === "Enter") { 894 - const handle = e.target.value.trim(); 895 - if (!handle) { 896 - // Empty search - go back to own bookmarks 897 - isViewingOtherUser = false; 898 - viewingUserDid = null; 899 - viewingUserHandle = null; 900 - if (userDid) await loadBookmarks(); 901 - updateViewingUserUI(); 902 - return; 903 - } 904 - 905 - const result = await resolveHandle(handle); 906 - if (result) { 907 - isViewingOtherUser = true; 908 - viewingUserDid = result.did; 909 - viewingUserHandle = handle; 910 - 911 - // Fetch user profile for avatar 912 - currentSearchedUserProfile = await fetchUserProfile(result.did); 913 - 914 - await loadBookmarks(result.did, result.pdsUrl); 915 - updateViewingUserUI(); 916 - } else { 917 - alert("User not found"); 918 - } 919 - } 992 + if (e.key === "Enter") { 993 + const handle = e.target.value.trim(); 994 + if (!handle) { 995 + // Empty search - go back to own bookmarks 996 + isViewingOtherUser = false; 997 + viewingUserDid = null; 998 + viewingUserHandle = null; 999 + if (userDid) await loadBookmarks(); 1000 + updateViewingUserUI(); 1001 + return; 1002 + } 1003 + 1004 + const result = await resolveHandle(handle); 1005 + if (result) { 1006 + isViewingOtherUser = true; 1007 + viewingUserDid = result.did; 1008 + viewingUserHandle = handle; 1009 + 1010 + // Fetch user profile for avatar 1011 + currentSearchedUserProfile = await fetchUserProfile(result.did); 1012 + 1013 + await loadBookmarks(result.did, result.pdsUrl); 1014 + updateViewingUserUI(); 1015 + } else { 1016 + alert("User not found"); 1017 + } 1018 + } 920 1019 }); 921 1020 922 1021 ··· 924 1023 // ====== Initialization ====== 925 1024 926 1025 document.addEventListener("DOMContentLoaded", async () => { 927 - // Wait for AtpAgent to be loaded 928 - let attempts = 0; 929 - while (!window.AtpAgent && attempts < 50) { 930 - await new Promise(resolve => setTimeout(resolve, 100)); 931 - attempts++; 932 - } 933 - 934 - if (!window.AtpAgent) { 935 - console.error("Failed to load AtpAgent"); 936 - return; 937 - } 938 - 939 - const initialized = await initializeATProto(); 940 - if (initialized) { 941 - showParamsIfPresent(); 942 - 943 - // Restore search from URL 944 - const initialSearch = new URLSearchParams(window.location.search).get("search"); 945 - if (initialSearch) { 946 - searchInput.value = initialSearch; 947 - runSearch(initialSearch); 948 - } 949 - } 1026 + // Wait for BrowserOAuthClient and AtpAgent to be loaded 1027 + let attempts = 0; 1028 + while ((!window.BrowserOAuthClient || !window.AtpAgent) && attempts < 50) { 1029 + await new Promise(resolve => setTimeout(resolve, 100)); 1030 + attempts++; 1031 + } 1032 + 1033 + if (!window.BrowserOAuthClient || !window.AtpAgent) { 1034 + console.error("Failed to load OAuth client or AtpAgent"); 1035 + return; 1036 + } 1037 + 1038 + const initialized = await initializeOAuth(); 1039 + if (initialized) { 1040 + showParamsIfPresent(); 1041 + 1042 + // Restore search from URL 1043 + const initialSearch = new URLSearchParams(window.location.search).get("search"); 1044 + if (initialSearch) { 1045 + searchInput.value = initialSearch; 1046 + runSearch(initialSearch); 1047 + } 1048 + } 950 1049 });