feat: implement images in listings comparison
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -113,4 +113,7 @@ export const api = {
|
||||
getStats: () => request<Stats>("/stats"),
|
||||
|
||||
getListings: () => request<Listing[]>("/listings"),
|
||||
|
||||
getListingImages: (globalId: string) =>
|
||||
request<{ images: string[] }>(`/listings/${globalId}/images`),
|
||||
};
|
||||
|
||||
110
frontend/src/components/ImageCarousel.tsx
Normal file
110
frontend/src/components/ImageCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user