PTT Term Theme

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

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

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