Files
stanza/apps/web/src/components/builder/file-preview.tsx
T
jake 27f90c1765 refactor: replace Fumadocs-owned docs chrome with custom DocsSidebar / DocsToc components
- Add `src/components/docs/docs-sidebar.tsx` — a bespoke sidebar that reads the Fumadocs page tree and renders a `<NavList>` of links using TanStack Router's `<Link>`; shows a `<details>` disclosure on mobile and a sticky `<aside>` rail on `md`+, active item highlighted via the site's `--accent` token
- Add `src/components/docs/docs-toc.tsx` — a bespoke table of contents that wraps Fumadocs' `AnchorProvider`/`useActiveAnchor` in a plain `<ul>` with depth-based padding; hidden below `xl`
- Replace `<DocsLayout>` / `<DocsPage>` / `<DocsTitle>` / `<DocsDescription>` in `docs.$.tsx` with the custom components and plain `<article>` markup so the docs page is fully owned by the site shell rather than Fumadocs' layout primitives; `baseOptions()` and `layout.shared.tsx` are deleted
- Simplify the Fumadocs CSS token override in `styles.css` — the `#nd-sidebar` specificity hack is no longer needed now that Fumadocs renders no sidebar of its own
- Move `bin` from the top-level field into `publishConfig` in both `apps/cli/package.json` and `packages/create-stanza/package.json` so the binary entries only apply to the published artifact, not local workspace resolution
- Add a `tslib` → `tslib/tslib.es6.mjs` alias in `vite.config.ts` to avoid Rolldown's CJS interop issue with tslib's UMD bundle in Vite 8
- Set `[--trees-border-radius-override:0px]` on the file-tree in `FilePreview` so selected rows render as flush rectangles rather than rounded pills
2026-05-21 18:58:22 -04:00

179 lines
7.3 KiB
TypeScript

