@c-rex/components 0.1.36 → 0.1.38

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 (95) hide show
  1. package/package.json +1 -18
  2. package/src/article/article-content.tsx +26 -35
  3. package/src/autocomplete.tsx +26 -5
  4. package/src/carousel/carousel.tsx +53 -52
  5. package/src/check-article-lang.tsx +8 -4
  6. package/src/favorites/bookmark-button.tsx +26 -11
  7. package/src/favorites/favorite-button.tsx +67 -21
  8. package/src/info/info-table.tsx +60 -38
  9. package/src/info/set-available-versions.tsx +19 -0
  10. package/src/navbar/language-switcher/content-language-switch.tsx +36 -36
  11. package/src/navbar/language-switcher/ui-language-switch.tsx +5 -6
  12. package/src/navbar/navbar.tsx +17 -8
  13. package/src/renditions/html.tsx +38 -31
  14. package/src/restriction-menu/restriction-menu-item.tsx +8 -5
  15. package/src/restriction-menu/restriction-menu.tsx +26 -25
  16. package/src/results/generic/table-result-list.tsx +1 -1
  17. package/src/results/utils.ts +9 -2
  18. package/src/stores/favorites-store.ts +21 -21
  19. package/src/stores/language-store.ts +2 -31
  20. package/src/article/article-action-bar.analysis.md +0 -15
  21. package/src/article/article-action-bar.stories.tsx +0 -15
  22. package/src/article/article-content.analysis.md +0 -15
  23. package/src/article/article-content.stories.tsx +0 -21
  24. package/src/autocomplete.analysis.md +0 -17
  25. package/src/breadcrumb.analysis.md +0 -15
  26. package/src/carousel/carousel.analysis.md +0 -17
  27. package/src/check-article-lang.analysis.md +0 -15
  28. package/src/directoryNodes/tree-of-content.analysis.md +0 -14
  29. package/src/directoryNodes/tree-of-content.stories.tsx +0 -22
  30. package/src/documents/result-list.analysis.md +0 -14
  31. package/src/documents/result-list.stories.tsx +0 -19
  32. package/src/favorites/bookmark-button.analysis.md +0 -17
  33. package/src/favorites/bookmark-button.stories.tsx +0 -19
  34. package/src/favorites/favorite-button.analysis.md +0 -18
  35. package/src/favorites/favorite-button.stories.tsx +0 -22
  36. package/src/icons/file-icon.analysis.md +0 -14
  37. package/src/icons/file-icon.stories.tsx +0 -19
  38. package/src/icons/flag-icon.analysis.md +0 -14
  39. package/src/icons/flag-icon.stories.tsx +0 -25
  40. package/src/icons/loading.analysis.md +0 -14
  41. package/src/icons/loading.stories.tsx +0 -21
  42. package/src/info/info-table.analysis.md +0 -15
  43. package/src/info/shared.analysis.md +0 -14
  44. package/src/info/stories/info-table.stories.tsx +0 -31
  45. package/src/info/stories/shared.stories.tsx +0 -24
  46. package/src/navbar/language-switcher/content-language-switch.analysis.md +0 -15
  47. package/src/navbar/language-switcher/shared.analysis.md +0 -14
  48. package/src/navbar/language-switcher/ui-language-switch.analysis.md +0 -15
  49. package/src/navbar/navbar.analysis.md +0 -14
  50. package/src/navbar/settings.analysis.md +0 -14
  51. package/src/navbar/sign-in-out-btns.analysis.md +0 -14
  52. package/src/navbar/stories/navbar.stories.tsx +0 -31
  53. package/src/navbar/stories/settings.stories.tsx +0 -15
  54. package/src/navbar/stories/sign-in-out-btns.stories.tsx +0 -15
  55. package/src/navbar/stories/user-menu.stories.tsx +0 -20
  56. package/src/navbar/user-menu.analysis.md +0 -14
  57. package/src/page-wrapper.analysis.md +0 -14
  58. package/src/render-article.analysis.md +0 -15
  59. package/src/renditions/file-download.analysis.md +0 -14
  60. package/src/renditions/file-download.stories.tsx +0 -19
  61. package/src/renditions/html.analysis.md +0 -17
  62. package/src/renditions/html.stories.tsx +0 -19
  63. package/src/renditions/image/container.analysis.md +0 -15
  64. package/src/renditions/image/container.stories.tsx +0 -19
  65. package/src/renditions/image/rendition.analysis.md +0 -14
  66. package/src/renditions/image/rendition.stories.tsx +0 -19
  67. package/src/restriction-menu/restriction-menu-container.analysis.md +0 -14
  68. package/src/restriction-menu/restriction-menu-item.analysis.md +0 -14
  69. package/src/restriction-menu/restriction-menu.analysis.md +0 -17
  70. package/src/results/analysis/cards.analysis.md +0 -14
  71. package/src/results/analysis/dialog-filter.analysis.md +0 -17
  72. package/src/results/analysis/empty.analysis.md +0 -14
  73. package/src/results/analysis/filter-navbar.analysis.md +0 -16
  74. package/src/results/analysis/pagination.analysis.md +0 -14
  75. package/src/results/analysis/table-with-images.analysis.md +0 -15
  76. package/src/results/analysis/table.analysis.md +0 -15
  77. package/src/results/filter-sidebar/index.analysis.md +0 -14
  78. package/src/results/generic/table-result-list.analysis.md +0 -15
  79. package/src/results/generic/table-result-list.stories.tsx +0 -21
  80. package/src/results/stories/cards.stories.tsx +0 -66
  81. package/src/results/stories/dialog-filter.stories.tsx +0 -20
  82. package/src/results/stories/empty.stories.tsx +0 -25
  83. package/src/results/stories/filter-navbar.stories.tsx +0 -19
  84. package/src/results/stories/filter-sidebar.stories.tsx +0 -20
  85. package/src/results/stories/pagination.stories.tsx +0 -24
  86. package/src/results/stories/table-with-images.stories.tsx +0 -19
  87. package/src/results/stories/table.stories.tsx +0 -78
  88. package/src/search-input.analysis.md +0 -15
  89. package/src/share-button.analysis.md +0 -19
  90. package/src/stories/autocomplete.stories.tsx +0 -20
  91. package/src/stories/breadcrumb.stories.tsx +0 -93
  92. package/src/stories/check-article-lang.stories.tsx +0 -22
  93. package/src/stories/render-article.stories.tsx +0 -19
  94. package/src/stories/search-input.stories.tsx +0 -21
  95. package/src/stories/share-button.stories.tsx +0 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c-rex/components",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "files": [
