bring sidebar

This commit is contained in:
wass08
2026-01-19 10:17:36 +09:00
parent 2caadae40e
commit cba00f7cc7
8 changed files with 669 additions and 217 deletions
+7 -2
View File
@@ -28,8 +28,9 @@ import {
} from "three/tsl";
import { MeshBasicNodeMaterial, PostProcessing } from "three/webgpu";
import { ActionMenu } from "./ui/action-menu";
import { WallTool } from "./tools/wall/wall-tool";
import { ToolManager } from "./tools/tool-manager";
import { AppSidebar } from "./ui/sidebar/app-sidebar";
import { SidebarProvider } from "./ui/primitives/sidebar";
const selectedObjects: Object3D[] = [];
@@ -43,9 +44,13 @@ export default function Editor() {
<TestUndo />
<ActionMenu />
<SidebarProvider className="fixed z-10">
<AppSidebar />
</SidebarProvider>
<Viewer>
<DraftSelector />
<Stats />
{/* <Stats /> */}
<Grid cellColor="#666" sectionColor="#999" fadeDistance={30} />
<Passes />
<ToolManager />
+251 -215
View File
@@ -1,50 +1,55 @@
'use client'
"use client";
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/primitives/button";
import { Input } from "@/components/ui/primitives/input";
import { Separator } from "@/components/ui/primitives/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
} from "@/components/ui/primitives/sheet";
import { Skeleton } from "@/components/ui/primitives/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/primitives/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '18rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "18rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context
return context;
}
function SidebarProvider({
@@ -55,55 +60,59 @@ function SidebarProvider({
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
)
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(
() => (isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)),
() =>
isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open),
[isMobile, setOpen],
)
);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@@ -116,21 +125,21 @@ function SidebarProvider({
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
)
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
className={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className,
)}
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
@@ -140,28 +149,28 @@ function SidebarProvider({
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
if (collapsible === "none") {
return (
<div
className={cn(
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
data-slot="sidebar"
@@ -169,7 +178,7 @@ function Sidebar({
>
{children}
</div>
)
);
}
if (isMobile) {
@@ -183,7 +192,7 @@ function Sidebar({
side={side}
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
>
@@ -194,13 +203,13 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-collapsible={state === 'collapsed' ? collapsible : ''}
className="group peer hidden text-sidebar-foreground md:block "
data-collapsible={state === "collapsed" ? collapsible : ""}
data-side={side}
data-slot="sidebar"
data-state={state}
@@ -209,25 +218,25 @@ function Sidebar({
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
data-slot="sidebar-gap"
/>
<div
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
data-slot="sidebar-container"
@@ -242,20 +251,24 @@ function Sidebar({
</div>
</div>
</div>
)
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
className={cn('size-7', className)}
className={cn("size-7", className)}
data-sidebar="trigger"
data-slot="sidebar-trigger"
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
size="icon"
variant="ghost"
@@ -264,22 +277,22 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
aria-label="Toggle Sidebar"
className={cn(
'-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
"-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
data-sidebar="rail"
@@ -289,206 +302,215 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
title="Toggle Sidebar"
{...props}
/>
)
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
className={cn(
'relative flex w-full flex-1 flex-col bg-background',
'md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm',
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className,
)}
data-slot="sidebar-inset"
{...props}
/>
)
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
className={cn('h-8 w-full bg-background shadow-none', className)}
className={cn("h-8 w-full bg-background shadow-none", className)}
data-sidebar="input"
data-slot="sidebar-input"
{...props}
/>
)
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn('flex flex-col gap-2 p-2', className)}
className={cn("flex flex-col gap-2 p-2", className)}
data-sidebar="header"
data-slot="sidebar-header"
{...props}
/>
)
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn('flex flex-col gap-2 p-2', className)}
className={cn("flex flex-col gap-2 p-2", className)}
data-sidebar="footer"
data-slot="sidebar-footer"
{...props}
/>
)
);
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn('mx-2 w-auto bg-sidebar-border', className)}
className={cn("mx-2 w-auto bg-sidebar-border", className)}
data-sidebar="separator"
data-slot="sidebar-separator"
{...props}
/>
)
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
data-sidebar="content"
data-slot="sidebar-content"
{...props}
/>
)
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
data-sidebar="group"
data-slot="sidebar-group"
{...props}
/>
)
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(
'flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
data-sidebar="group-label"
data-slot="sidebar-group-label"
{...props}
/>
)
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button'
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
'after:-inset-2 after:absolute md:after:hidden',
'group-data-[collapsible=icon]:hidden',
"after:-inset-2 after:absolute md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
data-sidebar="group-action"
data-slot="sidebar-group-action"
{...props}
/>
)
);
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn('w-full text-sm', className)}
className={cn("w-full text-sm", className)}
data-sidebar="group-content"
data-slot="sidebar-group-content"
{...props}
/>
)
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
data-sidebar="menu"
data-slot="sidebar-menu"
{...props}
/>
)
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
className={cn('group/menu-item relative', className)}
className={cn("group/menu-item relative", className)}
data-sidebar="menu-item"
data-slot="sidebar-menu-item"
{...props}
/>
)
);
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button'
const { isMobile, state } = useSidebar()
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
@@ -499,16 +521,16 @@ function SidebarMenuButton({
data-slot="sidebar-menu-button"
{...props}
/>
)
);
if (!tooltip) {
return button
return button;
}
if (typeof tooltip === 'string') {
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
};
}
return (
@@ -516,12 +538,12 @@ function SidebarMenuButton({
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
align="center"
hidden={state !== 'collapsed' || isMobile}
hidden={state !== "collapsed" || isMobile}
side="right"
{...tooltip}
/>
</Tooltip>
)
);
}
function SidebarMenuAction({
@@ -529,130 +551,144 @@ function SidebarMenuAction({
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
showOnHover?: boolean
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button'
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
'after:-inset-2 after:absolute md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
"after:-inset-2 after:absolute md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
data-sidebar="menu-action"
data-slot="sidebar-menu-action"
{...props}
/>
)
);
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
data-sidebar="menu-badge"
data-slot="sidebar-menu-badge"
{...props}
/>
)
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, [])
const width = React.useMemo(
() => `${Math.floor(Math.random() * 40) + 50}%`,
[],
);
return (
<div
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
data-sidebar="menu-skeleton"
data-slot="sidebar-menu-skeleton"
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
data-sidebar="menu-sub"
data-slot="sidebar-menu-sub"
{...props}
/>
)
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
className={cn('group/menu-sub-item relative', className)}
className={cn("group/menu-sub-item relative", className)}
data-sidebar="menu-sub-item"
data-slot="sidebar-menu-sub-item"
{...props}
/>
)
);
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
size?: 'sm' | 'md'
isActive?: boolean
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : 'a'
const Comp = asChild ? Slot : "a";
return (
<Comp
className={cn(
'-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
data-active={isActive}
@@ -661,7 +697,7 @@ function SidebarMenuSubButton({
data-slot="sidebar-menu-sub-button"
{...props}
/>
)
);
}
export {
@@ -689,4 +725,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
};
@@ -0,0 +1,281 @@
"use client";
// import JsonView from '@uiw/react-json-view'
import { ChevronDown, ChevronRight, Plus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useShallow } from "zustand/shallow";
import { IconRail, type PanelId } from "./icon-rail";
import { Button } from "@/components/ui/primitives/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/primitives/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/primitives/dialog";
import {
Sidebar,
SidebarContent,
SidebarHeader,
} from "@/components/ui/primitives/sidebar";
import { cn } from "@/lib/utils";
import useEditor from "@/store/use-editor";
import { useViewer } from "@pascal-app/viewer";
import { SitePanel } from "./panels/site-panel";
export function AppSidebar() {
// const isHelpOpen = useEditor((state) => state.isHelpOpen);
// const setIsHelpOpen = useEditor((state) => state.setIsHelpOpen);
// const isJsonInspectorOpen = useEditor((state) => state.isJsonInspectorOpen);
// const setIsJsonInspectorOpen = useEditor(
// (state) => state.setIsJsonInspectorOpen,
// );
// const selectedNodeIds = useEditor((state) => state.selectedNodeIds);
// const handleDeleteSelected = useEditor((state) => state.handleDeleteSelected);
// const serializeLayout = useEditor((state) => state.serializeLayout);
const activeTool = useEditor((state) => state.tool);
const currentLevelId = useViewer((state) => state.currentLevelId);
const setActiveTool = useEditor((state) => state.setTool);
const [jsonCollapsed, setJsonCollapsed] = useState<boolean | number>(1);
const [mounted, setMounted] = useState(false);
const [activePanel, setActivePanel] = useState<PanelId>("site");
// Wait for client-side hydration to complete before rendering store-dependent content
useEffect(() => {
setMounted(true);
}, []);
// Handle backspace key to delete selected elements
// useEffect(() => {
// const handleKeyDown = (event: KeyboardEvent) => {
// if (event.key === "Backspace" && selectedNodeIds.length > 0) {
// event.preventDefault();
// handleDeleteSelected();
// }
// };
// document.addEventListener("keydown", handleKeyDown);
// return () => document.removeEventListener("keydown", handleKeyDown);
// }, [selectedNodeIds, handleDeleteSelected]);
// Auto-switch to zones panel when zone tool is active
useEffect(() => {
if (activeTool === "zone") {
setActivePanel("zones");
}
}, [activeTool]);
const renderPanelContent = () => {
switch (activePanel) {
case "site":
return <SitePanel />;
// case "zones":
// return <ZonesPanel />;
// case "collections":
// return <CollectionsPanel />;
// case "settings":
// return (
// <SettingsPanel
// onOpenHelp={() => setIsHelpOpen(true)}
// onOpenJsonInspector={() => setIsJsonInspectorOpen(true)}
// />
// );
default:
return null;
}
};
const getPanelTitle = () => {
switch (activePanel) {
case "site":
return "Site";
case "zones":
return "Zones";
case "collections":
return "Collections";
case "settings":
return "Settings";
default:
return "";
}
};
const handleAddZone = () => {
if (currentLevelId) {
setActiveTool("zone");
}
};
// const handleAddCollection = () => {
// const existingNames = collections.map((c) => c.name);
// let counter = 1;
// let newName = `Collection ${counter}`;
// while (existingNames.includes(newName)) {
// counter++;
// newName = `Collection ${counter}`;
// }
// addCollection(newName);
// };
const renderHeaderAction = () => {
switch (activePanel) {
case "zones":
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-6 p-0"
disabled={!currentLevelId}
onClick={handleAddZone}
size="sm"
variant="ghost"
>
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{currentLevelId ? "Add new zone" : "Select a level first"}
</TooltipContent>
</Tooltip>
);
// case "collections":
// if (addToCollectionState.isActive) {
// return (
// <Tooltip>
// <TooltipTrigger asChild>
// <Button
// className="size-6 p-0 text-destructive"
// onClick={cancelAddToCollection}
// size="sm"
// variant="ghost"
// >
// <X className="size-4" />
// </Button>
// </TooltipTrigger>
// <TooltipContent>Cancel</TooltipContent>
// </Tooltip>
// );
// }
// return (
// <Tooltip>
// <TooltipTrigger asChild>
// <Button
// className="size-6 p-0"
// onClick={handleAddCollection}
// size="sm"
// variant="ghost"
// >
// <Plus className="size-4" />
// </Button>
// </TooltipTrigger>
// <TooltipContent>Add new collection</TooltipContent>
// </Tooltip>
// );
default:
return null;
}
};
return (
<Sidebar className={cn("dark text-white ")} variant="floating">
<div className="flex h-full">
{/* Icon Rail */}
<IconRail activePanel={activePanel} onPanelChange={setActivePanel} />
{/* Panel Content */}
<div className="flex flex-1 flex-col overflow-hidden">
<SidebarHeader className="flex-row items-center justify-between px-3 py-2">
<h3 className="font-semibold text-base">{getPanelTitle()}</h3>
{renderHeaderAction()}
</SidebarHeader>
<SidebarContent
className={cn("no-scrollbar flex flex-1 flex-col overflow-hidden")}
>
{renderPanelContent()}
</SidebarContent>
</div>
</div>
{/* Dialogs */}
{/*
<Dialog onOpenChange={setIsJsonInspectorOpen} open={isJsonInspectorOpen}>
<DialogContent className="max-h-[80vh] max-w-3xl overflow-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Build Data Inspector</span>
<div className="flex gap-2">
<Button
className="gap-1"
onClick={() => setJsonCollapsed(false)}
size="sm"
variant="outline"
>
<ChevronDown className="h-3 w-3" />
Expand All
</Button>
<Button
className="gap-1"
onClick={() => setJsonCollapsed(true)}
size="sm"
variant="outline"
>
<ChevronRight className="h-3 w-3" />
Collapse All
</Button>
</div>
</DialogTitle>
<DialogDescription>
View the raw JSON structure of your current build
</DialogDescription>
</DialogHeader>
<div className="mt-4">
<JsonView
collapsed={jsonCollapsed}
style={{
fontSize: '12px',
}}
value={serializeLayout()}
/>
</div>
</DialogContent>
</Dialog>
<Dialog onOpenChange={setIsHelpOpen} open={isHelpOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>House Builder Controls</DialogTitle>
<DialogDescription>
- Click on grid intersections to place walls using the building
tools.
<br />- Hold spacebar to enable camera controls (orbit, pan,
zoom).
<br />- Use control modes (Select/Delete/Building) to switch
between different interactions.
<br />- Create multiple levels and organize your 3D objects and
guides within each level.
<br />- Click on level names to select them and expand/collapse
with the chevron icons.
<br />- Upload PNG/JPEG reference images as guides within each
level.
<br />- Save your build as JSON file for later use or database
storage.
<br />- Load previously saved builds from JSON files.
<br />- Inspect data to view the raw JSON structure of your
current build.
<br />- Export your 3D model as GLB file.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
*/}
</Sidebar>
);
}
@@ -0,0 +1,65 @@
"use client";
import { Building2, Layers, Map as MapIcon, Settings } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/primitives/tooltip";
import { cn } from "@/lib/utils";
export type PanelId = "site" | "zones" | "collections" | "settings";
interface IconRailProps {
activePanel: PanelId;
onPanelChange: (panel: PanelId) => void;
className?: string;
}
const panels: { id: PanelId; icon: typeof Building2; label: string }[] = [
{ id: "site", icon: Building2, label: "Site" },
{ id: "zones", icon: MapIcon, label: "Zones" },
{ id: "collections", icon: Layers, label: "Collections" },
{ id: "settings", icon: Settings, label: "Settings" },
];
export function IconRail({
activePanel,
onPanelChange,
className,
}: IconRailProps) {
return (
<div
className={cn(
"flex w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
className,
)}
>
{panels.map((panel) => {
const Icon = panel.icon;
const isActive = activePanel === panel.id;
return (
<Tooltip key={panel.id}>
<TooltipTrigger asChild>
<button
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
onClick={() => onPanelChange(panel.id)}
type="button"
>
<Icon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{panel.label}</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
export { panels };
@@ -0,0 +1,12 @@
import { useScene } from "@pascal-app/core";
export function SitePanel() {
const rootNodes = useScene((state) => state.rootNodeIds);
return (
<div>
{rootNodes.map((nodeId) => (
<div key={nodeId}>{nodeId}</div>
))}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
+2
View File
@@ -13,6 +13,8 @@
"dependencies": {
"@pascal-app/core": "*",
"@pascal-app/viewer": "*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/uikit-lucide": "^1.0.60",