feat: add localization and site settings
This commit is contained in:
280
src/components/admin/AddonScreenshots.tsx
Normal file
280
src/components/admin/AddonScreenshots.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user