feat: Banner UI美化 & 新增文章/公告/图库/媒体管理等功能

- Banner: Ken Burns缩放动效、左右导航箭头、进度条指示器、hover暂停、暗角遮罩、shimmer按钮动画
- 新增文章管理(CRUD)与公开文章页
- 新增Banner/Gallery图片管理API
- 新增媒体管理页面
- 新增更新日志页面
- 新增页面访问追踪
- 新增Markdown渲染组件
- .gitignore排除.cursor目录

Made-with: Cursor
This commit is contained in:
rucky
2026-03-25 09:17:35 +08:00
parent 241a76caeb
commit bf92a69332
66 changed files with 5241 additions and 155 deletions

View File

@@ -0,0 +1,67 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Calendar, ArrowLeft } from "lucide-react";
import { MarkdownContent } from "@/components/public/MarkdownContent";
export const dynamic = "force-dynamic";
export default async function ArticleDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await prisma.article.findUnique({
where: { slug, published: true },
});
if (!article) notFound();
return (
<article className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
<Link
href="/articles"
className="mb-6 inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
>
<ArrowLeft className="h-3.5 w-3.5" />
</Link>
<h1 className="mb-3 text-2xl font-bold text-amber-100 sm:text-3xl">
{article.title}
</h1>
<div className="mb-6 flex items-center gap-1.5 text-sm text-gray-500">
<Calendar className="h-3.5 w-3.5" />
{new Date(article.createdAt).toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
{article.coverImage && (
<div className="mb-8 overflow-hidden rounded-lg">
<img
src={article.coverImage}
alt={article.title}
className="w-full object-cover"
/>
</div>
)}
<MarkdownContent content={article.content} />
<div className="mt-12 border-t border-amber-500/10 pt-6">
<Link
href="/articles"
className="inline-flex items-center gap-1.5 text-sm text-gray-400 transition-colors hover:text-amber-200"
>
<ArrowLeft className="h-3.5 w-3.5" />
</Link>
</div>
</article>
);
}

View File

