Teachable 課程章節收藏

在 Teachable 課程側邊欄為每個章節新增收藏按鈕,並提供篩選功能

// ==UserScript==
// @name         Teachable 課程章節收藏
// @namespace    https://gist.github.com/Aya-X
// @version      1.0.0
// @description  在 Teachable 課程側邊欄為每個章節新增收藏按鈕,並提供篩選功能
// @author       Aya
// @match        *://*.teachable.com/courses/*
// @match        *://*.hexschool.com/courses/*
// @grant        GM_addStyle
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgOTYgOTYwIDk2MCIgd2lkdGg9IjQ4Ij48cGF0aCBkPSJNMjAwIDg5Ni4wNDNWMjI0LjY1Mmg1NjB2Njc1LjM5MWwtMjgwLTEyMC4wNDMtMjgwIDEyMC4wNDNabTg0Ljg3LTYxLjY1MyAxOTQuNDM1LTgzLjM0OCAxOTUuMTMtODIuMzVWMzEwLjg3SDI4NC44N1Y4MzQuMzlabTAtNTE5LjUyMmg0NzkuNTY1SDI4NC44N1Y4MzQuMzlWMzE0LjM5WiIvPjwvc3ZnPg==
// @license      MIT
// ==/UserScript==

/* eslint-env browser, greasemonkey */

