@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.
Files changed (138) 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/src/record-view.tsx +10 -5
  6. package/template/.env.example +20 -0
  7. package/template/app/(app)/branches/branches-table.tsx +69 -0
  8. package/template/app/(app)/branches/page.tsx +13 -0
  9. package/template/app/(app)/businesses/businesses-table.tsx +39 -0
  10. package/template/app/(app)/businesses/page.tsx +13 -0
  11. package/template/app/(app)/calendar/page.tsx +725 -0
  12. package/template/app/(app)/charts/charts-content.tsx +190 -0
  13. package/template/app/(app)/charts/layout.tsx +8 -0
  14. package/template/app/(app)/charts/page.tsx +44 -0
  15. package/template/app/(app)/chat/layout.tsx +14 -0
  16. package/template/app/(app)/chat/page.tsx +311 -0
  17. package/template/app/(app)/components/layout.tsx +8 -0
  18. package/template/app/(app)/components/page.tsx +464 -0
  19. package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
  20. package/template/app/(app)/crm/companies/page.tsx +13 -0
  21. package/template/app/(app)/crm/opportunities/opportunities-board.tsx +484 -0
  22. package/template/app/(app)/crm/opportunities/page.tsx +13 -0
  23. package/template/app/(app)/crm/people/page.tsx +13 -0
  24. package/template/app/(app)/crm/people/people-table.tsx +50 -0
  25. package/template/app/(app)/dashboard/layout.tsx +8 -0
  26. package/template/app/(app)/dashboard/page.tsx +239 -0
  27. package/template/app/(app)/departments/departments-table.tsx +55 -0
  28. package/template/app/(app)/departments/page.tsx +13 -0
  29. package/template/app/(app)/employees/employees-table.tsx +64 -0
  30. package/template/app/(app)/employees/page.tsx +13 -0
  31. package/template/app/(app)/forms/layout.tsx +8 -0
  32. package/template/app/(app)/forms/page.tsx +388 -0
  33. package/template/app/(app)/layout.tsx +74 -0
  34. package/template/app/(app)/markets/markets-table.tsx +76 -0
  35. package/template/app/(app)/markets/page.tsx +13 -0
  36. package/template/app/(app)/organizations/edit/page.tsx +72 -0
  37. package/template/app/(app)/organizations/new/page.tsx +46 -0
  38. package/template/app/(app)/organizations/organizations-config.tsx +97 -0
  39. package/template/app/(app)/organizations/organizations-table.tsx +42 -0
  40. package/template/app/(app)/organizations/page.tsx +13 -0
  41. package/template/app/(app)/settings/layout.tsx +8 -0
  42. package/template/app/(app)/settings/page.tsx +255 -0
  43. package/template/app/(app)/steps/page.tsx +263 -0
  44. package/template/app/(app)/support/layout.tsx +14 -0
  45. package/template/app/(app)/support/page.tsx +345 -0
  46. package/template/app/(app)/system/cities/cities-table.tsx +34 -0
  47. package/template/app/(app)/system/cities/page.tsx +13 -0
  48. package/template/app/(app)/system/countries/countries-table.tsx +34 -0
  49. package/template/app/(app)/system/countries/page.tsx +13 -0
  50. package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
  51. package/template/app/(app)/system/currencies/page.tsx +13 -0
  52. package/template/app/(app)/system/languages/languages-table.tsx +32 -0
  53. package/template/app/(app)/system/languages/page.tsx +13 -0
  54. package/template/app/(app)/system/regions/page.tsx +13 -0
  55. package/template/app/(app)/system/regions/regions-table.tsx +32 -0
  56. package/template/app/(app)/users/page.tsx +13 -0
  57. package/template/app/(app)/users/users-table.tsx +97 -0
  58. package/template/app/_components/app-sidebar.tsx +565 -0
  59. package/template/app/_components/auth.tsx +184 -0
  60. package/template/app/_components/breadcrumbs.tsx +26 -0
  61. package/template/app/_components/global-search.tsx +209 -0
  62. package/template/app/_components/logo.tsx +49 -0
  63. package/template/app/_components/nav-config.ts +138 -0
  64. package/template/app/_components/open-tabs.tsx +530 -0
  65. package/template/app/_components/quick-actions.tsx +140 -0
  66. package/template/app/_components/route-meta.ts +137 -0
  67. package/template/app/_components/set-page-title.tsx +15 -0
  68. package/template/app/_components/showcase.tsx +29 -0
  69. package/template/app/_components/stat-card.tsx +56 -0
  70. package/template/app/_components/theme-toggle.tsx +46 -0
  71. package/template/app/_components/top-bar.tsx +109 -0
  72. package/template/app/_components/user-menu.tsx +109 -0
  73. package/template/app/_components/wordmark.tsx +45 -0
  74. package/template/app/apple-icon.png +0 -0
  75. package/template/app/auth/forgot-password/page.tsx +102 -0
  76. package/template/app/auth/layout.tsx +43 -0
  77. package/template/app/auth/page.tsx +13 -0
  78. package/template/app/auth/reset-password/page.tsx +108 -0
  79. package/template/app/auth/signin/page.tsx +238 -0
  80. package/template/app/auth/signup/page.tsx +159 -0
  81. package/template/app/auth/verify/page.tsx +86 -0
  82. package/template/app/error.tsx +56 -0
  83. package/template/app/globals.css +6 -0
  84. package/template/app/icon.png +0 -0
  85. package/template/app/icon.svg +4 -0
  86. package/template/app/layout.tsx +103 -0
  87. package/template/app/not-found.tsx +29 -0
  88. package/template/app/onboarding/layout.tsx +31 -0
  89. package/template/app/onboarding/page.tsx +399 -0
  90. package/template/app/page.tsx +13 -0
  91. package/template/app/register-business/layout.tsx +30 -0
  92. package/template/app/register-business/page.tsx +234 -0
  93. package/template/components/ui/accordion.tsx +66 -0
  94. package/template/components/ui/alert-dialog.tsx +196 -0
  95. package/template/components/ui/alert.tsx +66 -0
  96. package/template/components/ui/aspect-ratio.tsx +11 -0
  97. package/template/components/ui/avatar.tsx +109 -0
  98. package/template/components/ui/badge.tsx +48 -0
  99. package/template/components/ui/breadcrumb.tsx +109 -0
  100. package/template/components/ui/button.tsx +64 -0
  101. package/template/components/ui/calendar.tsx +220 -0
  102. package/template/components/ui/card.tsx +92 -0
  103. package/template/components/ui/checkbox.tsx +32 -0
  104. package/template/components/ui/collapsible.tsx +33 -0
  105. package/template/components/ui/command.tsx +184 -0
  106. package/template/components/ui/dialog.tsx +158 -0
  107. package/template/components/ui/dropdown-menu.tsx +257 -0
  108. package/template/components/ui/form.tsx +167 -0
  109. package/template/components/ui/hover-card.tsx +44 -0
  110. package/template/components/ui/input-otp.tsx +77 -0
  111. package/template/components/ui/input.tsx +21 -0
  112. package/template/components/ui/label.tsx +24 -0
  113. package/template/components/ui/popover.tsx +89 -0
  114. package/template/components/ui/progress.tsx +31 -0
  115. package/template/components/ui/radio-group.tsx +45 -0
  116. package/template/components/ui/scroll-area.tsx +58 -0
  117. package/template/components/ui/select.tsx +190 -0
  118. package/template/components/ui/separator.tsx +28 -0
  119. package/template/components/ui/sheet.tsx +143 -0
  120. package/template/components/ui/skeleton.tsx +13 -0
  121. package/template/components/ui/slider.tsx +63 -0
  122. package/template/components/ui/sonner.tsx +40 -0
  123. package/template/components/ui/switch.tsx +35 -0
  124. package/template/components/ui/table.tsx +116 -0
  125. package/template/components/ui/tabs.tsx +91 -0
  126. package/template/components/ui/textarea.tsx +18 -0
  127. package/template/components/ui/toggle-group.tsx +83 -0
  128. package/template/components/ui/toggle.tsx +47 -0
  129. package/template/components/ui/tooltip.tsx +57 -0
  130. package/template/lib/auth-demo.ts +94 -0
  131. package/template/lib/crm-data.ts +80 -0
  132. package/template/lib/demo-data.ts +267 -0
  133. package/template/lib/mock-data.ts +169 -0
  134. package/template/lib/org-store.ts +50 -0
  135. package/template/lib/seo.ts +188 -0
  136. package/template/lib/utils.ts +3 -0
  137. package/template/next.config.mjs +13 -0
  138. 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 &amp; 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&apos;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
+ }