@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 +5 -1
- package/src/article/article-action-bar.tsx +72 -80
- package/src/article/article-content.tsx +6 -5
- package/src/favorites/bookmark-button.tsx +8 -6
- package/src/favorites/favorite-button.tsx +41 -29
- package/src/icons/file-icon.tsx +1 -1
- package/src/info/information-unit-metadata-grid.tsx +100 -79
- package/src/navbar/navbar.tsx +12 -13
- package/src/navbar/settings.tsx +6 -2
- package/src/render-article.tsx +1 -1
- package/src/stores/__tests__/favorites-store.test.ts +54 -0
- package/src/stores/favorites-store.ts +87 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c-rex/components",
|
|
3
|
-
"version": "0.3.0-build.
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
38
|
+
<FavoriteButton
|
|
39
|
+
id={id}
|
|
40
|
+
type={articleType}
|
|
41
|
+
label={favoriteLabel}
|
|
42
|
+
/>
|
|
58
43
|
|
|
59
|
-
|
|
44
|
+
<Button
|
|
45
|
+
variant="ghost"
|
|
46
|
+
size="icon"
|
|
60
47
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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-
|
|
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
|
-
|
|
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={
|
|
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="
|
|
84
|
-
size="
|
|
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="
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
132
|
+
<Loader2 className="animate-spin" />
|
|
121
133
|
) : (
|
|
122
|
-
<FaStar className="
|
|
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="
|
|
143
|
+
<Loader2 className="animate-spin" />
|
|
132
144
|
) : (
|
|
133
|
-
<FaRegStar
|
|
145
|
+
<FaRegStar />
|
|
134
146
|
)}
|
|
135
147
|
</Button>
|
|
136
148
|
)
|
package/src/icons/file-icon.tsx
CHANGED
|
@@ -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="
|
|
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 !
|
|
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
|
-
|
|
80
|
-
|
|
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
|
}
|
package/src/navbar/navbar.tsx
CHANGED
|
@@ -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="
|
|
79
|
-
<Menu className="size-
|
|
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 && (
|
package/src/navbar/settings.tsx
CHANGED
|
@@ -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
|
|
27
|
-
<
|
|
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>
|
package/src/render-article.tsx
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
22
|
-
favorites
|
|
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
|
|
32
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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:
|
|
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:
|
|
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
|
+
};
|