@c-rex/components 0.3.0-build.28 → 0.3.0-build.30

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.28",
3
+ "version": "0.3.0-build.30",
4
4
  "files": [
5
5
  "src"
6
6
  ],
@@ -53,6 +53,10 @@
53
53
  "types": "./src/info/information-unit-metadata-grid.tsx",
54
54
  "import": "./src/info/information-unit-metadata-grid.tsx"
55
55
  },
56
+ "./information-unit-metadata-grid-client": {
57
+ "types": "./src/info/information-unit-metadata-grid-client.tsx",
58
+ "import": "./src/info/information-unit-metadata-grid-client.tsx"
59
+ },
56
60
  "./info-card": {
57
61
  "types": "./src/info/info-card.tsx",
58
62
  "import": "./src/info/info-card.tsx"
@@ -73,6 +77,10 @@
73
77
  "types": "./src/renditions/html.tsx",
74
78
  "import": "./src/renditions/html.tsx"
75
79
  },
80
+ "./html-rendition-client": {
81
+ "types": "./src/renditions/html-client.tsx",
82
+ "import": "./src/renditions/html-client.tsx"
83
+ },
76
84
  "./image-rendition-container": {
77
85
  "types": "./src/renditions/container.ts",
78
86
  "import": "./src/renditions/container.ts"
@@ -169,6 +177,10 @@
169
177
  "types": "./src/results/generic/table-result-list.tsx",
170
178
  "import": "./src/results/generic/table-result-list.tsx"
171
179
  },
180
+ "./generic-search-results-client": {
181
+ "types": "./src/results/generic/search-results-client.tsx",
182
+ "import": "./src/results/generic/search-results-client.tsx"
183
+ },
172
184
  "./carousel": {
173
185
  "types": "./src/carousel/carousel.tsx",
174
186
  "import": "./src/carousel/carousel.tsx"
@@ -192,7 +204,27 @@
192
204
  "./footer": {
193
205
  "types": "./src/footer/footer.tsx",
194
206
  "import": "./src/footer/footer.tsx"
195
- }
207
+ },
208
+ "./information-unit-carousel-item": {
209
+ "types": "./src/carousel/information-unit-carousel-item.tsx",
210
+ "import": "./src/carousel/information-unit-carousel-item.tsx"
211
+ },
212
+ "./information-unit-fragment-ids": {
213
+ "types": "./src/info/information-unit-fragment-ids.ts",
214
+ "import": "./src/info/information-unit-fragment-ids.ts"
215
+ },
216
+ "./information-unit-preview-image": {
217
+ "types": "./src/info/information-unit-preview-image.tsx",
218
+ "import": "./src/info/information-unit-preview-image.tsx"
219
+ },
220
+ "./restriction-selection-menu": {
221
+ "types": "./src/restriction-menu/restriction-selection-menu.tsx",
222
+ "import": "./src/restriction-menu/restriction-selection-menu.tsx"
223
+ },
224
+ "./taxonomy-restriction-menu": {
225
+ "types": "./src/restriction-menu/taxonomy-restriction-menu.tsx",
226
+ "import": "./src/restriction-menu/taxonomy-restriction-menu.tsx"
227
+ }
196
228
  },
