@viliha/vui-ui 1.1.7 → 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.
- package/AGENT.md +79 -9
- package/README.md +60 -0
- package/bin/vui.mjs +211 -0
- package/package.json +10 -2
- package/src/record-view.tsx +10 -5
- package/template/.env.example +20 -0
- package/template/app/(app)/branches/branches-table.tsx +69 -0
- package/template/app/(app)/branches/page.tsx +13 -0
- package/template/app/(app)/businesses/businesses-table.tsx +39 -0
- package/template/app/(app)/businesses/page.tsx +13 -0
- package/template/app/(app)/calendar/page.tsx +725 -0
- package/template/app/(app)/charts/charts-content.tsx +190 -0
- package/template/app/(app)/charts/layout.tsx +8 -0
- package/template/app/(app)/charts/page.tsx +44 -0
- package/template/app/(app)/chat/layout.tsx +14 -0
- package/template/app/(app)/chat/page.tsx +311 -0
- package/template/app/(app)/components/layout.tsx +8 -0
- package/template/app/(app)/components/page.tsx +464 -0
- package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
- package/template/app/(app)/crm/companies/page.tsx +13 -0
- package/template/app/(app)/crm/opportunities/opportunities-board.tsx +484 -0
- package/template/app/(app)/crm/opportunities/page.tsx +13 -0
- package/template/app/(app)/crm/people/page.tsx +13 -0
- package/template/app/(app)/crm/people/people-table.tsx +50 -0
- package/template/app/(app)/dashboard/layout.tsx +8 -0
- package/template/app/(app)/dashboard/page.tsx +239 -0
- package/template/app/(app)/departments/departments-table.tsx +55 -0
- package/template/app/(app)/departments/page.tsx +13 -0
- package/template/app/(app)/employees/employees-table.tsx +64 -0
- package/template/app/(app)/employees/page.tsx +13 -0
- package/template/app/(app)/forms/layout.tsx +8 -0
- package/template/app/(app)/forms/page.tsx +388 -0
- package/template/app/(app)/layout.tsx +74 -0
- package/template/app/(app)/markets/markets-table.tsx +76 -0
- package/template/app/(app)/markets/page.tsx +13 -0
- package/template/app/(app)/organizations/edit/page.tsx +72 -0
- package/template/app/(app)/organizations/new/page.tsx +46 -0
- package/template/app/(app)/organizations/organizations-config.tsx +97 -0
- package/template/app/(app)/organizations/organizations-table.tsx +42 -0
- package/template/app/(app)/organizations/page.tsx +13 -0
- package/template/app/(app)/settings/layout.tsx +8 -0
- package/template/app/(app)/settings/page.tsx +255 -0
- package/template/app/(app)/steps/page.tsx +263 -0
- package/template/app/(app)/support/layout.tsx +14 -0
- package/template/app/(app)/support/page.tsx +345 -0
- package/template/app/(app)/system/cities/cities-table.tsx +34 -0
- package/template/app/(app)/system/cities/page.tsx +13 -0
- package/template/app/(app)/system/countries/countries-table.tsx +34 -0
- package/template/app/(app)/system/countries/page.tsx +13 -0
- package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
- package/template/app/(app)/system/currencies/page.tsx +13 -0
- package/template/app/(app)/system/languages/languages-table.tsx +32 -0
- package/template/app/(app)/system/languages/page.tsx +13 -0
- package/template/app/(app)/system/regions/page.tsx +13 -0
- package/template/app/(app)/system/regions/regions-table.tsx +32 -0
- package/template/app/(app)/users/page.tsx +13 -0
- package/template/app/(app)/users/users-table.tsx +97 -0
- package/template/app/_components/app-sidebar.tsx +565 -0
- package/template/app/_components/auth.tsx +184 -0
- package/template/app/_components/breadcrumbs.tsx +26 -0
- package/template/app/_components/global-search.tsx +209 -0
- package/template/app/_components/logo.tsx +49 -0
- package/template/app/_components/nav-config.ts +138 -0
- package/template/app/_components/open-tabs.tsx +530 -0
- package/template/app/_components/quick-actions.tsx +140 -0
- package/template/app/_components/route-meta.ts +137 -0
- package/template/app/_components/set-page-title.tsx +15 -0
- package/template/app/_components/showcase.tsx +29 -0
- package/template/app/_components/stat-card.tsx +56 -0
- package/template/app/_components/theme-toggle.tsx +46 -0
- package/template/app/_components/top-bar.tsx +109 -0
- package/template/app/_components/user-menu.tsx +109 -0
- package/template/app/_components/wordmark.tsx +45 -0
- package/template/app/apple-icon.png +0 -0
- package/template/app/auth/forgot-password/page.tsx +102 -0
- package/template/app/auth/layout.tsx +43 -0
- package/template/app/auth/page.tsx +13 -0
- package/template/app/auth/reset-password/page.tsx +108 -0
- package/template/app/auth/signin/page.tsx +238 -0
- package/template/app/auth/signup/page.tsx +159 -0
- package/template/app/auth/verify/page.tsx +86 -0
- package/template/app/error.tsx +56 -0
- package/template/app/globals.css +6 -0
- package/template/app/icon.png +0 -0
- package/template/app/icon.svg +4 -0
- package/template/app/layout.tsx +103 -0
- package/template/app/not-found.tsx +29 -0
- package/template/app/onboarding/layout.tsx +31 -0
- package/template/app/onboarding/page.tsx +399 -0
- package/template/app/page.tsx +13 -0
- package/template/app/register-business/layout.tsx +30 -0
- package/template/app/register-business/page.tsx +234 -0
- package/template/components/ui/accordion.tsx +66 -0
- package/template/components/ui/alert-dialog.tsx +196 -0
- package/template/components/ui/alert.tsx +66 -0
- package/template/components/ui/aspect-ratio.tsx +11 -0
- package/template/components/ui/avatar.tsx +109 -0
- package/template/components/ui/badge.tsx +48 -0
- package/template/components/ui/breadcrumb.tsx +109 -0
- package/template/components/ui/button.tsx +64 -0
- package/template/components/ui/calendar.tsx +220 -0
- package/template/components/ui/card.tsx +92 -0
- package/template/components/ui/checkbox.tsx +32 -0
- package/template/components/ui/collapsible.tsx +33 -0
- package/template/components/ui/command.tsx +184 -0
- package/template/components/ui/dialog.tsx +158 -0
- package/template/components/ui/dropdown-menu.tsx +257 -0
- package/template/components/ui/form.tsx +167 -0
- package/template/components/ui/hover-card.tsx +44 -0
- package/template/components/ui/input-otp.tsx +77 -0
- package/template/components/ui/input.tsx +21 -0
- package/template/components/ui/label.tsx +24 -0
- package/template/components/ui/popover.tsx +89 -0
- package/template/components/ui/progress.tsx +31 -0
- package/template/components/ui/radio-group.tsx +45 -0
- package/template/components/ui/scroll-area.tsx +58 -0
- package/template/components/ui/select.tsx +190 -0
- package/template/components/ui/separator.tsx +28 -0
- package/template/components/ui/sheet.tsx +143 -0
- package/template/components/ui/skeleton.tsx +13 -0
- package/template/components/ui/slider.tsx +63 -0
- package/template/components/ui/sonner.tsx +40 -0
- package/template/components/ui/switch.tsx +35 -0
- package/template/components/ui/table.tsx +116 -0
- package/template/components/ui/tabs.tsx +91 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/toggle-group.tsx +83 -0
- package/template/components/ui/toggle.tsx +47 -0
- package/template/components/ui/tooltip.tsx +57 -0
- package/template/lib/auth-demo.ts +94 -0
- package/template/lib/crm-data.ts +80 -0
- package/template/lib/demo-data.ts +267 -0
- package/template/lib/mock-data.ts +169 -0
- package/template/lib/org-store.ts +50 -0
- package/template/lib/seo.ts +188 -0
- package/template/lib/utils.ts +3 -0
- package/template/next.config.mjs +13 -0
- 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
|
+
}
|