@c-rex/components 0.3.0-build.36 → 0.3.0-build.38

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.36",
3
+ "version": "0.3.0-build.38",
4
4
  "files": [
5
5
  "src"
6
6
  ],
@@ -232,7 +232,11 @@
232
232
  "./taxonomy-restriction-command-menu": {
233
233
  "types": "./src/restriction-menu/taxonomy-restriction-command-menu.tsx",
234
234
  "import": "./src/restriction-menu/taxonomy-restriction-command-menu.tsx"
235
- }
235
+ },
236
+ "./blog-author-card": {
237
+ "types": "./src/blog/blog-author-card.tsx",
238
+ "import": "./src/blog/blog-author-card.tsx"
239
+ }
236
240
  },
237
241
  "scripts": {
238
242
  "storybook": "storybook dev -p 6006",
@@ -15,8 +15,10 @@ type Props = {
15
15
  id: string;
16
16
  articleType: ResultTypes;
17
17
  favoriteLabel: string;
18
+ className?: string;
19
+ children?: React.ReactNode;
18
20
  }
19
- export const ArticleActionBar: FC<Props> = ({ id, articleType, favoriteLabel }) => {
21
+ export const ArticleActionBar: FC<Props> = ({ id, articleType, favoriteLabel, className, children }) => {
20
22
  const t = useTranslations();
21
23
  const inputRef = useRef<HTMLInputElement>(null);
22
24
  const { next } = useHighlight();
@@ -32,7 +34,10 @@ export const ArticleActionBar: FC<Props> = ({ id, articleType, favoriteLabel })
32
34
  }, [open]);
33
35
 
34
36
  return (
35
- <div className="w-9 flex gap-2 transition-all duration-300 z-20 items-end flex-col sticky top-40 md:top-24 self-start">
37
+ <div className={cn(
38
+ "w-9 flex gap-2 transition-all duration-300 z-20 items-end flex-col sticky top-32 self-start",
39
+ className
40
+ )}>
36
41
 
37
42
  <SidebarTrigger side="right" />
38
43
 
@@ -101,6 +106,8 @@ export const ArticleActionBar: FC<Props> = ({ id, articleType, favoriteLabel })
101
106
 
102
107
  </>
103
108
  )}
109
+
110
+ {children}
104
111
  </div>
105
112
  )
106
113
  }
@@ -101,27 +101,6 @@ export const AutoComplete = ({
101
101
  });
102
102
  };
103
103
 
104
- const clearSearch = () => {
105
- setQuery("");
106
- setOpen(false);
107
- const nextParams = new URLSearchParams(keepParams ? searchParams.toString() : "");
108
- nextParams.set("page", "1");
109
- nextParams.delete("search");
110
- const queryString = nextParams.toString();
111
-
112
- onSelectParams?.forEach(param => {
113
- nextParams.set(param.key, param.value);
114
- });
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
- });
123
- };
124
-
125
104
  useEffect(() => setQuery(initialValue), [initialValue]);
