官网 初版

This commit is contained in:
rucky
2026-03-18 17:13:27 +08:00
parent 879c4bdfc8
commit 241a76caeb
95 changed files with 8889 additions and 113 deletions

View File

@@ -0,0 +1,209 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Download, Package, Calendar, Tag } from "lucide-react";
import { DownloadButton } from "@/components/public/DownloadButton";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const addon = await prisma.addon.findUnique({
where: { slug },
select: { name: true, summary: true },
});
if (!addon) return { title: "Not Found" };
return {
title: `${addon.name} - Nanami`,
description: addon.summary,
};
}
export const dynamic = "force-dynamic";
export default async function AddonDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const addon = await prisma.addon.findUnique({
where: { slug },
include: {
releases: { orderBy: { createdAt: "desc" } },
screenshots: { orderBy: { sortOrder: "asc" } },
},
});
if (!addon || !addon.published) notFound();
const latestRelease = addon.releases.find((r) => r.isLatest);
return (
<div className="mx-auto max-w-6xl px-4 py-12">
{/* Header */}
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
{addon.iconUrl ? (
<img
src={addon.iconUrl}
alt={addon.name}
className="h-20 w-20 rounded-2xl object-cover"
/>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-primary/10">
<Package className="h-10 w-10 text-primary" />
</div>
)}
<div className="flex-1">
<h1 className="text-3xl font-bold">{addon.name}</h1>
<p className="mt-2 text-lg text-muted-foreground">{addon.summary}</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Badge>{addon.category}</Badge>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Download className="h-3.5 w-3.5" />
{addon.totalDownloads.toLocaleString()}
</span>
{latestRelease && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Tag className="h-3.5 w-3.5" />
v{latestRelease.version}
</span>
)}
</div>
</div>
{latestRelease && (
<div className="shrink-0">
<DownloadButton
releaseId={latestRelease.id}
version={latestRelease.version}
size="lg"
/>
</div>
)}
</div>
<Separator className="my-8" />
<div className="grid gap-8 lg:grid-cols-3">
{/* Description */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-neutral max-w-none dark:prose-invert">
<MarkdownContent content={addon.description} />
</div>
</CardContent>
</Card>
{/* Screenshots */}
{addon.screenshots.length > 0 && (
<Card className="mt-6">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{addon.screenshots.map((ss) => (
<img
key={ss.id}
src={ss.imageUrl}
alt="Screenshot"
className="rounded-lg border object-cover"
/>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Sidebar - Releases */}
<div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{addon.releases.length}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{addon.releases.map((release) => (
<div
key={release.id}
className="rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold">v{release.version}</span>
{release.isLatest && (
<Badge variant="default" className="text-xs">
</Badge>
)}
</div>
<DownloadButton
releaseId={release.id}
version={release.version}
size="sm"
/>
</div>
{release.gameVersion && (
<p className="mt-1 text-xs text-muted-foreground">
WoW {release.gameVersion}
</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(release.createdAt).toLocaleDateString("zh-CN")}
</span>
<span className="flex items-center gap-1">
<Download className="h-3 w-3" />
{release.downloadCount}
</span>
</div>
{release.changelog && (
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-line">
{release.changelog}
</p>
)}
</div>
))}
{addon.releases.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function MarkdownContent({ content }: { content: string }) {
const html = content
.replace(/^### (.*$)/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
.replace(/^## (.*$)/gm, '<h2 class="text-xl font-semibold mt-6 mb-3">$1</h2>')
.replace(/^# (.*$)/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, '<code class="rounded bg-muted px-1.5 py-0.5 text-sm">$1</code>')
.replace(/^- (.*$)/gm, '<li class="ml-4 list-disc">$1</li>')
.replace(/\n\n/g, '<br/><br/>')
.replace(/\n/g, "<br/>");
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

View File

@@ -0,0 +1,101 @@
import { prisma } from "@/lib/db";
import { AddonCard } from "@/components/public/AddonCard";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
const categoryLabels: Record<string, string> = {
general: "通用",
gameplay: "游戏玩法",
ui: "界面增强",
combat: "战斗",
raid: "团队副本",
pvp: "PvP",
tradeskill: "专业技能",
utility: "实用工具",
};
export const metadata = {
title: "插件列表 - Nanami",
};
export const dynamic = "force-dynamic";
export default async function AddonsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string; search?: string }>;
}) {
const { category, search } = await searchParams;
const where: Record<string, unknown> = { published: true };
if (category) where.category = category;
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
];
}
const addons = await prisma.addon.findMany({
where,
include: {
releases: {
where: { isLatest: true },
select: { version: true },
},
},
orderBy: { totalDownloads: "desc" },
});
const categories = await prisma.addon.groupBy({
by: ["category"],
where: { published: true },
_count: { id: true },
});
return (
<div className="mx-auto max-w-6xl px-4 py-12">
<h1 className="text-3xl font-bold"></h1>
<p className="mt-2 text-muted-foreground">
World of Warcraft
</p>
{/* Category Filter */}
<div className="mt-6 flex flex-wrap gap-2">
<Link href="/addons">
<Badge
variant={!category ? "default" : "outline"}
className="cursor-pointer"
>
</Badge>
</Link>
{categories.map((cat) => (
<Link key={cat.category} href={`/addons?category=${cat.category}`}>
<Badge
variant={category === cat.category ? "default" : "outline"}
className="cursor-pointer"
>
{categoryLabels[cat.category] || cat.category} ({cat._count.id})
</Badge>
</Link>
))}
</div>
{/* Addon Grid */}
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{addons.map((addon) => (
<AddonCard key={addon.id} addon={addon} />
))}
</div>
{addons.length === 0 && (
<div className="mt-16 text-center">
<p className="text-lg text-muted-foreground">
{search ? `没有找到"${search}"相关的插件` : "暂无插件"}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dark flex min-h-screen flex-col bg-[#0d0b15]">
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}

110
src/app/(public)/page.tsx Normal file
View File

@@ -0,0 +1,110 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import { AddonCard } from "@/components/public/AddonCard";
import { HeroBanner } from "@/components/public/HeroBanner";
import { Sparkles, Shield, Zap } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const [featuredAddons, totalDownloads, launcher] = await Promise.all([
prisma.addon.findMany({
where: { published: true },
include: {
releases: {
where: { isLatest: true },
select: { version: true },
},
},
orderBy: { totalDownloads: "desc" },
take: 6,
}),
prisma.addon.aggregate({
_sum: { totalDownloads: true },
}),
prisma.software.findUnique({
where: { slug: "nanami-launcher" },
include: {
versions: {
where: { isLatest: true },
take: 1,
},
},
}),
]);
const launcherVersion = launcher?.versions[0]?.version ?? null;
return (
<>
<HeroBanner
totalDownloads={totalDownloads._sum.totalDownloads ?? undefined}
launcherVersion={launcherVersion}
/>
{/* Features */}
<section className="relative border-t border-amber-900/20 bg-gradient-to-b from-[#0d0b15] to-[#110f1a] dark:from-[#0d0b15] dark:to-[#110f1a]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(168,85,247,0.06)_0%,transparent_70%)]" />
<div className="relative mx-auto max-w-6xl px-4 py-16">
<div className="grid gap-8 md:grid-cols-3">
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-500/10">
<Sparkles className="h-6 w-6 text-amber-400" />
</div>
<h3 className="mt-4 font-semibold text-amber-100"></h3>
<p className="mt-2 text-sm text-gray-400">
1.18.0
</p>
</div>
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
<Shield className="h-6 w-6 text-purple-400" />
</div>
<h3 className="mt-4 font-semibold text-amber-100">
</h3>
<p className="mt-2 text-sm text-gray-400">
Nanami
</p>
</div>
<div className="flex flex-col items-center rounded-xl border border-amber-500/10 bg-white/5 p-6 text-center backdrop-blur transition-colors hover:border-amber-500/25">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-cyan-500/10">
<Zap className="h-6 w-6 text-cyan-400" />
</div>
<h3 className="mt-4 font-semibold text-amber-100">
AI
</h3>
<p className="mt-2 text-sm text-gray-400">
</p>
</div>
</div>
</div>
</section>
{/* Featured Addons */}
{featuredAddons.length > 0 && (
<section className="border-t border-amber-900/20 bg-gradient-to-b from-[#110f1a] to-[#0d0b15] dark:from-[#110f1a] dark:to-[#0d0b15]">
<div className="mx-auto max-w-6xl px-4 py-16">
<div className="mb-8 flex items-center justify-between">
<h2 className="text-2xl font-bold text-amber-100"></h2>
<Button
variant="outline"
className="border-amber-500/20 text-amber-200 hover:border-amber-500/40 hover:bg-amber-500/10 hover:text-amber-100"
render={<Link href="/addons" />}
>
</Button>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{featuredAddons.map((addon) => (
<AddonCard key={addon.id} addon={addon} />
))}
</div>
</div>
</section>
)}
</>
);
}

View File

@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { AddonForm } from "@/components/admin/AddonForm";
export const dynamic = "force-dynamic";
export default async function EditAddonPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const addon = await prisma.addon.findUnique({ where: { id } });
if (!addon) notFound();
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
<AddonForm initialData={addon} />
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { AddonForm } from "@/components/admin/AddonForm";
export default function NewAddonPage() {
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
<AddonForm />
</div>
);
}

View File

@@ -0,0 +1,87 @@
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 { DeleteAddonButton } from "@/components/admin/DeleteAddonButton";
export const dynamic = "force-dynamic";
export default async function AdminAddonsPage() {
const addons = await prisma.addon.findMany({
include: { _count: { select: { releases: true } } },
orderBy: { updatedAt: "desc" },
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold"></h1>
<Button render={<Link href="/admin/addons/new" />}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Slug</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{addons.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
addons.map((addon) => (
<TableRow key={addon.id}>
<TableCell className="font-medium">{addon.name}</TableCell>
<TableCell className="text-muted-foreground">
{addon.slug}
</TableCell>
<TableCell>
<Badge variant="secondary">{addon.category}</Badge>
</TableCell>
<TableCell>
<Badge variant={addon.published ? "default" : "outline"}>
{addon.published ? "已发布" : "草稿"}
</Badge>
</TableCell>
<TableCell>{addon._count.releases}</TableCell>
<TableCell>{addon.totalDownloads}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" render={<Link href={`/admin/addons/${addon.id}/edit`} />}>
</Button>
<DeleteAddonButton addonId={addon.id} addonName={addon.name} />
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { Sidebar } from "@/components/admin/Sidebar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-y-auto bg-muted/40 p-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { prisma } from "@/lib/db";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Package, Download, FileUp } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const [addonCount, totalDownloads, releaseCount, recentReleases] =
await Promise.all([
prisma.addon.count(),
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
prisma.release.count(),
prisma.release.findMany({
take: 5,
orderBy: { createdAt: "desc" },
include: { addon: { select: { name: true } } },
}),
]);
const stats = [
{
title: "插件总数",
value: addonCount,
icon: Package,
},
{
title: "总下载量",
value: totalDownloads._sum.totalDownloads || 0,
icon: Download,
},
{
title: "版本发布数",
value: releaseCount,
icon: FileUp,
},
];
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold"></h1>
<div className="grid gap-4 md:grid-cols-3">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{stat.title}
</CardTitle>
<stat.icon className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stat.value}</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{recentReleases.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-4">
{recentReleases.map((release) => (
<div
key={release.id}
className="flex items-center justify-between border-b pb-3 last:border-0"
>
<div>
<p className="font-medium">{release.addon.name}</p>
<p className="text-sm text-muted-foreground">
v{release.version}
{release.gameVersion &&
` · WoW ${release.gameVersion}`}
</p>
</div>
<div className="text-right text-sm text-muted-foreground">
<p>{release.downloadCount} </p>
<p>{new Date(release.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { prisma } from "@/lib/db";
import { ReleaseForm } from "@/components/admin/ReleaseForm";
export const dynamic = "force-dynamic";
export default async function NewReleasePage() {
const addons = await prisma.addon.findMany({
select: { id: true, name: true },
orderBy: { name: "asc" },
});
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"></h1>
{addons.length === 0 ? (
<p className="text-muted-foreground">
</p>
) : (
<ReleaseForm addons={addons} />
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
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";
export const dynamic = "force-dynamic";
export default async function AdminReleasesPage() {
const releases = await prisma.release.findMany({
include: { addon: { select: { name: true, slug: true } } },
orderBy: { createdAt: "desc" },
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold"></h1>
<Button render={<Link href="/admin/releases/new" />}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</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>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
export default function SettingsPage() {
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const formData = new FormData(e.currentTarget);
const newPassword = formData.get("newPassword") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (newPassword !== confirmPassword) {
toast.error("两次输入的新密码不一致");
setLoading(false);
return;
}
const res = await fetch("/api/admin/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword: formData.get("currentPassword"),
newPassword,
}),
});
const data = await res.json();
if (res.ok) {
toast.success("密码修改成功");
(e.target as HTMLFormElement).reset();
} else {
toast.error(data.error || "修改失败");
}
setLoading(false);
}
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-3xl font-bold"></h1>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword"></Label>
<Input
id="currentPassword"
name="currentPassword"
type="password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword"></Label>
<Input
id="newPassword"
name="newPassword"
type="password"
required
minLength={6}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={6}
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? "修改中..." : "修改密码"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { SoftwareEditForm } from "@/components/admin/SoftwareEditForm";
export const dynamic = "force-dynamic";
export default async function EditSoftwarePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const software = await prisma.software.findUnique({
where: { id },
include: { versions: { orderBy: { versionCode: "desc" } } },
});
if (!software) notFound();
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold"> - {software.name}</h1>
<SoftwareEditForm software={software} />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { SoftwareVersionForm } from "@/components/admin/SoftwareVersionForm";
export const dynamic = "force-dynamic";
export default async function NewSoftwareVersionPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const software = await prisma.software.findUnique({
where: { id },
select: { id: true, name: true, slug: true },
});
if (!software) notFound();
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-3xl font-bold">
- {software.name}
</h1>
<SoftwareVersionForm softwareId={software.id} />
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
export default function NewSoftwarePage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
function generateSlug(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const fd = new FormData(e.currentTarget);
const res = await fetch("/api/software", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: fd.get("name"),
slug: fd.get("slug"),
description: fd.get("description"),
}),
});
if (res.ok) {
toast.success("创建成功");
router.push("/admin/software");
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "创建失败");
}
setLoading(false);
}
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-3xl font-bold"></h1>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name" name="name" required
onChange={(e) => {
const slugInput = document.getElementById("slug") as HTMLInputElement;
if (slugInput) slugInput.value = generateSlug(e.target.value);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input id="slug" name="slug" required pattern="[a-z0-9-]+" />
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea id="description" name="description" rows={3} />
</div>
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading ? "创建中..." : "创建"}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Plus } from "lucide-react";
import { SoftwareVersionTable } from "@/components/admin/SoftwareVersionTable";
const SOFTWARE_DEFS = [
{
slug: "nanami-launcher",
name: "Nanami 启动器(全量包)",
description: "Nanami 插件启动器完整安装包,用户首次下载或大版本更新",
badgeLabel: "全量",
},
{
slug: "nanami-launcher-patch",
name: "Nanami 热更新包",
description: "app.asar 热更新包,客户端静默下载替换实现热更新",
badgeLabel: "热更新",
},
];
async function ensureSoftware(slug: string, name: string, description: string) {
let sw = await prisma.software.findUnique({ where: { slug } });
if (!sw) {
sw = await prisma.software.create({ data: { name, slug, description } });
}
return sw;
}
export const dynamic = "force-dynamic";
export default async function AdminSoftwarePage() {
const softwareItems = await Promise.all(
SOFTWARE_DEFS.map(async (def) => {
const sw = await ensureSoftware(def.slug, def.name, def.description);
const versions = await prisma.softwareVersion.findMany({
where: { softwareId: sw.id },
orderBy: { versionCode: "desc" },
});
const totalDownloads = versions.reduce((s, v) => s + v.downloadCount, 0);
const latestVersion = versions.find((v) => v.isLatest);
return { ...def, sw, versions, totalDownloads, latestVersion };
})
);
return (
<div className="space-y-10">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="mt-1 text-muted-foreground">
slug
</p>
</div>
{softwareItems.map((item) => {
const serializedVersions = item.versions.map((v) => ({
...v,
createdAt: v.createdAt.toISOString(),
}));
return (
<div key={item.slug} className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold">{item.name}</h2>
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
slug: {item.slug}
</span>
</div>
<Button
render={
<Link
href={`/admin/software/${item.sw.id}/versions/new`}
/>
}
>
<Plus className="mr-2 h-4 w-4" />
</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>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="p-0">
<SoftwareVersionTable versions={serializedVersions} />
</CardContent>
</Card>
</div>
);
})}
</div>
);
}

11
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,11 @@
export const metadata = {
title: "管理后台 - Nanami",
};
export default function AdminRootLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,7 @@
export default function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const result = await signIn("credentials", {
username: formData.get("username"),
password: formData.get("password"),
redirect: false,
});
if (result?.error) {
setError("用户名或密码错误");
setLoading(false);
} else {
router.push("/admin");
router.refresh();
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/40">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
name="username"
required
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const addon = await prisma.addon.findFirst({
where: { OR: [{ id }, { slug: id }] },
include: {
releases: { orderBy: { createdAt: "desc" } },
screenshots: { orderBy: { sortOrder: "asc" } },
},
});
if (!addon) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(addon);
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { name, slug, summary, description, iconUrl, category, published } =
body;
if (slug) {
const existing = await prisma.addon.findFirst({
where: { slug, NOT: { id } },
});
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
}
const addon = await prisma.addon.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(slug !== undefined && { slug }),
...(summary !== undefined && { summary }),
...(description !== undefined && { description }),
...(iconUrl !== undefined && { iconUrl }),
...(category !== undefined && { category }),
...(published !== undefined && { published }),
},
});
return NextResponse.json(addon);
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
await prisma.addon.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const category = searchParams.get("category");
const search = searchParams.get("search");
const publishedOnly = searchParams.get("published") !== "false";
const where: Record<string, unknown> = {};
if (publishedOnly) where.published = true;
if (category) where.category = category;
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
];
}
const addons = await prisma.addon.findMany({
where,
include: {
releases: {
where: { isLatest: true },
take: 1,
},
_count: { select: { releases: true } },
},
orderBy: { updatedAt: "desc" },
});
return NextResponse.json(addons);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { name, slug, summary, description, iconUrl, category } = body;
if (!name || !slug || !summary) {
return NextResponse.json(
{ error: "name, slug, summary are required" },
{ status: 400 }
);
}
const existing = await prisma.addon.findUnique({ where: { slug } });
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
const addon = await prisma.addon.create({
data: {
name,
slug,
summary,
description: description || "",
iconUrl: iconUrl || null,
category: category || "general",
},
});
return NextResponse.json(addon, { status: 201 });
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
import bcrypt from "bcryptjs";
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
return NextResponse.json(
{ error: "请填写当前密码和新密码" },
{ status: 400 }
);
}
if (newPassword.length < 6) {
return NextResponse.json(
{ error: "新密码长度不能少于6位" },
{ status: 400 }
);
}
const admin = await prisma.admin.findFirst({
where: { username: session.user.name! },
});
if (!admin) {
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
}
const isValid = await bcrypt.compare(currentPassword, admin.passwordHash);
if (!isValid) {
return NextResponse.json({ error: "当前密码错误" }, { status: 403 });
}
const newHash = await bcrypt.hash(newPassword, 12);
await prisma.admin.update({
where: { id: admin.id },
data: { passwordHash: newHash },
});
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { readFile, stat } from "fs/promises";
import path from "path";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const release = await prisma.release.findUnique({
where: { id },
include: { addon: { select: { name: true, slug: true } } },
});
if (!release) {
return NextResponse.json({ error: "Release not found" }, { status: 404 });
}
await prisma.release.update({
where: { id },
data: { downloadCount: { increment: 1 } },
});
await prisma.addon.update({
where: { id: release.addonId },
data: { totalDownloads: { increment: 1 } },
});
if (release.downloadType === "url" && release.externalUrl) {
return NextResponse.redirect(release.externalUrl);
}
if (release.filePath) {
const filePath = path.join(process.cwd(), release.filePath);
try {
await stat(filePath);
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const fileBuffer = await readFile(filePath);
const fileName = `${release.addon.slug}-${release.version}${path.extname(release.filePath)}`;
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Length": fileBuffer.length.toString(),
},
});
}
return NextResponse.json(
{ error: "No download source available" },
{ status: 404 }
);
}

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const addonId = searchParams.get("addonId");
const where: Record<string, unknown> = {};
if (addonId) where.addonId = addonId;
const releases = await prisma.release.findMany({
where,
include: { addon: { select: { id: true, name: true, slug: true } } },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(releases);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const {
addonId,
version,
changelog,
downloadType,
filePath,
externalUrl,
gameVersion,
} = body;
if (!addonId || !version) {
return NextResponse.json(
{ error: "addonId and version are required" },
{ status: 400 }
);
}
if (downloadType === "url" && !externalUrl) {
return NextResponse.json(
{ error: "externalUrl is required for url type" },
{ status: 400 }
);
}
const addon = await prisma.addon.findUnique({ where: { id: addonId } });
if (!addon) {
return NextResponse.json({ error: "Addon not found" }, { status: 404 });
}
await prisma.release.updateMany({
where: { addonId, isLatest: true },
data: { isLatest: false },
});
const release = await prisma.release.create({
data: {
addonId,
version,
changelog: changelog || "",
downloadType: downloadType || "local",
filePath: filePath || null,
externalUrl: externalUrl || null,
gameVersion: gameVersion || "",
isLatest: true,
},
});
return NextResponse.json(release, { status: 201 });
}

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const software = await prisma.software.findFirst({
where: { OR: [{ id }, { slug: id }] },
include: {
versions: { orderBy: { versionCode: "desc" } },
},
});
if (!software) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(software);
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const software = await prisma.software.update({
where: { id },
data: {
...(body.name !== undefined && { name: body.name }),
...(body.slug !== undefined && { slug: body.slug }),
...(body.description !== undefined && { description: body.description }),
},
});
return NextResponse.json(software);
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
await prisma.software.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id: softwareId } = await params;
const body = await request.json();
const {
version,
versionCode,
changelog,
downloadType,
filePath,
externalUrl,
fileSize,
forceUpdate,
minVersion,
} = body;
if (!version || !versionCode) {
return NextResponse.json(
{ error: "version and versionCode are required" },
{ status: 400 }
);
}
const software = await prisma.software.findUnique({
where: { id: softwareId },
});
if (!software) {
return NextResponse.json(
{ error: "Software not found" },
{ status: 404 }
);
}
await prisma.softwareVersion.updateMany({
where: { softwareId, isLatest: true },
data: { isLatest: false },
});
const sv = await prisma.softwareVersion.create({
data: {
softwareId,
version,
versionCode: Number(versionCode),
changelog: changelog || "",
downloadType: downloadType || "local",
filePath: filePath || null,
externalUrl: externalUrl || null,
fileSize: Number(fileSize) || 0,
forceUpdate: forceUpdate || false,
minVersion: minVersion || null,
isLatest: true,
},
});
return NextResponse.json(sv, { status: 201 });
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug");
const currentVersionCode = searchParams.get("versionCode");
if (!slug) {
return NextResponse.json(
{ error: "slug parameter is required" },
{ status: 400 }
);
}
const software = await prisma.software.findUnique({
where: { slug },
include: {
versions: {
where: { isLatest: true },
take: 1,
},
},
});
if (!software || software.versions.length === 0) {
return NextResponse.json(
{ error: "Software or latest version not found" },
{ status: 404 }
);
}
const latest = software.versions[0];
const clientVersionCode = currentVersionCode
? parseInt(currentVersionCode, 10)
: 0;
const hasUpdate = latest.versionCode > clientVersionCode;
const downloadUrl = latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl
: `${request.nextUrl.origin}/api/software/download/${latest.id}`;
return NextResponse.json({
hasUpdate,
forceUpdate: hasUpdate && latest.forceUpdate,
latest: {
version: latest.version,
versionCode: latest.versionCode,
changelog: latest.changelog,
downloadUrl,
fileSize: latest.fileSize,
minVersion: latest.minVersion,
createdAt: latest.createdAt,
},
});
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { readFile, stat } from "fs/promises";
import path from "path";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const sv = await prisma.softwareVersion.findUnique({
where: { id },
include: { software: { select: { slug: true } } },
});
if (!sv) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
await prisma.softwareVersion.update({
where: { id },
data: { downloadCount: { increment: 1 } },
});
if (sv.downloadType === "url" && sv.externalUrl) {
return new Response(null, {
status: 302,
headers: { Location: sv.externalUrl },
});
}
if (sv.filePath) {
const filePath = path.join(process.cwd(), sv.filePath);
try {
await stat(filePath);
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const fileBuffer = await readFile(filePath);
const ext = path.extname(sv.filePath);
const fileName = `${sv.software.slug}-v${sv.version}${ext}`;
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Length": fileBuffer.length.toString(),
},
});
}
return NextResponse.json(
{ error: "No download source available" },
{ status: 404 }
);
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { readFile, stat } from "fs/promises";
import path from "path";
const LAUNCHER_SLUG = "nanami-launcher";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const infoOnly = searchParams.get("info") === "1";
const software = await prisma.software.findUnique({
where: { slug: LAUNCHER_SLUG },
include: {
versions: {
where: { isLatest: true },
take: 1,
},
},
});
if (!software || software.versions.length === 0) {
if (infoOnly) {
return NextResponse.json({ available: false });
}
return NextResponse.json(
{ error: "暂无可下载版本" },
{ status: 404 }
);
}
const latest = software.versions[0];
if (infoOnly) {
const downloadUrl =
latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl
: `/api/software/download/${latest.id}`;
return NextResponse.json({
available: true,
version: latest.version,
versionCode: latest.versionCode,
changelog: latest.changelog,
fileSize: latest.fileSize,
createdAt: latest.createdAt,
downloadUrl,
downloadType: latest.downloadType,
});
}
await prisma.softwareVersion.update({
where: { id: latest.id },
data: { downloadCount: { increment: 1 } },
});
if (latest.downloadType === "url" && latest.externalUrl) {
return new Response(null, {
status: 302,
headers: { Location: latest.externalUrl },
});
}
if (latest.filePath) {
const filePath = path.join(process.cwd(), latest.filePath);
try {
await stat(filePath);
} catch {
return NextResponse.json({ error: "文件不存在" }, { status: 404 });
}
const fileBuffer = await readFile(filePath);
const ext = path.extname(latest.filePath);
const fileName = `nanami-launcher-v${latest.version}${ext}`;
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Length": fileBuffer.length.toString(),
},
});
}
return NextResponse.json({ error: "无下载来源" }, { status: 404 });
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function GET() {
const software = await prisma.software.findMany({
include: {
versions: {
where: { isLatest: true },
take: 1,
},
_count: { select: { versions: true } },
},
orderBy: { updatedAt: "desc" },
});
return NextResponse.json(software);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { name, slug, description } = await request.json();
if (!name || !slug) {
return NextResponse.json(
{ error: "name and slug are required" },
{ status: 400 }
);
}
const existing = await prisma.software.findUnique({ where: { slug } });
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
const software = await prisma.software.create({
data: { name, slug, description: description || "" },
});
return NextResponse.json(software, { status: 201 });
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const body = await request.json();
const existing = await prisma.softwareVersion.findUnique({
where: { id: versionId },
});
if (!existing) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const setLatest = body.isLatest === true && !existing.isLatest;
if (setLatest) {
await prisma.softwareVersion.updateMany({
where: { softwareId: existing.softwareId, isLatest: true },
data: { isLatest: false },
});
}
const updated = await prisma.softwareVersion.update({
where: { id: versionId },
data: {
...(body.version !== undefined && { version: body.version }),
...(body.versionCode !== undefined && {
versionCode: Number(body.versionCode),
}),
...(body.changelog !== undefined && { changelog: body.changelog }),
...(body.downloadType !== undefined && {
downloadType: body.downloadType,
}),
...(body.filePath !== undefined && { filePath: body.filePath || null }),
...(body.externalUrl !== undefined && {
externalUrl: body.externalUrl || null,
}),
...(body.fileSize !== undefined && { fileSize: Number(body.fileSize) }),
...(body.forceUpdate !== undefined && { forceUpdate: body.forceUpdate }),
...(body.minVersion !== undefined && {
minVersion: body.minVersion || null,
}),
...(body.isLatest !== undefined && { isLatest: body.isLatest }),
},
});
return NextResponse.json(updated);
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
await prisma.softwareVersion.delete({ where: { id: versionId } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const filename = `${timestamp}-${safeName}`;
const uploadDir = path.join(process.cwd(), "uploads");
await mkdir(uploadDir, { recursive: true });
const filepath = path.join(uploadDir, filename);
await writeFile(filepath, buffer);
return NextResponse.json({
filePath: `/uploads/${filename}`,
originalName: file.name,
size: file.size,
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -7,8 +7,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-sans: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", "Cascadia Code", "Menlo", "Consolas", monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -126,4 +126,71 @@
html {
@apply font-sans;
}
}
/* ---- Hero Aurora Effects ---- */
.hero-aurora-wrapper {
position: relative;
z-index: 0;
}
.hero-aurora {
position: absolute;
left: 0;
right: 0;
height: 80px;
z-index: 30;
pointer-events: none;
filter: blur(30px);
opacity: 0.7;
}
.hero-aurora--top {
top: -30px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(168, 85, 247, 0.4) 15%,
rgba(59, 130, 246, 0.3) 30%,
rgba(236, 72, 153, 0.35) 50%,
rgba(139, 92, 246, 0.4) 70%,
rgba(6, 182, 212, 0.3) 85%,
transparent 100%
);
animation: auroraShift 8s ease-in-out infinite alternate;
}
.hero-aurora--bottom {
bottom: -30px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(245, 158, 11, 0.35) 15%,
rgba(168, 85, 247, 0.3) 35%,
rgba(6, 182, 212, 0.35) 55%,
rgba(236, 72, 153, 0.3) 75%,
rgba(245, 158, 11, 0.35) 90%,
transparent 100%
);
animation: auroraShift 8s ease-in-out infinite alternate-reverse;
}
@keyframes auroraShift {
0% {
background-position: 0% 50%;
opacity: 0.5;
}
50% {
opacity: 0.8;
}
100% {
background-position: 100% 50%;
opacity: 0.5;
}
}
/* Force background-size for aurora animation */
.hero-aurora--top,
.hero-aurora--bottom {
background-size: 200% 100%;
}

View File

@@ -1,20 +1,11 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Nanami - WoW Addons",
description: "World of Warcraft 插件发布与下载平台",
};
export default function RootLayout({
@@ -23,11 +14,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="zh-CN" suppressHydrationWarning>
<body className="antialiased">
<ThemeProvider>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);

View File

@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}