PTT Term Theme

自訂 PTT 終端機(term.ptt.cc)的配色與背景圖

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         PTT Term Theme
// @namespace    GitHub:Mystic0428
// @version      1.2.0
// @description  自訂 PTT 終端機(term.ptt.cc)的配色與背景圖
// @author       GitHub:Mystic0428
// @match        https://term.ptt.cc/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=ptt.cc
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ==================== 預設值 ====================
    const DEFAULTS = {
        bodyBg:   '#46525e',
        colorQ7:  '#ffffff',
        colorQ3:  '#fdff00',
        imageSrc: 'https://i.imgur.com/s1tMBrr.jpg',
    };

    const STYLE_ID = 'ptm-style';
    const STORAGE_KEY = 'ptt-term-theme:v1';
    const PANEL_STYLE_ID = 'ptm-panel-style';

    // ==================== 模組狀態 ====================
    let state;
    let panelEl = null;
    let panelOpen = false;
    const fields = {};  // { bodyBg, colorQ7, colorQ3, imageSrc } → input 元素

    // ==================== 儲存 ====================
    function load() {
        const raw = localStorage.getItem(STORAGE_KEY);
        if (!raw) return { ...DEFAULTS };
        try {
            const parsed = JSON.parse(raw);
            const clean = { ...DEFAULTS };
            for (const k of Object.keys(DEFAULTS)) {
                if (typeof parsed[k] === typeof DEFAULTS[k]) {
                    clean[k] = parsed[k];
                }
            }
            return clean;
        } catch (e) {
            try {
                localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...DEFAULTS }));
            } catch (_) { /* recovery write best-effort */ }
            return { ...DEFAULTS };
        }
    }

    function save(state) {
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
        } catch (e) {
            alert('儲存失敗,可能是瀏覽器儲存空間已滿');
        }
    }

    // ==================== 樣式套用 ====================
    function buildCss(state) {
        const img = state.imageSrc.replace(/["\\]/g, '\\$&');
        return `
            .q7 { color: ${state.colorQ7}; }
            .q3 { color: ${state.colorQ3}; }
            #easyReadingLastRow,
            #easyReadingReplyRow,
            .main {
                background: url("${img}") center / 100% no-repeat;
            }
            body { background-color: ${state.bodyBg}; }
        `;
    }

    function apply(state) {
        let style = document.getElementById(STYLE_ID);
        if (!style) {
            style = document.createElement('style');
            style.id = STYLE_ID;
            document.head.appendChild(style);
        }
        style.textContent = buildCss(state);
    }

    // ==================== 面板 UI ====================
    function injectPanelStyles() {
        if (document.getElementById(PANEL_STYLE_ID)) return;
        const s = document.createElement('style');
        s.id = PANEL_STYLE_ID;
        s.textContent = `
            .ptm-gear {
                position: fixed;
                top: 12px;
                right: 12px;
                width: 28px;
                height: 28px;
                border-radius: 50%;
                border: none;
                background: #333;
                color: #fff;
                font-size: 16px;
                line-height: 28px;
                padding: 0;
                cursor: pointer;
                opacity: 0.3;
                transition: opacity 0.2s;
                z-index: 999999;
            }
            .ptm-gear:hover { opacity: 1; }

            .ptm-panel {
                position: fixed;
                top: 48px;
                right: 12px;
                width: 260px;
                background: #2a2a2a;
                color: #fff;
                border-radius: 6px;
                box-shadow: 0 4px 12px rgba(0,0,0,0.3);
                padding: 14px 16px;
                z-index: 999999;
                font-family: sans-serif;
                font-size: 13px;
                box-sizing: border-box;
            }

            .ptm-field {
                margin-bottom: 10px;
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 8px;
            }
            .ptm-field-stacked {
                flex-direction: column;
                align-items: stretch;
            }
            .ptm-field label { flex: 1; }
            .ptm-field input[type="color"] {
                width: 48px;
                height: 26px;
                border: none;
                background: none;
                padding: 0;
                cursor: pointer;
            }
            .ptm-field input[type="text"] {
                width: 100%;
                margin-top: 4px;
                padding: 4px 6px;
                box-sizing: border-box;
                background: #1a1a1a;
                color: #fff;
                border: 1px solid #555;
                border-radius: 3px;
                font-family: monospace;
                font-size: 12px;
            }
            .ptm-upload-btn {
                margin-top: 6px;
                padding: 4px 8px;
                background: #555;
                color: #fff;
                border: none;
                border-radius: 3px;
                cursor: pointer;
                font-size: 12px;
            }
            .ptm-upload-btn:hover { background: #666; }
            .ptm-reset-btn {
                margin-top: 10px;
                padding: 5px 10px;
                background: transparent;
                color: #ff6b6b;
                border: 1px solid #ff6b6b;
                border-radius: 3px;
                cursor: pointer;
                float: right;
                font-size: 12px;
            }
            .ptm-reset-btn:hover { background: rgba(255,107,107,0.15); }
        `;
        document.head.appendChild(s);
    }

    function buildGear() {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'ptm-gear';
        btn.setAttribute('aria-label', '自訂外觀');
        btn.textContent = '⚙';
        btn.addEventListener('click', (e) => {
            e.stopPropagation();
            togglePanel();
        });
        return btn;
    }

    function buildColorField(labelText, key) {
        const row = document.createElement('div');
        row.className = 'ptm-field';

        const label = document.createElement('label');
        label.textContent = labelText;

        const input = document.createElement('input');
        input.type = 'color';
        input.value = state[key];
        input.addEventListener('input', () => {
            state[key] = input.value;
            save(state);
            apply(state);
        });

        fields[key] = input;
        row.appendChild(label);
        row.appendChild(input);
        return row;
    }

    function buildUrlField() {
        const row = document.createElement('div');
        row.className = 'ptm-field ptm-field-stacked';

        const label = document.createElement('label');
        label.textContent = '背景圖 URL';

        const input = document.createElement('input');
        input.type = 'text';
        input.value = state.imageSrc;
        input.addEventListener('input', () => {
            state.imageSrc = input.value;
            save(state);
            apply(state);
        });
        fields.imageSrc = input;

        const uploadBtn = document.createElement('button');
        uploadBtn.type = 'button';
        uploadBtn.className = 'ptm-upload-btn';
        uploadBtn.textContent = '本地上傳';

        const fileInput = document.createElement('input');
        fileInput.type = 'file';
        fileInput.accept = 'image/*';
        fileInput.style.display = 'none';

        uploadBtn.addEventListener('click', () => fileInput.click());

        fileInput.addEventListener('change', () => {
            const file = fileInput.files[0];
            if (!file) return;
            const MB = 1024 * 1024;
            if (file.size > 4 * MB) {
                alert('圖片過大(> 4MB),請壓縮後再試');
                fileInput.value = '';
                return;
            }
            if (file.size > 2 * MB) {
                alert('圖片較大,存檔可能受限');
            }
            const reader = new FileReader();
            reader.onload = () => {
                const dataUri = reader.result;
                input.value = dataUri;
                state.imageSrc = dataUri;
                save(state);
                apply(state);
            };
            reader.onerror = () => {
                alert('讀取檔案失敗,請重試');
            };
            reader.readAsDataURL(file);
            fileInput.value = '';
        });

        row.appendChild(label);
        row.appendChild(input);
        row.appendChild(uploadBtn);
        row.appendChild(fileInput);
        return row;
    }

    function buildResetButton() {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'ptm-reset-btn';
        btn.textContent = '恢復預設';
        btn.addEventListener('click', () => {
            if (!confirm('確定要恢復預設樣式?')) return;
            localStorage.removeItem(STORAGE_KEY);
            state = { ...DEFAULTS };
            Object.entries(DEFAULTS).forEach(([k, v]) => {
                if (fields[k]) fields[k].value = v;
            });
            apply(state);
        });
        return btn;
    }

    function buildPanel() {
        const panel = document.createElement('div');
        panel.className = 'ptm-panel';
        panel.style.display = 'none';
        panel.addEventListener('click', (e) => e.stopPropagation());

        panel.appendChild(buildColorField('主要底色', 'bodyBg'));
        panel.appendChild(buildColorField('白字顏色(.q7)', 'colorQ7'));
        panel.appendChild(buildColorField('黃字顏色(.q3)', 'colorQ3'));
        panel.appendChild(buildUrlField());
        panel.appendChild(buildResetButton());

        return panel;
    }

    function togglePanel() {
        panelOpen = !panelOpen;
        panelEl.style.display = panelOpen ? 'block' : 'none';
    }

    function setupPanel() {
        injectPanelStyles();
        const gear = buildGear();
        panelEl = buildPanel();
        document.body.appendChild(gear);
        document.body.appendChild(panelEl);
        document.addEventListener('click', () => {
            if (panelOpen) togglePanel();
        });
    }

    // ==================== 初始化 ====================
    state = load();
    apply(state);
    setupPanel();
})();