first commit
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CheckIcon,
|
||||
ImageOffIcon,
|
||||
Loader2Icon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { GalleryPhoto } from "@/types/admin/gallery";
|
||||
|
||||
interface GalleryGridProps {
|
||||
initialPhotos?: GalleryPhoto[];
|
||||
}
|
||||
|
||||
type PickedFilter = "all" | "picked" | "unpicked";
|
||||
type ColorFilter =
|
||||
| "all"
|
||||
| "black"
|
||||
| "blue"
|
||||
| "green"
|
||||
| "light"
|
||||
| "orange"
|
||||
| "purple"
|
||||
| "red"
|
||||
| "yellow";
|
||||
|
||||
const PER_PAGE = 24;
|
||||
const COLUMNS_COUNT = 4;
|
||||
|
||||
const colorFilters: Array<{ label: string; value: ColorFilter }> = [
|
||||
{ label: "All colors", value: "all" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Black", value: "black" },
|
||||
{ label: "Red", value: "red" },
|
||||
{ label: "Orange", value: "orange" },
|
||||
{ label: "Yellow", value: "yellow" },
|
||||
{ label: "Green", value: "green" },
|
||||
{ label: "Blue", value: "blue" },
|
||||
{ label: "Purple", value: "purple" },
|
||||
];
|
||||
|
||||
function getImageUrl(photo: GalleryPhoto): string {
|
||||
const urls = photo.urls as Record<string, string> | undefined;
|
||||
return urls?.small ?? urls?.regular ?? urls?.thumb ?? urls?.raw ?? "";
|
||||
}
|
||||
|
||||
function getFullImageUrl(photo: GalleryPhoto): string {
|
||||
const urls = photo.urls as Record<string, string> | undefined;
|
||||
return photo.oss_url ?? urls?.full ?? urls?.regular ?? urls?.raw ?? "";
|
||||
}
|
||||
|
||||
function getUserName(photo: GalleryPhoto): string {
|
||||
const user = photo.user_info as Record<string, unknown> | undefined;
|
||||
return (user?.name as string) || (user?.username as string) || "Unknown";
|
||||
}
|
||||
|
||||
function getPhotoAspectRatio(photo: GalleryPhoto): number {
|
||||
if (photo.width && photo.height && photo.height > 0) {
|
||||
return photo.width / photo.height;
|
||||
}
|
||||
return 1.5;
|
||||
}
|
||||
|
||||
function distributeToColumns(
|
||||
photos: GalleryPhoto[],
|
||||
columnCount: number,
|
||||
): GalleryPhoto[][] {
|
||||
const columns: GalleryPhoto[][] = Array.from(
|
||||
{ length: columnCount },
|
||||
() => [],
|
||||
);
|
||||
const columnHeights = Array(columnCount).fill(0);
|
||||
|
||||
for (const photo of photos) {
|
||||
const shortestColIndex = columnHeights.indexOf(
|
||||
Math.min(...columnHeights),
|
||||
);
|
||||
|
||||
columns[shortestColIndex].push(photo);
|
||||
columnHeights[shortestColIndex] += 1 / getPhotoAspectRatio(photo);
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function GalleryGrid({ initialPhotos = [] }: GalleryGridProps) {
|
||||
const [columns, setColumns] = useState<GalleryPhoto[][]>([]);
|
||||
const [allPhotos, setAllPhotos] = useState<GalleryPhoto[]>(initialPhotos);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<GalleryPhoto | null>(
|
||||
null,
|
||||
);
|
||||
const [lightboxVisible, setLightboxVisible] = useState(false);
|
||||
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
|
||||
const [pickedFilter, setPickedFilter] = useState<PickedFilter>("all");
|
||||
const [colorFilter, setColorFilter] = useState<ColorFilter>("all");
|
||||
const [pickingIds, setPickingIds] = useState<Set<string>>(new Set());
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadingRef = useRef(loading);
|
||||
const hasMoreRef = useRef(hasMore);
|
||||
|
||||
useEffect(() => {
|
||||
loadingRef.current = loading;
|
||||
}, [loading]);
|
||||
|
||||
useEffect(() => {
|
||||
hasMoreRef.current = hasMore;
|
||||
}, [hasMore]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(distributeToColumns(allPhotos, COLUMNS_COUNT));
|
||||
}, [allPhotos]);
|
||||
|
||||
const fetchPhotos = useCallback(
|
||||
async (targetPage: number, replace = false) => {
|
||||
if (loadingRef.current || (!replace && !hasMoreRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(targetPage),
|
||||
per_page: String(PER_PAGE),
|
||||
});
|
||||
|
||||
if (pickedFilter === "picked") {
|
||||
params.set("picked", "true");
|
||||
} else if (pickedFilter === "unpicked") {
|
||||
params.set("picked", "false");
|
||||
}
|
||||
|
||||
if (colorFilter !== "all") {
|
||||
params.set("color_family", colorFilter);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/admin/gallery-photos?${params.toString()}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.ok) {
|
||||
throw new Error(json.error || "Failed to fetch photos.");
|
||||
}
|
||||
|
||||
const newPhotos: GalleryPhoto[] = json.data;
|
||||
|
||||
setAllPhotos((prev) =>
|
||||
replace || targetPage === 1
|
||||
? newPhotos
|
||||
: [...prev, ...newPhotos],
|
||||
);
|
||||
setHasMore(json.pagination.hasMore);
|
||||
setPage(targetPage);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[colorFilter, pickedFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
hasMoreRef.current = true;
|
||||
setHasMore(true);
|
||||
setPage(1);
|
||||
setAllPhotos([]);
|
||||
setLoadedImages(new Set());
|
||||
void fetchPhotos(1, true);
|
||||
}, [fetchPhotos]);
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current?.disconnect();
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (
|
||||
entries[0]?.isIntersecting &&
|
||||
hasMoreRef.current &&
|
||||
!loadingRef.current
|
||||
) {
|
||||
void fetchPhotos(page + 1);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
|
||||
if (sentinelRef.current) {
|
||||
observerRef.current.observe(sentinelRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
}, [fetchPhotos, page]);
|
||||
|
||||
const replacePhoto = useCallback(
|
||||
(updated: GalleryPhoto) => {
|
||||
setAllPhotos((prev) => {
|
||||
const shouldRemoveFromCurrentFilter =
|
||||
(pickedFilter === "picked" && !updated.is_picked) ||
|
||||
(pickedFilter === "unpicked" && updated.is_picked);
|
||||
|
||||
if (shouldRemoveFromCurrentFilter) {
|
||||
return prev.filter(
|
||||
(photo) => photo.unsplash_id !== updated.unsplash_id,
|
||||
);
|
||||
}
|
||||
|
||||
return prev.map((photo) =>
|
||||
photo.unsplash_id === updated.unsplash_id ? updated : photo,
|
||||
);
|
||||
});
|
||||
setSelectedPhoto((photo) =>
|
||||
photo?.unsplash_id === updated.unsplash_id ? updated : photo,
|
||||
);
|
||||
},
|
||||
[pickedFilter],
|
||||
);
|
||||
|
||||
const togglePicked = async (photo: GalleryPhoto) => {
|
||||
const nextPicked = !photo.is_picked;
|
||||
const toastId = toast.loading(
|
||||
nextPicked ? "Picking photo..." : "Unpicking photo...",
|
||||
);
|
||||
|
||||
setPickingIds((prev) => new Set(prev).add(photo.unsplash_id));
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/gallery-photos", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
unsplashId: photo.unsplash_id,
|
||||
isPicked: nextPicked,
|
||||
}),
|
||||
});
|
||||
const json = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok || !json?.ok) {
|
||||
throw new Error(
|
||||
json?.error ||
|
||||
`Failed to update pick status (${res.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
replacePhoto(json.data);
|
||||
toast.success(nextPicked ? "Photo picked" : "Photo unpicked", {
|
||||
id: toastId,
|
||||
description: json.data.oss_sync_error || undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Unknown pick error.";
|
||||
setError(message);
|
||||
toast.error("Pick request failed", {
|
||||
id: toastId,
|
||||
description: message,
|
||||
});
|
||||
} finally {
|
||||
setPickingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(photo.unsplash_id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoClick = (photo: GalleryPhoto) => {
|
||||
setSelectedPhoto(photo);
|
||||
requestAnimationFrame(() => {
|
||||
setLightboxVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setLightboxVisible(false);
|
||||
setTimeout(() => {
|
||||
setSelectedPhoto(null);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleImageLoad = (unsplashId: string) => {
|
||||
setLoadedImages((prev) => new Set(prev).add(unsplashId));
|
||||
};
|
||||
|
||||
const totalPickedOnPage = useMemo(
|
||||
() => allPhotos.filter((photo) => photo.is_picked).length,
|
||||
[allPhotos],
|
||||
);
|
||||
|
||||
const renderPhotoCard = (
|
||||
photo: GalleryPhoto,
|
||||
colIndex: number,
|
||||
itemIndex: number,
|
||||
) => {
|
||||
const isLoaded = loadedImages.has(photo.unsplash_id);
|
||||
const isPicking = pickingIds.has(photo.unsplash_id);
|
||||
const staggerDelay = ((colIndex * 3 + itemIndex) % 12) * 60;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${colIndex}-${photo.unsplash_id}`}
|
||||
className="group animate-photo-enter mb-4 cursor-pointer opacity-0"
|
||||
style={{
|
||||
animationDelay: `${staggerDelay}ms`,
|
||||
animationFillMode: "forwards",
|
||||
}}
|
||||
onClick={() => handlePhotoClick(photo)}
|
||||
>
|
||||
<div
|
||||
className={`bg-muted ease-out-quart relative overflow-hidden rounded-lg shadow-sm transition-all duration-250 hover:shadow-lg ${
|
||||
isPicking ? "opacity-70" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-500"
|
||||
style={{
|
||||
backgroundColor: photo.color || "#f0f0f0",
|
||||
opacity: isLoaded ? 0 : 1,
|
||||
}}
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getImageUrl(photo)}
|
||||
alt={
|
||||
photo.alt_description ||
|
||||
photo.description ||
|
||||
"Unsplash photo"
|
||||
}
|
||||
loading="lazy"
|
||||
onLoad={() => handleImageLoad(photo.unsplash_id)}
|
||||
className={`ease-out-quart h-auto w-full object-cover transition-all duration-250 group-hover:scale-[1.03] ${
|
||||
isLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
style={{
|
||||
transitionProperty: "opacity, transform",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-2 left-2 z-20 flex items-center gap-1">
|
||||
{photo.is_picked && (
|
||||
<Badge className="bg-primary text-primary-foreground">
|
||||
<SparklesIcon />
|
||||
Picked
|
||||
</Badge>
|
||||
)}
|
||||
{photo.oss_url && (
|
||||
<Badge variant="secondary">
|
||||
<CheckIcon />
|
||||
OSS
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 z-20 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={photo.is_picked ? "secondary" : "default"}
|
||||
disabled={isPicking}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void togglePicked(photo);
|
||||
}}
|
||||
>
|
||||
{isPicking ? (
|
||||
<Loader2Icon className="animate-spin" />
|
||||
) : photo.is_picked ? (
|
||||
<XIcon />
|
||||
) : (
|
||||
<SparklesIcon />
|
||||
)}
|
||||
{photo.is_picked ? "Unpick" : "Pick"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ease-out-quart pointer-events-none absolute inset-0 z-10 flex items-end bg-linear-to-t from-black/60 via-black/0 to-transparent p-3 opacity-0 transition-opacity duration-250 group-hover:opacity-100">
|
||||
<div className="ease-out-quart translate-y-2 text-white transition-transform duration-250 group-hover:translate-y-0">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{getUserName(photo)}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1 text-xs text-white/80">
|
||||
{photo.likes ? (
|
||||
<span>{photo.likes} likes</span>
|
||||
) : null}
|
||||
{photo.color_family ? (
|
||||
<span>{photo.color_family}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between lg:px-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={pickedFilter === "all" ? "default" : "outline"}
|
||||
onClick={() => setPickedFilter("all")}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
pickedFilter === "picked" ? "default" : "outline"
|
||||
}
|
||||
onClick={() => setPickedFilter("picked")}
|
||||
>
|
||||
<SparklesIcon />
|
||||
Picked
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
pickedFilter === "unpicked" ? "default" : "outline"
|
||||
}
|
||||
onClick={() => setPickedFilter("unpicked")}
|
||||
>
|
||||
<ImageOffIcon />
|
||||
Unpicked
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={colorFilter}
|
||||
onChange={(event) =>
|
||||
setColorFilter(event.target.value as ColorFilter)
|
||||
}
|
||||
className="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-8 rounded-lg border px-2.5 text-sm transition-colors outline-none focus-visible:ring-3"
|
||||
>
|
||||
{colorFilters.map((filter) => (
|
||||
<option key={filter.value} value={filter.value}>
|
||||
{filter.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Badge variant="outline">
|
||||
{allPhotos.length} loaded / {totalPickedOnPage} picked
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 px-4 py-4">
|
||||
{columns.map((columnPhotos, colIndex) => (
|
||||
<div key={colIndex} className="min-w-0 flex-1">
|
||||
{columnPhotos.map((photo, itemIndex) =>
|
||||
renderPhotoCard(photo, colIndex, itemIndex),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-pulse-subtle text-muted-foreground flex items-center gap-2">
|
||||
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<span className="text-sm">Loading more...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-sm text-red-500">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void fetchPhotos(page, page === 1)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && allPhotos.length > 0 && (
|
||||
<div className="flex justify-center py-8">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No more photos
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && allPhotos.length === 0 && !error && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
No photos found
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Run the sync script to populate the gallery.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} className="h-4" />
|
||||
|
||||
{selectedPhoto && (
|
||||
<div
|
||||
className={`ease-out-quart fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-250 ${
|
||||
lightboxVisible
|
||||
? "bg-black/90 opacity-100"
|
||||
: "bg-black/0 opacity-0"
|
||||
}`}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
<div
|
||||
className={`ease-out-quart relative flex max-h-[90vh] w-full max-w-5xl flex-col items-center transition-all duration-250 ${
|
||||
lightboxVisible
|
||||
? "scale-100 opacity-100"
|
||||
: "scale-95 opacity-0"
|
||||
}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getFullImageUrl(selectedPhoto)}
|
||||
alt={
|
||||
selectedPhoto.alt_description ||
|
||||
selectedPhoto.description ||
|
||||
"Unsplash photo"
|
||||
}
|
||||
className="max-h-[85vh] max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
<div className="mt-4 text-center text-white">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{getUserName(selectedPhoto)}
|
||||
</p>
|
||||
{selectedPhoto.is_picked && (
|
||||
<Badge>
|
||||
<SparklesIcon />
|
||||
Picked
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{selectedPhoto.description && (
|
||||
<p className="mt-1 max-w-xl text-sm text-white/70">
|
||||
{selectedPhoto.description}
|
||||
</p>
|
||||
)}
|
||||
{selectedPhoto.oss_sync_error && (
|
||||
<p className="mt-2 max-w-xl text-xs text-red-200">
|
||||
{selectedPhoto.oss_sync_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<Button
|
||||
variant={
|
||||
selectedPhoto.is_picked
|
||||
? "secondary"
|
||||
: "default"
|
||||
}
|
||||
disabled={pickingIds.has(
|
||||
selectedPhoto.unsplash_id,
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void togglePicked(selectedPhoto);
|
||||
}}
|
||||
>
|
||||
{pickingIds.has(selectedPhoto.unsplash_id) ? (
|
||||
<Loader2Icon className="animate-spin" />
|
||||
) : selectedPhoto.is_picked ? (
|
||||
<XIcon />
|
||||
) : (
|
||||
<SparklesIcon />
|
||||
)}
|
||||
{selectedPhoto.is_picked ? "Unpick" : "Pick"}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user