add: search page in frontend
This commit is contained in:
parent
ce548951d8
commit
18f6186215
@ -28,7 +28,6 @@ const getSongSearchResult = async (searchQuery: string) => {
|
||||
.from(songs)
|
||||
.innerJoin(latestVideoSnapshot, eq(songs.aid, latestVideoSnapshot.aid))
|
||||
.where(like(songs.name, `%${searchQuery}%`))
|
||||
.limit(10);
|
||||
|
||||
const results = data
|
||||
.map((song) => {
|
||||
@ -73,19 +72,26 @@ const getSongSearchResult = async (searchQuery: string) => {
|
||||
};
|
||||
|
||||
const getVideoSearchResult = async (searchQuery: string) => {
|
||||
const extractAVID = (query: string): number | null => {
|
||||
const avMatch = query.match(/av(\d+)/i);
|
||||
if (avMatch) {
|
||||
return Number.parseInt(avMatch[1]);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const results = await db
|
||||
.select()
|
||||
.from(bilibiliMetadata)
|
||||
.innerJoin(latestVideoSnapshot, eq(bilibiliMetadata.aid, latestVideoSnapshot.aid))
|
||||
.where(
|
||||
or(
|
||||
eq(bilibiliMetadata.bvid, searchQuery),
|
||||
eq(bilibiliMetadata.aid, Number.parseInt(searchQuery) || 0)
|
||||
eq(bilibiliMetadata.aid, extractAVID(searchQuery) || 0)
|
||||
)
|
||||
)
|
||||
.limit(10);
|
||||
return results.map((video) => ({
|
||||
type: "bili-video" as "bili-video",
|
||||
data: video,
|
||||
data: { views: video.latest_video_snapshot.views, ...video.bilibili_metadata },
|
||||
rank: 1 // Exact match
|
||||
}));
|
||||
};
|
||||
@ -102,7 +108,8 @@ const BiliVideoDataSchema = t.Object({
|
||||
tags: t.Union([t.String(), t.Null()]),
|
||||
title: t.Union([t.String(), t.Null()]),
|
||||
status: t.Number(),
|
||||
coverUrl: t.Union([t.String(), t.Null()])
|
||||
coverUrl: t.Union([t.String(), t.Null()]),
|
||||
views: t.Number()
|
||||
});
|
||||
|
||||
const SongDataSchema = t.Object({
|
||||
@ -129,7 +136,7 @@ export const searchHandler = new Elysia({ prefix: "/search" }).get(
|
||||
getVideoSearchResult(searchQuery)
|
||||
]);
|
||||
|
||||
const combinedResults: (SongSearchResult | BiliVideoSearchResult)[] = [
|
||||
const combinedResults = [
|
||||
...songResults,
|
||||
...videoResults
|
||||
];
|
||||
|
||||
@ -56,7 +56,8 @@ export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get(
|
||||
} else {
|
||||
return c.status(400, {
|
||||
code: "MALFORMED_SLOT",
|
||||
message: "We cannot parse the video ID, or we currently do not support this format.",
|
||||
message:
|
||||
"We cannot parse the video ID, or we currently do not support this format.",
|
||||
errors: []
|
||||
});
|
||||
}
|
||||
@ -66,16 +67,18 @@ export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get(
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
const data = await getVideoInfo(aid, "getVideoInfo");
|
||||
const r = await getVideoInfo(aid, "getVideoInfo");
|
||||
|
||||
if (typeof data == "number") {
|
||||
if (typeof r == "number") {
|
||||
return c.status(500, {
|
||||
code: "THIRD_PARTY_ERROR",
|
||||
message: `Got status code ${data} from bilibili API.`,
|
||||
message: `Got status code ${r} from bilibili API.`,
|
||||
errors: []
|
||||
});
|
||||
}
|
||||
|
||||
const { data } = r;
|
||||
|
||||
await setCache(aid, JSON.stringify(data));
|
||||
await insertVideoSnapshot(data);
|
||||
|
||||
|
||||
@ -93,11 +93,11 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--primary: oklch(0.324 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.875 0 0);
|
||||
--muted: oklch(0.188 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
24
packages/temp_frontend/app/components/Error.tsx
Normal file
24
packages/temp_frontend/app/components/Error.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Title } from "./Title";
|
||||
|
||||
export function Error({ error }: { error: { status: number; value: { message?: string } } }) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen flex items-center justify-center">
|
||||
<Title title="出错了" />
|
||||
<div className="max-w-md w-full bg-gray-100 dark:bg-neutral-900 rounded-2xl shadow-lg p-6 flex flex-col gap-4 items-center text-center">
|
||||
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-500 text-white text-3xl">
|
||||
<TriangleAlert size={34} className="-translate-y-0.5" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-neutral-900 dark:text-neutral-100">出错了</h1>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">状态码:{error.status}</p>
|
||||
{error.value.message && (
|
||||
<p className="text-neutral-600 dark:text-neutral-400 break-words">
|
||||
<span className="font-medium text-neutral-700 dark:text-neutral-300">错误信息</span>
|
||||
<br />
|
||||
{error.value.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
packages/temp_frontend/app/components/Layout.tsx
Normal file
28
packages/temp_frontend/app/components/Layout.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Search } from "./Search";
|
||||
|
||||
export function Layout({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<main className="w-full max-sm:mx-6 pt-14 sm:w-xl xl:w-2xl">
|
||||
<a href="/">
|
||||
<h1 className="text-4xl mb-5">中V档案馆</h1>
|
||||
</a>
|
||||
<Search />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutWithouSearch({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<main className="w-full max-sm:mx-6 pt-14 sm:w-xl xl:w-2xl">
|
||||
<a href="/">
|
||||
<h1 className="text-4xl mb-5">中V档案馆</h1>
|
||||
</a>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
packages/temp_frontend/app/components/Search.tsx
Normal file
42
packages/temp_frontend/app/components/Search.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { SearchIcon } from "@/components/icons/search";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
interface SearchBoxProps extends React.ComponentProps<"div"> {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
export function SearchBox({ query = "", setQuery, onSearch, className, ...rest }: SearchBoxProps) {
|
||||
return (
|
||||
<div className={"flex h-12 gap-2 relative w-full " + (className ? ` ${className}` : "")} {...rest}>
|
||||
<Input
|
||||
className="h-full pl-5 pr-12 rounded-full"
|
||||
type="search"
|
||||
placeholder="搜索"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onSearch();
|
||||
}
|
||||
}}
|
||||
id="search-input"
|
||||
/>
|
||||
<Button variant="ghost" className="absolute rounded-full size-10 top-1 right-1" onClick={onSearch}>
|
||||
<SearchIcon className="size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Search(props: React.ComponentProps<"div">) {
|
||||
const [query, setQuery] = useState("");
|
||||
let navigate = useNavigate();
|
||||
return (
|
||||
<SearchBox query={query} setQuery={setQuery} onSearch={() => navigate(`/search?q=${query}`)} {...props} />
|
||||
);
|
||||
}
|
||||
132
packages/temp_frontend/app/components/SearchResults.tsx
Normal file
132
packages/temp_frontend/app/components/SearchResults.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import type { SearchResult } from "@/routes/search";
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: SearchResult;
|
||||
}
|
||||
|
||||
const formatDateTime = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0"); // 月份从0开始,补0
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hour = String(date.getHours()).padStart(2, "0");
|
||||
const minute = String(date.getMinutes()).padStart(2, "0");
|
||||
const second = String(date.getSeconds()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
};
|
||||
|
||||
export function SearchResults({ results }: SearchResultsProps) {
|
||||
if (!results || results.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">没有找到相关结果</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mb-20 mt-5">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={`${result.type}-${result.data.id}-${index}`}
|
||||
className="bg-white dark:bg-neutral-800 rounded-lg shadow-sm border border-gray-200 dark:border-neutral-700 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
{result.type === "song" ? <SongResult result={result} /> : <BiliVideoResult result={result} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SongResult({ result }: { result: Exclude<SearchResult, null>[number] }) {
|
||||
if (result.type !== "song") return null;
|
||||
|
||||
const { data } = result;
|
||||
return (
|
||||
<div className="flex items-start space-x-4">
|
||||
{data.image && (
|
||||
<img
|
||||
src={data.image}
|
||||
alt="歌曲封面"
|
||||
className="w-42 h-24 rounded-sm object-cover flex-shrink-0"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-medium truncate">{data.name}</h3>
|
||||
{data.producer && <p className="text-sm text-muted-foreground truncate">{data.producer}</p>}
|
||||
<div className="flex items-center space-x-4 my-1 text-xs text-muted-foreground">
|
||||
{data.duration && (
|
||||
<span>
|
||||
{Math.floor(data.duration / 60)}:{(data.duration % 60).toString().padStart(2, "0")}
|
||||
</span>
|
||||
)}
|
||||
{data.publishedAt && <span>{formatDateTime(new Date(data.publishedAt))}</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{data.aid && (
|
||||
<a href={`https://www.bilibili.com/video/av${data.aid}`} className="text-pink-400 text-sm">
|
||||
观看视频
|
||||
</a>
|
||||
)}
|
||||
<a href={`/song/${data.id}/info`} className="text-sm text-secondary-foreground">
|
||||
查看曲目详情
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BiliVideoResult({ result }: { result: Exclude<SearchResult, null>[number] }) {
|
||||
if (result.type !== "bili-video") return null;
|
||||
|
||||
const { data } = result;
|
||||
return (
|
||||
<div className="flex flex-col items-start space-x-4">
|
||||
{data.coverUrl && (
|
||||
<img
|
||||
src={data.coverUrl}
|
||||
alt="视频封面"
|
||||
className="w-full rounded-lg object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-col mt-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">{data.title}</h3>
|
||||
{data.description && (
|
||||
<pre className="text-sm font-sans text-gray-600 dark:text-gray-400 line-clamp-3 mt-1 text-wrap">
|
||||
{data.description}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 my-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
{data.duration && (
|
||||
<span>
|
||||
{Math.floor(data.duration / 60)}:{(data.duration % 60).toString().padStart(2, "0")}
|
||||
</span>
|
||||
)}
|
||||
{data.publishedAt && <span>{formatDateTime(new Date(data.publishedAt))}</span>}
|
||||
{data.bvid && <span>{data.bvid}</span>}
|
||||
{data.views && <span>{data.views.toLocaleString()} 播放</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{data.bvid && (
|
||||
<a
|
||||
href={`https://www.bilibili.com/video/${data.bvid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-pink-400 text-sm"
|
||||
>
|
||||
观看视频
|
||||
</a>
|
||||
)}
|
||||
{data.bvid && (
|
||||
<a href={`/song/av${data.aid}/info`} className="text-sm text-secondary-foreground">
|
||||
查看曲目详情
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
packages/temp_frontend/app/components/Title.tsx
Normal file
11
packages/temp_frontend/app/components/Title.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
export const Title = ({ title }: { title: string }) => {
|
||||
let location = useLocation();
|
||||
useEffect(() => {
|
||||
document.title = title + " - 中V档案馆";
|
||||
}, [title, location]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -23,10 +23,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>中V档案馆</title>
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<body className="overflow-x-hidden">
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
|
||||
@ -4,4 +4,5 @@ export default [
|
||||
index("routes/home.tsx"),
|
||||
route("song/:id/info", "routes/song/[id]/info.tsx"),
|
||||
route("chart-demo", "routes/chartDemo.tsx"),
|
||||
route("search", "routes/search/index.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@ -1,26 +1,10 @@
|
||||
import { Layout } from "@/components/Layout";
|
||||
import type { Route } from "./+types/home";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchIcon } from "@/components/icons/search";
|
||||
|
||||
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: "中V档案馆" }];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<main className="w-full max-md:mx-6 pt-14 md:w-xl xl:w-2xl">
|
||||
<h1 className="text-4xl my-2">中V档案馆</h1>
|
||||
<div className="flex h-12 mt-5 gap-2 relative">
|
||||
<Input className="h-full pl-5 pr-12 rounded-full" type="search" placeholder="搜索" />
|
||||
<Button variant="ghost" className="absolute rounded-full size-10 top-1 right-1">
|
||||
<SearchIcon className="size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <Layout></Layout>;
|
||||
}
|
||||
|
||||
87
packages/temp_frontend/app/routes/search/index.tsx
Normal file
87
packages/temp_frontend/app/routes/search/index.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "@elysia/src";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Error } from "@/components/Error";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { SearchBox } from "@/components/Search";
|
||||
import { SearchResults } from "@/components/SearchResults";
|
||||
import { Title } from "@/components/Title";
|
||||
import { Layout, LayoutWithouSearch } from "@/components/Layout";
|
||||
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
|
||||
export type SearchResult = Awaited<ReturnType<typeof app.search.result.get>>["data"];
|
||||
type SearchError = Awaited<ReturnType<typeof app.search.result.get>>["error"];
|
||||
|
||||
const Search = ({
|
||||
query,
|
||||
setQuery,
|
||||
onSearch,
|
||||
...props
|
||||
}: {
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
onSearch: () => void;
|
||||
} & React.ComponentProps<"div">) => <SearchBox query={query} setQuery={setQuery} onSearch={onSearch} {...props} />;
|
||||
|
||||
export default function SearchResult() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [query, setQuery] = useState(searchParams.get("q") || "");
|
||||
const [data, setData] = useState<SearchResult | null>(null);
|
||||
const [error, setError] = useState<SearchError | null>(null);
|
||||
|
||||
const search = async () => {
|
||||
setData(null);
|
||||
setError(null);
|
||||
const { data, error } = await app.search.result.get({
|
||||
query: {
|
||||
query: searchParams.get("q") || "",
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
console.log(error);
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
setData(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams.get("q")) return;
|
||||
search();
|
||||
setQuery(searchParams.get("q") || "");
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = () => setSearchParams({ q: query });
|
||||
|
||||
if (!searchParams.get("q")) {
|
||||
return (
|
||||
<LayoutWithouSearch>
|
||||
<Search query={query} setQuery={setQuery} onSearch={handleSearch} />
|
||||
</LayoutWithouSearch>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<LayoutWithouSearch>
|
||||
<Title title={searchParams.get("q") || "搜索"} />
|
||||
<Search query={query} setQuery={setQuery} onSearch={handleSearch} className="mb-6" />
|
||||
<Skeleton className="w-full h-24 mb-2" />
|
||||
</LayoutWithouSearch>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Error error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutWithouSearch>
|
||||
<Title title={searchParams.get("q") || ""} />
|
||||
<Search query={query} setQuery={setQuery} onSearch={handleSearch} className="mb-6" />
|
||||
<SearchResults results={data} />
|
||||
</LayoutWithouSearch>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,10 @@ import type { App } from "@elysia/src";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Title } from "@/components/Title";
|
||||
import { Search } from "@/components/Search";
|
||||
import { Error } from "@/components/Error";
|
||||
import { Layout } from "@/components/Layout";
|
||||
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
|
||||
@ -32,37 +36,34 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<main className="w-full max-sm:mx-6 pt-14 sm:w-xl xl:w-2xl">
|
||||
<Skeleton className="mt-6 w-full aspect-video rounded-lg" />
|
||||
<div className="mt-6 flex justify-between items-baseline">
|
||||
<Skeleton className="w-60 h-10 rounded-sm" />
|
||||
<Skeleton className="w-25 h-10 rounded-sm" />
|
||||
<Layout>
|
||||
<Title title="加载中" />
|
||||
<Skeleton className="mt-6 w-full aspect-video rounded-lg" />
|
||||
<div className="mt-6 flex justify-between items-baseline">
|
||||
<Skeleton className="w-60 h-10 rounded-sm" />
|
||||
<Skeleton className="w-25 h-10 rounded-sm" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error?.status === 404) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen flex items-center justify-center">
|
||||
<Title title="未找到曲目" />
|
||||
<div className="max-w-md w-full bg-gray-100 dark:bg-neutral-900 rounded-2xl shadow-lg p-6 flex flex-col gap-4 items-center text-center">
|
||||
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-500 text-white text-3xl">
|
||||
<TriangleAlert size={34} className="-translate-y-0.5" />
|
||||
</div>
|
||||
</main>
|
||||
<h1 className="text-3xl font-semibold text-neutral-900 dark:text-neutral-100">无法找到曲目</h1>
|
||||
<a href={`/song/${loaderData.id}/add`} className="text-secondary-foreground">点此收录</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen flex items-center justify-center">
|
||||
<div className="max-w-md w-full bg-gray-100 dark:bg-neutral-900 rounded-2xl shadow-lg p-6 flex flex-col gap-4 items-center text-center">
|
||||
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-500 text-white text-3xl">
|
||||
<TriangleAlert size={34} className="-translate-y-0.5" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-neutral-900 dark:text-neutral-100">出错了</h1>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">状态码:{error.status}</p>
|
||||
{error.value.message && (
|
||||
<p className="text-neutral-600 dark:text-neutral-400 break-words">
|
||||
<span className="font-medium text-neutral-700 dark:text-neutral-300">错误信息</span>
|
||||
<br />
|
||||
{error.value.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <Error error={error} />;
|
||||
}
|
||||
|
||||
const formatDuration = (duration: number) => {
|
||||
@ -78,7 +79,12 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<Title title={data!.name ? data!.name : "未知歌曲名"} />
|
||||
<main className="w-full max-sm:mx-6 pt-14 sm:w-xl xl:w-2xl">
|
||||
<a href="/">
|
||||
<h1 className="text-4xl mb-5">中V档案馆</h1>
|
||||
</a>
|
||||
<Search />
|
||||
{data!.cover && (
|
||||
<img
|
||||
src={data!.cover}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user