@c-rex/components 0.3.0-build.35 → 0.3.0-build.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 (50) hide show
  1. package/package.json +32 -36
  2. package/src/article/article-action-bar.tsx +12 -2
  3. package/src/{check-article-lang.tsx → article/check-article-lang.tsx} +1 -1
  4. package/src/article/render-article-highlight.tsx +108 -0
  5. package/src/article/render-article.tsx +28 -0
  6. package/src/autocomplete.tsx +7 -25
  7. package/src/blog/blog-author-card.tsx +116 -0
  8. package/src/carousel/carousel.tsx +5 -2
  9. package/src/carousel/information-unit-carousel-item.tsx +1 -1
  10. package/src/content-unavailable.tsx +20 -0
  11. package/src/directoryNodes/directory-tree-context.tsx +9 -4
  12. package/src/documents/description-preview.tsx +14 -4
  13. package/src/documents/result-list-item.tsx +40 -46
  14. package/src/favorites/__tests__/favorites-hydration.test.tsx +245 -0
  15. package/src/favorites/bookmark-button.tsx +38 -20
  16. package/src/favorites/favorite-button.tsx +23 -24
  17. package/src/favorites/favorites-context.tsx +287 -0
  18. package/src/icons/file-icon.tsx +9 -26
  19. package/src/info/information-unit-metadata-grid-client.tsx +21 -21
  20. package/src/navbar/navbar.tsx +16 -30
  21. package/src/navbar/settings.tsx +1 -1
  22. package/src/page-wrapper.tsx +3 -3
  23. package/src/renditions/html-client.tsx +8 -6
  24. package/src/renditions/html.tsx +3 -1
  25. package/src/restriction-menu/restriction-menu-item.tsx +48 -58
  26. package/src/restriction-menu/restriction-selection-command-menu.tsx +445 -0
  27. package/src/restriction-menu/restriction-selection-menu.tsx +5 -7
  28. package/src/restriction-menu/taxonomy-restriction-command-menu.tsx +111 -0
  29. package/src/restriction-menu/taxonomy-restriction-menu.tsx +19 -12
  30. package/src/results/filter-navbar.tsx +81 -76
  31. package/src/results/filter-sidebar/context.tsx +32 -0
  32. package/src/results/filter-sidebar/index.tsx +40 -35
  33. package/src/results/generic/search-results-client.tsx +5 -4
  34. package/src/results/generic/table-result-list.tsx +16 -16
  35. package/src/results/information-unit-search-results-card-list.tsx +4 -1
  36. package/src/results/information-unit-search-results-cards.tsx +169 -69
  37. package/src/results/pagination.tsx +43 -40
  38. package/src/search-input.tsx +4 -2
  39. package/src/toc/toc-breadcrumb.tsx +1 -1
  40. package/src/toc/toc-browse-controls.tsx +2 -2
  41. package/src/toc/toc-tree-panel.tsx +19 -16
  42. package/src/article/article-content.tsx +0 -19
  43. package/src/breadcrumb.tsx +0 -124
  44. package/src/directoryNodes/tree-of-content.tsx +0 -68
  45. package/src/render-article.tsx +0 -75
  46. package/src/restriction-menu/restriction-menu-container.tsx +0 -4
  47. package/src/restriction-menu/restriction-menu.tsx +0 -4
  48. package/src/stores/__tests__/favorites-store.test.ts +0 -54
  49. package/src/stores/favorites-store.ts +0 -163
  50. /package/src/{render-article.module.css → article/render-article.module.css} +0 -0
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { FC, useEffect, useState } from "react";
4
4
  import { FragmentsGetByIdClient } from "../generated/client-components";
5
+ import { Skeleton } from "@c-rex/ui/skeleton";
5
6
  import { ExpandableSummary } from "./expandable-summary";
6
7
  import type { RenditionModel } from "@c-rex/interfaces";
8
+ import { useTranslations } from "next-intl";
7
9
 
8
10
  interface Props {
9
11
  fragmentShortId?: string;
@@ -48,6 +50,7 @@ const DocumentDescriptionPreviewContent: FC<{
48
50
  isLoading,
49
51
  hasError,
50
52
  }) => {
53
+ const t = useTranslations();
51
54
  const [text, setText] = useState<string | null>(null);
52
55
  const [loadingText, setLoadingText] = useState(false);
53
56
 
@@ -84,15 +87,20 @@ const DocumentDescriptionPreviewContent: FC<{
84
87
  }, [title, viewHref]);
