@c-rex/components 0.1.38 → 0.1.39
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 +73 -73
- package/package.json +250 -218
- package/src/article/article-action-bar.tsx +110 -110
- package/src/article/article-content.tsx +18 -46
- package/src/autocomplete.tsx +201 -201
- package/src/breadcrumb.tsx +124 -124
- package/src/carousel/carousel.tsx +353 -353
- package/src/check-article-lang.tsx +47 -47
- package/src/directoryNodes/directory-tree-context.tsx +388 -0
- package/src/directoryNodes/tree-of-content.tsx +68 -67
- package/src/documents/result-list.tsx +124 -127
- package/src/favorites/bookmark-button.tsx +97 -94
- package/src/favorites/favorite-button.tsx +137 -120
- package/src/footer/footer-shell.tsx +52 -0
- package/src/footer/footer.tsx +7 -0
- package/src/footer/legal-links-block.tsx +25 -0
- package/src/footer/organization-contact-block.tsx +94 -0
- package/src/footer/social-links-block.tsx +38 -0
- package/src/footer/types.ts +10 -0
- package/src/footer/vcard-footer.tsx +72 -0
- package/src/generated/client-components.tsx +1366 -1350
- package/src/generated/create-client-request.tsx +116 -113
- package/src/generated/create-server-request.tsx +70 -61
- package/src/generated/create-suggestions-request.tsx +55 -55
- package/src/generated/server-components.tsx +1056 -1056
- package/src/generated/suggestions.tsx +302 -299
- package/src/icons/file-icon.tsx +8 -8
- package/src/icons/flag-icon.tsx +15 -15
- package/src/icons/loading.tsx +11 -11
- package/src/icons/social-icon.tsx +24 -0
- package/src/info/info-card.tsx +43 -0
- package/src/info/{info-table.tsx → information-unit-metadata-grid.tsx} +157 -168
- package/src/info/shared.tsx +49 -25
- package/src/navbar/language-switcher/content-language-switch.tsx +92 -92
- package/src/navbar/language-switcher/shared.tsx +33 -33
- package/src/navbar/language-switcher/ui-language-switch.tsx +37 -37
- package/src/navbar/navbar.tsx +157 -152
- package/src/navbar/settings.tsx +62 -62
- package/src/navbar/sign-in-out-btns.tsx +35 -35
- package/src/navbar/user-menu.tsx +60 -60
- package/src/page-wrapper.tsx +54 -31
- package/src/render-article.module.css +155 -0
- package/src/render-article.tsx +75 -68
- package/src/renditions/file-download.tsx +83 -83
- package/src/renditions/html.tsx +64 -64
- package/src/renditions/image/container.tsx +54 -54
- package/src/renditions/image/rendition.tsx +55 -55
- package/src/restriction-menu/restriction-menu-container.tsx +117 -53
- package/src/restriction-menu/restriction-menu-item.tsx +155 -147
- package/src/restriction-menu/restriction-menu.tsx +341 -156
- package/src/results/dialog-filter.tsx +166 -166
- package/src/results/empty.tsx +15 -15
- package/src/results/filter-navbar.tsx +294 -261
- package/src/results/filter-sidebar/__tests__/utils.test.ts +129 -0
- package/src/results/filter-sidebar/index.tsx +270 -126
- package/src/results/filter-sidebar/utils.ts +196 -164
- package/src/results/generic/table-result-list.tsx +97 -99
- package/src/results/{table-with-images.tsx → information-unit-search-results-card-list.tsx} +125 -127
- package/src/results/{cards.tsx → information-unit-search-results-cards.tsx} +99 -99
- package/src/results/{table.tsx → information-unit-search-results-table.tsx} +104 -104
- package/src/results/pagination.tsx +81 -81
- package/src/results/summary.ts +30 -0
- package/src/results/utils.ts +54 -54
- package/src/search-input.tsx +70 -70
- package/src/share-button.tsx +49 -49
- package/src/stores/favorites-store.ts +88 -88
- package/src/stores/highlight-store.ts +15 -15
- package/src/stores/language-store.ts +14 -14
- package/src/stores/restriction-store.ts +11 -11
- package/src/stores/search-settings-store.ts +68 -64
- package/src/info/set-available-versions.tsx +0 -19
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { FC, useEffect } from "react";
|
|
4
|
-
import { useAppConfig } from "@c-rex/contexts/config-provider";
|
|
5
|
-
import { AvailableVersionsInterface } from "@c-rex/interfaces";
|
|
6
|
-
import { toast } from "sonner"
|
|
7
|
-
import { useTranslations } from "next-intl"
|
|
8
|
-
import { useSearchSettingsStore } from "./stores/search-settings-store";
|
|
9
|
-
|
|
10
|
-
interface Props {
|
|
11
|
-
availableVersions: AvailableVersionsInterface[]
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const CheckArticleLangToast: FC<Props> = ({ availableVersions }) => {
|
|
15
|
-
const t = useTranslations();
|
|
16
|
-
const { setAvailableVersions } = useAppConfig()
|
|
17
|
-
const searchLanguage = useSearchSettingsStore((state) => state.language);
|
|
18
|
-
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
setAvailableVersions(availableVersions)
|
|
21
|
-
}, [availableVersions, setAvailableVersions])
|
|
22
|
-
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
if (!searchLanguage) return;
|
|
25
|
-
const activeArticle = availableVersions.find((item) => item.active)
|
|
26
|
-
|
|
27
|
-
if (activeArticle == undefined || activeArticle.lang == searchLanguage) return
|
|
28
|
-
|
|
29
|
-
const articleAvailable = availableVersions.find((item) => item.lang === searchLanguage)
|
|
30
|
-
if (articleAvailable != undefined) {
|
|
31
|
-
articleAvailableInToast(articleAvailable.lang, articleAvailable.link)
|
|
32
|
-
}
|
|
33
|
-
}, [availableVersions, searchLanguage])
|
|
34
|
-
|
|
35
|
-
const articleAvailableInToast = (lang: string, link: string) => {
|
|
36
|
-
toast(t('toast.read', { lang }), {
|
|
37
|
-
description: t('toast.description', { lang }),
|
|
38
|
-
action: {
|
|
39
|
-
label: t('toast.label', { lang }),
|
|
40
|
-
onClick: () => window.location.href = link,
|
|
41
|
-
},
|
|
42
|
-
duration: 10000,
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return null
|
|
47
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { FC, useEffect } from "react";
|
|
4
|
+
import { useAppConfig } from "@c-rex/contexts/config-provider";
|
|
5
|
+
import { AvailableVersionsInterface } from "@c-rex/interfaces";
|
|
6
|
+
import { toast } from "sonner"
|
|
7
|
+
import { useTranslations } from "next-intl"
|
|
8
|
+
import { useSearchSettingsStore } from "./stores/search-settings-store";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
availableVersions: AvailableVersionsInterface[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const CheckArticleLangToast: FC<Props> = ({ availableVersions }) => {
|
|
15
|
+
const t = useTranslations();
|
|
16
|
+
const { setAvailableVersions } = useAppConfig()
|
|
17
|
+
const searchLanguage = useSearchSettingsStore((state) => state.language);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setAvailableVersions(availableVersions)
|
|
21
|
+
}, [availableVersions, setAvailableVersions])
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!searchLanguage) return;
|
|
25
|
+
const activeArticle = availableVersions.find((item) => item.active)
|
|
26
|
+
|
|
27
|
+
if (activeArticle == undefined || activeArticle.lang == searchLanguage) return
|
|
28
|
+
|
|
29
|
+
const articleAvailable = availableVersions.find((item) => item.lang === searchLanguage)
|
|
30
|
+
if (articleAvailable != undefined) {
|
|
31
|
+
articleAvailableInToast(articleAvailable.lang, articleAvailable.link)
|
|
32
|
+
}
|
|
33
|
+
}, [availableVersions, searchLanguage])
|
|
34
|
+
|
|
35
|
+
const articleAvailableInToast = (lang: string, link: string) => {
|
|
36
|
+
toast(t('toast.read', { lang }), {
|
|
37
|
+
description: t('toast.description', { lang }),
|
|
38
|
+
action: {
|
|
39
|
+
label: t('toast.label', { lang }),
|
|
40
|
+
onClick: () => window.location.href = link,
|
|
41
|
+
},
|
|
42
|
+
duration: 10000,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useMemo, useState, type FC, type PropsWithChildren } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import type { TreeOfContent } from "@c-rex/interfaces";
|
|
6
|
+
import {
|
|
7
|
+
Breadcrumb as BreadcrumbComponent,
|
|
8
|
+
BreadcrumbItem,
|
|
9
|
+
BreadcrumbList,
|
|
10
|
+
BreadcrumbPage,
|
|
11
|
+
BreadcrumbSeparator,
|
|
12
|
+
} from "@c-rex/ui/breadcrumb";
|
|
13
|
+
import {
|
|
14
|
+
SidebarMenu,
|
|
15
|
+
SidebarMenuButton,
|
|
16
|
+
SidebarMenuItem,
|
|
17
|
+
SidebarMenuSub,
|
|
18
|
+
} from "@c-rex/ui/sidebar";
|
|
19
|
+
import { Skeleton } from "@c-rex/ui/skeleton";
|
|
20
|
+
import { generateBreadcrumbItems } from "@c-rex/utils";
|
|
21
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
22
|
+
|
|
23
|
+
type LoadTreeFn = (entryDirectoryNodeShortId: string, signal: AbortSignal) => Promise<TreeOfContent[]>;
|
|
24
|
+
|
|
25
|
+
type DirectoryTreeState = {
|
|
26
|
+
tree: TreeOfContent[];
|
|
27
|
+
isLoading: boolean;
|
|
28
|
+
expandActivePathOnLoad: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const DirectoryTreeContext = createContext<DirectoryTreeState>({
|
|
32
|
+
tree: [],
|
|
33
|
+
isLoading: true,
|
|
34
|
+
expandActivePathOnLoad: false,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
type DirectoryTreeShapeCacheEntry = {
|
|
38
|
+
tree: TreeOfContent[];
|
|
39
|
+
expiresAtEpochMs: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const customLoadTreeScopeIds = new WeakMap<LoadTreeFn, string>();
|
|
43
|
+
let customLoadTreeScopeCounter = 0;
|
|
44
|
+
|
|
45
|
+
const DIRECTORY_TREE_SHAPE_CACHE_MAX_ENTRIES = 8;
|
|
46
|
+
const DIRECTORY_TREE_SHAPE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
47
|
+
const directoryTreeShapeCache = new Map<string, DirectoryTreeShapeCacheEntry>();
|
|
48
|
+
|
|
49
|
+
const cloneTree = (nodes: TreeOfContent[]): TreeOfContent[] =>
|
|
50
|
+
nodes.map((node) => ({
|
|
51
|
+
...node,
|
|
52
|
+
active: false,
|
|
53
|
+
children: cloneTree(node.children || []),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const nodePathById = (nodes: TreeOfContent[], targetId: string): string[] | null => {
|
|
57
|
+
for (const node of nodes) {
|
|
58
|
+
if (node.id === targetId) {
|
|
59
|
+
return [node.id];
|
|
60
|
+
}
|
|
61
|
+
const childPath = nodePathById(node.children || [], targetId);
|
|
62
|
+
if (childPath) {
|
|
63
|
+
return [node.id, ...childPath];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const markActivePath = (nodes: TreeOfContent[], activePath: Set<string>): TreeOfContent[] =>
|
|
70
|
+
nodes.map((node) => ({
|
|
71
|
+
...node,
|
|
72
|
+
active: activePath.has(node.id),
|
|
73
|
+
children: markActivePath(node.children || [], activePath),
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
const getTreeShapeFromCache = (scopeKey: string): TreeOfContent[] | null => {
|
|
77
|
+
const nowEpochMs = Date.now();
|
|
78
|
+
const entry = directoryTreeShapeCache.get(scopeKey);
|
|
79
|
+
if (!entry) return null;
|
|
80
|
+
if (entry.expiresAtEpochMs <= nowEpochMs) {
|
|
81
|
+
directoryTreeShapeCache.delete(scopeKey);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// LRU touch
|
|
86
|
+
directoryTreeShapeCache.delete(scopeKey);
|
|
87
|
+
directoryTreeShapeCache.set(scopeKey, entry);
|
|
88
|
+
return entry.tree;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const setTreeShapeInCache = (scopeKey: string, tree: TreeOfContent[]) => {
|
|
92
|
+
if (directoryTreeShapeCache.has(scopeKey)) {
|
|
93
|
+
directoryTreeShapeCache.delete(scopeKey);
|
|
94
|
+
}
|
|
95
|
+
directoryTreeShapeCache.set(scopeKey, {
|
|
96
|
+
tree: cloneTree(tree),
|
|
97
|
+
expiresAtEpochMs: Date.now() + DIRECTORY_TREE_SHAPE_CACHE_TTL_MS,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
while (directoryTreeShapeCache.size > DIRECTORY_TREE_SHAPE_CACHE_MAX_ENTRIES) {
|
|
101
|
+
const oldestKey = directoryTreeShapeCache.keys().next().value as string | undefined;
|
|
102
|
+
if (!oldestKey) return;
|
|
103
|
+
directoryTreeShapeCache.delete(oldestKey);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const resolveCustomLoadTreeScopeKey = (loadTree: LoadTreeFn): string => {
|
|
108
|
+
const existingScopeId = customLoadTreeScopeIds.get(loadTree);
|
|
109
|
+
if (existingScopeId) return existingScopeId;
|
|
110
|
+
|
|
111
|
+
customLoadTreeScopeCounter += 1;
|
|
112
|
+
const nextScopeId = `custom-loader-${customLoadTreeScopeCounter}`;
|
|
113
|
+
customLoadTreeScopeIds.set(loadTree, nextScopeId);
|
|
114
|
+
return nextScopeId;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const buildTreeForEntryFromCache = (scopeKey: string, entryDirectoryNodeShortId: string): TreeOfContent[] | null => {
|
|
118
|
+
const cachedDirectoryTreeShape = getTreeShapeFromCache(scopeKey);
|
|
119
|
+
if (!cachedDirectoryTreeShape) return null;
|
|
120
|
+
const path = nodePathById(cachedDirectoryTreeShape, entryDirectoryNodeShortId);
|
|
121
|
+
if (!path) return null;
|
|
122
|
+
return markActivePath(cloneTree(cachedDirectoryTreeShape), new Set(path));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const buildDefaultTreeEndpointUrl = (treeEndpoint: string, shortId: string, includeLinkIds: boolean): string => {
|
|
126
|
+
const joiner = treeEndpoint.includes("?") ? "&" : "?";
|
|
127
|
+
return `${treeEndpoint}${joiner}shortId=${encodeURIComponent(shortId)}&includeLinkIds=${includeLinkIds ? "true" : "false"}`;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
type DirectoryTreeProviderProps = PropsWithChildren<{
|
|
131
|
+
entryDirectoryNodeShortId?: string;
|
|
132
|
+
// Common default behavior: document entry false, topic entry true.
|
|
133
|
+
expandActivePathOnLoad?: boolean;
|
|
134
|
+
// Optional injection point to decouple tree loading from app-specific endpoints.
|
|
135
|
+
loadTree?: LoadTreeFn;
|
|
136
|
+
// Default endpoint fallback if no custom loadTree is provided.
|
|
137
|
+
treeEndpoint?: string;
|
|
138
|
+
includeLinkIds?: boolean;
|
|
139
|
+
}>;
|
|
140
|
+
|
|
141
|
+
export const DirectoryTreeProvider: FC<DirectoryTreeProviderProps> = ({
|
|
142
|
+
entryDirectoryNodeShortId,
|
|
143
|
+
expandActivePathOnLoad = false,
|
|
144
|
+
loadTree,
|
|
145
|
+
treeEndpoint = "/api/directory-nodes/tree",
|
|
146
|
+
includeLinkIds = true,
|
|
147
|
+
children,
|
|
148
|
+
}) => {
|
|
149
|
+
const [tree, setTree] = useState<TreeOfContent[]>([]);
|
|
150
|
+
const [isLoading, setIsLoading] = useState(Boolean(entryDirectoryNodeShortId));
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!entryDirectoryNodeShortId) {
|
|
154
|
+
setTree([]);
|
|
155
|
+
setIsLoading(false);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let cancelled = false;
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
|
|
162
|
+
const loadTreeDefault: LoadTreeFn = async (shortId, signal) => {
|
|
163
|
+
const response = await fetch(
|
|
164
|
+
buildDefaultTreeEndpointUrl(treeEndpoint, shortId, includeLinkIds),
|
|
165
|
+
{ signal, credentials: "include" }
|
|
166
|
+
);
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
throw new Error(`Failed to load tree: HTTP ${response.status}`);
|
|
169
|
+
}
|
|
170
|
+
const payload = (await response.json()) as { tree?: TreeOfContent[] };
|
|
171
|
+
return payload.tree || [];
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const activeLoadTree = loadTree || loadTreeDefault;
|
|
175
|
+
const cacheScopeKey = loadTree
|
|
176
|
+
? resolveCustomLoadTreeScopeKey(loadTree)
|
|
177
|
+
: `${treeEndpoint}|includeLinkIds=${includeLinkIds ? "1" : "0"}`;
|
|
178
|
+
|
|
179
|
+
const runLoad = async () => {
|
|
180
|
+
const treeFromCache = buildTreeForEntryFromCache(cacheScopeKey, entryDirectoryNodeShortId);
|
|
181
|
+
if (treeFromCache) {
|
|
182
|
+
setTree(treeFromCache);
|
|
183
|
+
setIsLoading(false);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const isInitialLoad = tree.length === 0;
|
|
188
|
+
if (isInitialLoad) {
|
|
189
|
+
setIsLoading(true);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const nextTree = await activeLoadTree(entryDirectoryNodeShortId, controller.signal);
|
|
194
|
+
if (!cancelled) {
|
|
195
|
+
setTree(nextTree);
|
|
196
|
+
setTreeShapeInCache(cacheScopeKey, nextTree);
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (!cancelled) {
|
|
200
|
+
console.error("[DirectoryTreeProvider] Failed to load tree", error);
|
|
201
|
+
setTree([]);
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
if (!cancelled) {
|
|
205
|
+
setIsLoading(false);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
runLoad();
|
|
211
|
+
|
|
212
|
+
return () => {
|
|
213
|
+
cancelled = true;
|
|
214
|
+
controller.abort();
|
|
215
|
+
};
|
|
216
|
+
}, [entryDirectoryNodeShortId, treeEndpoint, includeLinkIds, loadTree]);
|
|
217
|
+
|
|
218
|
+
const value = useMemo(
|
|
219
|
+
() => ({ tree, isLoading, expandActivePathOnLoad }),
|
|
220
|
+
[tree, isLoading, expandActivePathOnLoad]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return <DirectoryTreeContext.Provider value={value}>{children}</DirectoryTreeContext.Provider>;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const useDirectoryTree = () => useContext(DirectoryTreeContext);
|
|
227
|
+
|
|
228
|
+
type DirectoryTreeSidebarMenuProps = {
|
|
229
|
+
onNavigateNode?: (node: TreeOfContent) => void | Promise<void>;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export const DirectoryTreeSidebarMenu: FC<DirectoryTreeSidebarMenuProps> = ({
|
|
233
|
+
onNavigateNode,
|
|
234
|
+
}) => {
|
|
235
|
+
const { tree, isLoading, expandActivePathOnLoad } = useDirectoryTree();
|
|
236
|
+
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (!expandActivePathOnLoad) {
|
|
240
|
+
setExpandedNodeIds(new Set());
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const nextExpanded = new Set<string>();
|
|
245
|
+
|
|
246
|
+
const collectActiveParents = (nodes: TreeOfContent[]) => {
|
|
247
|
+
nodes.forEach((node) => {
|
|
248
|
+
if (node.active && node.children && node.children.length > 0) {
|
|
249
|
+
nextExpanded.add(node.id);
|
|
250
|
+
}
|
|
251
|
+
if (node.children && node.children.length > 0) {
|
|
252
|
+
collectActiveParents(node.children);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
collectActiveParents(tree);
|
|
258
|
+
setExpandedNodeIds(nextExpanded);
|
|
259
|
+
}, [tree, expandActivePathOnLoad]);
|
|
260
|
+
|
|
261
|
+
if (isLoading && tree.length === 0) {
|
|
262
|
+
return (
|
|
263
|
+
<div className="pt-4">
|
|
264
|
+
<Skeleton className="w-auto h-10 mb-2" />
|
|
265
|
+
<Skeleton className="w-auto h-10 mb-2" />
|
|
266
|
+
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
267
|
+
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!tree.length) return null;
|
|
273
|
+
|
|
274
|
+
const toggleNode = (nodeId: string) => {
|
|
275
|
+
setExpandedNodeIds((prev) => {
|
|
276
|
+
const next = new Set(prev);
|
|
277
|
+
if (next.has(nodeId)) {
|
|
278
|
+
next.delete(nodeId);
|
|
279
|
+
} else {
|
|
280
|
+
next.add(nodeId);
|
|
281
|
+
}
|
|
282
|
+
return next;
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const renderNode = (node: TreeOfContent) => (
|
|
287
|
+
<SidebarMenuItem key={node.id}>
|
|
288
|
+
<div className="flex items-center gap-1">
|
|
289
|
+
<SidebarMenuButton asChild isActive={node.active} className="flex-1">
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
onClick={() => onNavigateNode?.(node)}
|
|
293
|
+
title={node.label}
|
|
294
|
+
className="w-full text-left justify-start"
|
|
295
|
+
>
|
|
296
|
+
{node.label}
|
|
297
|
+
</button>
|
|
298
|
+
</SidebarMenuButton>
|
|
299
|
+
|
|
300
|
+
{node.children?.length ? (
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
aria-label={expandedNodeIds.has(node.id) ? "Collapse section" : "Expand section"}
|
|
304
|
+
className="h-8 w-8 inline-flex items-center justify-center text-muted-foreground hover:text-foreground"
|
|
305
|
+
onClick={() => toggleNode(node.id)}
|
|
306
|
+
>
|
|
307
|
+
{expandedNodeIds.has(node.id) ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
308
|
+
</button>
|
|
309
|
+
) : null}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{node.children?.length && expandedNodeIds.has(node.id) ? (
|
|
313
|
+
<SidebarMenuSub>
|
|
314
|
+
{node.children.map((child) => renderNode(child))}
|
|
315
|
+
</SidebarMenuSub>
|
|
316
|
+
) : null}
|
|
317
|
+
</SidebarMenuItem>
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<SidebarMenu className={isLoading ? "opacity-75 transition-opacity" : undefined}>
|
|
322
|
+
{tree.map((item) => renderNode(item))}
|
|
323
|
+
</SidebarMenu>
|
|
324
|
+
);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
type DirectoryTreeBreadcrumbProps = {
|
|
328
|
+
lang: string;
|
|
329
|
+
homeLabel?: string;
|
|
330
|
+
onNavigateHome?: () => void | Promise<void>;
|
|
331
|
+
onNavigateNodeId?: (nodeId: string) => void | Promise<void>;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
export const DirectoryTreeBreadcrumb: FC<DirectoryTreeBreadcrumbProps> = ({
|
|
335
|
+
lang,
|
|
336
|
+
homeLabel = "Home",
|
|
337
|
+
onNavigateHome,
|
|
338
|
+
onNavigateNodeId,
|
|
339
|
+
}) => {
|
|
340
|
+
const { tree, isLoading } = useDirectoryTree();
|
|
341
|
+
const router = useRouter();
|
|
342
|
+
const items = generateBreadcrumbItems(tree);
|
|
343
|
+
|
|
344
|
+
const navigateHome = () => {
|
|
345
|
+
if (onNavigateHome) {
|
|
346
|
+
return onNavigateHome();
|
|
347
|
+
}
|
|
348
|
+
router.push("/");
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
if (isLoading && tree.length === 0) {
|
|
352
|
+
return <Skeleton className="h-4 w-48 hidden sm:block" />;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!tree.length || items.length === 0) return null;
|
|
356
|
+
|
|
357
|
+
const lastIndex = items.length - 1;
|
|
358
|
+
return (
|
|
359
|
+
<BreadcrumbComponent lang={lang} className={`hidden sm:block ${isLoading ? "opacity-75 transition-opacity" : ""}`}>
|
|
360
|
+
<BreadcrumbList>
|
|
361
|
+
<BreadcrumbItem>
|
|
362
|
+
<button type="button" onClick={navigateHome}>
|
|
363
|
+
{homeLabel}
|
|
364
|
+
</button>
|
|
365
|
+
</BreadcrumbItem>
|
|
366
|
+
<BreadcrumbSeparator />
|
|
367
|
+
|
|
368
|
+
{items.map((item, index) => {
|
|
369
|
+
const isLast = index === lastIndex;
|
|
370
|
+
return (
|
|
371
|
+
<span key={`${item.id}-${index}`} className="contents">
|
|
372
|
+
<BreadcrumbItem>
|
|
373
|
+
{isLast ? (
|
|
374
|
+
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
|
375
|
+
) : (
|
|
376
|
+
<button type="button" onClick={() => onNavigateNodeId?.(item.id)}>
|
|
377
|
+
{item.label}
|
|
378
|
+
</button>
|
|
379
|
+
)}
|
|
380
|
+
</BreadcrumbItem>
|
|
381
|
+
{!isLast && <BreadcrumbSeparator />}
|
|
382
|
+
</span>
|
|
383
|
+
);
|
|
384
|
+
})}
|
|
385
|
+
</BreadcrumbList>
|
|
386
|
+
</BreadcrumbComponent>
|
|
387
|
+
);
|
|
388
|
+
};
|
|
@@ -1,67 +1,68 @@
|
|
|
1
|
-
import {
|
|
2
|
-
SidebarGroup,
|
|
3
|
-
SidebarMenu,
|
|
4
|
-
SidebarMenuButton,
|
|
5
|
-
SidebarMenuItem,
|
|
6
|
-
SidebarMenuSub,
|
|
7
|
-
SidebarMenuSubButton,
|
|
8
|
-
SidebarMenuSubItem,
|
|
9
|
-
} from "@c-rex/ui/sidebar";
|
|
10
|
-
import { DirectoryNodeModel } from "@c-rex/interfaces";
|
|
11
|
-
import { generateTreeOfContent } from "@c-rex/utils/directoryNodes";
|
|
12
|
-
import Link from "next/link";
|
|
13
|
-
import { Suspense } from "react";
|
|
14
|
-
import { Skeleton } from "@c-rex/ui/skeleton";
|
|
15
|
-
|
|
16
|
-
interface SidebarProps {
|
|
17
|
-
directoryNodes
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<Skeleton className="w-auto h-10 mb-2" />
|
|
29
|
-
<Skeleton className="w-auto h-10 mb-2
|
|
30
|
-
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
31
|
-
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
32
|
-
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
33
|
-
<Skeleton className="w-auto h-10 mb-2" />
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
1
|
+
import {
|
|
2
|
+
SidebarGroup,
|
|
3
|
+
SidebarMenu,
|
|
4
|
+
SidebarMenuButton,
|
|
5
|
+
SidebarMenuItem,
|
|
6
|
+
SidebarMenuSub,
|
|
7
|
+
SidebarMenuSubButton,
|
|
8
|
+
SidebarMenuSubItem,
|
|
9
|
+
} from "@c-rex/ui/sidebar";
|
|
10
|
+
import { DirectoryNodeModel, TreeOfContent as TreeOfContentModel } from "@c-rex/interfaces";
|
|
11
|
+
import { generateTreeOfContent } from "@c-rex/utils/directoryNodes";
|
|
12
|
+
import Link from "next/link";
|
|
13
|
+
import { Suspense } from "react";
|
|
14
|
+
import { Skeleton } from "@c-rex/ui/skeleton";
|
|
15
|
+
|
|
16
|
+
interface SidebarProps {
|
|
17
|
+
directoryNodes?: DirectoryNodeModel[];
|
|
18
|
+
tree?: TreeOfContentModel[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function TreeOfContent({ directoryNodes, tree: providedTree }: SidebarProps) {
|
|
22
|
+
const tree = providedTree ?? (directoryNodes ? await generateTreeOfContent(directoryNodes) : []);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Suspense
|
|
26
|
+
fallback={
|
|
27
|
+
<SidebarGroup className="pt-4">
|
|
28
|
+
<Skeleton className="w-auto h-10 mb-2" />
|
|
29
|
+
<Skeleton className="w-auto h-10 mb-2" />
|
|
30
|
+
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
31
|
+
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
32
|
+
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
33
|
+
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
34
|
+
<Skeleton className="w-auto h-10 mb-2" />
|
|
35
|
+
</SidebarGroup>
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
<SidebarMenu>
|
|
39
|
+
{tree.map((item) => (
|
|
40
|
+
<SidebarMenuItem key={item.id}>
|
|
41
|
+
<SidebarMenuButton asChild isActive={item.active}>
|
|
42
|
+
<Link prefetch={false} href={`/topics/${item.linkId}/pages`} title={item.label}>
|
|
43
|
+
{item.label}
|
|
44
|
+
</Link>
|
|
45
|
+
</SidebarMenuButton>
|
|
46
|
+
|
|
47
|
+
{item.children?.length ? (
|
|
48
|
+
<SidebarMenuSub>
|
|
49
|
+
{item.children.map((item) => (
|
|
50
|
+
<SidebarMenuSubItem key={item.label}>
|
|
51
|
+
<SidebarMenuSubButton
|
|
52
|
+
asChild
|
|
53
|
+
isActive={item.active}
|
|
54
|
+
>
|
|
55
|
+
<Link prefetch={false} href={`/topics/${item.linkId}/pages`} title={item.label}>
|
|
56
|
+
{item.label}
|
|
57
|
+
</Link>
|
|
58
|
+
</SidebarMenuSubButton>
|
|
59
|
+
</SidebarMenuSubItem>
|
|
60
|
+
))}
|
|
61
|
+
</SidebarMenuSub>
|
|
62
|
+
) : null}
|
|
63
|
+
</SidebarMenuItem>
|
|
64
|
+
))}
|
|
65
|
+
</SidebarMenu>
|
|
66
|
+
</Suspense>
|
|
67
|
+
);
|
|
68
|
+
}
|