first commit

This commit is contained in:
luoyangwei
2026-06-06 00:49:07 +08:00
commit 2b860d201e
134 changed files with 22773 additions and 0 deletions
+610
View File
@@ -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>
);
}