85
88
 
86
89
  if (hasError) {
87
- return <span className="text-muted-foreground">No rendition available</span>;
90
+ return <span className="text-muted-foreground">{t("noRenditionAvailable")}</span>;
88
91
  }
89
92
 
90
93
  if (isLoading || loadingText) {
91
- return <span className="text-muted-foreground">Loading rendition...</span>;
94
+ return (
95
+ <div className="flex flex-col gap-1 pt-1">
96
+ <Skeleton className="h-4 w-full" />
97
+ <Skeleton className="h-4 w-5/6" />
98
+ </div>
99
+ );
92
100
  }
93
101
 
94
102
  if (!text) {
95
- return <span className="text-muted-foreground">No rendition available</span>;
103
+ return <span className="text-muted-foreground">{t("noRenditionAvailable")}</span>;
96
104
  }
97
105
 
98
106
  return <ExpandableSummary text={text} />;
@@ -102,8 +110,10 @@ export const DocumentDescriptionPreview: FC<Props> = ({
102
110
  fragmentShortId,
103
111
  title,
104
112
  }) => {
113
+ const t = useTranslations();
114
+
105
115
  if (!fragmentShortId) {
106
- return <span className="text-muted-foreground">No rendition available</span>;
116
+ return <span className="text-muted-foreground">{t("noRenditionAvailable")}</span>;
107
117
  }
108
118
 
109
119
  return (
@@ -1,13 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import { FC, useEffect, useMemo, useRef, useState } from "react";
4
+ import { useTranslations } from "next-intl";
4
5
  import { CommonItemsModel } from "@c-rex/interfaces";
5
6
  import { FileStack } from "lucide-react";
6
7
  import { cn, findRelatedFragmentShortId, generateQueryParams } from "@c-rex/utils";
7
8
  import { Button } from "@c-rex/ui/button";
8
9
  import { Badge } from "@c-rex/ui/badge";
9
10
  import { Tooltip, TooltipContent, TooltipTrigger } from "@c-rex/ui/tooltip";
10
- import { RESULT_TYPES } from "@c-rex/constants";
11
11
  import { FileDownloadDropdown } from "@c-rex/components/file-download";
12
12
  import { FavoriteButton } from "@c-rex/components/favorite-button";
13
13
  import { ImageRenditionContainer } from "../renditions/image/container";
@@ -34,10 +34,8 @@ type RowContentProps = {
34
34
  item: CommonItemsModel;
35
35
  itemLink: string;
36
36
  title: string;
37
- language: string;
38
37
  itemType: ResultTypes;
39
38
  multipleVersions: string[];
40
- isDocument: boolean;
41
39
  isLast: boolean;
42
40
  previewFragmentShortId?: string;
43
41
  descriptionFragmentShortId?: string;
@@ -48,15 +46,15 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
48
46
  item,
49
47
  itemLink,
50
48
  title,
51
- language,
52
49
  itemType,
53
50
  multipleVersions,
54
- isDocument,
55
51
  isLast,
56
52
  previewFragmentShortId,
57
53
  descriptionFragmentShortId,
58
54
  isLoadingDetails = false,
59
55
  }) => {
56
+ const t = useTranslations();
57
+
60
58
  return (
61
59
  <div
62
60
  className={cn(
@@ -65,14 +63,14 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
65
63
  `c-rex-result-item c-rex-result-${itemType}`
66
64
  )}
67
65
  >
68
- <div className="w-20 sm:w-24 flex shrink-0 items-start justify-center pt-1">
66
+ <div className="w-20 sm:w-24 flex shrink-0 justify-center pt-1">
69
67
  {isLoadingDetails ? (
70
68
  <Skeleton className="w-20 sm:w-24 aspect-[210/297]" />
71
69
  ) : (
72
- <Link href={itemLink} className="block w-20 sm:w-24">
70
+ <Link href={itemLink} className="w-full h-full block">
73
71
  <ImageRenditionContainer
74
72
  fragmentShortId={previewFragmentShortId}
75
- emptyImageStyle="w-20 sm:w-24 aspect-[210/297]"
73
+ emptyImageStyle="w-full h-full"
76
74
  imageStyle="block w-full h-auto max-w-full object-contain object-top mx-auto"
77
75
  />
78
76
  </Link>
@@ -81,11 +79,16 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
81
79
 
82
80
  <div className="flex-1 p-2 flex flex-col justify-start">
83
81
  <span className="text-sm text-muted-foreground">
84
- {item.revision} - {language}
82
+ {item.revision}
85
83
  </span>
86
84
 
87
85
  <span className="text-lg font-medium">
88
- <a className="hover:underline" href={itemLink}>{title}</a>
86
+ <Link
87
+ className="hover:underline [overflow-wrap:anywhere] hyphens-auto"
88
+ href={itemLink}
89
+ >
90
+ {title}
91
+ </Link>
89
92
  </span>
90
93
 
91
94
  <div>
@@ -109,36 +112,32 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
109
112
  </span>
110
113
  </div>
111
114
 
112
- <div className="flex flex-col p-2 ml-auto justify-start self-start">
113
- <div className="flex gap-2">
114
- <FileDownloadDropdown renditions={item.renditions} />
115
-
116
- <FavoriteButton
117
- id={item.shortId!}
118
- type={itemType}
119
- label={title}
120
- />
121
-
122
- {multipleVersions.length > 1 && (
123
- <Tooltip>
124
- <TooltipTrigger asChild>
125
- <Button variant="ghost" size="icon">
126
- <FileStack />
127
- </Button>
128
- </TooltipTrigger>
129
- <TooltipContent>
130
- Available in: {multipleVersions.join(", ")}
131
- </TooltipContent>
132
- </Tooltip>
133
- )}
115
+ <div className="flex flex-row p-2 ml-auto justify-between self-start gap-2 w-full sm:justify-start sm:w-auto">
116
+ <FileDownloadDropdown renditions={item.renditions} />
117
+
118
+ <FavoriteButton
119
+ id={item.shortId!}
120
+ type={itemType}
121
+ label={title}
122
+ />
123
+
124
+ {multipleVersions.length > 1 && (
125
+ <Tooltip>
126
+ <TooltipTrigger asChild>
127
+ <Button variant="ghost" size="icon">
128
+ <FileStack />
129
+ </Button>
130
+ </TooltipTrigger>
131
+ <TooltipContent>
132
+ {t("availableIn")}: {multipleVersions.join(", ")}
133
+ </TooltipContent>
134
+ </Tooltip>
135
+ )}
134
136
 
135
- {isDocument && (
136
- <BookmarkButton
137
- shortId={item.shortId!}
138
- triggerVariant="ghost"
139
- />
140
- )}
141
- </div>
137
+ <BookmarkButton
138
+ shortId={item.shortId!}
139
+ triggerVariant="ghost"
140
+ />
142
141
  </div>
143
142
  </div>
144
143
  );
@@ -152,8 +151,7 @@ export const DocumentsResultListItem: FC<Props> = ({
152
151
  isLast,
153
152
  eager = false,
154
153
  }) => {
155
- const { title, language, itemType, multipleVersions, packageId } = getResultItemSummary(item);
156
- const isDocument = itemType === RESULT_TYPES.DOCUMENT;
154
+ const { title, itemType, multipleVersions, packageId } = getResultItemSummary(item);
157
155
  const [canLoadDetails, setCanLoadDetails] = useState(eager);
158
156
  const rowRef = useRef<HTMLDivElement | null>(null);
159
157
  const queryParams: QueryParams[] = [];
@@ -199,14 +197,12 @@ export const DocumentsResultListItem: FC<Props> = ({
199
197
  item={item}
200
198
  itemLink={itemLink}
201
199
  title={title}
202
- language={language}
203
200
  itemType={itemType}
204
201
  multipleVersions={multipleVersions}
205
- isDocument={isDocument}
206
202
  isLast={isLast}
207
203
  isLoadingDetails={!canLoadDetails}
208
204
  />
209
- ), [canLoadDetails, isDocument, isLast, item, itemLink, itemType, language, multipleVersions, title]);
205
+ ), [canLoadDetails, isLast, item, itemLink, itemType, multipleVersions, title]);
210
206
 
211
207
  return (
212
208
  <div ref={rowRef}>
@@ -238,10 +234,8 @@ export const DocumentsResultListItem: FC<Props> = ({
238
234
  item={item}
239
235
  itemLink={itemLink}
240
236
  title={title}
241
- language={language}
242
237
  itemType={itemType}
243
238
  multipleVersions={multipleVersions}
244
- isDocument={isDocument}
245
239
  isLast={isLast}
246
240
  previewFragmentShortId={previewFragmentShortId}
247
241
  descriptionFragmentShortId={descriptionFragmentShortId}
@@ -0,0 +1,245 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import React, { useEffect } from "react";
4
+ import { TextDecoder, TextEncoder } from "util";
5
+ import { act } from "react";
6
+ import { createRoot, hydrateRoot } from "react-dom/client";
7
+ import { FavoriteButton } from "../favorite-button";
8
+ import { BookmarkButton } from "../bookmark-button";
9
+ import {
10
+ FAVORITES_STORAGE_KEY,
11
+ FavoritesProvider,
12
+ type FavoritesState,
13
+ useFavorites,
14
+ } from "../favorites-context";
15
+
16
+ global.TextEncoder = TextEncoder;
17
+ global.TextDecoder = TextDecoder as typeof global.TextDecoder;
18
+ (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
19
+
20
+ const { renderToString } = require("react-dom/server");
21
+
22
+ jest.mock("next-intl", () => ({
23
+ useTranslations: () => (key: string) => {
24
+ const messages: Record<string, string> = {
25
+ title: "Bookmarks",
26
+ description: "Manage your bookmarks here",
27
+ empty: "No bookmarks yet.",
28
+ };
29
+
30
+ return messages[key] ?? key;
31
+ },
32
+ }));
33
+
34
+ jest.mock("@c-rex/utils", () => ({
35
+ getLocalStorageJson: (key: string) => {
36
+ const value = window.localStorage.getItem(key);
37
+ return value ? JSON.parse(value) : null;
38
+ },
39
+ setLocalStorageJson: (key: string, value: unknown) => {
40
+ window.localStorage.setItem(key, JSON.stringify(value));
41
+ },
42
+ removeLocalStorageItem: (key: string) => {
43
+ window.localStorage.removeItem(key);
44
+ },
45
+ }));
46
+
47
+ jest.mock("next/link", () => ({
48
+ __esModule: true,
49
+ default: ({ children, href, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
50
+ <a href={href} {...props}>{children}</a>
51
+ ),
52
+ }));
53
+
54
+ jest.mock("@c-rex/ui/button", () => ({
55
+ Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
56
+ <button {...props}>{children}</button>
57
+ ),
58
+ }));
59
+
60
+ jest.mock("@c-rex/ui/dialog", () => ({
61
+ Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog">{children}</div>,
62
+ DialogTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
63
+ DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
64
+ DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
65
+ DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
66
+ DialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
67
+ }));
68
+
69
+ jest.mock("@c-rex/ui/table", () => ({
70
+ Table: ({ children }: { children: React.ReactNode }) => <table>{children}</table>,
71
+ TableBody: ({ children }: { children: React.ReactNode }) => <tbody>{children}</tbody>,
72
+ TableRow: ({ children, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => <tr {...props}>{children}</tr>,
73
+ TableCell: ({ children, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) => <td {...props}>{children}</td>,
74
+ }));
75
+
76
+ jest.mock("sonner", () => ({
77
+ toast: {
78
+ error: jest.fn(),
79
+ },
80
+ }));
81
+
82
+ const persistedState: FavoritesState = {
83
+ favorites: [
84
+ { id: "doc-1", label: "Document 1", color: "" },
85
+ { id: "topic-1", label: "Topic 1", color: "red-500" },
86
+ ],
87
+ documents: {
88
+ "doc-1": {
89
+ label: "Document 1",
90
+ topics: [{ id: "topic-1", label: "Topic 1", color: "red-500" }],
91
+ },
92
+ },
93
+ };
94
+
95
+ const ProviderProbe = () => {
96
+ const isHydrated = useFavorites((state) => state.isHydrated);
97
+ const favoritesCount = useFavorites((state) => state.favorites.length);
98
+
99
+ return (
100
+ <div
101
+ data-hydrated={String(isHydrated)}
102
+ data-favorites-count={String(favoritesCount)}
103
+ />
104
+ );
105
+ };
106
+
107
+ const EffectProbe = ({ onHydrated }: { onHydrated: (payload: { hydrated: boolean; count: number }) => void }) => {
108
+ const isHydrated = useFavorites((state) => state.isHydrated);
109
+ const favoritesCount = useFavorites((state) => state.favorites.length);
110
+
111
+ useEffect(() => {
112
+ onHydrated({ hydrated: isHydrated, count: favoritesCount });
113
+ }, [favoritesCount, isHydrated, onHydrated]);
114
+
115
+ return null;
116
+ };
117
+
118
+ describe("favorites hydration", () => {
119
+ beforeEach(() => {
120
+ window.localStorage.clear();
121
+ jest.clearAllMocks();
122
+ });
123
+
124
+ it("keeps server markup stable before hydration and loads persisted state after mount", async () => {
125
+ window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(persistedState));
126
+
127
+ const serverMarkup = renderToString(
128
+ <FavoritesProvider>
129
+ <ProviderProbe />
130
+ </FavoritesProvider>
131
+ );
132
+
133
+ expect(serverMarkup).toContain('data-hydrated="false"');
134
+ expect(serverMarkup).toContain('data-favorites-count="0"');
135
+
136
+ const container = document.createElement("div");
137
+ container.innerHTML = serverMarkup;
138
+
139
+ await act(async () => {
140
+ hydrateRoot(
141
+ container,
142
+ <FavoritesProvider>
143
+ <ProviderProbe />
144
+ </FavoritesProvider>
145
+ );
146
+ });
147
+
148
+ const probe = container.querySelector("div[data-hydrated]");
149
+
150
+ expect(probe?.getAttribute("data-hydrated")).toBe("true");
151
+ expect(probe?.getAttribute("data-favorites-count")).toBe("2");
152
+ });
153
+
154
+ it("renders favorite button as loading on the server and shows persisted favorite after hydration", async () => {
155
+ window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(persistedState));
156
+
157
+ const serverMarkup = renderToString(
158
+ <FavoritesProvider>
159
+ <FavoriteButton id="doc-1" type="DOCUMENT" label="Document 1" />
160
+ </FavoritesProvider>
161
+ );
162
+
163
+ expect(serverMarkup).toContain("Loading favorites");
164
+ expect(serverMarkup).not.toContain("Remove favorite");
165
+
166
+ const container = document.createElement("div");
167
+ document.body.appendChild(container);
168
+ const root = createRoot(container);
169
+
170
+ await act(async () => {
171
+ root.render(
172
+ <FavoritesProvider>
173
+ <FavoriteButton id="doc-1" type="DOCUMENT" label="Document 1" />
174
+ </FavoritesProvider>
175
+ );
176
+ });
177
+
178
+ const button = container.querySelector("button");
179
+
180
+ expect(button?.getAttribute("aria-label")).toBe("Remove favorite");
181
+ expect(button?.hasAttribute("disabled")).toBe(false);
182
+
183
+ await act(async () => {
184
+ root.unmount();
185
+ });
186
+ });
187
+
188
+ it("hides bookmark count on the server and reveals persisted bookmarks after hydration", async () => {
189
+ window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(persistedState));
190
+
191
+ const serverMarkup = renderToString(
192
+ <FavoritesProvider>
193
+ <BookmarkButton shortId="doc-1" />
194
+ </FavoritesProvider>
195
+ );
196
+
197
+ expect(serverMarkup).not.toContain(">1<");
198
+ expect(serverMarkup).toContain("Loading...");
199
+
200
+ const container = document.createElement("div");
201
+ document.body.appendChild(container);
202
+ const root = createRoot(container);
203
+
204
+ await act(async () => {
205
+ root.render(
206
+ <FavoritesProvider>
207
+ <BookmarkButton shortId="doc-1" />
208
+ </FavoritesProvider>
209
+ );
210
+ });
211
+
212
+ expect(container.textContent).toContain("1");
213
+ expect(container.textContent).toContain("Topic 1");
214
+ expect(container.textContent).not.toContain("Loading...");
215
+
216
+ await act(async () => {
217
+ root.unmount();
218
+ });
219
+ });
220
+
221
+ it("marks the provider as hydrated and exposes persisted favorites to consumers after mount", async () => {
222
+ window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(persistedState));
223
+ const onHydrated = jest.fn();
224
+ const container = document.createElement("div");
225
+ document.body.appendChild(container);
226
+ const root = createRoot(container);
227
+
228
+ await act(async () => {
229
+ root.render(
230
+ <FavoritesProvider>
231
+ <EffectProbe onHydrated={onHydrated} />
232
+ </FavoritesProvider>
233
+ );
234
+ });
235
+
236
+ expect(onHydrated).toHaveBeenLastCalledWith({
237
+ hydrated: true,
238
+ count: 2,
239
+ });
240
+
241
+ await act(async () => {
242
+ root.unmount();
243
+ });
244
+ });
245
+ });
@@ -2,27 +2,31 @@
2
2
 
3
3
  import { ComponentProps, FC } from "react";
4
4
  import { Button } from "@c-rex/ui/button";
5
- import { Trash } from "lucide-react";
6
- import { cn } from "@c-rex/utils";
5
+ import { Loader2, Trash } from "lucide-react";
7
6
  import Link from "next/link";
8
- import { useFavoritesStore } from "../stores/favorites-store";
9
- import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@c-rex/ui/dialog";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogTrigger
14
+ } from "@c-rex/ui/dialog";
10
15
  import { useTranslations } from "next-intl";
11
16
  import {
12
17
  Table,
13
18
  TableBody,
14
19
  TableCell,
15
20
  TableRow,
16
- } from "@c-rex/ui/table"
21
+ } from "@c-rex/ui/table";
17
22
  import { FaRegBookmark } from "react-icons/fa6";
18
-
19
- const EMPTY_TOPICS: { id: string; label: string; color: string }[] = [];
23
+ import { useFavorites } from "./favorites-context";
20
24
 
21
25
  type BookmarkProps = {
22
26
  shortId: string;
23
27
  linkPattern?: string;
24
28
  triggerVariant?: ComponentProps<typeof Button>["variant"];
25
- }
29
+ };
26
30
 
27
31
  export const BookmarkButton: FC<BookmarkProps> = ({
28
32
  shortId,
@@ -30,35 +34,49 @@ export const BookmarkButton: FC<BookmarkProps> = ({
30
34
  triggerVariant = "outline"
31
35
  }) => {
32
36
  const t = useTranslations("bookmarks");
33
- const removeFavoriteTopic = useFavoritesStore((state) => state.unfavoriteTopic);
34
- const documentLabel = useFavoritesStore((state) => state.documents[shortId]?.label);
35
- const markersList = useFavoritesStore((state) => state.documents[shortId]?.topics) ?? EMPTY_TOPICS;
37
+ const tRoot = useTranslations();
38
+ const removeFavoriteTopic = useFavorites((state) => state.unfavoriteTopic);
39
+ const documentLabel = useFavorites((state) => state.documents[shortId]?.label);
40
+ const markersList = useFavorites((state) => state.documents[shortId]?.topics) ?? [];
41
+ const isHydrated = useFavorites((state) => state.isHydrated);
42
+ const visibleMarkersCount = isHydrated ? markersList.length : 0;
43
+
36
44
  return (
37
45
  <Dialog>
38
46
  <DialogTrigger asChild>
39
- <Button variant={triggerVariant} size="icon" className="relative">
47
+ <Button variant={triggerVariant} size="icon" className="relative" aria-label={t("title")}>
40
48
  <FaRegBookmark className="text-primary !size-4" />
41
49
 
42
- {markersList.length > 0 && (
50
+ {isHydrated && visibleMarkersCount > 0 && (
43
51
  <span
44
52
  className="absolute -top-[10px] -right-[10px] min-w-5 min-h-5 bg-primary text-white rounded-full"
45
53
  >
46
- {markersList.length}
54
+ {visibleMarkersCount}
47
55
  </span>
48
56
  )}
49
57
  </Button>
50
-
51
58
  </DialogTrigger>
52
59
  <DialogContent>
53
60
  <DialogHeader>
54
61
  <DialogTitle>{t("title")}</DialogTitle>
55
62
  <DialogDescription>
56
- {documentLabel || t("description")}
63
+ {isHydrated ? (documentLabel || t("description")) : t("description")}
57
64
  </DialogDescription>
58
65
  </DialogHeader>
59
66
  <Table>
60
67
  <TableBody>
61
- {markersList.length === 0 && (
68
+ {!isHydrated && (
69
+ <TableRow>
70
+ <TableCell colSpan={3} className="text-center">
71
+ <span className="inline-flex items-center gap-2">
72
+ <Loader2 className="size-4 animate-spin" />
73
+ {tRoot("loading")}
74
+ </span>
75
+ </TableCell>
76
+ </TableRow>
77
+ )}
78
+
79
+ {isHydrated && visibleMarkersCount === 0 && (
62
80
  <TableRow>
63
81
  <TableCell colSpan={3} className="text-center">
64
82
  {t("empty")}
@@ -66,7 +84,7 @@ export const BookmarkButton: FC<BookmarkProps> = ({
66
84
  </TableRow>
67
85
  )}
68
86
 
69
- {markersList.map((item) => (
87
+ {isHydrated && markersList.map((item) => (
70
88
  <TableRow key={item.id} className="min-h-12">
71
89
  <TableCell>
72
90
  <FaRegBookmark className={`text-${item.color}`} />
@@ -95,5 +113,5 @@ export const BookmarkButton: FC<BookmarkProps> = ({
95
113
  </Table>
96
114
  </DialogContent>
97
115
  </Dialog>
98
- )
99
- }
116
+ );
117
+ };
@@ -3,7 +3,7 @@
3
3
  import { FC, useState } from "react";
4
4
  import { Button } from "@c-rex/ui/button";
5
5
  import { FaStar, FaRegStar } from "react-icons/fa";
6
- import { useFavoritesStore } from "../stores/favorites-store";
6
+ import { useFavorites } from "./favorites-context";
7
7
  import { MARKER_COLORS, RESULT_TYPES } from "@c-rex/constants";
8
8
  import { ResultTypes } from "@c-rex/types";
9
9
  import { Loader2 } from "lucide-react";
@@ -14,14 +14,16 @@ export const FavoriteButton: FC<{
14
14
  type: ResultTypes;
15
15
  label: string;
16
16
  }> = ({ id, type, label }) => {
17
- const addFavoriteTopic = useFavoritesStore((state) => state.favoriteTopic);
18
- const addFavoriteDocument = useFavoritesStore((state) => state.favoriteDocument);
19
- const removeFavoriteTopic = useFavoritesStore((state) => state.unfavoriteTopic);
20
- const removeFavoriteDocument = useFavoritesStore((state) => state.unfavoriteDocument);
21
-
22
- const favoriteDocumentList = useFavoritesStore((state) => state.documents);
23
- const favoriteList = useFavoritesStore((state) => state.favorites);
17
+ const addFavoriteTopic = useFavorites((state) => state.favoriteTopic);
18
+ const addFavoriteDocument = useFavorites((state) => state.favoriteDocument);
19
+ const removeFavoriteTopic = useFavorites((state) => state.unfavoriteTopic);
20
+ const removeFavoriteDocument = useFavorites((state) => state.unfavoriteDocument);
21
+
22
+ const favoriteDocumentList = useFavorites((state) => state.documents);
23
+ const favoriteList = useFavorites((state) => state.favorites);
24
+ const isHydrated = useFavorites((state) => state.isHydrated);
24
25
  const isFavorite = favoriteList.find((fav) => fav.id === id);
26
+
25
27
  const [documentData, setDocumentData] = useState<{ id: string, label: string } | null>(null);
26
28
  const [topicData, setTopicData] = useState<{ id: string, label: string } | null>(null);
27
29
  const [isLoading, setIsLoading] = useState(false);
@@ -109,7 +111,7 @@ export const FavoriteButton: FC<{
109
111
  };
110
112
 
111
113
  const handleToggle = async () => {
112
- if (isLoading) return;
114
+ if (!isHydrated || isLoading) return;
113
115
  setIsLoading(true);
114
116
 
115
117
  try {
@@ -125,25 +127,22 @@ export const FavoriteButton: FC<{
125
127
  }
126
128
  };
127
129
 
128
- if (isFavorite) {
129
- return (
130
- <Button variant="ghost" size="icon" onClick={handleToggle} disabled={isLoading}>
131
- {isLoading ? (
132
- <Loader2 className="animate-spin" />
133
- ) : (
134
- <FaStar className="color-primary" />
135
- )}
136
- </Button>
137
- );
138
- }
130
+ const showLoading = !isHydrated || isLoading;
139
131
 
140
132
  return (
141
- <Button variant="ghost" size="icon" onClick={handleToggle} disabled={isLoading}>
142
- {isLoading ? (
133
+ <Button
134
+ variant="ghost"
135
+ size="icon"
136
+ onClick={handleToggle}
137
+ disabled={showLoading}
138
+ >
139
+ {showLoading ? (
143
140
  <Loader2 className="animate-spin" />
141
+ ) : isFavorite ? (
142
+ <FaStar className="color-primary" />
144
143
  ) : (
145
144
  <FaRegStar />
146
145
  )}
147
146
  </Button>
148
- )
149
- }
147
+ );
148
+ };