@c-rex/components 0.3.0-build.41 → 0.3.0-build.42

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.41",
3
+ "version": "0.3.0-build.42",
4
4
  "files": [
5
5
  "src"
6
6
  ],
@@ -53,6 +53,10 @@
53
53
  "types": "./src/info/information-unit-metadata-grid-client.tsx",
54
54
  "import": "./src/info/information-unit-metadata-grid-client.tsx"
55
55
  },
56
+ "./information-unit-authors-grid-client": {
57
+ "types": "./src/info/information-unit-authors-grid-client.tsx",
58
+ "import": "./src/info/information-unit-authors-grid-client.tsx"
59
+ },
56
60
  "./info-card": {
57
61
  "types": "./src/info/info-card.tsx",
58
62
  "import": "./src/info/info-card.tsx"
@@ -145,6 +149,10 @@
145
149
  "types": "./src/stores/search-settings-store.ts",
146
150
  "import": "./src/stores/search-settings-store.ts"
147
151
  },
152
+ "./ui-preferences-store": {
153
+ "types": "./src/stores/ui-preferences-store.ts",
154
+ "import": "./src/stores/ui-preferences-store.ts"
155
+ },
148
156
  "./article-content": {
149
157
  "types": "./src/article/article-content.tsx",
150
158
  "import": "./src/article/article-content.tsx"
@@ -36,6 +36,7 @@ export const AutoComplete = ({
36
36
  const [pkg] = useQueryState("package");
37
37
 
38
38
  const containerRef = useRef<HTMLDivElement>(null);
39
+ const inputRef = useRef<HTMLInputElement>(null);
39
40
  const suggestionRequestIdRef = useRef(0);
40
41
  const suggestionAbortControllerRef = useRef<AbortController | null>(null);
41
42
  const pathname = usePathname();
@@ -56,18 +57,22 @@ export const AutoComplete = ({
56
57
  const contentLang = useSearchSettingsStore.getState().language;
57
58
  const restrictions = searchParams.get("restrict")
58
59
 
59
- if (restrictions) {
60
- if (restrictions.includes("informationSubjects")) {
60
+ const mergedScopes: string[] = [];
61
61
 
62
- const informationSubject = restrictions.split("informationSubjects=")[1]?.split(',') || [];
63
- if (informationSubject.length > 0) {
64
- params.scopes = informationSubject.map(subject => `informationSubjects=${subject}`);
65
- }
66
- }
62
+ if (restrictions?.includes("informationSubjects")) {
63
+ const informationSubject = restrictions.split("informationSubjects=")[1]?.split(",") || [];
64
+ mergedScopes.push(...informationSubject.map((subject) => `informationSubjects=${subject}`));
65
+ }
66
+
67
+ if (pkg != null) {
68
+ mergedScopes.push(pkg);
69
+ }
70
+
71
+ if (mergedScopes.length > 0) {
72
+ params.scopes = mergedScopes as unknown as string[];
67
73
  }
68
74
 
69
75
  if (contentLang) params.lang = contentLang;
70
- if (pkg != null) params.scopes = pkg as unknown as string[];
71
76
 
72
77
  const results = await suggestionRequest({ endpoint, prefix, queryParams: params, signal });
73
78
 
@@ -168,6 +173,7 @@ export const AutoComplete = ({
168
173
  >
169
174
  <InputGroup variant={embedded ? "embedded" : "default"}>
170
175
  <InputGroupInput
176
+ ref={inputRef}
171
177
  variant={embedded ? "embedded" : undefined}
172
178
  className={inputClass}
173
179
  type="text"
@@ -190,7 +196,11 @@ export const AutoComplete = ({
190
196
  size="icon-xs"
191
197
  variant="ghost"
192
198
  aria-label={t("clearSearch")}
193
- onClick={() => setQuery("")}
199
+ onClick={(e) => {
200
+ e.preventDefault();
201
+ setQuery("")
202
+ inputRef.current?.focus();
203
+ }}
194
204
  >
195
205
  <X className="size-3" />
196
206
  </InputGroupButton>
@@ -217,11 +227,7 @@ export const AutoComplete = ({
217
227
  <li
218
228
  key={option}
219
229
  className="px-4 py-2 hover:bg-accent cursor-pointer text-sm"
220
- onClick={() => {
221
- //handleSelect(`"${option}"`)
222
- // TODO: check if the quotes are necessary
223
- handleSelect(option)
224
- }}
230
+ onClick={() => handleSelect(option)}
225
231
  >
226
232
  {option}
227
233
  </li>
@@ -54,6 +54,7 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
54
54
  isLoadingDetails = false,
55
55
  }) => {
56
56
  const t = useTranslations();
57
+ const tTypes = useTranslations("itemTypes");
57
58
 
58
59
  return (
59
60
  <div
@@ -92,7 +93,7 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
92
93
  </span>
93
94
 
94
95
  <div>
95
- <Badge>{itemType}</Badge>
96
+ <Badge>{tTypes(itemType.toLowerCase() as any) ?? itemType}</Badge>
96
97
  </div>
97
98
 
98
99
  <span className="text-sm block">
@@ -20,17 +20,20 @@ import {
20
20
  TableRow,
21
21
  } from "@c-rex/ui/table";
22
22
  import { FaRegBookmark } from "react-icons/fa6";
23
+ import { FaFile } from "react-icons/fa6";
23
24
  import { useFavorites } from "./favorites-context";
24
25
 
25
26
  type BookmarkProps = {
26
27
  shortId: string;
27
28
  linkPattern?: string;
29
+ documentLinkPattern?: string;
28
30
  triggerVariant?: ComponentProps<typeof Button>["variant"];
29
31
  };
30
32
 
31
33
  export const BookmarkButton: FC<BookmarkProps> = ({
32
34
  shortId,
33
35
  linkPattern = `/topics/{shortId}/pages`,
36
+ documentLinkPattern,
34
37
  triggerVariant = "outline"
35
38
  }) => {
36
39
  const t = useTranslations("bookmarks");
@@ -76,7 +79,23 @@ export const BookmarkButton: FC<BookmarkProps> = ({
76
79
  </TableRow>
77
80
  )}
78
81
 
79
- {isHydrated && visibleMarkersCount === 0 && (
82
+ {isHydrated && documentLinkPattern && documentLabel && (
83
+ <TableRow className="min-h-12">
84
+ <TableCell>
85
+ <FaFile className="text-primary" />
86
+ </TableCell>
87
+ <TableCell colSpan={2}>
88
+ <Link
89
+ href={documentLinkPattern.replace("{shortId}", shortId).replace("{id}", shortId)}
90
+ className="hover:underline font-medium"
91
+ >
92
+ {documentLabel}
93
+ </Link>
94
+ </TableCell>
95
+ </TableRow>
96
+ )}
97
+
98
+ {isHydrated && visibleMarkersCount === 0 && !documentLinkPattern && (
80
99
  <TableRow>
81
100
  <TableCell colSpan={3} className="text-center">
82
101
  {t("empty")}
@@ -8,12 +8,14 @@ import { MARKER_COLORS, RESULT_TYPES } from "@c-rex/constants";
8
8
  import { ResultTypes } from "@c-rex/types";
9
9
  import { Loader2 } from "lucide-react";
10
10
  import { toast } from "sonner";
11
+ import { useTranslations } from "next-intl";
11
12
 
12
13
  export const FavoriteButton: FC<{
13
14
  id: string;
14
15
  type: ResultTypes;
15
16
  label: string;
16
17
  }> = ({ id, type, label }) => {
18
+ const t = useTranslations();
17
19
  const addFavoriteTopic = useFavorites((state) => state.favoriteTopic);
18
20
  const addFavoriteDocument = useFavorites((state) => state.favoriteDocument);
19
21
  const removeFavoriteTopic = useFavorites((state) => state.unfavoriteTopic);
@@ -22,7 +24,12 @@ export const FavoriteButton: FC<{
22
24
  const favoriteDocumentList = useFavorites((state) => state.documents);
23
25
  const favoriteList = useFavorites((state) => state.favorites);
24
26
  const isHydrated = useFavorites((state) => state.isHydrated);
25
- const isFavorite = favoriteList.find((fav) => fav.id === id);
27
+
28
+ const isFavoriteInList = Boolean(favoriteList.find((fav) => fav.id === id));
29
+ const isFavoriteViaDocument = type === RESULT_TYPES.TOPIC
30
+ ? Object.values(favoriteDocumentList).some((doc) => doc.topics.some((t) => t.id === id))
31
+ : false;
32
+ const isFavorite = isFavoriteInList || isFavoriteViaDocument;
26
33
 
27
34
  const [documentData, setDocumentData] = useState<{ id: string, label: string } | null>(null);
28
35
  const [topicData, setTopicData] = useState<{ id: string, label: string } | null>(null);
@@ -121,7 +128,7 @@ export const FavoriteButton: FC<{
121
128
  await addFavorite(id);
122
129
  }
123
130
  } catch {
124
- toast.error("Não foi possível atualizar os favoritos.");
131
+ toast.error(t("favoritesUpdateError"));
125
132
  } finally {
126
133
  setIsLoading(false);
127
134
  }
@@ -135,6 +142,8 @@ export const FavoriteButton: FC<{
135
142
  size="icon"
136
143
  onClick={handleToggle}
137
144
  disabled={showLoading}
145
+ aria-label={isFavorite ? t("favorites") : t("favorites")}
146
+ aria-pressed={!showLoading && isFavorite}
138
147
  >
139
148
  {showLoading ? (
140
149
  <Loader2 className="animate-spin" />
@@ -25,6 +25,7 @@ export const SocialLinksBlock = ({ links, className, ariaLabel }: SocialLinksBlo
25
25
  target="_blank"
26
26
  rel="noreferrer"
27
27
  title={link.label}
28
+ aria-label={link.label}
28
29
  className="underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
29
30
  >
30
31
  <SocialIcon label={link.label} className="size-7" />
@@ -1,10 +1,3 @@
1
- /**
2
- * Auto-generated from OpenAPI spec (Client-Side)
3
- * Source: https://staging.c-rex.net/ids/api/swagger/v1/swagger.json
4
- * Generated: 2025-12-11T16:23:03.635Z
5
- * Do not edit manually
6
- */
7
-
8
1
  "use client";
9
2
 
10
3
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@c-rex/ui/card";
5
+ import { User, Mail, Link as LinkIcon } from "lucide-react";
6
+ import Link from "next/link";
7
+ import { cn } from "@c-rex/utils";
8
+
9
+ type Author = {
10
+ name: string;
11
+ photo: string | null;
12
+ title?: string | null;
13
+ role?: string | null;
14
+ emails?: string[];
15
+ urls?: string[];
16
+ };
17
+
18
+ type Props = {
19
+ title: string;
20
+ vcardUrls: string[];
21
+ vcardFields?: string[];
22
+ embedded?: boolean;
23
+ className?: string;
24
+ };
25
+
26
+ const AuthorAvatar = ({ author }: { author: Author }) => (
27
+ author.photo ? (
28
+ <img
29
+ src={author.photo}
30
+ alt={author.name}
31
+ className="size-10 rounded-full object-cover shrink-0 border border-border"
32
+ />
33
+ ) : (
34
+ <div className="size-10 rounded-full bg-muted shrink-0 flex items-center justify-center border border-border">
35
+ <User className="size-5 text-muted-foreground" />
36
+ </div>
37
+ )
38
+ );
39
+
40
+ const AuthorEntry = ({ author, fields }: { author: Author, fields: string[] }) => {
41
+ const showPhoto = fields.includes("photo");
42
+ const showName = fields.includes("name");
43
+ const showTitle = fields.includes("title");
44
+ const showRole = fields.includes("role");
45
+ const showEmails = fields.includes("emails");
46
+ const showUrls = fields.includes("urls");
47
+
48
+ return (
49
+ <div className="flex items-start gap-3 w-full overflow-hidden">
50
+ {showPhoto && <AuthorAvatar author={author} />}
51
+ <div className="flex flex-col gap-1 min-w-0 flex-1">
52
+ {showName && (
53
+ <span className="text-sm font-semibold text-foreground leading-tight truncate">
54
+ {author.name}
55
+ </span>
56
+ )}
57
+
58
+ {(showTitle && author.title) || (showRole && author.role) ? (
59
+ <span className="text-xs text-muted-foreground leading-tight truncate">
60
+ {(showTitle && author.title) || (showRole && author.role)}
61
+ </span>
62
+ ) : null}
63
+
64
+ <div className="flex flex-col gap-1 mt-1">
65
+ {showEmails && author.emails?.map((email) => (
66
+ <Link
67
+ key={email}
68
+ href={`mailto:${email}`}
69
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-primary transition-colors min-w-0"
70
+ title={email}
71
+ >
72
+ <Mail className="size-3.5 shrink-0" />
73
+ <span className="truncate">{email}</span>
74
+ </Link>
75
+ ))}
76
+
77
+ {showUrls && author.urls?.map((url) => (
78
+ <Link
79
+ key={url}
80
+ href={url}
81
+ target="_blank"
82
+ rel="noopener noreferrer"
83
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-primary transition-colors min-w-0"
84
+ title={url}
85
+ >
86
+ <LinkIcon className="size-3.5 shrink-0" />
87
+ <span className="truncate">{url.replace(/^https?:\/\//, "")}</span>
88
+ </Link>
89
+ ))}
90
+ </div>
91
+ </div>
92
+ </div>
93
+ );
94
+ };
95
+
96
+ export const InformationUnitAuthorsGridClient = ({
97
+ title,
98
+ vcardUrls,
99
+ vcardFields = ["name"],
100
+ embedded = false,
101
+ className,
102
+ }: Props) => {
103
+ const [authors, setAuthors] = useState<Author[]>([]);
104
+ const [loading, setLoading] = useState(true);
105
+
106
+ useEffect(() => {
107
+ if (!vcardUrls?.length) {
108
+ setLoading(false);
109
+ return;
110
+ }
111
+
112
+ const controller = new AbortController();
113
+
114
+ Promise.all(
115
+ vcardUrls.map((url) =>
116
+ fetch(url, { signal: controller.signal })
117
+ .then((r) => r.json())
118
+ .then((vcard) => ({
119
+ name: vcard.fullName || "",
120
+ photo: vcard.photos?.[0]?.source || null,
121
+ title: vcard.titles?.[0] || null,
122
+ role: vcard.roles?.[0] || null,
123
+ emails: vcard.emails?.map((e: any) => e.value) || [],
124
+ urls: vcard.urls?.map((u: any) => u.value) || [],
125
+ }))
126
+ .catch((): null => null)
127
+ )
128
+ ).then((results) => {
129
+ setAuthors(results.filter(Boolean) as Author[]);
130
+ setLoading(false);
131
+ });
132
+
133
+ return () => controller.abort();
134
+ }, [vcardUrls]);
135
+
136
+ if (loading) {
137
+ return (
138
+ <Card className={cn("!gap-0", className)}>
139
+ <CardHeader className="pb-3">
140
+ <div className="h-6 w-24 bg-muted animate-pulse rounded" />
141
+ </CardHeader>
142
+ <CardContent className="space-y-4">
143
+ {[1, 2].map((i) => (
144
+ <div key={i} className="flex items-center gap-3 animate-pulse">
145
+ <div className="size-10 rounded-full bg-muted shrink-0" />
146
+ <div className="space-y-2 flex-1">
147
+ <div className="h-4 w-3/4 bg-muted rounded" />
148
+ <div className="h-3 w-1/2 bg-muted rounded" />
149
+ </div>
150
+ </div>
151
+ ))}
152
+ </CardContent>
153
+ </Card>
154
+ );
155
+ }
156
+
157
+ if (authors.length === 0) return null;
158
+
159
+ const content = (
160
+ <div className="flex flex-col gap-5">
161
+ {authors.map((author, i) => (
162
+ <AuthorEntry key={i} author={author} fields={vcardFields} />
163
+ ))}
164
+ </div>
165
+ );
166
+
167
+ if (embedded) {
168
+ return <div className={cn("p-4", className)}>{content}</div>;
169
+ }
170
+
171
+ return (
172
+ <Card className={cn("!gap-0", className)}>
173
+ <CardHeader className="pb-3">
174
+ <CardTitle className="text-lg font-heading tracking-tight">
175
+ {title}
176
+ </CardTitle>
177
+ </CardHeader>
178
+ <CardContent className="pb-5">
179
+ {content}
180
+ </CardContent>
181
+ </Card>
182
+ );
183
+ };
@@ -27,6 +27,7 @@ import {
27
27
  type Props = {
28
28
  title: string;
29
29
  linkPattern: string;
30
+ bookmarkDocumentLinkPattern?: string;
30
31
  data: CommonItemsModel;
31
32
  metadataIncludeProperties?: Array<keyof InformationUnitModel>;
32
33
  metadataExcludeProperties?: Array<keyof InformationUnitModel>;
@@ -115,6 +116,7 @@ export const InformationUnitMetadataGridClient = ({
115
116
  data,
116
117
  embedded = false,
117
118
  linkPattern,
119
+ bookmarkDocumentLinkPattern,
118
120
  metadataIncludeProperties,
119
121
  metadataExcludeProperties,
120
122
  showBookmarkButton = false,
@@ -150,7 +152,10 @@ export const InformationUnitMetadataGridClient = ({
150
152
  <h4 className="text-sm font-medium">{t("favorites")}</h4>
151
153
  </TableCell>
152
154
  <TableCell className="min-h-12 pt-3 text-xs text-muted-foreground align-top break-words whitespace-normal">
153
- <BookmarkButton shortId={data.shortId!} />
155
+ <BookmarkButton
156
+ shortId={data.shortId!}
157
+ documentLinkPattern={bookmarkDocumentLinkPattern}
158
+ />
154
159
  </TableCell>
155
160
  </TableRow>
156
161
  )}
@@ -211,7 +216,12 @@ export const InformationUnitMetadataGridClient = ({
211
216
  <CardHeader>
212
217
  <CardTitle className="text-lg flex justify-between items-end">
213
218
  {title}
214
- {showBookmarkButton && <BookmarkButton shortId={data.shortId!} />}
219
+ {showBookmarkButton && (
220
+ <BookmarkButton
221
+ shortId={data.shortId!}
222
+ documentLinkPattern={bookmarkDocumentLinkPattern}
223
+ />
224
+ )}
215
225
  </CardTitle>
216
226
  </CardHeader>
217
227
  {cardContent}
@@ -4,10 +4,8 @@ import { SignInBtn } from "./sign-in-out-btns";
4
4
  import { getServerSession } from "next-auth";
5
5
  import { SettingsMenu } from "./settings";
6
6
  import { UserMenu } from "./user-menu";
7
- import { SearchInput } from "../search-input";
8
7
  import { CrexSDK } from "@c-rex/core/sdk";
9
8
  import * as AutocompleteOptions from "../generated/suggestions";
10
- import { cn } from "@c-rex/utils";
11
9
  import { getTranslations } from "next-intl/server";
12
10
  import { Button } from "@c-rex/ui/button";
13
11
  import { DropdownHoverItem } from "@c-rex/ui/dropdown-hover-item";
@@ -0,0 +1,148 @@
1
+ import { resolveMenuState } from "../restriction-menu-logic";
2
+
3
+ const BASE = { maxItemsByRow: 7, itemCount: 0 };
4
+
5
+ describe("resolveMenuState – restrict construction", () => {
6
+ it("hierarchy mode (components): prepends hasInformationUnits=true when onlyUsedEntries=true", () => {
7
+ const { restrict } = resolveMenuState({ ...BASE, queryRestrict: [], onlyUsedEntries: true });
8
+ expect(restrict).toEqual(["hasInformationUnits=true"]);
9
+ });
10
+
11
+ it("flat mode (informationSubjects): prepends hasInformationUnits=true when onlyUsedEntries=true", () => {
12
+ const { restrict } = resolveMenuState({
13
+ ...BASE,
14
+ queryRestrict: ["someOtherFilter=x"],
15
+ onlyUsedEntries: true,
16
+ });
17
+ expect(restrict[0]).toBe("hasInformationUnits=true");
18
+ expect(restrict).toContain("someOtherFilter=x");
19
+ });
20
+
21
+ it("omits hasInformationUnits filter when onlyUsedEntries=false", () => {
22
+ const { restrict } = resolveMenuState({ ...BASE, queryRestrict: [], onlyUsedEntries: false });
23
+ expect(restrict).not.toContain("hasInformationUnits=true");
24
+ });
25
+
26
+ it("passes through existing queryRestrict entries when onlyUsedEntries=false", () => {
27
+ const { restrict } = resolveMenuState({
28
+ ...BASE,
29
+ queryRestrict: ["category=foo"],
30
+ onlyUsedEntries: false,
31
+ });
32
+ expect(restrict).toEqual(["category=foo"]);
33
+ });
34
+ });
35
+
36
+ describe("resolveMenuState – resolvedPageSize", () => {
37
+ it("deferred mode, not loaded: uses maxItemsByRow as page size", () => {
38
+ const { resolvedPageSize } = resolveMenuState({
39
+ ...BASE,
40
+ maxItemsByRow: 7,
41
+ fetchMode: "deferred",
42
+ loadAll: false,
43
+ });
44
+ expect(resolvedPageSize).toBe(7);
45
+ });
46
+
47
+ it("deferred mode, loadAll=true: uses 100", () => {
48
+ const { resolvedPageSize } = resolveMenuState({
49
+ ...BASE,
50
+ maxItemsByRow: 7,
51
+ fetchMode: "deferred",
52
+ loadAll: true,
53
+ });
54
+ expect(resolvedPageSize).toBe(100);
55
+ });
56
+
57
+ it("all mode: uses 100 regardless of loadAll", () => {
58
+ const { resolvedPageSize } = resolveMenuState({
59
+ ...BASE,
60
+ maxItemsByRow: 7,
61
+ fetchMode: "all",
62
+ loadAll: false,
63
+ });
64
+ expect(resolvedPageSize).toBe(100);
65
+ });
66
+
67
+ it("explicit pageSize overrides deferred mode", () => {
68
+ const { resolvedPageSize } = resolveMenuState({
69
+ ...BASE,
70
+ maxItemsByRow: 7,
71
+ fetchMode: "deferred",
72
+ loadAll: false,
73
+ explicitPageSize: 25,
74
+ });
75
+ expect(resolvedPageSize).toBe(25);
76
+ });
77
+ });
78
+
79
+ describe("resolveMenuState – hasMoreItems", () => {
80
+ it("deferred mode: hasMoreItems=true when itemCount equals resolvedPageSize and not loaded", () => {
81
+ const { hasMoreItems } = resolveMenuState({
82
+ ...BASE,
83
+ maxItemsByRow: 7,
84
+ fetchMode: "deferred",
85
+ loadAll: false,
86
+ itemCount: 7,
87
+ });
88
+ expect(hasMoreItems).toBe(true);
89
+ });
90
+
91
+ it("deferred mode: hasMoreItems=false after loadAll=true", () => {
92
+ const { hasMoreItems } = resolveMenuState({
93
+ ...BASE,
94
+ maxItemsByRow: 7,
95
+ fetchMode: "deferred",
96
+ loadAll: true,
97
+ itemCount: 7,
98
+ });
99
+ expect(hasMoreItems).toBe(false);
100
+ });
101
+
102
+ it("server-side pagination: hasMoreItems=true when totalItemCount > itemCount", () => {
103
+ const { hasMoreItems } = resolveMenuState({
104
+ ...BASE,
105
+ maxItemsByRow: 7,
106
+ fetchMode: "deferred",
107
+ loadAll: false,
108
+ itemCount: 7,
109
+ totalItemCount: 20,
110
+ });
111
+ expect(hasMoreItems).toBe(true);
112
+ });
113
+
114
+ it("server-side pagination: hasMoreItems=false when all items loaded", () => {
115
+ const { hasMoreItems } = resolveMenuState({
116
+ ...BASE,
117
+ maxItemsByRow: 7,
118
+ fetchMode: "deferred",
119
+ loadAll: false,
120
+ itemCount: 7,
121
+ totalItemCount: 7,
122
+ });
123
+ expect(hasMoreItems).toBe(false);
124
+ });
125
+
126
+ it("explicit pageSize: hasMoreItems driven only by server totalItemCount", () => {
127
+ const noPagination = resolveMenuState({
128
+ ...BASE,
129
+ maxItemsByRow: 7,
130
+ fetchMode: "deferred",
131
+ loadAll: false,
132
+ explicitPageSize: 25,
133
+ itemCount: 25,
134
+ });
135
+ expect(noPagination.hasMoreItems).toBe(false);
136
+
137
+ const withMore = resolveMenuState({
138
+ ...BASE,
139
+ maxItemsByRow: 7,
140
+ fetchMode: "deferred",
141
+ loadAll: false,
142
+ explicitPageSize: 25,
143
+ itemCount: 25,
144
+ totalItemCount: 50,
145
+ });
146
+ expect(withMore.hasMoreItems).toBe(true);
147
+ });
148
+ });
@@ -31,6 +31,7 @@ export const RestrictionNavigationItem: FC<Props> = ({
31
31
  history: "push",
32
32
  });
33
33
  const startSearchNavigation = useSearchNavigationStore((state) => state.start);
34
+ const searchNavigationPending = useSearchNavigationStore((state) => state.pending);
34
35
 
35
36
  const { restrictionValue, shouldRemoveRestrictParam } = getRestrictionValue({
36
37
  shortId,
@@ -49,6 +50,8 @@ export const RestrictionNavigationItem: FC<Props> = ({
49
50
  <Button
50
51
  variant={selected ? "default" : "outline"}
51
52
  rounded="full"
53
+ aria-pressed={selected}
54
+ disabled={searchNavigationPending}
52
55
  onClick={() => {
53
56
  startSearchNavigation();
54
57
  if (shouldRemoveRestrictParam) {
@@ -82,6 +85,7 @@ export const RestrictionDropdownItem: FC<Props> = ({
82
85
  history: "push",
83
86
  });
84
87
  const startSearchNavigation = useSearchNavigationStore((state) => state.start);
88
+ const searchNavigationPending = useSearchNavigationStore((state) => state.pending);
85
89
 
86
90
  const { restrictionValue, shouldRemoveRestrictParam } = getRestrictionValue({
87
91
  shortId,
@@ -97,6 +101,8 @@ export const RestrictionDropdownItem: FC<Props> = ({
97
101
  <TooltipTrigger asChild>
98
102
  <Button
99
103
  variant={selected ? "default" : "ghost"}
104
+ aria-pressed={selected}
105
+ disabled={searchNavigationPending}
100
106
  onClick={() => {
101
107
  if (onClick) {
102
108
  onClick();
@@ -141,16 +147,16 @@ function getRestrictionValue({
141
147
 
142
148
  if (currentRestrict && multipleSelection) {
143
149
  if (selected) {
144
- const restrictionsLength = currentRestrict.split(",").length;
145
- //if there is only one restriction, we can remove the whole restrict param
146
- if (restrictionsLength === 1) {
150
+ const eqIndex = currentRestrict.indexOf("=");
151
+ if (eqIndex === -1) {
147
152
  shouldRemoveRestrictParam = true;
148
153
  } else {
149
- restrictParam = currentRestrict.replace(`${shortId}`, "").replace(",,", ",").replace(/(^,)|(,$)/g, "");
150
-
151
- // Remove the restrict param if nothing remains after '='
152
- if (/^[^=]+=$/.test(restrictParam)) {
154
+ const fieldPrefix = currentRestrict.slice(0, eqIndex + 1);
155
+ const ids = currentRestrict.slice(eqIndex + 1).split(",").filter((id) => id !== shortId);
156
+ if (ids.length === 0) {
153
157
  shouldRemoveRestrictParam = true;
158
+ } else {
159
+ restrictParam = fieldPrefix + ids.join(",");
154
160
  }
155
161
  }
156
162
  } else {
@@ -0,0 +1,51 @@
1
+ type FetchMode = "all" | "deferred";
2
+
3
+ export type ResolvedMenuState = {
4
+ restrict: string[];
5
+ resolvedPageSize: number;
6
+ hasMoreItems: boolean;
7
+ };
8
+
9
+ export function resolveMenuState({
10
+ queryRestrict = [],
11
+ onlyUsedEntries = true,
12
+ fetchMode = "deferred",
13
+ loadAll = false,
14
+ maxItemsByRow,
15
+ explicitPageSize,
16
+ itemCount,
17
+ totalItemCount,
18
+ }: {
19
+ queryRestrict?: string[];
20
+ onlyUsedEntries?: boolean;
21
+ fetchMode?: FetchMode;
22
+ loadAll?: boolean;
23
+ maxItemsByRow: number;
24
+ explicitPageSize?: number;
25
+ itemCount: number;
26
+ totalItemCount?: number;
27
+ }): ResolvedMenuState {
28
+ const restrict = onlyUsedEntries
29
+ ? ["hasInformationUnits=true", ...queryRestrict]
30
+ : queryRestrict;
31
+
32
+ const resolvedPageSize = explicitPageSize
33
+ ? explicitPageSize
34
+ : fetchMode === "deferred" && !loadAll
35
+ ? Math.max(maxItemsByRow, 1)
36
+ : 100;
37
+
38
+ const hasMoreFromServer =
39
+ typeof totalItemCount === "number" ? totalItemCount > itemCount : false;
40
+ const allItemsLoaded =
41
+ typeof totalItemCount === "number" && itemCount >= totalItemCount;
42
+ const hasMoreItems =
43
+ hasMoreFromServer ||
44
+ (!explicitPageSize &&
45
+ !allItemsLoaded &&
46
+ fetchMode === "deferred" &&
47
+ !loadAll &&
48
+ itemCount >= resolvedPageSize);
49
+
50
+ return { restrict, resolvedPageSize, hasMoreItems };
51
+ }
@@ -6,6 +6,7 @@ import { FC, ReactNode, useMemo, useState } from "react";
6
6
  import * as ComponentOptions from "../generated/client-components";
7
7
  import { Skeleton } from "@c-rex/ui/skeleton";
8
8
  import { RestrictionSelectionMenu } from "./restriction-selection-menu";
9
+ import { resolveMenuState } from "./restriction-menu-logic";
9
10
  import { DEVICE_OPTIONS } from "@c-rex/constants";
10
11
 
11
12
  type GenericRequestData = {
@@ -62,18 +63,21 @@ export const TaxonomyRestrictionMenu: FC<TaxonomyRestrictionMenuProps> = ({
62
63
  }) => {
63
64
  const [loadAll, setLoadAll] = useState(false);
64
65
  const RequestComponent = ComponentOptions[requestType] as unknown as FC<GenericRequestProps>;
65
- const queryRestrict = queryParams?.Restrict || [];
66
- const restrict = onlyUsedEntries ? ["hasInformationUnits=true", ...queryRestrict] : queryRestrict;
66
+ const queryRestrict = useMemo(() => queryParams?.Restrict || [], [queryParams?.Restrict]);
67
67
  const explicitPageSize =
68
68
  Number.isFinite(Number(queryParams?.PageSize)) && Number(queryParams?.PageSize) > 0
69
69
  ? Number(queryParams?.PageSize)
70
70
  : undefined;
71
71
  const maxItemsByRow = useMemo(() => Math.max(...Object.values(itemsByRow)), [itemsByRow]);
72
- const resolvedPageSize = useMemo(() => {
73
- if (explicitPageSize) return explicitPageSize;
74
- if (fetchMode === "deferred" && !loadAll) return Math.max(maxItemsByRow, 1);
75
- return 100;
76
- }, [explicitPageSize, fetchMode, maxItemsByRow, loadAll]);
72
+ const { restrict, resolvedPageSize } = useMemo(() => resolveMenuState({
73
+ queryRestrict,
74
+ onlyUsedEntries,
75
+ fetchMode,
76
+ loadAll,
77
+ maxItemsByRow,
78
+ explicitPageSize,
79
+ itemCount: 0,
80
+ }), [queryRestrict, onlyUsedEntries, fetchMode, loadAll, maxItemsByRow, explicitPageSize]);
77
81
  const requestedFields = Array.isArray(queryParams?.Fields) ? queryParams.Fields : undefined;
78
82
  const resolvedFields = useMemo(() => {
79
83
  const baseFields = requestedFields && requestedFields.length > 0 ? requestedFields : ["labels"];
@@ -102,9 +106,16 @@ export const TaxonomyRestrictionMenu: FC<TaxonomyRestrictionMenuProps> = ({
102
106
  if (!data) return null;
103
107
 
104
108
  const itemCount = data.items?.length || 0;
105
- const totalItemCount = data.pageInfo?.totalItemCount;
106
- const hasMoreFromServer = typeof totalItemCount === "number" ? totalItemCount > itemCount : false;
107
- const hasMoreItems = hasMoreFromServer || (explicitPageSize ? false : (fetchMode === "deferred" && !loadAll && itemCount >= resolvedPageSize));
109
+ const { hasMoreItems } = resolveMenuState({
110
+ queryRestrict,
111
+ onlyUsedEntries,
112
+ fetchMode,
113
+ loadAll,
114
+ maxItemsByRow,
115
+ explicitPageSize,
116
+ itemCount,
117
+ totalItemCount: data.pageInfo?.totalItemCount,
118
+ });
108
119
 
109
120
  return (
110
121
  <RestrictionSelectionMenu
@@ -47,6 +47,7 @@ export const FilterNavbar: FC<FilterNavbarProps> = ({
47
47
  const { setIsMobileFiltersOpen } = useFilterSidebarState();
48
48
  const restrictionList = useRestrictionStore((state) => state.restrictionList);
49
49
  const startSearchNavigation = useSearchNavigationStore((state) => state.start);
50
+ const searchNavigationPending = useSearchNavigationStore((state) => state.pending);
50
51
  const effectiveFacetPresentation = facetPresentation || facetLabelOverrides;
51
52
  const [params, setParams] = useQueryStates({
52
53
  language: parseAsString.withDefault(useSearchSettingsStore.getState().language || ''),
@@ -207,7 +208,7 @@ export const FilterNavbar: FC<FilterNavbarProps> = ({
207
208
  }
208
209
 
209
210
  return (
210
- <div className="pb-4 flex flex-wrap gap-1">
211
+ <div className={`pb-4 flex flex-wrap gap-1 transition-opacity duration-150${searchNavigationPending ? " opacity-60 pointer-events-none" : ""}`}>
211
212
  {hasMobileFilters && (
212
213
  <Button
213
214
  size="sm"
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { FC, useEffect, useMemo, useState } from "react";
3
+ import { FC, useMemo, useState } from "react";
4
4
  import { useLocale, useTranslations } from 'next-intl'
5
- import { Check, ChevronDown, ChevronRight } from "lucide-react"
5
+ import { Check, ChevronDown, ChevronRight, Loader2 } from "lucide-react"
6
6
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@c-rex/ui/collapsible";
7
7
  import { useBreakpoint } from "@c-rex/ui/hooks";
8
8
  import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@c-rex/ui/sheet";
@@ -76,6 +76,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
76
76
  shallow: false,
77
77
  });
78
78
  const startSearchNavigation = useSearchNavigationStore((state) => state.start);
79
+ const searchNavigationPending = useSearchNavigationStore((state) => state.pending);
79
80
  const effectiveFacetPresentation = facetPresentation || facetLabelOverrides;
80
81
 
81
82
  const filteredTags = useMemo(() => {
@@ -105,6 +106,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
105
106
  };
106
107
 
107
108
  const onClickHandler = (key: string, item: FilterItem) => {
109
+ if (!item.active && item.hits === 0) return;
108
110
  startSearchNavigation();
109
111
  if (item.active) {
110
112
  setParams(removeFilterItem(key, item, params.filter))
@@ -171,20 +173,24 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
171
173
 
172
174
  return (
173
175
  <>
174
- {visibleItems.map((item) => (
175
- <SidebarMenuSubItem key={item.shortId}>
176
- <SidebarMenuSubButton
177
- className="cursor-pointer !py-1.5"
178
- data-taxonomy-id={item.taxonomyId}
179
- data-taxonomy-short-id={item.shortId}
180
- isActive={item.active}
181
- onClick={() => onClickHandler(key, item)}
182
- >
183
- {item.label} ({item.hits}/{item.total})
184
- {item.active && <Check className="ml-2" />}
185
- </SidebarMenuSubButton>
186
- </SidebarMenuSubItem>
187
- ))}
176
+ {visibleItems.map((item) => {
177
+ const isDisabled = !item.active && item.hits === 0;
178
+ return (
179
+ <SidebarMenuSubItem key={item.shortId}>
180
+ <SidebarMenuSubButton
181
+ className={isDisabled ? "!py-1 opacity-40 cursor-not-allowed" : "cursor-pointer !py-1"}
182
+ data-taxonomy-id={item.taxonomyId}
183
+ data-taxonomy-short-id={item.shortId}
184
+ isActive={item.active}
185
+ aria-disabled={isDisabled}
186
+ onClick={() => onClickHandler(key, item)}
187
+ >
188
+ {item.label} ({item.hits}/{item.total})
189
+ {item.active && <Check className="ml-2" />}
190
+ </SidebarMenuSubButton>
191
+ </SidebarMenuSubItem>
192
+ );
193
+ })}
188
194
  {(hasMoreItems || canShowLess) && (
189
195
  <SidebarMenuSubItem>
190
196
  <div className="flex items-center gap-3 px-2">
@@ -222,20 +228,24 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
222
228
 
223
229
  return (
224
230
  <>
225
- {visibleItems.map((item) => (
226
- <SidebarMenuSubItem key={item.shortId}>
227
- <SidebarMenuSubButton
228
- className="cursor-pointer !py-1.5"
229
- data-taxonomy-id={item.taxonomyId}
230
- data-taxonomy-short-id={item.shortId}
231
- isActive={item.active}
232
- onClick={() => onClickHandler(facetKey, item)}
233
- >
234
- {item.label} ({item.hits}/{item.total})
235
- {item.active && <Check className="ml-2" />}
236
- </SidebarMenuSubButton>
237
- </SidebarMenuSubItem>
238
- ))}
231
+ {visibleItems.map((item) => {
232
+ const isDisabled = !item.active && item.hits === 0;
233
+ return (
234
+ <SidebarMenuSubItem key={item.shortId}>
235
+ <SidebarMenuSubButton
236
+ className={isDisabled ? "!py-1 opacity-40 cursor-not-allowed" : "cursor-pointer !py-1"}
237
+ data-taxonomy-id={item.taxonomyId}
238
+ data-taxonomy-short-id={item.shortId}
239
+ isActive={item.active}
240
+ aria-disabled={isDisabled}
241
+ onClick={() => onClickHandler(facetKey, item)}
242
+ >
243
+ {item.label} ({item.hits}/{item.total})
244
+ {item.active && <Check className="ml-2" />}
245
+ </SidebarMenuSubButton>
246
+ </SidebarMenuSubItem>
247
+ );
248
+ })}
239
249
  {(hasMoreItems || canShowLess) && (
240
250
  <SidebarMenuSubItem>
241
251
  <div className="flex items-center gap-3 px-2">
@@ -298,7 +308,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
298
308
  <span className={toggleSlotClass} aria-hidden="true" />
299
309
  )}
300
310
  <SidebarMenuSubButton
301
- className={item.isStructural ? "!py-1.5 cursor-default text-muted-foreground" : "cursor-pointer !py-1.5"}
311
+ className={item.isStructural ? "!py-1 cursor-default text-muted-foreground" : "cursor-pointer !py-1"}
302
312
  data-taxonomy-id={item.taxonomyId}
303
313
  data-taxonomy-short-id={item.shortId}
304
314
  isActive={item.active}
@@ -320,7 +330,7 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
320
330
  };
321
331
 
322
332
  const content = (
323
- <SidebarContent className="!gap-0 capitalize" suppressHydrationWarning>
333
+ <SidebarContent className={`!gap-1.5 p-1.5 capitalize${searchNavigationPending ? " opacity-60 pointer-events-none" : ""}`} suppressHydrationWarning>
324
334
  {Object.entries(filteredTags).map(([key, value]) => {
325
335
  const items = value as ResolvedFilterItem[];
326
336
  const hasTaxonomy = Boolean(resolveFacetTaxonomy(key, facetTaxonomies));
@@ -337,21 +347,21 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
337
347
  <div
338
348
  key={key}
339
349
  data-filter-group-key={key}
340
- className="border-b border-border/40 pb-1 mb-1 last:border-b-0 last:mb-0"
350
+ className="rounded-md border border-border/40 overflow-hidden bg-background/50"
341
351
  >
342
352
  {!hasSectionLabels && (
343
353
  <Collapsible defaultOpen className="py-0 group/collapsible">
344
- <SidebarGroup className="!p-1">
345
- <SidebarGroupLabel asChild className="hover:bg-sidebar-accent text-sidebar-accent-foreground text-sm font-bold">
346
- <CollapsibleTrigger className="!h-8 capitalize">
354
+ <SidebarGroup className="!p-0">
355
+ <SidebarGroupLabel asChild className="hover:bg-sidebar-accent text-sidebar-accent-foreground text-xs font-bold bg-muted/30">
356
+ <CollapsibleTrigger className="!h-7 capitalize rounded-none px-2">
347
357
  {propertyLabel}
348
358
  <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
349
359
  </CollapsibleTrigger>
350
360
  </SidebarGroupLabel>
351
361
  <CollapsibleContent>
352
- <SidebarGroupContent>
353
- <SidebarMenu className="!gap-0.5">
354
- <SidebarMenuSub className="!ml-2 !pl-1.5 !py-0 !gap-0.5">
362
+ <SidebarGroupContent className="pb-1">
363
+ <SidebarMenu className="!gap-0">
364
+ <SidebarMenuSub className="!ml-1.5 !pl-1 !py-0.5 !gap-0">
355
365
  {!hierarchicalFacet && renderFacetItems(key, items)}
356
366
  {hierarchicalFacet && visibleRoots.map((item) => renderHierarchyNode(key, item, hierarchy!.children, 0))}
357
367
  {hierarchicalFacet && (hasMoreRoots || canShowLessRoots) && (
@@ -411,22 +421,22 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
411
421
  <Collapsible
412
422
  defaultOpen
413
423
  key={`${key}:${sectionLabel}`}
414
- className="py-0 group/collapsible"
424
+ className="py-0 group/collapsible border-t border-border/30 first:border-t-0"
415
425
  >
416
- <SidebarGroup className="!p-1">
426
+ <SidebarGroup className="!p-0">
417
427
  <SidebarGroupLabel
418
428
  asChild
419
- className="hover:bg-sidebar-accent text-sidebar-accent-foreground text-sm font-bold"
429
+ className="hover:bg-sidebar-accent text-sidebar-accent-foreground text-xs font-bold bg-muted/30"
420
430
  >
421
- <CollapsibleTrigger className="!h-8">
431
+ <CollapsibleTrigger className="!h-7 rounded-none px-2">
422
432
  {sectionLabel}
423
433
  <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
424
434
  </CollapsibleTrigger>
425
435
  </SidebarGroupLabel>
426
436
  <CollapsibleContent>
427
- <SidebarGroupContent>
428
- <SidebarMenu className="!gap-0.5">
429
- <SidebarMenuSub className="!ml-2 !pl-1.5 !py-0 !gap-0.5">
437
+ <SidebarGroupContent className="pb-1">
438
+ <SidebarMenu className="!gap-0">
439
+ <SidebarMenuSub className="!ml-1.5 !pl-1 !py-0.5 !gap-0">
430
440
  {shouldRenderSectionHierarchy && !hasNestedGroups &&
431
441
  visibleSectionRoots.map((item) => renderHierarchyNode(key, item, sectionHierarchy!.children, 0))}
432
442
  {shouldRenderSectionHierarchy && !hasNestedGroups && (hasMoreSectionRoots || canShowLessSectionRoots) && (
@@ -500,7 +510,10 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
500
510
  className="w-[calc(100vw-2rem)] max-w-sm overflow-y-auto !px-2 !pt-6 lg:hidden"
501
511
  >
502
512
  <SheetHeader className="justify-center items-end font-bold">
503
- <SheetTitle>{t("filter.filters")}</SheetTitle>
513
+ <SheetTitle className="flex items-center gap-2">
514
+ {t("filter.filters")}
515
+ {searchNavigationPending && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
516
+ </SheetTitle>
504
517
  <span className="text-xs text-muted-foreground leading-5">
505
518
  {totalItemCount} {t("results.results")}
506
519
  </span>
@@ -509,9 +522,12 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
509
522
  </SheetContent>
510
523
  </Sheet>
511
524
 
512
- <div className="hidden w-60 rounded-md border bg-sidebar pb-4 lg:block lg:w-80">
525
+ <div className="hidden w-56 rounded-md border bg-sidebar pb-2 lg:block lg:w-72">
513
526
  <SidebarHeader className="justify-center items-end font-bold">
514
- {t("filter.filters")}
527
+ <span className="flex items-center gap-2">
528
+ {t("filter.filters")}
529
+ {searchNavigationPending && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
530
+ </span>
515
531
  <span className="text-xs text-muted-foreground leading-5">
516
532
  {totalItemCount} {t("results.results")}
517
533
  </span>
@@ -184,7 +184,7 @@ const SearchResultsBody = <TItem extends CommonItemsModel>({
184
184
  excludeProperties={facetExcludeProperties}
185
185
  />
186
186
 
187
- <div className="flex-1 flex flex-col gap-4">
187
+ <div className={`flex-1 flex flex-col gap-4 transition-opacity duration-150${isLoading ? " opacity-60 pointer-events-none" : ""}`}>
188
188
  {Number(data.pageInfo.pageCount) > 1 && (
189
189
  <Pagination pageInfo={data.pageInfo} className="pt-0" />
190
190
  )}
@@ -34,6 +34,7 @@ export const InformationUnitSearchResultsCardList: FC<InformationUnitSearchResul
34
34
  descriptionFragmentSubjectIds = [],
35
35
  }) => {
36
36
  const t = useTranslations();
37
+ const tTypes = useTranslations("itemTypes");
37
38
 
38
39
  return (
39
40
  <div className="flex-1">
@@ -91,7 +92,7 @@ export const InformationUnitSearchResultsCardList: FC<InformationUnitSearchResul
91
92
  </span>
92
93
 
93
94
  <div>
94
- <Badge>{itemType}</Badge>
95
+ <Badge>{tTypes(itemType.toLowerCase() as any) ?? itemType}</Badge>
95
96
  </div>
96
97
  <span className="text-sm">
97
98
  <HtmlRendition
@@ -5,7 +5,7 @@ import { FC, useEffect, useRef, useState } from "react";
5
5
  import Link from "next/link";
6
6
  import { cn, formatDateToLocale } from "@c-rex/utils";
7
7
  import { Badge } from "@c-rex/ui/badge";
8
- import { useLocale } from "next-intl";
8
+ import { useLocale, useTranslations } from "next-intl";
9
9
  import { TopicsResponseItem } from "@c-rex/interfaces";
10
10
  import { Card } from "@c-rex/ui/card";
11
11
  import { useQueryState } from "nuqs";
@@ -28,6 +28,7 @@ const InformationUnitSearchResultCard: FC<{
28
28
  index: number;
29
29
  query: string | null;
30
30
  }> = ({ item, index, query }) => {
31
+ const t = useTranslations();
31
32
  const locale = useLocale();
32
33
  const date = formatDateToLocale(item.created!, locale);
33
34
  const cardRef = useRef<HTMLDivElement>(null);
@@ -170,8 +171,8 @@ const InformationUnitSearchResultCard: FC<{
170
171
  </div>
171
172
 
172
173
  {!item.disabled && (
173
- <Link href={item.link} className="absolute inset-0">
174
- <span className="sr-only">View article</span>
174
+ <Link href={item.link} className="absolute inset-0" aria-label={t("results.viewArticle")}>
175
+ <span className="sr-only">{t("results.viewArticle")}</span>
175
176
  </Link>
176
177
  )}
177
178
  </Card>
@@ -18,7 +18,9 @@ const IconsToFileExtension: Record<string, React.ReactNode> = {
18
18
  };
19
19
 
20
20
  const InformationUnitSearchResultsTable: FC<InformationUnitSearchResultsTableProps> = ({ items }) => {
21
- const t = useTranslations("results")
21
+ const t = useTranslations("results");
22
+ const tRoot = useTranslations();
23
+ const tTypes = useTranslations("itemTypes");
22
24
  const [query] = useQueryState("search");
23
25
 
24
26
 
@@ -55,7 +57,7 @@ const InformationUnitSearchResultsTable: FC<InformationUnitSearchResultsTablePro
55
57
 
56
58
  <div className="w-4/5 md:w-1/5 p-2">
57
59
  <Badge variant="secondary">
58
- {item.type}
60
+ {tTypes(item.type.toLowerCase() as any) ?? item.type}
59
61
  </Badge>
60
62
  </div>
61
63
 
@@ -78,12 +80,12 @@ const InformationUnitSearchResultsTable: FC<InformationUnitSearchResultsTablePro
78
80
  <DropdownMenuContent>
79
81
  <DropdownMenuItem>
80
82
  <a href={item.files[fileKey].view} target="_blank" rel="noreferrer" className="flex items-center">
81
- <Eye className="mr-2" /> Open {/*TODO: use i18n functions*/}
83
+ <Eye className="mr-2" /> {tRoot("open")}
82
84
  </a>
83
85
  </DropdownMenuItem>
84
86
  <DropdownMenuItem>
85
87
  <a href={item.files[fileKey].download} target="_blank" rel="noreferrer" className="flex items-center">
86
- <CloudDownload className="mr-2" /> Download {/*TODO: use i18n functions*/}
88
+ <CloudDownload className="mr-2" /> {tRoot("download")}
87
89
  </a>
88
90
  </DropdownMenuItem>
89
91
  </DropdownMenuContent>
@@ -14,6 +14,7 @@ import { usePathname, useSearchParams } from "next/navigation";
14
14
  import { ResultContainerPageInfoModel } from "@c-rex/interfaces";
15
15
  import { useTranslations } from "next-intl";
16
16
  import { cn } from "@c-rex/utils";
17
+ import { useSearchNavigationStore } from "../stores/search-navigation-store";
17
18
 
18
19
  interface PaginationProps {
19
20
  pageInfo: ResultContainerPageInfoModel;
@@ -67,6 +68,7 @@ export const Pagination: FC<PaginationProps> = ({ pageInfo, className }) => {
67
68
  const t = useTranslations("results");
68
69
  const pathname = usePathname();
69
70
  const searchParams = useSearchParams();
71
+ const searchNavigationPending = useSearchNavigationStore((state) => state.pending);
70
72
 
71
73
  const pageNumber = pageInfo.pageNumber || 1;
72
74
  const pageCount = pageInfo.pageCount || 1;
@@ -78,7 +80,7 @@ export const Pagination: FC<PaginationProps> = ({ pageInfo, className }) => {
78
80
  const search = currentSearch.length > 0 ? `?${currentSearch}` : "";
79
81
 
80
82
  return (
81
- <div className={cn("flex flex-col gap-3 py-4", className)}>
83
+ <div className={cn("flex flex-col gap-3 py-4 transition-opacity duration-150", searchNavigationPending ? "opacity-60 pointer-events-none" : "", className)}>
82
84
  <PaginationUI className="py-4 items-center justify-center sm:justify-between">
83
85
  <span className="hidden sm:block text-sm text-muted-foreground">
84
86
  {t("paginationResults", {
@@ -25,8 +25,6 @@ export type SearchSettingsState = {
25
25
  wildcard: WildCardType,
26
26
  operator: OperatorType,
27
27
  like: boolean,
28
- facetExcludeProperties?: string[],
29
- metadataExcludeProperties?: string[],
30
28
  }
31
29
 
32
30
  export type SearchSettingsStore = SearchSettingsState & {
@@ -38,8 +36,6 @@ export const defaultSearchSettings: SearchSettingsState = {
38
36
  wildcard: WILD_CARD_OPTIONS.BOTH,
39
37
  operator: OPERATOR_OPTIONS.OR,
40
38
  like: false,
41
- facetExcludeProperties: [],
42
- metadataExcludeProperties: [],
43
39
  }
44
40
 
45
41
  /**
@@ -0,0 +1,128 @@
1
+ import { create } from "zustand";
2
+ import { persist, createJSONStorage, StateStorage } from "zustand/middleware";
3
+
4
+ const cookieStorage: StateStorage = {
5
+ getItem: (name: string): string | null => {
6
+ if (typeof document === "undefined") return null;
7
+ const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
8
+ return match ? decodeURIComponent(match[2] as string) : null;
9
+ },
10
+ setItem: (name: string, value: string): void => {
11
+ if (typeof document === "undefined") return;
12
+ const maxAge = 60 * 60 * 24 * 365;
13
+ document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${maxAge};SameSite=Lax`;
14
+ },
15
+ removeItem: (name: string): void => {
16
+ if (typeof document === "undefined") return;
17
+ document.cookie = `${name}=;path=/;max-age=0`;
18
+ },
19
+ };
20
+
21
+ export type UiPreferencesState = {
22
+ facetExcludeProperties: string[];
23
+ metadataExcludeProperties: string[];
24
+ };
25
+
26
+ export type UiPreferencesStore = UiPreferencesState & {
27
+ updatePreferences: (prefs: Partial<UiPreferencesState>) => void;
28
+ };
29
+
30
+ export const defaultUiPreferences: UiPreferencesState = {
31
+ facetExcludeProperties: [],
32
+ metadataExcludeProperties: [],
33
+ };
34
+
35
+ function readLegacyValues(): Partial<UiPreferencesState> {
36
+ if (typeof document === "undefined") return {};
37
+ try {
38
+ const match = document.cookie.match(new RegExp(`(^| )c-rex-search-settings=([^;]+)`));
39
+ if (!match) return {};
40
+ const parsed = JSON.parse(decodeURIComponent(match[2] as string));
41
+ const state = parsed?.state || {};
42
+ const result: Partial<UiPreferencesState> = {};
43
+ if (Array.isArray(state.facetExcludeProperties)) {
44
+ result.facetExcludeProperties = state.facetExcludeProperties;
45
+ }
46
+ if (Array.isArray(state.metadataExcludeProperties)) {
47
+ result.metadataExcludeProperties = state.metadataExcludeProperties;
48
+ }
49
+ return result;
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ export const useUiPreferencesStore = create<UiPreferencesStore>()(
56
+ persist(
57
+ (set, get) => ({
58
+ ...defaultUiPreferences,
59
+ updatePreferences: (prefs) => set((state) => ({ ...state, ...prefs })),
60
+ }),
61
+ {
62
+ name: "c-rex-ui-preferences",
63
+ storage: createJSONStorage(() => cookieStorage),
64
+ onRehydrateStorage: () => (state) => {
65
+ if (!state) return;
66
+ const isNew =
67
+ state.facetExcludeProperties.length === 0 &&
68
+ state.metadataExcludeProperties.length === 0;
69
+ if (isNew) {
70
+ const legacy = readLegacyValues();
71
+ if (Object.keys(legacy).length > 0) {
72
+ state.updatePreferences(legacy);
73
+ }
74
+ }
75
+ },
76
+ }
77
+ )
78
+ );
79
+
80
+ /**
81
+ * Read UI preferences from cookie on the server.
82
+ * Falls back to legacy `c-rex-search-settings` cookie for migration.
83
+ */
84
+ export function getUiPreferencesFromCookie(
85
+ newCookieValue: string | undefined,
86
+ legacyCookieValue?: string | undefined
87
+ ): UiPreferencesState {
88
+ if (newCookieValue) {
89
+ try {
90
+ const parsed = JSON.parse(newCookieValue);
91
+ const state = parsed?.state || {};
92
+ const result: UiPreferencesState = { ...defaultUiPreferences };
93
+ if (Array.isArray(state.facetExcludeProperties)) {
94
+ result.facetExcludeProperties = state.facetExcludeProperties;
95
+ }
96
+ if (Array.isArray(state.metadataExcludeProperties)) {
97
+ result.metadataExcludeProperties = state.metadataExcludeProperties;
98
+ }
99
+ if (
100
+ result.facetExcludeProperties.length === 0 &&
101
+ result.metadataExcludeProperties.length === 0 &&
102
+ legacyCookieValue
103
+ ) {
104
+ return migrateFromLegacy(legacyCookieValue);
105
+ }
106
+ return result;
107
+ } catch {
108
+ // fall through to legacy
109
+ }
110
+ }
111
+ if (legacyCookieValue) {
112
+ return migrateFromLegacy(legacyCookieValue);
113
+ }
114
+ return { ...defaultUiPreferences };
115
+ }
116
+
117
+ function migrateFromLegacy(cookieValue: string): UiPreferencesState {
118
+ try {
119
+ const parsed = JSON.parse(cookieValue);
120
+ const state = parsed?.state || {};
121
+ return {
122
+ facetExcludeProperties: Array.isArray(state.facetExcludeProperties) ? state.facetExcludeProperties : [],
123
+ metadataExcludeProperties: Array.isArray(state.metadataExcludeProperties) ? state.metadataExcludeProperties : [],
124
+ };
125
+ } catch {
126
+ return { ...defaultUiPreferences };
127
+ }
128
+ }