Files
mengyanote/mengyanote-frontend/src/components/Sidebar.jsx
2026-05-16 19:03:44 +08:00

376 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo } from 'react';
import { useApp } from '../context/AppContext';
import { NODE_TYPES } from '../utils/fileUtils';
import './Sidebar.css';
function pathHash32(path) {
let h = 0;
for (let i = 0; i < path.length; i += 1) {
h = Math.imul(31, h) + path.charCodeAt(i);
}
return h >>> 0;
}
/** 可见顺序:红→橙→黄→绿→青→蓝→紫→浅黑→浅白,循环;均为浅色粉彩 */
const FOLDER_PALETTE_LIGHT = [
{
bar: 'hsl(355 58% 64%)',
bg: 'hsl(355 48% 97.4%)',
border: 'hsl(355 38% 90%)',
iconTop: 'hsl(355 62% 88%)',
iconBottom: 'hsl(355 58% 72%)',
wash: 'hsl(355 42% 95%)',
washStrong: 'hsl(355 38% 92%)',
muted: 'hsl(350 12% 46%)',
},
{
bar: 'hsl(28 78% 62%)',
bg: 'hsl(32 62% 97.2%)',
border: 'hsl(30 48% 89%)',
iconTop: 'hsl(32 72% 87%)',
iconBottom: 'hsl(28 75% 70%)',
wash: 'hsl(32 50% 94.5%)',
washStrong: 'hsl(30 45% 91.5%)',
muted: 'hsl(28 14% 45%)',
},
{
bar: 'hsl(48 72% 58%)',
bg: 'hsl(50 58% 97.6%)',
border: 'hsl(48 42% 89%)',
iconTop: 'hsl(52 68% 88%)',
iconBottom: 'hsl(48 70% 68%)',
wash: 'hsl(50 48% 95%)',
washStrong: 'hsl(48 40% 92%)',
muted: 'hsl(45 12% 44%)',
},
{
bar: 'hsl(145 52% 52%)',
bg: 'hsl(142 45% 97.3%)',
border: 'hsl(145 36% 88%)',
iconTop: 'hsl(142 48% 86%)',
iconBottom: 'hsl(145 50% 66%)',
wash: 'hsl(142 38% 94.5%)',
washStrong: 'hsl(145 32% 91%)',
muted: 'hsl(145 10% 43%)',
},
{
bar: 'hsl(178 55% 48%)',
bg: 'hsl(175 42% 97.4%)',
border: 'hsl(178 34% 88%)',
iconTop: 'hsl(175 46% 85%)',
iconBottom: 'hsl(178 50% 62%)',
wash: 'hsl(175 36% 94.5%)',
washStrong: 'hsl(178 30% 91%)',
muted: 'hsl(178 10% 42%)',
},
{
bar: 'hsl(218 62% 62%)',
bg: 'hsl(215 50% 97.5%)',
border: 'hsl(218 40% 89%)',
iconTop: 'hsl(215 58% 87%)',
iconBottom: 'hsl(218 62% 68%)',
wash: 'hsl(215 42% 95%)',
washStrong: 'hsl(218 36% 92%)',
muted: 'hsl(218 12% 44%)',
},
{
bar: 'hsl(268 52% 62%)',
bg: 'hsl(265 45% 97.6%)',
border: 'hsl(268 36% 90%)',
iconTop: 'hsl(265 50% 88%)',
iconBottom: 'hsl(268 55% 70%)',
wash: 'hsl(265 38% 95%)',
washStrong: 'hsl(268 32% 92%)',
muted: 'hsl(265 12% 44%)',
},
{
bar: 'hsl(220 14% 46%)',
bg: 'hsl(220 10% 95.8%)',
border: 'hsl(220 8% 87%)',
iconTop: 'hsl(220 12% 84%)',
iconBottom: 'hsl(220 14% 60%)',
wash: 'hsl(220 8% 93%)',
washStrong: 'hsl(220 6% 90%)',
muted: 'hsl(220 10% 42%)',
},
{
bar: 'hsl(210 12% 74%)',
bg: 'hsl(42 28% 98.4%)',
border: 'hsl(40 18% 91%)',
iconTop: 'hsl(45 22% 94%)',
iconBottom: 'hsl(210 10% 78%)',
wash: 'hsl(42 22% 96.2%)',
washStrong: 'hsl(40 18% 94%)',
muted: 'hsl(220 8% 46%)',
},
];
const PALETTE_LEN = FOLDER_PALETTE_LIGHT.length;
function folderThemeForSlot(slot) {
return FOLDER_PALETTE_LIGHT[((slot % PALETTE_LEN) + PALETTE_LEN) % PALETTE_LEN];
}
/**
* 按侧边栏当前可见顺序(深度优先:列出文件夹 → 若展开则进入子级)分配 0,1,2… 再对 9 取模。
*/
function buildVisibleFolderSlotMap(nodes) {
const map = new Map();
let seq = 0;
function walk(list) {
if (!list) return;
for (const n of list) {
if (n.type !== NODE_TYPES.FOLDER) continue;
map.set(n.path, seq % PALETTE_LEN);
seq += 1;
if (n.isExpanded && n.children?.length) {
walk(n.children);
}
}
}
walk(nodes);
return map;
}
function folderGradientId(path) {
return `fol-${pathHash32(path).toString(36)}`;
}
/** 填充式文件夹 SVG渐变填充展开时使用略开的轮廓 */
function FolderGlyph({ path, expanded }) {
const gid = folderGradientId(path);
/* Material Design Icons 风格viewBox 0 0 24 24 */
const closedPath =
'M10 4H4C2.89 4 2 4.89 2 6v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8c0-1.11-.9-2-2-2h-8l-2-2z';
const openPath =
'M19 20H4C2.89 20 2 19.1 2 18V8C2 6.89 2.89 6 4 6H10L12 4H19A2 2 0 0 1 21 6V18C21 19.1 20.1 20 19 20Z';
return (
<svg
className="folder-glyph-svg"
width="20"
height="16"
viewBox="0 0 24 24"
aria-hidden
>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--folder-icon-top)" />
<stop offset="100%" stopColor="var(--folder-icon-bottom)" />
</linearGradient>
</defs>
<path fill={`url(#${gid})`} d={expanded ? openPath : closedPath} />
</svg>
);
}
function FileGlyph() {
return (
<svg className="file-glyph-svg" width="18" height="18" viewBox="0 0 24 24" aria-hidden>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"
/>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
d="M14 2v6h6"
/>
</svg>
);
}
function TreeNode({ node, level = 0, folderSlotByPath }) {
const { selectFile, toggleNodeExpansion, currentFile } = useApp();
// 计算当前目录下的直接文章数量
const articleCount = useMemo(() => {
if (node.type !== NODE_TYPES.FOLDER || !node.children) {
return 0;
}
return node.children.filter(child =>
child.type === NODE_TYPES.FILE && child.name.endsWith('.md')
).length;
}, [node]);
const handleClick = () => {
if (node.type === NODE_TYPES.FOLDER) {
toggleNodeExpansion(node.path);
} else {
selectFile(node.path);
}
};
const isSelected = currentFile === node.path;
const hasChildren = node.children && node.children.length > 0;
const paddingLeft = `${level * 10 + 10}px`;
const contentClass =
node.type === NODE_TYPES.FOLDER
? `tree-node-content tree-node-content--folder${isSelected ? ' selected' : ''}`
: `tree-node-content tree-node-content--file${isSelected ? ' selected' : ''}`;
const folderSlot =
node.type === NODE_TYPES.FOLDER ? folderSlotByPath.get(node.path) ?? 0 : null;
const folderTheme =
node.type === NODE_TYPES.FOLDER ? folderThemeForSlot(folderSlot) : null;
const folderShellStyle =
folderTheme &&
({
'--folder-bar': folderTheme.bar,
'--folder-bg': folderTheme.bg,
'--folder-border': folderTheme.border,
'--folder-icon-top': folderTheme.iconTop,
'--folder-icon-bottom': folderTheme.iconBottom,
'--folder-wash': folderTheme.wash,
'--folder-wash-strong': folderTheme.washStrong,
'--folder-muted': folderTheme.muted,
});
const fileRow = (
<div
className={contentClass}
style={{ paddingLeft }}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick();
}
}}
>
{node.type === NODE_TYPES.FOLDER && (
<span className="tree-node-icon-wrap tree-node-icon-wrap--folder" aria-hidden>
<FolderGlyph path={node.path} expanded={Boolean(node.isExpanded && hasChildren)} />
</span>
)}
{node.type === NODE_TYPES.FILE && (
<span className="tree-node-icon-wrap tree-node-icon-wrap--file" aria-hidden>
<FileGlyph />
</span>
)}
<span className="tree-node-name">
{node.type === NODE_TYPES.FILE && node.name.endsWith('.md')
? node.name.slice(0, -3)
: node.name}
</span>
{node.type === NODE_TYPES.FOLDER && articleCount > 0 && (
<span className="article-count-badge">[{articleCount}]</span>
)}
</div>
);
if (node.type === NODE_TYPES.FOLDER) {
return (
<div className="tree-node tree-node--folder">
<div className="folder-shell" style={folderShellStyle}>
{fileRow}
{node.isExpanded && hasChildren && (
<div className="tree-node-children">
{node.children.map((child) => (
<TreeNode
key={child.path}
node={child}
level={level + 1}
folderSlotByPath={folderSlotByPath}
/>
))}
</div>
)}
</div>
</div>
);
}
return (
<div className="tree-node tree-node--file">
{fileRow}
</div>
);
}
export default function Sidebar() {
const { directoryTree, isLoading, error, sidebarOpen, toggleSidebar } = useApp();
const folderSlotByPath = useMemo(
() => buildVisibleFolderSlotMap(directoryTree),
[directoryTree]
);
return (
<>
<aside className={sidebarOpen ? 'sidebar open' : 'sidebar closed'} aria-hidden={!sidebarOpen}>
<div className="sidebar-header">
<h2>
<span className="sidebar-title-deco" aria-hidden>
<span className="sidebar-title-deco-block sidebar-title-deco-block--g" />
<span className="sidebar-title-deco-block sidebar-title-deco-block--r" />
<span className="sidebar-title-deco-block sidebar-title-deco-block--b" />
</span>
文章目录
</h2>
<button
type="button"
onClick={toggleSidebar}
className="toggle-button"
aria-label={sidebarOpen ? '隐藏目录' : '展开目录'}
title={sidebarOpen ? '隐藏目录' : '展开目录'}
>
{sidebarOpen ? '◀' : '▶'}
</button>
</div>
<div className="sidebar-content">
{isLoading && (
<div className="loading">
<div className="loading-spinner" aria-hidden />
<span>加载中...</span>
</div>
)}
{error && (
<div className="error">
<span>目录加载失败: {error}</span>
</div>
)}
{!isLoading && !error && directoryTree.length > 0 && (
<div className="directory-tree">
{directoryTree.map((node) => (
<TreeNode key={node.path} node={node} folderSlotByPath={folderSlotByPath} />
))}
</div>
)}
{!isLoading && !error && directoryTree.length === 0 && (
<div className="sidebar-empty-hint">
<p>暂无文章目录</p>
<p className="sidebar-empty-detail">
接口已返回空列表请在后端确认笔记目录已挂载且路径正确例如将整个
<code>data</code>
挂到容器的
<code>/app/mengyanote</code>
笔记应在
<code>mengyanote/mengyanote/</code>
或查看
<code>/api/health</code>
中的
<code>markdown_root</code>
<code>markdown_root_entry_count</code>
</p>
</div>
)}
</div>
</aside>
</>
);
}