feat(viewer): Add data source dialog
Introduce a dialog for switching between built-in content and a user-selected local folder. Persist the folder handle in IndexedDB so the selection survives page refreshes. Scan the folder for .md, .yarn, and .csv files to build the file index. Update App and Sidebar to pass file tree and heading data, and add TypeScript declarations for the File System Access API.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { Component, createSignal, Show } from "solid-js";
|
||||
import {
|
||||
loadFromUserFolder,
|
||||
switchToBuiltInSource,
|
||||
getActiveSource,
|
||||
clearIndex,
|
||||
} from "../data-loader/file-index";
|
||||
|
||||
export interface DataSourceDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSourceChanged: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for choosing how to source content files.
|
||||
* Supports:
|
||||
* - Built-in content (CLI / webpack bundled files)
|
||||
* - Local folder (via File System Access API)
|
||||
*/
|
||||
export const DataSourceDialog: Component<DataSourceDialogProps> = (props) => {
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [folderPath, setFolderPath] = createSignal<string | null>(null);
|
||||
|
||||
const currentSource = () => getActiveSource();
|
||||
|
||||
const handleFolderPick = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const paths = await loadFromUserFolder();
|
||||
|
||||
if (paths === null) {
|
||||
// User cancelled or API not supported — don't show error for cancellation
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (paths.length === 0) {
|
||||
setError(
|
||||
"No supported files (.md, .yarn, .csv) found in the selected folder.",
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFolderPath(`${paths.length} files loaded`);
|
||||
setLoading(false);
|
||||
props.onSourceChanged();
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const handleBuiltIn = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await switchToBuiltInSource();
|
||||
// Force re-index from built-in sources
|
||||
clearIndex();
|
||||
|
||||
setLoading(false);
|
||||
props.onSourceChanged();
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 class="text-lg font-bold text-gray-900 mb-4">Content Source</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
{/* Built-in option */}
|
||||
<button
|
||||
onClick={handleBuiltIn}
|
||||
disabled={loading()}
|
||||
class="w-full text-left p-4 rounded-lg border transition-colors"
|
||||
classList={{
|
||||
"border-blue-500 bg-blue-50":
|
||||
currentSource() === "cli" || currentSource() === "webpack",
|
||||
"border-gray-200 hover:border-gray-300":
|
||||
currentSource() !== "cli" && currentSource() !== "webpack",
|
||||
"opacity-50 cursor-not-allowed": loading(),
|
||||
}}
|
||||
>
|
||||
<div class="font-medium text-gray-900">Built-in Content</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
Use the bundled content files shipped with the app.
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
currentSource() === "cli" || currentSource() === "webpack"
|
||||
}
|
||||
>
|
||||
<div class="text-xs text-blue-600 mt-1 font-medium">Active</div>
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
{/* Folder picker option */}
|
||||
<button
|
||||
onClick={handleFolderPick}
|
||||
disabled={
|
||||
loading() ||
|
||||
typeof window === "undefined" ||
|
||||
!("showDirectoryPicker" in window)
|
||||
}
|
||||
class="w-full text-left p-4 rounded-lg border transition-colors"
|
||||
classList={{
|
||||
"border-blue-500 bg-blue-50": currentSource() === "folder",
|
||||
"border-gray-200 hover:border-gray-300":
|
||||
currentSource() !== "folder",
|
||||
"opacity-50 cursor-not-allowed":
|
||||
loading() || !("showDirectoryPicker" in window),
|
||||
}}
|
||||
>
|
||||
<div class="font-medium text-gray-900">Local Folder</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
Pick a folder on your computer. Supports .md, .yarn, and .csv
|
||||
files.
|
||||
{"showDirectoryPicker" in window
|
||||
? " Will be remembered across sessions."
|
||||
: " (Not supported in this browser)"}
|
||||
</div>
|
||||
<Show when={currentSource() === "folder"}>
|
||||
<div class="text-xs text-blue-600 mt-1 font-medium">
|
||||
Active{folderPath() ? ` — ${folderPath()}` : ""}
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 rounded hover:bg-gray-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSourceDialog;
|
||||
+31
-17
@@ -6,6 +6,8 @@ import { FileTreeNode, HeadingNode } from "./FileTree";
|
||||
export interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
fileTree?: FileNode[];
|
||||
pathHeadings?: Record<string, TocNode[]>;
|
||||
}
|
||||
|
||||
interface SidebarContentProps {
|
||||
@@ -25,7 +27,9 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||
// 响应式获取当前文件的标题列表
|
||||
const currentFileHeadings = createMemo(() => {
|
||||
const pathname = decodeURIComponent(location.pathname);
|
||||
return props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || [];
|
||||
return (
|
||||
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -68,11 +72,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||
本页
|
||||
</h3>
|
||||
{currentFileHeadings().map((node) => (
|
||||
<HeadingNode
|
||||
node={node}
|
||||
basePath={location.pathname}
|
||||
depth={0}
|
||||
/>
|
||||
<HeadingNode node={node} basePath={location.pathname} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
@@ -85,14 +85,20 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||
*/
|
||||
export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||
const location = useLocation();
|
||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({});
|
||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
|
||||
Record<string, TocNode[]>
|
||||
>({});
|
||||
|
||||
// 加载目录数据
|
||||
const fileTree = () => props.fileTree ?? selfFileTree();
|
||||
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
|
||||
|
||||
// 加载目录数据 (only if props not provided)
|
||||
onMount(async () => {
|
||||
if (props.fileTree && props.pathHeadings) return;
|
||||
const toc = await generateToc();
|
||||
setFileTree(toc.fileTree);
|
||||
setPathHeadings(toc.pathHeadings);
|
||||
setSelfFileTree(toc.fileTree);
|
||||
setSelfPathHeadings(toc.pathHeadings);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -124,16 +130,24 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||
/**
|
||||
* 桌面端固定侧边栏
|
||||
*/
|
||||
export const DesktopSidebar: Component<{}> = () => {
|
||||
export const DesktopSidebar: Component<{
|
||||
fileTree?: FileNode[];
|
||||
pathHeadings?: Record<string, TocNode[]>;
|
||||
}> = (props) => {
|
||||
const location = useLocation();
|
||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({});
|
||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
|
||||
Record<string, TocNode[]>
|
||||
>({});
|
||||
|
||||
const fileTree = () => props.fileTree ?? selfFileTree();
|
||||
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
|
||||
|
||||
// 加载目录数据
|
||||
onMount(async () => {
|
||||
if (props.fileTree && props.pathHeadings) return;
|
||||
const toc = await generateToc();
|
||||
setFileTree(toc.fileTree);
|
||||
setPathHeadings(toc.pathHeadings);
|
||||
setSelfFileTree(toc.fileTree);
|
||||
setSelfPathHeadings(toc.pathHeadings);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,8 @@ export type { SidebarProps } from "./Sidebar";
|
||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||
export { default as DocDialog } from "./DocDialog";
|
||||
export type { DocDialogProps } from "./DocDialog";
|
||||
export { default as DataSourceDialog } from "./DataSourceDialog";
|
||||
export type { DataSourceDialogProps } from "./DataSourceDialog";
|
||||
|
||||
// 导出数据类型
|
||||
export type { DiceProps } from "./md-dice";
|
||||
|
||||
Reference in New Issue
Block a user