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

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.39",
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,6 +15,7 @@ type Props = {
15
15
  removeRestrictParam?: boolean;
16
16
  selected?: boolean;
17
17
  onClick?: () => void;
18
+ multipleSelection?: boolean;
18
19
  };
19
20
 
20
21
  export const RestrictionNavigationItem: FC<Props> = ({
@@ -23,6 +24,7 @@ export const RestrictionNavigationItem: FC<Props> = ({
23
24
  restrictField,
24
25
  removeRestrictParam = false,
25
26
  selected = false,
27
+ multipleSelection = true,
26
28
  }) => {
27
29
  const [restrict, setRestrict] = useQueryState("restrict", {
28
30
  shallow: false,
@@ -35,6 +37,7 @@ export const RestrictionNavigationItem: FC<Props> = ({
35
37
  restrictField,
36
38
  removeRestrictParam,
37
39
  selected,
40
+ multipleSelection,
38
41
  currentRestrict: restrict,
39
42
  });
40
43
 
@@ -72,6 +75,7 @@ export const RestrictionDropdownItem: FC<Props> = ({
72
75
  restrictField,
73
76
  selected = false,
74
77
  onClick,
78
+ multipleSelection = true
75
79
  }) => {
76
80
  const [restrict, setRestrict] = useQueryState("restrict", {
77
81
  shallow: false,
@@ -83,6 +87,7 @@ export const RestrictionDropdownItem: FC<Props> = ({
83
87
  shortId,
84
88
  restrictField,
85
89
  selected,
90
+ multipleSelection,
86
91
  currentRestrict: restrict,
87
92
  });
88
93
 
@@ -121,7 +126,7 @@ function getRestrictionValue({
121
126
  restrictField,
122
127
  removeRestrictParam = false,
123
128
  selected = false,
124
-
129
+ multipleSelection = true,
125
130
  currentRestrict,
126
131
  }: {
127
132
  shortId?: string;
@@ -129,11 +134,12 @@ function getRestrictionValue({
129
134
  removeRestrictParam?: boolean;
130
135
  selected?: boolean;
131
136
  currentRestrict: string | null;
137
+ multipleSelection?: boolean;
132
138
  }): { restrictionValue: string | null; shouldRemoveRestrictParam: boolean } {
133
139
  let restrictParam = "";
134
140
  let shouldRemoveRestrictParam = removeRestrictParam;
135
141
 
136
- if (currentRestrict) {
142
+ if (currentRestrict && multipleSelection) {
137
143
  if (selected) {
138
144
  const restrictionsLength = currentRestrict.split(",").length;
139
145
  //if there is only one restriction, we can remove the whole restrict param
@@ -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";
@@ -29,7 +29,6 @@ type Props = {
29
29
  hasMoreItems?: boolean,
30
30
  showAllWhenEmpty?: boolean,
31
31
  onRequestMore?: () => void,
32
- stripLabelPrefix?: string,
33
32
  itemsByRow?: {
34
33
  [DEVICE_OPTIONS.MOBILE]: number,
35
34
  [DEVICE_OPTIONS.TABLET]: number,
@@ -209,6 +208,7 @@ const RestrictionCommandDialog: FC<RestrictionCommandDialogProps> = ({
209
208
 
210
209
  <Dialog open={open} onOpenChange={setOpen}>
211
210
  <DialogContent className="overflow-hidden p-0">
211
+ <DialogTitle className="sr-only">{t("search")}</DialogTitle>
212
212
  <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
213
  <CommandInput placeholder={t("search")} value={search} onValueChange={setSearch} />
214
214
  <CommandList ref={listRef}>
@@ -263,8 +263,7 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
263
263
  hasMoreItems = false,
264
264
  showAllWhenEmpty = true,
265
265
  onRequestMore,
266
- stripLabelPrefix,
267
- navigationMenuListClassName = "items-center justify-between flex-row",
266
+ navigationMenuListClassName = "items-center justify-start gap-4 flex-row",
268
267
  itemsByRow = {
269
268
  [DEVICE_OPTIONS.MOBILE]: 2,
270
269
  [DEVICE_OPTIONS.TABLET]: 4,
@@ -273,9 +272,6 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
273
272
  }) => {
274
273
  const t = useTranslations();
275
274
  const setRestrictionList = useRestrictionStore((state) => state.setRestrictionList);
276
- const formatLabel = stripLabelPrefix
277
- ? (label: string) => label.replace(new RegExp(`^${stripLabelPrefix}`, "i"), "")
278
- : undefined;
279
275
 
280
276
  const [params] = useQueryStates({
281
277
  restrict: parseAsString,
@@ -372,7 +368,7 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
372
368
 
373
369
  {!enableHierarchy && visibleItems.map((item) => {
374
370
  const rawLabel = getLabelByLang(item.labels, lang);
375
- const label = rawLabel && formatLabel ? formatLabel(rawLabel) : rawLabel;
371
+ const label = rawLabel
376
372
  return (
377
373
  <RestrictionNavigationItem
378
374
  key={item.shortId}
@@ -388,7 +384,7 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
388
384
  const shortId = rootNode.item.shortId || "";
389
385
  const hasChildren = rootNode.children.length > 0;
390
386
  const rawLabel = getLabelByLang(rootNode.item.labels, lang);
391
- const label = rawLabel && formatLabel ? formatLabel(rawLabel) : rawLabel;
387
+ const label = rawLabel
392
388
  const rootSelected = restrictionValues.includes(shortId);
393
389
  const hasActiveDescendant = hasChildren && hasSelectedDescendant(rootNode, selectedRestrictionIds);
394
390
  const shouldHighlightBranch = hasActiveDescendant && !rootSelected;
@@ -414,7 +410,7 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
414
410
  selectedRestrictionIds={selectedRestrictionIds}
415
411
  lang={lang}
416
412
  highlighted={shouldHighlightBranch}
417
- formatLabel={formatLabel}
413
+
418
414
  />
419
415
  </NavigationMenuItem>
420
416
  );
@@ -433,7 +429,6 @@ export const RestrictionSelectionCommandMenu: FC<Props> = ({
433
429
  selectedRestrictionIds={selectedRestrictionIds}
434
430
  lang={lang}
435
431
  highlighted={false}
436
- formatLabel={formatLabel}
437
432
  />
438
433
  </NavigationMenuItem>
439
434
  )}
@@ -32,6 +32,8 @@ type Props = {
32
32
  [DEVICE_OPTIONS.TABLET]: number,
33
33
  [DEVICE_OPTIONS.DESKTOP]: number,
34
34
  }
35
+ multipleSelection?: boolean;
36
+ updatePosition?: boolean;
35
37
  }
36
38
 
37
39
  type RestrictionTreeNode = {
@@ -117,12 +119,14 @@ export const RestrictionSelectionMenu: FC<Props> = ({
117
119
  hasMoreItems = false,
118
120
  showAllWhenEmpty = true,
119
121
  onRequestMore,
120
- navigationMenuListClassName = "items-center justify-between flex-row",
122
+ navigationMenuListClassName = "items-center justify-start gap-4 flex-row",
121
123
  itemsByRow = {
122
124
  [DEVICE_OPTIONS.MOBILE]: 2,
123
125
  [DEVICE_OPTIONS.TABLET]: 4,
124
126
  [DEVICE_OPTIONS.DESKTOP]: 7,
125
- }
127
+ },
128
+ multipleSelection = true,
129
+ updatePosition = true
126
130
  }) => {
127
131
  const t = useTranslations();
128
132
  const setRestrictionList = useRestrictionStore((state) => state.setRestrictionList);
@@ -154,6 +158,8 @@ export const RestrictionSelectionMenu: FC<Props> = ({
154
158
  const sortedItems = useMemo(() => {
155
159
  //if shortId it is on the restrictionValues, it should be on top of the list, otherwise keep the original order
156
160
 
161
+ if (updatePosition === false) return items;
162
+
157
163
  const sorted = [...items].sort((a, b) => {
158
164
  const aShortId = a.shortId || "";
159
165
  const bShortId = b.shortId || "";
@@ -173,7 +179,7 @@ export const RestrictionSelectionMenu: FC<Props> = ({
173
179
  return aIndex - bIndex; // sort by index in restrictionValues
174
180
  });
175
181
  return sorted;
176
- }, [items, restrictionValues]);
182
+ }, [items, restrictionValues, updatePosition]);
177
183
 
178
184
  const hierarchyRoots = useMemo(() => {
179
185
  if (!enableHierarchy) return [];
@@ -277,7 +283,7 @@ export const RestrictionSelectionMenu: FC<Props> = ({
277
283
  };
278
284
 
279
285
  return (
280
- <NavigationMenu viewport={false} className="max-w-full w-full c-rex-restriction-menu">
286
+ <NavigationMenu viewport={false} className="max-w-full w-full c-rex-restriction-menu overflow-auto pb-4">
281
287
  <NavigationMenuList className={cn("w-full", navigationMenuListClassName)}>
282
288
 
283
289
 
@@ -293,6 +299,7 @@ export const RestrictionSelectionMenu: FC<Props> = ({
293
299
  <RestrictionNavigationItem
294
300
  key={item.shortId}
295
301
  shortId={item.shortId!}
302
+ multipleSelection={multipleSelection}
296
303
  restrictField={restrictField as string}
297
304
  label={getLabelByLang(item.labels, lang)}
298
305
  selected={restrictionValues.includes(item.shortId!)}
@@ -315,6 +322,7 @@ export const RestrictionSelectionMenu: FC<Props> = ({
315
322
  restrictField={restrictField}
316
323
  label={label}
317
324
  selected={restrictionValues.includes(shortId)}
325
+ multipleSelection={multipleSelection}
318
326
  />
319
327
  );
320
328
  }
@@ -364,6 +372,7 @@ export const RestrictionSelectionMenu: FC<Props> = ({
364
372
  >
365
373
  <RestrictionDropdownItem
366
374
  shortId={item.shortId!}
375
+ multipleSelection={multipleSelection}
367
376
  restrictField={restrictField as string}
368
377
  label={getLabelByLang(item.labels, lang)}
369
378
  selected={restrictionValues.includes(item.shortId!)}
@@ -29,7 +29,6 @@ export type TaxonomyRestrictionCommandMenuProps = {
29
29
  fetchMode?: RestrictionMenuFetchMode;
30
30
  showAllWhenEmpty?: boolean;
31
31
  queryParams?: GenericQueryParams;
32
- stripLabelPrefix?: string;
33
32
  };
34
33
 
35
34
  export const TaxonomyRestrictionCommandMenu: FC<TaxonomyRestrictionCommandMenuProps> = ({
@@ -41,8 +40,7 @@ export const TaxonomyRestrictionCommandMenu: FC<TaxonomyRestrictionCommandMenuPr
41
40
  enableHierarchy = false,
42
41
  fetchMode = "deferred",
43
42
  showAllWhenEmpty = true,
44
- navigationMenuListClassName = "items-center justify-between flex-row",
45
- stripLabelPrefix,
43
+ navigationMenuListClassName = "items-center justify-start gap-4 flex-row",
46
44
  }) => {
47
45
  const [loadAll, setLoadAll] = useState(false);
48
46
  const RequestComponent = ComponentOptions[requestType] as unknown as FC<GenericRequestProps>;
@@ -102,7 +100,6 @@ export const TaxonomyRestrictionCommandMenu: FC<TaxonomyRestrictionCommandMenuPr
102
100
  }
103
101
  }}
104
102
  navigationMenuListClassName={navigationMenuListClassName}
105
- stripLabelPrefix={stripLabelPrefix}
106
103
  />
107
104
  );
108
105
  }}
@@ -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,25 +23,39 @@ 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;
29
34
  fetchMode?: RestrictionMenuFetchMode;
30
35
  showAllWhenEmpty?: boolean;
31
36
  queryParams?: GenericQueryParams;
37
+ multipleSelection?: boolean;
38
+ updatePosition?: boolean;
39
+ };
40
+
41
+ const DEFAULT_ITEMS_BY_ROW = {
42
+ [DEVICE_OPTIONS.MOBILE]: 7,
43
+ [DEVICE_OPTIONS.TABLET]: 7,
44
+ [DEVICE_OPTIONS.DESKTOP]: 7,
32
45
  };
33
46
 
34
47
  export const TaxonomyRestrictionMenu: FC<TaxonomyRestrictionMenuProps> = ({
35
48
  queryParams,
36
49
  restrictField,
37
- itemsToRender = 7,
50
+ itemsByRow = DEFAULT_ITEMS_BY_ROW,
38
51
  requestType,
39
52
  onlyUsedEntries = true,
40
53
  enableHierarchy = false,
41
54
  fetchMode = "deferred",
42
55
  showAllWhenEmpty = true,
43
- navigationMenuListClassName = "items-center justify-between flex-row",
56
+ navigationMenuListClassName = "items-center justify-start gap-4 flex-row",
57
+ multipleSelection = true,
58
+ updatePosition = true,
44
59
  }) => {
45
60
  const [loadAll, setLoadAll] = useState(false);
46
61
  const RequestComponent = ComponentOptions[requestType] as unknown as FC<GenericRequestProps>;
@@ -50,11 +65,12 @@ export const TaxonomyRestrictionMenu: FC<TaxonomyRestrictionMenuProps> = ({
50
65
  Number.isFinite(Number(queryParams?.PageSize)) && Number(queryParams?.PageSize) > 0
51
66
  ? Number(queryParams?.PageSize)
52
67
  : undefined;
68
+ const maxItemsByRow = useMemo(() => Math.max(...Object.values(itemsByRow)), [itemsByRow]);
53
69
  const resolvedPageSize = useMemo(() => {
54
70
  if (explicitPageSize) return explicitPageSize;
55
- if (fetchMode === "deferred" && !loadAll) return Math.max(itemsToRender, 1);
71
+ if (fetchMode === "deferred" && !loadAll) return Math.max(maxItemsByRow, 1);
56
72
  return 100;
57
- }, [explicitPageSize, fetchMode, itemsToRender, loadAll]);
73
+ }, [explicitPageSize, fetchMode, maxItemsByRow, loadAll]);
58
74
  const requestedFields = Array.isArray(queryParams?.Fields) ? queryParams.Fields : undefined;
59
75
  const resolvedFields = useMemo(() => {
60
76
  const baseFields = requestedFields && requestedFields.length > 0 ? requestedFields : ["labels"];
@@ -94,12 +110,15 @@ export const TaxonomyRestrictionMenu: FC<TaxonomyRestrictionMenuProps> = ({
94
110
  enableHierarchy={enableHierarchy}
95
111
  hasMoreItems={hasMoreItems}
96
112
  showAllWhenEmpty={showAllWhenEmpty}
113
+ itemsByRow={itemsByRow}
97
114
  onRequestMore={() => {
98
115
  if (fetchMode === "deferred" && !explicitPageSize) {
99
116
  setLoadAll(true);
100
117
  }
101
118
  }}
102
119
  navigationMenuListClassName={navigationMenuListClassName}
120
+ multipleSelection={multipleSelection}
121
+ updatePosition={updatePosition}
103
122
  />
104
123
  );
105
124
  }}
@@ -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;