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:
@@ -1,8 +1,6 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
|
||||||
import Canvas from "@/components/canvas/canvas";
|
import { CanvasShell } from "@/components/canvas/canvas-shell";
|
||||||
import ConnectionBanner from "@/components/canvas/connection-banner";
|
|
||||||
import CanvasSidebar from "@/components/canvas/canvas-sidebar";
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { fetchAuthQuery, isAuthenticated } from "@/lib/auth-server";
|
import { fetchAuthQuery, isAuthenticated } from "@/lib/auth-server";
|
||||||
@@ -48,13 +46,5 @@ export default async function CanvasPage({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <CanvasShell canvasId={typedCanvasId} />;
|
||||||
<div className="flex h-screen w-screen overflow-hidden">
|
|
||||||
<CanvasSidebar canvasId={typedCanvasId} />
|
|
||||||
<div className="relative min-h-0 min-w-0 flex-1">
|
|
||||||
<ConnectionBanner />
|
|
||||||
<Canvas canvasId={typedCanvasId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
77
components/canvas/canvas-shell.tsx
Normal file
77
components/canvas/canvas-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,7 +77,13 @@ const CATALOG_ICONS: Partial<Record<string, LucideIcon>> = {
|
|||||||
presentation: Presentation,
|
presentation: Presentation,
|
||||||
};
|
};
|
||||||
|
|
||||||
function SidebarRow({ entry }: { entry: NodeCatalogEntry }) {
|
function SidebarRow({
|
||||||
|
entry,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
entry: NodeCatalogEntry;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
const enabled = isNodePaletteEnabled(entry);
|
const enabled = isNodePaletteEnabled(entry);
|
||||||
const Icon = CATALOG_ICONS[entry.type] ?? ClipboardList;
|
const Icon = CATALOG_ICONS[entry.type] ?? ClipboardList;
|
||||||
|
|
||||||
@@ -95,28 +101,36 @@ function SidebarRow({ entry }: { entry: NodeCatalogEntry }) {
|
|||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
title={enabled ? `${entry.label} — auf den Canvas ziehen` : hint}
|
title={enabled ? `${entry.label} — auf den Canvas ziehen` : hint}
|
||||||
className={cn(
|
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
|
enabled
|
||||||
? "cursor-grab border-border/80 bg-card hover:bg-accent active:cursor-grabbing"
|
? "cursor-grab border-border/80 bg-card hover:bg-accent active:cursor-grabbing"
|
||||||
: "cursor-not-allowed border-transparent bg-muted/30 text-muted-foreground",
|
: "cursor-not-allowed border-transparent bg-muted/30 text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0 opacity-80" />
|
<Icon className="size-4 shrink-0 opacity-80" />
|
||||||
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
|
{!compact ? <span className="min-w-0 flex-1 truncate">{entry.label}</span> : null}
|
||||||
{entry.phase > 1 ? (
|
{!compact && entry.phase > 1 ? (
|
||||||
<span className="shrink-0 text-[10px] font-medium tabular-nums text-muted-foreground/80">
|
<span className="shrink-0 text-[10px] font-medium tabular-nums text-muted-foreground/80">
|
||||||
P{entry.phase}
|
P{entry.phase}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{compact ? <span className="sr-only">{entry.label}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type CanvasSidebarProps = {
|
type CanvasSidebarProps = {
|
||||||
canvasId: Id<"canvases">;
|
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 canvas = useAuthQuery(api.canvases.get, { canvasId });
|
||||||
const byCategory = catalogEntriesByCategory();
|
const byCategory = catalogEntriesByCategory();
|
||||||
const [collapsedByCategory, setCollapsedByCategory] = useState<
|
const [collapsedByCategory, setCollapsedByCategory] = useState<
|
||||||
@@ -127,8 +141,24 @@ export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const railEntries = NODE_CATEGORIES_ORDERED.flatMap(
|
||||||
|
(categoryId) => byCategory.get(categoryId) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex w-60 shrink-0 flex-col border-r border-border/80 bg-background">
|
<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">
|
<div className="border-b border-border/80 px-4 py-4">
|
||||||
{canvas === undefined ? (
|
{canvas === undefined ? (
|
||||||
<div className="h-12 animate-pulse rounded-md bg-muted/50" />
|
<div className="h-12 animate-pulse rounded-md bg-muted/50" />
|
||||||
@@ -143,8 +173,22 @@ export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{NODE_CATEGORIES_ORDERED.map((categoryId) => {
|
{NODE_CATEGORIES_ORDERED.map((categoryId) => {
|
||||||
const entries = byCategory.get(categoryId) ?? [];
|
const entries = byCategory.get(categoryId) ?? [];
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
@@ -181,9 +225,11 @@ export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CanvasUserMenu />
|
<CanvasUserMenu compact={railMode} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ function getInitials(nameOrEmail: string) {
|
|||||||
return normalized.slice(0, 2).toUpperCase();
|
return normalized.slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasUserMenu() {
|
type CanvasUserMenuProps = {
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: session, isPending } = authClient.useSession();
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
|
||||||
@@ -37,7 +41,49 @@ export function CanvasUserMenu() {
|
|||||||
if (isPending && !session?.user) {
|
if (isPending && !session?.user) {
|
||||||
return (
|
return (
|
||||||
<div className="border-t p-3">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
components/ui/resizable.tsx
Normal file
50
components/ui/resizable.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
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-lg bg-border" />
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.Separator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-resizable-panels": "^4.8.0",
|
||||||
"resend": "^4.8.0",
|
"resend": "^4.8.0",
|
||||||
"shadcn": "^4.1.0",
|
"shadcn": "^4.1.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -86,6 +86,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
react-resizable-panels:
|
||||||
|
specifier: ^4.8.0
|
||||||
|
version: 4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
resend:
|
resend:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -5569,6 +5572,12 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
react-resizable-panels@4.8.0:
|
||||||
|
resolution: {integrity: sha512-2uEABkewb3ky/ZgIlAUxWa1W/LjsK494fdV1QsXxst7CDRHCzo7h22tWWu3NNaBjmiuriOCt3CvhipnaYcpoIw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
react-style-singleton@2.2.3:
|
react-style-singleton@2.2.3:
|
||||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -12080,6 +12089,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
react-resizable-panels@4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
|
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
get-nonce: 1.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user