@c-rex/components 0.1.33 → 0.1.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c-rex/components",
3
- "version": "0.1.33",
3
+ "version": "0.1.37",
4
4
  "files": [
5
5
  "src"
6
6
  ],
@@ -3,53 +3,44 @@ import { RenditionModel } from "@c-rex/interfaces";
3
3
  import { RenderArticle } from "../render-article";
4
4
  import { ArticleActionBar } from "./article-action-bar";
5
5
  import * as cheerio from "cheerio"
6
+ import { HtmlRendition } from "../renditions/html";
6
7
 
7
8
  interface Props {
8
9
  renditions: RenditionModel[] | null | undefined;
9
10
  }
10
11
 
11
12
  export const ArticleContent: FC<Props> = async ({ renditions }) => {
12
- const empty = (
13
- <div>No HTML Rendition Available</div>
14
- )
15
-
16
- if (renditions == null || renditions.length == 0) return empty;
17
-
18
- const filteredRenditions = renditions.filter((item) => item.format == "application/xhtml+xml");
19
-
20
- if (filteredRenditions.length == 0 || filteredRenditions[0] == undefined || filteredRenditions[0].links == undefined) return empty;
21
-
22
- const filteredLinks = filteredRenditions[0].links.filter((item) => item.rel == "view");
23
13
 
24
- if (filteredLinks.length == 0 || filteredLinks[0] == undefined || filteredLinks[0].href == undefined) return empty;
14
+ const articleRender = (html: string) => {
25
15
 
26
- const url = filteredLinks[0].href;
27
- const html = await fetch(url, {
28
- method: "GET",
29
- headers: {
30
- Accept: "application/xhtml+xml",
31
- },
32
- }).then(res => res.text());
16
+ const $ = cheerio.load(html)
33
17
 
34
- const $ = cheerio.load(html)
18
+ const metaTags = $("meta").map((_, el) => {
19
+ const name = $(el).attr("name")
20
+ const content = $(el).attr("content")
21
+ return name && content ? { name, content } : null
22
+ }).get().filter(Boolean)
35
23
 
36
- const metaTags = $("meta").map((_, el) => {
37
- const name = $(el).attr("name")
38
- const content = $(el).attr("content")
39
- return name && content ? { name, content } : null
40
- }).get().filter(Boolean)
24
+ const articleHtml = $("main").html() || ""
41
25
 
42
- const articleHtml = $("main").html() || ""
26
+ return (
27
+ <>
28
+ {metaTags.map((tag) => (
29
+ <meta key={`${tag.name}-${tag.content}`} name={tag.name} content={tag.content} />
30
+ ))}
31
+ <div className="pr-4 relative">
32
+ <RenderArticle htmlContent={articleHtml} contentLang="" />
33
+ </div>
34
+ <ArticleActionBar />
35
+ </>
36
+ )
37
+ }
43
38
 
44
39
  return (
45
- <>
46
- {metaTags.map((tag) => (
47
- <meta key={`${tag.name}-${tag.content}`} name={tag.name} content={tag.content} />
48
- ))}
49
- <div className="pr-4 relative">
50
- <RenderArticle htmlContent={articleHtml} contentLang="" />
51
- </div>
52
- <ArticleActionBar />
53
- </>
40
+ <HtmlRendition
41
+ renditions={renditions}
42
+ render={articleRender}
43
+ shortId=""
44
+ />
54
45
  )
55
46
  }
@@ -1,14 +1,14 @@
1
1
  "use client"
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
- import { Input } from "@c-rex/ui/input";
4
+ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@c-rex/ui/input-group";
5
5
  import { useTranslations } from "next-intl";
6
6
  import { SuggestionQueryParams } from "@c-rex/interfaces";
7
-
8
- import { useSearchSettingsStore } from "./stores/search-settings-store";
9
- import { useRouter } from 'next/navigation';
7
+ import { X } from "lucide-react";
8
+ import { useRouter, useSearchParams } from "next/navigation";
10
9
  import { suggestionRequest } from "./generated/create-suggestions-request";
11
10
  import { useQueryState } from "nuqs";
11
+ import { useSearchSettingsStore } from "./stores/search-settings-store";
12
12
 
