add: some basic info shown in the song info page
This commit is contained in:
parent
c7b0174742
commit
d22fdd4390
6
bun.lock
6
bun.lock
@ -197,7 +197,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.544.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@ -1893,7 +1893,7 @@
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
|
||||
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
|
||||
|
||||
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
||||
|
||||
@ -3071,6 +3071,8 @@
|
||||
|
||||
"plaette/@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="],
|
||||
|
||||
"plaette/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
|
||||
|
||||
"plaette/vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="],
|
||||
|
||||
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
@ -13,7 +13,11 @@ async function getSongIDFromBiliID(id: string) {
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const songID = await dbMain.select({ id: songs.id }).from(songs).where(eq(songs.aid, aid)).limit(1);
|
||||
const songID = await dbMain
|
||||
.select({ id: songs.id })
|
||||
.from(songs)
|
||||
.where(eq(songs.aid, aid))
|
||||
.limit(1);
|
||||
if (songID.length > 0) {
|
||||
return songID[0].id;
|
||||
}
|
||||
@ -44,23 +48,29 @@ async function getSingers(id: number) {
|
||||
})
|
||||
.from(relations)
|
||||
.innerJoin(singer, eq(relations.targetId, singer.id))
|
||||
.where(and(eq(relations.sourceId, id), eq(relations.sourceType, "song"), eq(relations.relation, "sing")));
|
||||
.where(
|
||||
and(
|
||||
eq(relations.sourceId, id),
|
||||
eq(relations.sourceType, "song"),
|
||||
eq(relations.relation, "sing")
|
||||
)
|
||||
);
|
||||
return singers.map((singer) => singer.singers);
|
||||
}
|
||||
|
||||
export const getSongInfoHandler = new Elysia({ prefix: "/song" }).get(
|
||||
"/:id/info",
|
||||
async (c) => {
|
||||
const id = c.params.id;
|
||||
async ({ params, status }) => {
|
||||
const id = params.id;
|
||||
const songID = await getSongID(id);
|
||||
if (!songID) {
|
||||
return c.status(404, {
|
||||
return status(404, {
|
||||
message: "song not found"
|
||||
});
|
||||
}
|
||||
const info = await getSongInfo(songID);
|
||||
if (!info) {
|
||||
return c.status(404, {
|
||||
return status(404, {
|
||||
message: "song not found"
|
||||
});
|
||||
}
|
||||
@ -70,7 +80,8 @@ export const getSongInfoHandler = new Elysia({ prefix: "/song" }).get(
|
||||
aid: info.aid,
|
||||
producer: info.producer,
|
||||
duration: info.duration,
|
||||
singers: singers
|
||||
singers: singers,
|
||||
cover: info.image || undefined
|
||||
};
|
||||
},
|
||||
{
|
||||
@ -80,7 +91,8 @@ export const getSongInfoHandler = new Elysia({ prefix: "/song" }).get(
|
||||
aid: t.Union([t.Number(), t.Null()]),
|
||||
producer: t.Union([t.String(), t.Null()]),
|
||||
duration: t.Union([t.Number(), t.Null()]),
|
||||
singers: t.Array(t.String())
|
||||
singers: t.Array(t.String()),
|
||||
cover: t.Optional(t.String())
|
||||
}),
|
||||
404: t.Object({
|
||||
message: t.String()
|
||||
@ -97,3 +109,51 @@ export const getSongInfoHandler = new Elysia({ prefix: "/song" }).get(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const patchInfoHandler = new Elysia({ prefix: "/song" }).patch(
|
||||
"/:id/info",
|
||||
async ({ params, status, body }) => {
|
||||
const id = params.id;
|
||||
const songID = await getSongID(id);
|
||||
if (!songID) {
|
||||
return status(404, {
|
||||
message: "song not found"
|
||||
});
|
||||
}
|
||||
const info = await getSongInfo(songID);
|
||||
if (!info) {
|
||||
return status(404, {
|
||||
message: "song not found"
|
||||
});
|
||||
}
|
||||
if (body.name) {
|
||||
await dbMain
|
||||
.update(songs)
|
||||
.set({ name: body.name })
|
||||
.where(eq(songs.id, songID));
|
||||
}
|
||||
if (body.producer) {
|
||||
await dbMain
|
||||
.update(songs)
|
||||
.set({ producer: body.producer })
|
||||
.where(eq(songs.id, songID));
|
||||
}
|
||||
return {
|
||||
message: "success"
|
||||
};
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
message: t.String()
|
||||
}),
|
||||
404: t.Object({
|
||||
message: t.String()
|
||||
})
|
||||
},
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
producer: t.Optional(t.String())
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
@ -3,7 +3,7 @@ import { getBindingInfo, logStartup } from "./startMessage";
|
||||
import { pingHandler } from "@elysia/routes/ping";
|
||||
import openapi from "@elysiajs/openapi";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { getSongInfoHandler } from "@elysia/routes/song/info";
|
||||
import { getSongInfoHandler, patchInfoHandler } from "@elysia/routes/song/info";
|
||||
import { rootHandler } from "@elysia/routes/root";
|
||||
import { getVideoMetadataHandler } from "@elysia/routes/video/metadata";
|
||||
import { closeMileStoneHandler } from "@elysia/routes/song/milestone";
|
||||
@ -19,20 +19,41 @@ const app = new Elysia({
|
||||
hostname: host
|
||||
}
|
||||
})
|
||||
.onAfterHandle({ as: "global" }, ({ responseValue, set, request }) => {
|
||||
.onAfterHandle({ as: "global" }, ({ responseValue, request }) => {
|
||||
const contentType = request.headers.get("Content-Type") || "";
|
||||
const accept = request.headers.get("Accept") || "";
|
||||
const secFetchMode = request.headers.get("Sec-Fetch-Mode");
|
||||
const requestJson = contentType.includes("application/json");
|
||||
const isBrowser = !requestJson && (accept.includes("text/html") || secFetchMode === "navigate");
|
||||
const isBrowser =
|
||||
!requestJson && (accept.includes("text/html") || secFetchMode === "navigate");
|
||||
const responseValueType = typeof responseValue;
|
||||
const isObject = responseValueType === "object";
|
||||
const response = isObject
|
||||
? responseValue
|
||||
: {
|
||||
message: responseValue
|
||||
};
|
||||
const text = isBrowser ? JSON.stringify(response, null, 2) : JSON.stringify(response);
|
||||
if (!isObject) {
|
||||
const response = {
|
||||
message: responseValue
|
||||
};
|
||||
const text = isBrowser ? JSON.stringify(response, null, 2) : JSON.stringify(response);
|
||||
return new Response(encoder.encode(text), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
}
|
||||
});
|
||||
}
|
||||
const realResponse = responseValue as Record<string, unknown>;
|
||||
if (realResponse.code) {
|
||||
const text = isBrowser
|
||||
? JSON.stringify(realResponse.response, null, 2)
|
||||
: JSON.stringify(realResponse.response);
|
||||
return new Response(encoder.encode(text), {
|
||||
status: realResponse.code as any,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
}
|
||||
});
|
||||
}
|
||||
const text = isBrowser
|
||||
? JSON.stringify(realResponse, null, 2)
|
||||
: JSON.stringify(realResponse);
|
||||
return new Response(encoder.encode(text), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
@ -46,6 +67,7 @@ const app = new Elysia({
|
||||
.use(getVideoMetadataHandler)
|
||||
.use(getSongInfoHandler)
|
||||
.use(closeMileStoneHandler)
|
||||
.use(patchInfoHandler)
|
||||
.listen(15412);
|
||||
|
||||
export const VERSION = "0.7.0";
|
||||
|
||||
13
packages/temp_frontend/app/components/ui/skeleton.tsx
Normal file
13
packages/temp_frontend/app/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib//utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@ -1,11 +1,9 @@
|
||||
import type { Route } from "./+types/home";
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "@elysia/src";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchIcon } from "@/components/icons/search";
|
||||
|
||||
const app = treaty<App>("localhost:15412");
|
||||
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: "中V档案馆" }];
|
||||
|
||||
@ -1,29 +1,105 @@
|
||||
import useSWR from "swr";
|
||||
import type { Route } from "./+types/info";
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "@elysia/src";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
const API_URL = "https://api.projectcvsa.com";
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
|
||||
type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"];
|
||||
type SongInfoError = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["error"];
|
||||
|
||||
export async function clientLoader({ params }: Route.LoaderArgs) {
|
||||
return { id: params.id };
|
||||
}
|
||||
|
||||
export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
const { data, error, isLoading } = useSWR(`${API_URL}/video/${loaderData.id}/info`, async (url) => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch song info");
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
const [data, setData] = useState<SongInfo | null>(null);
|
||||
const [error, setError] = useState<SongInfoError | null>(null);
|
||||
|
||||
if (isLoading) return <div>加载中...</div>;
|
||||
if (error) return <div>错误: {error.message}</div>;
|
||||
if (!data) return <div>暂无数据</div>;
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { data, error } = await app.song({ id: loaderData.id }).info.get();
|
||||
if (error) {
|
||||
console.log(error);
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
setData(data);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
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" />
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDuration = (duration: number) => {
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const songNameOnChange = async (e: React.FocusEvent<HTMLHeadingElement, Element>) => {
|
||||
const name = e.target.textContent;
|
||||
await app.song({ id: loaderData.id }).info.patch({ name: name || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>歌曲信息</h1>
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||
<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">
|
||||
{data!.cover && (
|
||||
<img
|
||||
src={data!.cover}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full aspect-video object-cover rounded-lg mt-6"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-6 flex justify-between">
|
||||
<h1 className="text-4xl font-medium" contentEditable={true} onBlur={songNameOnChange}>
|
||||
{data!.name ? data!.name : "未知歌曲名"}
|
||||
</h1>
|
||||
<div className="flex flex-col items-end h-10 whitespace-nowrap">
|
||||
<span className="leading-5 text-neutral-800 dark:text-neutral-200">
|
||||
{data!.duration ? formatDuration(data!.duration) : "未知时长"}
|
||||
</span>
|
||||
<span className="text-lg leading-5 text-neutral-800 dark:text-neutral-200 font-bold">
|
||||
{data!.producer ? data!.producer : "未知P主"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.544.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user