Add Reactions to Civitai Posts

Add reaction buttons to posts on civitai.com

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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