mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 19:16:00 -04:00
feat: replace fixed-size file-preview grid with draggable react-resizable-panels layout
- Add `react-resizable-panels` dependency and a `ResizablePanelGroup` / `ResizablePanel` / `ResizableHandle` shadcn-style UI primitive in `src/components/ui/resizable.tsx` - Replace the static `grid-cols-[260px_1fr]` layout in `FilePreview` with a `ResizablePanelGroup` (horizontal on `sm`+, vertical on mobile) so the tree and preview panes are user-draggable; default split is 35 / 65 - Add a `useMediaQuery` hook (SSR-safe, defaults to the wide layout to avoid hydration mismatch) to switch the panel group orientation at the `sm` breakpoint - Fix `PreviewPane` height propagation — both the empty state and the code pane now use `h-full`/`flex-1`/`min-h-0` so the scroll container fills the resizable panel instead of relying on `max-h` clamps
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
"nitro": "^3.0.260429-beta",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-resizable-panels": "^4.11.1",
|
||||
"shadcn": "^4.8.0",
|
||||
"shiki": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -6,6 +6,8 @@ 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"].
|
||||
@@ -89,6 +91,10 @@ export function FilePreview({
|
||||
// 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">
|
||||
@@ -102,33 +108,21 @@ export function FilePreview({
|
||||
{filePaths.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="relative grid grid-cols-1 sm:min-h-[420px] sm:grid-cols-[minmax(0,260px)_minmax(0,1fr)] lg:min-h-0 lg:flex-1">
|
||||
<div className="border-b border-border sm:border-r sm:border-b-0">
|
||||
<FileTree
|
||||
// The shadow-DOM tree captures `style` only at mount, so a
|
||||
// post-hydration light→dark flip (theme: "system" resolves to
|
||||
// "light" during SSR) would otherwise leave a light tree on a
|
||||
// dark page. Re-key on the resolved theme to remount with the
|
||||
// right palette; `model` lives in the parent, so selection holds.
|
||||
key={resolvedTheme}
|
||||
model={model}
|
||||
// `--trees-*-override` vars inherit through the shadow boundary,
|
||||
// so we map the tree's palette onto the app's design tokens:
|
||||
// - bg transparent lets the card bg show through;
|
||||
// - `themeToTreeStyles({ type })` leaves the selected-row bg
|
||||
// transparent (no `colors` map), so without an override the
|
||||
// active file has no visual indication — drive it from accent;
|
||||
// - the tree's accent (focus ring + selected-row border) defaults
|
||||
// to a hardcoded blue that clashes with our monochrome palette;
|
||||
// point it at the app's focus-ring token.
|
||||
// The tree's default left/right inset is 16px with no vertical
|
||||
// padding; tighten the inline inset to 8px and mirror it as
|
||||
// top/bottom padding (`py-2`) so the rows sit evenly inset.
|
||||
className="h-[180px] overflow-auto py-2 [--trees-accent-override:var(--ring)] [--trees-bg-override:transparent] [--trees-padding-inline-override:8px] [--trees-selected-bg-override:var(--accent)] [--trees-selected-fg-override:var(--accent-foreground)] sm:h-full sm:max-h-[480px] lg:max-h-none"
|
||||
style={treeStyle}
|
||||
/>
|
||||
</div>
|
||||
<PreviewPane preview={preview} path={activePath} />
|
||||
<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-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" />
|
||||
@@ -163,19 +157,19 @@ function PreviewPane({
|
||||
|
||||
if (!preview || !path) {
|
||||
return (
|
||||
<div className="flex items-center justify-center px-6 py-10 text-sm text-muted-foreground">
|
||||
<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 flex-col lg:min-h-0">
|
||||
<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="max-h-[360px] overflow-auto text-xs leading-relaxed sm:max-h-[480px] lg:max-h-none lg:min-h-0 lg:flex-1 [&_pre]:bg-transparent! [&_pre]:p-4!"
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function Header() {
|
||||
<div className="flex items-center gap-2">
|
||||
<SiteSearch index={index} />
|
||||
<Button nativeButton={false} render={GITHUB_LINK} variant="outline">
|
||||
<IconBrandGithub data-icon="inline-start" />
|
||||
<IconBrandGithub className="text-muted-foreground" />
|
||||
<span className="sr-only">GitHub</span>
|
||||
</Button>
|
||||
<ModeToggle />
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
|
||||
const TOGGLE_BUTTON = (
|
||||
<Button variant="outline" size="icon" aria-label="Toggle theme">
|
||||
<IconSun className="scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<IconMoon className="absolute scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<IconSun className="scale-100 rotate-0 text-muted-foreground transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<IconMoon className="absolute scale-0 rotate-90 text-muted-foreground transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn("flex h-full w-full aria-[orientation=vertical]:flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && <div className="z-10 flex h-6 w-1 shrink-0 rounded-none bg-border" />}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Subscribe to a CSS media query and return whether it currently matches.
|
||||
*
|
||||
* `matchMedia` is client-only, so the first (server + hydration) render uses
|
||||
* `defaultValue`; the real value is read on mount. Pass the default that
|
||||
* matches your most common environment to minimize the post-hydration flip
|
||||
* (e.g. `true` for a desktop-first `min-width` query).
|
||||
*/
|
||||
export function useMediaQuery(query: string, defaultValue = false): boolean {
|
||||
const [matches, setMatches] = useState(defaultValue);
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
setMatches(mql.matches);
|
||||
const onChange = () => setMatches(mql.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
Generated
+14
@@ -153,6 +153,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.6(react@19.2.6)
|
||||
react-resizable-panels:
|
||||
specifier: ^4.11.1
|
||||
version: 4.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
shadcn:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.0(@types/node@25.9.1)(typescript@6.0.3)
|
||||
@@ -4754,6 +4757,12 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-resizable-panels@4.11.1:
|
||||
resolution: {integrity: sha512-kA4w58V6wYdRLm2rg9pzroZwGlqBLul1FjMP0J8kqTo3zSHtjeH+LXmZaldCo6+HWqs1e5hOcPoajKXdOze37Q==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -10141,6 +10150,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
|
||||
react-resizable-panels@4.11.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.15)(react@19.2.6):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
|
||||
Reference in New Issue
Block a user