// ==UserScript==
// @name GeoGuessr Liked Maps Widget
// @version 0.9.8
// @namespace https://github.com/asmodeo
// @icon https://parmageo.vercel.app/gg.ico
// @description Creates a widget on the GeoGuessr start page featuring your liked maps
// @author Parma
// @match https://www.geoguessr.com/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect geoguessr.com
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ======================
// Constants & Configuration
// ======================
const CONFIG = {
STORAGE_KEY: 'geoguessr_liked_maps',
SORT_STORAGE_KEY: 'geoguessr_sort_preference',
PINNED_MAPS_STORAGE_KEY: 'geoguessr_pinned_maps',
SELECTORS: {
RIGHT_SIDEBAR: '.pro-user-start-page_right__1Hf3g',
DAILY_CHALLENGE_WIDGET: '.pro-user-start-page_right__1Hf3g .widget_root__j5z2N'
}
};
const ONE_DAY_MS = 24 * 60 * 60 * 1000; // 24 hours in ms
// ======================
// State Management
// ======================
let currentPathname = window.location.pathname;
let widgetInitialized = false;
let likedMaps = [];
let filteredMaps = [];
let searchTerm = '';
let currentSort = GM_getValue(CONFIG.SORT_STORAGE_KEY, 'default');
let pinnedMapIds = new Set();
let tooltip = null;
// Auto-sync state
let lastSyncTimestamp = GM_getValue('geoguessr_last_sync_timestamp', 0);
let autoSyncTimerId = null;
let sidebarObserver = null;
// Initialization flag
let initialDataLoaded = false;
// ======================
// Utility Functions
// ======================
/**
* Checks if the current page is the GeoGuessr homepage.
* @returns {boolean}
*/
function isHomePage() {
return window.location.pathname === '/' || window.location.pathname === '';
}
/**
* Trims special and whitespace characters from the start of a string.
* @param {string} str
* @returns {string}
*/
function trimSpecialCharacters(str) {
return str.replace(/^[\s\t\u0000-\u001F\u007F-\u009F\u2000-\u200F\u2028-\u202F]+/, '');
}
/**
* Debounces a function call.
* @param {Function} func
* @param {number} delay
* @returns {Function}
*/
function debounce(func, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
}
// ======================
// Auto-Sync Management
// ======================
/**
* Clears any existing auto-sync timer.
*/
function clearAutoSync() {
if (autoSyncTimerId) {
clearTimeout(autoSyncTimerId);
autoSyncTimerId = null;
}
}
/**
* Schedules the next auto-sync for 24 hours from now.
*/
function scheduleNextAutoSync() {
if (!isHomePage() || document.hidden) return;
clearAutoSync();
autoSyncTimerId = setTimeout(() => {
fetchLikedMaps((success) => {
if (success) {
lastSyncTimestamp = Date.now();
GM_setValue('geoguessr_last_sync_timestamp', lastSyncTimestamp);
}
scheduleNextAutoSync();
});
}, ONE_DAY_MS);
}
/**
* Initiates the auto-sync system, performing an immediate sync if needed.
*/
function startAutoSync() {
const now = Date.now();
if (lastSyncTimestamp === 0 || (now - lastSyncTimestamp) >= ONE_DAY_MS) {
const message = lastSyncTimestamp === 0 ?
'GeoGuessr Liked Maps Widget: First run. Performing initial sync.' :
'GeoGuessr Liked Maps Widget: More than 24 hours since last sync. Performing sync.';
console.log(message);
fetchLikedMaps((success) => {
if (success) {
lastSyncTimestamp = Date.now();
GM_setValue('geoguessr_last_sync_timestamp', lastSyncTimestamp);
}
scheduleNextAutoSync();
});
} else {
scheduleNextAutoSync();
}
}
// ======================
// Map Data Management
// ======================
/**
* Sorts an array of map objects based on the current sort preference.
* @param {Array} maps
* @returns {Array}
*/
function sortMaps(maps) {
const sorted = [...maps];
switch (currentSort) {
case 'a-z':
return sorted.sort((a, b) => {
const nameA = trimSpecialCharacters(a.name).toLowerCase();
const nameB = trimSpecialCharacters(b.name).toLowerCase();
return nameA.localeCompare(nameB);
});
case 'z-a':
return sorted.sort((a, b) => {
const nameA = trimSpecialCharacters(a.name).toLowerCase();
const nameB = trimSpecialCharacters(b.name).toLowerCase();
return nameB.localeCompare(nameA);
});
case 'creator':
return sorted.sort((a, b) => {
const creatorA = (a.inExplorerMode ? '_CLASSIC_MAP_' : (a.creator?.nick || 'Unknown')).toLowerCase();
const creatorB = (b.inExplorerMode ? '_CLASSIC_MAP_' : (b.creator?.nick || 'Unknown')).toLowerCase();
return creatorA.localeCompare(creatorB);
});
case 'updated':
return sorted.sort((a, b) => {
const dateA = new Date(a.updatedAt || 0);
const dateB = new Date(b.updatedAt || 0);
return dateB - dateA;
});
default:
return sorted;
}
}
/**
* Filters and sorts the liked maps based on the current search term and pin state.
* Pinned maps are always shown first.
*/
function filterAndSortMaps() {
let filtered;
if (searchTerm === '') {
filtered = [...likedMaps];
} else {
filtered = likedMaps.filter(map =>
trimSpecialCharacters(map.name).toLowerCase().includes(searchTerm)
);
}
const pinnedMaps = filtered.filter(map => pinnedMapIds.has(map.id));
const unpinnedMaps = filtered.filter(map => !pinnedMapIds.has(map.id));
const sortedPinned = sortMaps(pinnedMaps);
const sortedUnpinned = sortMaps(unpinnedMaps);
filteredMaps = [...sortedPinned, ...sortedUnpinned];
}
/**
* Fetches the user's liked maps from the GeoGuessr API.
* @param {Function} callback - Called with a boolean indicating success.
*/
function fetchLikedMaps(callback) {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://www.geoguessr.com/api/v3/likes?count=0',
headers: {
'Accept': 'application/json',
'Cache-Control': 'no-cache',
'User-Agent': 'GeoGuessrLikedMapsWidget/0.9.8 (UserScript; https://greasyfork.org/)'
},
onload: function (response) {
const ok = response.status >= 200 && response.status < 300;
if (ok) {
try {
const data = JSON.parse(response.responseText);
// Cleanse pinned map IDs
const newLikedMapIds = new Set(data.map(map => map.id));
pinnedMapIds = new Set([...pinnedMapIds].filter(id => newLikedMapIds.has(id)));
likedMaps = data || [];
// Load and sanitize pinned map IDs from storage
const storedPinned = GM_getValue(CONFIG.PINNED_MAPS_STORAGE_KEY, '[]');
try {
const parsedPinned = JSON.parse(storedPinned);
// Use the cleansed set, but save it back to storage
GM_setValue(CONFIG.PINNED_MAPS_STORAGE_KEY, JSON.stringify([...pinnedMapIds]));
} catch (e) {
console.error('Error parsing stored pinned maps:', e);
}
filterAndSortMaps();
GM_setValue(CONFIG.STORAGE_KEY, JSON.stringify(likedMaps));
initialDataLoaded = true;
if (widgetInitialized) {
updateWidgetContent();
}
showSyncFeedback(true);
if (typeof callback === 'function') callback(true);
} catch (e) {
console.error('Error parsing JSON:', e);
showError('Error parsing liked maps data');
showSyncFeedback(false);
if (typeof callback === 'function') callback(false);
}
} else {
console.error('Failed to fetch liked maps. Status:', response.status);
showError('Failed to fetch liked maps. Are you logged in?');
showSyncFeedback(false);
if (typeof callback === 'function') callback(false);
}
},
onerror: function (error) {
console.error('Error fetching liked maps:', error);
showError('Error fetching liked maps. Check console for details.');
showSyncFeedback(false);
if (typeof callback === 'function') callback(false);
}
});
}
/**
* Attempts to load liked maps from local storage. If unavailable or invalid, fetches from API.
*/
function loadLikedMapsEarly() {
const storedMaps = GM_getValue(CONFIG.STORAGE_KEY, null);
if (storedMaps) {
try {
likedMaps = JSON.parse(storedMaps);
const storedPinned = GM_getValue(CONFIG.PINNED_MAPS_STORAGE_KEY, '[]');
try {
const parsedPinned = JSON.parse(storedPinned);
pinnedMapIds = new Set(parsedPinned);
} catch (e) {
console.error('Error parsing stored pinned maps:', e);
pinnedMapIds = new Set();
}
filterAndSortMaps();
initialDataLoaded = true;
} catch (e) {
console.error('Error parsing stored maps:', e);
fetchLikedMaps();
}
} else {
fetchLikedMaps();
}
}
// ======================
// UI & Tooltip Management
// ======================
/**
* Removes the currently displayed tooltip, if any.
*/
function removeTooltip() {
if (tooltip) {
tooltip.remove();
tooltip = null;
}
}
/**
* Creates and displays a tooltip for a map.
* @param {Object} map - The map object.
* @param {HTMLElement} element - The element to position the tooltip relative to.
*/
function showMapTooltip(map, element) {
removeTooltip();
const coordinateCount = map.coordinateCount || 'Unknown';
const updatedAt = map.updatedAt ? new Date(map.updatedAt).toLocaleDateString() : 'Unknown';
const description = map.description || 'No description available.';
const tags = map.tags || [];
tooltip = document.createElement('div');
tooltip.className = 'map-tooltip';
tooltip.innerHTML = `
<div class="tooltip-header">
<div class="tooltip-locations">${coordinateCount} locations</div>
<div class="tooltip-updated">Updated: ${updatedAt}</div>
</div>
<div class="tooltip-description">${description}</div>
${tags.length > 0 ? `
<div class="tooltip-tags">
${tags.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')}
</div>
` : ''}
`;
document.body.appendChild(tooltip);
const rect = element.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const left = rect.left - tooltipRect.width;
const top = rect.top + rect.height * 2;
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
setTimeout(() => tooltip.classList.add('visible'), 10);
}
/**
* Shows visual feedback on the sync button (success or error).
* @param {boolean} success
*/
function showSyncFeedback(success) {
const syncButton = document.querySelector('.sync-button');
if (!syncButton) return;
const iconElement = syncButton.querySelector('.button_icon__qFeMJ');
if (!iconElement) return;
if (!syncButton.dataset.originalIcon) {
syncButton.dataset.originalIcon = iconElement.innerHTML;
}
syncButton.classList.remove('syncing', 'sync-success', 'sync-error');
if (success) {
iconElement.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
`;
syncButton.classList.add('sync-success');
} else {
iconElement.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
`;
syncButton.classList.add('sync-error');
}
setTimeout(() => {
syncButton.classList.remove('sync-success', 'sync-error');
if (syncButton.dataset.originalIcon) {
iconElement.innerHTML = syncButton.dataset.originalIcon;
}
}, 2000);
}
/**
* Displays an error message in the widget content area.
* @param {string} message
*/
function showError(message) {
updateWidgetContent(message);
}
// ======================
// Widget Management
// ======================
/**
* Removes the widget and its tooltip from the DOM.
*/
function removeWidget() {
const widget = document.getElementById('geoguessr-liked-maps-widget');
if (widget) {
widget.remove();
}
removeTooltip();
}
/**
* Retrieves the right sidebar element.
* @returns {HTMLElement|null}
*/
function getRightSidebar() {
return document.querySelector(CONFIG.SELECTORS.RIGHT_SIDEBAR);
}
/**
* Creates the skeleton/HTML structure of the widget.
* @returns {HTMLElement}
*/
function createWidgetSkeleton() {
const widget = document.createElement('div');
widget.id = 'geoguessr-liked-maps-widget';
widget.className = 'widget_root__j5z2N';
const widgetBorder = document.createElement('div');
widgetBorder.className = 'widget_widgetBorder__91no_';
widgetBorder.style.setProperty('--slideInDirection', '1');
const widgetOuter = document.createElement('div');
widgetOuter.className = 'widget_widgetOuter__6pfjR';
const widgetInner = document.createElement('div');
widgetInner.className = 'widget_widgetInner__rjXwy';
const header = document.createElement('div');
header.className = 'widget_header__51RHy';
header.innerHTML = `
<div class="widget_title___3rHd">
<label style="--fs:var(--font-size-16);--lh:var(--line-height-16)" class="label_label__9xkbh shared_boldWeight__U2puG label_italic__LM62Y shared_whiteVariant__BKF94">Liked Maps</label>
</div>
<div class="widget_rightSlot__B0ZxO">
<div class="sort-container">
<button class="sort-button button_button__aR6_e button_variantTertiary__wW1d2 button_sizeSmall__MB_qj" title="Sort Maps">
<span class="button_icon__qFeMJ">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/>
</svg>
</span>
</button>
<div class="sort-dropdown">
<div class="sort-option">
<label class="sort-radio-label">
<input type="radio" name="sort" value="default" ${currentSort === 'default' ? 'checked' : ''}>
<span class="radio-custom"></span>
Default
</label>
</div>
<div class="sort-option">
<label class="sort-radio-label">
<input type="radio" name="sort" value="a-z" ${currentSort === 'a-z' ? 'checked' : ''}>
<span class="radio-custom"></span>
A-Z
</label>
</div>
<div class="sort-option">
<label class="sort-radio-label">
<input type="radio" name="sort" value="z-a" ${currentSort === 'z-a' ? 'checked' : ''}>
<span class="radio-custom"></span>
Z-A
</label>
</div>
<div class="sort-option">
<label class="sort-radio-label">
<input type="radio" name="sort" value="creator" ${currentSort === 'creator' ? 'checked' : ''}>
<span class="radio-custom"></span>
Creator
</label>
</div>
<div class="sort-option">
<label class="sort-radio-label">
<input type="radio" name="sort" value="updated" ${currentSort === 'updated' ? 'checked' : ''}>
<span class="radio-custom"></span>
Last Updated
</label>
</div>
</div>
</div>
<div class="search-container">
<input type="text" class="map-search-input" placeholder="Search maps..." value="${searchTerm}" />
<button class="clear-search-button ${!searchTerm ? 'hidden' : ''}" title="Clear search">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<button class="sync-button button_button__aR6_e button_variantTertiary__wW1d2 button_sizeSmall__MB_qj" title="Sync Liked Maps">
<span class="button_icon__qFeMJ">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26l1.45-1.45c-.42-.99-.69-2.06-.69-3.21 0-3.31 2.69-6 6-6zm6.76 1.74l-1.45 1.45c.42.99.69 2.06.69 3.21 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z"/>
</svg>
</span>
</button>
</div>`;
widgetInner.appendChild(header);
const dividerWrapper = document.createElement('div');
dividerWrapper.className = 'widget_dividerWrapper__uSf4D';
dividerWrapper.innerHTML = '<hr class="styles_divider__SMppY styles_variantWhiteTransparentSoft__bJmMq">';
widgetInner.appendChild(dividerWrapper);
const contentArea = document.createElement('div');
contentArea.className = 'liked-maps-content';
contentArea.style.overflowY = 'auto';
contentArea.innerHTML = `
<div style="padding: 20px; text-align: center; color: #a0aec0;">
<div class="loading-spinner" style="margin-bottom: 10px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26l1.45-1.45c-.42-.99-.69-2.06-.69-3.21 0-3.31 2.69-6 6-6zm6.76 1.74l-1.45 1.45c.42.99.69 2.06.69 3.21 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z"/>
</svg>
</div>
<div style="font-size: 12px;">Loading liked maps...</div>
</div>`;
widgetInner.appendChild(contentArea);
widgetOuter.appendChild(widgetInner);
widgetBorder.appendChild(widgetOuter);
widget.appendChild(widgetBorder);
return widget;
}
/**
* Updates the content of the widget, displaying maps, errors, or placeholders.
* @param {string|null} errorMessage
*/
function updateWidgetContent(errorMessage = null) {
const widget = document.getElementById('geoguessr-liked-maps-widget');
if (!widget) return;
const contentArea = widget.querySelector('.liked-maps-content');
if (!contentArea) return;
const rightSidebar = getRightSidebar();
if (rightSidebar) {
const sidebarHeight = rightSidebar.offsetHeight;
const calculatedHeight = sidebarHeight - 624; // Safety margin for other widgets
const minHeight = 2 * 37 + 6; // 2 items minimum + padding
const maxHeight = 12 * 37 + 6; // 12 items maximum + padding
contentArea.style.height = `${Math.min(Math.max(calculatedHeight, minHeight), maxHeight)}px`;
}
widget.classList.remove('widget_loading');
let mapItemsHTML = '';
if (errorMessage) {
mapItemsHTML = `
<div style="padding: 20px; text-align: center; color: #e53e3e;">
<div style="margin-bottom: 10px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v2z"/>
</svg>
</div>
<div style="font-size: 12px;">${errorMessage}</div>
</div>`;
} else if (!filteredMaps || filteredMaps.length === 0) {
mapItemsHTML = `
<div style="padding: 20px; text-align: center; color: #a0aec0;">
<div style="margin-bottom: 10px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l5-5-5-5v10z"/>
</svg>
</div>
<div style="font-size: 12px;">${likedMaps.length === 0 ? 'No liked maps found. Click sync to load your liked maps.' : 'No maps match your search.'}</div>
</div>`;
} else {
filteredMaps.forEach((map) => {
const originalIndex = likedMaps.findIndex(m => m.id === map.id);
const creatorName = map.inExplorerMode ? '_CLASSIC_MAP_' : (map.creator?.nick || 'Unknown');
const creatorUrl = map.inExplorerMode ? null : (map.creator && map.creator.url ? map.creator.url : null);
const collaboratorCount = map.collaborators ? map.collaborators.length : 0;
const creatorDisplay = collaboratorCount > 0 ?
`${creatorName} (+${collaboratorCount})` : creatorName;
const backgroundClass = map.inExplorerMode ? 'map-avatar_classic' :
`map-avatar_${map.avatar?.background || 'day'}`;
const isPinned = pinnedMapIds.has(map.id);
const pinIconHtml = `
<div class="pin-icon ${isPinned ? 'pinned' : ''}"
data-map-id="${map.id}"
title="${isPinned ? 'Unpin map' : 'Pin map'}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>`;
mapItemsHTML += `
<div class="liked-map-item" data-map-index="${originalIndex}">
${pinIconHtml}
<div class="map-thumbnail ${backgroundClass}"></div>
<div class="liked-map-name">${map.name}</div>
${map.inExplorerMode ? '<div class="official-badge">Classic</div>' :
creatorUrl ? `<a href="${creatorUrl}" class="liked-map-creator" onclick="event.stopPropagation();">${creatorDisplay}</a>` :
`<div class="liked-map-creator">${creatorDisplay}</div>`
}
<div class="liked-map-info-icon" data-map-index="${originalIndex}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
</svg>
</div>
</div>`;
});
}
// Update clear search button visibility
const searchContainer = widget.querySelector('.search-container');
if (searchContainer) {
const clearButton = searchContainer.querySelector('.clear-search-button');
if (clearButton) {
if (searchTerm) {
clearButton.classList.remove('hidden');
} else {
clearButton.classList.add('hidden');
}
}
}
contentArea.innerHTML = `<div style="padding: 6px 8px 12px 8px;">${mapItemsHTML}</div>`;
// Attach event listeners after updating content
attachMapItemListeners(contentArea);
}
/**
* Mounts the widget into the right sidebar, positioning it before the daily challenge widget.
* @param {HTMLElement} widget
* @returns {boolean} True if successfully mounted.
*/
function mountInSidebar(widget) {
const rightSidebar = getRightSidebar();
if (!rightSidebar) return false;
const existing = rightSidebar.querySelector('#geoguessr-liked-maps-widget');
if (existing) existing.remove();
const dailyChallengeWidget = rightSidebar.querySelector('.daily-streak_root__njtkG')?.closest('.widget_root__j5z2N');
if (dailyChallengeWidget) {
rightSidebar.insertBefore(widget, dailyChallengeWidget);
} else {
const firstWidget = rightSidebar.querySelector('.widget_root__j5z2N');
if (firstWidget) {
rightSidebar.insertBefore(widget, firstWidget);
} else {
rightSidebar.appendChild(widget);
}
}
return true;
}
/**
* Creates and initializes the widget on the homepage.
*/
function createWidget() {
if (!isHomePage()) {
removeWidget();
return;
}
const rightSidebar = getRightSidebar();
if (!rightSidebar) return;
removeWidget();
const widget = createWidgetSkeleton();
if (mountInSidebar(widget)) {
setupWidgetInteractions(widget);
widgetInitialized = true;
// Update immediately to show "Loading..." or cached data
setTimeout(() => updateWidgetContent(), 50);
return widget;
}
return null;
}
// ======================
// Widget Interaction Handlers
// ======================
/**
* Clears the search input and resets the map list.
*/
function clearSearch() {
searchTerm = '';
filterAndSortMaps();
updateWidgetContent();
const searchInput = document.querySelector('.map-search-input');
if (searchInput) {
searchInput.value = '';
searchInput.focus();
}
}
/**
* Sets up all event listeners for the widget (sort, sync, search, pin, info, map navigation).
* @param {HTMLElement} widget
*/
function setupWidgetInteractions(widget) {
// Sort Button and Dropdown
const sortButton = widget.querySelector('.sort-button');
const sortDropdown = widget.querySelector('.sort-dropdown');
if (sortButton && sortDropdown) {
sortButton.addEventListener('click', function (e) {
e.stopPropagation();
const isVisible = sortDropdown.classList.contains('visible');
document.querySelectorAll('.sort-dropdown.visible').forEach(dropdown => {
if (dropdown !== sortDropdown) {
dropdown.classList.remove('visible');
}
});
sortDropdown.classList.toggle('visible', !isVisible);
});
const sortRadios = sortDropdown.querySelectorAll('input[name="sort"]');
sortRadios.forEach(radio => {
radio.addEventListener('change', function () {
if (this.checked) {
currentSort = this.value;
GM_setValue(CONFIG.SORT_STORAGE_KEY, currentSort);
filterAndSortMaps();
updateWidgetContent();
sortDropdown.classList.remove('visible');
}
});
});
}
document.addEventListener('click', function (e) {
if (sortDropdown && !sortDropdown.contains(e.target) && !sortButton.contains(e.target)) {
sortDropdown.classList.remove('visible');
}
});
// Sync Button
const syncButton = widget.querySelector('.sync-button');
if (syncButton) {
syncButton.addEventListener('click', function () {
this.classList.add('syncing');
clearAutoSync();
fetchLikedMaps((success) => {
scheduleNextAutoSync();
});
});
}
// Search Input
const searchInput = widget.querySelector('.map-search-input');
if (searchInput) {
if (searchTerm) {
setTimeout(() => searchInput.focus(), 100);
}
const debouncedSearch = debounce(() => {
filterAndSortMaps();
updateWidgetContent();
}, 300);
searchInput.addEventListener('input', function () {
searchTerm = trimSpecialCharacters(this.value).toLowerCase();
debouncedSearch();
});
}
// Clear Search Button
const clearButton = widget.querySelector('.clear-search-button');
if (clearButton) {
clearButton.addEventListener('click', clearSearch);
}
}
/**
* Attach event listeners to map items (called after content updates)
* @param {HTMLElement} container
*/
function attachMapItemListeners(container) {
// Map Item Click Event Listeners
const mapItems = container.querySelectorAll('.liked-map-item');
mapItems.forEach(item => {
item.addEventListener('click', function (e) {
if (!e.target.closest('.liked-map-info-icon') && !e.target.closest('.pin-icon')) {
const mapIndex = this.dataset.mapIndex;
if (mapIndex !== undefined && likedMaps[mapIndex] && likedMaps[mapIndex].url) {
window.location.href = likedMaps[mapIndex].url;
}
}
});
});
// Info Icon Event Listeners
const infoIcons = container.querySelectorAll('.liked-map-info-icon');
infoIcons.forEach(icon => {
icon.addEventListener('mouseover', function (e) {
const mapIndex = this.dataset.mapIndex;
if (mapIndex !== undefined && likedMaps[mapIndex]) {
showMapTooltip(likedMaps[mapIndex], this);
}
});
icon.addEventListener('mouseout', function () {
removeTooltip();
});
});
// Pin Icon Event Listeners
const pinIcons = container.querySelectorAll('.pin-icon');
pinIcons.forEach(pinIcon => {
pinIcon.addEventListener('click', function (e) {
e.stopPropagation(); // Prevent the click from triggering the map link
const mapId = this.getAttribute('data-map-id');
if (pinnedMapIds.has(mapId)) {
// Unpin
pinnedMapIds.delete(mapId);
this.classList.remove('pinned');
this.setAttribute('title', 'Pin map');
} else {
// Pin
pinnedMapIds.add(mapId);
this.classList.add('pinned');
this.setAttribute('title', 'Unpin map');
}
// Persist the updated pin state to storage
GM_setValue(CONFIG.PINNED_MAPS_STORAGE_KEY, JSON.stringify([...pinnedMapIds]));
// Re-sort and update the UI
filterAndSortMaps();
updateWidgetContent();
});
});
}
// ======================
// Page & Observer Management
// ======================
/**
* Ensures the widget is mounted into the DOM.
*/
function ensureMounted() {
const checkReady = setInterval(() => {
const rightSidebar = getRightSidebar();
if (rightSidebar && rightSidebar.offsetWidth > 0) {
clearInterval(checkReady);
if (widgetContainer) {
const targetElement = getTargetElement();
if (targetElement && targetElement.parentNode) {
targetElement.parentNode.insertBefore(widgetContainer, targetElement.nextSibling);
}
}
}
}, 200);
}
/**
* Checks if the page URL has changed and triggers re-initialization if necessary.
*/
function checkPageChange() {
if (window.location.pathname !== currentPathname) {
currentPathname = window.location.pathname;
if (!isHomePage()) {
removeWidget();
widgetInitialized = false;
clearAutoSync();
} else {
widgetInitialized = false;
initialDataLoaded = false;
setTimeout(initializeWidget, 200);
}
}
}
/**
* Initializes the widget on the homepage by waiting for the right sidebar.
*/
function initializeWidget() {
if (widgetInitialized || !isHomePage()) return;
// Load initial data early to prevent a blank state
if (!initialDataLoaded) {
loadLikedMapsEarly();
}
// Use a robust check to ensure the sidebar is ready
const checkReady = setInterval(() => {
const rightSidebar = getRightSidebar();
if (rightSidebar && rightSidebar.offsetWidth > 0) {
clearInterval(checkReady);
const widget = createWidget();
if (widget) {
startAutoSync();
ensureMounted();
setTimeout(() => {
const widgetBorder = widget.querySelector('.widget_widgetBorder__91no_');
if (widgetBorder) {
widgetBorder.classList.add('widget_hasLoaded__uIeOz');
}
}, 50);
widgetInitialized = true;
}
}
}, 100); // Check every 100ms
// Add a fallback timer in case something unexpected happens
setTimeout(() => {
clearInterval(checkReady);
if (!widgetInitialized) {
console.log("GeoGuessr Liked Maps Widget: Fallback initialization triggered.");
const widget = createWidget();
if (widget) {
startAutoSync();
ensureMounted();
setTimeout(() => {
const widgetBorder = widget.querySelector('#geoguessr-liked-maps-widget .widget_widgetBorder__91no_');
if (widgetBorder) {
widgetBorder.classList.add('widget_hasLoaded__uIeOz');
}
}, 50);
widgetInitialized = true;
}
if (!widgetInitialized) {
console.warn("GeoGuessr Liked Maps Widget: Failed to initialize after 2 seconds.");
const sidebar = getRightSidebar();
if (sidebar) {
const errorMessage = document.createElement('p');
errorMessage.textContent = 'Liked Maps widget failed to load. Please refresh the page.';
errorMessage.style.color = 'red';
errorMessage.style.marginTop = '10px';
sidebar.prepend(errorMessage);
}
}
}
}, 2000); // Wait up to 2 seconds before giving up on the interval check
}
/**
* Handles window resize events to re-initialize the widget or update its layout.
*/
const handleResize = debounce(() => {
if (isHomePage() && !widgetInitialized) {
setTimeout(initializeWidget, 100);
}
if (isHomePage() && widgetInitialized) {
const contentArea = document.querySelector('#geoguessr-liked-maps-widget .liked-maps-content');
const rightSidebar = getRightSidebar();
if (contentArea && rightSidebar) {
const sidebarHeight = rightSidebar.offsetHeight;
const calculatedHeight = sidebarHeight - 624; // Safety margin for other widgets
const minHeight = 2 * 37 + 6; // 2 items minimum + padding
const maxHeight = 12 * 37 + 6; // 12 items maximum + padding
contentArea.style.height = `${Math.min(Math.max(calculatedHeight, minHeight), maxHeight)}px`;
}
}
}, 10);
// ======================
// Styling
// ======================
/**
* Injects all necessary CSS styles into the document head.
*/
function addCustomStyles() {
const styles = `
/* Widget root */
.widget_root__j5z2N {
position: relative;
}
/* Main widget border - This is the element that slides in */
.widget_widgetBorder__91no_ {
--slideInDirection: -1;
-webkit-backdrop-filter: blur(.5rem);
backdrop-filter: blur(.5rem);
background: color-mix(in srgb, var(--ds-color-purple-100) 90%, transparent);
border: .0625rem solid var(--ds-color-purple-80, var(--ds-color-purple-80));
border-radius: 1rem;
min-height: 1rem;
opacity: 0;
padding: .25rem;
transform: translateX(calc(110% * min(var(--slideInDirection), var(--allElementsOnLeftSide))));
transition: all .75s cubic-bezier(.44,0,0,1);
}
.widget_widgetBorder__91no_.widget_hasLoaded__uIeOz {
opacity: 1;
transform: translateX(0);
}
.widget_widgetBorder__91no_.widget_slideInRight__TJ0yO {
--slideInDirection: 1;
}
/* Outer container */
.widget_widgetOuter__6pfjR {
background: linear-gradient(hsla(0,0%,100%,.039), hsla(0,0%,100%,.012) 2.5rem);
border-radius: .75rem;
box-shadow: inset 0 0 .25rem var(--ds-color-purple-70);
height: 100%;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
width: 100%;
}
/* Inner container */
.widget_widgetInner__rjXwy {
--padding: 1rem;
display: flex;
flex-direction: column;
position: relative;
}
/* Header */
.widget_header__51RHy {
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
min-height: 1.5rem;
padding: calc(var(--padding)*.75) var(--padding) calc(var(--padding)*.5);
}
.widget_header__51RHy .widget_title___3rHd {
position: relative;
}
.widget_header__51RHy > .widget_rightSlot__B0ZxO > a,
.widget_header__51RHy > .widget_rightSlot__B0ZxO > button {
margin-top: -.1875rem;
}
.widget_rightSlot__B0ZxO {
display: flex;
}
/* Divider */
.widget_dividerWrapper__uSf4D {
margin: 0 var(--padding);
width: calc(100% - var(--padding)*2);
}
/* Simple widget border (fallback if needed) */
.widget_simpleWidgetBorder__Xgyhn {
-webkit-backdrop-filter: blur(.5rem);
backdrop-filter: blur(.5rem);
background: color-mix(in srgb, var(--ds-color-purple-100) 95%, transparent);
border: .0625rem solid var(--ds-color-purple-80, var(--ds-color-purple-80));
border-radius: 1rem;
min-height: 1rem;
padding: .25rem;
}
/* Loading spinner animation */
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Control buttons (sort, sync) */
.sort-button,
.sync-button {
display: flex !important;
align-items: center !important;
justify-content: center !important;
height: 28px !important;
width: 28px !important;
min-width: 28px !important;
padding: 0 !important;
border: none !important;
box-sizing: border-box !important;
margin-top: 0px !important;
}
.button_icon__qFeMJ {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 14px !important;
height: 14px !important;
margin: 0 !important;
}
.button_icon__qFeMJ svg {
width: 100%;
height: 100%;
display: block;
}
/* Sync button feedback animations */
.sync-button.sync-success {
animation: pulseSuccess 0.5s ease-in-out;
background: #48bb78 !important;
color: white !important;
border-color: #48bb78 !important;
}
.sync-button.sync-error {
animation: pulseError 0.5s ease-in-out;
background: #f56565 !important;
color: white !important;
border-color: #f56565 !important;
}
@keyframes pulseSuccess {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(72, 187, 120, 0.7);
}
50% {
transform: scale(1.05);
}
70% {
box-shadow: 0 0 0 10px rgba(72, 187, 120, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(72, 187, 120, 0);
}
}
@keyframes pulseError {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(245, 101, 101, 0.7);
}
50% {
transform: scale(1.05);
}
70% {
box-shadow: 0 0 0 10px rgba(245, 101, 101, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(245, 101, 101, 0);
}
}
.sync-button.sync-success .button_icon__qFeMJ,
.sync-button.sync-error .button_icon__qFeMJ {
color: white !important;
}
/* Syncing animation */
.sync-button.syncing {
animation: spin 1s linear infinite;
}
/* Search container */
.search-container {
position: relative;
display: flex;
align-items: center;
height: 28px;
width: 160px !important;
}
.map-search-input {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #e2e8f0;
padding: 6px 8px;
font-size: 12px;
font-family: 'ggfont', sans-serif;
display: flex !important;
transition: all 0.15s ease;
height: 28px;
width: 100% !important;
box-sizing: border-box !important;
}
.map-search-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.15);
}
.map-search-input::placeholder {
color: #a0aec0;
}
.clear-search-button {
position: absolute;
right: 6px;
background: none;
border: none;
color: #a0aec0;
cursor: pointer;
padding: 2px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.clear-search-button:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.1);
}
.clear-search-button.hidden {
display: none;
}
/* Sort dropdown */
.sort-container {
position: relative;
display: flex;
align-items: center;
}
.sort-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: #2d3748;
border: 1px solid #4a5568;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1001;
min-width: 130px;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.2s ease;
pointer-events: none;
}
.sort-dropdown.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.sort-option {
padding: 0;
}
.sort-radio-label {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 12px;
color: #e2e8f0;
cursor: pointer;
transition: background 0.15s ease;
user-select: none;
}
.sort-radio-label:hover {
background: rgba(255, 255, 255, 0.08);
}
.sort-radio-label:first-child {
border-radius: 6px 6px 0 0;
}
.sort-radio-label:last-child {
border-radius: 0 0 6px 6px;
}
.sort-radio-label input[type="radio"] {
display: none;
}
.radio-custom {
width: 12px;
height: 12px;
border: 2px solid #a0aec0;
border-radius: 50%;
position: relative;
flex-shrink: 0;
transition: all 0.15s ease;
}
.sort-radio-label input[type="radio"]:checked + .radio-custom {
border-color: #63b3ed;
background: #63b3ed;
}
.sort-radio-label input[type="radio"]:checked + .radio-custom::after {
content: '';
width: 4px;
height: 4px;
background: white;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* Map items */
.liked-map-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
margin-bottom: 3px;
cursor: pointer;
transition: all 0.15s ease;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
}
.liked-map-item:hover {
background: rgba(255, 255, 255, 0.08) !important;
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* Pin Icon Styles */
/* Padding around the icon to make it easier to click */
/* Margin to offset the padding so it doesn't affect layout */
.pin-icon {
width: 16px;
height: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s ease;
opacity: 1;
padding: 6px;
margin: -6px;
}
.pin-icon:hover {
transform: scale(1.1);
cursor: pointer;
}
.pin-icon:active {
transform: scale(1.2);
}
.pin-icon svg {
width: 100%;
height: 100%;
display: block;
fill: transparent;
stroke: #a0aec0;
stroke-width: 1.5;
transition: all 0.15s ease;
}
.pin-icon:hover svg {
stroke: #e2e8f0;
filter: drop-shadow(0 0 3px rgba(226, 232, 240, 0.5));
}
.pin-icon.pinned svg {
fill: #f8bf02;
stroke: none;
filter: drop-shadow(0 0 4px rgba(248, 191, 2, 0.6));
animation: gentle-shimmer 3s ease-in-out infinite;
}
.pin-icon.pinned:hover svg {
fill: #ffd700;
filter: drop-shadow(0 0 6px rgba(255, 215, 0, 0.8));
animation: none;
}
@keyframes gentle-shimmer {
0%, 100% { filter: drop-shadow(0 0 4px rgba(248, 191, 2, 0.6)); }
50% { filter: drop-shadow(0 0 6px rgba(255, 215, 0, 0.4)) drop-shadow(0 0 2px rgba(255, 255, 255, 0.3)); }
}
/* Map Avatar Styles */
.map-avatar_day { background: #d4eaed; }
.map-avatar_morning { background: linear-gradient(180deg, #c2db9c, #6eafe0); }
.map-avatar_evening { background: linear-gradient(180deg, #a25e92, #01354b); }
.map-avatar_night { background: #01354b; }
.map-avatar_darknight { background: linear-gradient(180deg, #3c1d35, #01354b); }
.map-avatar_sunrise { background: linear-gradient(180deg, #f8ab12, #e7861f); }
.map-avatar_sunset { background: linear-gradient(180deg, #b34692, #ec6079); }
.map-avatar_classic { background: #c52626; }
.map-thumbnail {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid white;
flex-shrink: 0;
}
/* Map Name & Creator */
.liked-map-name {
font-size: 12px;
font-weight: 600;
color: #e2e8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.liked-map-creator {
font-size: 11px;
color: var(--ds-color-white-40);
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: auto;
min-width: unset;
text-align: right;
margin-right: 4px;
text-decoration: none;
transition: color 0.15s ease;
}
.liked-map-creator:hover {
color: #e2e8f0 !important;
transform: scale(1.05);
}
/* Special badge for classic maps */
.official-badge {
background: linear-gradient(135deg, #9900ffff, #6b03a7ff);
color: #ffffffff !important;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
padding: 3px 6px;
border-radius: 4px;
letter-spacing: 0.5px;
box-shadow: 0 2px 4px rgba(174, 0, 255, 0.3);
margin-left: auto;
white-space: nowrap;
}
/* Info icon */
.liked-map-info-icon {
width: 14px;
height: 14px;
color: #a0aec0;
cursor: help;
opacity: 0.7;
transition: all 0.15s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.liked-map-info-icon:hover {
opacity: 1;
color: #e2e8f0;
transform: scale(1.1);
}
/* Maps container and Scrollbar */
.liked-maps-content::-webkit-scrollbar {
width: 8px;
}
.liked-maps-content::-webkit-scrollbar-track {
background: transparent;
}
.liked-maps-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.liked-maps-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
border: 2px solid transparent;
background-clip: padding-box;
}
.liked-maps-content::-webkit-scrollbar-button {
display: none;
}
.liked-maps-content {
overflow-y: auto !important;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
/* Ensure sidebar doesn't overflow */
.pro-user-start-page_right__1Hf3g {
box-sizing: border-box !important;
}
/* Tooltip */
.map-tooltip {
position: fixed;
background: #2d3748;
border: 1px solid #4a5568;
border-radius: 6px;
padding: 12px;
font-size: 12px;
color: #e2e8f0;
z-index: 1000;
max-width: 300px;
min-width: 250px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.map-tooltip.visible {
opacity: 1;
}
.tooltip-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-weight: 600;
}
.tooltip-locations {
color: #63b3ed;
}
.tooltip-updated {
color: #a0aec0;
font-size: 11px;
}
.tooltip-description {
margin-bottom: 8px;
line-height: 1.4;
}
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.tooltip-tag {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
}
/* Button colors */
.button_variantTertiary__wW1d2 {
background: rgba(255, 255, 255, 0.1) !important;
color: #e2e8f0 !important;
}
.button_variantTertiary__wW1d2:hover {
background: rgba(255, 255, 255, 0.15) !important;
}
/* Adjust spacing between header elements */
.widget_header__51RHy > .widget_rightSlot__B0ZxO > .sort-container {
margin-right: 8px !important;
}
.widget_header__51RHy > .widget_rightSlot__B0ZxO > .search-container {
margin-right: 8px !important;
}`;
const styleSheet = document.createElement('style');
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
}
/**
* Main initialization function.
*/
function init() {
addCustomStyles();
loadLikedMapsEarly();
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
checkPageChange();
}
});
urlObserver.observe(document, { subtree: true, childList: true });
initializeWidget();
window.addEventListener('resize', handleResize);
}
window.addEventListener('load', init);
})();