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
@@ -0,0 +1,517 @@
"use client";
import { useMemo, useState } from "react";
import {
ImageIcon,
Loader2Icon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
SaveIcon,
Trash2Icon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type {
CollectionPhoto,
PhotoCollection,
} from "@/types/admin/collection";
type CollectionForm = {
id: string | null;
title: string;
slug: string;
description: string;
coverPhotoId: string | null;
isPublished: boolean;
photoIds: string[];
};
type ApiResponse<T> = {
ok: boolean;
data: T;
error?: string;
};
const emptyForm: CollectionForm = {
id: null,
title: "",
slug: "",
description: "",
coverPhotoId: null,
isPublished: false,
photoIds: [],
};
function getPhotoUrl(photo: CollectionPhoto) {
const urls = photo.urls as Record<string, string> | undefined;
return photo.oss_url ?? urls?.small ?? urls?.thumb ?? urls?.regular ?? "";
}
function getPhotoLabel(photo: CollectionPhoto) {
return (
photo.alt_description ||
photo.description ||
photo.unsplash_id ||
"Picked photo"
);
}
function getFormFromCollection(collection: PhotoCollection): CollectionForm {
return {
id: collection.id,
title: collection.title,
slug: collection.slug,
description: collection.description ?? "",
coverPhotoId: collection.cover_photo_id,
isPublished: collection.is_published,
photoIds: collection.photos.map((photo) => photo.id),
};
}
export function CollectionsManager({
initialCollections,
initialPickedPhotos,
}: {
initialCollections: PhotoCollection[];
initialPickedPhotos: CollectionPhoto[];
}) {
const [collections, setCollections] =
useState<PhotoCollection[]>(initialCollections);
const [pickedPhotos, setPickedPhotos] =
useState<CollectionPhoto[]>(initialPickedPhotos);
const [form, setForm] = useState<CollectionForm>(emptyForm);
const [saving, setSaving] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const selectedPhotoIds = useMemo(
() => new Set(form.photoIds),
[form.photoIds],
);
const selectedPhotos = useMemo(
() =>
form.photoIds
.map((photoId) =>
pickedPhotos.find((photo) => photo.id === photoId),
)
.filter((photo): photo is CollectionPhoto => Boolean(photo)),
[form.photoIds, pickedPhotos],
);
const updateForm = (patch: Partial<CollectionForm>) => {
setForm((current) => ({ ...current, ...patch }));
};
const startNew = () => {
setError(null);
setForm(emptyForm);
};
const togglePhoto = (photoId: string) => {
setForm((current) => {
const exists = current.photoIds.includes(photoId);
const photoIds = exists
? current.photoIds.filter((id) => id !== photoId)
: [...current.photoIds, photoId];
const coverPhotoId =
current.coverPhotoId && photoIds.includes(current.coverPhotoId)
? current.coverPhotoId
: (photoIds[0] ?? null);
return {
...current,
photoIds,
coverPhotoId,
};
});
};
const saveCollection = async () => {
setSaving(true);
setError(null);
try {
const res = await fetch(
form.id
? `/api/admin/photo-collections/${form.id}`
: "/api/admin/photo-collections",
{
method: form.id ? "PATCH" : "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
title: form.title,
slug: form.slug || null,
description: form.description || null,
coverPhotoId: form.coverPhotoId,
isPublished: form.isPublished,
photoIds: form.photoIds,
}),
},
);
const json = (await res.json()) as ApiResponse<PhotoCollection>;
if (!json.ok) {
throw new Error(json.error || "Failed to save collection.");
}
setCollections((current) => {
const exists = current.some(
(collection) => collection.id === json.data.id,
);
if (exists) {
return current.map((collection) =>
collection.id === json.data.id ? json.data : collection,
);
}
return [json.data, ...current];
});
setForm(getFormFromCollection(json.data));
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error.");
} finally {
setSaving(false);
}
};
const deleteCollection = async (collection: PhotoCollection) => {
if (!confirm(`Delete "${collection.title}"?`)) {
return;
}
setDeletingId(collection.id);
setError(null);
try {
const res = await fetch(
`/api/admin/photo-collections/${collection.id}`,
{
method: "DELETE",
},
);
const json = (await res.json()) as ApiResponse<unknown>;
if (!json.ok) {
throw new Error(json.error || "Failed to delete collection.");
}
setCollections((current) =>
current.filter((item) => item.id !== collection.id),
);
if (form.id === collection.id) {
setForm(emptyForm);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error.");
} finally {
setDeletingId(null);
}
};
const refreshPickedPhotos = async () => {
setRefreshing(true);
setError(null);
try {
const res = await fetch(
"/api/admin/gallery-photos?picked=true&per_page=100",
);
const json = (await res.json()) as ApiResponse<CollectionPhoto[]>;
if (!json.ok) {
throw new Error(json.error || "Failed to refresh photos.");
}
setPickedPhotos(json.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error.");
} finally {
setRefreshing(false);
}
};
return (
<main className="grid flex-1 gap-4 p-4 lg:grid-cols-[minmax(0,1fr)_420px] lg:p-6">
<section className="flex min-w-0 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Collections</h2>
<Button onClick={startNew}>
<PlusIcon />
New
</Button>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Photos</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{collections.map((collection) => (
<TableRow key={collection.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="bg-muted relative size-12 shrink-0 overflow-hidden rounded-md">
{collection.cover_photo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getPhotoUrl(
collection.cover_photo,
)}
alt={collection.title}
className="size-full object-cover"
/>
) : (
<ImageIcon className="text-muted-foreground absolute top-1/2 left-1/2 size-4 -translate-x-1/2 -translate-y-1/2" />
)}
</div>
<div className="min-w-0">
<div className="truncate font-medium">
{collection.title}
</div>
<div className="text-muted-foreground truncate font-mono text-xs">
{collection.slug}
</div>
</div>
</div>
</TableCell>
<TableCell>
{collection.photos.length}
</TableCell>
<TableCell>
<Badge
variant={
collection.is_published
? "default"
: "secondary"
}
>
{collection.is_published
? "Published"
: "Draft"}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
size="icon-sm"
variant="ghost"
onClick={() =>
setForm(
getFormFromCollection(
collection,
),
)
}
>
<PencilIcon />
</Button>
<Button
size="icon-sm"
variant="ghost"
disabled={
deletingId === collection.id
}
onClick={() =>
void deleteCollection(
collection,
)
}
>
{deletingId ===
collection.id ? (
<Loader2Icon className="animate-spin" />
) : (
<Trash2Icon />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
{collections.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-muted-foreground h-32 text-center text-sm"
>
No collections yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<aside className="flex min-w-0 flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>
{form.id ? "Edit collection" : "New collection"}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Title</span>
<Input
value={form.title}
onChange={(event) =>
updateForm({ title: event.target.value })
}
/>
</label>
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Slug</span>
<Input
value={form.slug}
onChange={(event) =>
updateForm({ slug: event.target.value })
}
/>
</label>
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Description</span>
<textarea
value={form.description}
onChange={(event) =>
updateForm({
description: event.target.value,
})
}
className="border-input focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 resize-none rounded-lg border bg-transparent px-2.5 py-2 text-sm transition-colors outline-none focus-visible:ring-3"
/>
</label>
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Cover</span>
<select
value={form.coverPhotoId ?? ""}
onChange={(event) =>
updateForm({
coverPhotoId:
event.target.value || null,
})
}
className="border-input focus-visible:border-ring focus-visible:ring-ring/50 bg-background h-8 rounded-lg border px-2.5 text-sm transition-colors outline-none focus-visible:ring-3"
>
<option value="">Auto</option>
{selectedPhotos.map((photo, index) => (
<option key={photo.id} value={photo.id}>
{index + 1}. {getPhotoLabel(photo)}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.isPublished}
onCheckedChange={(value) =>
updateForm({ isPublished: Boolean(value) })
}
/>
<span className="font-medium">Published</span>
</label>
{error && (
<p className="text-destructive text-sm">{error}</p>
)}
<Button
disabled={saving || !form.title.trim()}
onClick={() => void saveCollection()}
>
{saving ? (
<Loader2Icon className="animate-spin" />
) : (
<SaveIcon />
)}
Save
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Picked photos</CardTitle>
<Button
size="icon-sm"
variant="ghost"
disabled={refreshing}
onClick={() => void refreshPickedPhotos()}
>
{refreshing ? (
<Loader2Icon className="animate-spin" />
) : (
<RefreshCwIcon />
)}
</Button>
</CardHeader>
<CardContent>
<div className="grid max-h-130 grid-cols-3 gap-2 overflow-y-auto pr-1">
{pickedPhotos.map((photo) => {
const selected = selectedPhotoIds.has(photo.id);
return (
<button
key={photo.id}
type="button"
className="group bg-muted hover:border-primary focus-visible:border-ring focus-visible:ring-ring/50 relative overflow-hidden rounded-md border text-left transition-colors outline-none focus-visible:ring-3"
onClick={() => togglePhoto(photo.id)}
>
<span className="block aspect-3/4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPhotoUrl(photo)}
alt={getPhotoLabel(photo)}
className="size-full object-cover transition-transform group-hover:scale-[1.03]"
/>
</span>
<span className="absolute top-1.5 left-1.5">
<Checkbox
checked={selected}
aria-label="Select photo"
tabIndex={-1}
/>
</span>
{form.coverPhotoId === photo.id && (
<Badge className="absolute right-1.5 bottom-1.5">
Cover
</Badge>
)}
</button>
);
})}
{pickedPhotos.length === 0 && (
<div className="text-muted-foreground col-span-3 flex h-32 items-center justify-center text-sm">
No picked photos.
</div>
)}
</div>
</CardContent>
</Card>
</aside>
</main>
);
}
+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>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { Loader2Icon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AdminAppUser } from "@/server/admin/app-users";
type ApiResponse<T> = {
ok: boolean;
data: T;
error?: string;
};
export function UsersManager({
initialUsers,
}: {
initialUsers: AdminAppUser[];
}) {
const [users, setUsers] = useState(initialUsers);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const updateStatus = async (user: AdminAppUser) => {
const status = user.status === "active" ? "disabled" : "active";
setUpdatingId(user.id);
setError(null);
try {
const res = await fetch(`/api/admin/app-users/${user.id}`, {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ status }),
});
const json = (await res.json()) as ApiResponse<AdminAppUser>;
if (!json.ok) {
throw new Error(json.error || "Failed to update user.");
}
setUsers((current) =>
current.map((item) =>
item.id === json.data.id ? json.data : item,
),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error.");
} finally {
setUpdatingId(null);
}
};
return (
<main className="flex flex-1 flex-col gap-4 p-4 lg:p-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">App users</h2>
<Badge variant="outline">{users.length} total</Badge>
</div>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Status</TableHead>
<TableHead>Favorites</TableHead>
<TableHead>Last login</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-28">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">
{user.email ?? user.phone ?? "No email"}
</div>
{user.display_name ? (
<div className="text-muted-foreground text-sm">
{user.display_name}
</div>
) : null}
</TableCell>
<TableCell>{user.auth_provider}</TableCell>
<TableCell>
<Badge
variant={
user.status === "active"
? "default"
: "secondary"
}
>
{user.status}
</Badge>
</TableCell>
<TableCell>{user.favorite_count}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{user.last_login_at
? new Date(
user.last_login_at,
).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{new Date(
user.created_at,
).toLocaleDateString()}
</TableCell>
<TableCell>
<Button
size="sm"
variant={
user.status === "active"
? "destructive"
: "secondary"
}
disabled={updatingId === user.id}
onClick={() => void updateStatus(user)}
>
{updatingId === user.id ? (
<Loader2Icon className="animate-spin" />
) : null}
{user.status === "active"
? "Disable"
: "Enable"}
</Button>
</TableCell>
</TableRow>
))}
{users.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="text-muted-foreground h-32 text-center text-sm"
>
No app users yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</main>
);
}