diff --git a/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts b/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts index 3823bf4..d001156 100644 --- a/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts +++ b/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts @@ -15,7 +15,7 @@ export const FullPageModuleConfig: ModuleConfigEntity = { apiUrl: '/full-page', /** Base Web Router URL for UI navigation */ - webUrl: '/app/full-page', + webUrl: '/app/example/full-page', /** Architectural category of the module, used for rendering and routing logic */ moduleCategory: 'FULL_PAGE', diff --git a/apps/web/src/apps/modules/example/index.tsx b/apps/web/src/apps/modules/example/index.tsx new file mode 100644 index 0000000..4aa852e --- /dev/null +++ b/apps/web/src/apps/modules/example/index.tsx @@ -0,0 +1,15 @@ +import { lazy } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; + +const FullPageModule = lazy(() => import('./full-page/presentation/factory')); +const SinglePageModule = lazy(() => import('./single-page/presentation/factory')); + +export default function ExampleModule() { + return ( + + } /> + } /> + } /> + + ); +} diff --git a/apps/web/src/apps/modules/example/single-page/domain/constants/single-page.constants.ts b/apps/web/src/apps/modules/example/single-page/domain/constants/single-page.constants.ts index e1a8bb9..895bfd7 100644 --- a/apps/web/src/apps/modules/example/single-page/domain/constants/single-page.constants.ts +++ b/apps/web/src/apps/modules/example/single-page/domain/constants/single-page.constants.ts @@ -15,7 +15,7 @@ export const SinglePageModuleConfig: ModuleConfigEntity = { apiUrl: '/single-page', /** Base Web Router URL for UI navigation */ - webUrl: 'apps/example/single-page', + webUrl: '/app/example/single-page', /** Architectural category of the module, used for rendering and routing logic */ moduleCategory: 'SINGLE_PAGE', diff --git a/apps/web/src/apps/modules/index.tsx b/apps/web/src/apps/modules/index.tsx index e38586a..839c01e 100644 --- a/apps/web/src/apps/modules/index.tsx +++ b/apps/web/src/apps/modules/index.tsx @@ -2,16 +2,14 @@ import { lazy } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; import ModuleLayout from './layouts/module.layout'; -const FullPageModule = lazy(() => import('./example/full-page/presentation/factory')); -const SinglePageModule = lazy(() => import('./example/single-page/presentation/factory')); +const ExampleModule = lazy(() => import('./example')); export default function AppModule() { return ( - } /> - } /> - } /> + } /> + } /> } /> diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.tsx b/apps/web/src/apps/modules/layouts/components/sidebar.tsx new file mode 100644 index 0000000..0e8fabd --- /dev/null +++ b/apps/web/src/apps/modules/layouts/components/sidebar.tsx @@ -0,0 +1,161 @@ +import { memo, useCallback, useMemo } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider } from '@repo/ui/components'; +import { useCoreAppShell } from '@repo/ui/components'; +import { ChevronsLeft, ChevronsRight } from 'lucide-react'; +import type { MenuItemType } from '../types/menu.types'; +import type { SidebarVariant } from '@repo/ui/components'; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface SidebarMenuProps { + /** Menu items to render */ + items: MenuItemType[]; + /** + * Override the sidebar variant. When omitted, reads from CoreAppShell context. + * Use `'expanded'` when rendering in the mobile slot (always show labels). + */ + variantOverride?: SidebarVariant; + /** Whether to show the collapse/expand toggle button (desktop only) */ + withToggle?: boolean; +} + +// --------------------------------------------------------------------------- +// Individual Menu Item (Expanded) +// --------------------------------------------------------------------------- + +interface MenuItemExpandedProps { + item: MenuItemType; + isActive: boolean; +} + +const MenuItemExpanded = memo(function MenuItemExpanded({ item, isActive }: MenuItemExpandedProps) { + const Icon = item.icon; + return ( + } + active={isActive} + variant="light" + styles={{ + root: { borderRadius: 'var(--mantine-radius-md)' }, + }} + /> + ); +}); + +// --------------------------------------------------------------------------- +// Individual Menu Item (Mini / Collapsed) +// --------------------------------------------------------------------------- + +interface MenuItemMiniProps { + item: MenuItemType; + isActive: boolean; +} + +const MenuItemMini = memo(function MenuItemMini({ item, isActive }: MenuItemMiniProps) { + const Icon = item.icon; + return ( + + + + + + ); +}); + +// --------------------------------------------------------------------------- +// SidebarMenu Component +// --------------------------------------------------------------------------- + +export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, withToggle = false }: SidebarMenuProps) { + const { pathname } = useLocation(); + const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell(); + + const variant = variantOverride ?? contextVariant; + const isMini = variant === 'mini'; + + // Determine active state: match if current path starts with the menu item's base path. + // Stripping trailing '/index' from item.path so parent routes also match child routes. + const activeKeys = useMemo(() => { + const keys = new Set(); + for (const item of items) { + // e.g. item.path = '/app/example/full-page/index' + // basePath = '/app/example/full-page' + const basePath = item.path.replace(/\/index$/, ''); + if (pathname === item.path || pathname.startsWith(basePath + '/') || pathname === basePath) { + keys.add(item.key); + } + } + return keys; + }, [pathname, items]); + + const handleToggle = useCallback(() => { + setSidebarVariant(isMini ? 'expanded' : 'mini'); + }, [isMini, setSidebarVariant]); + + // -- Mini (Collapsed) Mode ------------------------------------------------ + if (isMini) { + return ( + + + {items.map((item) => ( + + ))} + + + {withToggle && ( + <> + + + + + + + + + + )} + + ); + } + + // -- Expanded Mode -------------------------------------------------------- + return ( + + + {items.map((item) => ( + + ))} + + + {withToggle && ( + <> + + + } + onClick={handleToggle} + variant="subtle" + styles={{ + root: { borderRadius: 'var(--mantine-radius-md)', color: 'var(--mantine-color-dimmed)' }, + }} + /> + + + )} + + ); +}); diff --git a/apps/web/src/apps/modules/layouts/data/menu.data.ts b/apps/web/src/apps/modules/layouts/data/menu.data.ts new file mode 100644 index 0000000..5209b55 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/data/menu.data.ts @@ -0,0 +1,26 @@ +import { FileText, LayoutDashboard } from 'lucide-react'; +import type { MenuItemType } from '../types/menu.types'; + +/** + * Application sidebar menu items. + * + * Paths are absolute and must align with the router hierarchy: + * BrowserRouter → /app/* → /example/* → /single-page/* | /full-page/* + * + * Each module's factory defines sub-routes (e.g. /index, /detail/:id). + * Menu items point to the default /index sub-route. + */ +export const MENU_ITEMS: MenuItemType[] = [ + { + key: 'example-single-page', + label: 'Example Single Page', + icon: FileText, + path: '/app/example/single-page/index', + }, + { + key: 'example-full-page', + label: 'Example Full Page', + icon: LayoutDashboard, + path: '/app/example/full-page/index', + }, +]; diff --git a/apps/web/src/apps/modules/layouts/module.layout.tsx b/apps/web/src/apps/modules/layouts/module.layout.tsx index e37987b..fa7c64d 100644 --- a/apps/web/src/apps/modules/layouts/module.layout.tsx +++ b/apps/web/src/apps/modules/layouts/module.layout.tsx @@ -1,5 +1,7 @@ import { CoreAppShell, CoreAppShellConfig } from '@repo/ui/components'; import HeaderLayout from './components/header.layout'; +import { SidebarMenu } from './components/sidebar'; +import { MENU_ITEMS } from './data/menu.data'; export default function ModuleLayout({ children }: { children: React.ReactNode }) { const configAppShell: CoreAppShellConfig = { @@ -17,7 +19,14 @@ export default function ModuleLayout({ children }: { children: React.ReactNode } }; return ( - }}> + , + sidebar: , + sidebarMobile: , + }} + > {children} ); diff --git a/apps/web/src/apps/modules/layouts/types/menu.types.ts b/apps/web/src/apps/modules/layouts/types/menu.types.ts new file mode 100644 index 0000000..9493d2e --- /dev/null +++ b/apps/web/src/apps/modules/layouts/types/menu.types.ts @@ -0,0 +1,17 @@ +import type { LucideIcon } from 'lucide-react'; + +/** + * Represents a single navigation menu item in the sidebar. + */ +export interface MenuItemType { + /** Unique identifier for the menu item */ + key: string; + /** Display label */ + label: string; + /** Lucide icon component */ + icon: LucideIcon; + /** Absolute route path for navigation */ + path: string; + /** Optional nested child menu items */ + children?: MenuItemType[]; +}