feat: implement images in listings comparison

This commit is contained in:
Stijnvandenbroek
2026-03-06 12:53:06 +00:00
parent e1a67da3ce
commit da5a455c36
7 changed files with 223 additions and 6 deletions

View File

@@ -5,7 +5,7 @@ server {
# API requests → backend
location /api/ {
proxy_pass http://elo-backend:8000/api/;
proxy_pass http://host.docker.internal:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

View File

@@ -113,4 +113,7 @@ export const api = {
getStats: () => request<Stats>("/stats"),
getListings: () => request<Listing[]>("/listings"),
getListingImages: (globalId: string) =>
request<{ images: string[] }>(`/listings/${globalId}/images`),
};

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { api } from "../api";
interface Props {
globalId: string;
}
export default function ImageCarousel({ globalId }: Props) {
const [images, setImages] = useState<string[]>([]);
const [index, setIndex] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
setIndex(0);
api
.getListingImages(globalId)
.then((data) => setImages(data.images))
.catch(() => setImages([]))
.finally(() => setLoading(false));
}, [globalId]);
if (loading) {
return (
<div className="w-full aspect-[4/3] bg-slate-100 rounded-lg flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-400" />
</div>
);
}
if (images.length === 0) {
return (
<div className="w-full aspect-[4/3] bg-slate-100 rounded-lg flex items-center justify-center text-slate-400 text-sm">
No images available
</div>
);
}
const prev = (e: React.MouseEvent) => {
e.stopPropagation();
setIndex((i) => (i - 1 + images.length) % images.length);
};
const next = (e: React.MouseEvent) => {
e.stopPropagation();
setIndex((i) => (i + 1) % images.length);
};
return (
<div className="relative w-full aspect-[4/3] bg-slate-100 rounded-lg overflow-hidden group">
<img
src={images[index]}
alt={`Photo ${index + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
{/* Left arrow */}
{images.length > 1 && (
<button
onClick={prev}
className="absolute left-1.5 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full w-8 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Previous image"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
)}
{/* Right arrow */}
{images.length > 1 && (
<button
onClick={next}
className="absolute right-1.5 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full w-8 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Next image"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
)}
{/* Counter */}
{images.length > 1 && (
<span className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-0.5 rounded-full">
{index + 1} / {images.length}
</span>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import type { Listing } from "../api";
import ImageCarousel from "./ImageCarousel";
interface Props {
listing: Listing;
@@ -37,6 +38,11 @@ export default function ListingCard({
: "border-slate-200 hover:border-indigo-400 hover:shadow-md"
} ${disabled ? "opacity-70 cursor-not-allowed" : "cursor-pointer"}`}
>
{/* Image carousel */}
<div className="mb-4">
<ImageCarousel globalId={listing.global_id} />
</div>
{/* Title & link */}
<div className="mb-3">
<h3 className="font-bold text-lg text-slate-800 leading-tight">