From 9d0a517cd85db4300f41c33b4f5924e654bb165c Mon Sep 17 00:00:00 2001 From: ImXiangYu <2670833123@qq.com> Date: Wed, 25 Feb 2026 22:26:50 +0800 Subject: [PATCH 1/3] refactor: modularize userscript build and keep README user-focused --- .github/workflows/verify-build.yml | 30 + AGENTS.md | 10 + better-github-nav.user.js | 1632 +++++++++++++--------------- package-lock.json | 473 ++++++++ package.json | 15 + scripts/build.mjs | 69 ++ scripts/userscript-header.txt | 15 + src/config.js | 66 ++ src/constants.js | 57 + src/i18n.js | 35 + src/main.js | 24 + src/navigation.js | 332 ++++++ src/settings-panel.js | 256 +++++ src/styles.js | 181 +++ 14 files changed, 2310 insertions(+), 885 deletions(-) create mode 100644 .github/workflows/verify-build.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/build.mjs create mode 100644 scripts/userscript-header.txt create mode 100644 src/config.js create mode 100644 src/constants.js create mode 100644 src/i18n.js create mode 100644 src/main.js create mode 100644 src/navigation.js create mode 100644 src/settings-panel.js create mode 100644 src/styles.js diff --git a/.github/workflows/verify-build.yml b/.github/workflows/verify-build.yml new file mode 100644 index 0000000..26a7f6b --- /dev/null +++ b/.github/workflows/verify-build.yml @@ -0,0 +1,30 @@ +name: Verify Userscript Build + +on: + push: + branches: + - dev + - main + pull_request: + +jobs: + verify-build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build userscript + run: npm run build + + - name: Ensure generated file is committed + run: git diff --exit-code -- better-github-nav.user.js diff --git a/AGENTS.md b/AGENTS.md index bb72f7e..f554d02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,16 @@ ## 版本约定 - 每次修改脚本都要递增小版本号(patch),并同步更新脚本内用于控制台打印的版本常量,便于确认变更已生效。 +## 发布流程(dev -> main) +- 当用户准备将 `dev` 分支合并到 `main`,或要求 AI 生成该合并的 PR 信息时,AI 必须先提醒:先完整执行以下发布流程,再进行 PR 文案与合并动作。 +- 标准发布流程: + 1. 确认源码改动已完成(如 `src/` 内模块)。 + 2. 递增补丁版本号(patch),并确保 `@version` 与脚本内版本常量一致。 + 3. 执行构建,生成最新 `better-github-nav.user.js`(发布文件)。 + 4. 本地自检产物(至少检查脚本头版本号、控制台版本常量、关键功能是否可用)。 + 5. 将源码改动与构建产物一并提交到 `dev`。 + 6. 仅在以上步骤完成后,再编写/提交 `dev -> main` 的 PR 信息。 + ## 输出要求 - 默认使用中文 - 如果要求生成git提交信息,使用英文 diff --git a/better-github-nav.user.js b/better-github-nav.user.js index 33545e5..60dd63e 100644 --- a/better-github-nav.user.js +++ b/better-github-nav.user.js @@ -2,7 +2,7 @@ // @name Better GitHub Navigation // @name:zh-CN 更好的 GitHub 导航栏 // @namespace https://github.com/ImXiangYu/better-github-nav -// @version 0.1.25 +// @version 0.1.26 // @description Add quick access to Dashboard, Trending, Explore, Collections, and Stars from GitHub's top navigation. // @description:zh-CN 在 GitHub 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。 // @author Ayubass @@ -14,923 +14,785 @@ // @grant GM_registerMenuCommand // ==/UserScript== -(function() { - 'use strict'; - const SCRIPT_VERSION = '0.1.25'; - const CUSTOM_BUTTON_CLASS = 'custom-gh-nav-btn'; - const CUSTOM_BUTTON_ACTIVE_CLASS = 'custom-gh-nav-btn-active'; - const CUSTOM_BUTTON_COMPACT_CLASS = 'custom-gh-nav-btn-compact'; - const QUICK_LINK_MARK_ATTR = 'data-better-gh-nav-quick-link'; - const CONFIG_STORAGE_KEY = 'better-gh-nav-config-v1'; - const UI_LANG_STORAGE_KEY = 'better-gh-nav-ui-lang-v1'; - const SETTINGS_OVERLAY_ID = 'custom-gh-nav-settings-overlay'; - const SETTINGS_PANEL_ID = 'custom-gh-nav-settings-panel'; - const SETTINGS_MESSAGE_ID = 'custom-gh-nav-settings-message'; - const DEFAULT_LINK_KEYS = ['dashboard', 'explore', 'trending', 'collections', 'stars']; - const PRESET_LINKS = [ - { key: 'dashboard', text: 'Dashboard', path: '/dashboard', getHref: () => '/dashboard' }, - { key: 'explore', text: 'Explore', path: '/explore', getHref: () => '/explore' }, - { key: 'trending', text: 'Trending', path: '/trending', getHref: () => '/trending' }, - { key: 'collections', text: 'Collections', path: '/collections', getHref: () => '/collections' }, - { key: 'stars', text: 'Stars', path: '/stars', getHref: username => (username ? `/${username}?tab=stars` : '/stars') } +(() => { + // src/constants.js + var SCRIPT_VERSION = "0.1.26"; + var CUSTOM_BUTTON_CLASS = "custom-gh-nav-btn"; + var CUSTOM_BUTTON_ACTIVE_CLASS = "custom-gh-nav-btn-active"; + var CUSTOM_BUTTON_COMPACT_CLASS = "custom-gh-nav-btn-compact"; + var QUICK_LINK_MARK_ATTR = "data-better-gh-nav-quick-link"; + var CONFIG_STORAGE_KEY = "better-gh-nav-config-v1"; + var UI_LANG_STORAGE_KEY = "better-gh-nav-ui-lang-v1"; + var SETTINGS_OVERLAY_ID = "custom-gh-nav-settings-overlay"; + var SETTINGS_PANEL_ID = "custom-gh-nav-settings-panel"; + var SETTINGS_MESSAGE_ID = "custom-gh-nav-settings-message"; + var DEFAULT_LINK_KEYS = ["dashboard", "explore", "trending", "collections", "stars"]; + var PRESET_LINKS = [ + { key: "dashboard", text: "Dashboard", path: "/dashboard", getHref: () => "/dashboard" }, + { key: "explore", text: "Explore", path: "/explore", getHref: () => "/explore" }, + { key: "trending", text: "Trending", path: "/trending", getHref: () => "/trending" }, + { key: "collections", text: "Collections", path: "/collections", getHref: () => "/collections" }, + { key: "stars", text: "Stars", path: "/stars", getHref: (username) => username ? `/${username}?tab=stars` : "/stars" } + ]; + var I18N = { + zh: { + menuOpenSettings: "Better GitHub Nav: 打开设置面板", + menuResetSettings: "Better GitHub Nav: 重置快捷链接配置", + menuLangZh: "Better GitHub Nav: 界面语言 -> 中文", + menuLangEn: "Better GitHub Nav: 界面语言 -> English", + menuLangAuto: "Better GitHub Nav: 界面语言 -> 自动(跟随页面)", + resetConfirm: "确认重置快捷链接配置为默认值吗?", + panelTitle: "Better GitHub Nav 设置", + panelDesc: "勾选决定显示项,拖动整行(或右侧手柄)调整显示顺序。", + resetDefault: "恢复默认", + cancel: "取消", + saveAndRefresh: "保存并刷新", + restoredPendingSave: "已恢复默认,点击保存后生效。", + atLeastOneLink: "至少保留 1 个快捷链接。", + dragHandleTitle: "拖动调整顺序", + dragRowTitle: "拖动整行调整顺序" + }, + en: { + menuOpenSettings: "Better GitHub Nav: Open Settings Panel", + menuResetSettings: "Better GitHub Nav: Reset Quick Link Config", + menuLangZh: "Better GitHub Nav: UI Language -> 中文", + menuLangEn: "Better GitHub Nav: UI Language -> English", + menuLangAuto: "Better GitHub Nav: UI Language -> Auto (Follow Page)", + resetConfirm: "Reset quick-link config to defaults?", + panelTitle: "Better GitHub Nav Settings", + panelDesc: "Select visible links and drag the row (or handle) to reorder.", + resetDefault: "Reset to Default", + cancel: "Cancel", + saveAndRefresh: "Save and Refresh", + restoredPendingSave: "Defaults restored. Click save to apply.", + atLeastOneLink: "Keep at least 1 quick link.", + dragHandleTitle: "Drag to reorder", + dragRowTitle: "Drag row to reorder" + } + }; + + // src/config.js + function sanitizeKeys(keys) { + const validSet = new Set(DEFAULT_LINK_KEYS); + const seen = /* @__PURE__ */ new Set(); + const result = []; + keys.forEach((key) => { + if (validSet.has(key) && !seen.has(key)) { + seen.add(key); + result.push(key); + } + }); + return result; + } + function sanitizeConfig(rawConfig) { + const enabledKeys = sanitizeKeys(Array.isArray(rawConfig?.enabledKeys) ? rawConfig.enabledKeys : DEFAULT_LINK_KEYS); + const orderKeysRaw = sanitizeKeys(Array.isArray(rawConfig?.orderKeys) ? rawConfig.orderKeys : DEFAULT_LINK_KEYS); + const orderSet = new Set(orderKeysRaw); + const orderKeys = [ + ...orderKeysRaw, + ...DEFAULT_LINK_KEYS.filter((key) => !orderSet.has(key)) ]; - const I18N = { - zh: { - menuOpenSettings: 'Better GitHub Nav: 打开设置面板', - menuResetSettings: 'Better GitHub Nav: 重置快捷链接配置', - menuLangZh: 'Better GitHub Nav: 界面语言 -> 中文', - menuLangEn: 'Better GitHub Nav: 界面语言 -> English', - menuLangAuto: 'Better GitHub Nav: 界面语言 -> 自动(跟随页面)', - resetConfirm: '确认重置快捷链接配置为默认值吗?', - panelTitle: 'Better GitHub Nav 设置', - panelDesc: '勾选决定显示项,拖动整行(或右侧手柄)调整显示顺序。', - resetDefault: '恢复默认', - cancel: '取消', - saveAndRefresh: '保存并刷新', - restoredPendingSave: '已恢复默认,点击保存后生效。', - atLeastOneLink: '至少保留 1 个快捷链接。', - dragHandleTitle: '拖动调整顺序', - dragRowTitle: '拖动整行调整顺序' - }, - en: { - menuOpenSettings: 'Better GitHub Nav: Open Settings Panel', - menuResetSettings: 'Better GitHub Nav: Reset Quick Link Config', - menuLangZh: 'Better GitHub Nav: UI Language -> 中文', - menuLangEn: 'Better GitHub Nav: UI Language -> English', - menuLangAuto: 'Better GitHub Nav: UI Language -> Auto (Follow Page)', - resetConfirm: 'Reset quick-link config to defaults?', - panelTitle: 'Better GitHub Nav Settings', - panelDesc: 'Select visible links and drag the row (or handle) to reorder.', - resetDefault: 'Reset to Default', - cancel: 'Cancel', - saveAndRefresh: 'Save and Refresh', - restoredPendingSave: 'Defaults restored. Click save to apply.', - atLeastOneLink: 'Keep at least 1 quick link.', - dragHandleTitle: 'Drag to reorder', - dragRowTitle: 'Drag row to reorder' - } + return { + enabledKeys: enabledKeys.length ? enabledKeys : DEFAULT_LINK_KEYS.slice(), + orderKeys: orderKeys.length ? orderKeys : DEFAULT_LINK_KEYS.slice() }; - let settingsEscHandler = null; - let uiLang = detectUiLang(); - - function t(key, vars = {}) { - const dict = I18N[uiLang] || I18N.en; - const fallback = I18N.en; - const template = dict[key] || fallback[key] || key; - return template.replace(/\{(\w+)\}/g, (_, varName) => String(vars[varName] ?? '')); + } + function loadConfig() { + try { + const raw = localStorage.getItem(CONFIG_STORAGE_KEY); + if (!raw) return sanitizeConfig({}); + return sanitizeConfig(JSON.parse(raw)); + } catch (e) { + return sanitizeConfig({}); } - - function detectUiLang() { - try { - const preferredLang = (localStorage.getItem(UI_LANG_STORAGE_KEY) || '').toLowerCase(); - if (preferredLang === 'zh' || preferredLang === 'en') return preferredLang; - } catch (e) { - // ignore storage read failure and fallback to auto detection + } + function saveConfig(config) { + localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(sanitizeConfig(config))); + } + function getConfiguredLinks(username) { + const config = loadConfig(); + const presetMap = new Map( + PRESET_LINKS.map((link) => [link.key, { + ...link, + id: `custom-gh-btn-${link.key}`, + href: link.getHref(username) + }]) + ); + return config.orderKeys.filter((key) => config.enabledKeys.includes(key)).map((key) => presetMap.get(key)).filter(Boolean); + } + function getDisplayNameByKey(key) { + const link = PRESET_LINKS.find((item) => item.key === key); + return link ? link.text : key; + } + + // src/styles.js + function ensureStyles() { + if (document.getElementById("custom-gh-nav-style")) return; + const style = document.createElement("style"); + style.id = "custom-gh-nav-style"; + style.textContent = ` + a.${CUSTOM_BUTTON_CLASS} { + border-radius: 6px; + padding-inline: 8px; + text-decoration: none; } - - const autoLang = (document.documentElement.lang || navigator.language || '').toLowerCase(); - return autoLang.startsWith('zh') ? 'zh' : 'en'; - } - - function setUiLangPreference(lang) { - try { - if (lang === 'zh' || lang === 'en') { - localStorage.setItem(UI_LANG_STORAGE_KEY, lang); - } else { - localStorage.removeItem(UI_LANG_STORAGE_KEY); - } - } catch (e) { - // ignore storage write failure; auto detection still works - } - uiLang = detectUiLang(); - } - - function sanitizeKeys(keys) { - const validSet = new Set(DEFAULT_LINK_KEYS); - const seen = new Set(); - const result = []; - keys.forEach(key => { - if (validSet.has(key) && !seen.has(key)) { - seen.add(key); - result.push(key); - } - }); - return result; - } - - function sanitizeConfig(rawConfig) { - const enabledKeys = sanitizeKeys(Array.isArray(rawConfig?.enabledKeys) ? rawConfig.enabledKeys : DEFAULT_LINK_KEYS); - const orderKeysRaw = sanitizeKeys(Array.isArray(rawConfig?.orderKeys) ? rawConfig.orderKeys : DEFAULT_LINK_KEYS); - const orderSet = new Set(orderKeysRaw); - const orderKeys = [ - ...orderKeysRaw, - ...DEFAULT_LINK_KEYS.filter(key => !orderSet.has(key)) - ]; - return { - enabledKeys: enabledKeys.length ? enabledKeys : DEFAULT_LINK_KEYS.slice(), - orderKeys: orderKeys.length ? orderKeys : DEFAULT_LINK_KEYS.slice() - }; - } - - function loadConfig() { - try { - const raw = localStorage.getItem(CONFIG_STORAGE_KEY); - if (!raw) return sanitizeConfig({}); - return sanitizeConfig(JSON.parse(raw)); - } catch (e) { - return sanitizeConfig({}); + a.${CUSTOM_BUTTON_CLASS}.${CUSTOM_BUTTON_COMPACT_CLASS} { + padding-inline: 4px; + } + a.${CUSTOM_BUTTON_CLASS}, + a.${CUSTOM_BUTTON_CLASS} span { + font-weight: 600; + } + a.${CUSTOM_BUTTON_CLASS}, + a.${CUSTOM_BUTTON_CLASS} * { + cursor: pointer; + } + a.${CUSTOM_BUTTON_CLASS}:hover { + background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.12)); + text-decoration: none; + } + a.${CUSTOM_BUTTON_CLASS}.${CUSTOM_BUTTON_ACTIVE_CLASS} { + background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.18)); + font-weight: 600; + } + #${SETTINGS_OVERLAY_ID} { + position: fixed; + inset: 0; + z-index: 2147483647; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + box-sizing: border-box; + } + #${SETTINGS_PANEL_ID} { + width: min(560px, 100%); + max-height: min(80vh, 720px); + overflow: auto; + background: var(--color-canvas-default, #fff); + color: var(--color-fg-default, #1f2328); + border: 1px solid var(--color-border-default, #d1d9e0); + border-radius: 10px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.25); + padding: 16px; + box-sizing: border-box; + } + .custom-gh-nav-settings-title { + margin: 0 0 8px; + font-size: 16px; + line-height: 1.4; + } + .custom-gh-nav-settings-desc { + margin: 0 0 12px; + color: var(--color-fg-muted, #656d76); + font-size: 13px; + } + .custom-gh-nav-settings-list { + display: flex; + flex-direction: column; + gap: 8px; + } + .custom-gh-nav-settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--color-border-muted, #d8dee4); + border-radius: 8px; + padding: 8px 10px; + background: var(--color-canvas-subtle, #f6f8fa); + cursor: grab; + } + .custom-gh-nav-settings-row:active { + cursor: grabbing; + } + .custom-gh-nav-settings-row-left { + display: inline-flex; + align-items: center; + gap: 8px; + user-select: none; + font-size: 13px; + } + .custom-gh-nav-settings-row-left input { + cursor: pointer; + } + .custom-gh-nav-settings-row-actions { + display: inline-flex; + align-items: center; + gap: 6px; + } + .custom-gh-nav-settings-drag-handle { + border: 1px solid var(--color-border-default, #d1d9e0); + background: var(--color-btn-bg, #f6f8fa); + color: var(--color-fg-muted, #656d76); + border-radius: 6px; + width: 32px; + height: 26px; + line-height: 1; + font-size: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + user-select: none; + pointer-events: none; + } + .custom-gh-nav-settings-row-dragging { + opacity: 0.55; + } + .custom-gh-nav-settings-row-drag-over { + border-color: var(--color-accent-fg, #0969da); + background: var(--color-accent-subtle, #ddf4ff); + } + .custom-gh-nav-settings-btn { + border: 1px solid var(--color-border-default, #d1d9e0); + background: var(--color-btn-bg, #f6f8fa); + color: var(--color-fg-default, #1f2328); + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + } + .custom-gh-nav-settings-btn:hover { + background: var(--color-btn-hover-bg, #f3f4f6); + } + .custom-gh-nav-settings-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + .custom-gh-nav-settings-btn-primary { + background: var(--color-btn-primary-bg, #1f883d); + border-color: var(--color-btn-primary-bg, #1f883d); + color: var(--color-btn-primary-text, #fff); + } + .custom-gh-nav-settings-btn-primary:hover { + background: var(--color-btn-primary-hover-bg, #1a7f37); + } + .custom-gh-nav-settings-footer { + margin-top: 12px; + display: flex; + justify-content: flex-end; + gap: 8px; + } + .custom-gh-nav-settings-message { + min-height: 20px; + margin-top: 8px; + color: var(--color-attention-fg, #9a6700); + font-size: 12px; } + `; + document.head.appendChild(style); + } + function setActiveStyle(aTag, active, compact = false) { + aTag.classList.add(CUSTOM_BUTTON_CLASS); + if (compact) { + aTag.classList.add(CUSTOM_BUTTON_COMPACT_CLASS); + } else { + aTag.classList.remove(CUSTOM_BUTTON_COMPACT_CLASS); } - - function saveConfig(config) { - localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(sanitizeConfig(config))); + if (active) { + aTag.setAttribute("aria-current", "page"); + aTag.classList.add(CUSTOM_BUTTON_ACTIVE_CLASS); + } else { + aTag.removeAttribute("aria-current"); + aTag.classList.remove(CUSTOM_BUTTON_ACTIVE_CLASS); } - - function getConfiguredLinks(username) { - const config = loadConfig(); - const presetMap = new Map( - PRESET_LINKS.map(link => [link.key, { - ...link, - id: `custom-gh-btn-${link.key}`, - href: link.getHref(username) - }]) - ); - return config.orderKeys - .filter(key => config.enabledKeys.includes(key)) - .map(key => presetMap.get(key)) - .filter(Boolean); + } + + // src/navigation.js + function normalizePath(href) { + try { + const url = new URL(href, location.origin); + const path = url.pathname.replace(/\/+$/, ""); + return path || "/"; + } catch (e) { + return ""; } - - function getDisplayNameByKey(key) { - const link = PRESET_LINKS.find(item => item.key === key); - return link ? link.text : key; + } + function isCurrentPage(linkPath) { + const currentPath = location.pathname.replace(/\/+$/, "") || "/"; + if (linkPath === "/dashboard") return currentPath === "/" || currentPath === "/dashboard"; + if (currentPath === linkPath) return true; + if (linkPath !== "/" && currentPath.startsWith(`${linkPath}/`)) return true; + return location.search.includes("tab=stars") && linkPath === normalizePath("/stars"); + } + function setLinkText(aTag, text) { + const innerSpan = aTag.querySelector("span"); + if (innerSpan) { + innerSpan.textContent = text; + } else { + aTag.textContent = text; } - - function closeConfigPanel() { - const overlay = document.getElementById(SETTINGS_OVERLAY_ID); - if (overlay) overlay.remove(); - if (settingsEscHandler) { - document.removeEventListener('keydown', settingsEscHandler); - settingsEscHandler = null; - } + } + function ensureAnchor(node, isLiParent) { + let aTag = isLiParent ? node.querySelector("a") : node.tagName.toLowerCase() === "a" ? node : node.querySelector("a"); + if (aTag) return aTag; + const fallbackText = (node.textContent || "").trim(); + const fallbackHref = !isLiParent && node.getAttribute && node.getAttribute("href") ? node.getAttribute("href") : `${location.pathname}${location.search}`; + const classSource = isLiParent ? node.querySelector('[class*="contextCrumb"], [class*="Breadcrumbs-Item"]') : node; + const spanTemplate = document.querySelector( + 'header a[class*="contextCrumb"] span[class*="contextCrumbLast"]' + ); + const spanSource = isLiParent ? node.querySelector("span") : node.querySelector("span"); + aTag = document.createElement("a"); + if (classSource && classSource.className) { + aTag.className = classSource.className.split(/\s+/).filter((cls) => cls && !cls.includes("contextCrumbStatic")).join(" "); } - - function createPanelState(config) { - const safeConfig = sanitizeConfig(config); - return { - order: safeConfig.orderKeys.slice(), - enabledSet: new Set(safeConfig.enabledKeys) - }; + if (spanSource && spanSource.className) { + const innerSpan = document.createElement("span"); + innerSpan.className = spanTemplate && spanTemplate.className ? spanTemplate.className : spanSource.className; + if (fallbackText) innerSpan.textContent = fallbackText; + aTag.appendChild(innerSpan); } - - function reorderKeys(state, draggedKey, targetKey, placeAfter = false) { - const fromIndex = state.order.indexOf(draggedKey); - const targetIndex = state.order.indexOf(targetKey); - if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return false; - - const [movedKey] = state.order.splice(fromIndex, 1); - let insertIndex = targetIndex + (placeAfter ? 1 : 0); - if (fromIndex < targetIndex) { - insertIndex -= 1; - } - state.order.splice(insertIndex, 0, movedKey); - return true; + if (!aTag.getAttribute("href") && fallbackHref) { + aTag.setAttribute("href", fallbackHref); } - - function clearDragClasses(listEl) { - const rows = listEl.querySelectorAll('.custom-gh-nav-settings-row'); - rows.forEach(row => { - row.classList.remove('custom-gh-nav-settings-row-dragging'); - row.classList.remove('custom-gh-nav-settings-row-drag-over'); - }); + if (!aTag.textContent.trim() && fallbackText) { + const innerSpan = aTag.querySelector("span"); + if (innerSpan) { + innerSpan.textContent = fallbackText; + } else { + aTag.textContent = fallbackText; + } } - - function renderPanelRows(listEl, state) { - listEl.replaceChildren(); - state.order.forEach(key => { - const row = document.createElement('div'); - row.className = 'custom-gh-nav-settings-row'; - row.draggable = true; - row.title = t('dragRowTitle'); - row.dataset.rowKey = key; - - const left = document.createElement('label'); - left.className = 'custom-gh-nav-settings-row-left'; - - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.checked = state.enabledSet.has(key); - checkbox.addEventListener('change', () => { - if (checkbox.checked) { - state.enabledSet.add(key); - } else { - state.enabledSet.delete(key); - } - }); - - const text = document.createElement('span'); - text.textContent = `${getDisplayNameByKey(key)} (${key})`; - - left.appendChild(checkbox); - left.appendChild(text); - - const actions = document.createElement('div'); - actions.className = 'custom-gh-nav-settings-row-actions'; - - const dragHandle = document.createElement('span'); - dragHandle.className = 'custom-gh-nav-settings-drag-handle'; - dragHandle.textContent = '≡'; - dragHandle.title = t('dragHandleTitle'); - dragHandle.setAttribute('aria-hidden', 'true'); - - row.addEventListener('dragstart', event => { - row.classList.add('custom-gh-nav-settings-row-dragging'); - listEl.dataset.dragKey = key; - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', key); - } - }); - row.addEventListener('dragend', () => { - delete listEl.dataset.dragKey; - clearDragClasses(listEl); - }); - - row.addEventListener('dragover', event => { - event.preventDefault(); - row.classList.add('custom-gh-nav-settings-row-drag-over'); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move'; - } - }); - row.addEventListener('dragleave', () => { - row.classList.remove('custom-gh-nav-settings-row-drag-over'); - }); - row.addEventListener('drop', event => { - event.preventDefault(); - row.classList.remove('custom-gh-nav-settings-row-drag-over'); - - const draggedKey = (event.dataTransfer && event.dataTransfer.getData('text/plain')) - || listEl.dataset.dragKey - || ''; - if (!draggedKey || draggedKey === key) return; - - const rect = row.getBoundingClientRect(); - const placeAfter = event.clientY > rect.top + rect.height / 2; - if (reorderKeys(state, draggedKey, key, placeAfter)) { - renderPanelRows(listEl, state); - } - }); - - actions.appendChild(dragHandle); - row.appendChild(left); - row.appendChild(actions); - listEl.appendChild(row); - }); + if (isLiParent) { + node.textContent = ""; + node.appendChild(aTag); + } else { + node.replaceChildren(aTag); } - - function openConfigPanel() { - closeConfigPanel(); - ensureStyles(); - - const state = createPanelState(loadConfig()); - const overlay = document.createElement('div'); - overlay.id = SETTINGS_OVERLAY_ID; - - const panel = document.createElement('div'); - panel.id = SETTINGS_PANEL_ID; - - const title = document.createElement('h3'); - title.className = 'custom-gh-nav-settings-title'; - title.textContent = t('panelTitle'); - - const desc = document.createElement('p'); - desc.className = 'custom-gh-nav-settings-desc'; - desc.textContent = t('panelDesc'); - - const list = document.createElement('div'); - list.className = 'custom-gh-nav-settings-list'; - renderPanelRows(list, state); - - const message = document.createElement('div'); - message.id = SETTINGS_MESSAGE_ID; - message.className = 'custom-gh-nav-settings-message'; - message.setAttribute('role', 'status'); - message.setAttribute('aria-live', 'polite'); - - const footer = document.createElement('div'); - footer.className = 'custom-gh-nav-settings-footer'; - - const resetBtn = document.createElement('button'); - resetBtn.type = 'button'; - resetBtn.className = 'custom-gh-nav-settings-btn'; - resetBtn.textContent = t('resetDefault'); - resetBtn.addEventListener('click', () => { - state.order = DEFAULT_LINK_KEYS.slice(); - state.enabledSet = new Set(DEFAULT_LINK_KEYS); - renderPanelRows(list, state); - message.textContent = t('restoredPendingSave'); - }); - - const cancelBtn = document.createElement('button'); - cancelBtn.type = 'button'; - cancelBtn.className = 'custom-gh-nav-settings-btn'; - cancelBtn.textContent = t('cancel'); - cancelBtn.addEventListener('click', closeConfigPanel); - - const saveBtn = document.createElement('button'); - saveBtn.type = 'button'; - saveBtn.className = 'custom-gh-nav-settings-btn custom-gh-nav-settings-btn-primary'; - saveBtn.textContent = t('saveAndRefresh'); - saveBtn.addEventListener('click', () => { - const enabledKeys = state.order.filter(key => state.enabledSet.has(key)); - if (!enabledKeys.length) { - message.textContent = t('atLeastOneLink'); - return; - } - saveConfig({ - enabledKeys, - orderKeys: state.order.slice() - }); - closeConfigPanel(); - location.reload(); - }); - - footer.appendChild(resetBtn); - footer.appendChild(cancelBtn); - footer.appendChild(saveBtn); - - panel.appendChild(title); - panel.appendChild(desc); - panel.appendChild(list); - panel.appendChild(message); - panel.appendChild(footer); - overlay.appendChild(panel); - - overlay.addEventListener('click', event => { - if (event.target === overlay) closeConfigPanel(); - }); - - settingsEscHandler = event => { - if (event.key === 'Escape') closeConfigPanel(); - }; - document.addEventListener('keydown', settingsEscHandler); - - document.body.appendChild(overlay); + return aTag; + } + function getAnchorHostNode(anchor) { + if (!anchor || !anchor.parentNode) return anchor; + return anchor.parentNode.tagName.toLowerCase() === "li" ? anchor.parentNode : anchor; + } + function cleanupQuickLinksForContainer(renderParent, keepNode) { + const quickAnchors = Array.from( + document.querySelectorAll( + 'header a[id^="custom-gh-btn-"], header a[' + QUICK_LINK_MARK_ATTR + '="1"]' + ) + ); + quickAnchors.forEach((anchor) => { + const host = getAnchorHostNode(anchor); + if (!host || !host.parentNode) return; + if (host === keepNode) return; + if (host.parentNode !== renderParent) { + host.remove(); + return; + } + host.remove(); + }); + } + function addCustomButtons() { + const userLoginMeta = document.querySelector('meta[name="user-login"]'); + const username = userLoginMeta ? userLoginMeta.getAttribute("content") : ""; + const navPresetLinks = getConfiguredLinks(username); + if (!navPresetLinks.length) return; + const primaryLink = navPresetLinks[0]; + const extraLinks = navPresetLinks.slice(1); + const fixedPages = /* @__PURE__ */ new Set(["/dashboard", "/trending", "/explore", "/collections"]); + const shortcutPaths = new Set(PRESET_LINKS.map((link) => link.path)); + const compactPages = /* @__PURE__ */ new Set(["/issues", "/pulls", "/repositories"]); + const isOnPresetPage = Array.from(fixedPages).some((path) => isCurrentPage(path)); + const shouldUseCompactButtons = Array.from(compactPages).some((path) => isCurrentPage(path)); + let targetNode = null; + let targetSource = ""; + if (isOnPresetPage) { + targetNode = document.querySelector( + 'header nav a[href="/dashboard"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav a[href="/trending"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav a[href="/explore"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' + ); + if (targetNode) targetSource = "preset-nav"; + if (!targetNode) { + targetNode = document.querySelector( + 'header nav a[id^="custom-gh-btn-"], header nav a[' + QUICK_LINK_MARK_ATTR + '="1"]' + ); + if (targetNode) targetSource = "preset-quick"; + } + } else { + const breadcrumbNodes = Array.from(document.querySelectorAll( + 'header nav[aria-label*="breadcrumb" i] a[href^="/"], header a[class*="contextCrumb"][href^="/"], header a[class*="Breadcrumbs-Item"][href^="/"]' + )).filter((link) => { + if (link.id && link.id.startsWith("custom-gh-btn-")) return false; + if (link.getAttribute(QUICK_LINK_MARK_ATTR) === "1") return false; + const href = normalizePath(link.getAttribute("href") || ""); + if (!href || href === "/") return false; + if (shortcutPaths.has(href)) return false; + return true; + }); + if (breadcrumbNodes.length) { + targetNode = breadcrumbNodes[breadcrumbNodes.length - 1]; + targetSource = "breadcrumb"; + } } - - function registerConfigMenu() { - if (typeof GM_registerMenuCommand !== 'function') return; - GM_registerMenuCommand(t('menuOpenSettings'), openConfigPanel); - - GM_registerMenuCommand(t('menuResetSettings'), () => { - const shouldReset = confirm(t('resetConfirm')); - if (!shouldReset) return; - localStorage.removeItem(CONFIG_STORAGE_KEY); - closeConfigPanel(); - location.reload(); - }); - - GM_registerMenuCommand(t('menuLangZh'), () => { - setUiLangPreference('zh'); - closeConfigPanel(); - location.reload(); - }); - - GM_registerMenuCommand(t('menuLangEn'), () => { - setUiLangPreference('en'); - closeConfigPanel(); - location.reload(); - }); - - GM_registerMenuCommand(t('menuLangAuto'), () => { - setUiLangPreference('auto'); - closeConfigPanel(); - location.reload(); - }); + if (!targetNode) { + targetNode = document.querySelector( + 'header nav a[aria-current="page"]:not([id^="custom-gh-btn-"]), header nav a[data-active="true"]:not([id^="custom-gh-btn-"]), header nav [aria-current="page"]:not(a), header nav [data-active="true"]:not(a)' + ); + if (targetNode) targetSource = "current-nav"; } - - function normalizePath(href) { - try { - const url = new URL(href, location.origin); - const path = url.pathname.replace(/\/+$/, ''); - return path || '/'; - } catch (e) { - return ''; + if (!targetNode) { + const navLinks = document.querySelectorAll( + 'header a:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' + ); + for (const link of navLinks) { + const text = link.textContent.trim().toLowerCase(); + const href = link.getAttribute("href"); + if (text === "dashboard" || href === "/dashboard") { + targetNode = link; + targetSource = "legacy-dashboard"; + break; } + } } - - function isCurrentPage(linkPath) { - const currentPath = location.pathname.replace(/\/+$/, '') || '/'; - if (linkPath === '/dashboard') return currentPath === '/' || currentPath === '/dashboard'; - if (currentPath === linkPath) return true; - if (linkPath !== '/' && currentPath.startsWith(`${linkPath}/`)) return true; - - // Stars 页面常见为 /?tab=stars - return location.search.includes('tab=stars') && linkPath === normalizePath('/stars'); + if (!targetNode) { + const currentPath = location.pathname.replace(/\/+$/, "") || "/"; + const globalNavCandidates = Array.from( + document.querySelectorAll( + 'header nav[aria-label*="global" i] a[href^="/"], header nav[aria-label*="header" i] a[href^="/"], header nav a[href="/pulls"], header nav a[href="/issues"], header nav a[href="/repositories"], header nav a[href="/codespaces"], header nav a[href="/marketplace"], header nav a[href="/explore"]' + ) + ).filter((link) => { + const href = normalizePath(link.getAttribute("href") || ""); + if (!href || href === "/") return false; + if (link.id && link.id.startsWith("custom-gh-btn-")) return false; + if (link.getAttribute(QUICK_LINK_MARK_ATTR) === "1") return false; + return true; + }); + if (globalNavCandidates.length) { + targetNode = globalNavCandidates.find((link) => { + const href = normalizePath(link.getAttribute("href") || ""); + return href === currentPath; + }) || globalNavCandidates[globalNavCandidates.length - 1]; + if (targetNode) targetSource = "global-nav"; + } } - - function ensureStyles() { - if (document.getElementById('custom-gh-nav-style')) return; - const style = document.createElement('style'); - style.id = 'custom-gh-nav-style'; - style.textContent = ` - a.${CUSTOM_BUTTON_CLASS} { - border-radius: 6px; - padding-inline: 8px; - text-decoration: none; - } - a.${CUSTOM_BUTTON_CLASS}.${CUSTOM_BUTTON_COMPACT_CLASS} { - padding-inline: 4px; - } - a.${CUSTOM_BUTTON_CLASS}, - a.${CUSTOM_BUTTON_CLASS} span { - font-weight: 600; - } - a.${CUSTOM_BUTTON_CLASS}, - a.${CUSTOM_BUTTON_CLASS} * { - cursor: pointer; - } - a.${CUSTOM_BUTTON_CLASS}:hover { - background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.12)); - text-decoration: none; - } - a.${CUSTOM_BUTTON_CLASS}.${CUSTOM_BUTTON_ACTIVE_CLASS} { - background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.18)); - font-weight: 600; - } - #${SETTINGS_OVERLAY_ID} { - position: fixed; - inset: 0; - z-index: 2147483647; - background: rgba(0, 0, 0, 0.45); - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - box-sizing: border-box; - } - #${SETTINGS_PANEL_ID} { - width: min(560px, 100%); - max-height: min(80vh, 720px); - overflow: auto; - background: var(--color-canvas-default, #fff); - color: var(--color-fg-default, #1f2328); - border: 1px solid var(--color-border-default, #d1d9e0); - border-radius: 10px; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.25); - padding: 16px; - box-sizing: border-box; - } - .custom-gh-nav-settings-title { - margin: 0 0 8px; - font-size: 16px; - line-height: 1.4; - } - .custom-gh-nav-settings-desc { - margin: 0 0 12px; - color: var(--color-fg-muted, #656d76); - font-size: 13px; - } - .custom-gh-nav-settings-list { - display: flex; - flex-direction: column; - gap: 8px; - } - .custom-gh-nav-settings-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - border: 1px solid var(--color-border-muted, #d8dee4); - border-radius: 8px; - padding: 8px 10px; - background: var(--color-canvas-subtle, #f6f8fa); - cursor: grab; - } - .custom-gh-nav-settings-row:active { - cursor: grabbing; - } - .custom-gh-nav-settings-row-left { - display: inline-flex; - align-items: center; - gap: 8px; - user-select: none; - font-size: 13px; - } - .custom-gh-nav-settings-row-left input { - cursor: pointer; - } - .custom-gh-nav-settings-row-actions { - display: inline-flex; - align-items: center; - gap: 6px; - } - .custom-gh-nav-settings-drag-handle { - border: 1px solid var(--color-border-default, #d1d9e0); - background: var(--color-btn-bg, #f6f8fa); - color: var(--color-fg-muted, #656d76); - border-radius: 6px; - width: 32px; - height: 26px; - line-height: 1; - font-size: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - user-select: none; - pointer-events: none; - } - .custom-gh-nav-settings-row-dragging { - opacity: 0.55; - } - .custom-gh-nav-settings-row-drag-over { - border-color: var(--color-accent-fg, #0969da); - background: var(--color-accent-subtle, #ddf4ff); - } - .custom-gh-nav-settings-btn { - border: 1px solid var(--color-border-default, #d1d9e0); - background: var(--color-btn-bg, #f6f8fa); - color: var(--color-fg-default, #1f2328); - border-radius: 6px; - padding: 4px 10px; - font-size: 12px; - cursor: pointer; - } - .custom-gh-nav-settings-btn:hover { - background: var(--color-btn-hover-bg, #f3f4f6); - } - .custom-gh-nav-settings-btn:disabled { - opacity: 0.45; - cursor: not-allowed; - } - .custom-gh-nav-settings-btn-primary { - background: var(--color-btn-primary-bg, #1f883d); - border-color: var(--color-btn-primary-bg, #1f883d); - color: var(--color-btn-primary-text, #fff); - } - .custom-gh-nav-settings-btn-primary:hover { - background: var(--color-btn-primary-hover-bg, #1a7f37); - } - .custom-gh-nav-settings-footer { - margin-top: 12px; - display: flex; - justify-content: flex-end; - gap: 8px; - } - .custom-gh-nav-settings-message { - min-height: 20px; - margin-top: 8px; - color: var(--color-attention-fg, #9a6700); - font-size: 12px; - } - `; - document.head.appendChild(style); + if (!targetNode) { + const currentTextNode = document.querySelector( + 'header nav [aria-current="page"]:not(a), header nav [data-active="true"]:not(a)' + ); + if (currentTextNode) { + targetNode = currentTextNode; + targetSource = "current-text"; + } } - - function setActiveStyle(aTag, active, compact = false) { - aTag.classList.add(CUSTOM_BUTTON_CLASS); - if (compact) { - aTag.classList.add(CUSTOM_BUTTON_COMPACT_CLASS); - } else { - aTag.classList.remove(CUSTOM_BUTTON_COMPACT_CLASS); - } - if (active) { - aTag.setAttribute('aria-current', 'page'); - aTag.classList.add(CUSTOM_BUTTON_ACTIVE_CLASS); - } else { - aTag.removeAttribute('aria-current'); - aTag.classList.remove(CUSTOM_BUTTON_ACTIVE_CLASS); - } + if (!targetNode) { + const contextCrumbTextNodes = document.querySelectorAll( + 'header span[class*="contextCrumbStatic"], header span[class*="contextCrumb"][class*="Breadcrumbs-Item"], header .prc-Breadcrumbs-Item-jcraJ' + ); + if (contextCrumbTextNodes.length) { + targetNode = contextCrumbTextNodes[contextCrumbTextNodes.length - 1]; + targetSource = "crumb-text"; + } } - - function setLinkText(aTag, text) { - const innerSpan = aTag.querySelector('span'); - if (innerSpan) { - innerSpan.textContent = text; - } else { - aTag.textContent = text; + let templateNode = targetNode; + if (targetNode) { + const localNav = targetNode.closest("nav, ul, ol"); + const localAnchors = localNav ? localNav.querySelectorAll( + 'a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' + ) : []; + if (localAnchors.length) { + templateNode = localAnchors[localAnchors.length - 1]; + } else { + const nativeNavAnchors = document.querySelectorAll( + 'header nav[aria-label*="breadcrumb" i] a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header a[class*="contextCrumb"][href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header a[class*="Breadcrumbs-Item"][href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav[aria-label*="global" i] a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav[aria-label*="header" i] a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav a[href="/pulls"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav a[href="/issues"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav a[href="/repositories"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav a[href="/codespaces"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav a[href="/marketplace"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), header nav a[href="/explore"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' + ); + if (nativeNavAnchors.length) { + templateNode = nativeNavAnchors[nativeNavAnchors.length - 1]; } + } } - - function ensureAnchor(node, isLiParent) { - let aTag = isLiParent ? node.querySelector('a') : (node.tagName.toLowerCase() === 'a' ? node : node.querySelector('a')); - if (aTag) return aTag; - - const fallbackText = (node.textContent || '').trim(); - const fallbackHref = (!isLiParent && node.getAttribute && node.getAttribute('href')) - ? node.getAttribute('href') - : `${location.pathname}${location.search}`; - const classSource = isLiParent - ? node.querySelector('[class*="contextCrumb"], [class*="Breadcrumbs-Item"]') - : node; - const spanTemplate = document.querySelector( - 'header a[class*="contextCrumb"] span[class*="contextCrumbLast"]' - ); - const spanSource = isLiParent ? node.querySelector('span') : node.querySelector('span'); - - aTag = document.createElement('a'); - if (classSource && classSource.className) { - aTag.className = classSource.className - .split(/\s+/) - .filter(cls => cls && !cls.includes('contextCrumbStatic')) - .join(' '); - } - if (spanSource && spanSource.className) { - const innerSpan = document.createElement('span'); - innerSpan.className = spanTemplate && spanTemplate.className - ? spanTemplate.className - : spanSource.className; - if (fallbackText) innerSpan.textContent = fallbackText; - aTag.appendChild(innerSpan); - } - if (!aTag.getAttribute('href') && fallbackHref) { - aTag.setAttribute('href', fallbackHref); - } - if (!aTag.textContent.trim() && fallbackText) { - const innerSpan = aTag.querySelector('span'); - if (innerSpan) { - innerSpan.textContent = fallbackText; - } else { - aTag.textContent = fallbackText; - } + if (targetNode) { + const isTargetLiParent = targetNode.parentNode.tagName.toLowerCase() === "li"; + const insertAnchorNode = isTargetLiParent ? targetNode.parentNode : targetNode; + const isTemplateLiParent = templateNode.parentNode.tagName.toLowerCase() === "li"; + const cloneTemplateNode = isTemplateLiParent ? templateNode.parentNode : templateNode; + const targetHasAnchor = isTargetLiParent ? Boolean(insertAnchorNode.querySelector("a")) : insertAnchorNode.tagName.toLowerCase() === "a" || Boolean(insertAnchorNode.querySelector("a")); + const shouldForceCreateAnchor = !targetHasAnchor && Boolean(targetNode.closest("header nav")); + const anchorTag = targetHasAnchor || shouldForceCreateAnchor ? ensureAnchor(insertAnchorNode, isTargetLiParent) : null; + cleanupQuickLinksForContainer(insertAnchorNode.parentNode, insertAnchorNode); + const hasShortcutActive = navPresetLinks.some((link) => isCurrentPage(link.path)); + if (isOnPresetPage && anchorTag && primaryLink) { + anchorTag.id = primaryLink.id; + anchorTag.setAttribute(QUICK_LINK_MARK_ATTR, "1"); + anchorTag.href = primaryLink.href; + setLinkText(anchorTag, primaryLink.text); + setActiveStyle(anchorTag, isCurrentPage(primaryLink.path), shouldUseCompactButtons); + } else { + if (anchorTag && anchorTag.id && anchorTag.id.startsWith("custom-gh-btn-")) { + anchorTag.removeAttribute("id"); } - - if (isLiParent) { - node.textContent = ''; - node.appendChild(aTag); - } else { - node.replaceChildren(aTag); + if (anchorTag) { + anchorTag.removeAttribute(QUICK_LINK_MARK_ATTR); + } + if (anchorTag) { + setActiveStyle(anchorTag, !hasShortcutActive, shouldUseCompactButtons); } - return aTag; + } + let insertAfterNode = insertAnchorNode; + const linksToRender = isOnPresetPage ? extraLinks : navPresetLinks; + linksToRender.forEach((linkInfo) => { + const newNode = cloneTemplateNode.cloneNode(true); + const aTag = ensureAnchor(newNode, isTemplateLiParent); + aTag.id = linkInfo.id; + aTag.setAttribute(QUICK_LINK_MARK_ATTR, "1"); + aTag.href = linkInfo.href; + setLinkText(aTag, linkInfo.text); + setActiveStyle(aTag, isCurrentPage(linkInfo.path), shouldUseCompactButtons); + insertAfterNode.parentNode.insertBefore(newNode, insertAfterNode.nextSibling); + insertAfterNode = newNode; + }); } - - function getAnchorHostNode(anchor) { - if (!anchor || !anchor.parentNode) return anchor; - return anchor.parentNode.tagName.toLowerCase() === 'li' ? anchor.parentNode : anchor; + } + + // src/i18n.js + var uiLang = detectUiLang(); + function t(key, vars = {}) { + const dict = I18N[uiLang] || I18N.en; + const fallback = I18N.en; + const template = dict[key] || fallback[key] || key; + return template.replace(/\{(\w+)\}/g, (_, varName) => String(vars[varName] ?? "")); + } + function detectUiLang() { + try { + const preferredLang = (localStorage.getItem(UI_LANG_STORAGE_KEY) || "").toLowerCase(); + if (preferredLang === "zh" || preferredLang === "en") return preferredLang; + } catch (e) { } - - function cleanupQuickLinksForContainer(renderParent, keepNode) { - const quickAnchors = Array.from( - document.querySelectorAll( - 'header a[id^="custom-gh-btn-"], header a[' + QUICK_LINK_MARK_ATTR + '="1"]' - ) - ); - - quickAnchors.forEach(anchor => { - const host = getAnchorHostNode(anchor); - if (!host || !host.parentNode) return; - if (host === keepNode) return; - if (host.parentNode !== renderParent) { - host.remove(); - return; - } - host.remove(); - }); + const autoLang = (document.documentElement.lang || navigator.language || "").toLowerCase(); + return autoLang.startsWith("zh") ? "zh" : "en"; + } + function setUiLangPreference(lang) { + try { + if (lang === "zh" || lang === "en") { + localStorage.setItem(UI_LANG_STORAGE_KEY, lang); + } else { + localStorage.removeItem(UI_LANG_STORAGE_KEY); + } + } catch (e) { } - - function addCustomButtons() { - // 获取当前登录的用户名,用来动态生成 Stars 页面的专属链接 - const userLoginMeta = document.querySelector('meta[name="user-login"]'); - const username = userLoginMeta ? userLoginMeta.getAttribute('content') : ''; - const navPresetLinks = getConfiguredLinks(username); - if (!navPresetLinks.length) return; - const primaryLink = navPresetLinks[0]; - const extraLinks = navPresetLinks.slice(1); - const fixedPages = new Set(['/dashboard', '/trending', '/explore', '/collections']); - const shortcutPaths = new Set(PRESET_LINKS.map(link => link.path)); - const compactPages = new Set(['/issues', '/pulls', '/repositories']); - - const isOnPresetPage = Array.from(fixedPages).some(path => isCurrentPage(path)); - const shouldUseCompactButtons = Array.from(compactPages).some(path => isCurrentPage(path)); - - // 预设页面优先主导航;其他页面优先 breadcrumb/context crumb 的最后一项(如仓库名) - let targetNode = null; - let targetSource = ''; - if (isOnPresetPage) { - targetNode = document.querySelector( - 'header nav a[href="/dashboard"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav a[href="/trending"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav a[href="/explore"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' - ); - if (targetNode) targetSource = 'preset-nav'; - if (!targetNode) { - targetNode = document.querySelector( - 'header nav a[id^="custom-gh-btn-"], header nav a[' + QUICK_LINK_MARK_ATTR + '="1"]' - ); - if (targetNode) targetSource = 'preset-quick'; - } + uiLang = detectUiLang(); + } + + // src/settings-panel.js + var settingsEscHandler = null; + function closeConfigPanel() { + const overlay = document.getElementById(SETTINGS_OVERLAY_ID); + if (overlay) overlay.remove(); + if (settingsEscHandler) { + document.removeEventListener("keydown", settingsEscHandler); + settingsEscHandler = null; + } + } + function createPanelState(config) { + const safeConfig = sanitizeConfig(config); + return { + order: safeConfig.orderKeys.slice(), + enabledSet: new Set(safeConfig.enabledKeys) + }; + } + function reorderKeys(state, draggedKey, targetKey, placeAfter = false) { + const fromIndex = state.order.indexOf(draggedKey); + const targetIndex = state.order.indexOf(targetKey); + if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return false; + const [movedKey] = state.order.splice(fromIndex, 1); + let insertIndex = targetIndex + (placeAfter ? 1 : 0); + if (fromIndex < targetIndex) { + insertIndex -= 1; + } + state.order.splice(insertIndex, 0, movedKey); + return true; + } + function clearDragClasses(listEl) { + const rows = listEl.querySelectorAll(".custom-gh-nav-settings-row"); + rows.forEach((row) => { + row.classList.remove("custom-gh-nav-settings-row-dragging"); + row.classList.remove("custom-gh-nav-settings-row-drag-over"); + }); + } + function renderPanelRows(listEl, state) { + listEl.replaceChildren(); + state.order.forEach((key) => { + const row = document.createElement("div"); + row.className = "custom-gh-nav-settings-row"; + row.draggable = true; + row.title = t("dragRowTitle"); + row.dataset.rowKey = key; + const left = document.createElement("label"); + left.className = "custom-gh-nav-settings-row-left"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = state.enabledSet.has(key); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + state.enabledSet.add(key); } else { - const breadcrumbNodes = Array.from(document.querySelectorAll( - 'header nav[aria-label*="breadcrumb" i] a[href^="/"], ' + - 'header a[class*="contextCrumb"][href^="/"], ' + - 'header a[class*="Breadcrumbs-Item"][href^="/"]' - )).filter(link => { - if (link.id && link.id.startsWith('custom-gh-btn-')) return false; - if (link.getAttribute(QUICK_LINK_MARK_ATTR) === '1') return false; - const href = normalizePath(link.getAttribute('href') || ''); - if (!href || href === '/') return false; - if (shortcutPaths.has(href)) return false; - return true; - }); - if (breadcrumbNodes.length) { - targetNode = breadcrumbNodes[breadcrumbNodes.length - 1]; - targetSource = 'breadcrumb'; - } + state.enabledSet.delete(key); } - - // 全局导航中优先使用当前页项,避免误选最后一个导航按钮导致当前页无高亮 - if (!targetNode) { - targetNode = document.querySelector( - 'header nav a[aria-current="page"]:not([id^="custom-gh-btn-"]), ' + - 'header nav a[data-active="true"]:not([id^="custom-gh-btn-"]), ' + - 'header nav [aria-current="page"]:not(a), ' + - 'header nav [data-active="true"]:not(a)' - ); - if (targetNode) targetSource = 'current-nav'; + }); + const text = document.createElement("span"); + text.textContent = `${getDisplayNameByKey(key)} (${key})`; + left.appendChild(checkbox); + left.appendChild(text); + const actions = document.createElement("div"); + actions.className = "custom-gh-nav-settings-row-actions"; + const dragHandle = document.createElement("span"); + dragHandle.className = "custom-gh-nav-settings-drag-handle"; + dragHandle.textContent = "≡"; + dragHandle.title = t("dragHandleTitle"); + dragHandle.setAttribute("aria-hidden", "true"); + row.addEventListener("dragstart", (event) => { + row.classList.add("custom-gh-nav-settings-row-dragging"); + listEl.dataset.dragKey = key; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", key); } - - // 兼容兜底:若未找到主导航,再尝试旧规则 - if (!targetNode) { - const navLinks = document.querySelectorAll( - 'header a:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' - ); - for (let link of navLinks) { - const text = link.textContent.trim().toLowerCase(); - const href = link.getAttribute('href'); - if (text === 'dashboard' || href === '/dashboard') { - targetNode = link; - targetSource = 'legacy-dashboard'; - break; - } - } + }); + row.addEventListener("dragend", () => { + delete listEl.dataset.dragKey; + clearDragClasses(listEl); + }); + row.addEventListener("dragover", (event) => { + event.preventDefault(); + row.classList.add("custom-gh-nav-settings-row-drag-over"); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; } - - // 通用兜底:在有全局导航的页面(如 /pulls /issues /repositories)优先按当前路径匹配 - if (!targetNode) { - const currentPath = location.pathname.replace(/\/+$/, '') || '/'; - const globalNavCandidates = Array.from( - document.querySelectorAll( - 'header nav[aria-label*="global" i] a[href^="/"], ' + - 'header nav[aria-label*="header" i] a[href^="/"], ' + - 'header nav a[href="/pulls"], ' + - 'header nav a[href="/issues"], ' + - 'header nav a[href="/repositories"], ' + - 'header nav a[href="/codespaces"], ' + - 'header nav a[href="/marketplace"], ' + - 'header nav a[href="/explore"]' - ) - ).filter(link => { - const href = normalizePath(link.getAttribute('href') || ''); - if (!href || href === '/') return false; - if (link.id && link.id.startsWith('custom-gh-btn-')) return false; - if (link.getAttribute(QUICK_LINK_MARK_ATTR) === '1') return false; - return true; - }); - if (globalNavCandidates.length) { - targetNode = globalNavCandidates.find(link => { - const href = normalizePath(link.getAttribute('href') || ''); - return href === currentPath; - }) || globalNavCandidates[globalNavCandidates.length - 1]; - if (targetNode) targetSource = 'global-nav'; - } - } - - // 文本型当前项兜底:部分页面当前导航项是不可点击文本(非 a) - if (!targetNode) { - const currentTextNode = document.querySelector( - 'header nav [aria-current="page"]:not(a), ' + - 'header nav [data-active="true"]:not(a)' - ); - if (currentTextNode) { - targetNode = currentTextNode; - targetSource = 'current-text'; - } + }); + row.addEventListener("dragleave", () => { + row.classList.remove("custom-gh-nav-settings-row-drag-over"); + }); + row.addEventListener("drop", (event) => { + event.preventDefault(); + row.classList.remove("custom-gh-nav-settings-row-drag-over"); + const draggedKey = event.dataTransfer && event.dataTransfer.getData("text/plain") || listEl.dataset.dragKey || ""; + if (!draggedKey || draggedKey === key) return; + const rect = row.getBoundingClientRect(); + const placeAfter = event.clientY > rect.top + rect.height / 2; + if (reorderKeys(state, draggedKey, key, placeAfter)) { + renderPanelRows(listEl, state); } - - // context crumb 文本项兜底:如 Issues/PRs 页为 span 而非 a - if (!targetNode) { - const contextCrumbTextNodes = document.querySelectorAll( - 'header span[class*="contextCrumbStatic"], ' + - 'header span[class*="contextCrumb"][class*="Breadcrumbs-Item"], ' + - 'header .prc-Breadcrumbs-Item-jcraJ' - ); - if (contextCrumbTextNodes.length) { - targetNode = contextCrumbTextNodes[contextCrumbTextNodes.length - 1]; - targetSource = 'crumb-text'; - } - } - - // 样式模板优先使用同容器内可点击链接,避免从纯文本节点克隆导致样式不一致 - let templateNode = targetNode; - if (targetNode) { - const localNav = targetNode.closest('nav, ul, ol'); - const localAnchors = localNav - ? localNav.querySelectorAll( - 'a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' - ) - : []; - - if (localAnchors.length) { - templateNode = localAnchors[localAnchors.length - 1]; - } else { - const nativeNavAnchors = document.querySelectorAll( - 'header nav[aria-label*="breadcrumb" i] a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header a[class*="contextCrumb"][href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header a[class*="Breadcrumbs-Item"][href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav[aria-label*="global" i] a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav[aria-label*="header" i] a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav a[href="/pulls"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav a[href="/issues"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav a[href="/repositories"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav a[href="/codespaces"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav a[href="/marketplace"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + - 'header nav a[href="/explore"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' - ); - if (nativeNavAnchors.length) { - templateNode = nativeNavAnchors[nativeNavAnchors.length - 1]; - } - } - } - - if (targetNode) { - // targetNode 用于决定插入位置,templateNode 用于克隆样式 - const isTargetLiParent = targetNode.parentNode.tagName.toLowerCase() === 'li'; - const insertAnchorNode = isTargetLiParent ? targetNode.parentNode : targetNode; - const isTemplateLiParent = templateNode.parentNode.tagName.toLowerCase() === 'li'; - const cloneTemplateNode = isTemplateLiParent ? templateNode.parentNode : templateNode; - const targetHasAnchor = isTargetLiParent - ? Boolean(insertAnchorNode.querySelector('a')) - : insertAnchorNode.tagName.toLowerCase() === 'a' || Boolean(insertAnchorNode.querySelector('a')); - const shouldForceCreateAnchor = !targetHasAnchor && Boolean(targetNode.closest('header nav')); - const anchorTag = (targetHasAnchor || shouldForceCreateAnchor) - ? ensureAnchor(insertAnchorNode, isTargetLiParent) - : null; - cleanupQuickLinksForContainer(insertAnchorNode.parentNode, insertAnchorNode); - - const hasShortcutActive = navPresetLinks.some(link => isCurrentPage(link.path)); - - if (isOnPresetPage && anchorTag && primaryLink) { - // 预设页面:首个按钮替换为当前配置顺序中的第一个 - anchorTag.id = primaryLink.id; - anchorTag.setAttribute(QUICK_LINK_MARK_ATTR, '1'); - anchorTag.href = primaryLink.href; - setLinkText(anchorTag, primaryLink.text); - setActiveStyle(anchorTag, isCurrentPage(primaryLink.path), shouldUseCompactButtons); - } else { - // 其他页面:保留原生当前按钮,仅做高亮 - if (anchorTag && anchorTag.id && anchorTag.id.startsWith('custom-gh-btn-')) { - anchorTag.removeAttribute('id'); - } - if (anchorTag) { - anchorTag.removeAttribute(QUICK_LINK_MARK_ATTR); - } - // 若快捷按钮已有命中(如 Stars 页),则避免双高亮 - if (anchorTag) { - setActiveStyle(anchorTag, !hasShortcutActive, shouldUseCompactButtons); - } - } - - // 设定插入的锚点,随着循环不断向后移动,保证按钮顺序正确 - let insertAfterNode = insertAnchorNode; - const linksToRender = isOnPresetPage ? extraLinks : navPresetLinks; - - linksToRender.forEach(linkInfo => { - const newNode = cloneTemplateNode.cloneNode(true); - const aTag = ensureAnchor(newNode, isTemplateLiParent); - - aTag.id = linkInfo.id; - aTag.setAttribute(QUICK_LINK_MARK_ATTR, '1'); - aTag.href = linkInfo.href; - setLinkText(aTag, linkInfo.text); - - setActiveStyle(aTag, isCurrentPage(linkInfo.path), shouldUseCompactButtons); - - // 将新按钮插入到锚点之后,并更新锚点 - insertAfterNode.parentNode.insertBefore(newNode, insertAfterNode.nextSibling); - insertAfterNode = newNode; - }); - } - } - - // 1. 页面初次加载时执行 - console.info(`[Better GitHub Navigation] loaded v${SCRIPT_VERSION}`); - window.__betterGithubNavVersion = SCRIPT_VERSION; - window.__openBetterGithubNavSettings = openConfigPanel; - registerConfigMenu(); + }); + actions.appendChild(dragHandle); + row.appendChild(left); + row.appendChild(actions); + listEl.appendChild(row); + }); + } + function openConfigPanel() { + closeConfigPanel(); ensureStyles(); - addCustomButtons(); - - // 2. 监听 GitHub 的 Turbo/PJAX 页面跳转事件,防止切换页面后按钮消失 - document.addEventListener('turbo:load', addCustomButtons); - document.addEventListener('pjax:end', addCustomButtons); - - // 3. 终极备用方案:使用 MutationObserver 监听 DOM 变化 - const observer = new MutationObserver(() => { - if (!document.querySelector('[id^="custom-gh-btn-"]') && document.querySelector('header')) { - addCustomButtons(); - } + const state = createPanelState(loadConfig()); + const overlay = document.createElement("div"); + overlay.id = SETTINGS_OVERLAY_ID; + const panel = document.createElement("div"); + panel.id = SETTINGS_PANEL_ID; + const title = document.createElement("h3"); + title.className = "custom-gh-nav-settings-title"; + title.textContent = t("panelTitle"); + const desc = document.createElement("p"); + desc.className = "custom-gh-nav-settings-desc"; + desc.textContent = t("panelDesc"); + const list = document.createElement("div"); + list.className = "custom-gh-nav-settings-list"; + renderPanelRows(list, state); + const message = document.createElement("div"); + message.id = SETTINGS_MESSAGE_ID; + message.className = "custom-gh-nav-settings-message"; + message.setAttribute("role", "status"); + message.setAttribute("aria-live", "polite"); + const footer = document.createElement("div"); + footer.className = "custom-gh-nav-settings-footer"; + const resetBtn = document.createElement("button"); + resetBtn.type = "button"; + resetBtn.className = "custom-gh-nav-settings-btn"; + resetBtn.textContent = t("resetDefault"); + resetBtn.addEventListener("click", () => { + state.order = DEFAULT_LINK_KEYS.slice(); + state.enabledSet = new Set(DEFAULT_LINK_KEYS); + renderPanelRows(list, state); + message.textContent = t("restoredPendingSave"); }); - observer.observe(document.body, { childList: true, subtree: true }); - + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "custom-gh-nav-settings-btn"; + cancelBtn.textContent = t("cancel"); + cancelBtn.addEventListener("click", closeConfigPanel); + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "custom-gh-nav-settings-btn custom-gh-nav-settings-btn-primary"; + saveBtn.textContent = t("saveAndRefresh"); + saveBtn.addEventListener("click", () => { + const enabledKeys = state.order.filter((key) => state.enabledSet.has(key)); + if (!enabledKeys.length) { + message.textContent = t("atLeastOneLink"); + return; + } + saveConfig({ + enabledKeys, + orderKeys: state.order.slice() + }); + closeConfigPanel(); + location.reload(); + }); + footer.appendChild(resetBtn); + footer.appendChild(cancelBtn); + footer.appendChild(saveBtn); + panel.appendChild(title); + panel.appendChild(desc); + panel.appendChild(list); + panel.appendChild(message); + panel.appendChild(footer); + overlay.appendChild(panel); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) closeConfigPanel(); + }); + settingsEscHandler = (event) => { + if (event.key === "Escape") closeConfigPanel(); + }; + document.addEventListener("keydown", settingsEscHandler); + document.body.appendChild(overlay); + } + function registerConfigMenu() { + if (typeof GM_registerMenuCommand !== "function") return; + GM_registerMenuCommand(t("menuOpenSettings"), openConfigPanel); + GM_registerMenuCommand(t("menuResetSettings"), () => { + const shouldReset = confirm(t("resetConfirm")); + if (!shouldReset) return; + localStorage.removeItem(CONFIG_STORAGE_KEY); + closeConfigPanel(); + location.reload(); + }); + GM_registerMenuCommand(t("menuLangZh"), () => { + setUiLangPreference("zh"); + closeConfigPanel(); + location.reload(); + }); + GM_registerMenuCommand(t("menuLangEn"), () => { + setUiLangPreference("en"); + closeConfigPanel(); + location.reload(); + }); + GM_registerMenuCommand(t("menuLangAuto"), () => { + setUiLangPreference("auto"); + closeConfigPanel(); + location.reload(); + }); + } + + // src/main.js + console.info(`[Better GitHub Navigation] loaded v${SCRIPT_VERSION}`); + window.__betterGithubNavVersion = SCRIPT_VERSION; + window.__openBetterGithubNavSettings = openConfigPanel; + registerConfigMenu(); + ensureStyles(); + addCustomButtons(); + document.addEventListener("turbo:load", addCustomButtons); + document.addEventListener("pjax:end", addCustomButtons); + var observer = new MutationObserver(() => { + if (!document.querySelector('[id^="custom-gh-btn-"]') && document.querySelector("header")) { + addCustomButtons(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); })(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5380119 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,473 @@ +{ + "name": "better-github-nav", + "version": "0.1.26", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "better-github-nav", + "version": "0.1.26", + "license": "MIT", + "devDependencies": { + "esbuild": "^0.27.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..833830c --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "better-github-nav", + "version": "0.1.26", + "description": "Build tools for the Better GitHub Navigation userscript.", + "private": true, + "scripts": { + "build": "node scripts/build.mjs", + "watch": "node scripts/build.mjs --watch", + "check:build": "npm run build && git diff --exit-code -- better-github-nav.user.js" + }, + "license": "MIT", + "devDependencies": { + "esbuild": "^0.27.3" + } +} diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..3999922 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,69 @@ +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { readFile } from 'node:fs/promises'; +import { build, context } from 'esbuild'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, '..'); +const ENTRY_FILE = path.join(ROOT_DIR, 'src', 'main.js'); +const HEADER_TEMPLATE_FILE = path.join(ROOT_DIR, 'scripts', 'userscript-header.txt'); +const PACKAGE_FILE = path.join(ROOT_DIR, 'package.json'); +const OUTPUT_FILE = path.join(ROOT_DIR, 'better-github-nav.user.js'); + +async function loadPackageVersion() { + const raw = await readFile(PACKAGE_FILE, 'utf8'); + const pkg = JSON.parse(raw); + if (!pkg.version || typeof pkg.version !== 'string') { + throw new Error('Invalid package.json version'); + } + return pkg.version; +} + +async function loadUserscriptHeader(version) { + const template = await readFile(HEADER_TEMPLATE_FILE, 'utf8'); + return template.replace(/__VERSION__/g, version).trimEnd(); +} + +function createBuildOptions(version, userscriptHeader) { + return { + entryPoints: [ENTRY_FILE], + bundle: true, + format: 'iife', + platform: 'browser', + target: ['es2020'], + outfile: OUTPUT_FILE, + charset: 'utf8', + legalComments: 'none', + minify: false, + define: { + __SCRIPT_VERSION__: JSON.stringify(version) + }, + banner: { + js: `${userscriptHeader}\n` + } + }; +} + +async function run() { + const isWatch = process.argv.includes('--watch'); + const version = await loadPackageVersion(); + const userscriptHeader = await loadUserscriptHeader(version); + const options = createBuildOptions(version, userscriptHeader); + + if (isWatch) { + const ctx = await context(options); + await ctx.watch(); + console.log(`[build] watching ${path.relative(ROOT_DIR, ENTRY_FILE)} -> ${path.relative(ROOT_DIR, OUTPUT_FILE)} (v${version})`); + return; + } + + await build(options); + console.log(`[build] generated ${path.relative(ROOT_DIR, OUTPUT_FILE)} (v${version})`); +} + +run().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/userscript-header.txt b/scripts/userscript-header.txt new file mode 100644 index 0000000..4bb9962 --- /dev/null +++ b/scripts/userscript-header.txt @@ -0,0 +1,15 @@ +// ==UserScript== +// @name Better GitHub Navigation +// @name:zh-CN 更好的 GitHub 导航栏 +// @namespace https://github.com/ImXiangYu/better-github-nav +// @version __VERSION__ +// @description Add quick access to Dashboard, Trending, Explore, Collections, and Stars from GitHub's top navigation. +// @description:zh-CN 在 GitHub 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。 +// @author Ayubass +// @license MIT +// @match https://github.com/* +// @icon https://github.githubassets.com/pinned-octocat.svg +// @updateURL https://raw.githubusercontent.com/ImXiangYu/better-github-nav/main/better-github-nav.user.js +// @downloadURL https://raw.githubusercontent.com/ImXiangYu/better-github-nav/main/better-github-nav.user.js +// @grant GM_registerMenuCommand +// ==/UserScript== diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..34ad9b8 --- /dev/null +++ b/src/config.js @@ -0,0 +1,66 @@ +import { + CONFIG_STORAGE_KEY, + DEFAULT_LINK_KEYS, + PRESET_LINKS +} from './constants.js'; + +export function sanitizeKeys(keys) { + const validSet = new Set(DEFAULT_LINK_KEYS); + const seen = new Set(); + const result = []; + keys.forEach(key => { + if (validSet.has(key) && !seen.has(key)) { + seen.add(key); + result.push(key); + } + }); + return result; +} + +export function sanitizeConfig(rawConfig) { + const enabledKeys = sanitizeKeys(Array.isArray(rawConfig?.enabledKeys) ? rawConfig.enabledKeys : DEFAULT_LINK_KEYS); + const orderKeysRaw = sanitizeKeys(Array.isArray(rawConfig?.orderKeys) ? rawConfig.orderKeys : DEFAULT_LINK_KEYS); + const orderSet = new Set(orderKeysRaw); + const orderKeys = [ + ...orderKeysRaw, + ...DEFAULT_LINK_KEYS.filter(key => !orderSet.has(key)) + ]; + return { + enabledKeys: enabledKeys.length ? enabledKeys : DEFAULT_LINK_KEYS.slice(), + orderKeys: orderKeys.length ? orderKeys : DEFAULT_LINK_KEYS.slice() + }; +} + +export function loadConfig() { + try { + const raw = localStorage.getItem(CONFIG_STORAGE_KEY); + if (!raw) return sanitizeConfig({}); + return sanitizeConfig(JSON.parse(raw)); + } catch (e) { + return sanitizeConfig({}); + } +} + +export function saveConfig(config) { + localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(sanitizeConfig(config))); +} + +export function getConfiguredLinks(username) { + const config = loadConfig(); + const presetMap = new Map( + PRESET_LINKS.map(link => [link.key, { + ...link, + id: `custom-gh-btn-${link.key}`, + href: link.getHref(username) + }]) + ); + return config.orderKeys + .filter(key => config.enabledKeys.includes(key)) + .map(key => presetMap.get(key)) + .filter(Boolean); +} + +export function getDisplayNameByKey(key) { + const link = PRESET_LINKS.find(item => item.key === key); + return link ? link.text : key; +} diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..fe908f7 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,57 @@ +export const SCRIPT_VERSION = __SCRIPT_VERSION__; + +export const CUSTOM_BUTTON_CLASS = 'custom-gh-nav-btn'; +export const CUSTOM_BUTTON_ACTIVE_CLASS = 'custom-gh-nav-btn-active'; +export const CUSTOM_BUTTON_COMPACT_CLASS = 'custom-gh-nav-btn-compact'; +export const QUICK_LINK_MARK_ATTR = 'data-better-gh-nav-quick-link'; +export const CONFIG_STORAGE_KEY = 'better-gh-nav-config-v1'; +export const UI_LANG_STORAGE_KEY = 'better-gh-nav-ui-lang-v1'; +export const SETTINGS_OVERLAY_ID = 'custom-gh-nav-settings-overlay'; +export const SETTINGS_PANEL_ID = 'custom-gh-nav-settings-panel'; +export const SETTINGS_MESSAGE_ID = 'custom-gh-nav-settings-message'; +export const DEFAULT_LINK_KEYS = ['dashboard', 'explore', 'trending', 'collections', 'stars']; + +export const PRESET_LINKS = [ + { key: 'dashboard', text: 'Dashboard', path: '/dashboard', getHref: () => '/dashboard' }, + { key: 'explore', text: 'Explore', path: '/explore', getHref: () => '/explore' }, + { key: 'trending', text: 'Trending', path: '/trending', getHref: () => '/trending' }, + { key: 'collections', text: 'Collections', path: '/collections', getHref: () => '/collections' }, + { key: 'stars', text: 'Stars', path: '/stars', getHref: username => (username ? `/${username}?tab=stars` : '/stars') } +]; + +export const I18N = { + zh: { + menuOpenSettings: 'Better GitHub Nav: 打开设置面板', + menuResetSettings: 'Better GitHub Nav: 重置快捷链接配置', + menuLangZh: 'Better GitHub Nav: 界面语言 -> 中文', + menuLangEn: 'Better GitHub Nav: 界面语言 -> English', + menuLangAuto: 'Better GitHub Nav: 界面语言 -> 自动(跟随页面)', + resetConfirm: '确认重置快捷链接配置为默认值吗?', + panelTitle: 'Better GitHub Nav 设置', + panelDesc: '勾选决定显示项,拖动整行(或右侧手柄)调整显示顺序。', + resetDefault: '恢复默认', + cancel: '取消', + saveAndRefresh: '保存并刷新', + restoredPendingSave: '已恢复默认,点击保存后生效。', + atLeastOneLink: '至少保留 1 个快捷链接。', + dragHandleTitle: '拖动调整顺序', + dragRowTitle: '拖动整行调整顺序' + }, + en: { + menuOpenSettings: 'Better GitHub Nav: Open Settings Panel', + menuResetSettings: 'Better GitHub Nav: Reset Quick Link Config', + menuLangZh: 'Better GitHub Nav: UI Language -> 中文', + menuLangEn: 'Better GitHub Nav: UI Language -> English', + menuLangAuto: 'Better GitHub Nav: UI Language -> Auto (Follow Page)', + resetConfirm: 'Reset quick-link config to defaults?', + panelTitle: 'Better GitHub Nav Settings', + panelDesc: 'Select visible links and drag the row (or handle) to reorder.', + resetDefault: 'Reset to Default', + cancel: 'Cancel', + saveAndRefresh: 'Save and Refresh', + restoredPendingSave: 'Defaults restored. Click save to apply.', + atLeastOneLink: 'Keep at least 1 quick link.', + dragHandleTitle: 'Drag to reorder', + dragRowTitle: 'Drag row to reorder' + } +}; diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000..2e3b31c --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,35 @@ +import { I18N, UI_LANG_STORAGE_KEY } from './constants.js'; + +let uiLang = detectUiLang(); + +export function t(key, vars = {}) { + const dict = I18N[uiLang] || I18N.en; + const fallback = I18N.en; + const template = dict[key] || fallback[key] || key; + return template.replace(/\{(\w+)\}/g, (_, varName) => String(vars[varName] ?? '')); +} + +export function detectUiLang() { + try { + const preferredLang = (localStorage.getItem(UI_LANG_STORAGE_KEY) || '').toLowerCase(); + if (preferredLang === 'zh' || preferredLang === 'en') return preferredLang; + } catch (e) { + // ignore storage read failure and fallback to auto detection + } + + const autoLang = (document.documentElement.lang || navigator.language || '').toLowerCase(); + return autoLang.startsWith('zh') ? 'zh' : 'en'; +} + +export function setUiLangPreference(lang) { + try { + if (lang === 'zh' || lang === 'en') { + localStorage.setItem(UI_LANG_STORAGE_KEY, lang); + } else { + localStorage.removeItem(UI_LANG_STORAGE_KEY); + } + } catch (e) { + // ignore storage write failure; auto detection still works + } + uiLang = detectUiLang(); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..14ade66 --- /dev/null +++ b/src/main.js @@ -0,0 +1,24 @@ +import { SCRIPT_VERSION } from './constants.js'; +import { addCustomButtons } from './navigation.js'; +import { openConfigPanel, registerConfigMenu } from './settings-panel.js'; +import { ensureStyles } from './styles.js'; + +// 1. 页面初次加载时执行 +console.info(`[Better GitHub Navigation] loaded v${SCRIPT_VERSION}`); +window.__betterGithubNavVersion = SCRIPT_VERSION; +window.__openBetterGithubNavSettings = openConfigPanel; +registerConfigMenu(); +ensureStyles(); +addCustomButtons(); + +// 2. 监听 GitHub 的 Turbo/PJAX 页面跳转事件,防止切换页面后按钮消失 +document.addEventListener('turbo:load', addCustomButtons); +document.addEventListener('pjax:end', addCustomButtons); + +// 3. 终极备用方案:使用 MutationObserver 监听 DOM 变化 +const observer = new MutationObserver(() => { + if (!document.querySelector('[id^="custom-gh-btn-"]') && document.querySelector('header')) { + addCustomButtons(); + } +}); +observer.observe(document.body, { childList: true, subtree: true }); diff --git a/src/navigation.js b/src/navigation.js new file mode 100644 index 0000000..2946888 --- /dev/null +++ b/src/navigation.js @@ -0,0 +1,332 @@ +import { PRESET_LINKS, QUICK_LINK_MARK_ATTR } from './constants.js'; +import { getConfiguredLinks } from './config.js'; +import { setActiveStyle } from './styles.js'; + +export function normalizePath(href) { + try { + const url = new URL(href, location.origin); + const path = url.pathname.replace(/\/+$/, ''); + return path || '/'; + } catch (e) { + return ''; + } +} + +export function isCurrentPage(linkPath) { + const currentPath = location.pathname.replace(/\/+$/, '') || '/'; + if (linkPath === '/dashboard') return currentPath === '/' || currentPath === '/dashboard'; + if (currentPath === linkPath) return true; + if (linkPath !== '/' && currentPath.startsWith(`${linkPath}/`)) return true; + + // Stars 页面常见为 /?tab=stars + return location.search.includes('tab=stars') && linkPath === normalizePath('/stars'); +} + +export function setLinkText(aTag, text) { + const innerSpan = aTag.querySelector('span'); + if (innerSpan) { + innerSpan.textContent = text; + } else { + aTag.textContent = text; + } +} + +export function ensureAnchor(node, isLiParent) { + let aTag = isLiParent ? node.querySelector('a') : (node.tagName.toLowerCase() === 'a' ? node : node.querySelector('a')); + if (aTag) return aTag; + + const fallbackText = (node.textContent || '').trim(); + const fallbackHref = (!isLiParent && node.getAttribute && node.getAttribute('href')) + ? node.getAttribute('href') + : `${location.pathname}${location.search}`; + const classSource = isLiParent + ? node.querySelector('[class*="contextCrumb"], [class*="Breadcrumbs-Item"]') + : node; + const spanTemplate = document.querySelector( + 'header a[class*="contextCrumb"] span[class*="contextCrumbLast"]' + ); + const spanSource = isLiParent ? node.querySelector('span') : node.querySelector('span'); + + aTag = document.createElement('a'); + if (classSource && classSource.className) { + aTag.className = classSource.className + .split(/\s+/) + .filter(cls => cls && !cls.includes('contextCrumbStatic')) + .join(' '); + } + if (spanSource && spanSource.className) { + const innerSpan = document.createElement('span'); + innerSpan.className = spanTemplate && spanTemplate.className + ? spanTemplate.className + : spanSource.className; + if (fallbackText) innerSpan.textContent = fallbackText; + aTag.appendChild(innerSpan); + } + if (!aTag.getAttribute('href') && fallbackHref) { + aTag.setAttribute('href', fallbackHref); + } + if (!aTag.textContent.trim() && fallbackText) { + const innerSpan = aTag.querySelector('span'); + if (innerSpan) { + innerSpan.textContent = fallbackText; + } else { + aTag.textContent = fallbackText; + } + } + + if (isLiParent) { + node.textContent = ''; + node.appendChild(aTag); + } else { + node.replaceChildren(aTag); + } + return aTag; +} + +function getAnchorHostNode(anchor) { + if (!anchor || !anchor.parentNode) return anchor; + return anchor.parentNode.tagName.toLowerCase() === 'li' ? anchor.parentNode : anchor; +} + +function cleanupQuickLinksForContainer(renderParent, keepNode) { + const quickAnchors = Array.from( + document.querySelectorAll( + 'header a[id^="custom-gh-btn-"], header a[' + QUICK_LINK_MARK_ATTR + '="1"]' + ) + ); + + quickAnchors.forEach(anchor => { + const host = getAnchorHostNode(anchor); + if (!host || !host.parentNode) return; + if (host === keepNode) return; + if (host.parentNode !== renderParent) { + host.remove(); + return; + } + host.remove(); + }); +} + +export function addCustomButtons() { + // 获取当前登录的用户名,用来动态生成 Stars 页面的专属链接 + const userLoginMeta = document.querySelector('meta[name="user-login"]'); + const username = userLoginMeta ? userLoginMeta.getAttribute('content') : ''; + const navPresetLinks = getConfiguredLinks(username); + if (!navPresetLinks.length) return; + const primaryLink = navPresetLinks[0]; + const extraLinks = navPresetLinks.slice(1); + const fixedPages = new Set(['/dashboard', '/trending', '/explore', '/collections']); + const shortcutPaths = new Set(PRESET_LINKS.map(link => link.path)); + const compactPages = new Set(['/issues', '/pulls', '/repositories']); + + const isOnPresetPage = Array.from(fixedPages).some(path => isCurrentPage(path)); + const shouldUseCompactButtons = Array.from(compactPages).some(path => isCurrentPage(path)); + + // 预设页面优先主导航;其他页面优先 breadcrumb/context crumb 的最后一项(如仓库名) + let targetNode = null; + let targetSource = ''; + if (isOnPresetPage) { + targetNode = document.querySelector( + 'header nav a[href="/dashboard"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav a[href="/trending"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav a[href="/explore"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' + ); + if (targetNode) targetSource = 'preset-nav'; + if (!targetNode) { + targetNode = document.querySelector( + 'header nav a[id^="custom-gh-btn-"], header nav a[' + QUICK_LINK_MARK_ATTR + '="1"]' + ); + if (targetNode) targetSource = 'preset-quick'; + } + } else { + const breadcrumbNodes = Array.from(document.querySelectorAll( + 'header nav[aria-label*="breadcrumb" i] a[href^="/"], ' + + 'header a[class*="contextCrumb"][href^="/"], ' + + 'header a[class*="Breadcrumbs-Item"][href^="/"]' + )).filter(link => { + if (link.id && link.id.startsWith('custom-gh-btn-')) return false; + if (link.getAttribute(QUICK_LINK_MARK_ATTR) === '1') return false; + const href = normalizePath(link.getAttribute('href') || ''); + if (!href || href === '/') return false; + if (shortcutPaths.has(href)) return false; + return true; + }); + if (breadcrumbNodes.length) { + targetNode = breadcrumbNodes[breadcrumbNodes.length - 1]; + targetSource = 'breadcrumb'; + } + } + + // 全局导航中优先使用当前页项,避免误选最后一个导航按钮导致当前页无高亮 + if (!targetNode) { + targetNode = document.querySelector( + 'header nav a[aria-current="page"]:not([id^="custom-gh-btn-"]), ' + + 'header nav a[data-active="true"]:not([id^="custom-gh-btn-"]), ' + + 'header nav [aria-current="page"]:not(a), ' + + 'header nav [data-active="true"]:not(a)' + ); + if (targetNode) targetSource = 'current-nav'; + } + + // 兼容兜底:若未找到主导航,再尝试旧规则 + if (!targetNode) { + const navLinks = document.querySelectorAll( + 'header a:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' + ); + for (const link of navLinks) { + const text = link.textContent.trim().toLowerCase(); + const href = link.getAttribute('href'); + if (text === 'dashboard' || href === '/dashboard') { + targetNode = link; + targetSource = 'legacy-dashboard'; + break; + } + } + } + + // 通用兜底:在有全局导航的页面(如 /pulls /issues /repositories)优先按当前路径匹配 + if (!targetNode) { + const currentPath = location.pathname.replace(/\/+$/, '') || '/'; + const globalNavCandidates = Array.from( + document.querySelectorAll( + 'header nav[aria-label*="global" i] a[href^="/"], ' + + 'header nav[aria-label*="header" i] a[href^="/"], ' + + 'header nav a[href="/pulls"], ' + + 'header nav a[href="/issues"], ' + + 'header nav a[href="/repositories"], ' + + 'header nav a[href="/codespaces"], ' + + 'header nav a[href="/marketplace"], ' + + 'header nav a[href="/explore"]' + ) + ).filter(link => { + const href = normalizePath(link.getAttribute('href') || ''); + if (!href || href === '/') return false; + if (link.id && link.id.startsWith('custom-gh-btn-')) return false; + if (link.getAttribute(QUICK_LINK_MARK_ATTR) === '1') return false; + return true; + }); + if (globalNavCandidates.length) { + targetNode = globalNavCandidates.find(link => { + const href = normalizePath(link.getAttribute('href') || ''); + return href === currentPath; + }) || globalNavCandidates[globalNavCandidates.length - 1]; + if (targetNode) targetSource = 'global-nav'; + } + } + + // 文本型当前项兜底:部分页面当前导航项是不可点击文本(非 a) + if (!targetNode) { + const currentTextNode = document.querySelector( + 'header nav [aria-current="page"]:not(a), ' + + 'header nav [data-active="true"]:not(a)' + ); + if (currentTextNode) { + targetNode = currentTextNode; + targetSource = 'current-text'; + } + } + + // context crumb 文本项兜底:如 Issues/PRs 页为 span 而非 a + if (!targetNode) { + const contextCrumbTextNodes = document.querySelectorAll( + 'header span[class*="contextCrumbStatic"], ' + + 'header span[class*="contextCrumb"][class*="Breadcrumbs-Item"], ' + + 'header .prc-Breadcrumbs-Item-jcraJ' + ); + if (contextCrumbTextNodes.length) { + targetNode = contextCrumbTextNodes[contextCrumbTextNodes.length - 1]; + targetSource = 'crumb-text'; + } + } + + // 样式模板优先使用同容器内可点击链接,避免从纯文本节点克隆导致样式不一致 + let templateNode = targetNode; + if (targetNode) { + const localNav = targetNode.closest('nav, ul, ol'); + const localAnchors = localNav + ? localNav.querySelectorAll( + 'a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' + ) + : []; + + if (localAnchors.length) { + templateNode = localAnchors[localAnchors.length - 1]; + } else { + const nativeNavAnchors = document.querySelectorAll( + 'header nav[aria-label*="breadcrumb" i] a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header a[class*="contextCrumb"][href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header a[class*="Breadcrumbs-Item"][href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav[aria-label*="global" i] a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav[aria-label*="header" i] a[href^="/"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav a[href="/pulls"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav a[href="/issues"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav a[href="/repositories"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav a[href="/codespaces"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav a[href="/marketplace"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"]), ' + + 'header nav a[href="/explore"]:not([id^="custom-gh-btn-"]):not([' + QUICK_LINK_MARK_ATTR + '="1"])' + ); + if (nativeNavAnchors.length) { + templateNode = nativeNavAnchors[nativeNavAnchors.length - 1]; + } + } + } + + if (targetNode) { + // targetNode 用于决定插入位置,templateNode 用于克隆样式 + const isTargetLiParent = targetNode.parentNode.tagName.toLowerCase() === 'li'; + const insertAnchorNode = isTargetLiParent ? targetNode.parentNode : targetNode; + const isTemplateLiParent = templateNode.parentNode.tagName.toLowerCase() === 'li'; + const cloneTemplateNode = isTemplateLiParent ? templateNode.parentNode : templateNode; + const targetHasAnchor = isTargetLiParent + ? Boolean(insertAnchorNode.querySelector('a')) + : insertAnchorNode.tagName.toLowerCase() === 'a' || Boolean(insertAnchorNode.querySelector('a')); + const shouldForceCreateAnchor = !targetHasAnchor && Boolean(targetNode.closest('header nav')); + const anchorTag = (targetHasAnchor || shouldForceCreateAnchor) + ? ensureAnchor(insertAnchorNode, isTargetLiParent) + : null; + cleanupQuickLinksForContainer(insertAnchorNode.parentNode, insertAnchorNode); + + const hasShortcutActive = navPresetLinks.some(link => isCurrentPage(link.path)); + + if (isOnPresetPage && anchorTag && primaryLink) { + // 预设页面:首个按钮替换为当前配置顺序中的第一个 + anchorTag.id = primaryLink.id; + anchorTag.setAttribute(QUICK_LINK_MARK_ATTR, '1'); + anchorTag.href = primaryLink.href; + setLinkText(anchorTag, primaryLink.text); + setActiveStyle(anchorTag, isCurrentPage(primaryLink.path), shouldUseCompactButtons); + } else { + // 其他页面:保留原生当前按钮,仅做高亮 + if (anchorTag && anchorTag.id && anchorTag.id.startsWith('custom-gh-btn-')) { + anchorTag.removeAttribute('id'); + } + if (anchorTag) { + anchorTag.removeAttribute(QUICK_LINK_MARK_ATTR); + } + // 若快捷按钮已有命中(如 Stars 页),则避免双高亮 + if (anchorTag) { + setActiveStyle(anchorTag, !hasShortcutActive, shouldUseCompactButtons); + } + } + + // 设定插入的锚点,随着循环不断向后移动,保证按钮顺序正确 + let insertAfterNode = insertAnchorNode; + const linksToRender = isOnPresetPage ? extraLinks : navPresetLinks; + + linksToRender.forEach(linkInfo => { + const newNode = cloneTemplateNode.cloneNode(true); + const aTag = ensureAnchor(newNode, isTemplateLiParent); + + aTag.id = linkInfo.id; + aTag.setAttribute(QUICK_LINK_MARK_ATTR, '1'); + aTag.href = linkInfo.href; + setLinkText(aTag, linkInfo.text); + + setActiveStyle(aTag, isCurrentPage(linkInfo.path), shouldUseCompactButtons); + + // 将新按钮插入到锚点之后,并更新锚点 + insertAfterNode.parentNode.insertBefore(newNode, insertAfterNode.nextSibling); + insertAfterNode = newNode; + }); + } +} diff --git a/src/settings-panel.js b/src/settings-panel.js new file mode 100644 index 0000000..e4e928e --- /dev/null +++ b/src/settings-panel.js @@ -0,0 +1,256 @@ +import { + CONFIG_STORAGE_KEY, + DEFAULT_LINK_KEYS, + SETTINGS_MESSAGE_ID, + SETTINGS_OVERLAY_ID, + SETTINGS_PANEL_ID +} from './constants.js'; +import { getDisplayNameByKey, loadConfig, sanitizeConfig, saveConfig } from './config.js'; +import { t, setUiLangPreference } from './i18n.js'; +import { ensureStyles } from './styles.js'; + +let settingsEscHandler = null; + +export function closeConfigPanel() { + const overlay = document.getElementById(SETTINGS_OVERLAY_ID); + if (overlay) overlay.remove(); + if (settingsEscHandler) { + document.removeEventListener('keydown', settingsEscHandler); + settingsEscHandler = null; + } +} + +function createPanelState(config) { + const safeConfig = sanitizeConfig(config); + return { + order: safeConfig.orderKeys.slice(), + enabledSet: new Set(safeConfig.enabledKeys) + }; +} + +function reorderKeys(state, draggedKey, targetKey, placeAfter = false) { + const fromIndex = state.order.indexOf(draggedKey); + const targetIndex = state.order.indexOf(targetKey); + if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return false; + + const [movedKey] = state.order.splice(fromIndex, 1); + let insertIndex = targetIndex + (placeAfter ? 1 : 0); + if (fromIndex < targetIndex) { + insertIndex -= 1; + } + state.order.splice(insertIndex, 0, movedKey); + return true; +} + +function clearDragClasses(listEl) { + const rows = listEl.querySelectorAll('.custom-gh-nav-settings-row'); + rows.forEach(row => { + row.classList.remove('custom-gh-nav-settings-row-dragging'); + row.classList.remove('custom-gh-nav-settings-row-drag-over'); + }); +} + +function renderPanelRows(listEl, state) { + listEl.replaceChildren(); + state.order.forEach(key => { + const row = document.createElement('div'); + row.className = 'custom-gh-nav-settings-row'; + row.draggable = true; + row.title = t('dragRowTitle'); + row.dataset.rowKey = key; + + const left = document.createElement('label'); + left.className = 'custom-gh-nav-settings-row-left'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = state.enabledSet.has(key); + checkbox.addEventListener('change', () => { + if (checkbox.checked) { + state.enabledSet.add(key); + } else { + state.enabledSet.delete(key); + } + }); + + const text = document.createElement('span'); + text.textContent = `${getDisplayNameByKey(key)} (${key})`; + + left.appendChild(checkbox); + left.appendChild(text); + + const actions = document.createElement('div'); + actions.className = 'custom-gh-nav-settings-row-actions'; + + const dragHandle = document.createElement('span'); + dragHandle.className = 'custom-gh-nav-settings-drag-handle'; + dragHandle.textContent = '≡'; + dragHandle.title = t('dragHandleTitle'); + dragHandle.setAttribute('aria-hidden', 'true'); + + row.addEventListener('dragstart', event => { + row.classList.add('custom-gh-nav-settings-row-dragging'); + listEl.dataset.dragKey = key; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', key); + } + }); + row.addEventListener('dragend', () => { + delete listEl.dataset.dragKey; + clearDragClasses(listEl); + }); + + row.addEventListener('dragover', event => { + event.preventDefault(); + row.classList.add('custom-gh-nav-settings-row-drag-over'); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + }); + row.addEventListener('dragleave', () => { + row.classList.remove('custom-gh-nav-settings-row-drag-over'); + }); + row.addEventListener('drop', event => { + event.preventDefault(); + row.classList.remove('custom-gh-nav-settings-row-drag-over'); + + const draggedKey = (event.dataTransfer && event.dataTransfer.getData('text/plain')) + || listEl.dataset.dragKey + || ''; + if (!draggedKey || draggedKey === key) return; + + const rect = row.getBoundingClientRect(); + const placeAfter = event.clientY > rect.top + rect.height / 2; + if (reorderKeys(state, draggedKey, key, placeAfter)) { + renderPanelRows(listEl, state); + } + }); + + actions.appendChild(dragHandle); + row.appendChild(left); + row.appendChild(actions); + listEl.appendChild(row); + }); +} + +export function openConfigPanel() { + closeConfigPanel(); + ensureStyles(); + + const state = createPanelState(loadConfig()); + const overlay = document.createElement('div'); + overlay.id = SETTINGS_OVERLAY_ID; + + const panel = document.createElement('div'); + panel.id = SETTINGS_PANEL_ID; + + const title = document.createElement('h3'); + title.className = 'custom-gh-nav-settings-title'; + title.textContent = t('panelTitle'); + + const desc = document.createElement('p'); + desc.className = 'custom-gh-nav-settings-desc'; + desc.textContent = t('panelDesc'); + + const list = document.createElement('div'); + list.className = 'custom-gh-nav-settings-list'; + renderPanelRows(list, state); + + const message = document.createElement('div'); + message.id = SETTINGS_MESSAGE_ID; + message.className = 'custom-gh-nav-settings-message'; + message.setAttribute('role', 'status'); + message.setAttribute('aria-live', 'polite'); + + const footer = document.createElement('div'); + footer.className = 'custom-gh-nav-settings-footer'; + + const resetBtn = document.createElement('button'); + resetBtn.type = 'button'; + resetBtn.className = 'custom-gh-nav-settings-btn'; + resetBtn.textContent = t('resetDefault'); + resetBtn.addEventListener('click', () => { + state.order = DEFAULT_LINK_KEYS.slice(); + state.enabledSet = new Set(DEFAULT_LINK_KEYS); + renderPanelRows(list, state); + message.textContent = t('restoredPendingSave'); + }); + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'custom-gh-nav-settings-btn'; + cancelBtn.textContent = t('cancel'); + cancelBtn.addEventListener('click', closeConfigPanel); + + const saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'custom-gh-nav-settings-btn custom-gh-nav-settings-btn-primary'; + saveBtn.textContent = t('saveAndRefresh'); + saveBtn.addEventListener('click', () => { + const enabledKeys = state.order.filter(key => state.enabledSet.has(key)); + if (!enabledKeys.length) { + message.textContent = t('atLeastOneLink'); + return; + } + saveConfig({ + enabledKeys, + orderKeys: state.order.slice() + }); + closeConfigPanel(); + location.reload(); + }); + + footer.appendChild(resetBtn); + footer.appendChild(cancelBtn); + footer.appendChild(saveBtn); + + panel.appendChild(title); + panel.appendChild(desc); + panel.appendChild(list); + panel.appendChild(message); + panel.appendChild(footer); + overlay.appendChild(panel); + + overlay.addEventListener('click', event => { + if (event.target === overlay) closeConfigPanel(); + }); + + settingsEscHandler = event => { + if (event.key === 'Escape') closeConfigPanel(); + }; + document.addEventListener('keydown', settingsEscHandler); + + document.body.appendChild(overlay); +} + +export function registerConfigMenu() { + if (typeof GM_registerMenuCommand !== 'function') return; + GM_registerMenuCommand(t('menuOpenSettings'), openConfigPanel); + + GM_registerMenuCommand(t('menuResetSettings'), () => { + const shouldReset = confirm(t('resetConfirm')); + if (!shouldReset) return; + localStorage.removeItem(CONFIG_STORAGE_KEY); + closeConfigPanel(); + location.reload(); + }); + + GM_registerMenuCommand(t('menuLangZh'), () => { + setUiLangPreference('zh'); + closeConfigPanel(); + location.reload(); + }); + + GM_registerMenuCommand(t('menuLangEn'), () => { + setUiLangPreference('en'); + closeConfigPanel(); + location.reload(); + }); + + GM_registerMenuCommand(t('menuLangAuto'), () => { + setUiLangPreference('auto'); + closeConfigPanel(); + location.reload(); + }); +} diff --git a/src/styles.js b/src/styles.js new file mode 100644 index 0000000..26e6e03 --- /dev/null +++ b/src/styles.js @@ -0,0 +1,181 @@ +import { + CUSTOM_BUTTON_ACTIVE_CLASS, + CUSTOM_BUTTON_CLASS, + CUSTOM_BUTTON_COMPACT_CLASS, + SETTINGS_OVERLAY_ID, + SETTINGS_PANEL_ID +} from './constants.js'; + +export function ensureStyles() { + if (document.getElementById('custom-gh-nav-style')) return; + const style = document.createElement('style'); + style.id = 'custom-gh-nav-style'; + style.textContent = ` + a.${CUSTOM_BUTTON_CLASS} { + border-radius: 6px; + padding-inline: 8px; + text-decoration: none; + } + a.${CUSTOM_BUTTON_CLASS}.${CUSTOM_BUTTON_COMPACT_CLASS} { + padding-inline: 4px; + } + a.${CUSTOM_BUTTON_CLASS}, + a.${CUSTOM_BUTTON_CLASS} span { + font-weight: 600; + } + a.${CUSTOM_BUTTON_CLASS}, + a.${CUSTOM_BUTTON_CLASS} * { + cursor: pointer; + } + a.${CUSTOM_BUTTON_CLASS}:hover { + background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.12)); + text-decoration: none; + } + a.${CUSTOM_BUTTON_CLASS}.${CUSTOM_BUTTON_ACTIVE_CLASS} { + background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.18)); + font-weight: 600; + } + #${SETTINGS_OVERLAY_ID} { + position: fixed; + inset: 0; + z-index: 2147483647; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + box-sizing: border-box; + } + #${SETTINGS_PANEL_ID} { + width: min(560px, 100%); + max-height: min(80vh, 720px); + overflow: auto; + background: var(--color-canvas-default, #fff); + color: var(--color-fg-default, #1f2328); + border: 1px solid var(--color-border-default, #d1d9e0); + border-radius: 10px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.25); + padding: 16px; + box-sizing: border-box; + } + .custom-gh-nav-settings-title { + margin: 0 0 8px; + font-size: 16px; + line-height: 1.4; + } + .custom-gh-nav-settings-desc { + margin: 0 0 12px; + color: var(--color-fg-muted, #656d76); + font-size: 13px; + } + .custom-gh-nav-settings-list { + display: flex; + flex-direction: column; + gap: 8px; + } + .custom-gh-nav-settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--color-border-muted, #d8dee4); + border-radius: 8px; + padding: 8px 10px; + background: var(--color-canvas-subtle, #f6f8fa); + cursor: grab; + } + .custom-gh-nav-settings-row:active { + cursor: grabbing; + } + .custom-gh-nav-settings-row-left { + display: inline-flex; + align-items: center; + gap: 8px; + user-select: none; + font-size: 13px; + } + .custom-gh-nav-settings-row-left input { + cursor: pointer; + } + .custom-gh-nav-settings-row-actions { + display: inline-flex; + align-items: center; + gap: 6px; + } + .custom-gh-nav-settings-drag-handle { + border: 1px solid var(--color-border-default, #d1d9e0); + background: var(--color-btn-bg, #f6f8fa); + color: var(--color-fg-muted, #656d76); + border-radius: 6px; + width: 32px; + height: 26px; + line-height: 1; + font-size: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + user-select: none; + pointer-events: none; + } + .custom-gh-nav-settings-row-dragging { + opacity: 0.55; + } + .custom-gh-nav-settings-row-drag-over { + border-color: var(--color-accent-fg, #0969da); + background: var(--color-accent-subtle, #ddf4ff); + } + .custom-gh-nav-settings-btn { + border: 1px solid var(--color-border-default, #d1d9e0); + background: var(--color-btn-bg, #f6f8fa); + color: var(--color-fg-default, #1f2328); + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + } + .custom-gh-nav-settings-btn:hover { + background: var(--color-btn-hover-bg, #f3f4f6); + } + .custom-gh-nav-settings-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + .custom-gh-nav-settings-btn-primary { + background: var(--color-btn-primary-bg, #1f883d); + border-color: var(--color-btn-primary-bg, #1f883d); + color: var(--color-btn-primary-text, #fff); + } + .custom-gh-nav-settings-btn-primary:hover { + background: var(--color-btn-primary-hover-bg, #1a7f37); + } + .custom-gh-nav-settings-footer { + margin-top: 12px; + display: flex; + justify-content: flex-end; + gap: 8px; + } + .custom-gh-nav-settings-message { + min-height: 20px; + margin-top: 8px; + color: var(--color-attention-fg, #9a6700); + font-size: 12px; + } + `; + document.head.appendChild(style); +} + +export function setActiveStyle(aTag, active, compact = false) { + aTag.classList.add(CUSTOM_BUTTON_CLASS); + if (compact) { + aTag.classList.add(CUSTOM_BUTTON_COMPACT_CLASS); + } else { + aTag.classList.remove(CUSTOM_BUTTON_COMPACT_CLASS); + } + if (active) { + aTag.setAttribute('aria-current', 'page'); + aTag.classList.add(CUSTOM_BUTTON_ACTIVE_CLASS); + } else { + aTag.removeAttribute('aria-current'); + aTag.classList.remove(CUSTOM_BUTTON_ACTIVE_CLASS); + } +} From 821e76ce93284b208044f737632df00de65098f6 Mon Sep 17 00:00:00 2001 From: ImXiangYu <2670833123@qq.com> Date: Wed, 25 Feb 2026 22:41:20 +0800 Subject: [PATCH 2/3] docs: update AGENTS.md --- AGENTS.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f554d02..cb3ebb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,17 +11,19 @@ - 仅在必须完整查看文件原文时使用 `Get-Content`。 ## 版本约定 -- 每次修改脚本都要递增小版本号(patch),并同步更新脚本内用于控制台打印的版本常量,便于确认变更已生效。 +- 每次修改脚本都要递增小版本号(patch)。 +- 版本号以 `package.json` 为单一来源;通过构建自动写入 `better-github-nav.user.js` 的 `@version` 与 `SCRIPT_VERSION`,不要手改产物内版本号。 ## 发布流程(dev -> main) - 当用户准备将 `dev` 分支合并到 `main`,或要求 AI 生成该合并的 PR 信息时,AI 必须先提醒:先完整执行以下发布流程,再进行 PR 文案与合并动作。 - 标准发布流程: 1. 确认源码改动已完成(如 `src/` 内模块)。 - 2. 递增补丁版本号(patch),并确保 `@version` 与脚本内版本常量一致。 - 3. 执行构建,生成最新 `better-github-nav.user.js`(发布文件)。 + 2. 执行 `npm version patch --no-git-tag-version`(仅升级本地版本号,不创建 tag)。 + 3. 执行 `npm run build`,生成最新 `better-github-nav.user.js`(发布文件)。 4. 本地自检产物(至少检查脚本头版本号、控制台版本常量、关键功能是否可用)。 - 5. 将源码改动与构建产物一并提交到 `dev`。 - 6. 仅在以上步骤完成后,再编写/提交 `dev -> main` 的 PR 信息。 + 5. 将源码改动与构建产物一并提交到 `dev`(至少包含 `src/`、`scripts/`、`package.json`、`package-lock.json`、`better-github-nav.user.js`)。 + 6. 推送后确认构建校验工作流通过(`Verify Userscript Build`)。 + 7. 仅在以上步骤完成后,再编写/提交 `dev -> main` 的 PR 信息。 ## 输出要求 - 默认使用中文 From 85eda7f220f54a86e1625c8906eb06afa18ec8f2 Mon Sep 17 00:00:00 2001 From: ImXiangYu <2670833123@qq.com> Date: Wed, 25 Feb 2026 22:45:01 +0800 Subject: [PATCH 3/3] chore: bump version to 0.1.27 --- better-github-nav.user.js | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/better-github-nav.user.js b/better-github-nav.user.js index 60dd63e..ea24d10 100644 --- a/better-github-nav.user.js +++ b/better-github-nav.user.js @@ -2,7 +2,7 @@ // @name Better GitHub Navigation // @name:zh-CN 更好的 GitHub 导航栏 // @namespace https://github.com/ImXiangYu/better-github-nav -// @version 0.1.26 +// @version 0.1.27 // @description Add quick access to Dashboard, Trending, Explore, Collections, and Stars from GitHub's top navigation. // @description:zh-CN 在 GitHub 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。 // @author Ayubass @@ -16,7 +16,7 @@ (() => { // src/constants.js - var SCRIPT_VERSION = "0.1.26"; + var SCRIPT_VERSION = "0.1.27"; var CUSTOM_BUTTON_CLASS = "custom-gh-nav-btn"; var CUSTOM_BUTTON_ACTIVE_CLASS = "custom-gh-nav-btn-active"; var CUSTOM_BUTTON_COMPACT_CLASS = "custom-gh-nav-btn-compact"; diff --git a/package-lock.json b/package-lock.json index 5380119..ed7d88c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "better-github-nav", - "version": "0.1.26", + "version": "0.1.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "better-github-nav", - "version": "0.1.26", + "version": "0.1.27", "license": "MIT", "devDependencies": { "esbuild": "^0.27.3" diff --git a/package.json b/package.json index 833830c..8650035 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "better-github-nav", - "version": "0.1.26", + "version": "0.1.27", "description": "Build tools for the Better GitHub Navigation userscript.", "private": true, "scripts": {