@nocobase/portal-template-default 1.0.3 → 1.0.4
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/README.MD +10 -5
- package/package.json +1 -1
- package/registry/nocobase-ai/components/chat/chat-message.tsx +8 -6
- package/registry/nocobase-ai/components/chat/chat-messages.tsx +8 -3
- package/registry/nocobase-ai/components/chat/markdown-message.tsx +26 -0
- package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +3 -3
- package/registry/nocobase-ai/demo/configuration-gate.tsx +83 -35
- package/registry/nocobase-i18n/README.md +23 -32
- package/registry/nocobase-i18n/components/language-switcher.tsx +1 -1
- package/registry/nocobase-i18n/demo/index.tsx +2 -2
- package/registry/nocobase-i18n/demo/prompt-generator.tsx +3 -2
- package/registry/nocobase-i18n/extension.tsx +3 -4
- package/registry/nocobase-i18n/index.ts +4 -3
- package/registry/nocobase-i18n/locales/en-US.ts +2 -52
- package/registry/nocobase-i18n/locales/index.ts +8 -0
- package/registry/nocobase-i18n/locales/zh-CN.ts +2 -52
- package/registry/nocobase-i18n/provider.tsx +18 -6
- package/registry/nocobase-i18n/server-resources.ts +13 -31
- package/registry/nocobase-i18n/tests/i18n-regression.mjs +14 -5
- package/registry/nocobase-route-surfaces/README.md +21 -0
- package/registry/nocobase-route-surfaces/demo/index.tsx +266 -0
- package/{src/extensions/nocobase-acl → registry/nocobase-route-surfaces}/demo/lazy-route.tsx +2 -1
- package/registry/nocobase-route-surfaces/demo/prompt-generator.tsx +90 -0
- package/registry/nocobase-route-surfaces/demo/scenarios.ts +95 -0
- package/registry/nocobase-route-surfaces/extension.tsx +119 -0
- package/registry/nocobase-route-surfaces/index.ts +7 -0
- package/registry/nocobase-route-surfaces/route-dialog.tsx +145 -0
- package/registry/nocobase-route-surfaces/route-drawer.tsx +186 -0
- package/registry/nocobase-route-surfaces/route-page.tsx +29 -0
- package/registry/nocobase-route-surfaces/route-surface-context.ts +13 -0
- package/registry/nocobase-route-surfaces/use-refine-unsaved-changes.tsx +106 -0
- package/registry/nocobase-route-surfaces/use-route-surface-close.ts +15 -0
- package/registry/nocobase-route-surfaces/use-route-surface-state.ts +65 -0
- package/registry.config.json +30 -3
- package/scripts/registry.mjs +0 -2
- package/src/App.tsx +65 -35
- package/src/app/extension.ts +1 -2
- package/src/app/extensions.tsx +0 -14
- package/src/components/app-shell/breadcrumb.tsx +67 -2
- package/src/components/app-shell/sidebar.tsx +2 -2
- package/src/components/resources/views/list-view.tsx +3 -2
- package/src/components/ui/sheet.tsx +22 -21
- package/src/lib/nocobase/client.ts +6 -1
- package/src/locales/en-US.ts +81 -0
- package/src/locales/zh-CN.ts +77 -0
- package/src/pages/users/create.tsx +76 -53
- package/src/pages/users/edit.tsx +87 -56
- package/src/pages/users/index.ts +3 -0
- package/src/pages/users/layout.tsx +15 -0
- package/src/pages/users/list.tsx +19 -21
- package/src/pages/users/role-badges.tsx +43 -0
- package/src/pages/users/role-detail.tsx +272 -0
- package/src/pages/users/role-utils.ts +6 -0
- package/src/pages/users/routes.ts +24 -0
- package/src/pages/users/show.tsx +195 -104
- package/src/pages/users/types.ts +13 -0
- package/src/providers/i18n/index.ts +3 -0
- package/{registry/nocobase-i18n → src/providers/i18n}/runtime.ts +63 -16
- package/src/providers/system-settings/context.ts +26 -0
- package/src/providers/system-settings/index.ts +2 -0
- package/src/providers/system-settings/provider.tsx +94 -0
- package/src/extensions/nocobase-acl/README.md +0 -19
- package/src/extensions/nocobase-acl/components/acl-boundary.tsx +0 -106
- package/src/extensions/nocobase-acl/components/index.ts +0 -3
- package/src/extensions/nocobase-acl/components/role-options.ts +0 -36
- package/src/extensions/nocobase-acl/components/role-switcher.tsx +0 -135
- package/src/extensions/nocobase-acl/demo/boundary-api.tsx +0 -186
- package/src/extensions/nocobase-acl/demo/components.tsx +0 -231
- package/src/extensions/nocobase-acl/demo/index.tsx +0 -505
- package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +0 -134
- package/src/extensions/nocobase-acl/demo/role-switcher-prompt-generator.tsx +0 -198
- package/src/extensions/nocobase-acl/demo/scenario-section.tsx +0 -37
- package/src/extensions/nocobase-acl/extension.tsx +0 -71
- package/src/extensions/nocobase-acl/index.ts +0 -1
- package/src/extensions/nocobase-ai/README.md +0 -70
- package/src/extensions/nocobase-ai/adapters/react-hook-form.ts +0 -20
- package/src/extensions/nocobase-ai/assets/nocobase-ai-chat.svg +0 -50
- package/src/extensions/nocobase-ai/components/ai-root-provider.tsx +0 -32
- package/src/extensions/nocobase-ai/components/chat/ai-employee-avatar.tsx +0 -31
- package/src/extensions/nocobase-ai/components/chat/chat-attachment.tsx +0 -92
- package/src/extensions/nocobase-ai/components/chat/chat-compact.tsx +0 -90
- package/src/extensions/nocobase-ai/components/chat/chat-composer.tsx +0 -429
- package/src/extensions/nocobase-ai/components/chat/chat-empty-state.tsx +0 -51
- package/src/extensions/nocobase-ai/components/chat/chat-header.tsx +0 -88
- package/src/extensions/nocobase-ai/components/chat/chat-history-dialog.tsx +0 -77
- package/src/extensions/nocobase-ai/components/chat/chat-message.tsx +0 -252
- package/src/extensions/nocobase-ai/components/chat/chat-messages.tsx +0 -173
- package/src/extensions/nocobase-ai/components/chat/chat-window.tsx +0 -118
- package/src/extensions/nocobase-ai/components/chat/conversation-list.tsx +0 -343
- package/src/extensions/nocobase-ai/components/chat/markdown-message.tsx +0 -165
- package/src/extensions/nocobase-ai/components/chat/model-select-options.tsx +0 -30
- package/src/extensions/nocobase-ai/components/chat/reasoning-panel.tsx +0 -29
- package/src/extensions/nocobase-ai/components/chat/sub-agent-conversation.tsx +0 -142
- package/src/extensions/nocobase-ai/components/chat/tool-call-card.tsx +0 -316
- package/src/extensions/nocobase-ai/components/chat/user-prompt-editor.tsx +0 -99
- package/src/extensions/nocobase-ai/components/chat/work-context-chip.tsx +0 -114
- package/src/extensions/nocobase-ai/components/index.ts +0 -59
- package/src/extensions/nocobase-ai/components/page-elements/ai-form.tsx +0 -44
- package/src/extensions/nocobase-ai/components/page-elements/page-element-provider.tsx +0 -469
- package/src/extensions/nocobase-ai/components/surfaces/chat-dialog.tsx +0 -31
- package/src/extensions/nocobase-ai/components/surfaces/chat-inline.tsx +0 -18
- package/src/extensions/nocobase-ai/components/surfaces/chat-page.tsx +0 -18
- package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +0 -54
- package/src/extensions/nocobase-ai/components/surfaces/chat-side-panel.tsx +0 -74
- package/src/extensions/nocobase-ai/components/surfaces/chat-surface-actions.tsx +0 -54
- package/src/extensions/nocobase-ai/components/tools/builtin-tool-renderers.tsx +0 -31
- package/src/extensions/nocobase-ai/components/tools/business-report-dialog.tsx +0 -364
- package/src/extensions/nocobase-ai/components/tools/business-report-renderer.tsx +0 -122
- package/src/extensions/nocobase-ai/components/tools/business-report-utils.tsx +0 -282
- package/src/extensions/nocobase-ai/components/tools/chart-renderer.tsx +0 -99
- package/src/extensions/nocobase-ai/components/tools/echarts-preview.tsx +0 -59
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime-advanced.ts +0 -18
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime-common.ts +0 -20
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime-components.ts +0 -34
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +0 -24
- package/src/extensions/nocobase-ai/components/tools/echarts-runtime.ts +0 -138
- package/src/extensions/nocobase-ai/components/tools/sub-agent-renderer.tsx +0 -67
- package/src/extensions/nocobase-ai/components/tools/suggestions-renderer.tsx +0 -59
- package/src/extensions/nocobase-ai/components/tools/tool-renderer-provider.tsx +0 -55
- package/src/extensions/nocobase-ai/components/tools/tool-renderer-utils.ts +0 -18
- package/src/extensions/nocobase-ai/components/tools/workflow-renderer.tsx +0 -108
- package/src/extensions/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +0 -73
- package/src/extensions/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +0 -149
- package/src/extensions/nocobase-ai/demo/configuration-gate.tsx +0 -49
- package/src/extensions/nocobase-ai/demo/container-showcase.tsx +0 -210
- package/src/extensions/nocobase-ai/demo/floating.tsx +0 -134
- package/src/extensions/nocobase-ai/demo/index.tsx +0 -346
- package/src/extensions/nocobase-ai/demo/interaction-showcase.tsx +0 -151
- package/src/extensions/nocobase-ai/demo/page-context-prompt-generator.tsx +0 -355
- package/src/extensions/nocobase-ai/demo/page-context.tsx +0 -884
- package/src/extensions/nocobase-ai/demo/page-element-showcase.tsx +0 -197
- package/src/extensions/nocobase-ai/demo/prompt-card.tsx +0 -41
- package/src/extensions/nocobase-ai/demo/prompt-generator.tsx +0 -452
- package/src/extensions/nocobase-ai/demo/shortcut.tsx +0 -1207
- package/src/extensions/nocobase-ai/demo/tool-cards.tsx +0 -530
- package/src/extensions/nocobase-ai/extension.tsx +0 -158
- package/src/extensions/nocobase-ai/global-ai-chat.tsx +0 -213
- package/src/extensions/nocobase-ai/index.ts +0 -3
- package/src/extensions/nocobase-ai/providers/ai-provider.tsx +0 -323
- package/src/extensions/nocobase-ai/providers/avatars.ts +0 -817
- package/src/extensions/nocobase-ai/providers/chat-context.tsx +0 -109
- package/src/extensions/nocobase-ai/providers/chat-controller.ts +0 -84
- package/src/extensions/nocobase-ai/providers/chat-message-utils.ts +0 -198
- package/src/extensions/nocobase-ai/providers/chat-provider.tsx +0 -871
- package/src/extensions/nocobase-ai/providers/chat-reducer.ts +0 -147
- package/src/extensions/nocobase-ai/providers/chat-task-utils.ts +0 -105
- package/src/extensions/nocobase-ai/providers/chat-transport.ts +0 -184
- package/src/extensions/nocobase-ai/providers/form-registry.tsx +0 -271
- package/src/extensions/nocobase-ai/providers/frontend-tool-registry.tsx +0 -231
- package/src/extensions/nocobase-ai/providers/index.ts +0 -61
- package/src/extensions/nocobase-ai/providers/model.ts +0 -32
- package/src/extensions/nocobase-ai/providers/page-context.tsx +0 -136
- package/src/extensions/nocobase-ai/providers/stream-coalescer.ts +0 -60
- package/src/extensions/nocobase-ai/providers/stream-event-utils.ts +0 -82
- package/src/extensions/nocobase-ai/providers/stream-parser.ts +0 -61
- package/src/extensions/nocobase-ai/providers/sub-agent-stream.ts +0 -316
- package/src/extensions/nocobase-ai/providers/types.ts +0 -183
- package/src/extensions/nocobase-ai/providers/ui-message-stream.ts +0 -463
- package/src/extensions/nocobase-ai/providers/use-automatic-tool-approval.ts +0 -94
- package/src/extensions/nocobase-ai/providers/use-chat-attachments.ts +0 -206
- package/src/extensions/nocobase-ai/providers/use-chat-message-actions.ts +0 -554
- package/src/extensions/nocobase-ai/providers/use-chat-runtime.ts +0 -210
- package/src/extensions/nocobase-ai/providers/use-chat-work-context.ts +0 -93
- package/src/extensions/nocobase-ai/providers/use-conversation-catalog.ts +0 -108
- package/src/extensions/nocobase-ai/providers/use-conversation-history.ts +0 -178
- package/src/extensions/nocobase-ai/services/index.ts +0 -7
- package/src/extensions/nocobase-ai/services/nocobase-ai-service.ts +0 -454
- package/src/extensions/nocobase-ai/services/types.ts +0 -82
- /package/{registry/nocobase-i18n → src/providers/i18n}/i18n-provider.ts +0 -0
- /package/{registry/nocobase-i18n → src/providers/i18n}/locale-store.ts +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { AppExtension } from "@/app/extension";
|
|
2
|
+
import { Layers3 } from "lucide-react";
|
|
3
|
+
import { lazy } from "react";
|
|
4
|
+
import { Route } from "react-router";
|
|
5
|
+
import { LazyRouteSurfaceDemo } from "./demo/lazy-route";
|
|
6
|
+
|
|
7
|
+
const DemoHome = lazy(() =>
|
|
8
|
+
import("./demo").then((module) => ({
|
|
9
|
+
default: module.RouteSurfacesDemoHome,
|
|
10
|
+
}))
|
|
11
|
+
);
|
|
12
|
+
const DemoDrawer = lazy(() =>
|
|
13
|
+
import("./demo").then((module) => ({ default: module.DemoDrawerRoute }))
|
|
14
|
+
);
|
|
15
|
+
const DemoSecondDrawer = lazy(() =>
|
|
16
|
+
import("./demo").then((module) => ({
|
|
17
|
+
default: module.DemoSecondDrawerRoute,
|
|
18
|
+
}))
|
|
19
|
+
);
|
|
20
|
+
const DemoDialog = lazy(() =>
|
|
21
|
+
import("./demo").then((module) => ({ default: module.DemoDialogRoute }))
|
|
22
|
+
);
|
|
23
|
+
const DemoPage = lazy(() =>
|
|
24
|
+
import("./demo").then((module) => ({ default: module.DemoPageRoute }))
|
|
25
|
+
);
|
|
26
|
+
const DemoPageDrawer = lazy(() =>
|
|
27
|
+
import("./demo").then((module) => ({
|
|
28
|
+
default: module.DemoPageDrawerRoute,
|
|
29
|
+
}))
|
|
30
|
+
);
|
|
31
|
+
const DemoPageDrawerDialog = lazy(() =>
|
|
32
|
+
import("./demo").then((module) => ({
|
|
33
|
+
default: module.DemoPageDrawerDialogRoute,
|
|
34
|
+
}))
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const routeSurfacesExtension: AppExtension = {
|
|
38
|
+
id: "nocobase-route-surfaces",
|
|
39
|
+
resources: [
|
|
40
|
+
{
|
|
41
|
+
name: "route-surfaces",
|
|
42
|
+
list: "/route-surfaces",
|
|
43
|
+
meta: {
|
|
44
|
+
label: "Route surfaces",
|
|
45
|
+
icon: <Layers3 />,
|
|
46
|
+
description: "URL-backed drawer, dialog, page, and nested route patterns.",
|
|
47
|
+
acl: { type: "authenticated" },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
routes: (
|
|
52
|
+
<>
|
|
53
|
+
<Route
|
|
54
|
+
key="route-surfaces-overlays"
|
|
55
|
+
path="/route-surfaces"
|
|
56
|
+
element={
|
|
57
|
+
<LazyRouteSurfaceDemo>
|
|
58
|
+
<DemoHome />
|
|
59
|
+
</LazyRouteSurfaceDemo>
|
|
60
|
+
}
|
|
61
|
+
>
|
|
62
|
+
<Route
|
|
63
|
+
path="drawer"
|
|
64
|
+
element={
|
|
65
|
+
<LazyRouteSurfaceDemo>
|
|
66
|
+
<DemoDrawer />
|
|
67
|
+
</LazyRouteSurfaceDemo>
|
|
68
|
+
}
|
|
69
|
+
>
|
|
70
|
+
<Route
|
|
71
|
+
path="second"
|
|
72
|
+
element={
|
|
73
|
+
<LazyRouteSurfaceDemo>
|
|
74
|
+
<DemoSecondDrawer />
|
|
75
|
+
</LazyRouteSurfaceDemo>
|
|
76
|
+
}
|
|
77
|
+
/>
|
|
78
|
+
</Route>
|
|
79
|
+
<Route
|
|
80
|
+
path="dialog"
|
|
81
|
+
element={
|
|
82
|
+
<LazyRouteSurfaceDemo>
|
|
83
|
+
<DemoDialog />
|
|
84
|
+
</LazyRouteSurfaceDemo>
|
|
85
|
+
}
|
|
86
|
+
/>
|
|
87
|
+
</Route>
|
|
88
|
+
<Route
|
|
89
|
+
key="route-surfaces-page"
|
|
90
|
+
path="/route-surfaces/page"
|
|
91
|
+
element={
|
|
92
|
+
<LazyRouteSurfaceDemo>
|
|
93
|
+
<DemoPage />
|
|
94
|
+
</LazyRouteSurfaceDemo>
|
|
95
|
+
}
|
|
96
|
+
>
|
|
97
|
+
<Route
|
|
98
|
+
path="drawer"
|
|
99
|
+
element={
|
|
100
|
+
<LazyRouteSurfaceDemo>
|
|
101
|
+
<DemoPageDrawer />
|
|
102
|
+
</LazyRouteSurfaceDemo>
|
|
103
|
+
}
|
|
104
|
+
>
|
|
105
|
+
<Route
|
|
106
|
+
path="dialog"
|
|
107
|
+
element={
|
|
108
|
+
<LazyRouteSurfaceDemo>
|
|
109
|
+
<DemoPageDrawerDialog />
|
|
110
|
+
</LazyRouteSurfaceDemo>
|
|
111
|
+
}
|
|
112
|
+
/>
|
|
113
|
+
</Route>
|
|
114
|
+
</Route>
|
|
115
|
+
</>
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default routeSurfacesExtension;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./route-dialog";
|
|
2
|
+
export * from "./route-drawer";
|
|
3
|
+
export * from "./route-page";
|
|
4
|
+
export * from "./route-surface-context";
|
|
5
|
+
export * from "./use-refine-unsaved-changes";
|
|
6
|
+
export * from "./use-route-surface-close";
|
|
7
|
+
export type { RouteSurfaceBeforeClose } from "./use-route-surface-state";
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { type ReactNode, useCallback, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import {
|
|
8
|
+
RouteOverlayDepthContext,
|
|
9
|
+
RouteSurfaceContext,
|
|
10
|
+
} from "./route-surface-context";
|
|
11
|
+
import {
|
|
12
|
+
type RouteSurfaceBeforeClose,
|
|
13
|
+
useRouteSurfaceState,
|
|
14
|
+
} from "./use-route-surface-state";
|
|
15
|
+
|
|
16
|
+
export function RouteDialog({
|
|
17
|
+
title,
|
|
18
|
+
description,
|
|
19
|
+
actions,
|
|
20
|
+
children,
|
|
21
|
+
closeLabel,
|
|
22
|
+
closeTo,
|
|
23
|
+
beforeClose,
|
|
24
|
+
className,
|
|
25
|
+
nested,
|
|
26
|
+
}: {
|
|
27
|
+
title: ReactNode;
|
|
28
|
+
description?: ReactNode;
|
|
29
|
+
actions?: ReactNode;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
closeLabel: string;
|
|
32
|
+
closeTo: string;
|
|
33
|
+
beforeClose?: RouteSurfaceBeforeClose;
|
|
34
|
+
className?: string;
|
|
35
|
+
nested?: ReactNode;
|
|
36
|
+
}) {
|
|
37
|
+
const parentOverlayDepth = useContext(RouteOverlayDepthContext);
|
|
38
|
+
const isNestedOverlay = parentOverlayDepth > 0;
|
|
39
|
+
const { open, setOpen, close, navigateAfterClose } = useRouteSurfaceState({
|
|
40
|
+
closeTo,
|
|
41
|
+
beforeClose,
|
|
42
|
+
animated: true,
|
|
43
|
+
});
|
|
44
|
+
const handleOpenChange = useCallback(
|
|
45
|
+
(nextOpen: boolean, eventDetails: DialogPrimitive.Root.ChangeEventDetails) => {
|
|
46
|
+
if (nextOpen) {
|
|
47
|
+
setOpen(true);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
eventDetails.cancel();
|
|
52
|
+
void close();
|
|
53
|
+
},
|
|
54
|
+
[close, setOpen]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<RouteSurfaceContext.Provider value={close}>
|
|
59
|
+
<RouteOverlayDepthContext.Provider value={parentOverlayDepth + 1}>
|
|
60
|
+
<DialogPrimitive.Root
|
|
61
|
+
open={open}
|
|
62
|
+
onOpenChange={handleOpenChange}
|
|
63
|
+
onOpenChangeComplete={(nextOpen) => {
|
|
64
|
+
if (!nextOpen) navigateAfterClose();
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<DialogPrimitive.Portal>
|
|
68
|
+
{isNestedOverlay ? (
|
|
69
|
+
<RouteDialogBackdrop open={open} onClose={close} />
|
|
70
|
+
) : (
|
|
71
|
+
<DialogPrimitive.Backdrop
|
|
72
|
+
className="fixed inset-0 isolate z-50 bg-black/10 duration-150 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
|
|
73
|
+
onClick={(event) => {
|
|
74
|
+
event.stopPropagation();
|
|
75
|
+
void close();
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
<DialogPrimitive.Popup
|
|
80
|
+
className={cn(
|
|
81
|
+
"fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-popover text-sm text-popover-foreground ring-1 ring-foreground/10 duration-150 outline-none sm:max-w-2xl data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
82
|
+
className
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
<header className="relative shrink-0 border-b px-5 py-4 pr-14 text-left">
|
|
86
|
+
<div className="flex items-start justify-between gap-4">
|
|
87
|
+
<div className="min-w-0 space-y-1">
|
|
88
|
+
<DialogPrimitive.Title className="font-heading truncate text-lg font-medium">
|
|
89
|
+
{title}
|
|
90
|
+
</DialogPrimitive.Title>
|
|
91
|
+
{description ? (
|
|
92
|
+
<DialogPrimitive.Description className="text-sm text-muted-foreground">
|
|
93
|
+
{description}
|
|
94
|
+
</DialogPrimitive.Description>
|
|
95
|
+
) : null}
|
|
96
|
+
</div>
|
|
97
|
+
{actions ? (
|
|
98
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
99
|
+
{actions}
|
|
100
|
+
</div>
|
|
101
|
+
) : null}
|
|
102
|
+
</div>
|
|
103
|
+
<Button
|
|
104
|
+
type="button"
|
|
105
|
+
variant="ghost"
|
|
106
|
+
size="icon-sm"
|
|
107
|
+
className="absolute top-3 right-3"
|
|
108
|
+
onClick={() => void close()}
|
|
109
|
+
>
|
|
110
|
+
<X />
|
|
111
|
+
<span className="sr-only">{closeLabel}</span>
|
|
112
|
+
</Button>
|
|
113
|
+
</header>
|
|
114
|
+
{children}
|
|
115
|
+
</DialogPrimitive.Popup>
|
|
116
|
+
</DialogPrimitive.Portal>
|
|
117
|
+
{nested}
|
|
118
|
+
</DialogPrimitive.Root>
|
|
119
|
+
</RouteOverlayDepthContext.Provider>
|
|
120
|
+
</RouteSurfaceContext.Provider>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function RouteDialogBackdrop({
|
|
125
|
+
open,
|
|
126
|
+
onClose,
|
|
127
|
+
}: {
|
|
128
|
+
open: boolean;
|
|
129
|
+
onClose: () => Promise<boolean>;
|
|
130
|
+
}) {
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
role="presentation"
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
className={cn(
|
|
136
|
+
"fixed inset-0 isolate z-50 bg-black/10 transition-opacity duration-150 supports-backdrop-filter:backdrop-blur-xs",
|
|
137
|
+
open ? "opacity-100" : "pointer-events-none opacity-0"
|
|
138
|
+
)}
|
|
139
|
+
onClick={(event) => {
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
void onClose();
|
|
142
|
+
}}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
import {
|
|
13
|
+
RouteOverlayDepthContext,
|
|
14
|
+
RouteSurfaceContext,
|
|
15
|
+
} from "./route-surface-context";
|
|
16
|
+
import {
|
|
17
|
+
type RouteSurfaceBeforeClose,
|
|
18
|
+
useRouteSurfaceState,
|
|
19
|
+
} from "./use-route-surface-state";
|
|
20
|
+
|
|
21
|
+
export function RouteDrawer({
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
actions,
|
|
25
|
+
children,
|
|
26
|
+
closeLabel,
|
|
27
|
+
closeTo,
|
|
28
|
+
beforeClose,
|
|
29
|
+
className,
|
|
30
|
+
nested,
|
|
31
|
+
stackOffset = "4rem",
|
|
32
|
+
}: {
|
|
33
|
+
title: ReactNode;
|
|
34
|
+
description?: ReactNode;
|
|
35
|
+
actions?: ReactNode;
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
closeLabel: string;
|
|
38
|
+
closeTo: string;
|
|
39
|
+
beforeClose?: RouteSurfaceBeforeClose;
|
|
40
|
+
className?: string;
|
|
41
|
+
nested?: ReactNode;
|
|
42
|
+
stackOffset?: string;
|
|
43
|
+
}) {
|
|
44
|
+
const parentOverlayDepth = useContext(RouteOverlayDepthContext);
|
|
45
|
+
const isNestedOverlay = parentOverlayDepth > 0;
|
|
46
|
+
const { open, setOpen, close, navigateAfterClose } = useRouteSurfaceState({
|
|
47
|
+
closeTo,
|
|
48
|
+
beforeClose,
|
|
49
|
+
animated: true,
|
|
50
|
+
});
|
|
51
|
+
const handleOpenChange = useCallback(
|
|
52
|
+
(nextOpen: boolean, eventDetails: DrawerPrimitive.Root.ChangeEventDetails) => {
|
|
53
|
+
if (nextOpen) {
|
|
54
|
+
setOpen(true);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
eventDetails.cancel();
|
|
59
|
+
void close();
|
|
60
|
+
},
|
|
61
|
+
[close, setOpen]
|
|
62
|
+
);
|
|
63
|
+
const stackStyle = {
|
|
64
|
+
"--peek": stackOffset,
|
|
65
|
+
"--stack-step": 0,
|
|
66
|
+
"--bleed": "0px",
|
|
67
|
+
} as CSSProperties;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<RouteSurfaceContext.Provider value={close}>
|
|
71
|
+
<RouteOverlayDepthContext.Provider value={parentOverlayDepth + 1}>
|
|
72
|
+
<DrawerPrimitive.Root
|
|
73
|
+
open={open}
|
|
74
|
+
swipeDirection="right"
|
|
75
|
+
onOpenChange={handleOpenChange}
|
|
76
|
+
onOpenChangeComplete={(nextOpen) => {
|
|
77
|
+
if (!nextOpen) navigateAfterClose();
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<DrawerPrimitive.Portal>
|
|
81
|
+
{isNestedOverlay ? (
|
|
82
|
+
<RouteDrawerBackdrop open={open} onClose={close} />
|
|
83
|
+
) : (
|
|
84
|
+
<DrawerPrimitive.Backdrop
|
|
85
|
+
className="fixed inset-0 z-50 min-h-dvh bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs"
|
|
86
|
+
onClick={(event) => {
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
void close();
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
<DrawerPrimitive.Viewport className="pointer-events-none fixed inset-0 z-50 select-none">
|
|
93
|
+
<DrawerPrimitive.Popup
|
|
94
|
+
style={stackStyle}
|
|
95
|
+
className={cn(
|
|
96
|
+
"group/route-drawer pointer-events-auto fixed inset-y-0 right-0 z-50 flex w-full transform-[translate3d(var(--translate-x,0px),0,0)] flex-row bg-popover text-sm text-popover-foreground shadow-xl transition-transform duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none sm:w-full lg:w-[42vw] lg:min-w-[40rem]",
|
|
97
|
+
"[--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)]",
|
|
98
|
+
"[--closed-transform:translate3d(calc(100%+2px),0,0)] [--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset))]",
|
|
99
|
+
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-nested-drawer-swiping:duration-0",
|
|
100
|
+
"data-nested-drawer-open:overflow-hidden",
|
|
101
|
+
className
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
<div
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
className="pointer-events-none absolute inset-0 z-20 bg-black/10 opacity-0 transition-opacity duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-nested-drawer-open/route-drawer:opacity-100 supports-backdrop-filter:backdrop-blur-xs"
|
|
107
|
+
/>
|
|
108
|
+
<DrawerPrimitive.Content className="flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain select-text group-data-swiping/route-drawer:select-none">
|
|
109
|
+
<header className="relative shrink-0 border-b px-5 py-4 pr-14 text-left">
|
|
110
|
+
<div className="flex items-start justify-between gap-4">
|
|
111
|
+
<div className="min-w-0 space-y-1">
|
|
112
|
+
<DrawerPrimitive.Title className="font-heading truncate text-lg font-medium text-foreground">
|
|
113
|
+
{title}
|
|
114
|
+
</DrawerPrimitive.Title>
|
|
115
|
+
{description ? (
|
|
116
|
+
<DrawerPrimitive.Description className="text-sm text-balance text-muted-foreground">
|
|
117
|
+
{description}
|
|
118
|
+
</DrawerPrimitive.Description>
|
|
119
|
+
) : null}
|
|
120
|
+
</div>
|
|
121
|
+
{actions ? (
|
|
122
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
123
|
+
{actions}
|
|
124
|
+
</div>
|
|
125
|
+
) : null}
|
|
126
|
+
</div>
|
|
127
|
+
<Button
|
|
128
|
+
type="button"
|
|
129
|
+
variant="ghost"
|
|
130
|
+
size="icon-sm"
|
|
131
|
+
className="absolute top-3 right-3"
|
|
132
|
+
onClick={() => void close()}
|
|
133
|
+
>
|
|
134
|
+
<X />
|
|
135
|
+
<span className="sr-only">{closeLabel}</span>
|
|
136
|
+
</Button>
|
|
137
|
+
</header>
|
|
138
|
+
{children}
|
|
139
|
+
</DrawerPrimitive.Content>
|
|
140
|
+
</DrawerPrimitive.Popup>
|
|
141
|
+
</DrawerPrimitive.Viewport>
|
|
142
|
+
</DrawerPrimitive.Portal>
|
|
143
|
+
{nested}
|
|
144
|
+
</DrawerPrimitive.Root>
|
|
145
|
+
</RouteOverlayDepthContext.Provider>
|
|
146
|
+
</RouteSurfaceContext.Provider>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function RouteDrawerBackdrop({
|
|
151
|
+
open,
|
|
152
|
+
onClose,
|
|
153
|
+
}: {
|
|
154
|
+
open: boolean;
|
|
155
|
+
onClose: () => Promise<boolean>;
|
|
156
|
+
}) {
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
role="presentation"
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
className={cn(
|
|
162
|
+
"fixed inset-0 z-50 min-h-dvh bg-black/10 transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none supports-backdrop-filter:backdrop-blur-xs",
|
|
163
|
+
open ? "opacity-100" : "pointer-events-none opacity-0"
|
|
164
|
+
)}
|
|
165
|
+
onClick={(event) => {
|
|
166
|
+
event.stopPropagation();
|
|
167
|
+
void onClose();
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function RouteDrawerFooter({
|
|
174
|
+
className,
|
|
175
|
+
...props
|
|
176
|
+
}: React.ComponentProps<"div">) {
|
|
177
|
+
return (
|
|
178
|
+
<div
|
|
179
|
+
className={cn(
|
|
180
|
+
"mt-auto flex shrink-0 flex-col gap-2 border-t px-5 py-4",
|
|
181
|
+
className
|
|
182
|
+
)}
|
|
183
|
+
{...props}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { RouteSurfaceContext } from "./route-surface-context";
|
|
4
|
+
import {
|
|
5
|
+
type RouteSurfaceBeforeClose,
|
|
6
|
+
useRouteSurfaceState,
|
|
7
|
+
} from "./use-route-surface-state";
|
|
8
|
+
|
|
9
|
+
export function RoutePage({
|
|
10
|
+
children,
|
|
11
|
+
closeTo,
|
|
12
|
+
beforeClose,
|
|
13
|
+
}: {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
closeTo: string;
|
|
16
|
+
beforeClose?: RouteSurfaceBeforeClose;
|
|
17
|
+
}) {
|
|
18
|
+
const { close } = useRouteSurfaceState({
|
|
19
|
+
closeTo,
|
|
20
|
+
beforeClose,
|
|
21
|
+
animated: false,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<RouteSurfaceContext.Provider value={close}>
|
|
26
|
+
{children}
|
|
27
|
+
</RouteSurfaceContext.Provider>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
|
|
3
|
+
export type RouteSurfaceCloseOptions = {
|
|
4
|
+
skipBeforeClose?: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type RouteSurfaceClose = (
|
|
8
|
+
options?: RouteSurfaceCloseOptions
|
|
9
|
+
) => Promise<boolean>;
|
|
10
|
+
|
|
11
|
+
export const RouteSurfaceContext = createContext<RouteSurfaceClose | null>(null);
|
|
12
|
+
|
|
13
|
+
export const RouteOverlayDepthContext = createContext(0);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useTranslate, useWarnAboutChange } from "@refinedev/core";
|
|
2
|
+
import { TriangleAlert } from "lucide-react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AlertDialog,
|
|
7
|
+
AlertDialogAction,
|
|
8
|
+
AlertDialogCancel,
|
|
9
|
+
AlertDialogContent,
|
|
10
|
+
AlertDialogDescription,
|
|
11
|
+
AlertDialogFooter,
|
|
12
|
+
AlertDialogHeader,
|
|
13
|
+
AlertDialogMedia,
|
|
14
|
+
AlertDialogTitle,
|
|
15
|
+
} from "@/components/ui/alert-dialog";
|
|
16
|
+
|
|
17
|
+
type PendingConfirmation = {
|
|
18
|
+
promise: Promise<boolean>;
|
|
19
|
+
resolve: (allowed: boolean) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function useRefineUnsavedChangesGuard() {
|
|
23
|
+
const translate = useTranslate();
|
|
24
|
+
const { warnWhen, setWarnWhen } = useWarnAboutChange();
|
|
25
|
+
const [open, setOpen] = useState(false);
|
|
26
|
+
const pendingRef = useRef<PendingConfirmation | null>(null);
|
|
27
|
+
|
|
28
|
+
const settle = useCallback(
|
|
29
|
+
(allowed: boolean) => {
|
|
30
|
+
const pending = pendingRef.current;
|
|
31
|
+
if (!pending) return;
|
|
32
|
+
|
|
33
|
+
pendingRef.current = null;
|
|
34
|
+
if (allowed) setWarnWhen(false);
|
|
35
|
+
setOpen(false);
|
|
36
|
+
pending.resolve(allowed);
|
|
37
|
+
},
|
|
38
|
+
[setWarnWhen]
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
useEffect(
|
|
42
|
+
() => () => {
|
|
43
|
+
pendingRef.current?.resolve(false);
|
|
44
|
+
pendingRef.current = null;
|
|
45
|
+
},
|
|
46
|
+
[]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const beforeClose = useCallback(() => {
|
|
50
|
+
if (!warnWhen) return true;
|
|
51
|
+
if (pendingRef.current) return pendingRef.current.promise;
|
|
52
|
+
|
|
53
|
+
let resolve!: (allowed: boolean) => void;
|
|
54
|
+
const promise = new Promise<boolean>((next) => {
|
|
55
|
+
resolve = next;
|
|
56
|
+
});
|
|
57
|
+
pendingRef.current = { promise, resolve };
|
|
58
|
+
setOpen(true);
|
|
59
|
+
return promise;
|
|
60
|
+
}, [warnWhen]);
|
|
61
|
+
|
|
62
|
+
const confirmation = useMemo(
|
|
63
|
+
() => (
|
|
64
|
+
<AlertDialog
|
|
65
|
+
open={open}
|
|
66
|
+
onOpenChange={(nextOpen) => {
|
|
67
|
+
if (!nextOpen) settle(false);
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<AlertDialogContent>
|
|
71
|
+
<AlertDialogHeader>
|
|
72
|
+
<AlertDialogMedia>
|
|
73
|
+
<TriangleAlert />
|
|
74
|
+
</AlertDialogMedia>
|
|
75
|
+
<AlertDialogTitle>
|
|
76
|
+
{translate(
|
|
77
|
+
"unsavedChanges.title",
|
|
78
|
+
"Discard unsaved changes?"
|
|
79
|
+
)}
|
|
80
|
+
</AlertDialogTitle>
|
|
81
|
+
<AlertDialogDescription>
|
|
82
|
+
{translate(
|
|
83
|
+
"unsavedChanges.description",
|
|
84
|
+
"Your changes have not been saved. Leaving now will discard them."
|
|
85
|
+
)}
|
|
86
|
+
</AlertDialogDescription>
|
|
87
|
+
</AlertDialogHeader>
|
|
88
|
+
<AlertDialogFooter>
|
|
89
|
+
<AlertDialogCancel onClick={() => settle(false)}>
|
|
90
|
+
{translate("unsavedChanges.stay", "Keep editing")}
|
|
91
|
+
</AlertDialogCancel>
|
|
92
|
+
<AlertDialogAction
|
|
93
|
+
variant="destructive"
|
|
94
|
+
onClick={() => settle(true)}
|
|
95
|
+
>
|
|
96
|
+
{translate("unsavedChanges.leave", "Discard changes")}
|
|
97
|
+
</AlertDialogAction>
|
|
98
|
+
</AlertDialogFooter>
|
|
99
|
+
</AlertDialogContent>
|
|
100
|
+
</AlertDialog>
|
|
101
|
+
),
|
|
102
|
+
[open, settle, translate]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return { beforeClose, confirmation };
|
|
106
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
|
|
3
|
+
import { RouteSurfaceContext } from "./route-surface-context";
|
|
4
|
+
|
|
5
|
+
export function useRouteSurfaceClose() {
|
|
6
|
+
const close = useContext(RouteSurfaceContext);
|
|
7
|
+
|
|
8
|
+
if (!close) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"useRouteSurfaceClose must be used inside a RouteDrawer, RouteDialog, or RoutePage."
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return close;
|
|
15
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
RouteSurfaceClose,
|
|
6
|
+
RouteSurfaceCloseOptions,
|
|
7
|
+
} from "./route-surface-context";
|
|
8
|
+
|
|
9
|
+
export type RouteSurfaceBeforeClose = () => boolean | Promise<boolean>;
|
|
10
|
+
|
|
11
|
+
export function useRouteSurfaceState({
|
|
12
|
+
closeTo,
|
|
13
|
+
beforeClose,
|
|
14
|
+
animated,
|
|
15
|
+
}: {
|
|
16
|
+
closeTo: string;
|
|
17
|
+
beforeClose?: RouteSurfaceBeforeClose;
|
|
18
|
+
animated: boolean;
|
|
19
|
+
}) {
|
|
20
|
+
const navigate = useNavigate();
|
|
21
|
+
const [open, setOpen] = useState(!animated);
|
|
22
|
+
const closingRef = useRef(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!animated) return;
|
|
26
|
+
|
|
27
|
+
const frame = requestAnimationFrame(() => setOpen(true));
|
|
28
|
+
return () => cancelAnimationFrame(frame);
|
|
29
|
+
}, [animated]);
|
|
30
|
+
|
|
31
|
+
const navigateAfterClose = useCallback(() => {
|
|
32
|
+
navigate(closeTo, { replace: true });
|
|
33
|
+
}, [closeTo, navigate]);
|
|
34
|
+
|
|
35
|
+
const close = useCallback<RouteSurfaceClose>(
|
|
36
|
+
async (options?: RouteSurfaceCloseOptions) => {
|
|
37
|
+
if (closingRef.current) return false;
|
|
38
|
+
|
|
39
|
+
closingRef.current = true;
|
|
40
|
+
try {
|
|
41
|
+
if (!options?.skipBeforeClose && beforeClose) {
|
|
42
|
+
const allowed = await beforeClose();
|
|
43
|
+
if (!allowed) return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (animated) {
|
|
47
|
+
setOpen(false);
|
|
48
|
+
} else {
|
|
49
|
+
navigateAfterClose();
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
} finally {
|
|
53
|
+
closingRef.current = false;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
[animated, beforeClose, navigateAfterClose]
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
open,
|
|
61
|
+
setOpen,
|
|
62
|
+
close,
|
|
63
|
+
navigateAfterClose,
|
|
64
|
+
};
|
|
65
|
+
}
|