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