Skip to content

Commit 8bb2b28

Browse files
committed
Add image carousel and robust type validation
1 parent 3c3a343 commit 8bb2b28

7 files changed

+379
-16
lines changed

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@xyflow/react": "^12.3.6",
4444
"class-variance-authority": "^0.7.1",
4545
"clsx": "^2.1.1",
46+
"embla-carousel-react": "^8.5.2",
4647
"lucide-react": "^0.468.0",
4748
"luxon": "^3.5.0",
4849
"moment": "^2.30.1",

src/components/result/GenericResultView.tsx

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ import { BookText, ChevronDown, GitFork, ImageOff, LinkIcon, Microscope } from "
1010
import { Badge } from "@/components/ui/badge"
1111
import { Button } from "@/components/ui/button"
1212
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
13-
import { SearchFieldConfiguration, SearchResult } from "@elastic/search-ui"
13+
import { SearchFieldConfiguration } from "@elastic/search-ui"
1414
import { GenericResultViewTag, GenericResultViewTagProps } from "@/components/result/GenericResultViewTag"
15+
import { GenericResultViewImageCarousel } from "@/components/result/GenericResultViewImageCarousel"
16+
import { z } from "zod"
1517

1618
const HTTP_REGEX = /https?:\/\/[a-z]+\.[a-z]+.*/gm
1719