13
13
  export type AutoCompleteProps = {
14
14
  initialValue?: string;
@@ -18,11 +18,13 @@ export type AutoCompleteProps = {
18
18
  onSelectParams?: { key: string, value: string }[];
19
19
  onSelectPath: string
20
20
  inputClass?: string
21
+ keepParams?: boolean;
21
22
  };
22
23
 
23
24
  export const AutoComplete = ({
24
25
  initialValue = "",
25
26
  embedded = true,
27
+ keepParams = false,
26
28
  endpoint,
27
29
  queryParams,
28
30
  onSelectParams,
@@ -33,7 +35,9 @@ export const AutoComplete = ({
33
35
  const [pkg] = useQueryState("package");
34
36
 
35
37
  const containerRef = useRef<HTMLDivElement>(null);
38
+ const searchParams = useSearchParams();
36
39
  const router = useRouter();
40
+
37
41
  const [open, setOpen] = useState(false);
38
42
  const [query, setQuery] = useState(initialValue);
39
43
  const [loading, setLoading] = useState(false);
@@ -41,40 +45,62 @@ export const AutoComplete = ({
41
45
 
42
46
  const fetchSuggestions = useCallback(async (prefix: string): Promise<string[]> => {
43
47
  const params = { ...queryParams };
48
+ const contentLang = useSearchSettingsStore.getState().language;
49
+ const restrictions = searchParams.get("restrict")
50
+
51
+ if (restrictions) {
52
+ if (restrictions.includes("informationSubjects")) {
53
+
54
+ const informationSubject = restrictions.split("informationSubjects=")[1]?.split(',') || [];
55
+ if (informationSubject.length > 0) {
56
+ params.scopes = informationSubject.map(subject => `informationSubjects=${subject}`);
57
+ }
58
+ }
59
+ }
60
+
61
+ if (contentLang) params.lang = contentLang;
44
62
  if (pkg != null) params.scopes = pkg as unknown as string[];
45
63
 
46
64
  const results = await suggestionRequest({ endpoint, prefix, queryParams: params });
47
65
 
48
66
  return results.data;
49
- }, [endpoint, queryParams]);
67
+ }, [endpoint, pkg, queryParams, searchParams]);
50
68
 
51
69
  const handleSelect = (value: string) => {
52
70
  setQuery(value);
53
71
  setOpen(false);
54
- onSelect(value);
55
- };
56
72
 
57
- const onSelect = (value: string) => {
58
- const params = new URLSearchParams();
59
- const searchSettings = useSearchSettingsStore.getState();
73
+ const nextParams = new URLSearchParams(keepParams ? searchParams.toString() : "");
74
+ nextParams.set("page", "1");
60
75
 
61
- params.set('search', value);
62
- params.set("page", "1")
63
- params.set("operator", searchSettings.operator)
64
- if (searchSettings.language) params.set("language", searchSettings.language)
65
- params.set("wildcard", searchSettings.wildcard)
66
- params.set("like", searchSettings.like.toString())
76
+ if (value.length > 0) {
77
+ nextParams.set("search", value);
78
+ } else {
79
+ nextParams.delete("search");
80
+ };
67
81
 
68
82
  onSelectParams?.forEach(param => {
69
- params.set(param.key, param.value);
83
+ nextParams.set(param.key, param.value);
70
84
  });
71
85
 
72
- router.push(`${onSelectPath}?${params.toString()}`);
86
+ router.push(`${onSelectPath}?${nextParams.toString()}`);
73
87
  };
74
88
 
75
- useEffect(() => {
76
- setQuery(initialValue);
77
- }, [initialValue]);
89
+ const clearSearch = () => {
90
+ setQuery("");
91
+ setOpen(false);
92
+ const nextParams = new URLSearchParams(keepParams ? searchParams.toString() : "");
93
+ nextParams.set("page", "1");
94
+ nextParams.delete("search");
95
+ const queryString = nextParams.toString();
96
+
97
+ onSelectParams?.forEach(param => {
98
+ nextParams.set(param.key, param.value);
99
+ });
100
+ router.push(queryString ? `${onSelectPath}?${queryString}` : onSelectPath);
101
+ };
102
+
103
+ useEffect(() => setQuery(initialValue), [initialValue]);
78
104
 
79
105
  useEffect(() => {
80
106
  const handleClickOutside = (e: MouseEvent) => {
@@ -105,23 +131,37 @@ export const AutoComplete = ({
105
131
 
106
132
  return (
107
133
  <div className="relative flex-1" ref={containerRef}>
108
- <Input
109
- variant={embedded ? "embedded" : undefined}
110
- className={inputClass}
111
- type="text"
112
- placeholder={t("search")}
113
- value={query}
114
- onChange={(e) => {
115
- setQuery(e.target.value);
116
- setOpen(true);
117
- }}
118
- onKeyDown={(e) => {
119
- if (e.key === "Enter") {
120
- e.preventDefault();
121
- handleSelect(query);
122
- }
123
- }}
124
- />
134
+ <InputGroup variant={embedded ? "embedded" : "default"}>
135
+ <InputGroupInput
136
+ variant={embedded ? "embedded" : undefined}
137
+ className={inputClass}
138
+ type="text"
139
+ placeholder={t("search")}
140
+ value={query}
141
+ onChange={(e) => {
142
+ setQuery(e.target.value);
143
+ setOpen(true);
144
+ }}
145
+ onKeyDown={(e) => {
146
+ if (e.key === "Enter") {
147
+ e.preventDefault();
148
+ handleSelect(query);
149
+ }
150
+ }}
151
+ />
152
+ {query.length > 0 && (
153
+ <InputGroupAddon align="inline-end">
154
+ <InputGroupButton
155
+ size="icon-xs"
156
+ variant="ghost"
157
+ aria-label="Clear search"
158
+ onClick={clearSearch}
159
+ >
160
+ <X className="size-3" />
161
+ </InputGroupButton>
162
+ </InputGroupAddon>
163
+ )}
164
+ </InputGroup>
125
165
 
126
166
  {open && (
127
167
  <ul className="suggestions-list absolute z-10 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
@@ -139,7 +179,11 @@ export const AutoComplete = ({
139
179
  <li
140
180
  key={option}
141
181
  className="px-4 py-2 hover:bg-accent cursor-pointer text-sm"
142
- onClick={() => handleSelect(`"${option}"`)}
182
+ onClick={() => {
183
+ //handleSelect(`"${option}"`)
184
+ // TODO: check if the quotes are necessary
185
+ handleSelect(option)
186
+ }}
143
187
  >
144
188
  {option}
145
189
  </li>
@@ -22,14 +22,33 @@ import { Badge } from "@c-rex/ui/badge";
22
22
  import { Flag } from "../icons/flag-icon"
23
23
  import { Empty } from "../results/empty";
24
24
  import { useLocale, useTranslations } from "next-intl";
25
+ import Link from "next/link";
26
+ import { useBreakpoint } from "@c-rex/ui/hooks";
27
+ import { DEVICE_OPTIONS } from "@c-rex/constants";
25
28
 
26
- // Types
27
-
28
- type ResponsiveSetting = {
29
- maxWidth: number;
30
- slidesToShow: number;
29
+ type PageInfo = {
30
+ hasNextPage: boolean;
31
+ hasPreviousPage: boolean;
32
+ pageCount: number;
33
+ };
34
+ type Props = {
35
+ className?: string;
36
+ arrows?: boolean;
37
+ autoplay?: boolean;
38
+ autoplaySpeed?: number;
39
+ itemsByRow?: {
40
+ [DEVICE_OPTIONS.MOBILE]: number,
41
+ [DEVICE_OPTIONS.TABLET]: number,
42
+ [DEVICE_OPTIONS.DESKTOP]: number,
43
+ };
44
+ indicators?: boolean;
45
+ showImages?: boolean;
46
+ carouselItemComponent?: FC<{ item: CommonItemsModel; showImages: boolean; linkPattern: string }>;
47
+ serviceType: keyof typeof ServiceOptions;
48
+ queryParams?: Record<string, any>;
49
+ loadByPages?: boolean;
50
+ linkPattern: string;
31
51
  };
32
-
33
52
  type CarouselContextProps = {
34
53
  next: () => void;
35
54
  prev: () => void;
@@ -54,30 +73,15 @@ export function useCarousel() {
54
73
  return context;
55
74
  }
56
75
 
57
- type PageInfo = {
58
- hasNextPage: boolean;
59
- hasPreviousPage: boolean;
60
- pageCount: number;
61
- };
62
- type Props = {
63
- className?: string;
64
- arrows?: boolean;
65
- autoplay?: boolean;
66
- autoplaySpeed?: number;
67
- responsive?: ResponsiveSetting[];
68
- indicators?: boolean;
69
- showImages?: boolean;
70
- carouselItemComponent?: FC<{ item: CommonItemsModel; showImages: boolean }>;
71
- serviceType: keyof typeof ServiceOptions;
72
- queryParams?: Record<string, any>;
73
- loadByPages?: boolean;
74
- };
75
-
76
76
  export const Carousel: FC<Props> = ({
77
77
  className,
78
78
  arrows = false,
79
79
  autoplay = false,
80
- responsive = [{ maxWidth: 99999, slidesToShow: 1 }],
80
+ itemsByRow = {
81
+ [DEVICE_OPTIONS.MOBILE]: 1,
82
+ [DEVICE_OPTIONS.TABLET]: 3,
83
+ [DEVICE_OPTIONS.DESKTOP]: 4,
84
+ },
81
85
  indicators = false,
82
86
  autoplaySpeed = 3000,
83
87
  showImages = false,
@@ -85,29 +89,18 @@ export const Carousel: FC<Props> = ({
85
89
  serviceType,
86
90
  queryParams = {},
87
91
  loadByPages = false,
92
+ linkPattern
88
93
  }) => {
89
94
  const service = ServiceOptions[serviceType] as typeof documentsGetAllClientService;
90
95
  const RenderComponent = carouselItemComponent || DefaultRenderCarouselItem;
91
96
 
92
- // Responsive
93
- const [slidesToShow, setSlidesToShow] = useState(responsive[0]?.slidesToShow || 1);
94
- const updateSlidesToShow = useCallback(() => {
95
- const width = window.innerWidth;
96
- const sorted = [...responsive].sort((a, b) => a.maxWidth - b.maxWidth);
97
- let found = sorted[sorted.length - 1]?.slidesToShow || 1;
98
- for (const r of sorted) {
99
- if (width <= r.maxWidth) {
100
- found = r.slidesToShow;
101
- break;
102
- }
103
- }
104
- setSlidesToShow(found);
105
- }, [responsive]);
97
+ const device = useBreakpoint();
98
+ const [slidesToShow, setSlidesToShow] = useState(1);
99
+
106
100
  useEffect(() => {
107
- updateSlidesToShow();
108
- window.addEventListener("resize", updateSlidesToShow);
109
- return () => window.removeEventListener("resize", updateSlidesToShow);
110
- }, [updateSlidesToShow]);
101
+ if (device == null) return;
102
+ setSlidesToShow(itemsByRow[device as keyof typeof DEVICE_OPTIONS] as number);
103
+ }, [device, itemsByRow]);
111
104
 
112
105
  // State
113
106
  const [current, setCurrent] = useState(0);
@@ -240,7 +233,7 @@ export const Carousel: FC<Props> = ({
240
233
  className={`flex-shrink-0 flex-grow-0 flex justify-center`}
241
234
  style={{ width: `${100 / slidesToShow}%` }}
242
235
  >
243
- <RenderComponent item={item} showImages={showImages} />
236
+ <RenderComponent item={item} showImages={showImages} linkPattern={linkPattern} />
244
237
  </div>
245
238
  ))
246
239
  ) : null}
@@ -258,7 +251,7 @@ const CarouselNext: FC = () => {
258
251
  const { next, current, slidesToShow, slidesLength, hasNextPage } = useCarousel();
259
252
  const disabled = hasNextPage === undefined ? current >= slidesLength - slidesToShow : !hasNextPage;
260
253
  return (
261
- <Button className="size-8" rounded="full" onClick={next} variant="ghost" disabled={disabled}>
254
+ <Button className="w-9" rounded="full" onClick={next} variant="default" disabled={disabled}>
262
255
  <ArrowRight />
263
256
  </Button>
264
257
  );
@@ -267,8 +260,9 @@ const CarouselNext: FC = () => {
267
260
  const CarouselPrev: FC = () => {
268
261
  const { prev, current, hasPrevPage } = useCarousel();
269
262
  const disabled = hasPrevPage === undefined ? current === 0 : !hasPrevPage;
263
+
270
264
  return (
271
- <Button className="size-8" rounded="full" onClick={prev} variant="ghost" disabled={disabled}>
265
+ <Button className="w-9" rounded="full" onClick={prev} variant="default" disabled={disabled}>
272
266
  <ArrowLeft />
273
267
  </Button>
274
268
  );
@@ -315,7 +309,11 @@ const CarouselIndicators: FC<{ className?: string }> = ({ className }) => {
315
309
  );
316
310
  };
317
311
 
318
- const DefaultRenderCarouselItem: FC<{ item: CommonItemsModel; showImages: boolean }> = ({ item, showImages }) => {
312
+ const DefaultRenderCarouselItem: FC<{
313
+ item: CommonItemsModel;
314
+ showImages: boolean;
315
+ linkPattern: string
316
+ }> = ({ item, showImages, linkPattern }) => {
319
317
  const locale = useLocale();
320
318
  const t = useTranslations("itemTypes");
321
319
 
@@ -338,7 +336,9 @@ const DefaultRenderCarouselItem: FC<{ item: CommonItemsModel; showImages: boolea
338
336
  />
339
337
  )}
340
338
 
341
- <span className="text-lg font-semibold">{title}</span>
339
+ <Link href={linkPattern.replace("{id}", item.shortId!)} className="hover:underline text-lg font-semibold flex-1">
340
+ {title}
341
+ </Link>
342
342
 
343
343
  <div className="flex justify-between w-full">
344
344
  <span className="w-8 block border">
@@ -2,14 +2,16 @@
2
2
 
3
3
  - Component: packages/components/src/favorites/bookmark-button.tsx
4
4
  - Type: Client component
5
- - Detected signals: useQueryStates=no, stores=yes, window/document=no, effects=no, fetch/call=no
5
+ - Detected signals: useQueryStates=no, stores=no, window/document=yes, effects=yes, fetch/call=no
6
6
 
7
7
  ## Possible re-render causes
8
- - Store updates for the selected document topics will re-render this component (expected and desired for live badge updates).
8
+ - Effects that update state can add extra renders on mount and dependency changes.
9
9
 
10
10
  ## Possible bugs/risks
11
- - Dynamic Tailwind class name interpolation (`text-${item.color}`) may be purged depending on Tailwind safelist config.
11
+ - Access to window/document needs care to avoid hydration mismatch and non-browser runtime issues.
12
+ - markersList is loaded only on mount/shortId change; later store updates may not refresh the badge.
12
13
 
13
14
  ## Recommended improvements
14
- - Keep the direct Zustand selector approach (`documents[shortId]?.topics`) to preserve reactive UI behavior.
15
- - If marker colors are dynamic, safelist all expected `text-*` classes or map colors to explicit class names.
15
+ - Keep browser-only access inside event handlers or useEffect, not in initial render paths.
16
+ - Read documents[shortId]?.topics directly through a Zustand selector to keep UI reactive.
17
+
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { ComponentProps, FC, useState } from "react";
4
- import { Button } from "@c-rex/ui/button";
3
+ import { ComponentProps, FC, ReactNode, useEffect, useState } from "react";
4
+ import { Button, ButtonProps } from "@c-rex/ui/button";
5
5
  import { Trash } from "lucide-react";
6
6
  import { cn } from "@c-rex/utils";
7
7
  import Link from "next/link";
@@ -20,18 +20,17 @@ type BookmarkProps = {
20
20
  triggerVariant?: ComponentProps<typeof Button>["variant"];
21
21
  }
22
22
 
23
- const EMPTY_TOPICS: Array<{ id: string; label: string; color: string }> = [];
24
-
25
23
  export const BookmarkButton: FC<BookmarkProps> = ({
26
24
  shortId,
27
25
  triggerVariant = "outline"
28
26
  }) => {
29
- const [open, setOpen] = useState(false);
30
- const document = useFavoritesStore((state) => state.documents[shortId]);
31
- const markersList = document?.topics ?? EMPTY_TOPICS;
27
+ const [markersList, setMarkersList] = useState<Array<{ id: string; label: string; color: string }>>([]);
32
28
 
29
+ useEffect(() => {
30
+ setMarkersList(useFavoritesStore.getState().documents[shortId]?.topics || []);
31
+ }, [shortId]);
33
32
  return (
34
- <Dialog open={open} onOpenChange={setOpen}>
33
+ <Dialog>
35
34
  <DialogTrigger asChild>
36
35
  <Button variant={triggerVariant} size="icon" className="relative">
37
36
  <FaRegBookmark className="text-primary" />
@@ -44,38 +43,37 @@ export const BookmarkButton: FC<BookmarkProps> = ({
44
43
  </span>
45
44
  )}
46
45
  </Button>
46
+
47
47
  </DialogTrigger>
48
- {open && (
49
- <DialogContent>
50
- <DialogHeader>
51
- <DialogTitle>Bookmarks</DialogTitle>
52
- <DialogDescription>
53
- Manage your bookmarks here
54
- </DialogDescription>
55
- </DialogHeader>
56
- <Table>
57
- <TableBody>
58
- {markersList.map((item) => (
59
- <TableRow key={item.id} className="min-h-12">
60
- <TableCell>
61
- <FaRegBookmark className={cn("w-5", `text-${item.color}`)} />
62
- </TableCell>
63
- <TableCell>
64
- <Link href={`/topics/${item.id}`}>
65
- {item.label}
66
- </Link>
67
- </TableCell>
68
- <TableCell>
69
- <Button variant="destructive" size="icon">
70
- <Trash className="w-5 hover:text-red-600 cursor-pointer" />
71
- </Button>
72
- </TableCell>
73
- </TableRow>
74
- ))}
75
- </TableBody>
76
- </Table>
77
- </DialogContent>
78
- )}
48
+ <DialogContent>
49
+ <DialogHeader>
50
+ <DialogTitle>Bookmarks</DialogTitle>
51
+ <DialogDescription>
52
+ Manage your bookmarks here
53
+ </DialogDescription>
54
+ </DialogHeader>
55
+ <Table>
56
+ <TableBody>
57
+ {markersList.map((item) => (
58
+ <TableRow key={item.id} className="min-h-12">
59
+ <TableCell>
60
+ <FaRegBookmark className={cn("w-5", `text-${item.color}`)} />
61
+ </TableCell>
62
+ <TableCell>
63
+ <Link href={`${window.location.origin}/topics/${item.id}`}>
64
+ {item.label}
65
+ </Link>
66
+ </TableCell>
67
+ <TableCell>
68
+ <Button variant="destructive" size="icon">
69
+ <Trash className="w-5 hover:text-red-600 cursor-pointer" />
70
+ </Button>
71
+ </TableCell>
72
+ </TableRow>
73
+ ))}
74
+ </TableBody>
75
+ </Table>
76
+ </DialogContent>
79
77
  </Dialog>
80
78
  )
81
79
  }
@@ -10,8 +10,9 @@
10
10
 
11
11
  ## Possible bugs/risks
12
12
  - Async flows can hit race conditions during fast param/route changes.
13
- - Residual risk: fetch errors are silently ignored (except abort), which can hide API issues.
13
+ - useEffect with empty deps calls getTopicDocumentData(id) and does not react if id/type changes.
14
14
 
15
15
  ## Recommended improvements
16
- - Keep cancellation guards (AbortController + unmount guard) in place as currently implemented.
17
- - Consider surfacing non-abort fetch failures through logging or telemetry.
16
+ - Consider cancellation guards (isMounted or AbortController) and standardized error handling.
17
+ - Include id and type in effect dependencies and handle fetch cancellation on unmount.
18
+
@@ -19,32 +19,10 @@ export const FavoriteButton: FC<{ id: string, type: ResultTypes, label: string }
19
19
  const [documentData, setDocumentData] = useState<{ id: string, label: string }>({ id, label });
20
20
 
21
21
  useEffect(() => {
22
- if (type !== RESULT_TYPES.TOPIC) {
23
- setDocumentData({ id, label });
24
- return;
22
+ if (type === RESULT_TYPES.TOPIC) {
23
+ getTopicDocumentData(id);
25
24
  }
26
-
27
- let isCancelled = false;
28
- const controller = new AbortController();
29
-
30
- const loadTopicDocumentData = async () => {
31
- try {
32
- const data = await getTopicDocumentData(id, controller.signal);
33
- if (!isCancelled) {
34
- setDocumentData(data);
35
- }
36
- } catch (error) {
37
- if ((error as Error).name === "AbortError") return;
38
- }
39
- };
40
-
41
- loadTopicDocumentData();
42
-
43
- return () => {
44
- isCancelled = true;
45
- controller.abort();
46
- };
47
- }, [id, label, type]);
25
+ }, []);
48
26
 
49
27
  const addFavorite = async (id: string) => {
50
28
  if (type === RESULT_TYPES.DOCUMENT) {
@@ -67,18 +45,17 @@ export const FavoriteButton: FC<{ id: string, type: ResultTypes, label: string }
67
45
  removeFavoriteTopic(documentData.id, id);
68
46
  }
69
47
 
70
- const getTopicDocumentData = async (topicId: string, signal?: AbortSignal): Promise<{ id: string, label: string }> => {
48
+ const getTopicDocumentData = async (topicId: string): Promise<void> => {
71
49
 
72
50
  const response = await fetch(`/api/information-units/document-by-topic-id?shortId=${topicId}`, {
73
- method: "GET",
74
- signal,
51
+ method: "GET"
75
52
  });
76
53
 
77
54
  if (!response.ok) throw new Error("Failed to fetch document by topic id")
78
55
 
79
- const { documentId, label: documentLabel } = await response.json();
56
+ const { documentId, label } = await response.json();
80
57
 
81
- return { id: documentId, label: documentLabel };
58
+ setDocumentData({ id: documentId, label });
82
59
  }
83
60
 
84
61
  if (isFavorite) {
@@ -30,7 +30,7 @@ export const NavBar: FC<NavBarProps> = async ({
30
30
  showInput,
31
31
  autocompleteType,
32
32
  onSelectPath,
33
- showMenu = false,
33
+ showMenu = true,
34
34
  ...props
35
35
  }) => {
36
36
  const t = await getTranslations();
@@ -75,27 +75,32 @@ export const NavBar: FC<NavBarProps> = async ({
75
75
  </Button>
76
76
  }
77
77
  >
78
- <Button asChild variant="link">
78
+ <Button asChild variant="link" className="flex justify-start">
79
+ <Link href="/">
80
+ {t('navigation.home')}
81
+ </Link>
82
+ </Button>
83
+ <Button asChild variant="link" className="flex justify-start">
79
84
  <Link href="/documents">
80
85
  {t('navigation.documents')}
81
86
  </Link>
82
87
  </Button>
83
- <Button asChild variant="link">
88
+ <Button asChild variant="link" className="flex justify-start">
84
89
  <Link href="/topics">
85
90
  {t('navigation.topics')}
86
91
  </Link>
87
92
  </Button>
88
- <Button asChild variant="link">
93
+ <Button asChild variant="link" className="flex justify-start">
89
94
  <Link href="/fragments">
90
95
  {t('navigation.fragments')}
91
96
  </Link>
92
97
  </Button>
93
- <Button asChild variant="link">
98
+ <Button asChild variant="link" className="flex justify-start">
94
99
  <Link href="/packages">
95
100
  {t('navigation.packages')}
96
101
  </Link>
97
102
  </Button>
98
- <Button asChild variant="link">
103
+ <Button asChild variant="link" className="flex justify-start">
99
104
  <Link href="/information-units">
100
105
  {t('navigation.informationUnits')}
101
106
  </Link>
@@ -1,11 +1,14 @@
1
1
  import { FC, JSX } from "react";
2
2
  import * as cheerio from "cheerio"
3
- import { FragmentsGetAll } from "../generated/server-components";
3
+ import { RenditionModel } from "@c-rex/interfaces";
4
+ import { fragmentsGetAllServer } from "@c-rex/services/server-requests";
5
+ import { call } from "@c-rex/utils";
4
6
 
5
7
  interface HtmlRenditionProps {
6
8
  htmlFormats?: string[]
7
9
  shortId: string,
8
- render?: (html: string) => JSX.Element
10
+ render?: (html: string) => JSX.Element,
11
+ renditions?: RenditionModel[] | null
9
12
  }
10
13
 
11
14
  const defaultRender = (html: string) => {
@@ -14,45 +17,49 @@ const defaultRender = (html: string) => {
14
17
  return <div dangerouslySetInnerHTML={{ __html: articleHtml }} />;
15
18
  }
16
19
 
17
- export const HtmlRendition: FC<HtmlRenditionProps> = ({
20
+ export const HtmlRendition: FC<HtmlRenditionProps> = async ({
18
21
  shortId,
19
22
  htmlFormats = ["application/xhtml+xml", "application/html", "text/html"],
20
- render
23
+ render = defaultRender,
24
+ renditions
21
25
  }) => {
22
26
  const empty = <div>No rendition available</div>;
23
27
 
24
- return (
25
- <FragmentsGetAll
26
- queryParams={{
27
- Fields: ["titles", "renditions"],
28
- Embed: ["renditions"],
29
- PageSize: 1,
30
- Links: true,
31
- Restrict: [
32
- `informationUnits=${shortId}`,
33
- ...htmlFormats.map(format => `renditions.format=${format}`)
34
- ],
28
+ if (renditions == undefined) {
29
+ const result = await fragmentsGetAllServer({
30
+ Fields: ["titles", "renditions"],
31
+ Embed: ["renditions"],
32
+ PageSize: 1,
33
+ Links: true,
34
+ Restrict: [
35
+ `informationUnits=${shortId}`,
36
+ ...htmlFormats.map(format => `renditions.format=${format}`)
37
+ ],
38
+ })
35
39
 
36
- }}
37
- render={async (data, error) => {
38
- if (error) {
39
- return <div>Error loading content</div>;
40
- }
40
+ renditions = result.items?.[0]?.renditions;
41
+ }
41
42
 
42
- const renditions = data?.items?.[0]?.renditions;
43
+ if (renditions == null || renditions.length == 0) return empty;
44
+ renditions = renditions.filter(rendition => htmlFormats.includes(rendition.format!));
43
45
 
44
- if (renditions == null || renditions.length == 0) return empty;
45
- if (renditions.length == 0 || renditions[0] == undefined || renditions[0].links == undefined) return empty;
46
+ if (renditions.length == 0 || renditions[0] == undefined || renditions[0].links == undefined) return empty;
46
47
 
47
- const filteredLinks = renditions[0].links.filter((item) => item.rel == "view");
48
+ const filteredLinks = renditions[0].links.filter((item) => item.rel == "view");
48
49
 
49
- if (filteredLinks.length == 0 || filteredLinks[0] == undefined || filteredLinks[0].href == undefined) return empty;
50
+ if (filteredLinks.length == 0 || filteredLinks[0] == undefined || filteredLinks[0].href == undefined) return empty;
50
51
 
51
- const url = filteredLinks[0].href;
52
- const html = await fetch(url).then(res => res.text());
52
+ const url = filteredLinks[0].href;
53
+ try {
53
54
 
54
- return render ? render(html) : defaultRender(html);
55
- }}
56
- />
57
- )
55
+ const html = await fetch(url).then(res => res.text());
56
+ return render(html);
57
+ } catch (error) {
58
+ call("CrexLogger.log", {
59
+ level: "error",
60
+ message: `HtmlRendition error: ${error}`
61
+ });
62
+
63
+ return empty;
64
+ }
58
65
  }
@@ -77,23 +77,26 @@ export const RestrictionDropdownItem: FC<Props> = ({
77
77
  shortId,
78
78
  label,
79
79
  restrictField,
80
+ selected = false,
80
81
  }) => {
81
82
  const [restrict, setRestrict] = useQueryState("restrict", {
82
83
  shallow: false,
83
84
  history: "push",
84
85
  });
85
86
 
86
- const { restrictionValue } = getRestrictionValue({
87
+ const { restrictionValue, shouldRemoveRestrictParam } = getRestrictionValue({
87
88
  shortId,
88
89
  restrictField,
90
+ selected,
89
91
  currentRestrict: restrict,
90
92
  });
91
93
 
92
94
  return (
93
95
  <Button
94
- variant="ghost"
95
- onClick={() => setRestrict(restrictionValue)}
96
- className="text-left text-wrap !h-auto w-full !justify-start cursor-pointer"
96
+ variant={selected ? "default" : "ghost"}
97
+ onClick={() => shouldRemoveRestrictParam ? setRestrict(null) : setRestrict(restrictionValue)}
98
+ rounded="full"
99
+ className="text-left text-wrap !h-auto min-h-10 w-full !justify-start cursor-pointer"
97
100
  >
98
101
  {label}
99
102
  </Button>
@@ -141,4 +144,4 @@ function getRestrictionValue({
141
144
  const restrictionValue = shouldRemoveRestrictParam ? null : restrictParam
142
145
 
143
146
  return { restrictionValue, shouldRemoveRestrictParam };
144
- }
147
+ }
@@ -5,10 +5,9 @@ import {
5
5
  NavigationMenu,
6
6
  NavigationMenuList,
7
7
  NavigationMenuItem,
8
+ NavigationMenuTrigger,
9
+ NavigationMenuContent,
8
10
  } from "@c-rex/ui/navigation-menu";
9
- import { Button } from "@c-rex/ui/button";
10
- import { ChevronDown } from "lucide-react";
11
- import { DropdownHoverItem } from "@c-rex/ui/dropdown-hover-item";
12
11
  import { RestrictionDropdownItem, RestrictionNavigationItem } from "./restriction-menu-item";
13
12
  import { parseAsString, useQueryStates } from "nuqs";
14
13
  import { InformationSubjectModel } from "@c-rex/interfaces";
@@ -110,13 +109,13 @@ export const RestrictionMenu: FC<Props> = ({
110
109
  }, [sortedItems, visibleCount]);
111
110
 
112
111
  return (
113
- <NavigationMenu className="max-w-full w-full c-rex-restriction-menu">
112
+ <NavigationMenu viewport={false} className="max-w-full w-full c-rex-restriction-menu">
114
113
  <NavigationMenuList className={cn("w-full", navigationMenuListClassName)}>
115
114
 
116
115
 
117
116
  <RestrictionNavigationItem
118
117
  removeRestrictParam
119
- label="All"
118
+ label={t('all')}
120
119
  selected={restrictionValues.length === 0}
121
120
  />
122
121
 
@@ -131,23 +130,26 @@ export const RestrictionMenu: FC<Props> = ({
131
130
  ))}
132
131
 
133
132
  <NavigationMenuItem>
134
- <DropdownHoverItem
135
- label={
136
- <Button variant="outline" rounded="full">
137
- More <ChevronDown className="size-4" />
138
- </Button>
139
- }
140
- >
141
- {hiddenItems.map((item) => (
142
- <RestrictionDropdownItem
143
- key={item.shortId}
144
- shortId={item.shortId!}
145
- restrictField={restrictField as string}
146
- label={getLabelByLang(item.labels, lang)}
147
- selected={restrictionValues.includes(item.shortId!)}
148
- />
149
- ))}
150
- </DropdownHoverItem>
133
+ <NavigationMenuTrigger>
134
+ {t('more')}
135
+ </NavigationMenuTrigger>
136
+ <NavigationMenuContent className="w-96 sm:w-[] md:w-[700px] lg:w-[65rem]">
137
+ <ul className="grid gap-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4">
138
+ {hiddenItems.map((item) => (
139
+ <li
140
+ key={item.shortId}
141
+ className="flex items-center"
142
+ >
143
+ <RestrictionDropdownItem
144
+ shortId={item.shortId!}
145
+ restrictField={restrictField as string}
146
+ label={getLabelByLang(item.labels, lang)}
147
+ selected={restrictionValues.includes(item.shortId!)}
148
+ />
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ </NavigationMenuContent>
151
153
  </NavigationMenuItem>
152
154
  </NavigationMenuList>
153
155
  </NavigationMenu>
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import { FC } from "react";
3
+ import { FC, MouseEvent } from "react";
4
4
  import {
5
5
  Pagination as PaginationUI,
6
6
  PaginationContent,
@@ -19,15 +19,25 @@ interface PaginationProps {
19
19
  export const Pagination: FC<PaginationProps> = ({ pageInfo }) => {
20
20
  const disabledClass = "opacity-50 pointer-events-none";
21
21
 
22
- const [_, setPage] = useQueryState('page',
22
+ const [, setPage] = useQueryState('page',
23
23
  parseAsInteger.withOptions({
24
24
  history: 'push',
25
25
  shallow: false,
26
26
  })
27
27
  )
28
28
 
29
- const onChangePage = (pageNumber: number) => {
30
- setPage(pageNumber);
29
+ const onChangePage = (event: MouseEvent<HTMLAnchorElement>, pageNumber: number) => {
30
+ event.preventDefault();
31
+
32
+ if (
33
+ pageNumber < 1
34
+ || (pageInfo.pageCount !== undefined && pageNumber > pageInfo.pageCount)
35
+ || pageNumber === pageInfo.pageNumber
36
+ ) {
37
+ return;
38
+ }
39
+
40
+ void setPage(pageNumber);
31
41
  }
32
42
 
33
43
  return (
@@ -37,7 +47,7 @@ export const Pagination: FC<PaginationProps> = ({ pageInfo }) => {
37
47
  <PaginationPrevious
38
48
  href="#"
39
49
  className={pageInfo.pageNumber === 1 ? disabledClass : ""}
40
- onClick={() => onChangePage(pageInfo.pageNumber! - 1)}
50
+ onClick={(event) => onChangePage(event, pageInfo.pageNumber! - 1)}
41
51
  />
42
52
  </PaginationItem>
43
53
 
@@ -46,7 +56,7 @@ export const Pagination: FC<PaginationProps> = ({ pageInfo }) => {
46
56
  <PaginationLink
47
57
  href="#"
48
58
  isActive={page === pageInfo.pageNumber}
49
- onClick={() => onChangePage(page)}
59
+ onClick={(event) => onChangePage(event, page)}
50
60
  >
51
61
  {page}
52
62
  </PaginationLink>
@@ -61,11 +71,11 @@ export const Pagination: FC<PaginationProps> = ({ pageInfo }) => {
61
71
  <PaginationItem>
62
72
  <PaginationNext
63
73
  href="#"
64
- onClick={() => onChangePage(pageInfo.pageNumber! + 1)}
74
+ onClick={(event) => onChangePage(event, pageInfo.pageNumber! + 1)}
65
75
  className={pageInfo.pageNumber === pageInfo.pageCount ? disabledClass : ""}
66
76
  />
67
77
  </PaginationItem>
68
78
  </PaginationContent>
69
79
  </PaginationUI>
70
80
  )
71
- }
81
+ }
@@ -44,7 +44,7 @@ export const SearchInput: FC<Props> = ({
44
44
  <>
45
45
  <Search className="shrink-0 opacity-50" />
46
46
 
47
- {/* Add scope=pkgID if checked is true */}
47
+
48
48
  <AutocompleteComponent
49
49
  onSelectParams={
50
50
  (pkg && checked && showPkgFilter) ? [{ key: "packages", value: pkg }] : []
@@ -60,29 +60,29 @@ export const useFavoritesStore = create<FavoritesStore>()(
60
60
 
61
61
 
62
62
  const favoriteTopic = (documents: Record<string, { topics: Favorite[] }>, documentId: string, id: string, label: string, color: string): Record<string, { topics: Favorite[] }> => {
63
- const currentDocument = documents[documentId];
64
- const topics = currentDocument?.topics || [];
65
-
66
- return {
67
- ...documents,
68
- [documentId]: {
69
- ...(currentDocument?.label ? { label: currentDocument.label } : {}),
70
- topics: [...topics, { id, label, color }],
71
- },
72
- };
63
+
64
+ const documentsCopy = { ...documents };
65
+ const notFound = documents[documentId] == undefined;
66
+
67
+ if (notFound) {
68
+ documentsCopy[documentId] = { topics: [] };
69
+ }
70
+
71
+ documentsCopy[documentId]!.topics.push({ id, label, color });
72
+
73
+ return documentsCopy
73
74
  };
74
75
 
75
76
  const unfavoriteTopic = (documents: Record<string, { topics: Favorite[] }>, documentId: string, id: string): Record<string, { topics: Favorite[] }> => {
76
- const currentDocument = documents[documentId];
77
- if (!currentDocument) {
78
- return documents;
77
+
78
+ const documentsCopy = { ...documents };
79
+ const notFound = documents[documentId] == undefined;
80
+
81
+ if (notFound) {
82
+ return documentsCopy;
79
83
  }
80
84
 
81
- return {
82
- ...documents,
83
- [documentId]: {
84
- ...(currentDocument.label ? { label: currentDocument.label } : {}),
85
- topics: currentDocument.topics.filter((topic) => topic.id !== id),
86
- },
87
- };
88
- }
85
+ documentsCopy[documentId]!.topics = documentsCopy[documentId]!.topics.filter(topic => topic.id !== id);
86
+
87
+ return documentsCopy
88
+ }