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 (
+
+
+
+
+
+
+
出错了
+
状态码:{error.status}
+ {error.value.message && (
+
+ 错误信息
+
+ {error.value.message}
+
+ )}
+
+
+ );
+}
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 (
+
+ );
+}
+
+export function LayoutWithouSearch({ children }: { children?: React.ReactNode }) {
+ return (
+
+ );
+}
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 (
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ onSearch();
+ }
+ }}
+ id="search-input"
+ />
+
+
+ );
+}
+
+export function Search(props: React.ComponentProps<"div">) {
+ const [query, setQuery] = useState("");
+ let navigate = useNavigate();
+ return (
+ 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 (
+
+ );
+ }
+
+ return (
+
+ {results.map((result, index) => (
+
+ {result.type === "song" ? : }
+
+ ))}
+
+ );
+}
+
+function SongResult({ result }: { result: Exclude[number] }) {
+ if (result.type !== "song") return null;
+
+ const { data } = result;
+ return (
+
+ {data.image && (
+

+ )}
+
+
{data.name}
+ {data.producer &&
{data.producer}
}
+
+ {data.duration && (
+
+ {Math.floor(data.duration / 60)}:{(data.duration % 60).toString().padStart(2, "0")}
+
+ )}
+ {data.publishedAt && {formatDateTime(new Date(data.publishedAt))}}
+
+
+
+
+ );
+}
+
+function BiliVideoResult({ result }: { result: Exclude[number] }) {
+ if (result.type !== "bili-video") return null;
+
+ const { data } = result;
+ return (
+
+ {data.coverUrl && (
+

+ )}
+
+
{data.title}
+ {data.description && (
+
+ {data.description}
+
+ )}
+
+ {data.duration && (
+
+ {Math.floor(data.duration / 60)}:{(data.duration % 60).toString().padStart(2, "0")}
+
+ )}
+ {data.publishedAt && {formatDateTime(new Date(data.publishedAt))}}
+ {data.bvid && {data.bvid}}
+ {data.views && {data.views.toLocaleString()} 播放}
+
+
+
+
+ );
+}
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 }) {
+ 中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 (
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+ );
+}
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(import.meta.env.VITE_API_URL!);
@@ -32,37 +36,34 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
if (!data && !error) {
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (error?.status === 404) {
+ return (
+
);
}
if (error) {
- return (
-
-
-
-
-
-
出错了
-
状态码:{error.status}
- {error.value.message && (
-
- 错误信息
-
- {error.value.message}
-
- )}
-
-
- );
+ return
;
}
const formatDuration = (duration: number) => {
@@ -78,7 +79,12 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
return (