1
0
cvsa/packages/temp_frontend/app/routes/home.tsx

234 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Layout } from "@/components/Layout";
import type { Route } from "./+types/home";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { treaty } from "@elysiajs/eden";
import type { App } from "@elysia/src";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/components/SearchResults";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
// @ts-ignore idk
const app = treaty<App>(import.meta.env.VITE_API_URL!);
type CloseMilestoneInfo = Awaited<ReturnType<ReturnType<(typeof app.songs)["close-milestone"]>["get"]>>["data"];
type CloseMilestoneError = Awaited<ReturnType<ReturnType<(typeof app.songs)["close-milestone"]>["get"]>>["error"];
export function meta({}: Route.MetaArgs) {
return [{ title: "中V档案馆" }];
}
type MilestoneType = "dendou" | "densetsu" | "shinwa";
const milestoneConfig = {
dendou: { name: "殿堂", range: [90000, 99999], target: 100000 },
densetsu: { name: "传说", range: [900000, 999999], target: 1000000 },
shinwa: { name: "神话", range: [5000000, 9999999], target: 10000000 },
};
function formatHours(hours: number): string {
if (hours >= 24 * 14) return `${Math.floor(hours / 24)}`;
if (hours >= 24) return `${Math.floor(hours / 24)}${Math.floor(hours % 24)} 小时`;
if (hours >= 1) return `${Math.floor(hours)}${Math.round((hours % 1) * 60)}`;
return `${Math.round(hours * 60)} 分钟`;
}
function addHoursToNow(hours: number): string {
const d = new Date();
d.setHours(d.getHours() + hours);
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
}
export default function Home() {
const [input, setInput] = useState("");
const [milestoneType, setMilestoneType] = useState<MilestoneType>("shinwa");
const [closeMilestoneInfo, setCloseMilestoneInfo] = useState<CloseMilestoneInfo>();
const [closeMilestoneError, setCloseMilestoneError] = useState<CloseMilestoneError>();
const [isLoading, setIsLoading] = useState(false);
const fetchMilestoneData = async (type: MilestoneType) => {
setIsLoading(true);
setCloseMilestoneError(undefined);
const { data, error } = await app.songs["close-milestone"]({ type }).get();
if (error) {
setCloseMilestoneError(error);
} else {
setCloseMilestoneInfo(data);
}
setIsLoading(false);
};
useEffect(() => {
fetchMilestoneData(milestoneType);
}, [milestoneType]);
const MilestoneVideoCard = ({ video }: { video: NonNullable<CloseMilestoneInfo>[number] }) => {
const config = milestoneConfig[milestoneType];
const remainingViews = config.target - video.eta.currentViews;
const progressPercentage = (video.eta.currentViews / config.target) * 100;
return (
<Card className="px-3 max-md:py-3 md:px-4 my-4 gap-0">
<div className="w-full flex items-start space-x-4 mb-4">
{video.bilibili_metadata.coverUrl && (
<img
src={video.bilibili_metadata.coverUrl}
alt="视频封面"
className="h-25 w-40 rounded-sm object-cover flex-shrink-0"
referrerPolicy="no-referrer"
/>
)}
<div className="flex flex-col w-full justify-between">
<h3 className="text-sm sm:text-lg font-medium line-clamp-2 text-wrap mb-2">
<a href={`/song/av${video.bilibili_metadata.aid}/info`} className="hover:underline">
{video.bilibili_metadata.title}
</a>
</h3>
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>: {video.eta.currentViews.toLocaleString()}</span>
<span>: {config.target.toLocaleString()}</span>
</div>
<Progress value={progressPercentage} />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-5 text-xs text-muted-foreground mb-2">
<div>
<p>: {remainingViews.toLocaleString()}</p>
<p>: {formatHours(video.eta.eta)}</p>
</div>
<div>
<p>: {Math.round(video.eta.speed)}/</p>
<p>: {addHoursToNow(video.eta.eta)}</p>
</div>
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
{video.bilibili_metadata.publishedAt && (
<span className="stat-num">
{formatDateTime(new Date(video.bilibili_metadata.publishedAt))}
</span>
)}
<a
href={`https://www.bilibili.com/video/av${video.bilibili_metadata.aid}`}
target="_blank"
rel="noopener noreferrer"
className="text-pink-400 text-xs hover:underline"
>
</a>
<a
href={`/song/av${video.bilibili_metadata.aid}/info`}
className="text-xs text-secondary-foreground hover:underline"
>
</a>
</div>
</Card>
);
};
const MilestoneVideos = () => {
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-white dark:bg-neutral-800 rounded-lg shadow-sm border border-gray-200 dark:border-neutral-700 p-4"
>
<div className="flex items-start space-x-4">
<Skeleton className="h-21 w-36 rounded-sm" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</div>
))}
</div>
);
}
if (closeMilestoneError) {
return (
<div className="text-center py-8">
<p className="text-red-500">: {closeMilestoneError.value?.message || "未知错误"}</p>
<Button variant="outline" className="mt-4" onClick={() => fetchMilestoneData(milestoneType)}>
</Button>
</div>
);
}
if (!closeMilestoneInfo || closeMilestoneInfo.length === 0) {
return (
<div className="text-center py-8">
<p className="text-secondary-foreground">{milestoneConfig[milestoneType].name}</p>
</div>
);
}
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
{closeMilestoneInfo.length} {milestoneConfig[milestoneType].name}
</p>
<ScrollArea className="h-140 w-full">
{closeMilestoneInfo.map((video) => (
<MilestoneVideoCard key={video.bilibili_metadata.aid} video={video} />
))}
</ScrollArea>
</div>
);
};
return (
<Layout>
<h2 className="text-2xl mt-5 mb-6"></h2>
<div className="flex max-sm:flex-col sm:items-center gap-7 mb-8">
<Button>
<a href="/util/time-calculator"></a>
</Button>
<div className="flex sm:w-96 gap-3">
<Input placeholder="输入BV号或av号" value={input} onChange={(e) => setInput(e.target.value)} />
<Button>
<a href={`/song/${input}/add`}></a>
</Button>
</div>
</div>
<h2 className="text-2xl mb-4"></h2>
<div className="flex items-center gap-4 mb-6">
<Select value={milestoneType} onValueChange={(value: MilestoneType) => setMilestoneType(value)}>
<SelectTrigger className="w-20">
<SelectValue placeholder="成就" />
</SelectTrigger>
<SelectContent>
<SelectItem value="dendou">殿</SelectItem>
<SelectItem value="densetsu"></SelectItem>
<SelectItem value="shinwa"></SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
{milestoneConfig[milestoneType].range[0].toLocaleString()} -{" "}
{milestoneConfig[milestoneType].range[1].toLocaleString()}
{milestoneConfig[milestoneType].name}
</span>
</div>
<MilestoneVideos />
</Layout>
);
}