376 lines
11 KiB
JavaScript
376 lines
11 KiB
JavaScript
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>
|
||
</>
|
||
);
|
||
}
|