Add Reactions to Civitai Posts

Add reaction buttons to posts on civitai.com

// ==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');
})();