PTT Term Theme

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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