Add Reactions to Civitai Posts

Add reaction buttons to posts on civitai.com

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

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