A fork of Woomarks that saves to pds
at main 28 kB view raw
1// ====== AT Protocol & Constants ====== 2// AtpAgent is loaded via window.AtpAgent from the module import 3 4// Bookmark lexicon definition (using community standard) 5const BOOKMARK_LEXICON = "community.lexicon.bookmarks.bookmark"; 6 7const LOCAL_GLOW = false; // No local storage differentiation needed 8const MAX_CHARS_PER_LINE = 15; 9const MAX_LINES = 4; 10const EST_CHAR_WIDTH = 0.6; // em 11const HYPHENATE_THRESHOLD = 12; 12const 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"], 23]; 24 25const FONT_LIST = [ 26 "Caveat", "Permanent Marker", "Courier", "Doto", "Bree Serif", 27 "Ultra", "Alfa Slab One", "Sedan SC", "EB Garamond", "Bebas Neue", 28]; 29 30// State variables 31let atpAgent = null; 32let oauthClient = null; 33let userDid = null; 34let bookmarks = []; 35let reversedOrder = false; 36let viewingUserDid = null; 37let viewingUserHandle = null; 38let isViewingOtherUser = false; 39let isListView = true; 40let currentSearchedUserProfile = null; 41 42// ====== DOM Elements ====== 43const loginDialog = document.getElementById("loginDialog"); 44const handleInput = document.getElementById("handleInput"); 45const loginBtn = document.getElementById("loginBtn"); 46const logoutBtn = document.getElementById("logoutBtn"); 47const userAvatar = document.getElementById("userAvatar"); 48const searchedUserAvatar = document.getElementById("searchedUserAvatar"); 49 50const dialog = document.getElementById("paramDialog"); 51const titleInput = document.getElementById("paramTitle"); 52const urlInput = document.getElementById("paramUrl"); 53const tagsInput = document.getElementById("tagsInput"); 54const saveBtn = document.getElementById("saveBtn"); 55const cancelBtn = document.getElementById("cancelBtn"); 56const openEmptyDialogBtn = document.getElementById("openEmptyDialogBtn"); 57const searchInput = document.getElementById("searchInput"); 58const sortToggleBtn = document.getElementById("sortToggleBtn"); 59const viewToggleBtn = document.getElementById("viewToggleBtn"); 60const userSearchInput = document.getElementById("userSearchInput"); 61const viewingUser = document.getElementById("viewingUser"); 62// guestSearchInput removed - using handleInput for both login and guest 63const guestViewBtn = document.getElementById("guestViewBtn"); 64 65// ====== AT Protocol Functions ====== 66 67/** 68 * Resolve handle to DID and PDS 69 */ 70async 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 } 102} 103 104/** 105 * Get client ID based on environment 106 */ 107function 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} 119 120/** 121 * Initialize OAuth client and check for existing session 122 */ 123async 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; 169} 170 171/** 172 * Start OAuth login flow 173 */ 174async function startOAuthLogin() { 175 let handle = handleInput.value.trim(); 176 if (!handle) return; 177 178 // Strip @ prefix if present 179 if (handle.startsWith('@')) { 180 handle = handle.slice(1); 181 } 182 183 console.log("Starting OAuth login for handle:", handle); 184 console.log("OAuth client:", oauthClient); 185 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 } 194 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 */ 224async 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 } 249} 250 251/** 252 * Fetch user profile information 253 */ 254async function fetchUserProfile(did) { 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 } 270} 271 272/** 273 * Logout from OAuth session 274 */ 275async function logout() { 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(); 294} 295 296/** 297 * Load bookmarks from PDS 298 */ 299async function loadBookmarks(targetDid = null, targetPdsUrl = null) { 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 } 325 326 const response = await agent.com.atproto.repo.listRecords({ 327 repo: did, 328 collection: BOOKMARK_LEXICON, 329 }); 330 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 })); 336 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 } 346} 347 348/** 349 * Save a bookmark to PDS 350 */ 351async function saveBookmark() { 352 const title = titleInput.value.trim(); 353 const url = urlInput.value.trim(); 354 const rawTags = tagsInput.value.trim(); 355 356 if (!url || !atpAgent || !userDid) return; 357 358 const tags = rawTags.split(",").map(t => t.trim()).filter(Boolean); 359 360 const bookmarkRecord = { 361 $type: BOOKMARK_LEXICON, 362 subject: url, 363 tags, 364 createdAt: new Date().toISOString(), 365 }; 366 367 // Add optional title if provided 368 if (title) { 369 bookmarkRecord.title = title; 370 } 371 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 } 395} 396 397/** 398 * Delete a bookmark from PDS 399 */ 400async function deleteBookmark(uri) { 401 if (!atpAgent || !userDid) return; 402 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}`); 424 425 renderBookmarks(); 426 } catch (error) { 427 console.error("Failed to delete bookmark:", error); 428 alert("Failed to delete bookmark: " + error.message); 429 } 430} 431 432// ====== UI Functions ====== 433 434async function updateUIForLoggedInState() { 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(); 451} 452 453function updateUIForLoggedOutState() { 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(); 462} 463 464function showLoginDialog() { 465 loginDialog.showModal(); 466 openEmptyDialogBtn.style.display = "none"; 467 sortToggleBtn.style.display = "none"; 468 viewToggleBtn.style.display = "none"; 469 searchInput.style.display = "none"; 470} 471 472function showMainUI() { 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"; 478} 479 480function updateViewingUserUI() { 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 } 496} 497 498// ====== Utility Functions ====== 499 500/** 501 * Hashes a string to a non-negative 32-bit integer. 502 */ 503function hashString(str) { 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); 510} 511 512/** 513 * Get a color pair deterministically by title. 514 */ 515function getColorPairByTitle(title, pairs) { 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]; 520} 521 522/** 523 * Get a font family deterministically by title. 524 */ 525function getFontByTitle(title, fonts) { 526 return fonts[hashString(title) % fonts.length]; 527} 528 529/** 530 * Format date as natural language for recent dates, otherwise as regular date 531 */ 532function formatNaturalDate(dateString) { 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 }); 557} 558 559// ====== Rendering Functions ====== 560 561/** 562 * Renders bookmarks in list view 563 */ 564function renderListView() { 565 const containerWrapper = document.querySelector(".containers"); 566 containerWrapper.innerHTML = ""; 567 568 const fragment = document.createDocumentFragment(); 569 const displayBookmarks = reversedOrder ? bookmarks : [...bookmarks].reverse(); 570 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; 576 577 if (!url) return; 578 579 const displayTitle = title.replace(/^https?:\/\/(www\.)?/i, ""); 580 581 // Create list item 582 const listItem = document.createElement("div"); 583 listItem.className = "bookmark-item"; 584 585 // Content container 586 const content = document.createElement("div"); 587 content.className = "bookmark-content"; 588 589 // Link group (title + URL together, but not date) 590 const linkGroup = document.createElement("div"); 591 linkGroup.className = "bookmark-link-group"; 592 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); 600 601 // URL-only container (without date) 602 const urlContainer = document.createElement("div"); 603 urlContainer.className = "bookmark-url-container"; 604 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); 613 614 linkGroup.appendChild(urlContainer); 615 content.appendChild(linkGroup); 616 617 // Meta row for date and tags (outside hover group) 618 const metaRow = document.createElement("div"); 619 metaRow.className = "bookmark-meta-row"; 620 621 // Tags on the left 622 if (tags.length > 0) { 623 const tagsDiv = document.createElement("div"); 624 tagsDiv.className = "bookmark-tags"; 625 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 }); 633 634 metaRow.appendChild(tagsDiv); 635 } 636 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 } 644 645 content.appendChild(metaRow); 646 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 }); 665 666 actions.appendChild(deleteBtn); 667 listItem.appendChild(actions); 668 } 669 670 fragment.appendChild(listItem); 671 }); 672 673 containerWrapper.appendChild(fragment); 674} 675 676/** 677 * Renders bookmarks in grid view (original) 678 */ 679function renderGridView() { 680 const containerWrapper = document.querySelector(".containers"); 681 containerWrapper.innerHTML = ""; 682 683 const fragment = document.createDocumentFragment(); 684 const displayBookmarks = reversedOrder ? bookmarks : [...bookmarks].reverse(); 685 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 || []; 690 691 if (!url) return; 692 693 const displayTitle = title.replace(/^https?:\/\/(www\.)?/i, ""); 694 const [bgColor, fontColor] = getColorPairByTitle(title, COLOR_PAIRS); 695 const fontFamily = getFontByTitle(title, FONT_LIST); 696 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`; 702 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 } 718 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); 725 726 // Tags 727 if (tags.length > 0) { 728 const wrapper = document.createElement("div"); 729 wrapper.className = "tags-wrapper"; 730 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 }); 738 739 container.appendChild(wrapper); 740 } 741 742 fragment.appendChild(container); 743 }); 744 745 containerWrapper.appendChild(fragment); 746 runTextFormatting(); 747} 748 749/** 750 * Renders bookmark containers 751 */ 752function renderBookmarks() { 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 } 761} 762 763/** 764 * Filter bookmarks by tag 765 */ 766function filterByTag(tag) { 767 searchInput.value = `#${tag}`; 768 searchInput.dispatchEvent(new Event("input")); 769} 770 771/** 772 * Formats text inside containers after rendering 773 */ 774function runTextFormatting() { 775 document.querySelectorAll(".container").forEach(container => { 776 const anchor = container.querySelector("a"); 777 if (!anchor) return; 778 779 const originalText = anchor.innerText.trim(); 780 const href = anchor.href; 781 if (!originalText || !href) return; 782 783 anchor.innerHTML = ""; 784 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/>"); 788 789 const span = document.createElement("span"); 790 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; 797 798 span.style.fontSize = `${fontSizeVW}vw`; 799 800 const firstSpan = document.createElement("span"); 801 firstSpan.innerHTML = firstPart; 802 span.appendChild(firstSpan); 803 804 if (restParts.length) { 805 const hr = document.createElement("hr"); 806 hr.classList.add("invisible-hr"); 807 808 const secondSpan = document.createElement("span"); 809 secondSpan.innerHTML = secondPart; 810 secondSpan.style.fontSize = `${(fontSizeVW * 2) / 3}vw`; 811 812 span.appendChild(hr); 813 span.appendChild(secondSpan); 814 } 815 816 anchor.appendChild(span); 817 }); 818} 819 820// ====== Search & Event Handlers ====== 821 822/** 823 * Debounce utility 824 */ 825function debounce(fn, delay) { 826 let timeout; 827 return (...args) => { 828 clearTimeout(timeout); 829 timeout = setTimeout(() => fn(...args), delay); 830 }; 831} 832 833/** 834 * Search functionality for bookmarks 835 */ 836function runSearch(term) { 837 const searchTerm = term.toLowerCase(); 838 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()); 845 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()); 860 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 } 869} 870 871/** 872 * Show dialog with URL params if present 873 */ 874function showParamsIfPresent() { 875 if (!dialog || !atpAgent) return; 876 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 } 886} 887 888// ====== Event Listeners ====== 889 890// Login/logout 891loginBtn.addEventListener("click", startOAuthLogin); 892 893// Submit login on Enter key 894handleInput.addEventListener("keypress", (e) => { 895 if (e.key === "Enter") { 896 startOAuthLogin(); 897 } 898}); 899logoutBtn.addEventListener("click", () => { 900 if (atpAgent) { 901 logout(); 902 } else { 903 showLoginDialog(); 904 } 905}); 906 907// Guest view functionality 908guestViewBtn?.addEventListener("click", async () => { 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 } 924}); 925 926// Dialog 927saveBtn.addEventListener("click", saveBookmark); 928cancelBtn?.addEventListener("click", () => { 929 dialog.close(); 930 window.history.replaceState({}, document.title, window.location.pathname); 931}); 932 933// Main UI 934openEmptyDialogBtn?.addEventListener("click", () => { 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(); 945}); 946 947// Search 948searchInput?.addEventListener( 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) 958); 959 960// Sort toggle 961sortToggleBtn?.addEventListener("click", () => { 962 reversedOrder = !reversedOrder; 963 renderBookmarks(); 964 965 if (reversedOrder) { 966 sortToggleBtn.lastChild.textContent = " ▼"; 967 } else { 968 sortToggleBtn.lastChild.textContent = " ▲"; 969 } 970}); 971 972// View toggle 973viewToggleBtn?.addEventListener("click", () => { 974 isListView = !isListView; 975 renderBookmarks(); 976 977 if (isListView) { 978 viewToggleBtn.innerHTML = '<span class="btn-text">Grid</span> ⊞'; 979 } else { 980 viewToggleBtn.innerHTML = '<span class="btn-text">List</span> ☰'; 981 } 982 983 // Re-apply current search 984 const currentSearch = searchInput.value.trim(); 985 if (currentSearch) { 986 runSearch(currentSearch); 987 } 988}); 989 990// User search 991userSearchInput?.addEventListener("keypress", async (e) => { 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 } 1019}); 1020 1021 1022 1023// ====== Initialization ====== 1024 1025document.addEventListener("DOMContentLoaded", async () => { 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 } 1049});