@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c-rex/components",
3
- "version": "0.3.0-build.24",
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"
@@ -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
- router.push(`${onSelectPath}?${nextParams.toString()}`);
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
- router.push(queryString ? `${onSelectPath}?${queryString}` : onSelectPath);
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(setSuggestions)
125
- .catch(() => setSuggestions([]))
126
- .finally(() => setLoading(false));
127
- }, 300);
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 () => clearTimeout(debounceFetch);
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
- <InputGroup variant={embedded ? "embedded" : "default"}>
135
- <InputGroupInput
136
- variant={embedded ? "embedded" : undefined}
137
- className={inputClass}
138
- type="text"
139
- placeholder={t("search")}
140
- value={query}
141
- onChange={(e) => {
142
- setQuery(e.target.value);
143
- setOpen(true);
144
- }}
145
- onKeyDown={(e) => {
146
- if (e.key === "Enter") {
147
- e.preventDefault();
148
- handleSelect(query);
149
- }
150
- }}
151
- />
152
- {query.length > 0 && (
153
- <InputGroupAddon align="inline-end">
154
- <InputGroupButton
155
- size="icon-xs"
156
- variant="ghost"
157
- aria-label="Clear search"
158
- onClick={clearSearch}
159
- >
160
- <X className="size-3" />
161
- </InputGroupButton>
162
- </InputGroupAddon>
163
- )}
164
- </InputGroup>
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<{ item: CommonItemsModel; showImages: boolean; linkPattern: string }>;
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
- setData(null);
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 () => { isMounted = false; };
155
- }, [queryParams, page, loadByPages, service]);
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 justify-center text-red-500">
225
- {JSON.stringify(error)}
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 item={item} showImages={showImages} linkPattern={linkPattern} />
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 = hasNextPage === undefined ? current >= slidesLength - slidesToShow : !hasNextPage;
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 = hasPrevPage === undefined ? current === 0 : !hasPrevPage;
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
- <ol className={cn("flex gap-2 z-20 list-none p-0 m-0", className)}>
296
- {Array.from({ length: totalOfIndicators }).map((_, index) => {
297
- const pageNumber = index + 1;
298
- return (
299
- <li key={pageNumber}>
300
- <Button
301
- className={`w-3 h-3 rounded-full border-0 cursor-pointer transition-colors ${pageNumber === page ? "bg-gray-800" : "bg-gray-300"}`}
302
- size="xs"
303
- onClick={() => setPage(pageNumber)}
304
- />
305
- </li>
306
- );
307
- })}
308
- </ol>
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
- linkPattern: string
316
- }> = ({ item, showImages, linkPattern }) => {
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
- <Badge className="absolute -top-2 -right-2">{t(itemType.toLowerCase())}</Badge>
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
- <span className="group-hover:underline text-lg font-semibold flex-1">
341
- {title}
342
- </span>
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
+ };