LZT counter

Универсальный счётчик LZT

// ==UserScript==
// @name         LZT counter
// @namespace    Счётчик кликов
// @author       Plarq
// @version      1.0
// @description  Универсальный счётчик LZT
// @license      Apache 2.0
// @match        https://zelenka.guru/*
// @match        https://lolz.live/*
// @icon         https://lolz.live/styles/brand/download/avatars/three_avatar.svg
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.xmlHttpRequest
// @grant        unsafeWindow
// @run-at       document-end
// @require      https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js
// ==/UserScript==

/* global _ */

(function() {
    'use strict';

    const CONFIG = {
        COUNTER_ID: 'lzt-counter-visible',
        SETTINGS_BTN_ID: 'lzt-counter-settings-btn',
        ACHIEVEMENTS_BTN_ID: 'lzt-counter-achievements-btn',
        SETTINGS_MENU_ID: 'lzt-counter-settings-menu',
        ACHIEVEMENTS_MENU_ID: 'lzt-counter-achievements-menu',
        STORAGE_KEY: 'lztGlobalCounterV2',
        STORAGE_MAX_KEY: 'lztGlobalCounterMaxV2',
        STYLES_KEY: 'lztCounterStyles',
        FONT_SIZE_KEY: 'lztCounterFontSize',
        BACKGROUND_OPACITY_KEY: 'lztCounterBgOpacity',
        BUTTON_SELECTORS: {
            'zelenka.guru': '[data-t="update"]',
            'lzt.market': '.feed__refresh-button',
            'lolz.market': '.refresh-feed',
            'lolz.live': '.UpdateFeedButton'
        },
        ACHIEVEMENTS: [
            { threshold: 100, title: 'Новокек' },
            { threshold: 500, title: 'Местный' },
            { threshold: 1000, title: 'Постоялец' },
            { threshold: 2500, title: 'Эксперт' },
            { threshold: 5000, title: 'Гуру' },
            { threshold: 10000, title: 'Искусственный интеллект' }
        ],
        INITIAL_POSITION: { x: 260, y: 950 },
        DEFAULT_FONT_SIZE: 18,
        DEFAULT_BG_OPACITY: 1,
        NOTIFICATION_DURATION: 5000,
        NOTIFICATION_OFFSET: 20,
        DEBUG: false
    };

    let isDragging = false;
    let animationFrameId = null;

    function log(...args) {
        if (CONFIG.DEBUG) console.log('[LZT Counter]', ...args);
    }

    function isFeedPage() {
        const path = window.location.pathname;
        return path.includes('/feed') || path === '/';
    }

    function hexToRgba(hex, alpha) {
        const r = parseInt(hex.slice(1, 3), 16);
        const g = parseInt(hex.slice(3, 5), 16);
        const b = parseInt(hex.slice(5, 7), 16);
        return `rgba(${r}, ${g}, ${b}, ${alpha})`;
    }

    async function applyStyles(counterElement) {
        const savedStyles = await GM.getValue(CONFIG.STYLES_KEY, {});
        const fontSize = await GM.getValue(CONFIG.FONT_SIZE_KEY, CONFIG.DEFAULT_FONT_SIZE);
        const bgOpacity = await GM.getValue(CONFIG.BACKGROUND_OPACITY_KEY, CONFIG.DEFAULT_BG_OPACITY);

        counterElement.style.cssText = `
            position: fixed;
            color: ${savedStyles.color || '#ffffff'};
            background: ${savedStyles.background === false ?
                'transparent' :
                hexToRgba('#1A1A1A', bgOpacity)};
            box-shadow: ${savedStyles.background === false ? 'none' : '0 2px 5px rgba(0,0,0,0.2)'};
            padding: ${savedStyles.background === false ? '0' : '8px 15px'};
            border-radius: 5px;
            z-index: 9998;
            cursor: pointer;
            font-size: ${fontSize}px;
            font-weight: bold;
            user-select: none;
            min-width: 60px;
            text-align: center;
            white-space: nowrap;
            transition: all 0.2s ease;
            box-sizing: border-box;
            left: ${savedStyles.customPosition?.x || CONFIG.INITIAL_POSITION.x}px;
            top: ${savedStyles.customPosition?.y || CONFIG.INITIAL_POSITION.y}px;
        `;
    }

    function createButton(text, color) {
        const btn = document.createElement('button');
        btn.textContent = text;
        btn.style.cssText = `
            flex: 1;
            background: ${color};
            color: white;
            border: none;
            padding: 8px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            white-space: nowrap;
            transition: opacity 0.2s;
        `;
        btn.addEventListener('mouseover', () => btn.style.opacity = '0.8');
        btn.addEventListener('mouseout', () => btn.style.opacity = '1');
        return btn;
    }

    function createSettingsMenu() {
        const menu = document.createElement('div');
        menu.id = CONFIG.SETTINGS_MENU_ID;
        menu.style.cssText = `
            position: fixed;
            bottom: 60px;
            left: 20px;
            background: #1A1A1A;
            color: white;
            padding: 15px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            z-index: 9999;
            display: none;
            width: 300px;
            box-sizing: border-box;
        `;

        const createRow = () => {
            const row = document.createElement('div');
            row.style.cssText = 'display: flex; gap: 8px; margin-bottom: 12px;';
            return row;
        };

        const row1 = createRow();
        const colorBtn = createButton('Цвет', '#228E5D');
        const bgBtn = createButton('Фон', '#228E5D');
        row1.appendChild(colorBtn);
        row1.appendChild(bgBtn);

        const row2 = createRow();
        const moveBtn = createButton('Переместить', '#228E5D');
        const resetBtn = createButton('Сброс позиции', '#228E5D');
        row2.appendChild(moveBtn);
        row2.appendChild(resetBtn);

        const createSlider = (config) => {
            const container = document.createElement('div');
            container.style.cssText = 'margin: 15px 0;';

            const label = document.createElement('div');
            label.textContent = config.label;
            label.style.cssText = `
                color: rgba(255,255,255,0.7);
                font-size: 12px;
                margin-bottom: 8px;
                display: flex;
                justify-content: space-between;
            `;

            const value = document.createElement('span');
            value.textContent = config.valueText;

            const slider = document.createElement('input');
            slider.type = 'range';
            Object.assign(slider, config.sliderProps);
            slider.style.cssText = `
                width: 100%;
                height: 4px;
                background: #333;
                border-radius: 2px;
                -webkit-appearance: none;
                outline: none;

                &::-webkit-slider-thumb {
                    -webkit-appearance: none;
                    width: 16px;
                    height: 16px;
                    background: #228E5D;
                    border-radius: 50%;
                    cursor: pointer;
                }
            `;

            slider.addEventListener('input', _.throttle(async (e) => {
                const newValue = config.parser(e.target.value);
                value.textContent = config.formatter(newValue);
                await GM.setValue(config.key, newValue);
                applyStyles(document.getElementById(CONFIG.COUNTER_ID));
            }, 100));

            label.appendChild(value);
            container.appendChild(label);
            container.appendChild(slider);
            return container;
        };

        const fontSizeSlider = createSlider({
            label: 'Размер текста:',
            key: CONFIG.FONT_SIZE_KEY,
            sliderProps: {
                min: 12,
                max: 36,
                step: 1,
                value: CONFIG.DEFAULT_FONT_SIZE
            },
            parser: parseInt,
            formatter: v => `${v}px`,
            valueText: `${CONFIG.DEFAULT_FONT_SIZE}px`
        });

        const opacitySlider = createSlider({
            label: 'Прозрачность фона:',
            key: CONFIG.BACKGROUND_OPACITY_KEY,
            sliderProps: {
                min: 0,
                max: 1,
                step: 0.1,
                value: CONFIG.DEFAULT_BG_OPACITY
            },
            parser: parseFloat,
            formatter: v => `${Math.round(v * 100)}%`,
            valueText: `${Math.round(CONFIG.DEFAULT_BG_OPACITY * 100)}%`
        });

        const hint = document.createElement('div');
        hint.style.cssText = `
            color: rgba(255,255,255,0.7);
            font-size: 12px;
            margin-top: 10px;
            padding-top: 10px;
            border-top: 1px solid rgba(255,255,255,0.2);
        `;
        hint.innerHTML = 'Для сохранения позиции<br>нажмите ЛКМ во время перемещения';

        menu.appendChild(row1);
        menu.appendChild(row2);
        menu.appendChild(fontSizeSlider);
        menu.appendChild(opacitySlider);
        menu.appendChild(hint);

        const colorInput = document.createElement('input');
        colorInput.type = 'color';
        colorInput.style.cssText = 'position: absolute; opacity: 0; pointer-events: none;';

        colorBtn.addEventListener('click', () => {
            colorInput.value = document.getElementById(CONFIG.COUNTER_ID).style.color;
            colorInput.click();
        });

        colorInput.addEventListener('input', async (e) => {
            const styles = await GM.getValue(CONFIG.STYLES_KEY, {});
            styles.color = e.target.value;
            await GM.setValue(CONFIG.STYLES_KEY, styles);
            applyStyles(document.getElementById(CONFIG.COUNTER_ID));
        });

        bgBtn.addEventListener('click', async () => {
            const styles = await GM.getValue(CONFIG.STYLES_KEY, {});
            styles.background = !styles.background;
            await GM.setValue(CONFIG.STYLES_KEY, styles);
            applyStyles(document.getElementById(CONFIG.COUNTER_ID));
            bgBtn.textContent = styles.background ? 'Убрать фон' : 'Вернуть фон';
        });

        moveBtn.addEventListener('click', () => startDragging());

        resetBtn.addEventListener('click', async () => {
            const styles = await GM.getValue(CONFIG.STYLES_KEY, {});
            delete styles.customPosition;
            await GM.setValue(CONFIG.STYLES_KEY, styles);
            applyStyles(document.getElementById(CONFIG.COUNTER_ID));
        });

        Promise.all([
            GM.getValue(CONFIG.FONT_SIZE_KEY, CONFIG.DEFAULT_FONT_SIZE),
            GM.getValue(CONFIG.BACKGROUND_OPACITY_KEY, CONFIG.DEFAULT_BG_OPACITY)
        ]).then(([fontSize, opacity]) => {
            fontSizeSlider.querySelector('input').value = fontSize;
            fontSizeSlider.querySelector('span').textContent = `${fontSize}px`;
            opacitySlider.querySelector('input').value = opacity;
            opacitySlider.querySelector('span').textContent = `${Math.round(opacity * 100)}%`;
        });

        document.body.appendChild(menu);
        document.body.appendChild(colorInput);
        return menu;
    }

    function createAchievementsMenu() {
        const menu = document.createElement('div');
        menu.id = CONFIG.ACHIEVEMENTS_MENU_ID;
        menu.style.cssText = `
            position: fixed;
            bottom: 60px;
            left: 20px;
            background: #1A1A1A;
            color: white;
            padding: 15px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            z-index: 9999;
            display: none;
            width: 300px;
            box-sizing: border-box;
        `;

        const title = document.createElement('div');
        title.textContent = 'Достижения';
        title.style.cssText = 'font-weight: bold; margin-bottom: 15px; font-size: 16px;';

        const progressContainer = document.createElement('div');
        progressContainer.style.marginBottom = '15px';

        const progressBar = document.createElement('div');
        progressBar.style.cssText = `
            background: #333;
            height: 10px;
            border-radius: 5px;
            overflow: hidden;
        `;

        const progressFill = document.createElement('div');
        progressFill.style.cssText = `
            background: #228E5D;
            height: 100%;
            width: 0%;
            transition: width 0.3s ease;
        `;

        progressBar.appendChild(progressFill);
        progressContainer.appendChild(progressBar);

        const achievementsList = document.createElement('div');
        achievementsList.style.cssText = 'max-height: 300px; overflow-y: auto;';

        menu.appendChild(title);
        menu.appendChild(progressContainer);
        menu.appendChild(achievementsList);

        async function updateMenu() {
            const maxCounter = await GM.getValue(CONFIG.STORAGE_MAX_KEY, 0);
            const achievements = CONFIG.ACHIEVEMENTS;

            let currentAchievement = null;
            let nextAchievement = null;
            for (let i = 0; i < achievements.length; i++) {
                if (maxCounter >= achievements[i].threshold) {
                    currentAchievement = achievements[i];
                    if (i < achievements.length - 1) {
                        nextAchievement = achievements[i + 1];
                    }
                } else {
                    if (!nextAchievement) nextAchievement = achievements[i];
                    break;
                }
            }

            if (currentAchievement && nextAchievement) {
                const progress = ((maxCounter - currentAchievement.threshold) / (nextAchievement.threshold - currentAchievement.threshold)) * 100;
                progressFill.style.width = `${Math.min(progress, 100)}%`;
            } else if (currentAchievement && !nextAchievement) {
                progressFill.style.width = '100%';
            } else if (!currentAchievement && nextAchievement) {
                const progress = (maxCounter / nextAchievement.threshold) * 100;
                progressFill.style.width = `${progress}%`;
            } else {
                progressFill.style.width = '0%';
            }

            achievementsList.innerHTML = '';
            achievements.forEach(ach => {
                const isUnlocked = maxCounter >= ach.threshold;
                const item = document.createElement('div');
                item.style.cssText = `
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    padding: 8px 0;
                    border-bottom: 1px solid rgba(255,255,255,0.1);
                `;

                const titleSpan = document.createElement('span');
                titleSpan.textContent = ach.title;
                titleSpan.style.color = isUnlocked ? '#fff' : 'rgba(255,255,255,0.5)';

                const thresholdSpan = document.createElement('span');
                thresholdSpan.textContent = ach.threshold;
                thresholdSpan.style.color = isUnlocked ? '#228E5D' : 'rgba(255,255,255,0.5)';
                thresholdSpan.style.fontSize = '14px';

                item.appendChild(titleSpan);
                item.appendChild(thresholdSpan);
                achievementsList.appendChild(item);
            });
        }

        menu.updateMenu = updateMenu;
        menu.addEventListener('click', (e) => e.stopPropagation());
        document.body.appendChild(menu);
        return menu;
    }

    function showAchievementNotification(achievement) {
        const notification = document.createElement('div');
        notification.className = 'lzt-achievement-notification';
        notification.innerHTML = `
            <div style="font-size: 16px; color: #228E5D; margin-bottom: 4px;">✓ Достижение получено!</div>
            <div style="font-size: 14px;"> Вы теперь ${achievement.title}</div>
        `;

        notification.style.cssText = `
            position: fixed;
            left: ${CONFIG.NOTIFICATION_OFFSET}px;
            top: 50%;
            transform: translateY(-50%);
            background: #1A1A1A;
            color: white;
            padding: 16px;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.2);
            z-index: 10000;
            cursor: pointer;
            opacity: 0;
            transition: opacity 0.3s ease, transform 0.3s ease;
            max-width: 250px;
        `;

        document.body.appendChild(notification);

        requestAnimationFrame(() => {
            notification.style.opacity = '1';
            notification.style.transform = `translate(10px, -50%)`;
        });

        setTimeout(() => {
            notification.style.opacity = '0';
            setTimeout(() => notification.remove(), 300);
        }, CONFIG.NOTIFICATION_DURATION);

        notification.addEventListener('click', () => {
            notification.style.opacity = '0';
            setTimeout(() => notification.remove(), 300);
        });
    }

    async function checkAchievements(newMax) {
        const shownAchievements = await GM.getValue('shownAchievements', {});
        const newAchievements = [];

        CONFIG.ACHIEVEMENTS.forEach(ach => {
            if (newMax >= ach.threshold && !shownAchievements[ach.threshold]) {
                newAchievements.push(ach);
                shownAchievements[ach.threshold] = true;
            }
        });

        if (newAchievements.length > 0) {
            await GM.setValue('shownAchievements', shownAchievements);
            newAchievements.forEach(ach => showAchievementNotification(ach));
        }
    }

    function startDragging() {
        const counter = document.getElementById(CONFIG.COUNTER_ID);
        const menu = document.getElementById(CONFIG.SETTINGS_MENU_ID);
        isDragging = true;
        menu.style.display = 'none';
        counter.style.cursor = 'grabbing';

        let lastX = 0, lastY = 0;
        let posX = 0, posY = 0;

        const pointerMoveHandler = e => {
            if (!isDragging) return;
            lastX = e.clientX;
            lastY = e.clientY;

            if (!animationFrameId) {
                animationFrameId = requestAnimationFrame(updatePosition);
            }
        };

        const updatePosition = () => {
            if (!isDragging) return;

            const counterRect = counter.getBoundingClientRect();
            const maxX = window.innerWidth - counterRect.width;
            const maxY = window.innerHeight - counterRect.height;

            posX = Math.min(Math.max(lastX - counterRect.width/2, 0), maxX);
            posY = Math.min(Math.max(lastY - counterRect.height/2, 0), maxY);

            counter.style.left = `${posX}px`;
            counter.style.top = `${posY}px`;

            animationFrameId = requestAnimationFrame(updatePosition);
        };

        const pointerUpHandler = async () => {
            isDragging = false;
            counter.style.cursor = 'pointer';
            cancelAnimationFrame(animationFrameId);
            animationFrameId = null;

            const styles = await GM.getValue(CONFIG.STYLES_KEY, {});
            styles.customPosition = {x: posX, y: posY};
            await GM.setValue(CONFIG.STYLES_KEY, styles);

            document.removeEventListener('pointermove', pointerMoveHandler);
            document.removeEventListener('pointerup', pointerUpHandler);
        };

        document.addEventListener('pointermove', pointerMoveHandler);
        document.addEventListener('pointerup', pointerUpHandler, {once: true});
    }

    async function main() {
        if (!isFeedPage()) return;

        const domain = window.location.hostname.replace('www.', '');
        log('Domain:', domain);

        let counter = await GM.getValue(CONFIG.STORAGE_KEY, 0);
        let maxCounter = await GM.getValue(CONFIG.STORAGE_MAX_KEY, 0);
        if (maxCounter < counter) {
            await GM.setValue(CONFIG.STORAGE_MAX_KEY, counter);
        }

        let counterElement = document.getElementById(CONFIG.COUNTER_ID);
        if (!counterElement) {
            counterElement = document.createElement('div');
            counterElement.id = CONFIG.COUNTER_ID;
            counterElement.textContent = counter;

            counterElement.addEventListener('dblclick', async () => {
                await GM.setValue(CONFIG.STORAGE_KEY, 0);
                counterElement.textContent = '0';
            });

            document.body.appendChild(counterElement);
            await applyStyles(counterElement);
        }

        if (!document.getElementById(CONFIG.SETTINGS_BTN_ID)) {
            const buttonsContainer = document.createElement('div');
            buttonsContainer.id = 'lzt-counter-buttons-container';
            buttonsContainer.style.cssText = `
                position: fixed;
                bottom: 20px;
                left: 20px;
                z-index: 9999;
                display: flex;
                gap: 8px;
            `;

            const settingsBtn = document.createElement('button');
            settingsBtn.id = CONFIG.SETTINGS_BTN_ID;
            settingsBtn.textContent = 'Настройки';
            settingsBtn.style.cssText = `
                padding: 8px 16px;
                background: #1A1A1A;
                color: white;
                border: none;
                border-radius: 5px;
                cursor: pointer;
                font-size: 14px;
                white-space: nowrap;
                box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            `;

            const achievementsBtn = document.createElement('button');
            achievementsBtn.id = CONFIG.ACHIEVEMENTS_BTN_ID;
            achievementsBtn.textContent = 'Достижения';
            achievementsBtn.style.cssText = settingsBtn.style.cssText;

            buttonsContainer.appendChild(settingsBtn);
            buttonsContainer.appendChild(achievementsBtn);
            document.body.appendChild(buttonsContainer);

            const settingsMenu = createSettingsMenu();
            const achievementsMenu = createAchievementsMenu();

            settingsBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                settingsMenu.style.display = settingsMenu.style.display === 'block' ? 'none' : 'block';
                achievementsMenu.style.display = 'none';
            });

            achievementsBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                achievementsMenu.style.display = achievementsMenu.style.display === 'block' ? 'none' : 'block';
                settingsMenu.style.display = 'none';
                achievementsMenu.updateMenu();
            });

            document.addEventListener('click', (e) => {
                if (!settingsMenu.contains(e.target) && !achievementsMenu.contains(e.target) &&
                    !settingsBtn.contains(e.target) && !achievementsBtn.contains(e.target)) {
                    settingsMenu.style.display = 'none';
                    achievementsMenu.style.display = 'none';
                }
            });
        }

        const buttonSelector = CONFIG.BUTTON_SELECTORS[domain];
        const handleClick = _.throttle(async () => {
            const current = await GM.getValue(CONFIG.STORAGE_KEY, 0);
            const newCounter = current + 1;
            const maxCounter = await GM.getValue(CONFIG.STORAGE_MAX_KEY, 0);
            const newMax = Math.max(maxCounter, newCounter);

            await GM.setValue(CONFIG.STORAGE_KEY, newCounter);
            await GM.setValue(CONFIG.STORAGE_MAX_KEY, newMax);

            document.getElementById(CONFIG.COUNTER_ID).textContent = newCounter;

            if (newMax > maxCounter) {
                await checkAchievements(newMax);
            }
        }, 1000);

        new MutationObserver(() => {
            const button = document.querySelector(buttonSelector);
            if (button && !button.dataset.listener) {
                button.addEventListener('click', handleClick);
                button.dataset.listener = 'true';
            }
        }).observe(document.documentElement, {
            childList: true,
            subtree: true
        });
    }

    main().catch(e => log('Error:', e));
})();