518 lines
22 KiB
TypeScript
518 lines
22 KiB
TypeScript
"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>
|
|
);
|
|
}
|