Creates a widget on the GeoGuessr start page featuring your liked maps
// ==UserScript==
// @name GeoGuessr Liked Maps Widget
// @version 0.9.10
// @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',
FOLDERS_STORAGE_KEY: 'geoguessr_liked_maps_folders',
SORT_STORAGE_KEY: 'geoguessr_sort_preference',
SELECTED_FOLDER_STORAGE_KEY: 'geoguessr_selected_folder',
PINNED_MAPS_STORAGE_KEY: 'geoguessr_pinned_maps',
};
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 likedMapsFolders = [];
let filteredMaps = [];
let searchTerm = '';
let currentSort = GM_getValue(CONFIG.SORT_STORAGE_KEY, 'default');
let currentFolderId = GM_getValue(CONFIG.SELECTED_FOLDER_STORAGE_KEY, 'all');
let pinnedMapIds = new Set();
let tooltip = null;
let initialDataLoaded = false;
// ======================
// Utility Functions
// ======================
/**
* Checks if the current page is GeoGuessr's home/start page.
* @returns {boolean}
*/
function isHomePage() {
return window.location.pathname === '/' || window.location.pathname === '';
}
/**
* Trims Unicode control and invisible formatting characters from a string.
* @param {string} str - Input string
* @returns {string}
*/
function trimSpecialCharacters(str) {
if (typeof str !== 'string') return str;
return str.replace(/^[\s\u0000-\u001F\u007F-\u009F\u2000-\u200F\u2028-\u202F\u2060-\u206F\uFEFF\uFFF0-\uFFFF]*/, '');
}
/**
* Returns a debounced version of the provided function.
* @template T
* @param {(...args: T[]) => void} func
* @param {number} delay - Milliseconds to delay
* @returns {(...args: T[]) => void}
*/
function debounce(func, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
}
/**
* Finds a class name that starts with the given prefix.
* @param {string} prefix
* @returns {string}
*/
function getClassName(prefix) {
const el = document.querySelector(`[class*="${prefix}"]`);
return el ? [...el.classList].find(cls => cls.startsWith(prefix)) || '' : '';
}
/**
* Returns a space-separated string of all classes from an element that contains the prefix.
* @param {string} prefix
* @returns {string}
*/
function getClassList(prefix) {
const el = document.querySelector(`[class*="${prefix}"]`);
return el ? Array.from(el.classList).join(' ') : '';
}
// ======================
// Auto-Sync Management
// ======================
// Auto-sync state
let lastSyncTimestamp = GM_getValue('geoguessr_last_sync_timestamp', 0);
let autoSyncTimerId = null;
/**
* Clears any pending auto-sync timer.
*/
function clearAutoSync() {
if (autoSyncTimerId) {
clearTimeout(autoSyncTimerId);
autoSyncTimerId = null;
}
}
/**
* Starts initial or scheduled sync based on last sync timestamp.
*/
function startAutoSync() {
clearAutoSync();
if (!isHomePage() || document.hidden) return;
const timeSinceLastSync = Date.now() - lastSyncTimestamp;
const syncImmediately = lastSyncTimestamp === 0 || timeSinceLastSync >= ONE_DAY_MS;
if (syncImmediately) {
console.log('GeoGuessr Liked Maps Widget: Performing auto-sync');
fetchLikedMapsAndFolders((success) => {
if (success) {
lastSyncTimestamp = Date.now();
GM_setValue('geoguessr_last_sync_timestamp', lastSyncTimestamp);
}
// Schedule next sync in exactly 24 hours
autoSyncTimerId = setTimeout(startAutoSync, ONE_DAY_MS);
});
} else {
// Schedule sync for when 24 hours have passed since last sync
const timeUntilNextSync = ONE_DAY_MS - timeSinceLastSync;
console.log(`GeoGuessr Liked Maps Widget: Next auto-sync in ${Math.round(timeUntilNextSync / (60 * 60 * 1000))} hours`);
autoSyncTimerId = setTimeout(startAutoSync, timeUntilNextSync);
}
}
// Handle page visibility
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearAutoSync();
} else if (isHomePage()) {
startAutoSync();
}
});
// ======================
// Map & Folder Data Management
// ======================
/**
* Sorts an array of maps according to the current sort preference.
* @param {Array} maps - Array of map objects
* @returns {Array} Sorted array
*/
function sortMaps(maps) {
const sorted = [...maps];
switch (currentSort) {
case 'a-z':
return sorted.sort((a, b) => trimSpecialCharacters(a.name).localeCompare(trimSpecialCharacters(b.name), undefined, { sensitivity: 'base' }));
case 'z-a':
return sorted.sort((a, b) => trimSpecialCharacters(b.name).localeCompare(trimSpecialCharacters(a.name), undefined, { sensitivity: 'base' }));
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) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
default:
return sorted;
}
}
/**
* Filters and sorts maps based on current folder, search term, and pin state.
*/
function filterAndSortMaps() {
let filtered = [...likedMaps];
// Apply folder filter
if (currentFolderId !== 'all') {
const folder = likedMapsFolders.find(f => f.id === currentFolderId);
if (folder?.likeIds) {
const folderIds = new Set(folder.likeIds);
filtered = filtered.filter(map => folderIds.has(map.id));
}
}
// Apply search filter
if (searchTerm) {
filtered = filtered.filter(map =>
trimSpecialCharacters(map.name).toLowerCase().includes(searchTerm)
);
}
// Separate pinned/unpinned and sort each group
const pinned = filtered.filter(map => pinnedMapIds.has(map.id));
const unpinned = filtered.filter(map => !pinnedMapIds.has(map.id));
filteredMaps = [...sortMaps(pinned), ...sortMaps(unpinned)];
}
/**
* Fetches liked maps and folders from GeoGuessr API and updates internal state.
* @param {Function} callback - Called with (success: boolean)
*/
function fetchLikedMapsAndFolders(callback) {
let mapsFetched = false;
let foldersFetched = false;
let mapsData = null;
let foldersData = null;
let hasFatalError = false;
const handleError = (message, detail) => {
console.error(message, detail);
showError(message);
showSyncFeedback(false);
if (typeof callback === 'function') callback(false);
};
const handleFatalError = (message, detail) => {
console.error(message, detail);
showError(message);
showSyncFeedback(false);
hasFatalError = true;
if (typeof callback === 'function') callback(false);
};
const checkDone = () => {
if (!mapsFetched || !foldersFetched || hasFatalError) return;
try {
// Use existing data if fetch failed but we have cached data
if (!mapsData) mapsData = likedMaps;
if (!foldersData) foldersData = likedMapsFolders;
// Process maps
const likedMapIds = new Set(mapsData.map(m => m.id));
let storedPinned;
try {
storedPinned = JSON.parse(GM_getValue(CONFIG.PINNED_MAPS_STORAGE_KEY, '[]'));
} catch (e) {
console.warn('Invalid pinned maps in storage, resetting.', e);
storedPinned = [];
}
const cleansedPinned = storedPinned.filter(id => likedMapIds.has(id));
pinnedMapIds = new Set(cleansedPinned);
GM_setValue(CONFIG.PINNED_MAPS_STORAGE_KEY, JSON.stringify(cleansedPinned));
// Update globals
likedMaps = mapsData;
likedMapsFolders = foldersData || [];
// Validate folder selection
if (currentFolderId !== 'all' && !likedMapsFolders.some(f => f.id === currentFolderId)) {
currentFolderId = 'all';
GM_setValue(CONFIG.SELECTED_FOLDER_STORAGE_KEY, currentFolderId);
updateFolderButtonStyle();
}
GM_setValue(CONFIG.STORAGE_KEY, JSON.stringify(likedMaps));
GM_setValue(CONFIG.FOLDERS_STORAGE_KEY, JSON.stringify(likedMapsFolders));
filterAndSortMaps();
initialDataLoaded = true;
if (widgetInitialized) updateWidgetContent();
showSyncFeedback(true);
if (typeof callback === 'function') callback(true);
} catch (e) {
handleFatalError('Error processing maps/folders', e);
}
};
// Fetch maps
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.10 (UserScript; https://greasyfork.org/)'
},
onload: function (response) {
const ok = response.status >= 200 && response.status < 300;
if (!ok) {
console.error('Failed to fetch liked maps', response.status);
showSyncFeedback(false);
mapsFetched = true;
checkDone();
return;
}
try {
mapsData = JSON.parse(response.responseText);
mapsFetched = true;
checkDone();
} catch (e) {
console.error('Error parsing liked maps data', e);
showSyncFeedback(false);
mapsFetched = true;
checkDone();
}
},
onerror: function (error) {
console.error('Error fetching liked maps', error);
showSyncFeedback(false);
mapsFetched = true;
checkDone();
}
});
// Fetch folders
GM_xmlhttpRequest({
method: 'GET',
url: 'https://www.geoguessr.com/api/v3/likes/folders',
headers: {
'Accept': 'application/json',
'Cache-Control': 'no-cache',
'User-Agent': 'GeoGuessrLikedMapsWidget/0.9.10 (UserScript; https://greasyfork.org/)'
},
onload: function (response) {
const ok = response.status >= 200 && response.status < 300;
if (ok) {
try {
foldersData = JSON.parse(response.responseText);
} catch (e) {
console.error('Error parsing folders:', e);
}
} else {
console.warn('Failed to fetch liked map folders. Status:', response.status);
}
foldersFetched = true;
checkDone();
},
onerror: function (error) {
console.warn('Error fetching liked map folders:', error);
foldersFetched = true;
checkDone();
}
});
}
/**
* Attempts to load liked maps/folders from storage early to populate the widget.
*/
function loadLikedMapsEarly() {
let hasError = false;
// Load maps
const storedMaps = GM_getValue(CONFIG.STORAGE_KEY, null);
if (storedMaps) {
try {
const parsed = JSON.parse(storedMaps);
likedMaps = Array.isArray(parsed) ? parsed : [];
if (!Array.isArray(parsed)) {
console.warn('Stored maps is not an array, resetting');
likedMaps = [];
hasError = true;
}
} catch (e) {
console.error('Error parsing stored maps:', e);
likedMaps = [];
hasError = true;
}
} else {
hasError = true;
}
// Load folders
const storedFolders = GM_getValue(CONFIG.FOLDERS_STORAGE_KEY, null);
if (storedFolders) {
try {
const parsed = JSON.parse(storedFolders);
likedMapsFolders = Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error('Error parsing stored folders:', e);
likedMapsFolders = [];
}
}
// Load pinned maps
const storedPinned = GM_getValue(CONFIG.PINNED_MAPS_STORAGE_KEY, '[]');
try {
const parsedPinned = JSON.parse(storedPinned);
pinnedMapIds = new Set(Array.isArray(parsedPinned) ? parsedPinned : []);
} catch (e) {
console.error('Error parsing stored pinned maps:', e);
pinnedMapIds = new Set();
}
if (hasError) {
fetchLikedMapsAndFolders();
} else {
filterAndSortMaps();
initialDataLoaded = true;
}
}
// ======================
// UI & Tooltip Management
// ======================
/**
* Removes any visible tooltip.
*/
function removeTooltip() {
if (tooltip) {
tooltip.remove();
tooltip = null;
}
}
/**
* Shows a tooltip with map metadata near the triggering element.
* @param {Object} map - Map object
* @param {Element} element - Trigger element
*/
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);
}
/**
* Updates visual state of folder button when a folder is selected.
*/
function updateFolderButtonStyle() {
const foldersButton = document.querySelector('.folders-button');
if (!foldersButton) return;
foldersButton.classList.toggle('active-folder', currentFolderId !== 'all');
}
/**
* Shows temporary visual feedback on sync button (success/error).
* @param {boolean} success
*/
function showSyncFeedback(success) {
const syncButton = document.querySelector('.sync-button');
if (!syncButton) return;
const iconElement = syncButton.querySelector('.button_icon_widget');
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 any associated UI (e.g., tooltip).
*/
function removeWidget() {
const widget = document.getElementById('geoguessr-liked-maps-widget');
if (widget) widget.remove();
removeTooltip();
clearAutoSync();
}
/**
* Queries the right sidebar container.
* @returns {Element|null}
*/
function getRightSidebar() {
return document.querySelector('[class*="pro-user-start-page_right"]');
}
/**
* Creates the initial skeleton of the widget DOM.
* @returns {HTMLDivElement}
*/
function createWidgetSkeleton() {
const widget = document.createElement('div');
widget.className = getClassName('widget_root');
widget.id = 'geoguessr-liked-maps-widget';
const widgetBorder = document.createElement('div');
widgetBorder.className = getClassName('widget_widgetBorder');
widgetBorder.style.setProperty('--slideInDirection', '1');
const widgetOuter = document.createElement('div');
widgetOuter.className = getClassName('widget_widgetOuter');
const widgetInner = document.createElement('div');
widgetInner.className = getClassName('widget_widgetInner');
const header = document.createElement('div');
header.className = getClassName('widget_header');
header.innerHTML = `
<div class="${getClassName('widget_title')}">
<label style="--fs:var(--font-size-16);--lh:var(--line-height-16)" class="${getClassList('label_label')}">Liked Maps</label>
</div>
<div class="${getClassName('widget_rightSlot')}">
<div class="transforming-search-container">
<button class="search-toggle-button" title="Search Maps">
<span class="button_icon_widget">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
</span>
</button>
<input type="text" class="map-search-input hidden" 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="folders-button" title="Select Folder">
<span class="button_icon_widget">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2zM4 18V6h5.17l2 2H20v10H4z"/>
</svg>
</span>
</button>
<div class="sort-container">
<button class="sort-button" title="Sort Maps">
<span class="button_icon_widget">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/>
</svg>
</span>
</button>
</div>
<button class="sync-button" title="Sync Liked Maps & Folders">
<span class="button_icon_widget">
<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 = getClassName('widget_dividerWrapper');
dividerWrapper.innerHTML = `<hr class="${getClassList('styles_divider')}">`;
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 widget content with current filtered maps or error state.
* @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;
const minHeight = 2 * 37 + 6;
const maxHeight = 12 * 37 + 6;
contentArea.style.height = `${Math.min(Math.max(calculatedHeight, minHeight), maxHeight)}px`;
}
let mapItemsHTML = '';
if (errorMessage) {
mapItemsHTML = `
<div style="padding: 20px; text-align: center; color: #897c99ff;">
<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?.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>`;
});
}
const clearButton = widget.querySelector('.clear-search-button');
if (clearButton) {
clearButton.classList.toggle('hidden', !searchTerm);
}
contentArea.innerHTML = `<div style="padding: 6px 8px 12px 8px;">${mapItemsHTML}</div>`;
attachMapItemListeners(contentArea);
}
/**
* Inserts widget into the right sidebar, prioritized before daily streak widget.
* @param {Element} widget
* @returns {boolean}
*/
function mountInSidebar(widget) {
const rightSidebar = getRightSidebar();
if (!rightSidebar) return false;
const existing = rightSidebar.querySelector('#geoguessr-liked-maps-widget');
if (existing) existing.remove();
const firstWidget = rightSidebar.querySelector('[class*="daily-streak_root"]')?.closest('[class*="widget_root"]');
if (firstWidget) {
rightSidebar.insertBefore(widget, firstWidget);
} else {
const anyWidget = rightSidebar.querySelector('[class*="widget_root"]');
if (anyWidget) {
rightSidebar.insertBefore(widget, anyWidget);
} else {
rightSidebar.appendChild(widget);
}
}
return true;
}
/**
* Creates and mounts the widget if on home page.
* @returns {Element|null}
*/
function createWidget() {
if (!isHomePage()) {
removeWidget();
return null;
}
const rightSidebar = getRightSidebar();
if (!rightSidebar) return null;
removeWidget();
const widget = createWidgetSkeleton();
if (mountInSidebar(widget)) {
setupWidgetInteractions(widget);
widgetInitialized = true;
setTimeout(() => {
const widgetBorder = widget.querySelector('[class*="widget_widgetBorder"]');
if (widgetBorder) {
widgetBorder.classList.remove(getClassName('widget_hasLoaded'));
void widgetBorder.offsetWidth;
widgetBorder.classList.add(getClassName('widget_hasLoaded'));
}
updateWidgetContent();
}, 50);
// Fallback content update
setTimeout(() => {
const contentArea = widget.querySelector('.liked-maps-content');
if (contentArea && contentArea.innerHTML.includes('Loading liked maps...')) {
console.log('GeoGuessr Liked Maps Widget: Fallback content update triggered');
updateWidgetContent();
}
}, 2000);
return widget;
}
return null;
}
// ======================
// Widget Interaction Handlers
// ======================
/**
* Clears the current search term and updates the widget.
*/
function clearSearch() {
searchTerm = '';
filterAndSortMaps();
updateWidgetContent();
const searchInput = document.querySelector('.map-search-input');
if (searchInput) {
searchInput.value = '';
searchInput.focus();
}
}
/**
* Attaches event listeners to map items (click, tooltip, pin).
* @param {Element} container
*/
function attachMapItemListeners(container) {
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;
const map = likedMaps[mapIndex];
if (!map?.url) return;
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
window.open(map.url, '_blank');
} else if (e.button === 0) {
window.location.href = map.url;
}
}
});
});
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', removeTooltip);
});
const pinIcons = container.querySelectorAll('.pin-icon');
pinIcons.forEach(pinIcon => {
pinIcon.addEventListener('click', function (e) {
e.stopPropagation();
const mapId = this.getAttribute('data-map-id');
const isPinned = pinnedMapIds.has(mapId);
if (isPinned) {
pinnedMapIds.delete(mapId);
this.classList.remove('pinned');
this.setAttribute('title', 'Pin map');
} else {
pinnedMapIds.add(mapId);
this.classList.add('pinned');
this.setAttribute('title', 'Unpin map');
}
GM_setValue(CONFIG.PINNED_MAPS_STORAGE_KEY, JSON.stringify([...pinnedMapIds]));
filterAndSortMaps();
updateWidgetContent();
});
});
}
/**
* Sets up all interactive elements in the widget (buttons, inputs, dropdowns).
* @param {Element} widget
*/
function setupWidgetInteractions(widget) {
let activeSortDropdown = null;
let activeFoldersDropdown = null;
let sortOutsideClickHandler = null;
let foldersOutsideClickHandler = null;
const closeAllDropdowns = () => {
const closeDropdown = (dropdown, handler, setter) => {
if (dropdown?.parentNode) dropdown.parentNode.remove();
if (handler) document.removeEventListener('click', handler);
setter(null);
};
closeDropdown(activeSortDropdown, sortOutsideClickHandler, d => { activeSortDropdown = d; sortOutsideClickHandler = null; });
closeDropdown(activeFoldersDropdown, foldersOutsideClickHandler, d => { activeFoldersDropdown = d; foldersOutsideClickHandler = null; });
activeSortDropdown = null;
activeFoldersDropdown = null;
sortOutsideClickHandler = null;
foldersOutsideClickHandler = null;
};
document.addEventListener('click', (ev) => {
const widgetEl = document.getElementById('geoguessr-liked-maps-widget');
if (widgetEl && !widgetEl.contains(ev.target)) closeAllDropdowns();
});
// === Folders Dropdown ===
const foldersButton = widget.querySelector('.folders-button');
if (foldersButton) {
foldersButton.addEventListener('click', function (e) {
e.stopPropagation();
if (activeFoldersDropdown) {
closeAllDropdowns();
return;
}
closeAllDropdowns();
const folderDropdown = document.createElement('div');
folderDropdown.className = 'folder-dropdown body-dropdown visible';
const fragment = document.createDocumentFragment();
const allOption = document.createElement('div');
allOption.className = 'folder-option';
allOption.innerHTML = `
<label class="folder-radio-label">
<input type="radio" name="folder" value="all">
<span class="radio-custom"></span>
<span class="folder-label-text">All (${likedMaps.length})</span>
</label>`;
fragment.appendChild(allOption);
likedMapsFolders.forEach(folder => {
const count = folder.likeIds?.length || 0;
const opt = document.createElement('div');
opt.className = 'folder-option';
opt.innerHTML = `
<label class="folder-radio-label">
<input type="radio" name="folder" value="${folder.id}">
<span class="radio-custom"></span>
<span class="folder-label-text">${folder.displayName} (${count})</span>
</label>`;
fragment.appendChild(opt);
});
folderDropdown.appendChild(fragment);
folderDropdown.querySelectorAll('input[name="folder"]').forEach(radio => {
radio.checked = radio.value === currentFolderId;
radio.addEventListener('change', function () {
if (this.checked) {
currentFolderId = this.value;
GM_setValue(CONFIG.SELECTED_FOLDER_STORAGE_KEY, currentFolderId);
filterAndSortMaps();
updateWidgetContent();
updateFolderButtonStyle();
closeAllDropdowns();
}
});
});
const container = document.createElement('div');
container.className = 'folder-container';
container.style.position = 'absolute';
container.style.zIndex = '3';
const rect = foldersButton.getBoundingClientRect();
const parentRect = foldersButton.offsetParent?.getBoundingClientRect() || rect;
container.style.top = `${rect.bottom - parentRect.top}px`;
container.style.right = `${parentRect.right - rect.right - 54}px`;
container.appendChild(folderDropdown);
folderDropdown.style.cssText = 'position:relative;top:2px;right:0;min-width:' + Math.max(130, rect.width) + 'px';
foldersButton.offsetParent?.appendChild(container);
activeFoldersDropdown = folderDropdown;
foldersOutsideClickHandler = (ev) => {
const dropdownContainer = activeFoldersDropdown?.parentNode;
if (!dropdownContainer || dropdownContainer.contains(ev.target) || foldersButton.contains(ev.target)) return;
closeAllDropdowns();
};
setTimeout(() => document.addEventListener('click', foldersOutsideClickHandler), 10);
});
}
updateFolderButtonStyle();
// === Sort Dropdown ===
const sortButton = widget.querySelector('.sort-button');
if (sortButton) {
const sortOptions = [
{ value: 'default', label: 'Default' },
{ value: 'a-z', label: 'A-Z' },
{ value: 'z-a', label: 'Z-A' },
{ value: 'creator', label: 'Creator' },
{ value: 'updated', label: 'Last Updated' }
];
sortButton.addEventListener('click', function (e) {
e.stopPropagation();
if (activeSortDropdown) {
closeAllDropdowns();
return;
}
closeAllDropdowns();
activeSortDropdown = document.createElement('div');
activeSortDropdown.className = 'sort-dropdown body-dropdown visible';
activeSortDropdown.innerHTML = sortOptions.map(opt => `
<div class="sort-option">
<label class="sort-radio-label">
<input type="radio" name="sort" value="${opt.value}" ${opt.value === currentSort ? 'checked' : ''}>
<span class="radio-custom"></span>
<span class="sort-label-text">${opt.label}</span>
</label>
</div>
`).join('');
activeSortDropdown.querySelectorAll('input[name="sort"]').forEach(radio => {
radio.addEventListener('change', function () {
if (this.checked) {
currentSort = this.value;
GM_setValue(CONFIG.SORT_STORAGE_KEY, currentSort);
filterAndSortMaps();
updateWidgetContent();
closeAllDropdowns();
}
});
});
const sortContainer = document.createElement('div');
sortContainer.className = 'sort-container';
sortContainer.style.cssText = 'position:absolute;z-index:3;';
const rect = sortButton.getBoundingClientRect();
const parentRect = sortButton.offsetParent?.getBoundingClientRect() || rect;
sortContainer.style.top = `${rect.bottom - parentRect.top}px`;
sortContainer.style.right = `${parentRect.right - rect.right - 54}px`;
sortContainer.appendChild(activeSortDropdown);
activeSortDropdown.style.cssText = 'position:relative;top:2px;right:0;min-width:' + Math.max(130, rect.width) + 'px';
sortButton.offsetParent?.appendChild(sortContainer);
sortOutsideClickHandler = (ev) => {
const container = activeSortDropdown?.parentNode;
if (!container || container.contains(ev.target) || sortButton.contains(ev.target)) return;
closeAllDropdowns();
};
setTimeout(() => document.addEventListener('click', sortOutsideClickHandler), 10);
});
}
// === Sync Button ===
const syncButton = widget.querySelector('.sync-button');
if (syncButton) {
syncButton.addEventListener('click', function () {
closeAllDropdowns();
this.classList.add('syncing');
clearAutoSync();
fetchLikedMapsAndFolders((success) => {
if (success) {
lastSyncTimestamp = Date.now();
GM_setValue('geoguessr_last_sync_timestamp', lastSyncTimestamp);
}
startAutoSync();
});
});
}
// === 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', () => {
searchTerm = trimSpecialCharacters(searchInput.value).toLowerCase();
debouncedSearch();
});
}
// === Clear Search ===
const clearButton = widget.querySelector('.clear-search-button');
if (clearButton) {
clearButton.addEventListener('click', () => {
closeAllDropdowns();
clearSearch();
});
}
// === Search Toggle ===
const searchToggleButton = widget.querySelector('.search-toggle-button');
const transformingSearchContainer = widget.querySelector('.transforming-search-container');
if (searchToggleButton && searchInput) {
let isSearchExpanded = !!searchTerm;
const toggleSearch = (expand = !isSearchExpanded) => {
if (expand) {
transformingSearchContainer.classList.add('expanded');
searchInput.classList.remove('hidden');
searchInput.focus();
isSearchExpanded = true;
} else {
transformingSearchContainer.classList.remove('expanded');
searchInput.classList.add('hidden');
isSearchExpanded = false;
}
};
searchToggleButton.addEventListener('click', (e) => {
e.stopPropagation();
closeAllDropdowns();
toggleSearch();
});
searchInput.addEventListener('focusout', () => {
setTimeout(() => {
if (isSearchExpanded && !searchTerm) {
const active = document.activeElement;
if (![searchInput, searchToggleButton, clearButton].includes(active)) {
toggleSearch(false);
}
}
}, 0);
});
}
}
// ======================
// Page & Observer Management
// ======================
/**
* Checks for page navigation and reinitializes widget if needed.
*/
function checkPageChange() {
if (window.location.pathname !== currentPathname) {
currentPathname = window.location.pathname;
if (!isHomePage()) {
removeWidget();
widgetInitialized = false;
clearAutoSync();
} else {
setTimeout(tryInitializeWidget, 200);
}
}
}
/**
* Attempts to initialize the widget if conditions are met.
*/
function tryInitializeWidget() {
if (!isHomePage()) return;
const existingWidget = document.getElementById('geoguessr-liked-maps-widget');
const rightSidebar = getRightSidebar();
if (!existingWidget && rightSidebar) {
widgetInitialized = false;
setTimeout(() => {
const freshWidget = createWidget();
if (freshWidget && !autoSyncTimerId) {
startAutoSync();
widgetInitialized = true;
}
}, 100);
} else if (existingWidget && rightSidebar) {
const widgetBorder = existingWidget.querySelector('[class*="widget_widgetBorder"]');
if (widgetBorder) {
widgetBorder.classList.remove(getClassName('widget_hasLoaded'));
void widgetBorder.offsetWidth;
widgetBorder.classList.add(getClassName('widget_hasLoaded'));
}
setTimeout(() => {
const contentArea = existingWidget.querySelector('.liked-maps-content');
if (contentArea && contentArea.innerHTML.includes('Loading liked maps...')) {
updateWidgetContent();
}
}, 1000);
}
}
/**
* Main initialization logic with fallbacks.
*/
function initializeWidget() {
if (widgetInitialized || !isHomePage()) return;
if (!initialDataLoaded) loadLikedMapsEarly();
const checkReady = setInterval(() => {
const rightSidebar = getRightSidebar();
if (rightSidebar && rightSidebar.offsetWidth > 0) {
clearInterval(checkReady);
const widget = createWidget();
if (widget) {
startAutoSync();
setTimeout(() => {
const widgetBorder = widget.querySelector('[class*="widget_widgetBorder"]');
if (widgetBorder) widgetBorder.classList.add(getClassName('widget_hasLoaded'));
}, 50);
widgetInitialized = true;
}
}
}, 100);
setTimeout(() => {
clearInterval(checkReady);
if (!widgetInitialized) {
console.log("GeoGuessr Liked Maps Widget: Fallback initialization triggered.");
const widget = createWidget();
if (widget) {
startAutoSync();
widgetInitialized = true;
}
}
}, 2000);
}
const handleResize = debounce(() => {
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;
const minHeight = 2 * 37 + 6;
const maxHeight = 12 * 37 + 6;
contentArea.style.height = `${Math.min(Math.max(calculatedHeight, minHeight), maxHeight)}px`;
}
}
}, 10);
// ======================
// Styling
// ======================
/**
* Injects all required CSS styles into the document head.
*/
function addCustomStyles() {
const styles = `
[class*="pro-user-start-page_right"] {
box-sizing: border-box !important;
}
#geoguessr-liked-maps-widget {
position: relative;
}
[class*="widget_widgetBorder"] {
--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);
animation: forceSlideIn 0.75s cubic-bezier(.44,0,0,1) 2s forwards;
}
[class*="widget_widgetBorder"][class*="widget_hasLoaded"] {
opacity: 1;
transform: translateX(0);
animation: none;
}
[class*="widget_widgetBorder"][class*="widget_slideInRight"] {
--slideInDirection: 1;
}
@keyframes forceSlideIn {
0% { opacity: 0; transform: translateX(calc(110% * min(var(--slideInDirection), var(--allElementsOnLeftSide)))); }
100% { opacity: 1; transform: translateX(0); }
}
[class*="widget_widgetOuter"] {
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%;
}
[class*="widget_widgetInner"] {
--padding: 1rem;
display: flex;
flex-direction: column;
position: relative;
}
[class*="widget_header"] {
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);
}
[class*="widget_header"] [class*="widget_title"] {
position: relative;
}
[class*="widget_header"] > [class*="widget_rightSlot"] > a,
[class*="widget_header"] > [class*="widget_rightSlot"] > button {
margin-top: -.1875rem;
}
[class*="widget_rightSlot"] {
display: flex;
}
[class*="widget_dividerWrapper"] {
margin: 0 var(--padding);
width: calc(100% - var(--padding)*2);
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.sort-button,
.sync-button,
.folders-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;
border-radius: 50% !important;
}
.sort-button,
.sync-button,
.folders-button,
.search-toggle-button {
background: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
}
.sort-button:hover,
.sync-button:hover,
.folders-button:hover,
.search-toggle-button:hover {
background: rgba(255, 255, 255, 0.15);
cursor: pointer;
}
.button_icon_widget {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 14px !important;
height: 14px !important;
margin: 0 !important;
}
.button_icon_widget svg {
width: 100%;
height: 100%;
display: block;
}
.sync-button.sync-success {
animation: pulseSuccess 0.5s ease-in-out;
background: #48bb78 !important;
color: white !important;
}
.sync-button.sync-error {
animation: pulseError 0.5s ease-in-out;
background: #f56565 !important;
color: white !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.syncing {
animation: spin 1s linear infinite;
}
.transforming-search-container {
position: relative;
display: flex;
align-items: center;
height: 28px;
width: 28px;
transition: width 0.3s ease;
}
.search-toggle-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;
border-radius: 50% !important;
cursor: pointer;
}
.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 6px 28px;
font-size: 12px;
font-family: 'ggfont', sans-serif;
height: 28px;
width: 100%;
box-sizing: border-box;
position: absolute;
top: 0;
left: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.transforming-search-container.expanded {
width: 180px;
}
.transforming-search-container.expanded .search-toggle-button {
background: transparent !important;
border: none !important;
color: #a0aec0 !important;
}
.transforming-search-container.expanded .map-search-input {
opacity: 1;
pointer-events: all;
transform: translateX(0);
}
.clear-search-button {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #a0aec0;
background: #202020;
cursor: pointer;
padding: 2px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
z-index: 2;
}
.clear-search-button:hover {
color: #a0aec0;
transform: scale(1.1) translateY(-50%);
}
.clear-search-button.hidden {
display: none;
}
.sort-container {
position: relative;
display: flex;
align-items: center;
z-index: 3;
}
.sort-dropdown,
.folder-dropdown {
position: relative;
background: #2d3748;
border: 1px solid #4a5568;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
min-width: 130px;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 4;
}
.sort-dropdown.visible,
.folder-dropdown.visible {
opacity: 1;
}
.sort-option,
.folder-option {
padding: 0;
}
.sort-radio-label,
.folder-radio-label {
display: flex;
align-items: center;
padding: 8px 12px;
font-size: 12px;
color: #e2e8f0;
cursor: pointer;
transition: background 0.15s ease;
user-select: none;
}
.sort-radio-label:hover,
.folder-radio-label:hover {
background: rgba(255, 255, 255, 0.08);
}
.sort-radio-label input[type="radio"],
.folder-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-dropdown .sort-radio-label input[type="radio"]:checked + .radio-custom,
.folder-dropdown .folder-radio-label input[type="radio"]:checked + .radio-custom {
border-color: #63b3ed;
background: #63b3ed;
}
.sort-dropdown .sort-radio-label input[type="radio"]:checked + .radio-custom::after,
.folder-dropdown .folder-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%);
}
.sort-label-text,
.folder-label-text {
min-width: 80px;
margin-left: 8px;
flex-grow: 1;
}
.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 {
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);
}
.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_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;
}
.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);
}
.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;
}
.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);
}
.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);
}
.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;
}
.map-tooltip {
position: fixed;
background: #2d3748;
border: 1px solid #4a5568;
border-radius: 6px;
padding: 12px;
font-size: 12px;
color: #e2e8f0;
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: 12px;
font-weight: 600;
}
.tooltip-locations {
color: #63b3ed;
}
.tooltip-updated {
color: #a0aec0;
font-size: 11px;
}
.tooltip-description {
margin-bottom: 12px;
line-height: 1.4;
}
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 12px;
}
.tooltip-tag {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
}
[class*="widget_header"] > [class*="widget_rightSlot"] > *:not(:last-child) {
margin-right: 8px !important;
}
.folders-button.active-folder {
outline: 1px solid rgba(255, 255, 255, 0.33) !important;
outline-offset: -1px;
border-radius: 50%;
}`;
const styleSheet = document.createElement('style');
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
}
// ======================
// Main Init
// ======================
function init() {
addCustomStyles();
loadLikedMapsEarly();
const sidebarObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
const addedNodes = Array.from(mutation.addedNodes);
const mightContainSidebar = addedNodes.some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches('[class*="pro-user-start-page_right"]') ||
node.querySelector?.('[class*="pro-user-start-page_right"]'))
);
if (mightContainSidebar && isHomePage()) {
tryInitializeWidget();
}
}
}
});
sidebarObserver.observe(document.body, { childList: true, subtree: true });
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);
})();