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});