first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user