@goplusvn/core 0.1.31 → 0.1.33
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/CHANGELOG.md +36 -0
- package/package.json +1 -2
- package/src/auth/index.ts +1 -1
- package/src/crud/lib/permissions.ts +1 -1
- package/src/rbac/index.ts +1 -2
- package/src/rbac/permissions.ts +1 -2
- package/src/types/index.ts +9 -0
- package/src/ui/auth/auth-bridge.tsx +29 -31
- package/src/ui/layout/horizontal-layout-header.tsx +6 -1
- package/src/ui/layout/horizontal-layout.tsx +3 -0
- package/src/ui/layout/main-layout.tsx +9 -0
- package/src/ui/layout/page-tabs.tsx +3 -6
- package/src/ui/layout/tab-navigation-provider.tsx +59 -17
- package/src/ui/layout/vertical-layout-header.tsx +4 -1
- package/src/ui/layout/vertical-layout.tsx +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.33 — Tab navigation: giữ bộ lọc + dữ liệu tươi; MainLayout `notificationSlot`
|
|
4
|
+
|
|
5
|
+
**Tab navigation (PageTabs / TabNavigationProvider):**
|
|
6
|
+
|
|
7
|
+
- Tab lưu **path đầy đủ gồm query string** — quay lại tab khôi phục đúng bộ lọc/
|
|
8
|
+
trang đang đứng. Tab id vẫn key theo pathname (đổi bộ lọc không đẻ tab mới);
|
|
9
|
+
query mới nhất được cập nhật vào `tab.path` (action `UPDATE_TAB_PATH`).
|
|
10
|
+
`useSearchParams` nằm trong leaf `SearchParamsChangeSignal` bọc `Suspense`
|
|
11
|
+
(tránh lỗi build trên route static).
|
|
12
|
+
- **Bỏ prefetch-all-tabs** (mỗi lần đổi tab bắn full RSC render mọi tab còn lại
|
|
13
|
+
— nặng server) và **bỏ hover-prefetch** (payload FULL-prefetch bị client cache
|
|
14
|
+
~5 phút → quay lại tab thấy dữ liệu cũ). Chuyển tab giờ luôn fetch tươi.
|
|
15
|
+
|
|
16
|
+
**MainLayout: prop `notificationSlot` (chuông thông báo do app cấp)**
|
|
17
|
+
|
|
18
|
+
Thêm prop optional `notificationSlot?: ReactNode` cho `MainLayout` (chảy xuống
|
|
19
|
+
`Vertical/HorizontalLayout` → header). Khi truyền, nó thay `<NotificationDropdown>`
|
|
20
|
+
tĩnh mặc định trong header — để app tự cấp chuông self-fetch dữ liệu của mình.
|
|
21
|
+
Bỏ trống → giữ nguyên dropdown tĩnh cũ (**backward-compat**, consumer khác không
|
|
22
|
+
phải sửa gì).
|
|
23
|
+
|
|
24
|
+
## 0.1.32 — Gỡ NextAuth khỏi core (BREAKING cho app chưa migrate)
|
|
25
|
+
|
|
26
|
+
Core không còn phụ thuộc next-auth (bỏ peer dependency):
|
|
27
|
+
|
|
28
|
+
- **auth-bridge**: gỡ `nextAuthBridgeClient` + import `next-auth/react`.
|
|
29
|
+
Mặc định khi KHÔNG mount `AuthBridgeProvider` giờ là fallback
|
|
30
|
+
unauthenticated (cảnh báo console) thay vì NextAuth. App PHẢI mount
|
|
31
|
+
provider với client auth của mình (vinhhoa: betterAuthBridgeClient).
|
|
32
|
+
- **Type `Session`**: các chữ ký RBAC (`checkPermission`,
|
|
33
|
+
`getUserPermissions`, `hasRole`…) đổi từ `Session` của next-auth sang
|
|
34
|
+
`Session` cấu trúc `{ user?: unknown }` export từ `@goerp/core/types` —
|
|
35
|
+
session của mọi auth gán được, app không phải sửa call-site.
|
|
36
|
+
- `auth/proxy-gate` (gate JWT NextAuth cho app cũ) GIỮ NGUYÊN nhưng app nào
|
|
37
|
+
dùng phải tự cài next-auth (giờ chỉ là devDependency của core).
|
|
38
|
+
|
|
3
39
|
## 0.1.31 — MultiSelect: mặc định 1 DÒNG (hết lệch hàng toolbar)
|
|
4
40
|
|
|
5
41
|
Wrap-mặc-định (0.1.27) làm trigger CAO LÊN khi chọn ≥2 option → lệch hàng
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.33",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -59,7 +59,6 @@
|
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"next": ">=14.0.0",
|
|
62
|
-
"next-auth": "4.24.11",
|
|
63
62
|
"react": "^18.0.0 || ^19.0.0",
|
|
64
63
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
65
64
|
},
|
package/src/auth/index.ts
CHANGED
package/src/rbac/index.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// Core RBAC (Role-Based Access Control) utilities for GoERP platform
|
|
3
3
|
// Consolidated from packages/shared/rbac
|
|
4
4
|
|
|
5
|
-
import type { Permission } from "../types";
|
|
6
|
-
import type { Session } from "next-auth";
|
|
5
|
+
import type { Permission, Session } from "../types";
|
|
7
6
|
import { logger } from "../utils";
|
|
8
7
|
|
|
9
8
|
// ============================================================================
|
package/src/rbac/permissions.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// Server-only permission utilities - no client/UI imports
|
|
3
3
|
// Use this in Server Components and API routes to avoid bundling client code
|
|
4
4
|
|
|
5
|
-
import type { Permission } from "../types";
|
|
6
|
-
import type { Session } from "next-auth";
|
|
5
|
+
import type { Permission, Session } from "../types";
|
|
7
6
|
|
|
8
7
|
// ============================================================================
|
|
9
8
|
// Action Mapping
|
package/src/types/index.ts
CHANGED
|
@@ -13,6 +13,15 @@ import type { z } from "zod";
|
|
|
13
13
|
export const supportedLocales = ["vi", "en"] as const;
|
|
14
14
|
export type LocaleType = (typeof supportedLocales)[number];
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Session cấu trúc — thay `import type { Session } from "next-auth"` (đã gỡ
|
|
18
|
+
* NextAuth khỏi core). Core chỉ cần `session?.user` rồi cast ExtendedUser;
|
|
19
|
+
* session của mọi auth (NextAuth cũ, Better Auth shim) đều gán được.
|
|
20
|
+
*/
|
|
21
|
+
export interface Session {
|
|
22
|
+
user?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
export interface SearchResultItem {
|
|
17
26
|
id: string;
|
|
18
27
|
title: string;
|
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
// AUTH BRIDGE — lớp trừu tượng client-auth
|
|
4
|
-
//
|
|
5
|
-
// hiện tại không đổi hành vi. App migrate sang Better Auth chỉ cần mount:
|
|
3
|
+
// AUTH BRIDGE — lớp trừu tượng client-auth: core KHÔNG phụ thuộc thư viện
|
|
4
|
+
// auth nào. App PHẢI mount provider với client của mình, ví dụ Better Auth:
|
|
6
5
|
//
|
|
7
6
|
// <AuthBridgeProvider client={betterAuthBridgeClient}>
|
|
8
7
|
//
|
|
9
|
-
// với client map từ createAuthClient()
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// với client map từ createAuthClient() (useSession / signIn.email / signOut).
|
|
9
|
+
// LƯU Ý: client phải là hằng số suốt vòng đời app (rules of hooks — useSession
|
|
10
|
+
// của bridge được gọi như một hook). Từ 0.1.32 core đã GỠ next-auth: không
|
|
11
|
+
// còn nextAuthBridgeClient mặc định; app còn dùng NextAuth tự viết client
|
|
12
|
+
// (map useSession/signIn/signOut của next-auth/react vào AuthBridgeClient).
|
|
12
13
|
|
|
13
14
|
import { createContext, useContext } from "react";
|
|
14
15
|
import type { ReactNode } from "react";
|
|
15
|
-
import {
|
|
16
|
-
signIn as nextAuthSignIn,
|
|
17
|
-
signOut as nextAuthSignOut,
|
|
18
|
-
useSession as nextAuthUseSession,
|
|
19
|
-
} from "next-auth/react";
|
|
20
16
|
|
|
21
17
|
export interface AuthSessionState {
|
|
22
18
|
/** Session object (shape do app quyết — core chỉ đọc user.id/permissions...) */
|
|
@@ -37,31 +33,33 @@ export interface AuthBridgeClient {
|
|
|
37
33
|
signOut: (options?: { callbackUrl?: string }) => Promise<void>;
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
const MISSING_PROVIDER_MSG =
|
|
37
|
+
"[goerp-core] AuthBridgeProvider chưa được mount — bọc app trong " +
|
|
38
|
+
'<AuthBridgeProvider client={...}> với client auth của app (xem ui/auth).';
|
|
39
|
+
|
|
40
|
+
let warnedMissingProvider = false;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fallback khi app QUÊN mount AuthBridgeProvider: session luôn
|
|
44
|
+
* unauthenticated (cảnh báo console 1 lần), signIn trả lỗi rõ ràng.
|
|
45
|
+
*/
|
|
46
|
+
const missingProviderClient: AuthBridgeClient = {
|
|
42
47
|
useSession: () => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
update: () => s.update(),
|
|
49
|
-
};
|
|
50
|
-
},
|
|
51
|
-
signInWithCredentials: async ({ email, password }) => {
|
|
52
|
-
const result = await nextAuthSignIn("credentials", {
|
|
53
|
-
redirect: false,
|
|
54
|
-
email,
|
|
55
|
-
password,
|
|
56
|
-
});
|
|
57
|
-
return { error: result?.error ?? null };
|
|
48
|
+
if (!warnedMissingProvider) {
|
|
49
|
+
warnedMissingProvider = true;
|
|
50
|
+
console.warn(MISSING_PROVIDER_MSG);
|
|
51
|
+
}
|
|
52
|
+
return { data: null, status: "unauthenticated", update: async () => null };
|
|
58
53
|
},
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
signInWithCredentials: async () => ({ error: MISSING_PROVIDER_MSG }),
|
|
55
|
+
signOut: async () => {
|
|
56
|
+
console.warn(MISSING_PROVIDER_MSG);
|
|
61
57
|
},
|
|
62
58
|
};
|
|
63
59
|
|
|
64
|
-
const AuthBridgeContext = createContext<AuthBridgeClient>(
|
|
60
|
+
const AuthBridgeContext = createContext<AuthBridgeClient>(
|
|
61
|
+
missingProviderClient,
|
|
62
|
+
);
|
|
65
63
|
|
|
66
64
|
export function AuthBridgeProvider({
|
|
67
65
|
client,
|
|
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
|
|
|
5
5
|
import { Settings } from "lucide-react";
|
|
6
6
|
|
|
7
7
|
import type { DictionaryType } from "../../hooks";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
8
9
|
import type { LocaleType, NavigationType } from "../../types";
|
|
9
10
|
|
|
10
11
|
import { ensureLocalizedPathname } from "../../utils";
|
|
@@ -23,9 +24,11 @@ import { TopBarHeaderMenubar } from "./top-bar-header-menubar";
|
|
|
23
24
|
export function HorizontalLayoutHeader({
|
|
24
25
|
dictionary,
|
|
25
26
|
navigation,
|
|
27
|
+
notificationSlot,
|
|
26
28
|
}: {
|
|
27
29
|
dictionary: DictionaryType;
|
|
28
30
|
navigation?: NavigationType[];
|
|
31
|
+
notificationSlot?: ReactNode;
|
|
29
32
|
}) {
|
|
30
33
|
const params = useParams();
|
|
31
34
|
const locale = params.lang as LocaleType;
|
|
@@ -56,7 +59,9 @@ export function HorizontalLayoutHeader({
|
|
|
56
59
|
navigation={navigation ?? []}
|
|
57
60
|
/>
|
|
58
61
|
<div className="hidden sm:flex items-center gap-2">
|
|
59
|
-
|
|
62
|
+
{notificationSlot ?? (
|
|
63
|
+
<NotificationDropdown dictionary={dictionary} />
|
|
64
|
+
)}
|
|
60
65
|
<FullScreenToggle />
|
|
61
66
|
</div>
|
|
62
67
|
<Customizer
|
|
@@ -16,6 +16,7 @@ interface HorizontalLayoutProps {
|
|
|
16
16
|
onGlobalSearch?: (query: string) => void;
|
|
17
17
|
searchResults?: any[];
|
|
18
18
|
searchLoading?: boolean;
|
|
19
|
+
notificationSlot?: ReactNode;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function HorizontalLayout({
|
|
@@ -25,6 +26,7 @@ export function HorizontalLayout({
|
|
|
25
26
|
onGlobalSearch,
|
|
26
27
|
searchResults,
|
|
27
28
|
searchLoading,
|
|
29
|
+
notificationSlot,
|
|
28
30
|
}: HorizontalLayoutProps) {
|
|
29
31
|
return (
|
|
30
32
|
<SidebarProvider>
|
|
@@ -39,6 +41,7 @@ export function HorizontalLayout({
|
|
|
39
41
|
<HorizontalLayoutHeader
|
|
40
42
|
dictionary={dictionary}
|
|
41
43
|
navigation={navigation}
|
|
44
|
+
notificationSlot={notificationSlot}
|
|
42
45
|
/>
|
|
43
46
|
<main className="w-full mx-auto flex-1 min-h-0 px-4 md:px-8 py-4">
|
|
44
47
|
{children}
|
|
@@ -15,6 +15,12 @@ interface LayoutProps {
|
|
|
15
15
|
onGlobalSearch?: (query: string) => void;
|
|
16
16
|
searchResults?: any[];
|
|
17
17
|
searchLoading?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Chuông thông báo do app tự cấp (self-fetch dữ liệu app). Khi truyền, thay
|
|
20
|
+
* cho <NotificationDropdown> tĩnh mặc định trong header. Bỏ trống → giữ
|
|
21
|
+
* dropdown tĩnh cũ (backward-compat cho consumer chưa nối dữ liệu).
|
|
22
|
+
*/
|
|
23
|
+
notificationSlot?: ReactNode;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
export function MainLayout({
|
|
@@ -24,6 +30,7 @@ export function MainLayout({
|
|
|
24
30
|
onGlobalSearch,
|
|
25
31
|
searchResults,
|
|
26
32
|
searchLoading,
|
|
33
|
+
notificationSlot,
|
|
27
34
|
}: LayoutProps) {
|
|
28
35
|
const mounted = useMounted();
|
|
29
36
|
const isVertical = useIsVertical();
|
|
@@ -40,6 +47,7 @@ export function MainLayout({
|
|
|
40
47
|
onGlobalSearch={onGlobalSearch}
|
|
41
48
|
searchResults={searchResults}
|
|
42
49
|
searchLoading={searchLoading}
|
|
50
|
+
notificationSlot={notificationSlot}
|
|
43
51
|
>
|
|
44
52
|
{children}
|
|
45
53
|
</VerticalLayout>
|
|
@@ -50,6 +58,7 @@ export function MainLayout({
|
|
|
50
58
|
onGlobalSearch={onGlobalSearch}
|
|
51
59
|
searchResults={searchResults}
|
|
52
60
|
searchLoading={searchLoading}
|
|
61
|
+
notificationSlot={notificationSlot}
|
|
53
62
|
>
|
|
54
63
|
{children}
|
|
55
64
|
</HorizontalLayout>
|
|
@@ -146,12 +146,9 @@ export function PageTabs({
|
|
|
146
146
|
tabIndex={0}
|
|
147
147
|
onClick={() => handleTabClick(tab.id)}
|
|
148
148
|
onContextMenu={() => handleContextMenu(tab.id)}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
router.prefetch(tab.path);
|
|
153
|
-
}
|
|
154
|
-
}}
|
|
149
|
+
// No hover prefetch: imperative prefetch is FULL-kind and its
|
|
150
|
+
// payload is client-cached ~5 minutes, so switching back to a
|
|
151
|
+
// tab would render stale data on these force-dynamic pages.
|
|
155
152
|
onKeyDown={(e) => {
|
|
156
153
|
if (e.key === "Enter" || e.key === " ") {
|
|
157
154
|
e.preventDefault();
|
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
useReducer,
|
|
8
8
|
useCallback,
|
|
9
9
|
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
Suspense,
|
|
10
12
|
} from "react";
|
|
11
|
-
import { usePathname, useRouter } from "next/navigation";
|
|
13
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
12
14
|
import type { ReactNode } from "react";
|
|
13
15
|
|
|
14
16
|
import type { DynamicIconNameType, NavigationType } from "../../types";
|
|
@@ -43,6 +45,7 @@ type TabNavigationAction =
|
|
|
43
45
|
}
|
|
44
46
|
| { type: "REMOVE_TAB"; payload: { id: string } }
|
|
45
47
|
| { type: "SET_ACTIVE_TAB"; payload: { id: string } }
|
|
48
|
+
| { type: "UPDATE_TAB_PATH"; payload: { id: string; path: string } }
|
|
46
49
|
| { type: "LOAD_STATE"; payload: TabNavigationState }
|
|
47
50
|
| { type: "CLEAR_TABS" }
|
|
48
51
|
| { type: "REMOVE_OTHER_TABS"; payload: { id: string } }
|
|
@@ -58,7 +61,9 @@ function tabNavigationReducer(
|
|
|
58
61
|
switch (action.type) {
|
|
59
62
|
case "ADD_TAB": {
|
|
60
63
|
const { path, title, iconName } = action.payload;
|
|
61
|
-
|
|
64
|
+
// path may carry a query string; the tab id is keyed by pathname only
|
|
65
|
+
// so the same page with different filters stays a single tab.
|
|
66
|
+
const normalizedPath = normalizePathname(path.split("?")[0]);
|
|
62
67
|
const tabId = normalizedPath;
|
|
63
68
|
|
|
64
69
|
// Check if tab already exists
|
|
@@ -119,6 +124,19 @@ function tabNavigationReducer(
|
|
|
119
124
|
};
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
case "UPDATE_TAB_PATH": {
|
|
128
|
+
// Tab id stays keyed by pathname; path stores the full URL (incl. query
|
|
129
|
+
// string) so re-activating a tab restores its filters/pagination.
|
|
130
|
+
const { id, path } = action.payload;
|
|
131
|
+
const tab = state.tabs.find((t) => t.id === id);
|
|
132
|
+
if (!tab || tab.path === path) return state;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
...state,
|
|
136
|
+
tabs: state.tabs.map((t) => (t.id === id ? { ...t, path } : t)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
122
140
|
case "LOAD_STATE": {
|
|
123
141
|
return action.payload;
|
|
124
142
|
}
|
|
@@ -194,6 +212,25 @@ const TabNavigationContext = createContext<
|
|
|
194
212
|
TabNavigationContextValue | undefined
|
|
195
213
|
>(undefined);
|
|
196
214
|
|
|
215
|
+
// useSearchParams requires a Suspense boundary on statically rendered routes,
|
|
216
|
+
// so it lives in this leaf instead of the provider itself. It only signals
|
|
217
|
+
// "the query string changed" — the provider reads the actual value from
|
|
218
|
+
// window.location so both pathname- and search-triggered runs agree.
|
|
219
|
+
function SearchParamsChangeSignal({
|
|
220
|
+
onChange,
|
|
221
|
+
}: {
|
|
222
|
+
onChange: (search: string) => void;
|
|
223
|
+
}) {
|
|
224
|
+
const searchParams = useSearchParams();
|
|
225
|
+
const search = searchParams?.toString() ?? "";
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
onChange(search);
|
|
229
|
+
}, [search, onChange]);
|
|
230
|
+
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
197
234
|
export function TabNavigationProvider({
|
|
198
235
|
children,
|
|
199
236
|
navigations,
|
|
@@ -205,6 +242,7 @@ export function TabNavigationProvider({
|
|
|
205
242
|
const pathname = usePathname();
|
|
206
243
|
const router = useRouter();
|
|
207
244
|
const isInitialized = useRef(false);
|
|
245
|
+
const [searchSignal, setSearchSignal] = useState("");
|
|
208
246
|
|
|
209
247
|
// Load state from sessionStorage on mount
|
|
210
248
|
useEffect(() => {
|
|
@@ -233,7 +271,7 @@ export function TabNavigationProvider({
|
|
|
233
271
|
}
|
|
234
272
|
}, [state]);
|
|
235
273
|
|
|
236
|
-
// Auto-add tab when pathname changes
|
|
274
|
+
// Auto-add tab when pathname or query string changes
|
|
237
275
|
useEffect(() => {
|
|
238
276
|
if (!pathname || !isInitialized.current) return;
|
|
239
277
|
|
|
@@ -247,9 +285,22 @@ export function TabNavigationProvider({
|
|
|
247
285
|
const iconName = findRouteIcon(pathname, navigations) || undefined;
|
|
248
286
|
const normalizedPath = normalizePathname(pathname);
|
|
249
287
|
|
|
288
|
+
// Full URL incl. query string — window.location is already updated by
|
|
289
|
+
// effect time, and reading it here keeps pathname-triggered and
|
|
290
|
+
// searchSignal-triggered runs consistent with each other.
|
|
291
|
+
const search = typeof window !== "undefined" ? window.location.search : "";
|
|
292
|
+
const fullPath = `${pathname}${search}`;
|
|
293
|
+
|
|
250
294
|
// Check if tab already exists
|
|
251
295
|
const existingTab = state.tabs.find((tab) => tab.id === normalizedPath);
|
|
252
296
|
if (existingTab) {
|
|
297
|
+
// Remember the latest filters/pagination for this tab
|
|
298
|
+
if (existingTab.path !== fullPath) {
|
|
299
|
+
dispatch({
|
|
300
|
+
type: "UPDATE_TAB_PATH",
|
|
301
|
+
payload: { id: normalizedPath, path: fullPath },
|
|
302
|
+
});
|
|
303
|
+
}
|
|
253
304
|
// Just set as active if not already active
|
|
254
305
|
if (state.activeTabId !== normalizedPath) {
|
|
255
306
|
dispatch({ type: "SET_ACTIVE_TAB", payload: { id: normalizedPath } });
|
|
@@ -258,23 +309,11 @@ export function TabNavigationProvider({
|
|
|
258
309
|
// Add new tab
|
|
259
310
|
dispatch({
|
|
260
311
|
type: "ADD_TAB",
|
|
261
|
-
payload: { path:
|
|
312
|
+
payload: { path: fullPath, title, iconName },
|
|
262
313
|
});
|
|
263
314
|
}
|
|
264
315
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
265
|
-
}, [pathname, navigations]);
|
|
266
|
-
|
|
267
|
-
// Prefetch all tab routes when tabs change
|
|
268
|
-
useEffect(() => {
|
|
269
|
-
if (!isInitialized.current) return;
|
|
270
|
-
|
|
271
|
-
// Prefetch all tab routes in background
|
|
272
|
-
state.tabs.forEach((tab) => {
|
|
273
|
-
if (tab.path && tab.id !== state.activeTabId) {
|
|
274
|
-
router.prefetch(tab.path);
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
}, [state.tabs, state.activeTabId, router]);
|
|
316
|
+
}, [pathname, searchSignal, navigations]);
|
|
278
317
|
|
|
279
318
|
const addTab = useCallback(
|
|
280
319
|
(path: string, title?: string) => {
|
|
@@ -510,6 +549,9 @@ export function TabNavigationProvider({
|
|
|
510
549
|
closeAndGoToParent,
|
|
511
550
|
}}
|
|
512
551
|
>
|
|
552
|
+
<Suspense fallback={null}>
|
|
553
|
+
<SearchParamsChangeSignal onChange={setSearchSignal} />
|
|
554
|
+
</Suspense>
|
|
513
555
|
{children}
|
|
514
556
|
</TabNavigationContext.Provider>
|
|
515
557
|
);
|
|
@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
|
|
|
4
4
|
import { Settings } from "lucide-react";
|
|
5
5
|
|
|
6
6
|
import type { DictionaryType } from "../../hooks";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
7
8
|
import type { LocaleType } from "../../types";
|
|
8
9
|
|
|
9
10
|
import { Button } from "../index";
|
|
@@ -17,8 +18,10 @@ import { ToggleMobileSidebar } from "./toggle-mobile-sidebar";
|
|
|
17
18
|
|
|
18
19
|
export function VerticalLayoutHeader({
|
|
19
20
|
dictionary,
|
|
21
|
+
notificationSlot,
|
|
20
22
|
}: {
|
|
21
23
|
dictionary: DictionaryType;
|
|
24
|
+
notificationSlot?: ReactNode;
|
|
22
25
|
}) {
|
|
23
26
|
const params = useParams();
|
|
24
27
|
const locale = params.lang as LocaleType;
|
|
@@ -44,7 +47,7 @@ export function VerticalLayoutHeader({
|
|
|
44
47
|
{/* Right: user actions */}
|
|
45
48
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
46
49
|
<div className="hidden sm:flex items-center gap-2">
|
|
47
|
-
<NotificationDropdown dictionary={dictionary} />
|
|
50
|
+
{notificationSlot ?? <NotificationDropdown dictionary={dictionary} />}
|
|
48
51
|
<FullScreenToggle />
|
|
49
52
|
</div>
|
|
50
53
|
<Customizer
|
|
@@ -16,6 +16,7 @@ interface VerticalLayoutProps {
|
|
|
16
16
|
onGlobalSearch?: (query: string) => void;
|
|
17
17
|
searchResults?: any[];
|
|
18
18
|
searchLoading?: boolean;
|
|
19
|
+
notificationSlot?: ReactNode;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function VerticalLayout({
|
|
@@ -25,6 +26,7 @@ export function VerticalLayout({
|
|
|
25
26
|
onGlobalSearch,
|
|
26
27
|
searchResults,
|
|
27
28
|
searchLoading,
|
|
29
|
+
notificationSlot,
|
|
28
30
|
}: VerticalLayoutProps) {
|
|
29
31
|
return (
|
|
30
32
|
<SidebarProvider>
|
|
@@ -36,7 +38,10 @@ export function VerticalLayout({
|
|
|
36
38
|
searchLoading={searchLoading}
|
|
37
39
|
/>
|
|
38
40
|
<SidebarInset className="w-full min-w-0 overflow-hidden h-screen flex flex-col">
|
|
39
|
-
<VerticalLayoutHeader
|
|
41
|
+
<VerticalLayoutHeader
|
|
42
|
+
dictionary={dictionary}
|
|
43
|
+
notificationSlot={notificationSlot}
|
|
44
|
+
/>
|
|
40
45
|
<main className="w-full mx-auto flex-1 min-h-0 px-4 md:px-8 py-4 overflow-auto">
|
|
41
46
|
{children}
|
|
42
47
|
</main>
|