@viliha/vui-ui 1.1.8 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/AGENT.md +79 -9
  2. package/README.md +60 -0
  3. package/bin/vui.mjs +211 -0
  4. package/package.json +10 -2
  5. package/template/.env.example +20 -0
  6. package/template/app/(app)/branches/branches-table.tsx +69 -0
  7. package/template/app/(app)/branches/page.tsx +13 -0
  8. package/template/app/(app)/businesses/businesses-table.tsx +39 -0
  9. package/template/app/(app)/businesses/page.tsx +13 -0
  10. package/template/app/(app)/calendar/page.tsx +725 -0
  11. package/template/app/(app)/charts/charts-content.tsx +190 -0
  12. package/template/app/(app)/charts/layout.tsx +8 -0
  13. package/template/app/(app)/charts/page.tsx +44 -0
  14. package/template/app/(app)/chat/layout.tsx +14 -0
  15. package/template/app/(app)/chat/page.tsx +311 -0
  16. package/template/app/(app)/components/layout.tsx +8 -0
  17. package/template/app/(app)/components/page.tsx +464 -0
  18. package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
  19. package/template/app/(app)/crm/companies/page.tsx +13 -0
  20. package/template/app/(app)/crm/opportunities/opportunities-board.tsx +484 -0
  21. package/template/app/(app)/crm/opportunities/page.tsx +13 -0
  22. package/template/app/(app)/crm/people/page.tsx +13 -0
  23. package/template/app/(app)/crm/people/people-table.tsx +50 -0
  24. package/template/app/(app)/dashboard/layout.tsx +8 -0
  25. package/template/app/(app)/dashboard/page.tsx +239 -0
  26. package/template/app/(app)/departments/departments-table.tsx +55 -0
  27. package/template/app/(app)/departments/page.tsx +13 -0
  28. package/template/app/(app)/employees/employees-table.tsx +64 -0
  29. package/template/app/(app)/employees/page.tsx +13 -0
  30. package/template/app/(app)/forms/layout.tsx +8 -0
  31. package/template/app/(app)/forms/page.tsx +388 -0
  32. package/template/app/(app)/layout.tsx +74 -0
  33. package/template/app/(app)/markets/markets-table.tsx +76 -0
  34. package/template/app/(app)/markets/page.tsx +13 -0
  35. package/template/app/(app)/organizations/edit/page.tsx +72 -0
  36. package/template/app/(app)/organizations/new/page.tsx +46 -0
  37. package/template/app/(app)/organizations/organizations-config.tsx +97 -0
  38. package/template/app/(app)/organizations/organizations-table.tsx +42 -0
  39. package/template/app/(app)/organizations/page.tsx +13 -0
  40. package/template/app/(app)/settings/layout.tsx +8 -0
  41. package/template/app/(app)/settings/page.tsx +255 -0
  42. package/template/app/(app)/steps/page.tsx +263 -0
  43. package/template/app/(app)/support/layout.tsx +14 -0
  44. package/template/app/(app)/support/page.tsx +345 -0
  45. package/template/app/(app)/system/cities/cities-table.tsx +34 -0
  46. package/template/app/(app)/system/cities/page.tsx +13 -0
  47. package/template/app/(app)/system/countries/countries-table.tsx +34 -0
  48. package/template/app/(app)/system/countries/page.tsx +13 -0
  49. package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
  50. package/template/app/(app)/system/currencies/page.tsx +13 -0
  51. package/template/app/(app)/system/languages/languages-table.tsx +32 -0
  52. package/template/app/(app)/system/languages/page.tsx +13 -0
  53. package/template/app/(app)/system/regions/page.tsx +13 -0
  54. package/template/app/(app)/system/regions/regions-table.tsx +32 -0
  55. package/template/app/(app)/users/page.tsx +13 -0
  56. package/template/app/(app)/users/users-table.tsx +97 -0
  57. package/template/app/_components/app-sidebar.tsx +565 -0
  58. package/template/app/_components/auth.tsx +184 -0
  59. package/template/app/_components/breadcrumbs.tsx +26 -0
  60. package/template/app/_components/global-search.tsx +209 -0
  61. package/template/app/_components/logo.tsx +49 -0
  62. package/template/app/_components/nav-config.ts +138 -0
  63. package/template/app/_components/open-tabs.tsx +530 -0
  64. package/template/app/_components/quick-actions.tsx +140 -0
  65. package/template/app/_components/route-meta.ts +137 -0
  66. package/template/app/_components/set-page-title.tsx +15 -0
  67. package/template/app/_components/showcase.tsx +29 -0
  68. package/template/app/_components/stat-card.tsx +56 -0
  69. package/template/app/_components/theme-toggle.tsx +46 -0
  70. package/template/app/_components/top-bar.tsx +109 -0
  71. package/template/app/_components/user-menu.tsx +109 -0
  72. package/template/app/_components/wordmark.tsx +45 -0
  73. package/template/app/apple-icon.png +0 -0
  74. package/template/app/auth/forgot-password/page.tsx +102 -0
  75. package/template/app/auth/layout.tsx +43 -0
  76. package/template/app/auth/page.tsx +13 -0
  77. package/template/app/auth/reset-password/page.tsx +108 -0
  78. package/template/app/auth/signin/page.tsx +238 -0
  79. package/template/app/auth/signup/page.tsx +159 -0
  80. package/template/app/auth/verify/page.tsx +86 -0
  81. package/template/app/error.tsx +56 -0
  82. package/template/app/globals.css +6 -0
  83. package/template/app/icon.png +0 -0
  84. package/template/app/icon.svg +4 -0
  85. package/template/app/layout.tsx +103 -0
  86. package/template/app/not-found.tsx +29 -0
  87. package/template/app/onboarding/layout.tsx +31 -0
  88. package/template/app/onboarding/page.tsx +399 -0
  89. package/template/app/page.tsx +13 -0
  90. package/template/app/register-business/layout.tsx +30 -0
  91. package/template/app/register-business/page.tsx +234 -0
  92. package/template/components/ui/accordion.tsx +66 -0
  93. package/template/components/ui/alert-dialog.tsx +196 -0
  94. package/template/components/ui/alert.tsx +66 -0
  95. package/template/components/ui/aspect-ratio.tsx +11 -0
  96. package/template/components/ui/avatar.tsx +109 -0
  97. package/template/components/ui/badge.tsx +48 -0
  98. package/template/components/ui/breadcrumb.tsx +109 -0
  99. package/template/components/ui/button.tsx +64 -0
  100. package/template/components/ui/calendar.tsx +220 -0
  101. package/template/components/ui/card.tsx +92 -0
  102. package/template/components/ui/checkbox.tsx +32 -0
  103. package/template/components/ui/collapsible.tsx +33 -0
  104. package/template/components/ui/command.tsx +184 -0
  105. package/template/components/ui/dialog.tsx +158 -0
  106. package/template/components/ui/dropdown-menu.tsx +257 -0
  107. package/template/components/ui/form.tsx +167 -0
  108. package/template/components/ui/hover-card.tsx +44 -0
  109. package/template/components/ui/input-otp.tsx +77 -0
  110. package/template/components/ui/input.tsx +21 -0
  111. package/template/components/ui/label.tsx +24 -0
  112. package/template/components/ui/popover.tsx +89 -0
  113. package/template/components/ui/progress.tsx +31 -0
  114. package/template/components/ui/radio-group.tsx +45 -0
  115. package/template/components/ui/scroll-area.tsx +58 -0
  116. package/template/components/ui/select.tsx +190 -0
  117. package/template/components/ui/separator.tsx +28 -0
  118. package/template/components/ui/sheet.tsx +143 -0
  119. package/template/components/ui/skeleton.tsx +13 -0
  120. package/template/components/ui/slider.tsx +63 -0
  121. package/template/components/ui/sonner.tsx +40 -0
  122. package/template/components/ui/switch.tsx +35 -0
  123. package/template/components/ui/table.tsx +116 -0
  124. package/template/components/ui/tabs.tsx +91 -0
  125. package/template/components/ui/textarea.tsx +18 -0
  126. package/template/components/ui/toggle-group.tsx +83 -0
  127. package/template/components/ui/toggle.tsx +47 -0
  128. package/template/components/ui/tooltip.tsx +57 -0
  129. package/template/lib/auth-demo.ts +94 -0
  130. package/template/lib/crm-data.ts +80 -0
  131. package/template/lib/demo-data.ts +267 -0
  132. package/template/lib/mock-data.ts +169 -0
  133. package/template/lib/org-store.ts +50 -0
  134. package/template/lib/seo.ts +188 -0
  135. package/template/lib/utils.ts +3 -0
  136. package/template/next.config.mjs +13 -0
  137. package/template/postcss.config.mjs +7 -0
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ import {
4
+ CardStackIcon as CreditCard,
5
+ CodeIcon as Hash,
6
+ DotFilledIcon as CircleDot,
7
+ EnvelopeClosedIcon as Mail,
8
+ TokensIcon as Banknote,
9
+ } from "@radix-ui/react-icons";
10
+
11
+ import { Badge } from "@viliha/vui-ui/badge";
12
+ import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";
13
+
14
+ type Payment = {
15
+ id: string;
16
+ amount: number;
17
+ status: "pending" | "processing" | "success" | "failed";
18
+ email: string;
19
+ };
20
+
21
+ const data: Payment[] = [
22
+ { id: "p_001", amount: 199, status: "success", email: "ava@example.com" },
23
+ { id: "p_002", amount: 49, status: "pending", email: "haruto@example.com" },
24
+ { id: "p_003", amount: 899, status: "processing", email: "lena@example.com" },
25
+ { id: "p_004", amount: 1299, status: "success", email: "chidi@example.com" },
26
+ { id: "p_005", amount: 12, status: "failed", email: "mia@example.com" },
27
+ ];
28
+
29
+ const statusVariant: Record<
30
+ Payment["status"],
31
+ "success" | "warning" | "secondary" | "destructive"
32
+ > = {
33
+ success: "success",
34
+ processing: "secondary",
35
+ pending: "warning",
36
+ failed: "destructive",
37
+ };
38
+
39
+ const fields: RecordField<Payment>[] = [
40
+ { key: "email", label: "Email", icon: Mail, editable: true, required: true, copyable: true, width: 240, group: "General", hideInTable: true },
41
+ {
42
+ key: "id",
43
+ label: "Payment ID",
44
+ icon: Hash,
45
+ group: "General",
46
+ render: (row) => <span className="font-mono">{row.id}</span>,
47
+ },
48
+ {
49
+ key: "status",
50
+ label: "Status",
51
+ icon: CircleDot,
52
+ group: "General",
53
+ render: (row) => (
54
+ <Badge variant={statusVariant[row.status]} className="capitalize">
55
+ {row.status}
56
+ </Badge>
57
+ ),
58
+ },
59
+ {
60
+ key: "amount",
61
+ label: "Amount",
62
+ icon: Banknote,
63
+ align: "right",
64
+ group: "System",
65
+ render: (row) => (
66
+ <span className="tabular-nums">
67
+ {new Intl.NumberFormat("en-US", {
68
+ style: "currency",
69
+ currency: "USD",
70
+ }).format(row.amount)}
71
+ </span>
72
+ ),
73
+ },
74
+ ];
75
+
76
+ export function UsersTable() {
77
+ return (
78
+ <RecordView
79
+ title="Payments"
80
+ singular="Payment"
81
+ icon={CreditCard}
82
+ fields={fields}
83
+ initialData={data}
84
+ getPrimary={(row) => ({
85
+ title: row.email,
86
+ subtitle: row.id,
87
+ initials: row.email.slice(0, 2).toUpperCase(),
88
+ })}
89
+ makeEmptyRow={(): Payment => ({
90
+ id: `p_${Date.now()}`,
91
+ amount: 0,
92
+ status: "pending",
93
+ email: "",
94
+ })}
95
+ />
96
+ );
97
+ }
@@ -0,0 +1,565 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import Link from "next/link";
5
+ import { usePathname } from "next/navigation";
6
+ import {
7
+ CaretSortIcon as ChevronsUpDown,
8
+ ChevronRightIcon as ChevronRight,
9
+ Cross2Icon as X,
10
+ GearIcon as Settings,
11
+ HamburgerMenuIcon as Menu,
12
+ HomeIcon as Home,
13
+ } from "@radix-ui/react-icons";
14
+
15
+ import { cn } from "@/lib/utils";
16
+ import { Logo } from "./logo";
17
+ import { QuickActionsLauncher } from "./quick-actions";
18
+ import { useOpenTabs } from "./open-tabs";
19
+ import {
20
+ isGroup,
21
+ NAV,
22
+ type IconType,
23
+ type NavEntry,
24
+ type NavLink,
25
+ } from "./nav-config";
26
+
27
+ function isActive(pathname: string, href: string): boolean {
28
+ if (href === "/") return pathname === "/";
29
+ return pathname === href || pathname.startsWith(`${href}/`);
30
+ }
31
+
32
+ /** Nav glyph. All icons share one size for a consistent left-aligned column.
33
+ Top-level icons are bordered chips (from the global icon rule): inactive keep
34
+ their brand color, active inverts (dark fill, light glyph). Sub-menu icons are
35
+ `plain` — no chip, muted — so nesting reads without any indentation. */
36
+ function NavIcon({
37
+ icon: Icon,
38
+ active,
39
+ color,
40
+ plain,
41
+ }: {
42
+ icon: IconType;
43
+ active: boolean;
44
+ color?: string;
45
+ plain?: boolean;
46
+ }) {
47
+ if (plain)
48
+ return (
49
+ <Icon
50
+ className={cn(
51
+ // keep the 2px chip padding (transparent border) so plain glyphs sit
52
+ // on the same baseline/box as bordered ones — only the border is gone.
53
+ "size-[18px] shrink-0 border-transparent bg-transparent transition-colors",
54
+ active ? "text-sidebar-foreground" : "text-muted-foreground",
55
+ )}
56
+ aria-hidden="true"
57
+ />
58
+ );
59
+ return (
60
+ <Icon
61
+ className={cn(
62
+ "size-[18px] shrink-0 transition-colors",
63
+ active
64
+ ? "border-sidebar-foreground bg-sidebar-foreground text-background"
65
+ : cn("bg-background", color ?? "text-muted-foreground"),
66
+ )}
67
+ aria-hidden="true"
68
+ />
69
+ );
70
+ }
71
+
72
+ /** Shared sidebar contents used by both the desktop aside and the mobile drawer. */
73
+ function SidebarBody({
74
+ onNavigate,
75
+ collapsed = false,
76
+ headerAction,
77
+ }: {
78
+ onNavigate?: () => void;
79
+ collapsed?: boolean;
80
+ /** Rendered at the right of the workspace row (e.g. the collapse toggle). */
81
+ headerAction?: React.ReactNode;
82
+ }) {
83
+ const pathname = usePathname();
84
+ const [openGroups, setOpenGroups] = React.useState<Set<string>>(() => {
85
+ const open = new Set<string>();
86
+ for (const section of NAV) {
87
+ for (const entry of section.items) {
88
+ if (
89
+ isGroup(entry) &&
90
+ entry.children.some((c) => isActive(pathname, c.href))
91
+ ) {
92
+ open.add(entry.label);
93
+ }
94
+ }
95
+ }
96
+ return open;
97
+ });
98
+ const toggleGroup = (label: string) =>
99
+ setOpenGroups((prev) => {
100
+ const next = new Set(prev);
101
+ if (next.has(label)) next.delete(label);
102
+ else next.add(label);
103
+ return next;
104
+ });
105
+
106
+ const { openTab } = useOpenTabs();
107
+
108
+ const renderLink = (item: NavLink, sub = false) => {
109
+ const active = isActive(pathname, item.href);
110
+ const onClick = (e: React.MouseEvent) => {
111
+ // ⌘/Ctrl+click → open in a background tab (stay on the current page),
112
+ // the same gesture as a browser. Plain click navigates as usual.
113
+ if (e.metaKey || e.ctrlKey) {
114
+ e.preventDefault();
115
+ openTab(item.href, { background: true });
116
+ return;
117
+ }
118
+ onNavigate?.();
119
+ };
120
+ return (
121
+ <Link
122
+ key={item.href}
123
+ href={item.href}
124
+ onClick={onClick}
125
+ aria-current={active ? "page" : undefined}
126
+ title={collapsed ? `${item.label} — ⌘-click for a new tab` : undefined}
127
+ className={cn(
128
+ "group/nav flex items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
129
+ active
130
+ ? "bg-sidebar-accent text-sidebar-foreground"
131
+ : cn(
132
+ "hover:bg-sidebar-accent hover:text-sidebar-foreground",
133
+ sub ? "text-muted-foreground" : "text-sidebar-foreground",
134
+ ),
135
+ )}
136
+ >
137
+ <NavIcon icon={item.icon} active={active} color={item.color} plain={sub} />
138
+ {!collapsed && <span className="truncate">{item.label}</span>}
139
+ </Link>
140
+ );
141
+ };
142
+
143
+ const renderEntry = (entry: NavEntry) => {
144
+ if (!isGroup(entry)) return renderLink(entry);
145
+
146
+ const open = openGroups.has(entry.label);
147
+ const anyActive = entry.children.some((c) => isActive(pathname, c.href));
148
+ const GroupIcon = entry.icon;
149
+
150
+ // Collapsed rail: show the PARENT icon with an expand chevron beside it;
151
+ // its children reveal inline (indented, smaller) when toggled open.
152
+ if (collapsed)
153
+ return (
154
+ <div key={entry.label} className="space-y-1">
155
+ <button
156
+ type="button"
157
+ onClick={() => toggleGroup(entry.label)}
158
+ aria-expanded={open}
159
+ title={entry.label}
160
+ className={cn(
161
+ "group/nav flex w-full items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
162
+ anyActive
163
+ ? "text-sidebar-foreground"
164
+ : "hover:bg-sidebar-accent",
165
+ )}
166
+ >
167
+ <NavIcon icon={GroupIcon} active={anyActive} color={entry.color} />
168
+ <ChevronRight
169
+ className={cn(
170
+ "size-3 shrink-0 text-muted-foreground transition-transform",
171
+ open && "rotate-90",
172
+ )}
173
+ aria-hidden="true"
174
+ />
175
+ </button>
176
+ {open && (
177
+ <div className="space-y-1">
178
+ {entry.children.map((child) => renderLink(child, true))}
179
+ </div>
180
+ )}
181
+ </div>
182
+ );
183
+
184
+ return (
185
+ <div key={entry.label} className="space-y-1">
186
+ <button
187
+ type="button"
188
+ onClick={() => toggleGroup(entry.label)}
189
+ aria-expanded={open}
190
+ className={cn(
191
+ "group/nav flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
192
+ anyActive
193
+ ? "text-sidebar-foreground"
194
+ : "text-sidebar-foreground hover:bg-sidebar-accent",
195
+ )}
196
+ >
197
+ <NavIcon icon={GroupIcon} active={anyActive} color={entry.color} />
198
+ <span className="flex-1 truncate text-left">{entry.label}</span>
199
+ <ChevronRight
200
+ className={cn(
201
+ "size-3.5 shrink-0 text-muted-foreground transition-transform",
202
+ open && "rotate-90",
203
+ )}
204
+ aria-hidden="true"
205
+ />
206
+ </button>
207
+ {open && (
208
+ <div className="space-y-1">
209
+ {entry.children.map((child) => renderLink(child, true))}
210
+ </div>
211
+ )}
212
+ </div>
213
+ );
214
+ };
215
+
216
+ return (
217
+ <>
218
+ {/* Workspace switcher + collapse toggle (same row) — header */}
219
+ <div className="flex h-12 items-center gap-1 border-b border-sidebar-border bg-background px-3">
220
+ <button
221
+ type="button"
222
+ className={cn(
223
+ "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent",
224
+ collapsed && "justify-center px-0",
225
+ )}
226
+ aria-label="Switch workspace"
227
+ title={collapsed ? "Vui Starter" : undefined}
228
+ >
229
+ <Logo variant="mark" className="h-6 w-6 shrink-0" />
230
+ {!collapsed && (
231
+ <>
232
+ <span className="min-w-0 flex-1 truncate text-lg font-bold tracking-tight text-foreground">
233
+ Vui Starter
234
+ </span>
235
+ <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
236
+ </>
237
+ )}
238
+ </button>
239
+ {!collapsed && headerAction}
240
+ </div>
241
+
242
+ {/* Quick actions — fixed above the scrolling nav, with a full-width
243
+ divider and comfortable top/bottom padding. */}
244
+ <div
245
+ className={cn(
246
+ "border-b border-sidebar-border px-3 py-3",
247
+ collapsed && "flex justify-center",
248
+ )}
249
+ >
250
+ <QuickActionsLauncher collapsed={collapsed} />
251
+ </div>
252
+
253
+ {/* Navigation */}
254
+ <nav className="flex-1 space-y-4 overflow-y-auto px-3 py-3">
255
+ {NAV.map((section, index) => (
256
+ <React.Fragment key={section.title ?? `section-${index}`}>
257
+ {/* Collapsed group separator — a nav-level sibling so the nav's
258
+ space-y rhythm spaces it equally above and below. */}
259
+ {section.title && collapsed && index > 0 && (
260
+ <div
261
+ className="mx-2 border-t border-sidebar-border"
262
+ aria-hidden="true"
263
+ />
264
+ )}
265
+ <div className="space-y-1">
266
+ {section.title && !collapsed && (
267
+ <p className="px-2 pb-1 text-sm font-medium text-muted-foreground">
268
+ {section.title}
269
+ </p>
270
+ )}
271
+ {section.items.map(renderEntry)}
272
+ </div>
273
+ </React.Fragment>
274
+ ))}
275
+ </nav>
276
+
277
+ </>
278
+ );
279
+ }
280
+
281
+ const SIDEBAR_MIN_W = 200;
282
+ const SIDEBAR_MAX_W = 420;
283
+ const SIDEBAR_DEFAULT_W = 240;
284
+ const SIDEBAR_COLLAPSED_W = 60;
285
+ const SIDEBAR_WIDTH_KEY = "sidebar:width";
286
+ const SIDEBAR_COLLAPSED_KEY = "sidebar:collapsed";
287
+
288
+ type SidebarContextValue = {
289
+ collapsed: boolean;
290
+ width: number;
291
+ resizing: boolean;
292
+ toggleCollapsed: () => void;
293
+ setWidth: React.Dispatch<React.SetStateAction<number>>;
294
+ setResizing: React.Dispatch<React.SetStateAction<boolean>>;
295
+ persist: (w: number, c: boolean) => void;
296
+ };
297
+
298
+ const SidebarContext = React.createContext<SidebarContextValue | null>(null);
299
+
300
+ function useSidebar(): SidebarContextValue {
301
+ const ctx = React.useContext(SidebarContext);
302
+ if (!ctx) throw new Error("useSidebar must be used within <SidebarProvider>");
303
+ return ctx;
304
+ }
305
+
306
+ /**
307
+ * Shares the sidebar's collapsed/width state so both the sidebar and the page
308
+ * header can read + toggle it (ui-system puts the expand toggle in the page
309
+ * header once the rail is collapsed). Persists to localStorage.
310
+ */
311
+ export function SidebarProvider({ children }: { children: React.ReactNode }) {
312
+ const [width, setWidth] = React.useState(SIDEBAR_DEFAULT_W);
313
+ const [collapsed, setCollapsed] = React.useState(false);
314
+ const [resizing, setResizing] = React.useState(false);
315
+
316
+ React.useEffect(() => {
317
+ const savedWidth = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY));
318
+ if (savedWidth >= SIDEBAR_MIN_W && savedWidth <= SIDEBAR_MAX_W) {
319
+ setWidth(savedWidth);
320
+ }
321
+ setCollapsed(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true");
322
+ }, []);
323
+
324
+ const persist = React.useCallback((w: number, c: boolean) => {
325
+ try {
326
+ localStorage.setItem(SIDEBAR_WIDTH_KEY, String(w));
327
+ localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(c));
328
+ } catch {
329
+ // storage unavailable — non-fatal
330
+ }
331
+ }, []);
332
+
333
+ const toggleCollapsed = () => {
334
+ const next = !collapsed;
335
+ setCollapsed(next);
336
+ persist(width, next);
337
+ };
338
+
339
+ return (
340
+ <SidebarContext.Provider
341
+ value={{
342
+ collapsed,
343
+ width,
344
+ resizing,
345
+ toggleCollapsed,
346
+ setWidth,
347
+ setResizing,
348
+ persist,
349
+ }}
350
+ >
351
+ {children}
352
+ </SidebarContext.Provider>
353
+ );
354
+ }
355
+
356
+ /**
357
+ * Desktop-only (≥ md) left sidebar, matching ui-system: the collapse toggle sits
358
+ * inline with the workspace title; drag the right edge to resize its width.
359
+ */
360
+ export function AppSidebar() {
361
+ const { collapsed, width, resizing, setWidth, setResizing, persist } =
362
+ useSidebar();
363
+
364
+ function startResize(e: React.MouseEvent) {
365
+ if (collapsed) return;
366
+ e.preventDefault();
367
+ setResizing(true);
368
+ const startX = e.clientX;
369
+ const startW = width;
370
+ const clamp = (dx: number) =>
371
+ Math.min(SIDEBAR_MAX_W, Math.max(SIDEBAR_MIN_W, startW + dx));
372
+ const onMove = (ev: MouseEvent) => setWidth(clamp(ev.clientX - startX));
373
+ const onUp = (ev: MouseEvent) => {
374
+ document.removeEventListener("mousemove", onMove);
375
+ document.removeEventListener("mouseup", onUp);
376
+ document.body.style.userSelect = "";
377
+ document.body.style.cursor = "";
378
+ setResizing(false);
379
+ persist(clamp(ev.clientX - startX), collapsed);
380
+ };
381
+ document.addEventListener("mousemove", onMove);
382
+ document.addEventListener("mouseup", onUp);
383
+ document.body.style.userSelect = "none";
384
+ document.body.style.cursor = "col-resize";
385
+ }
386
+
387
+ return (
388
+ <aside
389
+ style={{ width: collapsed ? SIDEBAR_COLLAPSED_W : width }}
390
+ className={cn(
391
+ "relative hidden shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex",
392
+ !resizing && "transition-[width] duration-200 ease-out",
393
+ )}
394
+ >
395
+ <SidebarBody collapsed={collapsed} />
396
+
397
+ {/* Resize handle (right edge) */}
398
+ {!collapsed && (
399
+ <button
400
+ type="button"
401
+ aria-label="Resize sidebar"
402
+ title="Drag to resize"
403
+ onMouseDown={startResize}
404
+ onKeyDown={(e) => {
405
+ if (e.key === "ArrowLeft") {
406
+ e.preventDefault();
407
+ setWidth((w) => {
408
+ const n = Math.max(SIDEBAR_MIN_W, w - 16);
409
+ persist(n, collapsed);
410
+ return n;
411
+ });
412
+ }
413
+ if (e.key === "ArrowRight") {
414
+ e.preventDefault();
415
+ setWidth((w) => {
416
+ const n = Math.min(SIDEBAR_MAX_W, w + 16);
417
+ persist(n, collapsed);
418
+ return n;
419
+ });
420
+ }
421
+ }}
422
+ className={cn(
423
+ "absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40 focus-visible:bg-primary/60 focus-visible:outline-none",
424
+ resizing && "bg-primary/50",
425
+ )}
426
+ />
427
+ )}
428
+ </aside>
429
+ );
430
+ }
431
+
432
+ /**
433
+ * Global sidebar collapse/expand toggle. Lives in the top bar so it's always
434
+ * available regardless of page or collapsed state (desktop only).
435
+ */
436
+ export function SidebarToggle() {
437
+ const { collapsed, toggleCollapsed } = useSidebar();
438
+ return (
439
+ <button
440
+ type="button"
441
+ onClick={toggleCollapsed}
442
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
443
+ aria-expanded={!collapsed}
444
+ title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
445
+ className="hidden size-9 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground md:grid"
446
+ >
447
+ <Menu className="size-5" aria-hidden="true" />
448
+ </button>
449
+ );
450
+ }
451
+
452
+ /** @deprecated Use the SidebarToggle in the top bar. Kept as a no-op shim. */
453
+ export function SidebarExpandButton() {
454
+ return null;
455
+ }
456
+
457
+ const BOTTOM_BAR_ITEMS: NavLink[] = [
458
+ { label: "Home", href: "/dashboard", icon: Home },
459
+ { label: "Settings", href: "/settings", icon: Settings },
460
+ ];
461
+
462
+ /**
463
+ * Mobile navigation (< md), matching ui-system: a fixed bottom bar whose
464
+ * "Menu" button toggles a full-width (100vw) slide-in drawer. Drawer closes on
465
+ * navigation, the Menu toggle, Escape, or the in-drawer close button.
466
+ */
467
+ export function MobileNav() {
468
+ const [open, setOpen] = React.useState(false);
469
+ const pathname = usePathname();
470
+ const close = React.useCallback(() => setOpen(false), []);
471
+
472
+ // Close the drawer whenever the route changes.
473
+ React.useEffect(() => {
474
+ setOpen(false);
475
+ }, [pathname]);
476
+
477
+ // Lock body scroll + Escape-to-close while the drawer is open.
478
+ React.useEffect(() => {
479
+ if (!open) return;
480
+ const onKey = (e: KeyboardEvent) => {
481
+ if (e.key === "Escape") setOpen(false);
482
+ };
483
+ document.addEventListener("keydown", onKey);
484
+ const previousOverflow = document.body.style.overflow;
485
+ document.body.style.overflow = "hidden";
486
+ return () => {
487
+ document.removeEventListener("keydown", onKey);
488
+ document.body.style.overflow = previousOverflow;
489
+ };
490
+ }, [open]);
491
+
492
+ return (
493
+ <>
494
+ {/* Full-width slide-in drawer */}
495
+ <div
496
+ id="mobile-nav-drawer"
497
+ role="dialog"
498
+ aria-modal="true"
499
+ aria-label="Navigation"
500
+ aria-hidden={!open}
501
+ className={cn(
502
+ "fixed inset-0 z-40 flex w-full flex-col bg-sidebar pb-14 transition-transform duration-200 ease-out md:hidden",
503
+ open ? "translate-x-0" : "pointer-events-none -translate-x-full",
504
+ )}
505
+ >
506
+ <SidebarBody
507
+ onNavigate={close}
508
+ headerAction={
509
+ <button
510
+ type="button"
511
+ onClick={close}
512
+ aria-label="Close navigation"
513
+ className="grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-sidebar-accent"
514
+ >
515
+ <X className="size-4" />
516
+ </button>
517
+ }
518
+ />
519
+ </div>
520
+
521
+ {/* Fixed bottom navigation bar */}
522
+ <nav
523
+ aria-label="Primary"
524
+ className="fixed inset-x-0 bottom-0 z-50 flex items-stretch border-t border-sidebar-border bg-sidebar md:hidden"
525
+ >
526
+ <button
527
+ type="button"
528
+ onClick={() => setOpen((value) => !value)}
529
+ aria-expanded={open}
530
+ aria-controls="mobile-nav-drawer"
531
+ aria-label="Menu"
532
+ className={cn(
533
+ "flex flex-1 flex-col items-center gap-0.5 py-2 transition-colors",
534
+ open
535
+ ? "text-sidebar-accent-foreground"
536
+ : "text-muted-foreground hover:text-sidebar-accent-foreground",
537
+ )}
538
+ >
539
+ <Menu className="size-5" aria-hidden="true" />
540
+ Menu
541
+ </button>
542
+ {BOTTOM_BAR_ITEMS.map((item) => {
543
+ const active = !open && isActive(pathname, item.href);
544
+ return (
545
+ <Link
546
+ key={item.href}
547
+ href={item.href}
548
+ onClick={close}
549
+ aria-current={active ? "page" : undefined}
550
+ className={cn(
551
+ "flex flex-1 flex-col items-center gap-0.5 py-2 transition-colors",
552
+ active
553
+ ? "text-sidebar-accent-foreground"
554
+ : "text-muted-foreground hover:text-sidebar-accent-foreground",
555
+ )}
556
+ >
557
+ <item.icon className="size-5 shrink-0" aria-hidden="true" />
558
+ {item.label}
559
+ </Link>
560
+ );
561
+ })}
562
+ </nav>
563
+ </>
564
+ );
565
+ }