更新首页部分内容和其他后台相关功能

This commit is contained in:
rucky
2026-03-25 18:17:28 +08:00
parent bf92a69332
commit 83f3c67dbd
26 changed files with 1121 additions and 334 deletions

View File

@@ -28,6 +28,9 @@ model Addon {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
releases Release[] releases Release[]
screenshots Screenshot[] screenshots Screenshot[]
@@index([published, totalDownloads])
@@index([category])
} }
model Release { model Release {
@@ -115,6 +118,8 @@ model Article {
published Boolean @default(false) published Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([published, createdAt])
} }
model PageView { model PageView {

View File

@@ -1,16 +1,10 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge"; import { Download, Package, Calendar, Tag, ArrowLeft } from "lucide-react";
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"; import { DownloadButton } from "@/components/public/DownloadButton";
import { MarkdownContent } from "@/components/public/MarkdownContent";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -29,7 +23,7 @@ export async function generateMetadata({
}; };
} }
export const dynamic = "force-dynamic"; export const revalidate = 120;
export default async function AddonDetailPage({ export default async function AddonDetailPage({
params, params,
@@ -49,32 +43,56 @@ export default async function AddonDetailPage({
const latestRelease = addon.releases.find((r) => r.isLatest); const latestRelease = addon.releases.find((r) => r.isLatest);
const categoryLabels: Record<string, string> = {
general: "通用", gameplay: "游戏玩法", ui: "界面增强",
combat: "战斗", raid: "团队副本", pvp: "PvP",
tradeskill: "专业技能", utility: "实用工具",
};
return ( return (
<div className="mx-auto max-w-6xl px-4 py-12"> <section className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
<Link
href="/addons"
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>
{/* Header */} {/* Header */}
<div className="flex flex-col gap-6 sm:flex-row sm:items-start"> <div className="flex flex-col gap-5 rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:flex-row sm:items-start sm:p-6">
{addon.iconUrl ? ( {addon.iconUrl ? (
<img <div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl ring-1 ring-amber-500/20 sm:h-20 sm:w-20">
src={addon.iconUrl} <Image
alt={addon.name} src={addon.iconUrl}
className="h-20 w-20 rounded-2xl object-cover" alt={addon.name}
/> fill
className="object-cover"
sizes="80px"
/>
</div>
) : ( ) : (
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-primary/10"> <div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 ring-1 ring-amber-500/20 sm:h-20 sm:w-20">
<Package className="h-10 w-10 text-primary" /> <Package className="h-8 w-8 text-amber-400 sm:h-10 sm:w-10" />
</div> </div>
)} )}
<div className="flex-1"> <div className="flex-1">
<h1 className="text-3xl font-bold">{addon.name}</h1> <h1 className="text-2xl font-bold text-amber-100 sm:text-3xl">
<p className="mt-2 text-lg text-muted-foreground">{addon.summary}</p> {addon.name}
</h1>
<p className="mt-2 text-sm text-gray-400 sm:text-base">
{addon.summary}
</p>
<div className="mt-3 flex flex-wrap items-center gap-3"> <div className="mt-3 flex flex-wrap items-center gap-3">
<Badge>{addon.category}</Badge> <span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-300/80">
<span className="flex items-center gap-1 text-sm text-muted-foreground"> {categoryLabels[addon.category] || addon.category}
</span>
<span className="flex items-center gap-1 text-sm text-gray-500">
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
{addon.totalDownloads.toLocaleString()} {addon.totalDownloads.toLocaleString()}
</span> </span>
{latestRelease && ( {latestRelease && (
<span className="flex items-center gap-1 text-sm text-muted-foreground"> <span className="flex items-center gap-1 text-sm text-gray-500">
<Tag className="h-3.5 w-3.5" /> <Tag className="h-3.5 w-3.5" />
v{latestRelease.version} v{latestRelease.version}
</span> </span>
@@ -92,66 +110,61 @@ export default async function AddonDetailPage({
)} )}
</div> </div>
<Separator className="my-8" /> <div className="mt-6 border-t border-amber-500/10" />
<div className="grid gap-8 lg:grid-cols-3"> <div className="mt-6 grid gap-6 lg:grid-cols-3 sm:mt-8">
{/* Description */} {/* Description */}
<div className="lg:col-span-2"> <div className="lg:col-span-2 space-y-6">
<Card> <div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
<CardHeader> <h2 className="mb-4 text-lg font-semibold text-amber-100"></h2>
<CardTitle></CardTitle> <MarkdownContent content={addon.description} />
</CardHeader> </div>
<CardContent>
<div className="prose prose-neutral max-w-none dark:prose-invert">
<MarkdownContent content={addon.description} />
</div>
</CardContent>
</Card>
{/* Screenshots */} {/* Screenshots */}
{addon.screenshots.length > 0 && ( {addon.screenshots.length > 0 && (
<Card className="mt-6"> <div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
<CardHeader> <h2 className="mb-4 text-lg font-semibold text-amber-100"></h2>
<CardTitle></CardTitle> <div className="grid gap-3 sm:grid-cols-2">
</CardHeader> {addon.screenshots.map((ss) => (
<CardContent> <div key={ss.id} className="overflow-hidden rounded-lg ring-1 ring-amber-500/10">
<div className="grid gap-4 sm:grid-cols-2"> <Image
{addon.screenshots.map((ss) => (
<img
key={ss.id}
src={ss.imageUrl} src={ss.imageUrl}
alt="Screenshot" alt="Screenshot"
className="rounded-lg border object-cover" width={600}
height={340}
className="w-full object-cover transition-transform duration-300 hover:scale-105"
/> />
))} </div>
</div> ))}
</CardContent> </div>
</Card> </div>
)} )}
</div> </div>
{/* Sidebar - Releases */} {/* Sidebar - Releases */}
<div> <div>
<Card> <div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
<CardHeader> <h2 className="mb-1 text-lg font-semibold text-amber-100">
<CardTitle></CardTitle>
<CardDescription> </h2>
{addon.releases.length} <p className="mb-4 text-sm text-gray-500">
</CardDescription> {addon.releases.length}
</CardHeader> </p>
<CardContent className="space-y-4"> <div className="space-y-3">
{addon.releases.map((release) => ( {addon.releases.map((release) => (
<div <div
key={release.id} key={release.id}
className="rounded-lg border p-4 transition-colors hover:bg-muted/50" className="rounded-lg border border-amber-500/10 bg-white/[0.02] p-4 transition-colors hover:border-amber-500/20 hover:bg-white/[0.04]"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold">v{release.version}</span> <span className="font-semibold text-amber-100">
v{release.version}
</span>
{release.isLatest && ( {release.isLatest && (
<Badge variant="default" className="text-xs"> <span className="rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-300">
</Badge> </span>
)} )}
</div> </div>
<DownloadButton <DownloadButton
@@ -161,11 +174,11 @@ export default async function AddonDetailPage({
/> />
</div> </div>
{release.gameVersion && ( {release.gameVersion && (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-gray-500">
WoW {release.gameVersion} WoW {release.gameVersion}
</p> </p>
)} )}
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground"> <div className="mt-2 flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{new Date(release.createdAt).toLocaleDateString("zh-CN")} {new Date(release.createdAt).toLocaleDateString("zh-CN")}
@@ -176,34 +189,19 @@ export default async function AddonDetailPage({
</span> </span>
</div> </div>
{release.changelog && ( {release.changelog && (
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-line"> <p className="mt-2 whitespace-pre-line text-sm text-gray-400">
{release.changelog} {release.changelog}
</p> </p>
)} )}
</div> </div>
))} ))}
{addon.releases.length === 0 && ( {addon.releases.length === 0 && (
<p className="text-sm text-muted-foreground"></p> <p className="text-sm text-gray-500"></p>
)} )}
</CardContent> </div>
</Card> </div>
</div> </div>
</div> </div>
</div> </section>
); );
} }
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,36 @@
export default function AddonsLoading() {
return (
<section className="mx-auto max-w-6xl animate-pulse px-3 py-10 sm:px-4 sm:py-16">
<div className="mb-2 h-8 w-40 rounded bg-white/[0.06]" />
<div className="mb-8 h-4 w-64 rounded bg-white/[0.04]" />
<div className="mb-8 flex gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-7 w-16 rounded-lg bg-white/[0.04]" />
))}
</div>
<div className="grid gap-4 sm:gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-amber-500/5 bg-white/[0.02] p-5"
>
<div className="flex gap-3">
<div className="h-12 w-12 rounded-lg bg-white/[0.06]" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded bg-white/[0.06]" />
<div className="h-3 w-1/3 rounded bg-white/[0.04]" />
</div>
</div>
<div className="mt-3 space-y-2">
<div className="h-3 w-full rounded bg-white/[0.04]" />
<div className="h-3 w-2/3 rounded bg-white/[0.04]" />
</div>
<div className="mt-3 h-3 w-24 rounded bg-white/[0.03]" />
</div>
))}
</div>
</section>
);
}

View File

@@ -1,7 +1,7 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { AddonCard } from "@/components/public/AddonCard"; import { AddonCard } from "@/components/public/AddonCard";
import { Badge } from "@/components/ui/badge";
import Link from "next/link"; import Link from "next/link";
import { Package, Search, ChevronLeft, ChevronRight } from "lucide-react";
const categoryLabels: Record<string, string> = { const categoryLabels: Record<string, string> = {
general: "通用", general: "通用",
@@ -16,16 +16,20 @@ const categoryLabels: Record<string, string> = {
export const metadata = { export const metadata = {
title: "插件列表 - Nanami", title: "插件列表 - Nanami",
description: "浏览和下载 Turtle WoW 插件",
}; };
export const dynamic = "force-dynamic"; export const revalidate = 30;
const PAGE_SIZE = 12;
export default async function AddonsPage({ export default async function AddonsPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ category?: string; search?: string }>; searchParams: Promise<{ category?: string; search?: string; page?: string }>;
}) { }) {
const { category, search } = await searchParams; const { category, search, page: pageStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
const where: Record<string, unknown> = { published: true }; const where: Record<string, unknown> = { published: true };
if (category) where.category = category; if (category) where.category = category;
@@ -36,66 +40,144 @@ export default async function AddonsPage({
]; ];
} }
const addons = await prisma.addon.findMany({ const [addons, total, categories] = await Promise.all([
where, prisma.addon.findMany({
include: { where,
releases: { include: {
where: { isLatest: true }, releases: {
select: { version: true }, where: { isLatest: true },
select: { version: true },
},
}, },
}, orderBy: { totalDownloads: "desc" },
orderBy: { totalDownloads: "desc" }, skip: (page - 1) * PAGE_SIZE,
}); take: PAGE_SIZE,
}),
prisma.addon.count({ where }),
prisma.addon.groupBy({
by: ["category"],
where: { published: true },
_count: { id: true },
}),
]);
const categories = await prisma.addon.groupBy({ const totalPages = Math.ceil(total / PAGE_SIZE);
by: ["category"],
where: { published: true }, const buildHref = (p: number) => {
_count: { id: true }, const params = new URLSearchParams();
}); if (category) params.set("category", category);
if (search) params.set("search", search);
if (p > 1) params.set("page", String(p));
const qs = params.toString();
return `/addons${qs ? `?${qs}` : ""}`;
};
return ( return (
<div className="mx-auto max-w-6xl px-4 py-12"> <section className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
<h1 className="text-3xl font-bold"></h1> <div className="mb-2 flex items-center gap-2 sm:gap-3">
<p className="mt-2 text-muted-foreground"> <Package className="h-5 w-5 text-amber-400 sm:h-6 sm:w-6" />
<h1 className="text-2xl font-bold text-amber-100 sm:text-3xl">
</h1>
</div>
<p className="mb-6 text-sm text-gray-400 sm:mb-10 sm:text-base">
World of Warcraft World of Warcraft
</p> </p>
{/* Category Filter */} {/* Category Filter */}
<div className="mt-6 flex flex-wrap gap-2"> <div className="mb-6 flex flex-wrap gap-2 sm:mb-8">
<Link href="/addons"> <Link
<Badge href="/addons"
variant={!category ? "default" : "outline"} className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all sm:text-sm ${
className="cursor-pointer" !category
> ? "bg-amber-500/20 text-amber-200 ring-1 ring-amber-500/30"
: "bg-white/[0.04] text-gray-400 hover:bg-white/[0.08] hover:text-gray-300"
</Badge> }`}
>
</Link> </Link>
{categories.map((cat) => ( {categories.map((cat) => (
<Link key={cat.category} href={`/addons?category=${cat.category}`}> <Link
<Badge key={cat.category}
variant={category === cat.category ? "default" : "outline"} href={`/addons?category=${cat.category}`}
className="cursor-pointer" className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all sm:text-sm ${
> category === cat.category
{categoryLabels[cat.category] || cat.category} ({cat._count.id}) ? "bg-amber-500/20 text-amber-200 ring-1 ring-amber-500/30"
</Badge> : "bg-white/[0.04] text-gray-400 hover:bg-white/[0.08] hover:text-gray-300"
}`}
>
{categoryLabels[cat.category] || cat.category} ({cat._count.id})
</Link> </Link>
))} ))}
</div> </div>
{/* Addon Grid */} {/* Addon Grid */}
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> {addons.length > 0 ? (
{addons.map((addon) => ( <>
<AddonCard key={addon.id} addon={addon} /> <div className="grid gap-4 sm:gap-6 sm:grid-cols-2 lg:grid-cols-3">
))} {addons.map((addon) => (
</div> <AddonCard key={addon.id} addon={addon} />
))}
</div>
{addons.length === 0 && ( {/* Pagination */}
<div className="mt-16 text-center"> {totalPages > 1 && (
<p className="text-lg text-muted-foreground"> <div className="mt-10 flex items-center justify-center gap-2">
{page > 1 ? (
<Link
href={buildHref(page - 1)}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-amber-500/15 bg-white/[0.03] text-gray-400 transition-colors hover:border-amber-500/30 hover:text-amber-200"
>
<ChevronLeft className="h-4 w-4" />
</Link>
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-white/5 text-gray-600">
<ChevronLeft className="h-4 w-4" />
</span>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
href={buildHref(p)}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-colors ${
p === page
? "bg-amber-500/20 text-amber-200 ring-1 ring-amber-500/30"
: "border border-amber-500/10 bg-white/[0.03] text-gray-400 hover:border-amber-500/25 hover:text-amber-200"
}`}
>
{p}
</Link>
))}
{page < totalPages ? (
<Link
href={buildHref(page + 1)}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-amber-500/15 bg-white/[0.03] text-gray-400 transition-colors hover:border-amber-500/30 hover:text-amber-200"
>
<ChevronRight className="h-4 w-4" />
</Link>
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-white/5 text-gray-600">
<ChevronRight className="h-4 w-4" />
</span>
)}
</div>
)}
</>
) : (
<div className="flex flex-col items-center py-20">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-500/10">
<Search className="h-7 w-7 text-amber-400/60" />
</div>
<p className="text-lg font-medium text-gray-400">
{search ? `没有找到"${search}"相关的插件` : "暂无插件"} {search ? `没有找到"${search}"相关的插件` : "暂无插件"}
</p> </p>
<p className="mt-1 text-sm text-gray-500">
{search ? "尝试更换关键词搜索" : "稍后再来查看吧"}
</p>
</div> </div>
)} )}
</div> </section>
); );
} }

View File

@@ -1,10 +1,33 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { Calendar, ArrowLeft } from "lucide-react"; import { Calendar, ArrowLeft } from "lucide-react";
import { MarkdownContent } from "@/components/public/MarkdownContent"; import { MarkdownContent } from "@/components/public/MarkdownContent";
export const dynamic = "force-dynamic"; export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await prisma.article.findUnique({
where: { slug, published: true },
select: { title: true, summary: true, coverImage: true },
});
if (!article) return { title: "文章未找到" };
return {
title: article.title,
description: article.summary || undefined,
openGraph: {
title: article.title,
description: article.summary || undefined,
images: article.coverImage ? [{ url: article.coverImage }] : undefined,
},
};
}
export const revalidate = 300;
export default async function ArticleDetailPage({ export default async function ArticleDetailPage({
params, params,
@@ -42,11 +65,14 @@ export default async function ArticleDetailPage({
</div> </div>
{article.coverImage && ( {article.coverImage && (
<div className="mb-8 overflow-hidden rounded-lg"> <div className="relative mb-8 aspect-[16/9] overflow-hidden rounded-lg">
<img <Image
src={article.coverImage} src={article.coverImage}
alt={article.title} alt={article.title}
className="w-full object-cover" fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 750px"
priority
/> />
</div> </div>
)} )}

View File

@@ -0,0 +1,24 @@
export default function ArticlesLoading() {
return (
<section className="mx-auto max-w-4xl animate-pulse px-3 py-10 sm:px-4 sm:py-16">
<div className="mb-2 h-8 w-48 rounded bg-white/[0.06]" />
<div className="mb-10 h-4 w-40 rounded bg-white/[0.04]" />
<div className="grid gap-6 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="overflow-hidden rounded-lg border border-amber-500/5 bg-white/[0.02]"
>
<div className="aspect-[16/9] bg-white/[0.04]" />
<div className="p-4 space-y-2">
<div className="h-5 w-3/4 rounded bg-white/[0.06]" />
<div className="h-3 w-full rounded bg-white/[0.04]" />
<div className="h-3 w-1/3 rounded bg-white/[0.03]" />
</div>
</div>
))}
</div>
</section>
);
}

View File

@@ -1,14 +1,36 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { Calendar } from "lucide-react"; import { Calendar, ChevronLeft, ChevronRight } from "lucide-react";
export const dynamic = "force-dynamic"; export const metadata = {
title: "公告与文章",
description: "Nanami 最新动态、更新公告与使用教程",
};
export default async function ArticlesPage() { export const revalidate = 30;
const articles = await prisma.article.findMany({
where: { published: true }, const PAGE_SIZE = 10;
orderBy: { createdAt: "desc" },
}); export default async function ArticlesPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
const [articles, total] = await Promise.all([
prisma.article.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
prisma.article.count({ where: { published: true } }),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
return ( return (
<section className="mx-auto max-w-4xl px-3 py-10 sm:px-4 sm:py-16"> <section className="mx-auto max-w-4xl px-3 py-10 sm:px-4 sm:py-16">
@@ -22,39 +44,88 @@ export default async function ArticlesPage() {
{articles.length === 0 ? ( {articles.length === 0 ? (
<p className="py-16 text-center text-gray-500"></p> <p className="py-16 text-center text-gray-500"></p>
) : ( ) : (
<div className="grid gap-6 sm:grid-cols-2"> <>
{articles.map((article) => ( <div className="grid gap-6 sm:grid-cols-2">
<Link {articles.map((article) => (
key={article.id} <Link
href={`/articles/${article.slug}`} key={article.id}
className="group overflow-hidden rounded-lg border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25" 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"> {article.coverImage && (
<img <div className="relative aspect-[16/9] overflow-hidden">
src={article.coverImage} <Image
alt={article.title} src={article.coverImage}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" alt={article.title}
/> fill
</div> className="object-cover transition-transform duration-300 group-hover:scale-105"
)} sizes="(max-width: 640px) 100vw, 50vw"
<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"> </div>
{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"> <div className="p-4 sm:p-5">
<Calendar className="h-3 w-3" /> <h2 className="mb-2 text-lg font-semibold text-amber-100 group-hover:text-amber-200 sm:text-xl">
{new Date(article.createdAt).toLocaleDateString("zh-CN")} {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> </div>
</div> </Link>
</Link> ))}
))} </div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-10 flex items-center justify-center gap-2">
{page > 1 ? (
<Link
href={`/articles?page=${page - 1}`}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-amber-500/15 bg-white/[0.03] text-gray-400 transition-colors hover:border-amber-500/30 hover:text-amber-200"
>
<ChevronLeft className="h-4 w-4" />
</Link>
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-white/5 text-gray-600">
<ChevronLeft className="h-4 w-4" />
</span>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
href={`/articles?page=${p}`}
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-colors ${
p === page
? "bg-amber-500/20 text-amber-200 ring-1 ring-amber-500/30"
: "border border-amber-500/10 bg-white/[0.03] text-gray-400 hover:border-amber-500/25 hover:text-amber-200"
}`}
>
{p}
</Link>
))}
{page < totalPages ? (
<Link
href={`/articles?page=${page + 1}`}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-amber-500/15 bg-white/[0.03] text-gray-400 transition-colors hover:border-amber-500/30 hover:text-amber-200"
>
<ChevronRight className="h-4 w-4" />
</Link>
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-white/5 text-gray-600">
<ChevronRight className="h-4 w-4" />
</span>
)}
</div>
)}
</>
)} )}
</section> </section>
); );