126
105
  useEffect(() => {
127
106
  stopSearchNavigation();
@@ -199,6 +178,7 @@ export const AutoComplete = ({
199
178
  setQuery(e.target.value);
200
179
  setOpen(true);
201
180
  }}
181
+ onFocus={() => setOpen(true)}
202
182
  />
203
183
  {isNavigating || searchNavigationPending ? (
204
184
  <InputGroupAddon align="inline-end">
@@ -210,7 +190,7 @@ export const AutoComplete = ({
210
190
  size="icon-xs"
211
191
  variant="ghost"
212
192
  aria-label={t("clearSearch")}
213
- onClick={clearSearch}
193
+ onClick={() => setQuery("")}
214
194
  >
215
195
  <X className="size-3" />
216
196
  </InputGroupButton>
@@ -221,7 +201,9 @@ export const AutoComplete = ({
221
201
 
222
202
  {open && (
223
203
  <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">
224
- {loading ? (
204
+ {query.length === 0 ? (
205
+ <li className="px-4 py-2 text-sm text-muted-foreground">{t("typingHint")}</li>
206
+ ) : loading ? (
225
207
  <li>
226
208
  <div className="flex items-center justify-center py-4">
227
209
  <div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-300 border-t-gray-950" />
@@ -0,0 +1,116 @@
1
+ import { User, Mail, Link as LinkIcon } from "lucide-react";
2
+ import Link from "next/link";
3
+ import { cn } from "@c-rex/utils";
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@c-rex/ui/card";
5
+
6
+ export type BlogAuthor = {
7
+ name: string;
8
+ photo: string | null;
9
+ title?: string | null;
10
+ role?: string | null;
11
+ emails?: string[];
12
+ urls?: string[];
13
+ };
14
+
15
+ type Props = {
16
+ authors: BlogAuthor[];
17
+ label: string;
18
+ embedded?: boolean;
19
+ fields?: Array<keyof BlogAuthor>;
20
+ className?: string;
21
+ };
22
+
23
+ const DEFAULT_FIELDS: Array<keyof BlogAuthor> = ["photo", "name", "title", "role"];
24
+
25
+ const AuthorAvatar = ({ author }: { author: BlogAuthor }) => (
26
+ author.photo ? (
27
+ <img
28
+ src={author.photo}
29
+ alt={author.name}
30
+ className="size-10 rounded-full object-cover shrink-0"
31
+ />
32
+ ) : (
33
+ <div className="size-10 rounded-full bg-muted shrink-0 flex items-center justify-center">
34
+ <User className="size-6" />
35
+ </div>
36
+ )
37
+ );
38
+
39
+ const AuthorEntry = ({ author, fields }: { author: BlogAuthor; fields: Array<keyof BlogAuthor> }) => {
40
+ const showPhoto = fields.includes("photo");
41
+ const showName = fields.includes("name");
42
+ const showTitle = fields.includes("title");
43
+ const showRole = fields.includes("role");
44
+ const showEmails = fields.includes("emails");
45
+ const showUrls = fields.includes("urls");
46
+
47
+ return (
48
+ <div className="flex items-start gap-3">
49
+ {showPhoto && <AuthorAvatar author={author} />}
50
+ <div className="flex flex-col gap-0.5 min-w-0">
51
+ {showName && (
52
+ <span className="text-sm font-medium truncate">{author.name}</span>
53
+ )}
54
+ {showTitle && author.title && (
55
+ <span className="text-xs text-muted-foreground truncate">{author.title}</span>
56
+ )}
57
+ {showRole && author.role && (
58
+ <span className="text-xs text-muted-foreground truncate">{author.role}</span>
59
+ )}
60
+ {showEmails && author.emails && author.emails.length > 0 && (
61
+ <div className="flex flex-col gap-0.5 mt-1">
62
+ {author.emails.map((email) => (
63
+ <Link
64
+ key={email}
65
+ href={`mailto:${email}`}
66
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
67
+ >
68
+ <Mail className="size-3 shrink-0" />
69
+ <span className="truncate">{email}</span>
70
+ </Link>
71
+ ))}
72
+ </div>
73
+ )}
74
+ {showUrls && author.urls && author.urls.length > 0 && (
75
+ <div className="flex flex-col gap-0.5 mt-1">
76
+ {author.urls.map((url) => (
77
+ <Link
78
+ key={url}
79
+ href={url}
80
+ target="_blank"
81
+ rel="noopener noreferrer"
82
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
83
+ >
84
+ <LinkIcon className="size-3 shrink-0" />
85
+ <span className="truncate">{url}</span>
86
+ </Link>
87
+ ))}
88
+ </div>
89
+ )}
90
+ </div>
91
+ </div>
92
+ );
93
+ };
94
+
95
+ export const BlogAuthorCard = ({ authors, label, embedded = false, fields = DEFAULT_FIELDS, className }: Props) => {
96
+ if (authors.length === 0) return null;
97
+
98
+ const content = (
99
+ <CardContent className="space-y-3 pb-4">
100
+ {authors.map((author, i) => (
101
+ <AuthorEntry key={i} author={author} fields={fields} />
102
+ ))}
103
+ </CardContent>
104
+ );
105
+
106
+ if (embedded) return content;
107
+
108
+ return (
109
+ <Card className={cn("gap-0", className)}>
110
+ <CardHeader>
111
+ <CardTitle className="text-lg">{label}</CardTitle>
112
+ </CardHeader>
113
+ {content}
114
+ </Card>
115
+ );
116
+ };
@@ -83,7 +83,12 @@ const DocumentResultListRowContent: FC<RowContentProps> = ({
83
83
  </span>
84
84
 
85
85
  <span className="text-lg font-medium">
86
- <a className="hover:underline [overflow-wrap:anywhere] hyphens-auto" href={itemLink}>{title}</a>
86
+ <Link
87
+ className="hover:underline [overflow-wrap:anywhere] hyphens-auto"
88
+ href={itemLink}
89
+ >
90
+ {title}
91
+ </Link>
87
92
  </span>
88
93
 
89
94
  <div>
@@ -11,7 +11,7 @@ import { cn } from "@c-rex/utils";
11
11
  import { getTranslations } from "next-intl/server";
12
12
  import { Button } from "@c-rex/ui/button";
13
13
  import { DropdownHoverItem } from "@c-rex/ui/dropdown-hover-item";
14
- import { Menu } from "lucide-react";
14
+ import { Menu, Search } from "lucide-react";
15
15
  import { getOrganizationBranding } from "@c-rex/services/vcard";
16
16
 
17
17
  type NavBarProps = {
@@ -53,14 +53,9 @@ export const NavBar: FC<NavBarProps> = async ({
53
53
  }
54
54
 
55
55
  return (
56
- <header className="sticky flex flex-col top-0 z-40 w-full p-4 backdrop-blur-xl transition-all bg-transparent border-b gap-2">
57
- <div className="w-full flex items-center justify-between gap-2">
58
- <div
59
- className={cn(
60
- "flex items-center gap-4",
61
- title && "lg:w-[calc(16rem-16px)]"
62
- )}
63
- >
56
+ <header className="sticky flex flex-col top-0 z-40 w-full backdrop-blur-xl transition-all bg-transparent border-b">
57
+ <div className="w-full flex items-center justify-between gap-2 p-4">
58
+ <div className="flex items-center gap-4">
64
59
  {showMenu && (
65
60
  <DropdownHoverItem
66
61
  label={
@@ -107,28 +102,22 @@ export const NavBar: FC<NavBarProps> = async ({
107
102
  <img
108
103
  src={organizationBranding.logoSrc}
109
104
  alt={`${organizationBranding.organizationName} logo`}
110
- className="h-14"
105
+ className="h-10"
111
106
  />
112
107
  </Link>
113
108
  )}
114
109
  </div>
115
110
 
116
- {title && (
117
- <div className="flex-1 hidden md:flex md:justify-center lg:justify-start">
118
- <h1 className="md:text-2xl lg:text-3xl font-bold tracking-tight text-balance">{title}</h1>
119
- </div>
120
- )}
121
-
122
111
  <div className="flex gap-2">
123
- {willShowInput &&
124
- <div className="hidden sm:flex flex-1 items-center px-3 border rounded-full h-8 c-rex-search-bar">
125
- <SearchInput
126
- autocompleteType={autocompleteType}
127
- onSelectPath={onSelectPath}
128
- {...props}
129
- />
130
- </div>
131
- }
112
+
113
+
114
+ <Button rounded="full" size="sm" className="w-8" variant="ghost">
115
+ <Search className="!size-5" />
116
+ </Button>
117
+
118
+ {clientConfigs.languageSwitcher.enabled && (
119
+ <SettingsMenu />
120
+ )}
132
121
 
133
122
  {clientConfigs.OIDC.userEnabled && (
134
123
  <>
@@ -140,15 +129,12 @@ export const NavBar: FC<NavBarProps> = async ({
140
129
  </>
141
130
  )}
142
131
 
143
- {clientConfigs.languageSwitcher.enabled && (
144
- <SettingsMenu />
145
- )}
146
132
  </div>
147
133
  </div>
148
134
 
149
135
  {title && (
150
- <div className="flex-1 flex justify-center md:hidden">
151
- <h1 className="text-2xl font-bold tracking-tight text-balance">{title}</h1>
136
+ <div className="flex-1 flex justify-center border-t py-2">
137
+ <h1 className="text-2xl font-bold tracking-tight text-balance text-center">{title}</h1>
152
138
  </div>
153
139
  )}
154
140
  </header>
@@ -25,7 +25,7 @@ export const SettingsMenu: FC = () => {
25
25
  return (
26
26
  <DropdownMenu>
27
27
  <DropdownMenuTrigger asChild>
28
- <Button variant="ghost" rounded="full" size="icon">
28
+ <Button rounded="full" size="sm" className="w-8" variant="ghost">
29
29
  <Settings className="!size-5" />
30
30
  </Button>
31
31
 
@@ -22,7 +22,7 @@ export const PageWrapper = ({
22
22
  renderRestrictionMenu,
23
23
  restrictField,
24
24
  requestType,
25
- itemsToRender,
25
+ itemsByRow,
26
26
  onlyUsedEntries = true,
27
27
  enableHierarchy = false,
28
28
  fetchMode = "deferred",
@@ -33,7 +33,7 @@ export const PageWrapper = ({
33
33
  const restrictionMenuProps: ComponentProps<typeof TaxonomyRestrictionMenu> = {
34
34
  restrictField: restrictField ?? "informationSubjects",
35
35
  requestType: requestType ?? "InformationSubjectsGetAllClient",
36
- itemsToRender,
36
+ itemsByRow,
37
37
  onlyUsedEntries,
38
38
  enableHierarchy,
39
39
  fetchMode,
@@ -46,7 +46,7 @@ export const PageWrapper = ({
46
46
  <NavBar showInput={showInput} showPkgFilter={showPkgFilter} {...props} />
47
47
 
48
48
  {showRestrictMenu && (
49
- <div className="container pt-6">
49
+ <div className="container pt-4">
50
50
  {renderRestrictionMenu ? renderRestrictionMenu(restrictionMenuProps) : (
51
51
  <TaxonomyRestrictionMenu {...restrictionMenuProps} />
52
52
  )}
@@ -15,7 +15,7 @@ import { useRestrictionStore } from "../stores/restriction-store";
15
15
  import { useBreakpoint } from "@c-rex/ui/hooks";
16
16
  import { DEVICE_OPTIONS } from "@c-rex/constants";
17
17
  import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@c-rex/ui/command";
18
- import { Dialog, DialogContent } from "@c-rex/ui/dialog";
18
+ import { Dialog, DialogContent, DialogTitle } from "@c-rex/ui/dialog";
19
19
  import { Button } from "@c-rex/ui/button";
20
20
  import { Check, ChevronDown } from "lucide-react";
21
21
  import { useSearchNavigationStore } from "../stores/search-navigation-store";
@@ -209,6 +209,7 @@ const RestrictionCommandDialog: FC<RestrictionCommandDialogProps> = ({
209
209
 
210
210
  <Dialog open={open} onOpenChange={setOpen}>
211
211
  <DialogContent className="overflow-hidden p-0">
212
+ <DialogTitle className="sr-only">{t("search")}</DialogTitle>
212
213
  <Command shouldFilter={false} className="[&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
213
214
  <CommandInput placeholder={t("search")} value={search} onValueChange={setSearch} />
214
215
  <CommandList ref={listRef}>
@@ -264,7 +265,7 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
264
265
  showAllWhenEmpty = true,
265
266
  onRequestMore,
266
267
  stripLabelPrefix,
267
- navigationMenuListClassName = "items-center justify-between flex-row",
268
+ navigationMenuListClassName = "items-center justify-start gap-4 flex-row",
268
269
  itemsByRow = {
269
270
  [DEVICE_OPTIONS.MOBILE]: 2,
270
271
  [DEVICE_OPTIONS.TABLET]: 4,
@@ -117,7 +117,7 @@ export const RestrictionSelectionMenu: FC<Props> = ({
117
117
  hasMoreItems = false,
118
118
  showAllWhenEmpty = true,
119
119
  onRequestMore,
120
- navigationMenuListClassName = "items-center justify-between flex-row",
120
+ navigationMenuListClassName = "items-center justify-start gap-4 flex-row",
121
121
  itemsByRow = {
122
122
  [DEVICE_OPTIONS.MOBILE]: 2,
123
123
  [DEVICE_OPTIONS.TABLET]: 4,
@@ -277,7 +277,7 @@ export const RestrictionSelectionMenu: FC<Props> = ({
277
277
  };
278
278
 
279
279
  return (
280
- <NavigationMenu viewport={false} className="max-w-full w-full c-rex-restriction-menu">
280
+ <NavigationMenu viewport={false} className="max-w-full w-full c-rex-restriction-menu overflow-auto pb-4">
281
281
  <NavigationMenuList className={cn("w-full", navigationMenuListClassName)}>
282
282
 
283
283
 
@@ -41,7 +41,7 @@ export const TaxonomyRestrictionCommandMenu: FC<TaxonomyRestrictionCommandMenuPr
41
41
  enableHierarchy = false,
42
42
  fetchMode = "deferred",
43
43
  showAllWhenEmpty = true,
44
- navigationMenuListClassName = "items-center justify-between flex-row",
44
+ navigationMenuListClassName = "items-center justify-start gap-4 flex-row",
45
45
  stripLabelPrefix,
46
46
  }) => {
47
47
  const [loadAll, setLoadAll] = useState(false);
@@ -5,6 +5,7 @@ import { FC, ReactNode, useMemo, useState } from "react";
5
5
  import * as ComponentOptions from "../generated/client-components";
6
6
  import { Skeleton } from "@c-rex/ui/skeleton";
7
7
  import { RestrictionSelectionMenu } from "./restriction-selection-menu";
8
+ import { DEVICE_OPTIONS } from "@c-rex/constants";
8
9
 
9
10
  type GenericRequestData = {
10
11
  items?: DomainEntityModel[];
@@ -22,7 +23,11 @@ type RestrictionMenuFetchMode = "all" | "deferred";
22
23
  export type TaxonomyRestrictionMenuProps = {
23
24
  restrictField: string;
24
25
  navigationMenuListClassName?: string;
25
- itemsToRender?: number;
26
+ itemsByRow?: {
27
+ [DEVICE_OPTIONS.MOBILE]: number;
28
+ [DEVICE_OPTIONS.TABLET]: number;
29
+ [DEVICE_OPTIONS.DESKTOP]: number;
30
+ };
26
31
  requestType: keyof typeof ComponentOptions;
27
32
  onlyUsedEntries?: boolean;
28
33
  enableHierarchy?: boolean;
@@ -31,16 +36,22 @@ export type TaxonomyRestrictionMenuProps = {
31
36
  queryParams?: GenericQueryParams;
32
37
  };
33
38
 
39
+ const DEFAULT_ITEMS_BY_ROW = {
40
+ [DEVICE_OPTIONS.MOBILE]: 7,
41
+ [DEVICE_OPTIONS.TABLET]: 7,
42
+ [DEVICE_OPTIONS.DESKTOP]: 7,
43
+ };
44
+
34
45
  export const TaxonomyRestrictionMenu: FC<TaxonomyRestrictionMenuProps> = ({
35
46
  queryParams,
36
47
  restrictField,
37
- itemsToRender = 7,
48
+ itemsByRow = DEFAULT_ITEMS_BY_ROW,
38
49
  requestType,
39
50
  onlyUsedEntries = true,
40
51
  enableHierarchy = false,
41
52
  fetchMode = "deferred",
42
53
  showAllWhenEmpty = true,
43
- navigationMenuListClassName = "items-center justify-between flex-row",
54
+ navigationMenuListClassName = "items-center justify-start gap-4 flex-row",
44
55
  }) => {
45
56
  const [loadAll, setLoadAll] = useState(false);
46
57
  const RequestComponent = ComponentOptions[requestType] as unknown as FC<GenericRequestProps>;
@@ -50,11 +61,12 @@ export const TaxonomyRestrictionMenu: FC<TaxonomyRestrictionMenuProps> = ({
50
61
  Number.isFinite(Number(queryParams?.PageSize)) && Number(queryParams?.PageSize) > 0
51
62
  ? Number(queryParams?.PageSize)
52
63
  : undefined;
64
+ const maxItemsByRow = useMemo(() => Math.max(...Object.values(itemsByRow)), [itemsByRow]);
53
65
  const resolvedPageSize = useMemo(() => {
54
66
  if (explicitPageSize) return explicitPageSize;
55
- if (fetchMode === "deferred" && !loadAll) return Math.max(itemsToRender, 1);
67
+ if (fetchMode === "deferred" && !loadAll) return Math.max(maxItemsByRow, 1);
56
68
  return 100;
57
- }, [explicitPageSize, fetchMode, itemsToRender, loadAll]);
69
+ }, [explicitPageSize, fetchMode, maxItemsByRow, loadAll]);
58
70
  const requestedFields = Array.isArray(queryParams?.Fields) ? queryParams.Fields : undefined;
59
71
  const resolvedFields = useMemo(() => {
60
72
  const baseFields = requestedFields && requestedFields.length > 0 ? requestedFields : ["labels"];
@@ -94,6 +106,7 @@ export const TaxonomyRestrictionMenu: FC<TaxonomyRestrictionMenuProps> = ({
94
106
  enableHierarchy={enableHierarchy}
95
107
  hasMoreItems={hasMoreItems}
96
108
  showAllWhenEmpty={showAllWhenEmpty}
109
+ itemsByRow={itemsByRow}
97
110
  onRequestMore={() => {
98
111
  if (fetchMode === "deferred" && !explicitPageSize) {
99
112
  setLoadAll(true);
@@ -69,10 +69,6 @@ const FilterSidebar: FC<FilterSidebarProps> = ({
69
69
  });
70
70
  const startSearchNavigation = useSearchNavigationStore((state) => state.start);
71
71
 
72
- useEffect(() => {
73
- console.log(isMobile, isMobileFiltersOpen)
74
- }, [isMobile, isMobileFiltersOpen]);
75
-
76
72
  const filteredTags = useMemo(() => {
77
73
  const resolved = memoizeFilteredTags(tags, params.filter, params.packages, {
78
74
  uiLanguage: locale,
@@ -1,96 +1,196 @@
1
1
  "use client"
2
2
 
3
- import { FC, useState } from "react";
3
+ import { FC, useEffect, useRef, useState } from "react";
4
4
 
5
5
  import Link from "next/link";
6
- import { cn } from "@c-rex/utils";
6
+ import { cn, formatDateToLocale } from "@c-rex/utils";
7
7
  import { Badge } from "@c-rex/ui/badge";
8
- import { useTranslations } from "next-intl";
8
+ import { useLocale } 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";
12
+ import { User } from "lucide-react";
12
13
 
13
14
  interface InformationUnitSearchResultsCardsProps {
14
15
  items: TopicsResponseItem[];
15
16
  }
16
17
 
17
- const InformationUnitSearchResultsCards: FC<InformationUnitSearchResultsCardsProps> = ({ items }) => {
18
- const t = useTranslations("results")
19
- const [isLoading, setLoading] = useState(true);
20
- const [query] = useQueryState("search");
18
+ type CardData = {
19
+ image: string | null;
20
+ description: string | null;
21
+ authors: { name: string; photo: string | null }[];
22
+ loaded: boolean;
23
+ };
21
24
 
22
- return (
23
- <div className="grid gap-6 grid-cols-2">
24
25
 
25
- {items.map((item, index) => (
26
- <Card
27
- key={item.shortId}
28
- className={cn(
29
- `c-rex-result-item information-unit-search-results-card relative p-0 c-rex-result-${item.type.toLowerCase()}`,
30
- index == 0
31
- ? "col-span-2 grid grid-cols-1 gap-3 md:grid-cols-2 md:gap-6"
32
- : "flex flex-col space-y-2",
33
- item.disabled ? "c-rex-result-item-disabled" : ""
26
+ const InformationUnitSearchResultCard: FC<{
27
+ item: TopicsResponseItem;
28
+ index: number;
29
+ query: string | null;
30
+ }> = ({ item, index, query }) => {
31
+ const locale = useLocale();
32
+ const date = formatDateToLocale(item.created!, locale);
33
+ const cardRef = useRef<HTMLDivElement>(null);
34
+ const [cardData, setCardData] = useState<CardData>({ image: null, description: null, authors: [], loaded: false });
35
+
36
+ useEffect(() => {
37
+ const el = cardRef.current;
38
+ if (!el) return;
39
+
40
+ const observer = new IntersectionObserver(
41
+ ([entry]) => {
42
+ if (!entry.isIntersecting) return;
43
+ observer.disconnect();
44
+
45
+ const fetches: Promise<void>[] = [];
46
+
47
+ if (item.renditionUrl) {
48
+ fetches.push(
49
+ fetch(item.renditionUrl)
50
+ .then(r => r.text())
51
+ .then(html => {
52
+ const doc = new DOMParser().parseFromString(html, "text/html");
53
+ setCardData(prev => ({
54
+ ...prev,
55
+ image: doc.querySelector(".teaserfig img")?.getAttribute("src") || null,
56
+ description: doc.querySelector(".teaser-p")?.textContent || null,
57
+ }));
58
+ })
59
+ .catch(() => { })
60
+ );
61
+ }
62
+
63
+ if (item.vcardUrls?.length) {
64
+ fetches.push(
65
+ Promise.all(
66
+ item.vcardUrls.map(url =>
67
+ fetch(url)
68
+ .then(r => r.json())
69
+ .then(vcard => ({ name: vcard.fullName || "", photo: vcard.photos?.[0]?.source || null }))
70
+ .catch(() => null)
71
+ )
72
+ ).then(results => {
73
+ setCardData(prev => ({
74
+ ...prev,
75
+ authors: results.filter(Boolean) as { name: string; photo: string | null }[],
76
+ }));
77
+ })
78
+ );
79
+ }
80
+
81
+ Promise.all(fetches).then(() => {
82
+ setCardData(prev => ({ ...prev, loaded: true }));
83
+ });
84
+ },
85
+ { rootMargin: "200px" }
86
+ );
87
+
88
+ observer.observe(el);
89
+ return () => observer.disconnect();
90
+ }, [item.renditionUrl, item.vcardUrls]);
91
+
92
+ return (
93
+ <div ref={cardRef} className={cn(index == 0 ? "md:col-span-2" : "")}>
94
+ <Card
95
+ className={cn(
96
+ "c-rex-result-item information-unit-search-results-card relative p-0 gap-0",
97
+ `c-rex-result-${item.type.toLowerCase()}`,
98
+ index == 0
99
+ ? "grid grid-cols-1 md:grid-cols-2"
100
+ : "h-full flex flex-col space-y-2",
101
+ item.disabled ? "c-rex-result-item-disabled" : ""
102
+ )}
103
+ >
104
+ <div className={cn(
105
+ "w-full overflow-hidden",
106
+ index == 0 ? "rounded-t-xl md:rounded-t-none md:rounded-tl-xl md:rounded-bl-xl" : "rounded-t-xl"
107
+ )}>
108
+ {cardData.image ? (
109
+ <img
110
+ src={cardData.image}
111
+ alt={item.title}
112
+ className="size-full object-cover object-center"
113
+ style={{ width: "100%", height: index == 0 ? "100%" : "190px" }}
114
+ loading={index == 0 ? "eager" : "lazy"}
115
+ />
116
+ ) : (
117
+ <div
118
+ style={{ height: "190px" }}
119
+ className={cn(
120
+ "w-full bg-gray-100 animate-pulse",
121
+ index == 0 ? "" : "rounded-t-xl"
122
+ )}
123
+ />
34
124
  )}
35
- >
36
-
37
- <div className={
38
- cn(
39
- "w-full overflow-hidden",
40
- index == 0 ? "rounded-tl-xl rounded-bl-xl" : "rounded-t-xl"
41
- )}>
42
- {item.image && (
43
- <img
44
- src={item.image}
45
- alt={item.title}
46
- className={cn(
47
- "size-full object-cover object-center",
48
- isLoading ? "bg-gray-300 animate-pulse" : "",
49
- )}
50
- style={{
51
- width: "100%", height: index == 0 ? "auto" : "190px"
52
- }}
53
- loading={index == 0 ? "eager" : "lazy"}
54
- onLoad={() => setLoading(false)}
55
- onError={() => setLoading(false)}
56
- />
57
- )}
125
+ </div>
58
126
 
127
+ <div className="flex flex-1 flex-col p-4 gap-4 justify-between">
128
+ <div className="w-full flex flex-col items-start gap-4">
129
+ <h2 className="line-clamp-2 font-heading text-2xl">
130
+ {item.title}
131
+ </h2>
132
+ <Badge variant="secondary"> {date} </Badge>
59
133
  </div>
60
134
 
61
- <div
62
- className={cn(
63
- "flex flex-1 flex-col p-4",
64
- index == 0 ? "justify-around" : "justify-between",
135
+ <div className="flex items-end flex-row">
136
+ {cardData.description ? (
137
+ <p className="m-0 flex-1 text-sm text-muted-foreground">
138
+ {cardData.description}
139
+ </p>
140
+ ) : (
141
+ <div className="flex-1 space-y-2">
142
+ <div className="h-3 w-full rounded bg-gray-100 animate-pulse" />
143
+ <div className="h-3 w-4/5 rounded bg-gray-100 animate-pulse" />
144
+ </div>
65
145
  )}
66
- >
67
- <div className="w-full">
68
- <h2 className="my-1.5 line-clamp-2 font-heading text-2xl">
69
- {item.title}
70
- </h2>
71
-
72
- {item.type && (
73
- <Badge variant="secondary">
74
- {item.type}
75
- </Badge>
76
- )}
77
- </div>
78
- <div className="mt-4 flex items-end flex-row gap-2">
79
- <p className="m-0 flex-1 text-sm text-muted-foreground">{item.description?.substring(0, 100)}...</p>
146
+ </div>
80
147
 
81
- {item.created && (
82
- <p className="m-0 text-sm text-muted-foreground">{item.created}</p>
83
- )}
148
+ {cardData.authors.length > 0 && (
149
+ <div className="flex flex-wrap gap-4">
150
+ {cardData.authors.map((author, i) => (
151
+ <div key={i} className="flex items-center gap-2">
84
152
 
85
- </div>
86
- </div>
87
153
 
88
- {!item.disabled && (
89
- <Link href={`${item.link}?q=${query}`} className="absolute inset-0">
90
- <span className="sr-only">{t("viewArticle")}</span>
91
- </Link>
154
+ {author.photo ? (
155
+ <img
156
+ src={author.photo}
157
+ alt={author.name}
158
+ className="size-6 rounded-full object-cover"
159
+ />
160
+ ) : (
161
+ <div className="size-6 rounded-full bg-muted flex items-center justify-center" >
162
+ <User className="size-4" />
163
+ </div>
164
+ )}
165
+ <span className="text-xs text-muted-foreground">{author.name}</span>
166
+ </div>
167
+ ))}
168
+ </div>
92
169
  )}
93
- </Card>
170
+ </div>
171
+
172
+ {!item.disabled && (
173
+ <Link href={item.link} className="absolute inset-0">
174
+ <span className="sr-only">View article</span>
175
+ </Link>
176
+ )}
177
+ </Card>
178
+ </div>
179
+ );
180
+ };
181
+
182
+ const InformationUnitSearchResultsCards: FC<InformationUnitSearchResultsCardsProps> = ({ items }) => {
183
+ const [query] = useQueryState("search");
184
+
185
+ return (
186
+ <div className="grid gap-6 md:grid-cols-2">
187
+ {items.map((item, index) => (
188
+ <InformationUnitSearchResultCard
189
+ key={item.shortId}
190
+ item={item}
191
+ index={index}
192
+ query={query}
193
+ />
94
194
  ))}
95
195
  </div>
96
196
  );
@@ -9,7 +9,7 @@ import {
9
9
  } from "@c-rex/ui/breadcrumb";
10
10
  import type { TocBreadcrumbItem } from "./types";
11
11
 
12
- type TocBreadcrumbProps = {
12
+ export type TocBreadcrumbProps = {
13
13
  lang: string;
14
14
  homeLabel: string;
15
15
  homeHref?: string;