import { themeToTreeStyles } from "@pierre/trees";
import { FileTree, useFileTree, useFileTreeSelection } from "@pierre/trees/react";
import { IconLoader2 } from "@tabler/icons-react";
import { useRouterState } from "@tanstack/react-router";
import { useEffect, useMemo, useRef } from "react";
import { useTheme } from "@/components/theme-provider";
import { Card } from "@/components/ui/card";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { useMediaQuery } from "@/hooks/use-media-query";
import type { Preview } from "@/server/highlighter";
// Every ancestor directory of each file path, e.g. "a/b/c.ts" → ["a", "a/b"].
function directoryPaths(paths: readonly string[]): string[] {
const dirs = new Set<string>();
for (const path of paths) {
const segments = path.split("/");
segments.pop();
let prefix = "";
for (const segment of segments) {
prefix = prefix ? `${prefix}/${segment}` : segment;
dirs.add(prefix);
}
}
return [...dirs];
}
export function FilePreview({
filePaths,
previews,
moduleCount,
}: {
filePaths: string[];
previews: Record<string, Preview>;
/** Number of selected modules (slots + add-ons) contributing files. */
moduleCount: number;
}) {
// @pierre/trees is path-driven. `useFileTree` builds the model once (lazy
// `useState` init), so it does NOT react to later `paths` changes on its own.
const { model } = useFileTree({
paths: filePaths,
search: false,
});
// The parent's loader reruns on every selection change, handing us a fresh
// `filePaths`. Re-seed the existing model so the tree reflects the new set.
// `resetPaths` collapses everything by default, so first snapshot which
// directories the user had expanded (against the *previous* path set, still
// live in the model) and replay them via `initialExpandedPaths`.
const prevPathsRef = useRef(filePaths);
useEffect(() => {
const expanded = directoryPaths(prevPathsRef.current).filter((dir) => {
const item = model.getItem(dir);
return item != null && "isExpanded" in item && item.isExpanded();
});
expanded.sort();
// Capture the open file before `resetPaths` wipes selection.
const previousSelection = model.getSelectedPaths()[0];
model.resetPaths(filePaths, { initialExpandedPaths: expanded });
// `resetPaths` clears selection; without a selected row the tree gives no
// indication of which file the preview pane is showing. Re-seed it: keep
// the open file if it survived the path change, else fall back to the
// default (root package.json / first file) the preview defaults to.
const fallback = filePaths.includes("package.json") ? "package.json" : filePaths[0];
const next =
previousSelection != null && filePaths.includes(previousSelection)
? previousSelection
: fallback;
if (next) model.getItem(next)?.select();
prevPathsRef.current = filePaths;
}, [model, filePaths]);
// The model owns selection internally; `useFileTreeSelection` exposes the
// currently-selected paths as a stable readonly array we subscribe to.
const selectedPaths = useFileTreeSelection(model);
// Until the user picks a file, default to the synthesized root package.json
// (the most useful at-a-glance summary of the stack) when it exists, else the
// first file in the tree.
const defaultPath = filePaths.includes("package.json") ? "package.json" : filePaths[0];
const activePath = selectedPaths[0] ?? defaultPath;
const preview = activePath ? previews[activePath] : undefined;
// Drive the tree's shadow-root palette from the app theme — otherwise it
// auto-detects via `prefers-color-scheme` and mismatches when the user picks
// a theme different from their OS setting.
const { resolvedTheme } = useTheme();
const treeStyle = useMemo(() => themeToTreeStyles({ type: resolvedTheme }), [resolvedTheme]);
// The route loader reruns its server fn on every selection change; surface
// that as a subtle overlay so the preview reads as "refreshing" rather than
// flashing stale content.
const isLoading = useRouterState({ select: (s) => s.isLoading });
// `sm` breakpoint (640px): below it the panes stack and resize vertically.
// Default `true` (desktop-first) so SSR/first paint match the common case.
const isWide = useMediaQuery("(min-width: 640px)", true);
return (
<Card className="gap-0 overflow-hidden p-0 lg:min-h-0 lg:flex-1">
<div className="flex items-center justify-between gap-3 border-b border-border bg-muted/30 px-4 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{filePaths.length > 0
? `${filePaths.length} file${filePaths.length === 1 ? "" : "s"} from ${moduleCount} module${moduleCount === 1 ? "" : "s"}`
: "Pick a module to preview generated files"}
</span>
</div>
{filePaths.length === 0 ? (
<EmptyState />
) : (
<div className="relative h-[420px] lg:h-auto lg:min-h-0 lg:flex-1">
<ResizablePanelGroup orientation={isWide ? "horizontal" : "vertical"}>
<ResizablePanel defaultSize="35%" minSize="20%">
<FileTree
key={resolvedTheme}
model={model}
className="h-full overflow-auto py-2 [--trees-accent-override:var(--ring)] [--trees-bg-override:transparent] [--trees-border-radius-override:0px] [--trees-padding-inline-override:8px] [--trees-selected-bg-override:var(--accent)] [--trees-selected-fg-override:var(--accent-foreground)]"
style={treeStyle}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="65%" minSize="35%">
<PreviewPane preview={preview} path={activePath} />
</ResizablePanel>
</ResizablePanelGroup>
{isLoading ? (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-card/50 backdrop-blur-[1px] transition-opacity duration-150">
<IconLoader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
)}
</Card>
);
}
function EmptyState() {
return (
<div className="flex min-h-[280px] items-center justify-center px-6 py-10 text-center text-sm text-muted-foreground lg:min-h-0 lg:flex-1">
The file tree will populate as you pick modules.
</div>
);
}
function PreviewPane({
preview,
path,
}: {
preview: Preview | undefined;
path: string | undefined;
}) {
const { resolvedTheme } = useTheme();
const inner = useMemo(
() => ({ __html: preview ? (resolvedTheme === "dark" ? preview.dark : preview.light) : "" }),
[preview, resolvedTheme],
);
if (!preview || !path) {
return (
<div className="flex h-full items-center justify-center px-6 py-10 text-sm text-muted-foreground">
Select a file
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="border-b border-border bg-muted/20 px-4 py-2 font-mono text-[11px] text-muted-foreground">
{path}
</div>
<div
className="min-h-0 flex-1 overflow-auto text-xs leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-4!"
// Shiki HTML is server-rendered from our trusted registry payload.
dangerouslySetInnerHTML={inner}
/>
</div>
);
}