@viliha/vui-ui 1.1.8 → 1.3.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 +59 -17
  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 +359 -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,530 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { usePathname, useRouter } from "next/navigation";
6
+ import {
7
+ Cross2Icon,
8
+ DragHandleDots2Icon,
9
+ FileIcon,
10
+ } from "@radix-ui/react-icons";
11
+
12
+ import { cn } from "@/lib/utils";
13
+ import { isGroup, NAV, type IconType } from "./nav-config";
14
+ import { colorFor, labelFor } from "./route-meta";
15
+
16
+ /**
17
+ * Open tabs — a browser-style strip of the pages you've opened, so several
18
+ * pages stay one click apart. Navigation-tab model: the strip tracks opened
19
+ * routes and switching is a `router.push` (the page re-renders); the tab *list*
20
+ * persists across reloads via sessionStorage. Labels/icons/colors come from the
21
+ * same single source as the sidebar (`nav-config` + `route-meta`), so a new
22
+ * page is tab-able with zero extra wiring.
23
+ */
24
+
25
+ type Tab = { href: string; label: string; icon: IconType; color?: string };
26
+
27
+ /** href → { label, icon, color }, flattened from NAV once. */
28
+ const NAV_META = (() => {
29
+ const m = new Map<string, { label: string; icon: IconType; color?: string }>();
30
+ for (const section of NAV) {
31
+ for (const entry of section.items) {
32
+ if (isGroup(entry)) {
33
+ for (const c of entry.children)
34
+ m.set(c.href, { label: c.label, icon: c.icon, color: c.color });
35
+ } else {
36
+ m.set(entry.href, {
37
+ label: entry.label,
38
+ icon: entry.icon,
39
+ color: entry.color,
40
+ });
41
+ }
42
+ }
43
+ }
44
+ return m;
45
+ })();
46
+
47
+ /** Canonical tab identity: drop the query and any trailing slash, so
48
+ * "/branches" (nav config) and "/branches/" (trailingSlash router) are one
49
+ * tab. Root "/" is preserved. */
50
+ function tabKey(href: string): string {
51
+ const path = (href.split("?")[0] ?? href) || "/";
52
+ return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
53
+ }
54
+
55
+ /** Resolve a tab's display metadata from its path (query ignored). */
56
+ function tabMeta(href: string): Tab {
57
+ const clean = tabKey(href);
58
+ const exact = NAV_META.get(clean);
59
+ if (exact) return { href: clean, ...exact };
60
+ // e.g. /organizations/new → inherit the parent's icon/color, label the leaf.
61
+ const parent = clean.slice(0, clean.lastIndexOf("/")) || "/";
62
+ const pm = NAV_META.get(parent);
63
+ const seg = clean.split("/").filter(Boolean).pop() ?? "";
64
+ return {
65
+ href: clean,
66
+ label: labelFor(seg),
67
+ icon: pm?.icon ?? FileIcon,
68
+ color: pm?.color ?? colorFor(clean),
69
+ };
70
+ }
71
+
72
+ const KEY = "vui.openTabs";
73
+ const COLORS_KEY = "vui.openTabColors";
74
+
75
+ /** Max tabs kept open, from the env (build-time inlined). Default 5, min 1. */
76
+ export const MAX_TABS = (() => {
77
+ const n = Number(process.env.NEXT_PUBLIC_MAX_TABS);
78
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 5;
79
+ })();
80
+
81
+ /** Seven color labels a user can tag a tab with (static classes for Tailwind). */
82
+ export const TAB_COLORS: { key: string; dot: string; ring: string }[] = [
83
+ { key: "blue", dot: "bg-blue-500", ring: "ring-blue-500" },
84
+ { key: "emerald", dot: "bg-emerald-500", ring: "ring-emerald-500" },
85
+ { key: "amber", dot: "bg-amber-500", ring: "ring-amber-500" },
86
+ { key: "violet", dot: "bg-violet-500", ring: "ring-violet-500" },
87
+ { key: "rose", dot: "bg-rose-500", ring: "ring-rose-500" },
88
+ { key: "cyan", dot: "bg-cyan-500", ring: "ring-cyan-500" },
89
+ { key: "orange", dot: "bg-orange-500", ring: "ring-orange-500" },
90
+ ];
91
+ const DOT_FOR = new Map(TAB_COLORS.map((c) => [c.key, c.dot]));
92
+
93
+ // useLayoutEffect on the client (to measure before paint), useEffect on the
94
+ // server — avoids the SSR warning. Stable per environment.
95
+ const useIsoLayoutEffect =
96
+ typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
97
+
98
+ type Ctx = {
99
+ tabs: Tab[];
100
+ activeHref: string;
101
+ /** Add a tab. `background: true` opens it without leaving the current page. */
102
+ openTab: (href: string, opts?: { background?: boolean }) => void;
103
+ close: (href: string) => void;
104
+ /** Move the tab `from` to the position of the tab `to` (drag reorder). */
105
+ reorder: (from: string, to: string) => void;
106
+ /** href → color key (see TAB_COLORS); undefined = no label. */
107
+ colors: Record<string, string>;
108
+ /** Tag a tab with a color (null clears it). */
109
+ setColor: (href: string, colorKey: string | null) => void;
110
+ /** Transient warning (e.g. the oldest tab was evicted at the cap). */
111
+ notice: string | null;
112
+ };
113
+ const OpenTabsContext = React.createContext<Ctx | null>(null);
114
+
115
+ export function useOpenTabs(): Ctx {
116
+ const ctx = React.useContext(OpenTabsContext);
117
+ if (!ctx)
118
+ throw new Error("useOpenTabs must be used within <OpenTabsProvider>");
119
+ return ctx;
120
+ }
121
+
122
+ export function OpenTabsProvider({ children }: { children: React.ReactNode }) {
123
+ // Normalize away the trailing slash (trailingSlash: true) so a nav-added
124
+ // "/branches/" and a ⌘-click-added "/branches" are the same tab.
125
+ const activePath = tabKey(usePathname());
126
+ const router = useRouter();
127
+ // Only hrefs are stored (icons don't serialize); tabs are derived on render.
128
+ const [hrefs, setHrefs] = React.useState<string[]>([]);
129
+ const [colors, setColors] = React.useState<Record<string, string>>({});
130
+ const [notice, setNotice] = React.useState<string | null>(null);
131
+ const noticeTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined);
132
+ // Latest active path for the (pure) reducer without re-creating callbacks.
133
+ const activeRef = React.useRef(activePath);
134
+ activeRef.current = activePath;
135
+ // Set inside the reducer when a tab is evicted; drained by the persist effect
136
+ // so the warning fires exactly once. ponytail: a ref write in a reducer is a
137
+ // benign, idempotent side effect — fine here, avoids a second state pass.
138
+ const evicted = React.useRef(false);
139
+
140
+ const showNotice = React.useCallback((msg: string) => {
141
+ setNotice(msg);
142
+ clearTimeout(noticeTimer.current);
143
+ noticeTimer.current = setTimeout(() => setNotice(null), 3200);
144
+ }, []);
145
+
146
+ /** Add `href`, then evict the oldest tab that isn't the one just added or the
147
+ * active page until within MAX_TABS. Pure enough to run in a reducer. */
148
+ const capAdd = React.useCallback((prev: string[], href: string): string[] => {
149
+ if (prev.includes(href)) return prev;
150
+ const next = [...prev, href];
151
+ while (next.length > MAX_TABS) {
152
+ const i = next.findIndex((h) => h !== href && h !== activeRef.current);
153
+ if (i === -1) break; // nothing safe to drop
154
+ next.splice(i, 1);
155
+ evicted.current = true;
156
+ }
157
+ return next;
158
+ }, []);
159
+
160
+ // Hydrate from sessionStorage once, ensuring the current page is present and
161
+ // the list is within the cap (older extras dropped silently on load).
162
+ React.useEffect(() => {
163
+ let stored: string[] = [];
164
+ try {
165
+ stored = JSON.parse(sessionStorage.getItem(KEY) || "[]");
166
+ } catch {
167
+ // ignore malformed storage
168
+ }
169
+ stored = stored.map(tabKey);
170
+ if (!stored.includes(activePath)) stored.push(activePath);
171
+ while (stored.length > MAX_TABS) {
172
+ const i = stored.findIndex((h) => h !== activePath);
173
+ if (i === -1) break;
174
+ stored.splice(i, 1);
175
+ }
176
+ setHrefs(stored);
177
+ try {
178
+ setColors(JSON.parse(sessionStorage.getItem(COLORS_KEY) || "{}"));
179
+ } catch {
180
+ // ignore malformed storage
181
+ }
182
+ // eslint-disable-next-line react-hooks/exhaustive-deps
183
+ }, []);
184
+
185
+ // Persist color labels.
186
+ React.useEffect(() => {
187
+ try {
188
+ sessionStorage.setItem(COLORS_KEY, JSON.stringify(colors));
189
+ } catch {
190
+ // ignore storage failures
191
+ }
192
+ }, [colors]);
193
+
194
+ // Add the current route as a tab when navigating.
195
+ React.useEffect(() => {
196
+ setHrefs((prev) => capAdd(prev, activePath));
197
+ }, [activePath, capAdd]);
198
+
199
+ // Persist the open list, and surface any eviction that just happened.
200
+ React.useEffect(() => {
201
+ try {
202
+ sessionStorage.setItem(KEY, JSON.stringify(hrefs));
203
+ } catch {
204
+ // ignore storage failures (private mode, quota)
205
+ }
206
+ if (evicted.current) {
207
+ evicted.current = false;
208
+ showNotice(`Only ${MAX_TABS} tabs stay open — closed the oldest.`);
209
+ }
210
+ }, [hrefs, showNotice]);
211
+
212
+ const openTab = React.useCallback(
213
+ (href: string, opts?: { background?: boolean }) => {
214
+ const clean = tabKey(href);
215
+ setHrefs((prev) => capAdd(prev, clean));
216
+ // Foreground open switches to it; background just parks it in the strip.
217
+ if (!opts?.background) router.push(href);
218
+ },
219
+ [router, capAdd],
220
+ );
221
+
222
+ const close = React.useCallback(
223
+ (href: string) => {
224
+ const key = tabKey(href);
225
+ const idx = hrefs.indexOf(key);
226
+ const next = hrefs.filter((h) => h !== key);
227
+ setHrefs(next.length ? next : ["/dashboard"]);
228
+ // If the active tab closed, fall back to its neighbour.
229
+ if (key === activePath) {
230
+ const target = next[idx - 1] ?? next[idx] ?? "/dashboard";
231
+ router.push(target);
232
+ }
233
+ },
234
+ [hrefs, activePath, router],
235
+ );
236
+
237
+ const reorder = React.useCallback((from: string, to: string) => {
238
+ const a = tabKey(from);
239
+ const b = tabKey(to);
240
+ if (a === b) return;
241
+ setHrefs((prev) => {
242
+ const fromIdx = prev.indexOf(a);
243
+ const toIdx = prev.indexOf(b);
244
+ if (fromIdx === -1 || toIdx === -1) return prev;
245
+ const next = [...prev];
246
+ next.splice(fromIdx, 1); // remove dragged tab
247
+ // Insert AFTER the target when moving right, BEFORE when moving left, so
248
+ // a rightward drag can actually pass the target (incl. reaching the end).
249
+ const at = next.indexOf(b) + (fromIdx < toIdx ? 1 : 0);
250
+ next.splice(at, 0, a);
251
+ return next;
252
+ });
253
+ }, []);
254
+
255
+ const setColor = React.useCallback((href: string, colorKey: string | null) => {
256
+ const key = tabKey(href);
257
+ setColors((prev) => {
258
+ const next = { ...prev };
259
+ if (colorKey) next[key] = colorKey;
260
+ else delete next[key];
261
+ return next;
262
+ });
263
+ }, []);
264
+
265
+ const value = React.useMemo<Ctx>(
266
+ () => ({
267
+ tabs: hrefs.map(tabMeta),
268
+ activeHref: activePath,
269
+ openTab,
270
+ close,
271
+ reorder,
272
+ colors,
273
+ setColor,
274
+ notice,
275
+ }),
276
+ [hrefs, activePath, openTab, close, reorder, colors, setColor, notice],
277
+ );
278
+
279
+ return (
280
+ <OpenTabsContext.Provider value={value}>
281
+ {children}
282
+ </OpenTabsContext.Provider>
283
+ );
284
+ }
285
+
286
+ /** The tab strip — render it directly under the top bar. Tabs can be dragged to
287
+ * reorder and right-clicked to tag with one of seven color labels. */
288
+ export function TabStrip() {
289
+ const { tabs, activeHref, close, reorder, colors, setColor, notice } =
290
+ useOpenTabs();
291
+ const router = useRouter();
292
+ const dragHref = React.useRef<string | null>(null);
293
+ const lastOver = React.useRef<string | null>(null);
294
+ const tabEls = React.useRef(new Map<string, HTMLDivElement>());
295
+ const prevRects = React.useRef(new Map<string, DOMRect>());
296
+ const [menu, setMenu] = React.useState<{
297
+ href: string;
298
+ x: number;
299
+ y: number;
300
+ } | null>(null);
301
+
302
+ React.useEffect(() => {
303
+ if (!menu) return;
304
+ const dismiss = () => setMenu(null);
305
+ window.addEventListener("click", dismiss);
306
+ window.addEventListener("scroll", dismiss, true);
307
+ return () => {
308
+ window.removeEventListener("click", dismiss);
309
+ window.removeEventListener("scroll", dismiss, true);
310
+ };
311
+ }, [menu]);
312
+
313
+ // FLIP: when the tab order changes, slide each tab from its old position to
314
+ // its new one so reordering animates smoothly instead of jumping.
315
+ useIsoLayoutEffect(() => {
316
+ const els = tabEls.current;
317
+ els.forEach((el, href) => {
318
+ const prev = prevRects.current.get(href);
319
+ const rect = el.getBoundingClientRect();
320
+ const dx = prev ? prev.left - rect.left : 0;
321
+ if (dx) {
322
+ el.style.transition = "none";
323
+ el.style.transform = `translateX(${dx}px)`;
324
+ requestAnimationFrame(() => {
325
+ el.style.transition = "transform 220ms cubic-bezier(0.22, 1, 0.36, 1)";
326
+ el.style.transform = "";
327
+ });
328
+ }
329
+ prevRects.current.set(href, rect);
330
+ });
331
+ for (const h of [...prevRects.current.keys()])
332
+ if (!els.has(h)) prevRects.current.delete(h);
333
+ });
334
+
335
+ if (tabs.length === 0) return null;
336
+
337
+ return (
338
+ <div
339
+ role="tablist"
340
+ aria-label="Open pages"
341
+ className="flex h-9 shrink-0 items-center gap-1 overflow-x-auto border-b border-border bg-background px-3"
342
+ >
343
+ <span className="mr-1 shrink-0 rounded-md bg-amber-500 px-2 py-0.5 text-xs font-semibold text-white">
344
+ Tabs
345
+ </span>
346
+ {tabs.map((t) => {
347
+ const active = t.href === activeHref;
348
+ const Icon = t.icon;
349
+ const colorKey = colors[t.href];
350
+ const dot = colorKey ? DOT_FOR.get(colorKey) : null;
351
+ return (
352
+ <div
353
+ key={t.href}
354
+ ref={(el) => {
355
+ if (el) tabEls.current.set(t.href, el);
356
+ else tabEls.current.delete(t.href);
357
+ }}
358
+ role="tab"
359
+ aria-selected={active}
360
+ tabIndex={active ? 0 : -1}
361
+ draggable
362
+ onDragStart={() => {
363
+ dragHref.current = t.href;
364
+ lastOver.current = t.href;
365
+ }}
366
+ onDragOver={(e) => {
367
+ e.preventDefault();
368
+ // Reorder live as you drag past a tab (FLIP animates the shift).
369
+ const d = dragHref.current;
370
+ if (d && d !== t.href && lastOver.current !== t.href) {
371
+ lastOver.current = t.href;
372
+ reorder(d, t.href);
373
+ }
374
+ }}
375
+ onDrop={(e) => e.preventDefault()}
376
+ onDragEnd={() => {
377
+ dragHref.current = null;
378
+ lastOver.current = null;
379
+ }}
380
+ onContextMenu={(e) => {
381
+ e.preventDefault();
382
+ setMenu({ href: t.href, x: e.clientX, y: e.clientY });
383
+ }}
384
+ title="Drag to reorder · right-click to color"
385
+ className="group flex h-8 shrink-0 cursor-grab items-center gap-0.5 active:cursor-grabbing"
386
+ >
387
+ {/* Grip — separate from the pill, constant muted color; the drag cue. */}
388
+ <DragHandleDots2Icon
389
+ aria-hidden="true"
390
+ className="size-3.5 shrink-0 text-muted-foreground/50"
391
+ />
392
+ {/* Pill — the active/color background lives here (dot + icon + close). */}
393
+ <div
394
+ className={cn(
395
+ "flex h-7 items-center gap-1.5 rounded-md pl-2 pr-1.5 text-sm transition-colors",
396
+ active
397
+ ? "bg-[var(--button-primary)] text-[var(--button-primary-foreground)] shadow-[var(--button-shadow)]"
398
+ : "text-muted-foreground group-hover:bg-accent/50",
399
+ )}
400
+ >
401
+ {dot && (
402
+ <span
403
+ aria-hidden="true"
404
+ className={cn("size-2 shrink-0 rounded-full", dot)}
405
+ />
406
+ )}
407
+ <button
408
+ type="button"
409
+ onClick={() => router.push(t.href)}
410
+ className="flex items-center gap-1.5"
411
+ >
412
+ <Icon
413
+ className={cn(
414
+ "size-3.5 shrink-0",
415
+ active ? "text-[var(--button-primary-foreground)]" : t.color,
416
+ )}
417
+ />
418
+ <span className="max-w-40 truncate">{t.label}</span>
419
+ </button>
420
+ <button
421
+ type="button"
422
+ onClick={() => close(t.href)}
423
+ aria-label={`Close ${t.label}`}
424
+ className={cn(
425
+ "grid size-4 place-items-center rounded opacity-70 transition-colors group-hover:opacity-100",
426
+ active
427
+ ? "text-[var(--button-primary-foreground)] hover:bg-white/20"
428
+ : "text-muted-foreground hover:bg-border hover:text-foreground",
429
+ )}
430
+ >
431
+ <Cross2Icon className="size-3" />
432
+ </button>
433
+ </div>
434
+ </div>
435
+ );
436
+ })}
437
+ {notice && (
438
+ <span
439
+ role="status"
440
+ className="vui-fade-in ml-auto shrink-0 whitespace-nowrap rounded-md border border-border bg-muted px-2 py-1 text-xs text-muted-foreground"
441
+ >
442
+ {notice}
443
+ </span>
444
+ )}
445
+
446
+ {menu &&
447
+ typeof document !== "undefined" &&
448
+ createPortal(
449
+ <div
450
+ aria-label="Tab color"
451
+ style={{ position: "fixed", top: menu.y + 4, left: menu.x }}
452
+ className="vui-pop-in z-[200] flex items-center gap-1.5 rounded-md border border-border bg-popover p-1.5 shadow-md"
453
+ >
454
+ {TAB_COLORS.map((c) => (
455
+ <button
456
+ key={c.key}
457
+ type="button"
458
+ aria-label={c.key}
459
+ title={c.key}
460
+ onClick={() => {
461
+ setColor(menu.href, c.key);
462
+ setMenu(null);
463
+ }}
464
+ className={cn(
465
+ "size-4 rounded-full ring-offset-1 transition-transform hover:scale-110",
466
+ c.dot,
467
+ colors[menu.href] === c.key && cn("ring-2", c.ring),
468
+ )}
469
+ />
470
+ ))}
471
+ <button
472
+ type="button"
473
+ aria-label="No color"
474
+ title="No color"
475
+ onClick={() => {
476
+ setColor(menu.href, null);
477
+ setMenu(null);
478
+ }}
479
+ className="grid size-4 place-items-center rounded-full border border-border text-muted-foreground hover:bg-accent"
480
+ >
481
+ <Cross2Icon className="size-2.5" />
482
+ </button>
483
+ </div>,
484
+ document.body,
485
+ )}
486
+ </div>
487
+ );
488
+ }
489
+
490
+ /**
491
+ * Keep-alive outlet — renders the content of every OPEN tab and shows only the
492
+ * active one (the rest are `hidden`), so switching tabs is instant with no
493
+ * remount or flash and each page keeps its live state. It works by caching the
494
+ * active route's element per pathname; inactive routes keep rendering their
495
+ * last element (same component type + key → React preserves the instance). New
496
+ * routes mount on first visit; closed tabs are pruned (bounded by MAX_TABS).
497
+ *
498
+ * Because it renders app pages itself, they are no longer re-created by the file
499
+ * router on navigation — fine here: the app is a static export (all client at
500
+ * runtime, no SSR/SEO to lose).
501
+ */
502
+ export function KeepAliveTabs({ children }: { children: React.ReactNode }) {
503
+ const active = tabKey(usePathname());
504
+ const { tabs } = useOpenTabs();
505
+ const cache = React.useRef(new Map<string, React.ReactNode>());
506
+ // Refresh the active route's element (so re-opening an /edit tab with a
507
+ // different record shows the right data); inactive tabs keep their cached
508
+ // element and stay mounted. In-progress form work survives via RecordForm's
509
+ // sessionStorage draft (see persistKey), which is StrictMode-safe.
510
+ cache.current.set(active, children);
511
+ // Drop entries for tabs that have been closed.
512
+ const open = new Set(tabs.map((t) => t.href));
513
+ for (const key of [...cache.current.keys()]) {
514
+ if (key !== active && !open.has(key)) cache.current.delete(key);
515
+ }
516
+ return (
517
+ <>
518
+ {[...cache.current.entries()].map(([href, node]) => (
519
+ <div
520
+ key={href}
521
+ data-tab={href}
522
+ hidden={href !== active}
523
+ className={href === active ? "flex min-h-0 flex-1 flex-col" : undefined}
524
+ >
525
+ {node}
526
+ </div>
527
+ ))}
528
+ </>
529
+ );
530
+ }
@@ -0,0 +1,140 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { LightningBoltIcon } from "@radix-ui/react-icons";
6
+
7
+ import {
8
+ CommandPalette,
9
+ type CommandAction,
10
+ } from "@viliha/vui-ui/command-palette";
11
+ import { Shortcut } from "@viliha/vui-ui/kbd";
12
+ import { cn } from "@/lib/utils";
13
+ import { isGroup, NAV, type IconType } from "./nav-config";
14
+
15
+ /**
16
+ * Quick actions — a ⌘K command palette launched from the sidebar. Wires the
17
+ * app-specific bits (NAV destinations + Next router) into the generic
18
+ * `CommandPalette` from @viliha/vui-ui, and exposes a launcher + global
19
+ * shortcuts. New pages added to NAV appear here automatically.
20
+ */
21
+
22
+ type NavEntryFlat = {
23
+ label: string;
24
+ href: string;
25
+ icon: IconType;
26
+ color?: string;
27
+ group: string;
28
+ };
29
+
30
+ /** All navigable destinations, flattened from NAV and tagged with their group. */
31
+ const NAV_ENTRIES: NavEntryFlat[] = (() => {
32
+ const out: NavEntryFlat[] = [];
33
+ for (const section of NAV) {
34
+ const group = section.title ?? "General";
35
+ for (const entry of section.items) {
36
+ if (isGroup(entry)) {
37
+ for (const child of entry.children)
38
+ out.push({ ...child, group: entry.label });
39
+ } else {
40
+ out.push({ ...entry, group });
41
+ }
42
+ }
43
+ }
44
+ return out;
45
+ })();
46
+
47
+ type Ctx = { open: () => void };
48
+ const QuickActionsContext = React.createContext<Ctx | null>(null);
49
+
50
+ export function useQuickActions(): Ctx {
51
+ const ctx = React.useContext(QuickActionsContext);
52
+ if (!ctx)
53
+ throw new Error("useQuickActions must be used within <QuickActionsProvider>");
54
+ return ctx;
55
+ }
56
+
57
+ function isEditableTarget(t: EventTarget | null): boolean {
58
+ const el = t as HTMLElement | null;
59
+ if (!el) return false;
60
+ return (
61
+ el.tagName === "INPUT" ||
62
+ el.tagName === "TEXTAREA" ||
63
+ el.tagName === "SELECT" ||
64
+ el.isContentEditable
65
+ );
66
+ }
67
+
68
+ /** Holds the open state, mounts the palette once, and wires the global
69
+ * ⌘K / "/" shortcuts. Wrap the app shell with this. */
70
+ export function QuickActionsProvider({ children }: { children: React.ReactNode }) {
71
+ const [open, setOpen] = React.useState(false);
72
+ const router = useRouter();
73
+
74
+ React.useEffect(() => {
75
+ const onKey = (e: KeyboardEvent) => {
76
+ // ⌘K / Ctrl+K — but not ⌘⌥K, which is the global-search shortcut.
77
+ if ((e.metaKey || e.ctrlKey) && !e.altKey && e.code === "KeyK") {
78
+ e.preventDefault();
79
+ setOpen((v) => !v);
80
+ } else if (e.key === "/" && !open && !isEditableTarget(e.target)) {
81
+ e.preventDefault();
82
+ setOpen(true);
83
+ }
84
+ };
85
+ window.addEventListener("keydown", onKey);
86
+ return () => window.removeEventListener("keydown", onKey);
87
+ }, [open]);
88
+
89
+ const actions = React.useMemo<CommandAction[]>(
90
+ () =>
91
+ NAV_ENTRIES.map((e) => ({
92
+ id: e.href,
93
+ label: e.label,
94
+ group: e.group,
95
+ icon: e.icon,
96
+ iconClassName: e.color,
97
+ onSelect: () => router.push(e.href),
98
+ })),
99
+ [router],
100
+ );
101
+
102
+ const value = React.useMemo(() => ({ open: () => setOpen(true) }), []);
103
+
104
+ return (
105
+ <QuickActionsContext.Provider value={value}>
106
+ {children}
107
+ <CommandPalette
108
+ open={open}
109
+ onClose={() => setOpen(false)}
110
+ actions={actions}
111
+ placeholder="Search for a page or action…"
112
+ />
113
+ </QuickActionsContext.Provider>
114
+ );
115
+ }
116
+
117
+ /** Sidebar launcher — collapses to an icon-only button when the rail is narrow. */
118
+ export function QuickActionsLauncher({ collapsed = false }: { collapsed?: boolean }) {
119
+ const { open } = useQuickActions();
120
+ return (
121
+ <button
122
+ type="button"
123
+ onClick={open}
124
+ aria-label="Quick actions"
125
+ title="Quick actions (⌘K)"
126
+ className={cn(
127
+ "flex items-center gap-2 rounded-md border border-sidebar-border bg-background text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
128
+ collapsed ? "size-9 justify-center px-0" : "w-full px-2.5 py-2",
129
+ )}
130
+ >
131
+ <LightningBoltIcon className="size-4 shrink-0 text-amber-500" aria-hidden="true" />
132
+ {!collapsed && (
133
+ <>
134
+ <span className="flex-1 text-left text-sm font-medium">Quick actions</span>
135
+ <Shortcut keys={["⌘", "K"]} />
136
+ </>
137
+ )}
138
+ </button>
139
+ );
140
+ }