更新首页部分内容和其他后台相关功能
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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 }} />;
|
|
||||||
}
|
|
||||||
|
|||||||
36
src/app/(public)/addons/loading.tsx
Normal file
36
src/app/(public)/addons/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
24
src/app/(public)/articles/loading.tsx
Normal file
24
src/app/(public)/articles/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
32
src/app/(public)/changelog/loading.tsx
Normal file
32
src/app/(public)/changelog/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
47
src/app/(public)/error.tsx
Normal file
47
src/app/(public)/error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/app/(public)/loading.tsx
Normal file
35
src/app/(public)/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/app/(public)/not-found.tsx
Normal file
38
src/app/(public)/not-found.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
7
src/app/(public)/template.tsx
Normal file
7
src/app/(public)/template.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default function PublicTemplate({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="page-enter">{children}</div>;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
25
src/app/manifest.ts
Normal 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
16
src/app/robots.ts
Normal 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
40
src/app/sitemap.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
© {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">
|
||||||
|
© {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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user