jpnkn to Reddit Style (Optimized)

Transforms jpnkn threads into a Reddit-like nested view, lazy loads image previews, handles broken images, and embeds YouTube videos.

// ==UserScript==
// @name         jpnkn to Reddit Style (Optimized)
// @name:en      jpnkn to Reddit Style (Optimized)
// @name:ja      jpnkn を Reddit 風に (最適化版)
// @name:ko      jpnkn  Reddit 스타일로 (최적화됨)
// @name:zh-CN   jpnkn 论坛转 Reddit 风格 (优化版)
// @name:zh-TW   jpnkn 論壇轉 Reddit 風格 (優化版)
// @license      MIT
// @namespace    http://tampermonkey.net/
// @version      0.9.1
// @description  Transforms jpnkn threads into a Reddit-like nested view, lazy loads image previews, handles broken images, and embeds YouTube videos.
// @description:en Transforms jpnkn threads into a Reddit-like nested view, lazy loads image previews, handles broken images, and embeds YouTube videos.
// @description:ja jpnknのスレッドをRedditのようなネスト表示に変換し、画像プレビューを遅延読み込みし、壊れた画像を処理し、YouTube動画を埋め込みます。
// @description:ko jpnkn 스레드를 Reddit과 유사한 중첩 보기로 변환하고, 이미지 미리보기를 지연 로드하며, 깨진 이미지를 처리하고, YouTube 비디오를 삽입합니다.
// @description:zh-CN 将 jpnkn 论坛帖子转换为类似 Reddit 的嵌套楼中楼视图,支持图片预览懒加载、处理失效图片链接以及嵌入 YouTube 视频。
// @description:zh-TW 將 jpnkn 論壇帖子轉換為類似 Reddit 的巢狀樓中樓檢視,支援圖片預覽延遲載入、處理失效圖片連結以及嵌入 YouTube 影片。
// @author       NBXX
// @match        https://bbs.jpnkn.com/test/read.cgi/*/*/*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const MAX_PREVIEW_HEIGHT = '400px';
    const INDENTATION_SIZE = 20;
    const SHOW_PREVIEWS_BY_DEFAULT = true; // For images (videos will have a button)
    const LAZY_LOAD_OFFSET = '200px'; // Load images when they are 200px away from viewport

    GM_addStyle(`
        .reddit-style-container { padding: 10px; }
        .reddit-post {
            border: 1px solid #ccc;
            border-radius: 4px;
            margin-bottom: 8px;
            background-color: #fff;
            box-shadow: 0 1px 2px rgba(0,0,0,0.05);
        }
        .reddit-post > .original-post-content {
            padding: 8px;
        }
        .reddit-post > .original-post-content dt { font-size: 0.9em; color: #555; }
        .reddit-post > .original-post-content dd { margin-left: 1.5em; font-size: 1em; line-height: 1.4; }
        .replies-wrapper {
            margin-left: ${INDENTATION_SIZE}px;
            padding-left: 10px;
            border-left: 2px solid #e0e0e0;
            margin-top: 5px;
        }
        .media-preview-container { margin-top: 8px; }
        .media-preview-container img {
            max-width: 100%;
            max-height: ${MAX_PREVIEW_HEIGHT};
            display: block;
            border: 1px solid #ddd;
            border-radius: 3px;
            background-color: #f9f9f9;
            cursor: zoom-in;
            min-height: 50px; /* Placeholder height before loading */
        }
        .media-preview-container img.expanded {
            max-height: none;
            cursor: zoom-out;
        }
        .media-toggle-btn, .video-toggle-btn {
            font-size: 0.8em;
            color: #007bff;
            cursor: pointer;
            margin-left: 5px;
            text-decoration: underline;
            display: inline-block; /* Keep it on the same line */
        }
        .toggle-replies-btn {
            cursor: pointer;
            color: #777;
            font-size: 0.8em;
            margin-left: 10px;
        }
        .original-link.broken-link {
            text-decoration: line-through;
            color: #d9534f; /* Bootstrap's danger color */
        }
        .youtube-embed-container {
            margin-top: 8px;
            position: relative;
            padding-bottom: 56.25%; /* 16:9 aspect ratio */
            height: 0;
            overflow: hidden;
            max-width: 100%;
            background: #000;
        }
        .youtube-embed-container iframe {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            border: 0;
        }
    `);

    let imageObserver;

    function initializeImageObserver() {
        if (imageObserver) {
            imageObserver.disconnect();
        }
        imageObserver = new IntersectionObserver((entries, observer) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const img = entry.target;
                    const src = img.dataset.src;
                    if (src) {
                        img.src = src;
                        img.removeAttribute('data-src'); // No need to load again
                    }
                    observer.unobserve(img);
                }
            });
        }, { rootMargin: LAZY_LOAD_OFFSET });
    }

    function isImageLink(url) {
        if (!url) return false;
        try {
            const path = new URL(url).pathname.toLowerCase();
            return /\.(jpeg|jpg|gif|png|webp)$/.test(path);
        } catch (e) { return false; }
    }

    function getYouTubeVideoId(url) {
        if (!url) return null;
        try {
            const parsedUrl = new URL(url);
            let videoId = null;
            if (parsedUrl.hostname === 'youtu.be') {
                videoId = parsedUrl.pathname.slice(1);
            } else if (parsedUrl.hostname.includes('youtube.com') && parsedUrl.pathname === '/watch') {
                videoId = parsedUrl.searchParams.get('v');
            } else if (parsedUrl.hostname.includes('youtube.com') && parsedUrl.pathname.startsWith('/embed/')) {
                videoId = parsedUrl.pathname.split('/embed/')[1].split('?')[0];
            }
            // Basic check for valid ID format
            if (videoId && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
                return videoId;
            }
            return null;
        } catch (e) {
            return null;
        }
    }

    function processPostContent(ddElement) {
        const links = ddElement.querySelectorAll('a');
        links.forEach(link => {
            const href = link.href;

            // 1. Image Previews (Lazy Loaded)
            if (isImageLink(href)) {
                link.classList.add('original-link');
                const container = document.createElement('div');
                container.className = 'media-preview-container';
                container.style.display = SHOW_PREVIEWS_BY_DEFAULT ? 'block' : 'none';

                const img = document.createElement('img');
                img.dataset.src = href; // Store real src in data attribute for lazy loading
                img.alt = 'Image Preview';
                // img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // 1x1 transparent gif

                img.addEventListener('click', () => img.classList.toggle('expanded'));
                img.onerror = () => {
                    container.style.display = 'none'; // Hide preview container
                    link.classList.add('broken-link');
                    link.title = '画像読み込み失敗';
                    if (toggleBtn) toggleBtn.style.display = 'none'; // Hide toggle if it exists
                };

                const toggleBtn = document.createElement('span');
                toggleBtn.className = 'media-toggle-btn';
                toggleBtn.textContent = SHOW_PREVIEWS_BY_DEFAULT ? '[画像を隠す]' : '[画像を表示]';
                toggleBtn.addEventListener('click', (e) => {
                    e.preventDefault();
                    const isVisible = container.style.display !== 'none';
                    container.style.display = isVisible ? 'none' : 'block';
                    toggleBtn.textContent = isVisible ? '[画像を表示]' : '[画像を隠す]';
                });

                container.appendChild(img);

                // Insert elements: toggle after link, container after that or parent
                link.insertAdjacentElement('afterend', toggleBtn);
                let insertTarget = link.nextSibling; // The toggle button
                if(insertTarget) {
                    insertTarget.insertAdjacentElement('afterend', container);
                } else {
                    link.parentElement.appendChild(container);
                }


                if (SHOW_PREVIEWS_BY_DEFAULT) { // Only observe if initially visible
                    imageObserver.observe(img);
                } else {
                    // If not shown by default, only observe when user clicks "show"
                    toggleBtn.addEventListener('click', () => {
                        if (container.style.display !== 'none' && img.dataset.src) {
                            imageObserver.observe(img);
                        }
                    }, { once: true }); // Only need to set this up once
                }

            } // End Image Preview
            // 2. YouTube Embeds
            else {
                const videoId = getYouTubeVideoId(href);
                if (videoId) {
                    link.classList.add('original-link');
                    const videoContainer = document.createElement('div');
                    // videoContainer.className = 'youtube-embed-container'; // Apply this when iframe is added
                    videoContainer.style.display = 'none'; // Initially hidden

                    const toggleVideoBtn = document.createElement('span');
                    toggleVideoBtn.className = 'video-toggle-btn';
                    toggleVideoBtn.textContent = '[動画を再生]';

                    toggleVideoBtn.addEventListener('click', (e) => {
                        e.preventDefault();
                        if (videoContainer.style.display === 'none') {
                            // Create iframe on demand
                            if (!videoContainer.querySelector('iframe')) {
                                videoContainer.innerHTML = ''; // Clear previous content if any
                                videoContainer.className = 'youtube-embed-container'; // Set class for aspect ratio
                                const iframe = document.createElement('iframe');
                                iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`; // Added autoplay
                                iframe.setAttribute('frameborder', '0');
                                iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
                                iframe.setAttribute('allowfullscreen', '');
                                videoContainer.appendChild(iframe);
                            }
                            videoContainer.style.display = 'block';
                            toggleVideoBtn.textContent = '[動画を隠す]';
                        } else {
                            videoContainer.style.display = 'none';
                            toggleVideoBtn.textContent = '[動画を再生]';
                            // Optional: videoContainer.innerHTML = ''; // To stop video and free resources
                        }
                    });

                    link.insertAdjacentElement('afterend', toggleVideoBtn);
                    let insertTarget = link.nextSibling; // The toggle button
                    if(insertTarget) {
                        insertTarget.insertAdjacentElement('afterend', videoContainer);
                    } else {
                        link.parentElement.appendChild(videoContainer);
                    }
                } // End YouTube
            }
        });
    }


    function transformThread() {
        const threadElement = document.getElementById('thread');
        if (!threadElement || threadElement.dataset.transformed === 'true') {
            // console.log("5ch Reddit Style: Thread element not found or already transformed.");
            return;
        }

        const originalPosts = Array.from(threadElement.querySelectorAll('div.res'));
        if (originalPosts.length === 0) {
            // console.log("5ch Reddit Style: No posts found to transform.");
            return;
        }
        console.log(`5ch Reddit Style: Found ${originalPosts.length} posts to process.`);

        initializeImageObserver(); // Initialize or re-initialize observer

        const postsData = new Map();

        originalPosts.forEach(postEl => {
            const resIndex = postEl.dataset.resIndex;
            if (!resIndex) return;
            const dtElement = postEl.querySelector('dt.info');
            const ddElement = postEl.querySelector('dd');
            if (!dtElement || !ddElement) return;
            postsData.set(resIndex, {
                id: resIndex,
                dtElement: dtElement.cloneNode(true),
                ddElement: ddElement.cloneNode(true),
                children: [],
                replyToIds: []
            });
        });

        postsData.forEach(post => {
            const replyLinks = post.ddElement.querySelectorAll('a');
            replyLinks.forEach(link => {
                const href = link.getAttribute('href');
                if (href) {
                    const parts = href.split('/');
                    const targetPart = parts[parts.length - 1];
                    if (targetPart) {
                        const match = targetPart.match(/^(\d+)/);
                        if (match && match[1]) {
                            const targetId = match[1];
                            if (postsData.has(targetId) && targetId !== post.id) {
                                post.replyToIds.push(targetId);
                            }
                        }
                    }
                }
            });
            if (post.replyToIds.length > 0) {
                const parentId = post.replyToIds[0];
                if (postsData.has(parentId)) {
                   postsData.get(parentId).children.push(post);
                }
            }
        });

        const newThreadContainer = document.createElement('div');
        newThreadContainer.className = 'reddit-style-container';

        function renderPostRecursive(post, parentDomElement) {
            const postWrapper = document.createElement('div');
            postWrapper.className = 'reddit-post';
            postWrapper.dataset.postId = post.id;

            const originalContentDiv = document.createElement('div');
            originalContentDiv.className = 'original-post-content';
            originalContentDiv.appendChild(post.dtElement);
            originalContentDiv.appendChild(post.ddElement);
            postWrapper.appendChild(originalContentDiv);

            processPostContent(post.ddElement); // Process for images/videos on the cloned dd

            parentDomElement.appendChild(postWrapper);

            if (post.children.length > 0) {
                const repliesWrapper = document.createElement('div');
                repliesWrapper.className = 'replies-wrapper';

                const toggleRepliesBtn = document.createElement('span');
                toggleRepliesBtn.className = 'toggle-replies-btn';
                toggleRepliesBtn.textContent = `[-] (${post.children.length} replies)`;
                let repliesVisible = true;

                toggleRepliesBtn.addEventListener('click', () => {
                    repliesVisible = !repliesVisible;
                    repliesWrapper.style.display = repliesVisible ? 'block' : 'none';
                    toggleRepliesBtn.textContent = repliesVisible ? `[-] (${post.children.length} replies)` : `[+] (${post.children.length} replies)`;
                });
                post.dtElement.appendChild(toggleRepliesBtn);

                postWrapper.appendChild(repliesWrapper);
                post.children.sort((a, b) => parseInt(a.id) - parseInt(b.id));
                post.children.forEach(childPost => {
                    renderPostRecursive(childPost, repliesWrapper);
                });
            }
        }

        const rootPosts = [];
        postsData.forEach(p => {
            let isChild = false;
            if (p.replyToIds.length > 0) {
                const parentId = p.replyToIds[0];
                if (postsData.has(parentId) && postsData.get(parentId).children.includes(p)) {
                    isChild = true;
                }
            }
            if (!isChild) {
                 rootPosts.push(p);
            }
        });

        rootPosts.sort((a, b) => parseInt(a.id) - parseInt(b.id));
        rootPosts.forEach(post => renderPostRecursive(post, newThreadContainer));

        threadElement.innerHTML = '';
        threadElement.appendChild(newThreadContainer);
        threadElement.dataset.transformed = 'true'; // Mark as transformed
        console.log("5ch Reddit Style: Transformation complete.");
    }

    const observerTarget = document.getElementById('thread');
    if (observerTarget) {
        let transformTimeout = null;
        const mainObserver = new MutationObserver((mutationsList, obs) => {
             if (observerTarget.dataset.transformed === 'true') {
                // If content is already transformed, we might want to handle new posts differently
                // For now, we'll just prevent re-transforming the whole thing.
                // A more advanced version would append new posts into the existing tree.
                // For example, if MQTT adds new .res elements, they should be processed and appended.
                // This basic script doesn't handle that incremental update gracefully yet.
                return;
            }

            for (const mutation of mutationsList) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    let hasResAdded = false;
                    for(const node of mutation.addedNodes){
                        if(node.nodeType === Node.ELEMENT_NODE && node.classList && node.classList.contains('res')){
                            hasResAdded = true;
                            break;
                        }
                    }
                    if(hasResAdded){
                        clearTimeout(transformTimeout);
                        transformTimeout = setTimeout(() => {
                            console.log("5ch Reddit Style: Detected content change, attempting transformation.");
                            transformThread();
                        }, 500); // Shortened debounce
                        return;
                    }
                }
            }
        });

        mainObserver.observe(observerTarget, { childList: true, subtree: false });

        // Fallback:
        setTimeout(() => {
            if (observerTarget.dataset.transformed !== 'true') {
                console.log("5ch Reddit Style: Fallback timeout, attempting transformation.");
                transformThread();
            }
        }, 1500);

    } else {
        console.error("5ch Reddit Style: #thread element not found for MutationObserver.");
    }

})();