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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user