5
5
  "src"
6
6
  ],
@@ -179,31 +179,19 @@
179
179
  }
180
180
  },
181
181
  "scripts": {
182
- "storybook": "storybook dev -p 6006",
183
- "build-storybook": "storybook build",
184
182
  "lint": "eslint .",
185
183
  "lint:fix": "eslint . --fix"
186
184
  },
187
185
  "devDependencies": {
188
186
  "@c-rex/eslint-config": "*",
189
187
  "@c-rex/typescript-config": "*",
190
- "@chromatic-com/storybook": "^3.2.6",
191
- "@storybook/addon-essentials": "^8.6.12",
192
- "@storybook/addon-onboarding": "^8.6.12",
193
- "@storybook/blocks": "^8.6.12",
194
- "@storybook/nextjs": "^8.6.12",
195
- "@storybook/react": "^8.6.12",
196
- "@storybook/test": "^8.6.12",
197
188
  "@turbo/gen": "^2.4.4",
198
189
  "@types/node": "^22.13.10",
199
190
  "@types/react": "19.0.10",
200
191
  "@types/react-dom": "19.0.4",
201
192
  "autoprefixer": "^10.4.21",
202
193
  "eslint": "^9.23.0",
203
- "eslint-plugin-storybook": "^0.12.0",
204
194
  "postcss": "^8.5.3",
205
- "storybook": "^8.6.12",
206
- "style-loader": "^4.0.0",
207
195
  "tailwindcss": "^3.4.17",
208
196
  "typescript": "latest"
209
197
  },
