@c-rex/components 0.1.37 → 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.
Files changed (146) hide show
  1. package/README.md +73 -73
  2. package/package.json +250 -235
  3. package/src/article/article-action-bar.tsx +110 -110
  4. package/src/article/article-content.tsx +18 -46
  5. package/src/autocomplete.tsx +201 -201
  6. package/src/breadcrumb.tsx +124 -124
  7. package/src/carousel/carousel.tsx +353 -352
  8. package/src/check-article-lang.tsx +47 -43
  9. package/src/directoryNodes/directory-tree-context.tsx +388 -0
  10. package/src/directoryNodes/tree-of-content.tsx +68 -67
  11. package/src/documents/result-list.tsx +124 -127
  12. package/src/favorites/bookmark-button.tsx +97 -79
  13. package/src/favorites/favorite-button.tsx +137 -74
  14. package/src/footer/footer-shell.tsx +52 -0
  15. package/src/footer/footer.tsx +7 -0
  16. package/src/footer/legal-links-block.tsx +25 -0
  17. package/src/footer/organization-contact-block.tsx +94 -0
  18. package/src/footer/social-links-block.tsx +38 -0
  19. package/src/footer/types.ts +10 -0
  20. package/src/footer/vcard-footer.tsx +72 -0
  21. package/src/generated/client-components.tsx +1366 -1350
  22. package/src/generated/create-client-request.tsx +116 -113
  23. package/src/generated/create-server-request.tsx +70 -61
  24. package/src/generated/create-suggestions-request.tsx +55 -55
  25. package/src/generated/server-components.tsx +1056 -1056
  26. package/src/generated/suggestions.tsx +302 -299
  27. package/src/icons/file-icon.tsx +8 -8
  28. package/src/icons/flag-icon.tsx +15 -15
  29. package/src/icons/loading.tsx +11 -11
  30. package/src/icons/social-icon.tsx +24 -0
  31. package/src/info/info-card.tsx +43 -0
  32. package/src/info/{info-table.tsx → information-unit-metadata-grid.tsx} +157 -146
  33. package/src/info/shared.tsx +49 -25
  34. package/src/navbar/language-switcher/content-language-switch.tsx +92 -92
  35. package/src/navbar/language-switcher/shared.tsx +33 -33
  36. package/src/navbar/language-switcher/ui-language-switch.tsx +37 -38
  37. package/src/navbar/navbar.tsx +157 -148
  38. package/src/navbar/settings.tsx +62 -62
  39. package/src/navbar/sign-in-out-btns.tsx +35 -35
  40. package/src/navbar/user-menu.tsx +60 -60
  41. package/src/page-wrapper.tsx +54 -31
  42. package/src/render-article.module.css +155 -0
  43. package/src/render-article.tsx +75 -68
  44. package/src/renditions/file-download.tsx +83 -83
  45. package/src/renditions/html.tsx +64 -64
  46. package/src/renditions/image/container.tsx +54 -54
  47. package/src/renditions/image/rendition.tsx +55 -55
  48. package/src/restriction-menu/restriction-menu-container.tsx +117 -53
  49. package/src/restriction-menu/restriction-menu-item.tsx +155 -147
  50. package/src/restriction-menu/restriction-menu.tsx +341 -157
  51. package/src/results/dialog-filter.tsx +166 -166
  52. package/src/results/empty.tsx +15 -15
  53. package/src/results/filter-navbar.tsx +294 -261
  54. package/src/results/filter-sidebar/__tests__/utils.test.ts +129 -0
  55. package/src/results/filter-sidebar/index.tsx +270 -126
  56. package/src/results/filter-sidebar/utils.ts +196 -164
  57. package/src/results/generic/table-result-list.tsx +97 -99
  58. package/src/results/{table-with-images.tsx → information-unit-search-results-card-list.tsx} +125 -127
  59. package/src/results/{cards.tsx → information-unit-search-results-cards.tsx} +99 -99
  60. package/src/results/{table.tsx → information-unit-search-results-table.tsx} +104 -104
  61. package/src/results/pagination.tsx +81 -81
  62. package/src/results/summary.ts +30 -0
  63. package/src/results/utils.ts +54 -47
  64. package/src/search-input.tsx +70 -70
  65. package/src/share-button.tsx +49 -49
  66. package/src/stores/favorites-store.ts +88 -88
  67. package/src/stores/highlight-store.ts +15 -15
  68. package/src/stores/language-store.ts +14 -43
  69. package/src/stores/restriction-store.ts +11 -11
  70. package/src/stores/search-settings-store.ts +68 -64
  71. package/src/article/article-action-bar.analysis.md +0 -15
  72. package/src/article/article-action-bar.stories.tsx +0 -15
  73. package/src/article/article-content.analysis.md +0 -15
  74. package/src/article/article-content.stories.tsx +0 -21
  75. package/src/autocomplete.analysis.md +0 -17
  76. package/src/breadcrumb.analysis.md +0 -15
  77. package/src/carousel/carousel.analysis.md +0 -17
  78. package/src/check-article-lang.analysis.md +0 -15
  79. package/src/directoryNodes/tree-of-content.analysis.md +0 -14
  80. package/src/directoryNodes/tree-of-content.stories.tsx +0 -22
  81. package/src/documents/result-list.analysis.md +0 -14
  82. package/src/documents/result-list.stories.tsx +0 -19
  83. package/src/favorites/bookmark-button.analysis.md +0 -17
  84. package/src/favorites/bookmark-button.stories.tsx +0 -19
  85. package/src/favorites/favorite-button.analysis.md +0 -18
  86. package/src/favorites/favorite-button.stories.tsx +0 -22
  87. package/src/icons/file-icon.analysis.md +0 -14
  88. package/src/icons/file-icon.stories.tsx +0 -19
  89. package/src/icons/flag-icon.analysis.md +0 -14
  90. package/src/icons/flag-icon.stories.tsx +0 -25
  91. package/src/icons/loading.analysis.md +0 -14
  92. package/src/icons/loading.stories.tsx +0 -21
  93. package/src/info/info-table.analysis.md +0 -15
  94. package/src/info/shared.analysis.md +0 -14
  95. package/src/info/stories/info-table.stories.tsx +0 -31
  96. package/src/info/stories/shared.stories.tsx +0 -24
  97. package/src/navbar/language-switcher/content-language-switch.analysis.md +0 -15
  98. package/src/navbar/language-switcher/shared.analysis.md +0 -14
  99. package/src/navbar/language-switcher/ui-language-switch.analysis.md +0 -15
  100. package/src/navbar/navbar.analysis.md +0 -14
  101. package/src/navbar/settings.analysis.md +0 -14
  102. package/src/navbar/sign-in-out-btns.analysis.md +0 -14
  103. package/src/navbar/stories/navbar.stories.tsx +0 -31
  104. package/src/navbar/stories/settings.stories.tsx +0 -15
  105. package/src/navbar/stories/sign-in-out-btns.stories.tsx +0 -15
  106. package/src/navbar/stories/user-menu.stories.tsx +0 -20
  107. package/src/navbar/user-menu.analysis.md +0 -14
  108. package/src/page-wrapper.analysis.md +0 -14
  109. package/src/render-article.analysis.md +0 -15
  110. package/src/renditions/file-download.analysis.md +0 -14
  111. package/src/renditions/file-download.stories.tsx +0 -19
  112. package/src/renditions/html.analysis.md +0 -17
  113. package/src/renditions/html.stories.tsx +0 -19
  114. package/src/renditions/image/container.analysis.md +0 -15
  115. package/src/renditions/image/container.stories.tsx +0 -19
  116. package/src/renditions/image/rendition.analysis.md +0 -14
  117. package/src/renditions/image/rendition.stories.tsx +0 -19
  118. package/src/restriction-menu/restriction-menu-container.analysis.md +0 -14
  119. package/src/restriction-menu/restriction-menu-item.analysis.md +0 -14
  120. package/src/restriction-menu/restriction-menu.analysis.md +0 -17
  121. package/src/results/analysis/cards.analysis.md +0 -14
  122. package/src/results/analysis/dialog-filter.analysis.md +0 -17
  123. package/src/results/analysis/empty.analysis.md +0 -14
  124. package/src/results/analysis/filter-navbar.analysis.md +0 -16
  125. package/src/results/analysis/pagination.analysis.md +0 -14
  126. package/src/results/analysis/table-with-images.analysis.md +0 -15
  127. package/src/results/analysis/table.analysis.md +0 -15
  128. package/src/results/filter-sidebar/index.analysis.md +0 -14
  129. package/src/results/generic/table-result-list.analysis.md +0 -15
  130. package/src/results/generic/table-result-list.stories.tsx +0 -21
  131. package/src/results/stories/cards.stories.tsx +0 -66
  132. package/src/results/stories/dialog-filter.stories.tsx +0 -20
  133. package/src/results/stories/empty.stories.tsx +0 -25
  134. package/src/results/stories/filter-navbar.stories.tsx +0 -19
  135. package/src/results/stories/filter-sidebar.stories.tsx +0 -20
  136. package/src/results/stories/pagination.stories.tsx +0 -24
  137. package/src/results/stories/table-with-images.stories.tsx +0 -19
  138. package/src/results/stories/table.stories.tsx +0 -78
  139. package/src/search-input.analysis.md +0 -15
  140. package/src/share-button.analysis.md +0 -19
  141. package/src/stories/autocomplete.stories.tsx +0 -20
  142. package/src/stories/breadcrumb.stories.tsx +0 -93
  143. package/src/stories/check-article-lang.stories.tsx +0 -22
  144. package/src/stories/render-article.stories.tsx +0 -19
  145. package/src/stories/search-input.stories.tsx +0 -21
  146. package/src/stories/share-button.stories.tsx +0 -15
