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,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