View File

@@ -0,0 +1,32 @@
export default function ChangelogLoading() {
return (
<section className="mx-auto max-w-3xl animate-pulse px-3 py-10 sm:px-4 sm:py-16">
<div className="mb-2 h-8 w-36 rounded bg-white/[0.06]" />
<div className="mb-10 h-4 w-56 rounded bg-white/[0.04]" />
<div className="relative">
<div className="absolute left-[15px] top-2 bottom-0 w-px bg-amber-500/10 sm:left-[19px]" />
<div className="space-y-8">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="relative pl-10 sm:pl-12">
<div className="absolute left-[10px] top-1.5 h-3 w-3 rounded-full bg-white/[0.08] sm:left-[13px]" />
<div className="rounded-lg border border-amber-500/5 bg-white/[0.02] p-4">
<div className="mb-3 h-6 w-24 rounded bg-white/[0.06]" />
<div className="mb-3 flex gap-4">
<div className="h-3 w-20 rounded bg-white/[0.04]" />
<div className="h-3 w-16 rounded bg-white/[0.04]" />
<div className="h-3 w-24 rounded bg-white/[0.04]" />
</div>
<div className="space-y-2">
<div className="h-3 w-full rounded bg-white/[0.04]" />
<div className="h-3 w-5/6 rounded bg-white/[0.04]" />
<div className="h-3 w-2/3 rounded bg-white/[0.04]" />
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { Download, Calendar, Tag } from "lucide-react"; import { Download, Calendar, Tag } from "lucide-react";
export const dynamic = "force-dynamic"; export const metadata = {
title: "版本历史",
description: "Nanami 启动器版本更新日志",
};
export const revalidate = 120;
export default async function ChangelogPage() { export default async function ChangelogPage() {
const software = await prisma.software.findUnique({ const software = await prisma.software.findUnique({

View File

@@ -0,0 +1,47 @@
"use client";
import { useEffect } from "react";
import { AlertTriangle, RotateCcw, Home } from "lucide-react";
import Link from "next/link";
export default function PublicError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 py-20">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-500/10 sm:h-20 sm:w-20">
<AlertTriangle className="h-8 w-8 text-red-400/70 sm:h-10 sm:w-10" />
</div>
<h1 className="mb-2 text-xl font-bold text-amber-100 sm:text-2xl">
</h1>
<p className="mb-8 max-w-md text-center text-sm text-gray-400 sm:text-base">
</p>
<div className="flex gap-3">
<button
onClick={reset}
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-5 py-2.5 text-sm font-medium text-amber-200 transition-colors hover:border-amber-500/50 hover:bg-amber-500/15"
>
<RotateCcw className="h-4 w-4" />
</button>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-5 py-2.5 text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-gray-300"
>
<Home className="h-4 w-4" />
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
export default function PublicLoading() {
return (
<div className="animate-pulse">
{/* Hero skeleton */}
<div className="relative bg-[#0a0912]">
<div className="h-[300px] bg-white/[0.03] sm:h-[500px]" />
</div>
{/* Content skeleton */}
<div className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
<div className="mb-8 h-8 w-48 rounded bg-white/[0.06]" />
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-amber-500/5 bg-white/[0.02] p-5"
>
<div className="flex gap-3">
<div className="h-12 w-12 rounded-lg bg-white/[0.06]" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded bg-white/[0.06]" />
<div className="h-3 w-1/3 rounded bg-white/[0.04]" />
</div>
</div>
<div className="mt-4 space-y-2">
<div className="h-3 w-full rounded bg-white/[0.04]" />
<div className="h-3 w-2/3 rounded bg-white/[0.04]" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import Link from "next/link";
import { Home, Search } from "lucide-react";
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 py-20">
<div className="relative mb-6">
<span className="text-8xl font-black text-amber-500/10 sm:text-9xl">
404
</span>
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-12 w-12 text-amber-400/40 sm:h-16 sm:w-16" />
</div>
</div>
<h1 className="mb-2 text-xl font-bold text-amber-100 sm:text-2xl">
</h1>
<p className="mb-8 max-w-md text-center text-sm text-gray-400 sm:text-base">
访
</p>
<div className="flex gap-3">
<Link
href="/"
className="inline-flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-5 py-2.5 text-sm font-medium text-amber-200 transition-colors hover:border-amber-500/50 hover:bg-amber-500/15"
>
<Home className="h-4 w-4" />
</Link>
<Link
href="/addons"
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-5 py-2.5 text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-gray-300"
>
</Link>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AddonCard } from "@/components/public/AddonCard"; import { AddonCard } from "@/components/public/AddonCard";
@@ -6,7 +7,7 @@ import { HeroBanner } from "@/components/public/HeroBanner";
import { GameGallery } from "@/components/public/GameGallery"; import { GameGallery } from "@/components/public/GameGallery";
import { Sparkles, Shield, Zap, Calendar } from "lucide-react"; import { Sparkles, Shield, Zap, Calendar } from "lucide-react";
export const dynamic = "force-dynamic"; export const revalidate = 60;
export default async function HomePage() { export default async function HomePage() {
const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] = const [featuredAddons, launcher, launcherDownloads, banners, galleryImages, latestArticles] =
@@ -127,11 +128,13 @@ export default async function HomePage() {
className="group overflow-hidden rounded-xl border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25" className="group overflow-hidden rounded-xl border border-amber-500/10 bg-white/[0.03] transition-colors hover:border-amber-500/25"
> >
{article.coverImage && ( {article.coverImage && (
<div className="aspect-[16/9] overflow-hidden"> <div className="relative aspect-[16/9] overflow-hidden">
<img <Image
src={article.coverImage} src={article.coverImage}
alt={article.title} alt={article.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, 33vw"
/> />
</div> </div>
)} )}

View File

@@ -0,0 +1,7 @@
export default function PublicTemplate({
children,
}: {
children: React.ReactNode;
}) {
return <div className="page-enter">{children}</div>;
}

View File

@@ -37,15 +37,12 @@ export async function GET(request: NextRequest) {
const hasUpdate = latest.versionCode > clientVersionCode; const hasUpdate = latest.versionCode > clientVersionCode;
const origin = const origin = process.env.API_BASE_URL || process.env.NEXTAUTH_URL || request.nextUrl.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 = const downloadUrl =
latest.downloadType === "url" && latest.externalUrl latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl ? latest.externalUrl
: `${origin}/api/software/download/${latest.id}`; : `${origin.replace(/\/$/, "")}/api/software/download/${latest.id}`;
return NextResponse.json({ return NextResponse.json({
hasUpdate, hasUpdate,

View File

@@ -286,7 +286,26 @@
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
/* ---- Page Transition ---- */
.page-enter {
animation: pageEnter 0.3s ease-out;
}
@keyframes pageEnter {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.page-enter {
animation: none;
}
.hero-shimmer, .hero-shimmer,
.hero-tagline { .hero-tagline {
animation: none; animation: none;

View File

@@ -4,8 +4,22 @@ import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Nanami - WoW Addons", title: {
description: "World of Warcraft 插件发布与下载平台", default: "Nanami - Turtle WoW 插件平台",
template: "%s | Nanami",
},
description: "Turtle WoW 一站式插件管理平台,轻松安装、更新、管理你的游戏插件",
keywords: ["Turtle WoW", "WoW 插件", "Nanami", "魔兽世界", "插件管理", "乌龟服"],
openGraph: {
title: "Nanami - Turtle WoW 插件平台",
description: "Turtle WoW 一站式插件管理平台",
type: "website",
locale: "zh_CN",
},
robots: {
index: true,
follow: true,
},
}; };
export default function RootLayout({ export default function RootLayout({

25
src/app/manifest.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Nanami - Turtle WoW 插件平台",
short_name: "Nanami",
description: "Turtle WoW 一站式插件管理平台",
start_url: "/",
display: "standalone",
background_color: "#0a0912",
theme_color: "#f59e0b",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
},
],
};
}

16
src/app/robots.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXTAUTH_URL || "https://nanami.rucky.cn";
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/api/"],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

40
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { MetadataRoute } from "next";
import { prisma } from "@/lib/db";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXTAUTH_URL || "https://nanami.rucky.cn";
const [articles, addons] = await Promise.all([
prisma.article.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
}),
prisma.addon.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
}),
]);
const staticPages: MetadataRoute.Sitemap = [
{ url: baseUrl, lastModified: new Date(), changeFrequency: "daily", priority: 1.0 },
{ url: `${baseUrl}/addons`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 },
{ url: `${baseUrl}/articles`, lastModified: new Date(), changeFrequency: "daily", priority: 0.8 },
{ url: `${baseUrl}/changelog`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.7 },
];
const articlePages: MetadataRoute.Sitemap = articles.map((a) => ({
url: `${baseUrl}/articles/${a.slug}`,
lastModified: a.updatedAt,
changeFrequency: "weekly" as const,
priority: 0.6,
}));
const addonPages: MetadataRoute.Sitemap = addons.map((a) => ({
url: `${baseUrl}/addons/${a.slug}`,
lastModified: a.updatedAt,
changeFrequency: "weekly" as const,
priority: 0.7,
}));
return [...staticPages, ...addonPages, ...articlePages];
}

View File

@@ -1,8 +1,17 @@
"use client"; "use client";
import { useState, useRef } from "react"; import { useState, useRef, useCallback } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Trash2, Upload, Loader2 } from "lucide-react"; import {
Trash2,
Upload,
Loader2,
GripVertical,
X,
Eye,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -24,6 +33,9 @@ interface Props {
export function MediaManager({ type, initial }: Props) { export function MediaManager({ type, initial }: Props) {
const [items, setItems] = useState<MediaItem[]>(initial); const [items, setItems] = useState<MediaItem[]>(initial);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [previewIdx, setPreviewIdx] = useState<number | null>(null);
const [dragId, setDragId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const endpoint = type === "banner" ? "/api/banners" : "/api/gallery"; const endpoint = type === "banner" ? "/api/banners" : "/api/gallery";
@@ -87,8 +99,70 @@ export function MediaManager({ type, initial }: Props) {
} }
}; };
const persistSortOrder = useCallback(
async (reordered: MediaItem[]) => {
try {
await Promise.all(
reordered.map((item, idx) =>
fetch(`${endpoint}/${item.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sortOrder: idx }),
})
)
);
} catch {
toast.error("排序保存失败");
}
},
[endpoint]
);
const handleDragStart = (e: React.DragEvent, id: string) => {
setDragId(id);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragId && dragId !== id) {
setDragOverId(id);
}
};
const handleDrop = (e: React.DragEvent, targetId: string) => {
e.preventDefault();
if (!dragId || dragId === targetId) {
setDragId(null);
setDragOverId(null);
return;
}
const fromIdx = items.findIndex((it) => it.id === dragId);
const toIdx = items.findIndex((it) => it.id === targetId);
if (fromIdx === -1 || toIdx === -1) return;
const newItems = [...items];
const [moved] = newItems.splice(fromIdx, 1);
newItems.splice(toIdx, 0, moved);
const updated = newItems.map((item, i) => ({ ...item, sortOrder: i }));
setItems(updated);
setDragId(null);
setDragOverId(null);
persistSortOrder(updated);
toast.success("排序已更新");
};
const handleDragEnd = () => {
setDragId(null);
setDragOverId(null);
};
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
@@ -111,7 +185,7 @@ export function MediaManager({ type, initial }: Props) {
onChange={handleUpload} onChange={handleUpload}
/> />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{items.length} {items.length} ·
</span> </span>
</div> </div>
@@ -120,95 +194,164 @@ export function MediaManager({ type, initial }: Props) {
</p> </p>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="space-y-1">
{items.map((item) => ( {items.map((item, idx) => (
<div <div
key={item.id} key={item.id}
className="overflow-hidden rounded-lg border bg-card" draggable
onDragStart={(e) => handleDragStart(e, item.id)}
onDragOver={(e) => handleDragOver(e, item.id)}
onDrop={(e) => handleDrop(e, item.id)}
onDragEnd={handleDragEnd}
className={`flex items-center gap-3 rounded-lg border p-2 transition-all ${
dragId === item.id
? "opacity-40 scale-[0.98] bg-muted/50"
: dragOverId === item.id
? "ring-2 ring-primary bg-primary/5"
: "bg-card hover:bg-muted/30"
} ${!item.enabled ? "opacity-60" : ""}`}
> >
<div className="relative aspect-video bg-muted"> {/* Drag handle */}
<div className="cursor-grab text-muted-foreground/50 hover:text-muted-foreground active:cursor-grabbing">
<GripVertical className="h-4 w-4" />
</div>
{/* Sort number */}
<span className="w-5 text-center text-xs font-mono text-muted-foreground">
{idx + 1}
</span>
{/* Thumbnail */}
<div
className="relative h-12 w-20 shrink-0 cursor-pointer overflow-hidden rounded border bg-muted"
onClick={() => setPreviewIdx(idx)}
>
<img <img
src={item.imageUrl} src={item.imageUrl}
alt="" alt=""
className="h-full w-full object-cover" className="h-full w-full object-cover"
draggable={false}
/> />
{!item.enabled && ( {!item.enabled && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50"> <div className="absolute inset-0 flex items-center justify-center bg-black/40">
<span className="text-sm font-medium text-white/80"> <span className="text-[9px] font-medium text-white/80">
</span> </span>
</div> </div>
)} )}
</div> <div className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all hover:bg-black/30 hover:opacity-100">
<div className="space-y-3 p-4"> <Eye className="h-4 w-4 text-white" />
{type === "gallery" && (
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={item.title ?? ""}
placeholder="可选标题"
onChange={(e) =>
setItems((prev) =>
prev.map((it) =>
it.id === item.id
? { ...it, title: e.target.value }
: it
)
)
}
onBlur={() =>
handleUpdate(item.id, { title: item.title })
}
/>
</div>
)}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label className="text-xs"></Label>
<Input
type="number"
value={item.sortOrder}
className="h-8 w-20"
onChange={(e) =>
setItems((prev) =>
prev.map((it) =>
it.id === item.id
? { ...it, sortOrder: parseInt(e.target.value) || 0 }
: it
)
)
}
onBlur={() =>
handleUpdate(item.id, { sortOrder: item.sortOrder })
}
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs"></Label>
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
handleUpdate(item.id, { enabled: checked })
}
/>
</div>
<Button
variant="ghost"
size="icon"
className="ml-auto h-8 w-8 text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
{/* Title (gallery only) */}
{type === "gallery" && (
<Input
value={item.title ?? ""}
placeholder="标题(可选)"
className="h-8 max-w-[180px] text-xs"
onChange={(e) =>
setItems((prev) =>
prev.map((it) =>
it.id === item.id
? { ...it, title: e.target.value }
: it
)
)
}
onBlur={() => handleUpdate(item.id, { title: item.title })}
/>
)}
{/* URL display */}
<span className="hidden flex-1 truncate text-xs text-muted-foreground/60 md:block">
{item.imageUrl}
</span>
{/* Controls */}
<div className="ml-auto flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
handleUpdate(item.id, { enabled: checked })
}
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Lightbox preview */}
{previewIdx !== null && items[previewIdx] && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
onClick={() => setPreviewIdx(null)}
>
<button
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white"
onClick={() => setPreviewIdx(null)}
>
<X className="h-5 w-5" />
</button>
{items.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
setPreviewIdx(
(previewIdx - 1 + items.length) % items.length
);
}}
className="absolute left-4 top-1/2 z-10 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setPreviewIdx((previewIdx + 1) % items.length);
}}
className="absolute right-4 top-1/2 z-10 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white"
>
<ChevronRight className="h-5 w-5" />
</button>
</>
)}
<div
className="flex flex-col items-center px-16"
onClick={(e) => e.stopPropagation()}
>
<img
src={items[previewIdx].imageUrl}
alt={items[previewIdx].title || ""}
className="max-h-[85vh] max-w-[90vw] rounded-lg object-contain"
/>
{items[previewIdx].title && (
<p className="mt-3 text-sm text-white/70">
{items[previewIdx].title}
</p>
)}
<span className="mt-2 text-xs text-white/40">
{previewIdx + 1} / {items.length}
</span>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,12 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import Image from "next/image";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Download, Package } from "lucide-react"; import { Download, Package } from "lucide-react";
interface AddonCardProps { interface AddonCardProps {
@@ -21,50 +14,70 @@ interface AddonCardProps {
}; };
} }
const categoryLabels: Record<string, string> = {
general: "通用",
gameplay: "游戏玩法",
ui: "界面增强",
combat: "战斗",
raid: "团队副本",
pvp: "PvP",
tradeskill: "专业技能",
utility: "实用工具",
};
export function AddonCard({ addon }: AddonCardProps) { export function AddonCard({ addon }: AddonCardProps) {
const latestVersion = addon.releases?.[0]?.version; const latestVersion = addon.releases?.[0]?.version;
return ( return (
<Link href={`/addons/${addon.slug}`}> <Link
<Card className="h-full transition-shadow hover:shadow-lg"> href={`/addons/${addon.slug}`}
<CardHeader> className="group relative overflow-hidden rounded-xl border border-amber-500/10 bg-white/[0.03] backdrop-blur transition-all duration-300 hover:border-amber-500/30 hover:bg-white/[0.06] hover:shadow-[0_0_24px_rgba(245,158,11,0.08)]"
<div className="flex items-start gap-3"> >
{addon.iconUrl ? ( <div className="p-4 sm:p-5">
<img <div className="flex items-start gap-3">
{addon.iconUrl ? (
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg ring-1 ring-amber-500/15">
<Image
src={addon.iconUrl} src={addon.iconUrl}
alt={addon.name} alt={addon.name}
className="h-12 w-12 rounded-lg object-cover" fill
className="object-cover"
sizes="48px"
/> />
) : ( </div>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> ) : (
<Package className="h-6 w-6 text-primary" /> <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 ring-1 ring-amber-500/15">
</div> <Package className="h-6 w-6 text-amber-400" />
)} </div>
<div className="flex-1"> )}
<CardTitle className="text-lg">{addon.name}</CardTitle> <div className="min-w-0 flex-1">
<div className="mt-1 flex items-center gap-2"> <h3 className="truncate font-semibold text-amber-100 transition-colors group-hover:text-amber-200">
<Badge variant="secondary" className="text-xs"> {addon.name}
{addon.category} </h3>
</Badge> <div className="mt-1 flex items-center gap-2">
{latestVersion && ( <span className="rounded-md bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-300/80">
<span className="text-xs text-muted-foreground"> {categoryLabels[addon.category] || addon.category}
v{latestVersion} </span>
</span> {latestVersion && (
)} <span className="text-[10px] text-gray-500">
</div> v{latestVersion}
</span>
)}
</div> </div>
</div> </div>
</CardHeader> </div>
<CardContent>
<CardDescription className="line-clamp-2"> <p className="mt-3 line-clamp-2 text-sm leading-relaxed text-gray-400">
{addon.summary} {addon.summary}
</CardDescription> </p>
<div className="mt-3 flex items-center gap-1 text-sm text-muted-foreground">
<Download className="h-3.5 w-3.5" /> <div className="mt-3 flex items-center gap-1.5 text-xs text-gray-500">
<span>{addon.totalDownloads.toLocaleString()} </span> <Download className="h-3 w-3" />
</div> <span>{addon.totalDownloads.toLocaleString()} </span>
</CardContent> </div>
</Card> </div>
<div className="pointer-events-none absolute -right-6 -top-6 h-20 w-20 rounded-full bg-amber-500/[0.03] blur-2xl transition-all duration-500 group-hover:bg-amber-500/[0.06]" />
</Link> </Link>
); );
} }

View File

@@ -1,13 +1,74 @@
import Link from "next/link";
import { Package, Download, FileText, Clock } from "lucide-react";
const quickLinks = [
{ href: "/addons", label: "插件列表", icon: Package },
{ href: "/articles", label: "公告文章", icon: FileText },
{ href: "/changelog", label: "更新日志", icon: Clock },
];
export function Footer() { export function Footer() {
return ( return (
<footer className="border-t border-amber-900/20 bg-[#0a0912]"> <footer className="border-t border-amber-900/20 bg-[#080710]">
<div className="mx-auto max-w-6xl px-4 py-8"> <div className="mx-auto max-w-6xl px-4 py-10 sm:py-12">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row"> <div className="grid gap-8 sm:grid-cols-3">
<p className="text-sm text-gray-500"> {/* Brand */}
&copy; {new Date().getFullYear()} Nanami <div>
<div className="flex items-center gap-2 text-lg font-bold text-amber-100">
<Package className="h-5 w-5 text-amber-400" />
<span>Nanami</span>
</div>
<p className="mt-3 text-sm leading-relaxed text-gray-500">
Turtle WoW
<br />
</p>
</div>
{/* Quick Links */}
<div>
<h4 className="mb-3 text-sm font-semibold text-amber-200/80">
</h4>
<ul className="space-y-2">
{quickLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="flex items-center gap-2 text-sm text-gray-500 transition-colors hover:text-amber-200"
>
<link.icon className="h-3.5 w-3.5" />
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Download */}
<div>
<h4 className="mb-3 text-sm font-semibold text-amber-200/80">
使
</h4>
<Link
href="/api/software/latest"
className="inline-flex items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-4 py-2.5 text-sm font-medium text-amber-200 transition-colors hover:border-amber-500/40 hover:bg-amber-500/10"
>
<Download className="h-4 w-4" />
Nanami
</Link>
<p className="mt-3 text-xs text-gray-600">
Windows 10 / 11 · 使
</p>
</div>
</div>
<div className="mt-8 border-t border-amber-900/15 pt-6 flex flex-col items-center justify-between gap-3 sm:flex-row">
<p className="text-xs text-gray-600">
&copy; {new Date().getFullYear()} Nanami. All rights reserved.
</p> </p>
<p className="text-sm text-gray-500"> <p className="text-xs text-gray-600">
Turtle WoW Made for Turtle WoW community
</p> </p>
</div> </div>
</div> </div>

View File

@@ -19,6 +19,7 @@ export function GameGallery({ items }: { items?: GalleryItem[] }) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const [lightbox, setLightbox] = useState(false); const [lightbox, setLightbox] = useState(false);
const thumbsRef = useRef<HTMLDivElement>(null); const thumbsRef = useRef<HTMLDivElement>(null);
const skipInitialThumbScroll = useRef(true);
const touchStartX = useRef(0); const touchStartX = useRef(0);
const empty = items && items.length === 0; const empty = items && items.length === 0;
@@ -30,6 +31,10 @@ export function GameGallery({ items }: { items?: GalleryItem[] }) {
); );
useEffect(() => { useEffect(() => {
if (skipInitialThumbScroll.current) {
skipInitialThumbScroll.current = false;
return;
}
const el = thumbsRef.current?.children[active] as HTMLElement | undefined; const el = thumbsRef.current?.children[active] as HTMLElement | undefined;
el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}, [active]); }, [active]);

View File

@@ -318,10 +318,10 @@ export function HeroBanner({
className="pointer-events-none relative z-10 w-full sm:hidden" className="pointer-events-none relative z-10 w-full sm:hidden"
style={{ paddingBottom: "clamp(220px, 56.25%, 420px)" }} style={{ paddingBottom: "clamp(220px, 56.25%, 420px)" }}
/> />
{/* Desktop spacer: 21:9, max 720px */} {/* Desktop spacer: 21:9 (9/21), capped height for ultrawide banners */}
<div <div
className="pointer-events-none relative z-10 hidden w-full sm:block" className="pointer-events-none relative z-10 hidden w-full sm:block"
style={{ paddingBottom: "min(42.86%, 720px)" }} style={{ paddingBottom: "min(42.86%, 480px)" }}
/> />
{/* Multi-layer gradient overlays */} {/* Multi-layer gradient overlays */}

View File

@@ -1,8 +1,20 @@
"use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Package } from "lucide-react"; import { usePathname } from "next/navigation";
import { ThemeToggle } from "@/components/ThemeToggle"; import { Package, Menu, X } from "lucide-react";
const navLinks = [
{ href: "/addons", label: "插件列表" },
{ href: "/articles", label: "公告" },
{ href: "/changelog", label: "更新日志" },
];
export function Navbar() { export function Navbar() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
return ( return (
<header className="sticky top-0 z-50 border-b border-amber-900/20 bg-[#0a0912]/95 backdrop-blur supports-[backdrop-filter]:bg-[#0a0912]/80"> <header className="sticky top-0 z-50 border-b border-amber-900/20 bg-[#0a0912]/95 backdrop-blur supports-[backdrop-filter]:bg-[#0a0912]/80">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-3 sm:h-14 sm:px-4"> <div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-3 sm:h-14 sm:px-4">
@@ -14,28 +26,61 @@ export function Navbar() {
<span>Nanami</span> <span>Nanami</span>
</Link> </Link>
<nav className="flex items-center gap-2 sm:gap-4"> {/* Desktop nav */}
<Link <nav className="hidden items-center gap-4 sm:flex">
href="/addons" {navLinks.map((link) => {
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm" const isActive = pathname === link.href || pathname.startsWith(link.href + "/");
> return (
<Link
</Link> key={link.href}
<Link href={link.href}
href="/articles" className={`relative text-sm transition-colors ${
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm" isActive
> ? "font-medium text-amber-200"
: "text-gray-400 hover:text-amber-200"
</Link> }`}
<Link >
href="/changelog" {link.label}
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm" {isActive && (
> <span className="absolute -bottom-[13px] left-0 right-0 h-px bg-amber-400 sm:-bottom-[17px]" />
)}
</Link> </Link>
<ThemeToggle /> );
})}
</nav> </nav>
{/* Mobile menu button */}
<button
onClick={() => setOpen(!open)}
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-400 transition-colors hover:text-amber-200 sm:hidden"
aria-label={open ? "关闭菜单" : "打开菜单"}
>
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div> </div>
{/* Mobile menu panel */}
{open && (
<nav className="border-t border-amber-900/20 bg-[#0a0912]/98 px-3 pb-4 pt-2 backdrop-blur sm:hidden">
{navLinks.map((link) => {
const isActive = pathname === link.href || pathname.startsWith(link.href + "/");
return (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className={`flex items-center rounded-lg px-3 py-2.5 text-sm transition-colors ${
isActive
? "bg-amber-500/10 font-medium text-amber-200"
: "text-gray-400 hover:bg-white/[0.04] hover:text-amber-200"
}`}
>
{link.label}
</Link>
);
})}
</nav>
)}
</header> </header>
); );
} }