zudoku 0.1.1-dev.13 → 0.1.1-dev.15
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/dist/app/main.js +2 -2
- package/dist/app/main.js.map +1 -1
- package/dist/config/config.d.ts +0 -1
- package/dist/vite/build.js +2 -8
- package/dist/vite/build.js.map +1 -1
- package/dist/vite/config.d.ts +9 -3
- package/dist/vite/config.js +24 -22
- package/dist/vite/config.js.map +1 -1
- package/dist/vite/dev-server.js +6 -3
- package/dist/vite/dev-server.js.map +1 -1
- package/dist/vite/plugin-api.js +1 -1
- package/dist/vite/plugin-api.js.map +1 -1
- package/dist/vite/plugin-auth.js +1 -2
- package/dist/vite/plugin-auth.js.map +1 -1
- package/dist/vite/plugin-docs.js +1 -1
- package/dist/vite/plugin-docs.js.map +1 -1
- package/dist/vite/plugin-docs.test.js +0 -1
- package/dist/vite/plugin-docs.test.js.map +1 -1
- package/package.json +10 -5
- package/src/app/App.tsx +30 -0
- package/src/app/DevPortal.tsx +93 -0
- package/src/app/Heading.tsx +60 -0
- package/src/app/Router.tsx +28 -0
- package/src/app/authentication/authentication.ts +18 -0
- package/src/app/authentication/clerk.ts +47 -0
- package/src/app/authentication/openid.ts +192 -0
- package/src/app/components/AnchorLink.tsx +19 -0
- package/src/app/components/CategoryHeading.tsx +16 -0
- package/src/app/components/Dialog.tsx +119 -0
- package/src/app/components/DynamicIcon.tsx +60 -0
- package/src/app/components/Header.tsx +69 -0
- package/src/app/components/Input.tsx +24 -0
- package/src/app/components/Layout.tsx +56 -0
- package/src/app/components/Markdown.tsx +37 -0
- package/src/app/components/SyntaxHighlight.tsx +94 -0
- package/src/app/components/TopNavigation.tsx +32 -0
- package/src/app/components/context/ComponentsContext.tsx +24 -0
- package/src/app/components/context/DevPortalProvider.ts +54 -0
- package/src/app/components/context/PluginSystem.ts +0 -0
- package/src/app/components/context/ThemeContext.tsx +46 -0
- package/src/app/components/context/ViewportAnchorContext.tsx +139 -0
- package/src/app/components/navigation/SideNavigation.tsx +18 -0
- package/src/app/components/navigation/SideNavigationCategory.tsx +74 -0
- package/src/app/components/navigation/SideNavigationItem.tsx +143 -0
- package/src/app/components/navigation/SideNavigationWrapper.tsx +15 -0
- package/src/app/components/navigation/useNavigationCollapsibleState.ts +27 -0
- package/src/app/components/navigation/util.ts +38 -0
- package/src/app/core/DevPortalContext.ts +164 -0
- package/src/app/core/helmet.ts +5 -0
- package/src/app/core/icons.tsx +1 -0
- package/src/app/core/plugins.ts +43 -0
- package/src/app/core/router.tsx +1 -0
- package/src/app/core/types/combine.ts +16 -0
- package/src/app/main.css +137 -0
- package/src/app/main.tsx +9 -0
- package/src/app/oas/graphql/index.ts +422 -0
- package/src/app/oas/graphql/server.ts +10 -0
- package/src/app/oas/parser/dereference/index.ts +59 -0
- package/src/app/oas/parser/dereference/resolveRef.ts +32 -0
- package/src/app/oas/parser/index.ts +94 -0
- package/src/app/oas/parser/schemas/v3.0.json +1489 -0
- package/src/app/oas/parser/schemas/v3.1.json +1298 -0
- package/src/app/oas/parser/upgrade/index.ts +108 -0
- package/src/app/plugins/api-key/SettingsApiKeys.tsx +22 -0
- package/src/app/plugins/api-key/index.tsx +123 -0
- package/src/app/plugins/markdown/MdxPage.tsx +128 -0
- package/src/app/plugins/markdown/Toc.tsx +122 -0
- package/src/app/plugins/markdown/generateRoutes.tsx +72 -0
- package/src/app/plugins/markdown/index.tsx +31 -0
- package/src/app/plugins/openapi/ColorizedParam.tsx +82 -0
- package/src/app/plugins/openapi/MakeRequest.tsx +49 -0
- package/src/app/plugins/openapi/MethodBadge.tsx +36 -0
- package/src/app/plugins/openapi/OperationList.tsx +117 -0
- package/src/app/plugins/openapi/OperationListItem.tsx +55 -0
- package/src/app/plugins/openapi/ParameterList.tsx +32 -0
- package/src/app/plugins/openapi/ParameterListItem.tsx +60 -0
- package/src/app/plugins/openapi/RequestBodySidecarBox.tsx +51 -0
- package/src/app/plugins/openapi/ResponsesSidecarBox.tsx +60 -0
- package/src/app/plugins/openapi/Select.tsx +35 -0
- package/src/app/plugins/openapi/Sidecar.tsx +160 -0
- package/src/app/plugins/openapi/SidecarBox.tsx +36 -0
- package/src/app/plugins/openapi/graphql/fragment-masking.ts +111 -0
- package/src/app/plugins/openapi/graphql/gql.ts +70 -0
- package/src/app/plugins/openapi/graphql/graphql.ts +795 -0
- package/src/app/plugins/openapi/graphql/index.ts +2 -0
- package/src/app/plugins/openapi/index.tsx +142 -0
- package/src/app/plugins/openapi/playground/Playground.tsx +309 -0
- package/src/app/plugins/openapi/queries.graphql +6 -0
- package/src/app/plugins/openapi/util/generateSchemaExample.ts +59 -0
- package/src/app/plugins/openapi/util/urql.ts +8 -0
- package/src/app/plugins/openapi/worker/createSharedWorkerClient.ts +60 -0
- package/src/app/plugins/openapi/worker/worker.ts +30 -0
- package/src/app/plugins/redirect/index.tsx +20 -0
- package/src/app/tailwind.ts +72 -0
- package/src/app/ui/Button.tsx +56 -0
- package/src/app/ui/Callout.tsx +87 -0
- package/src/app/ui/Card.tsx +82 -0
- package/src/app/ui/Note.tsx +58 -0
- package/src/app/ui/Tabs.tsx +52 -0
- package/src/app/util/MdxComponents.tsx +70 -0
- package/src/app/util/cn.ts +6 -0
- package/src/app/util/createVariantComponent.tsx +30 -0
- package/src/app/util/createWaitForNotify.ts +18 -0
- package/src/app/util/groupBy.ts +24 -0
- package/src/app/util/joinPath.tsx +10 -0
- package/src/app/util/pastellize.ts +25 -0
- package/src/app/util/slugify.ts +3 -0
- package/src/app/util/traverseNavigation.ts +55 -0
- package/src/app/util/useScrollToAnchor.ts +38 -0
- package/src/app/util/useScrollToTop.ts +13 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cx } from "class-variance-authority";
|
|
2
|
+
import { NavLink } from "react-router-dom";
|
|
3
|
+
|
|
4
|
+
import { useDevPortal } from "./context/DevPortalProvider.js";
|
|
5
|
+
|
|
6
|
+
export const TopNavigation = () => {
|
|
7
|
+
const { navigation } = useDevPortal();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<nav className="border-b border-border text-sm px-12 h-[--top-nav-height]">
|
|
11
|
+
<ul className="flex flex-row items-center gap-8">
|
|
12
|
+
{navigation.map((item) => (
|
|
13
|
+
<li key={item.label}>
|
|
14
|
+
<NavLink
|
|
15
|
+
className={({ isActive }) =>
|
|
16
|
+
cx(
|
|
17
|
+
"block py-3.5 font-medium -mb-px border-b-2",
|
|
18
|
+
isActive
|
|
19
|
+
? "border-primary text-foreground"
|
|
20
|
+
: "border-transparent text-foreground/75 hover:text-foreground hover:border-accent-foreground/25",
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
to={item.path}
|
|
24
|
+
>
|
|
25
|
+
{item.label}
|
|
26
|
+
</NavLink>
|
|
27
|
+
</li>
|
|
28
|
+
))}
|
|
29
|
+
</ul>
|
|
30
|
+
</nav>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
type ComponentProps,
|
|
5
|
+
type ComponentType,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { Header } from "../Header.js";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_COMPONENTS = {
|
|
10
|
+
Header,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ComponentsContextType = {
|
|
14
|
+
Header?: ComponentType<ComponentProps<typeof Header>>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ComponentsContext =
|
|
18
|
+
createContext<Required<ComponentsContextType>>(DEFAULT_COMPONENTS);
|
|
19
|
+
|
|
20
|
+
export const ComponentsProvider = ComponentsContext.Provider;
|
|
21
|
+
|
|
22
|
+
export const useComponents = () => {
|
|
23
|
+
return useContext(ComponentsContext);
|
|
24
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useSuspenseQuery } from "@tanstack/react-query";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
import { matchPath, useLocation } from "react-router-dom";
|
|
4
|
+
import { DevPortalContext } from "../../core/DevPortalContext.js";
|
|
5
|
+
|
|
6
|
+
const DevPortalReactContext = createContext<DevPortalContext | undefined>(
|
|
7
|
+
undefined,
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export const DevPortalProvider = DevPortalReactContext.Provider;
|
|
11
|
+
|
|
12
|
+
export const useDevPortal = () => {
|
|
13
|
+
const context = useContext(DevPortalReactContext);
|
|
14
|
+
|
|
15
|
+
if (!context) {
|
|
16
|
+
throw new Error("useDevPortal must be used within a DevPortalProvider.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return context;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const useApiIdentities = () => {
|
|
23
|
+
const { getApiIdentities } = useDevPortal();
|
|
24
|
+
return useSuspenseQuery({
|
|
25
|
+
queryFn: getApiIdentities,
|
|
26
|
+
queryKey: ["api-keys"],
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const useTopNavigationItem = () => {
|
|
31
|
+
const { navigation } = useDevPortal();
|
|
32
|
+
const location = useLocation();
|
|
33
|
+
|
|
34
|
+
return navigation.find((item) =>
|
|
35
|
+
matchPath({ path: item.path, end: false }, location.pathname),
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const useNavigation = () => {
|
|
40
|
+
const { getNavigation } = useDevPortal();
|
|
41
|
+
const navItem = useTopNavigationItem();
|
|
42
|
+
|
|
43
|
+
const path = navItem?.path ?? "";
|
|
44
|
+
|
|
45
|
+
return useSuspenseQuery({
|
|
46
|
+
queryFn: async () => {
|
|
47
|
+
return {
|
|
48
|
+
items: [...(navItem?.categories ?? []), ...(await getNavigation(path))],
|
|
49
|
+
currentTopNavItem: navItem,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
queryKey: ["navigation", path],
|
|
53
|
+
});
|
|
54
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useState,
|
|
7
|
+
useContext,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
const ThemeContext = createContext<readonly [boolean, () => void]>([
|
|
11
|
+
false,
|
|
12
|
+
() => {},
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export const useTheme = () => {
|
|
16
|
+
const context = useContext(ThemeContext);
|
|
17
|
+
if (!context) {
|
|
18
|
+
throw new Error("useTheme must be used within a ThemeProvider");
|
|
19
|
+
}
|
|
20
|
+
return context;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const ThemeProvider = (props: { children: ReactNode }) => {
|
|
24
|
+
const [dark, setDark] = useState(false);
|
|
25
|
+
|
|
26
|
+
// On mount, read the preferred theme from the persistence
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const theme = localStorage.getItem("theme");
|
|
29
|
+
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
|
30
|
+
const isDark = theme === "dark" || (!theme && prefersDark.matches);
|
|
31
|
+
|
|
32
|
+
setDark(isDark);
|
|
33
|
+
}, [dark]);
|
|
34
|
+
|
|
35
|
+
// To toggle between dark and light modes
|
|
36
|
+
const toggle = useCallback(() => {
|
|
37
|
+
const toggled = !dark;
|
|
38
|
+
document.documentElement.classList.toggle("dark", toggled);
|
|
39
|
+
localStorage.setItem("theme", toggled ? "dark" : "light");
|
|
40
|
+
setDark(toggled);
|
|
41
|
+
}, [dark]);
|
|
42
|
+
|
|
43
|
+
const value = [dark, toggle] as const;
|
|
44
|
+
|
|
45
|
+
return <ThemeContext.Provider value={value} {...props} />;
|
|
46
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactNode,
|
|
3
|
+
createContext,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
type AnchorContextType = {
|
|
13
|
+
activeAnchor?: string;
|
|
14
|
+
setActiveAnchor: (anchor: string) => void;
|
|
15
|
+
observe: (element: HTMLElement | null) => void;
|
|
16
|
+
unobserve: (element: HTMLElement | null) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ViewportAnchorContext = createContext<AnchorContextType | undefined>(
|
|
20
|
+
undefined,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const useViewportAnchor = () => {
|
|
24
|
+
const context = useContext(ViewportAnchorContext);
|
|
25
|
+
|
|
26
|
+
if (!context) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"useViewportAnchor must be used within a CurrentAnchorProvider",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return context;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const useRegisterAnchorElement = () => {
|
|
36
|
+
const elementRef = useRef<HTMLElement | null>(null);
|
|
37
|
+
|
|
38
|
+
const { observe, unobserve } = useViewportAnchor();
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const element = elementRef.current;
|
|
42
|
+
|
|
43
|
+
if (!element) return;
|
|
44
|
+
|
|
45
|
+
observe(element);
|
|
46
|
+
|
|
47
|
+
return () => unobserve(element);
|
|
48
|
+
}, [observe, unobserve]);
|
|
49
|
+
|
|
50
|
+
const setRef = useCallback((el: HTMLElement | null) => {
|
|
51
|
+
if (!el) return;
|
|
52
|
+
elementRef.current = el;
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
return { ref: setRef };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const ViewportAnchorProvider = ({
|
|
59
|
+
children,
|
|
60
|
+
}: {
|
|
61
|
+
children: ReactNode;
|
|
62
|
+
}) => {
|
|
63
|
+
const [activeAnchor, setActiveAnchor] = useState("");
|
|
64
|
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
65
|
+
const registeredElements = useRef(new Set<HTMLElement>());
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
observerRef.current = new IntersectionObserver(
|
|
69
|
+
(entries) => {
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (entry.isIntersecting && entry.target.id) {
|
|
72
|
+
setActiveAnchor(entry.target.id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
// 115px is the height of the sticky header
|
|
78
|
+
// see --header-height in `main.css`
|
|
79
|
+
rootMargin: "115px 0px -80% 0px",
|
|
80
|
+
threshold: 0.75,
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return () => observerRef.current?.disconnect();
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const elements = registeredElements.current;
|
|
89
|
+
const handleScroll = () => {
|
|
90
|
+
const hasReachedTop = window.scrollY === 0;
|
|
91
|
+
const hasReachedBottom =
|
|
92
|
+
window.innerHeight + window.scrollY >= document.body.scrollHeight;
|
|
93
|
+
|
|
94
|
+
if (hasReachedTop) {
|
|
95
|
+
// reset the active anchor when we reach the top
|
|
96
|
+
setActiveAnchor("");
|
|
97
|
+
} else if (hasReachedBottom) {
|
|
98
|
+
requestIdleCallback(() => {
|
|
99
|
+
// set the last anchor when we reach the bottom
|
|
100
|
+
const lastItem = Array.from(elements).pop();
|
|
101
|
+
setActiveAnchor(lastItem?.id ?? "");
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
document.addEventListener("scroll", handleScroll);
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
elements.clear();
|
|
110
|
+
document.removeEventListener("scroll", handleScroll);
|
|
111
|
+
};
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const observeFns = useMemo(() => {
|
|
115
|
+
return {
|
|
116
|
+
observe: (element: HTMLElement | null) => {
|
|
117
|
+
if (!element || !observerRef.current) return;
|
|
118
|
+
registeredElements.current.add(element);
|
|
119
|
+
observerRef.current.observe(element);
|
|
120
|
+
},
|
|
121
|
+
unobserve: (element: HTMLElement | null) => {
|
|
122
|
+
if (!element || !observerRef.current) return;
|
|
123
|
+
registeredElements.current.delete(element);
|
|
124
|
+
observerRef.current.unobserve(element);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const value = useMemo(
|
|
130
|
+
() => ({ activeAnchor, setActiveAnchor, ...observeFns }),
|
|
131
|
+
[activeAnchor, setActiveAnchor, observeFns],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<ViewportAnchorContext.Provider value={value}>
|
|
136
|
+
{children}
|
|
137
|
+
</ViewportAnchorContext.Provider>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { useNavigation } from "../context/DevPortalProvider.js";
|
|
4
|
+
import { SideNavigationCategory } from "./SideNavigationCategory.js";
|
|
5
|
+
import { SideNavigationWrapper } from "./SideNavigationWrapper.js";
|
|
6
|
+
|
|
7
|
+
export const SideNavigation = () => {
|
|
8
|
+
const navRef = useRef<HTMLDivElement | null>(null);
|
|
9
|
+
const navigation = useNavigation();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<SideNavigationWrapper ref={navRef}>
|
|
13
|
+
{navigation.data.items.map((category) => (
|
|
14
|
+
<SideNavigationCategory key={category.label} category={category} />
|
|
15
|
+
))}
|
|
16
|
+
</SideNavigationWrapper>
|
|
17
|
+
);
|
|
18
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as Collapsible from "@radix-ui/react-collapsible";
|
|
2
|
+
import { ChevronRightIcon } from "lucide-react";
|
|
3
|
+
import { useLocation } from "react-router-dom";
|
|
4
|
+
import type { NavigationCategory } from "../../core/DevPortalContext.js";
|
|
5
|
+
import { cn } from "../../util/cn.js";
|
|
6
|
+
import { joinPath } from "../../util/joinPath.js";
|
|
7
|
+
import { useTopNavigationItem } from "../context/DevPortalProvider.js";
|
|
8
|
+
import { useViewportAnchor } from "../context/ViewportAnchorContext.js";
|
|
9
|
+
import { SideNavigationItem } from "./SideNavigationItem.js";
|
|
10
|
+
import { useNavigationCollapsibleState } from "./useNavigationCollapsibleState.js";
|
|
11
|
+
import { checkHasActiveItem, isPathItem } from "./util.js";
|
|
12
|
+
|
|
13
|
+
export const SideNavigationCategory = ({
|
|
14
|
+
category,
|
|
15
|
+
}: {
|
|
16
|
+
category: NavigationCategory;
|
|
17
|
+
}) => {
|
|
18
|
+
const { activeAnchor } = useViewportAnchor();
|
|
19
|
+
const navItem = useTopNavigationItem();
|
|
20
|
+
const location = useLocation();
|
|
21
|
+
|
|
22
|
+
const isCollapsible = category.collapsible ?? true;
|
|
23
|
+
|
|
24
|
+
const [isOpen, setIsOpen] = useNavigationCollapsibleState({
|
|
25
|
+
item: category,
|
|
26
|
+
path: navItem?.path ?? "",
|
|
27
|
+
defaultOpen: () =>
|
|
28
|
+
!isCollapsible ||
|
|
29
|
+
category.expanded ||
|
|
30
|
+
checkHasActiveItem(category, location.pathname, navItem?.path ?? ""),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Collapsible.Root
|
|
35
|
+
key={category.label}
|
|
36
|
+
open={isOpen}
|
|
37
|
+
onOpenChange={() => setIsOpen((prev) => !prev)}
|
|
38
|
+
>
|
|
39
|
+
{category.label.length > 0 && (
|
|
40
|
+
<Collapsible.Trigger asChild={isCollapsible} disabled={!isCollapsible}>
|
|
41
|
+
<h5
|
|
42
|
+
className={cn(
|
|
43
|
+
"flex group items-center justify-between cursor-pointer font-semibold text-foreground/90 px-[--padding-nav-item] py-1.5 rounded-lg transition-colors duration-300 -mx-[--padding-nav-item]",
|
|
44
|
+
isCollapsible ? "hover:bg-accent" : "cursor-auto",
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
{category.label}
|
|
48
|
+
{isCollapsible && (
|
|
49
|
+
<ChevronRightIcon
|
|
50
|
+
className="group-data-[state=open]:rotate-90 transition"
|
|
51
|
+
size={16}
|
|
52
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
</h5>
|
|
55
|
+
</Collapsible.Trigger>
|
|
56
|
+
)}
|
|
57
|
+
<Collapsible.Content className="CollapsibleContent -mx-[--padding-nav-item]">
|
|
58
|
+
{/* margin on Collapsible.Content will lead jumpiness when animating because it's not added to the calculated height */}
|
|
59
|
+
<ul className="space-y-0.5 mt-1.5 mb-4 ms-3">
|
|
60
|
+
{category.children.map((item) => (
|
|
61
|
+
<SideNavigationItem
|
|
62
|
+
key={isPathItem(item) ? item.path + item.label : item.href}
|
|
63
|
+
category={category}
|
|
64
|
+
item={item}
|
|
65
|
+
activeAnchor={activeAnchor}
|
|
66
|
+
currentTopNavItem={navItem}
|
|
67
|
+
basePath={joinPath(navItem?.path, category.path)}
|
|
68
|
+
/>
|
|
69
|
+
))}
|
|
70
|
+
</ul>
|
|
71
|
+
</Collapsible.Content>
|
|
72
|
+
</Collapsible.Root>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import * as Collapsible from "@radix-ui/react-collapsible";
|
|
2
|
+
import { cva } from "class-variance-authority";
|
|
3
|
+
import { ChevronRightIcon, ExternalLinkIcon } from "lucide-react";
|
|
4
|
+
import { NavLink, useLocation } from "react-router-dom";
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
NavigationCategory,
|
|
8
|
+
NavigationCategoryItem,
|
|
9
|
+
NavigationItem,
|
|
10
|
+
} from "../../core/DevPortalContext.js";
|
|
11
|
+
import { cn } from "../../util/cn.js";
|
|
12
|
+
import { joinPath } from "../../util/joinPath.js";
|
|
13
|
+
import { AnchorLink } from "../AnchorLink.js";
|
|
14
|
+
import { DynamicIcon, isValidIcon } from "../DynamicIcon.js";
|
|
15
|
+
import { useNavigationCollapsibleState } from "./useNavigationCollapsibleState.js";
|
|
16
|
+
import { checkHasActiveItem, isLinkItem, isPathItem } from "./util.js";
|
|
17
|
+
|
|
18
|
+
export const linkItem = cva(
|
|
19
|
+
"flex px-[--padding-nav-item] py-1.5 rounded-lg hover:bg-accent transition-colors duration-300",
|
|
20
|
+
{
|
|
21
|
+
variants: {
|
|
22
|
+
isActive: {
|
|
23
|
+
true: "text-primary font-medium",
|
|
24
|
+
false: "text-foreground/80",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const DATA_ANCHOR_ATTR = "data-anchor";
|
|
31
|
+
|
|
32
|
+
export const SideNavigationItem = ({
|
|
33
|
+
category,
|
|
34
|
+
item,
|
|
35
|
+
activeAnchor,
|
|
36
|
+
currentTopNavItem,
|
|
37
|
+
basePath = "",
|
|
38
|
+
}: {
|
|
39
|
+
category: NavigationCategory;
|
|
40
|
+
item: NavigationCategoryItem;
|
|
41
|
+
activeAnchor?: string;
|
|
42
|
+
currentTopNavItem?: NavigationItem;
|
|
43
|
+
basePath?: string;
|
|
44
|
+
}) => {
|
|
45
|
+
const currentPath = isPathItem(item) ? joinPath(basePath, item.path) : "";
|
|
46
|
+
const location = useLocation();
|
|
47
|
+
|
|
48
|
+
const [isOpen, setIsOpen] = useNavigationCollapsibleState({
|
|
49
|
+
item,
|
|
50
|
+
path: currentPath,
|
|
51
|
+
defaultOpen: () => checkHasActiveItem(item, location.pathname, currentPath),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (isLinkItem(item)) {
|
|
55
|
+
const classes = cn(
|
|
56
|
+
"flex items-center gap-2",
|
|
57
|
+
linkItem({ isActive: item.href === location.pathname }),
|
|
58
|
+
);
|
|
59
|
+
return item.href.startsWith("http") ? (
|
|
60
|
+
<a
|
|
61
|
+
className={classes}
|
|
62
|
+
href={item.href}
|
|
63
|
+
target="_blank"
|
|
64
|
+
rel="noopener noreferrer"
|
|
65
|
+
>
|
|
66
|
+
{item.label}
|
|
67
|
+
<ExternalLinkIcon size={14} />
|
|
68
|
+
</a>
|
|
69
|
+
) : (
|
|
70
|
+
<NavLink className={classes} to={item.href}>
|
|
71
|
+
{item.label}
|
|
72
|
+
</NavLink>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const linkContent = (
|
|
77
|
+
<div className="flex justify-between w-full">
|
|
78
|
+
<div className="flex items-center gap-2 truncate w-full">
|
|
79
|
+
{isValidIcon(item.icon) && <DynamicIcon name={item.icon} size={16} />}
|
|
80
|
+
{typeof item.label !== "string" ? (
|
|
81
|
+
item.label
|
|
82
|
+
) : (
|
|
83
|
+
<span className="truncate">{item.label}</span>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
{item.children && (
|
|
87
|
+
<ChevronRightIcon
|
|
88
|
+
size={16}
|
|
89
|
+
className="transition shrink-0 group-data-[state=open]:rotate-90"
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<li title={typeof item.label === "string" ? item.label : item.title}>
|
|
97
|
+
{item.children ? (
|
|
98
|
+
<Collapsible.Root
|
|
99
|
+
open={isOpen}
|
|
100
|
+
onOpenChange={() => setIsOpen((prev) => !prev)}
|
|
101
|
+
className="flex flex-col"
|
|
102
|
+
>
|
|
103
|
+
<Collapsible.Trigger
|
|
104
|
+
className={cn("group text-start", linkItem({ isActive: false }))}
|
|
105
|
+
>
|
|
106
|
+
{linkContent}
|
|
107
|
+
</Collapsible.Trigger>
|
|
108
|
+
<Collapsible.Content className="CollapsibleContent ms-[calc(var(--padding-nav-item)*1.125)]">
|
|
109
|
+
<ul className="mt-1 border-border border-l ps-1.5">
|
|
110
|
+
{item.children.map((child) => (
|
|
111
|
+
<SideNavigationItem
|
|
112
|
+
key={isPathItem(child) ? child.path : child.href}
|
|
113
|
+
category={category}
|
|
114
|
+
item={child}
|
|
115
|
+
activeAnchor={activeAnchor}
|
|
116
|
+
currentTopNavItem={currentTopNavItem}
|
|
117
|
+
basePath={currentPath}
|
|
118
|
+
/>
|
|
119
|
+
))}
|
|
120
|
+
</ul>
|
|
121
|
+
</Collapsible.Content>
|
|
122
|
+
</Collapsible.Root>
|
|
123
|
+
) : item.path.startsWith("#") ? (
|
|
124
|
+
<AnchorLink
|
|
125
|
+
to={item.path}
|
|
126
|
+
{...{ [DATA_ANCHOR_ATTR]: item.path }}
|
|
127
|
+
className={linkItem({
|
|
128
|
+
isActive: item.path.slice(1) === activeAnchor,
|
|
129
|
+
})}
|
|
130
|
+
>
|
|
131
|
+
{linkContent}
|
|
132
|
+
</AnchorLink>
|
|
133
|
+
) : (
|
|
134
|
+
<NavLink
|
|
135
|
+
className={({ isActive }) => linkItem({ isActive })}
|
|
136
|
+
to={currentPath}
|
|
137
|
+
>
|
|
138
|
+
{linkContent}
|
|
139
|
+
</NavLink>
|
|
140
|
+
)}
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { forwardRef, type PropsWithChildren } from "react";
|
|
2
|
+
|
|
3
|
+
export const SideNavigationWrapper = forwardRef<
|
|
4
|
+
HTMLDivElement,
|
|
5
|
+
PropsWithChildren
|
|
6
|
+
>(({ children }, ref) => {
|
|
7
|
+
return (
|
|
8
|
+
<nav
|
|
9
|
+
className="hidden lg:flex flex-col fixed text-sm overflow-y-auto shrink-0 p-[--padding-nav-item] -mx-[--padding-nav-item] pb-20 pt-[--padding-content-top] w-[--side-nav-width] h-[calc(100%-var(--header-height))] scroll-pt-2 gap-2"
|
|
10
|
+
ref={ref}
|
|
11
|
+
>
|
|
12
|
+
{children}
|
|
13
|
+
</nav>
|
|
14
|
+
);
|
|
15
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useLocation } from "react-router-dom";
|
|
3
|
+
import type { NavigationNode } from "../../util/traverseNavigation.js";
|
|
4
|
+
import { checkHasActiveItem } from "./util.js";
|
|
5
|
+
|
|
6
|
+
export const useNavigationCollapsibleState = ({
|
|
7
|
+
item,
|
|
8
|
+
defaultOpen,
|
|
9
|
+
path,
|
|
10
|
+
}: {
|
|
11
|
+
item: NavigationNode;
|
|
12
|
+
defaultOpen: () => boolean;
|
|
13
|
+
path: string;
|
|
14
|
+
}) => {
|
|
15
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
16
|
+
const location = useLocation();
|
|
17
|
+
const previousLocationPath = useRef(location.pathname);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!isOpen && previousLocationPath.current !== location.pathname) {
|
|
21
|
+
setIsOpen(checkHasActiveItem(item, location.pathname, path));
|
|
22
|
+
}
|
|
23
|
+
previousLocationPath.current = location.pathname;
|
|
24
|
+
}, [isOpen, item, path, location.pathname]);
|
|
25
|
+
|
|
26
|
+
return [isOpen, setIsOpen] as const;
|
|
27
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { matchPath } from "react-router-dom";
|
|
2
|
+
import type {
|
|
3
|
+
HrefNavigationCategoryItem,
|
|
4
|
+
PathNavigationCategoryItem,
|
|
5
|
+
} from "../../core/DevPortalContext.js";
|
|
6
|
+
import {
|
|
7
|
+
traverseNavigationNode,
|
|
8
|
+
type NavigationNode,
|
|
9
|
+
} from "../../util/traverseNavigation.js";
|
|
10
|
+
|
|
11
|
+
export const isPathItem = (
|
|
12
|
+
item: NavigationNode,
|
|
13
|
+
): item is PathNavigationCategoryItem => "path" in item;
|
|
14
|
+
|
|
15
|
+
export const isLinkItem = (
|
|
16
|
+
item: NavigationNode,
|
|
17
|
+
): item is HrefNavigationCategoryItem => "href" in item;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Recursively checks if the current item or any of its children are active.
|
|
21
|
+
*/
|
|
22
|
+
export const checkHasActiveItem = (
|
|
23
|
+
item: NavigationNode,
|
|
24
|
+
locationPath: string,
|
|
25
|
+
basePath: string,
|
|
26
|
+
) => {
|
|
27
|
+
return Boolean(
|
|
28
|
+
traverseNavigationNode(
|
|
29
|
+
item,
|
|
30
|
+
(node, fullPath) => {
|
|
31
|
+
if (isPathItem(node) && matchPath(fullPath, locationPath)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
basePath,
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
};
|