@@ -1,43 +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
-
18
- useEffect(() => {
19
- setAvailableVersions(availableVersions)
20
- const searchLanguage = useSearchSettingsStore.getState().language;
21
- const activeArticle = availableVersions.filter((item) => item.active)[0]
22
-
23
- if (activeArticle == undefined || activeArticle.lang == searchLanguage) return
24
-
25
- const articleAvailable = availableVersions.find((item) => item.lang === searchLanguage)
26
- if (articleAvailable != undefined) {
27
- articleAvailableInToast(articleAvailable.lang, articleAvailable.link)
28
- }
29
- }, [])
30
-
31
- const articleAvailableInToast = (lang: string, link: string) => {
32
- toast(t('toast.read', { lang }), {
33
- description: t('toast.description', { lang }),
34
- action: {
35
- label: t('toast.label', { lang }),
36
- onClick: () => window.location.href = link,
37
- },
38
- duration: 10000,
39
- })
40
- }
41
-
42
- return null
43
- }
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: DirectoryNodeModel[];
18
- }
19
-
20
- export async function TreeOfContent({ directoryNodes }: SidebarProps) {
21
- const tree = await generateTreeOfContent(directoryNodes);
22
-
23
- return (
24
- <Suspense
25
- fallback={
26
- <SidebarGroup className="pt-4">
27
- <Skeleton className="w-auto h-10 mb-2" />
28
- <Skeleton className="w-auto h-10 mb-2" />
29
- <Skeleton className="w-auto h-10 mb-2 ml-8" />
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
- </SidebarGroup>
35
- }
36
- >
37
- <SidebarMenu>
38
- {tree.map((item) => (
39
- <SidebarMenuItem key={item.id}>
40
- <SidebarMenuButton asChild isActive={item.active}>
41
- <Link href={`/topics/${item.linkId}/pages`} title={item.label}>
42
- {item.label}
43
- </Link>
44
- </SidebarMenuButton>
45
-
46
- {item.children?.length ? (
47
- <SidebarMenuSub>
48
- {item.children.map((item) => (
49
- <SidebarMenuSubItem key={item.label}>
50
- <SidebarMenuSubButton
51
- asChild
52
- isActive={item.active}
53
- >
54
- <Link href={`/topics/${item.linkId}/pages`} title={item.label}>
55
- {item.label}
56
- </Link>
57
- </SidebarMenuSubButton>
58
- </SidebarMenuSubItem>
59
- ))}
60
- </SidebarMenuSub>
61
- ) : null}
62
- </SidebarMenuItem>
63
- ))}
64
- </SidebarMenu>
65
- </Suspense>
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
+ }