ttrpg-tools/src/App.tsx

191 lines
6.3 KiB
TypeScript
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 {
Component,
createContext,
useContext,
createEffect,
createMemo,
createSignal,
} from "solid-js";
import { useLocation } from "@solidjs/router";
import { useJournalStream } from "./components/stores/journalStream";
// 导入组件以注册自定义元素
import "./components";
import {
Article,
MobileSidebar,
DesktopSidebar,
DocDialog,
DataSourceDialog,
RevealManager,
} from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal";
// ---------------------------------------------------------------------------
// Scroll container context lets child components (FileTree, RevealManager)
// find the correct scrollable element instead of relying on window.scroll
// ---------------------------------------------------------------------------
const ScrollContainerCtx = createContext<() => HTMLElement | undefined>();
/** Access the main content scroll container from any descendant. */
export function useScrollContainer(): () => HTMLElement | undefined {
const ctx = useContext(ScrollContainerCtx);
return ctx ?? (() => undefined);
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
const App: Component = () => {
const location = useLocation();
const stream = useJournalStream();
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
const [isDocOpen, setIsDocOpen] = createSignal(false);
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
// Sidebar and TOC data — reload when source changes
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
const [pathHeadings, setPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
const [tocKey, setTocKey] = createSignal(0);
let mainRef!: HTMLElement;
const loadToc = async () => {
const toc = await generateToc();
setFileTree(toc.fileTree);
setPathHeadings(toc.pathHeadings);
};
// Load TOC on mount and when source changes
createEffect(() => {
void tocKey();
void loadToc();
});
const handleSourceChanged = () => {
setTocKey((k) => k + 1);
};
const currentPath = createMemo(() => {
// 根据路由加载对应的 markdown 文件
let path = decodeURIComponent(location.pathname);
if (!path) path = "/content/";
if (path.endsWith("/")) path += "index";
if (!path.endsWith(".md")) path += ".md";
return path;
});
return (
<div class="h-screen flex flex-col overflow-hidden bg-gray-50">
{/* Header */}
<header class="shrink-0 h-16 bg-white shadow z-30">
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
{/* 仅在移动端显示菜单按钮 */}
<button
onClick={() => setIsSidebarOpen(true)}
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
title="目录"
>
</button>
<h1 class="text-2xl font-bold text-gray-900">Tabletop Tools</h1>
<div class="flex-1" />
<button
onClick={() => setIsJournalOpen((v) => !v)}
class={`relative w-8 h-8 rounded flex items-center justify-center text-sm ${
isJournalOpen()
? "bg-blue-100 text-blue-600"
: "text-gray-500 hover:bg-gray-100"
}`}
title={`Journal (${stream.connectionStatus})`}
>
<span
class="absolute top-0.5 right-0.5 w-2 h-2 rounded-full border border-white"
classList={{
"bg-gray-400": stream.connectionStatus === "disconnected",
"bg-yellow-400 animate-pulse":
stream.connectionStatus === "connecting",
"bg-green-500": stream.connectionStatus === "connected",
"bg-red-500": stream.connectionStatus === "error",
}}
/>
📋
</button>
<button
onClick={() => setIsDocOpen(true)}
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
title="指令文档"
>
?
</button>
</div>
</header>
{/* 移动端抽屉式侧边栏 (overlay) */}
<MobileSidebar
isOpen={isSidebarOpen()}
onClose={() => setIsSidebarOpen(false)}
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* Body: sidebar + main + journal */}
<div class="flex flex-1 min-h-0">
{/* 桌面端固定侧边栏 (flex child) */}
<DesktopSidebar
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* Main content — the scrollable region */}
<ScrollContainerCtx.Provider value={() => mainRef}>
<main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto">
<div class="max-w-4xl mx-auto px-4 py-8">
<Article
class="prose text-black prose-sm max-w-full"
src={currentPath()}
>
<RevealManager />
</Article>
</div>
</main>
</ScrollContainerCtx.Provider>
{/* Journal panel wrapper — on desktop: flex child with width transition;
on mobile: passes through to JournalPanel's own overlay */}
<div
class="contents md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
classList={{
"md:w-105": isJournalOpen(),
"md:w-0": !isJournalOpen(),
}}
>
<JournalPanel
open={isJournalOpen()}
onClose={() => setIsJournalOpen(false)}
/>
</div>
</div>
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
<DataSourceDialog
isOpen={isDataSourceOpen()}
onClose={() => setIsDataSourceOpen(false)}
onSourceChanged={handleSourceChanged}
/>
</div>
);
};
export default App;