Files
nanami-web/src/components/admin/AddonScreenshots.tsx
2026-05-12 09:58:25 +08:00

281 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useRef, useCallback } from "react";
import { toast } from "sonner";
import {
Trash2,
Upload,
Loader2,
GripVertical,
X,
ChevronLeft,
ChevronRight,
Eye,
} from "lucide-react";
import { Button } from "@/components/ui/button";
interface Screenshot {
id: string;
imageUrl: string;
sortOrder: number;
}
interface Props {
addonId: string;
initial: Screenshot[];
}
export function AddonScreenshots({ addonId, initial }: Props) {
const [items, setItems] = useState<Screenshot[]>(initial);
const [uploading, setUploading] = useState(false);
const [previewIdx, setPreviewIdx] = useState<number | null>(null);
const [dragId, setDragId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const endpoint = `/api/addons/${addonId}/screenshots`;
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files?.length) return;
setUploading(true);
try {
for (const file of Array.from(files)) {
const fd = new FormData();
fd.append("file", file);
const uploadRes = await fetch("/api/upload", { method: "POST", body: fd });
if (!uploadRes.ok) throw new Error("上传失败");
const { filePath } = await uploadRes.json();
const maxSort = items.reduce((m, it) => Math.max(m, it.sortOrder), -1);
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: filePath, sortOrder: maxSort + 1 }),
});
if (!res.ok) throw new Error("保存失败");
const created = await res.json();
setItems((prev) => [...prev, created]);
}
toast.success("截图上传成功");
} catch {
toast.error("上传失败");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const handleDelete = async (id: string) => {
if (!confirm("确认删除该截图?")) return;
const res = await fetch(`${endpoint}/${id}`, { method: "DELETE" });
if (res.ok) {
setItems((prev) => prev.filter((it) => it.id !== id));
toast.success("已删除");
} else {
toast.error("删除失败");
}
};
const persistSortOrder = useCallback(
async (reordered: Screenshot[]) => {
try {
await Promise.all(
reordered.map((item, idx) =>
fetch(`${endpoint}/${item.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sortOrder: idx }),
})
)
);
} catch {
toast.error("排序保存失败");
}
},
[endpoint]
);
const handleDragStart = (e: React.DragEvent, id: string) => {
setDragId(id);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragId && dragId !== id) setDragOverId(id);
};
const handleDrop = (e: React.DragEvent, targetId: string) => {
e.preventDefault();
if (!dragId || dragId === targetId) {
setDragId(null);
setDragOverId(null);
return;
}
const fromIdx = items.findIndex((it) => it.id === dragId);
const toIdx = items.findIndex((it) => it.id === targetId);
if (fromIdx === -1 || toIdx === -1) return;
const newItems = [...items];
const [moved] = newItems.splice(fromIdx, 1);
newItems.splice(toIdx, 0, moved);
const updated = newItems.map((item, i) => ({ ...item, sortOrder: i }));
setItems(updated);
setDragId(null);
setDragOverId(null);
persistSortOrder(updated);
toast.success("排序已更新");
};
const handleDragEnd = () => {
setDragId(null);
setDragOverId(null);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="gap-2"
variant="outline"
size="sm"
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleUpload}
/>
<span className="text-xs text-muted-foreground">
{items.length} ·
</span>
</div>
{items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
</p>
) : (
<div className="space-y-1">
{items.map((item, idx) => (
<div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e, item.id)}
onDragOver={(e) => handleDragOver(e, item.id)}
onDrop={(e) => handleDrop(e, item.id)}
onDragEnd={handleDragEnd}
className={`flex items-center gap-3 rounded-lg border p-2 transition-all ${
dragId === item.id
? "scale-[0.98] bg-muted/50 opacity-40"
: dragOverId === item.id
? "bg-primary/5 ring-2 ring-primary"
: "bg-card hover:bg-muted/30"
}`}
>
<div className="cursor-grab text-muted-foreground/50 hover:text-muted-foreground active:cursor-grabbing">
<GripVertical className="h-4 w-4" />
</div>
<span className="w-5 text-center font-mono text-xs text-muted-foreground">
{idx + 1}
</span>
<div
className="relative h-12 w-20 shrink-0 cursor-pointer overflow-hidden rounded border bg-muted"
onClick={() => setPreviewIdx(idx)}
>
<img
src={item.imageUrl}
alt=""
className="h-full w-full object-cover"
draggable={false}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all hover:bg-black/30 hover:opacity-100">
<Eye className="h-4 w-4 text-white" />
</div>
</div>
<span className="hidden flex-1 truncate text-xs text-muted-foreground/60 md:block">
{item.imageUrl}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="ml-auto h-8 w-8 text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{/* Lightbox */}
{previewIdx !== null && items[previewIdx] && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
onClick={() => setPreviewIdx(null)}
>
<button
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white"
onClick={() => setPreviewIdx(null)}
>
<X className="h-5 w-5" />
</button>
{items.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
setPreviewIdx((previewIdx - 1 + items.length) % items.length);
}}
className="absolute left-4 top-1/2 z-10 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setPreviewIdx((previewIdx + 1) % items.length);
}}
className="absolute right-4 top-1/2 z-10 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white"
>
<ChevronRight className="h-5 w-5" />
</button>
</>
)}
<div
className="flex flex-col items-center px-16"
onClick={(e) => e.stopPropagation()}
>
<img
src={items[previewIdx].imageUrl}
alt=""
className="max-h-[85vh] max-w-[90vw] rounded-lg object-contain"
/>
<span className="mt-2 text-xs text-white/40">
{previewIdx + 1} / {items.length}
</span>
</div>
</div>
)}
</div>
);
}