Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/web/app/components/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createPortal } from "react-dom";
import { useOutletContext } from "react-router";
import { cn, useIsMobile } from "@kreozalabs/kei-ui";
import { useHeaderPortalTarget } from "./HeaderPortalContext";
import { IdleFadeWrapper } from "./IdleFadeWrapper";
import { HeaderTitleArea } from "./HeaderTitleArea";
import { SidebarToggle } from "./SidebarToggle";
import type { AppLayoutContext } from "./AppLayout";

interface AppHeaderProps {
title?: string;
Expand All @@ -15,6 +18,8 @@ interface AppHeaderProps {
export function AppHeader({ title, subtitle, icon, className, children }: AppHeaderProps) {
const isMobile = useIsMobile();
const portalTarget = useHeaderPortalTarget();
const context = useOutletContext<AppLayoutContext | undefined>();
const toggleSidebar = context?.toggleSidebar;

const headerContent = (
<IdleFadeWrapper>
Expand All @@ -30,11 +35,12 @@ export function AppHeader({ title, subtitle, icon, className, children }: AppHea
return (
<header
className={cn(
"bg-muted/95 border-border/40 sticky top-0 z-40 w-full shrink-0 border-b px-6 pt-2 pb-1 backdrop-blur-xl md:h-20",
"bg-muted/95 border-border/40 sticky top-0 z-40 flex w-full shrink-0 items-start gap-2 border-b px-4 pt-2.5 pb-2 backdrop-blur-xl",
className
)}
>
{headerContent}
{toggleSidebar && <SidebarToggle onClick={toggleSidebar} className="mt-0.5 shrink-0" />}
<div className="min-w-0 flex-1">{headerContent}</div>
</header>
);
}
Expand Down
27 changes: 22 additions & 5 deletions apps/web/app/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useEffect } from "react";
import { Outlet, isRouteErrorResponse } from "react-router";
import { PlusIcon } from "lucide-react";
import { Button, cn } from "@kreozalabs/kei-ui";
import { Button, cn, useIsMobile } from "@kreozalabs/kei-ui";
import { AppSidebar } from "./AppSidebar";
import { MobileNav } from "./MobileNav";
import { ErrorPage } from "../ErrorPage";
Expand All @@ -14,6 +14,7 @@ import { HeaderPortalContext } from "./HeaderPortalContext";
import { DbSyncStatus } from "./DbSyncStatus";
import { useFullscreen } from "@/hooks/useFullscreen";
import { useHotkeys } from "react-hotkeys-hook";
import { useSwipeGesture, GESTURE_EDGE_THRESHOLD } from "@/hooks/useSwipeGesture";

export interface AppLayoutContext {
isSidebarOpen: boolean;
Expand All @@ -31,7 +32,23 @@ export function AppLayout({ error }: { error?: unknown }) {

function AppLayoutContent({ error }: { error?: unknown }) {
const { settings } = useSettings();
const isMobile = useIsMobile();
const [isSidebarOpen, setIsSidebarOpen] = useState(true);

useEffect(() => {
if (isMobile) {
queueMicrotask(() => {
setIsSidebarOpen(false);
});
}
}, [isMobile]);

useSwipeGesture({
onSwipeRight: () => setIsSidebarOpen(true),
edgeThreshold: GESTURE_EDGE_THRESHOLD,
enabled: isMobile && !isSidebarOpen,
});

const { openActionInput } = useActionInputModal();
const { hasCustomFab } = useMobileFAB();
const [headerPortalRef, setHeaderPortalRef] = useState<HTMLElement | null>(null);
Expand Down Expand Up @@ -64,7 +81,7 @@ function AppLayoutContent({ error }: { error?: unknown }) {
<div
ref={setHeaderPortalRef}
id="global-header-content"
className="flex flex-1 items-center justify-between px-6"
className="flex flex-1 items-center justify-between px-4 md:px-6"
/>

<div className="flex items-center gap-4">
Expand All @@ -84,8 +101,8 @@ function AppLayoutContent({ error }: { error?: unknown }) {

{/* Main Workspace below header */}
<div className="flex flex-1 flex-row overflow-hidden">
{/* Desktop Sidebar */}
<AppSidebar isOpen={isSidebarOpen} />
{/* Sidebar */}
<AppSidebar isOpen={isSidebarOpen} onOpenChange={setIsSidebarOpen} />

{/* Main Content Area */}
<main className="bg-background relative flex min-w-0 flex-1 flex-col overflow-hidden">
Expand Down
211 changes: 130 additions & 81 deletions apps/web/app/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,98 +1,147 @@
import { NavLink } from "react-router";
import { cn } from "@kreozalabs/kei-ui";
import { useRef } from "react";
import { cn, useIsMobile } from "@kreozalabs/kei-ui";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerDescription,
} from "@kreozalabs/kei-ui/components/drawer";
import { navGroups } from "@/config/navigation";
import { SettingsIcon } from "lucide-react";
import { useSwipeGesture } from "@/hooks/useSwipeGesture";

interface AppSidebarProps {
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}

export function AppSidebar({ isOpen = true }: AppSidebarProps) {
return (
<aside
export function AppSidebar({ isOpen = true, onOpenChange }: AppSidebarProps) {
const isMobile = useIsMobile();
const sidebarRef = useRef<HTMLDivElement>(null);

const handleLinkClick = () => {
if (isMobile) {
onOpenChange?.(false);
}
};

useSwipeGesture({
onSwipeLeft: () => onOpenChange?.(false),
enabled: isMobile && isOpen,
targetRef: sidebarRef,
});

const content = (
<div
ref={sidebarRef}
className={cn(
"group hidden shrink-0 flex-col overflow-hidden transition-[width,opacity] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] md:flex",
isOpen ? "w-72 opacity-100" : "invisible w-0 opacity-0"
"no-scrollbar flex h-full w-full flex-col overflow-y-auto transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] md:w-72",
!isOpen && !isMobile && "-translate-x-12"
)}
>
<div
className={cn(
"no-scrollbar flex h-full w-72 flex-col overflow-y-auto transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
!isOpen && "-translate-x-12"
)}
>
<div className="flex flex-1 flex-col gap-2 space-y-2 p-4 pt-6">
<div className="flex flex-1 flex-col gap-2.5 space-y-6">
{navGroups.map((group) => (
<div key={group.label} className="flex flex-col space-y-1">
<h4 className="text-muted-foreground/50 mb-3 px-3 text-[11px] font-bold tracking-wider uppercase">
{group.label}
</h4>
{group.items.map((item) => {
const isHighlight = item.variant === "highlight";
return (
<NavLink
key={item.id}
to={item.href}
end={item.href === "/app"}
draggable={false}
className={({ isActive }) =>
cn(
"group flex w-full items-center justify-between rounded-lg border-none px-3 py-2 font-medium transition-none",
isActive
? cn(
"bg-primary/10 text-primary shadow-none",
isHighlight && "bg-primary/15"
)
: cn(
"text-muted-foreground hover:bg-muted/60",
isHighlight && "text-foreground/80 font-bold"
)
)
}
>
{({ isActive }) => (
<>
<div className="flex items-center gap-2.5">
<item.icon
className={cn(
"size-4.5",
isActive || isHighlight
? "text-primary"
: "text-muted-foreground/70",
isHighlight && !isActive && "opacity-60"
)}
/>
<span className="text-sm tracking-tight">{item.label}</span>
</div>
</>
)}
</NavLink>
);
})}
</div>
))}
</div>
<div className="flex flex-1 flex-col gap-2 space-y-2 p-4 pt-6">
<div className="flex flex-1 flex-col gap-2.5 space-y-6">
{navGroups.map((group) => (
<div key={group.label} className="flex flex-col space-y-1">
<h4 className="text-muted-foreground/50 mb-3 px-3 text-[11px] font-bold tracking-wider uppercase">
{group.label}
</h4>
{group.items.map((item) => {
const isHighlight = item.variant === "highlight";
return (
<NavLink
key={item.id}
to={item.href}
end={item.href === "/app"}
draggable={false}
onClick={handleLinkClick}
className={({ isActive }) =>
cn(
"group flex w-full items-center justify-between rounded-lg border-none px-3 py-2 font-medium transition-none",
isActive
? cn(
"bg-primary/10 text-primary shadow-none",
isHighlight && "bg-primary/15"
)
: cn(
"text-muted-foreground hover:bg-muted/60",
isHighlight && "text-foreground/80 font-bold"
)
)
}
>
{({ isActive }) => (
<>
<div className="flex items-center gap-2.5">
<item.icon
className={cn(
"size-4.5",
isActive || isHighlight ? "text-primary" : "text-muted-foreground/70",
isHighlight && !isActive && "opacity-60"
)}
/>
<span className="text-sm tracking-tight">{item.label}</span>
</div>
</>
)}
</NavLink>
);
})}
</div>
))}
</div>

<div className="border-border/40 mt-auto border-t pt-4">
<NavLink
to="/app/settings"
draggable={false}
className={({ isActive }) =>
cn(
"group flex w-full items-center gap-2.5 rounded-lg border-none px-3 py-2 font-medium transition-none",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
)
}
>
<SettingsIcon className="size-4.5" />
<span className="text-sm tracking-tight">Settings</span>
</NavLink>
</div>
<div className="border-border/40 mt-auto border-t pt-4">
<NavLink
to="/app/settings"
draggable={false}
onClick={handleLinkClick}
className={({ isActive }) =>
cn(
"group flex w-full items-center gap-2.5 rounded-lg border-none px-3 py-2 font-medium transition-none",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
)
}
>
<SettingsIcon className="size-4.5" />
<span className="text-sm tracking-tight">Settings</span>
</NavLink>
</div>
</div>
</div>
);

if (isMobile) {
return (
<Drawer open={isOpen} onOpenChange={onOpenChange} direction="left">
<DrawerContent
className="bg-background rounded-none border-none p-0 data-[vaul-drawer-direction=left]:w-72"
onCloseAutoFocus={(e) => {
e.preventDefault();
document.getElementById("sidebar-toggle-button")?.focus();
}}
>
<DrawerTitle className="sr-only">Navigation Menu</DrawerTitle>
<DrawerDescription className="sr-only">
Main application navigation links
</DrawerDescription>
{content}
</DrawerContent>
</Drawer>
);
}

return (
<aside
className={cn(
"group hidden shrink-0 flex-col overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] md:flex",
isOpen ? "w-72 opacity-100" : "w-0 opacity-0"
)}
>
{content}
</aside>
);
}
6 changes: 5 additions & 1 deletion apps/web/app/components/layout/SidebarToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ export function SidebarToggle({ onClick, className }: SidebarToggleProps) {
<Button
variant="ghost"
size="icon"
id="sidebar-toggle-button"
className={cn(
"hover:bg-muted/80 text-muted-foreground/40 hover:text-foreground size-8 rounded-lg border-none transition-all active:scale-90",
className
)}
onClick={onClick}
onClick={(e) => {
e.currentTarget.blur();
onClick?.();
}}
title="Toggle Sidebar (Ctrl+B)"
>
<PanelLeftIcon className="size-5" />
Expand Down
Loading