diff --git a/packages/elysia/routes/search/index.ts b/packages/elysia/routes/search/index.ts index adf04b0..0ff6e02 100644 --- a/packages/elysia/routes/search/index.ts +++ b/packages/elysia/routes/search/index.ts @@ -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 ]; diff --git a/packages/elysia/routes/video/metadata.ts b/packages/elysia/routes/video/metadata.ts index 473ea51..15f830f 100644 --- a/packages/elysia/routes/video/metadata.ts +++ b/packages/elysia/routes/video/metadata.ts @@ -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); diff --git a/packages/temp_frontend/app/app.css b/packages/temp_frontend/app/app.css index ec87ebf..8c5e2ba 100644 --- a/packages/temp_frontend/app/app.css +++ b/packages/temp_frontend/app/app.css @@ -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); diff --git a/packages/temp_frontend/app/components/Error.tsx b/packages/temp_frontend/app/components/Error.tsx new file mode 100644 index 0000000..0855ee8 --- /dev/null +++ b/packages/temp_frontend/app/components/Error.tsx @@ -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="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> + ); +} diff --git a/packages/temp_frontend/app/components/Layout.tsx b/packages/temp_frontend/app/components/Layout.tsx new file mode 100644 index 0000000..7ae8a5f --- /dev/null +++ b/packages/temp_frontend/app/components/Layout.tsx @@ -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> + ); +} diff --git a/packages/temp_frontend/app/components/Search.tsx b/packages/temp_frontend/app/components/Search.tsx new file mode 100644 index 0000000..e3d212a --- /dev/null +++ b/packages/temp_frontend/app/components/Search.tsx @@ -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} /> + ); +} diff --git a/packages/temp_frontend/app/components/SearchResults.tsx b/packages/temp_frontend/app/components/SearchResults.tsx new file mode 100644 index 0000000..a21ac2b --- /dev/null +++ b/packages/temp_frontend/app/components/SearchResults.tsx @@ -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> + ); +} diff --git a/packages/temp_frontend/app/components/Title.tsx b/packages/temp_frontend/app/components/Title.tsx new file mode 100644 index 0000000..59d223c --- /dev/null +++ b/packages/temp_frontend/app/components/Title.tsx @@ -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; +}; diff --git a/packages/temp_frontend/app/root.tsx b/packages/temp_frontend/app/root.tsx index 4587b9d..051a01e 100644 --- a/packages/temp_frontend/app/root.tsx +++ b/packages/temp_frontend/app/root.tsx @@ -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档案馆 - + {children} diff --git a/packages/temp_frontend/app/routes.ts b/packages/temp_frontend/app/routes.ts index eea526c..c0e4558 100644 --- a/packages/temp_frontend/app/routes.ts +++ b/packages/temp_frontend/app/routes.ts @@ -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; diff --git a/packages/temp_frontend/app/routes/home.tsx b/packages/temp_frontend/app/routes/home.tsx index 20eb26a..650d436 100644 --- a/packages/temp_frontend/app/routes/home.tsx +++ b/packages/temp_frontend/app/routes/home.tsx @@ -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 ( -
-
-

中V档案馆

-
- - -
-
-
- ); + return ; } diff --git a/packages/temp_frontend/app/routes/search/index.tsx b/packages/temp_frontend/app/routes/search/index.tsx new file mode 100644 index 0000000..3db760e --- /dev/null +++ b/packages/temp_frontend/app/routes/search/index.tsx @@ -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(import.meta.env.VITE_API_URL!); + +export type SearchResult = Awaited>["data"]; +type SearchError = Awaited>["error"]; + +const Search = ({ + query, + setQuery, + onSearch, + ...props +}: { + query: string; + setQuery: (value: string) => void; + onSearch: () => void; +} & React.ComponentProps<"div">) => ; + +export default function SearchResult() { + const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useState(searchParams.get("q") || ""); + const [data, setData] = useState(null); + const [error, setError] = useState(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 ( + + + + ); + } + + if (!data && !error) { + return ( + + + <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> + ); +} diff --git a/packages/temp_frontend/app/routes/song/[id]/info.tsx b/packages/temp_frontend/app/routes/song/[id]/info.tsx index 62a9397..acb94fe 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/info.tsx @@ -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}