@c-rex/components 0.3.0-build.24 → 0.3.0-build.26
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/autocomplete.tsx +100 -44
- package/src/carousel/carousel.tsx +161 -51
- package/src/documents/expandable-summary.tsx +48 -0
- package/src/documents/result-list.tsx +51 -15
- package/src/generated/create-client-request.tsx +16 -2
- package/src/generated/create-suggestions-request.tsx +5 -3
- package/src/icons/file-icon.tsx +78 -6
- package/src/info/information-unit-metadata-grid.tsx +3 -52
- package/src/info/shared.tsx +11 -0
- package/src/navbar/language-switcher/content-language-switch.tsx +3 -0
- package/src/page-wrapper.tsx +16 -10
- package/src/renditions/file-download.tsx +39 -48
- package/src/renditions/html.tsx +18 -17
- package/src/renditions/image/container.tsx +36 -34
- package/src/renditions/image/rendition.tsx +2 -2
- package/src/restriction-menu/restriction-menu-item.tsx +17 -2
- package/src/restriction-menu/restriction-menu.tsx +45 -5
- package/src/results/dialog-filter.tsx +3 -0
- package/src/results/filter-navbar.tsx +5 -0
- package/src/results/filter-sidebar/index.tsx +328 -46
- package/src/results/filter-sidebar/utils.ts +64 -5
- package/src/results/generic/table-result-list.tsx +2 -2
- package/src/results/information-unit-search-results-card-list.tsx +21 -10
- package/src/results/pagination.tsx +84 -35
- package/src/stores/search-navigation-store.ts +15 -0
- package/src/results/utils.ts +0 -54
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.26",
|
|
4
4
|
"files": [
|
|
5
5
|
"src"
|
|
6
6
|
],
|
|
@@ -177,6 +177,10 @@
|
|
|
177
177
|
"types": "./src/restriction-menu/restriction-menu.tsx",
|
|
178
178
|
"import": "./src/restriction-menu/restriction-menu.tsx"
|
|
179
179
|
},
|
|
180
|
+
"./restriction-menu-container": {
|
|
181
|
+
"types": "./src/restriction-menu/restriction-menu-container.tsx",
|
|
182
|
+
"import": "./src/restriction-menu/restriction-menu-container.tsx"
|
|
183
|
+
},
|
|
180
184
|
"./restriction-menu-item": {
|
|
181
185
|
"types": "./src/restriction-menu/restriction-menu-item.tsx",
|
|
182
186
|
"import": "./src/restriction-menu/restriction-menu-item.tsx"
|
package/src/autocomplete.tsx
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
|
4
4
|
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@c-rex/ui/input-group";
|
|
5
5
|
import { useTranslations } from "next-intl";
|
|
6
6
|
import { SuggestionQueryParams } from "@c-rex/interfaces";
|
|
7
|
-
import { X } from "lucide-react";
|
|
8
|
-
import { useRouter, useSearchParams } from "next/navigation";
|
|
7
|
+
import { Loader2, X } from "lucide-react";
|
|
8
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
9
9
|
import { suggestionRequest } from "./generated/create-suggestions-request";
|
|
10
10
|
import { useQueryState } from "nuqs";
|
|
11
11
|
import { useSearchSettingsStore } from "./stores/search-settings-store";
|
|
12
|
+
import { useSearchNavigationStore } from "./stores/search-navigation-store";
|
|
12
13
|
|
|
13
14
|
export type AutoCompleteProps = {
|
|
14
15
|
initialValue?: string;
|
|
@@ -35,6 +36,9 @@ export const AutoComplete = ({
|
|
|
35
36
|
const [pkg] = useQueryState("package");
|
|
36
37
|
|
|
37
38
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
const suggestionRequestIdRef = useRef(0);
|
|
40
|
+
const suggestionAbortControllerRef = useRef<AbortController | null>(null);
|
|
41
|
+
const pathname = usePathname();
|
|
38
42
|
const searchParams = useSearchParams();
|
|
39
43
|
const router = useRouter();
|
|
40
44
|
|
|
@@ -42,8 +46,12 @@ export const AutoComplete = ({
|
|
|
42
46
|
const [query, setQuery] = useState(initialValue);
|
|
43
47
|
const [loading, setLoading] = useState(false);
|
|
44
48
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
|
49
|
+
const [isNavigating, startNavigation] = useTransition();
|
|
50
|
+
const searchNavigationPending = useSearchNavigationStore((state) => state.pending);
|
|
51
|
+
const startSearchNavigation = useSearchNavigationStore((state) => state.start);
|
|
52
|
+
const stopSearchNavigation = useSearchNavigationStore((state) => state.stop);
|
|
45
53
|
|
|
46
|
-
const fetchSuggestions = useCallback(async (prefix: string): Promise<string[]> => {
|
|
54
|
+
const fetchSuggestions = useCallback(async (prefix: string, signal?: AbortSignal): Promise<string[]> => {
|
|
47
55
|
const params = { ...queryParams };
|
|
48
56
|
const contentLang = useSearchSettingsStore.getState().language;
|
|
49
57
|
const restrictions = searchParams.get("restrict")
|
|
@@ -61,7 +69,7 @@ export const AutoComplete = ({
|
|
|
61
69
|
if (contentLang) params.lang = contentLang;
|
|
62
70
|
if (pkg != null) params.scopes = pkg as unknown as string[];
|
|
63
71
|
|
|
64
|
-
const results = await suggestionRequest({ endpoint, prefix, queryParams: params });
|
|
72
|
+
const results = await suggestionRequest({ endpoint, prefix, queryParams: params, signal });
|
|
65
73
|
|
|
66
74
|
return results.data;
|
|
67
75
|
}, [endpoint, pkg, queryParams, searchParams]);
|
|
@@ -83,7 +91,14 @@ export const AutoComplete = ({
|
|
|
83
91
|
nextParams.set(param.key, param.value);
|
|
84
92
|
});
|
|
85
93
|
|
|
86
|
-
|
|
94
|
+
const targetUrl = `${onSelectPath}?${nextParams.toString()}`;
|
|
95
|
+
const currentUrl = `${pathname}${searchParams.toString().length > 0 ? `?${searchParams.toString()}` : ""}`;
|
|
96
|
+
if (targetUrl === currentUrl) return;
|
|
97
|
+
|
|
98
|
+
startSearchNavigation();
|
|
99
|
+
startNavigation(() => {
|
|
100
|
+
router.push(targetUrl);
|
|
101
|
+
});
|
|
87
102
|
};
|
|
88
103
|
|
|
89
104
|
const clearSearch = () => {
|
|
@@ -97,10 +112,20 @@ export const AutoComplete = ({
|
|
|
97
112
|
onSelectParams?.forEach(param => {
|
|
98
113
|
nextParams.set(param.key, param.value);
|
|
99
114
|
});
|
|
100
|
-
|
|
115
|
+
const targetUrl = queryString ? `${onSelectPath}?${queryString}` : onSelectPath;
|
|
116
|
+
const currentUrl = `${pathname}${searchParams.toString().length > 0 ? `?${searchParams.toString()}` : ""}`;
|
|
117
|
+
if (targetUrl === currentUrl) return;
|
|
118
|
+
|
|
119
|
+
startSearchNavigation();
|
|
120
|
+
startNavigation(() => {
|
|
121
|
+
router.push(targetUrl);
|
|
122
|
+
});
|
|
101
123
|
};
|
|
102
124
|
|
|
103
125
|
useEffect(() => setQuery(initialValue), [initialValue]);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
stopSearchNavigation();
|
|
128
|
+
}, [pathname, searchParams, stopSearchNavigation]);
|
|
104
129
|
|
|
105
130
|
useEffect(() => {
|
|
106
131
|
const handleClickOutside = (e: MouseEvent) => {
|
|
@@ -114,54 +139,85 @@ export const AutoComplete = ({
|
|
|
114
139
|
|
|
115
140
|
useEffect(() => {
|
|
116
141
|
if (query.length < 2) {
|
|
142
|
+
suggestionRequestIdRef.current += 1;
|
|
143
|
+
suggestionAbortControllerRef.current?.abort();
|
|
144
|
+
suggestionAbortControllerRef.current = null;
|
|
145
|
+
setLoading(false);
|
|
117
146
|
setSuggestions([]);
|
|
118
147
|
return;
|
|
119
148
|
}
|
|
120
149
|
|
|
121
150
|
const debounceFetch = setTimeout(() => {
|
|
151
|
+
const requestId = ++suggestionRequestIdRef.current;
|
|
152
|
+
suggestionAbortControllerRef.current?.abort();
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
suggestionAbortControllerRef.current = controller;
|
|
122
155
|
setLoading(true);
|
|
123
|
-
fetchSuggestions(query)
|
|
124
|
-
.then(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
156
|
+
fetchSuggestions(query, controller.signal)
|
|
157
|
+
.then((results) => {
|
|
158
|
+
if (requestId !== suggestionRequestIdRef.current) return;
|
|
159
|
+
setSuggestions(results);
|
|
160
|
+
})
|
|
161
|
+
.catch(() => {
|
|
162
|
+
if (requestId !== suggestionRequestIdRef.current) return;
|
|
163
|
+
setSuggestions([]);
|
|
164
|
+
})
|
|
165
|
+
.finally(() => {
|
|
166
|
+
if (requestId !== suggestionRequestIdRef.current) return;
|
|
167
|
+
setLoading(false);
|
|
168
|
+
});
|
|
169
|
+
}, 250);
|
|
128
170
|
|
|
129
|
-
return () =>
|
|
171
|
+
return () => {
|
|
172
|
+
clearTimeout(debounceFetch);
|
|
173
|
+
};
|
|
130
174
|
}, [query, fetchSuggestions]);
|
|
131
175
|
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
return () => {
|
|
178
|
+
suggestionAbortControllerRef.current?.abort();
|
|
179
|
+
};
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
132
182
|
return (
|
|
133
183
|
<div className="relative flex-1" ref={containerRef}>
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
184
|
+
<form
|
|
185
|
+
onSubmit={(e) => {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
handleSelect(query);
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
<InputGroup variant={embedded ? "embedded" : "default"}>
|
|
191
|
+
<InputGroupInput
|
|
192
|
+
variant={embedded ? "embedded" : undefined}
|
|
193
|
+
className={inputClass}
|
|
194
|
+
type="text"
|
|
195
|
+
placeholder={t("search")}
|
|
196
|
+
value={query}
|
|
197
|
+
disabled={isNavigating || searchNavigationPending}
|
|
198
|
+
onChange={(e) => {
|
|
199
|
+
setQuery(e.target.value);
|
|
200
|
+
setOpen(true);
|
|
201
|
+
}}
|
|
202
|
+
/>
|
|
203
|
+
{isNavigating || searchNavigationPending ? (
|
|
204
|
+
<InputGroupAddon align="inline-end">
|
|
205
|
+
<Loader2 className="size-4 animate-spin" />
|
|
206
|
+
</InputGroupAddon>
|
|
207
|
+
) : query.length > 0 && (
|
|
208
|
+
<InputGroupAddon align="inline-end">
|
|
209
|
+
<InputGroupButton
|
|
210
|
+
size="icon-xs"
|
|
211
|
+
variant="ghost"
|
|
212
|
+
aria-label="Clear search"
|
|
213
|
+
onClick={clearSearch}
|
|
214
|
+
>
|
|
215
|
+
<X className="size-3" />
|
|
216
|
+
</InputGroupButton>
|
|
217
|
+
</InputGroupAddon>
|
|
218
|
+
)}
|
|
219
|
+
</InputGroup>
|
|
220
|
+
</form>
|
|
165
221
|
|
|
166
222
|
{open && (
|
|
167
223
|
<ul className="suggestions-list absolute z-10 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
|
@@ -9,9 +9,9 @@ import {
|
|
|
9
9
|
createContext,
|
|
10
10
|
useContext,
|
|
11
11
|
} from "react";
|
|
12
|
-
import { cn, formatDateToLocale, getLanguage, getTitle, getType } from "@c-rex/utils";
|
|
12
|
+
import { cn, findRelatedFragmentShortId, formatDateToLocale, getLanguage, getTitle, getType } from "@c-rex/utils";
|
|
13
13
|
import { Button } from "@c-rex/ui/button";
|
|
14
|
-
import { ArrowLeft, ArrowRight } from "lucide-react";
|
|
14
|
+
import { ArrowLeft, ArrowRight, LoaderCircle } from "lucide-react";
|
|
15
15
|
import { Skeleton } from "@c-rex/ui/skeleton";
|
|
16
16
|
import * as ServiceOptions from "@c-rex/services/client-requests";
|
|
17
17
|
import { documentsGetAllClientService } from "@c-rex/services/client-requests";
|
|
@@ -21,10 +21,13 @@ import { Card } from "@c-rex/ui/card";
|
|
|
21
21
|
import { Badge } from "@c-rex/ui/badge";
|
|
22
22
|
import { Flag } from "../icons/flag-icon"
|
|
23
23
|
import { Empty } from "../results/empty";
|
|
24
|
-
import { useLocale, useTranslations } from "next-intl";
|
|
24
|
+
import { useLocale, useMessages, useTranslations } from "next-intl";
|
|
25
25
|
import Link from "next/link";
|
|
26
26
|
import { useBreakpoint } from "@c-rex/ui/hooks";
|
|
27
27
|
import { DEVICE_OPTIONS } from "@c-rex/constants";
|
|
28
|
+
import { Alert, AlertDescription, AlertTitle } from "@c-rex/ui/alert";
|
|
29
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@c-rex/ui/tooltip";
|
|
30
|
+
import { toast } from "sonner";
|
|
28
31
|
|
|
29
32
|
type PageInfo = {
|
|
30
33
|
hasNextPage: boolean;
|
|
@@ -43,11 +46,18 @@ type Props = {
|
|
|
43
46
|
};
|
|
44
47
|
indicators?: boolean;
|
|
45
48
|
showImages?: boolean;
|
|
46
|
-
carouselItemComponent?: FC<{
|
|
49
|
+
carouselItemComponent?: FC<{
|
|
50
|
+
item: CommonItemsModel;
|
|
51
|
+
showImages: boolean;
|
|
52
|
+
loadImage: boolean;
|
|
53
|
+
linkPattern: string;
|
|
54
|
+
imageFragmentSubjectIds?: string[];
|
|
55
|
+
}>;
|
|
47
56
|
serviceType: keyof typeof ServiceOptions;
|
|
48
57
|
queryParams?: Record<string, any>;
|
|
49
58
|
loadByPages?: boolean;
|
|
50
59
|
linkPattern: string;
|
|
60
|
+
imageFragmentSubjectIds?: string[];
|
|
51
61
|
};
|
|
52
62
|
type CarouselContextProps = {
|
|
53
63
|
next: () => void;
|
|
@@ -61,6 +71,8 @@ type CarouselContextProps = {
|
|
|
61
71
|
setPage?: (page: number) => void;
|
|
62
72
|
hasNextPage?: boolean;
|
|
63
73
|
hasPrevPage?: boolean;
|
|
74
|
+
loading: boolean;
|
|
75
|
+
pagedMode: boolean;
|
|
64
76
|
};
|
|
65
77
|
|
|
66
78
|
const CarouselContext = createContext<CarouselContextProps | null>(null);
|
|
@@ -89,12 +101,14 @@ export const Carousel: FC<Props> = ({
|
|
|
89
101
|
serviceType,
|
|
90
102
|
queryParams = {},
|
|
91
103
|
loadByPages = false,
|
|
92
|
-
linkPattern
|
|
104
|
+
linkPattern,
|
|
105
|
+
imageFragmentSubjectIds = [],
|
|
93
106
|
}) => {
|
|
94
107
|
const service = ServiceOptions[serviceType] as typeof documentsGetAllClientService;
|
|
95
108
|
const RenderComponent = carouselItemComponent || DefaultRenderCarouselItem;
|
|
96
109
|
|
|
97
110
|
const device = useBreakpoint();
|
|
111
|
+
const t = useTranslations("results");
|
|
98
112
|
const [slidesToShow, setSlidesToShow] = useState(1);
|
|
99
113
|
|
|
100
114
|
useEffect(() => {
|
|
@@ -120,39 +134,47 @@ export const Carousel: FC<Props> = ({
|
|
|
120
134
|
// Data loading
|
|
121
135
|
useEffect(() => {
|
|
122
136
|
let isMounted = true;
|
|
137
|
+
const controller = new AbortController();
|
|
123
138
|
setIsLoading(true);
|
|
124
139
|
setError(null);
|
|
125
|
-
|
|
140
|
+
if (!loadByPages || data == null) {
|
|
141
|
+
setData(null);
|
|
142
|
+
}
|
|
126
143
|
if (loadByPages) {
|
|
127
144
|
(async () => {
|
|
128
145
|
try {
|
|
129
|
-
const result = await service({ ...queryParams, PageNumber: page });
|
|
146
|
+
const result = await service({ ...queryParams, PageNumber: page }, { signal: controller.signal });
|
|
130
147
|
if (!isMounted) return;
|
|
131
148
|
setData((result && 'items' in result) ? result.items as CommonItemsModel[] : []);
|
|
132
149
|
setPageInfo((result && 'pageInfo' in result) ? result.pageInfo as PageInfo : null);
|
|
133
150
|
} catch (err) {
|
|
151
|
+
if (controller.signal.aborted) return;
|
|
134
152
|
if (!isMounted) return;
|
|
135
153
|
setError(err);
|
|
136
154
|
} finally {
|
|
137
|
-
if (isMounted) setIsLoading(false);
|
|
155
|
+
if (isMounted && !controller.signal.aborted) setIsLoading(false);
|
|
138
156
|
}
|
|
139
157
|
})();
|
|
140
158
|
} else {
|
|
141
159
|
(async () => {
|
|
142
160
|
try {
|
|
143
|
-
const result = await service(queryParams);
|
|
161
|
+
const result = await service(queryParams, { signal: controller.signal });
|
|
144
162
|
if (!isMounted) return;
|
|
145
163
|
setData((result && 'items' in result) ? result.items as CommonItemsModel[] : []);
|
|
146
164
|
} catch (err) {
|
|
165
|
+
if (controller.signal.aborted) return;
|
|
147
166
|
if (!isMounted) return;
|
|
148
167
|
setError(err);
|
|
149
168
|
} finally {
|
|
150
|
-
if (isMounted) setIsLoading(false);
|
|
169
|
+
if (isMounted && !controller.signal.aborted) setIsLoading(false);
|
|
151
170
|
}
|
|
152
171
|
})();
|
|
153
172
|
}
|
|
154
|
-
return () => {
|
|
155
|
-
|
|
173
|
+
return () => {
|
|
174
|
+
isMounted = false;
|
|
175
|
+
controller.abort();
|
|
176
|
+
};
|
|
177
|
+
}, [loadByPages, page, queryParams, service]);
|
|
156
178
|
|
|
157
179
|
// Page info
|
|
158
180
|
useEffect(() => {
|
|
@@ -162,6 +184,14 @@ export const Carousel: FC<Props> = ({
|
|
|
162
184
|
}
|
|
163
185
|
}, [pageInfo, loadByPages]);
|
|
164
186
|
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!error) return;
|
|
189
|
+
|
|
190
|
+
toast.error(t("requestFailedTitle"), {
|
|
191
|
+
description: error instanceof Error ? error.message : t("requestFailedDescription"),
|
|
192
|
+
});
|
|
193
|
+
}, [error, t]);
|
|
194
|
+
|
|
165
195
|
// Autoplay logic (static mode only)
|
|
166
196
|
useEffect(() => {
|
|
167
197
|
if (!autoplay || loading || !data || data.length === 0 || loadByPages) return;
|
|
@@ -206,6 +236,8 @@ export const Carousel: FC<Props> = ({
|
|
|
206
236
|
setPage: loadByPages ? setPage : undefined,
|
|
207
237
|
hasNextPage: loadByPages ? hasNextPage : undefined,
|
|
208
238
|
hasPrevPage: loadByPages ? hasPrevPage : undefined,
|
|
239
|
+
loading,
|
|
240
|
+
pagedMode: loadByPages,
|
|
209
241
|
};
|
|
210
242
|
|
|
211
243
|
return (
|
|
@@ -214,26 +246,42 @@ export const Carousel: FC<Props> = ({
|
|
|
214
246
|
<div className={cn("w-full flex items-center")}>
|
|
215
247
|
{(arrows && data && data.length > 0) && <CarouselPrev />}
|
|
216
248
|
<div className={cn("flex-1 overflow-hidden relative flex items-center")}>
|
|
249
|
+
{loadByPages && loading && data && data.length > 0 && (
|
|
250
|
+
<div className="absolute right-3 top-3 z-20 rounded-full bg-background/95 p-2 text-muted-foreground shadow-sm">
|
|
251
|
+
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
217
254
|
<div
|
|
218
255
|
className="flex will-change-transform transition-all duration-600 ease-[cubic-bezier(0.4,0,0.2,1)] w-full"
|
|
219
256
|
style={{ transform: `translateX(-${(current * 100) / slidesToShow}%)` }}
|
|
220
257
|
>
|
|
221
|
-
{loading ? (
|
|
258
|
+
{loading && (!data || data.length === 0) ? (
|
|
222
259
|
<Skeleton className="w-full h-80" />
|
|
223
260
|
) : error ? (
|
|
224
|
-
<div className="w-full h-80 flex items-center
|
|
225
|
-
|
|
261
|
+
<div className="w-full min-h-80 flex items-center">
|
|
262
|
+
<Alert variant="destructive" className="my-2">
|
|
263
|
+
<AlertTitle>{t("requestFailedTitle")}</AlertTitle>
|
|
264
|
+
<AlertDescription>
|
|
265
|
+
{t("requestFailedDescription")}
|
|
266
|
+
</AlertDescription>
|
|
267
|
+
</Alert>
|
|
226
268
|
</div>
|
|
227
269
|
) : data && data.length == 0 ? (
|
|
228
270
|
<Empty />
|
|
229
271
|
) : data && data.length > 0 ? (
|
|
230
|
-
data.map((item: CommonItemsModel) => (
|
|
272
|
+
data.map((item: CommonItemsModel, index: number) => (
|
|
231
273
|
<div
|
|
232
274
|
key={item.shortId}
|
|
233
275
|
className={`flex-shrink-0 flex-grow-0 flex justify-center`}
|
|
234
276
|
style={{ width: `${100 / slidesToShow}%` }}
|
|
235
277
|
>
|
|
236
|
-
<RenderComponent
|
|
278
|
+
<RenderComponent
|
|
279
|
+
item={item}
|
|
280
|
+
showImages={showImages}
|
|
281
|
+
loadImage={showImages && index >= current && index < current + slidesToShow}
|
|
282
|
+
linkPattern={linkPattern}
|
|
283
|
+
imageFragmentSubjectIds={imageFragmentSubjectIds}
|
|
284
|
+
/>
|
|
237
285
|
</div>
|
|
238
286
|
))
|
|
239
287
|
) : null}
|
|
@@ -248,8 +296,10 @@ export const Carousel: FC<Props> = ({
|
|
|
248
296
|
};
|
|
249
297
|
|
|
250
298
|
const CarouselNext: FC = () => {
|
|
251
|
-
const { next, current, slidesToShow, slidesLength, hasNextPage } = useCarousel();
|
|
252
|
-
const disabled =
|
|
299
|
+
const { next, current, slidesToShow, slidesLength, hasNextPage, loading, pagedMode } = useCarousel();
|
|
300
|
+
const disabled = pagedMode
|
|
301
|
+
? loading || !hasNextPage
|
|
302
|
+
: current >= slidesLength - slidesToShow;
|
|
253
303
|
return (
|
|
254
304
|
<Button className="w-9" rounded="full" onClick={next} variant="default" disabled={disabled}>
|
|
255
305
|
<ArrowRight />
|
|
@@ -258,8 +308,8 @@ const CarouselNext: FC = () => {
|
|
|
258
308
|
};
|
|
259
309
|
|
|
260
310
|
const CarouselPrev: FC = () => {
|
|
261
|
-
const { prev, current, hasPrevPage } = useCarousel();
|
|
262
|
-
const disabled =
|
|
311
|
+
const { prev, current, hasPrevPage, loading, pagedMode } = useCarousel();
|
|
312
|
+
const disabled = pagedMode ? loading || !hasPrevPage : current === 0;
|
|
263
313
|
|
|
264
314
|
return (
|
|
265
315
|
<Button className="w-9" rounded="full" onClick={prev} variant="default" disabled={disabled}>
|
|
@@ -269,11 +319,18 @@ const CarouselPrev: FC = () => {
|
|
|
269
319
|
};
|
|
270
320
|
|
|
271
321
|
const CarouselIndicators: FC<{ className?: string }> = ({ className }) => {
|
|
272
|
-
const { current, setCurrent, slidesToShow, slidesLength, page, setPage, pageCount } = useCarousel();
|
|
322
|
+
const { current, setCurrent, slidesToShow, slidesLength, page, setPage, pageCount, loading, pagedMode } = useCarousel();
|
|
273
323
|
// Static mode
|
|
274
324
|
if (!setPage) {
|
|
275
325
|
const totalOfIndicators = Math.max(slidesLength - slidesToShow + 1, 1);
|
|
276
326
|
if (totalOfIndicators === 1) return null;
|
|
327
|
+
if (totalOfIndicators > 8) {
|
|
328
|
+
return (
|
|
329
|
+
<div className={cn("mt-3 flex items-center gap-2 text-sm text-muted-foreground", className)}>
|
|
330
|
+
<span className="rounded-full border px-3 py-1">{current + 1} / {totalOfIndicators}</span>
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
277
334
|
return (
|
|
278
335
|
<ol className={cn("flex gap-2 z-20 list-none p-0 m-0", className)}>
|
|
279
336
|
{Array.from({ length: totalOfIndicators }).map((_, index) => (
|
|
@@ -291,55 +348,108 @@ const CarouselIndicators: FC<{ className?: string }> = ({ className }) => {
|
|
|
291
348
|
// Paginated mode
|
|
292
349
|
const totalOfIndicators = pageCount || 1;
|
|
293
350
|
if (totalOfIndicators === 1) return null;
|
|
351
|
+
const currentPage = page || 1;
|
|
352
|
+
const maxVisibleIndicators = 7;
|
|
353
|
+
const visibleIndicators = Math.min(totalOfIndicators, maxVisibleIndicators);
|
|
354
|
+
const halfWindow = Math.floor(visibleIndicators / 2);
|
|
355
|
+
let startPage = Math.max(1, currentPage - halfWindow);
|
|
356
|
+
let endPage = startPage + visibleIndicators - 1;
|
|
357
|
+
|
|
358
|
+
if (endPage > totalOfIndicators) {
|
|
359
|
+
endPage = totalOfIndicators;
|
|
360
|
+
startPage = Math.max(1, endPage - visibleIndicators + 1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const pages = Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index);
|
|
364
|
+
const hasHiddenBefore = startPage > 1;
|
|
365
|
+
const hasHiddenAfter = endPage < totalOfIndicators;
|
|
366
|
+
|
|
294
367
|
return (
|
|
295
|
-
<
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
368
|
+
<div className={cn("mt-3 flex items-center gap-3", className)}>
|
|
369
|
+
<ol className="flex items-center gap-2 z-20 list-none p-0 m-0">
|
|
370
|
+
{pages.map((pageNumber, index) => {
|
|
371
|
+
const isActive = pageNumber === currentPage;
|
|
372
|
+
const isEdgeHint =
|
|
373
|
+
(index === 0 && hasHiddenBefore) ||
|
|
374
|
+
(index === pages.length - 1 && hasHiddenAfter);
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<li key={pageNumber}>
|
|
378
|
+
<Button
|
|
379
|
+
className={cn(
|
|
380
|
+
"rounded-full border-0 cursor-pointer transition-all",
|
|
381
|
+
isActive ? "w-3 h-3 bg-gray-800" : "bg-gray-300",
|
|
382
|
+
!isActive && isEdgeHint ? "w-2 h-2 opacity-60" : !isActive ? "w-3 h-3" : ""
|
|
383
|
+
)}
|
|
384
|
+
size="xs"
|
|
385
|
+
disabled={loading}
|
|
386
|
+
onClick={() => setPage(pageNumber)}
|
|
387
|
+
aria-label={`Go to page ${pageNumber}`}
|
|
388
|
+
/>
|
|
389
|
+
</li>
|
|
390
|
+
);
|
|
391
|
+
})}
|
|
392
|
+
</ol>
|
|
393
|
+
{pagedMode && loading && <LoaderCircle className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
|
394
|
+
</div>
|
|
309
395
|
);
|
|
310
396
|
};
|
|
311
397
|
|
|
312
398
|
const DefaultRenderCarouselItem: FC<{
|
|
313
399
|
item: CommonItemsModel;
|
|
314
400
|
showImages: boolean;
|
|
315
|
-
|
|
316
|
-
|
|
401
|
+
loadImage: boolean;
|
|
402
|
+
linkPattern: string;
|
|
403
|
+
imageFragmentSubjectIds?: string[];
|
|
404
|
+
}> = ({ item, showImages, loadImage, linkPattern, imageFragmentSubjectIds = [] }) => {
|
|
317
405
|
const locale = useLocale();
|
|
406
|
+
const messages = useMessages() as Record<string, unknown>;
|
|
318
407
|
const t = useTranslations("itemTypes");
|
|
319
408
|
|
|
320
409
|
const date = formatDateToLocale(item.created!, locale);
|
|
321
410
|
const title = getTitle(item.titles, item.labels);
|
|
322
|
-
const itemType = getType(item.class);
|
|
411
|
+
const itemType = getType(item.class, locale);
|
|
412
|
+
const itemTypeKey = itemType?.toLowerCase();
|
|
413
|
+
const itemTypeMessages = (messages.itemTypes || {}) as Record<string, string>;
|
|
414
|
+
const itemTypeLabel = itemTypeKey && itemTypeMessages[itemTypeKey]
|
|
415
|
+
? t(itemTypeKey)
|
|
416
|
+
: itemType;
|
|
323
417
|
const language = getLanguage(item.languages)
|
|
324
418
|
const countryCode = language.split("-")[1] || "";
|
|
325
419
|
const link = linkPattern.replace("{shortId}", item.shortId!);
|
|
420
|
+
const previewFragmentShortId = findRelatedFragmentShortId({
|
|
421
|
+
item,
|
|
422
|
+
informationSubjectIds: imageFragmentSubjectIds,
|
|
423
|
+
});
|
|
326
424
|
|
|
327
425
|
return (
|
|
328
426
|
<Link href={link} className="group p-2 flex flex-1">
|
|
329
|
-
<Card className="p-4 flex-1 justify-between relative">
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
{showImages && (
|
|
333
|
-
<ImageRenditionContainer
|
|
334
|
-
itemShortId={item.shortId!}
|
|
335
|
-
emptyImageStyle="h-48 w-full"
|
|
336
|
-
imageStyle="object-cover h-48 w-full"
|
|
337
|
-
/>
|
|
427
|
+
<Card className={cn("p-4 flex-1 justify-between relative", !showImages && "min-h-0")}>
|
|
428
|
+
{itemType && (
|
|
429
|
+
<Badge className="absolute -top-2 -right-2">{itemTypeLabel}</Badge>
|
|
338
430
|
)}
|
|
339
431
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
432
|
+
{showImages ? (
|
|
433
|
+
<div className="h-48 w-full overflow-hidden">
|
|
434
|
+
<ImageRenditionContainer
|
|
435
|
+
fragmentShortId={loadImage ? previewFragmentShortId : undefined}
|
|
436
|
+
emptyImageStyle="h-48 w-full"
|
|
437
|
+
skeletonStyle="h-48 w-full"
|
|
438
|
+
imageStyle="object-contain object-top h-48 !w-auto max-w-full mx-auto"
|
|
439
|
+
/>
|
|
440
|
+
</div>
|
|
441
|
+
) : null}
|
|
442
|
+
|
|
443
|
+
<Tooltip>
|
|
444
|
+
<TooltipTrigger asChild>
|
|
445
|
+
<div className="h-44 overflow-hidden">
|
|
446
|
+
<span className="line-clamp-5 text-lg font-semibold group-hover:underline">
|
|
447
|
+
{title}
|
|
448
|
+
</span>
|
|
449
|
+
</div>
|
|
450
|
+
</TooltipTrigger>
|
|
451
|
+
<TooltipContent className="max-w-sm break-words">{title}</TooltipContent>
|
|
452
|
+
</Tooltip>
|
|
343
453
|
|
|
344
454
|
<div className="flex justify-between w-full">
|
|
345
455
|
<span className="w-8 block">
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { FC, useState } from "react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { Button } from "@c-rex/ui/button";
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
text: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const collapsedStyle: React.CSSProperties = {
|
|
12
|
+
display: "-webkit-box",
|
|
13
|
+
WebkitLineClamp: 3,
|
|
14
|
+
WebkitBoxOrient: "vertical",
|
|
15
|
+
overflow: "hidden",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const ExpandableSummary: FC<Props> = ({ text }) => {
|
|
19
|
+
const t = useTranslations();
|
|
20
|
+
const [expanded, setExpanded] = useState(false);
|
|
21
|
+
|
|
22
|
+
if (!text.trim()) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex flex-col gap-1">
|
|
28
|
+
<span
|
|
29
|
+
className="block text-sm"
|
|
30
|
+
style={expanded ? undefined : collapsedStyle}
|
|
31
|
+
title={text}
|
|
32
|
+
>
|
|
33
|
+
{text}
|
|
34
|
+
</span>
|
|
35
|
+
{text.length > 180 && (
|
|
36
|
+
<div>
|
|
37
|
+
<Button
|
|
38
|
+
variant="link"
|
|
39
|
+
className="h-auto p-0 text-sm"
|
|
40
|
+
onClick={() => setExpanded((value) => !value)}
|
|
41
|
+
>
|
|
42
|
+
{expanded ? t("less") : t("more")}
|
|
43
|
+
</Button>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
};
|