feat: add react-resizable-panels dependency and update canvas components for improved layout

- Introduced the react-resizable-panels package to enhance panel resizing capabilities.
- Refactored CanvasPage to utilize CanvasShell for a cleaner layout.
- Updated CanvasSidebar to support a compact mode and improved rendering logic for user entries.
- Enhanced CanvasUserMenu with a compact option for better UI adaptability.
This commit is contained in:
Matthias
2026-04-01 08:46:26 +02:00
parent b428f5f4df
commit c1d7a49bc3
7 changed files with 293 additions and 69 deletions

View File

@@ -0,0 +1,77 @@
"use client";
import { useCallback, useState } from "react";
import Canvas from "@/components/canvas/canvas";
import ConnectionBanner from "@/components/canvas/connection-banner";
import CanvasSidebar from "@/components/canvas/canvas-sidebar";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import type { Id } from "@/convex/_generated/dataModel";
const SIDEBAR_DEFAULT_SIZE = "18%";
const SIDEBAR_COLLAPSE_THRESHOLD = "10%";
const SIDEBAR_MAX_SIZE = "40%";
const SIDEBAR_COLLAPSED_SIZE = "64px";
const SIDEBAR_RAIL_MAX_WIDTH_PX = 112;
const MAIN_PANEL_MIN_SIZE = "40%";
type CanvasShellProps = {
canvasId: Id<"canvases">;
};
type PanelSize = {
asPercentage: number;
inPixels: number;
};
export function CanvasShell({ canvasId }: CanvasShellProps) {
const [isSidebarRail, setIsSidebarRail] = useState(false);
const handleSidebarResize = useCallback((panelSize: PanelSize) => {
setIsSidebarRail(panelSize.inPixels <= SIDEBAR_RAIL_MAX_WIDTH_PX);
}, []);
return (
<div className="h-screen w-screen overflow-hidden overscroll-none">
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full min-h-0 min-w-0 overflow-hidden"
>
<ResizablePanel
id="canvas-sidebar-panel"
order={1}
defaultSize={SIDEBAR_DEFAULT_SIZE}
minSize={SIDEBAR_COLLAPSE_THRESHOLD}
maxSize={SIDEBAR_MAX_SIZE}
collapsible
collapsedSize={SIDEBAR_COLLAPSED_SIZE}
className="min-h-0 min-w-0 overflow-hidden"
onResize={handleSidebarResize}
>
<CanvasSidebar canvasId={canvasId} railMode={isSidebarRail} />
</ResizablePanel>
<ResizableHandle
withHandle
className="w-1 bg-border/80 transition-colors hover:bg-primary/30"
/>
<ResizablePanel
id="canvas-main-panel"
order={2}
minSize={MAIN_PANEL_MIN_SIZE}
className="min-h-0 min-w-0"
>
<div className="relative h-full min-h-0 w-full min-w-0 overflow-hidden">
<ConnectionBanner />
<Canvas canvasId={canvasId} />
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -77,7 +77,13 @@ const CATALOG_ICONS: Partial<Record<string, LucideIcon>> = {
presentation: Presentation,
};
function SidebarRow({ entry }: { entry: NodeCatalogEntry }) {
function SidebarRow({
entry,
compact = false,
}: {
entry: NodeCatalogEntry;
compact?: boolean;
}) {
const enabled = isNodePaletteEnabled(entry);
const Icon = CATALOG_ICONS[entry.type] ?? ClipboardList;
@@ -95,28 +101,36 @@ function SidebarRow({ entry }: { entry: NodeCatalogEntry }) {
onDragStart={onDragStart}
title={enabled ? `${entry.label} — auf den Canvas ziehen` : hint}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
"rounded-lg border transition-colors",
compact
? "flex h-10 w-full items-center justify-center p-0"
: "flex items-center gap-2 px-3 py-2 text-sm",
enabled
? "cursor-grab border-border/80 bg-card hover:bg-accent active:cursor-grabbing"
: "cursor-not-allowed border-transparent bg-muted/30 text-muted-foreground",
)}
>
<Icon className="size-4 shrink-0 opacity-80" />
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
{entry.phase > 1 ? (
{!compact ? <span className="min-w-0 flex-1 truncate">{entry.label}</span> : null}
{!compact && entry.phase > 1 ? (
<span className="shrink-0 text-[10px] font-medium tabular-nums text-muted-foreground/80">
P{entry.phase}
</span>
) : null}
{compact ? <span className="sr-only">{entry.label}</span> : null}
</div>
);
}
type CanvasSidebarProps = {
canvasId: Id<"canvases">;
railMode?: boolean;
};
export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) {
export default function CanvasSidebar({
canvasId,
railMode = false,
}: CanvasSidebarProps) {
const canvas = useAuthQuery(api.canvases.get, { canvasId });
const byCategory = catalogEntriesByCategory();
const [collapsedByCategory, setCollapsedByCategory] = useState<
@@ -127,63 +141,95 @@ export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) {
),
);
const railEntries = NODE_CATEGORIES_ORDERED.flatMap(
(categoryId) => byCategory.get(categoryId) ?? [],
);
return (
<aside className="flex w-60 shrink-0 flex-col border-r border-border/80 bg-background">
<div className="border-b border-border/80 px-4 py-4">
{canvas === undefined ? (
<div className="h-12 animate-pulse rounded-md bg-muted/50" />
<aside className="flex h-full w-full min-w-0 overflow-hidden flex-col border-r border-border/80 bg-background">
{railMode ? (
<div className="border-b border-border/80 px-2 py-3">
<div className="flex items-center justify-center">
<span
className="line-clamp-1 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
title={canvas?.name ?? "Canvas"}
>
{canvas?.name?.slice(0, 2).toUpperCase() ?? "CV"}
</span>
</div>
</div>
) : (
<div className="border-b border-border/80 px-4 py-4">
{canvas === undefined ? (
<div className="h-12 animate-pulse rounded-md bg-muted/50" />
) : (
<>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Canvas
</p>
<h1 className="mt-1 line-clamp-2 text-base font-semibold leading-snug text-foreground">
{canvas?.name ?? "…"}
</h1>
</>
)}
</div>
)}
<div
className={cn(
"flex-1 overflow-y-auto overscroll-contain",
railMode ? "p-2" : "p-3",
)}
>
{railMode ? (
<div className="flex flex-col gap-1.5">
{railEntries.map((entry) => (
<SidebarRow key={entry.type} entry={entry} compact />
))}
</div>
) : (
<>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Canvas
</p>
<h1 className="mt-1 line-clamp-2 text-base font-semibold leading-snug text-foreground">
{canvas?.name ?? "…"}
</h1>
{NODE_CATEGORIES_ORDERED.map((categoryId) => {
const entries = byCategory.get(categoryId) ?? [];
if (entries.length === 0) return null;
const { label } = NODE_CATEGORY_META[categoryId];
const isCollapsed = collapsedByCategory[categoryId] ?? categoryId !== "source";
return (
<div key={categoryId} className="mb-4 last:mb-0">
<button
type="button"
onClick={() =>
setCollapsedByCategory((prev) => ({
...prev,
[categoryId]: !(prev[categoryId] ?? categoryId !== "source"),
}))
}
className="mb-2 flex w-full items-center justify-between rounded-md px-0.5 py-1 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
aria-expanded={!isCollapsed}
aria-controls={`sidebar-category-${categoryId}`}
>
<span>{label}</span>
{isCollapsed ? (
<ChevronRight className="size-3.5 shrink-0" />
) : (
<ChevronDown className="size-3.5 shrink-0" />
)}
</button>
{!isCollapsed ? (
<div id={`sidebar-category-${categoryId}`} className="flex flex-col gap-1.5">
{entries.map((entry) => (
<SidebarRow key={entry.type} entry={entry} />
))}
</div>
) : null}
</div>
);
})}
</>
)}
</div>
<div className="flex-1 overflow-y-auto p-3">
{NODE_CATEGORIES_ORDERED.map((categoryId) => {
const entries = byCategory.get(categoryId) ?? [];
if (entries.length === 0) return null;
const { label } = NODE_CATEGORY_META[categoryId];
const isCollapsed = collapsedByCategory[categoryId] ?? categoryId !== "source";
return (
<div key={categoryId} className="mb-4 last:mb-0">
<button
type="button"
onClick={() =>
setCollapsedByCategory((prev) => ({
...prev,
[categoryId]: !(prev[categoryId] ?? categoryId !== "source"),
}))
}
className="mb-2 flex w-full items-center justify-between rounded-md px-0.5 py-1 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
aria-expanded={!isCollapsed}
aria-controls={`sidebar-category-${categoryId}`}
>
<span>{label}</span>
{isCollapsed ? (
<ChevronRight className="size-3.5 shrink-0" />
) : (
<ChevronDown className="size-3.5 shrink-0" />
)}
</button>
{!isCollapsed ? (
<div id={`sidebar-category-${categoryId}`} className="flex flex-col gap-1.5">
{entries.map((entry) => (
<SidebarRow key={entry.type} entry={entry} />
))}
</div>
) : null}
</div>
);
})}
</div>
<CanvasUserMenu />
<CanvasUserMenu compact={railMode} />
</aside>
);
}

View File

@@ -20,7 +20,11 @@ function getInitials(nameOrEmail: string) {
return normalized.slice(0, 2).toUpperCase();
}
export function CanvasUserMenu() {
type CanvasUserMenuProps = {
compact?: boolean;
};
export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
@@ -37,7 +41,49 @@ export function CanvasUserMenu() {
if (isPending && !session?.user) {
return (
<div className="border-t p-3">
<div className="h-10 animate-pulse rounded-lg bg-muted/60" />
<div
className={
compact
? "mx-auto h-9 w-9 animate-pulse rounded-full bg-muted/60"
: "h-10 animate-pulse rounded-lg bg-muted/60"
}
/>
</div>
);
}
if (compact) {
return (
<div className="border-t border-border/80 p-2">
<div className="flex flex-col items-center gap-1.5">
<Avatar className="size-9 shrink-0 border border-border/60">
<AvatarFallback className="bg-muted text-xs font-medium text-muted-foreground">
{initials}
</AvatarFallback>
</Avatar>
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
asChild
title="Dashboard"
>
<Link href="/dashboard" aria-label="Dashboard">
<LayoutDashboard className="size-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
type="button"
title="Abmelden"
aria-label="Abmelden"
onClick={() => void handleSignOut()}
>
<LogOut className="size-4" />
</Button>
</div>
</div>
);
}