feat: add bookmark/favorites feature with sidebar list

Remove unused GitLinkAction and ShareButton components.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
赵昌 2026-05-13 19:23:14 +08:00
parent c35ceb7bb5
commit d18c56c27d
10 changed files with 261 additions and 275 deletions

View File

@ -0,0 +1,104 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useLocation } from '@docusaurus/router';
import { useAllDocsData } from '@docusaurus/plugin-content-docs/client';
import styles from './styles.module.css';
const STORAGE_KEY = 'gitlink_bookmarks';
function getBookmarks() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch {
return [];
}
}
function deriveTitle(id) {
if (!id) return '';
const segments = id.split('/');
return segments[segments.length - 1];
}
export default function BookmarkButton() {
const location = useLocation();
const allDocsData = useAllDocsData();
const [bookmarked, setBookmarked] = useState(false);
const [toast, setToast] = useState(null);
// Check if current doc is bookmarked
useEffect(() => {
const stored = getBookmarks();
setBookmarked(stored.some((d) => d.path === location.pathname));
}, [location.pathname]);
const showToast = useCallback((msg) => {
setToast(msg);
setTimeout(() => setToast(null), 2000);
}, []);
const toggleBookmark = useCallback(() => {
if (!allDocsData) return;
const pluginIds = Object.keys(allDocsData);
if (pluginIds.length === 0) return;
const plugin = allDocsData[pluginIds[0]];
let activeVersion = null;
for (const version of plugin.versions) {
if (!version.docs) continue;
const docs = Object.values(version.docs);
if (docs.find((doc) => doc.path && location.pathname.endsWith(doc.path))) {
activeVersion = version;
break;
}
}
if (!activeVersion || !activeVersion.docs) return;
const allDocs = Object.values(activeVersion.docs);
const currentDoc = allDocs.find(
(doc) => doc.path && location.pathname.endsWith(doc.path)
);
if (!currentDoc) return;
const stored = getBookmarks();
const existing = stored.findIndex((d) => d.path === currentDoc.path);
if (existing >= 0) {
// Remove bookmark
stored.splice(existing, 1);
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
setBookmarked(false);
showToast('已取消收藏');
} else {
// Add bookmark
stored.unshift({
path: currentDoc.path,
id: currentDoc.id,
title: deriveTitle(currentDoc.id),
timestamp: Date.now(),
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
setBookmarked(true);
showToast('已收藏,可在侧边栏查看');
}
// Dispatch custom event so BookmarkList updates
window.dispatchEvent(new Event('bookmark-change'));
}, [location.pathname, allDocsData, showToast]);
return (
<>
<button
className={`${styles.btn} ${bookmarked ? styles.active : ''}`}
onClick={toggleBookmark}
title={bookmarked ? '取消收藏' : '收藏本文'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill={bookmarked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
{bookmarked ? '已收藏' : '收藏本文'}
</button>
{toast && <div className={styles.toast}>{toast}</div>}
</>
);
}

View File

@ -0,0 +1,49 @@
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 6px;
background: var(--ifm-background-color);
color: var(--ifm-color-emphasis-700);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
line-height: 1.4;
}
.btn:hover {
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
background: var(--ifm-color-primary-contrast-background);
}
.btn.active {
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
background: var(--ifm-color-primary-contrast-background);
}
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem 1.2rem;
border-radius: 6px;
background: var(--ifm-color-emphasis-700);
color: var(--ifm-color-emphasis-100);
font-size: 0.85rem;
z-index: var(--ifm-z-index-fixed);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
animation: fadeInOut 2s ease forwards;
pointer-events: none;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateX(-50%) translateY(8px); }
15% { opacity: 1; transform: translateX(-50%) translateY(0); }
85% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-8px); }
}

View File