197
229
  "scripts": {
198
230
  "storybook": "storybook dev -p 6006",
@@ -9,31 +9,30 @@ import {
9
9
  createContext,
10
10
  useContext,
11
11
  } from "react";
12
- import { cn, findRelatedFragmentShortId, formatDateToLocale, getLanguage, getTitle, getType } from "@c-rex/utils";
12
+ import { cn } from "@c-rex/utils";
13
13
  import { Button } from "@c-rex/ui/button";
14
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";
18
18
  import { CommonItemsModel } from "@c-rex/interfaces";
19
- import { ImageRenditionContainer } from "../renditions/image/container";
20
- import { Card } from "@c-rex/ui/card";
21
- import { Badge } from "@c-rex/ui/badge";
22
- import { Flag } from "../icons/flag-icon"
23
19
  import { Empty } from "../results/empty";
24
- import { useLocale, useMessages, useTranslations } from "next-intl";
25
- import Link from "next/link";
20
+ import { useTranslations } from "next-intl";
26
21
  import { useBreakpoint } from "@c-rex/ui/hooks";
27
22
  import { DEVICE_OPTIONS } from "@c-rex/constants";
28
23
  import { Alert, AlertDescription, AlertTitle } from "@c-rex/ui/alert";
29
- import { Tooltip, TooltipContent, TooltipTrigger } from "@c-rex/ui/tooltip";
30
24
  import { toast } from "sonner";
25
+ import { InformationUnitCarouselItem } from "./information-unit-carousel-item";
26
+ import { Card } from "@c-rex/ui/card";
31
27
 
32
28
  type PageInfo = {
33
29
  hasNextPage: boolean;
34
30
  hasPreviousPage: boolean;
35
31
  pageCount: number;
36
32
  };
33
+
34
+ type CarouselQueryParams = Record<string, unknown>;
35
+
37
36
  type Props = {
38
37
  className?: string;
39
38
  arrows?: boolean;
@@ -54,10 +53,11 @@ type Props = {
54
53
  imageFragmentSubjectIds?: string[];
55
54
  }>;
56
55
  serviceType: keyof typeof ServiceOptions;
57
- queryParams?: Record<string, any>;
56
+ queryParams?: CarouselQueryParams;
58
57
  loadByPages?: boolean;
59
58
  linkPattern: string;
60
59
  imageFragmentSubjectIds?: string[];
60
+ deferUntilVisible?: boolean;
61
61
  };
62
62
  type CarouselContextProps = {
63
63
  next: () => void;
@@ -77,6 +77,33 @@ type CarouselContextProps = {
77
77
 
78
78
  const CarouselContext = createContext<CarouselContextProps | null>(null);
79
79
 
80
+ const CarouselSkeletonTrack: FC<{ slidesToShow: number }> = ({ slidesToShow }) => (
81
+ <div className="flex w-full">
82
+ {Array.from({ length: slidesToShow }).map((_, index) => (
83
+ <div
84
+ key={`carousel-skeleton-${index}`}
85
+ className="flex-shrink-0 flex-grow-0 flex justify-center p-2"
86
+ style={{ width: `${100 / slidesToShow}%` }}
87
+ >
88
+ <Card className="p-4 flex-1 justify-between relative">
89
+ <div className="h-48 w-full overflow-hidden">
90
+ <Skeleton className="h-48 w-full" />
91
+ </div>
92
+ <div className="h-44 flex flex-col gap-3 pt-4">
93
+ <Skeleton className="h-6 w-full" />
94
+ <Skeleton className="h-6 w-5/6" />
95
+ <Skeleton className="h-6 w-2/3" />
96
+ </div>
97
+ <div className="flex justify-between w-full">
98
+ <Skeleton className="h-5 w-8" />
99
+ <Skeleton className="h-5 w-24" />
100
+ </div>
101
+ </Card>
102
+ </div>
103
+ ))}
104
+ </div>
105
+ );
106
+
80
107
  export function useCarousel() {
81
108
  const context = useContext(CarouselContext);
82
109
  if (!context) {
@@ -103,6 +130,7 @@ export const Carousel: FC<Props> = ({
103
130
  loadByPages = false,
104
131
  linkPattern,
105
132
  imageFragmentSubjectIds = [],
133
+ deferUntilVisible = false,
106
134
  }) => {
107
135
  const service = ServiceOptions[serviceType] as typeof documentsGetAllClientService;
108
136
  const RenderComponent = carouselItemComponent || DefaultRenderCarouselItem;
@@ -110,12 +138,33 @@ export const Carousel: FC<Props> = ({
110
138
  const device = useBreakpoint();
111
139
  const t = useTranslations("results");
112
140
  const [slidesToShow, setSlidesToShow] = useState(1);
141
+ const containerRef = useRef<HTMLDivElement | null>(null);
142
+ const [canLoad, setCanLoad] = useState(!deferUntilVisible);
113
143
 
114
144
  useEffect(() => {
115
145
  if (device == null) return;
116
146
  setSlidesToShow(itemsByRow[device as keyof typeof DEVICE_OPTIONS] as number);
117
147
  }, [device, itemsByRow]);
118
148
 
149
+ useEffect(() => {
150
+ if (!deferUntilVisible || canLoad) return;
151
+ const node = containerRef.current;
152
+ if (!node) return;
153
+
154
+ const observer = new IntersectionObserver(
155
+ (entries) => {
156
+ const firstEntry = entries[0];
157
+ if (!firstEntry?.isIntersecting) return;
158
+ setCanLoad(true);
159
+ observer.disconnect();
160
+ },
161
+ { rootMargin: "200px 0px", threshold: 0.05 }
162
+ );
163
+
164
+ observer.observe(node);
165
+ return () => observer.disconnect();
166
+ }, [deferUntilVisible, canLoad]);
167
+
119
168
  // State
120
169
  const [current, setCurrent] = useState(0);
121
170
  const [data, setData] = useState<CommonItemsModel[] | null>(null);
@@ -133,6 +182,9 @@ export const Carousel: FC<Props> = ({
133
182
 
134
183
  // Data loading
135
184
  useEffect(() => {
185
+ if (!canLoad) {
186
+ return;
187
+ }
136
188
  let isMounted = true;
137
189
  const controller = new AbortController();
138
190
  setIsLoading(true);
@@ -174,7 +226,7 @@ export const Carousel: FC<Props> = ({
174
226
  isMounted = false;
175
227
  controller.abort();
176
228
  };
177
- }, [loadByPages, page, queryParams, service]);
229
+ }, [canLoad, loadByPages, page, queryParams, service]);
178
230
 
179
231
  // Page info
180
232
  useEffect(() => {
@@ -243,7 +295,7 @@ export const Carousel: FC<Props> = ({
243
295
  return (
244
296
  <CarouselContext.Provider value={contextValue}>
245
297
  <div className={cn("flex items-center flex-col", className)}>
246
- <div className={cn("w-full flex items-center")}>
298
+ <div ref={containerRef} className={cn("w-full flex items-center")}>
247
299
  {(arrows && data && data.length > 0) && <CarouselPrev />}
248
300
  <div className={cn("flex-1 overflow-hidden relative flex items-center")}>
249
301
  {loadByPages && loading && data && data.length > 0 && (
@@ -252,11 +304,14 @@ export const Carousel: FC<Props> = ({
252
304
  </div>
253
305
  )}
254
306
  <div
255
- className="flex will-change-transform transition-all duration-600 ease-[cubic-bezier(0.4,0,0.2,1)] w-full"
256
- style={{ transform: `translateX(-${(current * 100) / slidesToShow}%)` }}
307
+ className="flex will-change-transform transition-all duration-600 w-full"
308
+ style={{
309
+ transform: `translateX(-${(current * 100) / slidesToShow}%)`,
310
+ transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
311
+ }}
257
312
  >
258
313
  {loading && (!data || data.length === 0) ? (
259
- <Skeleton className="w-full h-80" />
314
+ <CarouselSkeletonTrack slidesToShow={slidesToShow} />
260
315
  ) : error ? (
261
316
  <div className="w-full min-h-80 flex items-center">
262
317
  <Alert variant="destructive" className="my-2">
@@ -410,63 +465,4 @@ const DefaultRenderCarouselItem: FC<{
410
465
  loadImage: boolean;
411
466
  linkPattern: string;
412
467
  imageFragmentSubjectIds?: string[];
413
- }> = ({ item, showImages, loadImage, linkPattern, imageFragmentSubjectIds = [] }) => {
414
- const locale = useLocale();
415
- const messages = useMessages() as Record<string, unknown>;
416
- const t = useTranslations("itemTypes");
417
-
418
- const date = formatDateToLocale(item.created!, locale);
419
- const title = getTitle(item.titles, item.labels);
420
- const itemType = getType(item.class, locale);
421
- const itemTypeKey = itemType?.toLowerCase();
422
- const itemTypeMessages = (messages.itemTypes || {}) as Record<string, string>;
423
- const itemTypeLabel = itemTypeKey && itemTypeMessages[itemTypeKey]
424
- ? t(itemTypeKey)
425
- : itemType;
426
- const language = getLanguage(item.languages)
427
- const countryCode = language.split("-")[1] || "";
428
- const link = linkPattern.replace("{shortId}", item.shortId!);
429
- const previewFragmentShortId = findRelatedFragmentShortId({
430
- item,
431
- informationSubjectIds: imageFragmentSubjectIds,
432
- });
433
-
434
- return (
435
- <Link href={link} className="group p-2 flex flex-1">
436
- <Card className={cn("p-4 flex-1 justify-between relative", !showImages && "min-h-0")}>
437
- {itemType && (
438
- <Badge className="absolute -top-2 -right-2">{itemTypeLabel}</Badge>
439
- )}
440
-
441
- {showImages ? (
442
- <div className="h-48 w-full overflow-hidden">
443
- <ImageRenditionContainer
444
- fragmentShortId={loadImage ? previewFragmentShortId : undefined}
445
- emptyImageStyle="h-48 w-full"
446
- skeletonStyle="h-48 w-full"
447
- imageStyle="object-contain object-top h-48 !w-auto max-w-full mx-auto"
448
- />
449
- </div>
450
- ) : null}
451
-
452
- <Tooltip>
453
- <TooltipTrigger asChild>
454
- <div className="h-44 overflow-hidden">
455
- <span className="line-clamp-5 text-lg font-semibold group-hover:underline">
456
- {title}
457
- </span>
458
- </div>
459
- </TooltipTrigger>
460
- <TooltipContent className="max-w-sm break-words">{title}</TooltipContent>
461
- </Tooltip>
462
-
463
- <div className="flex justify-between w-full">
464
- <span className="w-8 block">
465
- <Flag countryCode={countryCode} />
466
- </span>
467
- <span className="text-gray-400">{date || item.revision}</span>
468
- </div>
469
- </Card>
470
- </Link>
471
- );
472
- };
468
+ }> = (props) => <InformationUnitCarouselItem {...props} />;
@@ -0,0 +1,85 @@
1
+ "use client";
2
+
3
+ import type { FC } from "react";
4
+ import Link from "next/link";
5
+ import { useLocale, useMessages, useTranslations } from "next-intl";
6
+ import { Card } from "@c-rex/ui/card";
7
+ import { Badge } from "@c-rex/ui/badge";
8
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@c-rex/ui/tooltip";
9
+ import type { CommonItemsModel } from "@c-rex/interfaces";
10
+ import { Flag } from "../icons/flag-icon";
11
+ import { InformationUnitPreviewImage } from "../info/information-unit-preview-image";
12
+ import { cn, formatDateToLocale, getLanguage, getTitle, getType } from "@c-rex/utils";
13
+
14
+ type Props = {
15
+ item: CommonItemsModel;
16
+ showImages: boolean;
17
+ loadImage: boolean;
18
+ linkPattern: string;
19
+ imageFragmentSubjectIds?: string[];
20
+ };
21
+
22
+ export const InformationUnitCarouselItem: FC<Props> = ({
23
+ item,
24
+ showImages,
25
+ loadImage,
26
+ linkPattern,
27
+ imageFragmentSubjectIds = [],
28
+ }) => {
29
+ const locale = useLocale();
30
+ const messages = useMessages() as Record<string, unknown>;
31
+ const t = useTranslations("itemTypes");
32
+
33
+ const date = formatDateToLocale(item.created!, locale);
34
+ const title = getTitle(item.titles, item.labels);
35
+ const itemType = getType(item.class, locale);
36
+ const itemTypeKey = itemType?.toLowerCase();
37
+ const itemTypeMessages = (messages.itemTypes || {}) as Record<string, string>;
38
+ const itemTypeLabel = itemTypeKey && itemTypeMessages[itemTypeKey]
39
+ ? t(itemTypeKey)
40
+ : itemType;
41
+ const language = getLanguage(item.languages);
42
+ const countryCode = language.split("-")[1] || "";
43
+ const link = linkPattern.replace("{shortId}", item.shortId!);
44
+
45
+ return (
46
+ <Link href={link} className="group p-2 flex flex-1">
47
+ <Card className={cn("p-4 flex-1 justify-between relative", !showImages && "min-h-0")}>
48
+ {itemType && (
49
+ <Badge className="absolute -top-2 -right-2">{itemTypeLabel}</Badge>
50
+ )}
51
+
52
+ {showImages ? (
53
+ <div className="h-48 w-full overflow-hidden">
54
+ <InformationUnitPreviewImage
55
+ item={item}
56
+ imageFragmentSubjectIds={imageFragmentSubjectIds}
57
+ loadImage={loadImage}
58
+ emptyImageStyle="h-48 w-full"
59
+ skeletonStyle="h-48 w-full"
60
+ imageStyle="object-contain object-top h-48 !w-auto max-w-full mx-auto"
61
+ />
62
+ </div>
63
+ ) : null}
64
+
65
+ <Tooltip>
66
+ <TooltipTrigger asChild>
67
+ <div className="h-44 overflow-hidden">
68
+ <span className="line-clamp-5 text-lg font-semibold group-hover:underline">
69
+ {title}
70
+ </span>
71
+ </div>
72
+ </TooltipTrigger>
73
+ <TooltipContent className="max-w-sm break-words">{title}</TooltipContent>
74
+ </Tooltip>
75
+
76
+ <div className="flex justify-between w-full">
77
+ <span className="w-8 block">
78
+ <Flag countryCode={countryCode} />
79
+ </span>
80
+ <span className="text-gray-400">{date || item.revision}</span>
81
+ </div>
82
+ </Card>
83
+ </Link>
84
+ );
85
+ };
@@ -260,11 +260,13 @@ export const DirectoryTreeSidebarMenu: FC<DirectoryTreeSidebarMenuProps> = ({
260
260
 
261
261
  if (isLoading && tree.length === 0) {
262
262
  return (
263
- <div className="pt-4">
263
+ <div className="pt-4 space-y-2">
264
264
  <Skeleton className="w-auto h-10 mb-2" />
265
265
  <Skeleton className="w-auto h-10 mb-2" />
266
266
  <Skeleton className="w-auto h-10 mb-2 ml-8" />
267
267
  <Skeleton className="w-auto h-10 mb-2 ml-8" />
268
+ <Skeleton className="w-auto h-10 mb-2" />
269
+ <div className="px-2 pt-1 text-xs text-muted-foreground">Inhaltsverzeichnis wird geladen...</div>
268
270
  </div>
269
271
  );
270
272
  }
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { FC, useEffect, useState } from "react";
4
+ import { FragmentsGetByIdClient } from "../generated/client-components";
5
+ import { ExpandableSummary } from "./expandable-summary";
6
+ import type { RenditionModel } from "@c-rex/interfaces";
7
+
8
+ interface Props {
9
+ fragmentShortId?: string;
10
+ title: string;
11
+ }
12
+
13
+ const htmlFormats = ["application/xhtml+xml", "application/html", "text/html"];
14
+
15
+ const getViewHref = (renditions?: RenditionModel[] | null): string | undefined => {
16
+ if (!renditions || renditions.length === 0) return undefined;
17
+ const htmlRendition = renditions.find((item) => item.format && htmlFormats.includes(item.format));
18
+ return htmlRendition?.links?.find((item) => item.rel === "view")?.href || undefined;
19
+ };
20
+
21
+ const extractSummaryText = (html: string, title: string) => {
22
+ if (typeof window === "undefined") return "";
23
+
24
+ const parser = new DOMParser();
25
+ const doc = parser.parseFromString(html, "text/html");
26
+ const rawText = (doc.body?.textContent || "").replace(/\s+/g, " ").trim();
27
+ const withoutCdata = rawText
28
+ .replace(/\/\/<!\[CDATA\[/g, "")
29
+ .replace(/\/\/\]\]>/g, "")
30
+ .replace(/<!\[CDATA\[/g, "")
31
+ .replace(/\]\]>/g, "")
32
+ .trim();
33
+ const withoutRepeatedTitle = withoutCdata.startsWith(title)
34
+ ? withoutCdata.slice(title.length).trim()
35
+ : withoutCdata;
36
+
37
+ return withoutRepeatedTitle.replace(/^\/{2,}\s*/, "").trim();
38
+ };
39
+
40
+ const DocumentDescriptionPreviewContent: FC<{
41
+ viewHref?: string;
42
+ title: string;
43
+ isLoading: boolean;
44
+ hasError: boolean;
45
+ }> = ({
46
+ viewHref,
47
+ title,
48
+ isLoading,
49
+ hasError,
50
+ }) => {
51
+ const [text, setText] = useState<string | null>(null);
52
+ const [loadingText, setLoadingText] = useState(false);
53
+
54
+ useEffect(() => {
55
+ if (!viewHref) {
56
+ setText(null);
57
+ return;
58
+ }
59
+
60
+ let isMounted = true;
61
+ const controller = new AbortController();
62
+ setLoadingText(true);
63
+
64
+ fetch(viewHref, { signal: controller.signal })
65
+ .then((response) => response.text())
66
+ .then((html) => {
67
+ if (!isMounted || controller.signal.aborted) return;
68
+ setText(extractSummaryText(html, title));
69
+ })
70
+ .catch(() => {
71
+ if (!isMounted || controller.signal.aborted) return;
72
+ setText("");
73
+ })
74
+ .finally(() => {
75
+ if (isMounted && !controller.signal.aborted) {
76
+ setLoadingText(false);
77
+ }
78
+ });
79
+
80
+ return () => {
81
+ isMounted = false;
82
+ controller.abort();
83
+ };
84
+ }, [title, viewHref]);
85
+
86
+ if (hasError) {
87
+ return <span className="text-muted-foreground">No rendition available</span>;
88
+ }
89
+
90
+ if (isLoading || loadingText) {
91
+ return <span className="text-muted-foreground">Loading rendition...</span>;
92
+ }
93
+
94
+ if (!text) {
95
+ return <span className="text-muted-foreground">No rendition available</span>;
96
+ }
97
+
98
+ return <ExpandableSummary text={text} />;
99
+ };
100
+
101
+ export const DocumentDescriptionPreview: FC<Props> = ({
102
+ fragmentShortId,
103
+ title,
104
+ }) => {
105
+ if (!fragmentShortId) {
106
+ return <span className="text-muted-foreground">No rendition available</span>;
107
+ }
108
+
109
+ return (
110
+ <FragmentsGetByIdClient
111
+ pathParams={{ id: fragmentShortId }}
112
+ queryParams={{
113
+ Fields: ["titles", "renditions"],
114
+ Embed: ["renditions"],
115
+ Links: true,
116
+ }}
117
+ >
118
+ {({ data, isLoading, error }) => {
119
+ const viewHref = getViewHref(data?.renditions);
120
+
121
+ return (
122
+ <DocumentDescriptionPreviewContent
123
+ viewHref={viewHref}
124
+ title={title}
125
+ isLoading={isLoading}
126
+ hasError={Boolean(error)}
127
+ />
128
+ );
129
+ }}
130
+ </FragmentsGetByIdClient>
131
+ );
132
+ };