@@ -226,10 +214,5 @@
226
214
  "react-icons": "^5.5.0",
227
215
  "tailwindcss-animate": "^1.0.7",
228
216
  "zustand": "^5.0.8"
229
- },
230
- "eslintConfig": {
231
- "extends": [
232
- "plugin:storybook/recommended"
233
- ]
234
217
  }
235
218
  }
@@ -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
  }
@@ -8,6 +8,7 @@ import { X } from "lucide-react";
8
8
  import { useRouter, useSearchParams } from "next/navigation";
9
9
  import { suggestionRequest } from "./generated/create-suggestions-request";
10
10
  import { useQueryState } from "nuqs";
11
+ import { useSearchSettingsStore } from "./stores/search-settings-store";
11
12
 
12
13
  export type AutoCompleteProps = {
13
14
  initialValue?: string;
@@ -36,6 +37,7 @@ export const AutoComplete = ({
36
37
  const containerRef = useRef<HTMLDivElement>(null);
37
38
  const searchParams = useSearchParams();
38
39
  const router = useRouter();
40
+
39
41
  const [open, setOpen] = useState(false);
40
42
  const [query, setQuery] = useState(initialValue);
41
43
  const [loading, setLoading] = useState(false);
@@ -43,19 +45,40 @@ export const AutoComplete = ({
43
45
 
44
46
  const fetchSuggestions = useCallback(async (prefix: string): Promise<string[]> => {
45
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;
46
62
  if (pkg != null) params.scopes = pkg as unknown as string[];
47
63
 
48
64
  const results = await suggestionRequest({ endpoint, prefix, queryParams: params });
49
65
 
50
66
  return results.data;
51
- }, [endpoint, pkg, queryParams]);
67
+ }, [endpoint, pkg, queryParams, searchParams]);
52
68
 
53
69
  const handleSelect = (value: string) => {
54
70
  setQuery(value);
55
71
  setOpen(false);
72
+
56
73
  const nextParams = new URLSearchParams(keepParams ? searchParams.toString() : "");
57
74
  nextParams.set("page", "1");
58
- nextParams.set("search", value);
75
+
76
+ if (value.length > 0) {
77
+ nextParams.set("search", value);
78
+ } else {
79
+ nextParams.delete("search");
80
+ };
81
+
59
82
  onSelectParams?.forEach(param => {
60
83
  nextParams.set(param.key, param.value);
61
84
  });
@@ -77,9 +100,7 @@ export const AutoComplete = ({
77
100
  router.push(queryString ? `${onSelectPath}?${queryString}` : onSelectPath);
78
101
  };
79
102
 
80
- useEffect(() => {
81
- setQuery(initialValue);
82
- }, [initialValue]);
103
+ useEffect(() => setQuery(initialValue), [initialValue]);
83
104
 
84
105
  useEffect(() => {
85
106
  const handleClickOutside = (e: MouseEvent) => {
@@ -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
 
@@ -324,9 +322,10 @@ const DefaultRenderCarouselItem: FC<{ item: CommonItemsModel; showImages: boolea
324
322
  const itemType = getType(item.class);
325
323
  const language = getLanguage(item.languages)
326
324
  const countryCode = language.split("-")[1] || "";
325
+ const link = linkPattern.replace("{shortId}", item.shortId!);
327
326
 
328
327
  return (
329
- <div className="p-2 flex flex-1">
328
+ <Link href={link} className="group p-2 flex flex-1">
330
329
  <Card className="p-4 flex-1 justify-between relative">
331
330
  <Badge className="absolute -top-2 -right-2">{t(itemType.toLowerCase())}</Badge>
332
331
 
@@ -338,15 +337,17 @@ const DefaultRenderCarouselItem: FC<{ item: CommonItemsModel; showImages: boolea
338
337
  />
339
338
  )}
340
339
 
341
- <span className="text-lg font-semibold">{title}</span>
340
+ <span className="group-hover:underline text-lg font-semibold flex-1">
341
+ {title}
342
+ </span>
342
343
 
343
344
  <div className="flex justify-between w-full">
344
- <span className="w-8 block border">
345
+ <span className="w-8 block">
345
346
  <Flag countryCode={countryCode} />
346
347
  </span>
347
348
  <span className="text-gray-400">{date || item.revision}</span>
348
349
  </div>
349
350
  </Card>
350
- </div>
351
+ </Link>
351
352
  );
352
353
  };
@@ -14,11 +14,15 @@ interface Props {
14
14
  export const CheckArticleLangToast: FC<Props> = ({ availableVersions }) => {
15
15
  const t = useTranslations();
16
16
  const { setAvailableVersions } = useAppConfig()
17
+ const searchLanguage = useSearchSettingsStore((state) => state.language);
17
18
 
18
19
  useEffect(() => {
19
20
  setAvailableVersions(availableVersions)
20
- const searchLanguage = useSearchSettingsStore.getState().language;
21
- const activeArticle = availableVersions.filter((item) => item.active)[0]
21
+ }, [availableVersions, setAvailableVersions])
22
+
23
+ useEffect(() => {
24
+ if (!searchLanguage) return;
25
+ const activeArticle = availableVersions.find((item) => item.active)
22
26
 
23
27
  if (activeArticle == undefined || activeArticle.lang == searchLanguage) return
24
28
 
@@ -26,7 +30,7 @@ export const CheckArticleLangToast: FC<Props> = ({ availableVersions }) => {
26
30
  if (articleAvailable != undefined) {
27
31
  articleAvailableInToast(articleAvailable.lang, articleAvailable.link)
28
32
  }
29
- }, [])
33
+ }, [availableVersions, searchLanguage])
30
34
 
31
35
  const articleAvailableInToast = (lang: string, link: string) => {
32
36
  toast(t('toast.read', { lang }), {
@@ -40,4 +44,4 @@ export const CheckArticleLangToast: FC<Props> = ({ availableVersions }) => {
40
44
  }
41
45
 
42
46
  return null
43
- }
47
+ }
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { ComponentProps, FC, ReactNode, useEffect, useState } from "react";
4
- import { Button, ButtonProps } from "@c-rex/ui/button";
3
+ import { ComponentProps, FC } from "react";
4
+ import { Button } 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";
8
8
  import { useFavoritesStore } from "../stores/favorites-store";
9
9
  import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@c-rex/ui/dialog";
10
+ import { useTranslations } from "next-intl";
10
11
  import {
11
12
  Table,
12
13
  TableBody,
@@ -15,20 +16,22 @@ import {
15
16
  } from "@c-rex/ui/table"
16
17
  import { FaRegBookmark } from "react-icons/fa6";
17
18
 
19
+ const EMPTY_TOPICS: { id: string; label: string; color: string }[] = [];
20
+
18
21
  type BookmarkProps = {
19
22
  shortId: string;
23
+ linkPattern?: string;
20
24
  triggerVariant?: ComponentProps<typeof Button>["variant"];
21
25
  }
22
26
 
23
27
  export const BookmarkButton: FC<BookmarkProps> = ({
24
28
  shortId,
29
+ linkPattern = `/topics/{id}/pages`,
25
30
  triggerVariant = "outline"
26
31
  }) => {
27
- const [markersList, setMarkersList] = useState<Array<{ id: string; label: string; color: string }>>([]);
28
-
29
- useEffect(() => {
30
- setMarkersList(useFavoritesStore.getState().documents[shortId]?.topics || []);
31
- }, [shortId]);
32
+ const t = useTranslations("bookmarks");
33
+ const removeFavoriteTopic = useFavoritesStore((state) => state.unfavoriteTopic);
34
+ const markersList = useFavoritesStore((state) => state.documents[shortId]?.topics) ?? EMPTY_TOPICS;
32
35
  return (
33
36
  <Dialog>
34
37
  <DialogTrigger asChild>
@@ -47,25 +50,37 @@ export const BookmarkButton: FC<BookmarkProps> = ({
47
50
  </DialogTrigger>
48
51
  <DialogContent>
49
52
  <DialogHeader>
50
- <DialogTitle>Bookmarks</DialogTitle>
53
+ <DialogTitle>{t("title")}</DialogTitle>
51
54
  <DialogDescription>
52
- Manage your bookmarks here
55
+ {t("description")}
53
56
  </DialogDescription>
54
57
  </DialogHeader>
55
58
  <Table>
56
59
  <TableBody>
60
+ {markersList.length === 0 && (
61
+ <TableRow>
62
+ <TableCell colSpan={3} className="text-center">
63
+ {t("empty")}
64
+ </TableCell>
65
+ </TableRow>
66
+ )}
67
+
57
68
  {markersList.map((item) => (
58
69
  <TableRow key={item.id} className="min-h-12">
59
70
  <TableCell>
60
71
  <FaRegBookmark className={cn("w-5", `text-${item.color}`)} />
61
72
  </TableCell>
62
73
  <TableCell>
63
- <Link href={`${window.location.origin}/topics/${item.id}`}>
74
+ <Link href={linkPattern.replace("{id}", item.id)} className="hover:underline">
64
75
  {item.label}
65
76
  </Link>
66
77
  </TableCell>
67
78
  <TableCell>
68
- <Button variant="destructive" size="icon">
79
+ <Button
80
+ variant="destructive"
81
+ size="icon"
82
+ onClick={() => removeFavoriteTopic(shortId, item.id)}
83
+ >
69
84
  <Trash className="w-5 hover:text-red-600 cursor-pointer" />
70
85
  </Button>
71
86
  </TableCell>
@@ -6,6 +6,8 @@ import { FaStar, FaRegStar } from "react-icons/fa";
6
6
  import { useFavoritesStore } from "../stores/favorites-store";
7
7
  import { MARKER_COLORS, RESULT_TYPES } from "@c-rex/constants";
8
8
  import { ResultTypes } from "@c-rex/types";
9
+ import { Loader2 } from "lucide-react";
10
+ import { toast } from "sonner";
9
11
 
10
12
  export const FavoriteButton: FC<{ id: string, type: ResultTypes, label: string }> = ({ id, type, label }) => {
11
13
  const addFavoriteTopic = useFavoritesStore((state) => state.favoriteTopic);
@@ -17,58 +19,102 @@ export const FavoriteButton: FC<{ id: string, type: ResultTypes, label: string }
17
19
  const favoriteList = useFavoritesStore((state) => state.favorites);
18
20
  const isFavorite = favoriteList.find((fav) => fav.id === id);
19
21
  const [documentData, setDocumentData] = useState<{ id: string, label: string }>({ id, label });
22
+ const [isLoading, setIsLoading] = useState(false);
20
23
 
21
24
  useEffect(() => {
22
25
  if (type === RESULT_TYPES.TOPIC) {
23
- getTopicDocumentData(id);
26
+ void getTopicDocumentData(id).catch(() => {
27
+ // Lazy retry on user action.
28
+ });
24
29
  }
25
- }, []);
30
+ }, [id, type]);
26
31
 
27
- const addFavorite = async (id: string) => {
32
+ const ensureDocumentData = async (topicId: string): Promise<{ id: string, label: string }> => {
33
+ if (type !== RESULT_TYPES.TOPIC) {
34
+ return { id, label };
35
+ }
36
+
37
+ if (documentData.id !== topicId) {
38
+ return documentData;
39
+ }
40
+
41
+ return await getTopicDocumentData(topicId);
42
+ };
43
+
44
+ const addFavorite = async (targetId: string) => {
28
45
  if (type === RESULT_TYPES.DOCUMENT) {
29
- addFavoriteDocument(id, label);
46
+ addFavoriteDocument(targetId, label);
30
47
  return;
31
48
  }
32
49
 
33
- const length = favoriteDocumentList[documentData.id]?.topics.length || 0;
50
+ const docData = await ensureDocumentData(targetId);
51
+ const length = favoriteDocumentList[docData.id]?.topics.length || 0;
34
52
  const color = MARKER_COLORS[length] || MARKER_COLORS[MARKER_COLORS.length - 1] as string;
35
53
 
36
- addFavoriteTopic(documentData.id, id, label, color);
37
- }
54
+ addFavoriteTopic(docData.id, targetId, label, color);
55
+ };
38
56
 
39
- const removeFavorite = (id: string) => {
57
+ const removeFavorite = async (targetId: string) => {
40
58
  if (type === RESULT_TYPES.DOCUMENT) {
41
- removeFavoriteDocument(id);
59
+ removeFavoriteDocument(targetId);
42
60
  return;
43
61
  }
44
62
 
45
- removeFavoriteTopic(documentData.id, id);
46
- }
47
-
48
- const getTopicDocumentData = async (topicId: string): Promise<void> => {
63
+ const docData = await ensureDocumentData(targetId);
64
+ removeFavoriteTopic(docData.id, targetId);
65
+ };
49
66
 
67
+ const getTopicDocumentData = async (topicId: string): Promise<{ id: string, label: string }> => {
50
68
  const response = await fetch(`/api/information-units/document-by-topic-id?shortId=${topicId}`, {
51
69
  method: "GET"
52
70
  });
53
71
 
54
- if (!response.ok) throw new Error("Failed to fetch document by topic id")
72
+ if (!response.ok) {
73
+ throw new Error("Failed to fetch document by topic id");
74
+ }
55
75
 
56
76
  const { documentId, label } = await response.json();
57
-
58
- setDocumentData({ id: documentId, label });
59
- }
77
+ const data = { id: documentId, label };
78
+ setDocumentData(data);
79
+ return data;
80
+ };
81
+
82
+ const handleToggle = async () => {
83
+ if (isLoading) return;
84
+ setIsLoading(true);
85
+
86
+ try {
87
+ if (isFavorite) {
88
+ await removeFavorite(id);
89
+ } else {
90
+ await addFavorite(id);
91
+ }
92
+ } catch {
93
+ toast.error("Não foi possível atualizar os favoritos.");
94
+ } finally {
95
+ setIsLoading(false);
96
+ }
97
+ };
60
98
 
61
99
  if (isFavorite) {
62
100
  return (
63
- <Button variant="ghost" size="icon" onClick={() => removeFavorite(id)}>
64
- <FaStar className="!h-5 !w-5 color-primary" />
101
+ <Button variant="ghost" size="icon" onClick={handleToggle} disabled={isLoading}>
102
+ {isLoading ? (
103
+ <Loader2 className="!h-5 !w-5 animate-spin" />
104
+ ) : (
105
+ <FaStar className="!h-5 !w-5 color-primary" />
106
+ )}
65
107
  </Button>
66
108
  );
67
109
  }
68
110
 
69
111
  return (
70
- <Button variant="ghost" size="icon" onClick={() => addFavorite(id)}>
71
- <FaRegStar className="!h-5 !w-5" />
112
+ <Button variant="ghost" size="icon" onClick={handleToggle} disabled={isLoading}>
113
+ {isLoading ? (
114
+ <Loader2 className="!h-5 !w-5 animate-spin" />
115
+ ) : (
116
+ <FaRegStar className="!h-5 !w-5" />
117
+ )}
72
118
  </Button>
73
119
  )
74
120
  }