1820
export interface GenericResultViewProps {
1921
/**
2022
* Search result that will be rendered in this view. Will be provided by FairDOElasticSearch
2123
*/
22-
result: SearchResult
24+
result: Record<string, unknown>
2325

2426
/**
2527
* The elastic field where the title of the card will be read from
@@ -114,18 +116,53 @@ export function GenericResultView({
114116

115117
const getField = useCallback(
116118
(field: string) => {
117-
return autoUnwrap(result[field])
119+
try {
120+
return autoUnwrap(
121+
z
122+
.string()
123+
.or(z.object({ raw: z.string() }))
124+
.optional()
125+
.parse(result[field])
126+
)
127+
} catch (e) {
128+
console.error(`Parsing field ${field} failed`, e)
129+
return undefined
130+
}
118131
},
119132
[result]
120133
)
121134

122135
const getArrayField = useCallback(
123136
(field: string) => {
124-
return autoUnwrapArray(result[field])
137+
try {
138+
return autoUnwrapArray(
139+
z
140+
.string()
141+
.array()
142+
.or(z.object({ raw: z.string().array() }))
143+
.optional()
144+
.parse(result[field])
145+
)
146+
} catch (e) {
147+
console.error(`Parsing array field ${field} failed`, e)
148+
return []
149+
}
125150
},
126151
[result]
127152
)
128153

154+
const getArrayOrSingleField = useCallback(
155+
(field: string) => {
156+
const _field: unknown = result[field]
157+
if (Array.isArray(_field) || (typeof _field === "object" && _field && "raw" in _field && Array.isArray(_field.raw))) {
158+
return getArrayField(field)
159+
} else {
160+
return getField(field)
161+
}
162+
},
163+
[getArrayField, getField, result]
164+
)
165+
129166
const pid = useMemo(() => {
130167
const _pid = getField(pidField ?? "pid")
131168
if (_pid && _pid.startsWith("https://")) {
@@ -151,8 +188,9 @@ export function GenericResultView({
151188
}, [digitalObjectLocationField, getField])
152189

153190
const previewImage = useMemo(() => {
154-
return getField(imageField ?? "imageURL")
155-
}, [getField, imageField])
191+
const images = getArrayOrSingleField(imageField ?? "imageURL")
192+
return Array.isArray(images) ? (images.length === 1 ? images[0] : images) : images
193+
}, [getArrayOrSingleField, imageField])
156194

157195
const identifier = useMemo(() => {
158196
return getField(additionalIdentifierField ?? "identifier")
@@ -164,6 +202,7 @@ export function GenericResultView({
164202

165203
const creationDate = useMemo(() => {
166204
const value = getField(creationDateField ?? "dateCreated")
205+
if (!value) return undefined
167206
const dateTime = DateTime.fromISO(value)
168207
return dateTime.isValid ? dateTime.toLocaleString() : value
169208
}, [creationDateField, getField])
@@ -185,9 +224,11 @@ export function GenericResultView({
185224

186225
if (search) {
187226
for (const entry of search.results) {
188-
addToResultCache(autoUnwrap(entry[pidField ?? "pid"]), {
189-
pid: autoUnwrap(entry[pidField ?? "pid"]),
190-
name: autoUnwrap(entry[titleField ?? "name"])
227+
const pid = autoUnwrap(entry[pidField ?? "pid"])
228+
if (!pid) continue
229+
addToResultCache(pid, {
230+
pid,
231+
name: autoUnwrap(entry[titleField ?? "name"]) ?? ""
191232
})
192233
}
193234
}
@@ -198,8 +239,8 @@ export function GenericResultView({
198239

199240
openRelationGraph(
200241
{
201-
id: pid,
202-
label: title,
242+
id: pid ?? "source",
243+
label: title ?? "Source",
203244
tag: "Current",
204245
remoteURL: doLocation,
205246
searchQuery: pid
@@ -212,6 +253,7 @@ export function GenericResultView({
212253
}, [doLocation, fetchRelatedItems, getResultFromCache, isMetadataFor, openRelationGraph, pid, title])
213254

214255
const goToMetadata = useCallback(() => {
256+
if (!hasMetadata) return
215257
searchFor(hasMetadata)
216258
}, [hasMetadata, searchFor])
217259

@@ -229,7 +271,9 @@ export function GenericResultView({
229271
}, [addToResultCache, pid, title])
230272

231273
return (
232-
<div className={`rfs-m-2 rfs-rounded-lg rfs-border rfs-border-border rfs-p-4 ${exactPidMatch ? "rfs-animate-outline-ping" : ""}`}>
274+
<div
275+
className={`rfs-m-2 rfs-rounded-lg rfs-border rfs-border-border rfs-p-4 rfs-group/resultView ${exactPidMatch ? "rfs-animate-outline-ping" : ""}`}
276+
>
233277
<div
234278
className={`rfs-grid ${imageField ? "rfs-grid-rows-[100px_1fr] md:rfs-grid-cols-[200px_1fr] md:rfs-grid-rows-1" : ""} rfs-gap-4 rfs-overflow-x-auto md:rfs-max-w-full`}
235279
>
@@ -238,7 +282,11 @@ export function GenericResultView({
238282
className={`rfs-flex rfs-justify-center rfs-rounded md:rfs-items-center rfs-p-2 d ${invertImageInDarkMode ? "dark:rfs-invert" : ""} `}
239283
>
240284
{previewImage ? (
241-
<img className="md:rfs-size-[200px]" src={previewImage} alt={`Preview for ${title}`} />
285+
Array.isArray(previewImage) ? (
286+
<GenericResultViewImageCarousel images={previewImage} title={title} />
287+
) : (
288+
<img className="md:rfs-size-[200px]" src={previewImage} alt={`Preview for ${title}`} />
289+
)
242290
) : (
243291
<div className="rfs-flex rfs-flex-col rfs-justify-center dark:rfs-text-background">
244292
<ImageOff className="rfs-size-6 rfs-text-muted-foreground/50" />
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Carousel, CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
2+
import { useEffect, useState } from "react"
3+
4+
export function GenericResultViewImageCarousel({ images, title }: { images: string[]; title?: string }) {
5+
const [api, setApi] = useState<CarouselApi>()
6+
const [slide, setSlide] = useState<number>(0)
7+
8+
useEffect(() => {
9+
if (api) {
10+
api.on("select", () => setSlide(api.selectedScrollSnap()))
11+
}
12+
}, [api])
13+
14+
return (
15+
<Carousel className="w-full max-w-xs" setApi={setApi}>
16+
<CarouselContent>
17+
{images.map((image, index) => (
18+
<CarouselItem key={index}>
19+
<img
20+
className="md:rfs-size-[200px]"
21+
src={image}
22+
alt={`Preview ${index + 1} of ${images.length} for ${title ?? "unnamed result"}`}
23+
/>
24+
</CarouselItem>
25+
))}
26+
</CarouselContent>
27+
<div className={"rfs-opacity-0 group-hover/resultView:rfs-opacity-100 rfs-transition-opacity"}>
28+
<CarouselPrevious />
29+
<CarouselNext />
30+
</div>
31+
<div className="rfs-text-muted-foreground rfs-text-center rfs-text-xs rfs-mt-2">
32+
Image {slide + 1} of {images.length}
33+
</div>
34+
</Carousel>
35+
)
36+
}

src/components/result/utils.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
export function autoUnwrap(item: string | { raw: string }) {
2-
if (typeof item === "string") {
1+
export function autoUnwrap(item?: string | { raw: string }) {
2+
if (!item) {
3+
return undefined
4+
} else if (typeof item === "string") {
35
return item
46
} else if (typeof item === "object" && "raw" in item && typeof item.raw === "string") {
57
return item.raw
@@ -8,7 +10,7 @@ export function autoUnwrap(item: string | { raw: string }) {
810
}
911
}
1012

11-
export function autoUnwrapArray(item: string[] | { raw: string[] }) {
13+
export function autoUnwrapArray(item?: string[] | { raw: string[] }) {
1214
if (!item) {
1315
return []
1416
}

0 commit comments

Comments
 (0)