diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 93542c3..54f990b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/app/(public)/addons/[slug]/page.tsx b/src/app/(public)/addons/[slug]/page.tsx index 7b138a8..a01516e 100644 --- a/src/app/(public)/addons/[slug]/page.tsx +++ b/src/app/(public)/addons/[slug]/page.tsx @@ -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 = { + general: "通用", gameplay: "游戏玩法", ui: "界面增强", + combat: "战斗", raid: "团队副本", pvp: "PvP", + tradeskill: "专业技能", utility: "实用工具", + }; + return ( -
+
+ + + 返回插件列表 + + {/* Header */} -
+
{addon.iconUrl ? ( - {addon.name} +
+ {addon.name} +
) : ( -
- +
+
)}
-

{addon.name}

-

{addon.summary}

+

+ {addon.name} +

+

+ {addon.summary} +

- {addon.category} - + + {categoryLabels[addon.category] || addon.category} + + {addon.totalDownloads.toLocaleString()} 次下载 {latestRelease && ( - + v{latestRelease.version} @@ -92,66 +110,61 @@ export default async function AddonDetailPage({ )}
- +
-
+
{/* Description */} -
- - - 介绍 - - -
- -
-
-
+
+
+

介绍

+ +
{/* Screenshots */} {addon.screenshots.length > 0 && ( - - - 截图 - - -
- {addon.screenshots.map((ss) => ( - +

截图

+
+ {addon.screenshots.map((ss) => ( +
+ Screenshot - ))} -
- - +
+ ))} +
+
)}
{/* Sidebar - Releases */}
- - - 版本历史 - - 共 {addon.releases.length} 个版本 - - - +
+

+ 版本历史 +

+

+ 共 {addon.releases.length} 个版本 +

+
{addon.releases.map((release) => (
- v{release.version} + + v{release.version} + {release.isLatest && ( - + 最新 - + )}
{release.gameVersion && ( -

+

WoW {release.gameVersion}

)} -
+
{new Date(release.createdAt).toLocaleDateString("zh-CN")} @@ -176,34 +189,19 @@ export default async function AddonDetailPage({
{release.changelog && ( -

+

{release.changelog}

)}
))} {addon.releases.length === 0 && ( -

暂无版本发布

+

暂无版本发布

)} - - +
+
-
+
); } - -function MarkdownContent({ content }: { content: string }) { - const html = content - .replace(/^### (.*$)/gm, '

$1

') - .replace(/^## (.*$)/gm, '

$1

') - .replace(/^# (.*$)/gm, '

$1

') - .replace(/\*\*(.*?)\*\*/g, "$1") - .replace(/\*(.*?)\*/g, "$1") - .replace(/`(.*?)`/g, '$1') - .replace(/^- (.*$)/gm, '
  • $1
  • ') - .replace(/\n\n/g, '

    ') - .replace(/\n/g, "
    "); - - return
    ; -} diff --git a/src/app/(public)/addons/loading.tsx b/src/app/(public)/addons/loading.tsx new file mode 100644 index 0000000..417d8b6 --- /dev/null +++ b/src/app/(public)/addons/loading.tsx @@ -0,0 +1,36 @@ +export default function AddonsLoading() { + return ( +
    +
    +
    + +
    + {Array.from({ length: 5 }).map((_, i) => ( +
    + ))} +
    + +
    + {Array.from({ length: 6 }).map((_, i) => ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ))} +
    +
    + ); +} diff --git a/src/app/(public)/addons/page.tsx b/src/app/(public)/addons/page.tsx index 090eeb7..f301788 100644 --- a/src/app/(public)/addons/page.tsx +++ b/src/app/(public)/addons/page.tsx @@ -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 = { general: "通用", @@ -16,16 +16,20 @@ const categoryLabels: Record = { 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 = { 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 ( -
    -

    插件列表

    -

    +

    +
    + +

    + 插件列表 +

    +
    +

    浏览和下载 World of Warcraft 插件

    {/* Category Filter */} -
    - - - 全部 - +
    + + 全部 {categories.map((cat) => ( - - - {categoryLabels[cat.category] || cat.category} ({cat._count.id}) - + + {categoryLabels[cat.category] || cat.category} ({cat._count.id}) ))}
    {/* Addon Grid */} -
    - {addons.map((addon) => ( - - ))} -
    + {addons.length > 0 ? ( + <> +
    + {addons.map((addon) => ( + + ))} +
    - {addons.length === 0 && ( -
    -

    + {/* Pagination */} + {totalPages > 1 && ( +

    + {page > 1 ? ( + + + + ) : ( + + + + )} + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + + {p} + + ))} + + {page < totalPages ? ( + + + + ) : ( + + + + )} +
    + )} + + ) : ( +
    +
    + +
    +

    {search ? `没有找到"${search}"相关的插件` : "暂无插件"}

    +

    + {search ? "尝试更换关键词搜索" : "稍后再来查看吧"} +

    )} -
    +
    ); } diff --git a/src/app/(public)/articles/[slug]/page.tsx b/src/app/(public)/articles/[slug]/page.tsx index e427caf..3e5c0cf 100644 --- a/src/app/(public)/articles/[slug]/page.tsx +++ b/src/app/(public)/articles/[slug]/page.tsx @@ -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({
    {article.coverImage && ( -
    - + {article.title}
    )} diff --git a/src/app/(public)/articles/loading.tsx b/src/app/(public)/articles/loading.tsx new file mode 100644 index 0000000..730cfa6 --- /dev/null +++ b/src/app/(public)/articles/loading.tsx @@ -0,0 +1,24 @@ +export default function ArticlesLoading() { + return ( +
    +
    +
    + +
    + {Array.from({ length: 4 }).map((_, i) => ( +
    +
    +
    +
    +
    +
    +
    +
    + ))} +
    +
    + ); +} diff --git a/src/app/(public)/articles/page.tsx b/src/app/(public)/articles/page.tsx index fbd2e63..f3a4199 100644 --- a/src/app/(public)/articles/page.tsx +++ b/src/app/(public)/articles/page.tsx @@ -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 (
    @@ -22,39 +44,88 @@ export default async function ArticlesPage() { {articles.length === 0 ? (

    暂无文章

    ) : ( -
    - {articles.map((article) => ( - - {article.coverImage && ( -
    - {article.title} -
    - )} -
    -

    - {article.title} -

    - {article.summary && ( -

    - {article.summary} -

    + <> +
    + {articles.map((article) => ( + + {article.coverImage && ( +
    + {article.title} +
    )} -
    - - {new Date(article.createdAt).toLocaleDateString("zh-CN")} +
    +

    + {article.title} +

    + {article.summary && ( +

    + {article.summary} +

    + )} +
    + + {new Date(article.createdAt).toLocaleDateString("zh-CN")} +
    -
    - - ))} -
    + + ))} +
    + + {/* Pagination */} + {totalPages > 1 && ( +
    + {page > 1 ? ( + + + + ) : ( + + + + )} + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + + {p} + + ))} + + {page < totalPages ? ( + + + + ) : ( + + + + )} +
    + )} + )}
    ); diff --git a/src/app/(public)/changelog/loading.tsx b/src/app/(public)/changelog/loading.tsx new file mode 100644 index 0000000..48db4a4 --- /dev/null +++ b/src/app/(public)/changelog/loading.tsx @@ -0,0 +1,32 @@ +export default function ChangelogLoading() { + return ( +
    +
    +
    + +
    +
    +
    + {Array.from({ length: 3 }).map((_, i) => ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ))} +
    +
    +
    + ); +} diff --git a/src/app/(public)/changelog/page.tsx b/src/app/(public)/changelog/page.tsx index ea9b643..17c0e1a 100644 --- a/src/app/(public)/changelog/page.tsx +++ b/src/app/(public)/changelog/page.tsx @@ -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({ diff --git a/src/app/(public)/error.tsx b/src/app/(public)/error.tsx new file mode 100644 index 0000000..0d72f43 --- /dev/null +++ b/src/app/(public)/error.tsx @@ -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 ( +
    +
    + +
    +

    + 出错了 +

    +

    + 加载页面时发生了意外错误。请尝试刷新页面,如果问题持续存在请联系管理员。 +

    +
    + + + + 返回首页 + +
    +
    + ); +} diff --git a/src/app/(public)/loading.tsx b/src/app/(public)/loading.tsx new file mode 100644 index 0000000..ce99757 --- /dev/null +++ b/src/app/(public)/loading.tsx @@ -0,0 +1,35 @@ +export default function PublicLoading() { + return ( +
    + {/* Hero skeleton */} +
    +
    +
    + + {/* Content skeleton */} +
    +
    +
    + {Array.from({ length: 6 }).map((_, i) => ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ))} +
    +
    +
    + ); +} diff --git a/src/app/(public)/not-found.tsx b/src/app/(public)/not-found.tsx new file mode 100644 index 0000000..cfbd436 --- /dev/null +++ b/src/app/(public)/not-found.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; +import { Home, Search } from "lucide-react"; + +export default function NotFound() { + return ( +
    +
    + + 404 + +
    + +
    +
    +

    + 页面未找到 +

    +

    + 你访问的页面不存在或已被移除。请检查链接地址,或返回首页浏览。 +

    +
    + + + 返回首页 + + + 浏览插件 + +
    +
    + ); +} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 6980465..ddf1ce5 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -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 && ( -
    - + {article.title}
    )} diff --git a/src/app/(public)/template.tsx b/src/app/(public)/template.tsx new file mode 100644 index 0000000..c5f9944 --- /dev/null +++ b/src/app/(public)/template.tsx @@ -0,0 +1,7 @@ +export default function PublicTemplate({ + children, +}: { + children: React.ReactNode; +}) { + return
    {children}
    ; +} diff --git a/src/app/api/software/check-update/route.ts b/src/app/api/software/check-update/route.ts index 1b460c2..5a824c8 100644 --- a/src/app/api/software/check-update/route.ts +++ b/src/app/api/software/check-update/route.ts @@ -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, diff --git a/src/app/globals.css b/src/app/globals.css index 74b3ef3..6b84035 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b8a424a..0f497d5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..800743b --- /dev/null +++ b/src/app/manifest.ts @@ -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", + }, + ], + }; +} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..33d842c --- /dev/null +++ b/src/app/robots.ts @@ -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`, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..7eee68e --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,40 @@ +import type { MetadataRoute } from "next"; +import { prisma } from "@/lib/db"; + +export default async function sitemap(): Promise { + 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]; +} diff --git a/src/components/admin/MediaManager.tsx b/src/components/admin/MediaManager.tsx index e8bfe0b..48ab0bb 100644 --- a/src/components/admin/MediaManager.tsx +++ b/src/components/admin/MediaManager.tsx @@ -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(initial); const [uploading, setUploading] = useState(false); + const [previewIdx, setPreviewIdx] = useState(null); + const [dragId, setDragId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); const fileRef = useRef(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 ( -
    +
    @@ -120,95 +194,164 @@ export function MediaManager({ type, initial }: Props) { 暂无图片,请点击上方按钮上传

    ) : ( -
    - {items.map((item) => ( +
    + {items.map((item, idx) => (
    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" : ""}`} > -
    + {/* Drag handle */} +
    + +
    + + {/* Sort number */} + + {idx + 1} + + + {/* Thumbnail */} +
    setPreviewIdx(idx)} + > {!item.enabled && ( -
    - - 已禁用 +
    + + 禁用
    )} -
    -
    - {type === "gallery" && ( -
    - - - setItems((prev) => - prev.map((it) => - it.id === item.id - ? { ...it, title: e.target.value } - : it - ) - ) - } - onBlur={() => - handleUpdate(item.id, { title: item.title }) - } - /> -
    - )} - -
    -
    - - - setItems((prev) => - prev.map((it) => - it.id === item.id - ? { ...it, sortOrder: parseInt(e.target.value) || 0 } - : it - ) - ) - } - onBlur={() => - handleUpdate(item.id, { sortOrder: item.sortOrder }) - } - /> -
    - -
    - - - handleUpdate(item.id, { enabled: checked }) - } - /> -
    - - +
    +
    + + {/* Title (gallery only) */} + {type === "gallery" && ( + + setItems((prev) => + prev.map((it) => + it.id === item.id + ? { ...it, title: e.target.value } + : it + ) + ) + } + onBlur={() => handleUpdate(item.id, { title: item.title })} + /> + )} + + {/* URL display */} + + {item.imageUrl} + + + {/* Controls */} +
    +
    + + + handleUpdate(item.id, { enabled: checked }) + } + /> +
    + + +
    ))}
    )} + + {/* Lightbox preview */} + {previewIdx !== null && items[previewIdx] && ( +
    setPreviewIdx(null)} + > + + + {items.length > 1 && ( + <> + + + + )} + +
    e.stopPropagation()} + > + {items[previewIdx].title + {items[previewIdx].title && ( +

    + {items[previewIdx].title} +

    + )} + + {previewIdx + 1} / {items.length} + +
    +
    + )}
    ); } diff --git a/src/components/public/AddonCard.tsx b/src/components/public/AddonCard.tsx index e2a3ae6..10298a4 100644 --- a/src/components/public/AddonCard.tsx +++ b/src/components/public/AddonCard.tsx @@ -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 = { + general: "通用", + gameplay: "游戏玩法", + ui: "界面增强", + combat: "战斗", + raid: "团队副本", + pvp: "PvP", + tradeskill: "专业技能", + utility: "实用工具", +}; + export function AddonCard({ addon }: AddonCardProps) { const latestVersion = addon.releases?.[0]?.version; return ( - - - -
    - {addon.iconUrl ? ( - +
    +
    + {addon.iconUrl ? ( +
    + {addon.name} - ) : ( -
    - -
    - )} -
    - {addon.name} -
    - - {addon.category} - - {latestVersion && ( - - v{latestVersion} - - )} -
    +
    + ) : ( +
    + +
    + )} +
    +

    + {addon.name} +

    +
    + + {categoryLabels[addon.category] || addon.category} + + {latestVersion && ( + + v{latestVersion} + + )}
    - - - - {addon.summary} - -
    - - {addon.totalDownloads.toLocaleString()} 次下载 -
    -
    - +
    + +

    + {addon.summary} +

    + +
    + + {addon.totalDownloads.toLocaleString()} 次下载 +
    +
    + +
    ); } diff --git a/src/components/public/Footer.tsx b/src/components/public/Footer.tsx index 8c452ca..bea213f 100644 --- a/src/components/public/Footer.tsx +++ b/src/components/public/Footer.tsx @@ -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 ( -