ttrpg-tools/src/components/Sidebar.tsx

201 lines
5.3 KiB
TypeScript
Raw Normal View History

2026-02-26 14:24:48 +08:00
import { Component, createSignal, onMount, Show } from "solid-js";
import { generateToc, type FileNode, type TocNode } from "../data-loader";
import { useLocation, useNavigate } from "@solidjs/router";
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
}
/**
*
*/
const FileTreeNode: Component<{
node: FileNode;
currentPath: string;
pathHeadings: Record<string, TocNode[]>;
depth: number;
}> = (props) => {
const navigate = useNavigate();
const [isExpanded, setIsExpanded] = createSignal(true);
const isDir = !!props.node.children;
const isActive = props.currentPath === props.node.path;
const handleClick = () => {
if (isDir) {
setIsExpanded(!isExpanded());
} else {
navigate(props.node.path);
props.onClose();
}
};
const indent = props.depth * 12;
return (
<div>
<div
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
isActive ? "bg-blue-50 text-blue-700" : "text-gray-700"
}`}
style={{ "padding-left": `${indent + 8}px` }}
onClick={handleClick}
>
<Show when={isDir}>
<span class="mr-1 text-gray-400">
{isExpanded() ? "📂" : "📁"}
</span>
</Show>
<Show when={!isDir}>
<span class="mr-1 text-gray-400">📄</span>
</Show>
<span class="text-sm truncate">{props.node.name}</span>
</div>
<Show when={isDir && isExpanded() && props.node.children}>
<div>
{props.node.children!.map((child) => (
<FileTreeNode
node={child}
currentPath={props.currentPath}
pathHeadings={props.pathHeadings}
depth={props.depth + 1}
onClose={props.onClose}
/>
))}
</div>
</Show>
</div>
);
};
/**
*
*/
const HeadingNode: Component<{
node: TocNode;
basePath: string;
depth: number;
}> = (props) => {
const navigate = useNavigate();
const anchor = props.node.title.toLowerCase().replace(/\s+/g, "-");
const href = `${props.basePath}#${anchor}`;
const handleClick = (e: MouseEvent) => {
e.preventDefault();
navigate(href);
};
const indent = props.depth * 12;
return (
<div>
<a
href={href}
class="block py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate"
style={{ "padding-left": `${indent + 8}px` }}
onClick={handleClick}
>
{props.node.title}
</a>
<Show when={props.node.children}>
<div>
{props.node.children!.map((child) => (
<HeadingNode
node={child}
basePath={props.basePath}
depth={props.depth + 1}
/>
))}
</div>
</Show>
</div>
);
};
/**
*
*/
export const Sidebar: Component<SidebarProps> = (props) => {
const location = useLocation();
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
const [pathHeadings, setPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
const [currentFileHeadings, setCurrentFileHeadings] = createSignal<TocNode[]>(
[],
);
2026-02-26 14:30:09 +08:00
onMount(async () => {
const toc = await generateToc();
2026-02-26 14:24:48 +08:00
setFileTree(toc.fileTree);
setPathHeadings(toc.pathHeadings);
});
// 根据当前路径更新当前文件的标题列表
onMount(() => {
const updateHeadings = () => {
const pathname = location.pathname;
const headings = pathHeadings()[pathname] || pathHeadings()[`${pathname}.md`];
setCurrentFileHeadings(headings || []);
};
updateHeadings();
});
return (
<Show when={props.isOpen}>
{/* 遮罩层 */}
<div
class="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={props.onClose}
/>
{/* 侧边栏 */}
<aside class="fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-50 overflow-y-auto">
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold text-gray-900"></h2>
<button
onClick={props.onClose}
class="text-gray-500 hover:text-gray-700"
title="关闭"
>
</button>
</div>
{/* 文件树 */}
<div class="mb-4">
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
</h3>
{fileTree().map((node) => (
<FileTreeNode
node={node}
currentPath={location.pathname}
pathHeadings={pathHeadings()}
depth={0}
onClose={props.onClose}
/>
))}
</div>
{/* 当前文件标题 */}
<Show when={currentFileHeadings().length > 0}>
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2 px-2">
</h3>
{currentFileHeadings().map((node) => (
<HeadingNode
node={node}
basePath={location.pathname}
depth={0}
/>
))}
</div>
</Show>
</div>
</aside>
</Show>
);
};