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[];
+}