@c-rex/components 0.3.0-build.18 → 0.3.0-build.19

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.3.0-build.18",
3
+ "version": "0.3.0-build.19",
4
4
  "files": [
5
5
  "src"
6
6
  ],
@@ -65,6 +65,10 @@
65
65
  "types": "./src/render-article.tsx",
66
66
  "import": "./src/render-article.tsx"
67
67
  },
68
+ "./article-action-bar": {
69
+ "types": "./src/article/article-action-bar.tsx",
70
+ "import": "./src/article/article-action-bar.tsx"
71
+ },
68
72
  "./html-rendition": {
69
73
  "types": "./src/renditions/html.tsx",
70
74
  "import": "./src/renditions/html.tsx"
@@ -2,21 +2,26 @@
2
2
  import { FC, useEffect, useRef, useState } from "react";
3
3
  import { useHighlightStore } from "../stores/highlight-store";
4
4
  import { Button } from "@c-rex/ui/button";
5
- import { ArrowBigLeft, ArrowBigRight, FileSearchIcon, PanelRight, Search, X } from "lucide-react";
5
+ import { ArrowBigDown, FileSearchIcon, Search, X } from "lucide-react";
6
6
  import { useQueryState } from "nuqs";
7
7
  import { useHighlight } from "@c-rex/contexts/highlight-provider";
8
- import { useMultiSidebar } from "@c-rex/ui/sidebar";
8
+ import { SidebarTrigger } from "@c-rex/ui/sidebar";
9
9
  import { cn } from "@c-rex/utils";
10
10
  import { useTranslations } from "next-intl";
11
-
12
- export const ArticleActionBar: FC = () => {
11
+ import { FavoriteButton } from "../favorites/favorite-button";
12
+ import { ResultTypes } from "@c-rex/types";
13
+ type Props = {
14
+ id: string;
15
+ articleType: ResultTypes;
16
+ favoriteLabel: string;
17
+ }
18
+ export const ArticleActionBar: FC<Props> = ({ id, articleType, favoriteLabel }) => {
13
19
  const t = useTranslations();
14
20
  const inputRef = useRef<HTMLInputElement>(null);
15
- const { next, prev } = useHighlight();
21
+ const { next } = useHighlight();
16
22
  const [open, setOpen] = useState(false);
17
23
  const enableHighlight = useHighlightStore((state) => state.enable);
18
24
  const toggleHighlight = useHighlightStore((state) => state.toggleHighlight);
19
- const { rightSidebar } = useMultiSidebar()
20
25
  const [query, setQuery] = useQueryState("q");
21
26
 
22
27
  useEffect(() => {
@@ -26,86 +31,73 @@ export const ArticleActionBar: FC = () => {
26
31
  }, [open]);
27
32
 
28
33
  return (
29
- <>
30
- <div className="w-auto justify-between bg-primary text-primary-foreground rounded-full shadow-lg flex bottom-4 p-2 float-right gap-2 transition-all duration-300 absolute right-4">
31
- {enableHighlight && (
34
+ <div className="w-9 flex gap-2 transition-all duration-300 flex-row z-20 items-end md:flex-col md:rounded-2xl md:sticky md:top-24 md:self-start">
32
35
 
33
- <>
34
- <div className="flex items-center">
35
- <input
36
- type="text"
37
- value={query as string || ""}
38
- ref={inputRef}
39
- onKeyDown={(e) => {
40
- if (e.key === "Enter") {
41
- next();
42
- }
43
- if (e.key === "Escape") {
44
- setOpen(false);
45
- }
46
- }}
47
- onChange={(e) => setQuery(e.target.value || null)}
48
- placeholder={t("search")}
49
- className={cn(
50
- "border border-gray-300 left-12 transition-all duration-300 rounded-full h-9 bg-secondary text-secondary-foreground focus:outline-none focus:ring-2 focus:ring-blue-500",
51
- open ? "flex flex-1 opacity-100 px-2 mr-2" : "w-0 opacity-0 px-0"
52
- )}
53
- />
36
+ <SidebarTrigger side="right" />
54
37
 
55
- <Button variant="ghost" size="icon" rounded="full" onClick={() => setOpen(!open)}>
56
- {open ? <X className="w-5 h-5" /> : <Search className="w-5 h-5" />}
57
- </Button>
38
+ <FavoriteButton
39
+ id={id}
40
+ type={articleType}
41
+ label={favoriteLabel}
42
+ />
58
43
 
59
- </div>
44
+ <Button
45
+ variant="ghost"
46
+ size="icon"
60
47
 
61
- <Button
62
- variant="ghost"
63
- size="icon"
64
- rounded="full"
65
- onClick={prev}
66
- className={cn(open && "hidden sm:inline-flex")}
67
- >
68
- <ArrowBigLeft />
69
- </Button>
48
+ onClick={() => toggleHighlight(!enableHighlight)}
49
+ className={cn("group", open && "hidden sm:inline-flex")}
50
+ >
51
+ {enableHighlight ?
52
+ <FileSearchIcon /> :
53
+ <div className="relative inline-block">
54
+ <FileSearchIcon />
55
+ <span className="absolute inset-0 w-[2px] rotate-45 translate-x-2 bg-accent-foreground" />
56
+ </div>
57
+ }
58
+ </Button>
59
+
60
+ {enableHighlight && (
61
+ <>
62
+ <div className="flex items-center gap-2 transition-all duration-300">
63
+ <input
64
+ type="text"
65
+ value={query as string || ""}
66
+ ref={inputRef}
67
+ onKeyDown={(e) => {
68
+ if (e.key === "Enter") {
69
+ next();
70
+ }
71
+ if (e.key === "Escape") {
72
+ setOpen(false);
73
+ }
74
+ }}
75
+ onChange={(e) => setQuery(e.target.value || null)}
76
+ placeholder={t("search")}
77
+ className={cn(
78
+ "border border-gray-300 left-12 transition-all duration-300 rounded-full h-9 bg-secondary text-secondary-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-lg",
79
+ open ? "flex flex-1 opacity-100 px-2 mr-2" : "w-0 opacity-0 px-0"
80
+ )}
81
+ />
70
82
 
71
- <Button
72
- variant="ghost"
73
- size="icon"
74
- rounded="full"
75
- onClick={next}
76
- className={cn(open && "hidden sm:inline-flex")}
77
- >
78
- <ArrowBigRight />
83
+ <Button variant="ghost" size="icon" onClick={() => setOpen(!open)} className={cn(open && "bg-accent")}>
84
+ {open ? <X /> : <Search />}
79
85
  </Button>
80
- </>
81
- )}
82
86
 
83
- <Button
84
- variant="ghost"
85
- size="icon"
86
- rounded="full"
87
- onClick={() => toggleHighlight(!enableHighlight)}
88
- className={cn("group", open && "hidden sm:inline-flex")}
89
- >
90
- {enableHighlight ?
91
- <FileSearchIcon /> :
92
- <div className="relative inline-block">
93
- <FileSearchIcon />
94
- <span className="absolute inset-0 w-[2px] bg-primary-foreground rotate-45 translate-x-2 group-hover:bg-accent-foreground" />
95
- </div>
96
- }
97
- </Button>
87
+ </div>
88
+
89
+ <Button
90
+ variant="ghost"
91
+ size="icon"
92
+
93
+ onClick={next}
94
+ className={cn(open && "hidden sm:inline-flex")}
95
+ >
96
+ <ArrowBigDown />
97
+ </Button>
98
98
 
99
- <Button
100
- variant="ghost"
101
- size="icon"
102
- rounded="full"
103
- onClick={rightSidebar.toggleSidebar}
104
- className={cn(open && "hidden sm:inline-flex")}
105
- >
106
- <PanelRight />
107
- </Button>
108
- </div>
109
- </>
99
+ </>
100
+ )}
101
+ </div>
110
102
  )
111
- }
103
+ }
@@ -1,6 +1,5 @@
1
1
  import { FC } from "react";
2
2
  import { RenderArticle } from "../render-article";
3
- import { ArticleActionBar } from "./article-action-bar";
4
3
 
5
4
  interface Props {
6
5
  articleHtml: string;
@@ -8,11 +7,13 @@ interface Props {
8
7
 
9
8
  export const ArticleContent: FC<Props> = async ({ articleHtml }) => {
10
9
  return (
11
- <>
12
- <div className="pr-4 relative">
10
+ <div className="relative flex flex-col gap-4 md:flex-row md:items-start">
11
+ <div className="relative min-w-0 flex-1 md:pr-8">
13
12
  <RenderArticle htmlContent={articleHtml} contentLang="" />
14
13
  </div>
15
- <ArticleActionBar />
16
- </>
14
+
15
+
16
+
17
+ </div>
17
18
  )
18
19
  }
@@ -31,12 +31,13 @@ export const BookmarkButton: FC<BookmarkProps> = ({
31
31
  }) => {
32
32
  const t = useTranslations("bookmarks");
33
33
  const removeFavoriteTopic = useFavoritesStore((state) => state.unfavoriteTopic);
34
+ const documentLabel = useFavoritesStore((state) => state.documents[shortId]?.label);
34
35
  const markersList = useFavoritesStore((state) => state.documents[shortId]?.topics) ?? EMPTY_TOPICS;
35
36
  return (
36
37
  <Dialog>
37
38
  <DialogTrigger asChild>
38
39
  <Button variant={triggerVariant} size="icon" className="relative">
39
- <FaRegBookmark className="text-primary" />
40
+ <FaRegBookmark className="text-primary !size-4" />
40
41
 
41
42
  {markersList.length > 0 && (
42
43
  <span
@@ -52,7 +53,7 @@ export const BookmarkButton: FC<BookmarkProps> = ({
52
53
  <DialogHeader>
53
54
  <DialogTitle>{t("title")}</DialogTitle>
54
55
  <DialogDescription>
55
- {t("description")}
56
+ {documentLabel || t("description")}
56
57
  </DialogDescription>
57
58
  </DialogHeader>
58
59
  <Table>
@@ -68,7 +69,7 @@ export const BookmarkButton: FC<BookmarkProps> = ({
68
69
  {markersList.map((item) => (
69
70
  <TableRow key={item.id} className="min-h-12">
70
71
  <TableCell>
71
- <FaRegBookmark className={cn("w-5", `text-${item.color}`)} />
72
+ <FaRegBookmark className={`text-${item.color}`} />
72
73
  </TableCell>
73
74
  <TableCell>
74
75
  <Link
@@ -80,11 +81,12 @@ export const BookmarkButton: FC<BookmarkProps> = ({
80
81
  </TableCell>
81
82
  <TableCell>
82
83
  <Button
83
- variant="destructive"
84
- size="icon"
84
+ variant="ghost"
85
+ size="sm"
86
+ className="!text-red-600 float-right"
85
87
  onClick={() => removeFavoriteTopic(shortId, item.id)}
86
88
  >
87
- <Trash className="w-5 hover:text-red-600 cursor-pointer" />
89
+ <Trash className="hover:text-red-600 cursor-pointer !size-4" />
88
90
  </Button>
89
91
  </TableCell>
90
92
  </TableRow>
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { FC, useEffect, useState } from "react";
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
6
  import { useFavoritesStore } from "../stores/favorites-store";
@@ -13,9 +13,7 @@ export const FavoriteButton: FC<{
13
13
  id: string;
14
14
  type: ResultTypes;
15
15
  label: string;
16
- topicDocumentId?: string;
17
- topicDocumentLabel?: string;
18
- }> = ({ id, type, label, topicDocumentId, topicDocumentLabel }) => {
16
+ }> = ({ id, type, label }) => {
19
17
  const addFavoriteTopic = useFavoritesStore((state) => state.favoriteTopic);
20
18
  const addFavoriteDocument = useFavoritesStore((state) => state.favoriteDocument);
21
19
  const removeFavoriteTopic = useFavoritesStore((state) => state.unfavoriteTopic);
@@ -24,43 +22,42 @@ export const FavoriteButton: FC<{
24
22
  const favoriteDocumentList = useFavoritesStore((state) => state.documents);
25
23
  const favoriteList = useFavoritesStore((state) => state.favorites);
26
24
  const isFavorite = favoriteList.find((fav) => fav.id === id);
27
- const [documentData, setDocumentData] = useState<{ id: string, label: string }>({
28
- id: topicDocumentId || id,
29
- label: topicDocumentLabel || label,
30
- });
25
+ const [documentData, setDocumentData] = useState<{ id: string, label: string } | null>(null);
26
+ const [topicData, setTopicData] = useState<{ id: string, label: string } | null>(null);
31
27
  const [isLoading, setIsLoading] = useState(false);
32
28
 
33
- useEffect(() => {
34
- if (type !== RESULT_TYPES.TOPIC || !topicDocumentId) return;
35
- setDocumentData({
36
- id: topicDocumentId,
37
- label: topicDocumentLabel || label,
38
- });
39
- }, [type, topicDocumentId, topicDocumentLabel, label]);
40
-
41
- useEffect(() => {
42
- if (type === RESULT_TYPES.TOPIC && !topicDocumentId) {
43
- void getTopicDocumentData(id).catch(() => {
44
- // Lazy retry on user action.
45
- });
46
- }
47
- }, [id, type, topicDocumentId]);
48
-
49
29
  const ensureDocumentData = async (topicId: string): Promise<{ id: string, label: string }> => {
50
30
  if (type !== RESULT_TYPES.TOPIC) {
51
31
  return { id, label };
52
32
  }
53
33
 
54
- if (documentData.id !== topicId) {
34
+ if (documentData && documentData.id !== topicId) {
55
35
  return documentData;
56
36
  }
57
37
 
58
38
  return await getTopicDocumentData(topicId);
59
39
  };
60
40
 
41
+ const ensureTopicData = async (documentId: string): Promise<{ id: string, label: string }> => {
42
+ if (type !== RESULT_TYPES.DOCUMENT) {
43
+ return { id, label };
44
+ }
45
+
46
+ if (topicData && documentId === id) {
47
+ return topicData;
48
+ }
49
+
50
+ return await getDocumentFirstTopicData(documentId);
51
+ };
52
+
61
53
  const addFavorite = async (targetId: string) => {
62
54
  if (type === RESULT_TYPES.DOCUMENT) {
55
+ const topic = await ensureTopicData(targetId);
56
+ const length = favoriteDocumentList[targetId]?.topics.length || 0;
57
+ const color = MARKER_COLORS[length] || MARKER_COLORS[MARKER_COLORS.length - 1] as string;
58
+
63
59
  addFavoriteDocument(targetId, label);
60
+ addFavoriteTopic(targetId, topic.id, topic.label, color);
64
61
  return;
65
62
  }
66
63
 
@@ -96,6 +93,21 @@ export const FavoriteButton: FC<{
96
93
  return data;
97
94
  };
98
95
 
96
+ const getDocumentFirstTopicData = async (documentId: string): Promise<{ id: string, label: string }> => {
97
+ const response = await fetch(`/api/information-units/first-topic-by-document-id?shortId=${encodeURIComponent(documentId)}`, {
98
+ method: "GET",
99
+ });
100
+
101
+ if (!response.ok) {
102
+ throw new Error("Failed to fetch first topic by document id");
103
+ }
104
+
105
+ const { topicId, label } = await response.json();
106
+ const data = { id: topicId, label };
107
+ setTopicData(data);
108
+ return data;
109
+ };
110
+
99
111
  const handleToggle = async () => {
100
112
  if (isLoading) return;
101
113
  setIsLoading(true);
@@ -117,9 +129,9 @@ export const FavoriteButton: FC<{
117
129
  return (
118
130
  <Button variant="ghost" size="icon" onClick={handleToggle} disabled={isLoading}>
119
131
  {isLoading ? (
120
- <Loader2 className="!h-5 !w-5 animate-spin" />
132
+ <Loader2 className="animate-spin" />
121
133
  ) : (
122
- <FaStar className="!h-5 !w-5 color-primary" />
134
+ <FaStar className="color-primary" />
123
135
  )}
124
136
  </Button>
125
137
  );
@@ -128,9 +140,9 @@ export const FavoriteButton: FC<{
128
140
  return (
129
141
  <Button variant="ghost" size="icon" onClick={handleToggle} disabled={isLoading}>
130
142
  {isLoading ? (
131
- <Loader2 className="!h-5 !w-5 animate-spin" />
143
+ <Loader2 className="animate-spin" />
132
144
  ) : (
133
- <FaRegStar className="!h-5 !w-5" />
145
+ <FaRegStar />
134
146
  )}
135
147
  </Button>
136
148
  )
@@ -2,7 +2,7 @@ import { PiFilePdf } from "react-icons/pi";
2
2
 
3
3
  export const FileIcon = ({ extension }: { extension: string }) => {
4
4
  const IconsToFileExtension: Record<string, React.ReactNode> = {
5
- "application/pdf": <PiFilePdf className="!h-5 !w-5 text-primary" />,
5
+ "application/pdf": <PiFilePdf className="text-primary" />,
6
6
  };
7
7
 
8
8
  return IconsToFileExtension[extension] || null;
@@ -38,11 +38,13 @@ type Props = {
38
38
  metadataExcludeProperties?: Array<keyof InformationUnitModel>;
39
39
  showBookmarkButton?: boolean;
40
40
  showFileRenditions?: boolean;
41
+ embedded?: boolean;
41
42
  }
42
43
 
43
44
  export const InformationUnitMetadataGrid: FC<Props> = async ({
44
45
  title,
45
46
  data,
47
+ embedded = false,
46
48
  linkPattern,
47
49
  metadataIncludeProperties,
48
50
  metadataExcludeProperties,
@@ -67,91 +69,110 @@ export const InformationUnitMetadataGrid: FC<Props> = async ({
67
69
  const resolveDetailsPath = (shortId: string): string =>
68
70
  linkPattern.replace("{shortId}", shortId);
69
71
 
72
+ const cardContent = (
73
+ <CardContent className="space-y-3 !p-0">
74
+ <Table>
75
+ <TableBody>
76
+
77
+ {showBookmarkButton && (
78
+ <TableRow className="min-h-12">
79
+ <TableCell className="font-medium w-28 pl-4">
80
+ <h4 className="text-sm font-medium">{t("favorites")}</h4>
81
+ </TableCell>
82
+ <TableCell className="text-xs text-muted-foreground flex flex-col min-h-12 pt-3">
83
+ <BookmarkButton shortId={data.shortId!} />
84
+ </TableCell>
85
+ </TableRow>
86
+ )}
87
+
88
+ {metadataRows.map((row) => (
89
+ <TableRow key={`${row.key}:${row.label}`} className="min-h-12">
90
+ <TableCell className="font-medium w-28 pl-4">
91
+ <h4 className="text-sm font-medium capitalize">
92
+ {row.labelSource === "translationKey" ? t(row.label) : row.label}
93
+ </h4>
94
+ </TableCell>
95
+ <TableCell className="text-xs text-muted-foreground flex items-center gap-2 min-h-12">
96
+ {renderMetadataDisplayValues(row, locale)}
97
+ </TableCell>
98
+ </TableRow>
99
+ ))}
100
+
101
+ {displayVersions.length > 0 && (
102
+ <TableRow className="min-h-12">
103
+ <TableCell className="font-medium w-28 pl-4">
104
+ <h4 className="text-sm font-medium">{t("availableIn")}</h4>
105
+ </TableCell>
106
+ <TableCell className="text-xs text-muted-foreground flex items-center gap-2 min-h-12">
107
+ {displayVersions.map((item) => {
108
+ const country = extractCountryCodeFromLanguage(item.language);
109
+ return (
110
+ <span className="w-8 block border" key={item.shortId}>
111
+ <Link href={resolveDetailsPath(item.shortId)} title={item.language}>
112
+ <Flag countryCode={country} />
113
+ </Link>
114
+ </span>
115
+ );
116
+ })}
117
+ </TableCell>
118
+ </TableRow>
119
+ )}
120
+
121
+ {Object.keys(files).length > 0 && (
122
+ <TableRow className="min-h-12">
123
+ <TableCell className="font-medium w-28 pl-4">
124
+ <h4 className="text-sm font-medium">{t("files")}</h4>
125
+ </TableCell>
126
+ <TableCell className="text-xs text-muted-foreground flex flex-col gap-2 min-h-12">
127
+
128
+ {Object.keys(files).map((fileKey) => {
129
+ if (!files[fileKey]) return null
130
+
131
+ return (
132
+ <DropdownMenu key={fileKey}>
133
+ <DropdownMenuTrigger className="mr-2" asChild >
134
+ <Button variant="outline" size="icon" >
135
+ <FileIcon extension={fileKey} />
136
+ </Button>
137
+ </DropdownMenuTrigger>
138
+ <DropdownMenuContent>
139
+ <DropdownMenuItem>
140
+ <a href={files[fileKey].view} target="_blank" rel="noreferrer" className="flex items-center">
141
+ <Eye className="mr-2" /> Open
142
+ </a>
143
+ </DropdownMenuItem>
144
+ <DropdownMenuItem>
145
+ <a href={files[fileKey].download} target="_blank" rel="noreferrer" className="flex items-center">
146
+ <CloudDownload className="mr-2" /> Download
147
+ </a>
148
+ </DropdownMenuItem>
149
+
150
+ </DropdownMenuContent>
151
+ </DropdownMenu>
152
+ )
153
+ })}
154
+
155
+ </TableCell>
156
+ </TableRow>
157
+ )}
158
+
159
+ </TableBody>
160
+ </Table>
161
+ </CardContent>
162
+ )
163
+
164
+ if (embedded) return cardContent;
165
+
70
166
  return (
71
- <Card className="p-0 !pt-4">
72
- <CardHeader>
167
+ <Card className="!p-0 !gap-0">
168
+ <CardHeader className="">
73
169
  <CardTitle className="text-lg flex justify-between items-end">
74
170
  {title}
75
171
 
76
172
  {showBookmarkButton && <BookmarkButton shortId={data.shortId!} />}
77
173
  </CardTitle>
78
174
  </CardHeader>
79
- <CardContent className="space-y-3 !p-0">
80
- <Table>
81
- <TableBody>
82
- {metadataRows.map((row) => (
83
- <TableRow key={`${row.key}:${row.label}`} className="min-h-12">
84
- <TableCell className="font-medium w-28 pl-4">
85
- <h4 className="text-sm font-medium capitalize">
86
- {row.labelSource === "translationKey" ? t(row.label) : row.label}
87
- </h4>
88
- </TableCell>
89
- <TableCell className="text-xs text-muted-foreground flex items-center gap-2 min-h-12">
90
- {renderMetadataDisplayValues(row, locale)}
91
- </TableCell>
92
- </TableRow>
93
- ))}
94
-
95
- {displayVersions.length > 0 && (
96
- <TableRow className="min-h-12">
97
- <TableCell className="font-medium w-28 pl-4">
98
- <h4 className="text-sm font-medium">{t("availableIn")}</h4>
99
- </TableCell>
100
- <TableCell className="text-xs text-muted-foreground flex items-center gap-2 min-h-12">
101
- {displayVersions.map((item) => {
102
- const country = extractCountryCodeFromLanguage(item.language);
103
- return (
104
- <span className="w-8 block border" key={item.shortId}>
105
- <Link href={resolveDetailsPath(item.shortId)} title={item.language}>
106
- <Flag countryCode={country} />
107
- </Link>
108
- </span>
109
- );
110
- })}
111
- </TableCell>
112
- </TableRow>
113
- )}
114
-
115
- {Object.keys(files).length > 0 && (
116
- <TableRow className="min-h-12">
117
- <TableCell className="font-medium w-28 pl-4">
118
- <h4 className="text-sm font-medium">{t("files")}</h4>
119
- </TableCell>
120
- <TableCell className="text-xs text-muted-foreground flex items-center gap-2 min-h-12">
121
-
122
- {Object.keys(files).map((fileKey) => {
123
- if (!files[fileKey]) return null
124
-
125
- return (
126
- <DropdownMenu key={fileKey}>
127
- <DropdownMenuTrigger className="mr-2" asChild >
128
- <Button variant="outline" size="icon" >
129
- <FileIcon extension={fileKey} />
130
- </Button>
131
- </DropdownMenuTrigger>
132
- <DropdownMenuContent>
133
- <DropdownMenuItem>
134
- <a href={files[fileKey].view} target="_blank" rel="noreferrer" className="flex items-center">
135
- <Eye className="mr-2" /> Open
136
- </a>
137
- </DropdownMenuItem>
138
- <DropdownMenuItem>
139
- <a href={files[fileKey].download} target="_blank" rel="noreferrer" className="flex items-center">
140
- <CloudDownload className="mr-2" /> Download
141
- </a>
142
- </DropdownMenuItem>
143
-
144
- </DropdownMenuContent>
145
- </DropdownMenu>
146
- )
147
- })}
148
-
149
- </TableCell>
150
- </TableRow>
151
- )}
152
- </TableBody>
153
- </Table>
154
- </CardContent>
155
- </Card >
175
+ {cardContent}
176
+ </Card>
156
177
  );
157
178
  }
@@ -61,22 +61,11 @@ export const NavBar: FC<NavBarProps> = async ({
61
61
  title && "lg:w-[calc(16rem-16px)]"
62
62
  )}
63
63
  >
64
-
65
- {showOrganizationLogo && organizationBranding && (
66
- <Link href="/">
67
- <img
68
- src={organizationBranding.logoSrc}
69
- alt={`${organizationBranding.organizationName} logo`}
70
- className="h-14"
71
- />
72
- </Link>
73
- )}
74
-
75
64
  {showMenu && (
76
65
  <DropdownHoverItem
77
66
  label={
78
- <Button variant="outline" rounded="full" size="icon">
79
- <Menu className="size-4" />
67
+ <Button variant="link" >
68
+ <Menu className="!size-8" />
80
69
  </Button>
81
70
  }
82
71
  >
@@ -112,6 +101,16 @@ export const NavBar: FC<NavBarProps> = async ({
112
101
  </Button>
113
102
  </DropdownHoverItem>
114
103
  )}
104
+
105
+ {showOrganizationLogo && organizationBranding && (
106
+ <Link href="/">
107
+ <img
108
+ src={organizationBranding.logoSrc}
109
+ alt={`${organizationBranding.organizationName} logo`}
110
+ className="h-14"
111
+ />
112
+ </Link>
113
+ )}
115
114
  </div>
116
115
 
117
116
  {title && (
@@ -15,6 +15,7 @@ import { FC } from 'react';
15
15
  import { ContentLanguageSwitch } from "./language-switcher/content-language-switch";
16
16
  import { UILanguageSwitch } from "./language-switcher/ui-language-switch";
17
17
  import { CrexSDK } from "@c-rex/core/sdk";
18
+ import { Button } from "@c-rex/ui/button";
18
19
 
19
20
  export const SettingsMenu: FC = () => {
20
21
  const t = useTranslations();
@@ -23,8 +24,11 @@ export const SettingsMenu: FC = () => {
23
24
 
24
25
  return (
25
26
  <DropdownMenu>
26
- <DropdownMenuTrigger className="block">
27
- <Settings />
27
+ <DropdownMenuTrigger asChild>
28
+ <Button variant="ghost" rounded="full" size="icon">
29
+ <Settings className="!size-5" />
30
+ </Button>
31
+
28
32
  </DropdownMenuTrigger>
29
33
  <DropdownMenuContent align="end" sideOffset={10} alignOffset={0}>
30
34
  <DropdownMenuLabel>{t("accountSettings.accountSettings")}</DropdownMenuLabel>
@@ -67,7 +67,7 @@ export const RenderArticle = ({ htmlContent, contentLang }: Props) => {
67
67
  id="ids-content"
68
68
  data-content-scope="dita"
69
69
  lang={contentLang}
70
- className={`ids-content ids-content--dita-ot pb-4 pr-4 ${styles.idsContent}`}
70
+ className={`ids-content ids-content--dita-ot ${styles.idsContent}`}
71
71
  >
72
72
  {highlightedContent}
73
73
  </main>
@@ -0,0 +1,54 @@
1
+ import { useFavoritesStore } from "../favorites-store";
2
+
3
+ describe("favorites-store", () => {
4
+ beforeEach(() => {
5
+ useFavoritesStore.setState({
6
+ favorites: [],
7
+ documents: {},
8
+ });
9
+ });
10
+
11
+ it("adds a document and its first topic without duplicating favorites", () => {
12
+ const { favoriteDocument, favoriteTopic } = useFavoritesStore.getState();
13
+
14
+ favoriteDocument("DOC-1", "Document 1");
15
+ favoriteTopic("DOC-1", "TOPIC-1", "Topic 1", "red");
16
+
17
+ const state = useFavoritesStore.getState();
18
+
19
+ expect(state.favorites).toHaveLength(2);
20
+ expect(state.favorites.map((item) => item.id).sort()).toEqual(["DOC-1", "TOPIC-1"]);
21
+ expect(state.documents["DOC-1"]).toEqual({
22
+ label: "Document 1",
23
+ topics: [{ id: "TOPIC-1", label: "Topic 1", color: "red" }],
24
+ });
25
+ });
26
+
27
+ it("does not duplicate a document or topic when favorited multiple times", () => {
28
+ const { favoriteDocument, favoriteTopic } = useFavoritesStore.getState();
29
+
30
+ favoriteDocument("DOC-1", "Document 1");
31
+ favoriteDocument("DOC-1", "Document 1");
32
+ favoriteTopic("DOC-1", "TOPIC-1", "Topic 1", "red");
33
+ favoriteTopic("DOC-1", "TOPIC-1", "Topic 1", "red");
34
+
35
+ const state = useFavoritesStore.getState();
36
+
37
+ expect(state.favorites).toHaveLength(2);
38
+ expect(state.documents["DOC-1"]?.topics).toHaveLength(1);
39
+ });
40
+
41
+ it("does not duplicate the document marker when a topic is favorited after the document", () => {
42
+ const { favoriteDocument, favoriteTopic } = useFavoritesStore.getState();
43
+
44
+ favoriteDocument("DOC-1", "Document 1");
45
+ favoriteTopic("DOC-1", "TOPIC-1", "Topic 1", "red");
46
+ favoriteTopic("DOC-1", "TOPIC-2", "Topic 2", "blue");
47
+
48
+ const state = useFavoritesStore.getState();
49
+ const documentEntries = state.favorites.filter((item) => item.id === "DOC-1");
50
+
51
+ expect(documentEntries).toHaveLength(1);
52
+ expect(state.documents["DOC-1"]?.topics).toHaveLength(2);
53
+ });
54
+ });
@@ -2,10 +2,12 @@ import { Favorite } from "@c-rex/types";
2
2
  import { create } from "zustand";
3
3
  import { persist } from "zustand/middleware";
4
4
 
5
+ type FavoriteDocumentState = { topics: Favorite[], label?: string };
6
+ type FavoriteDocumentsMap = Record<string, FavoriteDocumentState>;
5
7
 
6
8
  type FavoritesStore = {
7
9
  favorites: Favorite[];
8
- documents: Record<string, { topics: Favorite[], label?: string }>;
10
+ documents: FavoriteDocumentsMap;
9
11
  favoriteTopic: (documentId: string, id: string, label: string, color: string) => void;
10
12
  unfavoriteTopic: (documentId: string, id: string) => void;
11
13
  favoriteDocument: (id: string, label: string) => void;
@@ -17,10 +19,19 @@ export const useFavoritesStore = create<FavoritesStore>()(
17
19
  documents: {},
18
20
  favorites: [],
19
21
  favoriteTopic: (documentId: string, id: string, label: string, color: string) =>
20
- set((state) => ({
21
- documents: favoriteTopic(state.documents, documentId, id, label, color),
22
- favorites: [...state.favorites, { id, label, color }, { id: documentId, label: "", color: "" }],
23
- })),
22
+ set((state) => {
23
+ const documents = favoriteTopic(state.documents, documentId, id, label, color);
24
+ const favorites = upsertFavorites(state.favorites, [
25
+ { id, label, color },
26
+ { id: documentId, label: state.documents[documentId]?.label || "", color: "" },
27
+ ]);
28
+
29
+ if (documents === state.documents && favorites === state.favorites) {
30
+ return state;
31
+ }
32
+
33
+ return { documents, favorites };
34
+ }),
24
35
  unfavoriteTopic: (documentId: string, id: string) =>
25
36
  set((state) => ({
26
37
  documents: unfavoriteTopic(state.documents, documentId, id),
@@ -28,18 +39,17 @@ export const useFavoritesStore = create<FavoritesStore>()(
28
39
  })),
29
40
  favoriteDocument: (id: string, label: string) =>
30
41
  set((state) => {
31
- const documentsCopy = { ...state.documents };
32
- if (documentsCopy[id]) {
42
+ const documents = upsertDocument(state.documents, id, label);
43
+ const favorites = upsertFavorites(state.favorites, [{ id, label, color: "" }]);
44
+
45
+ if (documents === state.documents && favorites === state.favorites) {
33
46
  return state;
34
47
  }
35
48
 
36
49
  return {
37
- documents: {
38
- ...state.documents,
39
- [id]: { topics: [], label },
40
- },
41
- favorites: [...state.favorites, { id, label, color: "" }],
42
- }
50
+ documents,
51
+ favorites,
52
+ };
43
53
  }),
44
54
  unfavoriteDocument: (id: string) =>
45
55
  set((state) => {
@@ -59,9 +69,12 @@ export const useFavoritesStore = create<FavoritesStore>()(
59
69
  );
60
70
 
61
71
 
62
- const favoriteTopic = (documents: Record<string, { topics: Favorite[] }>, documentId: string, id: string, label: string, color: string): Record<string, { topics: Favorite[] }> => {
72
+ const favoriteTopic = (documents: FavoriteDocumentsMap, documentId: string, id: string, label: string, color: string): FavoriteDocumentsMap => {
63
73
  const currentDocument = documents[documentId];
64
74
  const currentTopics = currentDocument?.topics ?? [];
75
+ if (currentTopics.some((topic) => topic.id === id)) {
76
+ return documents;
77
+ }
65
78
 
66
79
  return {
67
80
  ...documents,
@@ -72,7 +85,7 @@ const favoriteTopic = (documents: Record<string, { topics: Favorite[] }>, docume
72
85
  };
73
86
  };
74
87
 
75
- const unfavoriteTopic = (documents: Record<string, { topics: Favorite[] }>, documentId: string, id: string): Record<string, { topics: Favorite[] }> => {
88
+ const unfavoriteTopic = (documents: FavoriteDocumentsMap, documentId: string, id: string): FavoriteDocumentsMap => {
76
89
  const currentDocument = documents[documentId];
77
90
  if (!currentDocument) {
78
91
  return documents;
@@ -85,4 +98,62 @@ const unfavoriteTopic = (documents: Record<string, { topics: Favorite[] }>, docu
85
98
  topics: currentDocument.topics.filter(topic => topic.id !== id),
86
99
  },
87
100
  };
88
- }
101
+ };
102
+
103
+ const upsertDocument = (documents: FavoriteDocumentsMap, documentId: string, label: string): FavoriteDocumentsMap => {
104
+ const currentDocument = documents[documentId];
105
+
106
+ if (!currentDocument) {
107
+ return {
108
+ ...documents,
109
+ [documentId]: { topics: [], label },
110
+ };
111
+ }
112
+
113
+ if (currentDocument.label === label) {
114
+ return documents;
115
+ }
116
+
117
+ return {
118
+ ...documents,
119
+ [documentId]: {
120
+ ...currentDocument,
121
+ label,
122
+ },
123
+ };
124
+ };
125
+
126
+ const upsertFavorites = (favorites: Favorite[], entries: Favorite[]): Favorite[] => {
127
+ let nextFavorites = favorites;
128
+
129
+ for (const entry of entries) {
130
+ nextFavorites = upsertFavorite(nextFavorites, entry);
131
+ }
132
+
133
+ return nextFavorites;
134
+ };
135
+
136
+ const upsertFavorite = (favorites: Favorite[], entry: Favorite): Favorite[] => {
137
+ const index = favorites.findIndex((favorite) => favorite.id === entry.id);
138
+ if (index === -1) {
139
+ return [...favorites, entry];
140
+ }
141
+
142
+ const current = favorites[index];
143
+ const next = {
144
+ ...current,
145
+ label: current.label || entry.label,
146
+ color: current.color || entry.color,
147
+ };
148
+
149
+ if (
150
+ current.label === next.label &&
151
+ current.color === next.color
152
+ ) {
153
+ return favorites;
154
+ }
155
+
156
+ const copy = [...favorites];
157
+ copy[index] = next;
158
+ return copy;
159
+ };