ttrpg-tools/src/App.tsx

191 lines
6.3 KiB
TypeScript
Raw Normal View History

import {
Component,
createContext,
useContext,
createEffect,
createMemo,
createSignal,
} from "solid-js";
2026-02-26 14:07:58 +08:00
import { useLocation } from "@solidjs/router";
import { useJournalStream } from "./components/stores/journalStream";
2026-02-26 00:17:23 +08:00
2026-02-26 00:47:26 +08:00
// 导入组件以注册自定义元素
2026-02-26 14:07:58 +08:00
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";
2026-02-26 00:47:26 +08:00
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
2026-02-26 00:17:23 +08:00
const App: Component = () => {
const location = useLocation();
const stream = useJournalStream();
2026-02-26 14:24:48 +08:00
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);
};
2026-02-26 00:17:23 +08:00
2026-02-26 14:51:26 +08:00
const currentPath = createMemo(() => {
2026-02-26 00:17:23 +08:00
// 根据路由加载对应的 markdown 文件
2026-02-26 14:07:58 +08:00
let path = decodeURIComponent(location.pathname);
if (!path) path = "/content/";
if (path.endsWith("/")) path += "index";
if (!path.endsWith(".md")) path += ".md";
2026-02-26 14:51:26 +08:00
return path;
2026-02-26 00:17:23 +08:00
});
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">
2026-02-26 15:22:40 +08:00
{/* 仅在移动端显示菜单按钮 */}
2026-02-26 14:24:48 +08:00
<button
onClick={() => setIsSidebarOpen(true)}
2026-02-26 15:22:40 +08:00
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
2026-02-26 14:24:48 +08:00
title="目录"
>
</button>
2026-02-26 00:17:23 +08:00
<h1 class="text-2xl font-bold text-gray-900">TTRPG 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>
2026-02-26 00:17:23 +08:00
</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}
/>
2026-02-26 00:17:23 +08:00
</div>
);
};
export default App;