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

@@ -1,6 +1,8 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { AddonForm } from "@/components/admin/AddonForm";
import { AddonScreenshots } from "@/components/admin/AddonScreenshots";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export const dynamic = "force-dynamic";
@@ -10,7 +12,10 @@ export default async function EditAddonPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const addon = await prisma.addon.findUnique({ where: { id } });
const addon = await prisma.addon.findUnique({
where: { id },
include: { screenshots: { orderBy: { sortOrder: "asc" } } },
});
if (!addon) notFound();
@@ -18,6 +23,17 @@ export default async function EditAddonPage({
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
<AddonForm initialData={addon} />
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<AddonScreenshots
addonId={addon.id}
initial={JSON.parse(JSON.stringify(addon.screenshots))}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,16 +1,8 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Plus } from "lucide-react";
import { ReleasesTable } from "@/components/admin/ReleasesTable";
export const dynamic = "force-dynamic";
@@ -31,57 +23,7 @@ export default async function AdminReleasesPage() {
</div>
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{releases.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
releases.map((release) => (
<TableRow key={release.id}>
<TableCell className="font-medium">
{release.addon.name}
</TableCell>
<TableCell>v{release.version}</TableCell>
<TableCell className="text-muted-foreground">
{release.gameVersion || "-"}
</TableCell>
<TableCell>
<Badge variant="secondary">
{release.downloadType === "local" ? "本地文件" : "外部链接"}
</Badge>
</TableCell>
<TableCell>{release.downloadCount}</TableCell>
<TableCell className="text-muted-foreground">
{new Date(release.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell>
{release.isLatest && (
<Badge></Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<ReleasesTable releases={JSON.parse(JSON.stringify(releases))} />
</div>
</div>
);

View File

@@ -1,16 +1,154 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Trash2, Upload, Copy, ExternalLink } from "lucide-react";
type SiteSettings = {
grayscale: boolean;
shutdownBannerEnabled: boolean;
shutdownTitle: string;
shutdownTitleEn: string;
shutdownSubtitle: string;
shutdownSubtitleEn: string;
shutdownAt: string | null;
bgmUrl: string;
bgmAutoplay: boolean;
bgmVolume: number;
};
function toDatetimeLocal(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
export default function SettingsPage() {
const [loading, setLoading] = useState(false);
const [launcherUrl, setLauncherUrl] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
useEffect(() => {
if (typeof window !== "undefined") {
setLauncherUrl(`${window.location.origin}/download/launcher`);
}
}, []);
async function handleCopyLauncherUrl() {
try {
await navigator.clipboard.writeText(launcherUrl);
toast.success("已复制到剪贴板");
} catch {
toast.error("复制失败");
}
}
const [siteLoading, setSiteLoading] = useState(true);
const [siteSaving, setSiteSaving] = useState(false);
const [grayscale, setGrayscale] = useState(false);
const [bannerEnabled, setBannerEnabled] = useState(false);
const [title, setTitle] = useState("");
const [titleEn, setTitleEn] = useState("");
const [subtitle, setSubtitle] = useState("");
const [subtitleEn, setSubtitleEn] = useState("");
const [shutdownAtLocal, setShutdownAtLocal] = useState("");
const [bgmUrl, setBgmUrl] = useState("");
const [bgmAutoplay, setBgmAutoplay] = useState(false);
const [bgmVolume, setBgmVolume] = useState(50);
const [bgmUploading, setBgmUploading] = useState(false);
const bgmFileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/admin/site-settings");
if (!res.ok) throw new Error();
const data: SiteSettings = await res.json();
setGrayscale(data.grayscale);
setBannerEnabled(data.shutdownBannerEnabled);
setTitle(data.shutdownTitle);
setTitleEn(data.shutdownTitleEn ?? "");
setSubtitle(data.shutdownSubtitle);
setSubtitleEn(data.shutdownSubtitleEn ?? "");
setShutdownAtLocal(toDatetimeLocal(data.shutdownAt));
setBgmUrl(data.bgmUrl || "");
setBgmAutoplay(!!data.bgmAutoplay);
setBgmVolume(
typeof data.bgmVolume === "number" ? data.bgmVolume : 50
);
} catch {
toast.error("加载站点设置失败");
} finally {
setSiteLoading(false);
}
})();
}, []);
async function handleSiteSave(e: React.FormEvent) {
e.preventDefault();
setSiteSaving(true);
const payload = {
grayscale,
shutdownBannerEnabled: bannerEnabled,
shutdownTitle: title,
shutdownTitleEn: titleEn,
shutdownSubtitle: subtitle,
shutdownSubtitleEn: subtitleEn,
shutdownAt: shutdownAtLocal
? new Date(shutdownAtLocal).toISOString()
: null,
bgmUrl,
bgmAutoplay,
bgmVolume,
};
const res = await fetch("/api/admin/site-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
toast.success("站点设置已保存");
} else {
const data = await res.json().catch(() => ({}));
toast.error(data?.error || "保存失败");
}
setSiteSaving(false);
}
async function handleBgmUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("audio/")) {
toast.error("请选择音频文件");
return;
}
setBgmUploading(true);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: form });
if (!res.ok) throw new Error();
const data = await res.json();
setBgmUrl(data.filePath as string);
toast.success("音频上传成功,请记得保存");
} catch {
toast.error("音频上传失败");
} finally {
setBgmUploading(false);
if (bgmFileRef.current) bgmFileRef.current.value = "";
}
}
async function handlePasswordSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
@@ -45,14 +183,273 @@ export default function SettingsPage() {
}
return (
<div className="mx-auto max-w-lg space-y-6">
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSiteSave} className="space-y-6">
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div className="space-y-1">
<Label className="text-base"></Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={grayscale}
onCheckedChange={setGrayscale}
disabled={siteLoading}
/>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-base"> Banner</Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={bannerEnabled}
onCheckedChange={setBannerEnabled}
disabled={siteLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownTitle"></Label>
<Textarea
id="shutdownTitle"
value={title}
onChange={(e) => setTitle(e.target.value)}
rows={2}
placeholder={"例如:旅程即将终结\n再见乌龟服"}
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
Markdown <code>****</code><code>**</code><code>[](https://...)</code>。
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownTitleEn">Title (English)</Label>
<Textarea
id="shutdownTitleEn"
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
rows={2}
placeholder={"e.g. Every journey ends,\nbut our story carries on"}
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
访退
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownSubtitle"> / </Label>
<Textarea
id="shutdownSubtitle"
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
rows={4}
placeholder="例如《Turtle WoW》即将关闭服务器将于欧洲时间 5月15日 凌晨 00:00 正式关闭。"
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
Markdown
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownSubtitleEn">Subtitle (English)</Label>
<Textarea
id="shutdownSubtitleEn"
value={subtitleEn}
onChange={(e) => setSubtitleEn(e.target.value)}
rows={4}
placeholder="e.g. Turtle WoW will be shutting down. Servers go offline at midnight European time on May 15."
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
访退
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shutdownAt"></Label>
<Input
id="shutdownAt"
type="datetime-local"
value={shutdownAtLocal}
onChange={(e) => setShutdownAtLocal(e.target.value)}
disabled={siteLoading}
/>
<p className="text-xs text-muted-foreground">
使访
</p>
</div>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div>
<Label className="text-base">BGM</Label>
<p className="mt-1 text-xs text-muted-foreground">
mp3 / ogg / wav
</p>
</div>
<div className="space-y-2">
<Label></Label>
{bgmUrl ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
<span className="flex-1 truncate font-mono text-xs text-muted-foreground">
{bgmUrl}
</span>
<button
type="button"
onClick={() => setBgmUrl("")}
className="text-destructive hover:text-destructive/80"
title="移除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<audio
src={bgmUrl}
controls
className="w-full"
preload="metadata"
/>
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => bgmFileRef.current?.click()}
disabled={bgmUploading}
>
{bgmUploading ? "上传中..." : "替换音频"}
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
onClick={() => bgmFileRef.current?.click()}
disabled={bgmUploading}
className="gap-2"
>
<Upload className="h-4 w-4" />
{bgmUploading ? "上传中..." : "上传音频"}
</Button>
)}
<input
ref={bgmFileRef}
type="file"
accept="audio/*"
className="hidden"
onChange={handleBgmUpload}
/>
</div>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label></Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={bgmAutoplay}
onCheckedChange={setBgmAutoplay}
disabled={siteLoading || !bgmUrl}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="bgmVolume"></Label>
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{bgmVolume}
</span>
</div>
<input
id="bgmVolume"
type="range"
min={0}
max={100}
value={bgmVolume}
onChange={(e) => setBgmVolume(Number(e.target.value))}
disabled={siteLoading || !bgmUrl}
className="w-full"
/>
</div>
</div>
<Button type="submit" disabled={siteLoading || siteSaving}>
{siteSaving ? "保存中..." : "保存"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
访
Nanami
</p>
<div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
<span className="flex-1 truncate font-mono text-xs">
{launcherUrl || "/download/launcher"}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyLauncherUrl}
disabled={!launcherUrl}
className="gap-1"
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
launcherUrl && window.open(launcherUrl, "_blank")
}
disabled={!launcherUrl}
className="gap-1"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword"></Label>
<Input

View File

@@ -86,35 +86,35 @@ export default async function AdminSoftwarePage() {
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{item.latestVersion
? `v${item.latestVersion.version}`
: "未发布"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{item.versions.length}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{item.totalDownloads}</p>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{(["1.18", "1.17"] as const).map((wv) => {
const latest = item.versions.find(
(v) => v.isLatest && v.wowVersion === wv
);
const total = item.versions.filter(
(v) => v.wowVersion === wv
).length;
const downloads = item.versions
.filter((v) => v.wowVersion === wv)
.reduce((s, v) => s + v.downloadCount, 0);
return (
<Card key={wv} className="border-amber-500/20">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<span>WoW {wv}</span>
<span className="text-xs text-muted-foreground/70">
({total} · {downloads} )
</span>
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{latest ? `v${latest.version}` : "未发布"}
</p>
</CardContent>
</Card>
);
})}
</div>
<Card>