feat: add localization and site settings

This commit is contained in:
rucky
2026-05-12 09:58:25 +08:00
parent 9dc6c0dcce
commit fa7aedb8e7
67 changed files with 5221 additions and 888 deletions

View 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>
);
}