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

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
releases Release[]
screenshots Screenshot[]
@@index([published, totalDownloads])
@@index([category])
}
model Release {
@@ -115,6 +118,8 @@ model Article {
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([published, createdAt])
}
model PageView {

View File

@@ -1,16 +1,10 @@
import { notFound } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Download, Package, Calendar, Tag } from "lucide-react";
import { Download, Package, Calendar, Tag, ArrowLeft } from "lucide-react";
import { DownloadButton } from "@/components/public/DownloadButton";
import { MarkdownContent } from "@/components/public/MarkdownContent";
export async function generateMetadata({
params,
@@ -29,7 +23,7 @@ export async function generateMetadata({
};
}
export const dynamic = "force-dynamic";
export const revalidate = 120;
export default async function AddonDetailPage({
params,
@@ -49,32 +43,56 @@ export default async function AddonDetailPage({
const latestRelease = addon.releases.find((r) => r.isLatest);
const categoryLabels: Record<string, string> = {
general: "通用", gameplay: "游戏玩法", ui: "界面增强",
combat: "战斗", raid: "团队副本", pvp: "PvP",
tradeskill: "专业技能", utility: "实用工具",
};
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 */}
<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 ? (
<img
src={addon.iconUrl}
alt={addon.name}
className="h-20 w-20 rounded-2xl object-cover"
/>
<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">
<Image
src={addon.iconUrl}
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">
<Package className="h-10 w-10 text-primary" />
<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-8 w-8 text-amber-400 sm:h-10 sm:w-10" />
</div>
)}
<div className="flex-1">
<h1 className="text-3xl font-bold">{addon.name}</h1>
<p className="mt-2 text-lg text-muted-foreground">{addon.summary}</p>
<h1 className="text-2xl font-bold text-amber-100 sm:text-3xl">
{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">
<Badge>{addon.category}</Badge>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-300/80">
{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" />
{addon.totalDownloads.toLocaleString()}
</span>
{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" />
v{latestRelease.version}
</span>
@@ -92,66 +110,61 @@ export default async function AddonDetailPage({
)}
</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 */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-neutral max-w-none dark:prose-invert">
<MarkdownContent content={addon.description} />
</div>
</CardContent>
</Card>
<div className="lg:col-span-2 space-y-6">
<div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
<h2 className="mb-4 text-lg font-semibold text-amber-100"></h2>
<MarkdownContent content={addon.description} />
</div>
{/* Screenshots */}
{addon.screenshots.length > 0 && (
<Card className="mt-6">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{addon.screenshots.map((ss) => (
<img
key={ss.id}
<div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
<h2 className="mb-4 text-lg font-semibold text-amber-100"></h2>
<div className="grid gap-3 sm:grid-cols-2">
{addon.screenshots.map((ss) => (
<div key={ss.id} className="overflow-hidden rounded-lg ring-1 ring-amber-500/10">
<Image
src={ss.imageUrl}
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>
</CardContent>
</Card>
</div>
))}
</div>
</div>
)}
</div>
{/* Sidebar - Releases */}
<div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{addon.releases.length}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-xl border border-amber-500/10 bg-white/[0.03] p-5 sm:p-6">
<h2 className="mb-1 text-lg font-semibold text-amber-100">
</h2>
<p className="mb-4 text-sm text-gray-500">
{addon.releases.length}
</p>
<div className="space-y-3">
{addon.releases.map((release) => (
<div
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 gap-2">
<span className="font-semibold">v{release.version}</span>
<span className="font-semibold text-amber-100">
v{release.version}
</span>
{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>
<DownloadButton
@@ -161,11 +174,11 @@ export default async function AddonDetailPage({
/>
</div>
{release.gameVersion && (
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-xs text-gray-500">
WoW {release.gameVersion}
</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">
<Calendar className="h-3 w-3" />
{new Date(release.createdAt).toLocaleDateString("zh-CN")}
@@ -176,34 +189,19 @@ export default async function AddonDetailPage({
</span>
</div>
{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}
</p>
)}
</div>
))}
{addon.releases.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-gray-500"></p>
)}
</CardContent>
</Card>
</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 { AddonCard } from "@/components/public/AddonCard";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { Package, Search, ChevronLeft, ChevronRight } from "lucide-react";
const categoryLabels: Record<string, string> = {
general: "通用",
@@ -16,16 +16,20 @@ const categoryLabels: Record<string, string> = {
export const metadata = {
title: "插件列表 - Nanami",
description: "浏览和下载 Turtle WoW 插件",
};
export const dynamic = "force-dynamic";
export const revalidate = 30;
const PAGE_SIZE = 12;
export default async function AddonsPage({
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 };
if (category) where.category = category;
@@ -36,66 +40,144 @@ export default async function AddonsPage({
];
}
const addons = await prisma.addon.findMany({
where,
include: {
releases: {
where: { isLatest: true },
select: { version: true },
const [addons, total, categories] = await Promise.all([
prisma.addon.findMany({
where,
include: {
releases: {
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({
by: ["category"],
where: { published: true },
_count: { id: true },
});
const totalPages = Math.ceil(total / PAGE_SIZE);
const buildHref = (p: number) => {
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 (
<div className="mx-auto max-w-6xl px-4 py-12">
<h1 className="text-3xl font-bold"></h1>
<p className="mt-2 text-muted-foreground">
<section className="mx-auto max-w-6xl px-3 py-10 sm:px-4 sm:py-16">
<div className="mb-2 flex items-center gap-2 sm:gap-3">
<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
</p>
{/* Category Filter */}
<div className="mt-6 flex flex-wrap gap-2">
<Link href="/addons">
<Badge
variant={!category ? "default" : "outline"}
className="cursor-pointer"
>
</Badge>
<div className="mb-6 flex flex-wrap gap-2 sm:mb-8">
<Link
href="/addons"
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all sm:text-sm ${
!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"
}`}
>
</Link>
{categories.map((cat) => (
<Link key={cat.category} href={`/addons?category=${cat.category}`}>
<Badge
variant={category === cat.category ? "default" : "outline"}
className="cursor-pointer"
>
{categoryLabels[cat.category] || cat.category} ({cat._count.id})
</Badge>
<Link
key={cat.category}
href={`/addons?category=${cat.category}`}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all sm:text-sm ${
category === cat.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"
}`}
>
{categoryLabels[cat.category] || cat.category} ({cat._count.id})
</Link>
))}
</div>
{/* Addon Grid */}
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{addons.map((addon) => (
<AddonCard key={addon.id} addon={addon} />
))}
</div>
{addons.length > 0 ? (
<>
<div className="grid gap-4 sm:gap-6 sm:grid-cols-2 lg:grid-cols-3">
{addons.map((addon) => (
<AddonCard key={addon.id} addon={addon} />
))}
</div>
{addons.length === 0 && (
<div className="mt-16 text-center">
<p className="text-lg text-muted-foreground">
{/* Pagination */}
{totalPages > 1 && (
<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}"相关的插件` : "暂无插件"}
</p>
<p className="mt-1 text-sm text-gray-500">
{search ? "尝试更换关键词搜索" : "稍后再来查看吧"}
</p>
</div>
)}
</div>
</section>
);
}

View File

@@ -1,10 +1,33 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { prisma } from "@/lib/db";
import { Calendar, ArrowLeft } from "lucide-react";
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({
params,
@@ -42,11 +65,14 @@ export default async function ArticleDetailPage({
</div>
{article.coverImage && (
<div className="mb-8 overflow-hidden rounded-lg">
<img
<div className="relative mb-8 aspect-[16/9] overflow-hidden rounded-lg">
<Image
src={article.coverImage}
alt={article.title}
className="w-full object-cover"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 750px"
priority
/>
</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 Image from "next/image";
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() {
const articles = await prisma.article.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
});
export const revalidate = 30;
const PAGE_SIZE = 10;
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 (
<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 ? (
<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="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="relative aspect-[16/9] overflow-hidden">
<Image
src={article.coverImage}
alt={article.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, 50vw"
/>
</div>
)}
<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 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>
</div>
</Link>
))}
</div>
</Link>
))}
</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>
);

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 { 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() {
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 Image from "next/image";
import { prisma } from "@/lib/db";
import { Button } from "@/components/ui/button";
import { AddonCard } from "@/components/public/AddonCard";
@@ -6,7 +7,7 @@ import { HeroBanner } from "@/components/public/HeroBanner";
import { GameGallery } from "@/components/public/GameGallery";
import { Sparkles, Shield, Zap, Calendar } from "lucide-react";
export const dynamic = "force-dynamic";
export const revalidate = 60;
export default async function HomePage() {
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"
>
{article.coverImage && (
<div className="aspect-[16/9] overflow-hidden">
<img
<div className="relative aspect-[16/9] overflow-hidden">
<Image
src={article.coverImage}
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>
)}

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 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 origin = process.env.API_BASE_URL || process.env.NEXTAUTH_URL || request.nextUrl.origin;
const downloadUrl =
latest.downloadType === "url" && latest.externalUrl
? latest.externalUrl
: `${origin}/api/software/download/${latest.id}`;
: `${origin.replace(/\/$/, "")}/api/software/download/${latest.id}`;
return NextResponse.json({
hasUpdate,

View File

@@ -286,7 +286,26 @@
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) {
.page-enter {
animation: none;
}
.hero-shimmer,
.hero-tagline {
animation: none;

View File

@@ -4,8 +4,22 @@ import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css";
export const metadata: Metadata = {
title: "Nanami - WoW Addons",
description: "World of Warcraft 插件发布与下载平台",
title: {
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({

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";
import { useState, useRef } from "react";
import { useState, useRef, useCallback } from "react";
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 { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
@@ -24,6 +33,9 @@ interface Props {
export function MediaManager({ type, initial }: Props) {
const [items, setItems] = useState<MediaItem[]>(initial);
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 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 (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-4">
<Button
onClick={() => fileRef.current?.click()}
@@ -111,7 +185,7 @@ export function MediaManager({ type, initial }: Props) {
onChange={handleUpload}
/>
<span className="text-sm text-muted-foreground">
{items.length}
{items.length} ·
</span>
</div>
@@ -120,95 +194,164 @@ export function MediaManager({ type, initial }: Props) {
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<div className="space-y-1">
{items.map((item, idx) => (
<div
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
src={item.imageUrl}
alt=""
className="h-full w-full object-cover"
draggable={false}
/>
{!item.enabled && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<span className="text-sm font-medium text-white/80">
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<span className="text-[9px] font-medium text-white/80">
</span>
</div>
)}
</div>
<div className="space-y-3 p-4">
{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 className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all hover:bg-black/30 hover:opacity-100">
<Eye className="h-4 w-4 text-white" />
</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>
)}
{/* 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>
);
}

View File

@@ -1,12 +1,5 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Image from "next/image";
import { Download, Package } from "lucide-react";
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) {
const latestVersion = addon.releases?.[0]?.version;
return (
<Link href={`/addons/${addon.slug}`}>
<Card className="h-full transition-shadow hover:shadow-lg">
<CardHeader>
<div className="flex items-start gap-3">
{addon.iconUrl ? (
<img
<Link
href={`/addons/${addon.slug}`}
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="p-4 sm:p-5">
<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}
alt={addon.name}
className="h-12 w-12 rounded-lg object-cover"
fill
className="object-cover"
sizes="48px"
/>
) : (
<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>
)}
<div className="flex-1">
<CardTitle className="text-lg">{addon.name}</CardTitle>
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{addon.category}
</Badge>
{latestVersion && (
<span className="text-xs text-muted-foreground">
v{latestVersion}
</span>
)}
</div>
</div>
) : (
<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">
<Package className="h-6 w-6 text-amber-400" />
</div>
)}
<div className="min-w-0 flex-1">
<h3 className="truncate font-semibold text-amber-100 transition-colors group-hover:text-amber-200">
{addon.name}
</h3>
<div className="mt-1 flex items-center gap-2">
<span className="rounded-md bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-300/80">
{categoryLabels[addon.category] || addon.category}
</span>
{latestVersion && (
<span className="text-[10px] text-gray-500">
v{latestVersion}
</span>
)}
</div>
</div>
</CardHeader>
<CardContent>
<CardDescription className="line-clamp-2">
{addon.summary}
</CardDescription>
<div className="mt-3 flex items-center gap-1 text-sm text-muted-foreground">
<Download className="h-3.5 w-3.5" />
<span>{addon.totalDownloads.toLocaleString()} </span>
</div>
</CardContent>
</Card>
</div>
<p className="mt-3 line-clamp-2 text-sm leading-relaxed text-gray-400">
{addon.summary}
</p>
<div className="mt-3 flex items-center gap-1.5 text-xs text-gray-500">
<Download className="h-3 w-3" />
<span>{addon.totalDownloads.toLocaleString()} </span>
</div>
</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>
);
}

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() {
return (
<footer className="border-t border-amber-900/20 bg-[#0a0912]">
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-sm text-gray-500">
&copy; {new Date().getFullYear()} Nanami
<footer className="border-t border-amber-900/20 bg-[#080710]">
<div className="mx-auto max-w-6xl px-4 py-10 sm:py-12">
<div className="grid gap-8 sm:grid-cols-3">
{/* Brand */}
<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 className="text-sm text-gray-500">
Turtle WoW
<p className="text-xs text-gray-600">
Made for Turtle WoW community
</p>
</div>
</div>

View File

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

View File

@@ -318,10 +318,10 @@ export function HeroBanner({
className="pointer-events-none relative z-10 w-full sm:hidden"
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
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 */}

View File

@@ -1,8 +1,20 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Package } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { usePathname } from "next/navigation";
import { Package, Menu, X } from "lucide-react";
const navLinks = [
{ href: "/addons", label: "插件列表" },
{ href: "/articles", label: "公告" },
{ href: "/changelog", label: "更新日志" },
];
export function Navbar() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
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">
<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>
</Link>
<nav className="flex items-center gap-2 sm:gap-4">
<Link
href="/addons"
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm"
>
</Link>
<Link
href="/articles"
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm"
>
</Link>
<Link
href="/changelog"
className="text-xs text-gray-400 transition-colors hover:text-amber-200 sm:text-sm"
>
</Link>
<ThemeToggle />
{/* Desktop nav */}
<nav className="hidden items-center gap-4 sm:flex">
{navLinks.map((link) => {
const isActive = pathname === link.href || pathname.startsWith(link.href + "/");
return (
<Link
key={link.href}
href={link.href}
className={`relative text-sm transition-colors ${
isActive
? "font-medium text-amber-200"
: "text-gray-400 hover:text-amber-200"
}`}
>
{link.label}
{isActive && (
<span className="absolute -bottom-[13px] left-0 right-0 h-px bg-amber-400 sm:-bottom-[17px]" />
)}
</Link>
);
})}
</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>
{/* 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>
);
}