1
0

add: search page in frontend

This commit is contained in:
alikia2x (寒寒) 2025-11-08 21:33:02 +08:00
parent ce548951d8
commit 18f6186215
13 changed files with 384 additions and 58 deletions

View File

@ -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
];

View File

@ -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);

View File

@ -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);

View 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>
);
}

View 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>
);
}

View 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} />
);
}

View 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>
);
}

View 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;
};

View File

@ -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 />

View File

@ -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;

View File

@ -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>;
}

View 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>
);
}

View File

@ -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}