Add Reactions to Civitai Posts

Add reaction buttons to posts on civitai.com

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Add Reactions to Civitai Posts
// @namespace    http://tampermonkey.net/
// @version      0.10
// @description  Add reaction buttons to posts on civitai.com
// @author       You
// @match        https://civitai.com/posts*
// @grant        none
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // Reaction types with correct emojis
    const reactions = {
        'Like': {
            emoji: '👍',
            key: 'likeCount'
        },
        'Heart': {
            emoji: '❤️',
            key: 'heartCount'
        },
        'Laugh': {
            emoji: '😂',
            key: 'laughCount'
        },
        'Cry': {
            emoji: '😢',
            key: 'cryCount'
        }
    };

    // Function to send reaction API request
    async function sendReaction(imageId, reactionType) {
        try {
            const response = await fetch('https://civitai.com/api/trpc/reaction.toggle', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    json: {
                        entityId: imageId,
                        entityType: "image",
                        reaction: reactionType,
                        authed: true
                    }
                })
            });

            if (response.ok) {
                return await response.json();
            } else {
                throw new Error(`HTTP ${response.status}`);
            }
        } catch (error) {
            console.error('Reaction failed:', error);
            return null;
        }
    }

    // Function to create reaction buttons
    function createReactionButtons(postElement, imageId, stats = {}) {
        // Check if buttons already exist
        if (postElement.querySelector('.custom-reactions')) {
            return;
        }

        const reactionsContainer = document.createElement('div');
        reactionsContainer.className = 'custom-reactions';
        reactionsContainer.style.cssText = `
            position: absolute;
            bottom: 8px;
            right: 8px;
            display: flex;
            gap: 4px;
            z-index: 20;
            background: rgba(0, 0, 0, 0.7);
            border-radius: 20px;
            padding: 4px 8px;
            backdrop-filter: blur(4px);
        `;

        Object.entries(reactions).forEach(([reactionType, config]) => {
            const currentCount = stats[config.key] || 0;

            const button = document.createElement('button');
            button.className = 'reaction-btn';
            button.style.cssText = `
                background: transparent;
                border: none;
                cursor: pointer;
                padding: 4px;
                border-radius: 50%;
                display: flex;
                align-items: center;
                justify-content: center;
                transition: all 0.2s;
                opacity: 0.8;
                color: white;
                font-size: 16px;
                gap: 2px;
                min-width: 35px;
            `;

            const countSpan = document.createElement('span');
            countSpan.className = 'reaction-count';
            countSpan.style.cssText = 'font-size: 12px; font-weight: bold;';
            countSpan.textContent = currentCount;

            button.textContent = config.emoji;
            if (currentCount > 0) {
                button.appendChild(document.createTextNode(' '));
                button.appendChild(countSpan);
            }

            // Hover effects
            button.addEventListener('mouseenter', () => {
                button.style.opacity = '1';
                button.style.transform = 'scale(1.1)';
                button.style.background = 'rgba(255, 255, 255, 0.2)';
            });

            button.addEventListener('mouseleave', () => {
                button.style.opacity = '0.8';
                button.style.transform = 'scale(1)';
                button.style.background = 'transparent';
            });

            // Click handler
            button.addEventListener('click', async (e) => {
                e.preventDefault();
                e.stopPropagation();

                // Disable button during request
                button.style.opacity = '0.5';
                button.style.pointerEvents = 'none';

                const result = await sendReaction(imageId, reactionType);

                if (result) {
                    // Update count optimistically
                    const newCount = currentCount + 1;
                    countSpan.textContent = newCount;

                    if (currentCount === 0) {
                        // Add count display if it wasn't there before
                        button.appendChild(document.createTextNode(' '));
                        button.appendChild(countSpan);
                    }

                    // Success feedback
                    button.style.background = 'rgba(0, 255, 0, 0.3)';
                    setTimeout(() => {
                        button.style.background = 'transparent';
                    }, 1000);
                } else {
                    // Error feedback
                    button.style.background = 'rgba(255, 0, 0, 0.3)';
                    setTimeout(() => {
                        button.style.background = 'transparent';
                    }, 1000);
                }

                // Restore button
                button.style.opacity = '0.8';
                button.style.pointerEvents = 'auto';
            });

            reactionsContainer.appendChild(button);
        });

        // Add the reactions container to the post
        postElement.style.position = 'relative';
        postElement.appendChild(reactionsContainer);
    }

    // Function to intercept and parse API responses to get image IDs and stats
    const originalFetch = window.fetch;
    const imageIdMap = new Map();
    const postStatsMap = new Map();

    window.fetch = async function(...args) {
        const response = await originalFetch.apply(this, args);

        // Check if this is a posts API call
        if (args[0] && args[0].includes('/api/trpc/post.getInfinite')) {
            try {
                const clonedResponse = response.clone();
                const data = await clonedResponse.json();

                if (data.result?.data?.json?.items) {
                    console.log('Processing', data.result.data.json.items.length, 'posts from API');

                    data.result.data.json.items.forEach(post => {
                        if (post.images && post.images.length > 0) {
                            const firstImage = post.images[0];
                            // Map the image URL hash to the actual image ID and stats
                            imageIdMap.set(firstImage.url, {
                                id: firstImage.id,
                                stats: post.stats || {}
                            });
                            console.log('Mapped image:', firstImage.url, 'to ID:', firstImage.id);
                        }
                    });

                    // Process posts after API response
                    setTimeout(processPostCards, 1000);
                }
            } catch (error) {
                console.error('Error parsing posts data:', error);
            }
        }

        return response;
    };

    // Function to process posts and add reaction buttons
    function processPostCards() {
        console.log('Processing post cards...');
        const postCards = document.querySelectorAll('div[id]:not(.reactions-processed)');
        console.log('Found', postCards.length, 'unprocessed post cards');

        let processed = 0;
        postCards.forEach(postCard => {
            const imgElement = postCard.querySelector('img.EdgeImage_image__iH4_q');
            if (imgElement && imgElement.src) {
                // Extract the image hash from the URL
                const urlParts = imgElement.src.split('/');
                const hashPart = urlParts.find(part => part.length > 30 && part.includes('-'));

                if (hashPart && imageIdMap.has(hashPart)) {
                    const imageData = imageIdMap.get(hashPart);
                    console.log('Creating reactions for image:', imageData.id);
                    createReactionButtons(postCard, imageData.id, imageData.stats);
                    postCard.classList.add('reactions-processed');
                    processed++;
                } else if (hashPart) {
                    console.log('No image data found for hash:', hashPart);
                } else {
                    console.log('Could not extract hash from image URL:', imgElement.src);
                }
            }
        });

        console.log('Successfully processed', processed, 'posts');
        console.log('Current imageIdMap size:', imageIdMap.size);
    }

    // Observer to watch for new posts being loaded
    const observer = new MutationObserver((mutations) => {
        let shouldProcess = false;

        mutations.forEach((mutation) => {
            mutation.addedNodes.forEach((node) => {
                if (node.nodeType === 1 && (node.matches('div[id]') || node.querySelector('div[id]'))) {
                    shouldProcess = true;
                }
            });
        });

        if (shouldProcess) {
            // Small delay to ensure all content is loaded
            setTimeout(processPostCards, 500);
        }
    });

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

    // Process initial posts with multiple attempts for slow loading
    setTimeout(processPostCards, 2000);
    setTimeout(processPostCards, 5000);
    setTimeout(processPostCards, 10000);
    setTimeout(processPostCards, 15000); // Additional attempt for very slow loading

    // Also process when scrolling stops (for infinite scroll) - more aggressive
    let scrollTimeout;
    window.addEventListener('scroll', () => {
        clearTimeout(scrollTimeout);
        scrollTimeout = setTimeout(() => {
            console.log('Scroll stopped, processing posts...');
            processPostCards();
        }, 500); // Reduced delay for faster response
    });

    // Also process periodically to catch any missed posts
    setInterval(() => {
        const unprocessedPosts = document.querySelectorAll('div[id]:not(.reactions-processed)').length;
        if (unprocessedPosts > 0) {
            console.log('Periodic check: found', unprocessedPosts, 'unprocessed posts');
            processPostCards();
        }
    }, 3000); // Check every 3 seconds

    console.log('Civitai Posts Reactions script v0.10 loaded');
})();