first commit
This commit is contained in:
commit
6a7a771f2e
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
36
README.md
Normal file
36
README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
app/fonts/GeistMonoVF.woff
Normal file
BIN
app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
app/fonts/GeistVF.woff
Normal file
BIN
app/fonts/GeistVF.woff
Normal file
Binary file not shown.
47
app/globals.css
Normal file
47
app/globals.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
27
app/layout.tsx
Normal file
27
app/layout.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import { Inter } from "next/font/google"
|
||||||
|
import "./globals.css"
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Vocal Rank",
|
||||||
|
description: "Vocal Rank",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
</head>
|
||||||
|
<body className={inter.className}>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
22
app/page.tsx
Normal file
22
app/page.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import Header from "@/components/header"
|
||||||
|
import ServerStats from "@/components/server-stats"
|
||||||
|
import UploaderSearch from "@/components/uploader-search"
|
||||||
|
import VideoSearch from "@/components/video-search"
|
||||||
|
import DatabaseStats from "@/components/database-stats"
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background text-foreground">
|
||||||
|
<Header />
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<ServerStats />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mt-8">
|
||||||
|
<UploaderSearch />
|
||||||
|
<VideoSearch />
|
||||||
|
</div>
|
||||||
|
<DatabaseStats />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
21
components.json
Normal file
21
components.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
74
components/database-stats.tsx
Normal file
74
components/database-stats.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
interface Count {
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatabaseStats() {
|
||||||
|
const [videoCount, setVideoCount] = useState<Count | null>(null)
|
||||||
|
const [uploaderCount, setUploaderCount] = useState<Count | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCounts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const videoResponse = await fetch("https://api.ninevocalrank.top/basic/v1/database/video_count")
|
||||||
|
const uploaderResponse = await fetch("https://api.ninevocalrank.top/basic/v1/database/uploader_count")
|
||||||
|
|
||||||
|
if (!videoResponse.ok || !uploaderResponse.ok) {
|
||||||
|
throw new Error("服务器响应错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoData = await videoResponse.json()
|
||||||
|
const uploaderData = await uploaderResponse.json()
|
||||||
|
|
||||||
|
setVideoCount(videoData)
|
||||||
|
setUploaderCount(uploaderData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取数据库统计信息时出错:", error)
|
||||||
|
setError("无法获取数据库统计信息")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCounts()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt-8 bg-gradient-to-br from-pink-100 to-indigo-100 dark:from-pink-900 dark:to-indigo-900">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold text-primary">数据库统计</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-red-500">{error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="font-bold text-xl mb-2">视频数量</h3>
|
||||||
|
<p className="text-2xl text-primary">{videoCount ? videoCount.count.toLocaleString() : "暂无数据"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="font-bold text-xl mb-2">UP主数量</h3>
|
||||||
|
<p className="text-2xl text-primary">
|
||||||
|
{uploaderCount ? uploaderCount.count.toLocaleString() : "暂无数据"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
10
components/header.tsx
Normal file
10
components/header.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function Header() {
|
||||||
|
return (
|
||||||
|
<header className="bg-primary text-primary-foreground">
|
||||||
|
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold">Vocal Rank</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
28
components/mode-toggle.tsx
Normal file
28
components/mode-toggle.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
import { Moon, Sun } from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
|
export function ModeToggle() {
|
||||||
|
const { setTheme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">切换主题</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>浅色</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>深色</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>系统</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
54
components/server-stats.tsx
Normal file
54
components/server-stats.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
export default function ServerStats() {
|
||||||
|
const [serverStat, setServerStat] = useState<{ timestamp: number } | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchServerStat = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await fetch("https://api.ninevocalrank.top/basic/v1/ServerStat/info")
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("服务器响应错误")
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setServerStat(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取服务器统计信息时出错:", error)
|
||||||
|
setError("无法获取服务器统计信息")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchServerStat()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-blue-100 to-purple-100 dark:from-blue-900 dark:to-purple-900">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold text-primary">服务器统计</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-red-500">{error}</p>
|
||||||
|
) : serverStat ? (
|
||||||
|
<p className="text-lg">最后更新时间: {new Date(serverStat.timestamp * 1000).toLocaleString("zh-CN")}</p>
|
||||||
|
) : (
|
||||||
|
<p>暂无数据</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
57
components/ui/button.tsx
Normal file
57
components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
76
components/ui/card.tsx
Normal file
76
components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
201
components/ui/dropdown-menu.tsx
Normal file
201
components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
22
components/ui/input.tsx
Normal file
22
components/ui/input.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
78
components/uploader-search.tsx
Normal file
78
components/uploader-search.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
interface Uploader {
|
||||||
|
mid: string
|
||||||
|
name: string
|
||||||
|
fans: number
|
||||||
|
archive_count: number
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploaderSearch() {
|
||||||
|
const [uid, setUid] = useState("")
|
||||||
|
const [uploaders, setUploaders] = useState<Uploader[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!uid) {
|
||||||
|
setError("请输入UID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.ninevocalrank.top/basic/v1/uploader/${uid}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("服务器响应错误")
|
||||||
|
}
|
||||||
|
const data: Uploader = await response.json()
|
||||||
|
setUploaders([data]) // 将单个 UP 主数据包装在数组中
|
||||||
|
} catch (error) {
|
||||||
|
console.error("搜索UP主时出错:", error)
|
||||||
|
setError("搜索过程中出现错误")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-green-100 to-blue-100 dark:from-green-900 dark:to-blue-900">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold text-primary">通过UID搜索UP主</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex space-x-2 mb-4">
|
||||||
|
<Input type="text" placeholder="输入UID" value={uid} onChange={(e) => setUid(e.target.value)} />
|
||||||
|
<Button onClick={handleSearch} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "搜索"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 mb-4">{error}</p>}
|
||||||
|
{uploaders.length > 0 ? (
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{uploaders.map((uploader) => (
|
||||||
|
<li key={uploader.mid} className="border p-4 rounded-lg bg-white shadow-md">
|
||||||
|
<h3 className="font-bold text-xl mb-2">{uploader.name}</h3>
|
||||||
|
<p className="text-gray-600">UID: {uploader.mid}</p>
|
||||||
|
<p className="text-gray-600">粉丝数: {uploader.fans.toLocaleString()}</p>
|
||||||
|
<p className="text-gray-600">视频数: {uploader.archive_count}</p>
|
||||||
|
<p className="text-gray-600">最后更新: {new Date(uploader.timestamp * 1000).toLocaleString("zh-CN")}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
!loading && !error && <p>暂无数据</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
279
components/video-search.tsx
Normal file
279
components/video-search.tsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Video {
|
||||||
|
video_stat: {
|
||||||
|
view: number;
|
||||||
|
like: number;
|
||||||
|
coin: number;
|
||||||
|
favorite: number;
|
||||||
|
reply: number;
|
||||||
|
share: number;
|
||||||
|
danmaku: number;
|
||||||
|
};
|
||||||
|
video_info: {
|
||||||
|
uploader_mid: string;
|
||||||
|
uploader_name: string;
|
||||||
|
title: string;
|
||||||
|
pic: string;
|
||||||
|
pages: number;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
video_id: {
|
||||||
|
avid: string;
|
||||||
|
bvid: string;
|
||||||
|
};
|
||||||
|
vrank_info: {
|
||||||
|
vrank_score: number;
|
||||||
|
rank: string;
|
||||||
|
rank_code: number;
|
||||||
|
progress_percentage: number;
|
||||||
|
};
|
||||||
|
video_increase: {
|
||||||
|
view: number;
|
||||||
|
like: number;
|
||||||
|
coin: number;
|
||||||
|
favorite: number;
|
||||||
|
reply: number;
|
||||||
|
share: number;
|
||||||
|
danmaku: number;
|
||||||
|
};
|
||||||
|
score_rank: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAchievement(views: number) {
|
||||||
|
if (views >= 10000000) return { name: "神话曲", next: null, progress: 100 };
|
||||||
|
if (views >= 1000000)
|
||||||
|
return {
|
||||||
|
name: "传说曲",
|
||||||
|
next: "神话曲",
|
||||||
|
progress: (views / 10000000) * 100,
|
||||||
|
};
|
||||||
|
if (views >= 100000)
|
||||||
|
return {
|
||||||
|
name: "殿堂曲",
|
||||||
|
next: "传说曲",
|
||||||
|
progress: (views / 1000000) * 100,
|
||||||
|
};
|
||||||
|
return { name: "未达成", next: "殿堂曲", progress: (views / 100000) * 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoSearch() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!searchTerm) {
|
||||||
|
setError("请输入搜索内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.ninevocalrank.top/vocaloid_rank/v1/video/${searchTerm}`
|
||||||
|
);
|
||||||
|
const weekly_response = await fetch(
|
||||||
|
`https://api.ninevocalrank.top/vocaloid_rank/v1/video/${searchTerm}`
|
||||||
|
);
|
||||||
|
if (!response.ok || !weekly_response.ok) {
|
||||||
|
throw new Error("服务器响应错误");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const weekly_data = await weekly_response.json();
|
||||||
|
|
||||||
|
// 合并 data 和 weekly_data
|
||||||
|
const combinedData = {
|
||||||
|
...data,
|
||||||
|
weekly_data: weekly_data,
|
||||||
|
};
|
||||||
|
|
||||||
|
setVideos([combinedData]);
|
||||||
|
if (videos.length === 0) {
|
||||||
|
setError("未找到匹配的视频");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("搜索视频时出错:", error);
|
||||||
|
setError("搜索过程中出现错误");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-yellow-100 to-red-100">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold text-primary">
|
||||||
|
搜索视频
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex space-x-2 mb-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="输入 BV/AV 号 "
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "搜索"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 mb-4">{error}</p>}
|
||||||
|
{videos.length > 0 ? (
|
||||||
|
<ul className="space-y-8">
|
||||||
|
{videos.map((video) => {
|
||||||
|
const achievement = getAchievement(video.video_stat.view);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={video.video_id.bvid}
|
||||||
|
className="border p-6 rounded-lg bg-white shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="w-full">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={video.video_info.pic}
|
||||||
|
className="w-full h-64 object-cover rounded-lg"
|
||||||
|
alt="视频封面"
|
||||||
|
></img>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<h3 className="font-bold text-xl mb-2">
|
||||||
|
{video.video_info.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
UP主: {video.video_info.uploader_name} (UID:{" "}
|
||||||
|
{video.video_info.uploader_mid})
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
BV号: {video.video_id.bvid}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
AV号: {video.video_id.avid}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<p className="text-gray-600">
|
||||||
|
播放量: {video.video_stat.view.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
点赞数: {video.video_stat.like.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
投币: {video.video_stat.coin.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
收藏: {video.video_stat.favorite.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
评论: {video.video_stat.reply.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
分享: {video.video_stat.share.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
弹幕数: {video.video_stat.danmaku.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 bg-blue-100 p-4 rounded-lg">
|
||||||
|
<h4 className="font-bold text-lg mb-2">周刊数据</h4>
|
||||||
|
{video.vrank_info ? (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
周刊得分:{" "}
|
||||||
|
{video.vrank_info.vrank_score.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 font-bold text-xl">
|
||||||
|
周刊排名: {video.score_rank}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-700">暂无周刊数据</p>
|
||||||
|
)}
|
||||||
|
{video.video_increase && (
|
||||||
|
<>
|
||||||
|
<h5 className="font-semibold mt-2">数据增长:</h5>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<p className="text-gray-700">
|
||||||
|
播放增长:{" "}
|
||||||
|
{video.video_increase.view.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
点赞增长:{" "}
|
||||||
|
{video.video_increase.like.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
投币增长:{" "}
|
||||||
|
{video.video_increase.coin.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
收藏增长:{" "}
|
||||||
|
{video.video_increase.favorite.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
评论增长:{" "}
|
||||||
|
{video.video_increase.reply.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
分享增长:{" "}
|
||||||
|
{video.video_increase.share.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
弹幕增长:{" "}
|
||||||
|
{video.video_increase.danmaku.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
数据更新时间:{" "}
|
||||||
|
{new Date(
|
||||||
|
video.video_info.timestamp * 1000
|
||||||
|
).toLocaleString("zh-CN")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="font-semibold">
|
||||||
|
成就: {achievement.name}
|
||||||
|
</p>
|
||||||
|
{achievement.next && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p>
|
||||||
|
距离 {achievement.next} 还需{" "}
|
||||||
|
{(achievement.next === "殿堂曲"
|
||||||
|
? 100000
|
||||||
|
: achievement.next === "传说曲"
|
||||||
|
? 1000000
|
||||||
|
: 10000000) - video.video_stat.view}{" "}
|
||||||
|
播放
|
||||||
|
</p>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2.5 mt-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2.5 rounded-full"
|
||||||
|
style={{ width: `${achievement.progress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
!loading && !error && <p>暂无数据</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
8
next.config.js
Normal file
8
next.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
domains: ["api.ninevocalrank.top", "i1.hdslb.com"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
|
|
6
next.config.mjs
Normal file
6
next.config.mjs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'export'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
6116
package-lock.json
generated
Normal file
6116
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "vocal-rank",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"next": "14.2.16",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"tailwind-merge": "^3.0.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.16",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
63
tailwind.config.ts
Normal file
63
tailwind.config.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
|
},
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
chart: {
|
||||||
|
'1': 'hsl(var(--chart-1))',
|
||||||
|
'2': 'hsl(var(--chart-2))',
|
||||||
|
'3': 'hsl(var(--chart-3))',
|
||||||
|
'4': 'hsl(var(--chart-4))',
|
||||||
|
'5': 'hsl(var(--chart-5))'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
|
export default config;
|
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user