(function () {
    'use strict';

    // === 事件發送器 ===
    /**
     * @class EventEmitter
     * @description 事件發送器實作
     */
    class EventEmitter {
        constructor() {
            this.events = {};
        }

        on(event, callback) {
            if (!this.events[event]) this.events[event] = [];
            this.events[event].push(callback);
        }

        emit(event, ...args) {
            if (!this.events[event]) return;
            this.events[event].forEach(callback => callback(...args));
        }

        off(event, callback) {
            if (!this.events[event]) return;
            this.events[event] = this.events[event].filter(cb => cb !== callback);
        }
    }

    // === 設定用類別 ===
    /**
     * @class Config
     * @description 儲存腳本所需的設定
     */
    class Config {
        static LESSON_ITEM_SELECTOR = 'li.section-item';
        static LESSON_ID_ATTRIBUTE = 'data-lecture-id';
        static STORAGE_KEY = 'teachable_favorite_lessons';
        static BUTTON_CONTAINER_SELECTOR = '.lecture-left';
        static COURSE_SECTION_SELECTOR = '.course-section';
        static COURSE_SIDEBAR_SELECTOR = '.course-sidebar';
        static FILTER_CLASS_NAME = 'favorite-filter-active';

        static ICONS = {
            OUTLINED: `<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#555555"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2zm0 15l-5-2.18L7 18V5h10v13z"/></svg>`,
            FILLED: `<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#FB8C00"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/></svg>`,
            FILTER: `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>`
        };

        static STYLES = `
            .teachable-course ${Config.LESSON_ITEM_SELECTOR} a.item {
                position: relative;
                display: flex;
                align-items: center;
                width: 100%;
            }
            .favorite-btn-container {
                position: absolute;
                right: 8px;
                top: 50%;
                transform: translateY(-50%);
                z-index: 10;
            }
            .favorite-btn {
                background: none;
                border: none;
                cursor: pointer;
                padding: 4px;
                border-radius: 50%;
                display: inline-flex;
                justify-content: center;
                align-items: center;
                transition: background-color 0.2s;
            }
            .favorite-btn:hover {
                background-color: rgba(0, 0, 0, 0.08);
            }
            .teachable-course ${Config.LESSON_ITEM_SELECTOR} .title-container {
                padding-right: 35px;
            }
            .favorite-filter-icon {
                display: inline-block;
                vertical-align: middle;
                line-height: 1;
                cursor: pointer;
                padding: 0 4px;
            }
            .favorite-filter-icon svg {
                display: block;
                fill: #ffffff;
                transition: fill 0.2s;
            }
            .favorite-filter-icon:hover svg {
                fill: #555555;
            }
            .favorite-filter-icon.active svg {
                fill: #FB8C00;
            }
            .${Config.FILTER_CLASS_NAME} ${Config.LESSON_ITEM_SELECTOR}:not(.is-favorite),
            .${Config.FILTER_CLASS_NAME} ${Config.COURSE_SECTION_SELECTOR}:not(.has-favorite) {
                display: none;
            }
        `;
    }

    // === 工具 ===
    /**
     * @class Utils
     * @description 提供通用的工具函式
     */
    class Utils {
        /**
         * 建立一個 Debounce 函式,該函式會延遲執行 func 函式,直到使用者停止觸發事件後的一段時間
         * @param {Function} func - 要進行 Debounce 的函式
         * @param {number} wait - 延遲的毫秒數
         * @returns {Function} 回傳新的 Debounce 化的函式
         */
        static debounce(func, wait) {
            let timeout;
            return function executedFunction(...args) {
                const later = () => {
                    clearTimeout(timeout);
                    func(...args);
                };
                clearTimeout(timeout);
                timeout = setTimeout(later, wait);
            };
        }
    }

    // === 資料管理類別 ===
    /**
     * @class FavoriteStorage
     * @description 處理收藏章節資料的載入、儲存和管理,使用 localStorage
     */
    class FavoriteStorage extends EventEmitter {
        /**
         * @param {string} [storageKey=Config.STORAGE_KEY] - 用於 localStorage 的鍵名
         */
        constructor(storageKey = Config.STORAGE_KEY) {
            super();
            this.storageKey = storageKey;
            this.favorites = new Set();
            this.load();
        }

        /**
         * 從 localStorage 載入收藏的章節 ID
         * @private
         */
        load() {
            try {
                const stored = localStorage.getItem(this.storageKey);
                if (stored) {
                    const parsedData = JSON.parse(stored);
                    this.favorites = new Set(Array.isArray(parsedData) ? parsedData : []);
                }
            } catch (error) {
                console.warn('載入收藏資料失敗:', error);
                this.favorites = new Set();
            }
        }

        /**
         * 將目前的收藏章節 ID 儲存到 localStorage
         * @private
         */
        save() {
            try {
                localStorage.setItem(this.storageKey, JSON.stringify(Array.from(this.favorites)));
            } catch (error) {
                console.error('儲存收藏資料失敗:', error);
            }
        }

        /**
         * 新增一個收藏章節
         * @param {string} lessonId - 章節 ID
         */
        add(lessonId) {
            this.favorites.add(lessonId);
            this.save();
            this.emit('change', { lessonId, action: 'add' });
        }

        /**
         * 移除一個收藏章節。
         * @param {string} lessonId - 章節 ID
         */
        remove(lessonId) {
            this.favorites.delete(lessonId);
            this.save();
            this.emit('change', { lessonId, action: 'remove' });
        }

        /**
         * 檢查某個章節是否已被收藏
         * @param {string} lessonId - 章節 ID
         * @returns {boolean} 如果已收藏則回傳 true,否則 false
         */
        has(lessonId) {
            return this.favorites.has(lessonId);
        }

        /**
         * 切換一個章節的收藏狀態
         * @param {string} lessonId - 章節 ID
         * @returns {boolean} 如果切換後為收藏狀態,回傳 true,否則 false
         */
        toggle(lessonId) {
            if (this.has(lessonId)) {
                this.remove(lessonId);
                return false;
            } else {
                this.add(lessonId);
                return true;
            }
        }

        /**
         * 取得所有收藏章節的 ID 列表
         * @returns {string[]} 收藏章節 ID 的陣列
         */
        getAll() {
            return Array.from(this.favorites);
        }

        /**
         * 取得收藏章節的總數
         * @returns {number} 收藏章節的數量
         */
        getCount() {
            return this.favorites.size;
        }

        /**
         * 清除所有收藏
         */
        clear() {
            this.favorites.clear();
            this.save();
            this.emit('clear');
        }
    }

    // === UI 類別 ===
    /**
     * @class FavoriteButton
     * @description 代表一個章節旁的收藏按鈕 UI 元件
     */
    class FavoriteButton extends EventEmitter {
        /**
         * @param {string} lessonId - 此按鈕對應的章節 ID
         * @param {FavoriteStorage} storage - 用於儲存狀態的 storage
         */
        constructor(lessonId, storage) {
            super();
            this.lessonId = lessonId;
            this.storage = storage;
            this.element = this.createElement();
            this.button = this.element.querySelector('.favorite-btn');
            this.updateState();
        }

        /**
         * 建立按鈕的 DOM 結構
         * @returns {HTMLDivElement} 包含按鈕的容器元素
         * @private
         */
        createElement() {
            const container = document.createElement('div');
            container.className = 'favorite-btn-container';
            const button = document.createElement('button');
            button.className = 'favorite-btn';
            button.addEventListener('click', this.handleClick.bind(this));
            container.appendChild(button);
            return container;
        }

        /**
         * 處理按鈕點擊事件
         * @param {MouseEvent} event - 點擊事件物件
         * @private
         */
        handleClick(event) {
            event.preventDefault();
            event.stopPropagation();
            const newState = this.storage.toggle(this.lessonId);
            this.updateState();
            this.emit('stateChanged', {
                lessonId: this.lessonId,
                isFavorited: newState
            });
        }

        /**
         * 根據收藏狀態更新按鈕的圖示、標題和 aria 屬性
         */
        updateState() {
            const isFavorited = this.storage.has(this.lessonId);
            this.button.innerHTML = isFavorited ? Config.ICONS.FILLED : Config.ICONS.OUTLINED;
            this.button.setAttribute('title', isFavorited ? '取消收藏' : '收藏此章節');
            this.button.setAttribute('aria-pressed', isFavorited.toString());
        }

        /**
         * 將按鈕附加到指定的元素
         * @param {HTMLElement} parentElement - 要附加到的元素
         */
        appendTo(parentElement) {
            parentElement.appendChild(this.element);
        }

        /**
         * 從 DOM 中移除此按鈕
         */
        remove() {
            if (this.element && this.element.parentNode) {
                this.element.parentNode.removeChild(this.element);
            }
        }

        /**
         * 檢查此按鈕對應的章節是否已收藏
         * @returns {boolean} 是否已收藏
         */
        isFavorited() {
            return this.storage.has(this.lessonId);
        }

        /**
         * 檢查按鈕元素是否仍在 DOM 中
         * @returns {boolean} 是否已連接到 DOM
         */
        isConnected() {
            return this.element && this.element.isConnected;
        }
    }

    // === 主要管理類別 ===
    /**
     * @class TeachableFavoriteManager
     * @description 腳本的主要控制器,管理收藏按鈕、篩選器和 DOM 監聽
     */
    class TeachableFavoriteManager extends EventEmitter {
        constructor() {
            super();
            this.storage = new FavoriteStorage();
            this.observer = null;
            this.lessonButtons = new Map();
            this.isFilterActive = false;
            this.filterButton = null;

            // 快取相關
            this._lessonItemsCache = null;
            this._cacheTimeout = null;
            this._observerTarget = null;

            this.debouncedProcess = Utils.debounce(() => {
                this.processExistingLessons();
                this.createFilterButton();
                if (this.isFilterActive) {
                    this.applyFavoriteFilter();
                }
            }, 300);

            // 監聽 storage 事件
            this.storage.on('change', () => {
                if (this.isFilterActive) {
                    this.applyFavoriteFilter();
                }
            });

            this.init();
        }

        /**
         * 初始化腳本,注入樣式、設定監聽器並處理現有章節
         * @private
         */
        init() {
            document.body.classList.add('teachable-course');
            this.injectStyles();
            this.createFilterButton();
            this.setupObserver();
            this.processExistingLessons();
        }

        /**
         * 注入腳本所需的 CSS 樣式
         * @private
         */
        injectStyles() {
            GM_addStyle(Config.STYLES);
        }

        /**
         * 取得章節項目,支援快取
         * @param {boolean} forceRefresh - 是否強制重新查詢
         * @returns {NodeList}
         */
        getLessonItems(forceRefresh = false) {
            if (!forceRefresh && this._lessonItemsCache) {
                return this._lessonItemsCache;
            }

            this._lessonItemsCache = document.querySelectorAll(Config.LESSON_ITEM_SELECTOR);

            // 5秒後清除快取
            clearTimeout(this._cacheTimeout);
            this._cacheTimeout = setTimeout(() => {
                this._lessonItemsCache = null;
            }, 5000);

            return this._lessonItemsCache;
        }

        /**
         * 設定 MutationObserver 來監聽課程列表的 DOM 變動,以動態新增或移除按鈕
         * @private
         */
        setupObserver() {
            // 優先找到更具體的容器
            this._observerTarget = document.querySelector(Config.COURSE_SIDEBAR_SELECTOR) ||
                document.querySelector('.course-content') ||
                document.body;

            const config = {
                childList: true,
                subtree: true,
                attributeFilter: ['data-lecture-id'] // 只監聽特定屬性的變化
            };

            this.observer = new MutationObserver((mutations) => {
                // 過濾相關的變動
                const relevantMutation = mutations.some(mutation => {
                    // 檢查是否有相關的節點變動
                    if (mutation.type === 'childList') {
                        // 檢查新增或移除的節點是否包含課程項目
                        return [...mutation.addedNodes, ...mutation.removedNodes].some(node => {
                            if (node.nodeType !== Node.ELEMENT_NODE) return false;
                            return node.matches && (
                                node.matches(Config.LESSON_ITEM_SELECTOR) ||
                                (node.querySelector && node.querySelector(Config.LESSON_ITEM_SELECTOR))
                            );
                        });
                    }
                    return false;
                });

                if (!relevantMutation) return;

                // 清除快取,因為 DOM 已改變
                this._lessonItemsCache = null;

                if (!document.querySelector(Config.BUTTON_CONTAINER_SELECTOR)) return;
                this.debouncedProcess();
            });

            this.observer.observe(this._observerTarget, config);
        }

        /**
         * 處理頁面上所有已存在的章節,為它們新增收藏按鈕
         */
        processExistingLessons() {
            const lessonItems = this.getLessonItems();
            this.cleanupDisconnectedButtons();

            lessonItems.forEach(item => {
                const lessonId = item.getAttribute(Config.LESSON_ID_ATTRIBUTE);
                if (!lessonId) return;

                // 如果按鈕已存在且仍在 DOM 中,則跳過
                if (this.lessonButtons.has(lessonId) && this.lessonButtons.get(lessonId).button.isConnected()) {
                    return;
                }

                const linkElement = item.querySelector('a.item');
                if (linkElement && linkElement.isConnected) {
                    this.addFavoriteButton(item, linkElement, lessonId);
                }
            });

            // 更新收藏狀態的 CSS 類別
            this.updateFavoriteClasses();
        }

        /**
         * 更新所有項目的收藏狀態 CSS 類別
         * @private
         */
        updateFavoriteClasses() {
            this.lessonButtons.forEach(({ item }, lessonId) => {
                item.classList.toggle('is-favorite', this.storage.has(lessonId));
            });

            // 更新區塊的收藏狀態
            const sections = document.querySelectorAll(Config.COURSE_SECTION_SELECTOR);
            sections.forEach(section => {
                const hasFavorite = section.querySelector('.is-favorite');
                section.classList.toggle('has-favorite', !!hasFavorite);
            });
        }

        /**
         * 為單一章節新增收藏按鈕。
         * @param {HTMLLIElement} item - 章節的 li 元素
         * @param {HTMLAnchorElement} linkElement - 章節的 a 連結元素
         * @param {string} lessonId - 章節 ID
         * @private
         */
        addFavoriteButton(item, linkElement, lessonId) {
            const favoriteButton = new FavoriteButton(lessonId, this.storage);

            // 監聽按鈕狀態改變事件
            favoriteButton.on('stateChanged', ({ lessonId, isFavorited }) => {
                item.classList.toggle('is-favorite', isFavorited);

                // 更新所屬區塊的狀態
                const section = item.closest(Config.COURSE_SECTION_SELECTOR);
                if (section) {
                    const hasFavorite = section.querySelector('.is-favorite');
                    section.classList.toggle('has-favorite', !!hasFavorite);
                }

                // 發送事件
                this.emit('favoriteChanged', { lessonId, isFavorited });
            });

            favoriteButton.appendTo(linkElement);
            const titleContainer = item.querySelector('.title-container');
            if (titleContainer) {
                titleContainer.style.paddingRight = '35px';
            }
            this.lessonButtons.set(lessonId, { item, button: favoriteButton });
        }

        /**
         * 清理已從 DOM 中移除的章節所對應的按鈕
         * @private
         */
        cleanupDisconnectedButtons() {
            for (const [lessonId, { item, button }] of this.lessonButtons.entries()) {
                if (!item.isConnected || !button.isConnected()) {
                    button.remove();
                    this.lessonButtons.delete(lessonId);
                }
            }
        }

        /**
         * 在課程側邊欄頂部建立「篩選收藏」按鈕
         */
        createFilterButton() {
            const container = document.querySelector(Config.BUTTON_CONTAINER_SELECTOR);
            if (container && !document.getElementById('favorite-filter-btn')) {
                const a = document.createElement('a');
                a.id = 'favorite-filter-btn';
                a.className = 'nav-icon-settings favorite-filter-icon';
                a.href = '#';
                a.setAttribute('role', 'button');
                a.title = '顯示已收藏章節';
                a.setAttribute('aria-label', '顯示已收藏章節');
                a.innerHTML = Config.ICONS.FILTER;

                a.addEventListener('click', (event) => {
                    event.preventDefault();
                    this.toggleFavoriteFilter();
                });

                const settingsDropdown = container.querySelector('.settings-dropdown');
                if (settingsDropdown) {
                    container.insertBefore(a, settingsDropdown);
                } else {
                    container.appendChild(a);
                }
                this.filterButton = a;
            }
        }

        /**
         * 切換收藏篩選器的啟用狀態
         */
        toggleFavoriteFilter() {
            this.isFilterActive = !this.isFilterActive;
            if (this.isFilterActive) {
                this.applyFavoriteFilter();
                this.filterButton.title = '顯示所有章節';
                this.filterButton.setAttribute('aria-label', '顯示所有章節');
                this.filterButton.classList.add('active');
            } else {
                this.clearFavoriteFilter();
                this.filterButton.title = '顯示已收藏章節';
                this.filterButton.setAttribute('aria-label', '顯示已收藏章節');
                this.filterButton.classList.remove('active');
            }
            this.emit('filterToggled', { isActive: this.isFilterActive });
        }

        /**
         * 套用收藏篩選,使用 CSS 類別控制顯示
         */
        applyFavoriteFilter() {
            // 先確保所有收藏狀態的類別都是最新的
            this.updateFavoriteClasses();
            // 只需要新增一個類別到 body,CSS 會處理其餘的顯示邏輯
            document.body.classList.add(Config.FILTER_CLASS_NAME);
        }

        /**
         * 清除收藏篩選
         */
        clearFavoriteFilter() {
            document.body.classList.remove(Config.FILTER_CLASS_NAME);
        }

        /**
         * 更新指定章節 ID 的按鈕狀態
         * @param {string} lessonId - 要更新按鈕的章節 ID
         */
        updateButtonState(lessonId) {
            const buttonInfo = this.lessonButtons.get(lessonId);
            if (buttonInfo && buttonInfo.button.isConnected()) {
                buttonInfo.button.updateState();
            }
        }

        /**
         * 更新所有收藏按鈕的狀態
         */
        updateAllButtons() {
            this.lessonButtons.forEach(({ button }) => {
                if (button.isConnected()) button.updateState();
            });
            this.updateFavoriteClasses();
        }

        /**
         * 移除所有監聽器、按鈕和樣式
         */
        destroy() {
            if (this.observer) {
                this.observer.disconnect();
                this.observer = null;
            }
            this.lessonButtons.forEach(({ button }) => button.remove());
            this.lessonButtons.clear();
            if (this.filterButton) {
                this.filterButton.remove();
                this.filterButton = null;
            }
            clearTimeout(this._cacheTimeout);
            this._lessonItemsCache = null;
        }

        /**
         * 重新執行一次初始化
         */
        refresh() {
            this.destroy();
            this.init();
        }

        // --- Public API methods ---
        getFavorites() {
            return this.storage.getAll();
        }
        addFavorite(lessonId) {
            this.storage.add(lessonId);
            this.updateButtonState(lessonId);
            this.updateFavoriteClasses();
        }
        removeFavorite(lessonId) {
            this.storage.remove(lessonId);
            this.updateButtonState(lessonId);
            this.updateFavoriteClasses();
        }
        isFavorite(lessonId) {
            return this.storage.has(lessonId);
        }
        getFavoriteCount() {
            return this.storage.getCount();
        }
        clearAllFavorites() {
            this.storage.clear();
            this.updateAllButtons();
        }
    }

    // === 腳本啟動 ===
    let favoriteManager = null;

    /**
     * 初始化應用程式,建立 TeachableFavoriteManager
     */
    function initializeApp() {
        try {
            if (!window.TeachableFavorites || !window.TeachableFavorites.getManager()) {
                favoriteManager = new TeachableFavoriteManager();
                console.log('Teachable 收藏功能已啟動 (優化版)');
            }
        } catch (error) {
            console.error('Teachable 收藏功能啟動失敗:', error);
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initializeApp);
    } else {
        initializeApp();
    }

    /**
     * @namespace TeachableFavorites
     * @description 將收藏功能 API 掛到 window 物件,以便從瀏覽器主控台進行操作
     */
    window.TeachableFavorites = {
        getFavorites: () => favoriteManager?.getFavorites() || [],
        addFavorite: (lessonId) => favoriteManager?.addFavorite(lessonId),
        removeFavorite: (lessonId) => favoriteManager?.removeFavorite(lessonId),
        isFavorite: (lessonId) => favoriteManager?.isFavorite(lessonId) || false,
        getCount: () => favoriteManager?.getFavoriteCount() || 0,
        clearAll: () => favoriteManager?.clearAllFavorites(),
        refresh: () => favoriteManager?.refresh(),
        getManager: () => favoriteManager,
        on: (event, callback) => favoriteManager?.on(event, callback),
        off: (event, callback) => favoriteManager?.off(event, callback)
    };
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址