@ -0,0 +1,45 @@
import React, { useState, useEffect } from 'react';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
const STORAGE_KEY = 'gitlink_bookmarks';
function getBookmarks() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch {
return [];
}
}
export default function BookmarkList() {
const [bookmarks, setBookmarks] = useState([]);
useEffect(() => {
setBookmarks(getBookmarks());
const handler = () => setBookmarks(getBookmarks());
window.addEventListener('bookmark-change', handler);
return () => window.removeEventListener('bookmark-change', handler);
}, []);
if (bookmarks.length === 0) return null;
return (
<div className={styles.container}>
<div className={styles.heading}>收藏</div>
<ul className={styles.list}>
{bookmarks.slice(0, 10).map((bm) => (
<li key={bm.path} className={styles.item}>
<Link to={bm.path} className={styles.link}>
<svg className={styles.starIcon} width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span className={styles.title}>{bm.title}</span>
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,57 @@
.container {
}
.heading {
font-size: 0.72rem;
font-weight: 600;
color: var(--ifm-color-emphasis-500);
margin-bottom: 0.35rem;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0 0.25rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.item {
margin: 0;
}
.link {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
text-decoration: none !important;
color: var(--ifm-font-color-base);
transition: all 0.12s ease;
}
.link:hover {
background: var(--ifm-menu-color-background-hover);
color: var(--ifm-menu-color-active);
}
.starIcon {
flex-shrink: 0;
color: var(--ifm-color-primary);
opacity: 0.7;
}
.title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}

View File

@ -1,138 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from '@docusaurus/router';
import { useAllDocsData } from '@docusaurus/plugin-content-docs/client';
import styles from './styles.module.css';
const ACTION_LINKS = [
//
{ match: '快速开始/注册GitLink账号', url: 'https://www.gitlink.org.cn/sign_up', label: '去 GitLink 注册账号' },
{ match: '快速开始/创建第一个开源项目', url: 'https://www.gitlink.org.cn/repo/create', label: '去 GitLink 创建项目' },
{ match: '快速开始/提交第一行代码', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 提交代码' },
{ match: '快速开始/搜索开源项目', url: 'https://www.gitlink.org.cn/explore', label: '去 GitLink 发现项目' },
{ match: '快速开始/导入GitHub等第三方Git项目', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 导入项目' },
//
{ match: '代码库管理/仓库创建', url: 'https://www.gitlink.org.cn/repo/create', label: '去 GitLink 创建仓库' },
{ match: '代码库管理/代码提交', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 提交代码' },
{ match: '代码库管理/分支管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理分支' },
{ match: '代码库管理/文件管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理文件' },
{ match: '代码库管理/成员管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理成员' },
{ match: '代码库管理/仓库设置', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 配置仓库' },
{ match: '代码库管理/标签和发行版管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理发行版' },
{ match: '代码库管理/Webhook', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 配置 Webhook' },
{ match: '代码库管理/WebIDE', url: 'https://www.gitlink.org.cn/user/projects', label: '打开 GitLink WebIDE' },
//
{ match: '合并请求/合并请求简介', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 查看合并请求' },
{ match: '合并请求/创建合并请求', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 创建合并请求' },
{ match: '合并请求/代码评审', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 评审代码' },
{ match: '合并请求/合并模式简介', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 合并代码' },
{ match: '合并请求/合并请求关联疑修', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 关联疑修' },
// (Issues)
{ match: '疑修/疑修简介', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 查看疑修' },
{ match: '疑修/疑修创建', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 提交疑修' },
{ match: '疑修/疑修列表', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 查看疑修列表' },
{ match: '疑修/疑修状态变更', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理疑修' },
{ match: '疑修/评论及操作记录', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 参与讨论' },
{ match: '疑修/标记管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理标记' },
{ match: '疑修/里程碑管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理里程碑' },
//
{ match: '组织管理/组织简介', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理组织' },
{ match: '组织管理/组织创建及设置', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 创建组织' },
{ match: '组织管理/组织成员管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理组织成员' },
{ match: '组织管理/组织团队管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理团队' },
{ match: '组织管理/组织项目管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理组织项目' },
// DevOps
{ match: 'DevOps引擎/引擎简介', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 使用 DevOps' },
{ match: 'DevOps引擎/代码流水线', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 配置流水线' },
{ match: 'DevOps引擎/图形流水线', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 编辑流水线' },
{ match: 'DevOps引擎/参数配置', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 配置参数' },
{ match: 'DevOps引擎/密钥设置', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 配置密钥' },
{ match: 'DevOps引擎/执行记录查询', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 查看执行记录' },
//
{ match: '维基/维基页面管理', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 编辑维基' },
{ match: '维基/模板导入及导出', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理维基模板' },
//
{ match: '第三方服务/跨平台代码同步', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 配置同步' },
{ match: '第三方服务/重睛鸟代码溯源', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 查看溯源' },
{ match: '第三方服务/WebIDE', url: 'https://www.gitlink.org.cn/user/projects', label: '打开 GitLink WebIDE' },
{ match: '通知/通知简介', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 管理通知' },
{ match: '通知/通知设置', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 配置通知' },
{ match: 'Bot市场/bot市场', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 浏览 Bot 市场' },
{ match: 'Bot市场/bot安装', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 安装 Bot' },
{ match: 'Bot市场/bot配置', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 配置 Bot' },
{ match: 'Bot市场/bot开发', url: 'https://www.gitlink.org.cn/user/projects', label: '去 GitLink 开发 Bot' },
{ match: '服务协议/GitLink服务协议', url: 'https://www.gitlink.org.cn', label: '查看 GitLink 首页' },
];
export default function GitLinkAction() {
const location = useLocation();
const allDocsData = useAllDocsData();
const [action, setAction] = useState(null);
useEffect(() => {
if (!allDocsData) return;
const pluginIds = Object.keys(allDocsData);
if (pluginIds.length === 0) return;
const plugin = allDocsData[pluginIds[0]];
let activeVersion = null;
for (const version of plugin.versions) {
if (!version.docs) continue;
const docs = Object.values(version.docs);
const found = docs.find(
(doc) => doc.path && location.pathname.endsWith(doc.path)
);
if (found) {
activeVersion = version;
break;
}
}
if (!activeVersion || !activeVersion.docs) return;
const allDocs = Object.values(activeVersion.docs);
const currentDoc = allDocs.find(
(doc) => doc.path && location.pathname.endsWith(doc.path)
);
if (!currentDoc) return;
// Find matching action link
const matched = ACTION_LINKS.find((link) => currentDoc.id.endsWith(link.match));
if (matched) {
setAction(matched);
} else {
setAction(null);
}
}, [location.pathname, allDocsData]);
if (!action) return null;
return (
<div className={styles.container}>
<a
href={action.url}
target="_blank"
rel="noopener noreferrer"
className={styles.button}
>
<svg className={styles.icon} width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
<span>{action.label}</span>
<svg className={styles.arrow} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17l9.2-9.2M17 17V7H7"/>
</svg>
</a>
</div>
);
}

View File

@ -1,41 +0,0 @@
.container {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border: 1px solid var(--ifm-color-primary);
border-radius: 6px;
background: var(--ifm-color-primary-contrast-background);
color: var(--ifm-color-primary);
font-size: 0.88rem;
font-weight: 500;
text-decoration: none !important;
transition: all 0.15s ease;
}
.button:hover {
background: var(--ifm-color-primary);
color: var(--ifm-color-primary-contrast-background);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.icon {
flex-shrink: 0;
}
.arrow {
flex-shrink: 0;
opacity: 0.6;
transition: transform 0.15s ease;
}
.button:hover .arrow {
opacity: 1;
transform: translate(2px, -2px);
}

View File

@ -1,38 +0,0 @@
import React, { useState, useCallback } from 'react';
import styles from './styles.module.css';
export default function ShareButton() {
const [toast, setToast] = useState(null);
const url = typeof window !== 'undefined' ? window.location.href : '';
const pageTitle = typeof document !== 'undefined' ? document.title : '';
const showToast = useCallback((msg) => {
setToast(msg);
setTimeout(() => setToast(null), 2500);
}, []);
const copyLink = useCallback(() => {
const text = `${pageTitle} ${url}`;
navigator.clipboard.writeText(text).then(
() => showToast('链接已复制,可粘贴发送给好友'),
() => showToast('复制失败,请手动复制地址栏链接')
);
}, [pageTitle, url, showToast]);
if (!url) return null;
return (
<div className={styles.container}>
<button className={styles.btn} onClick={copyLink} title="复制链接">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
复制链接分享
</button>
{toast && <div className={styles.toast}>{toast}</div>}
</div>
);
}

View File

@ -1,56 +0,0 @@
.container {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.85rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 6px;
background: var(--ifm-card-background-color);
color: var(--ifm-color-emphasis-700);
font-size: 0.82rem;
cursor: pointer;
transition: all 0.15s ease;
line-height: 1.4;
}
.btn:hover {
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
background: var(--ifm-color-primary-contrast-background);
}
.btn svg {
opacity: 0.65;
}
.btn:hover svg {
opacity: 1;
}
/* Toast */
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
background: var(--ifm-color-emphasis-800);
color: #fff;
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-size: 0.85rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
animation: fadeIn 0.2s ease;
white-space: nowrap;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}

View File

@ -3,7 +3,7 @@ import Footer from '@theme-original/DocItem/Footer';
import type FooterType from '@theme/DocItem/Footer';
import type {WrapperProps} from '@docusaurus/types';
import DocFeedback from '../../../components/DocFeedback';
import GitLinkAction from '../../../components/GitLinkAction';
import BookmarkButton from '../../../components/BookmarkButton';
type Props = WrapperProps<typeof FooterType>;
@ -11,7 +11,9 @@ export default function FooterWrapper(props: Props): ReactNode {
return (
<>
<Footer {...props} />
<GitLinkAction />
<div style={{marginTop: '0.5rem'}}>
<BookmarkButton />
</div>
<DocFeedback />
</>
);

View File

@ -6,6 +6,7 @@ import CollapseButton from '@theme/DocSidebar/Desktop/CollapseButton';
import Content from '@theme/DocSidebar/Desktop/Content';
import type {Props} from '@theme/DocSidebar/Desktop';
import RecentDocs from '../../../components/RecentDocs';
import BookmarkList from '../../../components/BookmarkList';
import styles from './styles.module.css';
@ -27,6 +28,7 @@ function DocSidebarDesktop({path, sidebar, onCollapse, isHidden}: Props) {
{hideOnScroll && <Logo tabIndex={-1} className={styles.sidebarLogo} />}
<div className={styles.recentSection}>
<RecentDocs />
<BookmarkList />
</div>
<Content path={path} sidebar={sidebar} />
{hideable && <CollapseButton onClick={onCollapse} />}