Codeforces Profile Preview

Shows a preview of Codeforces profiles when hovering over username links

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Codeforces Profile Preview
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Shows a preview of Codeforces profiles when hovering over username links
// @author       You
// @match        *://codeforces.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // Inject Styles
  const styles = `
#cf-profile-preview {
  position: fixed;
  display: none;
  width: 280px;
  padding: 10px;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
  z-index: 10000;
  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-size: 12px;
  line-height: 1.3;
}

#cf-profile-preview .user-legendary {
  color: #F00 !important;
  font-weight: bold;
}

#cf-profile-preview .legendary-user-first-letter {
  color: #000 !important;
}

#cf-profile-preview .user-red {
  color: red !important;
}

#cf-profile-preview .user-orange {
  color: #FF8C00 !important;
}

#cf-profile-preview .user-violet {
  color: #a0a !important;
}

#cf-profile-preview .user-blue {
  color: blue !important;
}

#cf-profile-preview .user-cyan {
  color: #03A89E !important;
}

#cf-profile-preview .user-green {
  color: green !important;
}

#cf-profile-preview .user-gray {
  color: gray !important;
}

#cf-profile-preview .user-4000 {
  color: black !important;
  font-weight: bold !important;
}

#cf-profile-preview h1 {
  margin: 0;
  padding: 0;
}

#cf-profile-preview .rated-user {
  font-weight: bold;
}

#cf-profile-preview a {
  color: #3B5998;
  text-decoration: none;
}

#cf-profile-preview a:hover {
  text-decoration: underline;
}

#cf-profile-preview .addFriend:hover,
#cf-profile-preview .removeFriend:hover {
  opacity: 0.8;
}
`;

  const styleEl = document.createElement('style');
  styleEl.textContent = styles;
  document.head.appendChild(styleEl);

  // Create preview element and add it to the body
  const preview = document.createElement('div');
  preview.id = 'cf-profile-preview';
  document.body.appendChild(preview);

  // Cache for profile data
  const profileCache = {};
  const profileFetchPromises = {}; // Track ongoing fetch promises
  let currentHoveredLink = null;
  let hideTimeout = null;
  let isMouseOverPreview = false;
  let currentLoadingUsername = null;

  // More accurate size measurement - recalculate based on content
  function getPopupHeight () {
    if (preview.style.display === 'none') {
      return 200; // default estimate when not visible
    }

    const rect = preview.getBoundingClientRect();
    return rect.height;
  }

  // Position the preview at the cursor with smart positioning
  function positionPreview (x, y, atBottom = false) {
    const previewWidth = 300; // width + padding + border
    const windowWidth = window.innerWidth;
    const windowHeight = window.innerHeight;

    // Default offset from cursor
    const xOffset = 3;
    const yOffset = 3;

    // Show the preview briefly to measure its height
    if (preview.style.display === 'none') {
      const originalVis = preview.style.visibility;
      preview.style.visibility = 'hidden';
      preview.style.display = 'block';

      // Force browser to recalculate layout
      const previewHeight = preview.offsetHeight;

      preview.style.display = 'none';
      preview.style.visibility = originalVis;

      // Now calculate position based on actual height
      if (atBottom || y + previewHeight + yOffset > windowHeight) {
        // Position above cursor with minimal gap
        y = y - previewHeight - yOffset;

        // Make sure it's not above the viewport
        if (y < 5) {
          y = 5;
        }
      } else {
        // Standard positioning below cursor
        y = y + yOffset;
      }
    } else {
      // Preview is already visible, use its current height
      const previewHeight = getPopupHeight();

      if (atBottom || y + previewHeight + yOffset > windowHeight) {
        y = y - previewHeight - yOffset;
        if (y < 5) y = 5;
      } else {
        y = y + yOffset;
      }
    }

    // Position horizontally - default to right of cursor
    x = x + xOffset;

    // Check if going off right edge
    if (x + previewWidth > windowWidth) {
      x = Math.max(5, x - previewWidth - (xOffset * 2)); // Position to the left of cursor
    }

    preview.style.left = `${x}px`;
    preview.style.top = `${y}px`;
  }

  // Schedule hiding the preview with delay
  function scheduleHidePreview () {
    clearTimeout(hideTimeout);
    hideTimeout = setTimeout(() => {
      if (!isMouseOverPreview && !currentHoveredLink) {
        preview.style.display = 'none';
        currentLoadingUsername = null;
      }
    }, 100); // small delay to allow moving between elements
  }

  // Helper function to safely trim a string or return empty string
  function safeTrim (str) {
    return typeof str === 'string' ? str.trim() : '';
  }

  // Find all profile links and add event listeners
  function setupProfileLinks () {
    const profileLinks = document.querySelectorAll('a[href*="/profile/"]');

    profileLinks.forEach(link => {
      // Skip if already processed
      if (link.dataset.cfPreviewAdded) return;

      // Skip links inside the preview itself
      if (preview.contains(link)) return;

      link.dataset.cfPreviewAdded = 'true';

      const hrefParts = link.href.split('/profile/');
      if (hrefParts.length < 2) return;

      const username = hrefParts[1].split('/')[0].split('?')[0];

      // Store the username in the element's data attribute for easy access
      link.dataset.cfUsername = username;

      // Mouse events
      link.addEventListener('mouseover', async (e) => {
        e.preventDefault(); // Prevent default tooltip
        link.title = ''; // Remove title to prevent default tooltip
        clearTimeout(hideTimeout); // Cancel any pending hide

        // Get the username from the element we're hovering over
        const thisUsername = link.dataset.cfUsername;

        // Set this as the current link before any async operations
        currentHoveredLink = link;

        // Save which profile we're trying to load
        currentLoadingUsername = thisUsername;

        // Check if we're near the bottom of the screen
        const nearBottom = e.clientY > (windowHeight - 150);

        // Create loading content first
        preview.innerHTML = '<div style="text-align:center;padding:10px;">Loading profile...</div>';

        // Position based on cursor location
        positionPreview(e.clientX, e.clientY, nearBottom);

        // Now show the preview
        preview.style.display = 'block';

        try {
          const profileData = await getProfileData(thisUsername);

          // Only render if we're still interested in this profile
          if (currentLoadingUsername === thisUsername) {
            renderProfile(profileData);

            // Reposition after rendering to account for actual content height
            positionPreview(e.clientX, e.clientY, nearBottom);
          }
        } catch (err) {
          // Only show error if we're still interested in this profile
          if (currentLoadingUsername === thisUsername) {
            if (err.message === '429') {
              preview.innerHTML = '<div style="color:orange;text-align:center;padding:10px;">Rate Limited (429)</div>';
            } else if (err.message && err.message.includes('Profile not found')) {
              preview.innerHTML = '<div style="color:red;text-align:center;padding:10px;">Profile not found</div>';
            } else {
              preview.innerHTML = '<div style="color:red;text-align:center;padding:10px;">Error loading profile</div>';
              console.warn('Error loading profile:', err);
            }
          }
        }
      });

      link.addEventListener('mouseout', (e) => {
        // If mouse went to preview, don't hide yet
        if (e.relatedTarget === preview || preview.contains(e.relatedTarget)) {
          return;
        }

        // Clear this link as the current one
        if (currentHoveredLink === link) {
          currentHoveredLink = null;
        }

        scheduleHidePreview();
      });
    });
  }

  // Add mouse event listeners to preview element
  preview.addEventListener('mouseenter', () => {
    clearTimeout(hideTimeout);
    isMouseOverPreview = true;
  });

  preview.addEventListener('mouseleave', (e) => {
    // If mouse went back to the original link, don't hide
    if (e.relatedTarget === currentHoveredLink || (currentHoveredLink && currentHoveredLink.contains(e.relatedTarget))) {
      return;
    }

    isMouseOverPreview = false;
    currentHoveredLink = null;
    scheduleHidePreview();
  });

  // Add scroll event to document to schedule hiding preview when scrolling
  document.addEventListener('scroll', () => {
    if (preview.style.display === 'block') {
      // Get mouse position
      const mouseX = window.event ? window.event.clientX : 0;
      const mouseY = window.event ? window.event.clientY : 0;

      // Get preview position and size
      const rect = preview.getBoundingClientRect();

      // Check if mouse is over preview
      if (!(mouseX >= rect.left && mouseX <= rect.right &&
        mouseY >= rect.top && mouseY <= rect.bottom)) {
        isMouseOverPreview = false;
        currentHoveredLink = null;
        scheduleHidePreview();
      }
    }
  }, true);

  // Add global mousemove to catch cases where mouseleave events might be missed
  document.addEventListener('mousemove', (e) => {
    if (preview.style.display === 'block') {
      const isOverPreview = preview.contains(e.target) || preview === e.target;
      // Check if over the current link
      const isOverLink = currentHoveredLink && (currentHoveredLink.contains(e.target) || currentHoveredLink === e.target);

      if (!isOverPreview && !isOverLink) {
        // We are not over the preview and not over the link.
        // If we have lingering state, clear it.
        let stateChanged = false;

        if (currentHoveredLink) {
          currentHoveredLink = null;
          stateChanged = true;
        }

        if (isMouseOverPreview) {
          isMouseOverPreview = false;
          stateChanged = true;
        }

        if (stateChanged) {
          scheduleHidePreview();
        }
      }
    }
  });

  // Get window dimensions - will be used for positioning
  let windowWidth = window.innerWidth;
  let windowHeight = window.innerHeight;

  // Update window dimensions when resized
  window.addEventListener('resize', () => {
    windowWidth = window.innerWidth;
    windowHeight = window.innerHeight;
  });

  // Parse HTML from profile page to extract additional data
  function parseProfileHTML (html, username) {
    try {
      const parser = new DOMParser();
      const htmlDoc = parser.parseFromString(html, 'text/html');
      const profileObj = profileCache[username];
      if (!profileObj) {
        throw new Error('Profile object not found in cache for ' + username);
      }

      // Find country, city and organization links
      const links = htmlDoc.querySelectorAll('.info a[href*="/ratings/"]');
      links.forEach(link => {
        const href = link.getAttribute('href');
        const text = link.textContent.trim();

        if (href.includes('/country/')) {
          if (href.includes('/city/')) {
            profileObj._cityLink = href;
            profileObj._cityName = safeTrim(text);
          } else {
            profileObj._countryLink = href;
            profileObj._countryName = safeTrim(text);
          }
        } else if (href.includes('/organization/')) {
          profileObj._organizationLink = href;
          profileObj._organizationName = safeTrim(text);
        }
      });

      // Find badges
      profileObj._badges = [];
      const badges = htmlDoc.querySelectorAll('.badge img');
      badges.forEach(badge => {
        profileObj._badges.push({
          src: badge.getAttribute('src'),
          title: badge.getAttribute('title') || ''
        });
      });

      // Find blog entries count
      const blogLinks = htmlDoc.querySelectorAll('a[href*="/blog/"]');
      for (const link of blogLinks) {
        const text = link.textContent;
        const match = text.match(/Blog entries \((\d+)\)/);
        if (match && match[1]) {
          profileObj._blogEntries = match[1];
          break;
        }
      }

      // Find friend status - search more broadly to ensure we find it
      profileObj._isFriend = false; // default to not a friend

      // First check for removeFriend class or element
      const removeFriendEl = htmlDoc.querySelector('.removeFriend, [class*="removeFriend"]');
      if (removeFriendEl) {
        profileObj._isFriend = true;
        profileObj._friendUserId = removeFriendEl.getAttribute('frienduserid');
      } else {
        // Also check for yellow star images which often indicate friend status
        const starImages = htmlDoc.querySelectorAll('img[src*="star_yellow"]');
        for (const img of starImages) {
          if (img.title && (img.title.includes('remove from') || img.title.includes('following'))) {
            profileObj._isFriend = true;
            profileObj._friendUserId = img.getAttribute('frienduserid');
            break;
          }
        }
      }

      // If not found yet, check for addFriend to get the ID
      if (!profileObj._friendUserId) {
        const addFriendEl = htmlDoc.querySelector('.addFriend, [class*="addFriend"]');
        if (addFriendEl) {
          profileObj._friendUserId = addFriendEl.getAttribute('frienduserid');
        } else {
          // Check gray star
          const starImages = htmlDoc.querySelectorAll('img[src*="star_gray"]');
          for (const img of starImages) {
            if (img.title && (img.title.includes('add to') || img.title.includes('following'))) {
              profileObj._friendUserId = img.getAttribute('frienduserid');
              break;
            }
          }
        }
      }

      // Find friend count
      // Try standard :has first, fallback to iterating if needed, but :has is supported in modern Chrome
      let friendOfElement = null;
      try {
        friendOfElement = htmlDoc.querySelector('li:has(img[src*="star_yellow"])');
      } catch (e) {
        // Fallback if :has is not supported (unlikely in modern Chrome but good for safety)
        const star = htmlDoc.querySelector('li img[src*="star_yellow"]');
        if (star) {
          friendOfElement = star.closest('li');
        }
      }
      if (friendOfElement) {
        const text = friendOfElement.textContent.trim();
        const match = text.match(/Friend of: (\d+)/);
        if (match && match[1]) {
          profileObj.friendOfCount = parseInt(match[1], 10);
        }
      }

      // Check handle coloring
      const ratedUserLinks = htmlDoc.querySelectorAll('.rated-user');
      for (const link of ratedUserLinks) {
        if (link.textContent.includes(username)) {
          const classList = Array.from(link.classList);
          for (const cls of classList) {
            if (cls.startsWith('user-')) {
              profileObj._userRatingClass = cls;
              break;
            }
          }
          break;
        }
      }

      // Store first/last name with trimmed whitespace
      if (profileObj.firstName) profileObj.firstName = safeTrim(profileObj.firstName);
      if (profileObj.lastName) profileObj.lastName = safeTrim(profileObj.lastName);
      if (profileObj.city) profileObj.city = safeTrim(profileObj.city);
      if (profileObj.country) profileObj.country = safeTrim(profileObj.country);
      if (profileObj.organization) profileObj.organization = safeTrim(profileObj.organization);

      return profileObj;
    } catch (err) {
      console.error('Error parsing HTML profile:', err);
      return null;
    }
  }

  // Get profile data from API and HTML page (combined fetch)
  async function getProfileData (username) {
    // Return from cache if available
    if (profileCache[username] && profileCache[username]._isFullyLoaded) {
      return profileCache[username];
    }

    // Return existing promise if we're already fetching this profile
    if (profileFetchPromises[username]) {
      return profileFetchPromises[username];
    }

    // Create a new promise for this profile fetch
    const fetchPromise = new Promise(async (resolve, reject) => {
      try {
        // Fetch both API data and HTML page simultaneously
        const [apiResponse, htmlResponse] = await Promise.all([
          fetch(`https://codeforces.com/api/user.info?handles=${username}`)
            .then(res => {
              if (res.status === 429) throw new Error('429');
              return res.json();
            }),
          fetch(`https://codeforces.com/profile/${username}`)
            .then(res => {
              if (res.status === 429) throw new Error('429');
              return res.text();
            })
        ]);

        if (apiResponse.status !== 'OK' || !apiResponse.result || !apiResponse.result.length) {
          throw new Error('Profile not found or API error');
        }

        // Store basic profile data
        profileCache[username] = apiResponse.result[0];

        // Parse HTML to extract additional info
        const fullProfileData = parseProfileHTML(htmlResponse, username);

        if (fullProfileData) {
          // Mark as fully loaded
          fullProfileData._isFullyLoaded = true;
          profileCache[username] = fullProfileData;
          resolve(fullProfileData);
        } else {
          // Even if HTML parsing fails, return at least the API data
          profileCache[username]._isFullyLoaded = true;
          resolve(profileCache[username]);
        }
      } catch (error) {
        if (error.message === '429') {
          console.warn('Codeforces Rate Limit (429)');
        } else if (error.message && error.message.includes('Profile not found')) {
          // Suppress this error from the console as it's expected for invalid handles
          // and the user requested to hide it.
          console.warn('Profile not found:', username);
        } else {
          console.warn('Error fetching profile data:', error);
        }
        reject(error);
      } finally {
        // Clean up the promise cache
        delete profileFetchPromises[username];
      }
    });

    // Store the promise to avoid duplicate fetches
    profileFetchPromises[username] = fetchPromise;

    return fetchPromise;
  }

  // Get CSS class for user based on rating
  function getUserRatingClass (rating) {
    if (!rating) return '';
    if (rating < 1200) return 'user-gray';
    if (rating < 1400) return 'user-green';
    if (rating < 1600) return 'user-cyan';
    if (rating < 1900) return 'user-blue';
    if (rating < 2100) return 'user-violet';
    if (rating < 2400) return 'user-orange';
    if (rating < 3000) return 'user-red';
    if (rating >= 4000) return 'user-4000';
    if (rating >= 3000) return 'user-legendary';
    return 'user-legendary';
  }

  // Handle adding/removing friend
  function toggleFriend (handle, friendUserId, isAdd) {
    const csrfToken = document.querySelector('meta[name="X-Csrf-Token"]')?.content;

    if (!csrfToken) {
      console.error('CSRF token not found');
      return;
    }

    if (!friendUserId) {
      console.error('Friend User ID not found');
      return;
    }

    const url = 'https://codeforces.com/data/friend';

    fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'X-Csrf-Token': csrfToken
      },
      body: `friendUserId=${friendUserId}&isAdd=${isAdd}&csrf_token=${encodeURIComponent(csrfToken)}`,
      credentials: 'include'
    })
      .then(response => response.json())
      .then(json => {
        if (json['success'] === 'true') {
          // Update the cache only after confirmation
          if (profileCache[handle]) {
            profileCache[handle]._isFriend = (isAdd === 'true');

            // Re-render if visible and still showing the same user
            if (preview.style.display === 'block' && currentLoadingUsername === handle) {
              renderProfile(profileCache[handle]);
            }
          }
        } else {
          console.error('Friend toggle failed:', json['reason'] || 'Unknown error');
        }
      })
      .catch(err => console.error('Error toggling friend status:', err));
  }

  // Helper function to build location string with proper commas
  function buildLocationString (profile) {
    const parts = [];

    // Get first and last name, ensuring they're trimmed
    const firstName = safeTrim(profile.firstName || '');
    const lastName = safeTrim(profile.lastName || '');

    if (firstName || lastName) {
      const fullName = (firstName + (firstName && lastName ? ' ' : '') + lastName).trim();
      if (fullName) parts.push(fullName);
    }

    // Add city
    if (profile._cityLink) {
      parts.push(`<a href="${profile._cityLink}">${profile._cityName}</a>`);
    } else if (profile.city) {
      parts.push(safeTrim(profile.city));
    }

    // Add country
    if (profile._countryLink) {
      parts.push(`<a href="${profile._countryLink}">${profile._countryName}</a>`);
    } else if (profile.country) {
      parts.push(safeTrim(profile.country));
    }

    return parts.join(', ');
  }

  // Render profile data in the preview
  function renderProfile (profile) {
    // Use either the stored class from HTML or calculate it from rating
    let ratingClass = profile._userRatingClass || getUserRatingClass(profile.rating);

    // Force user-4000 for rating >= 4000 regardless of what was parsed
    if (profile.rating >= 4000) {
      ratingClass = 'user-4000';
    }
    let rankText = profile.rank ? profile.rank.charAt(0).toUpperCase() + profile.rank.slice(1) : 'Unrated';

    // Special case for rating >= 4000: rank is the handle
    if (profile.rating >= 4000) {
      rankText = profile.handle;
    }

    // Check if legendary to apply special style for first letter
    const isLegendary = ratingClass === 'user-legendary';
    const handle = profile.handle || 'Unknown';
    const displayHandle = isLegendary
      ? `<span class="legendary-user-first-letter">${handle[0]}</span>${handle.substring(1)}`
      : handle;

    // Build location string with correct commas
    const locationString = buildLocationString(profile);

    // Get organization with proper trimming
    const organization = profile._organizationLink
      ? `<a href="${profile._organizationLink}">${profile._organizationName}</a>`
      : (profile.organization ? safeTrim(profile.organization) : '');

    // Format the data to match Codeforces styling but in a compact way
    preview.innerHTML = `
            <div class="info" style="position: relative;">
                <div class="main-info" style="display: flex; margin-bottom: 6px;">
                    <div class="cf-preview-image-wrapper" style="margin-right: 8px; height: 0; min-height: 0;">
                        <div style="position: relative; height: 100%;">
                            <img src="${profile.titlePhoto || '//userpic.codeforces.org/no-title.jpg'}" style="width:auto; max-width: 80px; height:100%; min-width: 50px; object-fit:cover; border-radius:3px;">
                            
                            ${profile._badges && profile._badges.length > 0 ?
        `<div style="position: absolute; bottom: -12px; left: 0; right: 0; display: flex; justify-content: center;">
                                    ${profile._badges.slice(0, 2).map(badge =>
          `<img src="${badge.src}" title="${badge.title}" style="width: 18px; height: 18px; margin: 0 -3px;">`
        ).join('')}
                                    ${profile._badges.length > 2 ? `<span style="font-size: 10px; color: #777; margin-left: 2px;">+${profile._badges.length - 2}</span>` : ''}
                                </div>` : ''
      }
                        </div>
                    </div>
                    <div class="cf-preview-text-column" style="flex-grow: 1; overflow: hidden;">
                        <div class="user-rank" style="font-size: 11px;">
                            <span class="${ratingClass}" style="font-weight: bold;">${rankText}</span>
                            ${typeof profile._isFriend !== 'undefined' ?
        `<span style="float:right; cursor:pointer;" class="${profile._isFriend ? 'removeFriend' : 'addFriend'}" data-handle="${profile.handle}">
                                    <img style="width:16px;height:16px;" src="//codeforces.org/s/94884/images/icons/star_${profile._isFriend ? 'yellow' : 'gray'}_24.png" 
                                         title="${profile._isFriend ? 'Remove from friends' : 'Add to friends'}">
                                </span>` : ''
      }
                        </div>
                        <h1 style="font-size: 16px; margin: 3px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
                            <a href="/profile/${handle}" class="rated-user ${ratingClass}">${displayHandle}</a>
                        </h1>
                        <div style="font-size: 0.8em; color: #777; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
                            ${locationString}
                        </div>
                        ${organization ?
        `<div style="font-size: 0.8em; color: #777; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">From ${organization}</div>` : ''
      }
                    </div>
                </div>
                
                <div style="border-top: 1px solid #eee; padding-top: 5px; display: flex;">
                    <div style="flex: 1; padding-right: 4px; border-right: 1px solid #eee;">
                        <div style="margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
                            <span style="font-size: 11px;">Rating: </span>
                            <span style="font-weight:bold;" class="${ratingClass}">${profile.rating || 'Unrated'}</span>
                        </div>
                        <div style="margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
                            Max: <span style="font-weight:bold;" class="${getUserRatingClass(profile.maxRating)}">${profile.maxRating || 'N/A'}</span>
                        </div>
                        ${profile.contribution ?
        `<div style="margin-bottom: 3px; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
                                Contribution: <span style="color: ${profile.contribution > 0 ? 'green' : 'gray'}; font-weight: bold;">${profile.contribution > 0 ? '+' + profile.contribution : profile.contribution}</span>
                            </div>` : ''
      }
                    </div>
                    <div style="flex: 1; padding-left: 6px; font-size: 11px;">
                        <div style="margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
                            Friend of: ${profile.friendOfCount || 0} users
                        </div>
                        <div style="margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
                            Last visit: ${profile.lastOnlineTimeSeconds ? formatTimeAgo(profile.lastOnlineTimeSeconds) : 'Unknown'}
                        </div>
                        <div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
                            <a href="/blog/${profile.handle}">Blog entries (${profile._blogEntries || '0'})</a>
                        </div>
                    </div>
                </div>
            </div>`;

    // Adjust image height to match text column
    const textCol = preview.querySelector('.cf-preview-text-column');
    const imgWrapper = preview.querySelector('.cf-preview-image-wrapper');

    if (textCol && imgWrapper) {
      // Ensure visible for measurement
      const wasHidden = preview.style.display === 'none';
      if (wasHidden) {
        preview.style.visibility = 'hidden';
        preview.style.display = 'block';
      }

      const height = textCol.offsetHeight;
      if (height > 0) {
        imgWrapper.style.height = height + 'px';
      }

      if (wasHidden) {
        preview.style.display = 'none';
        preview.style.visibility = '';
      }
    }

    // Add event listeners for friend buttons after rendering
    const friendButton = preview.querySelector('.addFriend, .removeFriend');
    if (friendButton) {
      friendButton.addEventListener('click', (e) => {
        e.stopPropagation();
        const handle = friendButton.getAttribute('data-handle');
        // If currently a friend (removeFriend class), we want to remove (isAdd = false)
        // If currently not a friend (addFriend class), we want to add (isAdd = true)
        const isRemoving = friendButton.classList.contains('removeFriend');
        const isAdd = isRemoving ? 'false' : 'true';
        const friendUserId = profile.friendUserId || profile._friendUserId; // Try both locations

        toggleFriend(handle, friendUserId, isAdd);
      });
    }
  }

  // Format time ago from timestamp
  function formatTimeAgo (timestamp) {
    const now = Math.floor(Date.now() / 1000);
    const seconds = now - timestamp;

    if (seconds < 60) return 'just now';
    if (seconds < 3600) return Math.floor(seconds / 60) + ' minutes ago';
    if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
    if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
    if (seconds < 2592000) return Math.floor(seconds / 604800) + ' weeks ago';
    if (seconds < 31536000) return Math.floor(seconds / 2592000) + ' months ago';
    return Math.floor(seconds / 31536000) + ' years ago';
  }

  // Run the setup initially
  setupProfileLinks();

  // Use MutationObserver to handle dynamically loaded content
  const observer = new MutationObserver((mutations) => {
    setupProfileLinks();
  });

  observer.observe(document.body, {
    childList: true,
    subtree: true
  });
})();