@@ -0,0 +1,61 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Calendar } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function ArticlesPage() {
const articles = await prisma.article.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
});
return (
<section className="mx-auto max-w-4xl px-3 py-10 sm:px-4 sm:py-16">
<h1 className="mb-2 text-2xl font-bold text-amber-100 sm:text-3xl">
</h1>
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
</p>
{articles.length === 0 ? (
<p className="py-16 text-center text-gray-500"></p>
) : (
<div className="grid gap-6 sm:grid-cols-2">
{articles.map((article) => (
<Link
key={article.id}
href={`/articles/${article.slug}`}
className="group overflow-hidden rounded-lg border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
>
{article.coverImage && (
<div className="aspect-[16/9] overflow-hidden">
<img
src={article.coverImage}
alt={article.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
)}
<div className="p-4 sm:p-5">
<h2 className="mb-2 text-lg font-semibold text-amber-100 group-hover:text-amber-200 sm:text-xl">
{article.title}
</h2>
{article.summary && (
<p className="mb-3 line-clamp-2 text-sm text-gray-400">
{article.summary}
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Calendar className="h-3 w-3" />
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
</div>
</div>
</Link>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,97 @@
import { prisma } from "@/lib/db";
import { Download, Calendar, Tag } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function ChangelogPage() {
const software = await prisma.software.findUnique({
where: { slug: "nanami-launcher" },
include: {
versions: {
orderBy: { versionCode: "desc" },
},
},
});
const versions = software?.versions ?? [];
return (
<section className="mx-auto max-w-3xl px-3 py-10 sm:px-4 sm:py-16">
<h1 className="mb-2 text-2xl font-bold text-amber-100 sm:text-3xl">
</h1>
<p className="mb-8 text-sm text-gray-400 sm:mb-12 sm:text-base">
Nanami
</p>
{versions.length === 0 ? (
<p className="py-16 text-center text-gray-500"></p>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[15px] top-2 bottom-0 w-px bg-amber-500/20 sm:left-[19px]" />
<div className="space-y-8 sm:space-y-10">
{versions.map((v, idx) => (
<div key={v.id} className="relative pl-10 sm:pl-12">
{/* Timeline dot */}
<div
className={`absolute left-[10px] top-1.5 h-3 w-3 rounded-full border-2 sm:left-[13px] sm:h-3.5 sm:w-3.5 ${
idx === 0
? "border-amber-400 bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)]"
: "border-amber-500/40 bg-[#0d0b15]"
}`}
/>
<div className="rounded-lg border border-amber-500/10 bg-white/[0.03] p-4 sm:p-5">
{/* Header */}
<div className="mb-3 flex flex-wrap items-center gap-2 sm:gap-3">
<h2 className="text-lg font-semibold text-amber-100 sm:text-xl">
v{v.version}
</h2>
{v.isLatest && (
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-300 sm:text-xs">
</span>
)}
{v.forceUpdate && (
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-medium text-red-400 sm:text-xs">
</span>
)}
</div>
{/* Meta */}
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-gray-500 sm:gap-4 sm:text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
{new Date(v.createdAt).toLocaleDateString("zh-CN")}
</span>
<span className="flex items-center gap-1">
<Tag className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
Build {v.versionCode}
</span>
<span className="flex items-center gap-1">
<Download className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
{v.downloadCount.toLocaleString()}
</span>
{v.fileSize > 0 && (
<span className="text-gray-600">
{(v.fileSize / 1024 / 1024).toFixed(1)} MB
</span>
)}
</div>
{/* Changelog */}
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300 sm:text-[15px]">
{v.changelog || "无更新说明"}
</div>
</div>
</div>
))}
</div>
</div>
)}
</section>
);
}

View File

@@ -1,5 +1,6 @@
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { PageTracker } from "@/components/public/PageTracker";
export default function PublicLayout({
children,
@@ -11,6 +12,7 @@ export default function PublicLayout({
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
<PageTracker />
</div>
);
}

View File

@@ -3,51 +3,73 @@ 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";
import { GameGallery } from "@/components/public/GameGallery";
import { Sparkles, Shield, Zap, Calendar } 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 },
const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] =
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,
orderBy: { totalDownloads: "desc" },
take: 6,
}),
prisma.software.findUnique({
where: { slug: "nanami-launcher" },
include: {
versions: {
where: { isLatest: true },
take: 1,
},
},
},
}),
]);
}),
prisma.softwareVersion.aggregate({
where: { software: { slug: "nanami-launcher" } },
_sum: { downloadCount: true },
}),
prisma.bannerImage.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
select: { imageUrl: true },
}),
prisma.galleryImage.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
select: { imageUrl: true, title: true },
}),
prisma.article.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 3,
}),
]);
const launcherVersion = launcher?.versions[0]?.version ?? null;
const totalDownloads = launcherDownloads._sum.downloadCount ?? 0;
return (
<>
<HeroBanner
totalDownloads={totalDownloads._sum.totalDownloads ?? undefined}
totalDownloads={totalDownloads || undefined}
launcherVersion={launcherVersion}
banners={banners}
/>
<GameGallery items={galleryImages} />
{/* 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="relative mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
<div className="grid gap-4 sm: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" />
@@ -83,11 +105,62 @@ export default async function HomePage() {
</div>
</section>
{/* Latest Articles */}
{latestArticles.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-3 py-10 sm:px-4 sm:py-16">
<div className="mb-6 flex items-center justify-between sm:mb-8">
<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="/articles" />}
>
</Button>
</div>
<div className="grid gap-4 sm:gap-6 md:grid-cols-3">
{latestArticles.map((article) => (
<Link
key={article.id}
href={`/articles/${article.slug}`}
className="group overflow-hidden rounded-xl border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
>
{article.coverImage && (
<div className="aspect-[16/9] overflow-hidden">
<img
src={article.coverImage}
alt={article.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
)}
<div className="p-4">
<h3 className="mb-1.5 font-semibold text-amber-100 group-hover:text-amber-200 line-clamp-1">
{article.title}
</h3>
{article.summary && (
<p className="mb-2 line-clamp-2 text-sm text-gray-400">
{article.summary}
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Calendar className="h-3 w-3" />
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
</div>
</div>
</Link>
))}
</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">
<div className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
<div className="mb-6 flex items-center justify-between sm:mb-8">
<h2 className="text-2xl font-bold text-amber-100"></h2>
<Button
variant="outline"

View File

@@ -0,0 +1,33 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { ArticleEditor } from "@/components/admin/ArticleEditor";
export const dynamic = "force-dynamic";
export default async function EditArticlePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const article = await prisma.article.findUnique({ where: { id } });
if (!article) notFound();
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold"></h1>
<ArticleEditor
initial={{
id: article.id,
title: article.title,
slug: article.slug,
summary: article.summary,
content: article.content,
coverImage: article.coverImage,
published: article.published,
}}
/>
</div>
);
}

View File

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

View File

@@ -0,0 +1,73 @@
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Plus } from "lucide-react";
import { ArticleActions } from "@/components/admin/ArticleActions";
export const dynamic = "force-dynamic";
export default async function ArticlesPage() {
const articles = await prisma.article.findMany({
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/articles/new" />}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{articles.length === 0 ? (
<p className="py-10 text-center text-muted-foreground">
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{articles.map((article) => (
<TableRow key={article.id}>
<TableCell className="font-medium">{article.title}</TableCell>
<TableCell>
<Badge variant={article.published ? "default" : "secondary"}>
{article.published ? "已发布" : "草稿"}
</Badge>
</TableCell>
<TableCell>
{new Date(article.createdAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell>
{new Date(article.updatedAt).toLocaleDateString("zh-CN")}
</TableCell>
<TableCell className="text-right">
<ArticleActions id={article.id} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { prisma } from "@/lib/db";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MediaManager } from "@/components/admin/MediaManager";
export const dynamic = "force-dynamic";
export default async function MediaPage() {
const [banners, gallery] = await Promise.all([
prisma.bannerImage.findMany({ orderBy: { sortOrder: "asc" } }),
prisma.galleryImage.findMany({ orderBy: { sortOrder: "asc" } }),
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground">
Banner
</p>
</div>
<Tabs defaultValue="banner">
<TabsList>
<TabsTrigger value="banner">Banner </TabsTrigger>
<TabsTrigger value="gallery"></TabsTrigger>
</TabsList>
<TabsContent value="banner" className="mt-6">
<MediaManager type="banner" initial={JSON.parse(JSON.stringify(banners))} />
</TabsContent>
<TabsContent value="gallery" className="mt-6">
<MediaManager type="gallery" initial={JSON.parse(JSON.stringify(gallery))} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -6,39 +6,70 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Package, Download, FileUp } from "lucide-react";
import { Package, Download, FileUp, Eye, Users, TrendingUp } from "lucide-react";
export const dynamic = "force-dynamic";
function getLast7Days() {
const days: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
days.push(d.toISOString().slice(0, 10));
}
return days;
}
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 today = new Date().toISOString().slice(0, 10);
const days = getLast7Days();
const [
addonCount,
totalDownloads,
releaseCount,
recentReleases,
todayPV,
totalPV,
todayUV,
pvByDay,
] = 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 } } },
}),
prisma.pageView.count({ where: { date: today } }),
prisma.pageView.count(),
prisma.pageView.groupBy({
by: ["ip"],
where: { date: today, ip: { not: "" } },
}).then((r) => r.length),
prisma.pageView.groupBy({
by: ["date"],
where: { date: { in: days } },
_count: true,
orderBy: { date: "asc" },
}),
]);
const pvMap = new Map(pvByDay.map((d) => [d.date, d._count]));
const chartData = days.map((d) => ({
date: d.slice(5),
pv: pvMap.get(d) || 0,
}));
const maxPV = Math.max(...chartData.map((d) => d.pv), 1);
const stats = [
{
title: "插件总数",
value: addonCount,
icon: Package,
},
{
title: "总下载量",
value: totalDownloads._sum.totalDownloads || 0,
icon: Download,
},
{
title: "版本发布数",
value: releaseCount,
icon: FileUp,
},
{ title: "插件总数", value: addonCount, icon: Package },
{ title: "总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download },
{ title: "版本发布数", value: releaseCount, icon: FileUp },
{ title: "今日访问 (PV)", value: todayPV, icon: Eye },
{ title: "今日独立访客 (UV)", value: todayUV, icon: Users },
{ title: "累计访问量", value: totalPV, icon: TrendingUp },
];
return (
@@ -55,12 +86,42 @@ export default async function DashboardPage() {
<stat.icon className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stat.value}</div>
<div className="text-3xl font-bold">
{stat.value.toLocaleString()}
</div>
</CardContent>
</Card>
))}
</div>
{/* 7-day PV chart */}
<Card>
<CardHeader>
<CardTitle> 7 访</CardTitle>
<CardDescription> (PV)</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-48 items-end gap-2">
{chartData.map((d) => (
<div key={d.date} className="flex flex-1 flex-col items-center gap-1">
<span className="text-xs font-medium text-muted-foreground">
{d.pv}
</span>
<div
className="w-full rounded-t bg-primary/80 transition-all"
style={{
height: `${Math.max((d.pv / maxPV) * 160, 4)}px`,
}}
/>
<span className="text-[10px] text-muted-foreground">
{d.date}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>

View File

@@ -0,0 +1,75 @@
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 article = await prisma.article.findFirst({
where: { OR: [{ id }, { slug: id }] },
});
if (!article) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(article);
}
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();
if (body.slug) {
const existing = await prisma.article.findFirst({
where: { slug: body.slug, NOT: { id } },
});
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
}
const article = await prisma.article.update({
where: { id },
data: {
...(body.title !== undefined && { title: body.title }),
...(body.slug !== undefined && { slug: body.slug }),
...(body.summary !== undefined && { summary: body.summary }),
...(body.content !== undefined && { content: body.content }),
...(body.coverImage !== undefined && {
coverImage: body.coverImage || null,
}),
...(body.published !== undefined && { published: body.published }),
},
});
return NextResponse.json(article);
}
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.article.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,57 @@
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 publishedOnly = searchParams.get("published") !== "false";
const limit = searchParams.get("limit");
const where = publishedOnly ? { published: true } : {};
const articles = await prisma.article.findMany({
where,
orderBy: { createdAt: "desc" },
...(limit ? { take: parseInt(limit, 10) } : {}),
});
return NextResponse.json(articles);
}
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 { title, slug, summary, content, coverImage, published } = body;
if (!title || !slug) {
return NextResponse.json(
{ error: "title and slug are required" },
{ status: 400 }
);
}
const existing = await prisma.article.findUnique({ where: { slug } });
if (existing) {
return NextResponse.json(
{ error: "Slug already exists" },
{ status: 409 }
);
}
const article = await prisma.article.create({
data: {
title,
slug,
summary: summary || "",
content: content || "",
coverImage: coverImage || null,
published: published ?? false,
},
});
return NextResponse.json(article, { status: 201 });
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
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 data: Record<string, unknown> = {};
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
if (body.enabled !== undefined) data.enabled = body.enabled;
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
const updated = await prisma.bannerImage.update({ where: { id }, data });
return NextResponse.json(updated);
}
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.bannerImage.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
const where = enabledOnly ? { enabled: true } : {};
const banners = await prisma.bannerImage.findMany({
where,
orderBy: { sortOrder: "asc" },
});
return NextResponse.json(banners);
}
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 banner = await prisma.bannerImage.create({
data: {
imageUrl: body.imageUrl,
sortOrder: body.sortOrder ?? 0,
enabled: body.enabled ?? true,
},
});
return NextResponse.json(banner);
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
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 data: Record<string, unknown> = {};
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
if (body.enabled !== undefined) data.enabled = body.enabled;
if (body.title !== undefined) data.title = body.title;
if (body.imageUrl !== undefined) data.imageUrl = body.imageUrl;
const updated = await prisma.galleryImage.update({ where: { id }, data });
return NextResponse.json(updated);
}
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.galleryImage.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "1";
const where = enabledOnly ? { enabled: true } : {};
const images = await prisma.galleryImage.findMany({
where,
orderBy: { sortOrder: "asc" },
});
return NextResponse.json(images);
}
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 image = await prisma.galleryImage.create({
data: {
imageUrl: body.imageUrl,
title: body.title ?? "",
sortOrder: body.sortOrder ?? 0,
enabled: body.enabled ?? true,
},
});
return NextResponse.json(image);
}

