@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.
- package/AGENT.md +79 -9
- package/README.md +60 -0
- package/bin/vui.mjs +211 -0
- package/package.json +10 -2
- 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,137 @@
|
|
|
1
|
+
/** Route → display label + brand color, mirroring the sidebar navigation.
|
|
2
|
+
* Powers the breadcrumb trail and the colored page-title icon. */
|
|
3
|
+
|
|
4
|
+
export const SEGMENT_LABELS: Record<string, string> = {
|
|
5
|
+
"": "Home",
|
|
6
|
+
dashboard: "Home",
|
|
7
|
+
charts: "Charts",
|
|
8
|
+
components: "Components",
|
|
9
|
+
forms: "Forms",
|
|
10
|
+
steps: "Steps",
|
|
11
|
+
calendar: "Calendar",
|
|
12
|
+
chat: "Chat",
|
|
13
|
+
support: "Support",
|
|
14
|
+
organizations: "Organizations",
|
|
15
|
+
branches: "Branches",
|
|
16
|
+
departments: "Departments",
|
|
17
|
+
employees: "Employees",
|
|
18
|
+
markets: "Markets",
|
|
19
|
+
businesses: "Businesses",
|
|
20
|
+
crm: "CRM",
|
|
21
|
+
companies: "Companies",
|
|
22
|
+
people: "People",
|
|
23
|
+
opportunities: "Opportunities",
|
|
24
|
+
system: "System",
|
|
25
|
+
regions: "Regions",
|
|
26
|
+
countries: "Countries",
|
|
27
|
+
cities: "Cities",
|
|
28
|
+
currencies: "Currencies",
|
|
29
|
+
languages: "Languages",
|
|
30
|
+
settings: "Settings",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Full pathname → icon color (Tailwind text-* class). */
|
|
34
|
+
export const ROUTE_COLORS: Record<string, string> = {
|
|
35
|
+
"/": "text-blue-500",
|
|
36
|
+
"/dashboard": "text-blue-500",
|
|
37
|
+
"/charts": "text-fuchsia-500",
|
|
38
|
+
"/components": "text-indigo-500",
|
|
39
|
+
"/forms": "text-teal-500",
|
|
40
|
+
"/steps": "text-violet-500",
|
|
41
|
+
"/calendar": "text-rose-500",
|
|
42
|
+
"/chat": "text-sky-500",
|
|
43
|
+
"/support": "text-emerald-500",
|
|
44
|
+
"/organizations": "text-blue-500",
|
|
45
|
+
"/branches": "text-violet-500",
|
|
46
|
+
"/departments": "text-amber-500",
|
|
47
|
+
"/employees": "text-cyan-500",
|
|
48
|
+
"/markets": "text-rose-500",
|
|
49
|
+
"/businesses": "text-emerald-500",
|
|
50
|
+
"/crm/companies": "text-blue-500",
|
|
51
|
+
"/crm/people": "text-sky-500",
|
|
52
|
+
"/crm/opportunities": "text-orange-500",
|
|
53
|
+
"/system/regions": "text-teal-500",
|
|
54
|
+
"/system/countries": "text-red-500",
|
|
55
|
+
"/system/cities": "text-amber-500",
|
|
56
|
+
"/system/currencies": "text-green-500",
|
|
57
|
+
"/system/languages": "text-purple-500",
|
|
58
|
+
"/settings": "text-slate-500",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Full pathname → raw accent color (drives the --page-accent CSS variable so
|
|
62
|
+
* shared components like tables can tint their icons per module). */
|
|
63
|
+
export const ROUTE_ACCENT: Record<string, string> = {
|
|
64
|
+
"/": "#3b82f6",
|
|
65
|
+
"/dashboard": "#3b82f6",
|
|
66
|
+
"/charts": "#d946ef",
|
|
67
|
+
"/components": "#6366f1",
|
|
68
|
+
"/forms": "#14b8a6",
|
|
69
|
+
"/steps": "#8b5cf6",
|
|
70
|
+
"/calendar": "#f43f5e",
|
|
71
|
+
"/chat": "#0ea5e9",
|
|
72
|
+
"/support": "#10b981",
|
|
73
|
+
"/organizations": "#3b82f6",
|
|
74
|
+
"/branches": "#8b5cf6",
|
|
75
|
+
"/departments": "#f59e0b",
|
|
76
|
+
"/employees": "#06b6d4",
|
|
77
|
+
"/markets": "#f43f5e",
|
|
78
|
+
"/businesses": "#10b981",
|
|
79
|
+
"/crm/companies": "#3b82f6",
|
|
80
|
+
"/crm/people": "#0ea5e9",
|
|
81
|
+
"/crm/opportunities": "#f97316",
|
|
82
|
+
"/system/regions": "#14b8a6",
|
|
83
|
+
"/system/countries": "#ef4444",
|
|
84
|
+
"/system/cities": "#f59e0b",
|
|
85
|
+
"/system/currencies": "#22c55e",
|
|
86
|
+
"/system/languages": "#a855f7",
|
|
87
|
+
"/settings": "#64748b",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Group-parent → first-child map is derived automatically from the nav config
|
|
91
|
+
// (see nav-config.ts), so breadcrumbs never need per-section wiring here.
|
|
92
|
+
export { SECTION_INDEX } from "./nav-config";
|
|
93
|
+
import { SECTION_INDEX } from "./nav-config";
|
|
94
|
+
|
|
95
|
+
export function accentFor(pathname: string): string {
|
|
96
|
+
return ROUTE_ACCENT[pathname] ?? "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function labelFor(segment: string): string {
|
|
100
|
+
return (
|
|
101
|
+
SEGMENT_LABELS[segment] ??
|
|
102
|
+
segment.charAt(0).toUpperCase() + segment.slice(1)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function colorFor(pathname: string): string {
|
|
107
|
+
return ROUTE_COLORS[pathname] ?? "text-muted-foreground";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type Crumb = { label: string; href: string; isLast: boolean };
|
|
111
|
+
|
|
112
|
+
/** Build a breadcrumb trail (always rooted at Home) from a pathname. */
|
|
113
|
+
export function crumbsFor(pathname: string): Crumb[] {
|
|
114
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
115
|
+
// The dashboard IS Home (it lives at /dashboard), so the trail always roots
|
|
116
|
+
// there and the "dashboard" segment isn't repeated as its own crumb.
|
|
117
|
+
const isDash = parts[0] === "dashboard";
|
|
118
|
+
const crumbs: Crumb[] = [
|
|
119
|
+
{
|
|
120
|
+
label: "Home",
|
|
121
|
+
href: "/dashboard",
|
|
122
|
+
isLast: parts.length === 0 || (isDash && parts.length === 1),
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
let acc = "";
|
|
126
|
+
parts.forEach((seg, i) => {
|
|
127
|
+
if (i === 0 && seg === "dashboard") {
|
|
128
|
+
acc = "/dashboard";
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
acc += `/${seg}`;
|
|
132
|
+
// Group parents (no index page) redirect to their first child.
|
|
133
|
+
const href = SECTION_INDEX[acc] ?? acc;
|
|
134
|
+
crumbs.push({ label: labelFor(seg), href, isLast: i === parts.length - 1 });
|
|
135
|
+
});
|
|
136
|
+
return crumbs;
|
|
137
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type IconType, usePageTitle } from "@viliha/vui-ui/record-view";
|
|
4
|
+
|
|
5
|
+
/** Registers a page's title/icon into the global top bar (clears on unmount). */
|
|
6
|
+
export function SetPageTitle({
|
|
7
|
+
title,
|
|
8
|
+
icon,
|
|
9
|
+
}: {
|
|
10
|
+
title: string;
|
|
11
|
+
icon?: IconType;
|
|
12
|
+
}) {
|
|
13
|
+
usePageTitle(title, icon);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/** Live demo + code snippet, used by the shadcn/ui showcase pages. */
|
|
4
|
+
export function Demo({
|
|
5
|
+
title,
|
|
6
|
+
desc,
|
|
7
|
+
code,
|
|
8
|
+
children,
|
|
9
|
+
}: {
|
|
10
|
+
title: string;
|
|
11
|
+
desc?: string;
|
|
12
|
+
code?: string;
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<section className="overflow-hidden rounded-lg border border-border bg-card">
|
|
17
|
+
<div className="border-b border-border bg-muted/40 px-4 py-2.5">
|
|
18
|
+
<h3 className="font-medium">{title}</h3>
|
|
19
|
+
{desc && <p className="text-sm text-muted-foreground">{desc}</p>}
|
|
20
|
+
</div>
|
|
21
|
+
<div className="flex flex-wrap items-start gap-4 p-5">{children}</div>
|
|
22
|
+
{code && (
|
|
23
|
+
<pre className="overflow-x-auto border-t border-border bg-background p-4 font-mono text-[12.5px] leading-relaxed text-muted-foreground">
|
|
24
|
+
<code>{code}</code>
|
|
25
|
+
</pre>
|
|
26
|
+
)}
|
|
27
|
+
</section>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowBottomRightIcon as ArrowDownRight,
|
|
3
|
+
ArrowRightIcon as ArrowRight,
|
|
4
|
+
ArrowTopRightIcon as ArrowUpRight,
|
|
5
|
+
} from "@radix-ui/react-icons";
|
|
6
|
+
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
type Trend = "up" | "down" | "flat";
|
|
10
|
+
|
|
11
|
+
type StatCardProps = {
|
|
12
|
+
label: string;
|
|
13
|
+
value: string | number;
|
|
14
|
+
delta: string;
|
|
15
|
+
trend: Trend;
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const trendIcon: Record<Trend, typeof ArrowRight> = {
|
|
20
|
+
up: ArrowUpRight,
|
|
21
|
+
down: ArrowDownRight,
|
|
22
|
+
flat: ArrowRight,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const trendColor: Record<Trend, string> = {
|
|
26
|
+
up: "text-emerald-600 dark:text-emerald-400",
|
|
27
|
+
down: "text-destructive",
|
|
28
|
+
flat: "text-muted-foreground",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function StatCard({
|
|
32
|
+
label,
|
|
33
|
+
value,
|
|
34
|
+
delta,
|
|
35
|
+
trend,
|
|
36
|
+
className,
|
|
37
|
+
}: StatCardProps) {
|
|
38
|
+
const TrendIcon = trendIcon[trend];
|
|
39
|
+
return (
|
|
40
|
+
<div className={cn("p-4", className)}>
|
|
41
|
+
<p className="text-muted-foreground">{label}</p>
|
|
42
|
+
<p className="mt-1.5 text-2xl font-semibold tracking-tight">
|
|
43
|
+
{typeof value === "number" ? value.toLocaleString() : value}
|
|
44
|
+
</p>
|
|
45
|
+
<p
|
|
46
|
+
className={cn(
|
|
47
|
+
"mt-2 flex items-center gap-1 font-medium",
|
|
48
|
+
trendColor[trend],
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
<TrendIcon className="size-3.5" aria-hidden="true" />
|
|
52
|
+
{delta}
|
|
53
|
+
</p>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
MoonIcon as Moon,
|
|
6
|
+
SunIcon as Sun,
|
|
7
|
+
} from "@radix-ui/react-icons";
|
|
8
|
+
|
|
9
|
+
import { Button } from "@viliha/vui-ui/button";
|
|
10
|
+
|
|
11
|
+
/** Reads/writes the `.dark` class on <html> and persists the choice. */
|
|
12
|
+
export function ThemeToggle() {
|
|
13
|
+
const [isDark, setIsDark] = React.useState(false);
|
|
14
|
+
|
|
15
|
+
React.useEffect(() => {
|
|
16
|
+
setIsDark(document.documentElement.classList.contains("dark"));
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
function toggle() {
|
|
20
|
+
const next = !document.documentElement.classList.contains("dark");
|
|
21
|
+
document.documentElement.classList.toggle("dark", next);
|
|
22
|
+
try {
|
|
23
|
+
localStorage.setItem("theme", next ? "dark" : "light");
|
|
24
|
+
} catch {
|
|
25
|
+
// ignore write failures (private mode, disabled storage)
|
|
26
|
+
}
|
|
27
|
+
setIsDark(next);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Button
|
|
32
|
+
variant="ghost"
|
|
33
|
+
size="icon"
|
|
34
|
+
onClick={toggle}
|
|
35
|
+
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
|
36
|
+
>
|
|
37
|
+
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
|
38
|
+
</Button>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runs before paint to set the initial theme from storage / OS preference,
|
|
44
|
+
* avoiding a flash of the wrong theme. Injected as a blocking inline script.
|
|
45
|
+
*/
|
|
46
|
+
export const themeInitScript = `(function(){try{var t=localStorage.getItem('theme');var d=t?t==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.toggle('dark',d);}catch(e){}})();`;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BellIcon,
|
|
5
|
+
GearIcon,
|
|
6
|
+
MagnifyingGlassIcon,
|
|
7
|
+
QuestionMarkCircledIcon,
|
|
8
|
+
ReaderIcon,
|
|
9
|
+
} from "@radix-ui/react-icons";
|
|
10
|
+
|
|
11
|
+
import Link from "next/link";
|
|
12
|
+
import { usePathname } from "next/navigation";
|
|
13
|
+
|
|
14
|
+
import { usePageChrome } from "@viliha/vui-ui/record-view";
|
|
15
|
+
|
|
16
|
+
import { SidebarToggle } from "@/app/_components/app-sidebar";
|
|
17
|
+
import { UserMenu } from "@/app/_components/user-menu";
|
|
18
|
+
import { useGlobalSearch } from "@/app/_components/global-search";
|
|
19
|
+
import { colorFor } from "@/app/_components/route-meta";
|
|
20
|
+
import { Shortcut } from "@viliha/vui-ui/kbd";
|
|
21
|
+
|
|
22
|
+
/** One shared size for every top-bar icon control, so the cluster is uniform. */
|
|
23
|
+
const iconControl =
|
|
24
|
+
"grid size-9 shrink-0 place-items-center rounded-md transition-colors hover:bg-accent";
|
|
25
|
+
const iconGlyph = "size-5 text-foreground [&_path]:[stroke-width:1px]";
|
|
26
|
+
|
|
27
|
+
/** Global top bar: sidebar toggle + page title (left), centered search,
|
|
28
|
+
help + notifications (right). */
|
|
29
|
+
export function TopBar() {
|
|
30
|
+
const { page } = usePageChrome();
|
|
31
|
+
const pathname = usePathname();
|
|
32
|
+
const PageIcon = page?.icon;
|
|
33
|
+
// Global search (records) lives in GlobalSearchProvider; the box below is its
|
|
34
|
+
// launcher. Quick actions (pages) is a separate ⌘K palette in the sidebar.
|
|
35
|
+
const { open: openSearch } = useGlobalSearch();
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<header className="relative flex h-12 shrink-0 items-center gap-2 border-b border-border bg-background px-4">
|
|
39
|
+
{/* Left — sidebar toggle + current page title/icon */}
|
|
40
|
+
<SidebarToggle />
|
|
41
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
42
|
+
{PageIcon && (
|
|
43
|
+
<PageIcon className={`size-4 shrink-0 ${colorFor(pathname)}`} />
|
|
44
|
+
)}
|
|
45
|
+
{page && (
|
|
46
|
+
<h1 className="truncate font-semibold tracking-tight">
|
|
47
|
+
{page.title}
|
|
48
|
+
</h1>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
{/* Center — global search launcher (opens the records palette). */}
|
|
53
|
+
<div className="absolute left-1/2 top-1/2 hidden w-full max-w-sm -translate-x-1/2 -translate-y-1/2 px-4 md:block">
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={openSearch}
|
|
57
|
+
aria-label="Global search"
|
|
58
|
+
className="flex h-8 w-full items-center gap-2 rounded-md border border-input bg-background px-2.5 text-muted-foreground transition-colors hover:bg-accent"
|
|
59
|
+
>
|
|
60
|
+
<MagnifyingGlassIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
|
61
|
+
<span className="flex-1 text-left text-sm">Search…</span>
|
|
62
|
+
<Shortcut keys={["⌘", "⌥", "K"]} />
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Right — help, notifications, settings, profile.
|
|
67
|
+
Bold, dark, larger glyphs (heavier stroke via [&_path]). */}
|
|
68
|
+
{/* All controls share one size (size-9 box, size-5 glyph) so the cluster
|
|
69
|
+
is visually uniform — see iconControl. */}
|
|
70
|
+
<div className="ml-auto flex items-center gap-1.5">
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
aria-label="Help & support"
|
|
74
|
+
className={`hidden sm:grid ${iconControl}`}
|
|
75
|
+
>
|
|
76
|
+
<QuestionMarkCircledIcon className={iconGlyph} />
|
|
77
|
+
</button>
|
|
78
|
+
<Link
|
|
79
|
+
href="/docs"
|
|
80
|
+
target="_blank"
|
|
81
|
+
rel="noopener noreferrer"
|
|
82
|
+
aria-label="Documentation (opens in a new tab)"
|
|
83
|
+
className={iconControl}
|
|
84
|
+
>
|
|
85
|
+
<ReaderIcon className={iconGlyph} />
|
|
86
|
+
</Link>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
aria-label="Notifications"
|
|
90
|
+
className={`relative ${iconControl}`}
|
|
91
|
+
>
|
|
92
|
+
<BellIcon className={iconGlyph} />
|
|
93
|
+
<span
|
|
94
|
+
className="absolute right-1.5 top-1.5 size-2 rounded-full bg-[var(--button-primary)] ring-2 ring-background"
|
|
95
|
+
aria-hidden="true"
|
|
96
|
+
/>
|
|
97
|
+
</button>
|
|
98
|
+
<Link
|
|
99
|
+
href="/settings"
|
|
100
|
+
aria-label="Settings"
|
|
101
|
+
className={`hidden sm:grid ${iconControl}`}
|
|
102
|
+
>
|
|
103
|
+
<GearIcon className={iconGlyph} />
|
|
104
|
+
</Link>
|
|
105
|
+
<UserMenu />
|
|
106
|
+
</div>
|
|
107
|
+
</header>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import {
|
|
6
|
+
ExitIcon,
|
|
7
|
+
GearIcon,
|
|
8
|
+
LockClosedIcon,
|
|
9
|
+
PersonIcon,
|
|
10
|
+
} from "@radix-ui/react-icons";
|
|
11
|
+
|
|
12
|
+
import { Avatar, AvatarFallback } from "@viliha/vui-ui/avatar";
|
|
13
|
+
import { MenuItem } from "@viliha/vui-ui/menu";
|
|
14
|
+
import { ThemeToggle } from "./theme-toggle";
|
|
15
|
+
|
|
16
|
+
// ponytail: no auth in the demo — mock the signed-in user. Swap for the real
|
|
17
|
+
// session/role check when wiring auth; the admin item keys off `isAdmin`.
|
|
18
|
+
const USER = {
|
|
19
|
+
name: "Admin User",
|
|
20
|
+
email: "admin@viliha.example",
|
|
21
|
+
role: "Administrator",
|
|
22
|
+
initials: "AU",
|
|
23
|
+
};
|
|
24
|
+
const isAdmin = USER.role === "Administrator";
|
|
25
|
+
|
|
26
|
+
/** GitLab-style profile menu in the top bar: avatar → popup with account
|
|
27
|
+
* info, an admin-only entry, appearance toggle, and sign out. */
|
|
28
|
+
export function UserMenu() {
|
|
29
|
+
const [open, setOpen] = React.useState(false);
|
|
30
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
if (!open) return;
|
|
34
|
+
const onDown = (e: MouseEvent) => {
|
|
35
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
36
|
+
};
|
|
37
|
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
38
|
+
document.addEventListener("mousedown", onDown);
|
|
39
|
+
document.addEventListener("keydown", onKey);
|
|
40
|
+
return () => {
|
|
41
|
+
document.removeEventListener("mousedown", onDown);
|
|
42
|
+
document.removeEventListener("keydown", onKey);
|
|
43
|
+
};
|
|
44
|
+
}, [open]);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="relative" ref={ref}>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
aria-haspopup="menu"
|
|
51
|
+
aria-expanded={open}
|
|
52
|
+
aria-label="Account menu"
|
|
53
|
+
onClick={() => setOpen((v) => !v)}
|
|
54
|
+
// Matches the Settings gear control (size-9 rounded-md box, size-5 glyph).
|
|
55
|
+
className="grid size-9 shrink-0 place-items-center rounded-md transition-colors hover:bg-accent"
|
|
56
|
+
>
|
|
57
|
+
<PersonIcon className="size-5 text-foreground [&_path]:[stroke-width:1px]" />
|
|
58
|
+
</button>
|
|
59
|
+
|
|
60
|
+
{open && (
|
|
61
|
+
<div
|
|
62
|
+
role="menu"
|
|
63
|
+
className="vui-pop-in absolute right-0 z-50 mt-2 w-60 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md"
|
|
64
|
+
style={{ "--vui-pop-origin": "top right" } as React.CSSProperties}
|
|
65
|
+
>
|
|
66
|
+
{/* Account header */}
|
|
67
|
+
<div className="flex items-center gap-3 border-b border-border px-3 py-3">
|
|
68
|
+
<Avatar className="size-9">
|
|
69
|
+
<AvatarFallback>{USER.initials}</AvatarFallback>
|
|
70
|
+
</Avatar>
|
|
71
|
+
<div className="min-w-0">
|
|
72
|
+
<p className="truncate font-medium">{USER.name}</p>
|
|
73
|
+
<p className="truncate text-muted-foreground">{USER.email}</p>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Menu items — MenuItem bakes in the bordered-row standard */}
|
|
78
|
+
<MenuItem as={Link} href="/settings" onClick={() => setOpen(false)}>
|
|
79
|
+
<PersonIcon className="size-4 text-sky-500" />
|
|
80
|
+
Profile
|
|
81
|
+
</MenuItem>
|
|
82
|
+
<MenuItem as={Link} href="/settings" onClick={() => setOpen(false)}>
|
|
83
|
+
<GearIcon className="size-4 text-slate-500" />
|
|
84
|
+
Settings
|
|
85
|
+
</MenuItem>
|
|
86
|
+
{isAdmin && (
|
|
87
|
+
<MenuItem
|
|
88
|
+
as={Link}
|
|
89
|
+
href="/system/regions"
|
|
90
|
+
onClick={() => setOpen(false)}
|
|
91
|
+
className="font-medium"
|
|
92
|
+
>
|
|
93
|
+
<LockClosedIcon className="size-4 text-amber-500" />
|
|
94
|
+
Admin area
|
|
95
|
+
</MenuItem>
|
|
96
|
+
)}
|
|
97
|
+
<div className="flex items-center justify-between border-b border-border px-3 py-1.5">
|
|
98
|
+
<span className="text-muted-foreground">Appearance</span>
|
|
99
|
+
<ThemeToggle />
|
|
100
|
+
</div>
|
|
101
|
+
<MenuItem as={Link} href="/auth/signin" onClick={() => setOpen(false)}>
|
|
102
|
+
<ExitIcon className="size-4 text-rose-500" />
|
|
103
|
+
Sign out
|
|
104
|
+
</MenuItem>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { Logo } from "./logo";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The brand lockup — Vui logo + "Vui Starter" wordmark — used consistently in
|
|
8
|
+
* the app sidebar, the auth screens, and the docs header. Single source of the
|
|
9
|
+
* logo + type styling so they never drift.
|
|
10
|
+
*/
|
|
11
|
+
export function Wordmark({
|
|
12
|
+
href,
|
|
13
|
+
className,
|
|
14
|
+
logoClassName,
|
|
15
|
+
textClassName,
|
|
16
|
+
}: {
|
|
17
|
+
/** Render as a link when set (e.g. header → home). */
|
|
18
|
+
href?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
logoClassName?: string;
|
|
21
|
+
textClassName?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const content = (
|
|
24
|
+
<>
|
|
25
|
+
<Logo className={cn("h-6 w-6 shrink-0", logoClassName)} />
|
|
26
|
+
<span
|
|
27
|
+
className={cn(
|
|
28
|
+
"text-lg font-bold tracking-tight text-foreground",
|
|
29
|
+
textClassName,
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
Vui Starter
|
|
33
|
+
</span>
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
36
|
+
const classes = cn("flex items-center gap-2", className);
|
|
37
|
+
|
|
38
|
+
return href ? (
|
|
39
|
+
<Link href={href} aria-label="Vui Starter" className={classes}>
|
|
40
|
+
{content}
|
|
41
|
+
</Link>
|
|
42
|
+
) : (
|
|
43
|
+
<span className={classes}>{content}</span>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import {
|
|
6
|
+
ArrowLeftIcon as ArrowLeft,
|
|
7
|
+
EnvelopeClosedIcon as Mail,
|
|
8
|
+
EnvelopeOpenIcon as MailCheck,
|
|
9
|
+
} from "@radix-ui/react-icons";
|
|
10
|
+
|
|
11
|
+
import { Button } from "@viliha/vui-ui/button";
|
|
12
|
+
import { Input } from "@viliha/vui-ui/input";
|
|
13
|
+
import {
|
|
14
|
+
AuthCard,
|
|
15
|
+
AuthCardAside,
|
|
16
|
+
AuthCardBody,
|
|
17
|
+
AuthCardFooter,
|
|
18
|
+
AuthCardHeader,
|
|
19
|
+
Field,
|
|
20
|
+
} from "@/app/_components/auth";
|
|
21
|
+
|
|
22
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
23
|
+
|
|
24
|
+
export default function ForgotPasswordPage() {
|
|
25
|
+
const [email, setEmail] = React.useState("");
|
|
26
|
+
const [error, setError] = React.useState<string>();
|
|
27
|
+
const [sent, setSent] = React.useState(false);
|
|
28
|
+
|
|
29
|
+
function submit(e: React.FormEvent) {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
if (!EMAIL_RE.test(email.trim())) {
|
|
32
|
+
setError("Enter a valid email address.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setError(undefined);
|
|
36
|
+
setSent(true);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (sent) {
|
|
40
|
+
return (
|
|
41
|
+
<AuthCard>
|
|
42
|
+
<AuthCardHeader
|
|
43
|
+
icon={<MailCheck className="size-6" />}
|
|
44
|
+
title="Check your email"
|
|
45
|
+
description={
|
|
46
|
+
<>
|
|
47
|
+
A reset link was sent to{" "}
|
|
48
|
+
<span className="font-medium text-foreground">{email}</span>
|
|
49
|
+
</>
|
|
50
|
+
}
|
|
51
|
+
/>
|
|
52
|
+
<AuthCardFooter className="space-y-2">
|
|
53
|
+
{/* Demo shortcut — no real inbox in the demo. */}
|
|
54
|
+
<Link href="/auth/reset-password" className="block">
|
|
55
|
+
<Button className="w-full">I've got the link — continue</Button>
|
|
56
|
+
</Link>
|
|
57
|
+
<Button variant="ghost" className="w-full" onClick={() => setSent(false)}>
|
|
58
|
+
Use a different email
|
|
59
|
+
</Button>
|
|
60
|
+
</AuthCardFooter>
|
|
61
|
+
</AuthCard>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<AuthCard>
|
|
67
|
+
<form onSubmit={submit}>
|
|
68
|
+
<AuthCardHeader
|
|
69
|
+
title="Reset your password"
|
|
70
|
+
description="Enter your email and we'll send you a reset link."
|
|
71
|
+
/>
|
|
72
|
+
<AuthCardBody>
|
|
73
|
+
<Field label="Work email" htmlFor="email" required error={error}>
|
|
74
|
+
<Input
|
|
75
|
+
id="email"
|
|
76
|
+
type="email"
|
|
77
|
+
value={email}
|
|
78
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
79
|
+
placeholder="you@company.com"
|
|
80
|
+
autoComplete="email"
|
|
81
|
+
/>
|
|
82
|
+
</Field>
|
|
83
|
+
</AuthCardBody>
|
|
84
|
+
<AuthCardFooter>
|
|
85
|
+
<Button type="submit" className="w-full">
|
|
86
|
+
<Mail className="size-4" />
|
|
87
|
+
Send reset link
|
|
88
|
+
</Button>
|
|
89
|
+
<AuthCardAside>
|
|
90
|
+
<Link
|
|
91
|
+
href="/auth/signin"
|
|
92
|
+
className="flex items-center justify-center gap-1 hover:text-foreground"
|
|
93
|
+
>
|
|
94
|
+
<ArrowLeft className="size-3.5" />
|
|
95
|
+
Back to sign in
|
|
96
|
+
</Link>
|
|
97
|
+
</AuthCardAside>
|
|
98
|
+
</AuthCardFooter>
|
|
99
|
+
</form>
|
|
100
|
+
</AuthCard>
|
|
101
|
+
);
|
|
102
|
+
}
|