Onche Topic Preview

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
        }
    });

})();