Onche Topic Preview

Prévisualise le premier message d'un topic au survol sur Onche.org

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Onche Topic Preview
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Prévisualise le premier message d'un topic au survol sur Onche.org
// @author       Bkz1
// @match        https://onche.org/*
// @license MIT
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    // Cache pour stocker les éléments de messages pré-chargés (URL normalisée -> Element cloné)
    const previewCache = new Map();

    // Variables de contrôle du survol
    let hoverTimeout = null;
    let activeLink = null;
    let mouseX = 0;
    let mouseY = 0;

    // Création et injection du style CSS personnalisé
    const style = document.createElement('style');
    style.textContent = `
        #onche-preview-tooltip {
            position: fixed;
            z-index: 999999;
            max-width: 600px;
            max-height: 400px;
            background: rgba(30, 39, 58, 0.96);
            border: 1px solid rgba(255, 255, 255, 0.12);
            border-radius: 12px;
            box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.05);
            backdrop-filter: blur(16px);
            -webkit-backdrop-filter: blur(16px);
            overflow-y: auto;
            padding: 16px;
            pointer-events: none;
            opacity: 0;
            visibility: hidden;
            transform: translateY(12px) scale(0.98);
            transition: opacity 0.2s cubic-bezier(0.25, 0.8, 0.25, 1),
                        transform 0.2s cubic-bezier(0.25, 0.8, 0.25, 1),
                        visibility 0.2s;
            font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            box-sizing: border-box;
        }

        #onche-preview-tooltip.visible {
            opacity: 1;
            visibility: visible;
            transform: translateY(0) scale(1);
        }

        /* Réinitialisation et harmonisation de la boîte de message native */
        #onche-preview-tooltip .message {
            border: none !important;
            background: transparent !important;
            padding: 0 !important;
            margin: 0 !important;
            box-shadow: none !important;
            display: flex !important;
            flex-direction: column !important;
        }

        #onche-preview-tooltip .message-top {
            border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
            padding-bottom: 10px !important;
            margin-bottom: 10px !important;
            display: flex !important;
            align-items: center !important;
            gap: 12px !important;
        }

        #onche-preview-tooltip .message-infos {
            display: flex !important;
            flex-direction: column !important;
            gap: 2px !important;
        }

        #onche-preview-tooltip .message-content {
            font-size: 14.5px !important;
            line-height: 1.6 !important;
            color: #e2e8f0 !important;
            word-break: break-word !important;
        }

        #onche-preview-tooltip .avatar {
            width: 44px !important;
            height: 44px !important;
            border-radius: 50% !important;
            object-fit: cover !important;
        }

        #onche-preview-tooltip .message-username {
            font-size: 14.5px !important;
            font-weight: 600 !important;
            color: #3b82f6 !important;
            text-decoration: none !important;
        }

        #onche-preview-tooltip .message-bottom {
            margin-top: 12px !important;
            padding-top: 8px !important;
            border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
            display: flex !important;
            justify-content: flex-end !important;
        }

        #onche-preview-tooltip .message-date {
            font-size: 12px !important;
            color: #64748b !important;
        }

        #onche-preview-tooltip .message-content img,
        #onche-preview-tooltip .message-content video {
            max-width: 100% !important;
            height: auto !important;
            border-radius: 6px !important;
            margin-top: 6px !important;
        }

        #onche-preview-tooltip .message-badges img {
            width: 18px !important;
            height: 18px !important;
            max-width: 18px !important;
            max-height: 18px !important;
            margin: 0 2px !important;
            display: inline-block !important;
            vertical-align: middle !important;
        }

        /* Personnalisation de la barre de défilement de l'aperçu */
        #onche-preview-tooltip::-webkit-scrollbar {
            width: 6px;
            height: 6px;
        }
        #onche-preview-tooltip::-webkit-scrollbar-track {
            background: transparent;
        }
        #onche-preview-tooltip::-webkit-scrollbar-thumb {
            background: rgba(255, 255, 255, 0.15);
            border-radius: 4px;
        }
        #onche-preview-tooltip::-webkit-scrollbar-thumb:hover {
            background: rgba(255, 255, 255, 0.3);
        }

        /* Spinner de chargement */
        .onche-preview-loader {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            padding: 24px;
            color: #94a3b8;
            font-size: 13.5px;
            font-weight: 500;
        }

        .onche-preview-spinner {
            width: 28px;
            height: 28px;
            border: 3px solid rgba(255, 255, 255, 0.08);
            border-top-color: #3b82f6;
            border-radius: 50%;
            animation: onche-spin 0.8s linear infinite;
            margin-bottom: 12px;
        }

        @keyframes onche-spin {
            to { transform: rotate(360deg); }
        }

        /* Affichage des erreurs */
        .onche-preview-error {
            display: flex;
            align-items: center;
            gap: 10px;
            color: #ef4444;
            font-size: 14px;
            padding: 12px;
            border-radius: 8px;
            background: rgba(239, 68, 68, 0.1);
            border: 1px solid rgba(239, 68, 68, 0.2);
        }
    `;
    document.head.appendChild(style);

    // Création et injection du conteneur du tooltip de prévisualisation
    const tooltip = document.createElement('div');
    tooltip.id = 'onche-preview-tooltip';
    document.body.appendChild(tooltip);

    // Normalise l'URL d'un topic pour cibler la page 1 et sans ancre/paramètre
    function getPage1Url(href) {
        try {
            const url = new URL(href, window.location.origin);
            url.hash = '';
            url.search = '';

            const pathname = url.pathname;
            // Vérifie si c'est un lien vers un topic
            if (!pathname.startsWith('/topic/')) return null;

            const segments = pathname.split('/').filter(Boolean);

            // Format typique : ["topic", "1196595", "slug", "page"]
            if (segments.length >= 3) {
                const lastSegment = segments[segments.length - 1];
                if (/^\d+$/.test(lastSegment)) {
                    // Supprime le segment du numéro de page
                    segments.pop();
                }
                url.pathname = '/' + segments.join('/');
            }
            return url.href;
        } catch (e) {
            return null;
        }
    }

    // Convertit les URLs relatives d'un élément cloné en URLs absolues
    function resolveRelativeUrls(element, baseUrl) {
        const attributes = ['src', 'href'];
        attributes.forEach(attr => {
            element.querySelectorAll(`[${attr}]`).forEach(el => {
                const val = el.getAttribute(attr);
                if (val) {
                    try {
                        el.setAttribute(attr, new URL(val, baseUrl).href);
                    } catch (e) {}
                }
            });
        });
    }

    // Cache et affiche le tooltip
    function hideTooltip() {
        tooltip.classList.remove('visible');
    }

    function showTooltipLoading() {
        tooltip.innerHTML = `
            <div class="onche-preview-loader">
                <div class="onche-preview-spinner"></div>
                <span>Chargement de l'aperçu...</span>
            </div>
        `;
        tooltip.classList.add('visible');
    }

    function showTooltipContent(contentNode) {
        tooltip.innerHTML = '';
        tooltip.appendChild(contentNode.cloneNode(true));
        tooltip.classList.add('visible');
    }

    function showTooltipError(message) {
        tooltip.innerHTML = `
            <div class="onche-preview-error">
                <span style="font-size: 18px; line-height: 1;">⚠️</span>
                <span>${message}</span>
            </div>
        `;
        tooltip.classList.add('visible');
    }

    // Met à jour la position du tooltip en évitant qu'il sorte de l'écran
    function updateTooltipPosition(x, y) {
        const offset = 18;
        let targetX = x + offset;
        let targetY = y + offset;

        const tooltipWidth = tooltip.offsetWidth;
        const tooltipHeight = tooltip.offsetHeight;

        const screenWidth = window.innerWidth;
        const screenHeight = window.innerHeight;

        // Ajustement horizontal si débordement à droite
        if (targetX + tooltipWidth > screenWidth) {
            targetX = x - tooltipWidth - offset;
        }
        if (targetX < 10) {
            targetX = 10;
        }

        // Ajustement vertical si débordement en bas
        if (targetY + tooltipHeight > screenHeight) {
            targetY = y - tooltipHeight - offset;
        }
        if (targetY < 10) {
            targetY = 10;
        }

        tooltip.style.left = `${targetX}px`;
        tooltip.style.top = `${targetY}px`;
    }

    // Déclenche le chargement et l'affichage de l'aperçu d'un topic
    function showPreviewForLink(link) {
        const page1Url = getPage1Url(link.href);
        if (!page1Url) return;

        showTooltipLoading();
        updateTooltipPosition(mouseX, mouseY);

        // Si l'aperçu est déjà en cache
        if (previewCache.has(page1Url)) {
            showTooltipContent(previewCache.get(page1Url));
            return;
        }

        // Chargement asynchrone de la première page du topic
        fetch(page1Url)
            .then(response => {
                if (!response.ok) {
                    throw new Error(`Erreur réseau (${response.status})`);
                }
                return response.text();
            })
            .then(html => {
                // S'assurer que le curseur est toujours sur ce lien
                if (activeLink !== link) return;

                const parser = new DOMParser();
                const doc = parser.parseFromString(html, 'text/html');
                const firstMessage = doc.querySelector('.message[data-is-first]') || doc.querySelector('.message');

                if (!firstMessage) {
                    throw new Error("Impossible de lire le contenu de ce sujet.");
                }

                // Cloner et nettoyer l'élément du premier message
                const previewEl = firstMessage.cloneNode(true);

                // Résoudre les liens et médias relatifs
                resolveRelativeUrls(previewEl, page1Url);

                // Retirer les éléments indésirables (signature, publicités, boutons)
                const signature = previewEl.querySelector('.signature');
                if (signature) signature.remove();

                const actions = previewEl.querySelector('.message-actions, .right');
                if (actions) actions.remove();

                const ads = previewEl.querySelector('.topic-ad');
                if (ads) ads.remove();

                // Stocker dans le cache local
                previewCache.set(page1Url, previewEl);

                showTooltipContent(previewEl);
            })
            .catch(err => {
                if (activeLink !== link) return;
                showTooltipError(err.message);
            });
    }

    // Gestion du cycle de survol avec temporisation
    function clearHover() {
        if (hoverTimeout) {
            clearTimeout(hoverTimeout);
            hoverTimeout = null;
        }
        activeLink = null;
        hideTooltip();
    }

    // Écouteur global pour suivre la souris
    document.addEventListener('mousemove', (e) => {
        mouseX = e.clientX;
        mouseY = e.clientY;
        if (tooltip.classList.contains('visible')) {
            updateTooltipPosition(mouseX, mouseY);
        }
    });

    // Délégation d'événements pour le survol des titres de topics
    document.addEventListener('mouseover', (e) => {
        const link = e.target.closest('a.topic-subject.link');
        if (!link) return;

        if (activeLink !== link) {
            clearHover();
            activeLink = link;
            hoverTimeout = setTimeout(() => {
                showPreviewForLink(link);
            }, 350); // Délai d'attente de 350ms
        }
    });

    document.addEventListener('mouseout', (e) => {
        if (!activeLink) return;

        // Vérifie si le pointeur quitte vraiment l'élément survolé
        const related = e.relatedTarget;
        if (!related || !activeLink.contains(related)) {
            clearHover();
        }
    });

})();