// ==UserScript==
// @name GeoGuessr Liked Maps Advanced Overhaul (LMAO)
// @namespace https://github.com/schnador/
// @version 1.1.0
// @description Adds organization to liked maps on GeoGuessr. Add tags and filter them. Integrates with Learnable Meta!
// @author snador
// @license GNU GPLv3
// @icon https://github.com/schnador/geoguessr-lmao/raw/main/img/lmao_icon.png
// @match https://www.geoguessr.com/*
// @connect learnablemeta.com
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant unsafeWindow
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
// --- STYLE INJECTION ---
((css) => {
if (typeof GM_addStyle === 'function') {
GM_addStyle(css);
} else {
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
})(`
.lmao-full-width-container {
width: 100%;
min-width: 100%;
margin: 0;
}
.lmao-likes-container {
flex: 1;
min-width: 0;
}
.lmao-map-teaser_tag {
border: .0625rem solid var(--ds-color-white-40);
border-radius: .3125rem;
font-size: .8125rem;
font-style: italic;
line-height: .875rem;
padding: .125rem .5rem .25rem;
text-transform: capitalize;
background: rgba(0,0,0,0.2);
max-height: 25px;
}
.lmao-map-teaser_tag.api-tag {
color: var(--ds-color-white-60);
border-color: var(--ds-color-white-40);
background: rgba(0,0,0,0.2);
}
.lmao-map-teaser_tag.user-tag {
color: #fff;
border-color: var(--ds-color-blue-80);
background: color-mix(in srgb, var(--ds-color-blue-50) 50%, transparent);
}
.lmao-map-teaser_tag.lmao-learnable-meta {
border-color: var(--ds-color-green-80);
background: color-mix(in srgb, var(--ds-color-green-70) 50%, transparent);
}
.lmao-map-teaser_tag.lmao-region {
border-color: var(--ds-color-green-80);
background: color-mix(in srgb, var(--ds-color-green-80) 50%, transparent);
}
.lmao-tag-remove-btn {
margin-left: 0.2em;
font-size: 1em;
color: #ffffff;
cursor: pointer;
background: transparent;
border: none;
padding: 0 0.2em;
}
.lmao-tag-input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: 0.75rem;
border: 0;
border-radius: .5rem;
box-shadow: inset 0 0 0.0625rem 0 hsla(0, 0%, 100%, .9);
box-sizing: border-box;
color: #fff;
font-family: var(--default-font);
font-size: 0.875rem;
outline: none;
padding: 0.75rem 0.75rem;
resize: none;
width: auto;
max-height: 25px;
display: block;
margin-top: 0.25em;
flex-basis: 100%;
margin-right: 2rem;
}
.lmao-controls {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 15rem;
min-width: 15rem;
background: rgb(16 16 28/80%);
padding: 1em;
z-index: 1000;
border-radius: 1rem;
height: min-content;
position: sticky;
top: 2rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
overflow-x: visible;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
margin-right: 1rem;
flex-shrink: 0;
}
.lmao-collapsible-tag-group {
margin-bottom: 0.5rem;
}
.lmao-collapsible-header {
font-size: var(--font-size-16);
font-weight: bold;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
}
.lmao-collapsible-arrow {
margin-right: 0.3em;
}
.lmao-collapsible-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-left: 0.25rem;
margin-top: 0.5rem;
}
.lmao-collapsible-tags.lmao-collapsed {
display: none;
}
.lmao-tag-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
border: 1px solid transparent;
position: relative;
}
.lmao-tag-chip:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
opacity: 1 !important;
}
.lmao-tag-chip.selected {
border-color: rgb(255, 255, 255);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.lmao-tag-chip:not(.selected) {
opacity: 0.5;
}
.lmao-tag-chip.user-tag {
background: color-mix(in srgb, var(--ds-color-blue-50) 50%, transparent);
color: #fff;
}
.lmao-tag-chip.api-tag {
background: rgba(0,0,0,0.2);
color: var(--ds-color-white-60);
}
.lmao-tag-chip.meta-tag {
background: color-mix(in srgb, var(--ds-color-green-70) 50%, transparent);
color: #fff;
}
.lmao-tag-chip.region-tag {
background: color-mix(in srgb, var(--ds-color-green-80) 50%, transparent);
color: #fff;
}
.lmao-tag-chip.dragging {
opacity: 0.5;
transform: rotate(3deg);
z-index: 1000;
}
.lmao-tag-chip.drag-over {
border-color: var(--ds-color-purple-60);
background: color-mix(in srgb, var(--ds-color-purple-50) 30%, transparent);
}
.lmao-tag-chip.edit-mode {
cursor: grab;
}
.lmao-tag-chip.edit-mode:active {
cursor: grabbing;
}
.lmao-drag-handle {
margin-right: 0.25rem;
opacity: 0.6;
font-size: 0.6rem;
}
.lmao-collapsible-tag-label {
margin: 0.2em 0;
}
.lmao-tag-visibility-toggles {
display: flex;
flex-direction: column;
gap: 0.2em;
}
.lmao-controls-header {
margin-top: 0.75rem;
margin-bottom: 0.25rem;
font-size: var(--font-size-18);
}
.lmao-checkbox-input {
border: .0625rem solid #ddd;
box-sizing: border-box;
outline: none;
padding: .625rem;
}
.lmao-checkbox-mark {
background: var(--ds-color-purple-100);
border-radius: .25rem;
box-shadow: var(--shadow-1);
left: 0;
top: 0;
border: .0625rem solid var(--ds-color-white-20);
}
.lmao-loading-indicator {
display: flex;
justify-content: center;
align-items: center;
padding: 2em;
width: 100%;
}
.lmao-loading-indicator-text {
font-size: 1.25em;
}
.map-teaser_mapTitleAndTags__iiqiz {
padding-right: 0.125rem;
}
.lmao-settings-button {
background: var(--ds-color-purple-100);
border: 1px solid var(--ds-color-white-20);
border-radius: 0.25rem;
color: white;
cursor: pointer;
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
width: 100%;
}
.lmao-settings-button:hover {
background: var(--ds-color-purple-80);
}
.lmao-file-input {
display: none;
}
.lmao-clear-filters-button {
background: var(--ds-color-red-100);
border: 1px solid var(--ds-color-white-20);
border-radius: 0.25rem;
color: white;
cursor: pointer;
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
margin-bottom: 1rem;
width: 100%;
}
.lmao-clear-filters-button:hover {
background: var(--ds-color-red-80);
}
.lmao-default-bottom-margin {
margin-bottom: 0.5rem;
}
.lmao-no-left-margin {
margin-left: 0;
}
.lmao-header-controls {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
align-items: center;
z-index: 1000;
}
.lmao-header-button {
background: var(--ds-color-purple-100);
border: 1px solid var(--ds-color-white-20);
border-radius: 0.25rem;
color: white;
cursor: pointer;
font-size: 0.75rem;
padding: 0.375rem 0.5rem;
white-space: nowrap;
}
.lmao-header-button:hover {
background: var(--ds-color-purple-80);
}
.lmao-header-button.lmao-clear-button {
background: var(--ds-color-purple-100);
}
.lmao-header-button.lmao-clear-button:hover {
background: var(--ds-color-red-80);
}
.lmao-header-button.lmao-help-button {
background: var(--ds-color-purple-100);
}
.lmao-header-button.lmao-help-button:hover {
background: var(--ds-color-purple-80);
}
.lmao-header-search-placeholder {
width: 200px;
height: 32px;
background: rgba(255, 255, 255, 0.1);
border-radius: 0.25rem;
border: 1px solid var(--ds-color-white-20);
opacity: 0.5;
}
.lmao-header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
margin-left: auto;
}
.lmao-settings-dropdown {
position: relative;
display: inline-block;
}
.lmao-settings-dropdown-content {
display: none;
position: absolute;
right: 0;
background: rgb(16 16 28/95%);
min-width: 160px;
border-radius: 0.5rem;
border: 1px solid var(--ds-color-white-20);
z-index: 1001;
backdrop-filter: blur(10px);
}
.lmao-settings-dropdown-content.show {
display: block;
}
.lmao-settings-dropdown-item {
background: transparent;
border: none;
color: white;
cursor: pointer;
font-size: 0.875rem;
padding: 0.75rem 1rem;
width: 100%;
text-align: left;
border-radius: 0;
}
.lmao-settings-dropdown-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.lmao-settings-dropdown-item:first-child {
border-radius: 0.5rem 0.5rem 0 0;
}
.lmao-settings-dropdown-item:last-child {
border-radius: 0 0 0.5rem 0.5rem;
}
.lmao-header-wrapper {
display: flex !important;
align-items: center !important;
width: 100% !important;
margin-bottom: 1rem !important;
position: relative;
justify-content: space-between;
}
.lmao-header-wrapper h1[class*="headline_heading__"] {
margin: 0 !important;
white-space: nowrap;
}
.lmao-header-wrapper .lmao-header-actions {
margin-left: auto;
flex-shrink: 0;
display: flex;
gap: 0.5rem;
align-items: center;
z-index: 1;
}
.lmao-header-actions .lmao-header-button {
white-space: nowrap;
font-size: 1rem;
padding: 0.5rem;
min-width: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Tooltip styling */
.lmao-tooltip {
position: relative;
display: inline-block;
}
.lmao-tooltip-text {
visibility: hidden;
width: auto;
min-width: 120px;
max-width: 250px;
background-color: rgba(0, 0, 0, 0.9) !important;
color: #fff !important;
text-align: center;
border-radius: 6px;
padding: 8px 12px;
position: fixed;
z-index: 10001;
opacity: 0;
transition: opacity 0.3s;
font-size: 0.75rem !important;
white-space: pre-wrap;
word-wrap: break-word;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
line-height: 1.4;
font-family: var(--default-font) !important;
}
.lmao-controls-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
font-size: var(--font-size-18);
}
.lmao-controls-header .lmao-icon {
font-size: 1rem;
opacity: 0.8;
}
.lmao-edit-toggle {
background: var(--ds-color-purple-100);
border: 1px solid var(--ds-color-white-20);
border-radius: 0.25rem;
color: white;
cursor: pointer;
font-size: 1rem;
padding: 0.5rem;
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
}
.lmao-edit-toggle:hover {
background: var(--ds-color-purple-80);
}
.lmao-edit-toggle.active {
background: var(--ds-color-green-80);
border-color: var(--ds-color-green-70);
}
.lmao-edit-toggle.active:hover {
background: var(--ds-color-green-70);
}
.lmao-tooltip label {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
}
.lmao-tooltip label:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Help Popup Styles */
.lmao-help-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgb(16 16 28/95%);
border: 1px solid var(--ds-color-white-20);
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 500px;
width: 90%;
z-index: 10002;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.lmao-help-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--ds-color-white-20);
}
.lmao-help-popup-title {
font-size: 1.25rem;
font-weight: 600;
color: white;
margin: 0;
}
.lmao-help-popup-close {
background: transparent;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
}
.lmao-help-popup-close:hover {
background: rgba(255, 255, 255, 0.1);
}
.lmao-help-popup-content {
color: white;
line-height: 1.6;
}
.lmao-help-popup-section {
margin-bottom: 1rem;
}
.lmao-help-popup-section h3 {
color: var(--ds-color-blue-80);
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.lmao-help-popup-section p {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
}
.lmao-help-popup-links {
display: flex;
gap: 1rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--ds-color-white-20);
}
.lmao-help-popup-links a {
color: var(--ds-color-blue-80);
text-decoration: none;
padding: 0.5rem 1rem;
border: 1px solid var(--ds-color-blue-80);
border-radius: 0.25rem;
transition: all 0.2s;
}
.lmao-help-popup-links a:hover {
background: var(--ds-color-blue-80);
color: white;
}
.lmao-help-popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 10001;
}
.lmao-small-clear-button {
background: var(--ds-color-red-100);
border: 1px solid var(--ds-color-white-20);
border-radius: 0.25rem;
color: white;
cursor: pointer;
font-size: 0.75rem;
padding: 0.25rem 0.375rem;
min-width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
margin-left: 0.5rem;
}
.lmao-small-clear-button:hover {
background: var(--ds-color-red-80);
}
/* Search Panel Styles */
.lmao-search-panel {
position: relative;
display: flex;
align-items: center;
gap: 0;
background: rgba(255, 255, 255, 0.1);
border-radius: 0.25rem;
border: 1px solid var(--ds-color-white-20);
overflow: visible; /* Changed from hidden to visible */
min-width: 300px;
max-width: 400px;
flex: 1;
transition: border-color 0.2s ease;
}
@media (max-width: 768px) {
.lmao-search-panel {
min-width: 250px;
max-width: 300px;
}
}
.lmao-search-dropdown {
background: var(--ds-color-purple-100);
border: none;
color: white;
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
white-space: nowrap;
border-radius: 0;
min-width: 80px;
border-right: 1px solid var(--ds-color-white-20);
}
.lmao-search-dropdown:hover {
background: var(--ds-color-purple-80);
}
.lmao-search-input {
flex: 1;
background: transparent;
border: none;
color: white;
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
outline: none;
min-width: 0;
}
.lmao-search-input::placeholder {
color: rgba(255, 255, 255, 0.6);
}
.lmao-search-clear {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 1rem;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
}
.lmao-search-clear:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
.lmao-search-dropdown-menu {
position: fixed; /* Changed to fixed since it's appended to body */
background: rgb(16 16 28/95%);
border: 1px solid var(--ds-color-white-20);
border-radius: 0.25rem;
border-top-left-radius: 0;
border-top-right-radius: 0;
z-index: 10002 !important;
min-width: 150px;
backdrop-filter: blur(10px);
display: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.lmao-search-dropdown-menu.show {
display: block !important;
}
.lmao-search-dropdown-item {
background: transparent;
border: none;
color: white;
cursor: pointer;
font-size: 0.875rem;
padding: 0.75rem 1rem;
text-align: left;
border-radius: 0;
display: flex;
align-items: center;
gap: 0.5rem;
user-select: none;
}
.lmao-search-dropdown-item input[type="checkbox"] {
accent-color: var(--ds-color-purple-100);
margin: 0;
margin-right: 0.5rem;
}
.lmao-search-dropdown-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.lmao-search-dropdown-item:first-child {
border-radius: 0;
}
.lmao-search-dropdown-item:last-child {
border-radius: 0 0 0.25rem 0.25rem;
}
.lmao-search-icon {
font-size: 0.875rem;
opacity: 0.8;
}
.lmao-map-counter {
margin: 0;
margin-right: 0.5rem;
margin-left: auto;
font-size: 1.5rem;
font-weight: bold;
color: var(--ds-color-white-100);
}
.lmao-heading-container {
display: flex;
flex-direction: row;
align-items: center;
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 1;
}
`);
var _GM_xmlhttpRequest = /* @__PURE__ */ (() =>
typeof GM_xmlhttpRequest != 'undefined' ? GM_xmlhttpRequest : void 0)();
var _unsafeWindow = /* @__PURE__ */ (() =>
typeof unsafeWindow != 'undefined' ? unsafeWindow : void 0)();
// --- CONFIGURATION OBJECT ---
/**
* Central configuration for LMAO userscript.
*/
const CONFIG = {
version: GM_info.script.version ? GM_info.script.version : 'unknown',
features: {
debugMode: true,
logLevel: 'INFO' // Default log level: TRACE, DEBUG, INFO, WARN, ERROR
}
// validPaths: ['/me/likes', '/maps/community'],
// validTabs: [undefined, 'liked-maps']
};
// --- LOG LEVELS ---
const LOG_LEVELS = {
TRACE: 0, // Most verbose - DOM finding, detailed operations
DEBUG: 1, // General debugging info
INFO: 2, // Important information
WARN: 3, // Warnings
ERROR: 4 // Errors only
};
// Convenient object for cleaner debugLog calls: debugLog(LogLevel.DEBUG, ...)
const LogLevel = {
TRACE: 'TRACE',
DEBUG: 'DEBUG',
INFO: 'INFO',
WARN: 'WARN',
ERROR: 'ERROR'
};
const TagCategory = {
USER: 'user',
REGION: 'region',
API: 'api',
LEARNABLE_META: 'learnableMeta'
};
/**
* Maps category string to TagCategory constant
* @param {string} category - Category string ('user', 'region', 'api', 'learnableMeta')
* @returns {string} TagCategory constant
*/
function getTagCategory(category) {
switch (category) {
case 'user':
return TagCategory.USER;
case 'region':
return TagCategory.REGION;
case 'api':
return TagCategory.API;
case 'learnableMeta':
return TagCategory.LEARNABLE_META;
default:
return TagCategory.USER;
}
}
/**
* Gets the current log level from CONFIG object.
* @returns {number} Current log level
*/
function getCurrentLogLevel() {
const configLevel = CONFIG.features.logLevel;
if (configLevel && LOG_LEVELS.hasOwnProperty(configLevel)) {
return LOG_LEVELS[configLevel];
}
return LOG_LEVELS.INFO; // Default log level
}
/**
* Helper function to set the log level for debugging.
* Updates the CONFIG object and saves it to lmaoDevConfig localStorage.
* Call this from the browser console to change log level.
* @param {string} level - One of: LogLevel.TRACE, LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR
* @example
* // In browser console:
* setLMAOLogLevel(LogLevel.DEBUG) // Show debug and higher
* setLMAOLogLevel(LogLevel.TRACE) // Show all logs (most verbose)
* setLMAOLogLevel(LogLevel.ERROR) // Show only errors
*/
function setLMAOLogLevel(level) {
if (!LOG_LEVELS.hasOwnProperty(level)) {
console.error('[LMAO] Invalid log level. Available levels:', Object.keys(LOG_LEVELS));
return;
}
try {
CONFIG.features.logLevel = level;
saveDevConfig();
console.info(`[LMAO] Log level set to: ${level} (${LOG_LEVELS[level]})`);
} catch (e) {
console.error('[LMAO] Failed to set log level:', e);
}
}
// Make the function available globally for console access
if (typeof _unsafeWindow !== 'undefined' && _unsafeWindow) {
_unsafeWindow.setLMAOLogLevel = setLMAOLogLevel;
} else {
window.setLMAOLogLevel = setLMAOLogLevel;
}
/**
* Debug logger for LMAO with log levels. Uses console methods based on log level.
* @param {string} level - Log level: LogLevel.TRACE, LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR
* @param {...any} args - Arguments to log
* @example
* debugLog(LogLevel.TRACE, 'Finding DOM element');
* debugLog(LogLevel.ERROR, 'Failed to fetch data', error);
* // Legacy string syntax still works:
* debugLog(LogLevel.DEBUG, 'Some debug info');
*/
function debugLog(level, ...args) {
if (!CONFIG.features.debugMode) return;
const currentLevel = getCurrentLogLevel();
const messageLevel = LOG_LEVELS[level] || LOG_LEVELS.DEBUG;
// Only log if message level is >= current log level
if (messageLevel < currentLevel) return;
// Get the calling function name from the stack
const stack = new Error().stack;
let fnName = 'unknown';
if (stack) {
const lines = stack.split('\n');
// The third line is usually the caller (first is Error, second is debugLog)
if (lines.length > 2) {
const match = lines[2].match(/at (\w+)/);
if (match) fnName = match[1];
}
}
const prefix = `[LMAO] ${level} ${fnName}:`;
// Use appropriate console method based on log level
switch (level) {
case LogLevel.ERROR:
console.error(prefix, ...args);
break;
case LogLevel.WARN:
console.warn(prefix, ...args);
break;
case LogLevel.INFO:
console.info(prefix, ...args);
break;
case LogLevel.DEBUG:
case LogLevel.TRACE:
default:
console.log(prefix, ...args);
break;
}
}
// --- CONSTANTS & LOCALSTORAGE KEYS ---
const LOCALSTORAGE_INTERNAL_CONFIG = 'lmaoDevConfig';
const LOCALSTORAGE_USER_TAGS_KEY = 'lmaoUserTags';
const LOCALSTORAGE_ADDITIONAL_MAP_INFO = 'lmaoAdditionalMapInfo';
const LOCALSTORAGE_STATE_KEY = 'lmaoState';
const LOCALSTORAGE_GEOMETA_PREFIX = 'geometa:map-info:';
const USER_TAG_CLASS = 'lmao-map-teaser_tag user-tag';
const API_TAG_CLASS = 'lmao-map-teaser_tag api-tag';
// Load dev config at startup
loadDevConfig();
// --- GLOBAL STATE ---
const AppState = {
maps: [],
userTagsList: [],
apiTagsList: [],
metaTagsList: [],
selectedTags: {
userTags: [],
regionTags: [],
apiTags: [],
learnableMeta: false
},
currentUserTags: {},
tagVisibility: {},
filterCollapse: {},
filterMode: 'ALL',
editMode: false,
learnableMetaCache: new Set(),
metaRegionCache: {},
controlsDiv: null,
searchQuery: '',
searchCriteria: {},
tagOrder: {}, // Will store tag order for each category
// State management methods
updateSelectedTags(newTags) {
this.selectedTags = newTags;
saveSelectedTags(this.selectedTags);
this.rebuildControls();
this.rerender();
},
updateTagVisibility(newVisibility) {
this.tagVisibility = { ...newVisibility };
saveTagVisibility(this.tagVisibility);
this.rerender();
},
updateFilterCollapse(newCollapse) {
this.filterCollapse = newCollapse;
saveFilterCollapse(this.filterCollapse);
this.rebuildControls();
},
updateFilterMode(newMode) {
this.filterMode = newMode;
this.rerender();
},
updateEditMode(newMode) {
this.editMode = newMode;
// Update edit toggle button appearance
const editToggleBtn = document.querySelector('.lmao-edit-toggle');
if (editToggleBtn) {
editToggleBtn.className = 'lmao-edit-toggle' + (newMode ? ' active' : '');
}
// Rebuild controls to show/hide drag handles
this.rebuildControls();
this.rerender();
},
updateSearchQuery(newQuery) {
this.searchQuery = newQuery;
this.rerender();
},
updateSearchCriteria(newCriteria) {
this.searchCriteria = [...newCriteria]; // Copy array
saveSearchCriteria(this.searchCriteria); // Save to localStorage
// Update dropdown display using shared function
const dropdown = document.querySelector('.lmao-search-dropdown');
if (dropdown) {
updateDropdownDisplay(dropdown, newCriteria);
}
// Update placeholder
const input = document.querySelector('.lmao-search-input');
if (input) {
if (newCriteria.length === 0) {
input.placeholder = 'Select search criteria first...';
} else if (newCriteria.length === 1) {
const placeholders = {
name: 'Search map names...',
description: 'Search descriptions...',
creator: 'Search creators...',
tags: 'Search tags...'
};
input.placeholder = placeholders[newCriteria[0]] || 'Search...';
} else {
input.placeholder = `Search...`;
}
}
if (window.updateSearchPanelBorder) {
window.updateSearchPanelBorder();
}
this.rerender();
},
addUserTag(map, tag) {
const key = getMapKey(map);
this.currentUserTags[key] = this.currentUserTags[key] || [];
this.currentUserTags[key].push(tag);
saveUserTags(this.currentUserTags);
this.userTagsList = Array.from(new Set(Object.values(this.currentUserTags).flat())).sort();
this.rebuildControls();
this.rerender();
},
removeUserTag(map, tag) {
const key = getMapKey(map);
this.currentUserTags[key] = (this.currentUserTags[key] || []).filter((t) => t !== tag);
saveUserTags(this.currentUserTags);
this.userTagsList = Array.from(new Set(Object.values(this.currentUserTags).flat())).sort();
this.rebuildControls();
this.rerender();
},
rebuildControls() {
debugLog(LogLevel.DEBUG, 'Rebuilding controls UI');
const newControls = createControlsUI();
// Find the proper container for the sidebar
// We want to insert it into the likes_map div so it's a sibling of the grid
const grid = findGridContainer();
let targetContainer = null;
if (grid) {
const likesMapDiv = grid.closest('div[class*="likes_map__"]');
if (likesMapDiv) {
targetContainer = likesMapDiv;
debugLog(LogLevel.DEBUG, 'Using likes_map div as target container');
}
}
if (!targetContainer) {
targetContainer = findFullHeightContainer();
debugLog(LogLevel.DEBUG, 'Falling back to main container');
}
if (!targetContainer) {
debugLog(LogLevel.ERROR, 'No suitable container found for sidebar');
return;
}
newControls.id = 'liked-maps-filter-controls';
if (this.controlsDiv) {
this.controlsDiv.replaceWith(newControls);
} else {
// Insert the sidebar at the beginning of the container
if (targetContainer.firstChild) {
targetContainer.insertBefore(newControls, targetContainer.firstChild);
} else {
targetContainer.appendChild(newControls);
}
}
this.controlsDiv = newControls;
debugLog(LogLevel.DEBUG, 'Sidebar inserted successfully');
// Add header actions
this.addHeaderActions();
},
addHeaderActions() {
// Check if wrapper already exists
const existingWrapper = document.querySelector('.lmao-header-wrapper');
if (existingWrapper) {
debugLog(LogLevel.DEBUG, 'Header wrapper already exists, skipping');
return;
}
// Find the header area
const heading = findHeading();
if (!heading) {
debugLog(LogLevel.ERROR, 'Heading not found for header actions');
return;
}
// Check if heading is already inside a wrapper (safety check)
if (heading.closest('.lmao-header-wrapper')) {
debugLog(LogLevel.DEBUG, 'Heading already in wrapper, skipping');
return;
}
debugLog(LogLevel.DEBUG, 'Found heading, creating header wrapper');
// Create wrapper div
const headerWrapper = document.createElement('div');
headerWrapper.className = 'lmao-header-wrapper';
// Create map counter
const mapCounter = document.createElement('h1');
mapCounter.id = 'lmao-map-counter';
mapCounter.className = 'lmao-map-counter';
// Create and add header actions
const headerActions = createHeaderActions();
// Remove h1 from its current position and add to wrapper
const parent = heading.parentElement;
if (parent) {
debugLog(LogLevel.DEBUG, 'Creating header wrapper with actions');
// Insert wrapper where h1 was
parent.insertBefore(headerWrapper, heading);
// wrap heading in heading container
parent.removeChild(heading);
const headingContainer = document.createElement('div');
headingContainer.className = 'lmao-heading-container';
headingContainer.appendChild(mapCounter);
headingContainer.appendChild(heading);
headerWrapper.appendChild(headingContainer);
// Add header actions to wrapper
headerWrapper.appendChild(headerActions);
// Update the map counter with initial count
updateMapCounter();
debugLog(LogLevel.DEBUG, 'Header wrapper created successfully');
} else {
debugLog(LogLevel.ERROR, 'No parent found for heading element');
}
},
rerender() {
patchTeasersWithControls();
updateMapCounter();
}
};
// --- UTILS ---
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Updates the dropdown button display based on the current search criteria
* @param {HTMLElement} dropdownElement - The dropdown button element to update
* @param {string[]} criteria - Array of selected search criteria
*/
function updateDropdownDisplay(dropdownElement, criteria) {
const criteriaIcons = {
name: '📝',
description: '📄',
creator: '👤',
tags: '🏷️'
};
if (criteria.length === 0) {
dropdownElement.textContent = 'Select criteria...';
} else if (criteria.length === 1) {
dropdownElement.textContent = criteriaIcons[criteria[0]] || 'Search';
} else if (criteria.length === 4) {
// Show magnifying glass when all criteria are selected
dropdownElement.textContent = '🔍';
} else {
// Show icons for multiple criteria (but not all)
dropdownElement.innerHTML = '';
criteria.forEach((criteriaItem, index) => {
const icon = document.createElement('span');
icon.textContent = criteriaIcons[criteriaItem] || '?';
icon.style.marginRight = index < criteria.length - 1 ? '0.25rem' : '0';
dropdownElement.appendChild(icon);
});
}
}
/**
* Checks if a map matches the current search query based on the selected search criteria.
* @param {Object} map - The map object to check
* @param {string} query - The search query
* @param {string[]} criteriaArray - Array of search criteria to check
* @returns {boolean} - True if the map matches the search query in any of the selected criteria
*/
function matchesSearchQuery(map, query, criteriaArray) {
if (!query || query.trim() === '' || !criteriaArray || criteriaArray.length === 0) return true;
const searchTerm = query.toLowerCase().trim();
const mapKey = getMapKey(map);
// Check each selected criteria - if ANY matches, return true
return criteriaArray.some((criteria) => {
switch (criteria) {
case 'name':
return map.name && map.name.toLowerCase().includes(searchTerm);
case 'description':
return map.description && map.description.toLowerCase().includes(searchTerm);
case 'creator':
return (
map.creator && map.creator.nick && map.creator.nick.toLowerCase().includes(searchTerm)
);
case 'tags': {
// Search in all types of tags: user tags, API tags, learnable meta, regions
const allTags = [...(map.tags || []), ...(AppState.currentUserTags[mapKey] || [])];
// Add Learnable Meta tag if applicable
if (isLearnableMetaFromCacheOrLocalStorage(mapKey, AppState.learnableMetaCache)) {
allTags.push('Learnable Meta');
}
// Add region tags if applicable
if (
AppState.metaRegionCache &&
AppState.metaRegionCache[mapKey] &&
Array.isArray(AppState.metaRegionCache[mapKey].regions)
) {
allTags.push(...AppState.metaRegionCache[mapKey].regions);
}
// Add Official tag if applicable
if (map.isUserMap === false) {
allTags.push('Official');
}
return allTags.some((tag) => tag.toLowerCase().includes(searchTerm));
}
default:
return false;
}
});
}
function getMapKey(map) {
return map.id;
}
function getMapByTeaserHref(maps, href) {
const idMatch = href && href.match(/\/maps\/([^/?#]+)/);
if (!idMatch) return null;
const mapIdOrSlug = idMatch[1];
// Try to find by id or by slug
return maps.find((m) => m.id === mapIdOrSlug || m.slug === mapIdOrSlug) || null;
}
/**
* Loads the dev configuration from localStorage and updates CONFIG object.
*/
function loadDevConfig() {
try {
const saved = _unsafeWindow.localStorage.getItem(LOCALSTORAGE_INTERNAL_CONFIG);
if (saved) {
const parsedConfig = JSON.parse(saved);
// Merge saved config into CONFIG, preserving defaults for missing keys
if (parsedConfig.features) {
CONFIG.features = { ...CONFIG.features, ...parsedConfig.features };
}
debugLog(LogLevel.DEBUG, 'Loaded dev config from localStorage', CONFIG);
}
} catch (e) {
debugLog(LogLevel.ERROR, 'Failed to load dev config', e);
}
}
function saveDevConfig() {
try {
_unsafeWindow.localStorage.setItem(LOCALSTORAGE_INTERNAL_CONFIG, JSON.stringify(CONFIG));
debugLog(LogLevel.DEBUG, 'Saved dev config to localStorage', CONFIG);
} catch (e) {
debugLog(LogLevel.ERROR, 'Failed to save dev config', e);
}
}
/**
* Fetches map info from the Learnable Meta API.
* @param {string} url - The API endpoint URL
* @returns {Promise<Object>} - The map info object
*/
async function fetchMapInfo(url) {
debugLog(LogLevel.DEBUG, 'fetching map info from API with URL', url);
return new Promise((resolve, reject) => {
if (typeof _GM_xmlhttpRequest !== 'function') {
console.error('GM_xmlhttpRequest is not available');
reject(
'GM_xmlhttpRequest is not available, please use Version 4.0+ of Tampermonkey or Violentmonkey'
);
return;
}
_GM_xmlhttpRequest({
method: 'GET',
url,
onload: (response) => {
debugLog(LogLevel.TRACE, 'onload', url, response.status);
if (response.status === 200 || response.status === 404) {
try {
const mapInfo = JSON.parse(response.responseText);
debugLog(LogLevel.DEBUG, 'fetched map info', mapInfo);
resolve(mapInfo);
} catch (e) {
console.error('failed to parse map info response', e);
reject('Failed to parse response');
}
} else {
console.error('failed to fetch map info', response);
reject(`HTTP error! status: ${response.status}`);
}
},
onerror: () => {
console.error('onerror');
reject('An error occurred while fetching data');
}
});
});
}
/**
* Gets map info from localStorage or Learnable Meta API.
* @param {string} geoguessrId - The map ID
* @param {boolean} [forceUpdate=false] - Force API fetch
* @returns {Promise<Object>} - The map info object
*/
async function getMapInfo(geoguessrId, forceUpdate = false) {
const localStorageMapInfoKey = `${LOCALSTORAGE_GEOMETA_PREFIX}${geoguessrId}`;
if (!forceUpdate) {
const savedMapInfo = _unsafeWindow.localStorage.getItem(localStorageMapInfoKey);
if (savedMapInfo) {
const mapInfoFromLocalStorage = JSON.parse(savedMapInfo);
debugLog(LogLevel.TRACE, 'loaded from localStorage', mapInfoFromLocalStorage);
return mapInfoFromLocalStorage;
}
}
const url = `https://learnablemeta.com/api/userscript/map/${geoguessrId}`;
const mapInfo = await fetchMapInfo(url);
_unsafeWindow.localStorage.setItem(localStorageMapInfoKey, JSON.stringify(mapInfo));
return mapInfo;
}
/**
* Fetches and caches Learnable Meta status for a map during initialization.
* @param {string} mapId - The map ID
* @returns {Promise<boolean>} - True if Learnable Meta, else false
*/
async function fetchAndCacheLearnableMeta(mapId) {
try {
const mapInfo = await getMapInfo(mapId);
return mapInfo && mapInfo.mapFound === true;
} catch (err) {
debugLog(LogLevel.ERROR, 'Failed to fetch and cache learnable meta', err);
return false;
}
}
/**
* Synchronously checks if a map is Learnable Meta using local cache or localStorage.
* @param {string} mapId - The map ID
* @param {Set<string>} [learnableMetaCache] - Optional cache set
* @returns {boolean}
*/
function isLearnableMetaFromCacheOrLocalStorage(mapId, learnableMetaCache) {
if (learnableMetaCache && learnableMetaCache.has(mapId)) return true;
const data = _unsafeWindow.localStorage.getItem(LOCALSTORAGE_GEOMETA_PREFIX + mapId);
if (!data) return false;
try {
const obj = JSON.parse(data);
debugLog(LogLevel.TRACE, 'loaded from localstorage', obj);
return obj && obj.mapFound === true;
} catch (err) {
debugLog(LogLevel.ERROR, 'Error parsing localStorage data', err);
return false;
}
}
/**
* Loads and updates region tags for all Learnable Meta maps at startup.
* Only fetches regions for maps not present in the cache.
*
* @param {string[]} learnableMetaMapIds - Array of GeoGuessr map IDs
* @returns {Promise<Object<string, string[]>>} Updated region cache
* @example
* const cache = await preloadMetaRegions(["abc123", "def456"]);
* // cache["abc123"] might be ["south america"]
*/
/**
* Loads the region tag cache from localStorage.
* @returns {Object<string, {regions: string[]}>} The region cache object
*/
function loadMetaRegionCache() {
try {
const raw = _unsafeWindow.localStorage.getItem(LOCALSTORAGE_ADDITIONAL_MAP_INFO);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) return parsed;
return {};
} catch (e) {
debugLog(LogLevel.ERROR, 'Failed to load meta region cache', e);
return {};
}
}
/**
* Saves the region tag cache to localStorage.
* @param {Object<string, {regions: string[]}>} cache - The region cache object
*/
function saveMetaRegionCache(cache) {
try {
_unsafeWindow.localStorage.setItem(LOCALSTORAGE_ADDITIONAL_MAP_INFO, JSON.stringify(cache));
} catch (e) {
debugLog(LogLevel.ERROR, 'Failed to save meta region cache', e);
}
}
/**
* Fetches region tags for a map from the Learnable Meta API.
* @param {string} mapId - The GeoGuessr map ID
* @returns {Promise<string[]>} Array of region tags (may be empty)
*/
async function fetchMetaRegionsFromAPI(mapId) {
const url = `https://learnablemeta.com/api/maps?geoguessrId=${mapId}`;
debugLog(LogLevel.DEBUG, 'Fetching meta regions from API', url);
return new Promise((resolve, reject) => {
if (typeof _GM_xmlhttpRequest !== 'function') {
reject('GM_xmlhttpRequest is not available');
return;
}
_GM_xmlhttpRequest({
method: 'GET',
url,
onload: (response) => {
if (response.status === 200) {
try {
const responseData = JSON.parse(response.responseText);
const data = responseData[0] || {}; // should always be ONE item in the array
debugLog(LogLevel.DEBUG, 'Meta regions response', data);
if (Array.isArray(data.regions)) {
debugLog(LogLevel.DEBUG, 'Found regions array:', data.regions);
resolve(data.regions);
} else {
resolve([]);
}
} catch (e) {
debugLog(LogLevel.ERROR, 'Failed to parse meta regions response', e);
resolve([]);
}
} else if (response.status === 404) {
resolve([]);
} else {
reject(`HTTP error! status: ${response.status}`);
}
},
onerror: () => {
reject('An error occurred while fetching meta regions');
}
});
});
}
/**
* Loads and updates region tags for all Learnable Meta maps at startup.
* Only fetches regions for maps not present in the cache.
*
* @param {string[]} learnableMetaMapIds - Array of GeoGuessr map IDs
* @returns {Promise<Object<string, {regions: string[]}>>} Updated region cache
* @example
* const cache = await preloadMetaRegions(["abc123", "def456"]);
* // cache["abc123"] might be { regions: ["south america"] }
*/
async function preloadMetaRegions(learnableMetaMapIds) {
const cache = loadMetaRegionCache();
debugLog(LogLevel.DEBUG, 'Loaded meta region cache', cache);
let updated = false;
for (const mapId of learnableMetaMapIds) {
if (!cache.hasOwnProperty(mapId)) {
try {
const regions = await fetchMetaRegionsFromAPI(mapId);
if (regions?.length) {
debugLog(LogLevel.INFO, 'Found regions for', mapId, regions);
cache[mapId] = { regions };
updated = true;
}
} catch (e) {
debugLog(LogLevel.ERROR, 'Failed to fetch regions for', mapId, e);
}
}
}
if (updated) saveMetaRegionCache(cache);
return cache;
}
// --- LOCALSTORAGE STATE ---
function loadUserTags() {
return JSON.parse(_unsafeWindow.localStorage.getItem(LOCALSTORAGE_USER_TAGS_KEY) || '{}');
}
function saveUserTags(userTags) {
debugLog(LogLevel.DEBUG, 'Saving user tags', userTags);
_unsafeWindow.localStorage.setItem(LOCALSTORAGE_USER_TAGS_KEY, JSON.stringify(userTags));
}
function saveTagVisibility(tagVisibility) {
debugLog(LogLevel.DEBUG, 'Saving tag visibility', tagVisibility);
const currentState = loadLMAOState();
currentState.tagVisibility = tagVisibility;
saveLMAOState(currentState);
}
function saveFilterCollapse(filterCollapse) {
debugLog(LogLevel.DEBUG, 'Saving filter collapse', filterCollapse);
const currentState = loadLMAOState();
currentState.filterCollapse = filterCollapse;
saveLMAOState(currentState);
}
function saveSelectedTags(selectedTags) {
debugLog(LogLevel.DEBUG, 'Saving selected tags', selectedTags);
const currentState = loadLMAOState();
currentState.selectedTags = selectedTags;
saveLMAOState(currentState);
}
// --- LMAO STATE MANAGEMENT ---
function loadLMAOState() {
try {
const defaultState = {
// defaults
searchCriteria: ['name', 'description', 'creator', 'tags'],
tagOrder: {},
tagVisibility: {
showUserTags: true,
showLearnableMetaTag: true,
showRegionTags: true,
showApiTags: false
},
filterCollapse: {
user: false,
regions: false,
api: true
},
selectedTags: {
userTags: [],
regionTags: [],
apiTags: [],
learnableMeta: false
}
};
const raw = _unsafeWindow.localStorage.getItem(LOCALSTORAGE_STATE_KEY);
if (!raw) return defaultState; // Default: all enabled
const parsed = JSON.parse(raw);
return { ...defaultState, ...parsed };
} catch (e) {
debugLog(LogLevel.ERROR, 'Failed to load LMAO state', e);
return defaultState;
}
}
function saveLMAOState(state) {
try {
_unsafeWindow.localStorage.setItem(LOCALSTORAGE_STATE_KEY, JSON.stringify(state));
debugLog(LogLevel.DEBUG, 'LMAO state saved', state);
} catch (e) {
debugLog(LogLevel.ERROR, 'Failed to save LMAO state', e);
}
}
function saveSearchCriteria(searchCriteria) {
const currentState = loadLMAOState();
currentState.searchCriteria = searchCriteria;
saveLMAOState(currentState);
}
// --- SELECTED TAGS HELPERS ---
/**
* Gets all selected tags as a flat array for backward compatibility
* @returns {string[]} Array of all selected tag names
*/
function getAllSelectedTags() {
const { userTags, regionTags, apiTags, learnableMeta } = AppState.selectedTags;
const allTags = [...userTags, ...regionTags, ...apiTags];
if (learnableMeta) {
allTags.push('Learnable Meta');
}
return allTags;
}
/**
* Gets the count of all selected tags
* @returns {number} Total number of selected tags
*/
function getSelectedTagsCount() {
const { userTags, regionTags, apiTags, learnableMeta } = AppState.selectedTags;
let count = userTags.length + regionTags.length + apiTags.length;
if (learnableMeta) count++;
return count;
}
/**
* Checks if a tag is selected in a specific category
* @param {string} tag - Tag name to check
* @param {string} category - Category to check ('user', 'region', 'api', 'learnableMeta')
* @returns {boolean} True if tag is selected in that category
*/
function isTagSelected(tag, category) {
switch (category) {
case 'user':
return AppState.selectedTags.userTags.includes(tag);
case 'region':
return AppState.selectedTags.regionTags.includes(tag);
case 'api':
return AppState.selectedTags.apiTags.includes(tag);
case 'learnableMeta':
return tag === 'Learnable Meta' && AppState.selectedTags.learnableMeta;
default:
return false;
}
}
/**
* Adds a tag to the selected tags in the specified category
* @param {string} tag - Tag name to add
* @param {string} category - Category to add to (TagCategory.USER, TagCategory.REGION, TagCategory.API, TagCategory.LEARNABLE_META)
*/
function addSelectedTag(tag, category) {
switch (category) {
case TagCategory.USER:
if (!AppState.selectedTags.userTags.includes(tag)) {
AppState.selectedTags.userTags.push(tag);
}
break;
case TagCategory.REGION:
if (!AppState.selectedTags.regionTags.includes(tag)) {
AppState.selectedTags.regionTags.push(tag);
}
break;
case TagCategory.API:
if (!AppState.selectedTags.apiTags.includes(tag)) {
AppState.selectedTags.apiTags.push(tag);
}
break;
case TagCategory.LEARNABLE_META:
AppState.selectedTags.learnableMeta = true;
break;
}
}
/**
* Clears all selected tags
*/
function clearAllSelectedTags() {
AppState.selectedTags.userTags = [];
AppState.selectedTags.regionTags = [];
AppState.selectedTags.apiTags = [];
AppState.selectedTags.learnableMeta = false;
}
/**
* Removes a tag from the selected tags in the specified category
* @param {string} tag - Tag name to remove
* @param {string} category - Category to remove from (TagCategory.USER, TagCategory.REGION, TagCategory.API, TagCategory.LEARNABLE_META)
*/
function removeSelectedTag(tag, category) {
switch (category) {
case TagCategory.USER:
const userIndex = AppState.selectedTags.userTags.indexOf(tag);
if (userIndex > -1) {
AppState.selectedTags.userTags.splice(userIndex, 1);
}
break;
case TagCategory.REGION:
const regionIndex = AppState.selectedTags.regionTags.indexOf(tag);
if (regionIndex > -1) {
AppState.selectedTags.regionTags.splice(regionIndex, 1);
}
break;
case TagCategory.API:
const apiIndex = AppState.selectedTags.apiTags.indexOf(tag);
if (apiIndex > -1) {
AppState.selectedTags.apiTags.splice(apiIndex, 1);
}
break;
case TagCategory.LEARNABLE_META:
AppState.selectedTags.learnableMeta = false;
break;
}
}
// --- TAG ORDER MANAGEMENT ---
function saveTagOrder(tagOrder) {
const currentState = loadLMAOState();
currentState.tagOrder = tagOrder;
saveLMAOState(currentState);
}
/**
* Sorts tags according to saved order, putting unordered tags at the end
* @param {string[]} tags - Array of tag names
* @param {string} category - Category key (TagCategory.USER, TagCategory.API, TagCategory.REGION)
* @returns {string[]} Sorted array of tags
*/
function sortTagsByOrder(tags, category) {
const order = AppState.tagOrder[category] || [];
const orderedTags = [];
const unorderedTags = [...tags];
// Add tags in saved order
order.forEach((tag) => {
const index = unorderedTags.indexOf(tag);
if (index !== -1) {
orderedTags.push(tag);
unorderedTags.splice(index, 1);
}
});
// Add remaining unordered tags at the end
return [...orderedTags, ...unorderedTags.sort()];
}
// --- EXPORT/IMPORT FUNCTIONS ---
function exportLMAOSettings() {
const exportData = {
version: CONFIG.version,
exportDate: new Date().toISOString(),
data: {}
};
// Collect all localStorage items that start with "lmao" (case-insensitive)
for (let i = 0; i < _unsafeWindow.localStorage.length; i++) {
const key = _unsafeWindow.localStorage.key(i);
if (key && key.toLowerCase().startsWith('lmao')) {
try {
const value = _unsafeWindow.localStorage.getItem(key);
exportData.data[key] = JSON.parse(value);
} catch (e) {
// If it's not JSON, store as string
exportData.data[key] = _unsafeWindow.localStorage.getItem(key);
}
}
}
return exportData;
}
function downloadExportData() {
try {
const exportData = exportLMAOSettings();
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `lmao-settings-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
debugLog(LogLevel.INFO, 'Settings exported successfully');
} catch (error) {
console.error('[LMAO] Failed to export settings:', error);
alert('Failed to export settings. Check console for details.');
}
}
function importLMAOSettings(importData) {
try {
if (!importData.data || typeof importData.data !== 'object') {
throw new Error('Invalid import data format');
}
// Import only LMAO-specific localStorage keys
Object.keys(importData.data).forEach((key) => {
if (key.toLowerCase().startsWith('lmao')) {
const value =
typeof importData.data[key] === 'object'
? JSON.stringify(importData.data[key])
: importData.data[key];
_unsafeWindow.localStorage.setItem(key, value);
}
});
debugLog(LogLevel.INFO, 'Settings imported successfully');
alert('Settings imported successfully! Please refresh the page to see changes.');
} catch (error) {
console.error('[LMAO] Failed to import settings:', error);
alert('Failed to import settings. Please check the file format.');
}
}
function handleImportFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
try {
const importData = JSON.parse(e.target.result);
importLMAOSettings(importData);
} catch (error) {
console.error('[LMAO] Failed to parse import file:', error);
alert('Invalid file format. Please select a valid LMAO settings file.');
}
};
reader.readAsText(file);
}
// --- API ---
async function fetchAllLikedMaps() {
const allMaps = [];
let paginationToken = null;
let url = 'https://www.geoguessr.com/api/v3/likes/maps?limit=50';
while (true) {
const res = await fetch(
paginationToken ? `${url}&paginationToken=${encodeURIComponent(paginationToken)}` : url,
{ credentials: 'include' }
);
if (!res.ok) {
console.error('[LMAO] Failed to fetch liked maps:', res.status);
break;
}
const data = await res.json();
allMaps.push(...data.items);
if (!data.paginationToken) break;
paginationToken = data.paginationToken;
}
return allMaps;
}
// --- UI HELPERS ---
// Global tooltip element - only one tooltip exists at a time
let _globalTooltipElement = null;
// Ensure tooltip is hidden when page is unloaded
window.addEventListener('beforeunload', () => {
if (_globalTooltipElement) {
_globalTooltipElement.style.visibility = 'hidden';
_globalTooltipElement.style.opacity = '0';
}
});
function createTooltip(element, tooltipText, offSet = false) {
const wrapper = document.createElement('div');
wrapper.className = 'lmao-tooltip';
// Ensure global tooltip element exists
if (!_globalTooltipElement) {
_globalTooltipElement = document.createElement('span');
_globalTooltipElement.className = 'lmao-tooltip-text';
_globalTooltipElement.style.visibility = 'hidden';
_globalTooltipElement.style.opacity = '0';
document.body.appendChild(_globalTooltipElement);
debugLog(LogLevel.DEBUG, 'Created global tooltip element');
}
let showTimeout, hideTimeout;
const showTooltip = () => {
debugLog(LogLevel.TRACE, `Showing tooltip: ${tooltipText}`);
clearTimeout(hideTimeout);
// Update tooltip text
_globalTooltipElement.textContent = tooltipText;
// Immediately hide any existing tooltip to prevent overlapping
_globalTooltipElement.style.visibility = 'hidden';
_globalTooltipElement.style.opacity = '0';
showTimeout = setTimeout(() => {
const rect = wrapper.getBoundingClientRect();
// Position above the element by default
let top = rect.top - 50 - 10; // Approximate tooltip height
let left = rect.left + rect.width / 2;
if (offSet) {
top += 20;
}
// If tooltip would go above viewport, show below instead
if (top < 10) {
top = rect.bottom + 10;
}
// Adjust horizontal position if tooltip would go off screen
const tooltipWidth = 200; // Approximate width
if (left - tooltipWidth / 2 < 10) {
left = tooltipWidth / 2 + 10;
}
if (left + tooltipWidth / 2 > window.innerWidth - 10) {
left = window.innerWidth - tooltipWidth / 2 - 10;
}
_globalTooltipElement.style.position = 'fixed';
_globalTooltipElement.style.top = top + 'px';
_globalTooltipElement.style.left = left + 'px';
_globalTooltipElement.style.transform = 'translateX(-50%)';
_globalTooltipElement.style.zIndex = offSet ? '99999' : '10001';
_globalTooltipElement.style.visibility = 'visible';
_globalTooltipElement.style.opacity = '1';
debugLog(LogLevel.TRACE, `Tooltip now visible: ${tooltipText}`);
}, 200);
};
const hideTooltip = () => {
debugLog(LogLevel.TRACE, `Hiding tooltip: ${tooltipText}`);
clearTimeout(showTimeout);
hideTimeout = setTimeout(() => {
_globalTooltipElement.style.visibility = 'hidden';
_globalTooltipElement.style.opacity = '0';
debugLog(LogLevel.TRACE, `Tooltip now hidden: ${tooltipText}`);
}, 100);
};
wrapper.addEventListener('mouseenter', showTooltip);
wrapper.addEventListener('mouseleave', hideTooltip);
wrapper.appendChild(element);
return wrapper;
}
function showHelpPopup() {
// Remove existing popup if any
const existingPopup = document.querySelector('.lmao-help-popup-overlay');
if (existingPopup) {
existingPopup.remove();
}
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'lmao-help-popup-overlay';
// Create popup
const popup = document.createElement('div');
popup.className = 'lmao-help-popup';
// Create header
const header = document.createElement('div');
header.className = 'lmao-help-popup-header';
const title = document.createElement('h2');
title.className = 'lmao-help-popup-title';
title.textContent = `GeoGuessr LMAO V${GM_info.script.version}`;
const closeBtn = document.createElement('button');
closeBtn.className = 'lmao-help-popup-close';
closeBtn.innerHTML = '×';
closeBtn.onclick = () => {
overlay.remove();
};
header.appendChild(title);
header.appendChild(closeBtn);
// Create content
const content = document.createElement('div');
content.className = 'lmao-help-popup-content';
// Features section
const featuresSection = document.createElement('div');
featuresSection.className = 'lmao-help-popup-section';
const featuresTitle = document.createElement('h3');
featuresTitle.textContent = 'Features';
const featuresText = document.createElement('p');
featuresText.textContent =
'LMAO (Liked Maps Advanced Overhaul) enhances your "Liked Maps" page by adding organization and filtering capabilities to your liked maps. You can add custom tags, filter by various criteria, and integrate with Learnable Meta for enhanced map information.';
featuresSection.appendChild(featuresTitle);
featuresSection.appendChild(featuresText);
// Usage section
const usageSection = document.createElement('div');
usageSection.className = 'lmao-help-popup-section';
const usageTitle = document.createElement('h3');
usageTitle.textContent = 'How to Use';
const usageText = document.createElement('p');
usageText.style.whiteSpace = 'pre-line';
usageText.textContent =
'• Click the edit mode button (✏️) to add tags, reorder and manage tags\n• Use the sidebar to filter maps by tags\n• Use the search panel to filter maps by name or other criteria\n• Use the clear button (🗑️) to reset all filters\n• Toggle tag visibility in the sidebar to remove clutter.';
usageSection.appendChild(usageTitle);
usageSection.appendChild(usageText);
// Links section
const linksSection = document.createElement('div');
linksSection.className = 'lmao-help-popup-links';
const repoLink = document.createElement('a');
repoLink.href = 'https://github.com/schnador/geoguessr-lmao';
repoLink.target = '_blank';
repoLink.textContent = 'GitHub Repository';
repoLink.rel = 'noopener noreferrer';
const learnableMetaLink = document.createElement('a');
learnableMetaLink.href = 'https://learnablemeta.com';
learnableMetaLink.target = '_blank';
learnableMetaLink.textContent = 'Learnable Meta';
learnableMetaLink.rel = 'noopener noreferrer';
const signature = document.createElement('p');
signature.textContent = 'Made by schnador\nwith ❤️';
signature.style.whiteSpace = 'pre-line';
signature.style.textAlign = 'center';
linksSection.appendChild(signature);
linksSection.appendChild(repoLink);
linksSection.appendChild(learnableMetaLink);
// Assemble popup
content.appendChild(featuresSection);
content.appendChild(usageSection);
content.appendChild(linksSection);
popup.appendChild(header);
popup.appendChild(content);
overlay.appendChild(popup);
document.body.appendChild(overlay);
// Close on overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
}
});
// Close on Escape key
document.addEventListener('keydown', function closeOnEscape(e) {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', closeOnEscape);
}
});
}
function createCheckbox(labelText, checked, onChange, classList = null) {
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = checked;
cb.addEventListener('change', () => onChange(cb.checked));
if (classList) {
cb.classList = classList;
}
label.appendChild(cb);
label.appendChild(document.createTextNode(' ' + labelText));
return label;
}
function createRadioGroup(options, selected, name, onChange) {
const div = document.createElement('div');
options.forEach((opt) => {
const label = document.createElement('label');
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = name;
radio.value = opt.value;
radio.checked = selected === opt.value;
radio.addEventListener('change', () => {
if (radio.checked) onChange(opt.value);
});
label.appendChild(radio);
label.appendChild(document.createTextNode(' ' + opt.label));
label.style.marginRight = '1em';
label.style.cursor = 'pointer';
// Add tooltip if provided
if (opt.tooltip) {
const labelWithTooltip = createTooltip(label, opt.tooltip);
div.appendChild(labelWithTooltip);
} else {
div.appendChild(label);
}
});
return div;
}
function createCollapsibleTagGroup(
title,
tags,
selectedTags,
onChange,
collapsed,
onCollapseToggle,
category = 'user'
) {
const groupDiv = document.createElement('div');
groupDiv.className = 'lmao-collapsible-tag-group';
// Header
const header = document.createElement('div');
header.className = 'lmao-collapsible-header';
const arrow = document.createElement('span');
arrow.className = 'lmao-collapsible-arrow';
arrow.textContent = collapsed ? '▶' : '▼';
header.appendChild(arrow);
header.appendChild(document.createTextNode(title));
header.onclick = () => onCollapseToggle(!collapsed);
groupDiv.appendChild(header);
// Tags container
const tagsDiv = document.createElement('div');
tagsDiv.className = 'lmao-collapsible-tags' + (collapsed ? ' lmao-collapsed' : '');
tagsDiv.setAttribute('data-category', category);
// Sort tags by saved order
const sortedTags = sortTagsByOrder(tags, category);
sortedTags.forEach((tag, index) => {
const chip = createTagChip(tag, isTagSelected(tag, category), category, (selected) => {
if (selected) {
addSelectedTag(tag, getTagCategory(category));
} else {
removeSelectedTag(tag, getTagCategory(category));
}
onChange(AppState.selectedTags);
});
// Add drag and drop functionality in edit mode
if (AppState.editMode) {
makeDraggable(chip, category, index, () => {
// Rebuild the group after reordering
const newSortedTags = sortTagsByOrder(tags, getTagCategory(category));
rebuildTagGroup(tagsDiv, newSortedTags, selectedTags, onChange, category);
});
}
tagsDiv.appendChild(chip);
});
groupDiv.appendChild(tagsDiv);
return groupDiv;
}
/**
* Creates a tag chip element
*/
function createTagChip(tag, selected, category, onToggle) {
const chip = document.createElement('div');
chip.className = `lmao-tag-chip ${getTagChipClass(category)}${selected ? ' selected' : ''}${
AppState.editMode ? ' edit-mode' : ''
}`;
chip.setAttribute('data-tag', tag);
chip.setAttribute('data-category', category);
// Add drag handle in edit mode, but not for learnable meta tag - currently the only in its category
if (AppState.editMode && category !== TagCategory.LEARNABLE_META) {
const handle = document.createElement('span');
handle.className = 'lmao-drag-handle';
handle.textContent = '⋮';
chip.appendChild(handle);
}
const text = document.createElement('span');
text.textContent = tag;
chip.appendChild(text);
// Click to toggle selection (only if not in edit mode or not dragging)
chip.addEventListener('click', (e) => {
if (!AppState.editMode && !chip.classList.contains('dragging')) {
onToggle(!selected);
chip.classList.toggle('selected', !selected);
}
});
return chip;
}
/**
* Gets the appropriate CSS class for a tag chip based on category
*/
function getTagChipClass(category) {
switch (category) {
case TagCategory.USER:
return 'user-tag';
case TagCategory.API:
return 'api-tag';
case TagCategory.LEARNABLE_META:
return 'meta-tag';
case TagCategory.REGION:
return 'region-tag';
default:
return 'user-tag';
}
}
/**
* Rebuilds a tag group container with new tag order
*/
function rebuildTagGroup(container, tags, selectedTags, onChange, category) {
container.innerHTML = '';
tags.forEach((tag, index) => {
const chip = createTagChip(tag, isTagSelected(tag, category), category, (selected) => {
if (selected) {
addSelectedTag(tag, getTagCategory(category));
} else {
removeSelectedTag(tag, getTagCategory(category));
}
onChange(AppState.selectedTags);
});
if (AppState.editMode) {
makeDraggable(chip, category, index, () => {
const newSortedTags = sortTagsByOrder(tags, getTagCategory(category));
rebuildTagGroup(container, newSortedTags, selectedTags, onChange, category);
});
}
container.appendChild(chip);
});
}
/**
* Makes a tag chip draggable for reordering
*/
function makeDraggable(chip, category, index, onReorder) {
chip.draggable = true;
chip.addEventListener('dragstart', (e) => {
chip.classList.add('dragging');
e.dataTransfer.setData(
'text/plain',
JSON.stringify({
tag: chip.getAttribute('data-tag'),
category: category,
index: index
})
);
e.dataTransfer.effectAllowed = 'move';
});
chip.addEventListener('dragend', () => {
chip.classList.remove('dragging');
// Remove drag-over class from all chips
document.querySelectorAll('.lmao-tag-chip.drag-over').forEach((c) => {
c.classList.remove('drag-over');
});
});
chip.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
chip.classList.add('drag-over');
});
chip.addEventListener('dragleave', () => {
chip.classList.remove('drag-over');
});
chip.addEventListener('drop', (e) => {
e.preventDefault();
chip.classList.remove('drag-over');
try {
const dragData = JSON.parse(e.dataTransfer.getData('text/plain'));
const targetTag = chip.getAttribute('data-tag');
const targetCategory = chip.getAttribute('data-category');
// Only allow reordering within the same category
if (dragData.category !== targetCategory) return;
const sourceTag = dragData.tag;
if (sourceTag === targetTag) return;
reorderTags(sourceTag, targetTag, category);
onReorder();
} catch (err) {
debugLog(LogLevel.ERROR, 'Error handling drop', err);
}
});
}
/**
* Reorders tags by moving sourceTag before targetTag
*/
function reorderTags(sourceTag, targetTag, category) {
debugLog(LogLevel.DEBUG, `Reordering ${sourceTag} before ${targetTag} in category ${category}`);
// Get current order or create new one
const currentOrder = AppState.tagOrder[category] || [];
const allTags = getCurrentTagsForCategory(category);
// Create a complete order array if it doesn't exist
let fullOrder = [...currentOrder];
allTags.forEach((tag) => {
if (!fullOrder.includes(tag)) {
fullOrder.push(tag);
}
});
// Remove source tag from current position
const sourceIndex = fullOrder.indexOf(sourceTag);
if (sourceIndex !== -1) {
fullOrder.splice(sourceIndex, 1);
}
// Find target position and insert source tag before it
const targetIndex = fullOrder.indexOf(targetTag);
if (targetIndex !== -1) {
fullOrder.splice(targetIndex, 0, sourceTag);
} else {
// If target not found, add to end
fullOrder.push(sourceTag);
}
// Update state and save
AppState.tagOrder[category] = fullOrder;
saveTagOrder(AppState.tagOrder);
debugLog(LogLevel.DEBUG, `New order for ${category}:`, fullOrder);
// Trigger rerender of map teasers to apply new tag order
AppState.rerender();
}
/**
* Gets all current tags for a category
*/
function getCurrentTagsForCategory(category) {
switch (category) {
case 'user':
return AppState.userTagsList;
case 'api':
return AppState.apiTagsList;
case 'region':
return AppState.metaTagsList.filter((tag) => tag !== 'Learnable Meta');
case 'learnableMeta':
return AppState.metaTagsList.filter((tag) => tag === 'Learnable Meta');
default:
return [];
}
}
function createTagVisibilityToggles(tagVisibility, onChange) {
const div = document.createElement('div');
div.className = 'lmao-tag-visibility-toggles';
div.appendChild(
createCheckbox('Show learnable meta tag', tagVisibility.showLearnableMetaTag, (checked) => {
tagVisibility.showLearnableMetaTag = checked;
saveTagVisibility(tagVisibility);
onChange({ ...tagVisibility });
})
);
div.appendChild(
createCheckbox('Show user tags', tagVisibility.showUserTags, (checked) => {
tagVisibility.showUserTags = checked;
saveTagVisibility(tagVisibility);
onChange({ ...tagVisibility });
})
);
div.appendChild(
createCheckbox('Show region tags', tagVisibility.showRegionTags, (checked) => {
tagVisibility.showRegionTags = checked;
saveTagVisibility(tagVisibility);
onChange({ ...tagVisibility });
})
);
div.appendChild(
createCheckbox('Show default tags', tagVisibility.showApiTags, (checked) => {
tagVisibility.showApiTags = checked;
saveTagVisibility(tagVisibility);
onChange({ ...tagVisibility });
})
);
return div;
}
function createControlsUI() {
const controlsDiv = document.createElement('div');
controlsDiv.className = 'lmao-controls';
const header = (title, icon, button = null) => {
const container = document.createElement('div');
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'space-between';
container.style.marginTop = '0.75rem';
container.style.marginBottom = '0.25rem';
const s = document.createElement('strong');
s.className = 'lmao-controls-header';
s.style.margin = '0';
if (icon) {
const iconSpan = document.createElement('span');
iconSpan.className = 'lmao-icon';
iconSpan.textContent = icon;
s.appendChild(iconSpan);
}
const textSpan = document.createElement('span');
textSpan.textContent = title;
s.appendChild(textSpan);
container.appendChild(s);
if (button) {
container.appendChild(button);
}
return container;
};
const headerFilterMode = header('Filter Mode', '⚙️');
headerFilterMode.style.marginTop = '0.25rem';
controlsDiv.appendChild(headerFilterMode);
controlsDiv.appendChild(
createRadioGroup(
[
{
value: 'ALL',
label: 'All tags',
tooltip: 'Show maps that have ALL selected tags\n(stricter filtering - fewer results)'
},
{
value: 'ANY',
label: 'Any tag',
tooltip:
'Show maps that have ANY of the selected tags\n(broader results - more maps shown)'
}
],
AppState.filterMode,
'lmao-filter-mode',
(newMode) => AppState.updateFilterMode(newMode)
)
);
// Create small clear button for Filter heading - only clears tag filters
const smallClearBtn = document.createElement('button');
smallClearBtn.innerHTML = '🧹'; // Broom icon for cleaning tag filters
smallClearBtn.className = 'lmao-small-clear-button';
smallClearBtn.onclick = () => {
clearAllSelectedTags();
AppState.updateSelectedTags(AppState.selectedTags);
};
const smallClearWithTooltip = createTooltip(smallClearBtn, 'Clear Tag Filters', true);
controlsDiv.appendChild(header('Filter', '🔍', smallClearWithTooltip));
// Show Learnable Meta chip (without header) if user has learnable meta maps
const hasLearnableMeta = AppState.learnableMetaCache.size > 0;
if (hasLearnableMeta) {
const learnableMetaChip = createTagChip(
'Learnable Meta',
AppState.selectedTags?.learnableMeta || false,
TagCategory.LEARNABLE_META,
(selected) => {
AppState.selectedTags.learnableMeta = selected;
AppState.updateSelectedTags(AppState.selectedTags);
}
);
learnableMetaChip.classList.add('lmao-default-bottom-margin');
controlsDiv.appendChild(learnableMetaChip);
}
controlsDiv.appendChild(
createCollapsibleTagGroup(
'User tags',
AppState.userTagsList,
AppState.selectedTags,
(newTags) => AppState.updateSelectedTags(newTags),
AppState.filterCollapse?.user,
(c) => AppState.updateFilterCollapse({ ...AppState.filterCollapse, user: c }),
'user'
)
);
// Show Regions section only if user has learnable meta maps and there are region tags
const regionTags = AppState.metaTagsList.filter((tag) => tag !== 'Learnable Meta');
if (hasLearnableMeta && regionTags.length > 0) {
controlsDiv.appendChild(
createCollapsibleTagGroup(
'Regions',
regionTags,
AppState.selectedTags,
(newTags) => AppState.updateSelectedTags(newTags),
AppState.filterCollapse?.regions,
(c) => AppState.updateFilterCollapse({ ...AppState.filterCollapse, regions: c }),
'region'
)
);
}
controlsDiv.appendChild(
createCollapsibleTagGroup(
'Default tags',
AppState.apiTagsList,
AppState.selectedTags,
(newTags) => AppState.updateSelectedTags(newTags),
AppState.filterCollapse?.api,
(c) => AppState.updateFilterCollapse({ ...AppState.filterCollapse, api: c }),
'api'
)
);
controlsDiv.appendChild(header('Tag Visibility', '👁️'));
controlsDiv.appendChild(
createTagVisibilityToggles(AppState.tagVisibility, (newVisibility) =>
AppState.updateTagVisibility(newVisibility)
)
);
return controlsDiv;
}
/**
* Creates the search panel component with dropdown and input.
* @returns {HTMLElement} The search panel element
*/
function createSearchPanel() {
const searchPanel = document.createElement('div');
searchPanel.className = 'lmao-search-panel';
const updateSearchPanelBorder = () => {
const currentCriteria = AppState.searchCriteria;
if (currentCriteria.length === 0) {
searchPanel.style.borderColor = 'var(--ds-color-red-80)'; // Red border when no criteria
} else {
searchPanel.style.borderColor = 'var(--ds-color-white-20)'; // Default border
}
};
// Make the function globally accessible so it can be called when criteria changes
window.updateSearchPanelBorder = updateSearchPanelBorder;
// Search criteria dropdown
const dropdown = document.createElement('button');
dropdown.className = 'lmao-search-dropdown';
dropdown.type = 'button';
// Set initial display based on current criteria using shared function
updateDropdownDisplay(dropdown, AppState.searchCriteria);
// Dropdown menu
const dropdownMenu = document.createElement('div');
dropdownMenu.className = 'lmao-search-dropdown-menu';
const searchCriteria = [
{ value: 'name', label: 'Name', icon: '📝' },
{ value: 'description', label: 'Description', icon: '📄' },
{ value: 'creator', label: 'Creator', icon: '👤' },
{ value: 'tags', label: 'Tags', icon: '🏷️' }
];
searchCriteria.forEach((criteria) => {
const item = document.createElement('label');
item.className = 'lmao-search-dropdown-item';
item.style.cursor = 'pointer';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = criteria.value;
checkbox.checked = AppState.searchCriteria.includes(criteria.value);
checkbox.style.marginRight = '0.5rem';
const icon = document.createElement('span');
icon.className = 'lmao-search-icon';
icon.textContent = criteria.icon;
const labelText = document.createElement('span');
labelText.textContent = criteria.label;
checkbox.onchange = (e) => {
e.stopPropagation();
const currentCriteria = [...AppState.searchCriteria];
if (checkbox.checked) {
if (!currentCriteria.includes(criteria.value)) {
currentCriteria.push(criteria.value);
}
} else {
const index = currentCriteria.indexOf(criteria.value);
if (index > -1) {
currentCriteria.splice(index, 1);
}
}
AppState.updateSearchCriteria(currentCriteria);
updateSearchPanelBorder();
};
item.appendChild(checkbox);
item.appendChild(icon);
item.appendChild(labelText);
// Prevent label click from closing dropdown
item.onclick = (e) => {
e.stopPropagation();
};
dropdownMenu.appendChild(item);
});
// Toggle dropdown - use only click event with proper handling
dropdown.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
dropdownMenu.classList.toggle('show');
const isShowing = dropdownMenu.classList.contains('show');
if (isShowing) {
// Position the dropdown relative to the button since it's now in document.body
const positionDropdown = () => {
const buttonRect = dropdown.getBoundingClientRect();
dropdownMenu.style.position = 'fixed';
dropdownMenu.style.top = buttonRect.bottom + 'px';
dropdownMenu.style.left = buttonRect.left + 'px';
dropdownMenu.style.minWidth = buttonRect.width + 'px';
};
positionDropdown();
// Add scroll listener to reposition dropdown when scrolling
const scrollHandler = () => {
if (dropdownMenu.classList.contains('show')) {
positionDropdown();
}
};
window.addEventListener('scroll', scrollHandler, true);
window.addEventListener('resize', scrollHandler);
// Store handlers for cleanup
dropdownMenu._scrollHandler = scrollHandler;
} else {
// Clean up scroll listeners when dropdown closes
if (dropdownMenu._scrollHandler) {
window.removeEventListener('scroll', dropdownMenu._scrollHandler, true);
window.removeEventListener('resize', dropdownMenu._scrollHandler);
dropdownMenu._scrollHandler = null;
}
}
});
// Search input
const input = document.createElement('input');
input.className = 'lmao-search-input';
input.type = 'text';
input.value = AppState.searchQuery;
// Set initial placeholder based on current criteria
const updatePlaceholder = () => {
const currentCriteria = AppState.searchCriteria;
if (currentCriteria.length === 0) {
input.placeholder = 'Select criteria first...';
} else {
input.placeholder = `Search...`;
}
};
updatePlaceholder();
updateSearchPanelBorder();
// Search input event handlers
let searchTimeout;
input.oninput = (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
AppState.updateSearchQuery(e.target.value);
}, 300); // Debounce search
};
input.onkeydown = (e) => {
e.stopPropagation();
};
// Close dropdown when clicking on the input field
input.onclick = (e) => {
e.stopPropagation();
if (dropdownMenu.classList.contains('show')) {
dropdownMenu.classList.remove('show');
// Clean up scroll listeners when dropdown closes
if (dropdownMenu._scrollHandler) {
window.removeEventListener('scroll', dropdownMenu._scrollHandler, true);
window.removeEventListener('resize', dropdownMenu._scrollHandler);
dropdownMenu._scrollHandler = null;
}
}
};
// Clear search button
const clearBtn = document.createElement('button');
clearBtn.className = 'lmao-search-clear';
clearBtn.innerHTML = '✕';
clearBtn.type = 'button';
clearBtn.title = 'Clear search';
clearBtn.onclick = (e) => {
e.stopPropagation();
input.value = '';
AppState.updateSearchQuery('');
};
// Assemble search panel (but append dropdown to body to avoid clipping)
searchPanel.appendChild(dropdown);
searchPanel.appendChild(input);
searchPanel.appendChild(clearBtn);
// Append dropdown menu to body to avoid overflow clipping
document.body.appendChild(dropdownMenu);
// Close dropdown when clicking outside - use a unique identifier to avoid conflicts
const closeDropdownHandler = (e) => {
// Only close if dropdown is open and click is outside both the search panel and dropdown menu
if (
dropdownMenu.classList.contains('show') &&
!searchPanel.contains(e.target) &&
!dropdownMenu.contains(e.target)
) {
dropdownMenu.classList.remove('show');
// Clean up scroll listeners when dropdown closes
if (dropdownMenu._scrollHandler) {
window.removeEventListener('scroll', dropdownMenu._scrollHandler, true);
window.removeEventListener('resize', dropdownMenu._scrollHandler);
dropdownMenu._scrollHandler = null;
}
}
};
// Add the outside click listener with a delay to avoid immediate conflicts
setTimeout(() => {
document.addEventListener('click', closeDropdownHandler, true); // Use capture phase
}, 100);
return searchPanel;
}
function createHeaderActions() {
const headerActions = document.createElement('div');
headerActions.className = 'lmao-header-actions';
// Add search panel first
const searchPanel = createSearchPanel();
headerActions.appendChild(searchPanel);
// Clear filters button with reset icon
const clearFiltersBtn = document.createElement('button');
clearFiltersBtn.innerHTML = '🗑️'; // Trash icon
clearFiltersBtn.className = 'lmao-header-button lmao-clear-button';
clearFiltersBtn.onclick = () => {
clearAllSelectedTags();
AppState.updateSelectedTags(AppState.selectedTags);
AppState.updateSearchQuery('');
// Also clear the search input
const searchInput = document.querySelector('.lmao-search-input');
if (searchInput) {
searchInput.value = '';
}
};
const clearFiltersWithTooltip = createTooltip(
clearFiltersBtn,
'Reset All (Filters & Search)',
true
);
headerActions.appendChild(clearFiltersWithTooltip);
// Edit mode toggle button
const editToggleBtn = document.createElement('button');
editToggleBtn.innerHTML = '✏️'; // Pencil icon
editToggleBtn.className = 'lmao-header-button lmao-edit-toggle' + (AppState.editMode ? ' active' : '');
editToggleBtn.onclick = () => {
const newMode = !AppState.editMode;
AppState.updateEditMode(newMode);
editToggleBtn.className = 'lmao-header-button lmao-edit-toggle' + (newMode ? ' active' : '');
};
const editToggleWithTooltip = createTooltip(editToggleBtn, 'Toggle Edit Mode', true);
headerActions.appendChild(editToggleWithTooltip);
// Settings dropdown
const settingsDropdown = document.createElement('div');
settingsDropdown.className = 'lmao-settings-dropdown';
const settingsBtn = document.createElement('button');
settingsBtn.innerHTML = '⚙️'; // Cogwheel icon
settingsBtn.className = 'lmao-header-button';
settingsBtn.onclick = (e) => {
e.stopPropagation();
const dropdown = settingsDropdown.querySelector('.lmao-settings-dropdown-content');
dropdown.classList.toggle('show');
};
const settingsBtnWithTooltip = createTooltip(settingsBtn, 'Settings', true);
settingsDropdown.appendChild(settingsBtnWithTooltip);
const dropdownContent = document.createElement('div');
dropdownContent.className = 'lmao-settings-dropdown-content';
// Export button
const exportBtn = document.createElement('button');
exportBtn.textContent = 'Export Settings';
exportBtn.className = 'lmao-settings-dropdown-item';
exportBtn.onclick = () => {
downloadExportData();
dropdownContent.classList.remove('show');
};
// Import button and hidden file input
const importBtn = document.createElement('button');
importBtn.textContent = 'Import Settings';
importBtn.className = 'lmao-settings-dropdown-item';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json';
fileInput.className = 'lmao-file-input';
fileInput.onchange = (e) => {
handleImportFile(e);
dropdownContent.classList.remove('show');
};
importBtn.onclick = () => fileInput.click();
dropdownContent.appendChild(exportBtn);
dropdownContent.appendChild(importBtn);
settingsDropdown.appendChild(dropdownContent);
settingsDropdown.appendChild(fileInput);
headerActions.appendChild(settingsDropdown);
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!settingsDropdown.contains(e.target)) {
dropdownContent.classList.remove('show');
}
});
// Help button with question mark emoji
const helpBtn = document.createElement('button');
helpBtn.innerHTML = '❓'; // Question mark emoji
helpBtn.className = 'lmao-header-button lmao-help-button';
helpBtn.onclick = () => {
showHelpPopup();
};
const helpBtnWithTooltip = createTooltip(helpBtn, 'Help & Information', true);
headerActions.appendChild(helpBtnWithTooltip);
return headerActions;
}
/**
* Updates the map counter in the header
*/
function updateMapCounter() {
const counter = document.getElementById('lmao-map-counter');
if (!counter) return;
const totalMaps = AppState.maps ? AppState.maps.length : 0;
const visibleMaps = countVisibleMaps();
if (visibleMaps === totalMaps) {
counter.textContent = totalMaps.toString();
} else {
counter.textContent = `${visibleMaps}/${totalMaps}`;
}
}
// --- FILTERING FUNCTIONS ---
/**
* Checks if a map matches ALL selected tags from ALL categories (ALL filter mode)
* @param {string[]} allTags - All tags on the map
* @param {string} mapKey - Map key for looking up user tags and meta data
* @returns {boolean} True if map matches all selected tags
*/
function matchesAllSelectedTags(allTags, mapKey) {
// Check user tags
if (AppState.selectedTags.userTags.length > 0) {
const userTags = AppState.currentUserTags[mapKey] || [];
if (!AppState.selectedTags.userTags.every((tag) => userTags.includes(tag))) {
return false;
}
}
// Check region tags
if (AppState.selectedTags.regionTags.length > 0) {
const regionTags = AppState.metaRegionCache?.[mapKey]?.regions || [];
if (!AppState.selectedTags.regionTags.every((tag) => regionTags.includes(tag))) {
return false;
}
}
// Check API tags
if (AppState.selectedTags.apiTags.length > 0) {
const apiTags = allTags.filter(
(tag) => !AppState.currentUserTags[mapKey]?.includes(tag) && tag !== 'Learnable Meta'
);
if (!AppState.selectedTags.apiTags.every((tag) => apiTags.includes(tag))) {
return false;
}
}
// Check Learnable Meta
if (AppState.selectedTags.learnableMeta) {
if (!isLearnableMetaFromCacheOrLocalStorage(mapKey, AppState.learnableMetaCache)) {
return false;
}
}
return true;
}
/**
* Checks if a map matches at least ONE selected tag from ANY category (ANY filter mode)
* @param {string[]} allTags - All tags on the map
* @param {string} mapKey - Map key for looking up user tags and meta data
* @returns {boolean} True if map matches at least one selected tag
*/
function matchesAnySelectedTags(allTags, mapKey) {
// Check user tags
if (AppState.selectedTags.userTags.length > 0) {
const userTags = AppState.currentUserTags[mapKey] || [];
if (AppState.selectedTags.userTags.some((tag) => userTags.includes(tag))) {
return true;
}
}
// Check region tags
if (AppState.selectedTags.regionTags.length > 0) {
const regionTags = AppState.metaRegionCache?.[mapKey]?.regions || [];
if (AppState.selectedTags.regionTags.some((tag) => regionTags.includes(tag))) {
return true;
}
}
// Check API tags
if (AppState.selectedTags.apiTags.length > 0) {
const apiTags = allTags.filter(
(tag) =>
!AppState.currentUserTags[mapKey]?.includes(tag) &&
tag !== 'Learnable Meta' &&
tag !== 'Official'
);
if (AppState.selectedTags.apiTags.some((tag) => apiTags.includes(tag))) {
return true;
}
}
// Check Learnable Meta
if (AppState.selectedTags.learnableMeta) {
if (isLearnableMetaFromCacheOrLocalStorage(mapKey, AppState.learnableMetaCache)) {
return true;
}
}
return false;
}
// --- PATCH TEASERS ---
function patchTeasersWithControls() {
const grid = findGridContainer();
if (!grid) return;
const teasers = findMapTeaserElements(grid);
teasers.forEach((teaser) => {
const href = teaser.getAttribute('href');
const map = getMapByTeaserHref(AppState.maps, href);
if (!map) return;
const mapKey = getMapKey(map);
// Compute all tags (user, api, meta)
const allTags = [
...new Set([...(map.tags || []), ...(AppState.currentUserTags[mapKey] || [])])
];
if (
isLearnableMetaFromCacheOrLocalStorage(mapKey, AppState.learnableMetaCache) &&
!allTags.includes('Learnable Meta')
) {
allTags.push('Learnable Meta');
}
if (map.isUserMap === false) allTags.push('Official');
// Filter logic - combine tag filtering and search filtering
let shouldShow = true;
// Tag filtering - category-specific
if (getSelectedTagsCount() > 0) {
if (AppState.filterMode === 'ALL') {
// ALL mode: must have ALL selected tags from ALL categories
if (!matchesAllSelectedTags(allTags, mapKey)) {
shouldShow = false;
}
} else {
// ANY mode: must have at least ONE selected tag from ANY category
if (!matchesAnySelectedTags(allTags, mapKey)) {
shouldShow = false;
}
}
}
// Search filtering - only apply if there's a search query and tag filtering passed
if (shouldShow && AppState.searchQuery && AppState.searchQuery.trim() !== '') {
if (!matchesSearchQuery(map, AppState.searchQuery, AppState.searchCriteria)) {
shouldShow = false;
}
}
// Apply visibility
if (!shouldShow) {
teaser.closest('li').style.display = 'none';
return;
}
teaser.closest('li').style.display = '';
const tagsContainer = findTagsContainer(teaser);
if (!tagsContainer) {
console.warn('[LMAO] Tags container not found for map', map.slug);
return;
}
// Clear existing tags
tagsContainer
.querySelectorAll('.lmao-map-teaser_tag, .lmao-tag-input')
.forEach((e) => e.remove());
// Add Official tag as a default tag if present and showApiTags is true
if (AppState.tagVisibility.showApiTags && allTags.includes('Official')) {
const tagDiv = document.createElement('span');
tagDiv.className = API_TAG_CLASS;
tagDiv.textContent = 'Official';
tagDiv.style.cursor = 'default';
tagDiv.addEventListener(
'mousedown',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true
);
tagDiv.addEventListener(
'click',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true
);
tagsContainer.appendChild(tagDiv);
}
// Hide or show native API tags based on toggle
Array.from(tagsContainer.children).forEach((child) => {
if (
child.tagName === 'DIV' &&
child.className &&
child.className.includes('map-teaser_tag') &&
!child.className.includes('user-tag') &&
!child.className.includes('api-tag') &&
!child.className.includes('lmao-tag-input')
) {
child.style.display = AppState.tagVisibility.showApiTags ? '' : 'none';
}
child.style.cursor = 'default';
child.addEventListener(
'mousedown',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true
);
child.addEventListener(
'click',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true
);
});
// Add Learnable Meta tag if present
if (
AppState.tagVisibility.showLearnableMetaTag &&
isLearnableMetaFromCacheOrLocalStorage(mapKey, AppState.learnableMetaCache)
) {
const tagDiv = document.createElement('span');
tagDiv.className = USER_TAG_CLASS + ' lmao-learnable-meta';
tagDiv.textContent = 'Learnable Meta';
tagDiv.style.cursor = 'default';
tagDiv.addEventListener(
'mousedown',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true
);
tagDiv.addEventListener(
'click',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true
);
tagsContainer.appendChild(tagDiv);
}
// Add region tags if learnable meta (in sorted order)
if (
AppState.tagVisibility.showRegionTags &&
AppState.learnableMetaCache.has(mapKey) &&
AppState.metaRegionCache &&
AppState.metaRegionCache[mapKey] &&
Array.isArray(AppState.metaRegionCache[mapKey].regions)
) {
const regionTags = AppState.metaRegionCache[mapKey].regions;
const sortedRegionTags = sortTagsByOrder(regionTags, TagCategory.REGION);
sortedRegionTags.forEach((region) => {
const tagDiv = document.createElement('span');
tagDiv.className = USER_TAG_CLASS + ' lmao-region';
tagDiv.textContent = region;
tagDiv.style.cursor = 'default';
tagDiv.addEventListener(
'mousedown',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true
);
tagDiv.addEventListener(
'click',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true
);
tagsContainer.appendChild(tagDiv);
});
}
// Add user tags if enabled (in sorted order)
if (AppState.tagVisibility.showUserTags) {
const userTags = AppState.currentUserTags[mapKey] || [];
const sortedUserTags = sortTagsByOrder(userTags, TagCategory.USER);
sortedUserTags.forEach((tag) => {
const tagDiv = document.createElement('span');
tagDiv.className = USER_TAG_CLASS;
tagDiv.style.cursor = 'default';
tagDiv.setAttribute('data-lmao-usertag', '1');
tagDiv.textContent = tag;
tagDiv.addEventListener(
'mousedown',
(e) => {
if (e.target === tagDiv) {
e.stopPropagation();
e.preventDefault();
}
},
true
);
tagDiv.addEventListener(
'click',
(e) => {
if (e.target === tagDiv) {
e.stopPropagation();
e.preventDefault();
}
},
true
);
if (AppState.editMode) {
const rmBtn = document.createElement('button');
rmBtn.textContent = '×';
rmBtn.title = 'Remove tag';
rmBtn.className = 'lmao-tag-remove-btn';
rmBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
debugLog(LogLevel.DEBUG, 'Removing tag', tag, 'from map', map.id);
AppState.removeUserTag(map, tag);
};
tagDiv.appendChild(rmBtn);
}
tagsContainer.appendChild(tagDiv);
});
}
// Add tag input if in edit mode
if (AppState.editMode) {
const datalistId = 'lmao-user-tags-datalist';
let datalist = document.getElementById(datalistId);
if (!datalist) {
datalist = document.createElement('datalist');
datalist.id = datalistId;
AppState.userTagsList.forEach((tag) => {
const option = document.createElement('option');
option.value = tag;
datalist.appendChild(option);
});
document.body.appendChild(datalist);
} else {
datalist.innerHTML = '';
AppState.userTagsList.forEach((tag) => {
const option = document.createElement('option');
option.value = tag;
datalist.appendChild(option);
});
}
const addTagInput = document.createElement('input');
addTagInput.placeholder = 'Add tag';
addTagInput.className = 'lmao-tag-input';
addTagInput.setAttribute('list', datalistId);
['mousedown', 'click'].forEach((evt) => {
addTagInput.addEventListener(evt, (e) => {
e.stopPropagation();
e.preventDefault();
e.target.focus();
});
});
addTagInput.addEventListener('input', function () {
const val = addTagInput.value.toLowerCase();
datalist.innerHTML = '';
AppState.userTagsList
.filter((tag) => tag.toLowerCase().startsWith(val))
.forEach((tag) => {
const option = document.createElement('option');
option.value = tag;
datalist.appendChild(option);
});
});
addTagInput.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
const val = addTagInput.value.trim();
if (
val &&
!(
(AppState.currentUserTags[mapKey] || []).includes(val) ||
(map.tags || []).includes(val)
)
) {
AppState.addUserTag(map, val);
addTagInput.value = '';
}
}
});
tagsContainer.appendChild(addTagInput);
}
});
// Update the map counter after filtering
updateMapCounter();
}
// --- DOM FINDERS ---
function findGridContainer() {
const grid = document.querySelector('div[class*="grid_grid__"]');
if (grid) {
debugLog(LogLevel.TRACE, 'Found grid container');
} else {
debugLog(LogLevel.ERROR, 'Grid container not found');
}
return grid;
}
function findMapTeaserElements(grid) {
const teasers = Array.from(grid.querySelectorAll('li > a[class*="map-teaser_mapTeaser__"]'));
debugLog(LogLevel.TRACE, `Found ${teasers.length} map teaser elements`);
return teasers;
}
/**
* Counts the number of visible map teasers
* @returns {number} The count of visible maps
*/
function countVisibleMaps() {
const grid = findGridContainer();
if (!grid) return 0;
const teasers = findMapTeaserElements(grid);
let visibleCount = 0;
teasers.forEach((teaser) => {
const listItem = teaser.closest('li');
if (listItem && listItem.style.display !== 'none') {
visibleCount++;
}
});
return visibleCount;
}
function findTagsContainer(mapTeaser) {
const container = mapTeaser.querySelector('div[class*="map-teaser_tagsContainer__"]');
if (!container) {
debugLog(LogLevel.WARN, 'Tags container not found for map teaser');
}
return container;
}
function findLikesMapDiv() {
const likesDiv = document.querySelector('div[class*="likes_map__"]');
if (likesDiv) {
debugLog(LogLevel.TRACE, 'Found likes map div');
} else {
debugLog(LogLevel.ERROR, 'Likes map div not found');
}
return likesDiv;
}
function findHeading() {
const heading = document.querySelector('h1[class*="headline_heading__"]');
if (heading) {
debugLog(LogLevel.TRACE, 'Found heading element');
} else {
debugLog(LogLevel.ERROR, 'Heading element not found');
}
return heading;
}
function findFullHeightContainer() {
const container = document.querySelector('main');
if (container) {
debugLog(LogLevel.TRACE, 'Found main container');
} else {
debugLog(LogLevel.ERROR, 'Main container not found');
}
return container;
}
/**
* Shows a loading indicator in the likes container.
*/
function showLoadingIndicator() {
const container = findLikesMapDiv();
if (!container) return;
let loader = document.getElementById('lmao-loading-indicator');
if (!loader) {
loader = document.createElement('div');
loader.id = 'lmao-loading-indicator';
loader.className = 'lmao-loading-indicator';
loader.innerHTML = `<span class="lmao-loading-indicator-text">⏳ Checking for learnable meta maps...</span>`;
container.parentNode.insertBefore(loader, container);
}
}
/**
* Removes the loading indicator from the likes container.
*/
function removeLoadingIndicator() {
const loader = document.getElementById('lmao-loading-indicator');
if (loader && loader.parentNode) loader.parentNode.removeChild(loader);
}
// --- MAIN ---
async function init() {
debugLog(LogLevel.INFO, 'Starting LMAO initialization');
showLoadingIndicator();
try {
const userTags = loadUserTags();
debugLog(
LogLevel.INFO,
`Loaded ${Object.keys(userTags).length} user tag entries from localStorage`
);
const maps = await fetchAllLikedMaps();
debugLog(LogLevel.INFO, `Fetched ${maps.length} liked maps from API`);
// Group tags for filter UI
const userTagsSet = new Set();
const apiTagsSet = new Set();
const metaTagsSet = new Set();
const learnableMetaCache = new Set();
// Await fetchAndCacheLearnableMeta only during init, then cache result in learnableMetaCache
const learnableMetaMapIds = [];
for (const map of maps) {
(userTags[getMapKey(map)] || []).forEach((t) => userTagsSet.add(t));
(map.tags || []).forEach((t) => apiTagsSet.add(t));
if (await fetchAndCacheLearnableMeta(getMapKey(map))) {
metaTagsSet.add('Learnable Meta');
learnableMetaCache.add(getMapKey(map));
learnableMetaMapIds.push(getMapKey(map));
}
if (map.isUserMap === false) apiTagsSet.add('Official');
}
// get regions from Learnable Meta API
debugLog(
LogLevel.INFO,
`Processing ${learnableMetaMapIds.length} Learnable Meta maps for regions`
);
const metaRegionCache = await preloadMetaRegions(learnableMetaMapIds);
Object.values(metaRegionCache).forEach((regionData) => {
if (regionData && Array.isArray(regionData.regions)) {
regionData.regions.forEach((region) => metaTagsSet.add(region));
}
});
debugLog(LogLevel.INFO, `Found ${metaTagsSet.size} total meta tags (including regions)`);
// Initialize AppState
const lmaoState = loadLMAOState();
AppState.maps = maps;
AppState.userTagsList = Array.from(userTagsSet).sort();
AppState.apiTagsList = Array.from(apiTagsSet).sort();
AppState.metaTagsList = Array.from(metaTagsSet).sort();
AppState.currentUserTags = { ...userTags };
AppState.selectedTags = lmaoState.selectedTags;
AppState.tagVisibility = lmaoState.tagVisibility;
AppState.filterCollapse = lmaoState.filterCollapse;
AppState.tagOrder = lmaoState.tagOrder;
AppState.searchCriteria = lmaoState.searchCriteria;
AppState.filterMode = 'ALL';
AppState.editMode = false;
AppState.learnableMetaCache = learnableMetaCache;
AppState.metaRegionCache = metaRegionCache;
const grid = findGridContainer();
if (!grid) return;
const container = grid.closest('div[class*="container_content__"]');
if (container && !container.className.includes('lmao-full-width-container'))
container.classList.add('lmao-full-width-container');
const likesMapDiv = grid.closest('div[class*="likes_map__"]');
if (likesMapDiv) {
likesMapDiv.style.display = 'flex';
likesMapDiv.style.alignItems = 'flex-start';
likesMapDiv.style.gap = '0';
likesMapDiv.style.marginTop = '1rem';
}
const likesMapContainer = likesMapDiv.parentElement;
if (likesMapContainer && !likesMapContainer.className.includes('lmao-likes-container')) {
likesMapContainer.classList.add('lmao-likes-container');
}
AppState.controlsDiv = document.getElementById('liked-maps-filter-controls');
if (!AppState.controlsDiv) AppState.rebuildControls();
grid.style.flexGrow = '1';
AppState.rerender();
debugLog(LogLevel.INFO, 'LMAO initialization completed successfully');
} finally {
removeLoadingIndicator();
window.scrollTo({ top: 0, behavior: 'instant' });
}
}
/**
* Checks if the current page is one where the script should activate.
* @returns {boolean}
*/
function isActivePage() {
const { pathname, search } = window.location;
if (pathname === '/me/likes') return true;
// disabled for now - would need handle the different class names to make it work.
// if (pathname === '/maps/community') {
// const params = new URLSearchParams(search);
// return params.get('tab') === 'liked-maps';
// }
return false;
}
// --- PAGE NAVIGATION HANDLING ---
/**
* Observe URL and DOM changes to trigger script activation on SPA navigation.
*/
function observePageAndGrid() {
let lastUrl = location.href;
let gridInitialized = false;
/**
* Try to initialize the script if on the correct page and grid is present.
* Remove controls if not on the correct page.
*/
function tryInit() {
try {
if (!isActivePage()) {
gridInitialized = false;
// Remove controls panel if present
const controlsDiv = document.getElementById('liked-maps-filter-controls');
if (controlsDiv && controlsDiv.parentNode) {
controlsDiv.parentNode.removeChild(controlsDiv);
}
return;
}
const grid = document.querySelector('div[class*="grid_grid__"]');
// debugLog('[LMAO] grid alive:', !!grid, 'initialized:', !!gridInitialized);
if (!grid) {
gridInitialized = false;
// Keep retrying until grid appears (for SPA back/forward navigation)
// debugLog('[LMAO] Grid not found. Retrying...');
setTimeout(tryInit, 100);
return;
}
if (!gridInitialized) {
gridInitialized = true;
debugLog(LogLevel.INFO, 'Grid found, initializing LMAO');
init();
saveDevConfig();
}
} catch (e) {
debugLog(LogLevel.ERROR, 'Error during tryInit:', e);
}
}
// Observe URL changes (pushState, replaceState, popstate)
const origPushState = history.pushState;
const origReplaceState = history.replaceState;
history.pushState = function (...args) {
origPushState.apply(this, args);
window.dispatchEvent(new Event('locationchange'));
};
history.replaceState = function (...args) {
origReplaceState.apply(this, args);
window.dispatchEvent(new Event('locationchange'));
};
window.addEventListener('popstate', () => window.dispatchEvent(new Event('locationchange')));
window.addEventListener('locationchange', () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
gridInitialized = false;
tryInit();
}
});
// Observe DOM changes for grid
const observer = new MutationObserver(() => {
tryInit();
});
observer.observe(document.body, { childList: true, subtree: true });
// Initial check
tryInit();
}
// --- WAIT FOR PAGE (MutationObserver version) ---
const waitForLoad = async () => {
while (!document.body) {
await sleep(100);
}
observePageAndGrid();
};
waitForLoad();
})();