36
src/app/api/seed/route.ts Normal file
View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function POST(request: NextRequest) {
const key = request.nextUrl.searchParams.get("key");
if (key !== "nanami-init-2024") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const bannerCount = await prisma.bannerImage.count();
const galleryCount = await prisma.galleryImage.count();
let bannersCreated = 0;
let galleryCreated = 0;
if (bannerCount === 0) {
const banners = [
{ imageUrl: "/banners/banner_2.png", sortOrder: 0 },
{ imageUrl: "/banners/banner_1.png", sortOrder: 1 },
{ imageUrl: "/banners/banner_3.png", sortOrder: 2 },
];
await prisma.bannerImage.createMany({ data: banners });
bannersCreated = banners.length;
}
if (galleryCount === 0) {
const gallery = Array.from({ length: 14 }, (_, i) => ({
imageUrl: `/views/view_${i + 1}.png`,
sortOrder: i,
}));
await prisma.galleryImage.createMany({ data: gallery });
galleryCreated = gallery.length;
}
return NextResponse.json({ bannersCreated, galleryCreated });
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
export async function GET() {
const now = new Date();
return NextResponse.json({
timestamp: now.getTime(),
iso: now.toISOString(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
}

View File

@@ -37,9 +37,15 @@ export async function GET(request: NextRequest) {
const hasUpdate = latest.versionCode > clientVersionCode;
const downloadUrl = latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl
: `${request.nextUrl.origin}/api/software/download/${latest.id}`;
const origin =
request.headers.get("x-forwarded-proto") && request.headers.get("host")
? `${request.headers.get("x-forwarded-proto")}://${request.headers.get("host")}`
: request.nextUrl.origin;
const downloadUrl =
latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl
: `${origin}/api/software/download/${latest.id}`;
return NextResponse.json({
hasUpdate,

View File

@@ -32,10 +32,19 @@ export async function GET(request: NextRequest) {
const latest = software.versions[0];
if (infoOnly) {
const track = searchParams.get("track") === "1";
if (track) {
await prisma.softwareVersion.update({
where: { id: latest.id },
data: { downloadCount: { increment: 1 } },
});
}
const downloadUrl =
latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl
: `/api/software/download/${latest.id}`;
return NextResponse.json({
available: true,
version: latest.version,

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const path = body.path || "/";
const referrer = body.referrer || "";
const ua = request.headers.get("user-agent") || "";
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"";
const date = new Date().toISOString().slice(0, 10);
await prisma.pageView.create({
data: { path, referrer, userAgent: ua, ip, date },
});
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: false }, { status: 500 });
}
}

View File

@@ -138,59 +138,275 @@
position: absolute;
left: 0;
right: 0;
height: 80px;
height: 100px;
z-index: 30;
pointer-events: none;
filter: blur(30px);
filter: blur(40px);
opacity: 0.7;
}
.hero-aurora--top {
top: -30px;
top: -40px;
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%,
rgba(168, 85, 247, 0.45) 12%,
rgba(59, 130, 246, 0.35) 28%,
rgba(236, 72, 153, 0.4) 45%,
rgba(245, 158, 11, 0.3) 60%,
rgba(139, 92, 246, 0.45) 75%,
rgba(6, 182, 212, 0.35) 88%,
transparent 100%
);
animation: auroraShift 8s ease-in-out infinite alternate;
animation: auroraShift 10s ease-in-out infinite alternate;
}
.hero-aurora--bottom {
bottom: -30px;
bottom: -40px;
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%,
rgba(245, 158, 11, 0.4) 12%,
rgba(168, 85, 247, 0.35) 30%,
rgba(6, 182, 212, 0.4) 48%,
rgba(236, 72, 153, 0.35) 65%,
rgba(139, 92, 246, 0.3) 80%,
rgba(245, 158, 11, 0.4) 92%,
transparent 100%
);
animation: auroraShift 8s ease-in-out infinite alternate-reverse;
animation: auroraShift 10s ease-in-out infinite alternate-reverse;
}
@keyframes auroraShift {
0% {
background-position: 0% 50%;
opacity: 0.5;
opacity: 0.45;
filter: blur(35px);
}
50% {
33% {
opacity: 0.8;
filter: blur(45px);
}
66% {
opacity: 0.6;
filter: blur(38px);
}
100% {
background-position: 100% 50%;
opacity: 0.5;
opacity: 0.45;
filter: blur(35px);
}
}
/* Force background-size for aurora animation */
.hero-aurora--top,
.hero-aurora--bottom {
background-size: 200% 100%;
}
/* ---- Hero Banner Effects ---- */
.hero-banner {
border-bottom: 1px solid rgba(245, 158, 11, 0.08);
}
.hero-scanlines {
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.015) 2px,
rgba(0, 0, 0, 0.015) 4px
);
mix-blend-mode: multiply;
}
.hero-tagline {
text-shadow: 0 0 20px rgba(245, 158, 11, 0.2);
animation: taglineFade 1.5s ease-out;
}
@keyframes taglineFade {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero-shimmer {
background: linear-gradient(
105deg,
transparent 40%,
rgba(255, 255, 255, 0.06) 45%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.06) 55%,
transparent 60%
);
background-size: 250% 100%;
animation: shimmerSweep 4s ease-in-out infinite;
}
@keyframes shimmerSweep {
0%, 100% {
background-position: 200% 0;
}
50% {
background-position: -50% 0;
}
}
.hero-download-btn::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
rgba(245, 158, 11, 0.3),
rgba(168, 85, 247, 0.15),
rgba(245, 158, 11, 0.3)
);
background-size: 200% 200%;
animation: borderGlow 4s ease-in-out infinite;
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
@keyframes borderGlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.hero-indicator {
backdrop-filter: blur(4px);
}
@media (prefers-reduced-motion: reduce) {
.hero-shimmer,
.hero-tagline {
animation: none;
}
.hero-download-btn::before {
animation: none;
}
.hero-scanlines {
display: none;
}
}
/* Download button shimmer */
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* ---- Gallery Thumbnail Scrollbar ---- */
.gallery-thumbs {
scrollbar-width: thin;
scrollbar-color: rgba(251, 191, 36, 0.25) transparent;
}
.gallery-thumbs::-webkit-scrollbar {
height: 6px;
}
.gallery-thumbs::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
border-radius: 3px;
}
.gallery-thumbs::-webkit-scrollbar-thumb {
background: rgba(251, 191, 36, 0.2);
border-radius: 3px;
transition: background 0.2s;
}
.gallery-thumbs::-webkit-scrollbar-thumb:hover {
background: rgba(251, 191, 36, 0.45);
}
/* ---- Article Markdown Prose ---- */
.prose-article {
color: #d1d5db;
line-height: 1.8;
font-size: 15px;
}
.prose-article h1,
.prose-article h2,
.prose-article h3,
.prose-article h4 {
color: #fef3c7;
font-weight: 700;
margin-top: 1.6em;
margin-bottom: 0.6em;
}
.prose-article h1 { font-size: 1.75em; }
.prose-article h2 { font-size: 1.4em; border-bottom: 1px solid rgba(251,191,36,0.15); padding-bottom: 0.3em; }
.prose-article h3 { font-size: 1.15em; }
.prose-article p { margin-bottom: 1em; }
.prose-article a {
color: #fbbf24;
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.15s;
}
.prose-article a:hover { color: #fde68a; }
.prose-article strong { color: #fef3c7; }
.prose-article blockquote {
border-left: 3px solid rgba(251,191,36,0.3);
padding: 0.5em 1em;
margin: 1em 0;
color: #9ca3af;
background: rgba(255,255,255,0.02);
border-radius: 0 6px 6px 0;
}
.prose-article ul, .prose-article ol { padding-left: 1.5em; margin-bottom: 1em; }
.prose-article li { margin-bottom: 0.3em; }
.prose-article ul { list-style-type: disc; }
.prose-article ol { list-style-type: decimal; }
.prose-article code {
background: rgba(255,255,255,0.06);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
color: #fbbf24;
}
.prose-article pre {
background: rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 8px;
padding: 1em;
overflow-x: auto;
margin-bottom: 1em;
}
.prose-article pre code {
background: none;
padding: 0;
color: #d1d5db;
}
.prose-article img {
max-width: 100%;
border-radius: 8px;
margin: 1em 0;
}
.prose-article table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1em;
}
.prose-article th, .prose-article td {
border: 1px solid rgba(255,255,255,0.1);
padding: 0.5em 0.75em;
text-align: left;
}
.prose-article th {
background: rgba(255,255,255,0.04);
color: #fef3c7;
font-weight: 600;
}
.prose-article hr {
border: none;
border-top: 1px solid rgba(251,191,36,0.15);
margin: 2em 0;
}

View File

@@ -0,0 +1,49 @@
import { NextRequest } from "next/server";
import { readFile, stat } from "fs/promises";
import path from "path";
const MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".zip": "application/zip",
".exe": "application/octet-stream",
};
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const segments = await params;
const filePath = path.join(process.cwd(), "uploads", ...segments.path);
const resolved = path.resolve(filePath);
const uploadsDir = path.resolve(path.join(process.cwd(), "uploads"));
if (!resolved.startsWith(uploadsDir)) {
return new Response("Forbidden", { status: 403 });
}
try {
const info = await stat(resolved);
if (!info.isFile()) {
return new Response("Not Found", { status: 404 });
}
const buffer = await readFile(resolved);
const ext = path.extname(resolved).toLowerCase();
const contentType = MIME[ext] || "application/octet-stream";
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch {
return new Response("Not Found", { status: 404 });
}
}