// ==UserScript== // @name Discourse Sidebar Feed Panel // @namespace https://linux.do/ // @version 2.1.2 // @description 将 Discourse 原生侧边栏改造为信息流面板,支持分类筛选、已读/未读过滤、拖拽调整宽度 // @author YsLtr // @match https://linux.do/* // @match https://www.nodeloc.com/* // @match https://forum.chrultrabook.com/* // @match https://community.openai.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=linux.do // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant unsafeWindow // @run-at document-idle // @license MIT // ==/UserScript== (function () { "use strict"; if (window.top !== window.self) return; // ========== 持久化键 ========== // 所有 GM_* 键都带 sfp_ 前缀,避免和其他 userscript 或站点本身的 // localStorage/GM 存储冲突。这里的键名一旦发布就尽量不要重命名: // 老版本用户升级时会直接读取这些值来恢复宽度、标签、筛选和刷新偏好。 const STATE_KEY = "sfp_feed_mode_enabled"; const ORDER_KEY = "sfp_current_order"; const PERIOD_KEY = "sfp_current_period"; const WIDTH_KEY = "sfp_sidebar_width"; const TAB_KEY = "sfp_current_tab"; const TAB_ORDER_KEY = "sfp_tab_order"; const FILTER_KEY = "sfp_current_filter"; const HIDE_PINNED_KEY = "sfp_activity_hide_pinned"; const SHOW_INCOMING_HINT_KEY = "sfp_show_incoming_hint"; const AUTO_SILENT_REFRESH_KEY = "sfp_auto_silent_refresh"; const AUTO_SILENT_REFRESH_INTERVAL_KEY = "sfp_auto_silent_refresh_interval"; const AUTO_REFRESH_ENABLED_KEY = "sfp_auto_refresh_enabled"; const AUTO_REFRESH_INTERVAL_KEY = "sfp_auto_refresh_interval"; const CATEGORY_DATA_CACHE_KEY = "sfp_category_data_cache_v1"; const TAG_STYLE_CACHE_KEY = "sfp_tag_style_cache_v1"; const SITE_STORAGE_PREFIX = "sfp_site"; const LEGACY_LINUXDO_ORIGIN = "https://linux.do"; const STORAGE_MISSING = "__SFP_STORAGE_MISSING__"; const SITE_SCOPED_STORAGE_KEYS = [ STATE_KEY, ORDER_KEY, PERIOD_KEY, WIDTH_KEY, TAB_KEY, TAB_ORDER_KEY, FILTER_KEY, HIDE_PINNED_KEY, SHOW_INCOMING_HINT_KEY, AUTO_SILENT_REFRESH_KEY, AUTO_SILENT_REFRESH_INTERVAL_KEY, AUTO_REFRESH_ENABLED_KEY, AUTO_REFRESH_INTERVAL_KEY, CATEGORY_DATA_CACHE_KEY, TAG_STYLE_CACHE_KEY, ]; function _siteStorageKey(key) { return `${SITE_STORAGE_PREFIX}:${encodeURIComponent(location.origin)}:${key}`; } function _getSiteValue(key, fallback) { return GM_getValue(_siteStorageKey(key), fallback); } function _setSiteValue(key, value) { GM_setValue(_siteStorageKey(key), value); } function _deleteSiteValue(key) { if (typeof GM_deleteValue === "function") GM_deleteValue(_siteStorageKey(key)); } function _deleteLegacyValue(key) { if (typeof GM_deleteValue === "function") GM_deleteValue(key); } function _migrateLegacyLinuxDoStorage() { if (location.origin !== LEGACY_LINUXDO_ORIGIN) return; SITE_SCOPED_STORAGE_KEYS.forEach((key) => { const legacyValue = GM_getValue(key, STORAGE_MISSING); if (legacyValue === STORAGE_MISSING) return; const scopedKey = _siteStorageKey(key); const scopedValue = GM_getValue(scopedKey, STORAGE_MISSING); if (scopedValue === STORAGE_MISSING) { GM_setValue(scopedKey, legacyValue); } _deleteLegacyValue(key); }); } _migrateLegacyLinuxDoStorage(); // ========== 常量 ========== // DEFAULT_WIDTH 同时也是当前最小宽度。之前的需求要求“允许压缩的最小宽度 // 改为 header-sidebar-toggle 的宽度”,但侧边栏内容在 272px 以下会破坏 // 话题卡片和设置浮层,因此这里保留信息流面板自己的最低可用宽度。 const DEFAULT_WIDTH = 272; const MIN_WIDTH = DEFAULT_WIDTH; const MAX_WIDTH = 500; // 两套刷新机制的默认值刻意不同: // - 最新活动依赖 Discourse message-bus 的增量候选,默认 0 表示用户手动点提醒; // - 其他排序没有可靠增量事件,默认 10 秒重新拉取当前列表。 const DEFAULT_AUTO_SILENT_REFRESH_INTERVAL = 0; const DEFAULT_AUTO_REFRESH_INTERVAL = 10; const AUTO_REFRESH_IDLE_LIMIT_MS = 10 * 60 * 1000; // 自动补页只在“筛选后当前页不够显示”时触发。窗口限速和空结果计数一起 // 防止未读/已读筛选在站点数据不足时连续请求后续页。 const AUTO_LOAD_RATE_WINDOW_MS = 5000; const AUTO_LOAD_MAX_REQUESTS_PER_WINDOW = 3; const AUTO_LOAD_MAX_EMPTY_FILTER_RESULTS = 3; const SETTINGS_BUTTON_SIZE = 28; const CATEGORY_DATA_CACHE_VERSION = 1; const TAG_STYLE_CACHE_VERSION = 1; // ========== 全局状态 ========== // currentOrder 历史上曾使用 default,后续需求把“默认”和“最新活动”合并。 // 这里在启动时迁移旧值,避免旧用户升级后落到不存在的排序分支。 let feedModeEnabled = _getSiteValue(STATE_KEY, false); let currentOrder = _getSiteValue(ORDER_KEY, "activity"); if (currentOrder === "default") { currentOrder = "activity"; _setSiteValue(ORDER_KEY, currentOrder); } let currentPeriod = _getSiteValue(PERIOD_KEY, "all"); let sfpSidebarWidth = _getSiteValue(WIDTH_KEY, DEFAULT_WIDTH); let currentTab = _getSiteValue(TAB_KEY, "all"); let currentFilter = _getSiteValue(FILTER_KEY, "all"); let hidePinned = _getSiteValue(HIDE_PINNED_KEY, false); let showIncomingHint = _getSiteValue(SHOW_INCOMING_HINT_KEY, true); let autoSilentRefreshEnabled = _getSiteValue(AUTO_SILENT_REFRESH_KEY, false); let autoSilentRefreshInterval = Math.max(0, Number(_getSiteValue(AUTO_SILENT_REFRESH_INTERVAL_KEY, DEFAULT_AUTO_SILENT_REFRESH_INTERVAL)) || DEFAULT_AUTO_SILENT_REFRESH_INTERVAL); let autoRefreshEnabled = _getSiteValue(AUTO_REFRESH_ENABLED_KEY, false); let autoRefreshInterval = Math.max(1, Number(_getSiteValue(AUTO_REFRESH_INTERVAL_KEY, DEFAULT_AUTO_REFRESH_INTERVAL)) || DEFAULT_AUTO_REFRESH_INTERVAL); let currentCategoryId = null; let allTopics = []; let usersMap = {}; let loadedTopicIds = new Set(); let currentPage = 0; let hasMorePages = true; let topicPageSize = 30; let isLoading = false; let isLoadingMore = false; let isRefreshing = false; let _pendingReload = false; // 自动刷新相关计时器都只存运行态,不持久化剩余秒数。页面切换或脚本重载后 // 重新按用户配置开始倒计时,比恢复旧倒计时更容易避免重复刷新。 let autoSilentRefreshTimer = null; let autoSilentRefreshSeconds = 0; let autoRefreshTimer = null; let autoRefreshSeconds = 0; // 自动补页按“当前查询快照”隔离。切换板块、排序、已读筛选后必须清零, // 否则上一个视图的限速或空结果会错误影响新视图。 let autoLoadTimestamps = []; let autoLoadEmptyFilterCount = 0; let autoLoadStoppedForSession = false; let autoLoadSessionKey = ""; // message-bus 只告诉我们“可能有变化的话题 id”。完整话题数据仍要从 // /latest.json?topic_ids=... 拉取;本地 cache 只用于在显示提醒前做板块范围 // 粗筛,避免每条推送都立即请求详情。topicIds 保留完整累计候选; // resident limit 只限制每次实际加载的最新候选数量,不能截断提醒计数。 const sidebarIncomingState = { topicIds: [], topicIdSet: new Set(), topicCache: new Map(), filteredTopicIds: [], filteredLoadTopicIds: [], filterRefreshTimer: null, filterRefreshToken: 0, viewSettling: false, filterStable: false, applyQueued: false, }; let sidebarMessageBus = null; let sidebarLatestMessageBusCallback = null; let sidebarNewMessageBusCallback = null; let activeLoadToken = 0; let activeLoadMoreToken = 0; let activeRefreshToken = 0; let lastPageActivityAt = Date.now(); let categoryMetaPromise = null; let categoryMetaLoaded = false; let tagStylePromise = null; let tagStyleLoaded = false; let siteDataPromise = null; let siteDataLoaded = false; let siteDataCache = null; let categoriesAndLatestPromise = null; let categoriesAndLatestLoaded = false; let categoriesAndLatestCache = null; let pendingTabBarScrollTab = null; let toggleBtn = null; let feedContainer = null; let feedScrollEl = null; let feedListEl = null; let feedHeaderEl = null; let feedRefreshBtn = null; let refreshBusyCount = 0; let feedScrollAbortController = null; let lastHeadActionAwayState = null; let resizerEl = null; let isResizing = false; let originalSidebarWidthBeforeFeed = null; let widthAnimationTimer = null; const topicHighlightTimers = new WeakMap(); // ========== 工具函数 ========== let cachedCsrfToken = null; function getCsrfToken() { if (cachedCsrfToken === null) { cachedCsrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } return cachedCsrfToken; } function clampNumber(value, min, max, fallback) { const numeric = Number(value); if (!Number.isFinite(numeric)) return fallback; return Math.min(max, Math.max(min, Math.round(numeric))); } function isAtScrollBoundary(el, deltaY) { // 当前调用方会提前过滤 deltaY === 0;这里保留 guard 是为了未来调用方。 if (!el || !deltaY) return false; const { scrollTop, scrollHeight, clientHeight } = el; const canScroll = scrollHeight > clientHeight; const atTop = scrollTop <= 0; const atBottom = Math.ceil(scrollTop + clientHeight) >= scrollHeight; const scrollingUp = deltaY < 0; const scrollingDown = deltaY > 0; return !canScroll || (scrollingUp && atTop) || (scrollingDown && atBottom); } function toAbsoluteSiteUrl(path) { if (!path) return ""; return new URL(path, location.origin).href; } function navigateTo(path) { const script = document.createElement("script"); script.textContent = `window.require("discourse/lib/url").default.routeTo("${path}");`; document.documentElement.appendChild(script); script.remove(); } function getDiscourse() { try { return (typeof unsafeWindow !== "undefined" && unsafeWindow.Discourse) || (typeof window !== "undefined" && window.Discourse) || (typeof Discourse !== "undefined" && Discourse) || null; } catch (e) { return null; } } function getMessageBus() { try { return getDiscourse()?.__container__?.lookup("service:message-bus") || null; } catch (e) { return null; } } function debounce(fn, delay) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } function escapeHtml(text) { if (!text) return ""; const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } function escapeAttr(text) { return escapeHtml(text).replace(/"/g, """).replace(/'/g, "'"); } const I18N = { "zh-CN": { all: "全部", orderActivity: "最新活动", orderCreated: "最新发布", orderViews: "最多浏览", orderPosts: "最多回复", orderLikes: "最多点赞", orderOpLikes: "楼主点赞", periodAll: "全部", periodDaily: "每日", periodWeekly: "每周", periodMonthly: "每月", periodQuarterly: "每季", periodYearly: "每年", filterAll: "全部", filterUnseen: "未读", filterRead: "已读", hidePinned: "隐藏置顶", toggleTitle: "切换侧边栏信息流", refresh: "刷新", settings: "设置", incomingHintLabel: "新活动数量", incomingHintTip: "在刷新按钮中显示当前板块范围内的新活动候选数量。离开首屏时点击数量会先回到顶部,再应用这些候选。", autoSilentLabel: "自动静默刷新", autoSilentTip: "在最新活动视图且停留首屏时按间隔自动应用新话题;离开首屏后暂停,只累计候选。", hidePinnedTip: "只在最新活动视图加载首页时,排除列表顶部已读的置顶话题。新活动队列和加载更多不会应用这个设置。", silentIntervalLabel: "静默刷新间隔", silentIntervalTip: "单位为秒,最小为 0。设为 0 时,有新活动会立即静默应用;大于 0 时按倒计时批量应用。", autoRefreshLabel: "自动刷新", autoRefreshTip: "用于非最新活动的排序视图,停留首屏时按间隔重新拉取当前列表。不要设置太快,频繁请求可能触发站点速率限制。", autoRefreshIntervalLabel: "自动刷新间隔", autoRefreshIntervalTip: "单位为秒,最小为 1。到达间隔后刷新当前筛选和排序下的列表。", secondsSuffix: "s", helpSuffix: "说明", tabMore: "展开板块 / 排序", tabPanelTitle: "拖动板块调整顺序", close: "关闭", loading: "加载中...", emptyTopics: "暂无话题", loadFailed: "加载失败", retry: "重试", noMatchingTopics: "无匹配话题", noUnreadInCategory: "该板块暂无未读话题", noUnread: "暂无未读话题", noRead: "暂无已读话题", currentPagePrefix: "当前页", nextPageNoMatch: "下一页无符合条件的话题", applyIncoming: "应用 {count} 个新的或更新的话题", backToTop: "回到顶部", hot: "热门", pinned: "已置顶", closedTitle: "此话题已被关闭;不再接受新回复", loadMore: "加载更多", noMore: "— 已经到底了 —", requestFailed: "请求失败", }, en: { all: "All", orderActivity: "Latest activity", orderCreated: "Newest", orderViews: "Most viewed", orderPosts: "Most replies", orderLikes: "Most liked", orderOpLikes: "OP likes", periodAll: "All", periodDaily: "Daily", periodWeekly: "Weekly", periodMonthly: "Monthly", periodQuarterly: "Quarterly", periodYearly: "Yearly", filterAll: "All", filterUnseen: "Unread", filterRead: "Read", hidePinned: "Hide pinned", toggleTitle: "Toggle sidebar feed", refresh: "Refresh", settings: "Settings", incomingHintLabel: "New activity count", incomingHintTip: "Show incoming candidates for the current category scope in the refresh button. Away from the head, clicking the count returns to the top before applying them.", autoSilentLabel: "Auto silent refresh", autoSilentTip: "In the latest activity view, automatically apply incoming topics while the feed is at the head. Away from the head, pause applying and keep accumulating candidates.", hidePinnedTip: "Only in the latest activity view, exclude read pinned topics at the top when loading the first page. Incoming topics and load more are not affected.", silentIntervalLabel: "Silent interval", silentIntervalTip: "Seconds. Minimum 0. With 0, incoming activity is applied immediately; otherwise candidates are batched by countdown.", autoRefreshLabel: "Auto refresh", autoRefreshTip: "For non-latest sorting views, re-fetch the current list while the feed is at the head. Avoid very short intervals to reduce rate-limit risk.", autoRefreshIntervalLabel: "Refresh interval", autoRefreshIntervalTip: "Seconds. Minimum 1. Refreshes the current filter and sorting view when the interval elapses.", secondsSuffix: "s", helpSuffix: " help", tabMore: "Expand categories / order", tabPanelTitle: "Drag categories to reorder", close: "Close", loading: "Loading...", emptyTopics: "No topics", loadFailed: "Load failed", retry: "Retry", noMatchingTopics: "No matching topics", noUnreadInCategory: "No unread topics in this category", noUnread: "No unread topics", noRead: "No read topics", currentPagePrefix: "Current page: ", nextPageNoMatch: "Next page has no matching topics", applyIncoming: "Apply {count} new or updated topics", backToTop: "Back to top", hot: "Hot", pinned: "Pinned", closedTitle: "This topic is closed; it no longer accepts replies", loadMore: "Load more", noMore: "No more topics", requestFailed: "Request failed", }, }; function getUiLocale() { const candidates = [ document.documentElement?.getAttribute("lang"), document.documentElement?.getAttribute("xml:lang"), getDiscourse()?.SiteSettings?.default_locale, navigator.language, ]; const language = candidates.find((value) => String(value || "").trim()) || "en"; return /^zh/i.test(language) ? "zh-CN" : "en"; } function t(key, params = {}) { const messages = I18N[getUiLocale()] || I18N.en; const template = messages[key] || I18N.en[key] || key; return String(template).replace(/\{(\w+)\}/g, (_, name) => { return params[name] === undefined ? `{${name}}` : String(params[name]); }); } function formatRelativeTime(dateStr) { const date = new Date(dateStr); if (Number.isNaN(date.getTime())) return ""; const now = new Date(); const diff = Math.max(0, now - date); const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (getUiLocale() !== "zh-CN") { const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); if (seconds < 60) return rtf.format(-Math.max(1, seconds), "second"); if (minutes < 60) return rtf.format(-minutes, "minute"); if (hours < 24) return rtf.format(-hours, "hour"); if (days < 30) return rtf.format(-days, "day"); const months = Math.floor(days / 30); if (months < 12) return rtf.format(-months, "month"); return rtf.format(-Math.floor(months / 12), "year"); } if (seconds < 60) return `${Math.max(1, seconds)}秒前`; if (minutes < 60) return `${minutes}分钟前`; if (hours < 24) return `${hours}小时前`; if (days < 30) return `${days}天前`; const months = Math.floor(days / 30); if (months < 12) return `${months}个月前`; return `${Math.floor(months / 12)}年前`; } function getAvatarUrl(template, size) { if (!template) return ""; let url = template.replace("{size}", String(size)); if (!url.startsWith("http")) url = toAbsoluteSiteUrl(url); return url; } function getUserProfileUrl(username) { if (!username) return ""; return `/u/${encodeURIComponent(username)}`; } function openPathInNewTab(path) { window.open(toAbsoluteSiteUrl(path), "_blank", "noopener,noreferrer"); } function handlePointerNavigation(e, path, { onPrimaryActivate = null, onMiddleActivate = null } = {}) { if (!path) return false; const isPrimaryClick = e.type === "click" && e.button === 0; const isMiddleClick = e.type === "auxclick" && e.button === 1; if (!isPrimaryClick && !isMiddleClick) return false; e.preventDefault(); e.stopPropagation(); if (isPrimaryClick && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) { onPrimaryActivate?.(); openPathInNewTab(path); return true; } if (isPrimaryClick) { onPrimaryActivate?.(); navigateTo(path); return true; } onMiddleActivate?.(); openPathInNewTab(path); return true; } function waitForEmber(callback, maxWait = 15000) { const start = Date.now(); function check() { try { if ( getDiscourse()?.__container__ ) { callback(); return; } } catch (e) { /* not ready */ } if (Date.now() - start < maxWait) { setTimeout(check, 500); } else { console.warn("[SFP] Timed out waiting for Ember"); } } check(); } function waitForStableHeaderMount(callback) { waitForEmber(() => { const run = () => { requestAnimationFrame(() => { requestAnimationFrame(() => { callback(); }); }); }; if (document.readyState === "complete") { run(); return; } window.addEventListener("load", run, { once: true }); }); } // ========== 站点数据 / 分类 ========== const ORDER_OPTION_DEFS = [ { labelKey: "orderActivity", value: "activity", capability: "latest" }, { labelKey: "orderCreated", value: "created", capability: "new" }, { labelKey: "orderViews", value: "views", capability: "top" }, { labelKey: "orderPosts", value: "posts", capability: "top" }, { labelKey: "orderLikes", value: "likes", capability: "top" }, { labelKey: "orderOpLikes", value: "op_likes", capability: "top" }, ]; const PERIOD_OPTION_DEFS = [ { labelKey: "periodAll", value: "all" }, { labelKey: "periodDaily", value: "daily" }, { labelKey: "periodWeekly", value: "weekly" }, { labelKey: "periodMonthly", value: "monthly" }, { labelKey: "periodQuarterly", value: "quarterly" }, { labelKey: "periodYearly", value: "yearly" }, ]; const FILTER_OPTION_DEFS = [ { labelKey: "filterAll", value: "all" }, { labelKey: "filterUnseen", value: "unseen" }, { labelKey: "filterRead", value: "read" }, ]; const categoryMetaById = new Map(); let tabCategories = []; let siteCapabilities = _createDefaultSiteCapabilities(); let siteControlsSignature = ""; const tagStyleByKey = new Map(); const SAFE_ICON_RE = /^[A-Za-z0-9_-]+$/; const SAFE_COLOR_RE = /^#?[A-Fa-f0-9]{3,8}$/; function _normalizeHexColor(color, fallback = "888") { if (!color) return fallback; const raw = String(color).trim(); if (!SAFE_COLOR_RE.test(raw)) return fallback; return raw.startsWith("#") ? raw.slice(1) : raw; } function _isSafeIconName(icon) { return typeof icon === "string" && SAFE_ICON_RE.test(icon); } function _safeIconName(icon) { return _isSafeIconName(icon) ? icon : ""; } function _safeCategoryStyleType(styleType, hasIcon) { return ["icon", "emoji", "square"].includes(styleType) ? styleType : (hasIcon ? "icon" : "square"); } function _svgIcon(icon, extraClass = "") { const safeIcon = _safeIconName(icon); if (!safeIcon) return ""; const className = `fa d-icon d-icon-${safeIcon} svg-icon fa-width-auto svg-string${extraClass ? ` ${extraClass}` : ""}`; return ``; } function _categoryColorMarkerHtml(color) { return ``; } function _createDefaultSiteCapabilities() { return { orderValues: new Set(ORDER_OPTION_DEFS.map((option) => option.value)), periodValues: new Set(PERIOD_OPTION_DEFS.map((option) => option.value)), filterValues: new Set(FILTER_OPTION_DEFS.map((option) => option.value)), rawFilters: new Set(), rawTopMenuItems: new Set(), }; } function _stringSet(values) { return new Set((Array.isArray(values) ? values : []) .map((value) => String(value || "").trim()) .filter(Boolean)); } function _updateSiteCapabilities(site) { const rawFilters = _stringSet(site?.filters); const rawTopMenuItems = _stringSet(site?.top_menu_items); _stringSet(site?.anonymous_top_menu_items).forEach((item) => rawTopMenuItems.add(item)); rawTopMenuItems.forEach((item) => rawFilters.add(item)); const supportsLatest = rawFilters.size === 0 || rawFilters.has("latest"); const supportsNew = rawFilters.has("new"); const supportsTop = rawFilters.has("top"); const orderValues = new Set(); if (supportsLatest) orderValues.add("activity"); if (supportsNew || supportsLatest) orderValues.add("created"); if (supportsTop) { ["views", "posts", "likes", "op_likes"].forEach((order) => orderValues.add(order)); } if (orderValues.size === 0) orderValues.add("activity"); const sitePeriods = _stringSet(site?.periods); const periodValues = sitePeriods.size > 0 ? new Set(PERIOD_OPTION_DEFS.map((option) => option.value).filter((value) => sitePeriods.has(value))) : new Set(PERIOD_OPTION_DEFS.map((option) => option.value)); if (periodValues.size === 0) periodValues.add("all"); const filterValues = new Set(["all"]); if (rawFilters.has("unseen") || rawFilters.has("unread") || rawFilters.has("new")) { filterValues.add("unseen"); } if (rawFilters.has("read")) filterValues.add("read"); siteCapabilities = { orderValues, periodValues, filterValues, rawFilters, rawTopMenuItems, }; } function _getOrderOptions() { return ORDER_OPTION_DEFS .filter((option) => siteCapabilities.orderValues.has(option.value)) .map((option) => ({ label: t(option.labelKey), value: option.value })); } function _getPeriodOptions() { return PERIOD_OPTION_DEFS .filter((option) => siteCapabilities.periodValues.has(option.value)) .map((option) => ({ label: t(option.labelKey), value: option.value })); } function _getFilterOptions() { return FILTER_OPTION_DEFS .filter((option) => siteCapabilities.filterValues.has(option.value)) .map((option) => ({ label: t(option.labelKey), value: option.value })); } function _categoryTabId(id) { return `cat-${Number(id)}`; } function _findTabCategoryByTabId(tabId) { return tabCategories.find((cat) => { return cat.tabId === tabId || cat.legacyTabIds?.includes(tabId); }) || null; } function _categoryIdFromPath(value) { if (!value || typeof value !== "string") return null; let path = value; try { path = new URL(value, location.origin).pathname; } catch (e) { path = value.split("?")[0].split("#")[0]; } const parts = path.split("/").filter(Boolean); if (parts[0] !== "c") return null; for (let index = parts.length - 1; index >= 1; index--) { const id = Number(parts[index]); if (Number.isInteger(id) && id > 0) return id; } return null; } function _categoryListCategories(data) { return Array.isArray(data?.category_list?.categories) ? data.category_list.categories : []; } function _mergeCategoryRecords(...categoryLists) { const rawById = new Map(); categoryLists.forEach((categoryList) => { if (!Array.isArray(categoryList)) return; categoryList.forEach((cat) => { const id = Number(cat?.id); if (!Number.isFinite(id)) return; rawById.set(id, { ...(rawById.get(id) || {}), ...cat, id, }); }); }); return Array.from(rawById.values()); } function _collectNavigationCategoryIds(site, rawById, categoryListCategories = []) { const ids = []; const seen = new Set(); const addId = (value) => { const id = Number(value); if (!Number.isInteger(id) || id <= 0 || !rawById.has(id) || seen.has(id)) return; seen.add(id); ids.push(id); }; const addRecord = (record) => { if (!record) return; if (typeof record === "number" || typeof record === "string") { addId(record); const pathId = _categoryIdFromPath(String(record)); if (pathId) addId(pathId); return; } if (typeof record !== "object") return; addId(record.category_id ?? record.categoryId ?? record.category?.id); const recordType = String(record.type || record.section_type || "").toLowerCase(); const isCategoryRecord = recordType === "category" || record.topic_count !== undefined || record.parent_category_id !== undefined || record.read_restricted !== undefined; if (isCategoryRecord && record.id !== undefined) { addId(record.id); } [record.value, record.url, record.href, record.path, record.link, record.route].forEach((path) => { const pathId = _categoryIdFromPath(path); if (pathId) addId(pathId); }); }; const addRecords = (records) => { if (!Array.isArray(records)) return; records.forEach(addRecord); }; [ site?.navigation_menu_categories, site?.navigation_menu_site_categories, site?.default_navigation_menu_categories, site?.anonymous_default_navigation_menu_categories, ].forEach(addRecords); [ site?.anonymous_sidebar_sections, site?.sidebar_sections, site?.navigation_menu_sections, ].forEach((sections) => { if (!Array.isArray(sections)) return; sections.forEach((section) => { addRecord(section); addRecords(section?.links); }); }); if (ids.length === 0) addRecords(categoryListCategories); return ids; } function _buildTabCategories(site, navigationCategories, rawById) { return _collectNavigationCategoryIds(site, rawById, navigationCategories) .map((id) => _getCategoryMeta(id)) .filter((meta) => meta?.name) .map((meta) => ({ id: meta.id, tabId: _categoryTabId(meta.id), legacyTabIds: [meta.slug].filter(Boolean), name: meta.name, icon: meta.icon, color: `#${meta.color}`, slug: meta.slug, })); } function _normalizeCurrentSiteState() { let changed = false; const orderOptions = _getOrderOptions(); const periodOptions = _getPeriodOptions(); const filterOptions = _getFilterOptions(); if (!siteCapabilities.orderValues.has(currentOrder)) { currentOrder = orderOptions[0]?.value || "activity"; _setSiteValue(ORDER_KEY, currentOrder); changed = true; } if (!siteCapabilities.periodValues.has(currentPeriod)) { currentPeriod = siteCapabilities.periodValues.has("all") ? "all" : (periodOptions[0]?.value || "all"); _setSiteValue(PERIOD_KEY, currentPeriod); changed = true; } if (!siteCapabilities.filterValues.has(currentFilter)) { currentFilter = filterOptions[0]?.value || "all"; _setSiteValue(FILTER_KEY, currentFilter); changed = true; } if (currentTab === "all") { currentCategoryId = null; } else { const tab = _findTabCategoryByTabId(currentTab); if (tab) { currentCategoryId = tab.id; if (currentTab !== tab.tabId) { currentTab = tab.tabId; _setSiteValue(TAB_KEY, currentTab); changed = true; } } else { currentTab = "all"; currentCategoryId = null; _setSiteValue(TAB_KEY, currentTab); changed = true; } } return changed; } function _siteControlsStateSignature() { return JSON.stringify({ locale: getUiLocale(), orders: _getOrderOptions().map((option) => option.value), periods: _getPeriodOptions().map((option) => option.value), filters: _getFilterOptions().map((option) => option.value), tabs: tabCategories.map((cat) => `${cat.id}:${cat.name}:${cat.icon}:${cat.color}`), currentTab, currentOrder, currentPeriod, currentFilter, }); } function _syncSiteDerivedControls({ force = false } = {}) { const nextSignature = _siteControlsStateSignature(); if (!force && nextSignature === siteControlsSignature) return; siteControlsSignature = nextSignature; if (feedHeaderEl) { feedHeaderEl.replaceChildren(); _buildHeaderControls(feedHeaderEl); } const tabShell = feedContainer?.querySelector(".sfp-tab-shell"); if (tabShell) _rerenderTabBar(tabShell); const filterBar = feedContainer?.querySelector(".sfp-filter-bar"); if (filterBar) filterBar.replaceWith(_buildFilterBar()); } function _normalizeCategoryMeta(raw = {}, parent = null) { const id = Number(raw.id); const icon = _safeIconName(raw.icon || ""); return { id, name: raw.name || "", color: _normalizeHexColor(raw.color, "888"), text_color: _normalizeHexColor(raw.text_color, "FFFFFF"), icon, style_type: _safeCategoryStyleType(raw.style_type, !!icon), slug: raw.slug || "", parent_category_id: raw.parent_category_id || null, parent_color: parent ? _normalizeHexColor(parent.color, "888") : null, parent_text_color: parent ? _normalizeHexColor(parent.text_color, "FFFFFF") : null, read_restricted: !!raw.read_restricted, description_text: raw.description_text || raw.description_excerpt || raw.description || "", description_excerpt: raw.description_excerpt || raw.description_text || raw.description || "", }; } function _getCategoryMeta(id) { const numericId = Number(id); if (!Number.isFinite(numericId)) return null; return categoryMetaById.get(numericId) || null; } function _cloneJsonArray(value) { if (!Array.isArray(value)) return []; try { return JSON.parse(JSON.stringify(value)); } catch (e) { return []; } } function _createCategoryDataCacheSource(site = {}, navigationCategories = []) { return { site: { categories: _cloneJsonArray(site?.categories), filters: _cloneJsonArray(site?.filters), periods: _cloneJsonArray(site?.periods), top_menu_items: _cloneJsonArray(site?.top_menu_items), anonymous_top_menu_items: _cloneJsonArray(site?.anonymous_top_menu_items), top_tags: _cloneJsonArray(site?.top_tags), can_tag_topics: site?.can_tag_topics, navigation_menu_categories: _cloneJsonArray(site?.navigation_menu_categories), navigation_menu_site_categories: _cloneJsonArray(site?.navigation_menu_site_categories), default_navigation_menu_categories: _cloneJsonArray(site?.default_navigation_menu_categories), anonymous_default_navigation_menu_categories: _cloneJsonArray(site?.anonymous_default_navigation_menu_categories), anonymous_sidebar_sections: _cloneJsonArray(site?.anonymous_sidebar_sections), sidebar_sections: _cloneJsonArray(site?.sidebar_sections), navigation_menu_sections: _cloneJsonArray(site?.navigation_menu_sections), }, navigationCategories: _cloneJsonArray(navigationCategories), }; } function _applyCategoryDataSource(source, { primeSiteData = false } = {}) { const site = source?.site || {}; const navigationCategories = Array.isArray(source?.navigationCategories) ? source.navigationCategories : []; const categories = _mergeCategoryRecords(site?.categories, navigationCategories); const rawById = new Map(categories.map((cat) => [Number(cat.id), cat])); _updateSiteCapabilities(site); categoryMetaById.clear(); categories.forEach((cat) => { const id = Number(cat.id); if (!Number.isFinite(id)) return; const parent = cat.parent_category_id ? rawById.get(Number(cat.parent_category_id)) : null; categoryMetaById.set(id, _normalizeCategoryMeta(cat, parent)); }); tabCategories = _buildTabCategories(site, navigationCategories, rawById); _normalizeCurrentSiteState(); if (primeSiteData && !siteDataLoaded) { siteDataCache = site; siteDataLoaded = true; } categoryMetaLoaded = true; } function _loadCategoryDataCache() { try { const cache = _getSiteValue(CATEGORY_DATA_CACHE_KEY, null); if (!cache || cache.version !== CATEGORY_DATA_CACHE_VERSION || !cache.source) return null; const source = cache.source; if (!source.site || !Array.isArray(source.site.categories) || !Array.isArray(source.navigationCategories)) { return null; } return source; } catch (e) { console.warn("[SFP] load category data cache failed:", e); return null; } } function _saveCategoryDataCache(source) { try { _setSiteValue(CATEGORY_DATA_CACHE_KEY, { version: CATEGORY_DATA_CACHE_VERSION, savedAt: Date.now(), source, }); } catch (e) { console.warn("[SFP] save category data cache failed:", e); } } function _resetRuntimeCategoryData() { categoryMetaPromise = null; categoryMetaLoaded = false; categoryMetaById.clear(); tabCategories = []; siteDataPromise = null; siteDataLoaded = false; siteDataCache = null; categoriesAndLatestPromise = null; categoriesAndLatestLoaded = false; categoriesAndLatestCache = null; } function _resetRuntimeTagStyleData() { tagStylePromise = null; tagStyleLoaded = false; tagStyleByKey.clear(); } function clearDataCaches({ reload = false } = {}) { _deleteSiteValue(CATEGORY_DATA_CACHE_KEY); _deleteSiteValue(TAG_STYLE_CACHE_KEY); _resetRuntimeCategoryData(); _resetRuntimeTagStyleData(); console.info("[SFP] category data and tag style caches cleared"); if (reload && feedModeEnabled) { _resetAutoLoadState(); loadTopics(); } } function getCategoryTabMeta(cat) { const meta = _getCategoryMeta(cat.id); return { ...cat, name: meta?.name || cat.name, icon: meta?.icon || cat.icon, color: meta?.color ? `#${meta.color}` : cat.color, }; } function _getSavedTabOrder() { const savedOrder = _getSiteValue(TAB_ORDER_KEY, []); if (!Array.isArray(savedOrder)) return []; return savedOrder.map((id) => Number(id)).filter((id) => Number.isFinite(id)); } function _getOrderedTabCategories() { const savedOrder = _getSavedTabOrder(); if (!savedOrder.length) return [...tabCategories]; return [...tabCategories].sort((a, b) => { const idxA = savedOrder.indexOf(a.id); const idxB = savedOrder.indexOf(b.id); if (idxA === -1 && idxB === -1) return 0; if (idxA === -1) return 1; if (idxB === -1) return -1; return idxA - idxB; }); } function _saveTabOrderFromGrid(grid) { const order = Array.from(grid.querySelectorAll(".sfp-tab-grid-item[data-category-id]")) .map((item) => Number(item.dataset.categoryId)) .filter((id) => Number.isFinite(id)); _setSiteValue(TAB_ORDER_KEY, order); } function _buildCategoryTabContent(cat) { const tabMeta = getCategoryTabMeta(cat); const iconHtml = tabMeta.icon ? _svgIcon(tabMeta.icon) : _categoryColorMarkerHtml(tabMeta.color); return `${iconHtml}${escapeHtml(tabMeta.name)}`; } function _cssEscape(value) { return window.CSS?.escape ? CSS.escape(String(value)) : String(value).replace(/["\\]/g, "\\$&"); } function _closeFloatingPanels(exceptEl = null) { document.querySelectorAll(".sfp-custom-select.open, .sfp-settings-wrap.open, .sfp-tab-shell.open").forEach((el) => { if (el !== exceptEl) el.classList.remove("open"); }); } function _scrollTabIntoView(shell, tabId = currentTab, behavior = "auto") { const bar = shell?.querySelector(".sfp-tab-bar"); const activeTab = shell?.querySelector(`.sfp-tab-bar .sfp-tab-item[data-tab="${_cssEscape(tabId)}"]`); if (!bar || !activeTab) return; const maxScrollLeft = Math.max(0, bar.scrollWidth - bar.clientWidth); const targetLeft = activeTab.offsetLeft - ((bar.clientWidth - activeTab.offsetWidth) / 2); const left = Math.min(maxScrollLeft, Math.max(0, targetLeft)); bar.scrollTo({ left, behavior }); } function _parsePreloadedPayload(raw) { if (!raw) return null; try { const decoded = raw.startsWith("%") ? decodeURIComponent(raw) : raw; return JSON.parse(decoded); } catch (e) { return null; } } function _extractPreloadedSiteData() { const candidates = [ ...document.querySelectorAll("[data-preloaded]"), ...document.querySelectorAll("script[type='application/json']"), ]; for (const el of candidates) { const payload = _parsePreloadedPayload(el.getAttribute("data-preloaded") || el.textContent || ""); const site = payload?._site || payload?.site || payload; if (site?.categories || site?.top_tags) return site; } return null; } async function loadSiteData() { if (siteDataLoaded) return siteDataCache; if (siteDataPromise) return siteDataPromise; siteDataPromise = (async () => { const preloaded = _extractPreloadedSiteData(); if (preloaded) { siteDataCache = preloaded; siteDataLoaded = true; return siteDataCache; } const resp = await fetch("/site.json", { headers: { "X-CSRF-Token": getCsrfToken() } }); if (!resp.ok) throw new Error(`site.json ${resp.status}`); siteDataCache = await resp.json(); siteDataLoaded = true; return siteDataCache; })().finally(() => { siteDataPromise = null; }); return siteDataPromise; } async function loadCategoriesAndLatestData() { if (categoriesAndLatestLoaded) return categoriesAndLatestCache; if (categoriesAndLatestPromise) return categoriesAndLatestPromise; categoriesAndLatestPromise = (async () => { const resp = await fetch("/categories_and_latest.json", { headers: { "X-CSRF-Token": getCsrfToken() } }); if (!resp.ok) throw new Error(`categories_and_latest.json ${resp.status}`); categoriesAndLatestCache = await resp.json(); categoriesAndLatestLoaded = true; return categoriesAndLatestCache; })().finally(() => { categoriesAndLatestPromise = null; }); return categoriesAndLatestPromise; } async function loadCategoryMetadata() { if (categoryMetaLoaded) return; if (categoryMetaPromise) return categoryMetaPromise; categoryMetaPromise = (async () => { try { const cachedSource = _loadCategoryDataCache(); if (cachedSource) { _applyCategoryDataSource(cachedSource, { primeSiteData: true }); return; } const [siteResult, categoriesAndLatestResult] = await Promise.allSettled([ loadSiteData(), loadCategoriesAndLatestData(), ]); const site = siteResult.status === "fulfilled" ? siteResult.value : null; const categoriesAndLatest = categoriesAndLatestResult.status === "fulfilled" ? categoriesAndLatestResult.value : null; if (siteResult.status === "rejected") console.warn("[SFP] load site data failed:", siteResult.reason); if (categoriesAndLatestResult.status === "rejected") console.warn("[SFP] load categories_and_latest failed:", categoriesAndLatestResult.reason); if (!site && !categoriesAndLatest) throw new Error("site category metadata unavailable"); const navigationCategories = _categoryListCategories(categoriesAndLatest); const source = _createCategoryDataCacheSource(site || {}, navigationCategories); _applyCategoryDataSource(source); _saveCategoryDataCache(source); } catch (e) { console.warn("[SFP] load category metadata failed:", e); tabCategories = []; _normalizeCurrentSiteState(); categoryMetaLoaded = true; } finally { categoryMetaPromise = null; } })(); return categoryMetaPromise; } function _tagIndexKeys(tag) { const keys = []; const add = (value) => { if (value === null || value === undefined) return; const key = String(value).trim().toLowerCase(); if (key && !keys.includes(key)) keys.push(key); }; if (typeof tag === "string") { add(tag); return keys; } add(tag?.name); add(tag?.slug); add(tag?.text); add(tag?.id); return keys; } function _tagDisplayName(tag) { return typeof tag === "string" ? tag : (tag?.name || tag?.text || tag?.slug || ""); } function _normalizeTagRecord(tag) { if (typeof tag === "string") { const name = tag.trim(); return name ? { name, slug: name } : null; } if (tag && typeof tag === "object") { const name = String(tag.name || tag.text || tag.slug || tag.id || "").trim(); if (!name) return null; return { id: tag.id, name, slug: tag.slug || tag.name || name, }; } const name = String(tag || "").trim(); return name ? { name, slug: name } : null; } function _getTopTagsFromSiteData(site) { if (site?.can_tag_topics === false) return []; const topTags = Array.isArray(site?.top_tags) ? site.top_tags : []; return topTags.map(_normalizeTagRecord).filter(Boolean); } function _cacheTagStyleAliases(tags) { tags.forEach((tag) => { const style = _getTagStyle(tag); if (style) _cacheTagStyle(_tagIndexKeys(tag), style); }); } function _getTagStyle(tag) { for (const key of _tagIndexKeys(tag)) { const style = tagStyleByKey.get(key); if (style) return style; } return null; } function _sanitizeTagStyle(styleText) { const pairs = []; String(styleText || "").split(";").forEach((part) => { const [rawName, rawValue] = part.split(":"); const name = rawName?.trim(); const value = rawValue?.trim(); if ((name === "--color1" || name === "--color2") && /^#[A-Fa-f0-9]{3,8}$/.test(value)) { pairs.push(`${name}: ${value}`); } }); return pairs.join("; "); } function _cacheTagStyle(keys, style) { keys.forEach((key) => { const normalized = String(key || "").trim().toLowerCase(); if (normalized) tagStyleByKey.set(normalized, style); }); } function _loadTagStyleCache() { if (tagStyleByKey.size > 0) return true; try { const cache = _getSiteValue(TAG_STYLE_CACHE_KEY, null); if (!cache || cache.version !== TAG_STYLE_CACHE_VERSION || !Array.isArray(cache.entries)) { return false; } cache.entries.forEach(([key, style]) => { if (!key || !style || typeof style !== "object") return; const icon = _safeIconName(style.icon || ""); const cssText = _sanitizeTagStyle(style.cssText || ""); if (!icon && !cssText) return; tagStyleByKey.set(String(key), { icon, cssText, hasIcon: !!icon, }); }); return tagStyleByKey.size > 0; } catch (e) { console.warn("[SFP] load tag style cache failed:", e); return false; } } function _saveTagStyleCache() { if (tagStyleByKey.size === 0) return; try { _setSiteValue(TAG_STYLE_CACHE_KEY, { version: TAG_STYLE_CACHE_VERSION, savedAt: Date.now(), entries: Array.from(tagStyleByKey.entries()), }); } catch (e) { console.warn("[SFP] save tag style cache failed:", e); } } function _extractTagStylesFromDocument(doc) { const anchors = Array.from(doc.querySelectorAll("a.discourse-tag[data-tag-name]")); anchors.forEach((anchor) => { const use = anchor.querySelector("svg use"); const icon = _safeIconName((use?.getAttribute("href") || use?.getAttribute("xlink:href") || "").replace(/^#/, "")); const style = { icon, cssText: _sanitizeTagStyle(anchor.getAttribute("style") || ""), hasIcon: !!icon, }; if (!style.hasIcon && !style.cssText) return; const hrefParts = (anchor.getAttribute("href") || "").split("/").filter(Boolean); _cacheTagStyle([ anchor.dataset.tagName, anchor.textContent, hrefParts[1], hrefParts[2], ], style); }); } function _waitForIframeTags(iframe, timeoutMs = 12000) { return new Promise((resolve) => { const start = Date.now(); const tick = () => { let doc = null; try { doc = iframe.contentDocument; if (doc && doc.querySelector("a.discourse-tag[data-tag-name]")) { resolve(doc); return; } } catch (e) { resolve(null); return; } if (Date.now() - start >= timeoutMs) { resolve(doc); return; } setTimeout(tick, 250); }; tick(); }); } async function loadTagStyleIndex() { if (tagStyleLoaded) return; if (tagStylePromise) return tagStylePromise; tagStylePromise = (async () => { let iframe = null; try { let siteTags = []; try { siteTags = _getTopTagsFromSiteData(await loadSiteData()); } catch (e) { siteTags = _getTopTagsFromSiteData(_extractPreloadedSiteData()); } if (_loadTagStyleCache()) { _cacheTagStyleAliases(siteTags); tagStyleLoaded = true; return; } _extractTagStylesFromDocument(document); iframe = document.createElement("iframe"); iframe.src = "/tags"; iframe.setAttribute("aria-hidden", "true"); iframe.style.cssText = "position:absolute;width:1px;height:1px;left:-10000px;top:-10000px;border:0;visibility:hidden;pointer-events:none;"; document.body.appendChild(iframe); const doc = await _waitForIframeTags(iframe); if (doc) _extractTagStylesFromDocument(doc); _cacheTagStyleAliases(siteTags); _saveTagStyleCache(); tagStyleLoaded = true; } catch (e) { console.warn("[SFP] load tag style index failed:", e); } finally { if (iframe) iframe.remove(); tagStylePromise = null; } })(); return tagStylePromise; } // ========== CSS 注入 ========== function injectStyles() { GM_addStyle(` /* ===== 切换按钮 ===== */ .sfp-toggle-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; background: var(--secondary); color: var(--primary-medium); cursor: pointer; border-radius: 6px; padding: 0; margin-left: 6px; vertical-align: middle; transition: color 0.2s, background 0.2s; flex-shrink: 0; } .sfp-toggle-btn:hover { color: var(--primary); background: var(--primary-low); } .sfp-toggle-btn.active { color: var(--secondary); background: var(--tertiary); } .sfp-toggle-btn svg { width: 18px; height: 18px; fill: currentColor; } .home-logo-wrapper-outlet .title { display: flex; align-items: center; gap: 2px; } /* ===== 侧边栏 Feed 模式 ===== */ .sidebar-wrapper:has(> .sidebar-container.sfp-feed-mode) { overflow-x: hidden !important; } .sidebar-container.sfp-feed-mode { overflow-x: hidden !important; } .sidebar-container.sfp-feed-mode .sfp-feed-container, .sidebar-container.sfp-feed-mode .sfp-feed-container * { box-sizing: border-box; } /* 隐藏所有非 feed 的直接子元素 */ .sidebar-container.sfp-feed-mode > :not(.sfp-feed-container):not(.sfp-resizer) { display: none !important; } /* 显式隐藏常见 sidebar 组件(嵌套情况兜底) */ .sidebar-container.sfp-feed-mode .sidebar-sections, .sidebar-container.sfp-feed-mode .sidebar-footer-container, .sidebar-container.sfp-feed-mode .sidebar-footer-wrapper, .sidebar-container.sfp-feed-mode .sidebar-footer, .sidebar-container.sfp-feed-mode .sidebar-custom-sections, .sidebar-container.sfp-feed-mode .sidebar-section-wrapper, .sidebar-container.sfp-feed-mode .sidebar-section-header, .sidebar-container.sfp-feed-mode .sidebar-section-link-wrapper { display: none !important; } .sidebar-container.sfp-feed-mode .sfp-feed-container { display: flex; flex-direction: column; position: relative; width: 100%; min-width: 0; height: 100%; overflow: hidden; max-width: 100%; } .sidebar-wrapper.sfp-width-animating, .sidebar-container.sfp-width-animating, #d-sidebar.sfp-width-animating { transition: width 220ms ease, max-width 220ms ease; } /* ===== 拖拽调整宽度 ===== */ .sfp-resizer { position: absolute; top: 0; right: -2px; width: 5px; height: 100%; cursor: ew-resize; z-index: 10001; transition: background 0.2s; } .sfp-resizer:hover, .sfp-resizer.sfp-resizing { background: var(--tertiary); } /* ===== Feed Header ===== */ .sfp-feed-header { position: relative; flex-shrink: 0; padding: 8px 12px; border-bottom: 1px solid var(--primary-low); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; overflow: visible; } .sfp-feed-header .sfp-header-spacer { flex: 1 1 auto; min-width: 8px; } .sfp-feed-header .sfp-refresh-btn, .sfp-feed-header .sfp-settings-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; background: var(--primary-very-low); color: var(--primary-medium); cursor: pointer; border-radius: 6px; padding: 0; flex-shrink: 0; transition: color 0.2s, background 0.2s; } .sfp-feed-header .sfp-refresh-btn:hover, .sfp-feed-header .sfp-settings-btn:hover, .sfp-settings-wrap.open .sfp-settings-btn { color: var(--tertiary); background: var(--primary-low); } .sfp-feed-header .sfp-refresh-btn.spinning svg { animation: sfp-spin 0.6s linear infinite; } .sfp-feed-header .sfp-refresh-btn.spinning { color: var(--tertiary); background: var(--primary-low); } .sfp-feed-header .sfp-refresh-btn.sfp-back-top-enter:not(.sfp-has-incoming-count) svg { animation: sfp-back-top-enter 0.18s ease; } .sfp-feed-header .sfp-refresh-btn .sfp-refresh-count { display: inline-block; min-width: 3ch; color: var(--tertiary); font-size: 13px; font-weight: 700; line-height: 1; text-align: center; white-space: nowrap; } @keyframes sfp-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes sfp-back-top-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .sfp-feed-header .sfp-refresh-btn svg, .sfp-feed-header .sfp-settings-btn svg { width: 16px; height: 16px; fill: currentColor; } .sfp-settings-btn { position: absolute; top: 0; right: 0; z-index: 2; gap: 3px; flex-direction: column; } .sfp-settings-line { width: 14px; height: 2px; border-radius: 2px; background: currentColor; transition: transform 0.28s ease, opacity 0.2s ease; transform-origin: center; } .sfp-settings-wrap.open .sfp-settings-line-1 { transform: translateY(5px) rotate(45deg); } .sfp-settings-wrap.open .sfp-settings-line-2 { opacity: 0; transform: scaleX(0); } .sfp-settings-wrap.open .sfp-settings-line-3 { transform: translateY(-5px) rotate(-45deg); } .sfp-settings-wrap { position: relative; width: 28px; height: 28px; flex-shrink: 0; overflow: visible; } .sfp-settings-shell { position: absolute; top: 0; right: 0; width: 28px; height: 28px; background: transparent; border: none; border-radius: 6px; box-shadow: none; z-index: 10003; overflow: hidden; transition: width 0.36s cubic-bezier(0.25, 1, 0.5, 1), height 0.36s cubic-bezier(0.25, 1, 0.5, 1), border-radius 0.24s ease, background 0.2s ease; } .sfp-settings-wrap.open .sfp-settings-shell { width: 204px; height: var(--sfp-settings-shell-height, 128px); overflow: visible; background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 8px; box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 14%, transparent); } .sfp-settings-panel { box-sizing: border-box; width: 204px; padding: 36px 10px 8px 10px; opacity: 0; visibility: hidden; transform: translateY(-8px); pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s; } .sfp-settings-wrap.open .sfp-settings-panel { opacity: 1; visibility: visible; transform: translateY(0); pointer-events: auto; transition: opacity 0.26s ease 0.12s, transform 0.26s ease 0.12s, visibility 0.26s 0.12s; } .sfp-setting-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 8px; font-size: 12px; color: var(--primary); line-height: 1.3; padding: 4px 0; } .sfp-setting-label { display: inline-flex; align-items: center; gap: 4px; min-width: 0; white-space: nowrap; } .sfp-setting-help-wrap { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; flex: 0 0 14px; pointer-events: none; } .sfp-setting-help { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; box-sizing: border-box; appearance: none; border: none; background: transparent; color: var(--primary-medium); cursor: help; padding: 0; pointer-events: auto; } .sfp-setting-help svg { width: 13px; height: 13px; display: block; fill: currentColor; pointer-events: none; } .sfp-setting-help:hover { color: var(--tertiary); outline: none; } .sfp-help-tooltip { position: fixed; z-index: 10004; width: 178px; max-width: calc(100vw - 32px); padding: 8px 9px; border: 1px solid var(--primary-low); border-radius: 6px; background: var(--secondary); box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 16%, transparent); color: var(--primary); font-size: 12px; font-weight: 400; line-height: 1.45; text-align: left; white-space: normal; opacity: 0; pointer-events: none; transform: translateY(-4px); transition: opacity 0.16s ease, transform 0.16s ease; } .sfp-help-tooltip.visible { opacity: 1; transform: translateY(0); } .sfp-setting-row input[type="checkbox"] { flex-shrink: 0; margin: 0; } .sfp-setting-interval { display: none; grid-template-columns: minmax(0, 1fr) 58px auto; align-items: center; column-gap: 6px; margin-top: 6px; font-size: 12px; color: var(--primary-medium); } .sfp-setting-interval.visible { display: grid; } .sfp-setting-row.hidden, .sfp-setting-interval.hidden { display: none; } .sfp-setting-interval input { width: 58px; height: 26px; padding: 2px 6px; border: 1px solid var(--primary-low); border-radius: 4px; background: var(--secondary); color: var(--primary); font-size: 12px; } /* ===== 自定义下拉 ===== */ .sfp-custom-select { position: relative; flex-shrink: 0; } .sfp-custom-select-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; font-size: 12px; height: 28px; border: none; background: var(--primary-very-low); color: var(--primary); border-radius: 6px; cursor: pointer; white-space: nowrap; user-select: none; transition: background 0.2s, color 0.2s; } .sfp-custom-select-btn:hover { background: var(--primary-low); } .sfp-custom-select-btn::after { content: ""; width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid currentColor; } .sfp-custom-select-dropdown { position: absolute; top: calc(100% + 4px); left: 0; min-width: 100%; background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 6px; box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent); z-index: 10002; display: none; overflow: hidden; } .sfp-custom-select.open .sfp-custom-select-dropdown { display: block; } .sfp-custom-select-option { display: block; width: 100%; padding: 6px 14px; font-size: 12px; border: none; background: none; color: var(--primary); cursor: pointer; text-align: left; white-space: nowrap; transition: background 0.15s; } .sfp-custom-select-option:hover { background: var(--primary-very-low); } .sfp-custom-select-option.selected { color: var(--tertiary); font-weight: 600; } /* ===== 分类标签栏 ===== */ .sfp-tab-shell { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 36px; align-items: stretch; width: 100%; min-width: 0; max-width: 100%; border-bottom: 1px solid var(--primary-low); flex-shrink: 0; background: var(--d-content-background, var(--secondary)); } .sfp-tab-bar { display: flex; gap: 8px; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; scrollbar-width: none; width: 100%; min-width: 0; max-width: 100%; padding: 8px 12px; margin: 0; flex-shrink: 0; background: transparent; } .sfp-tab-bar::-webkit-scrollbar { display: none; } .sfp-tab-item { display: inline-flex; align-items: center; gap: 3px; padding: 4px 12px; font-size: 13px; color: var(--primary-medium); cursor: pointer; white-space: nowrap; border-radius: 16px; background: var(--primary-very-low); transition: all 0.2s; border: 1px solid transparent; flex-shrink: 0; user-select: none; } .sfp-tab-item:hover { color: var(--primary); background: var(--primary-low); } .sfp-tab-item.active { color: var(--secondary); background: var(--tertiary); border-color: var(--tertiary); } .sfp-tab-item svg { width: 12px; height: 12px; fill: currentColor; flex-shrink: 0; } .sfp-category-color-marker { width: 0.72em; height: 0.72em; border-radius: 50%; background: var(--sfp-category-marker-color, currentColor); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 12%, transparent); flex: 0 0 auto; } .sfp-tab-more-btn { display: inline-flex; align-items: center; justify-content: center; width: 36px; min-width: 36px; padding: 0; border: none; border-left: 1px solid var(--primary-low); background: var(--d-content-background, var(--secondary)); color: var(--primary-medium); cursor: pointer; transition: background 0.2s, color 0.2s; } .sfp-tab-more-btn:hover, .sfp-tab-shell.open .sfp-tab-more-btn { background: var(--primary-very-low); color: var(--primary); } .sfp-tab-more-btn svg { width: 16px; height: 16px; fill: currentColor; } .sfp-tab-panel { position: absolute; top: 100%; right: 8px; left: 8px; display: none; padding: 10px; max-height: min(58vh, 420px); overflow-y: auto; background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 8px; box-shadow: 0 10px 28px color-mix(in srgb, var(--primary) 16%, transparent); z-index: 10002; } .sfp-tab-shell.open .sfp-tab-panel { display: block; } .sfp-tab-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; font-size: 12px; color: var(--primary-medium); line-height: 1.3; } .sfp-tab-panel-title { display: inline-flex; align-items: center; gap: 6px; min-width: 0; } .sfp-tab-panel-title svg { width: 13px; height: 13px; fill: currentColor; flex-shrink: 0; } .sfp-tab-panel-close { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: none; border-radius: 4px; background: transparent; color: var(--primary-medium); cursor: pointer; } .sfp-tab-panel-close:hover { background: var(--primary-very-low); color: var(--primary); } .sfp-tab-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); gap: 6px; } .sfp-tab-grid-item { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-width: 0; min-height: 32px; padding: 6px 8px; border: 1px solid transparent; border-radius: 6px; background: var(--primary-very-low); color: var(--primary-medium); cursor: pointer; font-size: 12px; line-height: 1.2; text-align: center; user-select: none; transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s; } .sfp-tab-grid-item svg { width: 12px; height: 12px; fill: currentColor; flex: 0 0 auto; } .sfp-tab-grid-item .sfp-category-color-marker { width: 0.75em; height: 0.75em; } .sfp-tab-grid-item span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sfp-tab-grid-item:hover { background: var(--primary-low); color: var(--primary); } .sfp-tab-grid-item.active { background: var(--tertiary); border-color: var(--tertiary); color: var(--secondary); } .sfp-tab-grid-item.dragging { opacity: 0.45; } .sfp-tab-grid-item.drop-target { border-color: var(--tertiary); box-shadow: inset 0 0 0 1px var(--tertiary); } /* ===== 筛选栏 ===== */ .sfp-filter-bar { position: relative; z-index: 3; display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; max-width: 100%; padding: 8px 16px; margin: 0; background: var(--primary-very-low); border-bottom: 1px solid var(--primary-low); font-size: 12px; color: var(--primary-medium); flex-shrink: 0; } .sfp-filter-item { cursor: pointer; padding: 2px 6px; border-radius: 4px; transition: all 0.2s; user-select: none; } .sfp-filter-item:hover { color: var(--tertiary); background: var(--primary-low); } .sfp-filter-item.active { color: var(--secondary); background: var(--tertiary); } /* ===== Feed 滚动区 ===== */ .sfp-feed-scroll { position: relative; flex: 1; min-width: 0; max-width: 100%; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; overscroll-behavior-y: contain; touch-action: pan-y pinch-zoom; --scrollbarBg: transparent; --scrollbarThumbBg: var(--d-selected, var(--token-color-surface-hovered)); --scrollbarWidth: var(--space-2, 0.5em); scrollbar-gutter: stable; scrollbar-color: var(--scrollbarThumbBg) var(--scrollbarBg); } .sfp-feed-scroll::-webkit-scrollbar { width: var(--scrollbarWidth); } .sfp-feed-scroll::-webkit-scrollbar-thumb { background-color: var(--scrollbarThumbBg); border-radius: calc(var(--scrollbarWidth) / 2); } .sfp-feed-scroll::-webkit-scrollbar-track { background-color: transparent; } .sfp-content-wrapper { position: relative; min-width: 0; max-width: 100%; min-height: 100%; } /* ===== 帖子列表项 ===== */ .sfp-topic-item { display: block; padding: 12px 20px; border-bottom: 1px solid var(--primary-very-low); color: inherit; cursor: pointer; text-decoration: none; transition: background 0.2s; position: relative; min-width: 0; max-width: 100%; overflow-wrap: break-word; word-break: break-word; } .sfp-topic-item:visited, .sfp-topic-item:hover, .sfp-topic-item:focus { color: inherit; text-decoration: none; } .sfp-topic-item:hover { background: var(--primary-very-low); } .sfp-topic-item:focus-visible { outline: 2px solid var(--tertiary); outline-offset: -2px; } .sfp-topic-item.sfp-filter-mismatch { opacity: 0.48; filter: grayscale(0.85); } .sfp-topic-item.sfp-topic-unavailable .sfp-topic-title-line { text-decoration: line-through; } .sfp-topic-item.sfp-new-highlight { --sfp-new-highlight-color: var( --tertiary-med-or-tertiary, var(--tertiary) ); animation: sfp-new-pulse 10s ease-out forwards; position: relative; } @keyframes sfp-new-pulse { 0% { box-shadow: inset 0 0 0 2px var(--sfp-new-highlight-color); background: color-mix( in srgb, var(--sfp-new-highlight-color) 15%, transparent ); } 100% { box-shadow: inset 0 0 0 0px transparent; background: transparent; } } /* 未读圆点 — 紧跟在时间后 */ .sfp-topic-item .sfp-topic-time { font-size: 12px; color: var(--primary-medium); white-space: nowrap; margin-left: auto; flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px; line-height: 1; } .sfp-topic-item .sfp-unread-dot { width: 8px; height: 8px; flex: 0 0 8px; display: inline-block; border-radius: 50%; color: var(--tertiary-med-or-tertiary, var(--tertiary)); background: currentColor; opacity: 0.75; vertical-align: middle; } /* 头像 + 用户信息行 */ .sfp-topic-item .sfp-topic-header { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } .sfp-topic-item .sfp-topic-user-link { display: inline-flex; align-items: center; gap: 5px; min-width: 0; color: inherit; text-decoration: none; cursor: pointer; } /* 防止父级 topic 链接的 hover/focus 样式把用户入口改成普通文本。 */ .sfp-topic-item .sfp-topic-user-link:hover, .sfp-topic-item .sfp-topic-user-link:focus { color: inherit; text-decoration: none; } .sfp-topic-item .sfp-topic-user-link:focus-visible { outline: 2px solid var(--tertiary); outline-offset: 2px; border-radius: 4px; } .sfp-topic-item .sfp-topic-avatar { width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0; object-fit: cover; } .sfp-topic-item .sfp-topic-meta-col { display: flex; flex-direction: column; min-width: 0; flex: 1; } .sfp-topic-item .sfp-topic-user-info { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; overflow: hidden; } .sfp-topic-item .sfp-topic-username { font-size: 13px; color: var(--primary); font-weight: 500; cursor: pointer; transition: color 0.2s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sfp-topic-item .sfp-topic-username:hover { color: var(--tertiary); } .sfp-topic-item .sfp-topic-name { font-size: 12px; color: var(--primary-medium); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* 标题 */ .sfp-topic-item .sfp-topic-title { font-size: 14px; font-weight: bold; color: var(--primary); line-height: 1.4; margin: 0; word-break: break-word; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; transition: color 0.2s; } .sfp-topic-item .sfp-topic-title:hover { color: var(--tertiary); } .sfp-topic-item.sfp-read .sfp-topic-title { color: var(--title-color--read, var(--primary-medium)); } .sfp-topic-item .sfp-topic-title-line { display: inline; } .sfp-topic-item .sfp-topic-status-badges { display: inline-flex; align-items: center; gap: 4px; margin-left: 2px; flex-shrink: 0; } .sfp-topic-item .topic-status-card { --badge-accent: var(--primary-medium); --badge-bg: var(--primary-very-low); --badge-border: var(--primary-low); display: inline-flex; align-items: center; gap: 3px; padding: 1px 6px; border: 1px solid var(--badge-border); border-radius: var(--d-border-radius, 8px); background: var(--badge-bg); color: var(--badge-accent); font-size: 11px; font-weight: 700; line-height: 1.45; margin-right: 5px; vertical-align: middle; white-space: nowrap; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--badge-accent) 8%, transparent); } .sfp-topic-item .topic-status-card.--hot { --badge-accent: var(--danger); --badge-bg: var(--danger-low, var(--d-hover, var(--tertiary-low))); --badge-border: color-mix(in srgb, var(--danger) 28%, transparent); } .sfp-topic-item .topic-status-card.--pinned { --badge-accent: var(--primary-medium); --badge-bg: var(--primary-very-low); --badge-border: var(--primary-low); } .sfp-topic-item .topic-status-card__name { color: var(--badge-accent); font-size: inherit; font-weight: inherit; line-height: inherit; margin: 0; } .sfp-topic-item .topic-status-card .d-icon { color: var(--badge-accent); width: 0.92em; height: 0.92em; flex-shrink: 0; } .sfp-topic-item .topic-statuses { float: left; } .sfp-topic-item .topic-statuses .topic-status { display: inline-flex; align-items: center; color: var(--primary-medium); margin: 0 0.18em 0 0; --icon-size: 0.86em; } .sfp-topic-item .topic-statuses .topic-status .d-icon { width: var(--icon-size); height: var(--icon-size); color: currentColor; } /* 分类 + 标签行 */ .sfp-topic-item .sfp-topic-category-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 5px; } .sfp-topic-item .sfp-category-badge { --badge-category-bg: light-dark( oklch(from var(--category-badge-color) 97% calc(c * 0.3) h), oklch(from var(--category-badge-color) 45% calc(c * 0.5) h) ); --badge-category-text: light-dark( oklch(from var(--category-badge-color) 35% calc(c * 0.6) h), oklch(from var(--category-badge-color) 95% calc(c * 0.2) h) ); display: inline-flex; align-items: center; gap: 0.33em; font-size: 11px; padding: 2px 6px; border-radius: var(--d-border-radius, 4px); background-color: var(--badge-category-bg, var(--primary-very-low)); color: var(--badge-category-text, var(--primary-medium)); flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sfp-topic-item .sfp-category-badge .badge-category { min-width: 0; align-items: center; } .sfp-topic-item .sfp-category-badge .badge-category__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--badge-category-text, var(--primary-medium)); } .sfp-topic-item .sfp-category-badge .d-icon { width: 0.9em; height: 0.9em; flex-shrink: 0; } @supports not (color: light-dark(tan, tan)) { .sfp-topic-item .sfp-category-badge { --badge-category-bg: color-mix(in srgb, var(--category-badge-color) 16%, transparent); --badge-category-text: var(--primary-high); } } /* 标签 */ .sfp-topic-item .sfp-topic-tags { display: flex; gap: 3px; flex-wrap: wrap; } .sfp-topic-item .sfp-tag { font-size: 11px; padding: 2px 6px; border-radius: var(--d-border-radius, 4px); line-height: 1.4; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; } .sfp-topic-item .sfp-tag .tag-icon .d-icon { width: 0.9em; height: 0.9em; } /* 统计行 */ .sfp-topic-item .sfp-topic-stats { display: flex; gap: 12px; margin-top: 8px; font-size: 12px; color: var(--primary-medium); } .sfp-topic-item .sfp-topic-stat { display: flex; align-items: center; gap: 4px; } .sfp-topic-item .sfp-topic-stat .d-icon { width: 1em; height: 1em; } /* ===== 加载状态 ===== */ .sfp-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--primary-medium); font-size: 13px; gap: 12px; } .sfp-spinner { width: 28px; height: 28px; border: 3px solid var(--primary-low); border-top-color: var(--tertiary); border-radius: 50%; animation: sfp-spin 0.8s linear infinite; } .sfp-empty { text-align: center; padding: 40px 10px; color: var(--primary-medium); font-size: 13px; } .sfp-load-more { padding: 14px 10px; text-align: center; font-size: 12px; color: var(--primary-medium); cursor: pointer; transition: color 0.2s; } .sfp-load-more-error { display: flex; justify-content: center; align-items: center; gap: 8px; cursor: default; } .sfp-load-more-error .sfp-load-more-retry { padding: 4px 10px; border: none; border-radius: 4px; background: var(--tertiary); color: var(--secondary); cursor: pointer; font-size: 12px; } .sfp-load-more:hover { color: var(--tertiary); } .sfp-load-more .sfp-load-more-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--primary-low); border-top-color: var(--tertiary); border-radius: 50%; animation: sfp-spin 0.8s linear infinite; vertical-align: middle; margin-right: 6px; } .sfp-no-more { padding: 14px 10px; text-align: center; font-size: 11px; color: var(--primary-low-mid); } .sfp-load-more-note { padding: 10px 10px 0; text-align: center; font-size: 12px; color: var(--primary-medium); } .sfp-error { padding: 40px 20px; text-align: center; color: var(--danger); display: flex; flex-direction: column; align-items: center; gap: 10px; } .sfp-error-icon { font-size: 32px; } .sfp-error-msg { font-size: 14px; font-weight: 600; } .sfp-error-detail { font-size: 12px; color: var(--primary-medium); word-break: break-word; } .sfp-error .sfp-retry-btn { margin-top: 6px; padding: 6px 16px; background: var(--tertiary); color: var(--secondary); border: none; border-radius: 4px; cursor: pointer; font-size: 12px; transition: opacity 0.2s; } .sfp-error .sfp-retry-btn:hover { opacity: 0.85; } `); } // ========== 切换开关 ========== function createToggle() { if (toggleBtn) return toggleBtn; const homeLogo = document.querySelector(".home-logo-wrapper-outlet"); if (!homeLogo) return null; toggleBtn = document.createElement("button"); toggleBtn.className = "sfp-toggle-btn" + (feedModeEnabled ? " active" : ""); toggleBtn.title = t("toggleTitle"); toggleBtn.innerHTML = ``; toggleBtn.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); feedModeEnabled = !feedModeEnabled; _setSiteValue(STATE_KEY, feedModeEnabled); toggleBtn.classList.toggle("active", feedModeEnabled); if (feedModeEnabled) { activateFeed(); } else { deactivateFeed(); } }); // 放入 .title 内部,logo 右边 const titleEl = homeLogo.querySelector(".title"); if (titleEl) { titleEl.appendChild(toggleBtn); } else { homeLogo.appendChild(toggleBtn); } return toggleBtn; } // ========== 侧边栏宽度控制 ========== function getMinSidebarWidth() { return MIN_WIDTH; } function getSidebarElement() { return document.querySelector("#d-sidebar") || document.querySelector(".sidebar-container"); } function applySidebarWidth(width) { const clampedWidth = Math.min(MAX_WIDTH, Math.max(getMinSidebarWidth(), width)); sfpSidebarWidth = clampedWidth; const sidebar = getSidebarElement(); if (sidebar) { sidebar.style.setProperty("width", clampedWidth + "px", "important"); } document.documentElement.style.setProperty("--d-sidebar-width", clampedWidth + "px"); } function getSidebarWidthTransitionElements(sidebar) { const wrapper = sidebar?.classList?.contains("sidebar-wrapper") ? sidebar : sidebar?.closest?.(".sidebar-wrapper"); return [sidebar, wrapper].filter(Boolean); } function setSidebarWidthForAnimation(sidebar, width, { enforceMin = true } = {}) { const minWidth = enforceMin ? getMinSidebarWidth() : 0; const clampedWidth = Math.min(MAX_WIDTH, Math.max(minWidth, width)); sidebar.style.setProperty("width", clampedWidth + "px", "important"); document.documentElement.style.setProperty("--d-sidebar-width", clampedWidth + "px"); } function animateSidebarWidth(targetWidth, { cleanupAfter = false, enforceMin = true } = {}) { const sidebar = getSidebarElement(); if (!sidebar) return; if (widthAnimationTimer) { window.clearTimeout(widthAnimationTimer); widthAnimationTimer = null; } const startWidth = sidebar.getBoundingClientRect().width || DEFAULT_WIDTH; const minWidth = enforceMin ? getMinSidebarWidth() : 0; const clampedTarget = Math.min(MAX_WIDTH, Math.max(minWidth, targetWidth)); const transitionEls = getSidebarWidthTransitionElements(sidebar); setSidebarWidthForAnimation(sidebar, startWidth, { enforceMin: false }); transitionEls.forEach((el) => el.classList.add("sfp-width-animating")); window.requestAnimationFrame(() => { setSidebarWidthForAnimation(sidebar, clampedTarget, { enforceMin }); widthAnimationTimer = window.setTimeout(() => { widthAnimationTimer = null; transitionEls.forEach((el) => el.classList.remove("sfp-width-animating")); if (cleanupAfter) { restoreSidebarWidth(); } }, 260); }); } function restoreSidebarWidth() { const sidebar = getSidebarElement(); if (sidebar) { sidebar.style.removeProperty("width"); } document.documentElement.style.removeProperty("--d-sidebar-width"); } function setupResizer() { const sidebar = getSidebarElement(); if (!sidebar) return; if (resizerEl && !sidebar.contains(resizerEl)) { resizerEl.remove(); resizerEl = null; } if (resizerEl) return; resizerEl = sidebar.querySelector(":scope > .sfp-resizer") || document.createElement("div"); resizerEl.className = "sfp-resizer"; if (!resizerEl.parentElement) { sidebar.appendChild(resizerEl); } resizerEl.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); isResizing = true; const startX = e.clientX; const startWidth = sidebar.offsetWidth; resizerEl.classList.add("sfp-resizing"); getSidebarWidthTransitionElements(sidebar).forEach((el) => el.classList.remove("sfp-width-animating")); document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; const onMouseMove = (e) => { if (!isResizing) return; const delta = e.clientX - startX; const newWidth = Math.min(MAX_WIDTH, Math.max(getMinSidebarWidth(), startWidth + delta)); applySidebarWidth(newWidth); }; const onMouseUp = () => { isResizing = false; resizerEl.classList.remove("sfp-resizing"); document.body.style.cursor = ""; document.body.style.userSelect = ""; _setSiteValue(WIDTH_KEY, sfpSidebarWidth); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } function removeResizer() { if (resizerEl) { resizerEl.remove(); resizerEl = null; } const sidebar = getSidebarElement(); sidebar?.querySelectorAll(":scope > .sfp-resizer").forEach((el) => el.remove()); } // ========== 激活 / 停用 ========== function activateFeed() { const sidebar = getSidebarElement(); if (!sidebar) return; if (!sidebar.classList.contains("sfp-feed-mode") || originalSidebarWidthBeforeFeed === null) { originalSidebarWidthBeforeFeed = sidebar.getBoundingClientRect().width || DEFAULT_WIDTH; } if (feedContainer && sidebar.contains(feedContainer)) { sidebar.classList.add("sfp-feed-mode"); animateSidebarWidth(sfpSidebarWidth); setupResizer(); _syncDefaultViewControls(); _syncIncomingHeadAction(); _syncHeadActionState(); return; } if (feedContainer) { _resetRefreshButtonBusy(); if (feedScrollAbortController) { feedScrollAbortController.abort(); feedScrollAbortController = null; } feedContainer.remove(); feedContainer = null; feedHeaderEl = null; feedRefreshBtn = null; feedScrollEl = null; feedListEl = null; } // 创建 feed 容器 feedContainer = document.createElement("div"); feedContainer.className = "sfp-feed-container"; feedHeaderEl = document.createElement("div"); feedHeaderEl.className = "sfp-feed-header"; _buildHeaderControls(feedHeaderEl); // 分类标签栏 const tabBar = _buildTabBar(); feedContainer.appendChild(feedHeaderEl); feedContainer.appendChild(tabBar); // 筛选栏 const filterBar = _buildFilterBar(); feedContainer.appendChild(filterBar); feedScrollEl = document.createElement("div"); feedScrollEl.className = "sfp-feed-scroll"; // 创建内容包装器,用于相对定位 const contentWrapper = document.createElement("div"); contentWrapper.className = "sfp-content-wrapper"; feedListEl = document.createElement("div"); feedListEl.className = "sfp-topic-list"; contentWrapper.appendChild(feedListEl); feedScrollEl.appendChild(contentWrapper); feedContainer.appendChild(feedScrollEl); sidebar.appendChild(feedContainer); sidebar.classList.add("sfp-feed-mode"); animateSidebarWidth(sfpSidebarWidth); setupResizer(); // 恢复当前 tab 筛选的分类 _restoreTabState(); _syncDefaultViewControls(); // 始终全量加载,数据已在 deactivateFeed 中清除 loadTopics(); // 无限滚动 _setupScrollLoadMore(); } function deactivateFeed() { const sidebar = getSidebarElement(); if (!sidebar) return; activeLoadToken++; activeLoadMoreToken++; activeRefreshToken++; isLoading = false; isLoadingMore = false; isRefreshing = false; _pendingReload = false; sidebarIncomingState.applyQueued = false; if (feedScrollAbortController) { feedScrollAbortController.abort(); feedScrollAbortController = null; } _stopAutoRefresh(); _stopAutoSilentRefresh(); _stopSidebarIncomingTracking(); _resetRefreshButtonBusy(); feedRefreshBtn?.classList.remove("sfp-back-top-enter"); if (feedContainer) { feedContainer.remove(); feedContainer = null; feedHeaderEl = null; feedRefreshBtn = null; feedScrollEl = null; feedListEl = null; } // 清除数据缓存,避免下次激活时显示旧数据 allTopics = []; usersMap = {}; loadedTopicIds.clear(); currentPage = 0; hasMorePages = true; _resetAutoLoadState(); sidebar.classList.remove("sfp-feed-mode"); removeResizer(); animateSidebarWidth(originalSidebarWidthBeforeFeed || DEFAULT_WIDTH, { cleanupAfter: true, enforceMin: false }); originalSidebarWidthBeforeFeed = null; } // ========== Header 控件 ========== function _buildHeaderControls(header) { const orderOptions = _getOrderOptions(); const periodOptions = _getPeriodOptions(); // Period 下拉(先创建,因为 order 切换时需要引用) const periodSelect = _buildCustomSelect(periodOptions, currentPeriod, (value) => { currentPeriod = value; _setSiteValue(PERIOD_KEY, currentPeriod); _resetAutoLoadState(); loadTopics(); }); periodSelect.classList.add("sfp-period-select"); _updatePeriodVisibility(periodSelect); // Order 下拉 const orderSelect = _buildCustomSelect(orderOptions, currentOrder, (value) => { currentOrder = value; _setSiteValue(ORDER_KEY, currentOrder); _updatePeriodVisibility(periodSelect); _beginSidebarIncomingViewSettling(); _syncDefaultViewControls(); _resetAutoLoadState(); loadTopics(); }); orderSelect.classList.add("sfp-order-select"); function _updatePeriodVisibility(ps) { ps.style.display = _needsPeriodForUrl(currentOrder) && periodOptions.length > 1 ? "" : "none"; } header.appendChild(orderSelect); header.appendChild(periodSelect); const spacer = document.createElement("span"); spacer.className = "sfp-header-spacer"; header.appendChild(spacer); header.appendChild(_buildSettingsControl()); // 刷新按钮 const refreshBtn = document.createElement("button"); refreshBtn.className = "sfp-refresh-btn"; refreshBtn.type = "button"; refreshBtn.title = t("refresh"); refreshBtn.setAttribute("aria-label", t("refresh")); refreshBtn.innerHTML = _refreshIconHtml(); refreshBtn.addEventListener("click", _handleHeadActionClick); feedRefreshBtn = refreshBtn; _syncRefreshButtonBusy(); header.appendChild(refreshBtn); } function _buildSettingsControl() { const wrapper = document.createElement("span"); wrapper.className = "sfp-settings-wrap"; const isLatestActivityView = _isLatestActivityView(); const shell = document.createElement("span"); shell.className = "sfp-settings-shell"; const btn = document.createElement("button"); btn.className = "sfp-settings-btn"; btn.type = "button"; btn.title = t("settings"); btn.innerHTML = ` `; const panel = document.createElement("div"); panel.className = "sfp-settings-panel"; // 设置项按当前排序视图分组,而不是一次性展示全部选项: // - 最新活动可以消费 message-bus 增量,因此提供“新活动提醒”和“静默刷新”; // - 浏览量/回复/点赞等排序没有同等可靠的增量通道,只提供普通自动刷新。 // 这样可以减少用户误以为所有排序都能无请求地接收新话题。 if (isLatestActivityView) { panel.innerHTML = `
${escapeHtml(t("hot"))}
`); } if (topic.pinned || topic.pinned_globally) { statusBadges.push(`${escapeHtml(t("pinned"))}
`); } return statusBadges.length ? `${statusBadges.join("")}` : ""; } function _topicTimeHtml(topic) { const timeStr = formatRelativeTime(topic.bumped_at || topic.last_posted_at || topic.created_at); const unreadDotHtml = _hasUnreadMarker(topic) ? '' : ""; return `${timeStr}${unreadDotHtml}`; } function _isTopicExplicitlyUnavailable(topic) { return !!topic && ( topic.deleted_at || topic.deleted || topic.hidden || topic.visible === false ); } // ========== 渲染 ========== function renderTopics(newTopicIds = []) { if (!feedListEl) return; feedListEl.innerHTML = ""; if (allTopics.length === 0) { feedListEl.innerHTML = `