611 lines
23 KiB
TypeScript
611 lines
23 KiB
TypeScript
"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>
|
|
);
|
|
}
|